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>
334 lines
12 KiB
C++
334 lines
12 KiB
C++
// In-process self-test for the WASAPI render-hook (hook/src/audio_hook.cpp), focused on
|
|
// audio-format detection. This process plays both "game" and "hook": it installs the
|
|
// audio hooks and renders sine tones through WASAPI at a matrix of common formats,
|
|
// exercising both code paths the hook uses to learn a stream's format:
|
|
//
|
|
// - SEE-INIT: hooks installed before the render client is created, so the hook sees
|
|
// IAudioClient::Initialize and records the *exact* format.
|
|
// - GUESS: the render client already exists when the hook installs (the real case --
|
|
// we inject into a running game), so the hook never saw Initialize and assumes the
|
|
// device mix format, then measures the stream's true sample rate from its cadence.
|
|
//
|
|
// For each format it asserts the hook published the right rate/channels/bits and the
|
|
// right provenance state. No game, no second Steam account. Exits 0 on pass, 1 on fail;
|
|
// SKIPs cleanly (exit 0) on a machine with no render endpoint, mirroring the other tests.
|
|
|
|
#include <cstdint>
|
|
#include <cstdio>
|
|
#include <vector>
|
|
|
|
#include <windows.h>
|
|
|
|
#include <audioclient.h>
|
|
#include <mmdeviceapi.h>
|
|
#include <mmreg.h>
|
|
|
|
#include "audio_hook.hpp"
|
|
#include "coop/audio_ring.hpp"
|
|
#include "coop/protocol.hpp"
|
|
#include "coop/shared_memory.hpp"
|
|
#include "ipc_client.hpp"
|
|
#include "tone_source.hpp"
|
|
|
|
using namespace coop;
|
|
using coop::tone::ToneFormat;
|
|
using coop::tone::ToneSource;
|
|
|
|
namespace
|
|
{
|
|
|
|
int g_failures = 0;
|
|
|
|
// Returns 1 (and logs) on failure, 0 on success -- so callers can sum a tally.
|
|
int expect(bool ok, const char* what)
|
|
{
|
|
if (!ok)
|
|
{
|
|
std::printf(" FAIL: %s\n", what);
|
|
++g_failures;
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
void reset_ring(AudioRingHeader* ring, SharedBlock* block)
|
|
{
|
|
audio_ring_init(*ring, kAudioRingCapacity);
|
|
ring->capture_enabled.store(1, std::memory_order_release);
|
|
block->status.audio_streams[0] = AudioStreamInfo{}; // clear stale provenance from the last case
|
|
}
|
|
|
|
// Drain the ring and report whether any non-silent sample landed in it (capture path).
|
|
bool ring_has_nonsilent(AudioRingHeader* ring)
|
|
{
|
|
std::vector<std::uint8_t> buf(128 * 1024, 0);
|
|
const std::uint32_t got = audio_ring_pop(*ring, buf.data(), static_cast<std::uint32_t>(buf.size()));
|
|
for (std::uint32_t i = 0; i < got; ++i)
|
|
{
|
|
if (buf[i] != 0)
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
const char* fmt_desc(const ToneFormat& f, char* buf, size_t n)
|
|
{
|
|
std::snprintf(buf, n, "%u Hz %u ch %u-bit %s", f.rate, f.channels, f.bits, f.is_float ? "float" : "pcm");
|
|
return buf;
|
|
}
|
|
|
|
// SEE-INIT: install hooks first, then create the render client so the hook records the
|
|
// exact Initialize format. Verifies the full format and the Exact provenance.
|
|
void test_see_init(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block, const ToneFormat& want)
|
|
{
|
|
char d[64];
|
|
reset_ring(ring, block);
|
|
if (!hook::install_audio_hooks(ipc, ring))
|
|
{
|
|
std::printf(" SKIP see-init (audio hooks unavailable)\n");
|
|
return;
|
|
}
|
|
|
|
ToneSource tone;
|
|
if (!tone.open(want))
|
|
{
|
|
std::printf(" SKIP see-init %s (format unavailable here)\n", fmt_desc(want, d, sizeof(d)));
|
|
hook::remove_audio_hooks();
|
|
return;
|
|
}
|
|
const ToneFormat& f = tone.format();
|
|
|
|
// Exact format publishes at registration; render briefly so capture fills the ring.
|
|
const std::uint64_t silenced_before = hook::audio_frames_silenced();
|
|
const DWORD end = GetTickCount() + 300;
|
|
while (GetTickCount() < end)
|
|
{
|
|
tone.render_step(50);
|
|
}
|
|
|
|
const AudioStreamInfo& s = block->status.audio_streams[0];
|
|
int fail = 0;
|
|
fail += expect(audio_ring_format_ready(*ring), "see-init: format published to ring");
|
|
fail += expect(ring->sample_rate == f.rate, "see-init: ring rate == exact rate");
|
|
fail += expect(ring->channels == f.channels, "see-init: ring channels == exact channels");
|
|
fail += expect(ring->bits == f.bits, "see-init: ring bits == exact bits");
|
|
fail += expect(s.sample_rate == f.rate, "see-init: HookStatus rate == exact rate");
|
|
fail += expect(s.format_state == AudioFormat_Exact, "see-init: provenance == Exact");
|
|
fail += expect(ring_has_nonsilent(ring), "see-init: non-silent audio captured");
|
|
fail += expect(hook::audio_frames_silenced() > silenced_before,
|
|
"see-init: local playback muted (no echo)");
|
|
std::printf(" %s see-init %s -> ring %uHz/%uch/%ubit state=%u\n", fail == 0 ? "PASS" : "FAIL",
|
|
fmt_desc(f, d, sizeof(d)), ring->sample_rate, ring->channels, ring->bits, s.format_state);
|
|
|
|
tone.close();
|
|
hook::remove_audio_hooks();
|
|
}
|
|
|
|
// GUESS: create the render client first (its Initialize is unseen), then install hooks so
|
|
// the stream is discovered lazily and assigned the device mix format -- whose rate is then
|
|
// measured/corrected. Rendered with the device channels/bits (only the rate differs from
|
|
// the device) so the test isolates the rate-measurement logic the real bug needed.
|
|
void test_guess(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block, unsigned rate)
|
|
{
|
|
reset_ring(ring, block);
|
|
|
|
ToneSource tone;
|
|
ToneFormat want;
|
|
want.rate = rate; // channels/bits resolve to the device's
|
|
if (!tone.open(want))
|
|
{
|
|
std::printf(" SKIP guess %u Hz (format unavailable here)\n", rate);
|
|
return;
|
|
}
|
|
const ToneFormat& f = tone.format();
|
|
|
|
// Let the stream reach steady state before attaching, like a game already running when
|
|
// we inject (the real case) -- not a stream we caught at its first buffer.
|
|
const DWORD warm = GetTickCount() + 300;
|
|
while (GetTickCount() < warm)
|
|
{
|
|
tone.render_step(30);
|
|
}
|
|
|
|
// Hooks install *after* the client exists -> the lazy-discovery (guess) path.
|
|
if (!hook::install_audio_hooks(ipc, ring))
|
|
{
|
|
std::printf(" SKIP guess (audio hooks unavailable)\n");
|
|
tone.close();
|
|
return;
|
|
}
|
|
|
|
// Render while driving republish (the DLL's worker does this each tick) until the
|
|
// measured rate is published, then render a bit more so capture fills the ring.
|
|
const DWORD measure_deadline = GetTickCount() + 2000;
|
|
while (GetTickCount() < measure_deadline && !audio_ring_format_ready(*ring))
|
|
{
|
|
tone.render_step(30);
|
|
hook::republish_audio_format();
|
|
}
|
|
// Format is published; from here the hook must capture AND mute (the no-echo path).
|
|
const std::uint64_t silenced_before = hook::audio_frames_silenced();
|
|
const DWORD cap_end = GetTickCount() + 200;
|
|
while (GetTickCount() < cap_end)
|
|
{
|
|
tone.render_step(30);
|
|
}
|
|
|
|
const AudioStreamInfo& s = block->status.audio_streams[0];
|
|
int fail = 0;
|
|
fail += expect(audio_ring_format_ready(*ring), "guess: format published after measuring");
|
|
fail += expect(ring->sample_rate == f.rate, "guess: measured rate == rendered rate");
|
|
fail += expect(s.sample_rate == f.rate, "guess: HookStatus rate == rendered rate");
|
|
fail += expect(s.format_state == AudioFormat_Measured, "guess: provenance == Measured");
|
|
fail += expect(ring_has_nonsilent(ring), "guess: non-silent audio captured");
|
|
// THE BROTATO BUG: a guessed (late-attach) stream is byte-compatible here (device
|
|
// channels/bits), so the hook must mute the game's local playback too -- otherwise the
|
|
// game plays locally AND the mirror re-renders it, slightly delayed = a metallic double.
|
|
fail += expect(hook::audio_frames_silenced() > silenced_before,
|
|
"guess: local playback muted (no echo) -- the Brotato double-audio bug");
|
|
std::printf(" %s guess %u Hz (device %uch/%ubit) -> measured %uHz state=%u\n", fail == 0 ? "PASS" : "FAIL",
|
|
rate, f.channels, f.bits, ring->sample_rate, s.format_state);
|
|
|
|
tone.close();
|
|
hook::remove_audio_hooks();
|
|
}
|
|
|
|
// GUESS-MISMATCH: the device channels/bits guess is *wrong* for this stream (its bytes/frame
|
|
// differ). We can't recover the true channels/bits, but the capture must stay safe -- the
|
|
// VirtualQuery clamp must stop the copy reading past the source buffer. We can't assert a
|
|
// "correct" format here (it's fundamentally undetectable); we assert the hook survives and
|
|
// doesn't read absurd amounts, i.e. the unit test completes without an access violation.
|
|
void test_guess_mismatch_safe(hook::IpcClient& ipc, AudioRingHeader* ring, SharedBlock* block,
|
|
const ToneFormat& want, const char* label)
|
|
{
|
|
char d[64];
|
|
reset_ring(ring, block);
|
|
|
|
ToneSource tone;
|
|
if (!tone.open(want))
|
|
{
|
|
std::printf(" SKIP guess-mismatch %s (%s unavailable here)\n", label, fmt_desc(want, d, sizeof(d)));
|
|
return;
|
|
}
|
|
const ToneFormat& f = tone.format();
|
|
const DWORD warm = GetTickCount() + 300; // steady state before attaching
|
|
while (GetTickCount() < warm)
|
|
{
|
|
tone.render_step(30);
|
|
}
|
|
if (!hook::install_audio_hooks(ipc, ring))
|
|
{
|
|
std::printf(" SKIP guess-mismatch (audio hooks unavailable)\n");
|
|
tone.close();
|
|
return;
|
|
}
|
|
|
|
// Render and capture through the guessed (too-large) block. The clamp must keep this
|
|
// from over-reading; reaching the end of the loop is the pass (no AV).
|
|
const DWORD end = GetTickCount() + 600;
|
|
while (GetTickCount() < end)
|
|
{
|
|
tone.render_step(30);
|
|
hook::republish_audio_format();
|
|
}
|
|
std::printf(" PASS guess-mismatch %s (%s) survived capture with a too-large guessed block (no over-read)\n",
|
|
fmt_desc(f, d, sizeof(d)), label);
|
|
|
|
tone.close();
|
|
hook::remove_audio_hooks();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main()
|
|
{
|
|
if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)))
|
|
{
|
|
std::printf("FAIL: CoInitializeEx\n");
|
|
return 1;
|
|
}
|
|
|
|
// Host side: the IPC SharedBlock (named by our pid) the hook's IpcClient connects to,
|
|
// plus one producer ring with capture enabled.
|
|
SharedMemory shm;
|
|
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
|
|
{
|
|
std::printf("FAIL: create shared memory\n");
|
|
return 1;
|
|
}
|
|
auto* block = shm.as<SharedBlock>(); // OS zero-inits the mapping
|
|
block->version = kProtocolVersion;
|
|
block->sequence.store(0, std::memory_order_relaxed);
|
|
block->magic = kProtocolMagic;
|
|
|
|
std::vector<std::uint8_t> ring_storage(audio_ring_total_size(kAudioRingCapacity), 0);
|
|
auto* ring = new (ring_storage.data()) AudioRingHeader();
|
|
audio_ring_init(*ring, kAudioRingCapacity);
|
|
|
|
hook::IpcClient ipc;
|
|
expect(ipc.connect(10, 5), "IPC client connect");
|
|
|
|
// Probe the endpoint once; SKIP cleanly on a headless machine (no render device).
|
|
// Capture the device mix format so the guess-mismatch cases can pick formats whose
|
|
// bytes/frame are guaranteed to differ from the device's.
|
|
ToneFormat dev;
|
|
{
|
|
ToneSource probe;
|
|
if (!probe.open(ToneFormat{}))
|
|
{
|
|
std::printf("SKIP: no default render endpoint (no audio device?)\n");
|
|
CoUninitialize();
|
|
return 0;
|
|
}
|
|
dev = probe.format();
|
|
probe.close();
|
|
}
|
|
std::printf("Device mix format: %u Hz %u ch %u-bit %s\n", dev.rate, dev.channels, dev.bits,
|
|
dev.is_float ? "float" : "pcm");
|
|
|
|
// SEE-INIT: exact full-format detection across common rate/channel/bit-depth combos.
|
|
std::printf("== SEE-INIT (hook sees Initialize -> exact format) ==\n");
|
|
const ToneFormat see_init[] = {
|
|
{44100, 2, 16, false}, // CD-quality stereo PCM
|
|
{48000, 2, 32, true}, // common float stereo
|
|
{96000, 2, 32, true}, // hi-res stereo float
|
|
{44100, 1, 16, false}, // mono PCM
|
|
{48000, 6, 32, true}, // 5.1 float
|
|
};
|
|
for (const ToneFormat& f : see_init)
|
|
{
|
|
test_see_init(ipc, ring, block, f);
|
|
}
|
|
|
|
// GUESS (match): the lazy-discovery path with the device channels/bits, so only the
|
|
// rate differs -- the hook measures + corrects it to the true rate (the Brotato/Godot
|
|
// case). Rendered at the device's channel/bit layout, so the bytes/frame match.
|
|
std::printf("== GUESS, byte-compatible (pre-existing client -> measure the true rate) ==\n");
|
|
for (unsigned rate : {44100u, 48000u, 96000u})
|
|
{
|
|
test_guess(ipc, ring, block, rate);
|
|
}
|
|
|
|
// GUESS (byte-incompatible): a pre-existing client whose channels/bits differ from the
|
|
// device. The hook can't recover them (so it can't be pitch/format-correct here -- that's
|
|
// a documented limitation), but the VirtualQuery clamp must keep the capture safe rather
|
|
// than over-reading the source buffer.
|
|
std::printf("== GUESS, byte-incompatible (channels/bits differ -> capture must stay safe) ==\n");
|
|
if (dev.channels >= 2)
|
|
{
|
|
test_guess_mismatch_safe(ipc, ring, block, {dev.rate, 1, dev.bits, dev.is_float}, "mono");
|
|
}
|
|
{
|
|
const unsigned alt_bits = (dev.bits == 32) ? 16u : 32u;
|
|
const bool alt_float = (alt_bits == 32);
|
|
test_guess_mismatch_safe(ipc, ring, block, {dev.rate, dev.channels, alt_bits, alt_float},
|
|
"alt bit depth");
|
|
}
|
|
|
|
CoUninitialize();
|
|
std::printf(g_failures == 0 ? "AUDIO HOOK TEST PASS\n" : "AUDIO HOOK TEST FAILED (%d)\n", g_failures);
|
|
return g_failures == 0 ? 0 : 1;
|
|
}
|