Fix Brotato hooked-audio double-play: mute guessed streams via SILENT flag
The hooked path must capture the game's frames AND mute its local playback
("no echo"). The mute was implemented as zero-the-buffer (memset) + release
with AUDCLNT_BUFFERFLAGS_SILENT. Zeroing num_frames*block is only safe when
block is the real frame size; for a guessed format (late attach -- the
Brotato case, where we never saw Initialize) the guessed block can exceed
the real buffer, so the conservative code skipped the whole mute for guessed
streams. That left the game audible: it played locally AND the mirror
re-rendered the same audio a few ms later = a metallic, out-of-sync double.
Fix: AUDCLNT_BUFFERFLAGS_SILENT already makes WASAPI ignore the buffer
contents and play silence -- it mutes with no write at all, so it's safe for
any format. Decouple the two: always mute via the flag; keep the memset only
for an exact/override format (belt-and-suspenders). One-line behavior change;
the byte-incompatible cases confirm the flag-mute never over-writes.
Test-first (now a documented rule, README "Tests"): added an
audio_frames_silenced() counter and a mute assertion to audio_hook_test for
both the exact and guessed paths. The guessed assertion FAILS on the unfixed
code (3 rates) and passes after the fix -- the regression guard for this bug.
README also updates the now-correct no-echo limitation and adds a
lessons-learned writeup.
ctest 17/17. Brotato confirmed fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -208,6 +208,7 @@ std::uint32_t g_registered = 0; // slots filled (<= kMaxAudioStreams)
|
||||
std::atomic<std::uint32_t> g_streams_seen{0}; // total distinct clients ever seen
|
||||
|
||||
std::atomic<std::uint64_t> g_frames_captured{0}; // total frames captured across streams
|
||||
std::atomic<std::uint64_t> g_frames_silenced{0}; // total frames whose local playback we muted (SILENT)
|
||||
|
||||
// When we attach to an already-running game we never saw its IAudioClient::Initialize,
|
||||
// so a render client discovered on the hot path gets the device mix format as a best
|
||||
@@ -368,18 +369,25 @@ HRESULT STDMETHODCALLTYPE hk_ReleaseBuffer(IAudioRenderClient* self, UINT32 num_
|
||||
if (block != 0 && audio_ring_push(*ring, t_gb_data, bytes, num_frames))
|
||||
{
|
||||
g_frames_captured.fetch_add(num_frames, std::memory_order_relaxed);
|
||||
// Only silence (zero the buffer) for an EXACT/override format, where `block`
|
||||
// is the real frame size so the memset stays in-bounds. For a guessed format
|
||||
// the block can exceed the real buffer, so zeroing it would over-WRITE into
|
||||
// adjacent audio memory (an intermittent crash the stress test caught) -- so we
|
||||
// capture but leave the game audible (echo). The no-echo path is reached via an
|
||||
// exact format (auto-attach early) or an operator override.
|
||||
// Mute the game's local playback so the only audio is the host's re-render.
|
||||
// Otherwise the game plays locally AND the mirror re-renders the same audio a
|
||||
// few ms later = a metallic double (the Brotato symptom). AUDCLNT_BUFFERFLAGS_SILENT
|
||||
// tells WASAPI to treat the buffer as silence and IGNORE its contents, so it
|
||||
// mutes WITHOUT writing the buffer -- safe even for a guessed-format stream whose
|
||||
// true frame size we don't know. (Muting used to be tied to the memset below,
|
||||
// which is unsafe for a guessed block, so guessed streams -- the late-attach /
|
||||
// Brotato case -- were captured but left audible. The flag is the actual mute;
|
||||
// the memset is not needed for it.) For an exact/override format we additionally
|
||||
// zero the buffer (belt-and-suspenders; `block` is the real frame size there, so
|
||||
// it stays in-bounds). Only mutes once the frames made the ring (above) -- a
|
||||
// stalled host degrades to echo, never to dead silence.
|
||||
if (!guessed)
|
||||
{
|
||||
std::memset(t_gb_data, 0, bytes);
|
||||
return g_vh_releasebuffer.original<ReleaseBufferFn>()(
|
||||
self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
|
||||
}
|
||||
g_frames_silenced.fetch_add(num_frames, std::memory_order_relaxed);
|
||||
return g_vh_releasebuffer.original<ReleaseBufferFn>()(
|
||||
self, num_frames, flags | AUDCLNT_BUFFERFLAGS_SILENT);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,6 +906,7 @@ void remove_audio_hooks()
|
||||
g_registered = 0;
|
||||
g_streams_seen.store(0, std::memory_order_relaxed);
|
||||
g_frames_captured.store(0, std::memory_order_relaxed);
|
||||
g_frames_silenced.store(0, std::memory_order_relaxed);
|
||||
for (std::uint32_t i = 0; i < kMaxAudioStreams; ++i)
|
||||
{
|
||||
g_streams[i].client.store(nullptr, std::memory_order_relaxed);
|
||||
@@ -937,6 +946,11 @@ void shutdown_audio_hooks()
|
||||
g_have_mix_format.store(0, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::uint64_t audio_frames_silenced()
|
||||
{
|
||||
return g_frames_silenced.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
std::uint64_t audio_frames_captured()
|
||||
{
|
||||
return g_frames_captured.load(std::memory_order_relaxed);
|
||||
|
||||
@@ -47,6 +47,11 @@ void shutdown_audio_hooks();
|
||||
// Cumulative frames the primary path copied to the ring and silenced locally.
|
||||
std::uint64_t audio_frames_captured();
|
||||
|
||||
// Cumulative frames whose local playback the hook muted (released to WASAPI with
|
||||
// AUDCLNT_BUFFERFLAGS_SILENT). The "no echo" guarantee: this must advance for every
|
||||
// captured stream, including a guessed-format (late-attach) one. Used by the self-test.
|
||||
std::uint64_t audio_frames_silenced();
|
||||
|
||||
// Distinct render streams ever observed (may exceed kMaxAudioStreams).
|
||||
std::uint32_t audio_streams_seen();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user