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 <noreply@anthropic.com>
This commit is contained in:
@@ -43,6 +43,32 @@ if(COOP_X86_HELPER_BUILD)
|
|||||||
add_subdirectory(third_party/safetyhook)
|
add_subdirectory(third_party/safetyhook)
|
||||||
add_subdirectory(hook)
|
add_subdirectory(hook)
|
||||||
add_subdirectory(tools/inject_helper)
|
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()
|
return()
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
@@ -63,6 +89,7 @@ if(COOP_BUILD_HOOK)
|
|||||||
enable_testing()
|
enable_testing()
|
||||||
add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test
|
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/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)
|
add_subdirectory(tests)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
|||||||
276
README.md
276
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
|
input back into the real game. Steam's RPT captures the mirror window — so any
|
||||||
XInput game becomes Remote-Play-Together-able.
|
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
|
## Architecture
|
||||||
|
|
||||||
| Concern | Mechanism | Component | Status |
|
| 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` | done |
|
| 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` | done |
|
| 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` | done |
|
| Keep game running unfocused | Hook spoofs focus so the game polls while the host holds OS focus | `coop_hook.dll` |
|
||||||
| Mirror video | Windows Graphics Capture of the game window, letterboxed into the host window | `coop_host.exe` | done |
|
| Mirror video (default) | Windows Graphics Capture of the game window, letterboxed into the host window | `coop_host.exe` |
|
||||||
| 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 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` | 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` |
|
||||||
| Host ↔ hook IPC | Named shared memory (seqlock for input, status back-channel) | `common/` | done |
|
| 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
|
## 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
|
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
|
(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.
|
fixed-width / bitness-stable, so the x64 host and x86 hook interoperate.
|
||||||
- **Local audio echo (fixed via the hook; falls back otherwise):** when the
|
- **Local audio echo on the fallback path:** when the render-hook is active it
|
||||||
render-hook is active it silences the game's local playback while mirroring it,
|
silences the game's local playback while mirroring it, so there is no echo. If
|
||||||
so there is no echo. If the hook can't attach or the game uses an
|
the hook can't attach or the game uses an unhooked render path, the host falls
|
||||||
unhooked/exotic render path, the host automatically falls back to
|
back to process-loopback capture, which does *not* mute the game — so the local
|
||||||
process-loopback capture, which does *not* mute the game — so on the fallback
|
machine hears the audio twice (guests hear it once). The Audio panel shows which
|
||||||
path the local machine still hears the audio twice (guests hear it once). The
|
path is active.
|
||||||
Audio panel shows which path is active.
|
- **Debug-oriented UI:** the ImGui overlay is laid out for diagnosing the
|
||||||
- **Debug-oriented UI:** the ImGui overlay is always visible and laid out for
|
pipeline, not for end use. F1 hides it entirely so the window is a clean mirror
|
||||||
diagnosing the pipeline, not for end use. It can't yet be toggled off.
|
for RPT.
|
||||||
|
|
||||||
## 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).
|
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
Done:
|
### Planned (next up)
|
||||||
|
|
||||||
- **Usable overlay. ✅** F1 hides the whole overlay so the window is a clean
|
The current focus is making specific games work end-to-end. Each item is a
|
||||||
mirror for Remote Play Together; a fading hint shows the way back. The
|
milestone with its own tests and commit.
|
||||||
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_<pid>`, 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.
|
|
||||||
|
|
||||||
- **Hooked video path (Present + OpenGL). ✅** The injected DLL captures the
|
1. **Release the mouse cursor for cursor-clipping games.** Games that confine the
|
||||||
game's frames into a shared keyed-mutex texture (`coop_video_<pid>`) that the
|
cursor while focused (e.g. Trails through Daybreak via `ClipCursor` /
|
||||||
host opens by name and samples — a lower-latency, border-free alternative to WGC.
|
per-frame `SetCursorPos` re-centering) trap the operator's mouse permanently,
|
||||||
It's an opt-in subsystem (the **Video Present-hook** checkbox in the Injection
|
because the focus spoof makes the game believe it's always focused — so the
|
||||||
panel, or just pick **Source: Hooked (Present)** in the Video mirror panel, which
|
operator can't reach the ImGui overlay. Add a cursor-release capability to the
|
||||||
installs it). Two producers cover the common graphics APIs:
|
Focus subsystem: hook `ClipCursor` (force `ClipCursor(NULL)` and swallow the
|
||||||
- **Direct3D (DXGI):** hooks `IDXGISwapChain::Present` / `Present1` and copies
|
game's clip) and the re-centering `SetCursorPos`, gated by a new host→hook flag
|
||||||
the backbuffer. Catches D3D10/11/12 games whose backbuffer is an
|
driven by a host toggle + hotkey. Defaults to released (the guest plays via the
|
||||||
`ID3D11Texture2D` (the common D3D11 case). Validated by `present_hook_test`
|
pad, so the game's own cursor clip is operator-only), with the option to
|
||||||
and against Slaps and Beans (32-bit D3D11) and Life is Strange: Before the
|
re-enable clipping per game.
|
||||||
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.
|
|
||||||
|
|
||||||
**Vulkan** games (present via `vkQueuePresentKHR`) aren't hooked yet — that needs
|
2. **Real capture metrics + latency stats.** The current FPS readout only measures
|
||||||
a Vulkan layer / device-dispatch hook plus a `vkCmdCopyImage` to a readable
|
how fast the host renders its own window, which hides capture stutter. Add a
|
||||||
image, a larger effort. **Use WGC** (the default Video source) for Vulkan, D3D9,
|
three-line frametime/FPS graph — **game present rate** (from `VideoShare`
|
||||||
or anything the hooked path doesn't capture — WGC works for any window.
|
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
|
3. **DX12 hooked capture.** Marvel's Spider-Man is D3D12, so the Present hook fires
|
||||||
`ExternalProject`, driven from the normal x64 build) produces `coop_hook_x86.dll`
|
but `GetBuffer(0)` as `ID3D11Texture2D` fails (the backbuffer is an
|
||||||
and a 32-bit `coop_inject_x86.exe`, staged next to the x64 binaries. The host
|
`ID3D12Resource`) and the hook idles; WGC works but stutters. Add a D3D12 path
|
||||||
detects a WOW64 target with `IsWow64Process2` and spawns the helper to inject
|
via a **D3D11On12 bridge**: capture the game's D3D12 command queue (hook
|
||||||
the x86 DLL. Validated end-to-end against Slaps and Beans (a 32-bit D3D11 game):
|
`ID3D12CommandQueue::ExecuteCommandLists`), create an `ID3D11On12Device`,
|
||||||
all subsystems hooked, and the IPC channels (status, audio ring, video share,
|
`CreateWrappedResource` around the backbuffer, and `CopyResource` into the
|
||||||
log) all flow across the x64↔x86 boundary.
|
*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
|
4. **Multi-stream audio capture + mixing.** Games with several concurrent WASAPI
|
||||||
(auto-detected under `third_party/steamworks_sdk/`), a **Use Steam Input**
|
render streams (e.g. Spider-Man) only get their first ("primary") stream
|
||||||
checkbox in the Controllers panel switches the input backend to the Steam Input
|
mirrored today; the rest keep playing locally and never reach the guest. Capture
|
||||||
API (action-based) at runtime; it loads a bundled action manifest
|
every tracked render stream into its own shared ring, silence each, and add a
|
||||||
(`steam_input_actions.vdf`) via `SetInputActionManifestFilePath`, so it needs no
|
host-side mixer that resamples each ring to the render format and sums them
|
||||||
partner-backend config. **It's off by default and XInput is the primary path**:
|
(with soft-clip).
|
||||||
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.
|
|
||||||
|
|
||||||
All planned phases are now implemented. Possible later work: per-stream audio
|
### Future work
|
||||||
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`,
|
- **Vulkan video hook.** Vulkan games present via `vkQueuePresentKHR`; hooking
|
||||||
D3D9, pure-D3D12) — WGC already covers those today.
|
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
|
## 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
|
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`.
|
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
|
### clangd / IDE setup
|
||||||
|
|
||||||
The Visual Studio CMake generator does **not** emit `compile_commands.json`, so
|
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
|
- **`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
|
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
|
COM vtables were discovered, the frames reached the ring (non-silent), the
|
||||||
primary stream was silenced, and exactly one render stream was counted. Skips
|
primary stream was silenced, and the render stream was counted. Skips cleanly if
|
||||||
cleanly if the machine has no audio endpoint.
|
the machine has no audio endpoint.
|
||||||
- **`srgb_format_test`** — unit test of the `srgb_to_unorm` mapping the hooked
|
- **`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.
|
video path uses so `*_SRGB`-backbuffer games aren't darkened. No device.
|
||||||
- **`opengl_hook_test`** — in-process self-test of the OpenGL capture path:
|
- **`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
|
shipping process-loopback capture (the fallback path) receives its audio by
|
||||||
PID. Skips cleanly if the machine has no audio endpoint.
|
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 <pid> [seconds]`)
|
[`tools/audio_probe`](tools/audio_probe) (`coop_audio_probe.exe <pid> [seconds]`)
|
||||||
brings up the audio render-hook without Steam / RPT / the host UI: it creates the
|
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
|
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,
|
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
|
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
|
the hook's file trace (`%TEMP%\coop_hook.log`) for the run.
|
||||||
`bin/<config>/`. **Kill the game between runs** — the loaded DLL locks
|
|
||||||
`coop_hook.dll` against the next rebuild.
|
[`tools/input_probe`](tools/input_probe)
|
||||||
|
(`coop_input_probe.exe <pid> [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/<config>/`.
|
||||||
|
**Kill the game between runs** — the loaded DLL locks `coop_hook.dll` against the
|
||||||
|
next rebuild.
|
||||||
|
|
||||||
## Running the tool (manual, end-to-end)
|
## 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** —
|
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**
|
the host window now shows a live, letterboxed copy of the game. **Source**
|
||||||
picks how the frames are grabbed: **WGC** (default, Windows Graphics Capture —
|
picks how the frames are grabbed: **WGC** (default, Windows Graphics Capture —
|
||||||
works for any window) or **Hooked (Present)** (the injected Present-hook's
|
works for any window) or **Hooked (Present)** (the injected hook's shared
|
||||||
shared texture — lower latency and no capture border, but only for DXGI /
|
texture — lower latency and no capture border, for DXGI / D3D11 and OpenGL
|
||||||
D3D11 games; selecting it installs the video subsystem in the game).
|
games; selecting it installs the video subsystem in the game).
|
||||||
|
|
||||||
4. **Mirror audio:** in the **Audio mirror** panel, tick **Mirror game audio**.
|
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
|
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
|
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
|
(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
|
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
|
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
|
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
|
not 13 — `SetEventHandle` (13) sits between `Reset` and `GetService`. Count the
|
||||||
full interface (including every inherited `IUnknown`/base method) when adding a
|
full interface (including every inherited `IUnknown`/base method) when adding a
|
||||||
new COM hook.
|
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.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
#include <atomic>
|
#include <atomic>
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
||||||
namespace coop
|
namespace coop
|
||||||
@@ -184,6 +185,15 @@ static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
|
|||||||
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
|
static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
|
||||||
"status counters need a lock-free 64-bit atomic for cross-process use");
|
"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 -------------------------------------------------------
|
// --- Seqlock helpers -------------------------------------------------------
|
||||||
|
|
||||||
// Writer side: publish a fresh set of pad states. Called from the host.
|
// Writer side: publish a fresh set of pad states. Called from the host.
|
||||||
|
|||||||
@@ -151,7 +151,10 @@ void try_register_lazy(IAudioRenderClient* rc);
|
|||||||
HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data)
|
HRESULT STDMETHODCALLTYPE hk_GetBuffer(IAudioRenderClient* self, UINT32 num_frames, BYTE** data)
|
||||||
{
|
{
|
||||||
hook_note_call(g_id_getbuffer);
|
hook_note_call(g_id_getbuffer);
|
||||||
const HRESULT hr = g_hk_getbuffer.call<HRESULT>(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<HRESULT>(self, num_frames, data);
|
||||||
if (SUCCEEDED(hr) && data != nullptr)
|
if (SUCCEEDED(hr) && data != nullptr)
|
||||||
{
|
{
|
||||||
t_gb_client = self;
|
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
|
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);
|
g_frames_captured.fetch_add(num_frames, std::memory_order_relaxed);
|
||||||
return g_hk_releasebuffer.call<HRESULT>(self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
|
return g_hk_releasebuffer.stdcall<HRESULT>(self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return g_hk_releasebuffer.call<HRESULT>(self, num_frames, flags);
|
return g_hk_releasebuffer.stdcall<HRESULT>(self, num_frames, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registers a newly created render client: assigns it a debug slot, marks the
|
// 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);
|
hook_note_call(g_id_initialize);
|
||||||
const HRESULT hr =
|
const HRESULT hr =
|
||||||
g_hk_initialize.call<HRESULT>(self, mode, flags, buffer_duration, periodicity, format, session);
|
g_hk_initialize.stdcall<HRESULT>(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,
|
logf("hk_Initialize: client=%p mode=%d flags=0x%lX hr=0x%08lX fmt=%s", self, mode,
|
||||||
static_cast<unsigned long>(flags), static_cast<unsigned long>(hr), format ? "yes" : "null");
|
static_cast<unsigned long>(flags), static_cast<unsigned long>(hr), format ? "yes" : "null");
|
||||||
if (SUCCEEDED(hr) && format != nullptr)
|
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)
|
HRESULT STDMETHODCALLTYPE hk_GetService(IAudioClient* self, REFIID riid, void** ppv)
|
||||||
{
|
{
|
||||||
hook_note_call(g_id_getservice);
|
hook_note_call(g_id_getservice);
|
||||||
const HRESULT hr = g_hk_getservice.call<HRESULT>(self, riid, ppv);
|
const HRESULT hr = g_hk_getservice.stdcall<HRESULT>(self, riid, ppv);
|
||||||
const bool is_render = (riid == __uuidof(IAudioRenderClient));
|
const bool is_render = (riid == __uuidof(IAudioRenderClient));
|
||||||
logf("hk_GetService: client=%p hr=0x%08lX render_client=%d", self, static_cast<unsigned long>(hr),
|
logf("hk_GetService: client=%p hr=0x%08lX render_client=%d", self, static_cast<unsigned long>(hr),
|
||||||
is_render ? 1 : 0);
|
is_render ? 1 : 0);
|
||||||
@@ -384,7 +387,7 @@ HRESULT STDMETHODCALLTYPE hk_Activate(IMMDevice* self, REFIID riid, DWORD cls_ct
|
|||||||
void** ppv)
|
void** ppv)
|
||||||
{
|
{
|
||||||
hook_note_call(g_id_activate);
|
hook_note_call(g_id_activate);
|
||||||
const HRESULT hr = g_hk_activate.call<HRESULT>(self, riid, cls_ctx, params, ppv);
|
const HRESULT hr = g_hk_activate.stdcall<HRESULT>(self, riid, cls_ctx, params, ppv);
|
||||||
const bool is_audioclient = (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) ||
|
const bool is_audioclient = (riid == __uuidof(IAudioClient) || riid == __uuidof(IAudioClient2) ||
|
||||||
riid == __uuidof(IAudioClient3));
|
riid == __uuidof(IAudioClient3));
|
||||||
logf("hk_Activate: device=%p hr=0x%08lX audioclient=%d", self, static_cast<unsigned long>(hr),
|
logf("hk_Activate: device=%p hr=0x%08lX audioclient=%d", self, static_cast<unsigned long>(hr),
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ BOOL WINAPI hk_SwapBuffers(HDC hdc)
|
|||||||
t_in_swap = true;
|
t_in_swap = true;
|
||||||
capture_gl(hdc);
|
capture_gl(hdc);
|
||||||
}
|
}
|
||||||
const BOOL r = g_hk_swapbuffers.call<BOOL>(hdc);
|
const BOOL r = g_hk_swapbuffers.stdcall<BOOL>(hdc); // __stdcall: call() is __cdecl on x86 -> crash
|
||||||
if (outer)
|
if (outer)
|
||||||
{
|
{
|
||||||
t_in_swap = false;
|
t_in_swap = false;
|
||||||
@@ -260,7 +260,7 @@ BOOL WINAPI hk_wglSwapBuffers(HDC hdc)
|
|||||||
t_in_swap = true;
|
t_in_swap = true;
|
||||||
capture_gl(hdc);
|
capture_gl(hdc);
|
||||||
}
|
}
|
||||||
const BOOL r = g_hk_wglswap.call<BOOL>(hdc);
|
const BOOL r = g_hk_wglswap.stdcall<BOOL>(hdc); // __stdcall: call() is __cdecl on x86 -> crash
|
||||||
if (outer)
|
if (outer)
|
||||||
{
|
{
|
||||||
t_in_swap = false;
|
t_in_swap = false;
|
||||||
|
|||||||
@@ -232,7 +232,12 @@ HRESULT STDMETHODCALLTYPE hk_Present(IDXGISwapChain* sc, UINT sync_interval, UIN
|
|||||||
{
|
{
|
||||||
capture_backbuffer(sc);
|
capture_backbuffer(sc);
|
||||||
}
|
}
|
||||||
return g_hk_present.call<HRESULT>(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<HRESULT>(sc, sync_interval, flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
HRESULT STDMETHODCALLTYPE hk_Present1(IDXGISwapChain1* sc, UINT sync_interval, UINT 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
|
capture_backbuffer(sc); // IDXGISwapChain1 derives from IDXGISwapChain
|
||||||
}
|
}
|
||||||
return g_hk_present1.call<HRESULT>(sc, sync_interval, flags, params);
|
return g_hk_present1.stdcall<HRESULT>(sc, sync_interval, flags, params); // __stdcall, see hk_Present
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a throwaway device + swapchain purely to read IDXGISwapChain::Present
|
// Create a throwaway device + swapchain purely to read IDXGISwapChain::Present
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// SafetyHook XInput interception. No injection or physical controller needed --
|
// SafetyHook XInput interception. No injection or physical controller needed --
|
||||||
// this process plays both host and game. Exits 0 on pass, 1 on failure.
|
// this process plays both host and game. Exits 0 on pass, 1 on failure.
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
@@ -33,8 +34,75 @@ void check(bool ok, const char* what)
|
|||||||
|
|
||||||
} // namespace
|
} // 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<GetState_t>(GetProcAddress(m, "XInputGetState"));
|
||||||
|
auto get_state_ex = reinterpret_cast<GetState_t>(GetProcAddress(m, MAKEINTRESOURCEA(100)));
|
||||||
|
auto get_caps = reinterpret_cast<GetCaps_t>(GetProcAddress(m, "XInputGetCapabilities"));
|
||||||
|
auto set_state = reinterpret_cast<SetState_t>(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()
|
int main()
|
||||||
{
|
{
|
||||||
|
dump_layout();
|
||||||
|
|
||||||
// --- Host side: create the section (named by our pid) and publish a pad. ---
|
// --- Host side: create the section (named by our pid) and publish a pad. ---
|
||||||
SharedMemory shm;
|
SharedMemory shm;
|
||||||
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
|
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
|
||||||
@@ -56,6 +124,17 @@ int main()
|
|||||||
pads[0].thumb_ry = -4321;
|
pads[0].thumb_ry = -4321;
|
||||||
publish_pads(*block, pads, kMaxPads);
|
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 side: connect and install over this process's own xinput. ---
|
||||||
hook::IpcClient ipc;
|
hook::IpcClient ipc;
|
||||||
check(ipc.connect(10, 5), "IPC client connect");
|
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(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");
|
||||||
|
|
||||||
|
// 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.
|
// 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.attached == 1, "status reports attached");
|
||||||
check(block->status.get_state_calls[0].load(std::memory_order_relaxed) >= 1, "status counts slot 0 GetState");
|
check(block->status.get_state_calls[0].load(std::memory_order_relaxed) >= 1, "status counts slot 0 GetState");
|
||||||
|
|||||||
7
tools/input_probe/CMakeLists.txt
Normal file
7
tools/input_probe/CMakeLists.txt
Normal file
@@ -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")
|
||||||
257
tools/input_probe/main.cpp
Normal file
257
tools/input_probe/main.cpp
Normal file
@@ -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 <pid> [seconds]
|
||||||
|
//
|
||||||
|
// Run from the same directory as coop_hook.dll (i.e. bin/<config>/). For a 32-bit
|
||||||
|
// (WOW64) target it shells out to coop_inject_x86.exe + coop_hook_x86.dll, exactly
|
||||||
|
// like the host.
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#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<LPTHREAD_START_ROUTINE>(
|
||||||
|
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 <pid> [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<coop::SharedBlock>();
|
||||||
|
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::LogRing>();
|
||||||
|
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<std::uint32_t>(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<unsigned long long>(gs0),
|
||||||
|
static_cast<unsigned long long>(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<unsigned long long>(e.calls));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::printf("\nDone. Leaving the hook loaded in the game.\n");
|
||||||
|
block->magic = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user