Hook registry: list installed hooks + call counts in Injection panel

Add a process-wide hook registry (hook/src/hook_registry) that every hook
module registers its hooks with and bumps a counter from each detour. The
XInput, focus-spoof, and audio render-hooks now register their individual
hooks (XInputGetState/Ex/Caps/SetState; GetForegroundWindow/GetActiveWindow/
GetFocus/WndProc guard; IMMDevice::Activate, IAudioClient::Initialize/
GetService, IAudioRenderClient::GetBuffer/ReleaseBuffer) and count calls.

The worker publishes the table to the host each tick over a new HookStatus
field (protocol v4 -> v5: HookEntry[] + count). The Injection panel shows it
as a collapsible table grouped by subsystem with an installed flag and call
count per hook; coop_audio_probe prints the same table headless.

Verified against Phantom Brave: 13 hooks listed with live counts (focus APIs
polled heavily, GetBuffer/ReleaseBuffer ticking with the audio render loop).
All four tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 20:09:39 +02:00
parent 565934e8cf
commit 1f905940ef
16 changed files with 371 additions and 14 deletions

View File

@@ -112,6 +112,11 @@ Done:
forwarding, controller polling rate, mirror resolution, audio source + buffered forwarding, controller polling rate, mirror resolution, audio source + buffered
ms) and reveal the verbose diagnostics (per-slot poll table, focus-API counts, ms) and reveal the verbose diagnostics (per-slot poll table, focus-API counts,
input-path detection, per-render-stream table) only when Debug details is on. input-path detection, per-render-stream table) only when Debug details is on.
- **Installed-hooks list. ✅** The DLL keeps a registry of every individual hook
it installs (XInput, focus, audio), with a running call counter per hook,
reported over IPC. The Injection panel shows it grouped by subsystem
(input / focus / audio) so you can see exactly what's hooked and how busy each
hook is. `coop_audio_probe` prints the same table headless.
Future work, roughly in priority order: Future work, roughly in priority order:

View File

@@ -11,7 +11,7 @@ namespace coop
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
// refuses to attach to a host with a mismatched version. // refuses to attach to a host with a mismatched version.
inline constexpr std::uint32_t kProtocolVersion = 4; inline constexpr std::uint32_t kProtocolVersion = 5;
// 'COOP' little-endian, used to sanity-check the mapping before trusting it. // 'COOP' little-endian, used to sanity-check the mapping before trusting it.
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
@@ -60,6 +60,28 @@ struct AudioStreamInfo
std::uint64_t frames_rendered; std::uint64_t frames_rendered;
}; };
// Orthogonal hook subsystems the host can install/remove independently.
enum HookSubsystem : std::uint32_t
{
HookSubsys_Input = 0, // XInput hooks (forward the guest pad)
HookSubsys_Focus = 1, // focus spoof (keep the game running unfocused)
HookSubsys_Audio = 2, // WASAPI render-hook (audio mirror without echo)
HookSubsys_Count = 3,
};
// Maximum individual hooks reported in the registry (a few per subsystem).
inline constexpr std::uint32_t kMaxHookEntries = 24;
// One installed hook, for the Injection panel's hook list. POD diagnostics, like
// AudioStreamInfo: the hook is the sole writer; benign cross-process races are ok.
struct HookEntry
{
char name[40]; // e.g. "XInputGetState"
std::uint32_t subsystem; // HookSubsystem
std::uint32_t installed; // 1 if currently hooked
std::uint64_t calls; // cumulative times the detour ran
};
// Indices into HookStatus::focus_query_calls. // Indices into HookStatus::focus_query_calls.
enum FocusApi : std::uint32_t enum FocusApi : std::uint32_t
{ {
@@ -97,6 +119,11 @@ struct HookStatus
// multi-stream game is visible before/without turning the mirror on. // multi-stream game is visible before/without turning the mirror on.
std::uint32_t audio_streams_seen; // distinct render clients ever created std::uint32_t audio_streams_seen; // distinct render clients ever created
AudioStreamInfo audio_streams[kMaxAudioStreams]; // per-slot detail, [0] is primary AudioStreamInfo audio_streams[kMaxAudioStreams]; // per-slot detail, [0] is primary
// Hook registry: every individual hook the DLL has installed, with a running
// call count. Lets the Injection panel list exactly what's hooked and how busy.
std::uint32_t hook_entry_count;
HookEntry hook_entries[kMaxHookEntries];
}; };
// Top-level shared block. The host is the sole writer of pad state; the hook is // Top-level shared block. The host is the sole writer of pad state; the hook is

View File

@@ -3,7 +3,8 @@ add_library(coop_hook SHARED
src/xinput_hook.cpp src/xinput_hook.cpp
src/focus_spoof.cpp src/focus_spoof.cpp
src/audio_hook.cpp src/audio_hook.cpp
src/debug_log.cpp) src/debug_log.cpp
src/hook_registry.cpp)
target_include_directories(coop_hook PRIVATE src) target_include_directories(coop_hook PRIVATE src)

