From ffaad6c4ae48b0d598b10e2dc5382a70b928d477 Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sun, 21 Jun 2026 19:55:27 +0200 Subject: [PATCH] DX12 capture: fence the copy off the game's queue + add drop detection Resolves the DX12 mirror stutter and makes dropped frames observable. Decouple the D3D11On12 copy from the game's present queue. Submitting the copy on the game's own present queue (the prior approach) ordered it correctly but stalled the game's presents: GPU back-pressure, plus the shared keyed-mutex AcquireSync is a CPU-blocking call on the render thread. Running it on an independent queue avoids the stall but races the game's render -> stale frames. Do both: run the copy on our own queue and order it after the frame with an ID3D12Fence the game's present queue signals (near-free) and our queue waits on. The present queue is still recovered for late injection via the ExecuteCommandLists hook (now used to signal the fence, not host the copy). Producer AcquireSync stays non-blocking (timeout 0) so a busy mutex drops a mirror frame instead of stalling the game. Add drop detection (protocol v12 -> v13). The hook counts captures skipped because the keyed mutex was busy (VideoShare.frames_dropped); the host counts published frames it never displayed (generation gaps). The Video panel shows "Frames lost: N/s capture N/s display", red when nonzero. This confirmed the game-window-vs-mirror behavior is a display-path artifact (unfocused windows lose VRR/independent flip), not a capture loss. Add a one-shot present-pattern log: per distinct swapchain (size/format/ buffer index) and per distinct present-flags value, with DXGI_PRESENT_TEST spelled out as an occlusion probe that draws nothing -- which is why Miles Morales shows ~2 presents per captured frame (the test present is counted but produces no frame). Docs: add the DX12 capture lessons to the README (rotating back buffer, fence/own-queue, capture-at-Present decoupling from DWM) and drop the now -moot DX12 overhead future-work item. Co-Authored-By: Claude Opus 4.8 --- README.md | 34 ++++- common/include/coop/protocol.hpp | 5 +- hook/src/ipc_client.hpp | 10 ++ hook/src/present_hook.cpp | 209 ++++++++++++++++++++++------ host/src/capture/shared_texture.cpp | 8 ++ host/src/capture/shared_texture.hpp | 7 + host/src/capture_panel.cpp | 9 ++ host/src/capture_panel.hpp | 2 + host/src/ipc/ipc_server.cpp | 1 + host/src/ipc/ipc_server.hpp | 5 +- 10 files changed, 241 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 8b1fe39..51d0500 100644 --- a/README.md +++ b/README.md @@ -93,9 +93,6 @@ for what's left. `GetRawInputData`, e.g. Trails through Daybreak) or **DirectInput** (`IDirectInputDevice8::GetDeviceState/GetDeviceData`) don't see it. Add hooks for those paths to synthesize the forwarded input there too. -- **DX12 hooked-capture overhead.** The D3D11On12 bridge mirrors D3D12 games but is - noticeably heavier than the native D3D11 path (see Lessons learned); reducing its - per-frame cost (cache wrapped resources, lighter sync) is a follow-up. ## Building @@ -280,6 +277,37 @@ Non-obvious things that cost time and constrain the design: slot catches every instance; but `IAudioClient::GetService` is **14**, not 13 (`SetEventHandle` sits at 13 between `Reset` and `GetService`). Count every inherited `IUnknown`/base method when adding a hook. +- **D3D12 capture copies the *rotating* back buffer, not `GetBuffer(0)`.** D3D11 + flip-model keeps `GetBuffer(0)` pointing at the live back buffer, but D3D12 rotates + buffers explicitly — the game renders into the buffer at + `IDXGISwapChain3::GetCurrentBackBufferIndex()`, which advances each `Present`. + Grabbing buffer 0 copies a stale buffer on N-1 of every N frames, so the mirror + silently runs at refresh/N — yet the Present counter, published FPS, generation, and + latency all read full rate (they count Presents, not unique content), so the metrics + look perfect while the eye sees missing frames. Query the current index right before + the trampoline `Present` (that's the just-rendered buffer) and copy that one. +- **Keep the D3D12 capture copy off the game's present queue, but ordered after its + frame.** The D3D11 path copies on the game's immediate context, so it's naturally + ordered after the frame and on the game's own timeline. For D3D12 the D3D11On12 + bridge needs a queue: running the copy on the *game's* present queue orders it + correctly but stalls the game's own presents (GPU back-pressure, plus the shared + keyed-mutex `AcquireSync` is a **CPU-blocking** call on the render thread). Running + it on an independent queue avoids the stall but races the game's render → stale + frames. The fix is both: run the copy on **our own** queue, and order it with a + **fence** the game's present queue signals after its frame (a near-free op) and our + queue waits on. The present queue is recovered for late injection by hooking + `ID3D12CommandQueue::ExecuteCommandLists` (the per-frame method, not creation). Make + the producer-side `AcquireSync` non-blocking (`timeout 0`) so a busy mutex drops a + *mirror* frame instead of stalling the game; the Video panel's "Frames lost" line + surfaces both capture- and display-stage drops. +- **Capturing at `Present` decouples the mirror from DWM composition.** The hook copies + the backbuffer inside the game's `Present`, which the game issues at its true render + rate regardless of how DWM composites that *window*. So an unfocused game window can + judder (DWM under-composites background windows; only the focused window gets VRR / + independent flip) while the mirror — which receives every `Present` — stays smooth. + This is why the game window not being focused doesn't matter: it isn't the surface + anyone sees. The same focus rule explains why an unfocused *tool* window can render + below the game's rate (it loses VRR), so in use the mirror is the focused window. - **SafetyHook on x86 has two traps that froze 32-bit Slaps and Beans.** (1) `InlineHook::call()` invokes the trampoline as `__cdecl`, but most targets are `__stdcall` (COM methods like `IDXGISwapChain::Present`, the WASAPI interfaces, diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index 279f679..d53a562 100644 --- a/common/include/coop/protocol.hpp +++ b/common/include/coop/protocol.hpp @@ -12,7 +12,7 @@ namespace coop // Bump whenever the layout of SharedBlock or CoopPadState changes. The hook // refuses to attach to a host with a mismatched version. -inline constexpr std::uint32_t kProtocolVersion = 12; +inline constexpr std::uint32_t kProtocolVersion = 13; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -168,6 +168,9 @@ struct VideoShare std::uint32_t format; // DXGI_FORMAT of the shared texture std::uint64_t present_calls; // cumulative Present() detours (diagnostic) std::int64_t present_qpc; // QueryPerformanceCounter at the last publish + std::uint64_t frames_dropped; // cumulative captures skipped because the shared + // keyed mutex was busy (host mid-copy) -- a frame + // the game produced that never reached the mirror }; // --- Mouse + keyboard forwarding ------------------------------------------- diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index 365fa6d..f4490b7 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -207,6 +207,16 @@ public: } } + // Record that a produced frame couldn't be copied into the shared texture because the + // keyed mutex was held by the host (we skip rather than block the game's render thread). + void note_video_dropped() + { + if (block_ != nullptr) + { + block_->video.frames_dropped += 1; + } + } + // Publish that a fresh backbuffer copy is in the shared texture (named // coop_video_) with these dimensions/format; bumps the generation the host // polls. The texture itself is shared out-of-band by name, not through here. diff --git a/hook/src/present_hook.cpp b/hook/src/present_hook.cpp index aea2682..af0fa19 100644 --- a/hook/src/present_hook.cpp +++ b/hook/src/present_hook.cpp @@ -60,21 +60,23 @@ bool g_unsupported_logged = false; ID3D11On12Device* g_on12 = nullptr; ID3D11Device* g_on12_d3d11 = nullptr; ID3D11DeviceContext* g_on12_ctx = nullptr; -ID3D12CommandQueue* g_on12_queue = nullptr; +ID3D12CommandQueue* g_on12_queue = nullptr; // our OWN copy queue (never the game's) ID3D12Device* g_on12_d3d12 = nullptr; -// The game's own present queue we built the bridge against (null = we fell back to -// creating our own queue because it hadn't been captured yet). When this changes we -// rebuild the bridge so the copy keeps running on the game's queue. -ID3D12CommandQueue* g_on12_src_queue = nullptr; +// Cross-queue ordering fence, created on the game's device. The game's present queue +// signals it after the frame's render (a near-free operation); our own copy queue waits +// on it before copying. This orders our copy correctly *after* the frame WITHOUT putting +// any copy or keyed-mutex work on the game's present queue -- doing that stalled the +// game's own presents (GPU back-pressure, plus the keyed mutex is a CPU-blocking acquire +// on the render thread). Lifetime tied to the bridge; guarded by g_tex_mutex. +ID3D12Fence* g_copy_fence = nullptr; +UINT64 g_copy_fence_val = 0; -// ExecuteCommandLists hook: recovers the game's D3D12 *present* command queue so the -// On12 copy can be submitted on the game's own queue (ordered after the frame's -// rendering, like the D3D11 immediate-context path) instead of an independent queue -// that races it -- the cause of the occasional stale-frame stutter under GPU load. -// We hook the per-frame method rather than swapchain/queue creation, so it works for -// late injection (the queue already exists). ID3D12CommandQueue method index 10: -// IUnknown 0-2, ID3D12Object 3-6, ID3D12DeviceChild 7 (ID3D12Pageable adds none), -// then ID3D12CommandQueue UpdateTileMappings 8, CopyTileMappings 9, ExecuteCommandLists 10. +// ExecuteCommandLists hook: recovers the game's D3D12 *present* command queue so we can +// signal the ordering fence (above) on it. We hook the per-frame method rather than +// swapchain/queue creation, so it works for late injection (the queue already exists). +// ID3D12CommandQueue method index 10: IUnknown 0-2, ID3D12Object 3-6, ID3D12DeviceChild +// 7 (ID3D12Pageable adds none), then UpdateTileMappings 8, CopyTileMappings 9, +// ExecuteCommandLists 10. constexpr unsigned kIdx_ID3D12CommandQueue_ExecuteCommandLists = 10; safetyhook::InlineHook g_hk_ecl; int g_id_ecl = -1; @@ -85,6 +87,60 @@ int g_id_ecl = -1; thread_local ID3D12CommandQueue* t_present_queue = nullptr; std::atomic g_present_queue{nullptr}; +// Present-pattern inventory. The first time we see each swapchain -- and each distinct +// present-flags value per swapchain -- we log it once, mapping how a game drives DXGI: +// how many swapchains it presents, their size/format, and how often it issues no-op +// DXGI_PRESENT_TEST presents (occlusion probes that draw nothing). That last point +// explains games whose raw Present count outpaces the frames actually captured, since a +// test present is counted but produces no frame. Render-thread only; small fixed tables. +constexpr int kMaxLoggedPresents = 16; +constexpr int kMaxLoggedSwapchains = 8; +struct LoggedPresent +{ + void* swapchain; + UINT flags; +}; +LoggedPresent g_logged_presents[kMaxLoggedPresents] = {}; +int g_logged_presents_n = 0; +void* g_logged_swapchains[kMaxLoggedSwapchains] = {}; +int g_logged_swapchains_n = 0; + +// True the first time this (swapchain, flags) pair is presented, so the caller logs once. +bool first_present_with_flags(void* swapchain, UINT flags) +{ + for (int i = 0; i < g_logged_presents_n; ++i) + { + if (g_logged_presents[i].swapchain == swapchain && g_logged_presents[i].flags == flags) + { + return false; + } + } + if (g_logged_presents_n >= kMaxLoggedPresents) + { + return false; + } + g_logged_presents[g_logged_presents_n++] = {swapchain, flags}; + return true; +} + +// True the first time this swapchain feeds the capture, so the caller logs it once. +bool first_capture_from(void* swapchain) +{ + for (int i = 0; i < g_logged_swapchains_n; ++i) + { + if (g_logged_swapchains[i] == swapchain) + { + return false; + } + } + if (g_logged_swapchains_n >= kMaxLoggedSwapchains) + { + return false; + } + g_logged_swapchains[g_logged_swapchains_n++] = swapchain; + return true; +} + void* vtable_method(void* obj, unsigned index) { return (*reinterpret_cast(obj))[index]; @@ -210,45 +266,41 @@ void release_on12_locked() g_on12_d3d12->Release(); g_on12_d3d12 = nullptr; } - g_on12_src_queue = nullptr; + if (g_copy_fence != nullptr) + { + g_copy_fence->Release(); + g_copy_fence = nullptr; + } + g_copy_fence_val = 0; } -// Build the D3D11On12 device for the game's D3D12 `dev`, submitting our copy work on -// `game_queue` (the game's present queue, captured via the ExecuteCommandLists hook) -// so it's ordered after the frame's rendering. If `game_queue` is null we fall back to -// a queue of our own (correct frame, but can race the game's render under load -- the -// old behavior) until the real queue is captured. Caller holds g_tex_mutex. Returns -// true when the bridge is ready. -bool ensure_on12_locked(ID3D12Device* dev, ID3D12CommandQueue* game_queue) +// Build the D3D11On12 device for the game's D3D12 `dev` on a copy queue of our own, plus +// a fence on `dev` for ordering our copy after the game's frame (signaled by the game's +// present queue in capture_backbuffer_d3d12, waited by our queue). Keeping the copy on +// our own queue is what stops our work from stalling the game's present queue. Caller +// holds g_tex_mutex. Returns true when the bridge is ready. +bool ensure_on12_locked(ID3D12Device* dev) { - if (g_on12 != nullptr && g_on12_d3d12 == dev && g_on12_src_queue == game_queue) + if (g_on12 != nullptr && g_on12_d3d12 == dev) { return true; } release_on12_locked(); - // Hold a ref on whichever queue the bridge uses, so Release on teardown is uniform. - ID3D12CommandQueue* queue = game_queue; - if (queue != nullptr) + D3D12_COMMAND_QUEUE_DESC qd{}; + qd.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; + ID3D12CommandQueue* queue = nullptr; + HRESULT hr = dev->CreateCommandQueue(&qd, __uuidof(ID3D12CommandQueue), reinterpret_cast(&queue)); + if (FAILED(hr) || queue == nullptr) { - queue->AddRef(); - } - else - { - D3D12_COMMAND_QUEUE_DESC qd{}; - qd.Type = D3D12_COMMAND_LIST_TYPE_DIRECT; - HRESULT hr = dev->CreateCommandQueue(&qd, __uuidof(ID3D12CommandQueue), reinterpret_cast(&queue)); - if (FAILED(hr) || queue == nullptr) - { - logf("present(d3d12): CreateCommandQueue failed hr=0x%08lX", static_cast(hr)); - return false; - } + logf("present(d3d12): CreateCommandQueue failed hr=0x%08lX", static_cast(hr)); + return false; } IUnknown* queues[] = {queue}; ID3D11Device* d11 = nullptr; ID3D11DeviceContext* ctx = nullptr; - HRESULT hr = D3D11On12CreateDevice(dev, 0, nullptr, 0, queues, 1, 0, &d11, &ctx, nullptr); + hr = D3D11On12CreateDevice(dev, 0, nullptr, 0, queues, 1, 0, &d11, &ctx, nullptr); if (FAILED(hr) || d11 == nullptr) { logf("present(d3d12): D3D11On12CreateDevice failed hr=0x%08lX", static_cast(hr)); @@ -269,14 +321,26 @@ bool ensure_on12_locked(ID3D12Device* dev, ID3D12CommandQueue* game_queue) return false; } + // Ordering fence on the game's device. Non-fatal if it fails -- we simply lose the + // cross-queue ordering (the copy can then race the frame, the pre-fence behavior). + ID3D12Fence* fence = nullptr; + hr = dev->CreateFence(0, D3D12_FENCE_FLAG_NONE, __uuidof(ID3D12Fence), reinterpret_cast(&fence)); + if (FAILED(hr) || fence == nullptr) + { + logf("present(d3d12): CreateFence failed hr=0x%08lX (copy will be unordered)", + static_cast(hr)); + fence = nullptr; + } + g_on12 = on12; g_on12_d3d11 = d11; g_on12_ctx = ctx; - g_on12_queue = queue; // we hold a ref (the AddRef'd game queue, or our created one) - g_on12_src_queue = game_queue; // what we built against (null = our own queue) + g_on12_queue = queue; g_on12_d3d12 = dev; + g_copy_fence = fence; + g_copy_fence_val = 0; dev->AddRef(); // we hold a reference for the lifetime of the bridge - logf("present(d3d12): D3D11On12 bridge ready (queue=%s)", game_queue != nullptr ? "game" : "own"); + logf("present(d3d12): D3D11On12 bridge ready (own copy queue, fence=%d)", fence != nullptr ? 1 : 0); return true; } @@ -316,6 +380,7 @@ void capture_backbuffer_d3d12(IDXGISwapChain* sc) bb->GetDevice(__uuidof(ID3D12Device), reinterpret_cast(&dev)); bool shared = false; + bool dropped = false; UINT w = 0, h = 0; DXGI_FORMAT fmt = DXGI_FORMAT_UNKNOWN; // The game's present queue, preferring the one seen on this (the render) thread. @@ -325,11 +390,30 @@ void capture_backbuffer_d3d12(IDXGISwapChain* sc) game_queue = g_present_queue.load(std::memory_order_relaxed); } + // Log each distinct swapchain feeding the capture once (size/format/buffer index). + if (first_capture_from(sc)) + { + const D3D12_RESOURCE_DESC rd = bb->GetDesc(); + logf("present: swapchain=%p capturing D3D12 backbuffer %llux%u fmt=%d samples=%u bufferindex=%u queue=%s", + sc, static_cast(rd.Width), rd.Height, static_cast(rd.Format), + rd.SampleDesc.Count, bb_index, game_queue != nullptr ? "known" : "unknown"); + } + if (dev != nullptr) { std::scoped_lock lock(g_tex_mutex); - if (ensure_on12_locked(dev, game_queue)) + if (ensure_on12_locked(dev)) { + // Order our copy after the game's frame without burdening the game's queue: the + // game queue signals the fence (cheap), our copy queue waits on it. Skipped if the + // queue isn't captured yet or the fence is missing (one possibly-early frame). + if (game_queue != nullptr && g_copy_fence != nullptr) + { + const UINT64 fence_val = ++g_copy_fence_val; + game_queue->Signal(g_copy_fence, fence_val); + g_on12_queue->Wait(g_copy_fence, fence_val); + } + D3D11_RESOURCE_FLAGS rf{}; rf.BindFlags = D3D11_BIND_RENDER_TARGET; ID3D11Resource* wrapped = nullptr; @@ -352,12 +436,16 @@ void capture_backbuffer_d3d12(IDXGISwapChain* sc) if (d.SampleDesc.Count == 1 && ensure_shared_texture_locked(g_on12_d3d11, w, h, fmt) && g_shared_mutex != nullptr) { - if (g_shared_mutex->AcquireSync(kVideoMutexKey, 8) == S_OK) + if (g_shared_mutex->AcquireSync(kVideoMutexKey, 0) == S_OK) { g_on12_ctx->CopyResource(g_shared_tex, wtex); g_shared_mutex->ReleaseSync(kVideoMutexKey); shared = true; } + else + { + dropped = true; // host held the mutex -> this frame never reaches the mirror + } } wtex->Release(); } @@ -381,6 +469,10 @@ void capture_backbuffer_d3d12(IDXGISwapChain* sc) g_ipc->publish_video_frame(w, h, static_cast(fmt)); } } + else if (dropped && g_ipc != nullptr) + { + g_ipc->note_video_dropped(); + } if (dev != nullptr) { dev->Release(); @@ -402,6 +494,13 @@ void capture_backbuffer(IDXGISwapChain* sc) D3D11_TEXTURE2D_DESC bd{}; backbuf->GetDesc(&bd); + // Log each distinct swapchain feeding the capture once (see the D3D12 path). + if (first_capture_from(sc)) + { + logf("present: swapchain=%p capturing D3D11 backbuffer %ux%u fmt=%d samples=%u", sc, bd.Width, bd.Height, + static_cast(bd.Format), bd.SampleDesc.Count); + } + // 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) @@ -419,6 +518,7 @@ void capture_backbuffer(IDXGISwapChain* sc) } bool shared = false; + bool dropped = false; if (device != nullptr && ctx != nullptr) { std::scoped_lock lock(g_tex_mutex); @@ -433,6 +533,10 @@ void capture_backbuffer(IDXGISwapChain* sc) g_shared_mutex->ReleaseSync(kVideoMutexKey); shared = true; } + else + { + dropped = true; // host held the mutex past the wait -> frame lost (rare on D3D11) + } } } @@ -444,6 +548,10 @@ void capture_backbuffer(IDXGISwapChain* sc) g_ipc->publish_video_frame(bd.Width, bd.Height, static_cast(bd.Format)); } } + else if (dropped && g_ipc != nullptr) + { + g_ipc->note_video_dropped(); + } if (ctx != nullptr) { @@ -516,6 +624,14 @@ HRESULT STDMETHODCALLTYPE hk_Present(IDXGISwapChain* sc, UINT sync_interval, UIN { g_ipc->note_present(); } + // Log each distinct (swapchain, flags) once. A DXGI_PRESENT_TEST present draws nothing + // (it only probes occlusion), so those inflate the Present count without producing a + // frame -- which is why some games show more presents than captured frames. + if (first_present_with_flags(sc, flags)) + { + logf("present: swapchain=%p Present flags=0x%08X%s", sc, flags, + (flags & DXGI_PRESENT_TEST) ? " (DXGI_PRESENT_TEST: occlusion probe, no frame drawn)" : ""); + } // DXGI_PRESENT_TEST presents nothing; don't bother copying for it. if ((flags & DXGI_PRESENT_TEST) == 0) { @@ -538,6 +654,11 @@ HRESULT STDMETHODCALLTYPE hk_Present1(IDXGISwapChain1* sc, UINT sync_interval, U { g_ipc->note_present(); } + if (first_present_with_flags(sc, flags)) // see hk_Present + { + logf("present: swapchain=%p Present1 flags=0x%08X%s", sc, flags, + (flags & DXGI_PRESENT_TEST) ? " (DXGI_PRESENT_TEST: occlusion probe, no frame drawn)" : ""); + } if ((flags & DXGI_PRESENT_TEST) == 0) { capture_backbuffer(sc); // IDXGISwapChain1 derives from IDXGISwapChain @@ -676,6 +797,8 @@ void remove_present_hooks() hook_set_installed(g_id_present1, false); hook_set_installed(g_id_ecl, false); g_present_queue.store(nullptr, std::memory_order_relaxed); + g_logged_presents_n = 0; // let a fresh injection re-log the present pattern + g_logged_swapchains_n = 0; { std::scoped_lock lock(g_tex_mutex); release_shared_locked(); diff --git a/host/src/capture/shared_texture.cpp b/host/src/capture/shared_texture.cpp index ef97622..467c4fc 100644 --- a/host/src/capture/shared_texture.cpp +++ b/host/src/capture/shared_texture.cpp @@ -27,6 +27,7 @@ void SharedTextureSource::reset() width_ = height_ = format_ = 0; last_generation_ = 0; frames_copied_ = 0; + frames_missed_ = 0; } bool SharedTextureSource::reopen(unsigned long pid, const VideoShareView& share) @@ -123,6 +124,13 @@ bool SharedTextureSource::update(const VideoShareView& share, unsigned long pid) { ctx_->CopyResource(private_.Get(), shared_.Get()); mutex_->ReleaseSync(kVideoMutexKey); + // Generations between the last copy and this one were published but never shown + // (we only ever copy the newest). last_generation_ == 0 is the first copy after a + // (re)open, where the gap to a large generation is meaningless, so skip it. + if (last_generation_ != 0 && share.generation > last_generation_ + 1) + { + frames_missed_ += share.generation - last_generation_ - 1; + } last_generation_ = share.generation; ++frames_copied_; } diff --git a/host/src/capture/shared_texture.hpp b/host/src/capture/shared_texture.hpp index a86e5d0..9c074df 100644 --- a/host/src/capture/shared_texture.hpp +++ b/host/src/capture/shared_texture.hpp @@ -47,6 +47,12 @@ public: { return frames_copied_; } + // Cumulative published frames the host never displayed because the generation + // advanced by more than one between copies (host render rate < hook publish rate). + [[nodiscard]] std::uint64_t frames_missed() const + { + return frames_missed_; + } private: bool reopen(unsigned long pid, const VideoShareView& share); @@ -64,6 +70,7 @@ private: std::uint32_t format_ = 0; std::uint32_t last_generation_ = 0; std::uint64_t frames_copied_ = 0; + std::uint64_t frames_missed_ = 0; }; } // namespace coop diff --git a/host/src/capture_panel.cpp b/host/src/capture_panel.cpp index 180edd5..2615ac0 100644 --- a/host/src/capture_panel.cpp +++ b/host/src/capture_panel.cpp @@ -198,6 +198,15 @@ void CapturePanel::draw_pipeline_metrics(const FrameStats& stats) ImGui::Text("Game present: %5.0f /s", present_rate_.sample(v.present_calls, now)); ImGui::Text("Hook publish: %5.0f /s", capture_rate_.sample(v.generation, now)); + // Dropped frames: capture-side = the game produced a frame the hook couldn't copy + // (shared mutex busy at present time); display-side = it was published but the host + // rendered past it without showing it. Either is a frame missing from the mirror. + const double cap_drop = drop_rate_.sample(v.frames_dropped, now); + const double disp_skip = display_skip_rate_.sample(shared_.frames_missed(), now); + const bool any_loss = cap_drop >= 0.5 || disp_skip >= 0.5; + ImGui::TextColored(any_loss ? kRed : kGreen, "Frames lost: %5.0f /s capture %5.0f /s display", cap_drop, + disp_skip); + // On each newly published frame, measure now - present_qpc (system-wide clock). if (v.generation != last_video_gen_ && v.present_qpc != 0 && qpc_freq_ > 0) { diff --git a/host/src/capture_panel.hpp b/host/src/capture_panel.hpp index b3e0cf8..d0f37f5 100644 --- a/host/src/capture_panel.hpp +++ b/host/src/capture_panel.hpp @@ -174,6 +174,8 @@ private: // Pipeline metrics: game-present + capture rates and capture->display latency. RateTracker present_rate_; RateTracker capture_rate_; + RateTracker drop_rate_; // hook-side captures skipped (mutex busy at present) + RateTracker display_skip_rate_; // host-side published frames never displayed // Per-frame FPS history for the multi-series perf graph (colored per source). Series tool_fps_; // host render rate diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index fb14ab5..95855e7 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -112,6 +112,7 @@ VideoShareView IpcServer::video_share() const v.format = s.format; v.present_calls = s.present_calls; v.present_qpc = s.present_qpc; + v.frames_dropped = s.frames_dropped; return v; } diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index a1db637..6a7b76c 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -53,8 +53,9 @@ struct VideoShareView 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) - std::int64_t present_qpc = 0; // QPC stamp of the last published frame + std::uint64_t present_calls = 0; // cumulative Present() detours (diagnostic) + std::int64_t present_qpc = 0; // QPC stamp of the last published frame + std::uint64_t frames_dropped = 0; // cumulative captures skipped (mutex busy at present) }; class IpcServer