vor 0.2.1

Cross-platform performance instrumentation with an in-app egui panel and live system and GPU metrics.
Documentation
//! Replay panel: render a `.vor` capture through the same frame bars,
//! flame chart, and metric rows as the live panel, fed from a stream
//! instead of in-process sampling.
//!
//! Frames flow into bounded rings (one per metric column), so the view
//! is the existing pull-based panel over recorded data. With `follow`
//! on it tails a growing file (live attach); off, or once the file
//! ends, it is a post-mortem of the last [`capacity`](ReplayState)
//! frames. Cursor, pin, range-select, and flame-zoom behave as in the
//! live panel.

use std::collections::{BTreeMap, VecDeque};
use std::fs::File;
use std::io::{self, Cursor};
use std::path::Path;
use std::sync::Arc;

use egui::{Align, Color32, Layout, Rect, Sense, Stroke, Vec2, pos2};
use puffin::{FrameData, FrameView};

use crate::record::read::{Frame, Reader};
use crate::viz::color::health_color;
use crate::viz::flame::flame_chart;
use crate::viz::layout::PlotLayout;
use crate::viz::metric::{LABEL_COL_W, RowSpec, RowView, STAT_COL_W, plot_series};
use crate::viz::state::{FlameView, PanelConfig};

const BARS_H: f32 = 80.0;

/// One metric column's bounded ring of values, kept aligned in length
/// with every other row so a cursor index means the same frame in all.
struct Row {
    name: String,
    unit: String,
    values: VecDeque<f64>,
}

/// Replayed-capture panel state. Open with [`open`](Self::open), then
/// call [`show`](Self::show) each egui frame.
pub struct ReplayState {
    reader: Reader<io::BufReader<File>>,
    n_system: usize,
    frame_ms_row: Option<usize>,
    capacity: usize,
    /// Rows `0..n_system` are the capture's system columns (positional);
    /// the rest are user metrics, appended as they are declared.
    rows: Vec<Row>,
    /// Decoded flame frame per ring slot, aligned with the rows.
    flames: VecDeque<Option<Arc<FrameData>>>,
    len: usize,
    total_frames: u64,
    flame_enabled: bool,
    pinned: Option<usize>,
    selection: Option<(usize, usize)>,
    flame_view: FlameView,
    follow: bool,
}

impl ReplayState {
    /// Open a `.vor` capture for replay.
    pub fn open(path: impl AsRef<Path>) -> io::Result<Self> {
        let reader = Reader::open(path)?;
        let capacity = PanelConfig::FRAME_MS.history_capacity;
        let n_system = reader.columns().len();
        let flame_enabled = reader.flame_enabled();
        let frame_ms_row = reader.columns().iter().position(|c| c.name == "frame_ms");
        let rows = reader
            .columns()
            .iter()
            .map(|c| Row {
                name: c.name.clone(),
                unit: c.unit.clone(),
                values: VecDeque::with_capacity(capacity),
            })
            .collect();
        Ok(Self {
            reader,
            n_system,
            frame_ms_row,
            capacity,
            rows,
            flames: VecDeque::with_capacity(capacity),
            len: 0,
            total_frames: 0,
            flame_enabled,
            pinned: None,
            selection: None,
            flame_view: FlameView::FULL,
            follow: true,
        })
    }

    /// Render the panel, ingesting any newly-available frames first
    /// (unless paused on a pinned frame).
    pub fn show(&mut self, ui: &mut egui::Ui) {
        if self.follow && self.pinned.is_none() {
            self.poll();
        }
        self.header(ui);
        self.bars(ui);
        ui.separator();
        self.flame(ui);
        ui.separator();
        self.metric_rows(ui);
    }

    /// Drain every complete frame the source currently offers into the
    /// rings. Returns at EOF / a partial trailing record; the next
    /// `show` retries, which is how a growing file is tailed.
    fn poll(&mut self) {
        loop {
            let frame = match self.reader.next_frame() {
                Ok(Some(frame)) => frame,
                Ok(None) => return,
                Err(e) => panic!("read .vor: {e}"),
            };
            let known = self.rows.len() - self.n_system;
            let new_cols: Vec<(String, String)> = self.reader.user_columns()[known..]
                .iter()
                .map(|c| (c.name.clone(), c.unit.clone()))
                .collect();
            self.push(frame, new_cols);
        }
    }

