From 435ab9d30f2fe324134e3c2e97442c5f02f58712 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sat, 20 Jun 2026 20:07:17 +0200 Subject: [PATCH] Fix 32-bit game crash: call hooked __stdcall functions with stdcall() SafetyHook's InlineHook::call() invokes the trampoline through a __cdecl pointer (the compiler default on x86). The functions we hook are __stdcall (IDXGISwapChain::Present/Present1, the WASAPI render interfaces, and the WINAPI SwapBuffers/wglSwapBuffers), so on 32-bit both sides cleaned the stack -> ESP imbalance -> Run-Time Check Failure #0 and an instant crash. On x64 every convention collapses to one, so it only bit 32-bit games: Slaps and Beans (Unity/Rewired, 32-bit D3D11) froze the moment the Present hook ran. The user's "crashes as soon as a button is pressed" was the Present, not the button. Switch every __stdcall trampoline call to SafetyHook's stdcall() (a no-op on x64). The XInput/focus hooks were unaffected because they never call the trampoline -- they return synthesized data. Reproduction + regression coverage: - tools/input_probe (coop_input_probe): injects, reports a connected pad, toggles a button, and takes a disable_mask to bisect which subsystem affects a game. Isolated the freeze to the video subsystem live. - hook_selftest_x86 + present_hook_test_x86: the x86 sub-build now builds and runs these (the x64 present_hook_test can't see a one-convention bug). present_hook_test_x86 drives a real swapchain through the trampoline -- it would hit RTC #0 before this fix. - hook_selftest strengthened to exercise every loaded xinput DLL's full export set (GetState, ordinal-100 GetStateEx, GetCapabilities, rumble SetState) and to dump the SharedBlock layout. - protocol.hpp: static_asserts lock the cross-bitness front-of-block offsets (verified byte-identical on x86 and x64). README roadmap trimmed (this milestone done) and a lessons-learned note added on the call()/stdcall() convention trap. Co-Authored-By: Claude Opus 4.8 --- CMakeLists.txt | 27 +++ README.md | 276 ++++++++++++++----------------- common/include/coop/protocol.hpp | 10 ++ hook/src/audio_hook.cpp | 15 +- hook/src/opengl_hook.cpp | 4 +- hook/src/present_hook.cpp | 9 +- tests/hook_selftest.cpp | 87 ++++++++++ tools/input_probe/CMakeLists.txt | 7 + tools/input_probe/main.cpp | 257 ++++++++++++++++++++++++++++ 9 files changed, 528 insertions(+), 164 deletions(-) create mode 100644 tools/input_probe/CMakeLists.txt create mode 100644 tools/input_probe/main.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index a505e42..b87e9d9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,32 @@ if(COOP_X86_HELPER_BUILD) add_subdirectory(third_party/safetyhook) add_subdirectory(hook) add_subdirectory(tools/inject_helper) + + # x86 reproduction of the XInput self-test. The x64 hook_selftest passes, so an + # x86 build of the same logic is how we reproduce (and then guard against) the + # 32-bit input-forwarding crash on real games like Slaps and Beans. + enable_testing() + add_executable(hook_selftest_x86 + tests/hook_selftest.cpp + hook/src/xinput_hook.cpp + hook/src/hook_registry.cpp) + target_include_directories(hook_selftest_x86 PRIVATE hook/src) + target_link_libraries(hook_selftest_x86 PRIVATE coop_common safetyhook::safetyhook xinput) + add_test(NAME hook_selftest_x86 COMMAND hook_selftest_x86) + + # x86 build of the Present-hook test. It drives a real swapchain through the + # SafetyHook trampoline, so it catches the x86-only calling-convention bug that + # froze 32-bit games (SafetyHook's call() is __cdecl; Present is __stdcall -> + # ESP imbalance / Run-Time Check Failure #0). The x64 present_hook_test can't see + # it (one calling convention), so this 32-bit build is the regression guard. + add_executable(present_hook_test_x86 + tests/present_hook_test.cpp + hook/src/present_hook.cpp + hook/src/debug_log.cpp + hook/src/hook_registry.cpp) + target_include_directories(present_hook_test_x86 PRIVATE hook/src) + target_link_libraries(present_hook_test_x86 PRIVATE coop_common safetyhook::safetyhook d3d11 dxgi) + add_test(NAME present_hook_test_x86 COMMAND present_hook_test_x86) return() endif() @@ -63,6 +89,7 @@ if(COOP_BUILD_HOOK) enable_testing() add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook + add_subdirectory(tools/input_probe) # coop_input_probe: inject + forward synthetic input add_subdirectory(tests) endif() diff --git a/README.md b/README.md index 9ca5cf6..53ad33b 100644 --- a/README.md +++ b/README.md @@ -15,17 +15,30 @@ window that is a live copy of the game's video + audio, and forwards the guests' input back into the real game. Steam's RPT captures the mirror window — so any XInput game becomes Remote-Play-Together-able. +The end-to-end path is working: launched under a donor appid, the host streams a +live video + audio mirror of a separately-running game over Remote Play Together +and forwards guest controllers back into it. + ## Architecture -| Concern | Mechanism | Component | Status | -| --- | --- | --- | --- | -| Receive guest input | XInput (RPT delivers guest pads to the focused window); optional, opt-in Steam Input when built with the Steamworks SDK | `coop_host.exe` | done | -| Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` | done | -| Keep game running unfocused | Hook spoofs focus so the game polls while the host holds OS focus | `coop_hook.dll` | done | -| Mirror video | Windows Graphics Capture of the game window, letterboxed into the host window | `coop_host.exe` | done | -| Mirror video (alt) | Injected Present-hook copies the DXGI backbuffer into a shared keyed-mutex texture the host samples (lower latency, no capture border) | `coop_hook.dll` + `coop_host.exe` | done | -| Mirror audio | Injected render-hook copies the game's WASAPI frames into a shared ring and silences the game locally (no echo); WASAPI process loopback is the automatic fallback | `coop_hook.dll` + `coop_host.exe` | done | -| Host ↔ hook IPC | Named shared memory (seqlock for input, status back-channel) | `common/` | done | +| Concern | Mechanism | Component | +| --- | --- | --- | +| Receive guest input | XInput (RPT delivers guest pads to the focused window); optional, opt-in Steam Input when built with the Steamworks SDK | `coop_host.exe` | +| Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` | +| Keep game running unfocused | Hook spoofs focus so the game polls while the host holds OS focus | `coop_hook.dll` | +| Mirror video (default) | Windows Graphics Capture of the game window, letterboxed into the host window | `coop_host.exe` | +| Mirror video (hooked) | Injected Present / OpenGL hook copies the backbuffer into a shared keyed-mutex texture the host samples (lower latency, no capture border) | `coop_hook.dll` + `coop_host.exe` | +| Mirror audio | Injected render-hook copies the game's WASAPI frames into a shared ring and silences the game locally (no echo); WASAPI process loopback is the automatic fallback | `coop_hook.dll` + `coop_host.exe` | +| Host ↔ hook IPC | Named shared memory (seqlock for input, status back-channel, video/audio/log shares) | `common/` | + +The hooked video path has two producers: **Direct3D (DXGI)** hooks +`IDXGISwapChain::Present` / `Present1` and copies the backbuffer (D3D10/11 games +whose backbuffer is an `ID3D11Texture2D`); **OpenGL** hooks +`SwapBuffers` / `wglSwapBuffers` and reads the backbuffer with `glReadPixels` (for +games that never touch DXGI, e.g. Phantom Brave). The host samples the copy as +plain UNORM (`srgb_to_unorm`) so `*_SRGB`-backbuffer games mirror at correct +brightness. **WGC remains the default** and covers anything the hooked path +doesn't (Vulkan, D3D9, DX12 — see Roadmap). ## Limitations @@ -43,150 +56,72 @@ XInput game becomes Remote-Play-Together-able. detects it (`IsWow64Process2`) and shells out to the helper to load the x86 DLL (a 64-bit process can't cleanly inject a 32-bit one). The shared-memory IPC is fixed-width / bitness-stable, so the x64 host and x86 hook interoperate. -- **Local audio echo (fixed via the hook; falls back otherwise):** when the - render-hook is active it silences the game's local playback while mirroring it, - so there is no echo. If the hook can't attach or the game uses an - unhooked/exotic render path, the host automatically falls back to - process-loopback capture, which does *not* mute the game — so on the fallback - path the local machine still hears the audio twice (guests hear it once). The - Audio panel shows which path is active. -- **Debug-oriented UI:** the ImGui overlay is always visible and laid out for - diagnosing the pipeline, not for end use. It can't yet be toggled off. - -## Status - -All phases below are implemented and verified. - -- **Phase 0 — donor spike. ✅** Confirmed Steam RPT streams an arbitrary - borderless window under a donor appid and routes guest gamepads into it as - XInput (correct slot assignment). This is the premise the whole tool rests on. -- **Phase 1a — input forwarding + focus spoofing. ✅** The host injects - `coop_hook.dll`; the DLL hooks XInput (SafetyHook) so the game reads the - forwarded pad state and *only* that state, and spoofs focus so the game keeps - running while the host holds the real OS focus. A hook→host status - back-channel reports attach state and poll rate. -- **Phase 1b — video mirror (WGC). ✅** The host captures the injected game's - window with Windows Graphics Capture and draws it letterboxed as its - background, so RPT streams a live mirror. -- **Phase 2 — audio mirror. ✅** The host captures the game's audio by PID via - WASAPI process loopback and re-renders it on the default endpoint, so RPT - carries game audio to guests. -- **Audio render-hook (echo fix). ✅ (capture proven on a real game; full RPT - pass pending)** The injected hook intercepts the game's WASAPI render path - (`IAudioRenderClient`), copies the frames into a shared audio ring for the host - to re-render, and releases the game's buffer silenced — so the operator no - longer hears the audio twice. It hooks the render vtables *proactively* so a - game that's already playing when injected is still captured. The host owns an - enable flag and re-renders the game's format via `AUTOCONVERTPCM`; if the hook - doesn't publish a format in time it reverts to process loopback. The Audio panel - shows the active source and a render-stream-count debug table. Validated - in-process by `audio_hook_test` and against a real already-playing game - (Phantom Brave) with `coop_audio_probe`: the pre-existing render client is - detected and real, non-silent audio reaches the ring with zero overruns. - -### Remaining verification - -The full-app capture path now works: the hook publishes the captured format to the -ring whenever the host attaches it, so toggling **Mirror game audio** switches to -**Source: Hooked (no echo)** instead of falling back to loopback (verified with -`coop_audio_probe` reproducing the app's inject-then-create-ring ordering against -Phantom Brave). These still need a human / full setup to confirm: - -- **Hooked audio over RPT, end-to-end.** Run the host, inject, tick **Mirror game - audio**, and confirm **Source: Hooked (no echo)**, the game goes locally silent, - and a guest on Remote Play Together still hears it. -- **Format guess for non-mix-format games.** For a stream that already exists at - injection time the hook can't see the game's `Initialize`, so it assumes the - device **mix format**. Phantom Brave matched it exactly (48 kHz/2ch/float). A - game that initialized shared mode with a different format would come out - wrong-pitched/garbled and needs per-stream format detection — not yet handled. -- **Multi-stream games.** v1 captures only the first ("primary") render stream; - confirm the Audio panel's render-stream table makes a multi-stream game obvious - (secondary streams stay local until per-stream mixing is added). +- **Local audio echo on the fallback path:** when the render-hook is active it + silences the game's local playback while mirroring it, so there is no echo. If + the hook can't attach or the game uses an unhooked render path, the host falls + back to process-loopback capture, which does *not* mute the game — so the local + machine hears the audio twice (guests hear it once). The Audio panel shows which + path is active. +- **Debug-oriented UI:** the ImGui overlay is laid out for diagnosing the + pipeline, not for end use. F1 hides it entirely so the window is a clean mirror + for RPT. ## Roadmap -Done: +### Planned (next up) -- **Usable overlay. ✅** F1 hides the whole overlay so the window is a clean - mirror for Remote Play Together; a fading hint shows the way back. The - pipelines keep running while hidden. -- **Generalized UI. ✅** A top menu bar carries a consolidated FPS / frame-time - (with jitter) readout and a **View** menu that toggles each panel and a global - **Debug details** switch. Panels default to general-purpose status (attached, - forwarding, controller polling rate, mirror resolution, audio source + buffered - ms) and reveal the verbose diagnostics (per-slot poll table, focus-API counts, - input-path detection, per-render-stream table) only when Debug details is on. -- **Installed-hooks list. ✅** The DLL keeps a registry of every individual hook - it installs (XInput, focus, audio), with a running call counter per hook, - reported over IPC. The Injection panel shows it grouped by subsystem - (input / focus / audio) so you can see exactly what's hooked and how busy each - hook is. `coop_audio_probe` prints the same table headless. -- **Per-subsystem hook control. ✅** The three injectable subsystems — input - forwarding (XInput), focus spoof, and the audio render-hook — are independently - controllable. The Injection panel has a checkbox per subsystem that - installs/removes its hooks at runtime over a host→hook control channel; the hook - reconciles each tick. Dependent features are guarded: the synthetic-input - control is disabled when input forwarding is off, and the Audio panel notes when - the render-hook is off (mirroring then uses loopback). Defaults to all-on so - behavior is unchanged unless you toggle something. -- **In-app Log window. ✅** The injected DLL streams its log lines to the host - over a shared log ring (`coop_log_`, a lossy multi-producer ring), and the - host shows them in a **Log** window with auto-scroll, a text filter, and clear. - Replaces tailing `%TEMP%\coop_hook.log` (which stays as an opt-in file mirror). - `coop_audio_probe` drains and prints the same stream headless. +The current focus is making specific games work end-to-end. Each item is a +milestone with its own tests and commit. -- **Hooked video path (Present + OpenGL). ✅** The injected DLL captures the - game's frames into a shared keyed-mutex texture (`coop_video_`) that the - host opens by name and samples — a lower-latency, border-free alternative to WGC. - It's an opt-in subsystem (the **Video Present-hook** checkbox in the Injection - panel, or just pick **Source: Hooked (Present)** in the Video mirror panel, which - installs it). Two producers cover the common graphics APIs: - - **Direct3D (DXGI):** hooks `IDXGISwapChain::Present` / `Present1` and copies - the backbuffer. Catches D3D10/11/12 games whose backbuffer is an - `ID3D11Texture2D` (the common D3D11 case). Validated by `present_hook_test` - and against Slaps and Beans (32-bit D3D11) and Life is Strange: Before the - Storm. The host samples the copy as plain UNORM (see `srgb_to_unorm`) so games - with an `*_SRGB` backbuffer (Life is Strange is `R8G8B8A8_UNORM_SRGB`) mirror - with correct brightness instead of being darkened by an sRGB→linear decode. - - **OpenGL:** hooks `SwapBuffers` / `wglSwapBuffers`, reads the backbuffer with - `glReadPixels`, and uploads it into the shared texture. Catches OpenGL games - that never touch DXGI (e.g. **Phantom Brave**, which is OpenGL — that's why - the Present hook alone showed no image). Validated by `opengl_hook_test` and - against Phantom Brave. `glReadPixels` forces a per-frame GPU→CPU readback, so - it's heavier than the D3D copy, but fine for the typically-2D OpenGL titles. +1. **Release the mouse cursor for cursor-clipping games.** Games that confine the + cursor while focused (e.g. Trails through Daybreak via `ClipCursor` / + per-frame `SetCursorPos` re-centering) trap the operator's mouse permanently, + because the focus spoof makes the game believe it's always focused — so the + operator can't reach the ImGui overlay. Add a cursor-release capability to the + Focus subsystem: hook `ClipCursor` (force `ClipCursor(NULL)` and swallow the + game's clip) and the re-centering `SetCursorPos`, gated by a new host→hook flag + driven by a host toggle + hotkey. Defaults to released (the guest plays via the + pad, so the game's own cursor clip is operator-only), with the option to + re-enable clipping per game. - **Vulkan** games (present via `vkQueuePresentKHR`) aren't hooked yet — that needs - a Vulkan layer / device-dispatch hook plus a `vkCmdCopyImage` to a readable - image, a larger effort. **Use WGC** (the default Video source) for Vulkan, D3D9, - or anything the hooked path doesn't capture — WGC works for any window. +2. **Real capture metrics + latency stats.** The current FPS readout only measures + how fast the host renders its own window, which hides capture stutter. Add a + three-line frametime/FPS graph — **game present rate** (from `VideoShare` + present deltas), **capture rate** (generation deltas / WGC arrivals), and + **tool render rate** — plus a **capture→display latency** stat: stamp each + published frame with a `QueryPerformanceCounter` value in `VideoShare`, and the + host reports `host-present QPC − game-present QPC` (min/avg/max ms) for the + matched frame. QPC is system-wide, so the two processes' timestamps compare + directly. -- **x86 (32-bit) game support. ✅** A nested Win32 sub-build (CMake - `ExternalProject`, driven from the normal x64 build) produces `coop_hook_x86.dll` - and a 32-bit `coop_inject_x86.exe`, staged next to the x64 binaries. The host - detects a WOW64 target with `IsWow64Process2` and spawns the helper to inject - the x86 DLL. Validated end-to-end against Slaps and Beans (a 32-bit D3D11 game): - all subsystems hooked, and the IPC channels (status, audio ring, video share, - log) all flow across the x64↔x86 boundary. +3. **DX12 hooked capture.** Marvel's Spider-Man is D3D12, so the Present hook fires + but `GetBuffer(0)` as `ID3D11Texture2D` fails (the backbuffer is an + `ID3D12Resource`) and the hook idles; WGC works but stutters. Add a D3D12 path + via a **D3D11On12 bridge**: capture the game's D3D12 command queue (hook + `ID3D12CommandQueue::ExecuteCommandLists`), create an `ID3D11On12Device`, + `CreateWrappedResource` around the backbuffer, and `CopyResource` into the + *existing* D3D11 shared keyed-mutex texture — so the host side is unchanged. -- **Steam Input (opt-in). ✅** When the host is built with the Steamworks SDK - (auto-detected under `third_party/steamworks_sdk/`), a **Use Steam Input** - checkbox in the Controllers panel switches the input backend to the Steam Input - API (action-based) at runtime; it loads a bundled action manifest - (`steam_input_actions.vdf`) via `SetInputActionManifestFilePath`, so it needs no - partner-backend config. **It's off by default and XInput is the primary path**: - merely initializing Steam Input activates Steam's in-process XInput interception, - which *hides* controllers from XInput unless they're bound to our action set for - the running appid — so defaulting to it silently broke input forwarding. Enabling - it is only useful once a controller is bound to Steam Input for the donor appid; - otherwise leave it off and the proven XInput path carries the guest input. - Verified to initialize and enumerate controllers against the live Steam client - (`coop_steam_input_probe`), and that toggling it off restores XInput. +4. **Multi-stream audio capture + mixing.** Games with several concurrent WASAPI + render streams (e.g. Spider-Man) only get their first ("primary") stream + mirrored today; the rest keep playing locally and never reach the guest. Capture + every tracked render stream into its own shared ring, silence each, and add a + host-side mixer that resamples each ring to the render format and sums them + (with soft-clip). -All planned phases are now implemented. Possible later work: per-stream audio -mixing for multi-stream games, per-stream format detection for the audio hook, and -hooking the remaining present paths for the video hook (Vulkan `vkQueuePresentKHR`, -D3D9, pure-D3D12) — WGC already covers those today. +### Future work + +- **Vulkan video hook.** Vulkan games present via `vkQueuePresentKHR`; hooking + them needs a Vulkan layer / device-dispatch hook plus a `vkCmdCopyImage` to a + readable image. Use WGC in the meantime. +- **D3D9 hooked path.** Covered by WGC today; a dedicated `IDirect3DDevice9::Present` + hook would be the lower-latency upgrade. +- **Per-stream audio format detection.** A render stream that already exists when + we inject is never seen at `Initialize`, so the hook assumes the device **mix + format**. A stream initialized in shared mode at a different format would come + out wrong-pitched. Detecting the real per-stream format would remove that guess. +- **Rumble / haptics forwarding.** `XInputSetState` is currently swallowed; routing + it back to the guest is a later phase. ## Building @@ -217,6 +152,12 @@ it's absent the host builds XInput-only (no other features depend on it). When present, the build links `steam_api64.lib`, stages `steam_api64.dll` and the action manifest next to the host, and also builds `coop_steam_input_probe`. +Steam Input is **off by default and XInput is the primary path**: merely +initializing Steam Input activates Steam's in-process XInput interception, which +hides controllers from XInput unless they're bound to our action set for the +running appid. Enable it (Controllers panel → **Use Steam Input**) only once a +controller is bound to Steam Input for the donor appid. + ### clangd / IDE setup The Visual Studio CMake generator does **not** emit `compile_commands.json`, so @@ -239,8 +180,8 @@ ctest --test-dir build -C Debug --output-on-failure - **`audio_hook_test`** — in-process self-test of the WASAPI render-hook: installs the hooks, renders a tone through WASAPI in the same process, and asserts the COM vtables were discovered, the frames reached the ring (non-silent), the - primary stream was silenced, and exactly one render stream was counted. Skips - cleanly if the machine has no audio endpoint. + primary stream was silenced, and the render stream was counted. Skips cleanly if + the machine has no audio endpoint. - **`srgb_format_test`** — unit test of the `srgb_to_unorm` mapping the hooked video path uses so `*_SRGB`-backbuffer games aren't darkened. No device. - **`opengl_hook_test`** — in-process self-test of the OpenGL capture path: @@ -259,16 +200,27 @@ ctest --test-dir build -C Debug --output-on-failure shipping process-loopback capture (the fallback path) receives its audio by PID. Skips cleanly if the machine has no audio endpoint. -### Debugging the render-hook against a real game +### Debugging the hooks against a real game [`tools/audio_probe`](tools/audio_probe) (`coop_audio_probe.exe [seconds]`) brings up the audio render-hook without Steam / RPT / the host UI: it creates the IPC block + audio ring the hook expects, injects `coop_hook.dll` into the target game, then drains the ring and prints per-stream format, captured-frame counts, peak amplitude (proves the audio is real, not silence), and overruns. It enables -the hook's file trace (`%TEMP%\coop_hook.log`) for the run. Run it from -`bin//`. **Kill the game between runs** — the loaded DLL locks -`coop_hook.dll` against the next rebuild. +the hook's file trace (`%TEMP%\coop_hook.log`) for the run. + +[`tools/input_probe`](tools/input_probe) +(`coop_input_probe.exe [seconds] [disable_mask]`) does the same for input: it +injects, reports one connected pad, and toggles a button each second so the game's +input layer sees a real state change. `disable_mask` (hex bits `0x1`=input +`0x2`=focus `0x4`=audio `0x8`=video) skips installing a subsystem, so you can +**bisect which injected subsystem affects a game** — this is how the 32-bit +Present-hook crash was isolated. + +Both auto-detect a 32-bit (WOW64) target and inject via `coop_inject_x86.exe` + +`coop_hook_x86.dll`, exactly like the host. Run them from `bin//`. +**Kill the game between runs** — the loaded DLL locks `coop_hook.dll` against the +next rebuild. ## Running the tool (manual, end-to-end) @@ -294,16 +246,16 @@ person/account to receive the stream. 3. **Mirror video:** in the **Video mirror** panel, tick **Mirror game window** — the host window now shows a live, letterboxed copy of the game. **Source** picks how the frames are grabbed: **WGC** (default, Windows Graphics Capture — - works for any window) or **Hooked (Present)** (the injected Present-hook's - shared texture — lower latency and no capture border, but only for DXGI / - D3D11 games; selecting it installs the video subsystem in the game). + works for any window) or **Hooked (Present)** (the injected hook's shared + texture — lower latency and no capture border, for DXGI / D3D11 and OpenGL + games; selecting it installs the video subsystem in the game). 4. **Mirror audio:** in the **Audio mirror** panel, tick **Mirror game audio**. With the hook injected, **Source** shows **Hooked (no echo)** and the game's local playback goes silent while guests still hear it. If it shows **Loopback (echo)** the hook's render path wasn't caught and you'll hear the game twice locally (guests still hear it once). The **Render streams** table shows how - many WASAPI streams the game emits (v1 mirrors the first/primary). + many WASAPI streams the game emits. 5. **Start Remote Play Together** from Steam and invite a guest. Verify the guest sees the mirrored video, hears the audio, and that their controller drives the @@ -349,3 +301,19 @@ Non-obvious things that cost time and constrain the design: not 13 — `SetEventHandle` (13) sits between `Reset` and `GetService`. Count the full interface (including every inherited `IUnknown`/base method) when adding a new COM hook. +- **SafetyHook's `call()` is `__cdecl` on x86 — use `stdcall()` for `__stdcall` + targets.** `InlineHook::call()` invokes the trampoline through a pointer with the + compiler's default convention, which is `__cdecl` on 32-bit. Most things we hook + are `__stdcall` (COM methods like `IDXGISwapChain::Present` and the WASAPI render + interfaces, plus `WINAPI` `SwapBuffers`). On x64 every convention collapses to one, + so `call()` is fine; on x86 it double-cleans the stack → ESP imbalance → an + instant crash (Debug builds surface it as **Run-Time Check Failure #0**). This + froze 32-bit games (Slaps and Beans) the moment the Present hook ran. Always call + trampolines with the matching convention — `stdcall()` for these — which is a + no-op on x64. The XInput/focus hooks dodged it only because they never call the + trampoline (they return synthesized data). +- **Steam Input init suppresses XInput.** Initializing the Steam Input API turns on + Steam's in-process XInput interception, which hides controllers from + `XInputGetState` unless they're bound to the running appid's action set — + defaulting to it silently broke input forwarding. XInput is the primary path; + Steam Input is opt-in. diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index 19db6e9..6a7592f 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -4,6 +4,7 @@ #pragma once #include +#include #include namespace coop @@ -184,6 +185,15 @@ static_assert(std::atomic::is_always_lock_free, static_assert(std::atomic::is_always_lock_free, "status counters need a lock-free 64-bit atomic for cross-process use"); +// The x64 host and the x86 hook map this same block, so its layout must be +// byte-identical across bitness. These offsets (verified equal on both arches) +// lock the front of the block -- the seqlock + pad state the input hot path reads; +// a future field reorder that diverges between x86 and x64 fails to compile on the +// arch that disagrees. (Fixed-width POD + no pointers is what keeps it stable.) +static_assert(offsetof(SharedBlock, sequence) == 12, "cross-bitness: sequence offset moved"); +static_assert(offsetof(SharedBlock, pads) == 16, "cross-bitness: pad-state offset moved"); +static_assert(offsetof(SharedBlock, status) == 96, "cross-bitness: status offset moved"); + // --- Seqlock helpers ------------------------------------------------------- // Writer side: publish a fresh set of pad states. Called from the host. diff --git a/hook/src/audio_hook.cpp b/hook/src/audio_hook.cpp index 864a123..e542670 100644 --- a/hook/src/audio_hook.cpp +++ b/hook/src/audio_hook.cpp @@ -151,7 +151,10 @@ void try_register_lazy(IAudioRenderClient* rc); HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data) { hook_note_call(g_id_getbuffer); - const HRESULT hr = g_hk_getbuffer.call(self, num_frames, data); + // stdcall(), NOT call(): these are COM methods (__stdcall). SafetyHook's call() + // uses a __cdecl pointer (the x86 default), which double-cleans the stack on + // 32-bit -> ESP imbalance -> Run-Time Check Failure #0 / crash. Harmless on x64. + const HRESULT hr = g_hk_getbuffer.stdcall(self, num_frames, data); if (SUCCEEDED(hr) && data != nullptr) { t_gb_client = self; @@ -204,11 +207,11 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_ { std::memset(t_gb_data, 0, bytes); // belt-and-suspenders vs a driver ignoring SILENT g_frames_captured.fetch_add(num_frames, std::memory_order_relaxed); - return g_hk_releasebuffer.call(self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT); + return g_hk_releasebuffer.stdcall(self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT); } } } - return g_hk_releasebuffer.call(self, num_frames, flags); + return g_hk_releasebuffer.stdcall(self, num_frames, flags); } // Registers a newly created render client: assigns it a debug slot, marks the @@ -312,7 +315,7 @@ HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mo { hook_note_call(g_id_initialize); const HRESULT hr = - g_hk_initialize.call(self, mode, flags, buffer_duration, periodicity, format, session); + g_hk_initialize.stdcall(self, mode, flags, buffer_duration, periodicity, format, session); logf("hk_Initialize: client=%p mode=%d flags=0x%lX hr=0x%08lX fmt=%s", self, mode, static_cast(flags), static_cast(hr), format ? "yes" : "null"); if (SUCCEEDED(hr) && format != nullptr) @@ -326,7 +329,7 @@ HRESULT STDMETHODCALLTYPE hk_Initialize(IAudioClient* self, AUDCLNT_SHAREMODE mo HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** ppv) { hook_note_call(g_id_getservice); - const HRESULT hr = g_hk_getservice.call(self, riid, ppv); + const HRESULT hr = g_hk_getservice.stdcall(self, riid, ppv); const bool is_render = (riid == __uuidof(IAudioRenderClient)); logf("hk_GetService: client=%p hr=0x%08lX render_client=%d", self, static_cast(hr), is_render ? 1 : 0); @@ -384,7 +387,7 @@ HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ct void** ppv) { hook_note_call(g_id_activate); - const HRESULT hr = g_hk_activate.call(self, riid, cls_ctx, params, ppv); + const HRESULT hr = g_hk_activate.stdcall(self, riid, cls_ctx, params, ppv); const bool is_audioclient = (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) || riid == __uuidof(IAudioClient3)); logf("hk_Activate: device=%p hr=0x%08lX audioclient=%d", self, static_cast(hr), diff --git a/hook/src/opengl_hook.cpp b/hook/src/opengl_hook.cpp index c167bc8..f3e4c27 100644 --- a/hook/src/opengl_hook.cpp +++ b/hook/src/opengl_hook.cpp @@ -242,7 +242,7 @@ BOOL WINAPI hk_SwapBuffers(HDC hdc) t_in_swap = true; capture_gl(hdc); } - const BOOL r = g_hk_swapbuffers.call(hdc); + const BOOL r = g_hk_swapbuffers.stdcall(hdc); // __stdcall: call() is __cdecl on x86 -> crash if (outer) { t_in_swap = false; @@ -260,7 +260,7 @@ BOOL WINAPI hk_wglSwapBuffers(HDC hdc) t_in_swap = true; capture_gl(hdc); } - const BOOL r = g_hk_wglswap.call(hdc); + const BOOL r = g_hk_wglswap.stdcall(hdc); // __stdcall: call() is __cdecl on x86 -> crash if (outer) { t_in_swap = false; diff --git a/hook/src/present_hook.cpp b/hook/src/present_hook.cpp index 4039c0d..94e4d35 100644 --- a/hook/src/present_hook.cpp +++ b/hook/src/present_hook.cpp @@ -232,7 +232,12 @@ HRESULT STDMETHODCALLTYPE hk_Present(IDXGISwapChain* sc, UINT sync_interval, UIN { capture_backbuffer(sc); } - return g_hk_present.call(sc, sync_interval, flags); + // stdcall(), NOT call(): IDXGISwapChain::Present is __stdcall, but SafetyHook's + // call() invokes the trampoline through a __cdecl pointer (the default on x86). + // On 32-bit that double-cleans the stack -> ESP imbalance -> Run-Time Check + // Failure #0 and an instant crash. On x64 the conventions collapse, so it only + // bit 32-bit games (e.g. Slaps and Beans froze the moment it presented). + return g_hk_present.stdcall(sc, sync_interval, flags); } HRESULT STDMETHODCALLTYPE hk_Present1(IDXGISwapChain1* sc, UINT sync_interval, UINT flags, @@ -248,7 +253,7 @@ HRESULT STDMETHODCALLTYPE hk_Present1(IDXGISwapChain1* sc, UINT sync_interval, U { capture_backbuffer(sc); // IDXGISwapChain1 derives from IDXGISwapChain } - return g_hk_present1.call(sc, sync_interval, flags, params); + return g_hk_present1.stdcall(sc, sync_interval, flags, params); // __stdcall, see hk_Present } // Create a throwaway device + swapchain purely to read IDXGISwapChain::Present diff --git a/tests/hook_selftest.cpp b/tests/hook_selftest.cpp index 2ff3ebf..589cb9e 100644 --- a/tests/hook_selftest.cpp +++ b/tests/hook_selftest.cpp @@ -2,6 +2,7 @@ // SafetyHook XInput interception. No injection or physical controller needed -- // this process plays both host and game. Exits 0 on pass, 1 on failure. +#include #include #include @@ -33,8 +34,75 @@ void check(bool ok, const char* what) } // namespace +// Exercises every export the game touches on one loaded xinput DLL, so the inline +// hook + trampoline over *that DLL's* real prologue is actually called -- not just +// installed. Older games load older variants (e.g. xinput1_3.dll) whose export +// prologues differ, which is where a 32-bit trampoline-relocation fault hides. +void exercise_dll(const wchar_t* dll_name) +{ + HMODULE m = GetModuleHandleW(dll_name); + if (m == nullptr) + { + return; // not loaded on this machine; nothing to exercise + } + char tag[96]; + + using GetState_t = DWORD(WINAPI*)(DWORD, XINPUT_STATE*); + using SetState_t = DWORD(WINAPI*)(DWORD, XINPUT_VIBRATION*); + using GetCaps_t = DWORD(WINAPI*)(DWORD, DWORD, XINPUT_CAPABILITIES*); + + auto get_state = reinterpret_cast(GetProcAddress(m, "XInputGetState")); + auto get_state_ex = reinterpret_cast(GetProcAddress(m, MAKEINTRESOURCEA(100))); + auto get_caps = reinterpret_cast(GetProcAddress(m, "XInputGetCapabilities")); + auto set_state = reinterpret_cast(GetProcAddress(m, "XInputSetState")); + + if (get_state != nullptr) + { + XINPUT_STATE s = {}; + std::snprintf(tag, sizeof(tag), "%ls XInputGetState forwards state", dll_name); + check(get_state(0, &s) == ERROR_SUCCESS && s.dwPacketNumber == 7, tag); + } + if (get_state_ex != nullptr) + { + XINPUT_STATE s = {}; + std::snprintf(tag, sizeof(tag), "%ls XInputGetStateEx (ord 100) forwards state", dll_name); + check(get_state_ex(0, &s) == ERROR_SUCCESS && s.dwPacketNumber == 7, tag); + } + if (get_caps != nullptr) + { + XINPUT_CAPABILITIES c = {}; + std::snprintf(tag, sizeof(tag), "%ls XInputGetCapabilities reports gamepad", dll_name); + check(get_caps(0, 0, &c) == ERROR_SUCCESS && c.Type == XINPUT_DEVTYPE_GAMEPAD, tag); + } + if (set_state != nullptr) + { + // A game commonly rumbles in response to a button press; this is the call + // path "crashes as soon as a button is pressed" pointed at. + XINPUT_VIBRATION v = {}; + v.wLeftMotorSpeed = 0x8000; + v.wRightMotorSpeed = 0x4000; + std::snprintf(tag, sizeof(tag), "%ls XInputSetState (rumble) accepted, no crash", dll_name); + check(set_state(0, &v) == ERROR_SUCCESS, tag); + } +} + +void dump_layout() +{ + std::printf("LAYOUT sizeof(SharedBlock)=%zu CoopPadState=%zu\n", sizeof(SharedBlock), sizeof(CoopPadState)); + std::printf("LAYOUT off pads=%zu sequence=%zu status=%zu control=%zu video=%zu\n", + offsetof(SharedBlock, pads), offsetof(SharedBlock, sequence), offsetof(SharedBlock, status), + offsetof(SharedBlock, control), offsetof(SharedBlock, video)); + std::printf("LAYOUT HookStatus sizeof=%zu get_state_calls=%zu attached=%zu audio_streams=%zu hook_entries=%zu\n", + sizeof(HookStatus), offsetof(HookStatus, get_state_calls), offsetof(HookStatus, attached), + offsetof(HookStatus, audio_streams), offsetof(HookStatus, hook_entries)); + std::printf("LAYOUT VideoShare sizeof=%zu present_calls=%zu HookControl sizeof=%zu\n", sizeof(VideoShare), + offsetof(VideoShare, present_calls), sizeof(HookControl)); +} + int main() { + dump_layout(); + // --- Host side: create the section (named by our pid) and publish a pad. --- SharedMemory shm; if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock))) @@ -56,6 +124,17 @@ int main() pads[0].thumb_ry = -4321; publish_pads(*block, pads, kMaxPads); + // Load every xinput variant *before* installing hooks, so install_xinput_hooks + // (which only hooks already-loaded modules) covers all of them and the matrix + // below exercises each DLL's real export prologue under SafetyHook. A real game + // loads exactly one, but which one varies by game age -- and the 32-bit crash + // only reproduces over the specific DLL the game uses. + const wchar_t* xinput_modules[] = {L"xinput1_4.dll", L"xinput1_3.dll", L"xinput9_1_0.dll", L"xinputuap.dll"}; + for (const wchar_t* name : xinput_modules) + { + LoadLibraryW(name); // best-effort; absent variants stay unloaded + } + // --- Hook side: connect and install over this process's own xinput. --- hook::IpcClient ipc; check(ipc.connect(10, 5), "IPC client connect"); @@ -77,6 +156,14 @@ int main() check(XInputGetCapabilities(0, 0, &caps) == ERROR_SUCCESS, "slot 0 capabilities reported"); check(caps.Type == XINPUT_DEVTYPE_GAMEPAD, "capability device type"); + // Now drive every loaded variant's full export set (GetState, ordinal-100 + // GetStateEx, GetCapabilities, and the rumble SetState a game calls on a button + // press) so each DLL's hooked prologue/trampoline is actually run. + for (const wchar_t* name : xinput_modules) + { + exercise_dll(name); + } + // 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.get_state_calls[0].load(std::memory_order_relaxed) >= 1, "status counts slot 0 GetState"); diff --git a/tools/input_probe/CMakeLists.txt b/tools/input_probe/CMakeLists.txt new file mode 100644 index 0000000..cd1928a --- /dev/null +++ b/tools/input_probe/CMakeLists.txt @@ -0,0 +1,7 @@ +# Dev harness: creates the hook's IPC block, injects coop_hook.dll into a target +# game by pid, reports one connected pad, and toggles a button so the game's input +# layer processes a real state change. Reproduces the "32-bit game crashes on +# button press" report headlessly, without Steam / RPT / the host UI. +add_executable(coop_input_probe main.cpp) +target_link_libraries(coop_input_probe PRIVATE coop_common) +set_target_properties(coop_input_probe PROPERTIES OUTPUT_NAME "coop_input_probe") diff --git a/tools/input_probe/main.cpp b/tools/input_probe/main.cpp new file mode 100644 index 0000000..9383403 --- /dev/null +++ b/tools/input_probe/main.cpp @@ -0,0 +1,257 @@ +// coop_input_probe -- standalone harness to bring up the injected XInput hook and +// forward synthetic controller input into a real game, without Steam / RPT / the +// host UI. This is the headless reproduction for the "32-bit game crashes as soon +// as a button is pressed" report: it injects coop_hook.dll, reports one connected +// pad, and toggles a button every second so the game's input layer processes a +// real state change. +// +// coop_input_probe [seconds] +// +// Run from the same directory as coop_hook.dll (i.e. bin//). For a 32-bit +// (WOW64) target it shells out to coop_inject_x86.exe + coop_hook_x86.dll, exactly +// like the host. + +#include +#include +#include +#include + +#include + +#include "coop/log_ring.hpp" +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" + +namespace +{ + +std::wstring dll_path_next_to_self() +{ + wchar_t exe[MAX_PATH] = {}; + GetModuleFileNameW(nullptr, exe, MAX_PATH); + std::wstring path(exe); + const size_t slash = path.find_last_of(L"\\/"); + if (slash != std::wstring::npos) + { + path.resize(slash + 1); + } + return path + L"coop_hook.dll"; +} + +std::wstring sibling_of(const std::wstring& path, const wchar_t* name) +{ + const size_t slash = path.find_last_of(L"\\/"); + return (slash == std::wstring::npos ? std::wstring() : path.substr(0, slash + 1)) + name; +} + +// Inject a 32-bit (WOW64) target via the x86 helper, mirroring the host/audio probe. +bool inject_via_helper(unsigned long pid, const std::wstring& dll_path) +{ + const std::wstring helper = sibling_of(dll_path, L"coop_inject_x86.exe"); + const std::wstring x86_dll = sibling_of(dll_path, L"coop_hook_x86.dll"); + if (GetFileAttributesW(helper.c_str()) == INVALID_FILE_ATTRIBUTES || + GetFileAttributesW(x86_dll.c_str()) == INVALID_FILE_ATTRIBUTES) + { + std::printf("ERROR: x86 helper/dll missing next to the probe.\n"); + return false; + } + std::wstring cmd = L"\"" + helper + L"\" " + std::to_wstring(pid) + L" \"" + x86_dll + L"\""; + std::printf("32-bit target: injecting coop_hook_x86.dll via coop_inject_x86.exe ...\n"); + STARTUPINFOW si{}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi{}; + if (!CreateProcessW(helper.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si, &pi)) + { + std::printf("ERROR: CreateProcess(coop_inject_x86) failed (%lu).\n", GetLastError()); + return false; + } + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD code = 1; + GetExitCodeProcess(pi.hProcess, &code); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + if (code != 0) + { + std::printf("ERROR: coop_inject_x86 reported failure (exit %lu).\n", code); + return false; + } + return true; +} + +bool inject(unsigned long pid, const std::wstring& dll_path) +{ + if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES) + { + std::printf("ERROR: coop_hook.dll not found at the probe's directory.\n"); + return false; + } + const DWORD access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | + PROCESS_VM_WRITE | PROCESS_VM_READ; + HANDLE process = OpenProcess(access, FALSE, pid); + if (process == nullptr) + { + std::printf("ERROR: OpenProcess(%lu) failed (%lu). Run as administrator?\n", pid, GetLastError()); + return false; + } + + USHORT proc_machine = IMAGE_FILE_MACHINE_UNKNOWN, native_machine = IMAGE_FILE_MACHINE_UNKNOWN; + if (IsWow64Process2(process, &proc_machine, &native_machine) && proc_machine != IMAGE_FILE_MACHINE_UNKNOWN) + { + CloseHandle(process); + return inject_via_helper(pid, dll_path); + } + const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t); + void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); + bool ok = false; + if (remote != nullptr && WriteProcessMemory(process, remote, dll_path.c_str(), bytes, nullptr)) + { + auto load_library = reinterpret_cast( + GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW")); + HANDLE thread = CreateRemoteThread(process, nullptr, 0, load_library, remote, 0, nullptr); + if (thread != nullptr) + { + WaitForSingleObject(thread, INFINITE); + DWORD exit_code = 0; + GetExitCodeThread(thread, &exit_code); + CloseHandle(thread); + ok = (exit_code != 0); + } + } + if (remote != nullptr) + { + VirtualFreeEx(process, remote, 0, MEM_RELEASE); + } + CloseHandle(process); + if (!ok) + { + std::printf("ERROR: injection failed (%lu).\n", GetLastError()); + } + return ok; +} + +} // namespace + +int wmain(int argc, wchar_t** argv) +{ + if (argc < 2) + { + std::printf("usage: coop_input_probe [seconds] [disable_mask]\n" + " Injects coop_hook.dll, reports one connected pad, and toggles a\n" + " button every second so the game processes a real state change.\n" + " disable_mask (hex): bit per subsystem NOT to install --\n" + " 0x1=input 0x2=focus 0x4=audio 0x8=video (default 0 = all on).\n"); + return 1; + } + const unsigned long pid = std::wcstoul(argv[1], nullptr, 10); + const int seconds = (argc >= 3) ? std::max(1, _wtoi(argv[2])) : 30; + const unsigned disable_mask = (argc >= 4) ? std::wcstoul(argv[3], nullptr, 0) : 0u; + if (pid == 0) + { + std::printf("ERROR: invalid pid.\n"); + return 1; + } + + // 1) Input SharedBlock: report one connected pad up front (buttons still zero), + // so the game sees a controller arrive before we start pressing anything. + coop::SharedMemory ipc; + if (!ipc.create(coop::shared_memory_name(pid), sizeof(coop::SharedBlock))) + { + std::printf("ERROR: create input mapping failed (%lu).\n", GetLastError()); + return 1; + } + auto* block = ipc.as(); + block->version = coop::kProtocolVersion; + block->sequence.store(0, std::memory_order_relaxed); + + // Subsystem isolation: skip installing the ones whose bit is set in disable_mask + // (0x1=input 0x2=focus 0x4=audio 0x8=video). Lets us bisect which injected + // subsystem freezes a given game. + static const char* kSubsysNames[] = {"input", "focus", "audio", "video"}; + for (std::uint32_t i = 0; i < coop::HookSubsys_Count; ++i) + { + const bool disabled = (disable_mask & (1u << i)) != 0; + block->control.subsystem_disabled[i].store(disabled ? 1u : 0u, std::memory_order_release); + std::printf("subsystem %-6s %s\n", kSubsysNames[i], disabled ? "DISABLED" : "on"); + } + + coop::CoopPadState pads[coop::kMaxPads] = {}; + pads[0].connected = 1; + pads[0].packet = 1; + coop::publish_pads(*block, pads, coop::kMaxPads); + block->magic = coop::kProtocolMagic; + + // Log ring (host's role) so the hook streams its trace back to us. + coop::SharedMemory log_shm; + coop::LogRing* log_ring = nullptr; + std::uint64_t log_cursor = 0; + if (log_shm.create(coop::log_ring_name(pid), coop::log_ring_total_size(coop::kLogCapacity))) + { + log_ring = log_shm.as(); + coop::log_ring_init(*log_ring, coop::kLogCapacity); + } + + // Enable the hook's file trace for this session. + { + wchar_t dir[MAX_PATH] = {}; + if (GetTempPathW(MAX_PATH, dir) != 0) + { + const std::wstring sentinel = std::wstring(dir) + L"coop_hook.log.on"; + HANDLE h = CreateFileW(sentinel.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, OPEN_ALWAYS, + FILE_ATTRIBUTE_NORMAL, nullptr); + if (h != INVALID_HANDLE_VALUE) + { + CloseHandle(h); + } + } + } + + std::printf("Injecting coop_hook.dll into pid %lu ...\n", pid); + if (!inject(pid, dll_path_next_to_self())) + { + return 1; + } + std::printf("Injected. Reporting pad 0 connected; toggling button A each second for %d s.\n", seconds); + std::printf("Hook trace: %%TEMP%%\\coop_hook.log\n\n"); + + const coop::HookStatus& status = block->status; + for (int t = 0; t < seconds; ++t) + { + // Toggle A (0x1000) every other second so the game's input layer sees a real + // edge -- this is the "press a button" event the crash report points at. + const bool press = (t % 2) == 1; + pads[0].packet = static_cast(t + 2); + pads[0].buttons = press ? 0x1000 : 0x0000; + pads[0].thumb_lx = press ? 20000 : 0; + coop::publish_pads(*block, pads, coop::kMaxPads); + + Sleep(1000); + + const std::uint32_t heartbeat = status.heartbeat.load(std::memory_order_relaxed); + const std::uint32_t attached = status.attached; + const std::uint64_t gs0 = status.get_state_calls[0].load(std::memory_order_relaxed); + const std::uint64_t gc0 = status.get_caps_calls[0].load(std::memory_order_relaxed); + std::printf("[%2ds] %s hb=%u attached=%u getstate[0]=%llu getcaps[0]=%llu buttons=0x%04X\n", t + 1, + press ? "A-DOWN" : "A-up ", heartbeat, attached, static_cast(gs0), + static_cast(gc0), pads[0].buttons); + + // Surface the hook's log lines as they arrive (shows where it got to). + if (log_ring != nullptr) + { + coop::log_ring_drain(*log_ring, log_cursor, + [](const coop::LogRecord& rec) { std::printf(" | %s\n", rec.text); }); + } + } + + std::printf("\nInstalled hooks (%u):\n", status.hook_entry_count); + static const char* kSubsys[] = {"Input", "Focus", "Audio", "Video"}; + for (std::uint32_t i = 0; i < status.hook_entry_count && i < coop::kMaxHookEntries; ++i) + { + const coop::HookEntry& e = status.hook_entries[i]; + std::printf(" [%-5s] %-34s %s calls=%llu\n", e.subsystem < 4 ? kSubsys[e.subsystem] : "?", e.name, + e.installed ? "ON " : "off", static_cast(e.calls)); + } + + std::printf("\nDone. Leaving the hook loaded in the game.\n"); + block->magic = 0; + return 0; +}