diff --git a/CMakeLists.txt b/CMakeLists.txt index b47dee3..69a3518 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -120,6 +120,7 @@ if(COOP_BUILD_HOOK) add_subdirectory(hook) enable_testing() add_subdirectory(tools/audio_tone) # coop_tone: audio source for the loopback test + add_subdirectory(tools/mock_game) # coop_mock_game: A/V test game for the capture/hook tests add_subdirectory(tools/audio_probe) # coop_audio_probe: inject + diagnose the render-hook add_subdirectory(tools/input_probe) # coop_input_probe: inject + forward synthetic input add_subdirectory(tests) diff --git a/tools/mock_game/CMakeLists.txt b/tools/mock_game/CMakeLists.txt new file mode 100644 index 0000000..8368f62 --- /dev/null +++ b/tools/mock_game/CMakeLists.txt @@ -0,0 +1,16 @@ +# CoopMockGame -- a tiny test "game" with an animated, frame-numbered render (DX11/DX12) +# and a configurable WASAPI tone, used by the capture/audio/hook stress tests. Not shipped. +add_executable(coop_mock_game + main.cpp + render_backend.cpp + render_dx11.cpp + render_dx12.cpp) + +# Reuses the shared ToneSource (also used by coop_tone + the audio hook self-test). +target_include_directories(coop_mock_game PRIVATE ${CMAKE_SOURCE_DIR}/tools/audio_tone) + +target_link_libraries(coop_mock_game PRIVATE d3d11 d3d12 dxgi ole32) +set_target_properties(coop_mock_game PROPERTIES OUTPUT_NAME "coop_mock_game") + +# Test fixture -> stage next to the tests (alongside coop_tone), not in the deployable root. +coop_output_subdir(tests coop_mock_game) diff --git a/tools/mock_game/main.cpp b/tools/mock_game/main.cpp new file mode 100644 index 0000000..ed7c8f0 --- /dev/null +++ b/tools/mock_game/main.cpp @@ -0,0 +1,147 @@ +// CoopMockGame -- a tiny test "game" used to exercise the capture + audio + hook paths. +// +// coop_mock_game.exe [dx11|dx12] [seconds] [rate] [channels] [bits] [pcm|float] +// +// It opens a normal visible window and renders an animated, frame-numbered pattern (see +// render_backend.hpp): a moving bar + per-frame background colour make motion obvious, and +// a top-left block encodes the exact frame number so a capture test can decode it and +// detect dropped / duplicated / stale frames. With audio args it also plays a configurable +// WASAPI tone (the same ToneSource coop_tone uses), so it doubles as an A/V source. seconds +// = 0 runs until the window is closed. Console app so it can print its pid + status. +#include +#include +#include +#include +#include + +#include + +#include "render_backend.hpp" +#include "tone_source.hpp" + +namespace +{ +std::atomic g_running{true}; + +LRESULT CALLBACK wnd_proc(HWND hwnd, UINT msg, WPARAM wparam, LPARAM lparam) +{ + if (msg == WM_DESTROY) + { + PostQuitMessage(0); + return 0; + } + return DefWindowProcW(hwnd, msg, wparam, lparam); +} + +// Spawns the audio render loop on its own thread (WASAPI wants its own COM apartment). +void audio_thread(coop::tone::ToneFormat want) +{ + if (FAILED(CoInitializeEx(nullptr, COINIT_MULTITHREADED))) + { + return; + } + coop::tone::ToneSource tone; + if (tone.open(want, 440.0)) + { + std::printf("MOCK_GAME audio: %u Hz %u ch %u-bit %s\n", tone.format().rate, tone.format().channels, + tone.format().bits, tone.format().is_float ? "float" : "pcm"); + std::fflush(stdout); + while (g_running.load(std::memory_order_relaxed)) + { + tone.render_step(200); + } + } + else + { + std::printf("MOCK_GAME audio: failed to open requested format\n"); + } + tone.close(); + CoUninitialize(); +} +} // namespace + +int main(int argc, char** argv) +{ + const std::string backend_name = argc > 1 ? argv[1] : "dx11"; + const double seconds = argc > 2 ? std::strtod(argv[2], nullptr) : 0.0; + const bool want_audio = argc > 3; + coop::tone::ToneFormat audio_fmt; + if (want_audio) + { + audio_fmt.rate = static_cast(std::strtoul(argv[3], nullptr, 10)); + audio_fmt.channels = argc > 4 ? static_cast(std::strtoul(argv[4], nullptr, 10)) : 2; + audio_fmt.bits = argc > 5 ? static_cast(std::strtoul(argv[5], nullptr, 10)) : 32; + audio_fmt.is_float = argc > 6 ? (std::string(argv[6]) == "float") : (audio_fmt.bits == 32); + } + + constexpr std::uint32_t kW = 640, kH = 480; + const HINSTANCE inst = GetModuleHandleW(nullptr); + WNDCLASSEXW wc = {}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = wnd_proc; + wc.hInstance = inst; + wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); + wc.lpszClassName = L"CoopMockGameWindow"; + RegisterClassExW(&wc); + + RECT r = {0, 0, static_cast(kW), static_cast(kH)}; + AdjustWindowRect(&r, WS_OVERLAPPEDWINDOW, FALSE); + HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"CoopMockGame", WS_OVERLAPPEDWINDOW | WS_VISIBLE, + CW_USEDEFAULT, CW_USEDEFAULT, r.right - r.left, r.bottom - r.top, nullptr, + nullptr, inst, nullptr); + if (hwnd == nullptr) + { + std::printf("MOCK_GAME error: CreateWindow failed\n"); + return 1; + } + + auto backend = coop::mock::RenderBackend::create(backend_name); + if (!backend || !backend->init(hwnd, kW, kH)) + { + std::printf("MOCK_GAME error: backend '%s' unavailable\n", backend_name.c_str()); + return 2; + } + + std::printf("MOCK_GAME pid=%lu backend=%s w=%u h=%u audio=%s\n", GetCurrentProcessId(), backend->name(), + kW, kH, want_audio ? "yes" : "no"); + std::fflush(stdout); + + std::thread audio; + if (want_audio) + { + audio = std::thread(audio_thread, audio_fmt); + } + + const ULONGLONG start = GetTickCount64(); + std::uint32_t frame = 0; + for (;;) + { + MSG msg; + while (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE)) + { + if (msg.message == WM_QUIT) + { + g_running.store(false, std::memory_order_relaxed); + } + TranslateMessage(&msg); + DispatchMessageW(&msg); + } + if (!g_running.load(std::memory_order_relaxed)) + { + break; + } + backend->render_and_present(frame++); + if (seconds > 0.0 && (GetTickCount64() - start) >= static_cast(seconds * 1000.0)) + { + break; + } + } + + g_running.store(false, std::memory_order_relaxed); + if (audio.joinable()) + { + audio.join(); + } + std::printf("MOCK_GAME done: %u frames\n", frame); + return 0; +} diff --git a/tools/mock_game/render_backend.cpp b/tools/mock_game/render_backend.cpp new file mode 100644 index 0000000..67f78c3 --- /dev/null +++ b/tools/mock_game/render_backend.cpp @@ -0,0 +1,19 @@ +#include "render_backend.hpp" + +namespace coop::mock +{ + +std::unique_ptr RenderBackend::create(const std::string& name) +{ + if (name == "dx11") + { + return create_dx11_backend(); + } + if (name == "dx12") + { + return create_dx12_backend(); + } + return nullptr; +} + +} // namespace coop::mock diff --git a/tools/mock_game/render_backend.hpp b/tools/mock_game/render_backend.hpp new file mode 100644 index 0000000..821f5ab --- /dev/null +++ b/tools/mock_game/render_backend.hpp @@ -0,0 +1,64 @@ +// Pluggable render backend for the mock game (a test fixture, not a shipped component). +// +// The mock game renders an *animated* (never static) pattern so dropped / duplicated / +// torn / stale frames are obvious both to the eye and to an automated capture test: a +// moving bar + a per-frame background colour give visible motion, and a small top-left +// "frame-counter" block encodes the exact frame number in its RGB so a test can decode it +// from a captured pixel and assert the sequence advances (the DX12 rotating-backbuffer bug +// showed up as a stuck/repeating counter). The backend is selectable (DX11 / DX12 today) +// and structured so OpenGL / Vulkan can be added by implementing this interface. +#pragma once + +#include +#include +#include + +#include + +namespace coop::mock +{ + +// Encode a frame counter into an RGB triple (and back). R/G/B are the low 24 bits, so it +// is unambiguous for ~16M frames. The swap chain is UNORM (not sRGB), so the bytes survive +// a capture copy exactly. The capture test samples the frame-counter block and decodes it. +inline void frame_to_rgb(std::uint32_t frame, std::uint8_t& r, std::uint8_t& g, std::uint8_t& b) +{ + r = static_cast(frame & 0xFF); + g = static_cast((frame >> 8) & 0xFF); + b = static_cast((frame >> 16) & 0xFF); +} + +inline std::uint32_t rgb_to_frame(std::uint8_t r, std::uint8_t g, std::uint8_t b) +{ + return static_cast(r) | (static_cast(g) << 8) | + (static_cast(b) << 16); +} + +// Size (px) of the top-left frame-counter block the test samples. +inline constexpr std::uint32_t kFrameBlock = 64; + +class RenderBackend +{ +public: + virtual ~RenderBackend() = default; + + // Bring up the device + swap chain on `hwnd` at the given client size. False on failure. + virtual bool init(HWND hwnd, std::uint32_t width, std::uint32_t height) = 0; + + // Draw frame `frame` (animated background + moving bar + the frame-counter block) and + // present it. One call per displayed frame. + virtual void render_and_present(std::uint32_t frame) = 0; + + // Human-readable backend name (e.g. "dx11"), for logging. + [[nodiscard]] virtual const char* name() const = 0; + + // Factory: returns a backend for `name` ("dx11" / "dx12"), or nullptr if unknown / + // unavailable on this machine. + static std::unique_ptr create(const std::string& name); +}; + +// Per-backend factories (defined in their own TUs; create() dispatches to them). +std::unique_ptr create_dx11_backend(); +std::unique_ptr create_dx12_backend(); + +} // namespace coop::mock diff --git a/tools/mock_game/render_dx11.cpp b/tools/mock_game/render_dx11.cpp new file mode 100644 index 0000000..1dd866f --- /dev/null +++ b/tools/mock_game/render_dx11.cpp @@ -0,0 +1,116 @@ +// DX11 backend for the mock game. Renders the animated pattern entirely with region +// clears (ID3D11DeviceContext1::ClearView) so it needs no shaders: an animated full-screen +// background, a moving vertical bar, and the top-left frame-counter block. +#include "render_backend.hpp" + +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +namespace coop::mock +{ +namespace +{ +class Dx11Backend : public RenderBackend +{ +public: + bool init(HWND hwnd, std::uint32_t width, std::uint32_t height) override + { + width_ = width; + height_ = height; + + const D3D_FEATURE_LEVEL levels[] = {D3D_FEATURE_LEVEL_11_1, D3D_FEATURE_LEVEL_11_0}; + ComPtr ctx0; + if (FAILED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, levels, + static_cast(std::size(levels)), D3D11_SDK_VERSION, + device_.GetAddressOf(), nullptr, ctx0.GetAddressOf()))) + { + return false; + } + if (FAILED(ctx0.As(&ctx_))) // ClearView needs ID3D11DeviceContext1 + { + return false; + } + + ComPtr dxgi_device; + ComPtr adapter; + ComPtr factory; + if (FAILED(device_.As(&dxgi_device)) || FAILED(dxgi_device->GetAdapter(adapter.GetAddressOf())) || + FAILED(adapter->GetParent(IID_PPV_ARGS(factory.GetAddressOf())))) + { + return false; + } + + DXGI_SWAP_CHAIN_DESC1 desc = {}; + desc.Width = width; + desc.Height = height; + desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; // UNORM (not sRGB) so the frame code is exact + desc.SampleDesc.Count = 1; + desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + desc.BufferCount = 2; + desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; + if (FAILED(factory->CreateSwapChainForHwnd(device_.Get(), hwnd, &desc, nullptr, nullptr, + swap_.GetAddressOf()))) + { + return false; + } + factory->MakeWindowAssociation(hwnd, DXGI_MWA_NO_ALT_ENTER); + + // D3D11 flip-model: GetBuffer(0) stays the live back buffer, so one RTV is reused. + ComPtr back; + if (FAILED(swap_->GetBuffer(0, IID_PPV_ARGS(back.GetAddressOf()))) || + FAILED(device_->CreateRenderTargetView(back.Get(), nullptr, rtv_.GetAddressOf()))) + { + return false; + } + return true; + } + + void render_and_present(std::uint32_t frame) override + { + // Animated background: each channel sweeps at a different rate -> constant motion. + const float bg[4] = {static_cast((frame * 2) % 256) / 255.0f, + static_cast((frame * 3) % 256) / 255.0f, + static_cast((frame * 5) % 256) / 255.0f, 1.0f}; + ctx_->ClearRenderTargetView(rtv_.Get(), bg); + + // Moving vertical bar (obvious motion; a dropped frame makes it jump). + const float white[4] = {1.0f, 1.0f, 1.0f, 1.0f}; + const LONG span = width_ > 24 ? static_cast(width_ - 24) : 1; + const LONG bx = static_cast((frame * 4) % static_cast(span)); + const D3D11_RECT bar = {bx, 0, bx + 24, static_cast(height_)}; + ctx_->ClearView(rtv_.Get(), white, &bar, 1); + + // Frame-counter block (top-left): RGB encodes the exact frame number for the test. + std::uint8_t r = 0, g = 0, b = 0; + frame_to_rgb(frame, r, g, b); + const float code[4] = {r / 255.0f, g / 255.0f, b / 255.0f, 1.0f}; + const D3D11_RECT block = {0, 0, static_cast(kFrameBlock), static_cast(kFrameBlock)}; + ctx_->ClearView(rtv_.Get(), code, &block, 1); + + swap_->Present(1, 0); // vsync -> a game-like cadence + } + + [[nodiscard]] const char* name() const override + { + return "dx11"; + } + +private: + std::uint32_t width_ = 0; + std::uint32_t height_ = 0; + ComPtr device_; + ComPtr ctx_; + ComPtr swap_; + ComPtr rtv_; +}; +} // namespace + +std::unique_ptr create_dx11_backend() +{ + return std::make_unique(); +} + +} // namespace coop::mock diff --git a/tools/mock_game/render_dx12.cpp b/tools/mock_game/render_dx12.cpp new file mode 100644 index 0000000..73589f0 --- /dev/null +++ b/tools/mock_game/render_dx12.cpp @@ -0,0 +1,198 @@ +// DX12 backend for the mock game. Renders the same animated pattern as the DX11 backend +// (animated background + moving bar + top-left frame-counter block) so the capture tests +// can verify the DX12 capture path against an identical, frame-numbered source. +#include "render_backend.hpp" + +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +namespace coop::mock +{ +namespace +{ +constexpr UINT kBackBuffers = 3; // DX12 rotates these explicitly -- the case the capture must get right + +class Dx12Backend : public RenderBackend +{ +public: + bool init(HWND hwnd, std::uint32_t width, std::uint32_t height) override + { + width_ = width; + height_ = height; + + if (FAILED(D3D12CreateDevice(nullptr, D3D_FEATURE_LEVEL_11_0, IID_PPV_ARGS(device_.GetAddressOf())))) + { + return false; + } + + D3D12_COMMAND_QUEUE_DESC qd = {}; + qd.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; + if (FAILED(device_->CreateCommandQueue(&qd, IID_PPV_ARGS(queue_.GetAddressOf())))) + { + return false; + } + + ComPtr factory; + if (FAILED(CreateDXGIFactory1(IID_PPV_ARGS(factory.GetAddressOf())))) + { + return false; + } + DXGI_SWAP_CHAIN_DESC1 desc = {}; + desc.Width = width; + desc.Height = height; + desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + desc.SampleDesc.Count = 1; + desc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + desc.BufferCount = kBackBuffers; + desc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; + ComPtr sc1; + if (FAILED(factory->CreateSwapChainForHwnd(queue_.Get(), hwnd, &desc, nullptr, nullptr, + sc1.GetAddressOf())) || + FAILED(sc1.As(&swap_))) + { + return false; + } + factory->MakeWindowAssociation(hwnd, DXGI_MWA_NO_ALT_ENTER); + + D3D12_DESCRIPTOR_HEAP_DESC hd = {}; + hd.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV; + hd.NumDescriptors = kBackBuffers; + if (FAILED(device_->CreateDescriptorHeap(&hd, IID_PPV_ARGS(rtv_heap_.GetAddressOf())))) + { + return false; + } + rtv_stride_ = device_->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV); + D3D12_CPU_DESCRIPTOR_HANDLE h = rtv_heap_->GetCPUDescriptorHandleForHeapStart(); + for (UINT i = 0; i < kBackBuffers; ++i) + { + if (FAILED(swap_->GetBuffer(i, IID_PPV_ARGS(targets_[i].GetAddressOf())))) + { + return false; + } + device_->CreateRenderTargetView(targets_[i].Get(), nullptr, h); + rtv_handles_[i] = h; + h.ptr += rtv_stride_; + if (FAILED(device_->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, + IID_PPV_ARGS(allocs_[i].GetAddressOf())))) + { + return false; + } + } + if (FAILED(device_->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, allocs_[0].Get(), nullptr, + IID_PPV_ARGS(list_.GetAddressOf())))) + { + return false; + } + list_->Close(); + + if (FAILED(device_->CreateFence(0, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(fence_.GetAddressOf())))) + { + return false; + } + fence_event_ = CreateEventW(nullptr, FALSE, FALSE, nullptr); + return fence_event_ != nullptr; + } + + void render_and_present(std::uint32_t frame) override + { + const UINT idx = swap_->GetCurrentBackBufferIndex(); + allocs_[idx]->Reset(); + list_->Reset(allocs_[idx].Get(), nullptr); + + transition(targets_[idx].Get(), D3D12_RESOURCE_STATE_PRESENT, D3D12_RESOURCE_STATE_RENDER_TARGET); + + // Animated background (full RT clear). + const float bg[4] = {static_cast((frame * 2) % 256) / 255.0f, + static_cast((frame * 3) % 256) / 255.0f, + static_cast((frame * 5) % 256) / 255.0f, 1.0f}; + list_->ClearRenderTargetView(rtv_handles_[idx], bg, 0, nullptr); + + // Moving vertical bar. + const float white[4] = {1.0f, 1.0f, 1.0f, 1.0f}; + const LONG span = width_ > 24 ? static_cast(width_ - 24) : 1; + const LONG bx = static_cast((frame * 4) % static_cast(span)); + const D3D12_RECT bar = {bx, 0, bx + 24, static_cast(height_)}; + list_->ClearRenderTargetView(rtv_handles_[idx], white, 1, &bar); + + // Frame-counter block (top-left). + std::uint8_t r = 0, g = 0, b = 0; + frame_to_rgb(frame, r, g, b); + const float code[4] = {r / 255.0f, g / 255.0f, b / 255.0f, 1.0f}; + const D3D12_RECT block = {0, 0, static_cast(kFrameBlock), static_cast(kFrameBlock)}; + list_->ClearRenderTargetView(rtv_handles_[idx], code, 1, &block); + + transition(targets_[idx].Get(), D3D12_RESOURCE_STATE_RENDER_TARGET, D3D12_RESOURCE_STATE_PRESENT); + list_->Close(); + ID3D12CommandList* lists[] = {list_.Get()}; + queue_->ExecuteCommandLists(1, lists); + + swap_->Present(1, 0); + wait_for_gpu(); // simple per-frame sync (mock game: correctness over throughput) + } + + [[nodiscard]] const char* name() const override + { + return "dx12"; + } + + ~Dx12Backend() override + { + if (fence_ != nullptr) + { + wait_for_gpu(); + } + if (fence_event_ != nullptr) + { + CloseHandle(fence_event_); + } + } + +private: + void transition(ID3D12Resource* res, D3D12_RESOURCE_STATES from, D3D12_RESOURCE_STATES to) + { + D3D12_RESOURCE_BARRIER b = {}; + b.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; + b.Transition.pResource = res; + b.Transition.StateBefore = from; + b.Transition.StateAfter = to; + b.Transition.Subresource = D3D12_RESOURCE_BARRIER_ALL_SUBRESOURCES; + list_->ResourceBarrier(1, &b); + } + + void wait_for_gpu() + { + const UINT64 v = ++fence_value_; + queue_->Signal(fence_.Get(), v); + if (fence_->GetCompletedValue() < v) + { + fence_->SetEventOnCompletion(v, fence_event_); + WaitForSingleObject(fence_event_, INFINITE); + } + } + + std::uint32_t width_ = 0; + std::uint32_t height_ = 0; + ComPtr device_; + ComPtr queue_; + ComPtr swap_; + ComPtr rtv_heap_; + UINT rtv_stride_ = 0; + ComPtr targets_[kBackBuffers]; + D3D12_CPU_DESCRIPTOR_HANDLE rtv_handles_[kBackBuffers] = {}; + ComPtr allocs_[kBackBuffers]; + ComPtr list_; + ComPtr fence_; + UINT64 fence_value_ = 0; + HANDLE fence_event_ = nullptr; +}; +} // namespace + +std::unique_ptr create_dx12_backend() +{ + return std::make_unique(); +} + +} // namespace coop::mock