From 4e076420bf327e18cd5fa57f996ff002778b2909 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Fri, 19 Jun 2026 15:23:30 +0200 Subject: [PATCH] Fix audio falling back to echo in the full app (format not published) In the full app the host creates the audio ring only when the operator toggles audio mirroring on -- after injection. So the hook registers the game's primary render stream while the ring is still null, and register_render_client_locked skips publishing the format (nothing to publish to). When the ring later attaches via set_audio_ring, the already-registered stream's format was never re-published: format_valid stayed 0, the host's wait_for_format timed out, and it fell back to loopback (the echo) -- on every game, including Phantom Brave. The in-process probe created the ring before injecting, so it never reproduced this. Fix: the hook stores the primary stream's format and republish_audio_format() publishes it whenever a ring is attached but has no format yet -- called from set_audio_ring and once per worker tick (the tick also covers the host re-initializing the ring on a mirror re-toggle, which clears format_valid). coop_audio_probe now creates the ring ~1.5 s AFTER injecting by default (ring_delay_ms arg) to match the app's ordering. Verified against Phantom Brave: the log shows "primary stream set ... no ring yet" at inject, then "republish_audio_format: published 48000Hz/2ch/32bit" when the ring attaches, and the host-shaped consumer then drains real audio with zero overruns. All four tests still pass. Co-Authored-By: Claude Opus 4.8 --- README.md | 6 +++- docs/audio-render-hook-plan.md | 30 +++++++++++++++--- hook/src/audio_hook.cpp | 35 +++++++++++++++++++++ hook/src/audio_hook.hpp | 6 ++++ hook/src/dllmain.cpp | 7 +++++ tools/audio_probe/main.cpp | 57 +++++++++++++++++++++++++--------- 6 files changed, 120 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 9da7fd3..c3daa23 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,11 @@ All phases below are implemented and verified. ### Remaining verification -Producer-side capture is proven; these still need a human / full setup to confirm: +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, diff --git a/docs/audio-render-hook-plan.md b/docs/audio-render-hook-plan.md index c3fe3d1..2f6f75a 100644 --- a/docs/audio-render-hook-plan.md +++ b/docs/audio-render-hook-plan.md @@ -8,11 +8,12 @@ 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. +Corrections applied during/after implementation: the `IAudioClient::GetService` +vtable index is **14**, not 13 (`SetEventHandle` is 13); 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; +and the captured format must be **(re)published when the ring attaches**, not only +at stream registration, or the full app falls back to the echo (see below). ## Lesson: reactive-only hooking fails on late injection (the bug that broke it) @@ -40,6 +41,25 @@ installing the `Activate` hook. If `Activate` is hooked first, the probe's own 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). +## Lesson: publish the format when the ring attaches, not only at registration + +A second late-binding bug only showed up in the full app, never in the probe. +The host creates the audio ring **when the operator toggles audio mirroring on**, +which is *after* injection — so the hook registers the game's primary stream while +`g_ring` is still null, and `register_render_client_locked` skips publishing the +format (there's no ring to publish to). When the ring later attaches via +`set_audio_ring`, the already-registered stream's format was never re-published, +so `format_valid` stayed 0, the host's `wait_for_format` timed out, and it fell +back to loopback (the echo) on every game. The in-process probe created the ring +*before* injecting, so it never reproduced this — `coop_audio_probe` now defaults +to creating the ring ~1.5 s **after** injecting to match the app. + +Fix (commit follows): the hook stores the primary stream's format and +`republish_audio_format()` (re)publishes it whenever a ring is attached but has no +format yet — called from `set_audio_ring` and once per worker tick (the latter +also covers the host re-initializing the ring on a mirror re-toggle, which clears +`format_valid`). + ## Problem `coop_host.exe` mirrors the game's audio so Steam Remote Play Together (which diff --git a/hook/src/audio_hook.cpp b/hook/src/audio_hook.cpp index f31069b..afcff31 100644 --- a/hook/src/audio_hook.cpp +++ b/hook/src/audio_hook.cpp @@ -70,6 +70,14 @@ std::atomic g_self_render{nullptr}; CapturedFormat g_mix_format; std::atomic g_have_mix_format{0}; +// The primary stream's actual format, captured when it's registered. The host +// creates the ring only when audio mirroring is toggled on — typically *after* +// the primary stream was already registered — so the format must be (re)published +// to the ring whenever it attaches. (The in-process probe creates the ring before +// injecting, so it never exercises this ordering; the full app always does.) +// Guarded by g_setup_mutex. +CapturedFormat g_primary_format; + // Per IAudioClient, the format captured at Initialize, looked up when its render // client is created. Setup-path only (never touched on the audio thread). std::unordered_map g_client_formats; @@ -238,6 +246,7 @@ void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat& if (slot == 0) { + g_primary_format = cf; g_primary_block_align.store(cf.block_align, std::memory_order_relaxed); g_primary.store(rc, std::memory_order_release); AudioRingHeader* ring = g_ring.load(std::memory_order_acquire); @@ -472,11 +481,36 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring) return static_cast(g_hk_activate); } +void republish_audio_format() +{ + AudioRingHeader* ring = g_ring.load(std::memory_order_acquire); + if (ring == nullptr || audio_ring_format_ready(*ring)) + { + return; // no ring yet, or the format is already published + } + std::scoped_lock lock(g_setup_mutex); + if (audio_ring_format_ready(*ring)) + { + return; // raced with another publisher + } + if (g_primary.load(std::memory_order_acquire) != nullptr && g_primary_format.rate != 0) + { + audio_ring_set_format(*ring, g_primary_format.rate, g_primary_format.channels, g_primary_format.bits, + g_primary_format.tag, g_primary_format.block_align); + logf("republish_audio_format: published %uHz/%uch/%ubit to ring %p", g_primary_format.rate, + g_primary_format.channels, g_primary_format.bits, ring); + } +} + void set_audio_ring(AudioRingHeader* ring) { g_ring.store(ring, std::memory_order_release); logf("set_audio_ring: ring=%p capture_enabled=%u", ring, ring ? ring->capture_enabled.load(std::memory_order_relaxed) : 0u); + // The primary may already be registered (game was playing before we injected + // and before the host created the ring); publish its format so the host stops + // waiting and consumes the ring instead of falling back to loopback. + republish_audio_format(); } void remove_audio_hooks() @@ -500,6 +534,7 @@ void remove_audio_hooks() g_self_client = nullptr; } g_have_mix_format.store(0, std::memory_order_relaxed); + g_primary_format = CapturedFormat{}; g_registered = 0; g_streams_seen.store(0, std::memory_order_relaxed); diff --git a/hook/src/audio_hook.hpp b/hook/src/audio_hook.hpp index b78a501..fcb1cf2 100644 --- a/hook/src/audio_hook.hpp +++ b/hook/src/audio_hook.hpp @@ -26,6 +26,12 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring); // Attach/replace the producer ring after install (e.g. host created it late). void set_audio_ring(AudioRingHeader* ring); +// Publish the registered primary stream's format to the attached ring if it +// isn't published yet. Idempotent; call periodically so a ring the host attaches +// (or re-initializes on a mirror re-toggle) gets the format even though the +// stream was registered earlier. No-op if there's no ring / no primary yet. +void republish_audio_format(); + // Removes all installed render hooks (best effort; used on DLL detach). void remove_audio_hooks(); diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index ca897ae..3f87ece 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -89,6 +89,13 @@ DWORD WINAPI worker_thread(LPVOID) } } } + // The primary stream is often registered before the ring is attached (or the + // host re-inits the ring on a mirror re-toggle, clearing its format); keep + // the format published so the host consumes the ring instead of falling back. + if (audio_ring_open) + { + coop::hook::republish_audio_format(); + } coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change g_ipc.heartbeat(); Sleep(250); diff --git a/tools/audio_probe/main.cpp b/tools/audio_probe/main.cpp index 9285c41..15f079a 100644 --- a/tools/audio_probe/main.cpp +++ b/tools/audio_probe/main.cpp @@ -89,11 +89,15 @@ int wmain(int argc, wchar_t** argv) { if (argc < 2) { - std::printf("usage: coop_audio_probe [seconds]\n"); + std::printf("usage: coop_audio_probe [seconds] [ring_delay_ms]\n" + " ring_delay_ms: how long after injecting to create the audio ring\n" + " (default 1500 = reproduces the real app, which creates the ring\n" + " only when audio mirroring is toggled on; 0 = ring before inject).\n"); return 1; } const unsigned long pid = std::wcstoul(argv[1], nullptr, 10); const int seconds = (argc >= 3) ? std::max(1, _wtoi(argv[2])) : 20; + const int ring_delay_ms = (argc >= 4) ? std::max(0, _wtoi(argv[3])) : 1500; if (pid == 0) { std::printf("ERROR: invalid pid.\n"); @@ -113,17 +117,6 @@ int wmain(int argc, wchar_t** argv) block->sequence.store(0, std::memory_order_relaxed); block->magic = coop::kProtocolMagic; - // 2) Audio ring, capture enabled (mirrors AudioMirror::thread_main). - coop::SharedMemory ring_shm; - if (!ring_shm.create(coop::audio_ring_name(pid), coop::audio_ring_total_size(coop::kAudioRingCapacity))) - { - std::printf("ERROR: create audio ring mapping failed (%lu).\n", GetLastError()); - return 1; - } - auto* ring = ring_shm.as(); - coop::audio_ring_init(*ring, coop::kAudioRingCapacity); - ring->capture_enabled.store(1, std::memory_order_release); - // Enable the hook's file trace (%TEMP%\coop_hook.log) for this debug session. { wchar_t dir[MAX_PATH] = {}; @@ -139,15 +132,49 @@ int wmain(int argc, wchar_t** argv) } } - // 3) Inject. + // Create the audio ring (capture enabled), mirroring AudioMirror::thread_main. + // By default we do this *after* injecting so the ordering matches the real app + // (the host creates the ring only when audio mirroring is toggled on, which is + // after the hook has already been injected and the game's stream registered). + coop::SharedMemory ring_shm; + coop::AudioRingHeader* ring = nullptr; + auto create_ring = [&]() -> bool { + if (!ring_shm.create(coop::audio_ring_name(pid), + coop::audio_ring_total_size(coop::kAudioRingCapacity))) + { + std::printf("ERROR: create audio ring mapping failed (%lu).\n", GetLastError()); + return false; + } + ring = ring_shm.as(); + coop::audio_ring_init(*ring, coop::kAudioRingCapacity); + ring->capture_enabled.store(1, std::memory_order_release); + return true; + }; + + if (ring_delay_ms == 0 && !create_ring()) + { + return 1; + } + + // Inject. std::printf("Injecting coop_hook.dll into pid %lu ...\n", pid); if (!inject(pid, dll_path_next_to_self())) { return 1; } - std::printf("Injected. Polling for %d s. Hook trace: %%TEMP%%\\coop_hook.log\n\n", seconds); - // 4) Poll + print. Drain the ring like the real host would (so it doesn't + if (ring_delay_ms > 0) + { + std::printf("Injected. Creating audio ring %d ms later (app-ordering)...\n", ring_delay_ms); + Sleep(static_cast(ring_delay_ms)); + if (!create_ring()) + { + return 1; + } + } + std::printf("Polling for %d s. Hook trace: %%TEMP%%\\coop_hook.log\n\n", seconds); + + // Poll + print. Drain the ring like the real host would (so it doesn't // overrun) and measure peak amplitude to prove we captured real audio. const coop::HookStatus& status = block->status; std::uint64_t prev_frames[coop::kMaxAudioStreams] = {};