M2(OpenGL): GL mock backend (no loader) + mock-backed capture coverage
Add render_gl.cpp (selectable as `gl`): renders the animated pattern with scissored clears (glClear + glScissor -- GL 1.1, exported straight from opengl32) and presents with SwapBuffers. No glad/submodule needed: a legacy wglCreateContext + <GL/gl.h> suffices, so the planned loader dependency was dropped. GL is bottom-left origin and the capture flips top-down, so the frame-counter block is drawn at the GL top to land top-left in the captured image. Window class gains CS_OWNDC for a stable GL DC; best-effort vsync via a runtime wglSwapIntervalEXT lookup. The GL SwapBuffers/wglSwapBuffers hook now bumps the shared present counter (it's the GL present), so present_calls works for GL games too. mock_game_test decodes GL frames through the existing glReadPixels capture path; 15/15 ctest. Roadmap: OpenGL milestone done and removed (Vulkan renumbered to M2); architecture/lessons/test docs updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
24
README.md
24
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
|
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
|
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;
|
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).
|
in particular).
|
||||||
|
|
||||||
- **M2 — OpenGL (mock → capture coverage).**
|
- **M2 — Vulkan (mock → capture).** The largest.
|
||||||
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.
|
|
||||||
1. **Mock backend** (`render_vk.cpp`). New submodules **Vulkan-Headers**
|
1. **Mock backend** (`render_vk.cpp`). New submodules **Vulkan-Headers**
|
||||||
(`KhronosGroup/Vulkan-Headers`, official) + **volk** (`zeux/volk`); raw
|
(`KhronosGroup/Vulkan-Headers`, official) + **volk** (`zeux/volk`); raw
|
||||||
`vkCreateWin32SurfaceKHR`, swap chain, per-frame acquire → clear → present. Background via
|
`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.
|
endpoint format). Skips cleanly if the machine has no audio endpoint.
|
||||||
- **`mock_game_test`** — comprehensive capture/audio/hook stress test against
|
- **`mock_game_test`** — comprehensive capture/audio/hook stress test against
|
||||||
**`coop_mock_game`** (an animated, frame-numbered A/V test game under
|
**`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
|
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**
|
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
|
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
|
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
|
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).
|
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` + `<GL/gl.h>` 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.**
|
- **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
|
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
|
`IAudioClient::Initialize`; the render-hook then assumes the device mix format for that
|
||||||
|
|||||||
@@ -240,6 +240,10 @@ BOOL WINAPI hk_SwapBuffers(HDC hdc)
|
|||||||
if (outer)
|
if (outer)
|
||||||
{
|
{
|
||||||
t_in_swap = true;
|
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);
|
capture_gl(hdc);
|
||||||
}
|
}
|
||||||
const BOOL r = g_hk_swapbuffers.stdcall<BOOL>(hdc); // __stdcall: call() is __cdecl on x86 -> crash
|
const BOOL r = g_hk_swapbuffers.stdcall<BOOL>(hdc); // __stdcall: call() is __cdecl on x86 -> crash
|
||||||
@@ -258,6 +262,10 @@ BOOL WINAPI hk_wglSwapBuffers(HDC hdc)
|
|||||||
if (outer)
|
if (outer)
|
||||||
{
|
{
|
||||||
t_in_swap = true;
|
t_in_swap = true;
|
||||||
|
if (g_ipc != nullptr)
|
||||||
|
{
|
||||||
|
g_ipc->note_present(); // wglSwapBuffers is the GL present
|
||||||
|
}
|
||||||
capture_gl(hdc);
|
capture_gl(hdc);
|
||||||
}
|
}
|
||||||
const BOOL r = g_hk_wglswap.stdcall<BOOL>(hdc); // __stdcall: call() is __cdecl on x86 -> crash
|
const BOOL r = g_hk_wglswap.stdcall<BOOL>(hdc); // __stdcall: call() is __cdecl on x86 -> crash
|
||||||
|
|||||||
@@ -522,6 +522,7 @@ int main()
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test_video_capture("gl", device);
|
||||||
test_video_capture("dx9ex", device);
|
test_video_capture("dx9ex", device);
|
||||||
test_video_capture("dx9", device);
|
test_video_capture("dx9", device);
|
||||||
test_video_capture("dx10", device);
|
test_video_capture("dx10", device);
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ add_executable(coop_mock_game
|
|||||||
render_dx11.cpp
|
render_dx11.cpp
|
||||||
render_dx12.cpp
|
render_dx12.cpp
|
||||||
render_dx10.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).
|
# 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_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")
|
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.
|
# Test fixture -> stage next to the tests (alongside coop_tone), not in the deployable root.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// CoopMockGame -- a tiny test "game" used to exercise the capture + audio + hook paths.
|
// 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
|
// 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
|
// 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);
|
const HINSTANCE inst = GetModuleHandleW(nullptr);
|
||||||
WNDCLASSEXW wc = {};
|
WNDCLASSEXW wc = {};
|
||||||
wc.cbSize = sizeof(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.lpfnWndProc = wnd_proc;
|
||||||
wc.hInstance = inst;
|
wc.hInstance = inst;
|
||||||
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ std::unique_ptr<RenderBackend> RenderBackend::create(const std::string& name)
|
|||||||
{
|
{
|
||||||
return create_dx9_backend(/*ex=*/false);
|
return create_dx9_backend(/*ex=*/false);
|
||||||
}
|
}
|
||||||
|
if (name == "gl" || name == "opengl")
|
||||||
|
{
|
||||||
|
return create_gl_backend();
|
||||||
|
}
|
||||||
return nullptr;
|
return nullptr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,5 +62,6 @@ std::unique_ptr<RenderBackend> create_dx11_backend();
|
|||||||
std::unique_ptr<RenderBackend> create_dx12_backend();
|
std::unique_ptr<RenderBackend> create_dx12_backend();
|
||||||
std::unique_ptr<RenderBackend> create_dx10_backend();
|
std::unique_ptr<RenderBackend> create_dx10_backend();
|
||||||
std::unique_ptr<RenderBackend> create_dx9_backend(bool ex); // ex: D3D9Ex vs plain D3D9
|
std::unique_ptr<RenderBackend> create_dx9_backend(bool ex); // ex: D3D9Ex vs plain D3D9
|
||||||
|
std::unique_ptr<RenderBackend> create_gl_backend();
|
||||||
|
|
||||||
} // namespace coop::mock
|
} // namespace coop::mock
|
||||||
|
|||||||
125
tools/mock_game/render_gl.cpp
Normal file
125
tools/mock_game/render_gl.cpp
Normal file
@@ -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 <windows.h>
|
||||||
|
|
||||||
|
#include <GL/gl.h>
|
||||||
|
|
||||||
|
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<PFN_wglSwapIntervalEXT>(wglGetProcAddress("wglSwapIntervalEXT")))
|
||||||
|
{
|
||||||
|
swap_interval(1);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void render_and_present(std::uint32_t frame) override
|
||||||
|
{
|
||||||
|
const GLsizei w = static_cast<GLsizei>(width_);
|
||||||
|
const GLsizei h = static_cast<GLsizei>(height_);
|
||||||
|
glViewport(0, 0, w, h);
|
||||||
|
|
||||||
|
// Animated background (whole framebuffer).
|
||||||
|
glDisable(GL_SCISSOR_TEST);
|
||||||
|
glClearColor(static_cast<float>((frame * 2) % 256) / 255.0f, static_cast<float>((frame * 3) % 256) / 255.0f,
|
||||||
|
static_cast<float>((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<GLint>((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<GLint>(kFrameBlock), static_cast<GLsizei>(kFrameBlock),
|
||||||
|
static_cast<GLsizei>(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<RenderBackend> create_gl_backend()
|
||||||
|
{
|
||||||
|
return std::make_unique<GlBackend>();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace coop::mock
|
||||||
Reference in New Issue
Block a user