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

@@ -136,19 +136,30 @@ Done:
Replaces tailing `%TEMP%\coop_hook.log` (which stays as an opt-in file mirror). Replaces tailing `%TEMP%\coop_hook.log` (which stays as an opt-in file mirror).
`coop_audio_probe` drains and prints the same stream headless. `coop_audio_probe` drains and prints the same stream headless.
- **Present-hook video path. ✅** The injected DLL hooks `IDXGISwapChain::Present` - **Hooked video path (Present + OpenGL). ✅** The injected DLL captures the
(and `Present1`) and copies the swapchain backbuffer into a shared keyed-mutex game's frames into a shared keyed-mutex texture (`coop_video_<pid>`) that the
texture (`coop_video_<pid>`); the host opens it by name and samples it — a host opens by name and samples — a lower-latency, border-free alternative to WGC.
lower-latency, border-free alternative to WGC. It's an opt-in subsystem (the It's an opt-in subsystem (the **Video Present-hook** checkbox in the Injection
**Video Present-hook** checkbox in the Injection panel, or just pick **Source: panel, or just pick **Source: Hooked (Present)** in the Video mirror panel, which
Hooked (Present)** in the Video mirror panel, which installs it). Only DXGI installs it). Two producers cover the common graphics APIs:
swapchains with an `ID3D11Texture2D` backbuffer are caught (the common D3D11 - **Direct3D (DXGI):** hooks `IDXGISwapChain::Present` / `Present1` and copies
case); D3D9 / pure-D3D12 games keep WGC. Validated by `present_hook_test` the backbuffer. Catches D3D10/11/12 games whose backbuffer is an
(drives a real D3D11 swapchain end-to-end and reads the pixels back through the `ID3D11Texture2D` (the common D3D11 case). Validated by `present_hook_test`
shared texture). The host samples the shared copy as plain UNORM (see and against Slaps and Beans (32-bit D3D11) and Life is Strange: Before the
`srgb_to_unorm`) so games with an `*_SRGB` backbuffer (e.g. Life is Strange: Storm. The host samples the copy as plain UNORM (see `srgb_to_unorm`) so games
Before the Storm, whose backbuffer is `R8G8B8A8_UNORM_SRGB`) mirror with correct with an `*_SRGB` backbuffer (Life is Strange is `R8G8B8A8_UNORM_SRGB`) mirror
brightness instead of being darkened by an sRGB→linear decode. with correct brightness instead of being darkened by an sRGB→linear decode.
- **OpenGL:** hooks `SwapBuffers` / `wglSwapBuffers`, reads the backbuffer with
`glReadPixels`, and uploads it into the shared texture. Catches OpenGL games
that never touch DXGI (e.g. **Phantom Brave**, which is OpenGL — that's why
the Present hook alone showed no image). Validated by `opengl_hook_test` and
against Phantom Brave. `glReadPixels` forces a per-frame GPU→CPU readback, so
it's heavier than the D3D copy, but fine for the typically-2D OpenGL titles.
**Vulkan** games (present via `vkQueuePresentKHR`) aren't hooked yet — that needs
a Vulkan layer / device-dispatch hook plus a `vkCmdCopyImage` to a readable
image, a larger effort. **Use WGC** (the default Video source) for Vulkan, D3D9,
or anything the hooked path doesn't capture — WGC works for any window.
- **x86 (32-bit) game support. ✅** A nested Win32 sub-build (CMake - **x86 (32-bit) game support. ✅** A nested Win32 sub-build (CMake
`ExternalProject`, driven from the normal x64 build) produces `coop_hook_x86.dll` `ExternalProject`, driven from the normal x64 build) produces `coop_hook_x86.dll`
@@ -174,7 +185,8 @@ Done:
All planned phases are now implemented. Possible later work: per-stream audio All planned phases are now implemented. Possible later work: per-stream audio
mixing for multi-stream games, per-stream format detection for the audio hook, and mixing for multi-stream games, per-stream format detection for the audio hook, and
hooking D3D9 / pure-D3D12 present paths for the video hook. hooking the remaining present paths for the video hook (Vulkan `vkQueuePresentKHR`,
D3D9, pure-D3D12) — WGC already covers those today.
## Building ## Building
@@ -229,6 +241,13 @@ ctest --test-dir build -C Debug --output-on-failure
COM vtables were discovered, the frames reached the ring (non-silent), the COM vtables were discovered, the frames reached the ring (non-silent), the
primary stream was silenced, and exactly one render stream was counted. Skips primary stream was silenced, and exactly one render stream was counted. Skips
cleanly if the machine has no audio endpoint. cleanly if the machine has no audio endpoint.
- **`srgb_format_test`** — unit test of the `srgb_to_unorm` mapping the hooked
video path uses so `*_SRGB`-backbuffer games aren't darkened. No device.
- **`opengl_hook_test`** — in-process self-test of the OpenGL capture path:
installs the swap hooks, drives a real OpenGL context (clears the backbuffer to a
known color, calls `SwapBuffers`), and asserts the detour fired, the frame was
`glReadPixels`'d into the shared texture, and a second device reads the exact
pixels back by name. Skips cleanly without an OpenGL / D3D11 device.
- **`present_hook_test`** — in-process self-test of the Present-hook video path: - **`present_hook_test`** — in-process self-test of the Present-hook video path:
installs the hook, drives a real D3D11 swapchain in the same process (clears the installs the hook, drives a real D3D11 swapchain in the same process (clears the
backbuffer to a known color and calls `Present`), and asserts the detour fired, backbuffer to a known color and calls `Present`), and asserts the detour fired,

