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

@@ -0,0 +1,128 @@
#include "capture/shared_texture.hpp"
#include "coop/protocol.hpp"
#include "coop/shared_memory.hpp"
namespace coop
{
bool SharedTextureSource::init(ID3D11Device* device)
{
if (device == nullptr || FAILED(device->QueryInterface(IID_PPV_ARGS(&device_))))
{
return false;
}
device_->GetImmediateContext(&ctx_);
return ctx_ != nullptr;
}
void SharedTextureSource::reset()
{
srv_.Reset();
private_.Reset();
mutex_.Reset();
shared_.Reset();
pid_ = 0;
width_ = height_ = format_ = 0;
last_generation_ = 0;
frames_copied_ = 0;
}
bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share)
{
srv_.Reset();
private_.Reset();
mutex_.Reset();
shared_.Reset();
width_ = height_ = format_ = 0;
pid_ = pid;
if (pid == 0 || share.width == 0 || share.height == 0)
{
return false; // the hook hasn't shared a backbuffer yet
}
const std::wstring name = video_share_name(pid);
if (FAILED(device_->OpenSharedResourceByName(name.c_str(),
DXGI_SHARED_RESOURCE_READ | DXGI_SHARED_RESOURCE_WRITE,
IID_PPV_ARGS(&shared_))) ||
shared_ == nullptr)
{
return false;
}
if (FAILED(shared_.As(&mutex_)) || mutex_ == nullptr)
{
shared_.Reset();
return false;
}
// Private copy we sample from, so we only hold the keyed mutex during the copy.
D3D11_TEXTURE2D_DESC desc{};
desc.Width = share.width;
desc.Height = share.height;
desc.MipLevels = 1;
desc.ArraySize = 1;
desc.Format = static_cast<DXGI_FORMAT>(share.format);
desc.SampleDesc.Count = 1;
desc.Usage = D3D11_USAGE_DEFAULT;
desc.BindFlags = D3D11_BIND_SHADER_RESOURCE;
if (FAILED(device_->CreateTexture2D(&desc, nullptr, &private_)) || private_ == nullptr)
{
mutex_.Reset();
shared_.Reset();
return false;
}
if (FAILED(device_->CreateShaderResourceView(private_.Get(), nullptr, &srv_)))
{
srv_.Reset();
private_.Reset();
mutex_.Reset();
shared_.Reset();
return false;
}
width_ = share.width;
height_ = share.height;
format_ = share.format;
return true;
}
bool SharedTextureSource::update(const VideoShareView& share, unsigned long pid)
{
if (device_ == nullptr || pid == 0)
{
reset();
return false;
}
// (Re)open whenever the target or the published backbuffer geometry changes.
if (pid != pid_ || share.width != width_ || share.height != height_ || share.format != format_)
{
if (!reopen(pid, share))
{
return srv_ != nullptr; // couldn't open yet; keep any prior frame
}
last_generation_ = 0; // force a copy of the current frame
}
if (shared_ == nullptr || mutex_ == nullptr)
{
return srv_ != nullptr;
}
if (share.generation == last_generation_)
{
return srv_ != nullptr; // no new frame; keep showing the last copy
}
// Bounded wait so a stalled producer can't hang the host's render thread.
if (mutex_->AcquireSync(kVideoMutexKey, 8) == S_OK)
{
ctx_->CopyResource(private_.Get(), shared_.Get());
mutex_->ReleaseSync(kVideoMutexKey);
last_generation_ = share.generation;
++frames_copied_;
}
return srv_ != nullptr;
}
} // namespace coop

View File

