From 1f905940efb0f8c63e09c90c6e4469d2b164de72 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Fri, 19 Jun 2026 20:09:39 +0200 Subject: [PATCH] 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 --- README.md | 5 ++ common/include/coop/protocol.hpp | 29 ++++++++- hook/CMakeLists.txt | 3 +- hook/src/audio_hook.cpp | 30 +++++++++ hook/src/dllmain.cpp | 3 + hook/src/focus_spoof.cpp | 34 ++++++++-- hook/src/hook_registry.cpp | 103 +++++++++++++++++++++++++++++++ hook/src/hook_registry.hpp | 32 ++++++++++ hook/src/ipc_client.hpp | 20 ++++++ hook/src/xinput_hook.cpp | 37 +++++++++-- host/src/injection_panel.cpp | 63 +++++++++++++++++++ host/src/injection_panel.hpp | 1 + host/src/ipc/ipc_server.cpp | 5 ++ host/src/ipc/ipc_server.hpp | 4 ++ tests/CMakeLists.txt | 6 +- tools/audio_probe/main.cpp | 10 +++ 16 files changed, 371 insertions(+), 14 deletions(-) create mode 100644 hook/src/hook_registry.cpp create mode 100644 hook/src/hook_registry.hpp diff --git a/README.md b/README.md index c3daa23..c661435 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,11 @@ Done: forwarding, controller polling rate, mirror resolution, audio source + buffered 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. +- **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: diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index 71b911e..18d86d4 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -11,7 +11,7 @@ namespace coop // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // 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. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -60,6 +60,28 @@ struct AudioStreamInfo 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. enum FocusApi : std::uint32_t { @@ -97,6 +119,11 @@ struct HookStatus // multi-stream game is visible before/without turning the mirror on. std::uint32_t audio_streams_seen; // distinct render clients ever created 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 diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt index 105cdd2..a89ddc4 100644 --- a/hook/CMakeLists.txt +++ b/hook/CMakeLists.txt @@ -3,7 +3,8 @@ add_library(coop_hook SHARED src/xinput_hook.cpp src/focus_spoof.cpp src/audio_hook.cpp - src/debug_log.cpp) + src/debug_log.cpp + src/hook_registry.cpp) target_include_directories(coop_hook PRIVATE src) diff --git a/hook/src/audio_hook.cpp b/hook/src/audio_hook.cpp index afcff31..864a123 100644 --- a/hook/src/audio_hook.cpp +++ b/hook/src/audio_hook.cpp @@ -14,6 +14,7 @@ #include #include "debug_log.hpp" +#include "hook_registry.hpp" namespace coop::hook { @@ -55,6 +56,13 @@ safetyhook::InlineHook g_hk_getbuffer; safetyhook::InlineHook g_hk_releasebuffer; 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 // IAudioClient / IAudioRenderClient vtables and hook GetBuffer/ReleaseBuffer // *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) { + hook_note_call(g_id_getbuffer); const HRESULT hr = g_hk_getbuffer.call(self, num_frames, data); 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) { + hook_note_call(g_id_releasebuffer); // 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 // 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, const WAVEFORMATEX* format, LPCGUID session) { + hook_note_call(g_id_initialize); const HRESULT hr = g_hk_initialize.call(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, @@ -314,6 +325,7 @@ HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mo HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** ppv) { + hook_note_call(g_id_getservice); const HRESULT hr = g_hk_getservice.call(self, riid, ppv); const bool is_render = (riid == __uuidof(IAudioRenderClient)); logf("hk_GetService: client=%p hr=0x%08lX render_client=%d", self, static_cast(hr), @@ -371,6 +383,7 @@ void install_audioclient_hooks(IAudioClient* ac) HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ctx, PROPVARIANT* params, void** ppv) { + hook_note_call(g_id_activate); const HRESULT hr = g_hk_activate.call(self, riid, cls_ctx, params, ppv); const bool is_audioclient = (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) || riid == __uuidof(IAudioClient3)); @@ -395,6 +408,12 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring) 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 // read the shared IMMDevice vtable and hook Activate. Every IMMDevice in the // 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. } + hook_set_installed(g_id_activate, static_cast(g_hk_activate)); + hook_set_installed(g_id_initialize, static_cast(g_hk_initialize)); + hook_set_installed(g_id_getservice, static_cast(g_hk_getservice)); + hook_set_installed(g_id_getbuffer, static_cast(g_hk_getbuffer)); + hook_set_installed(g_id_releasebuffer, static_cast(g_hk_releasebuffer)); + logf("install_audio_hooks: activate=%d init=%d getsvc=%d getbuf=%d relbuf=%d (device=%p)", static_cast(g_hk_activate) ? 1 : 0, static_cast(g_hk_initialize) ? 1 : 0, static_cast(g_hk_getservice) ? 1 : 0, static_cast(g_hk_getbuffer) ? 1 : 0, @@ -522,6 +547,11 @@ void remove_audio_hooks() g_hk_initialize = {}; g_hk_activate = {}; 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. if (IAudioRenderClient* sr = g_self_render.exchange(nullptr, std::memory_order_acq_rel)) diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index 3f87ece..86799aa 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -17,6 +17,7 @@ #include "coop/shared_memory.hpp" #include "debug_log.hpp" #include "focus_spoof.hpp" +#include "hook_registry.hpp" #include "ipc_client.hpp" #include "xinput_hook.hpp" @@ -97,6 +98,7 @@ DWORD WINAPI worker_thread(LPVOID) coop::hook::republish_audio_format(); } 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(); Sleep(250); } @@ -130,6 +132,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) coop::hook::remove_focus_spoof(); coop::hook::remove_xinput_hooks(); coop::hook::remove_audio_hooks(); + coop::hook::hook_registry_reset(); } break; default: diff --git a/hook/src/focus_spoof.cpp b/hook/src/focus_spoof.cpp index 42cc4dc..f0f5699 100644 --- a/hook/src/focus_spoof.cpp +++ b/hook/src/focus_spoof.cpp @@ -6,6 +6,8 @@ #include +#include "hook_registry.hpp" + namespace coop::hook { @@ -18,6 +20,11 @@ bool g_unicode = true; std::vector g_focus_hooks; 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 { DWORD pid; @@ -67,15 +74,19 @@ LRESULT CALLBACK subclass_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam if (LOWORD(wparam) == WA_INACTIVE) { wparam = MAKEWPARAM(WA_ACTIVE, HIWORD(wparam)); + hook_note_call(g_id_wndproc); } break; case WM_ACTIVATEAPP: wparam = TRUE; // app is "still active" + hook_note_call(g_id_wndproc); break; case WM_NCACTIVATE: wparam = TRUE; // keep the active (non-greyed) appearance + hook_note_call(g_id_wndproc); break; case WM_KILLFOCUS: + hook_note_call(g_id_wndproc); return 0; // swallow: never tell the game it lost keyboard focus default: break; @@ -86,6 +97,7 @@ LRESULT CALLBACK subclass_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam HWND WINAPI hk_GetForegroundWindow() { + hook_note_call(g_id_foreground); if (g_focus_ipc != nullptr) { g_focus_ipc->note_focus_query(FocusApi_Foreground); @@ -95,6 +107,7 @@ HWND WINAPI hk_GetForegroundWindow() HWND WINAPI hk_GetActiveWindow() { + hook_note_call(g_id_active); if (g_focus_ipc != nullptr) { g_focus_ipc->note_focus_query(FocusApi_Active); @@ -104,6 +117,7 @@ HWND WINAPI hk_GetActiveWindow() HWND WINAPI hk_GetFocus() { + hook_note_call(g_id_focus); if (g_focus_ipc != nullptr) { g_focus_ipc->note_focus_query(FocusApi_Focus); @@ -111,11 +125,12 @@ HWND WINAPI hk_GetFocus() 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(GetProcAddress(module, name))) { 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 } + 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()); if (hwnd == nullptr) { @@ -144,12 +164,14 @@ bool install_focus_spoof(IpcClient& ipc) ? SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast(&subclass_proc)) : SetWindowLongPtrA(hwnd, GWLP_WNDPROC, reinterpret_cast(&subclass_proc)); g_orig_proc = reinterpret_cast(replaced); + hook_set_installed(g_id_wndproc, true); if (HMODULE user32 = GetModuleHandleW(L"user32.dll")) { - hook_export(user32, "GetForegroundWindow", reinterpret_cast(&hk_GetForegroundWindow)); - hook_export(user32, "GetActiveWindow", reinterpret_cast(&hk_GetActiveWindow)); - hook_export(user32, "GetFocus", reinterpret_cast(&hk_GetFocus)); + hook_export(user32, "GetForegroundWindow", reinterpret_cast(&hk_GetForegroundWindow), + g_id_foreground); + hook_export(user32, "GetActiveWindow", reinterpret_cast(&hk_GetActiveWindow), g_id_active); + hook_export(user32, "GetFocus", reinterpret_cast(&hk_GetFocus), g_id_focus); } ipc.mark_focus_spoof(true, reinterpret_cast(hwnd)); @@ -204,6 +226,10 @@ void remove_focus_spoof() } } 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_orig_proc = nullptr; g_focus_ipc = nullptr; diff --git a/hook/src/hook_registry.cpp b/hook/src/hook_registry.cpp new file mode 100644 index 0000000..41d7297 --- /dev/null +++ b/hook/src/hook_registry.cpp @@ -0,0 +1,103 @@ +#include "hook_registry.hpp" + +#include +#include +#include + +namespace coop::hook +{ + +namespace +{ + +struct Slot +{ + char name[40] = {}; + std::atomic subsystem{0}; + std::atomic installed{0}; + std::atomic calls{0}; + std::atomic used{0}; +}; + +Slot g_slots[kMaxHookEntries]; +std::atomic 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(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(count); +} + +void hook_set_installed(int id, bool installed) +{ + if (id >= 0 && id < static_cast(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(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 diff --git a/hook/src/hook_registry.hpp b/hook/src/hook_registry.hpp new file mode 100644 index 0000000..808ddfd --- /dev/null +++ b/hook/src/hook_registry.hpp @@ -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 + +#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 diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index bbd55bb..88f6dc9 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -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: SharedMemory shm_; SharedBlock* block_ = nullptr; diff --git a/hook/src/xinput_hook.cpp b/hook/src/xinput_hook.cpp index 2e0b23a..3d5bea0 100644 --- a/hook/src/xinput_hook.cpp +++ b/hook/src/xinput_hook.cpp @@ -9,6 +9,8 @@ #include +#include "hook_registry.hpp" + namespace coop::hook { @@ -22,6 +24,13 @@ constexpr std::uint16_t kGuideButton = 0x0400; IpcClient* g_ipc = nullptr; std::vector 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 // flicker the controller as disconnected inside the game. std::array 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) { + hook_note_call(g_id_getstate); return query_state(user_index, state, /*keep_guide=*/false); } 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); } DWORD WINAPI hk_XInputGetCapabilities(DWORD user_index, DWORD /*flags*/, XINPUT_CAPABILITIES* caps) { + hook_note_call(g_id_getcaps); if (caps == nullptr || user_index >= kMaxPads) { 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. 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) { return ERROR_DEVICE_NOT_CONNECTED; @@ -138,7 +151,7 @@ DWORD WINAPI hk_XInputSetState(DWORD user_index, XINPUT_VIBRATION* /*vibration*/ 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) { @@ -147,10 +160,11 @@ void hook_export(HMODULE module, const char* name, void* detour) if (void* target = reinterpret_cast(GetProcAddress(module, name))) { 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) { @@ -159,6 +173,7 @@ void hook_ordinal(HMODULE module, WORD ordinal, void* detour) if (void* target = reinterpret_cast(GetProcAddress(module, MAKEINTRESOURCEA(ordinal)))) { 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; 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 // 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"}; @@ -183,10 +203,11 @@ bool install_xinput_hooks(IpcClient& ipc) { continue; } - hook_export(module, "XInputGetState", reinterpret_cast(&hk_XInputGetState)); - hook_ordinal(module, 100, reinterpret_cast(&hk_XInputGetStateEx)); - hook_export(module, "XInputGetCapabilities", reinterpret_cast(&hk_XInputGetCapabilities)); - hook_export(module, "XInputSetState", reinterpret_cast(&hk_XInputSetState)); + hook_export(module, "XInputGetState", reinterpret_cast(&hk_XInputGetState), g_id_getstate); + hook_ordinal(module, 100, reinterpret_cast(&hk_XInputGetStateEx), g_id_getstateex); + hook_export(module, "XInputGetCapabilities", reinterpret_cast(&hk_XInputGetCapabilities), + g_id_getcaps); + hook_export(module, "XInputSetState", reinterpret_cast(&hk_XInputSetState), g_id_setstate); } if (!g_hooks.empty()) { @@ -199,6 +220,10 @@ bool install_xinput_hooks(IpcClient& ipc) void remove_xinput_hooks() { 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; } diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index efc368f..34da7a0 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -139,6 +139,67 @@ void InjectionPanel::publish(const std::array& pads) 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(e.calls)); + } + } + ImGui::EndTable(); + } +} + void InjectionPanel::draw_hook_status(bool debug_details) { if (!server_.running()) @@ -160,6 +221,8 @@ void InjectionPanel::draw_hook_status(bool debug_details) status.focus_spoof ? "active" : "inactive"); ImGui::TextDisabled("Controller poll rates are in the Controllers panel."); + draw_hook_list(status); + if (!debug_details) { return; // everything below is diagnostic detail diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index 7df3028..c3332e6 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -45,6 +45,7 @@ public: private: void refresh_processes(); void inject_selected(); + void draw_hook_list(const HookStatusView& status); void draw_hook_status(bool debug_details); std::vector processes_; diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 1bef035..767e881 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -71,6 +71,11 @@ HookStatusView IpcServer::hook_status() const { 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; } diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index fe1607b..4508263 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -31,6 +31,10 @@ struct HookStatusView // Audio render-hook diagnostics (for the Audio panel's stream-count view). std::uint32_t audio_streams_seen = 0; 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 45c94d3..110ec85 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,7 +2,8 @@ # Reuses the hook's xinput_hook.cpp directly so it exercises the shipping code. add_executable(hook_selftest 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) @@ -42,7 +43,8 @@ add_test(NAME audio_loopback_test COMMAND audio_loopback_test) add_executable(audio_hook_test audio_hook_test.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) diff --git a/tools/audio_probe/main.cpp b/tools/audio_probe/main.cpp index 15f079a..086b073 100644 --- a/tools/audio_probe/main.cpp +++ b/tools/audio_probe/main.cpp @@ -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(e.calls)); + } + 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 return 0;