docs: record the late-injection render-hook fix + probe tool

Update the plan's status (validated against a real already-playing game),
add the "reactive-only hooking fails on late injection" lesson and the
self-deadlock ordering sub-lesson, and document tools/audio_probe in the
README. Mirrors commit c7be4ee.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 14:55:11 +02:00
parent c7be4eeb9a
commit 365043c3da
2 changed files with 50 additions and 7 deletions

View File

@@ -142,6 +142,17 @@ 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
[`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. Run it 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)
This needs Steam, a donor game that supports Remote Play Together, and a second This needs Steam, a donor game that supports Remote Play Together, and a second

View File

@@ -1,12 +1,44 @@
# Plan: fix the local audio echo via an injection render-hook (Option B) # Plan: fix the local audio echo via an injection render-hook (Option B)
Status: **implemented (M1–M4), pending manual end-to-end (M5).** Milestones 1–4 Status: **implemented and validated against a real already-playing game.** M1–M4
below are built, committed, and covered by `audio_ring_test` + `audio_hook_test` are built and covered by `audio_ring_test` + `audio_hook_test`. The render-hook
(in-process). The remaining step is M5: confirm in a real game with a guest that now correctly captures a game's audio when injected into an already-running,
there's no local echo, the guest still hears audio, and the stream count reads already-playing process (verified with Phantom Brave, `coop_audio_probe`: the
correctly. This document is the green-lit design; the only correction applied pre-existing 48 kHz/2ch/float render client is detected, real non-silent audio
during implementation was the `IAudioClient::GetService` vtable index (14, not reaches the ring, zero overruns when consumed). The remaining step is the human
13 — `SetEventHandle` is 13). end-to-end with a guest over RPT: no local echo, guest hears audio.
Two corrections were applied during/after implementation: the
`IAudioClient::GetService` vtable index is **14**, not 13 (`SetEventHandle` is
13); and the inner hooks must be installed **proactively** (see below), not only
reactively — the original reactive-only design captured nothing on late injection,
which is the normal case.
## Lesson: reactive-only hooking fails on late injection (the bug that broke it)
The first cut installed the IAudioClient / IAudioRenderClient hooks only when the
*game* called `IMMDevice::Activate` → `GetService`. But the tool injects into a
game that is already running and already playing — its render client was created
before we attached, so those calls never fire again. Result: no stream is ever
registered, nothing is captured, and the host always falls back to process
loopback (the echo). Every game tested fell back.
Fix (commit `c7be4ee`): at anchor time, build our **own** probe `IAudioClient` +
`IAudioRenderClient` with raw calls and hook `GetBuffer`/`ReleaseBuffer` (plus
`Initialize`/`GetService`) on *their* vtables. Because every instance of a COM
coclass shares one vtable, this patches the shared vtables and intercepts the
game's pre-existing render client too. The first render client seen actively
releasing buffers is adopted as the primary on the audio thread (try-lock,
one-time) using the device **mix format** as its assumed format — we never saw its
`Initialize`, and shared-mode clients overwhelmingly use the mix format. Streams
created *after* injection still register via the reactive path with their real
format.
Sub-lesson — ordering / self-deadlock: create the probe objects *before*
installing the `Activate` hook. If `Activate` is hooked first, the probe's own
`device->Activate` re-enters `hk_Activate` → `install_audioclient_hooks`, which
blocks on the setup mutex the installer already holds — freezing the worker thread
(and any game thread that later calls `Activate`, which crashed the game).
## Problem ## Problem