#include "focus_spoof.hpp" #include #include #include #include "find_window.hpp" #include "hook_guard.hpp" #include "hook_install.hpp" #include "hook_registry.hpp" namespace coop::hook { namespace { DetourGate g_gate; // drains in-flight focus / WNDPROC detours before remove nulls their state HWND g_game_hwnd = nullptr; WNDPROC g_orig_proc = nullptr; 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; int g_id_clipcursor = -1; int g_id_setcursorpos = -1; // Cursor-release hooks kept separately so their trampolines can be called. safetyhook::InlineHook g_hk_clipcursor; safetyhook::InlineHook g_hk_setcursorpos; // Replacement window procedure: convince the game it is never deactivated. LRESULT CALLBACK subclass_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) { DetourGate::Guard guard(g_gate); // keep g_orig_proc / g_unicode valid for this whole dispatch switch (msg) { case WM_ACTIVATE: 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; } // Read g_orig_proc once; if the subclass is live but the original isn't published yet (the tiny // install/remove window), fall back to DefWindowProc rather than call through a null pointer. const WNDPROC orig = g_orig_proc; if (orig == nullptr) { return g_unicode ? DefWindowProcW(hwnd, msg, wparam, lparam) : DefWindowProcA(hwnd, msg, wparam, lparam); } return g_unicode ? CallWindowProcW(orig, hwnd, msg, wparam, lparam) : CallWindowProcA(orig, hwnd, msg, wparam, lparam); } HWND WINAPI hk_GetForegroundWindow() { DetourGate::Guard guard(g_gate); hook_note_call(g_id_foreground); if (g_focus_ipc != nullptr) { g_focus_ipc->note_focus_query(FocusApi_Foreground); } return g_game_hwnd; } HWND WINAPI hk_GetActiveWindow() { DetourGate::Guard guard(g_gate); hook_note_call(g_id_active); if (g_focus_ipc != nullptr) { g_focus_ipc->note_focus_query(FocusApi_Active); } return g_game_hwnd; } HWND WINAPI hk_GetFocus() { DetourGate::Guard guard(g_gate); hook_note_call(g_id_focus); if (g_focus_ipc != nullptr) { g_focus_ipc->note_focus_query(FocusApi_Focus); } return g_game_hwnd; } // When cursor release is requested (the default), free any clip the game asks for so // the operator's mouse isn't trapped; otherwise honor the game's clip. BOOL WINAPI hk_ClipCursor(const RECT* rect) { DetourGate::Guard guard(g_gate); hook_note_call(g_id_clipcursor); const bool allow = g_focus_ipc != nullptr && g_focus_ipc->cursor_clip_allowed(); return g_hk_clipcursor.stdcall(allow ? rect : nullptr); } // Swallow the game's per-frame cursor re-centering while releasing, so the operator's // mouse can move freely (e.g. to reach the overlay); pass it through when clipping. BOOL WINAPI hk_SetCursorPos(int x, int y) { DetourGate::Guard guard(g_gate); hook_note_call(g_id_setcursorpos); const bool allow = g_focus_ipc != nullptr && g_focus_ipc->cursor_clip_allowed(); if (!allow) { return TRUE; } return g_hk_setcursorpos.stdcall(x, y); } 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(); install_inline(g_focus_hooks.back(), target, detour); // assign-then-enable (no install race) hook_set_installed(registry_id, true); } } } // namespace bool install_focus_spoof(IpcClient& ipc) { g_focus_ipc = &ipc; if (g_game_hwnd != nullptr) { 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); g_id_clipcursor = hook_register("ClipCursor (cursor release)", HookSubsys_Focus); g_id_setcursorpos = hook_register("SetCursorPos (cursor release)", HookSubsys_Focus); 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; // Publish g_orig_proc BEFORE activating the subclass, so a message that dispatches the instant the // subclass goes live finds a valid original (not the null/stale value from a prior install cycle) // -- the WNDPROC analogue of the inline-hook install race. Replacing GWLP_WNDPROC from another // thread is safe (the new proc runs on the window's own thread); match A/W for CallWindowProc. g_orig_proc = g_unicode ? reinterpret_cast(GetWindowLongPtrW(hwnd, GWLP_WNDPROC)) : reinterpret_cast(GetWindowLongPtrA(hwnd, GWLP_WNDPROC)); if (g_unicode) { SetWindowLongPtrW(hwnd, GWLP_WNDPROC, reinterpret_cast(&subclass_proc)); } else { SetWindowLongPtrA(hwnd, GWLP_WNDPROC, reinterpret_cast(&subclass_proc)); } hook_set_installed(g_id_wndproc, true); if (HMODULE user32 = GetModuleHandleW(L"user32.dll")) { 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); if (void* clip = reinterpret_cast(GetProcAddress(user32, "ClipCursor"))) { install_inline(g_hk_clipcursor, clip, &hk_ClipCursor); hook_set_installed(g_id_clipcursor, static_cast(g_hk_clipcursor)); } if (void* setpos = reinterpret_cast(GetProcAddress(user32, "SetCursorPos"))) { install_inline(g_hk_setcursorpos, setpos, &hk_SetCursorPos); hook_set_installed(g_id_setcursorpos, static_cast(g_hk_setcursorpos)); } } // Free any clip the game already set, so release takes effect immediately. if (!ipc.cursor_clip_allowed()) { ClipCursor(nullptr); } ipc.mark_focus_spoof(true, reinterpret_cast(hwnd)); 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 release_cursor_tick() { if (g_focus_ipc != nullptr && g_game_hwnd != nullptr && !g_focus_ipc->cursor_clip_allowed()) { ClipCursor(nullptr); // routes through hk_ClipCursor -> frees the cursor } } 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)); } } // Disable the inline focus hooks in REVERSE install order. GetForegroundWindow/GetActiveWindow // share user32 code (the real GetForegroundWindow's path runs through GetActiveWindow's body), so // GetActiveWindow's inline patch corrupts that shared code. GetForegroundWindow is installed // first, so disabling in reverse unhooks GetActiveWindow (restoring the shared bytes) while // GetForegroundWindow is still hooked -- its detour returns g_game_hwnd and never reaches the // patched bytes. The reverse of the enable order (GFW first) keeps the invariant "GetActiveWindow // hooked => GetForegroundWindow hooked" across the whole install/remove cycle, so a call never // lands in a half-patched shared region. for (auto it = g_focus_hooks.rbegin(); it != g_focus_hooks.rend(); ++it) { disable_for_removal(*it); } disable_for_removal(g_hk_clipcursor); disable_for_removal(g_hk_setcursorpos); ClipCursor(nullptr); // leave the cursor free when the spoof is removed 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); hook_set_installed(g_id_clipcursor, false); hook_set_installed(g_id_setcursorpos, false); // The WNDPROC is restored and the inline hooks disabled above, so no NEW detour can start. Drain // any focus / WNDPROC detour still in-flight on the game's window thread before nulling the // state they read (g_orig_proc / g_game_hwnd / g_focus_ipc) -- otherwise a dispatch mid-flight // could call a null original WNDPROC or a dangling IPC pointer. g_gate.drain(); // The focus-query hooks (GetForegroundWindow/GetActiveWindow/GetFocus) return g_game_hwnd and // never call the trampoline, so destroying them is safe; recreate on re-install. The cursor hooks // DO call the trampoline, so keep them ALIVE (disabled) -- persistent, re-enabled on re-install // (see hook_install.hpp) -- so a stale detour never hits a freed trampoline. g_focus_hooks.clear(); if (g_focus_ipc != nullptr) { g_focus_ipc->mark_focus_spoof(false, 0); } g_game_hwnd = nullptr; g_orig_proc = nullptr; g_focus_ipc = nullptr; } } // namespace coop::hook