From 5b64b4362ef941e11f51577117c8cc2c0b3fc16d Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 21:45:25 +0200 Subject: [PATCH 1/6] fix(inspector): timeline time uses footer time table, not avg fps The timeline window derived frame time as frame_index / (total_frames / duration_seconds). On captures with recording stalls the average fps is far below the live rate, so displayed times drift by the total stalled time (observed: a frame stamped 13:54 wall clock displayed as ~6:47 on a capture with 643s of stalls at ~29fps live rate). Map frame -> elapsed seconds through the footer ReplayTimeData table instead: created_utc preferred, game_time fallback, walking back to the nearest stamped frame for frames recorded without a value. Files without a time table keep the old linear mapping. Applies to the current-time readout and the strip hover tooltip. --- .../include/services/timeline_view_service.h | 13 ++++ .../src/services/timeline_view_service.cpp | 72 +++++++++++++++++++ .../inspector/src/windows/timeline_window.cpp | 30 ++++---- 3 files changed, 101 insertions(+), 14 deletions(-) diff --git a/tools/inspector/include/services/timeline_view_service.h b/tools/inspector/include/services/timeline_view_service.h index 6393153..902c6aa 100644 --- a/tools/inspector/include/services/timeline_view_service.h +++ b/tools/inspector/include/services/timeline_view_service.h @@ -1,5 +1,7 @@ #pragma once +#include "vtx/common/vtx_types.h" + namespace VtxServices { struct ClockTime { @@ -38,8 +40,19 @@ namespace VtxServices { static float ComputePlaybackFps(int total_frames, float duration_seconds, float fallback_fps = 30.0f); static ClockTime ToClockTime(float total_seconds); static DurationSplit SplitDuration(float total_seconds); + + // Elapsed seconds since capture start for `frame`, read from the footer + // per-frame time table (created_utc preferred, game_time fallback). + // Falls back to linear frame/avg-fps mapping when the table is absent, + // so captures with recording stalls resolve to wall-clock positions + // instead of drifting by the stalled time. + static float FrameToElapsedSeconds(int frame, const VTX::ReplayTimeData& times, int total_frames, + float duration_seconds, float fallback_fps = 30.0f); + static TimelineClockSpan BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, float fallback_fps = 30.0f); + static TimelineClockSpan BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, + const VTX::ReplayTimeData& times, float fallback_fps = 30.0f); static int ClampFrame(int frame, int total_frames); static float ComputeItemFullWidth(const TimelineBarState& bar_state); static float ComputeCenteredScroll(int frame_index, float item_full_width, float view_width); diff --git a/tools/inspector/src/services/timeline_view_service.cpp b/tools/inspector/src/services/timeline_view_service.cpp index a9e08ac..82d623c 100644 --- a/tools/inspector/src/services/timeline_view_service.cpp +++ b/tools/inspector/src/services/timeline_view_service.cpp @@ -1,6 +1,53 @@ #include "services/timeline_view_service.h" #include +#include +#include + +namespace { + + constexpr double kTicksPerSecond = 10'000'000.0; + + // First nonzero tick in the table; 0 when the table has no usable entries. + uint64_t FirstNonZeroTick(const std::vector& ticks) { + for (const uint64_t tick : ticks) { + if (tick != 0) { + return tick; + } + } + return 0; + } + + // Tick for `frame`, walking back to the nearest earlier stamped frame when + // the entry is missing/zero (frames without a recorded value store 0). + uint64_t TickAtOrBefore(const std::vector& ticks, int frame) { + if (ticks.empty() || frame < 0) { + return 0; + } + int index = std::min(frame, static_cast(ticks.size()) - 1); + for (; index >= 0; --index) { + if (ticks[index] != 0) { + return ticks[index]; + } + } + return 0; + } + + // Elapsed seconds for `frame` from a per-frame tick table (100ns ticks). + // Returns a negative value when the table cannot answer for this frame. + float ElapsedFromTickTable(const std::vector& ticks, int frame) { + const uint64_t base_tick = FirstNonZeroTick(ticks); + if (base_tick == 0) { + return -1.0f; + } + const uint64_t frame_tick = TickAtOrBefore(ticks, frame); + if (frame_tick == 0 || frame_tick < base_tick) { + return -1.0f; + } + return static_cast(static_cast(frame_tick - base_tick) / kTicksPerSecond); + } + +} // namespace namespace VtxServices { @@ -24,6 +71,20 @@ namespace VtxServices { return DurationSplit {.minutes = minutes, .seconds = seconds}; } + float TimelineViewService::FrameToElapsedSeconds(int frame, const VTX::ReplayTimeData& times, int total_frames, + float duration_seconds, float fallback_fps) { + const float utc_elapsed = ElapsedFromTickTable(times.created_utc, frame); + if (utc_elapsed >= 0.0f) { + return utc_elapsed; + } + const float game_elapsed = ElapsedFromTickTable(times.game_time, frame); + if (game_elapsed >= 0.0f) { + return game_elapsed; + } + const float fps = ComputePlaybackFps(total_frames, duration_seconds, fallback_fps); + return static_cast(frame) / fps; + } + TimelineClockSpan TimelineViewService::BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, float fallback_fps) { const float fps = ComputePlaybackFps(total_frames, duration_seconds, fallback_fps); @@ -32,6 +93,17 @@ namespace VtxServices { .current = ToClockTime(current_time_sec), .total = ToClockTime(duration_seconds), .fps = fps}; } + TimelineClockSpan TimelineViewService::BuildTimelineClockSpan(int current_frame, int total_frames, + float duration_seconds, + const VTX::ReplayTimeData& times, + float fallback_fps) { + const float fps = ComputePlaybackFps(total_frames, duration_seconds, fallback_fps); + const float current_time_sec = + FrameToElapsedSeconds(current_frame, times, total_frames, duration_seconds, fallback_fps); + return TimelineClockSpan { + .current = ToClockTime(current_time_sec), .total = ToClockTime(duration_seconds), .fps = fps}; + } + int TimelineViewService::ClampFrame(int frame, int total_frames) { if (total_frames <= 0) { return 0; diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index 9bc9943..c913db2 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -53,14 +53,15 @@ namespace { } // Builds hover payload (frame index + derived time labels) for tooltip rendering. - TimelineHoverInfo BuildHoverInfo(int hovered_frame, int total_frames, float duration_seconds) { + TimelineHoverInfo BuildHoverInfo(int hovered_frame, int total_frames, float duration_seconds, + const VTX::ReplayTimeData& times) { TimelineHoverInfo info; if (hovered_frame < 0) { return info; } - const float fps = VtxServices::TimelineViewService::ComputePlaybackFps(total_frames, duration_seconds); - const float frame_time_sec = static_cast(hovered_frame) / fps; + const float frame_time_sec = VtxServices::TimelineViewService::FrameToElapsedSeconds( + hovered_frame, times, total_frames, duration_seconds); info.has_hover = true; info.frame_index = hovered_frame; info.frame_time_seconds = frame_time_sec; @@ -70,10 +71,10 @@ namespace { // Builds strip bars, hover state, and optional scroll requests for current frame. TimelineStripViewModel BuildStripViewModel(VtxServices::TimelineBarState& timeline_bar_state, int total_frames, - int current_frame, float duration_seconds, float scroll_x, - float view_width, float origin_x, float origin_y, float timeline_height, - bool ctrl_down, float wheel, bool is_window_hovered, float mouse_x, - float mouse_y) { + int current_frame, float duration_seconds, + const VTX::ReplayTimeData& times, float scroll_x, float view_width, + float origin_x, float origin_y, float timeline_height, bool ctrl_down, + float wheel, bool is_window_hovered, float mouse_x, float mouse_y) { TimelineStripViewModel view_model; float working_scroll_x = scroll_x; @@ -118,7 +119,7 @@ namespace { }); } - view_model.hover_info = BuildHoverInfo(hovered_frame_idx, total_frames, duration_seconds); + view_model.hover_info = BuildHoverInfo(hovered_frame_idx, total_frames, duration_seconds, times); return view_model; } @@ -151,7 +152,8 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { (void)duration; if (ImGui::BeginChild("InfoPanel", ImVec2(0, 45), true)) { const auto time_span = VtxServices::TimelineViewService::BuildTimelineClockSpan( - inspector_session_->GetCurrentFrame(), total_frames, inspector_session_->GetFooter().duration_seconds); + inspector_session_->GetCurrentFrame(), total_frames, inspector_session_->GetFooter().duration_seconds, + inspector_session_->GetFooter().times); ImGui::AlignTextToFramePadding(); ImGui::Text("Time: %02d:%02d / %02d:%02d", time_span.current.minutes, time_span.current.seconds, @@ -198,11 +200,11 @@ void TimelineWindow::DrawFrameStripTimeline(int total_frames) { // Step 1: Build strip model from current viewport + input state. const ImVec2 p = ImGui::GetCursorScreenPos(); const auto mouse_pos = ImGui::GetMousePos(); - const auto strip_vm = - BuildStripViewModel(timeline_bar_state_, total_frames, inspector_session_->GetCurrentFrame(), - inspector_session_->GetFooter().duration_seconds, ImGui::GetScrollX(), - ImGui::GetWindowWidth(), p.x, p.y, timeline_height, ImGui::GetIO().KeyCtrl, - ImGui::GetIO().MouseWheel, ImGui::IsWindowHovered(), mouse_pos.x, mouse_pos.y); + const auto strip_vm = BuildStripViewModel( + timeline_bar_state_, total_frames, inspector_session_->GetCurrentFrame(), + inspector_session_->GetFooter().duration_seconds, inspector_session_->GetFooter().times, + ImGui::GetScrollX(), ImGui::GetWindowWidth(), p.x, p.y, timeline_height, ImGui::GetIO().KeyCtrl, + ImGui::GetIO().MouseWheel, ImGui::IsWindowHovered(), mouse_pos.x, mouse_pos.y); if (strip_vm.request_scroll) { ImGui::SetScrollX(strip_vm.desired_scroll_x); timeline_bar_state_.last_tracked_frame = inspector_session_->GetCurrentFrame(); From 5e897c46cf3d028d2b98338a0386167de1f70d1d Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 21:45:29 +0200 Subject: [PATCH 2/6] chore: ignore build-inspector/ --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 4b1582d..d991c32 100644 --- a/.gitignore +++ b/.gitignore @@ -67,4 +67,4 @@ __pycache__/ # OS Thumbs.db Desktop.ini -.DS_Store \ No newline at end of file +.DS_Storebuild-inspector/ From f0f7a695c8cce3e4148f33a5afae4cf22130cce5 Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 21:58:45 +0200 Subject: [PATCH 3/6] feat(inspector): paint recording gaps red on the timeline strip BuildDroppedFrameMap flags every frame whose wall-clock span from the previous stamped frame (footer created_utc, game_time fallback) exceeds 1.5x the expected interval, and estimates the missing frame count per gap. The timeline strip paints flagged frames red, the info panel shows a gap/missing-frame summary plus an editable "Drop FPS" expected rate (default 30), and the hover tooltip reports the gap duration and estimated missing frames. Files without a time table flag nothing. --- .../include/services/timeline_view_service.h | 18 ++++++ .../include/windows/timeline_window.h | 11 ++++ .../src/services/timeline_view_service.cpp | 47 ++++++++++++++ .../inspector/src/windows/timeline_window.cpp | 62 ++++++++++++++++--- 4 files changed, 130 insertions(+), 8 deletions(-) diff --git a/tools/inspector/include/services/timeline_view_service.h b/tools/inspector/include/services/timeline_view_service.h index 902c6aa..a2a765e 100644 --- a/tools/inspector/include/services/timeline_view_service.h +++ b/tools/inspector/include/services/timeline_view_service.h @@ -35,6 +35,18 @@ namespace VtxServices { float total_content_width = 0.0f; }; + // Per-frame recording-gap map derived from the footer time table against an + // expected capture rate. A frame is flagged when the wall-clock span from the + // previous stamped frame exceeds 1.5x the expected interval; every frame + // inside that span carries the gap duration and the estimated missing count. + struct DroppedFrameMap { + std::vector flagged; // 1 = frame sits in a recording gap + std::vector gap_ms; // wall-clock gap the frame belongs to (0 when none) + std::vector missing; // estimated frames missing in that gap + int gap_count = 0; // number of distinct gaps + int64_t total_missing = 0; // estimated frames missing across all gaps + }; + class TimelineViewService { public: static float ComputePlaybackFps(int total_frames, float duration_seconds, float fallback_fps = 30.0f); @@ -49,6 +61,12 @@ namespace VtxServices { static float FrameToElapsedSeconds(int frame, const VTX::ReplayTimeData& times, int total_frames, float duration_seconds, float fallback_fps = 30.0f); + // Builds the recording-gap map for the whole replay from the footer time + // table (created_utc preferred, game_time fallback). Returns an empty map + // (no frames flagged) when the file carries no usable time table. + static DroppedFrameMap BuildDroppedFrameMap(const VTX::ReplayTimeData& times, int total_frames, + float expected_fps); + static TimelineClockSpan BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, float fallback_fps = 30.0f); static TimelineClockSpan BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, diff --git a/tools/inspector/include/windows/timeline_window.h b/tools/inspector/include/windows/timeline_window.h index 4c3c960..b0d3d9d 100644 --- a/tools/inspector/include/windows/timeline_window.h +++ b/tools/inspector/include/windows/timeline_window.h @@ -18,7 +18,18 @@ class TimelineWindow : public ImGuiWindow { //dynamic timeline void DrawFrameStripTimeline(int total_frames); +private: + // Returns the recording-gap map for the loaded replay, rebuilding the cache + // when the expected fps or the loaded file changed. + const VtxServices::DroppedFrameMap& GetDroppedFrameMap(int total_frames); + private: VtxServices::TimelineBarState timeline_bar_state_ {}; std::shared_ptr inspector_session_; + + float drop_detect_fps_ = 30.0f; + VtxServices::DroppedFrameMap dropped_map_ {}; + float dropped_map_fps_ = 0.0f; + int dropped_map_frames_ = -1; + size_t dropped_map_stamp_count_ = 0; }; diff --git a/tools/inspector/src/services/timeline_view_service.cpp b/tools/inspector/src/services/timeline_view_service.cpp index 82d623c..78e8aed 100644 --- a/tools/inspector/src/services/timeline_view_service.cpp +++ b/tools/inspector/src/services/timeline_view_service.cpp @@ -93,6 +93,53 @@ namespace VtxServices { .current = ToClockTime(current_time_sec), .total = ToClockTime(duration_seconds), .fps = fps}; } + DroppedFrameMap TimelineViewService::BuildDroppedFrameMap(const VTX::ReplayTimeData& times, int total_frames, + float expected_fps) { + DroppedFrameMap map; + if (total_frames <= 0 || expected_fps <= 0.0f) { + return map; + } + map.flagged.assign(static_cast(total_frames), 0); + map.gap_ms.assign(static_cast(total_frames), 0.0f); + map.missing.assign(static_cast(total_frames), 0); + + const std::vector& ticks = + FirstNonZeroTick(times.created_utc) != 0 ? times.created_utc : times.game_time; + if (FirstNonZeroTick(ticks) == 0) { + return map; + } + + const double expected_interval_ms = 1000.0 / static_cast(expected_fps); + const int last_index = std::min(total_frames, static_cast(ticks.size())) - 1; + + int prev_stamped = -1; + for (int frame = 0; frame <= last_index; ++frame) { + if (ticks[static_cast(frame)] == 0) { + continue; + } + if (prev_stamped >= 0) { + const int span_frames = frame - prev_stamped; + const double actual_ms = + static_cast(ticks[static_cast(frame)] - ticks[static_cast(prev_stamped)]) / + 10'000.0; + const double expected_ms = expected_interval_ms * span_frames; + if (actual_ms > expected_ms * 1.5) { + const int missing = + std::max(1, static_cast(actual_ms / expected_interval_ms + 0.5) - span_frames); + ++map.gap_count; + map.total_missing += missing; + for (int i = prev_stamped + 1; i <= frame; ++i) { + map.flagged[static_cast(i)] = 1; + map.gap_ms[static_cast(i)] = static_cast(actual_ms); + map.missing[static_cast(i)] = missing; + } + } + } + prev_stamped = frame; + } + return map; + } + TimelineClockSpan TimelineViewService::BuildTimelineClockSpan(int current_frame, int total_frames, float duration_seconds, const VTX::ReplayTimeData& times, diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index c913db2..32a12c0 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -14,6 +14,9 @@ namespace { int frame_index = -1; float frame_time_seconds = 0.0f; VtxServices::ClockTime frame_time_clock; + bool in_gap = false; + float gap_ms = 0.0f; + int gap_missing = 0; }; struct TimelineFrameBarView { @@ -24,6 +27,7 @@ namespace { float y_bottom = 0.0f; bool is_current = false; bool is_hovered = false; + bool is_dropped = false; }; struct TimelineStripViewModel { @@ -54,7 +58,8 @@ namespace { // Builds hover payload (frame index + derived time labels) for tooltip rendering. TimelineHoverInfo BuildHoverInfo(int hovered_frame, int total_frames, float duration_seconds, - const VTX::ReplayTimeData& times) { + const VTX::ReplayTimeData& times, + const VtxServices::DroppedFrameMap& dropped_map) { TimelineHoverInfo info; if (hovered_frame < 0) { return info; @@ -66,15 +71,23 @@ namespace { info.frame_index = hovered_frame; info.frame_time_seconds = frame_time_sec; info.frame_time_clock = VtxServices::TimelineViewService::ToClockTime(frame_time_sec); + const size_t idx = static_cast(hovered_frame); + if (idx < dropped_map.flagged.size() && dropped_map.flagged[idx] != 0) { + info.in_gap = true; + info.gap_ms = dropped_map.gap_ms[idx]; + info.gap_missing = dropped_map.missing[idx]; + } return info; } // Builds strip bars, hover state, and optional scroll requests for current frame. TimelineStripViewModel BuildStripViewModel(VtxServices::TimelineBarState& timeline_bar_state, int total_frames, int current_frame, float duration_seconds, - const VTX::ReplayTimeData& times, float scroll_x, float view_width, - float origin_x, float origin_y, float timeline_height, bool ctrl_down, - float wheel, bool is_window_hovered, float mouse_x, float mouse_y) { + const VTX::ReplayTimeData& times, + const VtxServices::DroppedFrameMap& dropped_map, float scroll_x, + float view_width, float origin_x, float origin_y, float timeline_height, + bool ctrl_down, float wheel, bool is_window_hovered, float mouse_x, + float mouse_y) { TimelineStripViewModel view_model; float working_scroll_x = scroll_x; @@ -116,10 +129,12 @@ namespace { .y_bottom = y_bottom, .is_current = i == current_frame, .is_hovered = i == hovered_frame_idx, + .is_dropped = static_cast(i) < dropped_map.flagged.size() && + dropped_map.flagged[static_cast(i)] != 0, }); } - view_model.hover_info = BuildHoverInfo(hovered_frame_idx, total_frames, duration_seconds, times); + view_model.hover_info = BuildHoverInfo(hovered_frame_idx, total_frames, duration_seconds, times, dropped_map); return view_model; } @@ -159,6 +174,20 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { ImGui::Text("Time: %02d:%02d / %02d:%02d", time_span.current.minutes, time_span.current.seconds, time_span.total.minutes, time_span.total.seconds); + // Expected capture rate for the recording-gap (red bar) detection. + ImGui::SameLine(); + ImGui::SetNextItemWidth(70.0f); + if (ImGui::InputFloat("Drop FPS", &drop_detect_fps_, 0.0f, 0.0f, "%.1f", + ImGuiInputTextFlags_EnterReturnsTrue)) { + drop_detect_fps_ = std::clamp(drop_detect_fps_, 1.0f, 1000.0f); + } + const auto& dropped_map = GetDroppedFrameMap(total_frames); + if (dropped_map.gap_count > 0) { + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.9f, 0.35f, 0.35f, 1.0f), "%d gaps, ~%lld frames missing", dropped_map.gap_count, + static_cast(dropped_map.total_missing)); + } + ImGui::SameLine(ImGui::GetWindowWidth() - 320.0f); ImGui::Text("Frame: %d / %d", inspector_session_->GetCurrentFrame(), total_frames); @@ -185,6 +214,19 @@ void TimelineWindow::DrawTimelineSlider(int total_frames, float duration_seconds inspector_session_->SetScrubbingTimeline(ImGui::IsItemActive()); } +const VtxServices::DroppedFrameMap& TimelineWindow::GetDroppedFrameMap(int total_frames) { + const auto& times = inspector_session_->GetFooter().times; + const size_t stamp_count = times.created_utc.size() + times.game_time.size(); + if (dropped_map_fps_ != drop_detect_fps_ || dropped_map_frames_ != total_frames || + dropped_map_stamp_count_ != stamp_count) { + dropped_map_ = VtxServices::TimelineViewService::BuildDroppedFrameMap(times, total_frames, drop_detect_fps_); + dropped_map_fps_ = drop_detect_fps_; + dropped_map_frames_ = total_frames; + dropped_map_stamp_count_ = stamp_count; + } + return dropped_map_; +} + // Renders zoomable frame bars and hover/click seek interactions. void TimelineWindow::DrawFrameStripTimeline(int total_frames) { const float avail_width = ImGui::GetContentRegionAvail().x; @@ -203,8 +245,8 @@ void TimelineWindow::DrawFrameStripTimeline(int total_frames) { const auto strip_vm = BuildStripViewModel( timeline_bar_state_, total_frames, inspector_session_->GetCurrentFrame(), inspector_session_->GetFooter().duration_seconds, inspector_session_->GetFooter().times, - ImGui::GetScrollX(), ImGui::GetWindowWidth(), p.x, p.y, timeline_height, ImGui::GetIO().KeyCtrl, - ImGui::GetIO().MouseWheel, ImGui::IsWindowHovered(), mouse_pos.x, mouse_pos.y); + GetDroppedFrameMap(total_frames), ImGui::GetScrollX(), ImGui::GetWindowWidth(), p.x, p.y, timeline_height, + ImGui::GetIO().KeyCtrl, ImGui::GetIO().MouseWheel, ImGui::IsWindowHovered(), mouse_pos.x, mouse_pos.y); if (strip_vm.request_scroll) { ImGui::SetScrollX(strip_vm.desired_scroll_x); timeline_bar_state_.last_tracked_frame = inspector_session_->GetCurrentFrame(); @@ -217,7 +259,7 @@ void TimelineWindow::DrawFrameStripTimeline(int total_frames) { IM_COL32(30, 30, 30, 255)); for (const auto& bar : strip_vm.bars) { - ImU32 bar_color = IM_COL32(128, 128, 128, 255); + ImU32 bar_color = bar.is_dropped ? IM_COL32(220, 60, 60, 255) : IM_COL32(128, 128, 128, 255); if (bar.is_current) { child_draw_list->AddRectFilled(ImVec2(bar.x_start - 1.0f, p.y), ImVec2(bar.x_end + 1.0f, bar.y_bottom), IM_COL32(255, 255, 255, 100)); @@ -234,6 +276,10 @@ void TimelineWindow::DrawFrameStripTimeline(int total_frames) { ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "Frame: %d", hover_info.frame_index); ImGui::Text("Time: %02d:%02d (%.2fs)", hover_info.frame_time_clock.minutes, hover_info.frame_time_clock.seconds, hover_info.frame_time_seconds); + if (hover_info.in_gap) { + ImGui::TextColored(ImVec4(0.9f, 0.35f, 0.35f, 1.0f), "Gap: %.2fs (~%d frames missing)", + hover_info.gap_ms / 1000.0f, hover_info.gap_missing); + } ImGui::Separator(); ImGui::TextDisabled("(Click to seek)"); ImGui::EndTooltip(); From 286fb4caa528f6c2a71c5f9b8df2cdbc3514ca30 Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 22:01:45 +0200 Subject: [PATCH 4/6] feat(inspector): paint recording gaps on the main slider, time-proportional Each gap from the dropped-frame map is overlaid on the slider track as a red band positioned and sized by wall clock (start_seconds / duration), so band width reads as gap duration - unlike the frame strip, where a two-minute stall spans only a few bars. --- .../include/services/timeline_view_service.h | 13 ++++++++++-- .../src/services/timeline_view_service.cpp | 8 +++++++ .../inspector/src/windows/timeline_window.cpp | 21 ++++++++++++++++++- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/tools/inspector/include/services/timeline_view_service.h b/tools/inspector/include/services/timeline_view_service.h index a2a765e..2ea823e 100644 --- a/tools/inspector/include/services/timeline_view_service.h +++ b/tools/inspector/include/services/timeline_view_service.h @@ -39,12 +39,21 @@ namespace VtxServices { // expected capture rate. A frame is flagged when the wall-clock span from the // previous stamped frame exceeds 1.5x the expected interval; every frame // inside that span carries the gap duration and the estimated missing count. + // One recording gap as a wall-clock span, for time-proportional painting. + struct DroppedFrameGap { + int first_frame = 0; // first flagged frame + int last_frame = 0; // stamped frame that ends the gap + float start_seconds = 0.0f; // elapsed seconds where the gap begins + float duration_ms = 0.0f; + }; + struct DroppedFrameMap { std::vector flagged; // 1 = frame sits in a recording gap std::vector gap_ms; // wall-clock gap the frame belongs to (0 when none) std::vector missing; // estimated frames missing in that gap - int gap_count = 0; // number of distinct gaps - int64_t total_missing = 0; // estimated frames missing across all gaps + std::vector gaps; + int gap_count = 0; // number of distinct gaps + int64_t total_missing = 0; // estimated frames missing across all gaps }; class TimelineViewService { diff --git a/tools/inspector/src/services/timeline_view_service.cpp b/tools/inspector/src/services/timeline_view_service.cpp index 78e8aed..69d40e8 100644 --- a/tools/inspector/src/services/timeline_view_service.cpp +++ b/tools/inspector/src/services/timeline_view_service.cpp @@ -111,6 +111,7 @@ namespace VtxServices { const double expected_interval_ms = 1000.0 / static_cast(expected_fps); const int last_index = std::min(total_frames, static_cast(ticks.size())) - 1; + const uint64_t base_tick = FirstNonZeroTick(ticks); int prev_stamped = -1; for (int frame = 0; frame <= last_index; ++frame) { @@ -133,6 +134,13 @@ namespace VtxServices { map.gap_ms[static_cast(i)] = static_cast(actual_ms); map.missing[static_cast(i)] = missing; } + map.gaps.push_back(DroppedFrameGap { + .first_frame = prev_stamped + 1, + .last_frame = frame, + .start_seconds = static_cast( + static_cast(ticks[static_cast(prev_stamped)] - base_tick) / 10'000'000.0), + .duration_ms = static_cast(actual_ms), + }); } } prev_stamped = frame; diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index 32a12c0..b914ddf 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -204,7 +204,6 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { // Renders main timeline slider and updates scrubbing state. void TimelineWindow::DrawTimelineSlider(int total_frames, float duration_seconds) { - (void)duration_seconds; ImGui::SetNextItemWidth(-1.0f); int current_frame = inspector_session_->GetCurrentFrame(); @@ -212,6 +211,26 @@ void TimelineWindow::DrawTimelineSlider(int total_frames, float duration_seconds inspector_session_->SetCurrentFrame(HandleGoToFrame(current_frame, total_frames)); } inspector_session_->SetScrubbingTimeline(ImGui::IsItemActive()); + + // Overlay recording gaps on the slider track, positioned and sized by wall + // clock so a band's width reads as the gap's duration. + if (duration_seconds > 0.0f) { + const auto& dropped_map = GetDroppedFrameMap(total_frames); + if (!dropped_map.gaps.empty()) { + const ImVec2 rect_min = ImGui::GetItemRectMin(); + const ImVec2 rect_max = ImGui::GetItemRectMax(); + const float rect_width = rect_max.x - rect_min.x; + ImDrawList* draw_list = ImGui::GetWindowDrawList(); + for (const auto& gap : dropped_map.gaps) { + const float t0 = std::clamp(gap.start_seconds / duration_seconds, 0.0f, 1.0f); + const float t1 = + std::clamp((gap.start_seconds + gap.duration_ms / 1000.0f) / duration_seconds, 0.0f, 1.0f); + const float x0 = rect_min.x + t0 * rect_width; + const float x1 = std::max(rect_min.x + t1 * rect_width, x0 + 1.0f); + draw_list->AddRectFilled(ImVec2(x0, rect_min.y), ImVec2(x1, rect_max.y), IM_COL32(220, 60, 60, 150)); + } + } + } } const VtxServices::DroppedFrameMap& TimelineWindow::GetDroppedFrameMap(int total_frames) { From 9577be7546ee2185c334d7cad494797c6abd3943 Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 22:06:18 +0200 Subject: [PATCH 5/6] feat(inspector): main slider scrubs wall-clock time, not frame index The slider axis is now elapsed seconds over the full capture duration: the grab sits at the current frame's footer-table time, and dragging resolves the frame via binary search for the last stamp at or before the grabbed time (zero entries skipped; linear fallback without a table). Equal slider distance now means equal wall-clock time, and the red gap bands align with the grab. Inside a gap the grab resolves to the frame at the gap's start, snapping to it on release. --- .../include/services/timeline_view_service.h | 7 ++++ .../src/services/timeline_view_service.cpp | 41 +++++++++++++++++++ .../inspector/src/windows/timeline_window.cpp | 15 +++++-- 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/tools/inspector/include/services/timeline_view_service.h b/tools/inspector/include/services/timeline_view_service.h index 2ea823e..ce9add9 100644 --- a/tools/inspector/include/services/timeline_view_service.h +++ b/tools/inspector/include/services/timeline_view_service.h @@ -70,6 +70,13 @@ namespace VtxServices { static float FrameToElapsedSeconds(int frame, const VTX::ReplayTimeData& times, int total_frames, float duration_seconds, float fallback_fps = 30.0f); + // Inverse of FrameToElapsedSeconds: the last frame stamped at or before + // `seconds`, found by binary search over the footer time table (zero + // entries are skipped). Falls back to linear time*avg-fps mapping when + // the table is absent. + static int FrameAtElapsedSeconds(float seconds, const VTX::ReplayTimeData& times, int total_frames, + float duration_seconds, float fallback_fps = 30.0f); + // Builds the recording-gap map for the whole replay from the footer time // table (created_utc preferred, game_time fallback). Returns an empty map // (no frames flagged) when the file carries no usable time table. diff --git a/tools/inspector/src/services/timeline_view_service.cpp b/tools/inspector/src/services/timeline_view_service.cpp index 69d40e8..c9f62a5 100644 --- a/tools/inspector/src/services/timeline_view_service.cpp +++ b/tools/inspector/src/services/timeline_view_service.cpp @@ -93,6 +93,47 @@ namespace VtxServices { .current = ToClockTime(current_time_sec), .total = ToClockTime(duration_seconds), .fps = fps}; } + int TimelineViewService::FrameAtElapsedSeconds(float seconds, const VTX::ReplayTimeData& times, int total_frames, + float duration_seconds, float fallback_fps) { + if (total_frames <= 0) { + return 0; + } + const std::vector& ticks = + FirstNonZeroTick(times.created_utc) != 0 ? times.created_utc : times.game_time; + const uint64_t base_tick = FirstNonZeroTick(ticks); + if (base_tick == 0) { + const float fps = ComputePlaybackFps(total_frames, duration_seconds, fallback_fps); + return ClampFrame(static_cast(std::max(seconds, 0.0f) * fps), total_frames); + } + + const uint64_t target_tick = + base_tick + static_cast(std::max(0.0, static_cast(seconds) * 10'000'000.0)); + + // Binary search for the greatest stamped frame with tick <= target. + // Zero (unstamped) entries are skipped by walking the probe left. + int low = 0; + int high = std::min(total_frames, static_cast(ticks.size())) - 1; + int result = 0; + while (low <= high) { + const int mid = low + (high - low) / 2; + int probe = mid; + while (probe >= low && ticks[static_cast(probe)] == 0) { + --probe; + } + if (probe < low) { + low = mid + 1; + continue; + } + if (ticks[static_cast(probe)] <= target_tick) { + result = probe; + low = mid + 1; + } else { + high = probe - 1; + } + } + return ClampFrame(result, total_frames); + } + DroppedFrameMap TimelineViewService::BuildDroppedFrameMap(const VTX::ReplayTimeData& times, int total_frames, float expected_fps) { DroppedFrameMap map; diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index b914ddf..34297e2 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -202,13 +202,20 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { ImGui::EndChild(); } -// Renders main timeline slider and updates scrubbing state. +// Renders the main timeline slider (wall-clock axis) and updates scrubbing +// state. The grab position is the current frame's elapsed time; dragging +// resolves the frame at the grabbed time via the footer time table, so equal +// slider distance means equal wall-clock time even across recording gaps. void TimelineWindow::DrawTimelineSlider(int total_frames, float duration_seconds) { ImGui::SetNextItemWidth(-1.0f); - int current_frame = inspector_session_->GetCurrentFrame(); - if (ImGui::SliderInt("##MainTimeline", ¤t_frame, 0, total_frames - 1, "")) { - inspector_session_->SetCurrentFrame(HandleGoToFrame(current_frame, total_frames)); + const auto& times = inspector_session_->GetFooter().times; + float current_seconds = VtxServices::TimelineViewService::FrameToElapsedSeconds( + inspector_session_->GetCurrentFrame(), times, total_frames, duration_seconds); + if (ImGui::SliderFloat("##MainTimeline", ¤t_seconds, 0.0f, duration_seconds, "")) { + const int frame = VtxServices::TimelineViewService::FrameAtElapsedSeconds(current_seconds, times, total_frames, + duration_seconds); + inspector_session_->SetCurrentFrame(HandleGoToFrame(frame, total_frames)); } inspector_session_->SetScrubbingTimeline(ImGui::IsItemActive()); From a7e2cc2c940d9650280dc43f690adcb960f83cef Mon Sep 17 00:00:00 2001 From: Onur Date: Fri, 24 Jul 2026 22:09:58 +0200 Subject: [PATCH 6/6] feat(inspector): derate entered Drop FPS by 0.75 for real-world rates Captures never sustain their nominal rate, so gap detection now runs at entered_fps * 0.75 (30 -> 22.5 effective). The input label shows the factor and the resulting effective rate. --- tools/inspector/src/windows/timeline_window.cpp | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tools/inspector/src/windows/timeline_window.cpp b/tools/inspector/src/windows/timeline_window.cpp index 34297e2..d29c4a9 100644 --- a/tools/inspector/src/windows/timeline_window.cpp +++ b/tools/inspector/src/windows/timeline_window.cpp @@ -1,6 +1,7 @@ #include "windows/timeline_window.h" #include +#include #include #include @@ -9,6 +10,10 @@ namespace { + // Real captures never sustain their nominal rate; the entered Drop FPS is + // derated by this factor before gap detection. + constexpr float kDropFpsRealWorldFactor = 0.75f; + struct TimelineHoverInfo { bool has_hover = false; int frame_index = -1; @@ -177,7 +182,10 @@ void TimelineWindow::DrawTimeAndFrameInfo(int total_frames, float duration) { // Expected capture rate for the recording-gap (red bar) detection. ImGui::SameLine(); ImGui::SetNextItemWidth(70.0f); - if (ImGui::InputFloat("Drop FPS", &drop_detect_fps_, 0.0f, 0.0f, "%.1f", + char drop_fps_label[64]; + std::snprintf(drop_fps_label, sizeof(drop_fps_label), "Drop FPS (x0.75 = %.1f)###DropFps", + drop_detect_fps_ * kDropFpsRealWorldFactor); + if (ImGui::InputFloat(drop_fps_label, &drop_detect_fps_, 0.0f, 0.0f, "%.1f", ImGuiInputTextFlags_EnterReturnsTrue)) { drop_detect_fps_ = std::clamp(drop_detect_fps_, 1.0f, 1000.0f); } @@ -245,7 +253,8 @@ const VtxServices::DroppedFrameMap& TimelineWindow::GetDroppedFrameMap(int total const size_t stamp_count = times.created_utc.size() + times.game_time.size(); if (dropped_map_fps_ != drop_detect_fps_ || dropped_map_frames_ != total_frames || dropped_map_stamp_count_ != stamp_count) { - dropped_map_ = VtxServices::TimelineViewService::BuildDroppedFrameMap(times, total_frames, drop_detect_fps_); + dropped_map_ = VtxServices::TimelineViewService::BuildDroppedFrameMap( + times, total_frames, drop_detect_fps_ * kDropFpsRealWorldFactor); dropped_map_fps_ = drop_detect_fps_; dropped_map_frames_ = total_frames; dropped_map_stamp_count_ = stamp_count;