Add mock-game capture/audio/hook stress test
mock_game_test launches coop_mock_game (DX11/DX12, frame-numbered A/V), injects coop_hook.dll, opens the hook's shared video texture, and decodes the frame number from the captured pixels to assert a monotonic, advancing mirror for both backends (catches dropped/stale/out-of-order frames). Then it runs an A/V + hook/unhook stress pass and confirms no crash + capture resumes. Adds a read_pixel() readback to the shipping SharedTextureSource for the decode. Robust to repeated/CI runs (kills stray games, retries inject). Trim the roadmap (the mock-game item is done) and document the test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
21
README.md
21
README.md
@@ -95,16 +95,6 @@ and covers anything the hooked path doesn't (Vulkan, D3D9 — see Roadmap).
|
|||||||
|
|
||||||
## 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
|
### Future work
|
||||||
|
|
||||||
- **Vulkan video hook.** Vulkan games present via `vkQueuePresentKHR`; hooking
|
- **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
|
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
|
each — confirming loopback is format-agnostic (it captures post-mix at the device
|
||||||
endpoint format). Skips cleanly if the machine has no audio endpoint.
|
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
|
### Debugging the hooks against a real game
|
||||||
|
|
||||||
|
|||||||
@@ -92,6 +92,44 @@ bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share)
|
|||||||
return true;
|
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<ID3D11Texture2D> 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<const std::uint8_t*>(map.pData) + static_cast<std::size_t>(y) * map.RowPitch +
|
||||||
|
static_cast<std::size_t>(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)
|
bool SharedTextureSource::update(const VideoShareView& share, unsigned long pid)
|
||||||
{
|
{
|
||||||
if (device_ == nullptr || pid == 0)
|
if (device_ == nullptr || pid == 0)
|
||||||
|
|||||||
@@ -31,6 +31,12 @@ public:
|
|||||||
// Drop the opened resources (target changed / mirror turned off).
|
// Drop the opened resources (target changed / mirror turned off).
|
||||||
void reset();
|
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
|
[[nodiscard]] ID3D11ShaderResourceView* srv() const
|
||||||
{
|
{
|
||||||
return srv_.Get();
|
return srv_.Get();
|
||||||
|
|||||||
@@ -140,6 +140,20 @@ target_link_libraries(dx12_present_hook_test PRIVATE
|
|||||||
|
|
||||||
add_test(NAME dx12_present_hook_test COMMAND dx12_present_hook_test)
|
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
|
# 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
|
# 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
|
# exercises the SwapBuffers hook, the glReadPixels readback, and the upload into
|
||||||
@@ -178,4 +192,5 @@ coop_output_subdir(tests
|
|||||||
srgb_format_test
|
srgb_format_test
|
||||||
present_hook_test
|
present_hook_test
|
||||||
dx12_present_hook_test
|
dx12_present_hook_test
|
||||||
opengl_hook_test)
|
opengl_hook_test
|
||||||
|
mock_game_test)
|
||||||
|
|||||||
447
tests/mock_game_test.cpp
Normal file
447
tests/mock_game_test.cpp
Normal file
@@ -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 <algorithm>
|
||||||
|
#include <cmath>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
#include <tlhelp32.h>
|
||||||
|
|
||||||
|
#include <d3d11.h>
|
||||||
|
|
||||||
|
#include "capture/shared_texture.hpp"
|
||||||
|
#include "coop/audio_ring.hpp"
|
||||||
|
#include "coop/log_ring.hpp"
|
||||||
|
#include "coop/protocol.hpp"
|
||||||
|
#include "coop/shared_memory.hpp"
|
||||||
|
#include "coop/tool_paths.hpp"
|
||||||
|
#include "render_backend.hpp" // coop::mock::rgb_to_frame / kFrameBlock
|
||||||
|
|
||||||
|
using namespace coop;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
int g_failures = 0;
|
||||||
|
void check(bool ok, const char* what)
|
||||||
|
{
|
||||||
|
std::printf("%s %s\n", ok ? " ok:" : "FAIL:", what);
|
||||||
|
if (!ok)
|
||||||
|
{
|
||||||
|
++g_failures;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
std::wstring tool_path(const wchar_t* name)
|
||||||
|
{
|
||||||
|
return exe_directory() + name; // coop_mock_game.exe is staged next to this test
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kill any leftover mock games from a previous (crashed/interrupted) run, so a stray one
|
||||||
|
// holding coop_hook.dll can't perturb this run.
|
||||||
|
void kill_stray_mock_games()
|
||||||
|
{
|
||||||
|
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||||
|
if (snap == INVALID_HANDLE_VALUE)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
PROCESSENTRY32W pe{};
|
||||||
|
pe.dwSize = sizeof(pe);
|
||||||
|
for (BOOL ok = Process32FirstW(snap, &pe); ok; ok = Process32NextW(snap, &pe))
|
||||||
|
{
|
||||||
|
if (_wcsicmp(pe.szExeFile, L"coop_mock_game.exe") == 0)
|
||||||
|
{
|
||||||
|
HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID);
|
||||||
|
if (h != nullptr)
|
||||||
|
{
|
||||||
|
TerminateProcess(h, 0);
|
||||||
|
CloseHandle(h);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CloseHandle(snap);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject coop_hook.dll (x64 -> the mock game is x64) into `pid` via LoadLibrary remote thread.
|
||||||
|
bool inject(unsigned long pid)
|
||||||
|
{
|
||||||
|
const std::wstring dll = deployed_artifact_path(L"coop_hook.dll"); // root is one dir up from tests/
|
||||||
|
if (GetFileAttributesW(dll.c_str()) == INVALID_FILE_ATTRIBUTES)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const DWORD access = PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION |
|
||||||
|
PROCESS_VM_WRITE | PROCESS_VM_READ;
|
||||||
|
HANDLE process = OpenProcess(access, FALSE, pid);
|
||||||
|
if (process == nullptr)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const SIZE_T bytes = (dll.size() + 1) * sizeof(wchar_t);
|
||||||
|
void* remote = VirtualAllocEx(process, nullptr, bytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
|
||||||
|
bool ok = false;
|
||||||
|
if (remote != nullptr && WriteProcessMemory(process, remote, dll.c_str(), bytes, nullptr))
|
||||||
|
{
|
||||||
|
auto load = reinterpret_cast<LPTHREAD_START_ROUTINE>(
|
||||||
|
GetProcAddress(GetModuleHandleW(L"kernel32.dll"), "LoadLibraryW"));
|
||||||
|
HANDLE th = CreateRemoteThread(process, nullptr, 0, load, remote, 0, nullptr);
|
||||||
|
if (th != nullptr)
|
||||||
|
{
|
||||||
|
WaitForSingleObject(th, INFINITE);
|
||||||
|
DWORD code = 0;
|
||||||
|
GetExitCodeThread(th, &code);
|
||||||
|
CloseHandle(th);
|
||||||
|
ok = code != 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (remote != nullptr)
|
||||||
|
{
|
||||||
|
VirtualFreeEx(process, remote, 0, MEM_RELEASE);
|
||||||
|
}
|
||||||
|
CloseHandle(process);
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject with a few retries: a freshly-launched process can briefly refuse a remote thread.
|
||||||
|
bool inject_retry(unsigned long pid)
|
||||||
|
{
|
||||||
|
for (int i = 0; i < 4; ++i)
|
||||||
|
{
|
||||||
|
if (inject(pid))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
Sleep(300);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MockGame
|
||||||
|
{
|
||||||
|
PROCESS_INFORMATION pi{};
|
||||||
|
bool ok = false;
|
||||||
|
|
||||||
|
// Launch coop_mock_game.exe with the given args (e.g. L"dx12 30" or L"dx11 30 48000 2 32 float").
|
||||||
|
static MockGame launch(const std::wstring& args)
|
||||||
|
{
|
||||||
|
MockGame g;
|
||||||
|
const std::wstring exe = tool_path(L"coop_mock_game.exe");
|
||||||
|
std::wstring cmd = L"\"" + exe + L"\" " + args;
|
||||||
|
STARTUPINFOW si{};
|
||||||
|
si.cb = sizeof(si);
|
||||||
|
g.ok = CreateProcessW(exe.c_str(), cmd.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &si,
|
||||||
|
&g.pi) != 0;
|
||||||
|
return g;
|
||||||
|
}
|
||||||
|
unsigned long pid() const
|
||||||
|
{
|
||||||
|
return pi.dwProcessId;
|
||||||
|
}
|
||||||
|
bool alive() const
|
||||||
|
{
|
||||||
|
return pi.hProcess != nullptr && WaitForSingleObject(pi.hProcess, 0) == WAIT_TIMEOUT;
|
||||||
|
}
|
||||||
|
unsigned long exit_code() const
|
||||||
|
{
|
||||||
|
DWORD code = 0;
|
||||||
|
if (pi.hProcess != nullptr)
|
||||||
|
{
|
||||||
|
GetExitCodeProcess(pi.hProcess, &code);
|
||||||
|
}
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
void kill()
|
||||||
|
{
|
||||||
|
if (pi.hProcess != nullptr)
|
||||||
|
{
|
||||||
|
TerminateProcess(pi.hProcess, 0);
|
||||||
|
WaitForSingleObject(pi.hProcess, 2000);
|
||||||
|
CloseHandle(pi.hThread);
|
||||||
|
CloseHandle(pi.hProcess);
|
||||||
|
pi = PROCESS_INFORMATION{};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create the input SharedBlock the hook needs, with the given subsystems disabled (bit per
|
||||||
|
// HookSubsystem). Keeps the mapping alive in `shm`.
|
||||||
|
SharedBlock* make_ipc(SharedMemory& shm, unsigned long pid, std::uint32_t disabled_mask)
|
||||||
|
{
|
||||||
|
if (!shm.create(shared_memory_name(pid), sizeof(SharedBlock)))
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
auto* block = shm.as<SharedBlock>();
|
||||||
|
block->version = kProtocolVersion;
|
||||||
|
block->pad_count = 0;
|
||||||
|
block->sequence.store(0, std::memory_order_relaxed);
|
||||||
|
for (std::uint32_t s = 0; s < HookSubsys_Count; ++s)
|
||||||
|
{
|
||||||
|
block->control.subsystem_disabled[s].store((disabled_mask >> s) & 1u, std::memory_order_release);
|
||||||
|
}
|
||||||
|
block->magic = kProtocolMagic;
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
|
||||||
|
ID3D11Device* make_device()
|
||||||
|
{
|
||||||
|
ID3D11Device* dev = nullptr;
|
||||||
|
const D3D_FEATURE_LEVEL fl[] = {D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0};
|
||||||
|
if (FAILED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, fl,
|
||||||
|
static_cast<UINT>(std::size(fl)), D3D11_SDK_VERSION, &dev, nullptr, nullptr)))
|
||||||
|
{
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
return dev;
|
||||||
|
}
|
||||||
|
|
||||||
|
VideoShareView read_video_share(const SharedBlock* block)
|
||||||
|
{
|
||||||
|
VideoShareView v;
|
||||||
|
v.generation = block->video.generation.load(std::memory_order_acquire);
|
||||||
|
v.width = block->video.width;
|
||||||
|
v.height = block->video.height;
|
||||||
|
v.format = block->video.format;
|
||||||
|
v.present_calls = block->video.present_calls;
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture from the mock game on `backend` and assert the decoded frame numbers form a
|
||||||
|
// monotonic, advancing sequence.
|
||||||
|
void test_video_capture(const char* backend, ID3D11Device* device)
|
||||||
|
{
|
||||||
|
std::printf("== video capture: %s ==\n", backend);
|
||||||
|
const std::wstring args = (std::string(backend) == "dx12" ? L"dx12 " : L"dx11 ") + std::wstring(L"30");
|
||||||
|
MockGame game = MockGame::launch(args);
|
||||||
|
if (!game.ok)
|
||||||
|
{
|
||||||
|
check(false, "launch coop_mock_game");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sleep(800); // let the window + swap chain come up
|
||||||
|
|
||||||
|
// Only the video subsystem (disable input/focus/audio/mkb to keep the test focused).
|
||||||
|
SharedMemory shm;
|
||||||
|
const std::uint32_t disabled = (1u << HookSubsys_Input) | (1u << HookSubsys_Focus) |
|
||||||
|
(1u << HookSubsys_Audio) | (1u << HookSubsys_Mkb);
|
||||||
|
SharedBlock* block = make_ipc(shm, game.pid(), disabled);
|
||||||
|
if (block == nullptr || !inject_retry(game.pid()))
|
||||||
|
{
|
||||||
|
check(false, "inject into mock game");
|
||||||
|
game.kill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedTextureSource src;
|
||||||
|
src.init(device);
|
||||||
|
|
||||||
|
std::vector<std::uint32_t> seq;
|
||||||
|
std::uint64_t backward = 0;
|
||||||
|
std::uint32_t last = 0;
|
||||||
|
bool have_last = false;
|
||||||
|
for (int i = 0; i < 80 && game.alive(); ++i) // ~4 s at 50 ms
|
||||||
|
{
|
||||||
|
Sleep(50);
|
||||||
|
const VideoShareView share = read_video_share(block);
|
||||||
|
if (!src.update(share, game.pid()))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::uint8_t px[4] = {};
|
||||||
|
if (!src.read_pixel(coop::mock::kFrameBlock / 2, coop::mock::kFrameBlock / 2, px))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const std::uint32_t f = coop::mock::rgb_to_frame(px[0], px[1], px[2]);
|
||||||
|
if (have_last && f < last)
|
||||||
|
{
|
||||||
|
++backward; // a stale / wrong (rotated) buffer -> frame number went backwards
|
||||||
|
}
|
||||||
|
last = f;
|
||||||
|
have_last = true;
|
||||||
|
seq.push_back(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
const std::uint32_t present = static_cast<std::uint32_t>(block->video.present_calls);
|
||||||
|
std::printf(" present_calls=%u copied=%llu samples=%zu backward=%llu\n", present,
|
||||||
|
static_cast<unsigned long long>(src.frames_copied()), seq.size(),
|
||||||
|
static_cast<unsigned long long>(backward));
|
||||||
|
|
||||||
|
check(present > 0, "hook captured Present calls");
|
||||||
|
check(src.frames_copied() >= 5, "host copied multiple shared frames");
|
||||||
|
check(seq.size() >= 5, "decoded multiple frame numbers from the captured pixels");
|
||||||
|
check(backward == 0, "captured frame numbers never go backwards (no stale/rotated buffer)");
|
||||||
|
if (!seq.empty())
|
||||||
|
{
|
||||||
|
const std::uint32_t span = seq.back() - seq.front();
|
||||||
|
std::printf(" frame# %u..%u (span %u)\n", seq.front(), seq.back(), span);
|
||||||
|
check(span >= 20, "captured frame numbers advance (mirror gets fresh frames)");
|
||||||
|
const bool all_same = std::all_of(seq.begin(), seq.end(), [&](std::uint32_t v) { return v == seq[0]; });
|
||||||
|
check(!all_same, "captured frames are not stuck on one number");
|
||||||
|
}
|
||||||
|
|
||||||
|
game.kill();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject with audio + video, verify both stream, then cycle the audio subsystem off/on a
|
||||||
|
// few times (hook/unhook stress) and confirm the game stays alive (heartbeat advances).
|
||||||
|
void test_av_and_hook_cycles(ID3D11Device* device)
|
||||||
|
{
|
||||||
|
std::printf("== A/V + hook/unhook stress (dx11 + audio) ==\n");
|
||||||
|
MockGame game = MockGame::launch(L"dx11 30 48000 2 32 float");
|
||||||
|
if (!game.ok)
|
||||||
|
{
|
||||||
|
check(false, "launch coop_mock_game (A/V)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Sleep(800);
|
||||||
|
|
||||||
|
SharedMemory shm;
|
||||||
|
SharedBlock* block = make_ipc(shm, game.pid(), /*disabled=*/0); // all subsystems on
|
||||||
|
SharedMemory ring_shm;
|
||||||
|
AudioRingHeader* ring = nullptr;
|
||||||
|
if (ring_shm.create(audio_ring_name(game.pid()), audio_ring_total_size(kAudioRingCapacity)))
|
||||||
|
{
|
||||||
|
ring = ring_shm.as<AudioRingHeader>();
|
||||||
|
audio_ring_init(*ring, kAudioRingCapacity);
|
||||||
|
ring->capture_enabled.store(1, std::memory_order_release);
|
||||||
|
}
|
||||||
|
if (block == nullptr || ring == nullptr || !inject_retry(game.pid()))
|
||||||
|
{
|
||||||
|
check(false, "inject into mock game (A/V)");
|
||||||
|
game.kill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SharedTextureSource src;
|
||||||
|
src.init(device);
|
||||||
|
|
||||||
|
// Let it settle, then verify video advances and audio is non-silent.
|
||||||
|
double peak = 0.0;
|
||||||
|
std::uint32_t first_frame = 0, last_frame = 0;
|
||||||
|
std::vector<std::uint8_t> drain(kAudioRingCapacity);
|
||||||
|
for (int i = 0; i < 60 && game.alive(); ++i) // ~3 s
|
||||||
|
{
|
||||||
|
Sleep(50);
|
||||||
|
const VideoShareView share = read_video_share(block);
|
||||||
|
if (src.update(share, game.pid()))
|
||||||
|
{
|
||||||
|
std::uint8_t px[4] = {};
|
||||||
|
if (src.read_pixel(coop::mock::kFrameBlock / 2, coop::mock::kFrameBlock / 2, px))
|
||||||
|
{
|
||||||
|
const std::uint32_t f = coop::mock::rgb_to_frame(px[0], px[1], px[2]);
|
||||||
|
if (first_frame == 0)
|
||||||
|
{
|
||||||
|
first_frame = f;
|
||||||
|
}
|
||||||
|
last_frame = f;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::uint32_t got = 0;
|
||||||
|
while ((got = audio_ring_pop(*ring, drain.data(), static_cast<std::uint32_t>(drain.size()))) > 0)
|
||||||
|
{
|
||||||
|
if (ring->bits == 32 && ring->format_tag == 3)
|
||||||
|
{
|
||||||
|
const auto* f = reinterpret_cast<const float*>(drain.data());
|
||||||
|
for (std::uint32_t k = 0; k < got / 4; ++k)
|
||||||
|
{
|
||||||
|
peak = std::max(peak, static_cast<double>(std::fabs(f[k])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (got < drain.size())
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check(last_frame > first_frame, "A/V: video frames advance");
|
||||||
|
check(peak > 0.01, "A/V: audio captured non-silent");
|
||||||
|
|
||||||
|
// Hook/unhook stress: toggle the audio subsystem off->on a few times. Each phase is >
|
||||||
|
// the worker's ~250 ms reconcile tick, so the install/remove fully completes each time
|
||||||
|
// (this is the realistic cadence -- an operator toggling a checkbox, not thrashing it).
|
||||||
|
const std::uint32_t hb_start = block->status.heartbeat.load(std::memory_order_relaxed);
|
||||||
|
for (int c = 0; c < 3 && game.alive(); ++c)
|
||||||
|
{
|
||||||
|
block->control.subsystem_disabled[HookSubsys_Audio].store(1, std::memory_order_release);
|
||||||
|
Sleep(400);
|
||||||
|
block->control.subsystem_disabled[HookSubsys_Audio].store(0, std::memory_order_release);
|
||||||
|
Sleep(400);
|
||||||
|
}
|
||||||
|
const std::uint32_t hb_end = block->status.heartbeat.load(std::memory_order_relaxed);
|
||||||
|
if (!game.alive())
|
||||||
|
{
|
||||||
|
std::printf(" game exit code = 0x%08lX\n", game.exit_code());
|
||||||
|
}
|
||||||
|
check(game.alive(), "game survived hook/unhook cycles (no crash)");
|
||||||
|
check(hb_end > hb_start, "hook heartbeat kept advancing through the cycles (no freeze)");
|
||||||
|
|
||||||
|
// After the final re-enable, capture must resume: the audio must come back (proving the
|
||||||
|
// rehook works end-to-end, not just that nothing crashed).
|
||||||
|
std::uint64_t produced_before = ring->frames_produced.load(std::memory_order_relaxed);
|
||||||
|
double peak2 = 0.0;
|
||||||
|
for (int i = 0; i < 30 && game.alive(); ++i) // ~1.5 s
|
||||||
|
{
|
||||||
|
Sleep(50);
|
||||||
|
std::uint32_t got = 0;
|
||||||
|
while ((got = audio_ring_pop(*ring, drain.data(), static_cast<std::uint32_t>(drain.size()))) > 0)
|
||||||
|
{
|
||||||
|
if (ring->bits == 32 && ring->format_tag == 3)
|
||||||
|
{
|
||||||
|
const auto* f = reinterpret_cast<const float*>(drain.data());
|
||||||
|
for (std::uint32_t k = 0; k < got / 4; ++k)
|
||||||
|
{
|
||||||
|
peak2 = std::max(peak2, static_cast<double>(std::fabs(f[k])));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (got < drain.size())
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const std::uint64_t produced_after = ring->frames_produced.load(std::memory_order_relaxed);
|
||||||
|
check(produced_after > produced_before && peak2 > 0.01, "audio capture resumed after re-enable");
|
||||||
|
|
||||||
|
game.kill();
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
int main()
|
||||||
|
{
|
||||||
|
kill_stray_mock_games(); // clean slate: no leftover game holding coop_hook.dll
|
||||||
|
|
||||||
|
ID3D11Device* device = make_device();
|
||||||
|
if (device == nullptr)
|
||||||
|
{
|
||||||
|
std::printf("No D3D11 device -- skipping mock_game_test.\n");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
test_video_capture("dx11", device);
|
||||||
|
test_video_capture("dx12", device);
|
||||||
|
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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user