Files
CoopAllTheThings/tests/mock_game_test.cpp
BlackMark 6d96531ac7 Graceful disconnect: tell the DLL to unhook everything
On an explicit Disconnect and on graceful tool exit, the host now asks the
injected DLL to remove every subsystem so the game runs exactly as if it was
never hooked (each hook restores its original bytes). The DLL stays injected but
dormant, ready for a later reconnect -- we never eject it.

Before, both paths just dropped the IPC channel (IpcServer::stop) without telling
the DLL, leaving the hooks active with frozen forwarded state until the game
exited.

- IpcServer::request_unhook_all() sets every subsystem_disabled flag (the DLL
  reconciles to fully unhooked on its next tick); all_hooks_removed() reads the
  hook registry back so the host can confirm the game is vanilla.
- InjectionPanel::disconnect_graceful() requests the unhook, waits (bounded) for
  the registry to clear, then stops. Wired into the Disconnect button (700ms) and
  the destructor (300ms). The flags persist in the section the DLL keeps alive, so
  the unhook completes even if the host exits before confirming.

Tests (failing first):
- ipc_server_test: request_unhook_all() disables all subsystems; all_hooks_removed()
  tracks the registry. Deterministic, no game.
- mock_game_test test_graceful_disconnect: inject -> hooks installed -> request
  unhook-all -> every hook removed (game vanilla) while the DLL stays alive
  (heartbeat advancing). Full suite still passes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 13:46:51 +02:00