    fn push(&mut self, frame: Frame, new_cols: Vec<(String, String)>) {
        let Frame {
            system,
            user,
            flame,
        } = frame;
        let present: BTreeMap<String, f64> = user.into_iter().collect();
        // A metric first seen now gets a fresh row backfilled flat to
        // the current length, so all rows stay the same length.
        for (name, unit) in new_cols {
            let first = present.get(&name).copied().unwrap_or(0.0);
            self.rows.push(Row {
                name,
                unit,
                values: VecDeque::from(vec![first; self.len]),
            });
        }
        assert_eq!(system.len(), self.n_system);
        for (i, value) in system.into_iter().enumerate() {
            self.rows[i].values.push_back(value);
        }
        // User rows take this frame's value, else carry the last one
        // forward so a sparse metric reads as a step, not a gap.
        for row in &mut self.rows[self.n_system..] {
            let value = present
                .get(&row.name)
                .copied()
                .or_else(|| row.values.back().copied())
                .unwrap_or(0.0);
            row.values.push_back(value);
        }
        self.flames.push_back(flame.map(decode_flame));
        self.len += 1;
        self.total_frames += 1;
        if self.len > self.capacity {
            for row in &mut self.rows {
                row.values.pop_front();
            }
            self.flames.pop_front();
            self.len -= 1;
        }
    }

    fn header(&mut self, ui: &mut egui::Ui) {
        ui.horizontal(|ui| {
            if self.pinned.is_some() {
                if ui.small_button("â–¶ resume").clicked() {
                    self.pinned = None;
                    self.selection = None;
                    self.flame_view = FlameView::FULL;
                }
                ui.label("paused · click a bar to inspect · shift-drag to zoom a range");
            } else {
                ui.checkbox(&mut self.follow, "follow");
                ui.label("click a bar to pause & inspect · shift-drag to zoom a range");
            }
            ui.with_layout(Layout::right_to_left(Align::Center), |ui| {
                ui.label(format!("{} frames", self.total_frames));
            });
        });
    }

    fn bars(&mut self, ui: &mut egui::Ui) {
        let PanelConfig {
            history_capacity,
            bar_good_threshold,
            bar_warn_threshold,
        } = PanelConfig::FRAME_MS;
        let (rect, resp) = ui.allocate_exact_size(
            Vec2::new(ui.available_width(), BARS_H),
            Sense::click_and_drag(),
        );
        let painter = ui.painter_at(rect);
        painter.rect_filled(rect, 2.0, Color32::from_gray(18));
        let Some(fm) = self.frame_ms_row else {
            return;
        };
        let n = self.rows[fm].values.len();
        if n == 0 {
            return;
        }

        let shift = ui.input(|i| i.modifiers.shift);
        let selecting = shift && resp.dragged();
        let layout = PlotLayout::new(
            rect,
            n,
            if selecting { None } else { self.selection },
            history_capacity,
        );
        let bar_w = (layout.slot_w - 1.0).max(1.0);
        let y_max = (bar_warn_threshold * 1.5) as f32;
        for (value, color) in [
            (bar_good_threshold, Color32::from_rgb(46, 204, 113)),
            (bar_warn_threshold, Color32::from_rgb(231, 76, 60)),
        ] {
            let y = rect.bottom() - rect.height() * (value as f32 / y_max);
            painter.line_segment([pos2(rect.left(), y), pos2(rect.right(), y)], Stroke::new(1.0, color));
        }
        for i in layout.range.clone() {
            let frame_ms = self.rows[fm].values[i];
            let x = layout.base_x + (i - layout.range.start) as f32 * layout.slot_w;
            let h = (frame_ms as f32 / y_max).clamp(0.02, 1.0) * rect.height();
            let bar = Rect::from_min_max(pos2(x, rect.bottom() - h), pos2(x + bar_w, rect.bottom()));
            painter.rect_filled(bar, 0.0, health_color(frame_ms, bar_good_threshold, bar_warn_threshold));
        }
        if selecting && let Some((lo, hi)) = self.selection {
            let x0 = layout.base_x + lo.saturating_sub(layout.range.start) as f32 * layout.slot_w;
            let x1 = layout.base_x + (hi + 1).saturating_sub(layout.range.start) as f32 * layout.slot_w;
            painter.rect_filled(
                Rect::from_min_max(pos2(x0, rect.top()), pos2(x1, rect.bottom())),
                0.0,
                Color32::from_white_alpha(28),
            );
        }
        if let Some(idx) = self.pinned
            && let Some(x) = layout.cursor_x(idx)
        {
            painter.line_segment(
                [pos2(x, rect.top()), pos2(x, rect.bottom())],
                Stroke::new(1.5, Color32::WHITE),
            );
        }
        self.bar_interaction(&resp, &layout, fm, shift);
    }