@@ -0,0 +1,69 @@
// Host-side consumer of the injected Present-hook's shared backbuffer texture.
// Opens the keyed-mutex texture the hook publishes (coop_video_<pid>) by name,
// copies the latest frame into a private texture under the keyed mutex, and
// exposes an SRV the FrameRenderer can draw -- the lower-latency alternative to
// Windows Graphics Capture. See hook/src/present_hook.cpp and coop/protocol.hpp.
#pragma once
#include <cstdint>
#include <string>
#include <d3d11_1.h>
#include <wrl/client.h>
#include "ipc/ipc_server.hpp"
namespace coop
{
class SharedTextureSource
{
public:
// Binds to the host's device (must support ID3D11Device1). Returns false if not.
bool init(ID3D11Device* device);
// Polls the hook's video channel for `pid`. (Re)opens the shared texture when
// the pid/dimensions/format change, and copies the newest frame into the
// private texture under the keyed mutex. Returns true if a frame is available
// to draw (sticky: keeps the last copy until a new one or a reset).
bool update(const VideoShareView& share, unsigned long pid);
// Drop the opened resources (target changed / mirror turned off).
void reset();
[[nodiscard]] ID3D11ShaderResourceView* srv() const
{
return srv_.Get();
}
[[nodiscard]] std::uint32_t width() const
{
return width_;
}
[[nodiscard]] std::uint32_t height() const
{
return height_;
}
[[nodiscard]] std::uint64_t frames_copied() const
{
return frames_copied_;
}
private:
bool reopen(unsigned long pid, const VideoShareView& share);
Microsoft::WRL::ComPtr<ID3D11Device1> device_;
Microsoft::WRL::ComPtr<ID3D11DeviceContext> ctx_;
Microsoft::WRL::ComPtr<ID3D11Texture2D> shared_; // opened from the hook, by name
Microsoft::WRL::ComPtr<IDXGIKeyedMutex> mutex_;
Microsoft::WRL::ComPtr<ID3D11Texture2D> private_; // our sampled copy
Microsoft::WRL::ComPtr<ID3D11ShaderResourceView> srv_;
unsigned long pid_ = 0;
std::uint32_t width_ = 0;
std::uint32_t height_ = 0;
std::uint32_t format_ = 0;
std::uint32_t last_generation_ = 0;
std::uint64_t frames_copied_ = 0;
};
} // namespace coop

View File

