diff --git a/README.md b/README.md index 596f0e9..2b3fb36 100644 --- a/README.md +++ b/README.md @@ -137,21 +137,10 @@ Conventions for every milestone below: panel so it fits (content can be moved between columns / rows — the most detailed case should still fit). Land an automated harness check (drive-to-max → screenshot → assert no overflow) so later milestones that add UI keep it green. *Independent of the backend work; - every milestone below must preserve this test* (M3's red banner and Vulkan-layer checkbox + every milestone below must preserve this test* (M2's red banner and Vulkan-layer checkbox in particular). -- **M2 — OpenGL (mock → capture coverage).** - 1. **Mock backend** (`render_gl.cpp`). Raw WGL context (`wglCreateContextAttribsARB`) with the - **glad** loader (new submodule, `Dav1dde/glad`). Background via `glClearColor`/`glClear`; - bar + block via `glScissor` + clear (shader-free GL 1.x). GL's framebuffer is - **bottom-left** origin, so the counter block is placed flipped so the captured (top-left) - pixel still decodes — matching the existing `glReadPixels` capture flip. Adds the - `.gitmodules` entry + the `coop_require_submodule()` check. - 2. **Capture coverage.** The GL `SwapBuffers`/`wglSwapBuffers` + `glReadPixels` path already - ships; add a mock-backed regression that decodes the mock's frames through it (upgrading the - synthetic `opengl_hook_test` to a real animated game). Small. - -- **M3 — Vulkan (mock → capture).** The largest. +- **M2 — Vulkan (mock → capture).** The largest. 1. **Mock backend** (`render_vk.cpp`). New submodules **Vulkan-Headers** (`KhronosGroup/Vulkan-Headers`, official) + **volk** (`zeux/volk`); raw `vkCreateWin32SurfaceKHR`, swap chain, per-frame acquire → clear → present. Background via @@ -296,7 +285,7 @@ ctest --test-dir build -C Debug --output-on-failure 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 **DX9 / DX9Ex / DX10 / DX11 / DX12** backends and a + [`tools/mock_game`](tools/mock_game) with selectable **DX9 / DX9Ex / DX10 / DX11 / DX12 / OpenGL** 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 each backend (the bar @@ -457,6 +446,13 @@ Non-obvious things that cost time and constrain the design: so **one path serves both** — the GPU shared-surface fast path the plan sketched for D3D9Ex wasn't worth it (D3D9 games are light enough that the read-back cost is fine, and it dodges the cross-device keyed-mutex-less sync of a legacy shared surface). +- **The OpenGL mock needs no GL loader.** Drawing the animated pattern with scissored clears + (`glClear` + `glScissor`) only touches **GL 1.1**, which `opengl32` exports directly — so the + planned **glad** submodule wasn't needed (a legacy `wglCreateContext` + `` suffices). + GL's framebuffer is **bottom-left** origin and the capture flips it top-down, so the mock draws + the frame-counter block at the GL *top* (`y = h - block`) to land at the captured image's + top-left. The GL `SwapBuffers` hook now also bumps the shared present counter (it's the GL + present), so the Video panel's present rate works for GL games too. - **A render client that predates our injection has no knowable format — measure it.** We inject into already-running games, so we usually never see the game's `IAudioClient::Initialize`; the render-hook then assumes the device mix format for that diff --git a/hook/src/opengl_hook.cpp b/hook/src/opengl_hook.cpp index f3e4c27..40473c5 100644 --- a/hook/src/opengl_hook.cpp +++ b/hook/src/opengl_hook.cpp @@ -240,6 +240,10 @@ BOOL WINAPI hk_SwapBuffers(HDC hdc) if (outer) { t_in_swap = true; + if (g_ipc != nullptr) + { + g_ipc->note_present(); // SwapBuffers is the GL present (counts toward video.present_calls) + } capture_gl(hdc); } const BOOL r = g_hk_swapbuffers.stdcall(hdc); // __stdcall: call() is __cdecl on x86 -> crash @@ -258,6 +262,10 @@ BOOL WINAPI hk_wglSwapBuffers(HDC hdc) if (outer) { t_in_swap = true; + if (g_ipc != nullptr) + { + g_ipc->note_present(); // wglSwapBuffers is the GL present + } capture_gl(hdc); } const BOOL r = g_hk_wglswap.stdcall(hdc); // __stdcall: call() is __cdecl on x86 -> crash diff --git a/tests/mock_game_test.cpp b/tests/mock_game_test.cpp index 3d891cf..32bcdc6 100644 --- a/tests/mock_game_test.cpp +++ b/tests/mock_game_test.cpp @@ -522,6 +522,7 @@ int main() return 0; } + test_video_capture("gl", device); test_video_capture("dx9ex", device); test_video_capture("dx9", device); test_video_capture("dx10", device); diff --git a/tools/mock_game/CMakeLists.txt b/tools/mock_game/CMakeLists.txt index 338b658..185a8d9 100644 --- a/tools/mock_game/CMakeLists.txt +++ b/tools/mock_game/CMakeLists.txt @@ -6,12 +6,13 @@ add_executable(coop_mock_game render_dx11.cpp render_dx12.cpp render_dx10.cpp - render_dx09.cpp) + render_dx09.cpp + render_gl.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 d3d10 d3d9 dxgi ole32) +target_link_libraries(coop_mock_game PRIVATE d3d11 d3d12 d3d10 d3d9 dxgi ole32 opengl32 gdi32) 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. diff --git a/tools/mock_game/main.cpp b/tools/mock_game/main.cpp index 6948235..1f3ed6c 100644 --- a/tools/mock_game/main.cpp +++ b/tools/mock_game/main.cpp @@ -1,6 +1,6 @@ // CoopMockGame -- a tiny test "game" used to exercise the capture + audio + hook paths. // -// coop_mock_game.exe [dx9|dx9ex|dx10|dx11|dx12] [seconds] [rate] [channels] [bits] [pcm|float] +// coop_mock_game.exe [dx9|dx9ex|dx10|dx11|dx12|gl] [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 @@ -78,6 +78,7 @@ int main(int argc, char** argv) const HINSTANCE inst = GetModuleHandleW(nullptr); WNDCLASSEXW wc = {}; wc.cbSize = sizeof(wc); + wc.style = CS_OWNDC; // a stable private DC, so the OpenGL backend can keep one GL context wc.lpfnWndProc = wnd_proc; wc.hInstance = inst; wc.hCursor = LoadCursorW(nullptr, IDC_ARROW); diff --git a/tools/mock_game/render_backend.cpp b/tools/mock_game/render_backend.cpp index a7a0ae0..4f47586 100644 --- a/tools/mock_game/render_backend.cpp +++ b/tools/mock_game/render_backend.cpp @@ -25,6 +25,10 @@ std::unique_ptr RenderBackend::create(const std::string& name) { return create_dx9_backend(/*ex=*/false); } + if (name == "gl" || name == "opengl") + { + return create_gl_backend(); + } return nullptr; } diff --git a/tools/mock_game/render_backend.hpp b/tools/mock_game/render_backend.hpp index 568fe9f..3fedff5 100644 --- a/tools/mock_game/render_backend.hpp +++ b/tools/mock_game/render_backend.hpp @@ -62,5 +62,6 @@ std::unique_ptr create_dx11_backend(); std::unique_ptr create_dx12_backend(); std::unique_ptr create_dx10_backend(); std::unique_ptr create_dx9_backend(bool ex); // ex: D3D9Ex vs plain D3D9 +std::unique_ptr create_gl_backend(); } // namespace coop::mock diff --git a/tools/mock_game/render_gl.cpp b/tools/mock_game/render_gl.cpp new file mode 100644 index 0000000..0c9c666 --- /dev/null +++ b/tools/mock_game/render_gl.cpp @@ -0,0 +1,125 @@ +// OpenGL backend for the mock game. Renders the animated pattern with scissored clears +// (glClear + glScissor -- all GL 1.1, exported straight from opengl32, so **no loader / +// submodule** is needed) and presents with SwapBuffers, so the OpenGL capture hook +// (SwapBuffers / wglSwapBuffers + glReadPixels) sees a genuine GL present. +// +// GL's framebuffer is bottom-left origin and the capture flips it vertically to top-down, so +// the frame-counter block is drawn at the GL *top* (y = h-block) to land at the captured +// image's top-left where the test samples it. +#include "render_backend.hpp" + +#include + +#include + +namespace coop::mock +{ +namespace +{ +class GlBackend : public RenderBackend +{ +public: + bool init(HWND hwnd, std::uint32_t width, std::uint32_t height) override + { + width_ = width; + height_ = height; + hwnd_ = hwnd; + hdc_ = GetDC(hwnd); + if (hdc_ == nullptr) + { + return false; + } + PIXELFORMATDESCRIPTOR pfd = {}; + pfd.nSize = sizeof(pfd); + pfd.nVersion = 1; + pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER; + pfd.iPixelType = PFD_TYPE_RGBA; + pfd.cColorBits = 32; + pfd.cAlphaBits = 8; + pfd.iLayerType = PFD_MAIN_PLANE; + const int pf = ChoosePixelFormat(hdc_, &pfd); + if (pf == 0 || !SetPixelFormat(hdc_, pf, &pfd)) + { + return false; + } + hglrc_ = wglCreateContext(hdc_); // legacy context is enough for GL 1.1 clears + if (hglrc_ == nullptr || !wglMakeCurrent(hdc_, hglrc_)) + { + return false; + } + // vsync if available (a game-like cadence; avoids spinning uncapped). Runtime extension + // lookup -- no loader/submodule needed. + using PFN_wglSwapIntervalEXT = BOOL(WINAPI*)(int); + if (auto swap_interval = reinterpret_cast(wglGetProcAddress("wglSwapIntervalEXT"))) + { + swap_interval(1); + } + return true; + } + + void render_and_present(std::uint32_t frame) override + { + const GLsizei w = static_cast(width_); + const GLsizei h = static_cast(height_); + glViewport(0, 0, w, h); + + // Animated background (whole framebuffer). + glDisable(GL_SCISSOR_TEST); + glClearColor(static_cast((frame * 2) % 256) / 255.0f, static_cast((frame * 3) % 256) / 255.0f, + static_cast((frame * 5) % 256) / 255.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + glEnable(GL_SCISSOR_TEST); + // Moving vertical bar (full height). + const std::uint32_t span = width_ > 24 ? width_ - 24 : 1; + const GLint bx = static_cast((frame * 4) % span); + glScissor(bx, 0, 24, h); + glClearColor(1.0f, 1.0f, 1.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + + // Frame-counter block at the GL top-left (y = h - block) so it lands top-left after the + // capture's vertical flip. + std::uint8_t r = 0, g = 0, b = 0; + frame_to_rgb(frame, r, g, b); + glScissor(0, h - static_cast(kFrameBlock), static_cast(kFrameBlock), + static_cast(kFrameBlock)); + glClearColor(r / 255.0f, g / 255.0f, b / 255.0f, 1.0f); + glClear(GL_COLOR_BUFFER_BIT); + glDisable(GL_SCISSOR_TEST); + + SwapBuffers(hdc_); // the capture hook intercepts this + } + + [[nodiscard]] const char* name() const override + { + return "gl"; + } + + ~GlBackend() override + { + wglMakeCurrent(nullptr, nullptr); + if (hglrc_ != nullptr) + { + wglDeleteContext(hglrc_); + } + if (hdc_ != nullptr && hwnd_ != nullptr) + { + ReleaseDC(hwnd_, hdc_); + } + } + +private: + std::uint32_t width_ = 0; + std::uint32_t height_ = 0; + HWND hwnd_ = nullptr; + HDC hdc_ = nullptr; + HGLRC hglrc_ = nullptr; +}; +} // namespace + +std::unique_ptr create_gl_backend() +{ + return std::make_unique(); +} + +} // namespace coop::mock