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:
@@ -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")
|
||||
|
||||
|
||||
@@ -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 <atomic>
|
||||
|
||||
#include <windows.h>
|
||||
|
||||
#include "focus_spoof.hpp"
|
||||
#include "ipc_client.hpp"
|
||||
#include "xinput_hook.hpp"
|
||||
|
||||
@@ -14,8 +18,9 @@ namespace
|
||||
{
|
||||
|
||||
coop::hook::IpcClient g_ipc;
|
||||
std::atomic<bool> 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;
|
||||
|
||||
163
hook/src/focus_spoof.cpp
Normal file
163
hook/src/focus_spoof.cpp
Normal 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
|
||||
21
hook/src/focus_spoof.hpp
Normal file
21
hook/src/focus_spoof.hpp
Normal file
@@ -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
|
||||
@@ -3,6 +3,7 @@
|
||||
// forwarded pad state the host publishes each frame.
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
|
||||
#include <windows.h>
|
||||
@@ -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;
|
||||
|
||||
@@ -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<safetyhook::InlineHook> 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<void*>(&hk_XInputGetCapabilities));
|
||||
hook_export(module, "XInputSetState", reinterpret_cast<void*>(&hk_XInputSetState));
|
||||
}
|
||||
return !g_hooks.empty();
|
||||
if (!g_hooks.empty())
|
||||
{
|
||||
g_ipc->mark_attached();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void remove_xinput_hooks()
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user