Files
CoopAllTheThings/tools/audio_probe/main.cpp
BlackMark 4e076420bf 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>
2026-06-19 15:23:30 +02:00

240 lines
8.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] [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;
}
const unsigned long pid = std::wcstoul(argv[1], nullptr, 10);
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)
{
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;
// 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);
}
}
}
// 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);
if (!inject(pid, dll_path_next_to_self()))
{
return 1;
}
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.
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;
}