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] = {};