diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index e9dc300..0daefad 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 = 2; +inline constexpr std::uint32_t kProtocolVersion = 3; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -42,19 +42,37 @@ struct CoopPadState static_assert(sizeof(CoopPadState) == 20, "CoopPadState layout must stay stable across both modules"); +// Indices into HookStatus::focus_query_calls. +enum FocusApi : std::uint32_t +{ + FocusApi_Foreground = 0, // GetForegroundWindow + FocusApi_Active = 1, // GetActiveWindow + FocusApi_Focus = 2, // GetFocus + FocusApi_Count = 3, +}; + // 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 +// it for the diagnostics overlay: is the hook attached, which slots is the game +// polling, does it use the focus APIs, and does it read input through a +// focus-gated path (Raw Input / DirectInput)? Diagnostics only, so the 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) + std::atomic heartbeat; // DLL bumps ~4x/sec while alive + std::atomic get_state_calls[kMaxPads]; // XInputGetState/Ex per slot + std::atomic get_caps_calls[kMaxPads]; // XInputGetCapabilities per slot + std::atomic focus_query_calls[FocusApi_Count]; // focus API calls, see FocusApi + + 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::uint64_t game_hwnd; // window the DLL subclassed (0 if none yet) + + // Input-path diagnostics: which focus-gated mechanism (if any) the game uses. + std::uint32_t raw_input_registered; // process has any Raw Input registration + std::uint32_t raw_input_gamepad; // ... for a joystick/gamepad usage page + std::uint32_t raw_input_gamepad_sink; // ... and that usage has RIDEV_INPUTSINK (bg delivery) + std::uint32_t dinput_loaded; // dinput8.dll is present in the process }; // Top-level shared block. The host is the sole writer of pad state; the hook is diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index ef316fd..a2d5e0b 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -43,6 +43,7 @@ DWORD WINAPI worker_thread(LPVOID) { focus_installed = coop::hook::install_focus_spoof(g_ipc); } + coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change g_ipc.heartbeat(); Sleep(250); } diff --git a/hook/src/focus_spoof.cpp b/hook/src/focus_spoof.cpp index 456faf6..42cc4dc 100644 --- a/hook/src/focus_spoof.cpp +++ b/hook/src/focus_spoof.cpp @@ -16,6 +16,7 @@ HWND g_game_hwnd = nullptr; WNDPROC g_orig_proc = nullptr; bool g_unicode = true; std::vector g_focus_hooks; +IpcClient* g_focus_ipc = nullptr; struct EnumContext { @@ -85,16 +86,28 @@ LRESULT CALLBACK subclass_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam HWND WINAPI hk_GetForegroundWindow() { + if (g_focus_ipc != nullptr) + { + g_focus_ipc->note_focus_query(FocusApi_Foreground); + } return g_game_hwnd; } HWND WINAPI hk_GetActiveWindow() { + if (g_focus_ipc != nullptr) + { + g_focus_ipc->note_focus_query(FocusApi_Active); + } return g_game_hwnd; } HWND WINAPI hk_GetFocus() { + if (g_focus_ipc != nullptr) + { + g_focus_ipc->note_focus_query(FocusApi_Focus); + } return g_game_hwnd; } @@ -110,6 +123,7 @@ void hook_export(HMODULE module, const char* name, void* detour) bool install_focus_spoof(IpcClient& ipc) { + g_focus_ipc = &ipc; if (g_game_hwnd != nullptr) { return true; // already active @@ -142,6 +156,40 @@ bool install_focus_spoof(IpcClient& ipc) return true; } +void update_input_diagnostics(IpcClient& ipc) +{ + // Inspect how the game currently reads input, to find a focus-gated path that + // would explain a controller working only when the game has true focus. + bool raw_registered = false; + bool raw_gamepad = false; + bool raw_gamepad_sink = false; + + UINT count = 0; + if (GetRegisteredRawInputDevices(nullptr, &count, sizeof(RAWINPUTDEVICE)) == 0 && count > 0) + { + std::vector devices(count); + const UINT got = GetRegisteredRawInputDevices(devices.data(), &count, sizeof(RAWINPUTDEVICE)); + if (got != static_cast(-1)) + { + raw_registered = got > 0; + for (UINT i = 0; i < got; ++i) + { + // Generic Desktop (0x01) joystick (0x04) / gamepad (0x05). + const bool is_pad = + devices[i].usUsagePage == 0x01 && (devices[i].usUsage == 0x04 || devices[i].usUsage == 0x05); + if (is_pad) + { + raw_gamepad = true; + raw_gamepad_sink = (devices[i].dwFlags & RIDEV_INPUTSINK) != 0; + } + } + } + } + + const bool dinput = GetModuleHandleW(L"dinput8.dll") != nullptr || GetModuleHandleW(L"dinput.dll") != nullptr; + ipc.set_input_diagnostics(raw_registered, raw_gamepad, raw_gamepad_sink, dinput); +} + void remove_focus_spoof() { if (g_game_hwnd != nullptr && g_orig_proc != nullptr) @@ -158,6 +206,7 @@ void remove_focus_spoof() g_focus_hooks.clear(); g_game_hwnd = nullptr; g_orig_proc = nullptr; + g_focus_ipc = nullptr; } } // namespace coop::hook diff --git a/hook/src/focus_spoof.hpp b/hook/src/focus_spoof.hpp index 59d2933..4d17175 100644 --- a/hook/src/focus_spoof.hpp +++ b/hook/src/focus_spoof.hpp @@ -15,6 +15,11 @@ namespace coop::hook // status through `ipc`. bool install_focus_spoof(IpcClient& ipc); +// Reports how the game currently reads input (Raw Input gamepad usage + sink +// flag, DirectInput presence) so the host can identify a focus-gated path. Cheap +// to call each tick. +void update_input_diagnostics(IpcClient& ipc); + // Restores the original window procedure and removes the focus API hooks. void remove_focus_spoof(); diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index 15ecfad..b97b732 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -57,14 +57,28 @@ public: // --- 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) + // Record that the game queried a controller slot via XInputGetState/Ex. + void note_state_query(std::uint32_t user_index) { - if (block_ != nullptr) + if (block_ != nullptr && user_index < kMaxPads) { - block_->status.xinput_queries.fetch_add(1, std::memory_order_relaxed); - block_->status.last_user_index = user_index; + block_->status.get_state_calls[user_index].fetch_add(1, std::memory_order_relaxed); + } + } + + void note_caps_query(std::uint32_t user_index) + { + if (block_ != nullptr && user_index < kMaxPads) + { + block_->status.get_caps_calls[user_index].fetch_add(1, std::memory_order_relaxed); + } + } + + void note_focus_query(FocusApi which) + { + if (block_ != nullptr && which < FocusApi_Count) + { + block_->status.focus_query_calls[which].fetch_add(1, std::memory_order_relaxed); } } @@ -86,6 +100,17 @@ public: } } + void set_input_diagnostics(bool raw_registered, bool raw_gamepad, bool raw_gamepad_sink, bool dinput) + { + if (block_ != nullptr) + { + block_->status.raw_input_registered = raw_registered ? 1u : 0u; + block_->status.raw_input_gamepad = raw_gamepad ? 1u : 0u; + block_->status.raw_input_gamepad_sink = raw_gamepad_sink ? 1u : 0u; + block_->status.dinput_loaded = dinput ? 1u : 0u; + } + } + void heartbeat() { if (block_ != nullptr) diff --git a/hook/src/xinput_hook.cpp b/hook/src/xinput_hook.cpp index a6bfcc4..2e0b23a 100644 --- a/hook/src/xinput_hook.cpp +++ b/hook/src/xinput_hook.cpp @@ -64,7 +64,7 @@ DWORD query_state(DWORD user_index, XINPUT_STATE* state, bool keep_guide) } if (g_ipc != nullptr) { - g_ipc->note_query(user_index); // proves to the host the game is polling us + g_ipc->note_state_query(user_index); // proves to the host the game is polling us } refresh_cache(); const CoopPadState& pad = g_cache[user_index]; @@ -100,6 +100,10 @@ DWORD WINAPI hk_XInputGetCapabilities(DWORD user_index, DWORD /*flags*/, XINPUT_ { return ERROR_DEVICE_NOT_CONNECTED; } + if (g_ipc != nullptr) + { + g_ipc->note_caps_query(user_index); + } refresh_cache(); if (!g_cache[user_index].connected) { diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index 45f3b17..6126512 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -148,15 +148,18 @@ void InjectionPanel::draw_hook_status() const HookStatusView status = server_.hook_status(); - // Convert the cumulative query counter into a rate every half second. + // Convert the cumulative per-slot counters into rates 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; + for (int i = 0; i < static_cast(kMaxPads); ++i) + { + const unsigned long long delta = + status.get_state[i] >= last_state_count_[i] ? status.get_state[i] - last_state_count_[i] : 0; + state_rate_[i] = dt > 0.0 ? static_cast(delta) / dt : 0.0; + last_state_count_[i] = status.get_state[i]; + } last_sample_time_ = now; } @@ -169,14 +172,65 @@ void InjectionPanel::draw_hook_status() 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"); + + // Per-slot XInput polling: shows exactly which slots the game reads and how + // fast -- the requested visualization. + if (ImGui::BeginTable("slots", 4, ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchProp)) + { + ImGui::TableSetupColumn("Slot"); + ImGui::TableSetupColumn("GetState/s"); + ImGui::TableSetupColumn("GetState total"); + ImGui::TableSetupColumn("GetCaps total"); + ImGui::TableHeadersRow(); + for (int i = 0; i < static_cast(kMaxPads); ++i) + { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); + ImGui::Text("%d", i); + ImGui::TableNextColumn(); + if (state_rate_[i] > 0.0) + { + ImGui::TextColored(kGreen, "%.0f", state_rate_[i]); + } + else + { + ImGui::TextDisabled("0"); + } + ImGui::TableNextColumn(); + ImGui::Text("%llu", static_cast(status.get_state[i])); + ImGui::TableNextColumn(); + ImGui::Text("%llu", static_cast(status.get_caps[i])); + } + ImGui::EndTable(); + } + + // Focus-API usage: tells us whether the game even consults these (which we + // spoof) when deciding it lost focus. + ImGui::Text("Focus API calls FG:%llu Active:%llu Focus:%llu", + static_cast(status.focus_calls[coop::FocusApi_Foreground]), + static_cast(status.focus_calls[coop::FocusApi_Active]), + static_cast(status.focus_calls[coop::FocusApi_Focus])); + + // Input-path diagnostics: a focus-gated detection path would explain a game + // that only accepts the controller when it has true focus. + ImGui::SeparatorText("Input path"); + if (status.raw_input_gamepad) + { + ImGui::TextColored(status.raw_input_gamepad_sink ? kGreen : kRed, "Raw Input gamepad: yes (INPUTSINK %s)", + status.raw_input_gamepad_sink ? "set -> bg ok" : "MISSING -> focus-gated!"); + } + else if (status.raw_input_registered) + { + ImGui::TextColored(kGrey, "Raw Input: registered, but not for a gamepad usage"); + } + else + { + ImGui::TextColored(kGrey, "Raw Input: not registered"); + } + ImGui::TextColored(status.dinput_loaded ? kRed : kGrey, "DirectInput dll loaded: %s", + status.dinput_loaded ? "yes (could be foreground-gated)" : "no"); } void InjectionPanel::draw() diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index 58a7dd0..cae5d13 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -41,10 +41,10 @@ private: bool test_input_ = false; - // Sampled to turn the hook's cumulative query counter into a poll rate. - unsigned long long last_query_count_ = 0; + // Sampled to turn the hook's cumulative per-slot counters into poll rates. + unsigned long long last_state_count_[kMaxPads] = {}; + double state_rate_[kMaxPads] = {}; 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 28f973d..20a8538 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -51,10 +51,21 @@ HookStatusView IpcServer::hook_status() const 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); + for (std::uint32_t i = 0; i < kMaxPads; ++i) + { + view.get_state[i] = s.get_state_calls[i].load(std::memory_order_relaxed); + view.get_caps[i] = s.get_caps_calls[i].load(std::memory_order_relaxed); + } + for (std::uint32_t i = 0; i < FocusApi_Count; ++i) + { + view.focus_calls[i] = s.focus_query_calls[i].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; + view.raw_input_registered = s.raw_input_registered != 0; + view.raw_input_gamepad = s.raw_input_gamepad != 0; + view.raw_input_gamepad_sink = s.raw_input_gamepad_sink != 0; + view.dinput_loaded = s.dinput_loaded != 0; return view; } diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index eb80906..5085f92 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -18,10 +18,15 @@ 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::uint64_t get_state[kMaxPads] = {}; + std::uint64_t get_caps[kMaxPads] = {}; + std::uint64_t focus_calls[FocusApi_Count] = {}; std::uint32_t game_pid = 0; std::uint64_t game_hwnd = 0; - std::uint32_t last_user_index = 0; + bool raw_input_registered = false; + bool raw_input_gamepad = false; + bool raw_input_gamepad_sink = false; + bool dinput_loaded = false; }; class IpcServer diff --git a/tests/hook_selftest.cpp b/tests/hook_selftest.cpp index b80b51f..2ff3ebf 100644 --- a/tests/hook_selftest.cpp +++ b/tests/hook_selftest.cpp @@ -79,7 +79,9 @@ int main() // 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"); + check(block->status.get_state_calls[0].load(std::memory_order_relaxed) >= 1, "status counts slot 0 GetState"); + check(block->status.get_state_calls[1].load(std::memory_order_relaxed) >= 1, "status counts slot 1 GetState"); + check(block->status.get_caps_calls[0].load(std::memory_order_relaxed) >= 1, "status counts slot 0 GetCaps"); hook::remove_xinput_hooks();