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 | | 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 | | 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 | 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 | | 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 | | 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). Replaces tailing `%TEMP%\coop_hook.log` (which stays as an opt-in file mirror).
`coop_audio_probe` drains and prints the same stream headless. `coop_audio_probe` drains and prints the same stream headless.
- **Present-hook video path. ✅** The injected DLL hooks `IDXGISwapChain::Present`
(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: 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 - **Steam Input:** consume guest input through the Steam Input API directly
rather than XInput. rather than XInput.
- **x86 support:** add an x86 build of `coop_hook.dll` plus an x86 injector - **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 COM vtables were discovered, the frames reached the ring (non-silent), the
primary stream was silenced, and exactly one render stream was counted. Skips primary stream was silenced, and exactly one render stream was counted. Skips
cleanly if the machine has no audio endpoint. cleanly if the machine has no audio endpoint.
- **`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 - **`audio_loopback_test`** — spawns `coop_tone.exe` (a standalone WASAPI
sine-wave source under [`tools/audio_tone`](tools/audio_tone)) and verifies the sine-wave source under [`tools/audio_tone`](tools/audio_tone)) and verifies the
shipping process-loopback capture (the fallback path) receives its audio by 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**. **Attached**, a non-zero **XInput polled: N/s**, and **Focus spoof: active**.
3. **Mirror video:** in the **Video mirror** panel, tick **Mirror game window** — 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**. 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 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 // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook
// refuses to attach to a host with a mismatched version. // 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. // 'COOP' little-endian, used to sanity-check the mapping before trusting it.
inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; 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_Input = 0, // XInput hooks (forward the guest pad)
HookSubsys_Focus = 1, // focus spoof (keep the game running unfocused) HookSubsys_Focus = 1, // focus spoof (keep the game running unfocused)
HookSubsys_Audio = 2, // WASAPI render-hook (audio mirror without echo) 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). // 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]; 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 // 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 // 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. // 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). // Host -> hook control (which subsystems to install).
HookControl control; HookControl control;
// Phase 2 appends the shared-texture handle/dimensions control fields here; // Hook -> host Present-hook video channel (shared-texture dimensions/format).
// keep new members at the end so existing offsets never shift. VideoShare video;
}; };
static_assert(std::atomic<std::uint32_t>::is_always_lock_free, 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); 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 } // namespace coop

View File

@@ -3,6 +3,7 @@ add_library(coop_hook SHARED
src/xinput_hook.cpp src/xinput_hook.cpp
src/focus_spoof.cpp src/focus_spoof.cpp
src/audio_hook.cpp src/audio_hook.cpp
src/present_hook.cpp
src/debug_log.cpp src/debug_log.cpp
src/hook_registry.cpp) src/hook_registry.cpp)
@@ -17,7 +18,9 @@ target_link_libraries(coop_hook PRIVATE
safetyhook::safetyhook safetyhook::safetyhook
user32 user32
ole32 ole32
mmdevapi) mmdevapi
d3d11
dxgi)
set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook") set_target_properties(coop_hook PROPERTIES OUTPUT_NAME "coop_hook")

View File

@@ -20,6 +20,7 @@
#include "focus_spoof.hpp" #include "focus_spoof.hpp"
#include "hook_registry.hpp" #include "hook_registry.hpp"
#include "ipc_client.hpp" #include "ipc_client.hpp"
#include "present_hook.hpp"
#include "xinput_hook.hpp" #include "xinput_hook.hpp"
namespace namespace
@@ -68,6 +69,7 @@ DWORD WINAPI worker_thread(LPVOID)
bool focus_installed = false; bool focus_installed = false;
bool audio_installed = false; bool audio_installed = false;
bool audio_ring_open = false; bool audio_ring_open = false;
bool video_installed = false;
// Each tick, reconcile each subsystem with the host's requested state: install // 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 // 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)"); 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) if (audio_installed && !audio_ring_open)
{ {
const std::wstring name = coop::audio_ring_name(GetCurrentProcessId()); 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_focus_spoof();
coop::hook::remove_xinput_hooks(); coop::hook::remove_xinput_hooks();
coop::hook::remove_audio_hooks(); coop::hook::remove_audio_hooks();
coop::hook::remove_present_hooks();
coop::hook::hook_registry_reset(); coop::hook::hook_registry_reset();
} }
break; 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 ----------------------------------------------------- // --- Hook registry -----------------------------------------------------
// Publish the installed-hooks table (name / subsystem / installed / calls). // 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/ipc/ipc_server.cpp
src/capture/frame_renderer.cpp src/capture/frame_renderer.cpp
src/capture/window_capture.cpp src/capture/window_capture.cpp
src/capture/shared_texture.cpp
src/audio/audio_loopback.cpp src/audio/audio_loopback.cpp
src/audio/process_loopback_capture.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 <cstdio>
#include "imgui.h" #include "imgui.h"
#include "injection_panel.hpp"
namespace coop 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) bool CapturePanel::init(ID3D11Device* device)
{ {
device_ = device; device_ = device;
shared_.init(device); // best effort; the Hooked source is unavailable if it fails
return renderer_.init(device); return renderer_.init(device);
} }
@@ -19,35 +27,89 @@ void CapturePanel::draw_ui(const FrameStats& stats)
ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver); ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver);
ImGui::Begin("Video mirror"); ImGui::Begin("Video mirror");
const bool have_target = target_ != nullptr && IsWindow(target_); const unsigned long hook_pid = injection_ != nullptr ? injection_->target_pid() : 0;
ImGui::BeginDisabled(!have_target); const bool have_wgc_target = target_ != nullptr && IsWindow(target_);
if (ImGui::Checkbox("Mirror game window", &enabled_)) const bool have_hook = hook_pid != 0;
{ const bool have_source = source_ == Source_Hooked ? have_hook : have_wgc_target;
if (!enabled_)
ImGui::BeginDisabled(!have_source);
if (ImGui::Checkbox("Mirror game window", &enabled_) && !enabled_)
{ {
capture_.stop(); capture_.stop();
shared_.reset();
if (injection_ != nullptr)
{
injection_->request_video(false); // stop the in-game Present hook
} }
} }
ImGui::EndDisabled(); ImGui::EndDisabled();
if (!have_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)
{ {
ImGui::TextDisabled("Inject into a game first (its window is the source)."); capture_.stop();
shared_.reset();
if (injection_ != nullptr)
{
injection_->request_video(enabled_ && source_ == Source_Hooked);
}
} }
// Start/restart capture when enabled and the target window changes. if (!have_source)
if (enabled_ && have_target && capture_.target() != target_) {
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_)) if (!capture_.start(target_, device_))
{ {
enabled_ = false; enabled_ = false;
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.4f, 1.0f), "Failed to start capture."); ImGui::TextColored(kRed, "Failed to start capture.");
} }
} }
if (capture_.running()) if (capture_.running())
{ {
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Capturing %ux%u", capture_.frame_width(), ImGui::TextColored(kGreen, "Capturing %ux%u (WGC)", capture_.frame_width(), capture_.frame_height());
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); 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) 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); 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. // Owns the video-mirror pipeline and its UI. Two interchangeable sources feed the
// The target window comes from the injected hook's reported game HWND. // 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 #pragma once
#include <cstdint> #include <cstdint>
@@ -8,23 +10,33 @@
#include <windows.h> #include <windows.h>
#include "capture/frame_renderer.hpp" #include "capture/frame_renderer.hpp"
#include "capture/shared_texture.hpp"
#include "capture/window_capture.hpp" #include "capture/window_capture.hpp"
#include "ui/app_chrome.hpp" #include "ui/app_chrome.hpp"
namespace coop namespace coop
{ {
class InjectionPanel;
class CapturePanel class CapturePanel
{ {
public: public:
bool init(ID3D11Device* device); 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) void set_target(HWND target)
{ {
target_ = 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 // `stats` are the host's render frame-timing, drawn as the mirror's
// frametime / FPS graphs (this window is what the mirror renders into). // frametime / FPS graphs (this window is what the mirror renders into).
void draw_ui(const FrameStats& stats); 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); void render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h);
private: 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); void draw_perf_graphs(const FrameStats& stats);
ID3D11Device* device_ = nullptr; ID3D11Device* device_ = nullptr;
FrameRenderer renderer_; FrameRenderer renderer_;
WindowCapture capture_; WindowCapture capture_;
SharedTextureSource shared_;
InjectionPanel* injection_ = nullptr;
HWND target_ = nullptr; HWND target_ = nullptr;
bool enabled_ = false; bool enabled_ = false;
int source_ = Source_Wgc;
}; };
} // namespace coop } // 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_Input, want_input_);
server_.set_subsystem_enabled(HookSubsys_Focus, want_focus_); server_.set_subsystem_enabled(HookSubsys_Focus, want_focus_);
server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_); 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()); const InjectResult result = inject_dll(selected_pid_, hook_dll_path());
if (result.status == InjectStatus::Ok) 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) 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; const std::uint32_t n = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
if (n == 0) if (n == 0)
@@ -181,7 +182,7 @@ void InjectionPanel::draw_hook_list(const HookStatusView& status)
{ {
ImGui::TableNextRow(); ImGui::TableNextRow();
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::TextDisabled("%s", kSubsysName[sub < 3 ? sub : 0]); ImGui::TextDisabled("%s", kSubsysName[sub < HookSubsys_Count ? sub : 0]);
ImGui::TableNextColumn(); ImGui::TableNextColumn();
ImGui::TableNextColumn(); ImGui::TableNextColumn();
header_done = true; 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"}, {"Input forwarding (XInput)", HookSubsys_Input, &want_input_, "controller input reaches the game"},
{"Focus spoof", HookSubsys_Focus, &want_focus_, "the game keeps running unfocused"}, {"Focus spoof", HookSubsys_Focus, &want_focus_, "the game keeps running unfocused"},
{"Audio render-hook", HookSubsys_Audio, &want_audio_, "audio mirror without echo"}, {"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) for (const Row& r : rows)

View File

@@ -49,6 +49,32 @@ public:
server_.drain_logs(std::forward<F>(emit)); 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: private:
void refresh_processes(); void refresh_processes();
void inject_selected(); void inject_selected();
@@ -72,6 +98,7 @@ private:
bool want_input_ = true; bool want_input_ = true;
bool want_focus_ = true; bool want_focus_ = true;
bool want_audio_ = true; bool want_audio_ = true;
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 bool injected_ = false; // a hook DLL is loaded in the target
// Heartbeat liveness tracking (is the injected DLL responding?). // Heartbeat liveness tracking (is the injected DLL responding?).

View File

@@ -88,6 +88,22 @@ HookStatusView IpcServer::hook_status() const
return view; 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) void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled)
{ {
if (block_ != nullptr && subsystem < HookSubsys_Count) if (block_ != nullptr && subsystem < HookSubsys_Count)

View File

@@ -38,6 +38,16 @@ struct HookStatusView
HookEntry hook_entries[kMaxHookEntries] = {}; 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 class IpcServer
{ {
public: public:
@@ -53,6 +63,9 @@ public:
// Reads the hook's diagnostics back-channel (zeroed if not started). // Reads the hook's diagnostics back-channel (zeroed if not started).
[[nodiscard]] HookStatusView hook_status() const; [[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 // Request a hook subsystem be installed (true) or removed (false). The hook
// reconciles on its next tick. No-op if not started. // reconciles on its next tick. No-op if not started.
void set_subsystem_enabled(std::uint32_t subsystem, bool enabled); 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); MessageBoxW(nullptr, L"Failed to initialize the video mirror.", L"CoopAllTheThings", MB_ICONERROR);
return 1; 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 // The overlay can be hidden (F1) so the window is a clean mirror for Remote
// Play Together; the pipelines keep running underneath either way. // Play Together; the pipelines keep running underneath either way.

View File

@@ -58,3 +58,24 @@ target_link_libraries(audio_hook_test PRIVATE
mmdevapi) mmdevapi)
add_test(NAME audio_hook_test COMMAND audio_hook_test) 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 produced = ring->frames_produced.load(std::memory_order_relaxed);
const std::uint64_t overruns = ring->overruns.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 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 " 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, (t + 1) * 0.5, heartbeat, streams, peak, fmt_ready ? 1 : 0, ring->sample_rate,
ring->channels, ring->bits, static_cast<unsigned long long>(produced), 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) for (std::uint32_t i = 0; i < coop::kMaxAudioStreams && i < streams; ++i)
{ {
const coop::AudioStreamInfo& s = status.audio_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. // 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); 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) for (std::uint32_t i = 0; i < status.hook_entry_count && i < coop::kMaxHookEntries; ++i)
{ {