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:
41
README.md
41
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
|
end-to-end: Steam RPT streams the window under a donor appid, and guest gamepads
|
||||||
arrive (with correct slot assignment) as XInput.
|
arrive (with correct slot assignment) as XInput.
|
||||||
|
|
||||||
**Phase 1a — input forwarding (current).** The host can inject `coop_hook.dll`
|
**Phase 1a — input forwarding + focus spoofing (current).** The host injects
|
||||||
into a running game; the DLL hooks XInput (via SafetyHook) so the game reads the
|
`coop_hook.dll`; the DLL hooks XInput (via SafetyHook) so the game reads the
|
||||||
controller state the host forwards over shared memory — and *only* that state, so
|
forwarded controller state and *only* that state. The DLL also **spoofs focus**
|
||||||
physical/other controllers are hidden from the game. The in-process
|
(hooks `GetForegroundWindow`/`GetActiveWindow`/`GetFocus` and subclasses the game
|
||||||
`hook_selftest` validates the IPC + hook core without needing a 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),
|
Still ahead (scoped in the plan): video mirror (WGC, then a `Present` hook),
|
||||||
audio (WASAPI process loopback), and x86 support.
|
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
|
controller, and a target game. `coop_host.exe` and `coop_hook.dll` must sit in
|
||||||
the same folder (the build places both in `bin/<Config>/`).
|
the same folder (the build places both in `bin/<Config>/`).
|
||||||
|
|
||||||
1. Start a DRM-free, **non-anti-cheat**, XInput game (e.g. a small controller
|
**Requirement:** run the target game **windowed or borderless**, not exclusive
|
||||||
sample or a permissive indie title) and get to a screen that reads the pad.
|
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
|
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
|
game's `.exe`, select it, and click **Inject & Connect**.
|
||||||
should turn green ("Injected … / Forwarding input to pid …").
|
3. Watch the **Hook status** section. Once it shows **Attached** and a non-zero
|
||||||
3. Press buttons on your physical controller. The game should respond — its
|
**"XInput polled: N/s"**, the game is provably reading our hook — injection
|
||||||
XInput now comes from the host's forwarded state, not the device directly.
|
works. **Focus spoof: active** confirms the window was found and subclassed.
|
||||||
Unplug-test: other controllers/slots are hidden from the game.
|
4. **Prove forwarding is the source:** tick **Forward synthetic test input**.
|
||||||
4. Click **Stop forwarding** (or quit the host) to tear down the channel.
|
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
|
> 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).
|
> reports "target is 32-bit", that game needs the x86 hook (a later phase).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace coop
|
|||||||
|
|
||||||
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
// Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
|
||||||
// refuses to attach to a host with a mismatched version.
|
// 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.
|
// 'COOP' little-endian, used to sanity-check the mapping before trusting it.
|
||||||
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u;
|
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");
|
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<std::uint32_t> heartbeat; // DLL bumps this ~4x/sec while alive
|
||||||
|
std::atomic<std::uint64_t> 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
|
// 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
|
// 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.
|
// reader grab a torn-free snapshot without a kernel lock on the hot path.
|
||||||
@@ -53,12 +68,17 @@ struct SharedBlock
|
|||||||
std::atomic<std::uint32_t> sequence;
|
std::atomic<std::uint32_t> sequence;
|
||||||
CoopPadState pads[kMaxPads];
|
CoopPadState pads[kMaxPads];
|
||||||
|
|
||||||
|
// Hook -> host diagnostics back-channel.
|
||||||
|
HookStatus status;
|
||||||
|
|
||||||
// Phase 2 appends the shared-texture handle/dimensions control fields here;
|
// Phase 2 appends the shared-texture handle/dimensions control fields here;
|
||||||
// keep new members at the end so existing offsets never shift.
|
// keep new members at the end so existing offsets never shift.
|
||||||
};
|
};
|
||||||
|
|
||||||
static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
|
static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
|
||||||
"seqlock requires a lock-free 32-bit atomic for cross-process use");
|
"seqlock requires a lock-free 32-bit atomic for cross-process use");
|
||||||
|
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
|
||||||
|
"status counters need a lock-free 64-bit atomic for cross-process use");
|
||||||
|
|
||||||
// --- Seqlock helpers -------------------------------------------------------
|
// --- Seqlock helpers -------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
add_library(coop_hook SHARED
|
add_library(coop_hook SHARED
|
||||||
src/dllmain.cpp
|
src/dllmain.cpp
|
||||||
src/xinput_hook.cpp)
|
src/xinput_hook.cpp
|
||||||
|
src/focus_spoof.cpp)
|
||||||
|
|
||||||
target_include_directories(coop_hook PRIVATE src)
|
target_include_directories(coop_hook PRIVATE src)
|
||||||
|
|
||||||
target_link_libraries(coop_hook PRIVATE
|
target_link_libraries(coop_hook PRIVATE
|
||||||
coop_common
|
coop_common
|
||||||
safetyhook::safetyhook)
|
safetyhook::safetyhook
|
||||||
|
user32)
|
||||||
|
|
||||||
set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook")
|
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.
|
// 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
|
// 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
|
// pid), hooks XInput so the game reads the forwarded controller state, and
|
||||||
// real work happens on a worker thread; DllMain only kicks it off to stay clear
|
// spoofs focus so the game keeps running while the tool holds the real OS focus.
|
||||||
// of the loader lock.
|
// 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 <windows.h>
|
||||||
|
|
||||||
|
#include "focus_spoof.hpp"
|
||||||
#include "ipc_client.hpp"
|
#include "ipc_client.hpp"
|
||||||
#include "xinput_hook.hpp"
|
#include "xinput_hook.hpp"
|
||||||
|
|
||||||
@@ -14,8 +18,9 @@ namespace
|
|||||||
{
|
{
|
||||||
|
|
||||||
coop::hook::IpcClient g_ipc;
|
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.
|
// The host creates the mapping around injection time; give it a few seconds.
|
||||||
if (!g_ipc.connect(/*attempts=*/200, /*delay_ms=*/25))
|
if (!g_ipc.connect(/*attempts=*/200, /*delay_ms=*/25))
|
||||||
@@ -23,11 +28,23 @@ DWORD WINAPI init_thread(LPVOID)
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// XInput may not be loaded yet at this point (games often load it lazily on
|
bool xinput_installed = false;
|
||||||
// first controller use), so keep retrying until a module appears.
|
bool focus_installed = false;
|
||||||
for (int i = 0; i < 400 && !coop::hook::install_xinput_hooks(g_ipc); ++i)
|
|
||||||
|
// 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;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -40,7 +57,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved)
|
|||||||
{
|
{
|
||||||
case DLL_PROCESS_ATTACH:
|
case DLL_PROCESS_ATTACH:
|
||||||
DisableThreadLibraryCalls(module);
|
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);
|
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.
|
// loader is already unwinding and touching other modules is unsafe.
|
||||||
if (reserved == nullptr)
|
if (reserved == nullptr)
|
||||||
{
|
{
|
||||||
|
g_running.store(false, std::memory_order_relaxed);
|
||||||
|
coop::hook::remove_focus_spoof();
|
||||||
coop::hook::remove_xinput_hooks();
|
coop::hook::remove_xinput_hooks();
|
||||||
}
|
}
|
||||||
break;
|
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.
|
// forwarded pad state the host publishes each frame.
|
||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
@@ -54,6 +55,45 @@ public:
|
|||||||
return read_pads(*block_, out, count);
|
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:
|
private:
|
||||||
SharedMemory shm_;
|
SharedMemory shm_;
|
||||||
SharedBlock* block_ = nullptr;
|
SharedBlock* block_ = nullptr;
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ namespace
|
|||||||
// XInputGetStateEx that many games use. Mirrors how Steam/x360ce expose it.
|
// XInputGetStateEx that many games use. Mirrors how Steam/x360ce expose it.
|
||||||
constexpr std::uint16_t kGuideButton = 0x0400;
|
constexpr std::uint16_t kGuideButton = 0x0400;
|
||||||
|
|
||||||
const IpcClient* g_ipc = nullptr;
|
IpcClient* g_ipc = nullptr;
|
||||||
std::vector<safetyhook::InlineHook> g_hooks;
|
std::vector<safetyhook::InlineHook> g_hooks;
|
||||||
|
|
||||||
// Last good snapshot, so a momentary failed IPC read (host mid-write) doesn't
|
// 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;
|
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();
|
refresh_cache();
|
||||||
const CoopPadState& pad = g_cache[user_index];
|
const CoopPadState& pad = g_cache[user_index];
|
||||||
if (!pad.connected)
|
if (!pad.connected)
|
||||||
@@ -156,7 +160,7 @@ void hook_ordinal(HMODULE module, WORD ordinal, void* detour)
|
|||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
bool install_xinput_hooks(const IpcClient& ipc)
|
bool install_xinput_hooks(IpcClient& ipc)
|
||||||
{
|
{
|
||||||
if (!g_hooks.empty())
|
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, "XInputGetCapabilities", reinterpret_cast<void*>(&hk_XInputGetCapabilities));
|
||||||
hook_export(module, "XInputSetState", reinterpret_cast<void*>(&hk_XInputSetState));
|
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()
|
void remove_xinput_hooks()
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace coop::hook
|
|||||||
// Locates the loaded XInput module(s) and hooks the state/capability entry
|
// 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
|
// 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.
|
// 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).
|
// Removes all installed hooks (best effort; used on DLL detach).
|
||||||
void remove_xinput_hooks();
|
void remove_xinput_hooks();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#include "injection_panel.hpp"
|
#include "injection_panel.hpp"
|
||||||
|
|
||||||
|
#include <cmath>
|
||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
|
|
||||||
#include "inject/injector.hpp"
|
#include "inject/injector.hpp"
|
||||||
@@ -108,6 +110,75 @@ void InjectionPanel::inject_selected()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& 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<double>(ms) / 1000.0;
|
||||||
|
|
||||||
|
std::array<PadInfo, kMaxPads> synthetic{};
|
||||||
|
PadInfo& pad = synthetic[0];
|
||||||
|
pad.connected = true;
|
||||||
|
pad.source = "synthetic test";
|
||||||
|
pad.state.connected = 1;
|
||||||
|
pad.state.packet = static_cast<std::uint32_t>(ms);
|
||||||
|
pad.state.thumb_lx = static_cast<std::int16_t>(std::cos(t) * 30000.0);
|
||||||
|
pad.state.thumb_ly = static_cast<std::int16_t>(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<double>(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<unsigned long long>(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()
|
void InjectionPanel::draw()
|
||||||
{
|
{
|
||||||
ImGui::SetNextWindowPos(ImVec2(24, 360), ImGuiCond_FirstUseEver);
|
ImGui::SetNextWindowPos(ImVec2(24, 360), ImGuiCond_FirstUseEver);
|
||||||
@@ -168,6 +239,15 @@ void InjectionPanel::draw()
|
|||||||
ImGui::TextColored(status_color_, "%s", status_.c_str());
|
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();
|
ImGui::End();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,15 +21,14 @@ public:
|
|||||||
|
|
||||||
void draw();
|
void draw();
|
||||||
|
|
||||||
// Forward the latest pad snapshot to the injected hook (if connected).
|
// Forward the latest pad snapshot to the injected hook (if connected). When
|
||||||
void publish(const std::array<PadInfo, kMaxPads>& pads)
|
// test-input mode is on, a synthetic pattern is sent instead of `pads`.
|
||||||
{
|
void publish(const std::array<PadInfo, kMaxPads>& pads);
|
||||||
server_.publish(pads);
|
|
||||||
}
|
|
||||||
|
|
||||||
private:
|
private:
|
||||||
void refresh_processes();
|
void refresh_processes();
|
||||||
void inject_selected();
|
void inject_selected();
|
||||||
|
void draw_hook_status();
|
||||||
|
|
||||||
std::vector<ProcessEntry> processes_;
|
std::vector<ProcessEntry> processes_;
|
||||||
char filter_[128] = {};
|
char filter_[128] = {};
|
||||||
@@ -39,6 +38,13 @@ private:
|
|||||||
IpcServer server_;
|
IpcServer server_;
|
||||||
std::string status_;
|
std::string status_;
|
||||||
ImVec4 status_color_;
|
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
|
} // namespace coop
|
||||||
|
|||||||
@@ -40,6 +40,24 @@ void IpcServer::publish(const std::array<PadInfo, kMaxPads>& pads)
|
|||||||
publish_pads(*block_, states, kMaxPads);
|
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()
|
void IpcServer::stop()
|
||||||
{
|
{
|
||||||
if (block_ != nullptr)
|
if (block_ != nullptr)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <array>
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
#include "coop/protocol.hpp"
|
#include "coop/protocol.hpp"
|
||||||
#include "coop/shared_memory.hpp"
|
#include "coop/shared_memory.hpp"
|
||||||
@@ -11,6 +12,18 @@
|
|||||||
namespace coop
|
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
|
class IpcServer
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
@@ -23,6 +36,9 @@ public:
|
|||||||
|
|
||||||
void stop();
|
void stop();
|
||||||
|
|
||||||
|
// Reads the hook's diagnostics back-channel (zeroed if not started).
|
||||||
|
[[nodiscard]] HookStatusView hook_status() const;
|
||||||
|
|
||||||
[[nodiscard]] bool running() const
|
[[nodiscard]] bool running() const
|
||||||
{
|
{
|
||||||
return block_ != nullptr;
|
return block_ != nullptr;
|
||||||
|
|||||||
@@ -77,6 +77,10 @@ int main()
|
|||||||
check(XInputGetCapabilities(0, 0, &caps) == ERROR_SUCCESS, "slot 0 capabilities reported");
|
check(XInputGetCapabilities(0, 0, &caps) == ERROR_SUCCESS, "slot 0 capabilities reported");
|
||||||
check(caps.Type == XINPUT_DEVTYPE_GAMEPAD, "capability device type");
|
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();
|
hook::remove_xinput_hooks();
|
||||||
|
|
||||||
std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);
|
std::printf(g_failures == 0 ? "SELFTEST PASS\n" : "SELFTEST FAILED (%d)\n", g_failures);
|
||||||
|
|||||||
Reference in New Issue
Block a user