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 <noreply@anthropic.com>
This commit is contained in:
@@ -81,7 +81,11 @@ All phases below are implemented and verified.
|
|||||||
|
|
||||||
### Remaining verification
|
### 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
|
- **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,
|
audio**, and confirm **Source: Hooked (no echo)**, the game goes locally silent,
|
||||||
|
|||||||
@@ -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
|
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.
|
end-to-end with a guest over RPT: no local echo, guest hears audio.
|
||||||
|
|
||||||
Two corrections were applied during/after implementation: the
|
Corrections applied during/after implementation: the `IAudioClient::GetService`
|
||||||
`IAudioClient::GetService` vtable index is **14**, not 13 (`SetEventHandle` is
|
vtable index is **14**, not 13 (`SetEventHandle` is 13); the inner hooks must be
|
||||||
13); and the inner hooks must be installed **proactively** (see below), not only
|
installed **proactively** (see below), not only reactively — the original
|
||||||
reactively — the original reactive-only design captured nothing on late injection,
|
reactive-only design captured nothing on late injection, which is the normal case;
|
||||||
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)
|
## 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
|
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).
|
(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
|
## Problem
|
||||||
|
|
||||||
`coop_host.exe` mirrors the game's audio so Steam Remote Play Together (which
|
`coop_host.exe` mirrors the game's audio so Steam Remote Play Together (which
|
||||||
|
|||||||
@@ -70,6 +70,14 @@ std::atomic<IAudioRenderClient*> g_self_render{nullptr};
|
|||||||
CapturedFormat g_mix_format;
|
CapturedFormat g_mix_format;
|
||||||
std::atomic<std::uint32_t> g_have_mix_format{0};
|
std::atomic<std::uint32_t> 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
|
// Per IAudioClient, the format captured at Initialize, looked up when its render
|
||||||
// client is created. Setup-path only (never touched on the audio thread).
|
// client is created. Setup-path only (never touched on the audio thread).
|
||||||
std::unordered_map<IAudioClient*, CapturedFormat> g_client_formats;
|
std::unordered_map<IAudioClient*, CapturedFormat> g_client_formats;
|
||||||
@@ -238,6 +246,7 @@ void register_render_client_locked(IAudioRenderClient* rc, const CapturedFormat&
|
|||||||
|
|
||||||
if (slot == 0)
|
if (slot == 0)
|
||||||
{
|
{
|
||||||
|
g_primary_format = cf;
|
||||||
g_primary_block_align.store(cf.block_align, std::memory_order_relaxed);
|
g_primary_block_align.store(cf.block_align, std::memory_order_relaxed);
|
||||||
g_primary.store(rc, std::memory_order_release);
|
g_primary.store(rc, std::memory_order_release);
|
||||||
AudioRingHeader* ring = g_ring.load(std::memory_order_acquire);
|
AudioRingHeader* ring = g_ring.load(std::memory_order_acquire);
|
||||||
@@ -472,11 +481,36 @@ bool install_audio_hooks(IpcClient& ipc, AudioRingHeader* ring)
|
|||||||
return static_cast<bool>(g_hk_activate);
|
return static_cast<bool>(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)
|
void set_audio_ring(AudioRingHeader* ring)
|
||||||
{
|
{
|
||||||
g_ring.store(ring, std::memory_order_release);
|
g_ring.store(ring, std::memory_order_release);
|
||||||
logf("set_audio_ring: ring=%p capture_enabled=%u", ring,
|
logf("set_audio_ring: ring=%p capture_enabled=%u", ring,
|
||||||
ring ? ring->capture_enabled.load(std::memory_order_relaxed) : 0u);
|
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()
|
void remove_audio_hooks()
|
||||||
@@ -500,6 +534,7 @@ void remove_audio_hooks()
|
|||||||
g_self_client = nullptr;
|
g_self_client = nullptr;
|
||||||
}
|
}
|
||||||
g_have_mix_format.store(0, std::memory_order_relaxed);
|
g_have_mix_format.store(0, std::memory_order_relaxed);
|
||||||
|
g_primary_format = CapturedFormat{};
|
||||||
|
|
||||||
g_registered = 0;
|
g_registered = 0;
|
||||||
g_streams_seen.store(0, std::memory_order_relaxed);
|
g_streams_seen.store(0, std::memory_order_relaxed);
|
||||||
|
|||||||
@@ -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).
|
// Attach/replace the producer ring after install (e.g. host created it late).
|
||||||
void set_audio_ring(AudioRingHeader* ring);
|
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).
|
// Removes all installed render hooks (best effort; used on DLL detach).
|
||||||
void remove_audio_hooks();
|
void remove_audio_hooks();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
coop::hook::update_input_diagnostics(g_ipc); // refreshes each tick; registrations can change
|
||||||
g_ipc.heartbeat();
|
g_ipc.heartbeat();
|
||||||
Sleep(250);
|
Sleep(250);
|
||||||
|
|||||||
@@ -89,11 +89,15 @@ int wmain(int argc, wchar_t** argv)
|
|||||||
{
|
{
|
||||||
if (argc < 2)
|
if (argc < 2)
|
||||||
{
|
{
|
||||||
std::printf("usage: coop_audio_probe <pid> [seconds]\n");
|
std::printf("usage: coop_audio_probe <pid> [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;
|
return 1;
|
||||||
}
|
}
|
||||||
const unsigned long pid = std::wcstoul(argv[1], nullptr, 10);
|
const unsigned long pid = std::wcstoul(argv[1], nullptr, 10);
|
||||||
const int seconds = (argc >= 3) ? std::max(1, _wtoi(argv[2])) : 20;
|
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)
|
if (pid == 0)
|
||||||
{
|
{
|
||||||
std::printf("ERROR: invalid pid.\n");
|
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->sequence.store(0, std::memory_order_relaxed);
|
||||||
block->magic = coop::kProtocolMagic;
|
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::AudioRingHeader>();
|
|
||||||
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.
|
// Enable the hook's file trace (%TEMP%\coop_hook.log) for this debug session.
|
||||||
{
|
{
|
||||||
wchar_t dir[MAX_PATH] = {};
|
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::AudioRingHeader>();
|
||||||
|
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);
|
std::printf("Injecting coop_hook.dll into pid %lu ...\n", pid);
|
||||||
if (!inject(pid, dll_path_next_to_self()))
|
if (!inject(pid, dll_path_next_to_self()))
|
||||||
{
|
{
|
||||||
return 1;
|
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<DWORD>(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.
|
// overrun) and measure peak amplitude to prove we captured real audio.
|
||||||
const coop::HookStatus& status = block->status;
|
const coop::HookStatus& status = block->status;
|
||||||
std::uint64_t prev_frames[coop::kMaxAudioStreams] = {};
|
std::uint64_t prev_frames[coop::kMaxAudioStreams] = {};
|
||||||
|
|||||||
Reference in New Issue
Block a user