Add OpenGL capture path for the hooked video mirror

The Present hook never fired in Phantom Brave because it's an OpenGL game
(OPENGL32.dll loaded, IDXGISwapChain::Present calls=0), so the hooked video source
showed no image. Add an OpenGL producer under the video subsystem: inline-hook
gdi32!SwapBuffers + opengl32!wglSwapBuffers (with a re-entrancy guard, since
SwapBuffers calls wglSwapBuffers), glReadPixels the backbuffer, flip it, and upload
it into the same shared keyed-mutex texture the host already samples -- so the host
is unchanged. DXGI games still hit the Present hook; both producers are installed
and whichever the game uses fills the texture.

Validated by opengl_hook_test (real GL context, clears to a known color, reads the
exact pixels back through the shared texture) and against Phantom Brave (SwapBuffers
~75/s, present=0, shared texture 1920x1080, generation advancing). Vulkan
(vkQueuePresentKHR) still needs WGC -- documented. All 7 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 14:33:47 +02:00
parent 914dba4d25
commit 9977ad5d5a
7 changed files with 649 additions and 18 deletions

View File

@@ -85,3 +85,25 @@ target_link_libraries(present_hook_test PRIVATE
dxgi)
add_test(NAME present_hook_test COMMAND present_hook_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
# the shared texture (opened by name + read back). Skips without GL / D3D11.
add_executable(opengl_hook_test
opengl_hook_test.cpp
${CMAKE_SOURCE_DIR}/hook/src/opengl_hook.cpp
${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp
${CMAKE_SOURCE_DIR}/hook/src/hook_registry.cpp)
target_include_directories(opengl_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src)
target_link_libraries(opengl_hook_test PRIVATE
coop_common
safetyhook::safetyhook
d3d11
dxgi
opengl32
gdi32)
add_test(NAME opengl_hook_test COMMAND opengl_hook_test)

203
tests/opengl_hook_test.cpp Normal file
View File

@@ -0,0 +1,203 @@
// In-process self-test for the OpenGL capture path (hook/src/opengl_hook.cpp).
// Plays both "game" and "host": installs the swap hooks, creates a real OpenGL
// context, clears the backbuffer to a known color, and calls SwapBuffers. With
// the hook live that must (1) fire the SwapBuffers detour, (2) glReadPixels the
// backbuffer and upload it into the shared keyed-mutex texture, and (3) publish
// the descriptor over IPC. A second D3D device then opens the shared texture by
// name and reads it back, proving the OpenGL->shared-texture path end to end --
// no game, no Steam.
//
// Needs an OpenGL context + a D3D11 device; reports SKIP and exits 0 if either
// is unavailable (headless CI), mirroring present_hook_test.
#include <cstdint>
#include <cstdio>
#include <windows.h>
#include <gl/GL.h>
#include <d3d11_1.h>
#include <dxgi1_2.h>
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
#include "ipc_client.hpp"
#include "opengl_hook.hpp"
using namespace coop;
namespace
{
int g_failures = 0;
void check(bool ok, const char* what)
{
if (!ok)
{
std::printf(" FAIL: %s\n", what);
++g_failures;
}
}
template <typename T>
void release(T*& p)
{
if (p)
{
p->Release();
p = nullptr;
}
}
constexpr int kW = 64;
constexpr int kH = 64;
bool near_byte(std::uint8_t got, int expected)
{
return std::abs(static_cast<int>(got) - expected) <= 3;
}
} // namespace
int main()
{
// --- Host side: SharedBlock so the hook's IpcClient connects. ---
SharedMemory shm;
if (!shm.create(shared_memory_name(GetCurrentProcessId()), sizeof(SharedBlock)))
{
std::printf("FAIL: create shared memory\n");
return 1;
}
auto* block = shm.as<SharedBlock>();
block->version = kProtocolVersion;
block->sequence.store(0, std::memory_order_relaxed);
block->magic = kProtocolMagic;
hook::IpcClient ipc;
check(ipc.connect(10, 5), "IPC client connect");
if (!hook::install_opengl_hooks(ipc))
{
std::printf("SKIP: could not install the OpenGL swap hooks\n");
return 0;
}
// --- Game side: a real OpenGL context on a hidden window. ---
WNDCLASSEXW wc{};
wc.cbSize = sizeof(wc);
wc.lpfnWndProc = DefWindowProcW;
wc.hInstance = GetModuleHandleW(nullptr);
wc.lpszClassName = L"coop_gl_test";
RegisterClassExW(&wc);
// WS_POPUP so the client area is exactly kW x kH (an overlapped window can't
// shrink below its minimum caption size, which would skew the captured dims).
HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"", WS_POPUP, 0, 0, kW, kH, nullptr, nullptr,
wc.hInstance, nullptr);
ShowWindow(hwnd, SW_SHOWNOACTIVATE); // a mapped window makes the backbuffer reliable
HDC hdc = GetDC(hwnd);
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;
const int pf = ChoosePixelFormat(hdc, &pfd);
HGLRC glrc = nullptr;
if (pf == 0 || !SetPixelFormat(hdc, pf, &pfd) || (glrc = wglCreateContext(hdc)) == nullptr ||
!wglMakeCurrent(hdc, glrc))
{
std::printf("SKIP: could not create an OpenGL context on this machine\n");
hook::remove_opengl_hooks();
return 0;
}
// Clear the backbuffer to a known color, then SwapBuffers (fires the detour).
for (int frame = 0; frame < 3; ++frame)
{
glViewport(0, 0, kW, kH);
glClearColor(0.20f, 0.40f, 0.60f, 1.0f); // -> ~{51,102,153,255}
glClear(GL_COLOR_BUFFER_BIT);
glFinish();
SwapBuffers(hdc);
}
std::printf("swaps=%llu frames_shared=%llu video{gen=%u %ux%u fmt=%u}\n",
static_cast<unsigned long long>(hook::opengl_swaps()),
static_cast<unsigned long long>(hook::opengl_frames_shared()), block->video.generation.load(),
block->video.width, block->video.height, block->video.format);
check(hook::opengl_swaps() >= 3, "SwapBuffers detour fired");
check(hook::opengl_frames_shared() > 0, "backbuffer uploaded into the shared texture");
check(block->video.generation.load() > 0, "video generation published to IPC");
check(block->video.width == kW && block->video.height == kH, "shared dimensions published");
// --- Consumer: open the shared texture by name, copy to staging, verify color.
if (hook::opengl_frames_shared() > 0)
{
ID3D11Device* devB = nullptr;
ID3D11DeviceContext* ctxB = nullptr;
if (SUCCEEDED(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0,
D3D11_SDK_VERSION, &devB, nullptr, &ctxB)))
{
ID3D11Device1* dev1 = nullptr;
devB->QueryInterface(IID_PPV_ARGS(&dev1));
const std::wstring name = video_share_name(GetCurrentProcessId());
ID3D11Texture2D* sharedB = nullptr;
IDXGIKeyedMutex* km = nullptr;
if (dev1 != nullptr &&
SUCCEEDED(dev1->OpenSharedResourceByName(name.c_str(),
DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE,
IID_PPV_ARGS(&sharedB))))
{
sharedB->QueryInterface(IID_PPV_ARGS(&km));
D3D11_TEXTURE2D_DESC sd{};
sharedB->GetDesc(&sd);
sd.Usage = D3D11_USAGE_STAGING;
sd.BindFlags = 0;
sd.CPUAccessFlags = D3D11_CPU_ACCESS_READ;
sd.MiscFlags = 0;
ID3D11Texture2D* staging = nullptr;
devB->CreateTexture2D(&sd, nullptr, &staging);
if (km != nullptr && staging != nullptr && km->AcquireSync(kVideoMutexKey, 1000) == S_OK)
{
ctxB->CopyResource(staging, sharedB);
km->ReleaseSync(kVideoMutexKey);
D3D11_MAPPED_SUBRESOURCE m{};
if (SUCCEEDED(ctxB->Map(staging, 0, D3D11_MAP_READ, 0, &m)))
{
const auto* px = static_cast<const std::uint8_t*>(m.pData);
std::printf("readback pixel0 = {%u,%u,%u,%u}\n", px[0], px[1], px[2], px[3]);
check(near_byte(px[0], 51) && near_byte(px[1], 102) && near_byte(px[2], 153),
"shared texture carries the rendered color");
ctxB->Unmap(staging, 0);
}
else
{
check(false, "map staging texture");
}
}
else
{
check(false, "acquire keyed mutex + copy shared texture");
}
release(staging);
}
else
{
check(false, "open shared texture by name");
}
release(km);
release(sharedB);
release(dev1);
}
release(ctxB);
release(devB);
}
wglMakeCurrent(nullptr, nullptr);
wglDeleteContext(glrc);
ReleaseDC(hwnd, hdc);
DestroyWindow(hwnd);
UnregisterClassW(wc.lpszClassName, wc.hInstance);
hook::remove_opengl_hooks();
std::printf(g_failures == 0 ? "OPENGL HOOK TEST PASS\n" : "OPENGL HOOK TEST FAILED (%d)\n", g_failures);
return g_failures == 0 ? 0 : 1;
}