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:
27
README.md
27
README.md
@@ -23,6 +23,7 @@ XInput game becomes Remote-Play-Together-able.
|
|||||||
| Forward input to game | DLL injection + XInput hook (SafetyHook) — game sees *only* our pad | `coop_hook.dll` | done |
|
| 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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
384
hook/src/present_hook.cpp
Normal 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
35
hook/src/present_hook.hpp
Normal 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
|
||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
128
host/src/capture/shared_texture.cpp
Normal file
128
host/src/capture/shared_texture.cpp
Normal 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
|
||||||
69
host/src/capture/shared_texture.hpp
Normal file
69
host/src/capture/shared_texture.hpp
Normal 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
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
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();
|
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.
|
// Source selector. Switching tears down the other source and (un)installs the
|
||||||
if (enabled_ && have_target && capture_.target() != target_)
|
// 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;
|
injection_->request_video(enabled_ && source_ == Source_Hooked);
|
||||||
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.4f, 1.0f), "Failed to start capture.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (capture_.running())
|
if (!have_source)
|
||||||
{
|
{
|
||||||
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Capturing %ux%u", capture_.frame_width(),
|
ImGui::TextDisabled(source_ == Source_Hooked
|
||||||
capture_.frame_height());
|
? "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);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,7 +98,8 @@ 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 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?).
|
// Heartbeat liveness tracking (is the injected DLL responding?).
|
||||||
std::uint32_t last_heartbeat_ = 0;
|
std::uint32_t last_heartbeat_ = 0;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
227
tests/present_hook_test.cpp
Normal 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;
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user