1097 lines
36 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 <atomic>
#include <cmath>
#include <cstdint>
#include <cstdio>
#include <string>
#include <thread>
#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;
}
// Real-world performance guard: with the hook (or layer) capturing, the game must keep rendering
// FAST. This is the dimension the frame-advance checks miss -- the Vulkan read-back stall (144->3
// FPS) still ADVANCED frames, just ~3/s, so every "frames advance" assertion passed while the game
// was unplayable. The mock does trivial work and renders UNCAPPED (no vsync), so on any modern GPU it
// presents at thousands/s (measured no-hook: dx9 ~21000, dx10 ~2800, dx11 ~17000, dx12 ~12000, gl
// ~26000, vk ~24000); the capture costs some of that (e.g. OpenGL's glReadPixels is the heaviest),
// but a healthy backend stays in the hundreds-thousands. A rate that has collapsed below the display
// refresh is either a present-thread stall (the bug) or an accidental vsync -- both regressions we
// want to catch -- so the floor sits above any common refresh (240) and far below the healthy range.
inline constexpr std::uint64_t kMockMinCaptureFps = 300;
void check_capture_present_rate(SharedBlock* block, const char* backend)
{
const std::uint64_t p0 = block->video.present_calls;
Sleep(1000);
const std::uint64_t fps = block->video.present_calls - p0;
std::printf(" %s present rate while capturing = %llu /s\n", backend, static_cast<unsigned long long>(fps));
check(fps >= kMockMinCaptureFps,
"game keeps rendering fast while capturing (no present-thread stall / accidental vsync)");
}
// 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);
std::wstring wbackend;
for (const char* p = backend; *p != '\0'; ++p) // backend names are ASCII (dx10/dx11/dx12/...)
{
wbackend.push_back(static_cast<wchar_t>(*p));
}
std::wstring args = wbackend + 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");
}
if (game.alive())
{
check_capture_present_rate(block, backend);
}
game.kill();
}
// Vulkan capture: Vulkan caches its present pointer at init, so late injection can't hook it.
// We launch the mock **suspended**, inject the hook, and resume; under COOP_MOCK_VK_EARLY the mock
// loads vulkan-1.dll and waits, giving the hook's worker time to hook vkGetInstanceProcAddr before
// the mock calls vkCreateInstance. Then we decode frames out of the captured pixels like the other
// backends. Exit code 2 = no Vulkan driver -> skip without failing.
void test_vk_capture(ID3D11Device* device)
{
std::printf("== video capture: vk (early-load) ==\n");
SetEnvironmentVariableW(L"COOP_MOCK_VK_EARLY", L"1");
PROCESS_INFORMATION pi{};
STARTUPINFOW si{};
si.cb = sizeof(si);
const std::wstring exe = tool_path(L"coop_mock_game.exe");
std::wstring cmd = L"\"" + exe + L"\" vk 30";
const BOOL ok =
CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr, nullptr, &si, &pi);
SetEnvironmentVariableW(L"COOP_MOCK_VK_EARLY", nullptr);
if (!ok)
{
check(false, "launch suspended vk mock");
return;
}
SharedMemory shm;
const std::uint32_t disabled = (1u << HookSubsys_Input) | (1u << HookSubsys_Focus) |
(1u << HookSubsys_Audio) | (1u << HookSubsys_Mkb);
SharedBlock* block = make_ipc(shm, pi.dwProcessId, disabled);
const bool injected = block != nullptr && inject_retry(pi.dwProcessId);
ResumeThread(pi.hThread); // the mock loads vulkan + waits, then renders
auto cleanup = [&] {
TerminateProcess(pi.hProcess, 0);
WaitForSingleObject(pi.hProcess, 2000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
};
if (!injected)
{
check(false, "inject suspended vk mock");
cleanup();
return;
}
auto alive = [&] { return WaitForSingleObject(pi.hProcess, 0) == WAIT_TIMEOUT; };
auto exit_code = [&] {
DWORD c = 0;
GetExitCodeProcess(pi.hProcess, &c);
return c;
};
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 < 200 && alive(); ++i) // up to ~10 s (the mock waits 1.5 s at start)
{
Sleep(50);
const VideoShareView share = read_video_share(block);
if (!src.update(share, pi.dwProcessId))
{
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;
}
last = f;
have_last = true;
seq.push_back(f);
if (seq.size() >= 40)
{
break;
}
}
if (!alive() && exit_code() == 2 && seq.empty())
{
std::printf(" Vulkan unavailable on this machine -- skipping vk capture\n");
cleanup();
return;
}
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 Vulkan present calls");
check(src.frames_copied() >= 5, "host copied multiple shared frames (vk)");
check(seq.size() >= 5, "decoded multiple frame numbers from the captured pixels (vk)");
check(backward == 0, "captured vk frame numbers never go backwards");
if (!seq.empty())
{
check(seq.back() - seq.front() >= 10, "captured vk frame numbers advance");
}
if (alive())
{
check_capture_present_rate(block, "vk (inject)");
}
cleanup();
}
// Vulkan capture via the implicit layer: register coop_vk_layer through the loader (VK_LAYER_PATH
// + VK_INSTANCE_LAYERS, with COOP_VK_LAYER_FORCE so it captures this process), launch the vk mock
// normally (the layer is in the chain from the first frame -- no early-load games), and decode
// frames out of the captured pixels. This is the productized form of the early-load path.
void test_vk_layer_capture(ID3D11Device* device)
{
std::printf("== video capture: vk implicit layer ==\n");
const std::wstring manifest = deployed_artifact_path(L"coop_vk_layer.json");
if (GetFileAttributesW(manifest.c_str()) == INVALID_FILE_ATTRIBUTES)
{
check(false, "coop_vk_layer.json staged");
return;
}
const std::wstring layer_dir = manifest.substr(0, manifest.find_last_of(L"\\/"));
SetEnvironmentVariableW(L"VK_LAYER_PATH", layer_dir.c_str());
SetEnvironmentVariableW(L"VK_INSTANCE_LAYERS", L"VK_LAYER_coop_capture");
SetEnvironmentVariableW(L"COOP_VK_LAYER_FORCE", L"1");
MockGame game = MockGame::launch(L"vk 30"); // normal launch; the layer is already in the chain
auto unset_env = [] {
SetEnvironmentVariableW(L"VK_LAYER_PATH", nullptr);
SetEnvironmentVariableW(L"VK_INSTANCE_LAYERS", nullptr);
SetEnvironmentVariableW(L"COOP_VK_LAYER_FORCE", nullptr);
};
unset_env();
if (!game.ok)
{
check(false, "launch vk mock (layer)");
return;
}
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); // the layer connects to this + publishes
if (block == nullptr)
{
check(false, "ipc block (layer)");
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 < 160 && game.alive(); ++i) // ~8 s
{
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;
}
last = f;
have_last = true;
seq.push_back(f);
if (seq.size() >= 40)
{
break;
}
}
if (!game.alive() && game.exit_code() == 2 && seq.empty())
{
std::printf(" Vulkan unavailable on this machine -- skipping layer capture\n");
game.kill();
return;
}
std::printf(" copied=%llu samples=%zu backward=%llu\n", static_cast<unsigned long long>(src.frames_copied()),
seq.size(), static_cast<unsigned long long>(backward));
check(src.frames_copied() >= 5, "layer copied multiple shared frames");
check(seq.size() >= 5, "decoded multiple frame numbers via the layer");
check(backward == 0, "layer-captured frame numbers never go backwards");
if (!seq.empty())
{
check(seq.back() - seq.front() >= 10, "layer-captured frame numbers advance");
}
if (game.alive())
{
check_capture_present_rate(block, "vk (layer)");
}
game.kill();
}
// Vulkan too-late detection: launch the vk mock normally (it inits Vulkan immediately), inject
// *late* (the realistic case), and assert the hook reports vk_too_late -- it sees vulkan-1.dll
// loaded but never caught the device, because the app resolved its present pointer first. This is
// what drives the host's relaunch banner.
void test_vk_too_late()
{
std::printf("== vk too-late detection (late inject) ==\n");
MockGame game = MockGame::launch(L"vk 30");
if (!game.ok)
{
check(false, "launch vk mock (too-late)");
return;
}
Sleep(1200); // let it create its instance/device and start presenting
if (!game.alive() && game.exit_code() == 2)
{
std::printf(" Vulkan unavailable on this machine -- skipping\n");
game.kill();
return;
}
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()))
{
if (!game.alive() && game.exit_code() == 2)
{
std::printf(" Vulkan unavailable -- skipping\n");
}
else
{
check(false, "inject vk mock (too-late)");
}
game.kill();
return;
}
bool too_late = false;
for (int i = 0; i < 160 && game.alive(); ++i) // ~8 s (past the hook's 4 s grace)
{
Sleep(50);
if (block->status.vk_too_late != 0)
{
too_late = true;
break;
}
}
check(too_late, "hook reports vk_too_late after a late inject into a Vulkan game");
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();
}
// Aggressively toggle every injected subsystem on/off from a separate thread while the game is
// actively presenting, to provoke an unsafe hook install/remove race. The known failure mode:
// remove_*_hooks frees the hook's shared D3D state (device / context / keyed-mutex texture, and
// the Vulkan read-back resources) while a capture detour on the game's render thread is still
// using it -> use-after-free -> the game crashes. This is the "spamming the Mirror video button
// crashed Brotato" bug; the gentle, audio-only cycles in test_av_and_hook_cycles never exercised
// the video teardown, so they missed it. We storm ALL subsystems (especially video) across every
// backend, then confirm the game never crashed/froze and that capture resumes. vk_early uses the
// suspended-launch + early-inject path (Vulkan caches its present pointer at init, so a late inject
// can't hook it).
void test_hook_storm(const char* backend, ID3D11Device* device, bool vk_early)
{
std::printf("== hook/unhook storm: %s ==\n", backend);
std::wstring wbackend;
for (const char* p = backend; *p != '\0'; ++p) // backend names are ASCII
{
wbackend.push_back(static_cast<wchar_t>(*p));
}
PROCESS_INFORMATION pi{};
STARTUPINFOW si{};
si.cb = sizeof(si);
const std::wstring exe = tool_path(L"coop_mock_game.exe");
std::wstring cmd = L"\"" + exe + L"\" " + wbackend + L" 60";
if (vk_early)
{
SetEnvironmentVariableW(L"COOP_MOCK_VK_EARLY", L"1");
}
const DWORD launch_flags = vk_early ? CREATE_SUSPENDED : 0;
const BOOL launched =
CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, launch_flags, nullptr, nullptr, &si, &pi);
if (vk_early)
{
SetEnvironmentVariableW(L"COOP_MOCK_VK_EARLY", nullptr);
}
if (!launched)
{
check(false, "launch mock game (storm)");
return;
}
auto alive = [&] { return WaitForSingleObject(pi.hProcess, 0) == WAIT_TIMEOUT; };
auto exit_code = [&] {
DWORD c = 0;
GetExitCodeProcess(pi.hProcess, &c);
return c;
};
auto cleanup = [&] {
TerminateProcess(pi.hProcess, 0);
WaitForSingleObject(pi.hProcess, 2000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
};
SharedMemory shm;
SharedBlock* block = make_ipc(shm, pi.dwProcessId, /*disabled=*/0); // all subsystems on
SharedMemory ring_shm;
AudioRingHeader* ring = nullptr;
if (ring_shm.create(audio_ring_name(pi.dwProcessId), audio_ring_total_size(kAudioRingCapacity)))
{
ring = ring_shm.as<AudioRingHeader>();
audio_ring_init(*ring, kAudioRingCapacity);
ring->capture_enabled.store(1, std::memory_order_release);
}
const bool injected = block != nullptr && inject_retry(pi.dwProcessId);
if (vk_early)
{
ResumeThread(pi.hThread); // the mock loads Vulkan + waits, then renders
}
if (!injected)
{
if (vk_early && !alive() && exit_code() == 2)
{
std::printf(" Vulkan unavailable -- skipping storm\n");
}
else
{
check(false, "inject mock game (storm)");
}
cleanup();
return;
}
Sleep(vk_early ? 2500 : 1000); // let the hook attach + the game start presenting
if (vk_early && !alive() && exit_code() == 2)
{
std::printf(" Vulkan unavailable -- skipping storm\n");
cleanup();
return;
}
const std::uint32_t hb_start = block->status.heartbeat.load(std::memory_order_relaxed);
// Storm thread: flip every subsystem on/off as fast as it can while the game presents, so a
// remove lands while a capture detour is mid-flight on the game's render thread.
std::atomic<bool> stop{false};
std::thread storm([&] {
bool off = false;
while (!stop.load(std::memory_order_relaxed))
{
off = !off;
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
{
block->control.subsystem_disabled[s].store(off ? 1u : 0u, std::memory_order_release);
}
Sleep(60);
}
});
bool crashed = false;
for (int i = 0; i < 170 && !crashed; ++i) // ~10 s of storming
{
Sleep(60);
if (!alive())
{
crashed = true;
}
}
stop.store(true, std::memory_order_relaxed);
storm.join();
if (crashed)
{
std::printf(" game CRASHED during the storm (exit 0x%08lX)\n", exit_code());
}
check(!crashed, "game survived the hook/unhook storm (no crash)");
if (crashed)
{
cleanup();
return;
}
// Re-enable everything and confirm the game is still alive + the hook still beating.
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
{
block->control.subsystem_disabled[s].store(0, std::memory_order_release);
}
Sleep(600);
check(alive(), "game alive after the storm settles");
check(block->status.heartbeat.load(std::memory_order_relaxed) > hb_start,
"hook heartbeat advanced across the storm (no freeze)");
// Capture must resume (the rehook works end-to-end). Vulkan can't re-arm after a toggle (its
// present pointer was cached at init), so only assert resume for the other backends.
if (!vk_early)
{
SharedTextureSource src;
src.init(device);
const std::uint64_t frames0 = src.frames_copied();
bool advanced = false;
for (int i = 0; i < 80 && alive(); ++i) // ~4 s
{
Sleep(50);
const VideoShareView share = read_video_share(block);
if (src.update(share, pi.dwProcessId) && src.frames_copied() > frames0 + 3)
{
advanced = true;
break;
}
}
check(advanced, "video capture resumed after the storm");
}
cleanup();
}
} // namespace
// Number of hooks the DLL currently reports as installed (across all subsystems).
std::uint32_t installed_hook_count(const SharedBlock* block)
{
std::uint32_t count = block->status.hook_entry_count;
if (count > kMaxHookEntries)
{
count = kMaxHookEntries;
}
std::uint32_t installed = 0;
for (std::uint32_t i = 0; i < count; ++i)
{
installed += block->status.hook_entries[i].installed != 0 ? 1u : 0u;
}
return installed;
}
// Graceful disconnect contract (the host-side IpcServer::request_unhook_all path): once the host
// asks for every subsystem off, the DLL must remove ALL its hooks -- the game runs as if it was
// never touched -- yet stay alive (heartbeat advancing) so it can be reconnected later. Models what
// the Disconnect button / tool-exit does (set all subsystem_disabled = 1) and asserts the outcome.
void test_graceful_disconnect(const char* backend)
{
std::printf("== graceful disconnect: %s ==\n", backend);
std::wstring wbackend;
for (const char* p = backend; *p != '\0'; ++p)
{
wbackend.push_back(static_cast<wchar_t>(*p));
}
MockGame game = MockGame::launch(wbackend + L" 30");
if (!game.ok)
{
check(false, "launch mock game (graceful disconnect)");
return;
}
Sleep(800);
// Enable input + focus + video + MKB (all of which the mock exercises); leave audio off so the
// test doesn't depend on an audio endpoint. Then inject.
SharedMemory shm;
SharedBlock* block = make_ipc(shm, game.pid(), 1u << HookSubsys_Audio);
if (block == nullptr || !inject_retry(game.pid()))
{
check(false, "inject mock game (graceful disconnect)");
game.kill();
return;
}
// Wait for the DLL to install its hooks.
bool installed = false;
for (int i = 0; i < 100 && game.alive() && !installed; ++i) // up to ~5 s
{
Sleep(50);
installed = installed_hook_count(block) > 0;
}
check(installed, "hooks installed after inject (something to unhook)");
const std::uint32_t hb0 = block->status.heartbeat.load(std::memory_order_relaxed);
// Graceful disconnect: request every subsystem removed (what request_unhook_all writes).
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
{
block->control.subsystem_disabled[s].store(1u, std::memory_order_release);
}
// The DLL must remove every hook -> game vanilla.
bool vanilla = false;
for (int i = 0; i < 100 && game.alive() && !vanilla; ++i) // up to ~5 s
{
Sleep(50);
vanilla = installed_hook_count(block) == 0;
}
check(vanilla, "DLL removed every hook on request (game runs vanilla)");
// ... and stay alive (dormant), so a later reconnect can re-enable it.
check(game.alive(), "game still alive after graceful disconnect");
Sleep(400);
const std::uint32_t hb1 = block->status.heartbeat.load(std::memory_order_relaxed);
check(hb1 != hb0, "DLL heartbeat still advancing (injected but dormant)");
game.kill();
}
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;
}
// Vulkan: present pointer cached at init -> can't be late-hooked, so we capture via the
// early-load path (suspended launch + inject + resume; the mock loads Vulkan and waits).
test_vk_capture(device);
test_vk_layer_capture(device);
test_vk_too_late();
test_video_capture("gl", device);
test_video_capture("dx9ex", device);
test_video_capture("dx9", device);
test_video_capture("dx10", device);
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);
// Graceful disconnect: the host asks the DLL to unhook everything; the game must return to
// vanilla while the DLL stays injected/dormant (the reconnect-friendly teardown).
test_graceful_disconnect("dx11");
// Aggressive hook/unhook storm across every backend: a separate thread thrashes every
// subsystem on/off while the game presents, to catch an unsafe install/remove race (the
// "spamming Mirror video crashed Brotato" use-after-free). vk uses the early-load path.
test_hook_storm("gl", device, /*vk_early=*/false);
test_hook_storm("dx9", device, /*vk_early=*/false);
test_hook_storm("dx10", device, /*vk_early=*/false);
test_hook_storm("dx11", device, /*vk_early=*/false);
test_hook_storm("dx12", device, /*vk_early=*/false);
test_hook_storm("vk", device, /*vk_early=*/true);
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;
}