Disconnect -> reconnect now reuses the DLL already in the game instead of injecting again, including across a tool restart or crash: a connected DLL keeps its per-pid shared section (and worker) alive after the host goes away, so a fresh host can find it and re-attach to the same section. - hook_dll_alive(pid) (host/src/inject/dll_probe.cpp): detect a live DLL by opening the per-pid section and polling its heartbeat (returns as soon as a beat lands; a missing section or stalled worker reads as not-alive). It does not check magic -- a graceful disconnect zeroes magic but the DLL keeps beating and the worker never re-checks magic post-connect. - InjectionPanel: the Inject and Connect button branches to reconnect_selected() when a live DLL is detected -- IpcServer::start() re-attaches to the SAME section the DLL still holds and re-publishes the subsystem state; no re-injection. Factored the shared post-connect setup (publish_subsystem_state / begin_liveness_tracking). The DLL needed no change -- it just resumes reading the re-attached section. - A false not-alive is benign: the inject path still re-attaches an already-injected DLL (LoadLibrary no-ops), so the timeout only needs to clear the worker's ~250ms beat period with margin. Test (mock_game_test test_reconnect): inject -> hooked -> graceful disconnect -> drop the host handle (simulating a restart while the DLL keeps the section alive) -> detect via heartbeat -> re-attach to the same section -> hooks re-install without re-injecting -> and hook_dll_alive goes false once the game is gone. Roadmap: both current tasks (graceful disconnect, reconnect) done -> removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1185 lines
39 KiB
C++
1185 lines
39 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 "inject/dll_probe.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();
|
|
}
|
|
|
|
// Reconnect contract: after a graceful disconnect (and even a simulated tool restart -- the host
|
|
// drops its section handle while the DLL keeps it alive), the host can detect the live DLL via its
|
|
// heartbeat (hook_dll_alive) and re-attach to the SAME per-pid section to resume control, without
|
|
// re-injecting. The DLL, still connected to that section, re-installs its hooks when the reconnected
|
|
// host re-enables them.
|
|
void test_reconnect(const char* backend)
|
|
{
|
|
std::printf("== reconnect to an already-injected DLL: %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 (reconnect)");
|
|
return;
|
|
}
|
|
Sleep(800);
|
|
|
|
const std::uint32_t disabled = 1u << HookSubsys_Audio; // input+focus+video+mkb on; audio off
|
|
SharedMemory shm_a;
|
|
SharedBlock* block_a = make_ipc(shm_a, game.pid(), disabled);
|
|
if (block_a == nullptr || !inject_retry(game.pid()))
|
|
{
|
|
check(false, "inject mock game (reconnect)");
|
|
game.kill();
|
|
return;
|
|
}
|
|
|
|
bool installed = false;
|
|
for (int i = 0; i < 100 && game.alive() && !installed; ++i)
|
|
{
|
|
Sleep(50);
|
|
installed = installed_hook_count(block_a) > 0;
|
|
}
|
|
check(installed, "first connection installed hooks");
|
|
check(hook_dll_alive(game.pid()), "hook_dll_alive() detects the live DLL");
|
|
|
|
// Graceful disconnect: unhook everything, then simulate the host going away (drop our handle;
|
|
// the DLL keeps the section alive). This stands in for both an explicit disconnect and a restart.
|
|
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
|
|
{
|
|
block_a->control.subsystem_disabled[s].store(1u, std::memory_order_release);
|
|
}
|
|
for (int i = 0; i < 100 && installed_hook_count(block_a) != 0; ++i)
|
|
{
|
|
Sleep(50);
|
|
}
|
|
check(installed_hook_count(block_a) == 0, "graceful disconnect unhooked the game");
|
|
// Let the DLL settle back to steady heartbeating: the unhook tick runs several bounded drains, so
|
|
// the worker can briefly not beat right after it. A real reconnect targets an already-dormant DLL,
|
|
// not one in the microsecond after a mass-unhook.
|
|
Sleep(500);
|
|
shm_a.reset(); // host A "exits" -- only the DLL holds the section now
|
|
|
|
// The DLL is still alive and holding the section: the restarted host can find it...
|
|
check(hook_dll_alive(game.pid()), "DLL still detectable after the host dropped its handle");
|
|
|
|
// ...and reconnect by re-attaching to the SAME section (no re-inject) and re-enabling subsystems.
|
|
SharedMemory shm_b;
|
|
SharedBlock* block_b = make_ipc(shm_b, game.pid(), 0u); // re-attach, all subsystems on
|
|
if (block_b == nullptr)
|
|
{
|
|
check(false, "reconnect: re-attach to the section");
|
|
game.kill();
|
|
return;
|
|
}
|
|
bool reinstalled = false;
|
|
for (int i = 0; i < 100 && game.alive() && !reinstalled; ++i)
|
|
{
|
|
Sleep(50);
|
|
reinstalled = installed_hook_count(block_b) > 0;
|
|
}
|
|
check(reinstalled, "reconnect re-installed the hooks via the existing DLL (no re-inject)");
|
|
check(game.alive(), "game alive after reconnect");
|
|
|
|
game.kill();
|
|
Sleep(200);
|
|
check(!hook_dll_alive(game.pid()), "hook_dll_alive() false once the game (and DLL) is gone");
|
|
}
|
|
|
|
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");
|
|
|
|
// Reconnect: detect the live DLL and re-attach to the same section (even across a simulated host
|
|
// restart) to resume control without re-injecting.
|
|
test_reconnect("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;
|
|
}
|