// coop_audio_validate -- quantify the fidelity of the injection audio-capture path. // // "The audio sounds slightly off" is hard to act on; this turns it into numbers. It // plays a known sine tone (coop_tone), injects coop_hook.dll exactly as the host does // (late attach: the ring is created after injection, so the hook must guess+measure the // rate -- the Brotato/Godot case), captures the hook's ring into memory, and runs the // fidelity analyzer (coop/tone_analysis.hpp): pitch error in cents, SNR/THD, click + // dropout counts. It also writes the captured audio to a .wav so it can be *listened* to. // // coop_audio_validate # spawn coop_tone @ 44100 Hz / 1000 Hz, full self-test // coop_audio_validate --rate 48000 --freq 440 --seconds 8 // coop_audio_validate --pid 1234 --freq 1000 # attach to an already-running tone/game // coop_audio_validate --wav capture.wav --freq 1000 # just analyze a recorded .wav (e.g. a // # host render-output dump from real Brotato) // // Run from bin//tools/ (next to the deployable root that holds coop_hook.dll; // coop_tone.exe is found in the sibling tests/ folder). The hook trace is %TEMP%\coop_hook.log. #include #include #include #include #include #include #include #include #include #include #include #include #include "audio/audio_loopback.hpp" #include "audio/process_loopback_capture.hpp" #include "coop/audio_ring.hpp" #include "coop/protocol.hpp" #include "coop/shared_memory.hpp" #include "coop/tone_analysis.hpp" #include "coop/tool_paths.hpp" #include "coop/wav.hpp" #include "tone_source.hpp" // in-process sine renderer (shared with coop_tone), for --selfcheck namespace { struct Options { unsigned long pid = 0; // attach to this pid instead of spawning coop_tone unsigned long listen = 0; // --listen: passively loopback-capture this pid's output (e.g. coop_host) double freq = 1000.0; // the tone frequency (for pitch analysis) unsigned rate = 44100; // tone render rate (the Brotato/Godot non-device case by default) unsigned channels = 2; unsigned bits = 32; // 32 = float, 16 = pcm int seconds = 6; // capture duration bool render = false; // --render: measure the host RENDER path (run_hooked), not just capture bool baseline = false; // --baseline: loopback-capture the tone directly (no hook/mirror) as a floor bool selfcheck = false; // --selfcheck: render a clean tone in-process + self-capture (control for self-capture) std::wstring wav_in; // analyze this .wav instead of capturing std::wstring wav_out; // where to dump the captured audio (default next to the exe) }; std::wstring sibling(const std::wstring& path, const wchar_t* name) { const std::size_t slash = path.find_last_of(L"\\/"); return (slash == std::wstring::npos ? std::wstring() : path.substr(0, slash + 1)) + name; } // coop_tone.exe is staged in bin//tests/; this tool runs from bin//tools/. std::wstring find_coop_tone() { const std::wstring here = coop::exe_directory() + L"coop_tone.exe"; if (GetFileAttributesW(here.c_str()) != INVALID_FILE_ATTRIBUTES) { return here; } std::wstring dir = coop::exe_directory(); if (!dir.empty()) { dir.pop_back(); } const std::size_t slash = dir.find_last_of(L"\\/"); const std::wstring root = (slash == std::wstring::npos) ? std::wstring() : dir.substr(0, slash + 1); const std::wstring in_tests = root + L"tests\\coop_tone.exe"; if (GetFileAttributesW(in_tests.c_str()) != INVALID_FILE_ATTRIBUTES) { return in_tests; } return here; } // --- injection (mirrors coop_audio_probe, incl. the x86 WOW64 helper) ------------------- bool inject_via_helper(unsigned long pid, const std::wstring& dll_path) { const std::wstring helper = sibling(dll_path, L"coop_inject_x86.exe"); const std::wstring x86_dll = sibling(dll_path, L"coop_hook_x86.dll"); if (GetFileAttributesW(helper.c_str()) == INVALID_FILE_ATTRIBUTES || GetFileAttributesW(x86_dll.c_str()) == INVALID_FILE_ATTRIBUTES) { std::printf("ERROR: x86 helper/dll missing next to the tool.\n"); return false; } std::wstring cmd = L"\"" + helper + L"\" " + std::to_wstring(pid) + L" \"" + x86_dll + L"\""; STARTUPINFOW si{}; si.cb = sizeof(si); PROCESS_INFORMATION pi{}; if (!CreateProcessW(helper.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si, &pi)) { std::printf("ERROR: CreateProcess(coop_inject_x86) failed (%lu).\n", GetLastError()); return false; } WaitForSingleObject(pi.hProcess, INFINITE); DWORD code = 1; GetExitCodeProcess(pi.hProcess, &code); CloseHandle(pi.hThread); CloseHandle(pi.hProcess); return code == 0; } bool inject(unsigned long pid, const std::wstring& dll_path) { if (GetFileAttributesW(dll_path.c_str()) == INVALID_FILE_ATTRIBUTES) { std::printf("ERROR: coop_hook.dll not found next to the tool.\n"); return false; } const DWORD access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ; HANDLE process = OpenProcess(access, FALSE, pid); if (process == nullptr) { std::printf("ERROR: OpenProcess(%lu) failed (%lu). Run as administrator?\n", pid, GetLastError()); return false; } USHORT proc_machine = IMAGE_FILE_MACHINE_UNKNOWN, native_machine = IMAGE_FILE_MACHINE_UNKNOWN; if (IsWow64Process2(process, &proc_machine, &native_machine) && proc_machine != IMAGE_FILE_MACHINE_UNKNOWN) { CloseHandle(process); return inject_via_helper(pid, dll_path); } const SIZE_T bytes = (dll_path.size() + 1) * sizeof(wchar_t); void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); bool ok = false; if (remote != nullptr && WriteProcessMemory(process, remote, dll_path.c_str(), bytes, nullptr)) { auto load_library = reinterpret_cast(GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW")); HANDLE thread = CreateRemoteThread(process, nullptr, 0, load_library, remote, 0, nullptr); if (thread != nullptr) { WaitForSingleObject(thread, INFINITE); DWORD exit_code = 0; GetExitCodeThread(thread, &exit_code); CloseHandle(thread); ok = (exit_code != 0); } } if (remote != nullptr) { VirtualFreeEx(process, remote, 0, MEM_RELEASE); } CloseHandle(process); return ok; } void enable_hook_trace() { wchar_t dir[MAX_PATH] = {}; if (GetTempPathW(MAX_PATH, dir) != 0) { const std::wstring sentinel = std::wstring(dir) + L"coop_hook.log.on"; HANDLE h = CreateFileW(sentinel.c_str(), GENERIC_WRITE, FILE_SHARE_READ, nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); if (h != INVALID_HANDLE_VALUE) { CloseHandle(h); } } } // Spawn coop_tone at the requested format; parse "TONE_RENDERING pid=NNN ..." from its // stdout. Returns the tone process + its pid (0 on failure). We keep the handle so the // tone keeps playing for the whole capture and is killed at the end. HANDLE spawn_tone(const Options& o, unsigned long& tone_pid) { const std::wstring exe = find_coop_tone(); if (GetFileAttributesW(exe.c_str()) == INVALID_FILE_ATTRIBUTES) { std::printf("ERROR: coop_tone.exe not found (looked next to the tool and in ../tests/).\n"); return nullptr; } HANDLE rd = nullptr, wr = nullptr; SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; if (!CreatePipe(&rd, &wr, &sa, 0)) { return nullptr; } SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); // coop_tone [seconds] [freq] [rate] [channels] [bits] [float|pcm] const wchar_t* kind = (o.bits == 32) ? L"float" : L"pcm"; std::wstring cmd = L"\"" + exe + L"\" " + std::to_wstring(o.seconds + 4) + L" " + std::to_wstring(static_cast(o.freq)) + L" " + std::to_wstring(o.rate) + L" " + std::to_wstring(o.channels) + L" " + std::to_wstring(o.bits) + L" " + kind; STARTUPINFOW si{}; si.cb = sizeof(si); si.dwFlags = STARTF_USESTDHANDLES; si.hStdOutput = wr; si.hStdError = wr; PROCESS_INFORMATION pi{}; const BOOL launched = CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, TRUE, 0, nullptr, nullptr, &si, &pi); CloseHandle(wr); if (!launched) { std::printf("ERROR: CreateProcess(coop_tone) failed (%lu).\n", GetLastError()); CloseHandle(rd); return nullptr; } CloseHandle(pi.hThread); tone_pid = pi.dwProcessId; // Read the first line ("TONE_RENDERING ...") so we know audio is actually flowing. std::string line; char ch = 0; DWORD got = 0; const DWORD start = GetTickCount(); while (GetTickCount() - start < 5000) { if (ReadFile(rd, &ch, 1, &got, nullptr) && got == 1) { if (ch == '\n') { break; } if (ch != '\r') { line.push_back(ch); } } else { break; } } CloseHandle(rd); if (line.rfind("TONE_RENDERING", 0) == 0) { std::printf("coop_tone: %s\n", line.c_str()); return pi.hProcess; } std::printf("ERROR: coop_tone did not start rendering (got: \"%s\").\n", line.c_str()); TerminateProcess(pi.hProcess, 1); CloseHandle(pi.hProcess); tone_pid = 0; return nullptr; } // Capture the hook's audio ring for `seconds`, draining frequently so the tool itself // never causes an overrun -- the captured buffer is then exactly what the hook produced. // Fills `pcm` (interleaved) and reports the declared format. Returns false if no format. bool capture_ring(coop::AudioRingHeader* ring, int seconds, std::vector& pcm, std::uint32_t& rate, std::uint32_t& channels, std::uint32_t& bits, std::uint32_t& format_tag, std::uint64_t& overruns) { // Wait up to 8 s for the hook to publish a format (late attach measures the rate first). const DWORD wait_end = GetTickCount() + 8000; while (!coop::audio_ring_format_ready(*ring)) { if (GetTickCount() >= wait_end) { std::printf("ERROR: hook never published an audio format (no stream captured).\n"); return false; } Sleep(20); } rate = ring->sample_rate; channels = ring->channels; bits = ring->bits; format_tag = ring->format_tag; std::printf("Hook published format: %u Hz / %u ch / %u-bit / tag %u. Capturing %d s...\n", rate, channels, bits, format_tag, seconds); std::vector scratch(coop::kAudioRingCapacity); const DWORD cap_end = GetTickCount() + static_cast(seconds) * 1000; while (GetTickCount() < cap_end) { std::uint32_t got = coop::audio_ring_pop(*ring, scratch.data(), static_cast(scratch.size())); if (got > 0) { pcm.insert(pcm.end(), scratch.begin(), scratch.begin() + got); } else { Sleep(2); // ring momentarily empty; poll again shortly } } // Drain any tail. std::uint32_t got = 0; while ((got = coop::audio_ring_pop(*ring, scratch.data(), static_cast(scratch.size()))) > 0) { pcm.insert(pcm.end(), scratch.begin(), scratch.begin() + got); } overruns = ring->overruns.load(std::memory_order_relaxed); return !pcm.empty(); } void print_report(const coop::ToneReport& r, double expected_hz, std::uint32_t declared_rate, std::uint64_t overruns) { std::printf("\n================ FIDELITY REPORT ================\n"); std::printf(" samples analyzed : %zu frames (%.2f s @ %u Hz)\n", r.frames, r.duration_sec, r.sample_rate); std::printf(" level : RMS %.4f peak %.4f clipped %.3f%%\n", r.rms, r.peak, r.clipped_fraction * 100.0); if (expected_hz > 0.0) { std::printf(" PITCH : %.2f Hz captured vs %.2f Hz played -> %+.1f cents (x%.4f)\n", r.dominant_hz, expected_hz, r.pitch_error_cents, r.pitch_error_ratio); // If the pitch is off, the most likely cause is a wrong declared rate. Show the rate // the captured pitch implies, so a misdetection is obvious at a glance. if (r.pitch_error_ratio > 0.0) { const double implied_true_rate = declared_rate / r.pitch_error_ratio; std::printf(" implied true rate: ~%.0f Hz (declared %u Hz)%s\n", implied_true_rate, declared_rate, std::fabs(r.pitch_error_cents) > 15.0 ? " <-- MISMATCH" : ""); } std::printf(" spectral purity : SNR %.1f dB THD %.3f%%\n", r.snr_db, r.thd_percent); } std::printf(" discontinuities : %u clicks (%.2f/s)\n", r.glitch_count, r.glitch_rate_per_sec); std::printf(" dropouts : %u gaps, %.1f ms total\n", r.dropout_count, r.dropout_ms); if (overruns != UINT64_MAX) { std::printf(" ring overruns : %llu (host fell behind -> dropped packets)\n", static_cast(overruns)); } std::printf("------------------- VERDICT --------------------\n"); int problems = 0; if (expected_hz > 0.0 && std::fabs(r.pitch_error_cents) > 15.0) { std::printf(" [X] PITCH SHIFT: captured rate is wrong (audible). Likely a mis-measured\n" " late-attach rate -- see implied true rate above.\n"); ++problems; } if (r.dropout_count > 0) { std::printf(" [X] DROPOUTS: %u silence gap(s) -- choppy / 'metallic' under-run artifacts.\n", r.dropout_count); ++problems; } if (r.glitch_rate_per_sec > 1.0) { std::printf(" [X] CLICKS: %.1f discontinuities/s -- torn/dropped packets.\n", r.glitch_rate_per_sec); ++problems; } if (expected_hz > 0.0 && r.snr_db < 40.0) { std::printf(" [X] DISTORTION: SNR %.1f dB is low for a pure tone.\n", r.snr_db); ++problems; } if (problems == 0) { std::printf(" [OK] Captured audio is faithful (pitch, purity, continuity all good).\n"); } std::printf("=================================================\n"); } // Resolve a WAVEFORMATEX (possibly EXTENSIBLE) to the scalar fields the analyzer wants. void resolve_waveformat(const WAVEFORMATEX* w, std::uint32_t& rate, std::uint32_t& channels, std::uint32_t& bits, std::uint32_t& tag) { rate = w->nSamplesPerSec; channels = w->nChannels; bits = w->wBitsPerSample; tag = w->wFormatTag; if (w->wFormatTag == WAVE_FORMAT_EXTENSIBLE && w->cbSize >= 22) { const auto* ext = reinterpret_cast(w); tag = (ext->SubFormat == KSDATAFORMAT_SUBTYPE_IEEE_FLOAT) ? coop::kToneFormatFloat : coop::kToneFormatPcm; } } // Loopback-capture `pid`'s render output (device-clock faithful, gaps included) for // `seconds`, into `pcm`, and report the device format. Shared by --render (self) and // --baseline (the tone directly). Assumes COM is already initialized on this thread. bool loopback_capture_pid(unsigned long pid, int seconds, std::vector& pcm, std::uint32_t& rate, std::uint32_t& channels, std::uint32_t& bits, std::uint32_t& tag, std::uint32_t& block_align) { WAVEFORMATEX* mix = coop::default_render_format(); if (mix == nullptr) { std::printf("ERROR: could not get the default render format.\n"); return false; } resolve_waveformat(mix, rate, channels, bits, tag); block_align = mix->nBlockAlign; std::mutex m; coop::ProcessLoopbackCapture cap; const bool ok = cap.start(pid, mix, [&](const BYTE* data, std::uint32_t frames, bool silent) { const std::size_t bytes = static_cast(frames) * mix->nBlockAlign; std::lock_guard lk(m); if (silent || data == nullptr) { pcm.insert(pcm.end(), bytes, 0); } else { pcm.insert(pcm.end(), data, data + bytes); } }); if (ok) { Sleep(static_cast(seconds) * 1000); } cap.stop(); CoTaskMemFree(mix); return ok; } // --listen: passively loopback-capture an already-running process's render output (e.g. the // live coop_host while it mirrors a real game). On the hooked path the game is silenced, so // coop_host's render mix IS exactly what the guest hears -- this records it to a .wav and runs // the analyzer. Pass --freq for an in-game test tone to get pitch numbers; otherwise the level / // click / dropout metrics still apply to real game audio. No injection, no mirror -- just listen. int run_listen_mode(const Options& o) { const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); std::printf("Listening to pid %lu's render output for %d s (e.g. the live coop_host mirror)...\n", o.listen, o.seconds); std::vector pcm; std::uint32_t rate = 0, channels = 0, bits = 0, tag = 0, block = 0; const bool ok = loopback_capture_pid(o.listen, o.seconds, pcm, rate, channels, bits, tag, block); int rc = 1; if (ok && !pcm.empty()) { std::wstring out = o.wav_out.empty() ? (coop::exe_directory() + L"coop_listen.wav") : o.wav_out; if (coop::wav_write(out, pcm.data(), pcm.size(), rate, channels, bits, tag)) { std::wprintf(L"Wrote captured output: %ls\n", out.c_str()); } // Trim the first ~0.7 s for analysis (loopback capture ramp-up) -- the .wav keeps it all. const std::size_t skip = std::min(pcm.size(), static_cast(rate) * block * 7 / 10); auto mono = coop::decode_channel(pcm.data() + skip, pcm.size() - skip, tag, bits, channels, 0); if (!mono.empty()) { std::printf("\n[LISTEN] pid %lu render output (what the guest hears):\n", o.listen); const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq); print_report(r, o.freq, rate, UINT64_MAX); rc = 0; } } else { std::printf("ERROR: no audio captured from pid %lu (is it rendering?).\n", o.listen); } if (com_ok) { CoUninitialize(); } return rc; } // --selfcheck: render a clean sine IN THIS PROCESS (no mirror) and self-loopback-capture // it. The control for --render: it shares the exact self-capture path but with a known-good // renderer, so if it reads clean (~60 dB, no gaps) then any defect --render shows is the // mirror's, not an artifact of capturing our own process. int run_selfcheck_mode(const Options& o) { const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); std::atomic stop{false}; std::thread renderer([&]() { if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) { return; } coop::tone::ToneSource tone; coop::tone::ToneFormat tf; // {} = device mix format (no resample), cleanest reference if (tone.open(tf, o.freq)) { while (!stop.load(std::memory_order_relaxed)) { tone.render_step(100); } tone.close(); } CoUninitialize(); }); Sleep(500); // let the in-process tone reach steady state std::printf("Self-rendering a clean %.0f Hz tone in-process + self-capturing (control)...\n", o.freq); std::vector pcm; std::uint32_t rate = 0, channels = 0, bits = 0, tag = 0, block = 0; const bool ok = loopback_capture_pid(GetCurrentProcessId(), o.seconds, pcm, rate, channels, bits, tag, block); stop.store(true, std::memory_order_relaxed); renderer.join(); int rc = 1; if (ok && !pcm.empty()) { const std::size_t skip = std::min(pcm.size(), static_cast(rate) * block * 7 / 10); auto mono = coop::decode_channel(pcm.data() + skip, pcm.size() - skip, tag, bits, channels, 0); if (!mono.empty()) { std::printf("\n[SELFCHECK] in-process tone via the self-capture path (control):\n"); const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq); print_report(r, o.freq, rate, UINT64_MAX); rc = 0; } } else { std::printf("ERROR: selfcheck produced no audio.\n"); } if (com_ok) { CoUninitialize(); } return rc; } // --baseline: loopback-capture the tone process DIRECTLY -- no hook, no mirror. This is the // fidelity floor of the measurement chain itself (the tone's own AUTOCONVERTPCM render + the // process-loopback capture). Comparing --render against this floor separates a real mirror // defect from the measurement's own noise. int run_baseline_mode(const Options& o, HANDLE tone_proc, unsigned long target_pid) { const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); std::printf("Loopback-capturing the tone directly (no hook, no mirror) -- measurement floor...\n"); std::vector pcm; std::uint32_t rate = 0, channels = 0, bits = 0, tag = 0, block = 0; const bool ok = loopback_capture_pid(target_pid, o.seconds, pcm, rate, channels, bits, tag, block); int rc = 1; if (ok && !pcm.empty()) { const std::size_t skip = std::min(pcm.size(), static_cast(rate) * block * 7 / 10); auto mono = coop::decode_channel(pcm.data() + skip, pcm.size() - skip, tag, bits, channels, 0); if (!mono.empty()) { std::printf("\n[BASELINE] tone direct (measurement floor):\n"); const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq); print_report(r, o.freq, rate, UINT64_MAX); rc = 0; } } else { std::printf("ERROR: baseline loopback capture produced no audio.\n"); } if (tone_proc != nullptr) { TerminateProcess(tone_proc, 0); CloseHandle(tone_proc); } if (com_ok) { CoUninitialize(); } return rc; } // --render: measure the host's RENDER path, not just the capture ring. We drive the REAL // shipping AudioMirror (its run_hooked re-renders the captured ring to the output device, // silencing the game), and at the same time loopback-capture THIS process's own audio -- // which is exactly what AudioMirror renders, *including* any under-run silence gaps the // device actually played. That makes the choppy / "metallic" re-prime artifact visible // (a write-side tap would miss it: the gap is silence the device inserts, not bytes we wrote). int run_render_mode(const Options& o, HANDLE tone_proc, unsigned long target_pid) { const bool com_ok = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); WAVEFORMATEX* mix = coop::default_render_format(); if (mix == nullptr) { std::printf("ERROR: could not get the default render format.\n"); if (com_ok) { CoUninitialize(); } return 1; } std::uint32_t rate = 0, channels = 0, bits = 0, tag = 0; resolve_waveformat(mix, rate, channels, bits, tag); std::printf("Render endpoint mix format: %u Hz / %u ch / %u-bit / tag %u\n", rate, channels, bits, tag); // Capture our own render output (the mirror's). Game audio is silenced on the hooked // path, so our process's render mix == exactly what the guest would hear. std::vector rendered; std::mutex rendered_mutex; coop::ProcessLoopbackCapture selfcap; const bool cap_ok = selfcap.start(GetCurrentProcessId(), mix, [&](const BYTE* data, std::uint32_t frames, bool silent) { const std::size_t bytes = static_cast(frames) * mix->nBlockAlign; std::lock_guard lk(rendered_mutex); if (silent || data == nullptr) { rendered.insert(rendered.end(), bytes, 0); } else { rendered.insert(rendered.end(), data, data + bytes); } }); if (!cap_ok) { std::printf("ERROR: self-loopback capture failed to start.\n"); CoTaskMemFree(mix); if (com_ok) { CoUninitialize(); } return 1; } // Drive the real mirror: it discovers the hook's ring, re-renders it (silencing the game). coop::AudioMirror mirror; if (!mirror.start(target_pid)) { std::printf("ERROR: AudioMirror failed to start.\n"); } std::printf("Rendering through the real AudioMirror for %d s (source warms up, then measure)...\n", o.seconds); Sleep(static_cast(o.seconds) * 1000); std::printf(" mirror: source=%s status=\"%s\" buffered=%u ms\n", mirror.source_name(), mirror.status().c_str(), mirror.buffered_ms()); mirror.stop(); selfcap.stop(); std::vector pcm; { std::lock_guard lk(rendered_mutex); pcm.swap(rendered); } // Trim the first ~0.7 s: it contains start-up priming / the loopback warming up, which // would otherwise read as a spurious leading dropout. const std::size_t skip = std::min(pcm.size(), static_cast(rate) * mix->nBlockAlign * 7 / 10); const std::uint8_t* body = pcm.data() + skip; const std::size_t body_bytes = pcm.size() - skip; std::wstring out = o.wav_out.empty() ? (coop::exe_directory() + L"coop_render.wav") : o.wav_out; if (coop::wav_write(out, body, body_bytes, rate, channels, bits, tag)) { std::wprintf(L"Wrote rendered output: %ls\n", out.c_str()); } auto mono = coop::decode_channel(body, body_bytes, tag, bits, channels, 0); if (mono.empty()) { std::printf("NOTE: render format isn't float32/int16; WAV written, analysis skipped.\n"); } else { std::printf("\n[RENDER PATH] what the guest actually hears (real AudioMirror output):\n"); const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq); print_report(r, o.freq, rate, UINT64_MAX); } CoTaskMemFree(mix); if (tone_proc != nullptr) { TerminateProcess(tone_proc, 0); CloseHandle(tone_proc); } if (com_ok) { CoUninitialize(); } return mono.empty() ? 1 : 0; } bool parse_args(int argc, wchar_t** argv, Options& o) { for (int i = 1; i < argc; ++i) { const std::wstring a = argv[i]; auto next = [&](unsigned& dst) { if (i + 1 < argc) { dst = static_cast(_wtoi(argv[++i])); } }; if (a == L"--pid" && i + 1 < argc) { o.pid = std::wcstoul(argv[++i], nullptr, 10); } else if (a == L"--listen" && i + 1 < argc) { o.listen = std::wcstoul(argv[++i], nullptr, 10); } else if (a == L"--freq" && i + 1 < argc) { o.freq = _wtof(argv[++i]); } else if (a == L"--rate") { next(o.rate); } else if (a == L"--channels") { next(o.channels); } else if (a == L"--bits") { next(o.bits); } else if (a == L"--seconds" && i + 1 < argc) { o.seconds = std::max(1, _wtoi(argv[++i])); } else if (a == L"--render") { o.render = true; } else if (a == L"--baseline") { o.baseline = true; } else if (a == L"--selfcheck") { o.selfcheck = true; } else if (a == L"--wav" && i + 1 < argc) { o.wav_in = argv[++i]; } else if (a == L"--out" && i + 1 < argc) { o.wav_out = argv[++i]; } else if (a == L"--help" || a == L"-h") { return false; } } return true; } } // namespace int wmain(int argc, wchar_t** argv) { Options o; if (!parse_args(argc, argv, o)) { std::printf("usage: coop_audio_validate [--pid N] [--listen N] [--freq Hz] [--rate Hz]\n" " [--channels N] [--bits 16|32] [--seconds N] [--render | --baseline | --selfcheck]\n" " [--wav file] [--out file]\n" " (no args) spawn coop_tone @ 44100/1000 Hz, capture the hook ring, analyze.\n" " --render also drive the real AudioMirror and measure its rendered output\n" " (surfaces under-run / re-prime 'metallic' gaps the capture side can't show).\n" " --baseline loopback-capture the tone directly (no hook/mirror) = the measurement floor.\n" " --selfcheck render a clean tone in-process + self-capture (control for the self-capture path).\n" " --listen N passively record pid N's output to a .wav and analyze it -- point it at the\n" " live coop_host to hear/quantify exactly what the guest gets on a real game.\n" " --wav F just analyze a recorded .wav.\n"); return 1; } // --- Mode C: analyze a recorded .wav --------------------------------------------- if (!o.wav_in.empty()) { coop::WavData wd; if (!coop::wav_read(o.wav_in, wd)) { std::wprintf(L"ERROR: could not read WAV '%ls'.\n", o.wav_in.c_str()); return 1; } std::printf("Loaded WAV: %u Hz / %u ch / %u-bit / tag %u, %zu bytes\n", wd.sample_rate, wd.channels, wd.bits, wd.format_tag, wd.pcm.size()); auto mono = coop::decode_channel(wd.pcm.data(), wd.pcm.size(), wd.format_tag, wd.bits, wd.channels, 0); if (mono.empty()) { std::printf("ERROR: unsupported WAV sample format (need 16-bit PCM or 32-bit float).\n"); return 1; } const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), wd.sample_rate, o.freq); print_report(r, o.freq, wd.sample_rate, UINT64_MAX); return 0; } // --- Control: render a clean tone in-process + self-capture (no target needed) ---- if (o.selfcheck) { return run_selfcheck_mode(o); } // --- Live: passively record an already-running process's output (e.g. coop_host) -- if (o.listen != 0) { return run_listen_mode(o); } // --- Acquire a target: spawn coop_tone, or attach to a given pid ------------------ HANDLE tone_proc = nullptr; unsigned long target_pid = o.pid; if (target_pid == 0) { tone_proc = spawn_tone(o, target_pid); if (tone_proc == nullptr) { return 1; } Sleep(700); // let the tone reach steady state before we inject } else { std::printf("Attaching to existing pid %lu (tone freq assumed %.0f Hz).\n", target_pid, o.freq); } // --- Mode: measurement floor (no hook, no mirror) --------------------------------- if (o.baseline) { return run_baseline_mode(o, tone_proc, target_pid); } // --- Set up the IPC the hook expects, then inject (late attach: ring AFTER inject) - coop::SharedMemory ipc; if (!ipc.create(coop::shared_memory_name(target_pid), sizeof(coop::SharedBlock))) { std::printf("ERROR: create input mapping failed (%lu).\n", GetLastError()); return 1; } auto* block = ipc.as(); block->version = coop::kProtocolVersion; block->pad_count = 0; block->sequence.store(0, std::memory_order_relaxed); block->magic = coop::kProtocolMagic; enable_hook_trace(); std::printf("Injecting coop_hook.dll into pid %lu ...\n", target_pid); if (!inject(target_pid, coop::deployed_artifact_path(L"coop_hook.dll"))) { std::printf("ERROR: injection failed.\n"); return 1; } // --- Mode B: measure the host RENDER path (real AudioMirror) ---------------------- if (o.render) { Sleep(1200); // let the hook register the stream before the mirror reads it const int rc = run_render_mode(o, tone_proc, target_pid); block->magic = 0; return rc; } // Create the audio ring ~1.5 s after injection -- this is the real app's ordering (the // host creates the ring only when audio mirroring is toggled on), and it forces the // hook's late-attach guess+measure path (the exact Brotato scenario). Sleep(1500); coop::SharedMemory ring_shm; if (!ring_shm.create(coop::audio_ring_name(target_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); // --- Capture + analyze ------------------------------------------------------------ std::vector pcm; std::uint32_t rate = 0, channels = 0, bits = 0, format_tag = 0; std::uint64_t overruns = 0; const bool captured = capture_ring(ring, o.seconds, pcm, rate, channels, bits, format_tag, overruns); if (captured) { // Dump the captured audio so it can be listened to. std::wstring out = o.wav_out.empty() ? (coop::exe_directory() + L"coop_capture.wav") : o.wav_out; if (coop::wav_write(out, pcm.data(), pcm.size(), rate, channels, bits, format_tag)) { std::wprintf(L"Wrote captured audio: %ls\n", out.c_str()); } auto mono = coop::decode_channel(pcm.data(), pcm.size(), format_tag, bits, channels, 0); if (mono.empty()) { std::printf("NOTE: captured format isn't float32/int16, can't decode for analysis (WAV still written).\n"); } else { const coop::ToneReport r = coop::analyze_tone(mono.data(), mono.size(), rate, o.freq); print_report(r, o.freq, rate, overruns); } } block->magic = 0; // invalidate so a late hook read won't trust stale data if (tone_proc != nullptr) { TerminateProcess(tone_proc, 0); CloseHandle(tone_proc); } return captured ? 0 : 1; }