View File

@@ -4,6 +4,7 @@ add_library(coop_hook SHARED
src/focus_spoof.cpp src/focus_spoof.cpp
src/audio_hook.cpp src/audio_hook.cpp
src/present_hook.cpp src/present_hook.cpp
src/opengl_hook.cpp
src/debug_log.cpp src/debug_log.cpp
src/hook_registry.cpp) src/hook_registry.cpp)

View File

@@ -20,6 +20,7 @@
#include "focus_spoof.hpp" #include "focus_spoof.hpp"
#include "hook_registry.hpp" #include "hook_registry.hpp"
#include "ipc_client.hpp" #include "ipc_client.hpp"
#include "opengl_hook.hpp"
#include "present_hook.hpp" #include "present_hook.hpp"
#include "xinput_hook.hpp" #include "xinput_hook.hpp"
@@ -122,22 +123,28 @@ DWORD WINAPI worker_thread(LPVOID)
coop::hook::logf("worker_thread: audio hooks removed (host request)"); coop::hook::logf("worker_thread: audio hooks removed (host request)");
} }
// --- Video (Present-hook) --- // --- Video (Present hook + OpenGL swap hook) ---
// Opt-in alternative to the host's WGC path; the host enables it on request. // Opt-in alternative to the host's WGC path; the host enables it on request.
// Install both producers: DXGI games hit the Present hook, OpenGL games hit
// the SwapBuffers hook, whichever the game uses fills the shared texture.
const bool want_video = g_ipc.subsystem_install_requested(coop::HookSubsys_Video); const bool want_video = g_ipc.subsystem_install_requested(coop::HookSubsys_Video);
if (want_video && !video_installed) if (want_video && !video_installed)
{ {
video_installed = coop::hook::install_present_hooks(g_ipc); const bool present_ok = coop::hook::install_present_hooks(g_ipc);
const bool gl_ok = coop::hook::install_opengl_hooks(g_ipc);
video_installed = present_ok || gl_ok;
if (video_installed) if (video_installed)
{ {
coop::hook::logf("worker_thread: present hook installed"); coop::hook::logf("worker_thread: video hooks installed (present=%d opengl=%d)",
present_ok ? 1 : 0, gl_ok ? 1 : 0);
} }
} }
else if (!want_video && video_installed) else if (!want_video && video_installed)
{ {
coop::hook::remove_present_hooks(); coop::hook::remove_present_hooks();
coop::hook::remove_opengl_hooks();
video_installed = false; video_installed = false;
coop::hook::logf("worker_thread: present hook removed (host request)"); coop::hook::logf("worker_thread: video hooks removed (host request)");
} }
if (audio_installed && !audio_ring_open) if (audio_installed && !audio_ring_open)
@@ -206,6 +213,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved)
coop::hook::remove_xinput_hooks(); coop::hook::remove_xinput_hooks();
coop::hook::remove_audio_hooks(); coop::hook::remove_audio_hooks();
coop::hook::remove_present_hooks(); coop::hook::remove_present_hooks();
coop::hook::remove_opengl_hooks();
coop::hook::hook_registry_reset(); coop::hook::hook_registry_reset();
} }
break; break;

348
hook/src/opengl_hook.cpp Normal file
View File

