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>
240 lines
8.0 KiB
C++
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;
|
|
}
|