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_<pid>); 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 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 11:34:12 +02:00
parent 9557b9ca69
commit 36b861d167
21 changed files with 1160 additions and 36 deletions

View File

@@ -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_<pid>`); 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

View File

@@ -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<std::uint32_t> 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_<pid> 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<std::uint32_t> 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<std::uint32_t>::is_always_lock_free,

View File

@@ -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

View File

@@ -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")

View File

@@ -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;

View File

@@ -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_<pid>) 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).

384
hook/src/present_hook.cpp Normal file
View File

@@ -0,0 +1,384 @@
#include "present_hook.hpp"
#include <atomic>
#include <mutex>
#include <windows.h>
#include <d3d11.h>
#include <dxgi1_2.h>
#include <safetyhook.hpp>
#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<std::uint64_t> g_present_calls{0};
std::atomic<std::uint64_t> 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<void***>(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<unsigned long>(hr), w, h, static_cast<int>(fmt));
return false;
}
IDXGIResource1* res = nullptr;
hr = tex->QueryInterface(__uuidof(IDXGIResource1), reinterpret_cast<void**>(&res));
if (FAILED(hr) || res == nullptr)
{
logf("present: QI IDXGIResource1 failed hr=0x%08lX", static_cast<unsigned long>(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<unsigned long>(hr));
tex->Release();
return false;
}
IDXGIKeyedMutex* mutex = nullptr;
hr = tex->QueryInterface(__uuidof(IDXGIKeyedMutex), reinterpret_cast<void**>(&mutex));
if (FAILED(hr) || mutex == nullptr)
{
logf("present: QI IDXGIKeyedMutex failed hr=0x%08lX", static_cast<unsigned long>(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<int>(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<void**>(&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<std::uint32_t>(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<HRESULT>(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<HRESULT>(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<void**>(&sc1))) &&
sc1 != nullptr)
{
*present1_out = vtable_method(sc1, kIdx_IDXGISwapChain1_Present1);
sc1->Release();
}
}
else
{
logf("present: D3D11CreateDeviceAndSwapChain(probe) failed hr=0x%08lX", static_cast<unsigned long>(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<void*>(&hk_Present));
if (present1 != nullptr)
{
g_hk_present1 = safetyhook::create_inline(present1, reinterpret_cast<void*>(&hk_Present1));
}
g_unsupported_logged = false;
hook_set_installed(g_id_present, static_cast<bool>(g_hk_present));
hook_set_installed(g_id_present1, static_cast<bool>(g_hk_present1));
logf("install_present_hooks: present=%p hooked=%d present1=%p hooked=%d", present,
static_cast<bool>(g_hk_present) ? 1 : 0, present1, static_cast<bool>(g_hk_present1) ? 1 : 0);
return static_cast<bool>(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

35
hook/src/present_hook.hpp Normal file
View File

@@ -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_<pid>), 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

View File

@@ -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)

View File

@@ -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<DXGI_FORMAT>(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

View File

@@ -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_<pid>) 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 <cstdint>
#include <string>
#include <d3d11_1.h>
#include <wrl/client.h>
#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<ID3D11Device1> device_;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> ctx_;
Microsoft::WRL::ComPtr<ID3D11Texture2D> shared_; // opened from the hook, by name
Microsoft::WRL::ComPtr<IDXGIKeyedMutex> mutex_;
Microsoft::WRL::ComPtr<ID3D11Texture2D> private_; // our sampled copy
Microsoft::WRL::ComPtr<ID3D11ShaderResourceView> 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

View File

@@ -3,13 +3,21 @@
#include <cstdio>
#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<unsigned long long>(shared_.frames_copied()));
}
else if (share.present_calls > 0)
{
ImGui::TextColored(kGreen, "Present hooked (%llu calls); opening shared texture...",
static_cast<unsigned long long>(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);
}

View File

@@ -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 <cstdint>
@@ -8,23 +10,33 @@
#include <windows.h>
#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

View File

@@ -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<PadInfo, kMaxPads>& 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)

View File

@@ -49,6 +49,32 @@ public:
server_.drain_logs(std::forward<F>(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;

View File

@@ -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)

View File

@@ -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);

View File

@@ -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.

View File

@@ -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)

227
tests/present_hook_test.cpp Normal file
View File

@@ -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 <cstdint>
#include <cstdio>
#include <vector>
#include <windows.h>
#include <d3d11_1.h>
#include <dxgi1_2.h>
#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 <typename T>
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<int>(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<SharedBlock>(); // 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<unsigned long>(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<void**>(&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<unsigned long long>(hook::present_calls()),
static_cast<unsigned long long>(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<const std::uint8_t*>(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;
}

View File

@@ -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<unsigned long long>(produced),
static_cast<unsigned long long>(overruns));
static_cast<unsigned long long>(overruns), static_cast<unsigned long long>(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)
{