@@ -0,0 +1,348 @@
#include "opengl_hook.hpp"
#include <atomic>
#include <vector>
#include <windows.h>
#include <d3d11.h>
#include <dxgi1_2.h>
#include <safetyhook.hpp>
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
#include "debug_log.hpp"
#include "hook_registry.hpp"
namespace coop::hook
{
namespace
{
// OpenGL enums (avoid pulling in <GL/gl.h> / linking opengl32 at load time).
constexpr unsigned GL_RGBA = 0x1908;
constexpr unsigned GL_UNSIGNED_BYTE = 0x1401;
constexpr unsigned GL_PACK_ALIGNMENT = 0x0D05;
using PFN_glReadPixels = void(WINAPI*)(int, int, int, int, unsigned, unsigned, void*);
using PFN_glPixelStorei = void(WINAPI*)(unsigned, int);
using PFN_wglGetCurrentContext = HGLRC(WINAPI*)();
IpcClient* g_ipc = nullptr;
unsigned long g_pid = 0;
safetyhook::InlineHook g_hk_swapbuffers; // gdi32!SwapBuffers
safetyhook::InlineHook g_hk_wglswap; // opengl32!wglSwapBuffers
int g_id_swapbuffers = -1;
int g_id_wglswap = -1;
std::atomic<std::uint64_t> g_swaps{0};
std::atomic<std::uint64_t> g_frames_shared{0};
PFN_glReadPixels g_glReadPixels = nullptr;
PFN_glPixelStorei g_glPixelStorei = nullptr;
PFN_wglGetCurrentContext g_wglGetCurrentContext = nullptr;
bool g_gl_resolved = false;
bool g_unsupported_logged = false;
// Our own D3D11 device hosting the shared texture (the GL path has no D3D device).
ID3D11Device* g_device = nullptr;
ID3D11DeviceContext* g_ctx = nullptr;
ID3D11Texture2D* g_shared_tex = nullptr;
IDXGIKeyedMutex* g_shared_mutex = nullptr;
HANDLE g_shared_handle = nullptr;
UINT g_share_w = 0;
UINT g_share_h = 0;
std::vector<unsigned char> g_read_buf; // glReadPixels target (bottom-up)
std::vector<unsigned char> g_flip_buf; // vertically flipped, uploaded to D3D
// GetBuffer/ReleaseBuffer-style re-entrancy guard: wglSwapBuffers may call
// gdi32!SwapBuffers (or vice versa); capture only on the outermost call.
thread_local bool t_in_swap = false;
void resolve_gl()
{
if (g_gl_resolved)
{
return;
}
HMODULE gl = GetModuleHandleW(L"opengl32.dll");
if (gl == nullptr)
{
return; // not an OpenGL process (yet)
}
g_glReadPixels = reinterpret_cast<PFN_glReadPixels>(GetProcAddress(gl, "glReadPixels"));
g_glPixelStorei = reinterpret_cast<PFN_glPixelStorei>(GetProcAddress(gl, "glPixelStorei"));
g_wglGetCurrentContext = reinterpret_cast<PFN_wglGetCurrentContext>(GetProcAddress(gl, "wglGetCurrentContext"));
g_gl_resolved = (g_glReadPixels != nullptr && g_wglGetCurrentContext != nullptr);
}
bool ensure_device()
{
if (g_device != nullptr)
{
return true;
}
const HRESULT hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0,
D3D11_SDK_VERSION, &g_device, nullptr, &g_ctx);
if (FAILED(hr) || g_device == nullptr)
{
logf("opengl: D3D11CreateDevice failed hr=0x%08lX", static_cast<unsigned long>(hr));
return false;
}
return true;
}
void release_shared()
{
if (g_shared_mutex != nullptr)
{
g_shared_mutex->Release();
g_shared_mutex = nullptr;
}
if (g_shared_tex != nullptr)
{
g_shared_tex->Release();
g_shared_tex = nullptr;
}
if (g_shared_handle != nullptr)
{
CloseHandle(g_shared_handle);
g_shared_handle = nullptr;
}
g_share_w = g_share_h = 0;
}
bool ensure_shared_texture(UINT w, UINT h)
{
if (g_shared_tex != nullptr && g_share_w == w && g_share_h == h)
{
return true;
}
release_shared();
D3D11_TEXTURE2D_DESC desc{};
desc.Width = w;
desc.Height = h;
desc.MipLevels = 1;
desc.ArraySize = 1;
desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; // glReadPixels(GL_RGBA) byte order
desc.SampleDesc.Count = 1;
desc.Usage = D3D11_USAGE_DEFAULT;
desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
desc.MiscFlags = D3D11_RESOURCE_MISC_SHARED_NTHANDLE | D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX;
if (FAILED(g_device->CreateTexture2D(&desc, nullptr, &g_shared_tex)) || g_shared_tex == nullptr)
{
return false;
}
IDXGIResource1* res = nullptr;
if (FAILED(g_shared_tex->QueryInterface(__uuidof(IDXGIResource1), reinterpret_cast<void**>(&res))) ||
res == nullptr)
{
release_shared();
return false;
}
const std::wstring name = video_share_name(g_pid);
const HRESULT hr = res->CreateSharedHandle(
nullptr, DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE, name.c_str(), &g_shared_handle);
res->Release();
if (FAILED(hr) || g_shared_handle == nullptr)
{
release_shared();
return false;
}
if (FAILED(g_shared_tex->QueryInterface(__uuidof(IDXGIKeyedMutex), reinterpret_cast<void**>(&g_shared_mutex))))
{
release_shared();
return false;
}
g_share_w = w;
g_share_h = h;
logf("opengl: shared texture ready %ux%u name=%ls", w, h, name.c_str());
return true;
}
// Read the current GL backbuffer for the window behind `hdc` and upload it.
void capture_gl(HDC hdc)
{
resolve_gl();
if (!g_gl_resolved || g_wglGetCurrentContext() == nullptr)
{
if (!g_unsupported_logged)
{
logf("opengl: no current GL context / glReadPixels; capture idle");
g_unsupported_logged = true;
}
return;
}
HWND hwnd = WindowFromDC(hdc);
RECT rc{};
if (hwnd == nullptr || !GetClientRect(hwnd, &rc))
{
return;
}
const UINT w = static_cast<UINT>(rc.right - rc.left);
const UINT h = static_cast<UINT>(rc.bottom - rc.top);
if (w == 0 || h == 0)
{
return;
}
if (!ensure_device() || !ensure_shared_texture(w, h))
{
return;
}
const size_t bytes = static_cast<size_t>(w) * h * 4;
if (g_read_buf.size() != bytes)
{
g_read_buf.resize(bytes);
g_flip_buf.resize(bytes);
}
if (g_glPixelStorei != nullptr)
{
g_glPixelStorei(GL_PACK_ALIGNMENT, 1);
}
// Reads the back buffer of the current context (bottom-up, origin lower-left).
g_glReadPixels(0, 0, static_cast<int>(w), static_cast<int>(h), GL_RGBA, GL_UNSIGNED_BYTE, g_read_buf.data());
// Flip vertically so the image is top-down like a D3D backbuffer.
const size_t row = static_cast<size_t>(w) * 4;
for (UINT y = 0; y < h; ++y)
{
memcpy(g_flip_buf.data() + y * row, g_read_buf.data() + (h - 1 - y) * row, row);
}
if (g_shared_mutex->AcquireSync(kVideoMutexKey, 8) == S_OK)
{
g_ctx->UpdateSubresource(g_shared_tex, 0, nullptr, g_flip_buf.data(), static_cast<UINT>(row), 0);
g_ctx->Flush();
g_shared_mutex->ReleaseSync(kVideoMutexKey);
g_frames_shared.fetch_add(1, std::memory_order_relaxed);
if (g_ipc != nullptr)
{
g_ipc->publish_video_frame(w, h, static_cast<std::uint32_t>(DXGI_FORMAT_R8G8B8A8_UNORM));
}
}
}
BOOL WINAPI hk_SwapBuffers(HDC hdc)
{
hook_note_call(g_id_swapbuffers);
g_swaps.fetch_add(1, std::memory_order_relaxed);
const bool outer = !t_in_swap;
if (outer)
{
t_in_swap = true;
capture_gl(hdc);
}
const BOOL r = g_hk_swapbuffers.call<BOOL>(hdc);
if (outer)
{
t_in_swap = false;
}
return r;
}
BOOL WINAPI hk_wglSwapBuffers(HDC hdc)
{
hook_note_call(g_id_wglswap);
g_swaps.fetch_add(1, std::memory_order_relaxed);
const bool outer = !t_in_swap;
if (outer)
{
t_in_swap = true;
capture_gl(hdc);
}
const BOOL r = g_hk_wglswap.call<BOOL>(hdc);
if (outer)
{
t_in_swap = false;
}
return r;
}
} // namespace
bool install_opengl_hooks(IpcClient& ipc)
{
g_ipc = &ipc;
g_pid = GetCurrentProcessId();
if (g_hk_swapbuffers || g_hk_wglswap)
{
return true; // already installed
}
g_id_swapbuffers = hook_register("SwapBuffers", HookSubsys_Video);
g_id_wglswap = hook_register("wglSwapBuffers", HookSubsys_Video);
g_unsupported_logged = false;
// gdi32!SwapBuffers is always available (the common GL present call).
if (HMODULE gdi = GetModuleHandleW(L"gdi32.dll"))
{
if (void* fn = reinterpret_cast<void*>(GetProcAddress(gdi, "SwapBuffers")))
{
g_hk_swapbuffers = safetyhook::create_inline(fn, reinterpret_cast<void*>(&hk_SwapBuffers));
}
}
// opengl32!wglSwapBuffers if OpenGL is already loaded.
if (HMODULE gl = GetModuleHandleW(L"opengl32.dll"))
{
if (void* fn = reinterpret_cast<void*>(GetProcAddress(gl, "wglSwapBuffers")))
{
g_hk_wglswap = safetyhook::create_inline(fn, reinterpret_cast<void*>(&hk_wglSwapBuffers));
}
}
hook_set_installed(g_id_swapbuffers, static_cast<bool>(g_hk_swapbuffers));
hook_set_installed(g_id_wglswap, static_cast<bool>(g_hk_wglswap));
logf("install_opengl_hooks: SwapBuffers=%d wglSwapBuffers=%d", static_cast<bool>(g_hk_swapbuffers) ? 1 : 0,
static_cast<bool>(g_hk_wglswap) ? 1 : 0);
return static_cast<bool>(g_hk_swapbuffers) || static_cast<bool>(g_hk_wglswap);
}
void remove_opengl_hooks()
{
g_hk_swapbuffers = {};
g_hk_wglswap = {};
hook_set_installed(g_id_swapbuffers, false);
hook_set_installed(g_id_wglswap, false);
release_shared();
if (g_ctx != nullptr)
{
g_ctx->Release();
g_ctx = nullptr;
}
if (g_device != nullptr)
{
g_device->Release();
g_device = nullptr;
}
g_read_buf.clear();
g_flip_buf.clear();
g_swaps.store(0, std::memory_order_relaxed);
g_frames_shared.store(0, std::memory_order_relaxed);
g_gl_resolved = false;
g_glReadPixels = nullptr;
g_glPixelStorei = nullptr;
g_wglGetCurrentContext = nullptr;
g_ipc = nullptr;
}
std::uint64_t opengl_swaps()
{
return g_swaps.load(std::memory_order_relaxed);
}
std::uint64_t opengl_frames_shared()
{
return g_frames_shared.load(std::memory_order_relaxed);
}
} // namespace coop::hook

