From 36b861d1677d5d70df81d25f2277c53a7aa5ca16 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sat, 20 Jun 2026 11:34:12 +0200 Subject: [PATCH] Phase 2: Present-hook video path (shared-texture mirror) Add an injected IDXGISwapChain::Present / Present1 hook as a lower-latency, border-free alternative to WGC. The hook copies the swapchain backbuffer into a shared keyed-mutex texture (coop_video_); the host opens it by name and samples it. New opt-in HookSubsys_Video (protocol v6 -> v7); the Video mirror panel gains a WGC vs Hooked source toggle that installs/removes the subsystem. Verified by present_hook_test (drives a real D3D11 swapchain end-to-end and reads the rendered pixels back through the shared texture) and against Phantom Brave (D3D9: hook installs cleanly and stays idle, WGC fallback). All 5 tests pass. Co-Authored-By: Claude Opus 4.8 --- README.md | 27 +- common/include/coop/protocol.hpp | 32 ++- common/include/coop/shared_memory.hpp | 7 + hook/CMakeLists.txt | 5 +- hook/src/dllmain.cpp | 21 ++ hook/src/ipc_client.hpp | 25 ++ hook/src/present_hook.cpp | 384 ++++++++++++++++++++++++++ hook/src/present_hook.hpp | 35 +++ host/CMakeLists.txt | 1 + host/src/capture/shared_texture.cpp | 128 +++++++++ host/src/capture/shared_texture.hpp | 69 +++++ host/src/capture_panel.cpp | 113 ++++++-- host/src/capture_panel.hpp | 27 +- host/src/injection_panel.cpp | 6 +- host/src/injection_panel.hpp | 29 +- host/src/ipc/ipc_server.cpp | 16 ++ host/src/ipc/ipc_server.hpp | 13 + host/src/main.cpp | 1 + tests/CMakeLists.txt | 21 ++ tests/present_hook_test.cpp | 227 +++++++++++++++ tools/audio_probe/main.cpp | 9 +- 21 files changed, 1160 insertions(+), 36 deletions(-) create mode 100644 hook/src/present_hook.cpp create mode 100644 hook/src/present_hook.hpp create mode 100644 host/src/capture/shared_texture.cpp create mode 100644 host/src/capture/shared_texture.hpp create mode 100644 tests/present_hook_test.cpp diff --git a/README.md b/README.md index 3fe0b3e..b77d148 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ XInput game becomes Remote-Play-Together-able. | Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` | done | | Keep game running unfocused | Hook spoofs focus so the game polls while the host holds OS focus | `coop_hook.dll` | done | | Mirror video | Windows Graphics Capture of the game window, letterboxed into the host window | `coop_host.exe` | done | +| Mirror video (alt) | Injected Present-hook copies the DXGI backbuffer into a shared keyed-mutex texture the host samples (lower latency, no capture border) | `coop_hook.dll` + `coop_host.exe` | done | | Mirror audio | Injected render-hook copies the game's WASAPI frames into a shared ring and silences the game locally (no echo); WASAPI process loopback is the automatic fallback | `coop_hook.dll` + `coop_host.exe` | done | | Host ↔ hook IPC | Named shared memory (seqlock for input, status back-channel) | `common/` | done | @@ -131,11 +132,19 @@ 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). + Future work, roughly in priority order: -- **Present-hook video path:** capture the game's frames by hooking - `IDXGISwapChain::Present` in the injected DLL and sharing the backbuffer via a - shared D3D11 texture, as a lower-latency / more stable alternative to WGC. - **Steam Input:** consume guest input through the Steam Input API directly rather than XInput. - **x86 support:** add an x86 build of `coop_hook.dll` plus an x86 injector @@ -185,6 +194,12 @@ 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. +- **`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, + the backbuffer reached the shared keyed-mutex texture, and a second device can + open it by name and read the exact pixels back. Skips cleanly if the machine has + no D3D11 device. - **`audio_loopback_test`** — spawns `coop_tone.exe` (a standalone WASAPI sine-wave source under [`tools/audio_tone`](tools/audio_tone)) and verifies the shipping process-loopback capture (the fallback path) receives its audio by @@ -223,7 +238,11 @@ person/account to receive the stream. **Attached**, a non-zero **XInput polled: N/s**, and **Focus spoof: active**. 3. **Mirror video:** in the **Video mirror** panel, tick **Mirror game window** — - the host window now shows a live, letterboxed copy of the game. + the host window now shows a live, letterboxed copy of the game. **Source** + picks how the frames are grabbed: **WGC** (default, Windows Graphics Capture — + works for any window) or **Hooked (Present)** (the injected Present-hook's + shared texture — lower latency and no capture border, but only for DXGI / + D3D11 games; selecting it installs the video subsystem in the game). 4. **Mirror audio:** in the **Audio mirror** panel, tick **Mirror game audio**. With the hook injected, **Source** shows **Hooked (no echo)** and the game's diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index a6d96f2..19db6e9 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -11,7 +11,7 @@ namespace coop // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // refuses to attach to a host with a mismatched version. -inline constexpr std::uint32_t kProtocolVersion = 6; +inline constexpr std::uint32_t kProtocolVersion = 7; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -66,7 +66,8 @@ enum HookSubsystem : std::uint32_t HookSubsys_Input = 0, // XInput hooks (forward the guest pad) HookSubsys_Focus = 1, // focus spoof (keep the game running unfocused) HookSubsys_Audio = 2, // WASAPI render-hook (audio mirror without echo) - HookSubsys_Count = 3, + HookSubsys_Video = 3, // IDXGISwapChain::Present hook (shared-texture video mirror) + HookSubsys_Count = 4, }; // Maximum individual hooks reported in the registry (a few per subsystem). @@ -134,6 +135,29 @@ struct HookControl std::atomic subsystem_disabled[HookSubsys_Count]; }; +// Present-hook video channel. When the video subsystem is installed, the hook +// copies the game's swapchain backbuffer into a shared keyed-mutex texture named +// coop_video_ and publishes its dimensions/format here; the host opens that +// texture by name and samples it (a lower-latency alternative to WGC). The hook +// is the sole writer. `generation` bumps on every published frame (0 = nothing +// shared yet); width/height/format describe the currently shared texture, so the +// host reopens it whenever they change. The keyed mutex uses key 0 on both sides. +struct VideoShare +{ + std::atomic generation; // bumps per published frame; 0 = none yet + std::uint32_t width; // shared texture dimensions / DXGI format + std::uint32_t height; + std::uint32_t format; // DXGI_FORMAT of the shared texture + std::uint64_t present_calls; // cumulative Present() detours (diagnostic) +}; + +// The shared backbuffer texture is named per target pid, like the audio ring. +inline constexpr wchar_t kVideoSharePrefix[] = L"Local\\coop_video_"; + +// Keyed-mutex key both producer and consumer use (a plain cross-process mutex on +// the texture; the keyed mutex is created released at key 0). +inline constexpr std::uint64_t kVideoMutexKey = 0; + // Top-level shared block. The host is the sole writer of pad state; the hook is // the sole reader. A seqlock (even = stable, odd = write in progress) lets the // reader grab a torn-free snapshot without a kernel lock on the hot path. @@ -151,8 +175,8 @@ struct SharedBlock // Host -> hook control (which subsystems to install). HookControl control; - // Phase 2 appends the shared-texture handle/dimensions control fields here; - // keep new members at the end so existing offsets never shift. + // Hook -> host Present-hook video channel (shared-texture dimensions/format). + VideoShare video; }; static_assert(std::atomic::is_always_lock_free, diff --git a/common/include/coop/shared_memory.hpp b/common/include/coop/shared_memory.hpp index 8617631..f119285 100644 --- a/common/include/coop/shared_memory.hpp +++ b/common/include/coop/shared_memory.hpp @@ -128,4 +128,11 @@ inline std::wstring shared_memory_name(unsigned long target_pid) return std::wstring(kSharedMemoryPrefix) + std::to_wstring(target_pid); } +// Name of the Present-hook shared backbuffer texture (created by the hook with +// CreateSharedHandle, opened by the host with OpenSharedResourceByName). +inline std::wstring video_share_name(unsigned long target_pid) +{ + return std::wstring(kVideoSharePrefix) + std::to_wstring(target_pid); +} + } // namespace coop diff --git a/hook/CMakeLists.txt b/hook/CMakeLists.txt index a89ddc4..38dff76 100644 --- a/hook/CMakeLists.txt +++ b/hook/CMakeLists.txt @@ -3,6 +3,7 @@ add_library(coop_hook SHARED src/xinput_hook.cpp src/focus_spoof.cpp src/audio_hook.cpp + src/present_hook.cpp src/debug_log.cpp src/hook_registry.cpp) @@ -17,7 +18,9 @@ target_link_libraries(coop_hook PRIVATE safetyhook::safetyhook user32 ole32 - mmdevapi) + mmdevapi + d3d11 + dxgi) set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook") diff --git a/hook/src/dllmain.cpp b/hook/src/dllmain.cpp index a072fa7..466c292 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 "present_hook.hpp" #include "xinput_hook.hpp" namespace @@ -68,6 +69,7 @@ DWORD WINAPI worker_thread(LPVOID) bool focus_installed = false; bool audio_installed = false; bool audio_ring_open = false; + bool video_installed = false; // Each tick, reconcile each subsystem with the host's requested state: install // what's wanted but missing (modules / the game window may appear lazily) and @@ -120,6 +122,24 @@ DWORD WINAPI worker_thread(LPVOID) coop::hook::logf("worker_thread: audio hooks removed (host request)"); } + // --- Video (Present-hook) --- + // Opt-in alternative to the host's WGC path; the host enables it on request. + 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); + if (video_installed) + { + coop::hook::logf("worker_thread: present hook installed"); + } + } + else if (!want_video && video_installed) + { + coop::hook::remove_present_hooks(); + video_installed = false; + coop::hook::logf("worker_thread: present hook removed (host request)"); + } + if (audio_installed && !audio_ring_open) { const std::wstring name = coop::audio_ring_name(GetCurrentProcessId()); @@ -185,6 +205,7 @@ BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) coop::hook::remove_focus_spoof(); coop::hook::remove_xinput_hooks(); coop::hook::remove_audio_hooks(); + coop::hook::remove_present_hooks(); coop::hook::hook_registry_reset(); } break; diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index ccd3f48..0fd5e34 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -169,6 +169,31 @@ public: } } + // --- Present-hook video channel ---------------------------------------- + + // Record that the game's Present() ran (diagnostic counter, hook is sole writer). + void note_present() + { + if (block_ != nullptr) + { + block_->video.present_calls += 1; + } + } + + // Publish that a fresh backbuffer copy is in the shared texture (named + // coop_video_) with these dimensions/format; bumps the generation the host + // polls. The texture itself is shared out-of-band by name, not through here. + void publish_video_frame(std::uint32_t width, std::uint32_t height, std::uint32_t format) + { + if (block_ != nullptr) + { + block_->video.width = width; + block_->video.height = height; + block_->video.format = format; + block_->video.generation.fetch_add(1, std::memory_order_release); + } + } + // --- Hook registry ----------------------------------------------------- // Publish the installed-hooks table (name / subsystem / installed / calls). diff --git a/hook/src/present_hook.cpp b/hook/src/present_hook.cpp new file mode 100644 index 0000000..4039c0d --- /dev/null +++ b/hook/src/present_hook.cpp @@ -0,0 +1,384 @@ +#include "present_hook.hpp" + +#include +#include + +#include + +#include +#include + +#include + +#include "coop/shared_memory.hpp" +#include "debug_log.hpp" +#include "hook_registry.hpp" + +namespace coop::hook +{ + +namespace +{ + +// IDXGISwapChain vtable layout (frozen ABI). IUnknown 0..2, IDXGIObject 3..6, +// IDXGIDeviceSubObject 7, then IDXGISwapChain: Present = 8, GetBuffer = 9. +// IDXGISwapChain1 adds methods after IDXGISwapChain (18 methods, 0..17), so +// Present1 sits at index 22. Flip-model D3D11/12 games present via Present1. +constexpr unsigned kIdx_IDXGISwapChain_Present = 8; +constexpr unsigned kIdx_IDXGISwapChain1_Present1 = 22; + +IpcClient* g_ipc = nullptr; +unsigned long g_pid = 0; + +safetyhook::InlineHook g_hk_present; +safetyhook::InlineHook g_hk_present1; +int g_id_present = -1; +int g_id_present1 = -1; + +std::atomic g_present_calls{0}; +std::atomic g_frames_shared{0}; + +// The shared backbuffer copy and its keyed mutex, created lazily on the first +// Present once we can see the game's device + backbuffer format. Guarded by +// g_tex_mutex (touched only on the render thread, but install/remove may race). +std::mutex g_tex_mutex; +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; +DXGI_FORMAT g_share_fmt = DXGI_FORMAT_UNKNOWN; +bool g_unsupported_logged = false; + +void* vtable_method(void* obj, unsigned index) +{ + return (*reinterpret_cast(obj))[index]; +} + +// Drop the shared texture/mutex/handle. Caller holds g_tex_mutex. +void release_shared_locked() +{ + 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; + g_share_fmt = DXGI_FORMAT_UNKNOWN; +} + +// (Re)create the shared keyed-mutex texture for a w x h `fmt` backbuffer on the +// game's `device`. Caller holds g_tex_mutex. Returns true if it's ready. +bool ensure_shared_texture_locked(ID3D11Device* device, UINT w, UINT h, DXGI_FORMAT fmt) +{ + if (g_shared_tex != nullptr && g_share_w == w && g_share_h == h && g_share_fmt == fmt) + { + return true; // already matches the current backbuffer + } + release_shared_locked(); + + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = w; + desc.Height = h; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = fmt; + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; + desc.MiscFlags = D3D11_RESOURCE_MISC_SHARED_NTHANDLE | D3D11_RESOURCE_MISC_SHARED_KEYEDMUTEX; + + ID3D11Texture2D* tex = nullptr; + HRESULT hr = device->CreateTexture2D(&desc, nullptr, &tex); + if (FAILED(hr) || tex == nullptr) + { + logf("present: CreateTexture2D(shared) failed hr=0x%08lX (%ux%u fmt=%d)", + static_cast(hr), w, h, static_cast(fmt)); + return false; + } + + IDXGIResource1* res = nullptr; + hr = tex->QueryInterface(__uuidof(IDXGIResource1), reinterpret_cast(&res)); + if (FAILED(hr) || res == nullptr) + { + logf("present: QI IDXGIResource1 failed hr=0x%08lX", static_cast(hr)); + tex->Release(); + return false; + } + const std::wstring name = video_share_name(g_pid); + HANDLE handle = nullptr; + hr = res->CreateSharedHandle(nullptr, DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE, + name.c_str(), &handle); + res->Release(); + if (FAILED(hr) || handle == nullptr) + { + logf("present: CreateSharedHandle failed hr=0x%08lX", static_cast(hr)); + tex->Release(); + return false; + } + + IDXGIKeyedMutex* mutex = nullptr; + hr = tex->QueryInterface(__uuidof(IDXGIKeyedMutex), reinterpret_cast(&mutex)); + if (FAILED(hr) || mutex == nullptr) + { + logf("present: QI IDXGIKeyedMutex failed hr=0x%08lX", static_cast(hr)); + CloseHandle(handle); + tex->Release(); + return false; + } + + g_shared_tex = tex; + g_shared_mutex = mutex; + g_shared_handle = handle; + g_share_w = w; + g_share_h = h; + g_share_fmt = fmt; + logf("present: shared texture ready %ux%u fmt=%d name=%ls", w, h, static_cast(fmt), name.c_str()); + return true; +} + +// Copy the swapchain's backbuffer into the shared texture and publish it. +void capture_backbuffer(IDXGISwapChain* sc) +{ + ID3D11Texture2D* backbuf = nullptr; + if (FAILED(sc->GetBuffer(0, __uuidof(ID3D11Texture2D), reinterpret_cast(&backbuf))) || + backbuf == nullptr) + { + if (!g_unsupported_logged) + { + logf("present: backbuffer is not an ID3D11Texture2D (D3D12/D3D9?); video hook idle"); + g_unsupported_logged = true; + } + return; + } + + D3D11_TEXTURE2D_DESC bd{}; + backbuf->GetDesc(&bd); + + // Multisampled backbuffers would need ResolveSubresource; flip-model swapchains + // are single-sampled. Skip the rare MSAA case rather than mis-copy. + if (bd.SampleDesc.Count != 1) + { + backbuf->Release(); + return; + } + + ID3D11Device* device = nullptr; + backbuf->GetDevice(&device); + ID3D11DeviceContext* ctx = nullptr; + if (device != nullptr) + { + device->GetImmediateContext(&ctx); + } + + bool shared = false; + if (device != nullptr && ctx != nullptr) + { + std::scoped_lock lock(g_tex_mutex); + if (ensure_shared_texture_locked(device, bd.Width, bd.Height, bd.Format) && g_shared_mutex != nullptr) + { + // Key 0 on both sides: a plain cross-process mutex on the texture (the + // keyed mutex is created released at key 0). Bounded wait so a stalled + // host consumer can never hang the game's render thread. + if (g_shared_mutex->AcquireSync(kVideoMutexKey, 8) == S_OK) + { + ctx->CopyResource(g_shared_tex, backbuf); + g_shared_mutex->ReleaseSync(kVideoMutexKey); + shared = true; + } + } + } + + if (shared) + { + g_frames_shared.fetch_add(1, std::memory_order_relaxed); + if (g_ipc != nullptr) + { + g_ipc->publish_video_frame(bd.Width, bd.Height, static_cast(bd.Format)); + } + } + + if (ctx != nullptr) + { + ctx->Release(); + } + if (device != nullptr) + { + device->Release(); + } + backbuf->Release(); +} + +HRESULT STDMETHODCALLTYPE hk_Present(IDXGISwapChain* sc, UINT sync_interval, UINT flags) +{ + hook_note_call(g_id_present); + g_present_calls.fetch_add(1, std::memory_order_relaxed); + if (g_ipc != nullptr) + { + g_ipc->note_present(); + } + // DXGI_PRESENT_TEST presents nothing; don't bother copying for it. + if ((flags & DXGI_PRESENT_TEST) == 0) + { + capture_backbuffer(sc); + } + return g_hk_present.call(sc, sync_interval, flags); +} + +HRESULT STDMETHODCALLTYPE hk_Present1(IDXGISwapChain1* sc, UINT sync_interval, UINT flags, + const DXGI_PRESENT_PARAMETERS* params) +{ + hook_note_call(g_id_present1); + g_present_calls.fetch_add(1, std::memory_order_relaxed); + if (g_ipc != nullptr) + { + g_ipc->note_present(); + } + if ((flags & DXGI_PRESENT_TEST) == 0) + { + capture_backbuffer(sc); // IDXGISwapChain1 derives from IDXGISwapChain + } + return g_hk_present1.call(sc, sync_interval, flags, params); +} + +// Create a throwaway device + swapchain purely to read IDXGISwapChain::Present +// (and IDXGISwapChain1::Present1) addresses, so we can inline-hook them. The +// inline hook patches the function itself, so it catches every swapchain in the +// process -- we can release the dummy after. `present1_out` may stay null on +// platforms without DXGI 1.2. +void* grab_present_address(void** present1_out) +{ + *present1_out = nullptr; + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = DefWindowProcW; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = L"coop_present_probe"; + RegisterClassExW(&wc); + HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"", WS_OVERLAPPEDWINDOW, 0, 0, 8, 8, nullptr, nullptr, + wc.hInstance, nullptr); + if (hwnd == nullptr) + { + return nullptr; + } + + DXGI_SWAP_CHAIN_DESC scd{}; + scd.BufferCount = 1; + scd.BufferDesc.Width = 8; + scd.BufferDesc.Height = 8; + scd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + scd.OutputWindow = hwnd; + scd.SampleDesc.Count = 1; + scd.Windowed = TRUE; + scd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD; + + IDXGISwapChain* swapchain = nullptr; + ID3D11Device* device = nullptr; + ID3D11DeviceContext* ctx = nullptr; + const HRESULT hr = D3D11CreateDeviceAndSwapChain(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0, + D3D11_SDK_VERSION, &scd, &swapchain, &device, nullptr, &ctx); + void* present = nullptr; + if (SUCCEEDED(hr) && swapchain != nullptr) + { + present = vtable_method(swapchain, kIdx_IDXGISwapChain_Present); + IDXGISwapChain1* sc1 = nullptr; + if (SUCCEEDED(swapchain->QueryInterface(__uuidof(IDXGISwapChain1), reinterpret_cast(&sc1))) && + sc1 != nullptr) + { + *present1_out = vtable_method(sc1, kIdx_IDXGISwapChain1_Present1); + sc1->Release(); + } + } + else + { + logf("present: D3D11CreateDeviceAndSwapChain(probe) failed hr=0x%08lX", static_cast(hr)); + } + if (ctx != nullptr) + { + ctx->Release(); + } + if (device != nullptr) + { + device->Release(); + } + if (swapchain != nullptr) + { + swapchain->Release(); + } + DestroyWindow(hwnd); + UnregisterClassW(wc.lpszClassName, wc.hInstance); + return present; +} + +} // namespace + +bool install_present_hooks(IpcClient& ipc) +{ + g_ipc = &ipc; + g_pid = GetCurrentProcessId(); + if (g_hk_present) + { + return true; // already installed + } + + g_id_present = hook_register("IDXGISwapChain::Present", HookSubsys_Video); + g_id_present1 = hook_register("IDXGISwapChain1::Present1", HookSubsys_Video); + + void* present1 = nullptr; + void* present = grab_present_address(&present1); + if (present == nullptr) + { + hook_set_installed(g_id_present, false); + hook_set_installed(g_id_present1, false); + return false; + } + g_hk_present = safetyhook::create_inline(present, reinterpret_cast(&hk_Present)); + if (present1 != nullptr) + { + g_hk_present1 = safetyhook::create_inline(present1, reinterpret_cast(&hk_Present1)); + } + g_unsupported_logged = false; + hook_set_installed(g_id_present, static_cast(g_hk_present)); + hook_set_installed(g_id_present1, static_cast(g_hk_present1)); + logf("install_present_hooks: present=%p hooked=%d present1=%p hooked=%d", present, + static_cast(g_hk_present) ? 1 : 0, present1, static_cast(g_hk_present1) ? 1 : 0); + return static_cast(g_hk_present); +} + +void remove_present_hooks() +{ + g_hk_present = {}; + g_hk_present1 = {}; + hook_set_installed(g_id_present, false); + hook_set_installed(g_id_present1, false); + { + std::scoped_lock lock(g_tex_mutex); + release_shared_locked(); + } + g_present_calls.store(0, std::memory_order_relaxed); + g_frames_shared.store(0, std::memory_order_relaxed); + g_ipc = nullptr; +} + +std::uint64_t present_calls() +{ + return g_present_calls.load(std::memory_order_relaxed); +} + +std::uint64_t present_frames_shared() +{ + return g_frames_shared.load(std::memory_order_relaxed); +} + +} // namespace coop::hook diff --git a/hook/src/present_hook.hpp b/hook/src/present_hook.hpp new file mode 100644 index 0000000..af5660b --- /dev/null +++ b/hook/src/present_hook.hpp @@ -0,0 +1,35 @@ +// Injected Present-hook video path: hooks IDXGISwapChain::Present in the target +// game, copies the swapchain backbuffer into a shared keyed-mutex texture +// (coop_video_), and publishes its dimensions/format to the host over IPC. +// The host opens that texture by name and samples it -- a lower-latency, more +// stable alternative to Windows Graphics Capture (which composites off the +// desktop and can stutter / show a capture border). +// +// Only DXGI swapchains (D3D10/11/12-backed games) are caught; the backbuffer +// must be an ID3D11Texture2D (the common D3D11 case). Games on D3D9 or a pure +// D3D12 resource path won't engage this hook -- WGC stays as the fallback. +#pragma once + +#include "ipc_client.hpp" + +namespace coop::hook +{ + +// Installs the Present hook. Grabs IDXGISwapChain::Present from a throwaway +// swapchain and inline-hooks it, so every swapchain in the process is caught. +// `ipc` must outlive the hook (used to publish the shared-texture descriptor). +// Returns true once the hook is in place; safe to call repeatedly. +bool install_present_hooks(IpcClient& ipc); + +// Removes the Present hook and releases the shared texture (best effort). +void remove_present_hooks(); + +// --- Diagnostics (used by the self-test) ----------------------------------- + +// Cumulative Present() detours observed. +std::uint64_t present_calls(); + +// Frames actually copied into the shared texture (a backbuffer we could share). +std::uint64_t present_frames_shared(); + +} // namespace coop::hook diff --git a/host/CMakeLists.txt b/host/CMakeLists.txt index 66661e7..aadd20a 100644 --- a/host/CMakeLists.txt +++ b/host/CMakeLists.txt @@ -14,6 +14,7 @@ add_executable(coop_host WIN32 src/ipc/ipc_server.cpp src/capture/frame_renderer.cpp src/capture/window_capture.cpp + src/capture/shared_texture.cpp src/audio/audio_loopback.cpp src/audio/process_loopback_capture.cpp) diff --git a/host/src/capture/shared_texture.cpp b/host/src/capture/shared_texture.cpp new file mode 100644 index 0000000..75c99aa --- /dev/null +++ b/host/src/capture/shared_texture.cpp @@ -0,0 +1,128 @@ +#include "capture/shared_texture.hpp" + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" + +namespace coop +{ + +bool SharedTextureSource::init(ID3D11Device* device) +{ + if (device == nullptr || FAILED(device->QueryInterface(IID_PPV_ARGS(&device_)))) + { + return false; + } + device_->GetImmediateContext(&ctx_); + return ctx_ != nullptr; +} + +void SharedTextureSource::reset() +{ + srv_.Reset(); + private_.Reset(); + mutex_.Reset(); + shared_.Reset(); + pid_ = 0; + width_ = height_ = format_ = 0; + last_generation_ = 0; + frames_copied_ = 0; +} + +bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share) +{ + srv_.Reset(); + private_.Reset(); + mutex_.Reset(); + shared_.Reset(); + width_ = height_ = format_ = 0; + pid_ = pid; + + if (pid == 0 || share.width == 0 || share.height == 0) + { + return false; // the hook hasn't shared a backbuffer yet + } + + const std::wstring name = video_share_name(pid); + if (FAILED(device_->OpenSharedResourceByName(name.c_str(), + DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE, + IID_PPV_ARGS(&shared_))) || + shared_ == nullptr) + { + return false; + } + if (FAILED(shared_.As(&mutex_)) || mutex_ == nullptr) + { + shared_.Reset(); + return false; + } + + // Private copy we sample from, so we only hold the keyed mutex during the copy. + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = share.width; + desc.Height = share.height; + desc.MipLevels = 1; + desc.ArraySize = 1; + desc.Format = static_cast(share.format); + desc.SampleDesc.Count = 1; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; + if (FAILED(device_->CreateTexture2D(&desc, nullptr, &private_)) || private_ == nullptr) + { + mutex_.Reset(); + shared_.Reset(); + return false; + } + if (FAILED(device_->CreateShaderResourceView(private_.Get(), nullptr, &srv_))) + { + srv_.Reset(); + private_.Reset(); + mutex_.Reset(); + shared_.Reset(); + return false; + } + + width_ = share.width; + height_ = share.height; + format_ = share.format; + return true; +} + +bool SharedTextureSource::update(const VideoShareView& share, unsigned long pid) +{ + if (device_ == nullptr || pid == 0) + { + reset(); + return false; + } + + // (Re)open whenever the target or the published backbuffer geometry changes. + if (pid != pid_ || share.width != width_ || share.height != height_ || share.format != format_) + { + if (!reopen(pid, share)) + { + return srv_ != nullptr; // couldn't open yet; keep any prior frame + } + last_generation_ = 0; // force a copy of the current frame + } + + if (shared_ == nullptr || mutex_ == nullptr) + { + return srv_ != nullptr; + } + if (share.generation == last_generation_) + { + return srv_ != nullptr; // no new frame; keep showing the last copy + } + + // Bounded wait so a stalled producer can't hang the host's render thread. + if (mutex_->AcquireSync(kVideoMutexKey, 8) == S_OK) + { + ctx_->CopyResource(private_.Get(), shared_.Get()); + mutex_->ReleaseSync(kVideoMutexKey); + last_generation_ = share.generation; + ++frames_copied_; + } + return srv_ != nullptr; +} + +} // namespace coop diff --git a/host/src/capture/shared_texture.hpp b/host/src/capture/shared_texture.hpp new file mode 100644 index 0000000..a86e5d0 --- /dev/null +++ b/host/src/capture/shared_texture.hpp @@ -0,0 +1,69 @@ +// Host-side consumer of the injected Present-hook's shared backbuffer texture. +// Opens the keyed-mutex texture the hook publishes (coop_video_) by name, +// copies the latest frame into a private texture under the keyed mutex, and +// exposes an SRV the FrameRenderer can draw -- the lower-latency alternative to +// Windows Graphics Capture. See hook/src/present_hook.cpp and coop/protocol.hpp. +#pragma once + +#include +#include + +#include +#include + +#include "ipc/ipc_server.hpp" + +namespace coop +{ + +class SharedTextureSource +{ +public: + // Binds to the host's device (must support ID3D11Device1). Returns false if not. + bool init(ID3D11Device* device); + + // Polls the hook's video channel for `pid`. (Re)opens the shared texture when + // the pid/dimensions/format change, and copies the newest frame into the + // private texture under the keyed mutex. Returns true if a frame is available + // to draw (sticky: keeps the last copy until a new one or a reset). + bool update(const VideoShareView& share, unsigned long pid); + + // Drop the opened resources (target changed / mirror turned off). + void reset(); + + [[nodiscard]] ID3D11ShaderResourceView* srv() const + { + return srv_.Get(); + } + [[nodiscard]] std::uint32_t width() const + { + return width_; + } + [[nodiscard]] std::uint32_t height() const + { + return height_; + } + [[nodiscard]] std::uint64_t frames_copied() const + { + return frames_copied_; + } + +private: + bool reopen(unsigned long pid, const VideoShareView& share); + + Microsoft::WRL::ComPtr device_; + Microsoft::WRL::ComPtr ctx_; + Microsoft::WRL::ComPtr shared_; // opened from the hook, by name + Microsoft::WRL::ComPtr mutex_; + Microsoft::WRL::ComPtr private_; // our sampled copy + Microsoft::WRL::ComPtr srv_; + + unsigned long pid_ = 0; + std::uint32_t width_ = 0; + std::uint32_t height_ = 0; + std::uint32_t format_ = 0; + std::uint32_t last_generation_ = 0; + std::uint64_t frames_copied_ = 0; +}; + +} // namespace coop diff --git a/host/src/capture_panel.cpp b/host/src/capture_panel.cpp index 25bddf1..a72ccb6 100644 --- a/host/src/capture_panel.cpp +++ b/host/src/capture_panel.cpp @@ -3,13 +3,21 @@ #include #include "imgui.h" +#include "injection_panel.hpp" namespace coop { +namespace +{ +const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f); +const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f); +} // namespace + bool CapturePanel::init(ID3D11Device* device) { device_ = device; + shared_.init(device); // best effort; the Hooked source is unavailable if it fails return renderer_.init(device); } @@ -19,35 +27,89 @@ void CapturePanel::draw_ui(const FrameStats& stats) ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver); ImGui::Begin("Video mirror"); - const bool have_target = target_ != nullptr && IsWindow(target_); - ImGui::BeginDisabled(!have_target); - if (ImGui::Checkbox("Mirror game window", &enabled_)) + const unsigned long hook_pid = injection_ != nullptr ? injection_->target_pid() : 0; + const bool have_wgc_target = target_ != nullptr && IsWindow(target_); + const bool have_hook = hook_pid != 0; + const bool have_source = source_ == Source_Hooked ? have_hook : have_wgc_target; + + ImGui::BeginDisabled(!have_source); + if (ImGui::Checkbox("Mirror game window", &enabled_) && !enabled_) { - if (!enabled_) + capture_.stop(); + shared_.reset(); + if (injection_ != nullptr) { - capture_.stop(); + injection_->request_video(false); // stop the in-game Present hook } } ImGui::EndDisabled(); - if (!have_target) - { - ImGui::TextDisabled("Inject into a game first (its window is the source)."); - } - // Start/restart capture when enabled and the target window changes. - if (enabled_ && have_target && capture_.target() != target_) + // Source selector. Switching tears down the other source and (un)installs the + // Present hook so the game only pays for the path actually in use. + ImGui::TextUnformatted("Source:"); + ImGui::SameLine(); + int prev_source = source_; + ImGui::RadioButton("WGC", &source_, Source_Wgc); + ImGui::SameLine(); + ImGui::RadioButton("Hooked (Present)", &source_, Source_Hooked); + if (source_ != prev_source) { - if (!capture_.start(target_, device_)) + capture_.stop(); + shared_.reset(); + if (injection_ != nullptr) { - enabled_ = false; - ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.4f, 1.0f), "Failed to start capture."); + injection_->request_video(enabled_ && source_ == Source_Hooked); } } - if (capture_.running()) + if (!have_source) { - ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Capturing %ux%u", capture_.frame_width(), - capture_.frame_height()); + ImGui::TextDisabled(source_ == Source_Hooked + ? "Inject into a game first (the Present hook is the source)." + : "Inject into a game first (its window is the source)."); + } + + if (source_ == Source_Wgc) + { + // Start/restart WGC capture when enabled and the target window changes. + if (enabled_ && have_wgc_target && capture_.target() != target_) + { + if (!capture_.start(target_, device_)) + { + enabled_ = false; + ImGui::TextColored(kRed, "Failed to start capture."); + } + } + if (capture_.running()) + { + ImGui::TextColored(kGreen, "Capturing %ux%u (WGC)", capture_.frame_width(), capture_.frame_height()); + } + } + else // Source_Hooked + { + if (enabled_ && injection_ != nullptr) + { + // Keep the subsystem requested (a fresh inject may have reset control). + if (!injection_->video_requested()) + { + injection_->request_video(true); + } + const VideoShareView share = injection_->video_share(); + if (shared_.frames_copied() > 0 && shared_.width() > 0) + { + ImGui::TextColored(kGreen, "Mirroring %ux%u (hooked, %llu frames)", shared_.width(), + shared_.height(), static_cast(shared_.frames_copied())); + } + else if (share.present_calls > 0) + { + ImGui::TextColored(kGreen, "Present hooked (%llu calls); opening shared texture...", + static_cast(share.present_calls)); + } + else + { + ImGui::TextDisabled("Waiting for hooked frames (the game may not render via DXGI)."); + } + } } draw_perf_graphs(stats); @@ -95,7 +157,22 @@ void CapturePanel::draw_perf_graphs(const FrameStats& stats) void CapturePanel::render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h) { - if (capture_.running()) + if (!enabled_) + { + return; + } + if (source_ == Source_Hooked) + { + if (injection_ == nullptr) + { + return; + } + if (shared_.update(injection_->video_share(), injection_->target_pid()) && shared_.srv() != nullptr) + { + renderer_.draw(ctx, shared_.srv(), shared_.width(), shared_.height(), dst_w, dst_h); + } + } + else if (capture_.running()) { capture_.draw_latest(renderer_, ctx, dst_w, dst_h); } diff --git a/host/src/capture_panel.hpp b/host/src/capture_panel.hpp index 519fb83..00d650b 100644 --- a/host/src/capture_panel.hpp +++ b/host/src/capture_panel.hpp @@ -1,5 +1,7 @@ -// Owns the video-mirror pipeline (WGC capture + frame renderer) and its UI. -// The target window comes from the injected hook's reported game HWND. +// Owns the video-mirror pipeline and its UI. Two interchangeable sources feed the +// same letterboxed renderer: Windows Graphics Capture (no injection, the default) +// and the injected Present-hook's shared backbuffer texture (lower latency, needs +// the video subsystem hooked). The target window/pid come from the injected hook. #pragma once #include @@ -8,23 +10,33 @@ #include #include "capture/frame_renderer.hpp" +#include "capture/shared_texture.hpp" #include "capture/window_capture.hpp" #include "ui/app_chrome.hpp" namespace coop { +class InjectionPanel; + class CapturePanel { public: bool init(ID3D11Device* device); - // The window to mirror (0 if none yet); typically the injected game's HWND. + // The window to mirror via WGC (0 if none yet); typically the injected game's HWND. void set_target(HWND target) { target_ = target; } + // The injection panel supplies the target pid + Present-hook video channel and + // lets this panel install/remove the video subsystem when the source is Hooked. + void set_injection(InjectionPanel* injection) + { + injection_ = injection; + } + // `stats` are the host's render frame-timing, drawn as the mirror's // frametime / FPS graphs (this window is what the mirror renders into). void draw_ui(const FrameStats& stats); @@ -33,13 +45,22 @@ public: void render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h); private: + enum Source : int + { + Source_Wgc = 0, // Windows Graphics Capture + Source_Hooked = 1, // injected Present-hook shared texture + }; + void draw_perf_graphs(const FrameStats& stats); ID3D11Device* device_ = nullptr; FrameRenderer renderer_; WindowCapture capture_; + SharedTextureSource shared_; + InjectionPanel* injection_ = nullptr; HWND target_ = nullptr; bool enabled_ = false; + int source_ = Source_Wgc; }; } // namespace coop diff --git a/host/src/injection_panel.cpp b/host/src/injection_panel.cpp index e1737bc..3498feb 100644 --- a/host/src/injection_panel.cpp +++ b/host/src/injection_panel.cpp @@ -96,6 +96,7 @@ void InjectionPanel::inject_selected() server_.set_subsystem_enabled(HookSubsys_Input, want_input_); server_.set_subsystem_enabled(HookSubsys_Focus, want_focus_); server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_); + server_.set_subsystem_enabled(HookSubsys_Video, want_video_); const InjectResult result = inject_dll(selected_pid_, hook_dll_path()); if (result.status == InjectStatus::Ok) @@ -147,7 +148,7 @@ void InjectionPanel::publish(const std::array& pads) void InjectionPanel::draw_hook_list(const HookStatusView& status) { - static const char* kSubsysName[] = {"Input", "Focus", "Audio"}; + static const char* kSubsysName[] = {"Input", "Focus", "Audio", "Video"}; const std::uint32_t n = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries; if (n == 0) @@ -181,7 +182,7 @@ void InjectionPanel::draw_hook_list(const HookStatusView& status) { ImGui::TableNextRow(); ImGui::TableNextColumn(); - ImGui::TextDisabled("%s", kSubsysName[sub < 3 ? sub : 0]); + ImGui::TextDisabled("%s", kSubsysName[sub < HookSubsys_Count ? sub : 0]); ImGui::TableNextColumn(); ImGui::TableNextColumn(); header_done = true; @@ -235,6 +236,7 @@ void InjectionPanel::draw_subsystem_controls(const HookStatusView& status) {"Input forwarding (XInput)", HookSubsys_Input, &want_input_, "controller input reaches the game"}, {"Focus spoof", HookSubsys_Focus, &want_focus_, "the game keeps running unfocused"}, {"Audio render-hook", HookSubsys_Audio, &want_audio_, "audio mirror without echo"}, + {"Video Present-hook", HookSubsys_Video, &want_video_, "Video mirror can use the hooked source"}, }; for (const Row& r : rows) diff --git a/host/src/injection_panel.hpp b/host/src/injection_panel.hpp index 100752d..e083551 100644 --- a/host/src/injection_panel.hpp +++ b/host/src/injection_panel.hpp @@ -49,6 +49,32 @@ public: server_.drain_logs(std::forward(emit)); } + // --- Present-hook video path (consumed by the Video mirror panel) ---------- + + [[nodiscard]] unsigned long target_pid() const + { + return server_.target_pid(); + } + + // The hook's Present-hook video channel snapshot (shared-texture descriptor). + [[nodiscard]] VideoShareView video_share() const + { + return server_.video_share(); + } + + // Request the Present-hook video subsystem be installed/removed. Keeps the + // Injection panel's own checkbox in sync, so the Video panel can drive it. + void request_video(bool on) + { + want_video_ = on; + server_.set_subsystem_enabled(HookSubsys_Video, on); + } + + [[nodiscard]] bool video_requested() const + { + return want_video_; + } + private: void refresh_processes(); void inject_selected(); @@ -72,7 +98,8 @@ private: bool want_input_ = true; bool want_focus_ = true; bool want_audio_ = true; - bool injected_ = false; // a hook DLL is loaded in the target + bool want_video_ = false; // Present-hook video path: opt-in (WGC is the default) + bool injected_ = false; // a hook DLL is loaded in the target // Heartbeat liveness tracking (is the injected DLL responding?). std::uint32_t last_heartbeat_ = 0; diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 673c01b..9964717 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -88,6 +88,22 @@ HookStatusView IpcServer::hook_status() const return view; } +VideoShareView IpcServer::video_share() const +{ + VideoShareView v; + if (block_ == nullptr) + { + return v; + } + const VideoShare& s = block_->video; + v.generation = s.generation.load(std::memory_order_acquire); + v.width = s.width; + v.height = s.height; + v.format = s.format; + v.present_calls = s.present_calls; + return v; +} + void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled) { if (block_ != nullptr && subsystem < HookSubsys_Count) diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index 72fe9a2..fc3f609 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -38,6 +38,16 @@ struct HookStatusView HookEntry hook_entries[kMaxHookEntries] = {}; }; +// Plain snapshot of the Present-hook video channel for the Video mirror panel. +struct VideoShareView +{ + std::uint32_t generation = 0; // bumps per shared frame; 0 = nothing shared yet + std::uint32_t width = 0; // shared texture dimensions / DXGI format + std::uint32_t height = 0; + std::uint32_t format = 0; + std::uint64_t present_calls = 0; // cumulative Present() detours (diagnostic) +}; + class IpcServer { public: @@ -53,6 +63,9 @@ public: // Reads the hook's diagnostics back-channel (zeroed if not started). [[nodiscard]] HookStatusView hook_status() const; + // Reads the Present-hook video channel (zeroed if not started). + [[nodiscard]] VideoShareView video_share() const; + // Request a hook subsystem be installed (true) or removed (false). The hook // reconciles on its next tick. No-op if not started. void set_subsystem_enabled(std::uint32_t subsystem, bool enabled); diff --git a/host/src/main.cpp b/host/src/main.cpp index b8622bf..701d939 100644 --- a/host/src/main.cpp +++ b/host/src/main.cpp @@ -75,6 +75,7 @@ int run() MessageBoxW(nullptr, L"Failed to initialize the video mirror.", L"CoopAllTheThings", MB_ICONERROR); return 1; } + capture.set_injection(&injection); // for the Present-hook (Hooked) video source // The overlay can be hidden (F1) so the window is a clean mirror for Remote // Play Together; the pipelines keep running underneath either way. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 110ec85..2c0edb4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -58,3 +58,24 @@ target_link_libraries(audio_hook_test PRIVATE mmdevapi) add_test(NAME audio_hook_test COMMAND audio_hook_test) + +# In-process self-test for the Present-hook video path. Reuses the shipping +# present_hook.cpp and drives a real D3D11 swapchain in the same process, so it +# exercises the IDXGISwapChain::Present inline hook, the shared keyed-mutex +# texture, and the open-by-name / readback contract (the video analogue of +# audio_hook_test). Skips on a machine without a D3D11 device. +add_executable(present_hook_test + present_hook_test.cpp + ${CMAKE_SOURCE_DIR}/hook/src/present_hook.cpp + ${CMAKE_SOURCE_DIR}/hook/src/debug_log.cpp + ${CMAKE_SOURCE_DIR}/hook/src/hook_registry.cpp) + +target_include_directories(present_hook_test PRIVATE ${CMAKE_SOURCE_DIR}/hook/src) + +target_link_libraries(present_hook_test PRIVATE + coop_common + safetyhook::safetyhook + d3d11 + dxgi) + +add_test(NAME present_hook_test COMMAND present_hook_test) diff --git a/tests/present_hook_test.cpp b/tests/present_hook_test.cpp new file mode 100644 index 0000000..9ac1444 --- /dev/null +++ b/tests/present_hook_test.cpp @@ -0,0 +1,227 @@ +// In-process self-test for the Present-hook video path (hook/src/present_hook.cpp). +// This process plays both "game" and "host": it installs the Present hook, then +// creates its own D3D11 swapchain, clears the backbuffer to a known color, and +// calls Present -- exactly as a game would. With the hook live, that Present must +// (1) fire the detour, (2) copy the backbuffer into the shared keyed-mutex +// texture, and (3) publish the descriptor to the IPC video channel. A second +// device then opens the shared texture by name and reads it back, proving the +// CreateSharedHandle / OpenSharedResourceByName / keyed-mutex / CopyResource +// contract both the hook and the host rely on -- with no game and no Steam. +// +// Requires a D3D11 device; on a headless / no-GPU machine it reports SKIP and +// exits 0 (mirrors audio_hook_test). + +#include +#include +#include + +#include + +#include +#include + +#include "coop/protocol.hpp" +#include "coop/shared_memory.hpp" +#include "ipc_client.hpp" +#include "present_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 UINT kW = 256; +constexpr UINT kH = 256; +// Distinctive clear color; UNORM bytes are ~ {51, 102, 153, 255}. +constexpr float kClear[4] = {0.20f, 0.40f, 0.60f, 1.0f}; + +bool near_byte(std::uint8_t got, int expected) +{ + return std::abs(static_cast(got) - expected) <= 2; +} + +} // namespace + +int main() +{ + // --- Host side: SharedBlock (named by our pid) 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(); // OS zero-fills the mapping + 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"); + + // --- Install the Present hook (inline-hooks IDXGISwapChain::Present). --- + if (!hook::install_present_hooks(ipc)) + { + std::printf("SKIP: could not install the Present hook (no D3D11 device?)\n"); + return 0; + } + + // --- Game side: our own swapchain on a hidden window; render + Present. --- + WNDCLASSEXW wc{}; + wc.cbSize = sizeof(wc); + wc.lpfnWndProc = DefWindowProcW; + wc.hInstance = GetModuleHandleW(nullptr); + wc.lpszClassName = L"coop_present_test"; + RegisterClassExW(&wc); + HWND hwnd = CreateWindowExW(0, wc.lpszClassName, L"", WS_OVERLAPPEDWINDOW, 0, 0, kW, kH, nullptr, nullptr, + wc.hInstance, nullptr); + + DXGI_SWAP_CHAIN_DESC scd{}; + scd.BufferCount = 2; + scd.BufferDesc.Width = kW; + scd.BufferDesc.Height = kH; + scd.BufferDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + scd.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT; + scd.OutputWindow = hwnd; + scd.SampleDesc.Count = 1; + scd.Windowed = TRUE; + scd.SwapEffect = DXGI_SWAP_EFFECT_DISCARD; + + IDXGISwapChain* swapchain = nullptr; + ID3D11Device* device = nullptr; + ID3D11DeviceContext* ctx = nullptr; + HRESULT hr = D3D11CreateDeviceAndSwapChain(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, 0, nullptr, 0, + D3D11_SDK_VERSION, &scd, &swapchain, &device, nullptr, &ctx); + if (FAILED(hr) || swapchain == nullptr) + { + std::printf("SKIP: could not create a D3D11 swapchain (hr=0x%08lX)\n", static_cast(hr)); + hook::remove_present_hooks(); + return 0; + } + + // Clear the backbuffer to the known color, then Present (fires the detour). + for (int frame = 0; frame < 3; ++frame) + { + ID3D11Texture2D* back = nullptr; + if (SUCCEEDED(swapchain->GetBuffer(0, __uuidof(ID3D11Texture2D), reinterpret_cast(&back)))) + { + ID3D11RenderTargetView* rtv = nullptr; + if (SUCCEEDED(device->CreateRenderTargetView(back, nullptr, &rtv))) + { + ctx->ClearRenderTargetView(rtv, kClear); + ctx->Flush(); + rtv->Release(); + } + back->Release(); + } + swapchain->Present(0, 0); + } + + std::printf("present_calls=%llu frames_shared=%llu video{gen=%u %ux%u fmt=%u}\n", + static_cast(hook::present_calls()), + static_cast(hook::present_frames_shared()), + block->video.generation.load(), block->video.width, block->video.height, block->video.format); + + check(hook::present_calls() >= 3, "Present detour fired"); + check(hook::present_frames_shared() > 0, "backbuffer copied 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"); + check(block->video.format == DXGI_FORMAT_R8G8B8A8_UNORM, "shared format published"); + + // --- Consumer side: open the shared texture by name (second device), copy it + // into a staging texture, map it, and verify the clear color survived. --- + { + 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; + check(SUCCEEDED(devB->CreateTexture2D(&sd, nullptr, &staging)), "create staging texture"); + + if (km != nullptr && staging != nullptr && km->AcquireSync(kVideoMutexKey, 1000) == S_OK) + { + ctxB->CopyResource(staging, sharedB); + km->ReleaseSync(kVideoMutexKey); + + D3D11_MAPPED_SUBRESOURCE mapped{}; + if (SUCCEEDED(ctxB->Map(staging, 0, D3D11_MAP_READ, 0, &mapped))) + { + const auto* px = static_cast(mapped.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); + } + + release(swapchain); + release(ctx); + release(device); + DestroyWindow(hwnd); + UnregisterClassW(wc.lpszClassName, wc.hInstance); + hook::remove_present_hooks(); + + std::printf(g_failures == 0 ? "PRESENT HOOK TEST PASS\n" : "PRESENT HOOK TEST FAILED (%d)\n", g_failures); + return g_failures == 0 ? 0 : 1; +} diff --git a/tools/audio_probe/main.cpp b/tools/audio_probe/main.cpp index 93f9dc6..25d5b95 100644 --- a/tools/audio_probe/main.cpp +++ b/tools/audio_probe/main.cpp @@ -242,12 +242,15 @@ int wmain(int argc, wchar_t** argv) const std::uint64_t produced = ring->frames_produced.load(std::memory_order_relaxed); const std::uint64_t overruns = ring->overruns.load(std::memory_order_relaxed); const bool fmt_ready = coop::audio_ring_format_ready(*ring); + const std::uint32_t vgen = block->video.generation.load(std::memory_order_acquire); + const std::uint64_t vpresent = block->video.present_calls; std::printf("[%4.1fs] hb=%u streams=%u peak=%.4f ring{fmt=%d %uHz/%uch/%ubit produced=%llu " - "overruns=%llu}\n", + "overruns=%llu} video{present=%llu gen=%u %ux%u}\n", (t + 1) * 0.5, heartbeat, streams, peak, fmt_ready ? 1 : 0, ring->sample_rate, ring->channels, ring->bits, static_cast(produced), - static_cast(overruns)); + static_cast(overruns), static_cast(vpresent), + vgen, block->video.width, block->video.height); for (std::uint32_t i = 0; i < coop::kMaxAudioStreams && i < streams; ++i) { const coop::AudioStreamInfo& s = status.audio_streams[i]; @@ -261,7 +264,7 @@ int wmain(int argc, wchar_t** argv) } // Dump the hook registry so the installed-hooks list can be verified headless. - static const char* kSubsys[] = {"Input", "Focus", "Audio"}; + static const char* kSubsys[] = {"Input", "Focus", "Audio", "Video"}; std::printf("\nInstalled hooks (%u):\n", status.hook_entry_count); for (std::uint32_t i = 0; i < status.hook_entry_count && i < coop::kMaxHookEntries; ++i) {