The mock backends presented with vsync ("a game-like cadence") -- wrong for a
perf/stress fixture: it does trivial work on an RTX 4090, so it must run as fast
as it can. Vsync capped them to tens of fps (dx9 30, dx10 23, dx11 63, dx12 126),
which hid both capture-induced slowdowns and the hook-removal race. Uncapped now:
dx9/dx10 INTERVAL_IMMEDIATE / Present(0,0) (BLT), dx11/dx12 ALLOW_TEARING +
Present(0, ALLOW_TEARING) (flip), gl wglSwapIntervalEXT(0), vk IMMEDIATE/MAILBOX.
Measured no-hook: dx9 ~21000, dx10 ~2800, dx11 ~17000, dx12 ~12000, gl ~26000, vk
~24000 fps.
mock_game_test now adds a present-rate floor per backend (>= 300/s while
capturing): with the hook live every backend stays in the hundreds-thousands
(vk 13500, dx11 9000+, gl 1800, dx9/10 ~1000-1600, dx12 2500). This is the
dimension the frame-advance checks missed -- the Vulkan 144->3 FPS stall still
advanced frames -- so it catches a present-thread stall OR an accidental vsync.
The faster storm exposed the hook-removal UAF fixed in the previous commit.
README roadmap + lessons-learned updated (incl. correcting the old "reset makes
in-flight trampoline calls safe" claim). Full suite 21/21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1012 lines
33 KiB
C++
1012 lines
33 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
|
|
|
|
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);
|
|
|
|
// 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;
|
|
}
|