Files
CoopAllTheThings/README.md
BlackMark 945d7fbf78 Docs: note the Vulkan layer needs vk_layer.h (not in Vulkan-Headers)
The opt-in implicit-layer piece requires the loader/layer interface header
vk_layer.h, which Vulkan-Headers doesn't ship (it's in Vulkan-Loader / the SDK)
-- so it needs a new submodule or hand-declared link structs, confirming it's a
separate, higher-risk component rather than a quick add.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 13:01:25 +02:00

40 KiB
Raw Blame History

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); and Vulkan inline-hooks the vulkan-1.dll vkGetInstanceProcAddr export to intercept the resolution chain (vkCreateInstance / vkCreateDevice / vkCreateSwapchainKHR / vkQueuePresentKHR) and reads the presented image back with vkCmdCopyImageToBuffer — but only when the hook is present before the game initializes Vulkan (it caches its present pointer at init), so the Vulkan path needs early presence via Auto-attach (and an opt-in implicit layer — see Roadmap); a too-late attach shows a red relaunch banner and falls back to WGC. 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.

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 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).

    • Mandatory final visual inspection. The headless ui_fit_test proves content fits its window, but not that it looks right. Before this milestone is considered done, run the actual coop_host.exe (build with -DCOOP_TEST_HARNESS=ON), drive it to its maximum-information state (inject a target, audio on, Debug details on), take an F10 screenshot of the running tool, and eyeball the real overlay — every panel readable, nothing clipped/overlapping, colours/labels correct. A green unit test is not a substitute for looking at the product; this real-screenshot check is required, not optional.
  • M2 — Vulkan: opt-in reliability layer (the one piece left). The Vulkan mock backend and hooked capture are done and tested: the hook inline-hooks the vulkan-1.dll vkGetInstanceProcAddr export and intercepts the resolution chain (vkCreateInstance / vkCreateDevice / vkCreateSwapchainKHR / vkQueuePresentKHR), reads the presented image back with vkCmdCopyImageToBuffer, and re-chains the present's wait semaphores so the copy orders after rendering. mock_game_test decodes Vulkan frames through it (via a suspended-launch + early-load path) and verifies the too-late detection; the host shows the red relaunch banner (verified live). Best-effort auto-attach already works (the existing poll-and-inject re-injects on relaunch, the vk hook retries until vulkan-1.dll loads), enough for games that don't initialize Vulkan instantly. Remaining:

    • Opt-in implicit Vulkan layer for games that init Vulkan immediately (where even a relaunch+auto-attach injects too late). A real chain-aware Vulkan layer (coop_vk_layer.dll
      • JSON manifest) the loader loads at vkCreateInstance — guaranteed before init — registered per-user (HKCU, no admin) and scoped to the target image, with an opt-in "Set up Vulkan layer" Injection-panel checkbox that registers/unregisters it (self-deactivating for non-target apps so a stale entry after a host crash is harmless). This is a separate component from the inline-hook capture (layer-chain dispatch + manifest/registry + the checkbox + IPC handshake), and it needs the loader/layer interface header vk_layer.h — which is not in Vulkan-Headers (it lives in Vulkan-Loader / the SDK), so it requires either a new submodule or hand-declared VkLayer*CreateInfo link structs. Until it lands, the banner directs immediate-init Vulkan games to WGC (which mirrors them fine, just at higher latency).

    Each of 1–3 is its own commit; 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.

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 once (and after adding sources or include dirs); it configures a parallel Ninja build in build-clangd/ that produces the database, which .clangd points clangd at. clangd's clang-cl driver resolves the MSVC / Windows SDK system includes on its own.

Tests

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) 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 with selectable DX9 / DX9Ex / DX10 / DX11 / DX12 / OpenGL / Vulkan 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). Vulkan is special: since its present pointer is cached at init, the test launches the mock suspended, injects, then resumes (the mock loads Vulkan and waits so the hook arms first) to decode frames; it also late-injects a Vulkan game and asserts the too-late flag trips (which drives the host's relaunch banner). 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 / Vulkan device.

Debugging the hooks against a real game

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 (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.

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:

    "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.
  • Vulkan can't be late-hooked, and capturing its present needs semaphore surgery. Vulkan games cache vkQueuePresentKHR at init (often via volk, which bypasses the loader trampoline), so late injection misses it — the hook must be in before vkCreateInstance. Catch the resolution chain instead: inline-hook the vulkan-1.dll vkGetInstanceProcAddr export and hand back wrappers for vkCreateInstance / vkCreateDevice / vkCreateSwapchainKHR / vkQueuePresentKHR (so a volk app resolves ours), tracking the device / queue / images / format; read the presented image back with vkCmdCopyImageToBuffer and upload it to the shared texture on a hook-owned D3D11 device. The trap: the read-back submit must wait on the present's wait semaphores (consume them) and signal a fresh semaphore the real present then waits on — both your copy and the present can't wait the same binary semaphore. "Injected too late" is detected as vulkan-1.dll loaded + hook in for >4 s + we never saw vkCreateDevice (the app set everything up before us) → red relaunch banner, WGC meanwhile. Testing needs an early-load path (suspended launch + inject + resume; the mock loads Vulkan and waits), since the normal late-inject flow can't catch a Vulkan present.
  • 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.