From 5e05d38be8db235870c6ecabcd1931d9595612de Mon Sep 17 00:00:00 2001 From: BlackMark Date: Sun, 21 Jun 2026 05:25:17 +0200 Subject: [PATCH] Add capture pipeline rate + latency metrics to the Video panel The FPS readout only measured the host's own render rate, hiding capture stutter. The Video panel now shows a "Pipeline rates" section: - Tool render (host FPS / frametime, as before); - hooked source: Game present (/s, from VideoShare.present_calls deltas), Hook publish (/s, generation deltas), and capture->display latency avg/min/max ms; - WGC source: WGC capture (/s) from a new WindowCapture frame-arrival counter (game present + latency are n/a, since WGC frames aren't game-timestamped). Latency uses a system-wide clock: protocol v11->v12 adds VideoShare.present_qpc, stamped by the hook at publish (publish_video_frame); the host measures now_qpc - present_qpc per newly published frame, windowed to min/avg/max each second. Verified: present_hook_test (x64 + x86) now asserts present_qpc is stamped; full build x64 + x86 clean; ctest x64 9/9, x86 3/3. The live rate/latency numbers need a real game mirroring to read meaningfully; wiring validated. Co-Authored-By: Claude Opus 4.8 --- README.md | 8 ---- common/include/coop/protocol.hpp | 3 +- hook/src/ipc_client.hpp | 3 ++ host/src/capture/window_capture.cpp | 1 + host/src/capture/window_capture.hpp | 7 +++ host/src/capture_panel.cpp | 73 +++++++++++++++++++++++++++++ host/src/capture_panel.hpp | 32 +++++++++++++ host/src/ipc/ipc_server.cpp | 1 + host/src/ipc/ipc_server.hpp | 1 + tests/present_hook_test.cpp | 1 + 10 files changed, 121 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c05823f..49d234d 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,6 @@ is removed from this list once done — so the top item is always next. The self-verifiable tooling / UI / input items come first; the game-pipeline items that need a real game (and Remote Play) to fully validate come last. -- **Real capture metrics + latency stats.** The current FPS readout only measures - how fast the host renders its own window, which hides capture stutter. Add a - three-line frametime/FPS graph — **game present rate** (from `VideoShare` present - deltas), **capture rate** (generation deltas / WGC arrivals), and **tool render - rate** — plus a **capture→display latency** stat: stamp each published frame with - a `QueryPerformanceCounter` value in `VideoShare`, and the host reports - `host-present QPC − game-present QPC` (min/avg/max ms) for the matched frame. QPC - is system-wide, so the two processes' timestamps compare directly. - **DX12 hooked capture (Spider-Man: Miles Morales).** Miles Morales is D3D12, so the Present hook fires but `GetBuffer(0)` as `ID3D11Texture2D` fails (the backbuffer is an `ID3D12Resource`) and the hook idles; WGC works but stutters. Add diff --git a/common/include/coop/protocol.hpp b/common/include/coop/protocol.hpp index fc15338..279f679 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 = 11; +inline constexpr std::uint32_t kProtocolVersion = 12; // 'COOP' little-endian, used to sanity-check the mapping before trusting it. inline constexpr std::uint32_t kProtocolMagic = 0x504F4F43u; @@ -167,6 +167,7 @@ struct VideoShare std::uint32_t height; 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 }; // --- Mouse + keyboard forwarding ------------------------------------------- diff --git a/hook/src/ipc_client.hpp b/hook/src/ipc_client.hpp index 00f4edd..365fa6d 100644 --- a/hook/src/ipc_client.hpp +++ b/hook/src/ipc_client.hpp @@ -217,6 +217,9 @@ public: block_->video.width = width; block_->video.height = height; block_->video.format = format; + LARGE_INTEGER qpc{}; + QueryPerformanceCounter(&qpc); + block_->video.present_qpc = qpc.QuadPart; // system-wide clock; host compares directly block_->video.generation.fetch_add(1, std::memory_order_release); } } diff --git a/host/src/capture/window_capture.cpp b/host/src/capture/window_capture.cpp index c5ff203..5c1661c 100644 --- a/host/src/capture/window_capture.cpp +++ b/host/src/capture/window_capture.cpp @@ -151,6 +151,7 @@ void WindowCapture::on_frame_arrived(winrt::Direct3D11CaptureFramePool const& po // Just grab the newest frame and stash it; the render thread does the copy. auto frame = pool.TryGetNextFrame(); std::lock_guard lock(mutex_); + ++frames_arrived_; // capture-rate metric (this is the WGC delivery cadence) if (pending_ != nullptr) { pending_.Close(); // drop the un-consumed previous frame back to the pool diff --git a/host/src/capture/window_capture.hpp b/host/src/capture/window_capture.hpp index 4424843..8ff5dd8 100644 --- a/host/src/capture/window_capture.hpp +++ b/host/src/capture/window_capture.hpp @@ -46,6 +46,12 @@ public: return height_; } + // Cumulative frames WGC has delivered (for the capture-rate metric). + [[nodiscard]] std::uint64_t frames_arrived() const + { + return frames_arrived_; + } + // Render thread: consume the newest frame (if any) and draw it letterboxed // into a dst_w x dst_h target via `renderer`. void draw_latest(FrameRenderer& renderer, ID3D11DeviceContext* ctx, std::uint32_t dst_w, std::uint32_t dst_h); @@ -60,6 +66,7 @@ private: Microsoft::WRL::ComPtr latest_srv_; std::uint32_t width_ = 0; std::uint32_t height_ = 0; + std::uint64_t frames_arrived_ = 0; // incremented in on_frame_arrived std::mutex mutex_; // guards pending_ handoff only (no context work under it) winrt::Windows::Graphics::Capture::Direct3D11CaptureFrame pending_{nullptr}; diff --git a/host/src/capture_panel.cpp b/host/src/capture_panel.cpp index 0afc168..02bd23d 100644 --- a/host/src/capture_panel.cpp +++ b/host/src/capture_panel.cpp @@ -18,6 +18,9 @@ const ImVec4 kRed(1.0f, 0.45f, 0.4f, 1.0f); bool CapturePanel::init(ID3D11Device* device) { device_ = device; + LARGE_INTEGER freq{}; + QueryPerformanceFrequency(&freq); + qpc_freq_ = freq.QuadPart; shared_.init(device); // best effort; the Hooked source is unavailable if it fails return renderer_.init(device); } @@ -112,11 +115,81 @@ void CapturePanel::draw_ui(const FrameStats& stats) } } + draw_pipeline_metrics(stats); draw_perf_graphs(stats); ImGui::End(); } +void CapturePanel::draw_pipeline_metrics(const FrameStats& stats) +{ + if (!enabled_) + { + return; + } + const double now = ImGui::GetTime(); + ImGui::SeparatorText("Pipeline rates"); + ImGui::Text("Tool render: %.0f FPS (%.2f ms)", stats.fps(), stats.avg_ms()); + + if (source_ == Source_Hooked) + { + const VideoShareView v = injection_ != nullptr ? injection_->video_share() : VideoShareView{}; + ImGui::Text("Game present: %.0f /s", present_rate_.sample(v.present_calls, now)); + ImGui::Text("Hook publish: %.0f /s", capture_rate_.sample(v.generation, now)); + + // 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) + { + last_video_gen_ = v.generation; + LARGE_INTEGER now_qpc{}; + QueryPerformanceCounter(&now_qpc); + const double ms = + static_cast(now_qpc.QuadPart - v.present_qpc) * 1000.0 / static_cast(qpc_freq_); + if (ms >= 0.0 && ms < 1000.0) // ignore clock edge cases + { + lat_sum_ += ms; + if (lat_n_ == 0 || ms < lat_wmin_) + { + lat_wmin_ = ms; + } + if (ms > lat_wmax_) + { + lat_wmax_ = ms; + } + ++lat_n_; + } + } + if (now - lat_window_start_ >= 1.0) // publish min/avg/max once a second + { + if (lat_n_ > 0) + { + lat_avg_ = static_cast(lat_sum_ / lat_n_); + lat_min_ = static_cast(lat_wmin_); + lat_max_ = static_cast(lat_wmax_); + } + lat_sum_ = 0.0; + lat_n_ = 0; + lat_wmin_ = 0.0; + lat_wmax_ = 0.0; + lat_window_start_ = now; + } + if (lat_avg_ > 0.0f) + { + ImGui::Text("Capture->display: avg %.1f min %.1f max %.1f ms", lat_avg_, lat_min_, lat_max_); + } + else + { + ImGui::TextDisabled("Capture->display latency: measuring..."); + } + } + else + { + ImGui::TextDisabled("Game present: n/a (WGC has no game frame timing)"); + ImGui::Text("WGC capture: %.0f /s", capture_rate_.sample(capture_.frames_arrived(), now)); + ImGui::TextDisabled("Latency: n/a (WGC frames aren't game-timestamped)"); + } +} + void CapturePanel::draw_perf_graphs(const FrameStats& stats) { // The mirror renders into this window, so the host's render frame timing is diff --git a/host/src/capture_panel.hpp b/host/src/capture_panel.hpp index 66b3720..64a7195 100644 --- a/host/src/capture_panel.hpp +++ b/host/src/capture_panel.hpp @@ -66,6 +66,26 @@ private: }; void draw_perf_graphs(const FrameStats& stats); + void draw_pipeline_metrics(const FrameStats& stats); + + // Turns a monotonic counter into a rate (recomputed ~2x/second). + struct RateTracker + { + std::uint64_t last_count = 0; + double last_time = 0.0; + double rate = 0.0; + double sample(std::uint64_t count, double now) + { + if (now - last_time >= 0.5) + { + const double dt = now - last_time; + rate = dt > 0.0 ? static_cast(count - last_count) / dt : 0.0; + last_count = count; + last_time = now; + } + return rate; + } + }; ID3D11Device* device_ = nullptr; FrameRenderer renderer_; @@ -75,6 +95,18 @@ private: HWND target_ = nullptr; bool enabled_ = false; int source_ = Source_Wgc; + + // Pipeline metrics: game-present + capture rates and capture->display latency. + RateTracker present_rate_; + RateTracker capture_rate_; + std::uint32_t last_video_gen_ = 0; + long long qpc_freq_ = 0; + double lat_sum_ = 0.0; + int lat_n_ = 0; + double lat_wmin_ = 0.0; + double lat_wmax_ = 0.0; + double lat_window_start_ = 0.0; + float lat_min_ = 0.0f, lat_avg_ = 0.0f, lat_max_ = 0.0f; // published min/avg/max ms }; } // namespace coop diff --git a/host/src/ipc/ipc_server.cpp b/host/src/ipc/ipc_server.cpp index 44eaf4a..19f7674 100644 --- a/host/src/ipc/ipc_server.cpp +++ b/host/src/ipc/ipc_server.cpp @@ -107,6 +107,7 @@ VideoShareView IpcServer::video_share() const v.height = s.height; v.format = s.format; v.present_calls = s.present_calls; + v.present_qpc = s.present_qpc; return v; } diff --git a/host/src/ipc/ipc_server.hpp b/host/src/ipc/ipc_server.hpp index e3c7dff..30b4475 100644 --- a/host/src/ipc/ipc_server.hpp +++ b/host/src/ipc/ipc_server.hpp @@ -53,6 +53,7 @@ struct VideoShareView 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 }; class IpcServer diff --git a/tests/present_hook_test.cpp b/tests/present_hook_test.cpp index 9ac1444..5d8a53c 100644 --- a/tests/present_hook_test.cpp +++ b/tests/present_hook_test.cpp @@ -146,6 +146,7 @@ int main() 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.present_qpc > 0, "present QPC stamped for the latency metric"); check(block->video.width == kW && block->video.height == kH, "shared dimensions published"); check(block->video.format == DXGI_FORMAT_R8G8B8A8_UNORM, "shared format published");