From 365043c3da9ceaf8e47a9fb671bf0fe4f1025bdd Mon Sep 17 00:00:00 2001 From: BlackMark Date: Fri, 19 Jun 2026 14:55:11 +0200 Subject: [PATCH] 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 --- README.md | 11 ++++++++ docs/audio-render-hook-plan.md | 46 ++++++++++++++++++++++++++++------ 2 files changed, 50 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 929ef21..ef80a2a 100644 --- a/README.md +++ b/README.md @@ -142,6 +142,17 @@ ctest --test-dir build -C Debug --output-on-failure shipping process-loopback capture (the fallback path) receives its audio by PID. Skips cleanly if the machine has no audio endpoint. +### Debugging the render-hook against a real game + +[`tools/audio_probe`](tools/audio_probe) (`coop_audio_probe.exe [seconds]`) +brings up the audio render-hook without Steam / RPT / the host UI: it creates the +IPC block + audio ring the hook expects, injects `coop_hook.dll` into the target +game, then drains the ring and prints per-stream format, captured-frame counts, +peak amplitude (proves the audio is real, not silence), and overruns. It enables +the hook's file trace (`%TEMP%\coop_hook.log`) for the run. Run it from +`bin//`. **Kill the game between runs** — the loaded DLL locks +`coop_hook.dll` against the next rebuild. + ## Running the tool (manual, end-to-end) This needs Steam, a donor game that supports Remote Play Together, and a second diff --git a/docs/audio-render-hook-plan.md b/docs/audio-render-hook-plan.md index 2fa7711..c3fe3d1 100644 --- a/docs/audio-render-hook-plan.md +++ b/docs/audio-render-hook-plan.md @@ -1,12 +1,44 @@ # 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 -below are built, committed, and covered by `audio_ring_test` + `audio_hook_test` -(in-process). The remaining step is M5: confirm in a real game with a guest that -there's no local echo, the guest still hears audio, and the stream count reads -correctly. This document is the green-lit design; the only correction applied -during implementation was the `IAudioClient::GetService` vtable index (14, not -13 — `SetEventHandle` is 13). +Status: **implemented and validated against a real already-playing game.** M1–M4 +are built and covered by `audio_ring_test` + `audio_hook_test`. The render-hook +now correctly captures a game's audio when injected into an already-running, +already-playing process (verified with Phantom Brave, `coop_audio_probe`: the +pre-existing 48 kHz/2ch/float render client is detected, real non-silent audio +reaches the ring, zero overruns when consumed). The remaining step is the human +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