Files
CoopAllTheThings/tools/audio_probe/main.cpp
BlackMark c7be4eeb9a Fix audio render-hook missing already-playing streams (late injection)
The render-hook only installed IAudioClient/IAudioRenderClient hooks
reactively, when it saw the game call IMMDevice::Activate -> GetService.
But we attach to a game that is already running and playing audio, so its
render client was created before injection: those calls never fire again,
no primary stream is ever registered, nothing is captured, and the host
always falls back to process loopback (the echo). Every game tested did so.

Fix: at anchor time, build our own probe IAudioClient + IAudioRenderClient
with raw calls and hook GetBuffer/ReleaseBuffer (plus Initialize/GetService)
on their vtables. Every instance of a COM coclass shares one vtable, so this
patches the shared vtables and intercepts the game's pre-existing render
client too. The first render client seen actively releasing buffers is
adopted as primary on the audio thread (try-lock, one-time) using the device
mix format as its assumed format (we never saw its Initialize). Streams
created after injection still register via the reactive path with their real
format.

Also fixes a self-deadlock: installing the Activate hook before the probe's
own device->Activate call re-entered hk_Activate -> install_audioclient_hooks,
which blocked on the setup mutex the installer already held, freezing the
worker (and any game thread that later called Activate -> crash). The probe
objects are now created raw, before any hook is installed.

Validated against Phantom Brave (injected while already playing): the
pre-existing 48 kHz/2ch/float render client is detected and registered as
primary, real non-silent audio reaches the ring (peak tracks the game's
levels), and a draining consumer sees zero overruns.

Tooling for iterating on real games without Steam/RPT/the host UI:
- tools/audio_probe: creates the IPC block + audio ring, injects the hook,
  drains the ring and prints stream/format/peak/overrun diagnostics by pid.
- hook/src/debug_log: opt-in file trace (%TEMP%\coop_hook.log), enabled by
  the COOP_HOOK_LOG env var or the %TEMP%\coop_hook.log.on sentinel the probe
  drops; off in normal use.

All four tests still pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 14:53:52 +02:00

213 lines
7.0 KiB
C++

// coop_audio_probe — standalone harness to bring up the injected audio
// render-hook against a real game without Steam / RPT / the host UI.
//
// Given a target pid it: creates the input SharedBlock and the audio ring the
// hook expects (named by that pid), enables capture, injects coop_hook.dll, then
// polls and prints the hook's status back-channel and the audio ring counters
// for a while. The hook writes a detailed trace to %TEMP%\coop_hook.log.
//
// coop_audio_probe <pid> [seconds]
//
// Run from the same directory as coop_hook.dll (i.e. bin/<config>/).
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <cstdlib>
#include <string>
#include <vector>
#include <windows.h>
#include "coop/audio_ring.hpp"
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
namespace
{
std::wstring dll_path_next_to_self()
{
wchar_t exe[MAX_PATH] = {};
GetModuleFileNameW(nullptr, exe, MAX_PATH);
std::wstring path(exe);
const size_t slash = path.find_last_of(L"\\/");
if (slash != std::wstring::npos)
{
path.resize(slash + 1);
}
return path + L"coop_hook.dll";
}
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 at the probe's directory.\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;
}
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<LPTHREAD_START_ROUTINE>(
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);
if (!ok)
{
std::printf("ERROR: injection failed (%lu).\n", GetLastError());
}
return ok;
}
} // namespace
int wmain(int argc, wchar_t** argv)
{
if (argc < 2)
{
std::printf("usage: coop_audio_probe <pid> [seconds]\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;
if (pid == 0)
{
std::printf("ERROR: invalid pid.\n");
return 1;
}
// 1) Input SharedBlock (the hook's worker exits if it can't connect to this).
coop::SharedMemory ipc;
if (!ipc.create(coop::shared_memory_name(pid), sizeof(coop::SharedBlock)))
{
std::printf("ERROR: create input mapping failed (%lu).\n", GetLastError());
return 1;
}
auto* block = ipc.as<coop::SharedBlock>();
block->version = coop::kProtocolVersion;
block->pad_count = 0;
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::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.
{
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);
}
}
}
// 3) 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
// overrun) and measure peak amplitude to prove we captured real audio.
const coop::HookStatus& status = block->status;
std::uint64_t prev_frames[coop::kMaxAudioStreams] = {};
std::vector<std::uint8_t> drain(coop::kAudioRingCapacity);
for (int t = 0; t < seconds * 2; ++t)
{
Sleep(500);
// Consume everything available and find the peak sample magnitude.
double peak = 0.0;
std::uint32_t got = 0;
while ((got = coop::audio_ring_pop(*ring, drain.data(), static_cast<std::uint32_t>(drain.size()))) > 0)
{
if (ring->format_tag == 3 /*IEEE_FLOAT*/ && ring->bits == 32)
{
const auto* f = reinterpret_cast<const float*>(drain.data());
for (std::uint32_t i = 0; i < got / 4; ++i)
{
peak = std::max(peak, static_cast<double>(std::abs(f[i])));
}
}
else if (ring->bits == 16)
{
const auto* s = reinterpret_cast<const std::int16_t*>(drain.data());
for (std::uint32_t i = 0; i < got / 2; ++i)
{
peak = std::max(peak, std::abs(s[i]) / 32768.0);
}
}
if (got < drain.size())
{
break;
}
}
const std::uint32_t streams = status.audio_streams_seen;
const std::uint32_t heartbeat = status.heartbeat.load(std::memory_order_relaxed);
const std::uint64_t produced = ring->frames_produced.load(std::memory_order_relaxed);
const std::uint64_t overruns = ring->overruns.load(std::memory_order_relaxed);
const bool fmt_ready = coop::audio_ring_format_ready(*ring);
std::printf("[%4.1fs] hb=%u streams=%u peak=%.4f ring{fmt=%d %uHz/%uch/%ubit produced=%llu "
"overruns=%llu}\n",
(t + 1) * 0.5, heartbeat, streams, peak, fmt_ready ? 1 : 0, ring->sample_rate,
ring->channels, ring->bits, static_cast<unsigned long long>(produced),
static_cast<unsigned long long>(overruns));
for (std::uint32_t i = 0; i < coop::kMaxAudioStreams && i < streams; ++i)
{
const coop::AudioStreamInfo& s = status.audio_streams[i];
const bool live = s.frames_rendered > prev_frames[i];
prev_frames[i] = s.frames_rendered;
std::printf(" stream %u %s %uHz/%uch/%ubit tag=%u frames=%llu %s\n", i,
s.is_primary ? "PRIMARY" : "extra ", s.sample_rate, s.channels, s.bits,
s.format_tag, static_cast<unsigned long long>(s.frames_rendered),
live ? "<live>" : "");
}
}
std::printf("\nDone. Leaving the hook loaded in the game.\n");
block->magic = 0; // invalidate so a late hook read won't trust stale data
return 0;
}