View File

@@ -14,6 +14,7 @@
#include <safetyhook.hpp> #include <safetyhook.hpp>
#include "debug_log.hpp" #include "debug_log.hpp"
#include "hook_registry.hpp"
namespace coop::hook namespace coop::hook
{ {
@@ -55,6 +56,13 @@ safetyhook::InlineHook g_hk_getbuffer;
safetyhook::InlineHook g_hk_releasebuffer; safetyhook::InlineHook g_hk_releasebuffer;
bool g_audioclient_hooked = false; bool g_audioclient_hooked = false;
// Registry ids for the hook list.
int g_id_activate = -1;
int g_id_initialize = -1;
int g_id_getservice = -1;
int g_id_getbuffer = -1;
int g_id_releasebuffer = -1;
// Our own probe COM objects, created at anchor time purely to read the shared // Our own probe COM objects, created at anchor time purely to read the shared
// IAudioClient / IAudioRenderClient vtables and hook GetBuffer/ReleaseBuffer // IAudioClient / IAudioRenderClient vtables and hook GetBuffer/ReleaseBuffer
// *proactively* — so render clients the game created before we injected (the // *proactively* — so render clients the game created before we injected (the
@@ -142,6 +150,7 @@ void try_register_lazy(IAudioRenderClient* rc);
HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data) HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data)
{ {
hook_note_call(g_id_getbuffer);
const HRESULT hr = g_hk_getbuffer.call<HRESULT>(self, num_frames, data); const HRESULT hr = g_hk_getbuffer.call<HRESULT>(self, num_frames, data);
if (SUCCEEDED(hr) && data != nullptr) if (SUCCEEDED(hr) && data != nullptr)
{ {
@@ -154,6 +163,7 @@ HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_fram
HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_frames, DWORD flags) HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_frames, DWORD flags)
{ {
hook_note_call(g_id_releasebuffer);
// A render client we've never seen actively rendering is almost certainly one // A render client we've never seen actively rendering is almost certainly one
// the game created before we injected; adopt it now (the first becomes the // the game created before we injected; adopt it now (the first becomes the
// primary we capture). Skip our own silent probe client. // primary we capture). Skip our own silent probe client.
@@ -300,6 +310,7 @@ HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mo
REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity, REFERENCE_TIME buffer_duration, REFERENCE_TIME periodicity,
const WAVEFORMATEX* format, LPCGUID session) const WAVEFORMATEX* format, LPCGUID session)
{ {
hook_note_call(g_id_initialize);
const HRESULT hr = const HRESULT hr =
g_hk_initialize.call<HRESULT>(self, mode, flags, buffer_duration, periodicity, format, session); g_hk_initialize.call<HRESULT>(self, mode, flags, buffer_duration, periodicity, format, session);
logf("hk_Initialize: client=%p mode=%d flags=0x%lX hr=0x%08lX fmt=%s", self, mode, logf("hk_Initialize: client=%p mode=%d flags=0x%lX hr=0x%08lX fmt=%s", self, mode,
@@ -314,6 +325,7 @@ HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mo
HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** ppv) HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** ppv)
{ {
hook_note_call(g_id_getservice);
const HRESULT hr = g_hk_getservice.call<HRESULT>(self, riid, ppv); const HRESULT hr = g_hk_getservice.call<HRESULT>(self, riid, ppv);
const bool is_render = (riid == __uuidof(IAudioRenderClient)); const bool is_render = (riid == __uuidof(IAudioRenderClient));
logf("hk_GetService: client=%p hr=0x%08lX render_client=%d", self, static_cast<unsigned long>(hr), logf("hk_GetService: client=%p hr=0x%08lX render_client=%d", self, static_cast<unsigned long>(hr),
@@ -371,6 +383,7 @@ void install_audioclient_hooks(IAudioClient* ac)
HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ctx, PROPVARIANT* params, HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ctx, PROPVARIANT* params,
void** ppv) void** ppv)
{ {
hook_note_call(g_id_activate);
const HRESULT hr = g_hk_activate.call<HRESULT>(self, riid, cls_ctx, params, ppv); const HRESULT hr = g_hk_activate.call<HRESULT>(self, riid, cls_ctx, params, ppv);
const bool is_audioclient = (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) || const bool is_audioclient = (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) ||
riid == __uuidof(IAudioClient3)); riid == __uuidof(IAudioClient3));
@@ -395,6 +408,12 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
return true; // anchor already installed return true; // anchor already installed
} }
g_id_activate = hook_register("IMMDevice::Activate", HookSubsys_Audio);
g_id_initialize = hook_register("IAudioClient::Initialize", HookSubsys_Audio);
g_id_getservice = hook_register("IAudioClient::GetService", HookSubsys_Audio);
g_id_getbuffer = hook_register("IAudioRenderClient::GetBuffer", HookSubsys_Audio);
g_id_releasebuffer = hook_register("IAudioRenderClient::ReleaseBuffer", HookSubsys_Audio);
// Anchor: instantiate our own enumerator + default render device purely to // Anchor: instantiate our own enumerator + default render device purely to
// read the shared IMMDevice vtable and hook Activate. Every IMMDevice in the // read the shared IMMDevice vtable and hook Activate. Every IMMDevice in the
// process shares this vtable, so the game's Activate calls are intercepted. // process shares this vtable, so the game's Activate calls are intercepted.
@@ -471,6 +490,12 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
// stay valid; they're released in remove_audio_hooks. // stay valid; they're released in remove_audio_hooks.
} }
hook_set_installed(g_id_activate, static_cast<bool>(g_hk_activate));
hook_set_installed(g_id_initialize, static_cast<bool>(g_hk_initialize));
hook_set_installed(g_id_getservice, static_cast<bool>(g_hk_getservice));
hook_set_installed(g_id_getbuffer, static_cast<bool>(g_hk_getbuffer));
hook_set_installed(g_id_releasebuffer, static_cast<bool>(g_hk_releasebuffer));
logf("install_audio_hooks: activate=%d init=%d getsvc=%d getbuf=%d relbuf=%d (device=%p)", logf("install_audio_hooks: activate=%d init=%d getsvc=%d getbuf=%d relbuf=%d (device=%p)",
static_cast<bool>(g_hk_activate) ? 1 : 0, static_cast<bool>(g_hk_initialize) ? 1 : 0, static_cast<bool>(g_hk_activate) ? 1 : 0, static_cast<bool>(g_hk_initialize) ? 1 : 0,
static_cast<bool>(g_hk_getservice) ? 1 : 0, static_cast<bool>(g_hk_getbuffer) ? 1 : 0, static_cast<bool>(g_hk_getservice) ? 1 : 0, static_cast<bool>(g_hk_getbuffer) ? 1 : 0,
@@ -522,6 +547,11 @@ void remove_audio_hooks()
g_hk_initialize = {}; g_hk_initialize = {};
g_hk_activate = {}; g_hk_activate = {};
g_audioclient_hooked = false; g_audioclient_hooked = false;
hook_set_installed(g_id_activate, false);
hook_set_installed(g_id_initialize, false);
hook_set_installed(g_id_getservice, false);
hook_set_installed(g_id_getbuffer, false);
hook_set_installed(g_id_releasebuffer, false);
// Hooks are gone; safe to drop the probe objects that held the vtables. // Hooks are gone; safe to drop the probe objects that held the vtables.
if (IAudioRenderClient* sr = g_self_render.exchange(nullptr, std::memory_order_acq_rel)) if (IAudioRenderClient* sr = g_self_render.exchange(nullptr, std::memory_order_acq_rel))

View File

@@ -17,6 +17,7 @@
#include "coop/shared_memory.hpp" #include "coop/shared_memory.hpp"
#include "debug_log.hpp" #include "debug_log.hpp"
#include "focus_spoof.hpp" #include "focus_spoof.hpp"
#include "hook_registry.hpp"
#include "ipc_client.hpp" #include "ipc_client.hpp"
#include "xinput_hook.hpp" #include "xinput_hook.hpp"
@@ -97,6 +98,7 @@ DWORD WINAPI worker_thread(LPVOID)
coop::hook::republish_audio_format(); coop::hook::republish_audio_format();
} }
coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change
coop::hook::hook_publish(g_ipc); // installed-hooks list + call counts
g_ipc.heartbeat(); g_ipc.heartbeat();
Sleep(250); Sleep(250);
} }
@@ -130,6 +132,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved)
coop::hook::remove_focus_spoof(); coop::hook::remove_focus_spoof();
coop::hook::remove_xinput_hooks(); coop::hook::remove_xinput_hooks();
coop::hook::remove_audio_hooks(); coop::hook::remove_audio_hooks();
coop::hook::hook_registry_reset();
} }
break; break;
default: default:

