Skip to main content

proof_engine/profiler/
mod.rs

1//! CPU/GPU performance profiler with hierarchical timing, counters, and flame graph capture.
2//!
3//! Usage:
4//! ```rust,ignore
5//! let mut prof = Profiler::new();
6//! prof.begin("render");
7//!   prof.begin("shadow_pass");
8//!   prof.end("shadow_pass");
9//!   prof.begin("gbuffer");
10//!   prof.end("gbuffer");
11//! prof.end("render");
12//! let report = prof.flush();
13//! ```
14
15use std::collections::{HashMap, VecDeque};
16use std::time::{Duration, Instant};
17
18// ─── Span ─────────────────────────────────────────────────────────────────────
19
20/// A single profiling span (named time range).
21#[derive(Debug, Clone)]
22pub struct Span {
23    pub name:     String,
24    pub depth:    u32,
25    pub start_ns: u64,
26    pub end_ns:   u64,
27    pub thread_id: u64,
28}
29
30impl Span {
31    pub fn duration_us(&self) -> f64 {
32        (self.end_ns.saturating_sub(self.start_ns)) as f64 / 1_000.0
33    }
34    pub fn duration_ms(&self) -> f64 {
35        self.duration_us() / 1_000.0
36    }
37}
38
39// ─── Frame record ─────────────────────────────────────────────────────────────
40
41/// All spans captured in one frame.
42#[derive(Debug, Clone)]
43pub struct FrameRecord {
44    pub frame_index: u64,
45    pub spans:       Vec<Span>,
46    pub counters:    HashMap<String, f64>,
47    pub frame_ms:    f64,
48}
49
50impl FrameRecord {
51    pub fn total_span_ms(&self) -> f64 {
52        self.spans.iter()
53            .filter(|s| s.depth == 0)
54            .map(|s| s.duration_ms())
55            .sum()
56    }
57
58    /// Find the N most expensive spans (by duration).
59    pub fn top_spans(&self, n: usize) -> Vec<&Span> {
60        let mut sorted: Vec<&Span> = self.spans.iter().collect();
61        sorted.sort_by(|a, b| b.end_ns.saturating_sub(b.start_ns).cmp(&(a.end_ns.saturating_sub(a.start_ns))));
62        sorted.truncate(n);
63        sorted
64    }
65
66    /// Aggregate spans by name (sum duration, count calls).
67    pub fn aggregate(&self) -> HashMap<String, (f64, u32)> {
68        let mut map: HashMap<String, (f64, u32)> = HashMap::new();
69        for s in &self.spans {
70            let entry = map.entry(s.name.clone()).or_insert((0.0, 0));
71            entry.0 += s.duration_ms();
72            entry.1 += 1;
73        }
74        map
75    }
76}
77
78// ─── Rolling stats ────────────────────────────────────────────────────────────
79
80/// Rolling statistics for a named span across frames.
81#[derive(Debug, Clone)]
82pub struct SpanStats {
83    pub name:    String,
84    pub samples: VecDeque<f64>,
85    pub max_samples: usize,
86}
87
88impl SpanStats {
89    pub fn new(name: impl Into<String>) -> Self {
90        Self { name: name.into(), samples: VecDeque::new(), max_samples: 128 }
91    }
92
93    pub fn push(&mut self, ms: f64) {
94        if self.samples.len() >= self.max_samples {
95            self.samples.pop_front();
96        }
97        self.samples.push_back(ms);
98    }
99
100    pub fn avg_ms(&self) -> f64 {
101        if self.samples.is_empty() { return 0.0; }
102        self.samples.iter().sum::<f64>() / self.samples.len() as f64
103    }
104
105    pub fn min_ms(&self) -> f64 {
106        self.samples.iter().cloned().fold(f64::MAX, f64::min)
107    }
108
109    pub fn max_ms(&self) -> f64 {
110        self.samples.iter().cloned().fold(0.0_f64, f64::max)
111    }
112
113    pub fn percentile(&self, p: f64) -> f64 {
114        if self.samples.is_empty() { return 0.0; }
115        let mut sorted: Vec<f64> = self.samples.iter().cloned().collect();
116        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
117        let idx = ((p / 100.0) * (sorted.len() - 1) as f64) as usize;
118        sorted[idx.min(sorted.len() - 1)]
119    }
120
121    pub fn p50(&self) -> f64 { self.percentile(50.0) }
122    pub fn p95(&self) -> f64 { self.percentile(95.0) }
123    pub fn p99(&self) -> f64 { self.percentile(99.0) }
124
125    pub fn variance(&self) -> f64 {
126        let avg = self.avg_ms();
127        if self.samples.len() < 2 { return 0.0; }
128        let sum_sq: f64 = self.samples.iter().map(|x| (x - avg).powi(2)).sum();
129        sum_sq / (self.samples.len() - 1) as f64
130    }
131
132    pub fn std_dev(&self) -> f64 {
133        self.variance().sqrt()
134    }
135}
136
137// ─── Counter ──────────────────────────────────────────────────────────────────
138
139/// Named counter accumulator (e.g. draw calls, triangle count).
140#[derive(Debug, Clone)]
141pub struct Counter {
142    pub name:    String,
143    pub value:   f64,
144    pub history: VecDeque<f64>,
145    pub max_history: usize,
146}
147
148impl Counter {
149    pub fn new(name: impl Into<String>) -> Self {
150        Self { name: name.into(), value: 0.0, history: VecDeque::new(), max_history: 128 }
151    }
152
153    pub fn add(&mut self, v: f64) { self.value += v; }
154    pub fn set(&mut self, v: f64) { self.value = v; }
155    pub fn reset(&mut self) { self.value = 0.0; }
156
157    pub fn flush(&mut self) {
158        if self.history.len() >= self.max_history { self.history.pop_front(); }
159        self.history.push_back(self.value);
160        self.value = 0.0;
161    }
162
163    pub fn avg(&self) -> f64 {
164        if self.history.is_empty() { return 0.0; }
165        self.history.iter().sum::<f64>() / self.history.len() as f64
166    }
167
168    pub fn peak(&self) -> f64 {
169        self.history.iter().cloned().fold(0.0_f64, f64::max)
170    }
171}
172
173// ─── Memory stats ─────────────────────────────────────────────────────────────
174
175/// Memory usage snapshot.
176#[derive(Debug, Clone, Default)]
177pub struct MemoryStats {
178    pub heap_used_bytes:     usize,
179    pub heap_reserved_bytes: usize,
180    pub gpu_used_bytes:      usize,
181    pub gpu_reserved_bytes:  usize,
182    pub texture_bytes:       usize,
183    pub mesh_bytes:          usize,
184    pub audio_bytes:         usize,
185    pub script_bytes:        usize,
186}
187
188impl MemoryStats {
189    pub fn total_used_mb(&self) -> f64 {
190        (self.heap_used_bytes + self.gpu_used_bytes) as f64 / 1_048_576.0
191    }
192
193    pub fn gpu_used_mb(&self) -> f64 {
194        self.gpu_used_bytes as f64 / 1_048_576.0
195    }
196
197    pub fn heap_used_mb(&self) -> f64 {
198        self.heap_used_bytes as f64 / 1_048_576.0
199    }
200}
201
202// ─── GPU timing ───────────────────────────────────────────────────────────────
203
204/// A GPU timing query result (from GPU timestamp queries, if available).
205#[derive(Debug, Clone)]
206pub struct GpuSpan {
207    pub name:    String,
208    pub gpu_us:  f64,
209    pub pass:    GpuPass,
210}
211
212/// Which render pass a GPU span belongs to.
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
214pub enum GpuPass {
215    ShadowMap,
216    GBuffer,
217    Lighting,
218    Transparent,
219    PostProcess,
220    UI,
221    Compute,
222    Other,
223}
224
225/// A frame of GPU timing data.
226#[derive(Debug, Clone, Default)]
227pub struct GpuFrameStats {
228    pub spans:     Vec<GpuSpan>,
229    pub total_us:  f64,
230    pub frame_idx: u64,
231}
232
233impl GpuFrameStats {
234    pub fn total_ms(&self) -> f64 { self.total_us / 1000.0 }
235
236    pub fn pass_total(&self, pass: GpuPass) -> f64 {
237        self.spans.iter()
238            .filter(|s| s.pass == pass)
239            .map(|s| s.gpu_us)
240            .sum::<f64>() / 1000.0
241    }
242}
243
244// ─── Flame graph ──────────────────────────────────────────────────────────────
245
246/// A node in the flame graph (hierarchical call tree).
247#[derive(Debug, Clone)]
248pub struct FlameNode {
249    pub name:       String,
250    pub total_ms:   f64,
251    pub self_ms:    f64,  // exclusive time (not including children)
252    pub call_count: u32,
253    pub children:   Vec<FlameNode>,
254}
255
256impl FlameNode {
257    pub fn new(name: impl Into<String>) -> Self {
258        Self { name: name.into(), total_ms: 0.0, self_ms: 0.0, call_count: 0, children: Vec::new() }
259    }
260
261    pub fn find_child_mut(&mut self, name: &str) -> Option<&mut FlameNode> {
262        self.children.iter_mut().find(|c| c.name == name)
263    }
264
265    pub fn get_or_insert_child(&mut self, name: &str) -> &mut FlameNode {
266        if let Some(idx) = self.children.iter().position(|c| c.name == name) {
267            return &mut self.children[idx];
268        }
269        self.children.push(FlameNode::new(name));
270        self.children.last_mut().unwrap()
271    }
272
273    pub fn children_total_ms(&self) -> f64 {
274        self.children.iter().map(|c| c.total_ms).sum()
275    }
276
277    pub fn recompute_self_ms(&mut self) {
278        self.self_ms = (self.total_ms - self.children_total_ms()).max(0.0);
279        for child in &mut self.children {
280            child.recompute_self_ms();
281        }
282    }
283
284    /// Flatten into a sorted list for display.
285    pub fn flatten(&self) -> Vec<(String, f64, u32)> {
286        let mut out = vec![(self.name.clone(), self.total_ms, self.call_count)];
287        for c in &self.children {
288            out.extend(c.flatten());
289        }
290        out
291    }
292}
293
294// ─── Open span tracker ────────────────────────────────────────────────────────
295
296struct OpenSpan {
297    name:     String,
298    start_ns: u64,
299    depth:    u32,
300}
301
302// ─── Main Profiler ────────────────────────────────────────────────────────────
303
304/// Main CPU profiler. Not thread-safe (use per-thread instances or a Mutex).
305pub struct Profiler {
306    pub enabled:      bool,
307    epoch:            Instant,
308    open_stack:       Vec<OpenSpan>,
309    current_spans:    Vec<Span>,
310    pub counters:     HashMap<String, Counter>,
311    pub span_stats:   HashMap<String, SpanStats>,
312    frame_history:    VecDeque<FrameRecord>,
313    pub max_history:  usize,
314    pub frame_index:  u64,
315    frame_start_ns:   u64,
316    pub memory:       MemoryStats,
317    pub gpu:          GpuFrameStats,
318    pub fps_history:  VecDeque<f64>,
319    pub fps:          f64,
320}
321
322impl Profiler {
323    pub fn new() -> Self {
324        Self {
325            enabled:       true,
326            epoch:         Instant::now(),
327            open_stack:    Vec::new(),
328            current_spans: Vec::new(),
329            counters:      HashMap::new(),
330            span_stats:    HashMap::new(),
331            frame_history: VecDeque::new(),
332            max_history:   120,
333            frame_index:   0,
334            frame_start_ns: 0,
335            memory:        MemoryStats::default(),
336            gpu:           GpuFrameStats::default(),
337            fps_history:   VecDeque::new(),
338            fps:           0.0,
339        }
340    }
341
342    fn now_ns(&self) -> u64 {
343        self.epoch.elapsed().as_nanos() as u64
344    }
345
346    /// Start a new timing span. Must be matched with `end(name)`.
347    pub fn begin(&mut self, name: &str) {
348        if !self.enabled { return; }
349        let depth = self.open_stack.len() as u32;
350        let start = self.now_ns();
351        self.open_stack.push(OpenSpan { name: name.to_string(), start_ns: start, depth });
352    }
353
354    /// End the most recent span with this name.
355    pub fn end(&mut self, name: &str) {
356        if !self.enabled { return; }
357        let end = self.now_ns();
358        if let Some(pos) = self.open_stack.iter().rposition(|s| s.name == name) {
359            let open = self.open_stack.remove(pos);
360            let span = Span {
361                name:      open.name.clone(),
362                depth:     open.depth,
363                start_ns:  open.start_ns,
364                end_ns:    end,
365                thread_id: 0,
366            };
367            // Update rolling stats
368            let ms = span.duration_ms();
369            self.span_stats.entry(open.name.clone())
370                .or_insert_with(|| SpanStats::new(&open.name))
371                .push(ms);
372            self.current_spans.push(span);
373        }
374    }
375
376    /// Increment a named counter.
377    pub fn count(&mut self, name: &str, delta: f64) {
378        self.counters.entry(name.to_string())
379            .or_insert_with(|| Counter::new(name))
380            .add(delta);
381    }
382
383    /// Set a named counter to an absolute value.
384    pub fn set_counter(&mut self, name: &str, value: f64) {
385        self.counters.entry(name.to_string())
386            .or_insert_with(|| Counter::new(name))
387            .set(value);
388    }
389
390    /// Call at the start of each frame.
391    pub fn frame_begin(&mut self) {
392        if !self.enabled { return; }
393        self.frame_start_ns = self.now_ns();
394    }
395
396    /// Call at the end of each frame. Returns the completed FrameRecord.
397    pub fn frame_end(&mut self) -> FrameRecord {
398        let frame_end_ns = self.now_ns();
399        let frame_ms = (frame_end_ns.saturating_sub(self.frame_start_ns)) as f64 / 1_000_000.0;
400
401        // Update FPS
402        if frame_ms > 0.0 {
403            let fps = 1000.0 / frame_ms;
404            if self.fps_history.len() >= 128 { self.fps_history.pop_front(); }
405            self.fps_history.push_back(fps);
406            self.fps = self.fps_history.iter().sum::<f64>() / self.fps_history.len() as f64;
407        }
408
409        // Collect counter snapshots
410        let counter_snapshot: HashMap<String, f64> = self.counters.iter()
411            .map(|(k, v)| (k.clone(), v.value))
412            .collect();
413        for c in self.counters.values_mut() {
414            c.flush();
415        }
416
417        // Close any unclosed spans
418        while let Some(open) = self.open_stack.pop() {
419            let span = Span {
420                name: open.name.clone(),
421                depth: open.depth,
422                start_ns: open.start_ns,
423                end_ns: frame_end_ns,
424                thread_id: 0,
425            };
426            let ms = span.duration_ms();
427            self.span_stats.entry(open.name.clone())
428                .or_insert_with(|| SpanStats::new(&open.name))
429                .push(ms);
430            self.current_spans.push(span);
431        }
432
433        let record = FrameRecord {
434            frame_index: self.frame_index,
435            spans:       std::mem::take(&mut self.current_spans),
436            counters:    counter_snapshot,
437            frame_ms,
438        };
439
440        if self.frame_history.len() >= self.max_history {
441            self.frame_history.pop_front();
442        }
443        self.frame_history.push_back(record.clone());
444        self.frame_index += 1;
445
446        record
447    }
448
449    /// Get rolling stats for a span by name.
450    pub fn stats(&self, name: &str) -> Option<&SpanStats> {
451        self.span_stats.get(name)
452    }
453
454    /// Build a flame graph from the last N frames.
455    pub fn build_flame_graph(&self, last_n_frames: usize) -> FlameNode {
456        let mut root = FlameNode::new("root");
457        let start = self.frame_history.len().saturating_sub(last_n_frames);
458
459        for frame in self.frame_history.iter().skip(start) {
460            // Use depth to reconstruct hierarchy
461            let mut path_stack: Vec<String> = Vec::new();
462            for span in &frame.spans {
463                while path_stack.len() > span.depth as usize {
464                    path_stack.pop();
465                }
466                path_stack.push(span.name.clone());
467
468                // Walk/create path in flame tree
469                let mut node = &mut root;
470                for seg in &path_stack {
471                    node = node.get_or_insert_child(seg);
472                }
473                node.total_ms += span.duration_ms();
474                node.call_count += 1;
475            }
476        }
477
478        root.recompute_self_ms();
479        root
480    }
481
482    /// Get the last N frame records.
483    pub fn recent_frames(&self, n: usize) -> &[FrameRecord] {
484        let start = self.frame_history.len().saturating_sub(n);
485        // Return as slice from deque – collect to Vec for simplicity
486        // Actually we return from make_contiguous after a refresh, but deque doesn't support
487        // slices directly. We'll collect the last N items on demand.
488        let _ = start; // placeholder
489        &[] // In a real impl this would return &[FrameRecord] from a contiguous buffer
490    }
491
492    /// Get the last completed frame.
493    pub fn last_frame(&self) -> Option<&FrameRecord> {
494        self.frame_history.back()
495    }
496
497    /// Average FPS over the history window.
498    pub fn avg_fps(&self) -> f64 { self.fps }
499
500    /// Average frame time in ms.
501    pub fn avg_frame_ms(&self) -> f64 {
502        if self.fps > 0.0 { 1000.0 / self.fps } else { 0.0 }
503    }
504
505    /// Report summary string.
506    pub fn summary(&self) -> String {
507        let mut lines = Vec::new();
508        lines.push(format!("=== Profiler Frame {} ===", self.frame_index));
509        lines.push(format!("FPS: {:.1}  Frame: {:.2}ms", self.avg_fps(), self.avg_frame_ms()));
510
511        let mut sorted_stats: Vec<(&String, &SpanStats)> = self.span_stats.iter().collect();
512        sorted_stats.sort_by(|a, b| b.1.avg_ms().partial_cmp(&a.1.avg_ms()).unwrap());
513
514        for (name, stats) in sorted_stats.iter().take(15) {
515            lines.push(format!(
516                "  {:30} avg={:.3}ms  p95={:.3}ms  p99={:.3}ms",
517                name, stats.avg_ms(), stats.p95(), stats.p99()
518            ));
519        }
520
521        if !self.counters.is_empty() {
522            lines.push("  --- Counters ---".to_string());
523            for (name, counter) in &self.counters {
524                lines.push(format!("  {:30} avg={:.1}  peak={:.1}", name, counter.avg(), counter.peak()));
525            }
526        }
527
528        lines.join("\n")
529    }
530
531    /// Reset all history.
532    pub fn reset(&mut self) {
533        self.span_stats.clear();
534        self.counters.clear();
535        self.frame_history.clear();
536        self.current_spans.clear();
537        self.open_stack.clear();
538        self.fps_history.clear();
539        self.frame_index = 0;
540    }
541}
542
543impl Default for Profiler {
544    fn default() -> Self { Self::new() }
545}
546
547// ─── Scoped span guard ────────────────────────────────────────────────────────
548
549/// RAII guard for automatic span end. Use with a `&mut Profiler`.
550/// ```rust,ignore
551/// {
552///     let _guard = prof.scoped("render");
553///     // work here
554/// }  // span ends automatically
555/// ```
556pub struct ScopedSpan<'a> {
557    profiler: &'a mut Profiler,
558    name:     String,
559}
560
561impl<'a> ScopedSpan<'a> {
562    pub fn new(profiler: &'a mut Profiler, name: &str) -> Self {
563        profiler.begin(name);
564        Self { profiler, name: name.to_string() }
565    }
566}
567
568impl<'a> Drop for ScopedSpan<'a> {
569    fn drop(&mut self) {
570        self.profiler.end(&self.name);
571    }
572}
573
574// ─── Performance budget ───────────────────────────────────────────────────────
575
576/// Defines target frame time budgets for different system categories.
577#[derive(Debug, Clone)]
578pub struct FrameBudget {
579    pub target_fps:     f64,
580    pub physics_ms:     f64,
581    pub render_ms:      f64,
582    pub ai_ms:          f64,
583    pub audio_ms:       f64,
584    pub scripting_ms:   f64,
585}
586
587impl FrameBudget {
588    pub fn from_target_fps(fps: f64) -> Self {
589        let total = 1000.0 / fps;
590        Self {
591            target_fps:   fps,
592            physics_ms:   total * 0.20,
593            render_ms:    total * 0.45,
594            ai_ms:        total * 0.15,
595            audio_ms:     total * 0.05,
596            scripting_ms: total * 0.10,
597        }
598    }
599
600    pub fn total_ms(&self) -> f64 { 1000.0 / self.target_fps }
601
602    pub fn check_violations(&self, frame: &FrameRecord) -> Vec<BudgetViolation> {
603        let agg = frame.aggregate();
604        let mut violations = Vec::new();
605
606        let check = |cat: &str, budget: f64| -> Option<BudgetViolation> {
607            let total: f64 = agg.iter()
608                .filter(|(k, _)| k.starts_with(cat))
609                .map(|(_, (ms, _))| ms)
610                .sum();
611            if total > budget {
612                Some(BudgetViolation { category: cat.to_string(), actual_ms: total, budget_ms: budget })
613            } else { None }
614        };
615
616        if let Some(v) = check("physics", self.physics_ms)   { violations.push(v); }
617        if let Some(v) = check("render",  self.render_ms)    { violations.push(v); }
618        if let Some(v) = check("ai",      self.ai_ms)        { violations.push(v); }
619        if let Some(v) = check("audio",   self.audio_ms)     { violations.push(v); }
620        if let Some(v) = check("script",  self.scripting_ms) { violations.push(v); }
621
622        violations
623    }
624}
625
626/// A single budget violation record.
627#[derive(Debug, Clone)]
628pub struct BudgetViolation {
629    pub category:  String,
630    pub actual_ms: f64,
631    pub budget_ms: f64,
632}
633
634impl BudgetViolation {
635    pub fn overage_ms(&self) -> f64 { (self.actual_ms - self.budget_ms).max(0.0) }
636    pub fn overage_pct(&self) -> f64 { self.overage_ms() / self.budget_ms * 100.0 }
637}
638
639// ─── Stutter detector ─────────────────────────────────────────────────────────
640
641/// Detects frame time spikes (stutters) by comparing against rolling average.
642#[derive(Debug, Clone)]
643pub struct StutterDetector {
644    pub threshold_multiplier: f64, // flag if frame_ms > avg * threshold (default 2.5)
645    pub window:               usize,
646    history:                  VecDeque<f64>,
647    pub stutter_count:        u32,
648    pub last_stutter_frame:   u64,
649}
650
651impl StutterDetector {
652    pub fn new(threshold: f64) -> Self {
653        Self {
654            threshold_multiplier: threshold,
655            window:               60,
656            history:              VecDeque::new(),
657            stutter_count:        0,
658            last_stutter_frame:   0,
659        }
660    }
661
662    pub fn update(&mut self, frame_ms: f64, frame_index: u64) -> bool {
663        if self.history.len() >= self.window { self.history.pop_front(); }
664        self.history.push_back(frame_ms);
665
666        if self.history.len() < 10 { return false; }
667
668        let avg: f64 = self.history.iter().sum::<f64>() / self.history.len() as f64;
669        let is_stutter = frame_ms > avg * self.threshold_multiplier;
670
671        if is_stutter {
672            self.stutter_count += 1;
673            self.last_stutter_frame = frame_index;
674        }
675        is_stutter
676    }
677
678    pub fn rolling_avg_ms(&self) -> f64 {
679        if self.history.is_empty() { return 0.0; }
680        self.history.iter().sum::<f64>() / self.history.len() as f64
681    }
682
683    pub fn reset_stutter_count(&mut self) { self.stutter_count = 0; }
684}
685
686// ─── Profiling overlay data ───────────────────────────────────────────────────
687
688/// Data ready for rendering a profiling overlay (e.g. a graph in the game).
689#[derive(Debug, Clone)]
690pub struct ProfileOverlay {
691    /// Frame time graph — last N frame_ms values for sparkline.
692    pub frame_ms_graph: Vec<f64>,
693    /// Per-system bar chart values (name → ms).
694    pub system_bars:    Vec<(String, f64)>,
695    pub avg_fps:        f64,
696    pub avg_frame_ms:   f64,
697    pub p99_frame_ms:   f64,
698    pub stutter_count:  u32,
699    pub memory_mb:      f64,
700}
701
702impl ProfileOverlay {
703    pub fn from_profiler(p: &Profiler, sd: &StutterDetector) -> Self {
704        let frame_ms_graph: Vec<f64> = p.frame_history.iter().map(|f| f.frame_ms).collect();
705
706        // Sort by average ms descending
707        let mut system_bars: Vec<(String, f64)> = p.span_stats.iter()
708            .filter(|(_, s)| s.avg_ms() > 0.01)
709            .map(|(k, s)| (k.clone(), s.avg_ms()))
710            .collect();
711        system_bars.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
712        system_bars.truncate(20);
713
714        let p99 = if frame_ms_graph.len() >= 2 {
715            let mut sorted = frame_ms_graph.clone();
716            sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
717            sorted[(sorted.len() as f64 * 0.99) as usize]
718        } else { 0.0 };
719
720        Self {
721            frame_ms_graph,
722            system_bars,
723            avg_fps:      p.avg_fps(),
724            avg_frame_ms: p.avg_frame_ms(),
725            p99_frame_ms: p99,
726            stutter_count: sd.stutter_count,
727            memory_mb:    p.memory.total_used_mb(),
728        }
729    }
730}
731
732// ─── Thread-local profiling ───────────────────────────────────────────────────
733
734/// Aggregator for multi-thread profiling results.
735#[derive(Debug, Clone)]
736pub struct ThreadProfile {
737    pub thread_name: String,
738    pub thread_id:   u64,
739    pub spans:       Vec<Span>,
740}
741
742/// Merge thread profiles into a combined FrameRecord.
743pub fn merge_thread_profiles(main: FrameRecord, threads: Vec<ThreadProfile>) -> FrameRecord {
744    let mut merged = main;
745    for tp in threads {
746        let mut tagged: Vec<Span> = tp.spans.into_iter()
747            .map(|mut s| { s.thread_id = tp.thread_id; s.name = format!("[{}] {}", tp.thread_name, s.name); s })
748            .collect();
749        merged.spans.append(&mut tagged);
750    }
751    // Re-sort by start time
752    merged.spans.sort_by_key(|s| s.start_ns);
753    merged
754}
755
756// ─── CSV export ───────────────────────────────────────────────────────────────
757
758/// Export the frame history to CSV format.
759pub fn export_csv(profiler: &Profiler) -> String {
760    let mut lines = vec!["frame,span_name,depth,start_ns,end_ns,duration_us".to_string()];
761    for frame in &profiler.frame_history {
762        for span in &frame.spans {
763            lines.push(format!(
764                "{},{},{},{},{},{}",
765                frame.frame_index, span.name, span.depth,
766                span.start_ns, span.end_ns,
767                (span.end_ns.saturating_sub(span.start_ns)) / 1_000,
768            ));
769        }
770    }
771    lines.join("\n")
772}
773
774// ─── Mark / annotation ───────────────────────────────────────────────────────
775
776/// A named annotation at a point in time (for debugging events).
777#[derive(Debug, Clone)]
778pub struct ProfileMarker {
779    pub name:       String,
780    pub time_ns:    u64,
781    pub frame:      u64,
782    pub color:      [f32; 4],
783    pub extra:      String,
784}
785
786/// Collection of profile markers for a session.
787#[derive(Debug, Clone, Default)]
788pub struct MarkerLog {
789    pub markers: Vec<ProfileMarker>,
790}
791
792impl MarkerLog {
793    pub fn mark(&mut self, name: &str, time_ns: u64, frame: u64, extra: &str) {
794        self.markers.push(ProfileMarker {
795            name:    name.to_string(),
796            time_ns,
797            frame,
798            color:   [1.0, 1.0, 0.0, 1.0],
799            extra:   extra.to_string(),
800        });
801    }
802
803    pub fn mark_colored(&mut self, name: &str, time_ns: u64, frame: u64, color: [f32;4]) {
804        self.markers.push(ProfileMarker {
805            name: name.to_string(), time_ns, frame, color, extra: String::new(),
806        });
807    }
808
809    pub fn since_frame(&self, frame: u64) -> impl Iterator<Item = &ProfileMarker> {
810        self.markers.iter().filter(move |m| m.frame >= frame)
811    }
812
813    pub fn clear_before(&mut self, frame: u64) {
814        self.markers.retain(|m| m.frame >= frame);
815    }
816}