From 9977ad5d5afe77f41d218e58b0bb21079a74a57a Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sat, 20 Jun 2026 14:33:47 +0200 Subject: [PATCH] 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 --- README.md | 47 +++-- hook/CMakeLists.txt | 1 + hook/src/dllmain.cpp | 16 +- hook/src/opengl_hook.cpp | 348 +++++++++++++++++++++++++++++++++++++ hook/src/opengl_hook.hpp | 30 ++++ tests/CMakeLists.txt | 22 +++ tests/opengl_hook_test.cpp | 203 ++++++++++++++++++++++ 7 files changed, 649 insertions(+), 18 deletions(-) create mode 100644 hook/src/opengl_hook.cpp create mode 100644 hook/src/opengl_hook.hpp create mode 100644 tests/opengl_hook_test.cpp diff --git a/README.md b/README.md index 2417437..9ca5cf6 100644 --- a/README.md +++ b/README.md @@ -136,19 +136,30 @@ Done: Replaces tailing `%TEMP%\coop_hook.log` (which stays as an opt-in file mirror). `coop_audio_probe` drains and prints the same stream headless. -- **Present-hook video path. ✅** The injected DLL hooks `IDXGISwapChain::Present` - (and `Present1`) and copies the swapchain backbuffer into a shared keyed-mutex - texture (`coop_video_`); the host opens it by name and samples it — a - lower-latency, border-free alternative to WGC. It's an opt-in subsystem (the - **Video Present-hook** checkbox in the Injection panel, or just pick **Source: - Hooked (Present)** in the Video mirror panel, which installs it). Only DXGI - swapchains with an `ID3D11Texture2D` backbuffer are caught (the common D3D11 - case); D3D9 / pure-D3D12 games keep WGC. Validated by `present_hook_test` - (drives a real D3D11 swapchain end-to-end and reads the pixels back through the - shared texture). The host samples the shared copy as plain UNORM (see - `srgb_to_unorm`) so games with an `*_SRGB` backbuffer (e.g. Life is Strange: - Before the Storm, whose backbuffer is `R8G8B8A8_UNORM_SRGB`) mirror with correct - brightness instead of being darkened by an sRGB→linear decode. +- **Hooked video path (Present + OpenGL). ✅** The injected DLL captures the + game's frames into a shared keyed-mutex texture (`coop_video_`) that the + host opens by name and samples — a lower-latency, border-free alternative to WGC. + It's an opt-in subsystem (the **Video Present-hook** checkbox in the Injection + panel, or just pick **Source: Hooked (Present)** in the Video mirror panel, which + installs it). Two producers cover the common graphics APIs: + - **Direct3D (DXGI):** hooks `IDXGISwapChain::Present` / `Present1` and copies + the backbuffer. Catches D3D10/11/12 games whose backbuffer is an + `ID3D11Texture2D` (the common D3D11 case). Validated by `present_hook_test` + and against Slaps and Beans (32-bit D3D11) and Life is Strange: Before the + Storm. The host samples the copy as plain UNORM (see `srgb_to_unorm`) so games + with an `*_SRGB` backbuffer (Life is Strange is `R8G8B8A8_UNORM_SRGB`) mirror + 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 `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 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 @@ -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 primary stream was silenced, and exactly one render stream was counted. Skips 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: 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, diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt index a19eb7a..d8ef30e 100644 --- a/hook/CMakeLists.txt +++ b/hook/CMakeLists.txt @@ -4,6 +4,7 @@ add_library(coop_hook SHARED src/focus_spoof.cpp src/audio_hook.cpp src/present_hook.cpp + src/opengl_hook.cpp src/debug_log.cpp src/hook_registry.cpp) diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index 466c292..711eabd 100644 --- a/hook/src/dllmain.cpp +++ b/hook/src/dllmain.cpp @@ -20,6 +20,7 @@ #include "focus_spoof.hpp" #include "hook_registry.hpp" #include "ipc_client.hpp" +#include "opengl_hook.hpp" #include "present_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)"); } - // --- Video (Present-hook) --- + // --- Video (Present hook + OpenGL swap hook) --- // 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); 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) { - 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) { coop::hook::remove_present_hooks(); + coop::hook::remove_opengl_hooks(); 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) @@ -206,6 +213,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) coop::hook::remove_xinput_hooks(); coop::hook::remove_audio_hooks(); coop::hook::remove_present_hooks(); + coop::hook::remove_opengl_hooks(); coop::hook::hook_registry_reset(); } break; diff --git a/hook/src/opengl_hook.cpp b/hook/src/opengl_hook.cpp new file mode 100644 index 0000000..c167bc8 --- /dev/null +++ b/hook/src/opengl_hook.cpp @@ -0,0 +1,348 @@ +#include "opengl_hook.hpp" + +#include +#include + +#include + +#include +#include + +#include + +#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 / 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 g_swaps{0}; +std::atomic 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 g_read_buf; // glReadPixels target (bottom-up) +std::vector 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(GetProcAddress(gl, "glReadPixels")); + g_glPixelStorei = reinterpret_cast(GetProcAddress(gl, "glPixelStorei")); + g_wglGetCurrentContext = reinterpret_cast(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(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(&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(&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(rc.right - rc.left); + const UINT h = static_cast(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(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(w), static_cast(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(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(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(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(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(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(GetProcAddress(gdi, "SwapBuffers"))) + { + g_hk_swapbuffers = safetyhook::create_inline(fn, reinterpret_cast(&hk_SwapBuffers)); + } + } + // opengl32!wglSwapBuffers if OpenGL is already loaded. + if (HMODULE gl = GetModuleHandleW(L"opengl32.dll")) + { + if (void* fn = reinterpret_cast(GetProcAddress(gl, "wglSwapBuffers"))) + { + g_hk_wglswap = safetyhook::create_inline(fn, reinterpret_cast(&hk_wglSwapBuffers)); + } + } + + hook_set_installed(g_id_swapbuffers, static_cast(g_hk_swapbuffers)); + hook_set_installed(g_id_wglswap, static_cast(g_hk_wglswap)); + logf("install_opengl_hooks: SwapBuffers=%d wglSwapBuffers=%d", static_cast(g_hk_swapbuffers) ? 1 : 0, + static_cast(g_hk_wglswap) ? 1 : 0); + return static_cast(g_hk_swapbuffers) || static_cast(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 diff --git a/hook/src/opengl_hook.hpp b/hook/src/opengl_hook.hpp new file mode 100644 index 0000000..32dc2e2 --- /dev/null +++ b/hook/src/opengl_hook.hpp @@ -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_) 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 diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ce29167..f4c2c4f 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -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) diff --git a/tests/opengl_hook_test.cpp b/tests/opengl_hook_test.cpp new file mode 100644 index 0000000..318fa23 --- /dev/null +++ b/tests/opengl_hook_test.cpp @@ -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 +#include + +#include + +#include + +#include +#include + +#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 +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(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(); + 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(hook::opengl_swaps()), + static_cast(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(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; +}