diff --git a/README.md b/README.md index 8e9a010..cb8091a 100644 --- a/README.md +++ b/README.md @@ -47,11 +47,15 @@ window with an ImGui overlay listing every controller it sees. Confirmed end-to-end: Steam RPT streams the window under a donor appid, and guest gamepads arrive (with correct slot assignment) as XInput. -**Phase 1a — input forwarding (current).** The host can inject `coop_hook.dll` -into a running game; the DLL hooks XInput (via SafetyHook) so the game reads the -controller state the host forwards over shared memory — and *only* that state, so -physical/other controllers are hidden from the game. The in-process -`hook_selftest` validates the IPC + hook core without needing a game. +**Phase 1a — input forwarding + focus spoofing (current).** The host injects +`coop_hook.dll`; the DLL hooks XInput (via SafetyHook) so the game reads the +forwarded controller state and *only* that state. The DLL also **spoofs focus** +(hooks `GetForegroundWindow`/`GetActiveWindow`/`GetFocus` and subclasses the game +window to swallow deactivation messages) so the game keeps running and polling +while the tool holds the real OS focus — required because Steam RPT only captures +the focused window. A hook→host status back-channel shows whether the hook is +attached and how fast the game is polling it. The in-process `hook_selftest` +validates the IPC + hook core without needing a game. Still ahead (scoped in the plan): video mirror (WGC, then a `Present` hook), audio (WASAPI process loopback), and x86 support. @@ -119,15 +123,26 @@ This needs no RPT, donor, or second account — just the host, the hook, a controller, and a target game. `coop_host.exe` and `coop_hook.dll` must sit in the same folder (the build places both in `bin//`). -1. Start a DRM-free, **non-anti-cheat**, XInput game (e.g. a small controller - sample or a permissive indie title) and get to a screen that reads the pad. +**Requirement:** run the target game **windowed or borderless**, not exclusive +fullscreen. Exclusive fullscreen minimizes on focus loss (defeating the focus +spoof) and can't be window-captured later. Only **controller** input is +forwarded — while the game is unfocused it won't receive OS keyboard/mouse. + +1. Start a DRM-free, **non-anti-cheat**, XInput game in windowed/borderless mode + and get to a screen that reads the pad. 2. Run `bin\Debug\coop_host.exe`. In the **Injection** panel, filter for the - game's `.exe`, select it, and click **Inject & Connect**. The status line - should turn green ("Injected … / Forwarding input to pid …"). -3. Press buttons on your physical controller. The game should respond — its - XInput now comes from the host's forwarded state, not the device directly. - Unplug-test: other controllers/slots are hidden from the game. -4. Click **Stop forwarding** (or quit the host) to tear down the channel. + game's `.exe`, select it, and click **Inject & Connect**. +3. Watch the **Hook status** section. Once it shows **Attached** and a non-zero + **"XInput polled: N/s"**, the game is provably reading our hook — injection + works. **Focus spoof: active** confirms the window was found and subclassed. +4. **Prove forwarding is the source:** tick **Forward synthetic test input**. + The game should now move on its own — left stick sweeping a circle, A pressed + every other second — independent of your physical controller. Untick it to + return control to your pad. +5. Sanity-check the focus spoof: click into another window so the game loses real + focus. It should keep running/animating (not pause), and the poll rate should + stay non-zero. +6. Click **Stop forwarding** (or quit the host) to tear down the channel. > If injection fails with an access error, run the host as administrator. If it > reports "target is 32-bit", that game needs the x86 hook (a later phase). diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index 673ef7d..e9dc300 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 = 1; +inline constexpr std::uint32_t kProtocolVersion = 2; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -42,6 +42,21 @@ struct CoopPadState static_assert(sizeof(CoopPadState) == 20, "CoopPadState layout must stay stable across both modules"); +// Hook -> host back-channel. The injected DLL is the sole writer; the host reads +// it for the diagnostics overlay (is the hook attached? is the game actually +// polling it? did focus spoofing take?). Diagnostics only, so the few non-atomic +// fields tolerate benign cross-process races. +struct HookStatus +{ + std::atomic heartbeat; // DLL bumps this ~4x/sec while alive + std::atomic xinput_queries; // cumulative XInputGetState/Ex calls served + std::uint32_t attached; // 1 once XInput hooks are installed + std::uint32_t focus_spoof; // 1 once focus spoofing is active + std::uint32_t game_pid; // the DLL's own pid (sanity check) + std::uint32_t last_user_index; // last slot the game queried + std::uint64_t game_hwnd; // window the DLL subclassed (0 if none yet) +}; + // Top-level shared block. The host is the sole writer of pad state; the hook is // the sole reader. A seqlock (even = stable, odd = write in progress) lets the // reader grab a torn-free snapshot without a kernel lock on the hot path. @@ -53,12 +68,17 @@ struct SharedBlock std::atomic sequence; CoopPadState pads[kMaxPads]; + // Hook -> host diagnostics back-channel. + HookStatus status; + // Phase 2 appends the shared-texture handle/dimensions control fields here; // keep new members at the end so existing offsets never shift. }; static_assert(std::atomic::is_always_lock_free, "seqlock requires a lock-free 32-bit atomic for cross-process use"); +static_assert(std::atomic::is_always_lock_free, + "status counters need a lock-free 64-bit atomic for cross-process use"); // --- Seqlock helpers ------------------------------------------------------- diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt index 9121159..12768a3 100644 --- a/hook/CMakeLists.txt +++ b/hook/CMakeLists.txt @@ -1,12 +1,14 @@ add_library(coop_hook SHARED src/dllmain.cpp - src/xinput_hook.cpp) + src/xinput_hook.cpp + src/focus_spoof.cpp) target_include_directories(coop_hook PRIVATE src) target_link_libraries(coop_hook PRIVATE coop_common - safetyhook::safetyhook) + safetyhook::safetyhook + user32) set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook") diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index d2313f2..ef316fd 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -1,12 +1,16 @@ // coop_hook.dll -- injected into the target game by the host. // // On load it opens the host's shared-memory channel (named by this process's -// pid), then hooks XInput so the game reads the forwarded controller state. All -// real work happens on a worker thread; DllMain only kicks it off to stay clear -// of the loader lock. +// pid), hooks XInput so the game reads the forwarded controller state, and +// spoofs focus so the game keeps running while the tool holds the real OS focus. +// All real work happens on a worker thread; DllMain only kicks it off to stay +// clear of the loader lock. + +#include #include +#include "focus_spoof.hpp" #include "ipc_client.hpp" #include "xinput_hook.hpp" @@ -14,8 +18,9 @@ namespace { coop::hook::IpcClient g_ipc; +std::atomic g_running{true}; -DWORD WINAPI init_thread(LPVOID) +DWORD WINAPI worker_thread(LPVOID) { // The host creates the mapping around injection time; give it a few seconds. if (!g_ipc.connect(/*attempts=*/200, /*delay_ms=*/25)) @@ -23,11 +28,23 @@ DWORD WINAPI init_thread(LPVOID) return 0; } - // XInput may not be loaded yet at this point (games often load it lazily on - // first controller use), so keep retrying until a module appears. - for (int i = 0; i < 400 && !coop::hook::install_xinput_hooks(g_ipc); ++i) + bool xinput_installed = false; + bool focus_installed = false; + + // Keep retrying the installs (XInput and the game window may both appear + // lazily) and beat a heartbeat so the host can show the hook is alive. + while (g_running.load(std::memory_order_relaxed)) { - Sleep(25); + if (!xinput_installed) + { + xinput_installed = coop::hook::install_xinput_hooks(g_ipc); + } + if (!focus_installed) + { + focus_installed = coop::hook::install_focus_spoof(g_ipc); + } + g_ipc.heartbeat(); + Sleep(250); } return 0; } @@ -40,7 +57,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) { case DLL_PROCESS_ATTACH: DisableThreadLibraryCalls(module); - if (HANDLE thread = CreateThread(nullptr, 0, &init_thread, nullptr, 0, nullptr)) + if (HANDLE thread = CreateThread(nullptr, 0, &worker_thread, nullptr, 0, nullptr)) { CloseHandle(thread); } @@ -50,6 +67,8 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) // loader is already unwinding and touching other modules is unsafe. if (reserved == nullptr) { + g_running.store(false, std::memory_order_relaxed); + coop::hook::remove_focus_spoof(); coop::hook::remove_xinput_hooks(); } break; diff --git a/hook/src/focus_spoof.cpp b/hook/src/focus_spoof.cpp new file mode 100644 index 0000000..456faf6 --- /dev/null +++ b/hook/src/focus_spoof.cpp @@ -0,0 +1,163 @@ +#include "focus_spoof.hpp" + +#include + +#include + +#include + +namespace coop::hook +{ + +namespace +{ + +HWND g_game_hwnd = nullptr; +WNDPROC g_orig_proc = nullptr; +bool g_unicode = true; +std::vector g_focus_hooks; + +struct EnumContext +{ + DWORD pid; + HWND best; + long best_area; +}; + +BOOL CALLBACK enum_proc(HWND hwnd, LPARAM lparam) +{ + auto* ctx = reinterpret_cast(lparam); + + DWORD pid = 0; + GetWindowThreadProcessId(hwnd, &pid); + if (pid != ctx->pid || !IsWindowVisible(hwnd) || GetWindow(hwnd, GW_OWNER) != nullptr) + { + return TRUE; // not ours, hidden, or an owned dialog -- keep looking + } + + RECT rect = {}; + if (!GetWindowRect(hwnd, &rect)) + { + return TRUE; + } + const long area = (rect.right - rect.left) * (rect.bottom - rect.top); + if (area > ctx->best_area) + { + ctx->best_area = area; + ctx->best = hwnd; + } + return TRUE; +} + +// The game's main window = the largest visible, unowned top-level window it owns. +HWND find_main_window(DWORD pid) +{ + EnumContext ctx{pid, nullptr, 0}; + EnumWindows(&enum_proc, reinterpret_cast(&ctx)); + return ctx.best; +} + +// Replacement window procedure: convince the game it is never deactivated. +LRESULT CALLBACK subclass_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) +{ + switch (msg) + { + case WM_ACTIVATE: + if (LOWORD(wparam) == WA_INACTIVE) + { + wparam = MAKEWPARAM(WA_ACTIVE, HIWORD(wparam)); + } + break; + case WM_ACTIVATEAPP: + wparam = TRUE; // app is "still active" + break; + case WM_NCACTIVATE: + wparam = TRUE; // keep the active (non-greyed) appearance + break; + case WM_KILLFOCUS: + return 0; // swallow: never tell the game it lost keyboard focus + default: + break; + } + return g_unicode ? CallWindowProcW(g_orig_proc, hwnd, msg, wparam, lparam) + : CallWindowProcA(g_orig_proc, hwnd, msg, wparam, lparam); +} + +HWND WINAPI hk_GetForegroundWindow() +{ + return g_game_hwnd; +} + +HWND WINAPI hk_GetActiveWindow() +{ + return g_game_hwnd; +} + +HWND WINAPI hk_GetFocus() +{ + return g_game_hwnd; +} + +void hook_export(HMODULE module, const char* name, void* detour) +{ + if (void* target = reinterpret_cast(GetProcAddress(module, name))) + { + g_focus_hooks.emplace_back(safetyhook::create_inline(target, detour)); + } +} + +} // namespace + +bool install_focus_spoof(IpcClient& ipc) +{ + if (g_game_hwnd != nullptr) + { + return true; // already active + } + + HWND hwnd = find_main_window(GetCurrentProcessId()); + if (hwnd == nullptr) + { + return false; // window not created yet; caller retries + } + + g_game_hwnd = hwnd; + g_unicode = IsWindowUnicode(hwnd) != FALSE; + + // Replacing GWLP_WNDPROC from another thread is safe (the new proc runs on + // the window's own thread); match A/W so CallWindowProc translates correctly. + const LONG_PTR replaced = g_unicode + ? SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast(&subclass_proc)) + : SetWindowLongPtrA(hwnd, GWLP_WNDPROC, reinterpret_cast(&subclass_proc)); + g_orig_proc = reinterpret_cast(replaced); + + 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)); + } + + ipc.mark_focus_spoof(true, reinterpret_cast(hwnd)); + return true; +} + +void remove_focus_spoof() +{ + if (g_game_hwnd != nullptr && g_orig_proc != nullptr) + { + if (g_unicode) + { + SetWindowLongPtrW(g_game_hwnd, GWLP_WNDPROC, reinterpret_cast(g_orig_proc)); + } + else + { + SetWindowLongPtrA(g_game_hwnd, GWLP_WNDPROC, reinterpret_cast(g_orig_proc)); + } + } + g_focus_hooks.clear(); + g_game_hwnd = nullptr; + g_orig_proc = nullptr; +} + +} // namespace coop::hook diff --git a/hook/src/focus_spoof.hpp b/hook/src/focus_spoof.hpp new file mode 100644 index 0000000..59d2933 --- /dev/null +++ b/hook/src/focus_spoof.hpp @@ -0,0 +1,21 @@ +// Makes the injected game believe it always has foreground focus, so it keeps +// running and polling input while the tool's window holds the real OS focus +// (required for Steam RPT to capture the tool). Without this, games that pause +// or stop polling on focus loss are unusable in the final design. +#pragma once + +#include "ipc_client.hpp" + +namespace coop::hook +{ + +// Finds the game's main window, subclasses it to suppress deactivation messages, +// and hooks the focus-query APIs to always report the game as active. Returns +// true once spoofing is active; safe to retry until the window exists. Reports +// status through `ipc`. +bool install_focus_spoof(IpcClient& ipc); + +// Restores the original window procedure and removes the focus API hooks. +void remove_focus_spoof(); + +} // namespace coop::hook diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index 50e2a8d..15ecfad 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -3,6 +3,7 @@ // forwarded pad state the host publishes each frame. #pragma once +#include #include #include @@ -54,6 +55,45 @@ public: return read_pads(*block_, out, count); } + // --- Status back-channel (hook -> host diagnostics) -------------------- + + // Record that the game queried a controller slot; the host turns the + // cumulative count into a poll rate to prove the hook is live. + void note_query(std::uint32_t user_index) + { + if (block_ != nullptr) + { + block_->status.xinput_queries.fetch_add(1, std::memory_order_relaxed); + block_->status.last_user_index = user_index; + } + } + + void mark_attached() + { + if (block_ != nullptr) + { + block_->status.game_pid = GetCurrentProcessId(); + block_->status.attached = 1; + } + } + + void mark_focus_spoof(bool active, std::uint64_t game_hwnd) + { + if (block_ != nullptr) + { + block_->status.focus_spoof = active ? 1u : 0u; + block_->status.game_hwnd = game_hwnd; + } + } + + void heartbeat() + { + if (block_ != nullptr) + { + block_->status.heartbeat.fetch_add(1, std::memory_order_relaxed); + } + } + private: SharedMemory shm_; SharedBlock* block_ = nullptr; diff --git a/hook/src/xinput_hook.cpp b/hook/src/xinput_hook.cpp index b610e38..a6bfcc4 100644 --- a/hook/src/xinput_hook.cpp +++ b/hook/src/xinput_hook.cpp @@ -19,7 +19,7 @@ namespace // XInputGetStateEx that many games use. Mirrors how Steam/x360ce expose it. constexpr std::uint16_t kGuideButton = 0x0400; -const IpcClient* g_ipc = nullptr; +IpcClient* g_ipc = nullptr; std::vector g_hooks; // Last good snapshot, so a momentary failed IPC read (host mid-write) doesn't @@ -62,6 +62,10 @@ DWORD query_state(DWORD user_index, XINPUT_STATE* state, bool keep_guide) { return ERROR_DEVICE_NOT_CONNECTED; } + if (g_ipc != nullptr) + { + g_ipc->note_query(user_index); // proves to the host the game is polling us + } refresh_cache(); const CoopPadState& pad = g_cache[user_index]; if (!pad.connected) @@ -156,7 +160,7 @@ void hook_ordinal(HMODULE module, WORD ordinal, void* detour) } // namespace -bool install_xinput_hooks(const IpcClient& ipc) +bool install_xinput_hooks(IpcClient& ipc) { if (!g_hooks.empty()) { @@ -180,7 +184,12 @@ bool install_xinput_hooks(const IpcClient& ipc) hook_export(module, "XInputGetCapabilities", reinterpret_cast(&hk_XInputGetCapabilities)); hook_export(module, "XInputSetState", reinterpret_cast(&hk_XInputSetState)); } - return !g_hooks.empty(); + if (!g_hooks.empty()) + { + g_ipc->mark_attached(); + return true; + } + return false; } void remove_xinput_hooks() diff --git a/hook/src/xinput_hook.hpp b/hook/src/xinput_hook.hpp index 1f2b56e..325eaaf 100644 --- a/hook/src/xinput_hook.hpp +++ b/hook/src/xinput_hook.hpp @@ -10,7 +10,7 @@ namespace coop::hook // Locates the loaded XInput module(s) and hooks the state/capability entry // points. `ipc` must outlive the hooks. Returns true if at least one module was // hooked. Safe to call repeatedly while waiting for xinput to load. -bool install_xinput_hooks(const IpcClient& ipc); +bool install_xinput_hooks(IpcClient& ipc); // Removes all installed hooks (best effort; used on DLL detach). void remove_xinput_hooks(); diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index add8083..45f3b17 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -1,5 +1,7 @@ #include "injection_panel.hpp" +#include + #include #include "inject/injector.hpp" @@ -108,6 +110,75 @@ void InjectionPanel::inject_selected() } } +void InjectionPanel::publish(const std::array& pads) +{ + if (!test_input_) + { + server_.publish(pads); + return; + } + + // Synthetic, unmistakably non-human pattern: left stick sweeps a circle and + // A is pressed every other second. If the game moves on its own to this, the + // forwarding pipeline is proven end-to-end. + const unsigned long long ms = GetTickCount64(); + const double t = static_cast(ms) / 1000.0; + + std::array synthetic{}; + PadInfo& pad = synthetic[0]; + pad.connected = true; + pad.source = "synthetic test"; + pad.state.connected = 1; + pad.state.packet = static_cast(ms); + pad.state.thumb_lx = static_cast(std::cos(t) * 30000.0); + pad.state.thumb_ly = static_cast(std::sin(t) * 30000.0); + if ((ms / 1000) % 2 == 0) + { + pad.state.buttons |= 0x1000; // XINPUT_GAMEPAD_A + } + server_.publish(synthetic); +} + +void InjectionPanel::draw_hook_status() +{ + if (!server_.running()) + { + return; + } + + const HookStatusView status = server_.hook_status(); + + // Convert the cumulative query counter into a rate every half second. + const double now = ImGui::GetTime(); + if (now - last_sample_time_ >= 0.5) + { + const double dt = now - last_sample_time_; + const unsigned long long delta = + status.xinput_queries >= last_query_count_ ? status.xinput_queries - last_query_count_ : 0; + query_rate_ = dt > 0.0 ? static_cast(delta) / dt : 0.0; + last_query_count_ = status.xinput_queries; + last_sample_time_ = now; + } + + ImGui::SeparatorText("Hook status"); + if (!status.attached) + { + ImGui::TextColored(kGrey, "Waiting for hook to attach in the game..."); + return; + } + + ImGui::TextColored(kGreen, "Attached (game pid %u, hwnd 0x%llX)", status.game_pid, + static_cast(status.game_hwnd)); + ImGui::Text("XInput polled: %.0f/s (last slot %u)", query_rate_, status.last_user_index); + if (query_rate_ > 0.0) + { + ImGui::SameLine(); + ImGui::TextColored(kGreen, " <- game is reading our input"); + } + ImGui::TextColored(status.focus_spoof ? kGreen : kGrey, "Focus spoof: %s", + status.focus_spoof ? "active" : "inactive"); +} + void InjectionPanel::draw() { ImGui::SetNextWindowPos(ImVec2(24, 360), ImGuiCond_FirstUseEver); @@ -168,6 +239,15 @@ void InjectionPanel::draw() ImGui::TextColored(status_color_, "%s", status_.c_str()); } + ImGui::Checkbox("Forward synthetic test input", &test_input_); + if (test_input_) + { + ImGui::SameLine(); + ImGui::TextDisabled("(ignores your controller)"); + } + + draw_hook_status(); + ImGui::End(); } diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index 98cc9e8..58a7dd0 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -21,15 +21,14 @@ public: void draw(); - // Forward the latest pad snapshot to the injected hook (if connected). - void publish(const std::array& pads) - { - server_.publish(pads); - } + // Forward the latest pad snapshot to the injected hook (if connected). When + // test-input mode is on, a synthetic pattern is sent instead of `pads`. + void publish(const std::array& pads); private: void refresh_processes(); void inject_selected(); + void draw_hook_status(); std::vector processes_; char filter_[128] = {}; @@ -39,6 +38,13 @@ private: IpcServer server_; std::string status_; ImVec4 status_color_; + + bool test_input_ = false; + + // Sampled to turn the hook's cumulative query counter into a poll rate. + unsigned long long last_query_count_ = 0; + double last_sample_time_ = 0.0; + double query_rate_ = 0.0; }; } // namespace coop diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index efabefc..28f973d 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -40,6 +40,24 @@ void IpcServer::publish(const std::array& pads) publish_pads(*block_, states, kMaxPads); } +HookStatusView IpcServer::hook_status() const +{ + HookStatusView view; + if (block_ == nullptr) + { + return view; + } + const HookStatus& s = block_->status; + view.attached = s.attached != 0; + view.focus_spoof = s.focus_spoof != 0; + view.heartbeat = s.heartbeat.load(std::memory_order_relaxed); + view.xinput_queries = s.xinput_queries.load(std::memory_order_relaxed); + view.game_pid = s.game_pid; + view.game_hwnd = s.game_hwnd; + view.last_user_index = s.last_user_index; + return view; +} + void IpcServer::stop() { if (block_ != nullptr) diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index 19af758..eb80906 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -3,6 +3,7 @@ #pragma once #include +#include #include "coop/protocol.hpp" #include "coop/shared_memory.hpp" @@ -11,6 +12,18 @@ namespace coop { +// Plain (non-atomic) snapshot of the hook's back-channel for the overlay. +struct HookStatusView +{ + bool attached = false; // XInput hooks installed in the game + bool focus_spoof = false; // focus spoofing active + std::uint32_t heartbeat = 0; // DLL liveness counter + std::uint64_t xinput_queries = 0; + std::uint32_t game_pid = 0; + std::uint64_t game_hwnd = 0; + std::uint32_t last_user_index = 0; +}; + class IpcServer { public: @@ -23,6 +36,9 @@ public: void stop(); + // Reads the hook's diagnostics back-channel (zeroed if not started). + [[nodiscard]] HookStatusView hook_status() const; + [[nodiscard]] bool running() const { return block_ != nullptr; diff --git a/tests/hook_selftest.cpp b/tests/hook_selftest.cpp index 531228a..b80b51f 100644 --- a/tests/hook_selftest.cpp +++ b/tests/hook_selftest.cpp @@ -77,6 +77,10 @@ int main() check(XInputGetCapabilities(0, 0, &caps) == ERROR_SUCCESS, "slot 0 capabilities reported"); check(caps.Type == XINPUT_DEVTYPE_GAMEPAD, "capability device type"); + // Status back-channel: the host relies on these to prove the hook is live. + check(block->status.attached == 1, "status reports attached"); + check(block->status.xinput_queries.load(std::memory_order_relaxed) >= 2, "status counts XInput queries"); + hook::remove_xinput_hooks(); std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);