diff --git a/README.md b/README.md index 84c0452..76be27d 100644 --- a/README.md +++ b/README.md @@ -95,16 +95,6 @@ and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap). ## Roadmap -### Planned (next up) - -- **Mock game + stress-test suite.** A bare-bones test game that renders an animated - (non-static) pattern — so dropped/duplicated/torn frames are obvious — with a - selectable graphics backend (DX11/DX12 now; structured to add OpenGL/Vulkan) and a - configurable audio output (rate/channels/bit-depth/format, like `coop_tone`). Then a - comprehensive suite that drives it through the real capture + audio + hook paths: - verify both capture backends, every audio variant, hook/unhook cycles, and that - nothing races or crashes under stress. - ### Future work - **Vulkan video hook.** Vulkan games present via `vkQueuePresentKHR`; hooking @@ -217,6 +207,17 @@ ctest --test-dir build -C Debug --output-on-failure process-loopback capture (the fallback backend) receives non-silent audio by PID for each — confirming loopback is format-agnostic (it captures post-mix at the device endpoint format). Skips cleanly if the machine has no audio endpoint. +- **`mock_game_test`** — comprehensive capture/audio/hook stress test against + **`coop_mock_game`** (an animated, frame-numbered A/V test game under + [`tools/mock_game`](tools/mock_game) with selectable **DX11 / DX12** backends and a + configurable WASAPI tone). It launches the game, injects `coop_hook.dll`, opens the + hook's shared video texture, and **decodes the frame number out of the captured pixels** + to assert the mirror sees a *monotonic, advancing* sequence for both backends (the bar + for no dropped / stale / out-of-order frames — what the DX12 rotating-backbuffer bug + broke). It then injects with audio + video, checks both stream, cycles the audio + subsystem off/on (hook/unhook stress), and confirms the game never crashes and capture + resumes. This suite drove out five real audio races (see Lessons learned). Skips cleanly + without a D3D11 device. ### Debugging the hooks against a real game diff --git a/host/src/capture/shared_texture.cpp b/host/src/capture/shared_texture.cpp index 467c4fc..65497a3 100644 --- a/host/src/capture/shared_texture.cpp +++ b/host/src/capture/shared_texture.cpp @@ -92,6 +92,44 @@ bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share) return true; } +bool SharedTextureSource::read_pixel(std::uint32_t x, std::uint32_t y, std::uint8_t out[4]) +{ + if (private_ == nullptr || ctx_ == nullptr || device_ == nullptr) + { + return false; + } + D3D11_TEXTURE2D_DESC desc{}; + private_->GetDesc(&desc); + if (x >= desc.Width || y >= desc.Height) + { + return false; + } + D3D11_TEXTURE2D_DESC staging_desc = desc; + staging_desc.Usage = D3D11_USAGE_STAGING; + staging_desc.BindFlags = 0; + staging_desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + staging_desc.MiscFlags = 0; + Microsoft::WRL::ComPtr staging; + if (FAILED(device_->CreateTexture2D(&staging_desc, nullptr, &staging))) + { + return false; + } + ctx_->CopyResource(staging.Get(), private_.Get()); + D3D11_MAPPED_SUBRESOURCE map{}; + if (FAILED(ctx_->Map(staging.Get(), 0, D3D11_MAP_READ, 0, &map))) + { + return false; + } + const auto* px = static_cast(map.pData) + static_cast(y) * map.RowPitch + + static_cast(x) * 4; // R8G8B8A8_UNORM + out[0] = px[0]; + out[1] = px[1]; + out[2] = px[2]; + out[3] = px[3]; + ctx_->Unmap(staging.Get(), 0); + return true; +} + bool SharedTextureSource::update(const VideoShareView& share, unsigned long pid) { if (device_ == nullptr || pid == 0) diff --git a/host/src/capture/shared_texture.hpp b/host/src/capture/shared_texture.hpp index 9c074df..ec5271b 100644 --- a/host/src/capture/shared_texture.hpp +++ b/host/src/capture/shared_texture.hpp @@ -31,6 +31,12 @@ public: // Drop the opened resources (target changed / mirror turned off). void reset(); + // Read the RGBA8 pixel at (x,y) of the last copied frame into out[4]. For verification + // (e.g. decoding the mock game's frame-number block in a capture test). Copies the + // private texture to a CPU-readable staging texture and maps it -- not a hot path. + // Returns false if no frame has been copied yet or the readback failed. + bool read_pixel(std::uint32_t x, std::uint32_t y, std::uint8_t out[4]); + [[nodiscard]] ID3D11ShaderResourceView* srv() const { return srv_.Get(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 706a5c2..8a60177 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -140,6 +140,20 @@ target_link_libraries(dx12_present_hook_test PRIVATE add_test(NAME dx12_present_hook_test COMMAND dx12_present_hook_test) +# Comprehensive capture/audio/hook stress test against coop_mock_game: launches the +# animated, frame-numbered A/V game, injects coop_hook.dll, decodes captured frame +# numbers (DX11 + DX12) to assert a monotonic/advancing mirror, checks audio capture, and +# stresses hook/unhook cycles. Reuses the shipping shared-texture reader. Skips without D3D11. +add_executable(mock_game_test + mock_game_test.cpp + ${CMAKE_SOURCE_DIR}/host/src/capture/shared_texture.cpp) +target_include_directories(mock_game_test PRIVATE + ${CMAKE_SOURCE_DIR}/host/src + ${CMAKE_SOURCE_DIR}/tools/mock_game) +target_link_libraries(mock_game_test PRIVATE coop_common d3d11 dxgi) +add_dependencies(mock_game_test coop_mock_game coop_hook) +add_test(NAME mock_game_test COMMAND mock_game_test) + # In-process self-test for the OpenGL capture path. Reuses the shipping # opengl_hook.cpp and drives a real OpenGL context in the same process, so it # exercises the SwapBuffers hook, the glReadPixels readback, and the upload into @@ -178,4 +192,5 @@ coop_output_subdir(tests srgb_format_test present_hook_test dx12_present_hook_test - opengl_hook_test) + opengl_hook_test + mock_game_test) diff --git a/tests/mock_game_test.cpp b/tests/mock_game_test.cpp new file mode 100644 index 0000000..d96f7ff --- /dev/null +++ b/tests/mock_game_test.cpp @@ -0,0 +1,447 @@ +// 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 +#include +#include +#include +#include +#include + +#include + +#include + +#include + +#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( + 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(); + 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(std::size(fl)), D3D11_SDK_VERSION, &dev, nullptr, nullptr))) + { + return nullptr; + } + return dev; +} + +VideoShareView read_video_share(const SharedBlock* block) +{ + VideoShareView v; + v.generation = block->video.generation.load(std::memory_order_acquire); + v.width = block->video.width; + v.height = block->video.height; + v.format = block->video.format; + v.present_calls = block->video.present_calls; + return v; +} + +// Capture from the mock game on `backend` and assert the decoded frame numbers form a +// monotonic, advancing sequence. +void test_video_capture(const char* backend, ID3D11Device* device) +{ + std::printf("== video capture: %s ==\n", backend); + const std::wstring args = (std::string(backend) == "dx12" ? L"dx12 " : L"dx11 ") + std::wstring(L"30"); + MockGame game = MockGame::launch(args); + if (!game.ok) + { + check(false, "launch coop_mock_game"); + return; + } + Sleep(800); // let the window + swap chain come up + + // Only the video subsystem (disable input/focus/audio/mkb to keep the test focused). + SharedMemory shm; + const std::uint32_t disabled = (1u << HookSubsys_Input) | (1u << HookSubsys_Focus) | + (1u << HookSubsys_Audio) | (1u << HookSubsys_Mkb); + SharedBlock* block = make_ipc(shm, game.pid(), disabled); + if (block == nullptr || !inject_retry(game.pid())) + { + check(false, "inject into mock game"); + game.kill(); + return; + } + + SharedTextureSource src; + src.init(device); + + std::vector 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(block->video.present_calls); + std::printf(" present_calls=%u copied=%llu samples=%zu backward=%llu\n", present, + static_cast(src.frames_copied()), seq.size(), + static_cast(backward)); + + check(present > 0, "hook captured Present calls"); + check(src.frames_copied() >= 5, "host copied multiple shared frames"); + check(seq.size() >= 5, "decoded multiple frame numbers from the captured pixels"); + check(backward == 0, "captured frame numbers never go backwards (no stale/rotated buffer)"); + if (!seq.empty()) + { + const std::uint32_t span = seq.back() - seq.front(); + std::printf(" frame# %u..%u (span %u)\n", seq.front(), seq.back(), span); + check(span >= 20, "captured frame numbers advance (mirror gets fresh frames)"); + const bool all_same = std::all_of(seq.begin(), seq.end(), [&](std::uint32_t v) { return v == seq[0]; }); + check(!all_same, "captured frames are not stuck on one number"); + } + + game.kill(); +} + +// 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(); + 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 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(drain.size()))) > 0) + { + if (ring->bits == 32 && ring->format_tag == 3) + { + const auto* f = reinterpret_cast(drain.data()); + for (std::uint32_t k = 0; k < got / 4; ++k) + { + peak = std::max(peak, static_cast(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(drain.size()))) > 0) + { + if (ring->bits == 32 && ring->format_tag == 3) + { + const auto* f = reinterpret_cast(drain.data()); + for (std::uint32_t k = 0; k < got / 4; ++k) + { + peak2 = std::max(peak2, static_cast(std::fabs(f[k]))); + } + } + if (got < drain.size()) + { + break; + } + } + } + const std::uint64_t produced_after = ring->frames_produced.load(std::memory_order_relaxed); + check(produced_after > produced_before && peak2 > 0.01, "audio capture resumed after re-enable"); + + game.kill(); +} +} // namespace + +int main() +{ + kill_stray_mock_games(); // clean slate: no leftover game holding coop_hook.dll + + ID3D11Device* device = make_device(); + if (device == nullptr) + { + std::printf("No D3D11 device -- skipping mock_game_test.\n"); + return 0; + } + + test_video_capture("dx11", device); + test_video_capture("dx12", device); + test_av_and_hook_cycles(device); + + device->Release(); + kill_stray_mock_games(); // belt-and-suspenders: ensure nothing is left running + + if (g_failures == 0) + { + std::printf("PASS mock_game_test\n"); + return 0; + } + std::printf("FAILED mock_game_test (%d)\n", g_failures); + return 1; +}