Add render_gl.cpp (selectable as `gl`): renders the animated pattern with scissored clears (glClear + glScissor -- GL 1.1, exported straight from opengl32) and presents with SwapBuffers. No glad/submodule needed: a legacy wglCreateContext + <GL/gl.h> suffices, so the planned loader dependency was dropped. GL is bottom-left origin and the capture flips top-down, so the frame-counter block is drawn at the GL top to land top-left in the captured image. Window class gains CS_OWNDC for a stable GL DC; best-effort vsync via a runtime wglSwapIntervalEXT lookup. The GL SwapBuffers/wglSwapBuffers hook now bumps the shared present counter (it's the GL present), so present_calls works for GL games too. mock_game_test decodes GL frames through the existing glReadPixels capture path; 15/15 ctest. Roadmap: OpenGL milestone done and removed (Vulkan renumbered to M2); architecture/lessons/test docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
542 lines
38 KiB
Markdown
542 lines
38 KiB
Markdown
# CoopAllTheThings
|
|
|
|
Steam **Remote Play Together (RPT)** for any XInput game — without breaking DRM,
|
|
achievements, or playtime.
|
|
|
|
Existing "donor game" tools (e.g. RemotePlayWhatever) copy a target game's files
|
|
into a donor game's folder and rename the executable so Steam streams the target
|
|
under the donor's appid. That breaks DRM-protected games, breaks achievements,
|
|
and credits playtime to the donor.
|
|
|
|
CoopAllTheThings takes a different approach: the **real game runs normally under
|
|
its own appid** (so DRM, achievements, and playtime all work), while a lightweight
|
|
**mirror app runs under the donor appid**. The mirror presents a borderless
|
|
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 |
|
|
| --- | --- | --- |
|
|
| 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` |
|
|
| Forward mouse + keyboard | Opt-in MKB subsystem: host streams its window's clicks/keys, the hook posts the matching window messages and synthesizes `GetAsyncKeyState`/`GetKeyboardState`/`GetCursorPos` for polling games | `coop_hook.dll` + `coop_host.exe` |
|
|
| 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 each of the game's WASAPI render streams into its own shared ring and silences the game locally (no echo); the host mixes the streams (soft-clipped); 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 three producers: **Direct3D (DXGI)** hooks
|
|
`IDXGISwapChain::Present` / `Present1` and copies the backbuffer — directly for
|
|
D3D11 games (the backbuffer is an `ID3D11Texture2D`), via a **D3D11On12
|
|
bridge** for D3D12 games (wrap the `ID3D12Resource` backbuffer, `CopyResource` into
|
|
the shared texture), and via a **D3D10 read-back** for D3D10 games (their backbuffer's
|
|
D3D11 view is empty and a feature-level-10 device can't host the shared texture, so
|
|
read it through the game's own D3D10 device and upload it via a hook-owned D3D11
|
|
device); **Direct3D 9** inline-hooks `IDirect3DDevice9::Present` (vtable index 17) and
|
|
read-backs the backbuffer with `GetRenderTargetData` (a D3D9 surface isn't D3D11-shareable),
|
|
swizzling BGRA→RGBA and uploading via a hook-owned D3D11 device — one path covers both plain
|
|
D3D9 and D3D9Ex; **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 — see Roadmap).
|
|
|
|
## Limitations
|
|
|
|
- **Anti-cheat:** the input path injects `coop_hook.dll` into the target game.
|
|
Games protected by kernel-level anti-cheat (Easy Anti-Cheat, BattlEye,
|
|
Vanguard, etc.) will detect the injected module and may **kick the player or
|
|
issue a ban**. Such games are explicitly **out of scope and unsupported** — do
|
|
not use CoopAllTheThings with them. The tool targets single-player and
|
|
co-op/local-multiplayer titles without active anti-cheat.
|
|
- **XInput only:** the game must read controllers via XInput (the common case).
|
|
DirectInput-only / RawInput-only games are not handled.
|
|
- **32-bit games supported via a helper:** the host is x64, but the build also
|
|
produces an x86 hook DLL (`coop_hook_x86.dll`) and a 32-bit injector helper
|
|
(`coop_inject_x86.exe`). When the target is a 32-bit (WOW64) process the host
|
|
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 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.
|
|
- **Hooked audio can only recover a pre-existing stream's *sample rate*, not its
|
|
channels/bit-depth.** The tool injects into an already-running game, so the audio
|
|
render-hook usually never saw the game's `IAudioClient::Initialize`. It recovers the
|
|
true **sample rate** by measuring the render cadence (so playback pitch is correct,
|
|
e.g. Godot/Brotato's 44100 Hz on a 48000 Hz endpoint), but **channels and bit-depth
|
|
can't be detected** — with `AUTOCONVERTPCM` `GetBuffer` returns a fixed staging
|
|
buffer (no buffer stride to measure) and WASAPI exposes no API for a pre-existing
|
|
client's format — so they're *assumed* to match the device mix format. That's correct
|
|
for the common case (engines render stereo float, matching the endpoint, differing
|
|
only in rate). A game rendering a *different* channel count or bit depth than the
|
|
device is mirrored with the wrong layout (garbled audio) on the hooked path, but never
|
|
an over-read/crash: a guessed stream is **captured but not silenced** (so it stays
|
|
audible locally — an echo), because zeroing it could over-*write* past the real buffer
|
|
(zeroing 8-channel-worth into a 2-channel buffer corrupts adjacent audio memory). Only
|
|
an **exact / override** format gets the no-echo silence (its frame size is known), so
|
|
the no-echo experience comes from an early (auto-attach) exact format or an operator
|
|
override. The loopback fallback is always format-correct. The Audio panel shows each
|
|
stream's
|
|
format provenance (*known* / *measuring* / *measured rate* / *low-confidence* /
|
|
*override*) so the assumption is visible, and (under Debug details) lets the operator
|
|
**re-measure** the rate or **override** the format when the guess is wrong. Overrides
|
|
are **remembered per game** (and a format caught exactly at `Initialize` is auto-saved
|
|
as that game's override), so a known-bad game is corrected automatically next launch.
|
|
Streams created *after* injection are captured exactly.
|
|
- **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; F2 frees the operator cursor; **F10 saves a PNG screenshot** (back buffer,
|
|
written next to the exe) regardless of window focus or occlusion.
|
|
|
|
## Roadmap
|
|
|
|
### Current work — per-API capture, end to end
|
|
|
|
Each rendering API is built as **one milestone**: first its `coop_mock_game` backend (an
|
|
animated, frame-numbered A/V source), then the injected **capture** for that same API
|
|
directly after — so every API reaches *verified end-to-end* (the mirror decodes the frame
|
|
counter and asserts a *monotonic, advancing* sequence) before the next one starts, rather
|
|
than building all the mock backends first and all the capture later. Order is
|
|
easiest-to-hardest, dependencies last.
|
|
|
|
Conventions for every milestone below:
|
|
|
|
- Each mock backend implements the existing
|
|
[`RenderBackend`](tools/mock_game/render_backend.hpp) interface (animated background +
|
|
moving bar + frame-counter block), is selectable on the command line, and renders
|
|
**clear/fill-only** — no geometry, no shaders, so **no shader-compiler dependency**.
|
|
- Sub-steps are **separate commits**; the README, CMake, and (where needed) `.gitmodules`
|
|
move with each. A mock-backend commit lands with a *liveness* smoke check (launches,
|
|
presents N frames, exits clean); the frame-accurate decode-through-the-hook assertion
|
|
lands with that API's capture commit right after.
|
|
- The host samples the **standard shared keyed-mutex texture** unchanged regardless of
|
|
source API: every backend publishes into it — native D3D11 on the game's device, D3D12 via
|
|
D3D11On12, and D3D9 / D3D10 / OpenGL / Vulkan via a **hook-owned D3D11 device**.
|
|
- New submodules are **not** auto-cloned — CMake checks each is populated and stops with a
|
|
`FATAL_ERROR` naming `git submodule update --init --recursive` (a reusable
|
|
`coop_require_submodule()` helper).
|
|
|
|
- **M1 — End-user UI fit pass.** Verify the shipping ImGui overlay (the *end-user* view, not
|
|
just the dev layout) with the debug-driving harness + F10 screenshot, and guarantee **every
|
|
panel fits its assigned window size even with Debug details enabled** — the
|
|
maximum-information case. Drive each panel to its fullest state via the harness (inject,
|
|
enable audio + video, expand Debug details, show the override controls, multiple audio
|
|
streams, the longest status / fallback strings), screenshot, and check nothing is clipped
|
|
or scrolled out of view. Where the densest case overflows, **resize and/or rearrange** the
|
|
panel so it fits (content can be moved between columns / rows — the most detailed case
|
|
should still fit). Land an automated harness check (drive-to-max → screenshot → assert no
|
|
overflow) so later milestones that add UI keep it green. *Independent of the backend work;
|
|
every milestone below must preserve this test* (M2's red banner and Vulkan-layer checkbox
|
|
in particular).
|
|
|
|
- **M2 — Vulkan (mock → capture).** The largest.
|
|
1. **Mock backend** (`render_vk.cpp`). New submodules **Vulkan-Headers**
|
|
(`KhronosGroup/Vulkan-Headers`, official) + **volk** (`zeux/volk`); raw
|
|
`vkCreateWin32SurfaceKHR`, swap chain, per-frame acquire → clear → present. Background via
|
|
`vkCmdClearColorImage`, bar + block via `vkCmdClearAttachments` clear-rects (no
|
|
pipeline/shaders → **no SPIR-V toolchain**). Loads Vulkan via **volk** on purpose — that's
|
|
the loader-bypass case the capture hook must handle. Validation layers used when a Vulkan
|
|
SDK is present, skipped otherwise.
|
|
2. **Capture.** Hook `vkQueuePresentKHR`; import a D3D11 keyed-mutex shared texture into Vulkan
|
|
(`VK_KHR_external_memory_win32` + `VK_KHR_win32_keyed_mutex`) and `vkCmdBlitImage` the
|
|
swap-chain image into it each present; the host samples it as usual. Because Vulkan caches
|
|
its present pointer at init, the hook **cannot be placed by late injection** — it must be
|
|
present before `vkCreateInstance`:
|
|
- **Attach via auto-attach — best-effort by default, opt-in layer for reliability.** By
|
|
default, arming Auto-attach for a detected Vulkan game injects as early as possible on
|
|
relaunch (the existing poll-and-inject) — enough for games that initialize Vulkan a little
|
|
into startup. For games that init Vulkan immediately, an **opt-in** Injection-panel
|
|
checkbox ("Set up Vulkan layer") registers a per-user (HKCU, no admin) implicit Vulkan
|
|
layer (thin `coop_vk_layer.dll` + JSON manifest) scoped to that game's image name; on
|
|
relaunch the loader loads it at `vkCreateInstance` — guaranteed before init — and it wires
|
|
up present capture + IPC. The layer is removed when the option is unticked or the host
|
|
exits, and self-deactivates for any non-target app, so a stale registration (e.g. after a
|
|
host crash) is harmless.
|
|
- **Too-late detection + red prompt.** A late-injected hook that finds `vulkan-1.dll` loaded
|
|
but no working hooked-present reports a *"Vulkan, injected too late"* status; the host
|
|
overlays a **red** banner on the mirror window: "Detected a Vulkan game — the hook attached
|
|
too late to mirror it with low latency. Enable Auto-attach (and, if it persists, 'Set up
|
|
Vulkan layer') and relaunch the game." **WGC keeps mirroring meanwhile** so the session
|
|
stays usable; the banner clears once the hooked path goes live. (New end-user UI → keep
|
|
M1's fit test green.)
|
|
- Sub-steps (separate commits): present-hook placement (best-effort inject + the implicit
|
|
layer), external-memory image import + keyed-mutex sync, the too-late status code, the
|
|
host red-banner UI, and the Auto-attach / Vulkan-layer Injection-panel controls. Test the
|
|
early (layer) path against the mock (decode frames) and the too-late path (assert the
|
|
status + banner fire).
|
|
|
|
### Future work
|
|
|
|
- **Mouse + keyboard forwarding for Raw Input / DirectInput games.** The MKB
|
|
subsystem forwards via window messages (`PostMessage`) plus synthesized
|
|
`GetAsyncKeyState` / `GetKeyboardState` / `GetCursorPos`, which covers message-loop
|
|
and polling games. Games that read keyboard/mouse via **Raw Input** (`WM_INPUT` /
|
|
`GetRawInputData`, e.g. Trails through Daybreak) or **DirectInput**
|
|
(`IDirectInputDevice8::GetDeviceState/GetDeviceData`) don't see it. Add hooks for
|
|
those paths to synthesize the forwarded input there too.
|
|
|
|
## Building
|
|
|
|
Requirements: Windows 10/11, Visual Studio 2022 (MSVC + C++ workload), CMake ≥ 3.21.
|
|
|
|
```sh
|
|
git clone --recurse-submodules <repo-url>
|
|
# or, if already cloned:
|
|
git submodule update --init --recursive
|
|
|
|
cmake -S . -B build -G "Visual Studio 17 2022" -A x64
|
|
cmake --build build --config Debug
|
|
# output: bin/Debug/coop_host.exe (+ coop_hook.dll, test exes)
|
|
```
|
|
|
|
The x64 build also drives a nested Win32 sub-build (CMake `ExternalProject`,
|
|
configured into `build/x86/`) that produces `coop_hook_x86.dll` and
|
|
`coop_inject_x86.exe` for 32-bit games, staged next to the x64 binaries. Disable
|
|
it with `-DCOOP_BUILD_X86_HELPER=OFF` if you don't need 32-bit support.
|
|
|
|
Third-party dependencies (Dear ImGui, SafetyHook) are git submodules under
|
|
`third_party/`. No vcpkg / package manager is used.
|
|
|
|
**Steam Input is optional.** It's enabled automatically when the Steamworks SDK is
|
|
vendored at `third_party/steamworks_sdk/` (extract the `steamworks_sdk_*.zip`
|
|
there). The SDK isn't redistributable, so it's gitignored and never committed; if
|
|
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
|
|
clangd has no include paths and reports false errors. Run
|
|
[`gen-compile-commands.bat`](gen-compile-commands.bat) once (and after adding
|
|
sources or include dirs); it configures a parallel Ninja build in `build-clangd/`
|
|
that produces the database, which [`.clangd`](.clangd) points clangd at. clangd's
|
|
clang-cl driver resolves the MSVC / Windows SDK system includes on its own.
|
|
|
|
## Tests
|
|
|
|
```sh
|
|
ctest --test-dir build -C Debug --output-on-failure
|
|
```
|
|
|
|
- **`hook_selftest`** — in-process check of the IPC + XInput hook core (no game,
|
|
no controller needed).
|
|
- **`audio_ring_test`** — unit test of the shared audio ring (lock-free SPSC
|
|
push/pop, wrap-around, format handshake, overrun/drop). No device needed.
|
|
- **`audio_mix_test`** — unit test of the multi-stream mixer math (decode / sum /
|
|
soft-clip / encode for float32 + int16). No device needed.
|
|
- **`rate_estimator_test`** — unit test of the robust sample-rate estimator (the fix for
|
|
the wrong-rate bug). Feeds synthetic, adversarial render cadences and asserts it
|
|
converges to the right standard rate, rejects burst windows (never commits to a wrong
|
|
neighbour, incl. the real 46205 misread), flags a genuinely non-standard rate
|
|
low-confidence instead of spinning, and ignores idle windows. Pure logic, no device.
|
|
- **`audio_overrides_test`** — unit test of the per-game audio override store
|
|
(persist/reload, case-insensitive lookup by image name, and the differing-overwrite
|
|
detection that drives the warning). No device.
|
|
- **`audio_hook_test`** — in-process self-test of the WASAPI render-hook's **format
|
|
detection**, the part that gets pitch right. Using a shared configurable
|
|
`ToneSource` (the same render helper `coop_tone` uses), it renders tones at a matrix
|
|
of common formats (44100/48000/96000 Hz, mono/stereo/5.1, 16-bit PCM / 32-bit float)
|
|
and asserts the hook reports the right rate/channels/bits + provenance for **both**
|
|
code paths: **see-init** (hooks installed first → exact `Initialize` format) and
|
|
**guess** (render client pre-exists → device-mix guess whose true rate is measured
|
|
from the cadence, the Brotato/Godot case). Also checks the frames reached the ring
|
|
non-silent. Skips cleanly with 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:
|
|
installs the swap hooks, drives a real OpenGL context (clears the backbuffer to a
|
|
known color, calls `SwapBuffers`), and asserts the detour fired, the frame was
|
|
`glReadPixels`'d into the shared texture, and a second device reads the exact
|
|
pixels back by name. Skips cleanly without an OpenGL / D3D11 device.
|
|
- **`dx12_present_hook_test`** — in-process self-test of the Present hook's **D3D12**
|
|
path: drives a real D3D12 swapchain through the (shared) `IDXGISwapChain::Present`
|
|
vtable and asserts the D3D11On12 bridge wraps the `ID3D12Resource` backbuffer and
|
|
copies it into the shared texture, then reads the exact rendered color back by
|
|
name. Skips cleanly without a D3D12 device.
|
|
- **`present_hook_test`** — in-process self-test of the Present-hook video path:
|
|
installs the hook, drives a real D3D11 swapchain in the same process (clears the
|
|
backbuffer to a known color and calls `Present`), and asserts the detour fired,
|
|
the backbuffer reached the shared keyed-mutex texture, and a second device can
|
|
open it by name and read the exact pixels back. Skips cleanly if the machine has
|
|
no D3D11 device.
|
|
- **`audio_loopback_test`** — spawns `coop_tone.exe` (a standalone configurable WASAPI
|
|
sine-wave source under [`tools/audio_tone`](tools/audio_tone)) at several source
|
|
formats (device default, 44100/48000/96000 Hz) and verifies the shipping
|
|
process-loopback capture (the fallback backend) receives non-silent audio by PID for
|
|
each — confirming loopback is format-agnostic (it captures post-mix at the device
|
|
endpoint format). Skips cleanly if the machine has no audio endpoint.
|
|
- **`mock_game_test`** — comprehensive capture/audio/hook stress test against
|
|
**`coop_mock_game`** (an animated, frame-numbered A/V test game under
|
|
[`tools/mock_game`](tools/mock_game) with selectable **DX9 / DX9Ex / DX10 / DX11 / DX12 / OpenGL** backends and a
|
|
configurable WASAPI tone). It launches the game, injects `coop_hook.dll`, opens the
|
|
hook's shared video texture, and **decodes the frame number out of the captured pixels**
|
|
to assert the mirror sees a *monotonic, advancing* sequence for each backend (the bar
|
|
for no dropped / stale / out-of-order frames — what the DX12 rotating-backbuffer bug
|
|
broke). It launches the game at several **audio formats** (44100/48000/96000, PCM +
|
|
float) and asserts the hook measures each one's rate through the full inject path, then
|
|
injects with audio + video, checks both stream, cycles the audio subsystem off/on
|
|
(hook/unhook stress), and confirms the game never crashes and capture resumes. This
|
|
suite drove out five real audio races (see Lessons learned). Skips cleanly without a
|
|
D3D11 device.
|
|
|
|
### Debugging the hooks against a real game
|
|
|
|
[`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
|
|
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.
|
|
|
|
[`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. The probes build into
|
|
`bin/<config>/tools/` (the deployable `bin/<config>/` root holds only shipping
|
|
artifacts; tests build into `bin/<config>/tests/`) and resolve `coop_hook.dll` from
|
|
the root one level up, so run them from there. **Kill the game between runs** — the
|
|
loaded DLL locks `coop_hook.dll` against the next rebuild.
|
|
|
|
A debug-only **test harness** drives the host overlay's own code paths (inject / enable
|
|
audio / re-measure / override / screenshot / read state) without simulating mouse input,
|
|
for scripted UI validation. Build it with `-DCOOP_TEST_HARNESS=ON` (off by default, so
|
|
the shipped host never contains it); the host then reads one command line from
|
|
`%TEMP%\coop_test_cmd.txt` and replies in `%TEMP%\coop_test_resp.txt`. See
|
|
[`host/src/test_harness.hpp`](host/src/test_harness.hpp).
|
|
|
|
## Running the tool (manual, end-to-end)
|
|
|
|
This needs Steam, a donor game that supports Remote Play Together, and a second
|
|
person/account to receive the stream.
|
|
|
|
1. **Launch the host under a donor appid.** Find the donor's appid (the number in
|
|
its store URL); the donor only needs RPT support and is never actually played:
|
|
|
|
```text
|
|
"C:\Program Files (x86)\Steam\steam.exe" -applaunch <donorAppId> "D:\dev\CoopAllTheThings\bin\Debug\coop_host.exe"
|
|
```
|
|
|
|
The borderless window appears and Steam marks the donor "running". If the donor
|
|
ignores the trailing path, set the host as the donor's **Launch Options**
|
|
(`"D:\...\coop_host.exe" %command%`) or use a launcher like RemotePlayDetached.
|
|
|
|
2. **Start the real game** windowed or borderless (not exclusive fullscreen — see
|
|
Lessons learned). In the host's **Injection** panel, filter for the game's
|
|
`.exe`, select it, and click **Inject & Connect**. Watch **Hook status** for
|
|
**Attached**, a non-zero **XInput polled: N/s**, and **Focus spoof: active**.
|
|
|
|
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 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.
|
|
|
|
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
|
|
real game.
|
|
|
|
Useful checks while developing without RPT: tick **Forward synthetic test input**
|
|
in the Injection panel to make the game move on its own (proving forwarding is the
|
|
source), and click away from the game to confirm focus spoofing keeps it running.
|
|
|
|
> Injection access error → run the host as administrator. A 32-bit (WOW64) target
|
|
> is injected automatically via `coop_inject_x86.exe` + `coop_hook_x86.dll`; if
|
|
> those aren't next to the host, rebuild (the x86 sub-build stages them there).
|
|
|
|
## Lessons learned
|
|
|
|
Non-obvious things that cost time and constrain the design:
|
|
|
|
- **RPT only streams the *focused* window.** The game can't hold focus itself, so
|
|
the hook spoofs it (`GetForegroundWindow` / `GetActiveWindow` / `GetFocus` +
|
|
swallowing deactivation messages) to keep the game polling and rendering while
|
|
the host owns real OS focus.
|
|
- **Run target games windowed or borderless, never exclusive fullscreen** —
|
|
exclusive fullscreen minimizes on focus loss (defeating the spoof) and can't be
|
|
window-captured. While unfocused the game gets no OS keyboard/mouse, only the
|
|
forwarded pad.
|
|
- **WGC captures occluded windows but not minimized ones.**
|
|
- **Process-loopback capture doesn't mute the source.** Capturing a process's
|
|
render doesn't stop it reaching the speakers, so the no-echo path instead injects
|
|
a WASAPI render-hook that copies each buffer then releases it with
|
|
`AUDCLNT_BUFFERFLAGS_SILENT`; loopback stays as the (echoing) fallback.
|
|
- **`ActivateAudioInterfaceAsync` needs an *agile* completion handler.** If the
|
|
handler doesn't answer `QueryInterface` for `IAgileObject`, the call is rejected
|
|
**synchronously** with `E_ILLEGAL_METHOD_CALL` (`0x8000000E`) — regardless of
|
|
apartment, device path, or activation params. (WRL/wil samples make the handler
|
|
agile for you.) Process loopback also needs the Win10 20H1 headers
|
|
(`NTDDI_VERSION ≥ 0x0A00000B`).
|
|
- **COM methods have no exports, so hooks walk vtables by frozen-ABI index — count
|
|
exactly.** All instances of a coclass share one vtable, so hooking one object's
|
|
slot catches every instance; but `IAudioClient::GetService` is **14**, not 13
|
|
(`SetEventHandle` sits at 13 between `Reset` and `GetService`). Count every
|
|
inherited `IUnknown`/base method when adding a hook.
|
|
- **D3D12 capture copies the *rotating* back buffer, not `GetBuffer(0)`.** D3D11
|
|
flip-model keeps `GetBuffer(0)` pointing at the live back buffer, but D3D12 rotates
|
|
buffers explicitly — the game renders into the buffer at
|
|
`IDXGISwapChain3::GetCurrentBackBufferIndex()`, which advances each `Present`.
|
|
Grabbing buffer 0 copies a stale buffer on N-1 of every N frames, so the mirror
|
|
silently runs at refresh/N — yet the Present counter, published FPS, generation, and
|
|
latency all read full rate (they count Presents, not unique content), so the metrics
|
|
look perfect while the eye sees missing frames. Query the current index right before
|
|
the trampoline `Present` (that's the just-rendered buffer) and copy that one.
|
|
- **Keep the D3D12 capture copy off the game's present queue, but ordered after its
|
|
frame.** The D3D11 path copies on the game's immediate context, so it's naturally
|
|
ordered after the frame and on the game's own timeline. For D3D12 the D3D11On12
|
|
bridge needs a queue: running the copy on the *game's* present queue orders it
|
|
correctly but stalls the game's own presents (GPU back-pressure, plus the shared
|
|
keyed-mutex `AcquireSync` is a **CPU-blocking** call on the render thread). Running
|
|
it on an independent queue avoids the stall but races the game's render → stale
|
|
frames. The fix is both: run the copy on **our own** queue, and order it with a
|
|
**fence** the game's present queue signals after its frame (a near-free op) and our
|
|
queue waits on. The present queue is recovered for late injection by hooking
|
|
`ID3D12CommandQueue::ExecuteCommandLists` (the per-frame method, not creation). Make
|
|
the producer-side `AcquireSync` non-blocking (`timeout 0`) so a busy mutex drops a
|
|
*mirror* frame instead of stalling the game; the Video panel's "Frames lost" line
|
|
surfaces both capture- and display-stage drops.
|
|
- **A D3D10 game's backbuffer lies about being D3D11.** A pure-D3D10 swapchain's backbuffer
|
|
QIs to `ID3D11Texture2D` *successfully*, but that D3D11 view reads back **empty** — the
|
|
rendered content only exists on the game's own D3D10 device. (And a D3D11 backbuffer QIs to
|
|
`ID3D10Texture2D` too, so `GetBuffer` alone can't tell them apart.) The reliable signal is
|
|
that a feature-level-10 device **rejects** `CreateTexture2D` with the NT-handle keyed-mutex
|
|
share flags (`E_INVALIDARG`): so try the D3D11 fast path, and on that failure switch (sticky)
|
|
to reading the backbuffer through the game's **D3D10** device into a staging texture and
|
|
`UpdateSubresource`-ing it into the shared texture on a **hook-owned D3D11 device** (the game
|
|
has no usable D3D11 device of its own). The staging `Map` blocks until the GPU copy completes,
|
|
so there's no cross-device race.
|
|
- **D3D9 capture: one read-back path covers plain D3D9 *and* D3D9Ex.** D3D9 has no DXGI
|
|
swapchain, so inline-hook `IDirect3DDevice9::Present` (vtable index **17**, found from a
|
|
throwaway device — clean prologue, so inline is safe; `stdcall()` on x86). A D3D9 surface
|
|
isn't D3D11-shareable, so read the backbuffer back with `GetRenderTargetData` into a
|
|
`D3DPOOL_SYSTEMMEM` surface (`LockRect` blocks until the copy → no race), **swizzle BGRA→RGBA**
|
|
(`X8R8G8B8`/`A8R8G8B8` store as little-endian `0xAARRGGBB` → bytes B,G,R,A) and upload into the
|
|
shared texture on a hook-owned D3D11 device. Both plain D3D9 and D3D9Ex call this same Present,
|
|
so **one path serves both** — the GPU shared-surface fast path the plan sketched for D3D9Ex
|
|
wasn't worth it (D3D9 games are light enough that the read-back cost is fine, and it dodges the
|
|
cross-device keyed-mutex-less sync of a legacy shared surface).
|
|
- **The OpenGL mock needs no GL loader.** Drawing the animated pattern with scissored clears
|
|
(`glClear` + `glScissor`) only touches **GL 1.1**, which `opengl32` exports directly — so the
|
|
planned **glad** submodule wasn't needed (a legacy `wglCreateContext` + `<GL/gl.h>` suffices).
|
|
GL's framebuffer is **bottom-left** origin and the capture flips it top-down, so the mock draws
|
|
the frame-counter block at the GL *top* (`y = h - block`) to land at the captured image's
|
|
top-left. The GL `SwapBuffers` hook now also bumps the shared present counter (it's the GL
|
|
present), so the Video panel's present rate works for GL games too.
|
|
- **A render client that predates our injection has no knowable format — measure it.**
|
|
We inject into already-running games, so we usually never see the game's
|
|
`IAudioClient::Initialize`; the render-hook then assumes the device mix format for that
|
|
stream. That's wrong for games that render at a non-device rate via WASAPI
|
|
`AUTOCONVERTPCM` (e.g. Godot / Brotato render 44100 Hz while the endpoint mixes at
|
|
48000), so the captured audio plays back **pitch-shifted up**. Fix: treat such a format
|
|
as a *guess* and measure the stream's true sample rate from its render cadence
|
|
(frames/sec over a short active window, snapped to the nearest standard rate) before
|
|
publishing it, deferring capture until verified. Discard the first measurement window:
|
|
the moment we attach, the stream's already-queued buffers arrive in a burst that
|
|
over-counts (the `audio_hook_test` matrix caught this), so measure the next,
|
|
steady-state window. **Only the rate is recoverable, though** — channels/bit-depth can't
|
|
be measured (`AUTOCONVERTPCM` hands `GetBuffer` a *fixed* staging buffer, so there's no
|
|
buffer stride; confirmed empirically) and WASAPI has no API for a pre-existing client's
|
|
format, so they stay the device-mix guess. That's right for the common case (engines
|
|
render stereo float = the endpoint), and a `VirtualQuery` clamp on the capture copy
|
|
keeps a too-large guessed block from ever over-reading the source buffer. Streams we *do*
|
|
watch get created carry their exact `Initialize` format, and the loopback fallback
|
|
captures post-mix at the device format (always correct). The Audio panel shows each
|
|
stream's provenance (known / measuring / measured rate (ch/bits assumed)) so what the
|
|
mirror is using is always visible — see Limitations.
|
|
- **A short measurement window *rejects* a bad reading, it can't *average* it away.**
|
|
Measuring the rate over ~200 ms made one extra ~10 ms WASAPI buffer at a window edge a
|
|
~5% error, which lands *between* standard rates (they're >8% apart) — so 44100 read as
|
|
~46205 and got published verbatim. The robust fix isn't just a longer window: it's to
|
|
**refuse any window that doesn't snap to a standard rate** and require **consensus**
|
|
across windows, since a quantization/burst error big enough to miss the right rate
|
|
lands in no-man's-land rather than on a wrong neighbour. Only commit a non-standard
|
|
estimate as explicitly *low-confidence* (shown red). The operator can also re-measure
|
|
or override the format via a per-stream `AudioRingHeader` op channel; the host rebuilds
|
|
its render client when `format_generation` bumps, so it takes effect live.
|
|
- **Panels must *fit* their assigned size at max info -- measure it, don't eyeball it.** The
|
|
overlay opens panels at fixed sizes that scale with the monitor, so with Debug details on a
|
|
dense panel can overflow and scroll content out of view. `ui_fit_test` drives the real
|
|
Controllers + Audio panels headlessly (null-backend ImGui: build the atlas with
|
|
`GetTexDataAsRGBA32`, set a non-null `TexID`, `Render()` needs no GPU) at reference
|
|
resolutions and asserts each window's `ScrollMax` is 0 -- read it on the *2nd+* frame, since
|
|
ImGui computes `ScrollMax` in `Begin()` from the *previous* frame's content size. Two fixes
|
|
fell out: the center column was too narrow (horizontal overflow -> widen it, merge the
|
|
Controllers poll/round-trip tables), and a *static* height split can't serve both modes, so
|
|
it's now **Debug-details-aware** (debug on -> Controllers/Audio get the height for their
|
|
tables; off -> Video's perf graphs are the tall content, since Video's height is
|
|
mirroring-driven, not debug-driven). The host's `uisize`/`uifit` harness commands check the
|
|
same thing against the live overlay.
|
|
- **Drive the ImGui overlay for tests through an IPC harness, not synthetic input.**
|
|
`PostMessage`-d mouse clicks don't reliably reach ImGui widgets, and key/coordinate
|
|
simulation is brittle. A tiny debug-only command channel (`-DCOOP_TEST_HARNESS`, file-
|
|
based) that calls the *same* code the buttons do — and replies with state — makes UI
|
|
validation deterministic and scriptable, and is compiled out of the shipped product.
|
|
- **A capture-style stress test against a frame-numbered mock game is worth a lot.** An
|
|
animated game that encodes its frame number in the pixels lets a test assert the mirror
|
|
shows a *monotonic, advancing* sequence (the bar for "no dropped / stale / out-of-order
|
|
frames"), and toggling subsystems while it renders flushes out concurrency bugs. This
|
|
one caught **five** real audio races: a `memset` over-*write* past a guessed stream's
|
|
real buffer (zeroing 8ch into a 2ch buffer corrupts adjacent audio memory → crash; the
|
|
fix: capture but don't silence a guessed stream), a null call through a vtable hook's
|
|
`original` after unhook (keep it valid), a non-atomic `g_ipc` TOCTOU, a stale
|
|
GetBuffer/ReleaseBuffer pairing across a hook toggle (epoch-stamp it), and COM-object
|
|
churn from re-creating the probe each toggle (build it once, keep it, only swap vtable
|
|
slots). **Silently silencing/zeroing a buffer whose true size you only guessed is an
|
|
over-write, not just an over-read** — clamp the read, but don't write what you can't size.
|
|
- **Capturing at `Present` decouples the mirror from DWM composition.** The hook copies
|
|
the backbuffer inside the game's `Present`, which the game issues at its true render
|
|
rate regardless of how DWM composites that *window*. So an unfocused game window can
|
|
judder (DWM under-composites background windows; only the focused window gets VRR /
|
|
independent flip) while the mirror — which receives every `Present` — stays smooth.
|
|
This is why the game window not being focused doesn't matter: it isn't the surface
|
|
anyone sees. The same focus rule explains why an unfocused *tool* window can render
|
|
below the game's rate (it loses VRR), so in use the mirror is the focused window.
|
|
- **SafetyHook on x86 has two traps that froze 32-bit Slaps and Beans.** (1)
|
|
`InlineHook::call()` invokes the trampoline as `__cdecl`, but most targets are
|
|
`__stdcall` (COM methods like `IDXGISwapChain::Present`, the WASAPI interfaces,
|
|
`WINAPI` `SwapBuffers`); on 32-bit that double-cleans the stack → ESP imbalance →
|
|
crash (Debug: **Run-Time Check Failure #0**). Use **`stdcall()`** (a no-op on
|
|
x64). (2) Don't *inline-hook* COM methods on x86 at all: MMDevApi/AudioSes
|
|
prologues do `push ebp; mov ebp,esp; and esp,-8` and read args **EBP-relative**,
|
|
which SafetyHook's trampoline relocation breaks (the original then runs with
|
|
garbage args and faults). Hook COM methods by **swapping the vtable entry**
|
|
instead (`VirtualProtect` the slot, overwrite the pointer, call the saved
|
|
original) — no code patching, pristine stack regardless of prologue. Inline
|
|
hooking stays fine for `Present`/`SwapBuffers` (clean prologues). Guarded by the
|
|
x86 hook tests.
|
|
- **Steam Input init suppresses XInput.** Initializing Steam Input 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 forwarding. XInput is primary; Steam Input is opt-in.
|