    fn bar_interaction(&mut self, resp: &egui::Response, layout: &PlotLayout, fm: usize, shift: bool) {
        if shift
            && resp.drag_started()
            && let Some(pos) = resp.interact_pointer_pos()
            && let Some(idx) = layout.hover_to_idx(pos.x)
        {
            self.selection = Some((idx, idx));
            self.pinned = Some(idx);
            self.flame_view = FlameView::FULL;
        }
        if shift
            && resp.dragged()
            && let Some(pos) = resp.interact_pointer_pos()
            && let Some(idx) = layout.hover_to_idx(pos.x)
            && let Some((anchor, _)) = self.selection
        {
            let (lo, hi) = if anchor <= idx { (anchor, idx) } else { (idx, anchor) };
            self.selection = Some((lo, hi));
            self.pinned = self.slowest(fm, lo, hi).or(Some(idx));
        }
        if (resp.clicked() || (!shift && resp.dragged()))
            && let Some(pos) = resp.interact_pointer_pos()
            && let Some(idx) = layout.hover_to_idx(pos.x)
        {
            self.selection = None;
            self.pinned = Some(idx);
            self.flame_view = FlameView::FULL;
        }
    }

    fn slowest(&self, fm: usize, lo: usize, hi: usize) -> Option<usize> {
        let values = &self.rows[fm].values;
        (lo..=hi.min(values.len().saturating_sub(1)))
            .max_by(|&a, &b| values[a].total_cmp(&values[b]))
    }

    fn flame(&mut self, ui: &mut egui::Ui) {
        if !self.flame_enabled {
            ui.label("(flame frames were not captured in this run)");
            return;
        }
        let idx = self.pinned.unwrap_or(self.len.saturating_sub(1));
        match self.flames.get(idx).and_then(Option::as_ref) {
            Some(frame) => {
                // flame_chart reads from a puffin FrameView; feed it the
                // one selected frame (pinned index 0 of a 1-frame view).
                let mut view = FrameView::default();
                view.add_frame(frame.clone());
                flame_chart::<()>(ui, &view, 1, Some(0), None, &mut self.flame_view);
            }
            None => {
                ui.label("(no flame frame captured at this step)");
            }
        }
    }

    fn metric_rows(&mut self, ui: &mut egui::Ui) {
        let plot_w = (ui.available_width() - LABEL_COL_W - STAT_COL_W - 16.0).max(120.0);
        let view = RowView {
            pinned: self.pinned,
            selection: self.selection,
            plot_w,
            history_capacity: self.capacity,
        };
        for row in &self.rows {
            let samples: Vec<f64> = row.values.iter().copied().collect();
            let spec = RowSpec {
                name: &row.name,
                unit: &row.unit,
                integer: false,
                description: "",
            };
            plot_series(ui, &spec, &samples, view, None);
        }
    }
}

fn decode_flame(bytes: Vec<u8>) -> Arc<FrameData> {
    let mut cursor = Cursor::new(bytes);
    Arc::new(FrameData::read_next(&mut cursor).unwrap().unwrap())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::record::format::{Column, Header, Record, VERSION, write_decl, write_frame, write_header};

    #[test]
    fn ingest_aligns_rows_and_backfills_late_metrics() {
        let mut bytes = Vec::new();
        write_header(
            &mut bytes,
            &Header {
                version: VERSION,
                flame_enabled: false,
                columns: vec![
                    Column { name: "frame_ms".to_owned(), unit: "ms".to_owned() },
                    Column { name: "memory_mb".to_owned(), unit: "MB".to_owned() },
                ],
            },
        )
        .unwrap();
        let frame = |sys: Vec<f64>, user: Vec<(u16, f64)>| Record {
            system: sys,
            user,
            flame: None,
        };
        write_frame(&mut bytes, &frame(vec![16.0, 100.0], vec![])).unwrap();
        // loss appears only from the second frame on (id past the 2 system columns).
        write_decl(&mut bytes, 2, "loss", "").unwrap();
        write_frame(&mut bytes, &frame(vec![17.0, 101.0], vec![(2, 0.5)])).unwrap();
        write_frame(&mut bytes, &frame(vec![18.0, 102.0], vec![(2, 0.4)])).unwrap();

        let path = std::env::temp_dir().join("vor_replay_ingest_test.vor");
        std::fs::write(&path, &bytes).unwrap();
        let mut state = ReplayState::open(&path).unwrap();
        state.poll();
        std::fs::remove_file(&path).unwrap();

        assert_eq!(state.total_frames, 3);
        assert_eq!(state.len, 3);
        assert_eq!(state.rows.len(), 3);
        let row = |name: &str| {
            let r = state.rows.iter().find(|r| r.name == name).unwrap();
            r.values.iter().copied().collect::<Vec<_>>()
        };
        assert_eq!(row("frame_ms"), vec![16.0, 17.0, 18.0]);
        assert_eq!(row("memory_mb"), vec![100.0, 101.0, 102.0]);
        // loss is backfilled flat to its first value for frame 0, then real.
        assert_eq!(row("loss"), vec![0.5, 0.5, 0.4]);
    }
}