@@ -3,13 +3,21 @@
#include <cstdio>
#include "imgui.h"
#include "injection_panel.hpp"
namespace coop
{
namespace
{
const ImVec4 kGreen(0.4f, 1.0f, 0.4f, 1.0f);
const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f);
} // namespace
bool CapturePanel::init(ID3D11Device* device)
{
device_ = device;
shared_.init(device); // best effort; the Hooked source is unavailable if it fails
return renderer_.init(device);
}
@@ -19,35 +27,89 @@ void CapturePanel::draw_ui(const FrameStats& stats)
ImGui::SetNextWindowSize(ImVec2(360, 0), ImGuiCond_FirstUseEver);
ImGui::Begin("Video mirror");
const bool have_target = target_ != nullptr && IsWindow(target_);
ImGui::BeginDisabled(!have_target);
if (ImGui::Checkbox("Mirror game window", &enabled_))
const unsigned long hook_pid = injection_ != nullptr ? injection_->target_pid() : 0;
const bool have_wgc_target = target_ != nullptr && IsWindow(target_);
const bool have_hook = hook_pid != 0;
const bool have_source = source_ == Source_Hooked ? have_hook : have_wgc_target;
ImGui::BeginDisabled(!have_source);
if (ImGui::Checkbox("Mirror game window", &enabled_) && !enabled_)
{
if (!enabled_)
capture_.stop();
shared_.reset();
if (injection_ != nullptr)
{
capture_.stop();
injection_->request_video(false); // stop the in-game Present hook
}
}
ImGui::EndDisabled();
if (!have_target)
{
ImGui::TextDisabled("Inject into a game first (its window is the source).");
}
// Start/restart capture when enabled and the target window changes.
if (enabled_ && have_target && capture_.target() != target_)
// Source selector. Switching tears down the other source and (un)installs the
// Present hook so the game only pays for the path actually in use.
ImGui::TextUnformatted("Source:");
ImGui::SameLine();
int prev_source = source_;
ImGui::RadioButton("WGC", &source_, Source_Wgc);
ImGui::SameLine();
ImGui::RadioButton("Hooked (Present)", &source_, Source_Hooked);
if (source_ != prev_source)
{
if (!capture_.start(target_, device_))
capture_.stop();
shared_.reset();
if (injection_ != nullptr)
{
enabled_ = false;
ImGui::TextColored(ImVec4(1.0f, 0.45f, 0.4f, 1.0f), "Failed to start capture.");
injection_->request_video(enabled_ && source_ == Source_Hooked);
}
}
if (capture_.running())
if (!have_source)
{
ImGui::TextColored(ImVec4(0.4f, 1.0f, 0.4f, 1.0f), "Capturing %ux%u", capture_.frame_width(),
capture_.frame_height());
ImGui::TextDisabled(source_ == Source_Hooked
? "Inject into a game first (the Present hook is the source)."
: "Inject into a game first (its window is the source).");
}
if (source_ == Source_Wgc)
{
// Start/restart WGC capture when enabled and the target window changes.
if (enabled_ && have_wgc_target && capture_.target() != target_)
{
if (!capture_.start(target_, device_))
{
enabled_ = false;
ImGui::TextColored(kRed, "Failed to start capture.");
}
}
if (capture_.running())
{
ImGui::TextColored(kGreen, "Capturing %ux%u (WGC)", capture_.frame_width(), capture_.frame_height());
}
}
else // Source_Hooked
{
if (enabled_ && injection_ != nullptr)
{
// Keep the subsystem requested (a fresh inject may have reset control).
if (!injection_->video_requested())
{
injection_->request_video(true);
}
const VideoShareView share = injection_->video_share();
if (shared_.frames_copied() > 0 && shared_.width() > 0)
{
ImGui::TextColored(kGreen, "Mirroring %ux%u (hooked, %llu frames)", shared_.width(),
shared_.height(), static_cast<unsigned long long>(shared_.frames_copied()));
}
else if (share.present_calls > 0)
{
ImGui::TextColored(kGreen, "Present hooked (%llu calls); opening shared texture...",
static_cast<unsigned long long>(share.present_calls));
}
else
{
ImGui::TextDisabled("Waiting for hooked frames (the game may not render via DXGI).");
}
}
}
draw_perf_graphs(stats);
@@ -95,7 +157,22 @@ void CapturePanel::draw_perf_graphs(const FrameStats& stats)
void CapturePanel::render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h)
{
if (capture_.running())
if (!enabled_)
{
return;
}
if (source_ == Source_Hooked)
{
if (injection_ == nullptr)
{
return;
}
if (shared_.update(injection_->video_share(), injection_->target_pid()) && shared_.srv() != nullptr)
{
renderer_.draw(ctx, shared_.srv(), shared_.width(), shared_.height(), dst_w, dst_h);
}
}
else if (capture_.running())
{
capture_.draw_latest(renderer_, ctx, dst_w, dst_h);
}

View File

@@ -1,5 +1,7 @@
// Owns the video-mirror pipeline (WGC capture + frame renderer) and its UI.
// The target window comes from the injected hook's reported game HWND.
// Owns the video-mirror pipeline and its UI. Two interchangeable sources feed the
// same letterboxed renderer: Windows Graphics Capture (no injection, the default)
// and the injected Present-hook's shared backbuffer texture (lower latency, needs
// the video subsystem hooked). The target window/pid come from the injected hook.
#pragma once
#include <cstdint>
@@ -8,23 +10,33 @@
#include <windows.h>
#include "capture/frame_renderer.hpp"
#include "capture/shared_texture.hpp"
#include "capture/window_capture.hpp"
#include "ui/app_chrome.hpp"
namespace coop
{
class InjectionPanel;
class CapturePanel
{
public:
bool init(ID3D11Device* device);
// The window to mirror (0 if none yet); typically the injected game's HWND.
// The window to mirror via WGC (0 if none yet); typically the injected game's HWND.
void set_target(HWND target)
{
target_ = target;
}
// The injection panel supplies the target pid + Present-hook video channel and
// lets this panel install/remove the video subsystem when the source is Hooked.
void set_injection(InjectionPanel* injection)
{
injection_ = injection;
}
// `stats` are the host's render frame-timing, drawn as the mirror's
// frametime / FPS graphs (this window is what the mirror renders into).
void draw_ui(const FrameStats& stats);
@@ -33,13 +45,22 @@ public:
void render(ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h);
private:
enum Source : int
{
Source_Wgc = 0, // Windows Graphics Capture
Source_Hooked = 1, // injected Present-hook shared texture
};
void draw_perf_graphs(const FrameStats& stats);
ID3D11Device* device_ = nullptr;
FrameRenderer renderer_;
WindowCapture capture_;
SharedTextureSource shared_;
InjectionPanel* injection_ = nullptr;
HWND target_ = nullptr;
bool enabled_ = false;
int source_ = Source_Wgc;
};
} // namespace coop

View File

@@ -96,6 +96,7 @@ void InjectionPanel::inject_selected()
server_.set_subsystem_enabled(HookSubsys_Input, want_input_);
server_.set_subsystem_enabled(HookSubsys_Focus, want_focus_);
server_.set_subsystem_enabled(HookSubsys_Audio, want_audio_);
server_.set_subsystem_enabled(HookSubsys_Video, want_video_);
const InjectResult result = inject_dll(selected_pid_, hook_dll_path());
if (result.status == InjectStatus::Ok)
@@ -147,7 +148,7 @@ void InjectionPanel::publish(const std::array<PadInfo, kMaxPads>& pads)
void InjectionPanel::draw_hook_list(const HookStatusView& status)
{
static const char* kSubsysName[] = {"Input", "Focus", "Audio"};
static const char* kSubsysName[] = {"Input", "Focus", "Audio", "Video"};
const std::uint32_t n = status.hook_entry_count < kMaxHookEntries ? status.hook_entry_count : kMaxHookEntries;
if (n == 0)
@@ -181,7 +182,7 @@ void InjectionPanel::draw_hook_list(const HookStatusView& status)
{
ImGui::TableNextRow();
ImGui::TableNextColumn();
ImGui::TextDisabled("%s", kSubsysName[sub < 3 ? sub : 0]);
ImGui::TextDisabled("%s", kSubsysName[sub < HookSubsys_Count ? sub : 0]);
ImGui::TableNextColumn();
ImGui::TableNextColumn();
header_done = true;
@@ -235,6 +236,7 @@ void InjectionPanel::draw_subsystem_controls(const HookStatusView& status)
{"Input forwarding (XInput)", HookSubsys_Input, &want_input_, "controller input reaches the game"},
{"Focus spoof", HookSubsys_Focus, &want_focus_, "the game keeps running unfocused"},
{"Audio render-hook", HookSubsys_Audio, &want_audio_, "audio mirror without echo"},
{"Video Present-hook", HookSubsys_Video, &want_video_, "Video mirror can use the hooked source"},
};
for (const Row& r : rows)

View File

@@ -49,6 +49,32 @@ public:
server_.drain_logs(std::forward<F>(emit));
}
// --- Present-hook video path (consumed by the Video mirror panel) ----------
[[nodiscard]] unsigned long target_pid() const
{
return server_.target_pid();
}
// The hook's Present-hook video channel snapshot (shared-texture descriptor).
[[nodiscard]] VideoShareView video_share() const
{
return server_.video_share();
}
// Request the Present-hook video subsystem be installed/removed. Keeps the
// Injection panel's own checkbox in sync, so the Video panel can drive it.
void request_video(bool on)
{
want_video_ = on;
server_.set_subsystem_enabled(HookSubsys_Video, on);
}
[[nodiscard]] bool video_requested() const
{
return want_video_;
}
private:
void refresh_processes();
void inject_selected();
@@ -72,7 +98,8 @@ private:
bool want_input_ = true;
bool want_focus_ = true;
bool want_audio_ = true;
bool injected_ = false; // a hook DLL is loaded in the target
bool want_video_ = false; // Present-hook video path: opt-in (WGC is the default)
bool injected_ = false; // a hook DLL is loaded in the target
// Heartbeat liveness tracking (is the injected DLL responding?).
std::uint32_t last_heartbeat_ = 0;

View File

@@ -88,6 +88,22 @@ HookStatusView IpcServer::hook_status() const
return view;
}
VideoShareView IpcServer::video_share() const
{
VideoShareView v;
if (block_ == nullptr)
{
return v;
}
const VideoShare& s = block_->video;
v.generation = s.generation.load(std::memory_order_acquire);
v.width = s.width;
v.height = s.height;
v.format = s.format;
v.present_calls = s.present_calls;
return v;
}
void IpcServer::set_subsystem_enabled(std::uint32_t subsystem, bool enabled)
{
if (block_ != nullptr && subsystem < HookSubsys_Count)

View File

@@ -38,6 +38,16 @@ struct HookStatusView
HookEntry hook_entries[kMaxHookEntries] = {};
};
// Plain snapshot of the Present-hook video channel for the Video mirror panel.
struct VideoShareView
{
std::uint32_t generation = 0; // bumps per shared frame; 0 = nothing shared yet
std::uint32_t width = 0; // shared texture dimensions / DXGI format
std::uint32_t height = 0;
std::uint32_t format = 0;
std::uint64_t present_calls = 0; // cumulative Present() detours (diagnostic)
};
class IpcServer
{
public:
@@ -53,6 +63,9 @@ public:
// Reads the hook's diagnostics back-channel (zeroed if not started).
[[nodiscard]] HookStatusView hook_status() const;
// Reads the Present-hook video channel (zeroed if not started).
[[nodiscard]] VideoShareView video_share() const;
// Request a hook subsystem be installed (true) or removed (false). The hook
// reconciles on its next tick. No-op if not started.
void set_subsystem_enabled(std::uint32_t subsystem, bool enabled);

View File

@@ -75,6 +75,7 @@ int run()
MessageBoxW(nullptr, L"Failed to initialize the video mirror.", L"CoopAllTheThings", MB_ICONERROR);
return 1;
}
capture.set_injection(&injection); // for the Present-hook (Hooked) video source
// The overlay can be hidden (F1) so the window is a clean mirror for Remote
// Play Together; the pipelines keep running underneath either way.