Files
CoopAllTheThings/tests/mock_game_test.cpp
BlackMark 2d65961bf2 Test: cover audio format variants in the mock-game stress suite
mock_game_test now launches coop_mock_game at 44100/48000/96000 (PCM + float)
and asserts the hook measures each variant's rate through the full inject path
(+ non-silent capture) -- the "all audio variants work" part of the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 05:34:50 +02:00

541 lines
17 KiB
C++

// Comprehensive capture/audio/hook stress test against coop_mock_game.
//
// Launches the mock game (an animated, frame-numbered A/V source), injects coop_hook.dll,
// and drives the real shared-memory paths the host uses -- no Steam, no host UI. For each
// graphics backend (DX11, DX12) it opens the hook's shared video texture, decodes the
// frame number out of the captured pixels, and asserts the mirror sees a *monotonic,
// advancing* sequence (this is what the DX12 rotating-backbuffer bug broke -- it showed
// stale/repeated frames). A final A/V test injects with audio + video, checks both stream,
// cycles the audio subsystem off/on a few times (hook/unhook stress), and confirms the
// game never freezes/crashes. Skips cleanly without a D3D11 device.
#include <algorithm>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
#include <windows.h>
#include <tlhelp32.h>
#include <d3d11.h>
#include "capture/shared_texture.hpp"
#include "coop/audio_ring.hpp"
#include "coop/log_ring.hpp"
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
#include "coop/tool_paths.hpp"
#include "render_backend.hpp" // coop::mock::rgb_to_frame / kFrameBlock
using namespace coop;
namespace
{
int g_failures = 0;
void check(bool ok, const char* what)
{
std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what);
if (!ok)
{
++g_failures;
}
}
std::wstring tool_path(const wchar_t* name)
{
return exe_directory() + name; // coop_mock_game.exe is staged next to this test
}
// Kill any leftover mock games from a previous (crashed/interrupted) run, so a stray one
// holding coop_hook.dll can't perturb this run.
void kill_stray_mock_games()
{
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE)
{
return;
}
PROCESSENTRY32W pe{};
pe.dwSize = sizeof(pe);
for (BOOL ok = Process32FirstW(snap, &pe); ok; ok = Process32NextW(snap, &pe))
{
if (_wcsicmp(pe.szExeFile, L"coop_mock_game.exe") == 0)
{
HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID);
if (h != nullptr)
{
TerminateProcess(h, 0);
CloseHandle(h);
}
}
}
CloseHandle(snap);
}
// Inject coop_hook.dll (x64 -> the mock game is x64) into `pid` via LoadLibrary remote thread.
bool inject(unsigned long pid)
{
const std::wstring dll = deployed_artifact_path(L"coop_hook.dll"); // root is one dir up from tests/
if (GetFileAttributesW(dll.c_str()) == INVALID_FILE_ATTRIBUTES)
{
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)
{
return false;
}
const SIZE_T bytes = (dll.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.c_str(), bytes, nullptr))
{
auto load = reinterpret_cast<LPTHREAD_START_ROUTINE>(
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
HANDLE th = CreateRemoteThread(process, nullptr, 0, load, remote, 0, nullptr);
if (th != nullptr)
{
WaitForSingleObject(th, INFINITE);
DWORD code = 0;
GetExitCodeThread(th, &code);
CloseHandle(th);
ok = code != 0;
}
}
if (remote != nullptr)
{
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
}
CloseHandle(process);
return ok;
}
// Inject with a few retries: a freshly-launched process can briefly refuse a remote thread.
bool inject_retry(unsigned long pid)
{
for (int i = 0; i < 4; ++i)
{
if (inject(pid))
{
return true;
}
Sleep(300);
}
return false;
}
struct MockGame
{
PROCESS_INFORMATION pi{};
bool ok = false;
// Launch coop_mock_game.exe with the given args (e.g. L"dx12 30" or L"dx11 30 48000 2 32 float").
static MockGame launch(const std::wstring& args)
{
MockGame g;
const std::wstring exe = tool_path(L"coop_mock_game.exe");
std::wstring cmd = L"\"" + exe + L"\" " + args;
STARTUPINFOW si{};
si.cb = sizeof(si);
g.ok = CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si,
&g.pi) != 0;
return g;
}
unsigned long pid() const
{
return pi.dwProcessId;
}
bool alive() const
{
return pi.hProcess != nullptr && WaitForSingleObject(pi.hProcess, 0) == WAIT_TIMEOUT;
}
unsigned long exit_code() const
{
DWORD code = 0;
if (pi.hProcess != nullptr)
{
GetExitCodeProcess(pi.hProcess, &code);
}
return code;
}
void kill()
{
if (pi.hProcess != nullptr)
{
TerminateProcess(pi.hProcess, 0);
WaitForSingleObject(pi.hProcess, 2000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
pi = PROCESS_INFORMATION{};
}
}
};
// Create the input SharedBlock the hook needs, with the given subsystems disabled (bit per
// HookSubsystem). Keeps the mapping alive in `shm`.
SharedBlock* make_ipc(SharedMemory& shm, unsigned long pid, std::uint32_t disabled_mask)
{
if (!shm.create(shared_memory_name(pid), sizeof(SharedBlock)))
{
return nullptr;
}
auto* block = shm.as<SharedBlock>();
block->version = kProtocolVersion;
block->pad_count = 0;
block->sequence.store(0, std::memory_order_relaxed);
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
{
block->control.subsystem_disabled[s].store((disabled_mask >> s) & 1u, std::memory_order_release);
}
block->magic = kProtocolMagic;
return block;
}
ID3D11Device* make_device()
{
ID3D11Device* dev = nullptr;
const D3D_FEATURE_LEVEL fl[] = {D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0};
if (FAILED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, fl,
static_cast<UINT>(std::size(fl)), D3D11_SDK_VERSION, &dev, nullptr, nullptr)))
{
return nullptr;
}
return dev;
}
VideoShareView read_video_share(const SharedBlock* block)
{
VideoShareView v;
v.generation = block->video.generation.load(std::memory_order_acquire);
v.width = block->video.width;
v.height = block->video.height;
v.format = block->video.format;
v.present_calls = block->video.present_calls;
return v;
}
// Capture from the mock game on `backend` and assert the decoded frame numbers form a
// monotonic, advancing sequence.
void test_video_capture(const char* backend, ID3D11Device* device)
{
std::printf("== video capture: %s ==\n", backend);
const std::wstring args = (std::string(backend) == "dx12" ? L"dx12 " : L"dx11 ") + std::wstring(L"30");
MockGame game = MockGame::launch(args);
if (!game.ok)
{
check(false, "launch coop_mock_game");
return;
}
Sleep(800); // let the window + swap chain come up
// Only the video subsystem (disable input/focus/audio/mkb to keep the test focused).
SharedMemory shm;
const std::uint32_t disabled = (1u << HookSubsys_Input) | (1u << HookSubsys_Focus) |
(1u << HookSubsys_Audio) | (1u << HookSubsys_Mkb);
SharedBlock* block = make_ipc(shm, game.pid(), disabled);
if (block == nullptr || !inject_retry(game.pid()))
{
check(false, "inject into mock game");
game.kill();
return;
}
SharedTextureSource src;
src.init(device);
std::vector<std::uint32_t> seq;
std::uint64_t backward = 0;
std::uint32_t last = 0;
bool have_last = false;
for (int i = 0; i < 80 && game.alive(); ++i) // ~4 s at 50 ms
{
Sleep(50);
const VideoShareView share = read_video_share(block);
if (!src.update(share, game.pid()))
{
continue;
}
std::uint8_t px[4] = {};
if (!src.read_pixel(coop::mock::kFrameBlock / 2, coop::mock::kFrameBlock / 2, px))
{
continue;
}
const std::uint32_t f = coop::mock::rgb_to_frame(px[0], px[1], px[2]);
if (have_last && f < last)
{
++backward; // a stale / wrong (rotated) buffer -> frame number went backwards
}
last = f;
have_last = true;
seq.push_back(f);
}
const std::uint32_t present = static_cast<std::uint32_t>(block->video.present_calls);
std::printf(" present_calls=%u copied=%llu samples=%zu backward=%llu\n", present,
static_cast<unsigned long long>(src.frames_copied()), seq.size(),
static_cast<unsigned long long>(backward));
check(present > 0, "hook captured Present calls");
check(src.frames_copied() >= 5, "host copied multiple shared frames");
check(seq.size() >= 5, "decoded multiple frame numbers from the captured pixels");
check(backward == 0, "captured frame numbers never go backwards (no stale/rotated buffer)");
if (!seq.empty())
{
const std::uint32_t span = seq.back() - seq.front();
std::printf(" frame# %u..%u (span %u)\n", seq.front(), seq.back(), span);
check(span >= 20, "captured frame numbers advance (mirror gets fresh frames)");
const bool all_same = std::all_of(seq.begin(), seq.end(), [&](std::uint32_t v) { return v == seq[0]; });
check(!all_same, "captured frames are not stuck on one number");
}
game.kill();
}
// Launch the mock game rendering audio at `rate`/`channels`/`bits`/`fmt`, inject the audio
// hook (late, so it's the guessed path), and verify the hook MEASURES the right sample rate
// for this variant and captures non-silent audio. (Channels/bit-depth aren't recoverable for
// a guessed stream -- that's the documented limitation -- so the rate is the variant check.)
void test_audio_variant(unsigned rate, unsigned channels, unsigned bits, const wchar_t* fmt)
{
wchar_t args[64];
swprintf(args, static_cast<int>(std::size(args)), L"dx11 30 %u %u %u %ls", rate, channels, bits, fmt);
std::printf("== audio variant: %u Hz %u ch %u-bit %ls ==\n", rate, channels, bits, fmt);
MockGame game = MockGame::launch(args);
if (!game.ok)
{
check(false, "launch coop_mock_game (audio variant)");
return;
}
Sleep(800);
// Audio subsystem only.
SharedMemory shm;
const std::uint32_t disabled = (1u << HookSubsys_Input) | (1u << HookSubsys_Focus) |
(1u << HookSubsys_Video) | (1u << HookSubsys_Mkb);
SharedBlock* block = make_ipc(shm, game.pid(), disabled);
SharedMemory ring_shm;
AudioRingHeader* ring = nullptr;
if (ring_shm.create(audio_ring_name(game.pid()), audio_ring_total_size(kAudioRingCapacity)))
{
ring = ring_shm.as<AudioRingHeader>();
audio_ring_init(*ring, kAudioRingCapacity);
ring->capture_enabled.store(1, std::memory_order_release);
}
if (block == nullptr || ring == nullptr || !inject_retry(game.pid()))
{
check(false, "inject into mock game (audio variant)");
game.kill();
return;
}
// Wait for the rate to be measured + published (Measured / LowConfidence / Exact), then
// drain to prove non-silent capture.
std::vector<std::uint8_t> drain(kAudioRingCapacity);
std::uint32_t measured = 0;
std::uint32_t state = 0;
double peak = 0.0;
for (int i = 0; i < 120 && game.alive(); ++i) // up to ~6 s
{
Sleep(50);
std::uint32_t got = 0;
while ((got = audio_ring_pop(*ring, drain.data(), static_cast<std::uint32_t>(drain.size()))) > 0)
{
if (ring->bits == 32 && ring->format_tag == 3)
{
const auto* f = reinterpret_cast<const float*>(drain.data());
for (std::uint32_t k = 0; k < got / 4; ++k)
{
peak = std::max(peak, static_cast<double>(std::fabs(f[k])));
}
}
else if (ring->bits == 16)
{
const auto* s = reinterpret_cast<const std::int16_t*>(drain.data());
for (std::uint32_t k = 0; k < got / 2; ++k)
{
peak = std::max(peak, std::abs(s[k]) / 32768.0);
}
}
if (got < drain.size())
{
break;
}
}
state = block->status.audio_streams[0].format_state;
if (state == AudioFormat_Measured || state == AudioFormat_LowConfidence || state == AudioFormat_Exact)
{
measured = block->status.audio_streams[0].sample_rate;
if (measured != 0 && peak > 0.01)
{
break;
}
}
}
std::printf(" measured rate=%u Hz (state=%u) peak=%.4f\n", measured, state, peak);
check(measured == rate, "hook measured this variant's sample rate");
check(peak > 0.01, "captured non-silent audio for this variant");
game.kill();
}
// Inject with audio + video, verify both stream, then cycle the audio subsystem off/on a
// few times (hook/unhook stress) and confirm the game stays alive (heartbeat advances).
void test_av_and_hook_cycles(ID3D11Device* device)
{
std::printf("== A/V + hook/unhook stress (dx11 + audio) ==\n");
MockGame game = MockGame::launch(L"dx11 30 48000 2 32 float");
if (!game.ok)
{
check(false, "launch coop_mock_game (A/V)");
return;
}
Sleep(800);
SharedMemory shm;
SharedBlock* block = make_ipc(shm, game.pid(), /*disabled=*/0); // all subsystems on
SharedMemory ring_shm;
AudioRingHeader* ring = nullptr;
if (ring_shm.create(audio_ring_name(game.pid()), audio_ring_total_size(kAudioRingCapacity)))
{
ring = ring_shm.as<AudioRingHeader>();
audio_ring_init(*ring, kAudioRingCapacity);
ring->capture_enabled.store(1, std::memory_order_release);
}
if (block == nullptr || ring == nullptr || !inject_retry(game.pid()))
{
check(false, "inject into mock game (A/V)");
game.kill();
return;
}
SharedTextureSource src;
src.init(device);
// Let it settle, then verify video advances and audio is non-silent.
double peak = 0.0;
std::uint32_t first_frame = 0, last_frame = 0;
std::vector<std::uint8_t> drain(kAudioRingCapacity);
for (int i = 0; i < 60 && game.alive(); ++i) // ~3 s
{
Sleep(50);
const VideoShareView share = read_video_share(block);
if (src.update(share, game.pid()))
{
std::uint8_t px[4] = {};
if (src.read_pixel(coop::mock::kFrameBlock / 2, coop::mock::kFrameBlock / 2, px))
{
const std::uint32_t f = coop::mock::rgb_to_frame(px[0], px[1], px[2]);
if (first_frame == 0)
{
first_frame = f;
}
last_frame = f;
}
}
std::uint32_t got = 0;
while ((got = audio_ring_pop(*ring, drain.data(), static_cast<std::uint32_t>(drain.size()))) > 0)
{
if (ring->bits == 32 && ring->format_tag == 3)
{
const auto* f = reinterpret_cast<const float*>(drain.data());
for (std::uint32_t k = 0; k < got / 4; ++k)
{
peak = std::max(peak, static_cast<double>(std::fabs(f[k])));
}
}
if (got < drain.size())
{
break;
}
}
}
check(last_frame > first_frame, "A/V: video frames advance");
check(peak > 0.01, "A/V: audio captured non-silent");
// Hook/unhook stress: toggle the audio subsystem off->on a few times. Each phase is >
// the worker's ~250 ms reconcile tick, so the install/remove fully completes each time
// (this is the realistic cadence -- an operator toggling a checkbox, not thrashing it).
const std::uint32_t hb_start = block->status.heartbeat.load(std::memory_order_relaxed);
for (int c = 0; c < 3 && game.alive(); ++c)
{
block->control.subsystem_disabled[HookSubsys_Audio].store(1, std::memory_order_release);
Sleep(400);
block->control.subsystem_disabled[HookSubsys_Audio].store(0, std::memory_order_release);
Sleep(400);
}
const std::uint32_t hb_end = block->status.heartbeat.load(std::memory_order_relaxed);
if (!game.alive())
{
std::printf(" game exit code = 0x%08lX\n", game.exit_code());
}
check(game.alive(), "game survived hook/unhook cycles (no crash)");
check(hb_end > hb_start, "hook heartbeat kept advancing through the cycles (no freeze)");
// After the final re-enable, capture must resume: the audio must come back (proving the
// rehook works end-to-end, not just that nothing crashed).
std::uint64_t produced_before = ring->frames_produced.load(std::memory_order_relaxed);
double peak2 = 0.0;
for (int i = 0; i < 30 && game.alive(); ++i) // ~1.5 s
{
Sleep(50);
std::uint32_t got = 0;
while ((got = audio_ring_pop(*ring, drain.data(), static_cast<std::uint32_t>(drain.size()))) > 0)
{
if (ring->bits == 32 && ring->format_tag == 3)
{
const auto* f = reinterpret_cast<const float*>(drain.data());
for (std::uint32_t k = 0; k < got / 4; ++k)
{
peak2 = std::max(peak2, static_cast<double>(std::fabs(f[k])));
}
}
if (got < drain.size())
{
break;
}
}
}
const std::uint64_t produced_after = ring->frames_produced.load(std::memory_order_relaxed);
check(produced_after > produced_before && peak2 > 0.01, "audio capture resumed after re-enable");
game.kill();
}
} // namespace
int main()
{
kill_stray_mock_games(); // clean slate: no leftover game holding coop_hook.dll
ID3D11Device* device = make_device();
if (device == nullptr)
{
std::printf("No D3D11 device -- skipping mock_game_test.\n");
return 0;
}
test_video_capture("dx11", device);
test_video_capture("dx12", device);
// Audio variants: the hook must measure each variant's rate through the full inject path.
test_audio_variant(44100, 2, 16, L"pcm");
test_audio_variant(48000, 2, 32, L"float");
test_audio_variant(96000, 2, 32, L"float");
test_av_and_hook_cycles(device);
device->Release();
kill_stray_mock_games(); // belt-and-suspenders: ensure nothing is left running
if (g_failures == 0)
{
std::printf("PASS mock_game_test\n");
return 0;
}
std::printf("FAILED mock_game_test (%d)\n", g_failures);
return 1;
}