30
hook/src/opengl_hook.hpp Normal file
View File

@@ -0,0 +1,30 @@
// Injected OpenGL capture path: hooks SwapBuffers / wglSwapBuffers in OpenGL games
// (which never call IDXGISwapChain::Present, so the DXGI present hook can't see
// them -- e.g. Phantom Brave), reads the GL backbuffer with glReadPixels, and
// uploads it into the same shared keyed-mutex texture (coop_video_<pid>) the host
// samples. Part of the video subsystem, alongside present_hook.
//
// glReadPixels forces a GPU->CPU readback each frame, so this is heavier than the
// D3D shared-texture copy; fine for the 2D / lower-framerate games that tend to be
// OpenGL. Vulkan games present via vkQueuePresentKHR and aren't covered here (see
// the README) -- use WGC for those.
#pragma once
#include "ipc_client.hpp"
namespace coop::hook
{
// Installs the OpenGL swap hooks. `ipc` must outlive the hooks. Returns true if at
// least SwapBuffers was hooked. Safe to call repeatedly.
bool install_opengl_hooks(IpcClient& ipc);
// Removes the OpenGL swap hooks and releases the shared texture (best effort).
void remove_opengl_hooks();
// --- Diagnostics -----------------------------------------------------------
std::uint64_t opengl_swaps(); // cumulative SwapBuffers/wglSwapBuffers detours
std::uint64_t opengl_frames_shared(); // frames uploaded into the shared texture
} // namespace coop::hook

View File

@@ -85,3 +85,25 @@ target_link_libraries(present_hook_test PRIVATE
dxgi) dxgi)
add_test(NAME present_hook_test COMMAND present_hook_test) 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;
}