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