View File

@@ -6,6 +6,8 @@
#include <safetyhook.hpp> #include <safetyhook.hpp>
#include "hook_registry.hpp"
namespace coop::hook namespace coop::hook
{ {
@@ -18,6 +20,11 @@ bool g_unicode = true;
std::vector<safetyhook::InlineHook> g_focus_hooks; std::vector<safetyhook::InlineHook> g_focus_hooks;
IpcClient* g_focus_ipc = nullptr; IpcClient* g_focus_ipc = nullptr;
int g_id_foreground = -1;
int g_id_active = -1;
int g_id_focus = -1;
int g_id_wndproc = -1;
struct EnumContext struct EnumContext
{ {
DWORD pid; DWORD pid;
@@ -67,15 +74,19 @@ LRESULT CALLBACK subclass_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam
if (LOWORD(wparam) == WA_INACTIVE) if (LOWORD(wparam) == WA_INACTIVE)
{ {
wparam = MAKEWPARAM(WA_ACTIVE, HIWORD(wparam)); wparam = MAKEWPARAM(WA_ACTIVE, HIWORD(wparam));
hook_note_call(g_id_wndproc);
} }
break; break;
case WM_ACTIVATEAPP: case WM_ACTIVATEAPP:
wparam = TRUE; // app is "still active" wparam = TRUE; // app is "still active"
hook_note_call(g_id_wndproc);
break; break;
case WM_NCACTIVATE: case WM_NCACTIVATE:
wparam = TRUE; // keep the active (non-greyed) appearance wparam = TRUE; // keep the active (non-greyed) appearance
hook_note_call(g_id_wndproc);
break; break;
case WM_KILLFOCUS: case WM_KILLFOCUS:
hook_note_call(g_id_wndproc);
return 0; // swallow: never tell the game it lost keyboard focus return 0; // swallow: never tell the game it lost keyboard focus
default: default:
break; break;
@@ -86,6 +97,7 @@ LRESULT CALLBACK subclass_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam
HWND WINAPI hk_GetForegroundWindow() HWND WINAPI hk_GetForegroundWindow()
{ {
hook_note_call(g_id_foreground);
if (g_focus_ipc != nullptr) if (g_focus_ipc != nullptr)
{ {
g_focus_ipc->note_focus_query(FocusApi_Foreground); g_focus_ipc->note_focus_query(FocusApi_Foreground);
@@ -95,6 +107,7 @@ HWND WINAPI hk_GetForegroundWindow()
HWND WINAPI hk_GetActiveWindow() HWND WINAPI hk_GetActiveWindow()
{ {
hook_note_call(g_id_active);
if (g_focus_ipc != nullptr) if (g_focus_ipc != nullptr)
{ {
g_focus_ipc->note_focus_query(FocusApi_Active); g_focus_ipc->note_focus_query(FocusApi_Active);
@@ -104,6 +117,7 @@ HWND WINAPI hk_GetActiveWindow()
HWND WINAPI hk_GetFocus() HWND WINAPI hk_GetFocus()
{ {
hook_note_call(g_id_focus);
if (g_focus_ipc != nullptr) if (g_focus_ipc != nullptr)
{ {
g_focus_ipc->note_focus_query(FocusApi_Focus); g_focus_ipc->note_focus_query(FocusApi_Focus);
@@ -111,11 +125,12 @@ HWND WINAPI hk_GetFocus()
return g_game_hwnd; return g_game_hwnd;
} }
void hook_export(HMODULE module, const char* name, void* detour) void hook_export(HMODULE module, const char* name, void* detour, int registry_id)
{ {
if (void* target = reinterpret_cast<void*>(GetProcAddress(module, name))) if (void* target = reinterpret_cast<void*>(GetProcAddress(module, name)))
{ {
g_focus_hooks.emplace_back(safetyhook::create_inline(target, detour)); g_focus_hooks.emplace_back(safetyhook::create_inline(target, detour));
hook_set_installed(registry_id, true);
} }
} }
@@ -129,6 +144,11 @@ bool install_focus_spoof(IpcClient& ipc)
return true; // already active return true; // already active
} }
g_id_foreground = hook_register("GetForegroundWindow", HookSubsys_Focus);
g_id_active = hook_register("GetActiveWindow", HookSubsys_Focus);
g_id_focus = hook_register("GetFocus", HookSubsys_Focus);
g_id_wndproc = hook_register("WndProc (deactivation guard)", HookSubsys_Focus);
HWND hwnd = find_main_window(GetCurrentProcessId()); HWND hwnd = find_main_window(GetCurrentProcessId());
if (hwnd == nullptr) if (hwnd == nullptr)
{ {
@@ -144,12 +164,14 @@ bool install_focus_spoof(IpcClient& ipc)
? SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&subclass_proc)) ? SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&subclass_proc))
: SetWindowLongPtrA(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&subclass_proc)); : SetWindowLongPtrA(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&subclass_proc));
g_orig_proc = reinterpret_cast<WNDPROC>(replaced); g_orig_proc = reinterpret_cast<WNDPROC>(replaced);
hook_set_installed(g_id_wndproc, true);
if (HMODULE user32 = GetModuleHandleW(L"user32.dll")) if (HMODULE user32 = GetModuleHandleW(L"user32.dll"))
{ {
hook_export(user32, "GetForegroundWindow", reinterpret_cast<void*>(&hk_GetForegroundWindow)); hook_export(user32, "GetForegroundWindow", reinterpret_cast<void*>(&hk_GetForegroundWindow),
hook_export(user32, "GetActiveWindow", reinterpret_cast<void*>(&hk_GetActiveWindow)); g_id_foreground);
hook_export(user32, "GetFocus", reinterpret_cast<void*>(&hk_GetFocus)); hook_export(user32, "GetActiveWindow", reinterpret_cast<void*>(&hk_GetActiveWindow), g_id_active);
hook_export(user32, "GetFocus", reinterpret_cast<void*>(&hk_GetFocus), g_id_focus);
} }
ipc.mark_focus_spoof(true, reinterpret_cast<std::uint64_t>(hwnd)); ipc.mark_focus_spoof(true, reinterpret_cast<std::uint64_t>(hwnd));
@@ -204,6 +226,10 @@ void remove_focus_spoof()
} }
} }
g_focus_hooks.clear(); g_focus_hooks.clear();
hook_set_installed(g_id_foreground, false);
hook_set_installed(g_id_active, false);
hook_set_installed(g_id_focus, false);
hook_set_installed(g_id_wndproc, false);
g_game_hwnd = nullptr; g_game_hwnd = nullptr;
g_orig_proc = nullptr; g_orig_proc = nullptr;
g_focus_ipc = nullptr; g_focus_ipc = nullptr;

103
hook/src/hook_registry.cpp Normal file
View File

@@ -0,0 +1,103 @@
#include "hook_registry.hpp"
#include <atomic>
#include <cstring>
#include <mutex>
namespace coop::hook
{
namespace
{
struct Slot
{
char name[40] = {};
std::atomic<std::uint32_t> subsystem{0};
std::atomic<std::uint32_t> installed{0};
std::atomic<std::uint64_t> calls{0};
std::atomic<std::uint32_t> used{0};
};
Slot g_slots[kMaxHookEntries];
std::atomic<std::uint32_t> g_count{0}; // high-water mark of allocated slots
std::mutex g_register_mutex; // registration only (rare); calls are lock-free
} // namespace
int hook_register(const char* name, std::uint32_t subsystem)
{
std::scoped_lock lock(g_register_mutex);
const std::uint32_t count = g_count.load(std::memory_order_relaxed);
for (std::uint32_t i = 0; i < count; ++i)
{
if (g_slots[i].used.load(std::memory_order_relaxed) && std::strcmp(g_slots[i].name, name) == 0)
{
return static_cast<int>(i); // already registered
}
}
if (count >= kMaxHookEntries)
{
return -1; // table full
}
Slot& s = g_slots[count];
std::strncpy(s.name, name, sizeof(s.name) - 1);
s.name[sizeof(s.name) - 1] = '\0';
s.subsystem.store(subsystem, std::memory_order_relaxed);
s.installed.store(0, std::memory_order_relaxed);
s.calls.store(0, std::memory_order_relaxed);
s.used.store(1, std::memory_order_release);
g_count.store(count + 1, std::memory_order_release);
return static_cast<int>(count);
}
void hook_set_installed(int id, bool installed)
{
if (id >= 0 && id < static_cast<int>(kMaxHookEntries))
{
g_slots[id].installed.store(installed ? 1u : 0u, std::memory_order_relaxed);
}
}
void hook_note_call(int id)
{
if (id >= 0 && id < static_cast<int>(kMaxHookEntries))
{
g_slots[id].calls.fetch_add(1, std::memory_order_relaxed);
}
}
void hook_publish(IpcClient& ipc)
{
const std::uint32_t count = g_count.load(std::memory_order_acquire);
HookEntry entries[kMaxHookEntries];
std::uint32_t n = 0;
for (std::uint32_t i = 0; i < count && i < kMaxHookEntries; ++i)
{
if (!g_slots[i].used.load(std::memory_order_acquire))
{
continue;
}
HookEntry& e = entries[n];
std::memcpy(e.name, g_slots[i].name, sizeof(e.name));
e.subsystem = g_slots[i].subsystem.load(std::memory_order_relaxed);
e.installed = g_slots[i].installed.load(std::memory_order_relaxed);
e.calls = g_slots[i].calls.load(std::memory_order_relaxed);
++n;
}
ipc.publish_hook_entries(entries, n);
}
void hook_registry_reset()
{
std::scoped_lock lock(g_register_mutex);
for (auto& s : g_slots)
{
s.used.store(0, std::memory_order_relaxed);
s.installed.store(0, std::memory_order_relaxed);
s.calls.store(0, std::memory_order_relaxed);
}
g_count.store(0, std::memory_order_release);
}
} // namespace coop::hook

View File

@@ -0,0 +1,32 @@
// Process-wide registry of the individual hooks the DLL installs, with a call
// counter per hook. Each hook module (XInput, focus, audio) registers its hooks
// once and bumps the counter from its detour; the worker thread publishes the
// table to the host over IPC for the Injection panel's hook list.
#pragma once
#include <cstdint>
#include "coop/protocol.hpp"
#include "ipc_client.hpp"
namespace coop::hook
{
// Find-or-create a registry slot for `name` in `subsystem`; returns a stable id
// (>= 0) used with the calls below, or -1 if the table is full. Idempotent: the
// same name returns the same id, so install/remove cycles keep one slot.
int hook_register(const char* name, std::uint32_t subsystem);
// Mark a hook installed / removed (drives the "installed" column).
void hook_set_installed(int id, bool installed);
// Bump a hook's call counter. Cheap (relaxed atomic); safe on any thread.
void hook_note_call(int id);
// Snapshot the registry into the host's HookStatus back-channel.
void hook_publish(IpcClient& ipc);
// Forget everything (DLL detach).
void hook_registry_reset();
} // namespace coop::hook

View File

@@ -148,6 +148,26 @@ public:
} }
} }
// --- Hook registry -----------------------------------------------------
// Publish the installed-hooks table (name / subsystem / installed / calls).
void publish_hook_entries(const HookEntry* entries, std::uint32_t count)
{
if (block_ == nullptr)
{
return;
}
if (count > kMaxHookEntries)
{
count = kMaxHookEntries;
}
for (std::uint32_t i = 0; i < count; ++i)
{
block_->status.hook_entries[i] = entries[i];
}
block_->status.hook_entry_count = count;
}
private: private:
SharedMemory shm_; SharedMemory shm_;
SharedBlock* block_ = nullptr; SharedBlock* block_ = nullptr;

