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:
47
README.md
47
README.md
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
348
hook/src/opengl_hook.cpp
Normal 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
30
hook/src/opengl_hook.hpp
Normal 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
|
||||||
@@ -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
203
tests/opengl_hook_test.cpp
Normal 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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user