Phase 1a: focus spoofing + hook observability

Two problems surfaced in testing: (1) no way to tell whether the injected
hook was actually the input source, and (2) the final design needs the tool
window focused for Steam RPT capture, which would pause/silence games that
react to focus loss. Both are addressed here.

- Focus spoofing (hook/focus_spoof): find the game's main window, subclass it
  to rewrite/swallow WM_ACTIVATE/ACTIVATEAPP/NCACTIVATE/KILLFOCUS, and inline-
  hook GetForegroundWindow/GetActiveWindow/GetFocus to always report the game
  as active. The game keeps running and polling while unfocused.
- Status back-channel (protocol v2): the DLL reports attached/focus-spoof
  flags, game pid/hwnd, a heartbeat, and a cumulative XInputGetState counter.
  The host overlay turns the counter into a live poll rate, so "is the hook
  working" is directly observable.
- Synthetic test-input toggle in the host: forwards a known automated pattern
  (stick circle + periodic A) to prove forwarding independent of the physical
  pad.
- hook_selftest extended to assert the status channel; passes.

Documented the windowed/borderless requirement and the new observable test
flow in the README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 00:13:06 +02:00
parent e370c8dcc5
commit df4325d21b
14 changed files with 447 additions and 34 deletions

163
hook/src/focus_spoof.cpp Normal file
View File

@@ -0,0 +1,163 @@
#include "focus_spoof.hpp"
#include <vector>
#include <windows.h>
#include <safetyhook.hpp>
namespace coop::hook
{
namespace
{
HWND g_game_hwnd = nullptr;
WNDPROC g_orig_proc = nullptr;
bool g_unicode = true;
std::vector<safetyhook::InlineHook> g_focus_hooks;
struct EnumContext
{
DWORD pid;
HWND best;
long best_area;
};
BOOL CALLBACK enum_proc(HWND hwnd, LPARAM lparam)
{
auto* ctx = reinterpret_cast<EnumContext*>(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<LPARAM>(&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<void*>(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<LONG_PTR>(&subclass_proc))
: SetWindowLongPtrA(hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(&subclass_proc));
g_orig_proc = reinterpret_cast<WNDPROC>(replaced);
if (HMODULE user32 = GetModuleHandleW(L"user32.dll"))
{
hook_export(user32, "GetForegroundWindow", reinterpret_cast<void*>(&hk_GetForegroundWindow));
hook_export(user32, "GetActiveWindow", reinterpret_cast<void*>(&hk_GetActiveWindow));
hook_export(user32, "GetFocus", reinterpret_cast<void*>(&hk_GetFocus));
}
ipc.mark_focus_spoof(true, reinterpret_cast<std::uint64_t>(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<LONG_PTR>(g_orig_proc));
}
else
{
SetWindowLongPtrA(g_game_hwnd, GWLP_WNDPROC, reinterpret_cast<LONG_PTR>(g_orig_proc));
}
}
g_focus_hooks.clear();
g_game_hwnd = nullptr;
g_orig_proc = nullptr;
}
} // namespace coop::hook