View File

@@ -9,6 +9,8 @@
#include <safetyhook.hpp> #include <safetyhook.hpp>
#include "hook_registry.hpp"
namespace coop::hook namespace coop::hook
{ {
@@ -22,6 +24,13 @@ constexpr std::uint16_t kGuideButton = 0x0400;
IpcClient* g_ipc = nullptr; IpcClient* g_ipc = nullptr;
std::vector<safetyhook::InlineHook> g_hooks; std::vector<safetyhook::InlineHook> g_hooks;
// Registry ids for the hook list (one per logical export; shared across the
// xinput*.dll variants that may each export it).
int g_id_getstate = -1;
int g_id_getstateex = -1;
int g_id_getcaps = -1;
int g_id_setstate = -1;
// Last good snapshot, so a momentary failed IPC read (host mid-write) doesn't // Last good snapshot, so a momentary failed IPC read (host mid-write) doesn't
// flicker the controller as disconnected inside the game. // flicker the controller as disconnected inside the game.
std::array<CoopPadState, kMaxPads> g_cache; std::array<CoopPadState, kMaxPads> g_cache;
@@ -86,16 +95,19 @@ DWORD query_state(DWORD user_index, XINPUT_STATE* state, bool keep_guide)
DWORD WINAPI hk_XInputGetState(DWORD user_index, XINPUT_STATE* state) DWORD WINAPI hk_XInputGetState(DWORD user_index, XINPUT_STATE* state)
{ {
hook_note_call(g_id_getstate);
return query_state(user_index, state, /*keep_guide=*/false); return query_state(user_index, state, /*keep_guide=*/false);
} }
DWORD WINAPI hk_XInputGetStateEx(DWORD user_index, XINPUT_STATE* state) DWORD WINAPI hk_XInputGetStateEx(DWORD user_index, XINPUT_STATE* state)
{ {
hook_note_call(g_id_getstateex);
return query_state(user_index, state, /*keep_guide=*/true); return query_state(user_index, state, /*keep_guide=*/true);
} }
DWORD WINAPI hk_XInputGetCapabilities(DWORD user_index, DWORD /*flags*/, XINPUT_CAPABILITIES* caps) DWORD WINAPI hk_XInputGetCapabilities(DWORD user_index, DWORD /*flags*/, XINPUT_CAPABILITIES* caps)
{ {
hook_note_call(g_id_getcaps);
if (caps == nullptr || user_index >= kMaxPads) if (caps == nullptr || user_index >= kMaxPads)
{ {
return ERROR_DEVICE_NOT_CONNECTED; return ERROR_DEVICE_NOT_CONNECTED;
@@ -131,6 +143,7 @@ DWORD WINAPI hk_XInputGetCapabilities(DWORD user_index, DWORD /*flags*/, XINPUT_
// phase; for now report success so the game's logic is happy. // phase; for now report success so the game's logic is happy.
DWORD WINAPI hk_XInputSetState(DWORD user_index, XINPUT_VIBRATION* /*vibration*/) DWORD WINAPI hk_XInputSetState(DWORD user_index, XINPUT_VIBRATION* /*vibration*/)
{ {
hook_note_call(g_id_setstate);
if (user_index >= kMaxPads || !g_cache[user_index].connected) if (user_index >= kMaxPads || !g_cache[user_index].connected)
{ {
return ERROR_DEVICE_NOT_CONNECTED; return ERROR_DEVICE_NOT_CONNECTED;
@@ -138,7 +151,7 @@ DWORD WINAPI hk_XInputSetState(DWORD user_index, XINPUT_VIBRATION* /*vibration*/
return ERROR_SUCCESS; return ERROR_SUCCESS;
} }
void hook_export(HMODULE module, const char* name, void* detour) void hook_export(HMODULE module, const char* name, void* detour, int registry_id)
{ {
if (module == nullptr) if (module == nullptr)
{ {
@@ -147,10 +160,11 @@ void hook_export(HMODULE module, const char* name, void* detour)
if (void* target = reinterpret_cast<void*>(GetProcAddress(module, name))) if (void* target = reinterpret_cast<void*>(GetProcAddress(module, name)))
{ {
g_hooks.emplace_back(safetyhook::create_inline(target, detour)); g_hooks.emplace_back(safetyhook::create_inline(target, detour));
hook_set_installed(registry_id, true);
} }
} }
void hook_ordinal(HMODULE module, WORD ordinal, void* detour) void hook_ordinal(HMODULE module, WORD ordinal, void* detour, int registry_id)
{ {
if (module == nullptr) if (module == nullptr)
{ {
@@ -159,6 +173,7 @@ void hook_ordinal(HMODULE module, WORD ordinal, void* detour)
if (void* target = reinterpret_cast<void*>(GetProcAddress(module, MAKEINTRESOURCEA(ordinal)))) if (void* target = reinterpret_cast<void*>(GetProcAddress(module, MAKEINTRESOURCEA(ordinal))))
{ {
g_hooks.emplace_back(safetyhook::create_inline(target, detour)); g_hooks.emplace_back(safetyhook::create_inline(target, detour));
hook_set_installed(registry_id, true);
} }
} }
@@ -173,6 +188,11 @@ bool install_xinput_hooks(IpcClient& ipc)
g_ipc = &ipc; g_ipc = &ipc;
refresh_cache(); refresh_cache();
g_id_getstate = hook_register("XInputGetState", HookSubsys_Input);
g_id_getstateex = hook_register("XInputGetStateEx (ord 100)", HookSubsys_Input);
g_id_getcaps = hook_register("XInputGetCapabilities", HookSubsys_Input);
g_id_setstate = hook_register("XInputSetState", HookSubsys_Input);
// A process generally loads exactly one of these, but hook every one that is // A process generally loads exactly one of these, but hook every one that is
// present so we don't miss the one the game actually calls. // present so we don't miss the one the game actually calls.
const wchar_t* modules[] = {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll", L"xinputuap.dll"}; const wchar_t* modules[] = {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll", L"xinputuap.dll"};
@@ -183,10 +203,11 @@ bool install_xinput_hooks(IpcClient& ipc)
{ {
continue; continue;
} }
hook_export(module, "XInputGetState", reinterpret_cast<void*>(&hk_XInputGetState)); hook_export(module, "XInputGetState", reinterpret_cast<void*>(&hk_XInputGetState), g_id_getstate);
hook_ordinal(module, 100, reinterpret_cast<void*>(&hk_XInputGetStateEx)); hook_ordinal(module, 100, reinterpret_cast<void*>(&hk_XInputGetStateEx), g_id_getstateex);
hook_export(module, "XInputGetCapabilities", reinterpret_cast<void*>(&hk_XInputGetCapabilities)); hook_export(module, "XInputGetCapabilities", reinterpret_cast<void*>(&hk_XInputGetCapabilities),
hook_export(module, "XInputSetState", reinterpret_cast<void*>(&hk_XInputSetState)); g_id_getcaps);
hook_export(module, "XInputSetState", reinterpret_cast<void*>(&hk_XInputSetState), g_id_setstate);
} }
if (!g_hooks.empty()) if (!g_hooks.empty())
{ {
@@ -199,6 +220,10 @@ bool install_xinput_hooks(IpcClient& ipc)
void remove_xinput_hooks() void remove_xinput_hooks()
{ {
g_hooks.clear(); // InlineHook destructor restores the original bytes g_hooks.clear(); // InlineHook destructor restores the original bytes
hook_set_installed(g_id_getstate, false);
hook_set_installed(g_id_getstateex, false);
hook_set_installed(g_id_getcaps, false);
hook_set_installed(g_id_setstate, false);
g_ipc = nullptr; g_ipc = nullptr;
} }

View File

@@ -139,6 +139,67 @@ void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
server_.publish(synthetic); server_.publish(synthetic);
} }
void InjectionPanel::draw_hook_list(const HookStatusView& status)
{
static const char* kSubsysName[] = {"Input", "Focus", "Audio"};
const std::uint32_t n = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
if (n == 0)
{
return;
}
if (!ImGui::CollapsingHeader("Installed hooks", ImGuiTreeNodeFlags_DefaultOpen))
{
return;
}
if (ImGui::BeginTable("hooks", 3, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp))
{
ImGui::TableSetupColumn("Hook");
ImGui::TableSetupColumn("On", ImGuiTableColumnFlags_WidthFixed);
ImGui::TableSetupColumn("Calls", ImGuiTableColumnFlags_WidthFixed);
ImGui::TableHeadersRow();
// Group rows by subsystem so related hooks sit together.
for (std::uint32_t sub = 0; sub < HookSubsys_Count; ++sub)
{
bool header_done = false;
for (std::uint32_t i = 0; i < n; ++i)
{
const HookEntry& e = status.hook_entries[i];
if (e.subsystem != sub)
{
continue;
}
if (!header_done)
{
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", kSubsysName[sub < 3 ? sub : 0]);
ImGui::TableNextColumn();
ImGui::TableNextColumn();
header_done = true;
}
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextUnformatted(e.name);
ImGui::TableNextColumn();
if (e.installed)
{
ImGui::TextColored(kGreen, "yes");
}
else
{
ImGui::TextDisabled("no");
}
ImGui::TableNextColumn();
ImGui::Text("%llu", static_cast<unsigned long long>(e.calls));
}
}
ImGui::EndTable();
}
}
void InjectionPanel::draw_hook_status(bool debug_details) void InjectionPanel::draw_hook_status(bool debug_details)
{ {
if (!server_.running()) if (!server_.running())
@@ -160,6 +221,8 @@ void InjectionPanel::draw_hook_status(bool debug_details)
status.focus_spoof ? "active" : "inactive"); status.focus_spoof ? "active" : "inactive");
ImGui::TextDisabled("Controller poll rates are in the Controllers panel."); ImGui::TextDisabled("Controller poll rates are in the Controllers panel.");
draw_hook_list(status);
if (!debug_details) if (!debug_details)
{ {
return; // everything below is diagnostic detail return; // everything below is diagnostic detail

View File

@@ -45,6 +45,7 @@ public:
private: private:
void refresh_processes(); void refresh_processes();
void inject_selected(); void inject_selected();
void draw_hook_list(const HookStatusView& status);
void draw_hook_status(bool debug_details); void draw_hook_status(bool debug_details);
std::vector<ProcessEntry> processes_; std::vector<ProcessEntry> processes_;

View File

@@ -71,6 +71,11 @@ HookStatusView IpcServer::hook_status() const
{ {
view.audio_streams[i] = s.audio_streams[i]; view.audio_streams[i] = s.audio_streams[i];
} }
view.hook_entry_count = s.hook_entry_count;
for (std::uint32_t i = 0; i < kMaxHookEntries; ++i)
{
view.hook_entries[i] = s.hook_entries[i];
}
return view; return view;
} }

View File

@@ -31,6 +31,10 @@ struct HookStatusView
// Audio render-hook diagnostics (for the Audio panel's stream-count view). // Audio render-hook diagnostics (for the Audio panel's stream-count view).
std::uint32_t audio_streams_seen = 0; std::uint32_t audio_streams_seen = 0;
AudioStreamInfo audio_streams[kMaxAudioStreams] = {}; AudioStreamInfo audio_streams[kMaxAudioStreams] = {};
// Installed-hooks registry (for the Injection panel's hook list).
std::uint32_t hook_entry_count = 0;
HookEntry hook_entries[kMaxHookEntries] = {};
}; };
class IpcServer class IpcServer

View File

@@ -2,7 +2,8 @@
# Reuses the hook's xinput_hook.cpp directly so it exercises the shipping code. # Reuses the hook's xinput_hook.cpp directly so it exercises the shipping code.
add_executable(hook_selftest add_executable(hook_selftest
hook_selftest.cpp hook_selftest.cpp
${CMAKE_SOURCE_DIR}/hook/src/xinput_hook.cpp) ${CMAKE_SOURCE_DIR}/hook/src/xinput_hook.cpp
${CMAKE_SOURCE_DIR}/hook/src/hook_registry.cpp)
target_include_directories(hook_selftest PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) target_include_directories(hook_selftest PRIVATE ${CMAKE_SOURCE_DIR}/hook/src)
@@ -42,7 +43,8 @@ add_test(NAME audio_loopback_test COMMAND audio_loopback_test)
add_executable(audio_hook_test add_executable(audio_hook_test
audio_hook_test.cpp audio_hook_test.cpp
${CMAKE_SOURCE_DIR}/hook/src/audio_hook.cpp ${CMAKE_SOURCE_DIR}/hook/src/audio_hook.cpp
${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp) ${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp
${CMAKE_SOURCE_DIR}/hook/src/hook_registry.cpp)
target_include_directories(audio_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) target_include_directories(audio_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src)

View File

@@ -233,6 +233,16 @@ int wmain(int argc, wchar_t** argv)
} }
} }
// Dump the hook registry so the installed-hooks list can be verified headless.
static const char* kSubsys[] = {"Input", "Focus", "Audio"};
std::printf("\nInstalled hooks (%u):\n", status.hook_entry_count);
for (std::uint32_t i = 0; i < status.hook_entry_count && i < coop::kMaxHookEntries; ++i)
{
const coop::HookEntry& e = status.hook_entries[i];
std::printf(" [%-5s] %-34s %s calls=%llu\n", e.subsystem < 3 ? kSubsys[e.subsystem] : "?", e.name,
e.installed ? "ON " : "off", static_cast<unsigned long long>(e.calls));
}
std::printf("\nDone. Leaving the hook loaded in the game.\n"); std::printf("\nDone. Leaving the hook loaded in the game.\n");
block->magic = 0; // invalidate so a late hook read won't trust stale data block->magic = 0; // invalidate so a late hook read won't trust stale data
return 0; return 0;