Skip to main content

proof_engine/editor/
profiler.rs

1// profiler.rs — CPU/GPU profiler with hierarchical spans, flame graph data,
2// counter tracking, memory accounting, and budget alerts.
3
4use std::collections::{HashMap, VecDeque};
5use std::fmt;
6
7// ─── Time primitives ─────────────────────────────────────────────────────────
8
9/// Monotonic timestamp in microseconds
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
11pub struct Timestamp(pub u64);
12
13impl Timestamp {
14    pub fn elapsed_since(&self, earlier: Timestamp) -> Duration {
15        Duration(self.0.saturating_sub(earlier.0))
16    }
17}
18
19/// Duration in microseconds
20#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
21pub struct Duration(pub u64);
22
23impl Duration {
24    pub const ZERO: Self = Self(0);
25
26    pub fn from_us(us: u64) -> Self { Self(us) }
27    pub fn from_ms(ms: f64) -> Self { Self((ms * 1000.0) as u64) }
28    pub fn from_secs(s: f64) -> Self { Self((s * 1_000_000.0) as u64) }
29
30    pub fn as_us(&self) -> u64 { self.0 }
31    pub fn as_ms(&self) -> f64 { self.0 as f64 / 1000.0 }
32    pub fn as_secs(&self) -> f64 { self.0 as f64 / 1_000_000.0 }
33
34    pub fn add(self, other: Self) -> Self { Self(self.0 + other.0) }
35    pub fn saturating_sub(self, other: Self) -> Self { Self(self.0.saturating_sub(other.0)) }
36}
37
38impl fmt::Display for Duration {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        if self.0 < 1000 {
41            write!(f, "{}µs", self.0)
42        } else if self.0 < 1_000_000 {
43            write!(f, "{:.2}ms", self.as_ms())
44        } else {
45            write!(f, "{:.3}s", self.as_secs())
46        }
47    }
48}
49
50// ─── Span ─────────────────────────────────────────────────────────────────────
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
53pub struct SpanId(pub u32);
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub enum SpanKind {
57    Cpu,
58    Gpu,
59    Memory,
60    Io,
61    Script,
62    Physics,
63    Render,
64    Audio,
65    Custom,
66}
67
68impl SpanKind {
69    pub fn color_rgb(&self) -> [u8; 3] {
70        match self {
71            Self::Cpu     => [70, 130, 200],
72            Self::Gpu     => [200, 100, 50],
73            Self::Memory  => [100, 180, 80],
74            Self::Io      => [180, 60, 180],
75            Self::Script  => [220, 180, 40],
76            Self::Physics => [60, 180, 180],
77            Self::Render  => [220, 80, 80],
78            Self::Audio   => [80, 200, 140],
79            Self::Custom  => [160, 160, 160],
80        }
81    }
82}
83
84#[derive(Debug, Clone)]
85pub struct ProfileSpan {
86    pub id: SpanId,
87    pub name: String,
88    pub kind: SpanKind,
89    pub start: Timestamp,
90    pub end: Option<Timestamp>,
91    pub parent: Option<SpanId>,
92    pub depth: u32,
93    pub thread: u32,
94    pub extra: Option<String>,
95}
96
97impl ProfileSpan {
98    pub fn duration(&self) -> Option<Duration> {
99        self.end.map(|e| e.elapsed_since(self.start))
100    }
101
102    pub fn is_open(&self) -> bool { self.end.is_none() }
103
104    pub fn contains(&self, other: &ProfileSpan) -> bool {
105        if let (Some(self_end), Some(other_end)) = (self.end, other.end) {
106            self.start <= other.start && self_end >= other_end
107        } else {
108            false
109        }
110    }
111}
112
113// ─── Frame data ───────────────────────────────────────────────────────────────
114
115#[derive(Debug, Clone)]
116pub struct FrameProfile {
117    pub frame_number: u64,
118    pub frame_start: Timestamp,
119    pub frame_end: Option<Timestamp>,
120    pub spans: Vec<ProfileSpan>,
121    pub counters: HashMap<String, CounterSample>,
122    pub memory: MemorySnapshot,
123    pub gpu_timestamp_start: Timestamp,
124    pub gpu_timestamp_end: Option<Timestamp>,
125}
126
127impl FrameProfile {
128    pub fn new(frame_number: u64, start: Timestamp) -> Self {
129        Self {
130            frame_number,
131            frame_start: start,
132            frame_end: None,
133            spans: Vec::new(),
134            counters: HashMap::new(),
135            memory: MemorySnapshot::default(),
136            gpu_timestamp_start: Timestamp::default(),
137            gpu_timestamp_end: None,
138        }
139    }
140
141    pub fn cpu_duration(&self) -> Option<Duration> {
142        self.frame_end.map(|e| e.elapsed_since(self.frame_start))
143    }
144
145    pub fn gpu_duration(&self) -> Option<Duration> {
146        self.gpu_timestamp_end.map(|e| e.elapsed_since(self.gpu_timestamp_start))
147    }
148
149    pub fn total_span_time(&self, kind: SpanKind) -> Duration {
150        self.spans.iter()
151            .filter(|s| s.kind == kind && s.parent.is_none())
152            .filter_map(|s| s.duration())
153            .fold(Duration::ZERO, |acc, d| acc.add(d))
154    }
155
156    pub fn slowest_span(&self) -> Option<&ProfileSpan> {
157        self.spans.iter()
158            .filter_map(|s| s.duration().map(|d| (s, d)))
159            .max_by_key(|(_, d)| d.0)
160            .map(|(s, _)| s)
161    }
162
163    pub fn spans_sorted_by_duration(&self) -> Vec<&ProfileSpan> {
164        let mut s: Vec<&ProfileSpan> = self.spans.iter().collect();
165        s.sort_by(|a, b| {
166            let da = a.duration().unwrap_or(Duration::ZERO);
167            let db = b.duration().unwrap_or(Duration::ZERO);
168            db.cmp(&da)
169        });
170        s
171    }
172
173    pub fn build_flamegraph(&self) -> FlamegraphNode {
174        let root_spans: Vec<&ProfileSpan> = self.spans.iter()
175            .filter(|s| s.parent.is_none())
176            .collect();
177        FlamegraphNode {
178            name: "Frame".into(),
179            kind: SpanKind::Cpu,
180            duration: self.cpu_duration().unwrap_or(Duration::ZERO),
181            children: root_spans.iter().map(|s| self.build_flame_node(s)).collect(),
182            depth: 0,
183        }
184    }
185
186    fn build_flame_node(&self, span: &ProfileSpan) -> FlamegraphNode {
187        let children: Vec<&ProfileSpan> = self.spans.iter()
188            .filter(|s| s.parent == Some(span.id))
189            .collect();
190        FlamegraphNode {
191            name: span.name.clone(),
192            kind: span.kind,
193            duration: span.duration().unwrap_or(Duration::ZERO),
194            children: children.iter().map(|s| self.build_flame_node(s)).collect(),
195            depth: span.depth,
196        }
197    }
198
199    pub fn top_n_spans(&self, n: usize) -> Vec<(&ProfileSpan, Duration)> {
200        let mut v: Vec<(&ProfileSpan, Duration)> = self.spans.iter()
201            .filter_map(|s| s.duration().map(|d| (s, d)))
202            .collect();
203        v.sort_by(|a, b| b.1.cmp(&a.1));
204        v.truncate(n);
205        v
206    }
207}
208
209// ─── Flame graph ─────────────────────────────────────────────────────────────
210
211#[derive(Debug, Clone)]
212pub struct FlamegraphNode {
213    pub name: String,
214    pub kind: SpanKind,
215    pub duration: Duration,
216    pub children: Vec<FlamegraphNode>,
217    pub depth: u32,
218}
219
220impl FlamegraphNode {
221    pub fn self_time(&self) -> Duration {
222        let children_total: u64 = self.children.iter()
223            .map(|c| c.duration.0)
224            .sum();
225        Duration(self.duration.0.saturating_sub(children_total))
226    }
227
228    pub fn max_depth(&self) -> u32 {
229        self.children.iter()
230            .map(|c| c.max_depth())
231            .max()
232            .unwrap_or(0)
233            + 1
234    }
235
236    pub fn total_span_count(&self) -> usize {
237        1 + self.children.iter().map(|c| c.total_span_count()).sum::<usize>()
238    }
239
240    pub fn visit<F: FnMut(&FlamegraphNode, u32)>(&self, depth: u32, f: &mut F) {
241        f(self, depth);
242        for child in &self.children {
243            child.visit(depth + 1, f);
244        }
245    }
246
247    pub fn ascii_tree(&self, indent: usize) -> String {
248        let mut out = format!(
249            "{}{} ({})\n",
250            "  ".repeat(indent),
251            self.name,
252            self.duration,
253        );
254        for child in &self.children {
255            out.push_str(&child.ascii_tree(indent + 1));
256        }
257        out
258    }
259}
260
261// ─── Counter / gauge ─────────────────────────────────────────────────────────
262
263#[derive(Debug, Clone)]
264pub struct CounterSample {
265    pub name: String,
266    pub value: f64,
267    pub unit: CounterUnit,
268    pub min: f64,
269    pub max: f64,
270}
271
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum CounterUnit {
274    Count,
275    Bytes,
276    Milliseconds,
277    Microseconds,
278    Percentage,
279    Hertz,
280    Triangles,
281    DrawCalls,
282    Custom,
283}
284
285impl CounterUnit {
286    pub fn format(&self, v: f64) -> String {
287        match self {
288            Self::Count      => format!("{:.0}", v),
289            Self::Bytes      => format_bytes(v as u64),
290            Self::Milliseconds  => format!("{:.2}ms", v),
291            Self::Microseconds  => format!("{:.1}µs", v),
292            Self::Percentage => format!("{:.1}%", v),
293            Self::Hertz      => format!("{:.0}Hz", v),
294            Self::Triangles  => format!("{}K tri", (v / 1000.0) as u64),
295            Self::DrawCalls  => format!("{:.0} DC", v),
296            Self::Custom     => format!("{:.3}", v),
297        }
298    }
299}
300
301fn format_bytes(b: u64) -> String {
302    const KB: u64 = 1024;
303    const MB: u64 = KB * 1024;
304    const GB: u64 = MB * 1024;
305    if b >= GB      { format!("{:.2} GB", b as f64 / GB as f64) }
306    else if b >= MB { format!("{:.1} MB", b as f64 / MB as f64) }
307    else if b >= KB { format!("{:.0} KB", b as f64 / KB as f64) }
308    else            { format!("{} B", b) }
309}
310
311// ─── Memory snapshot ─────────────────────────────────────────────────────────
312
313#[derive(Debug, Clone, Default)]
314pub struct MemorySnapshot {
315    pub heap_bytes: u64,
316    pub stack_bytes: u64,
317    pub gpu_vram_bytes: u64,
318    pub texture_bytes: u64,
319    pub vertex_buffer_bytes: u64,
320    pub index_buffer_bytes: u64,
321    pub uniform_buffer_bytes: u64,
322    pub render_target_bytes: u64,
323    pub audio_bytes: u64,
324    pub script_bytes: u64,
325    pub asset_cache_bytes: u64,
326}
327
328impl MemorySnapshot {
329    pub fn total_cpu(&self) -> u64 {
330        self.heap_bytes + self.stack_bytes + self.audio_bytes + self.script_bytes
331    }
332
333    pub fn total_gpu(&self) -> u64 {
334        self.gpu_vram_bytes
335    }
336
337    pub fn total_gpu_breakdown(&self) -> u64 {
338        self.texture_bytes + self.vertex_buffer_bytes + self.index_buffer_bytes
339            + self.uniform_buffer_bytes + self.render_target_bytes
340    }
341
342    pub fn format_summary(&self) -> String {
343        format!(
344            "CPU: {} | GPU: {} (Tex:{} VB:{} RT:{})",
345            format_bytes(self.total_cpu()),
346            format_bytes(self.total_gpu()),
347            format_bytes(self.texture_bytes),
348            format_bytes(self.vertex_buffer_bytes),
349            format_bytes(self.render_target_bytes),
350        )
351    }
352}
353
354// ─── Budget ───────────────────────────────────────────────────────────────────
355
356#[derive(Debug, Clone)]
357pub struct PerformanceBudget {
358    pub name: String,
359    pub frame_time_ms: f64,
360    pub cpu_ms: f64,
361    pub gpu_ms: f64,
362    pub draw_calls: u32,
363    pub triangle_count: u32,
364    pub vram_bytes: u64,
365    pub texture_bytes: u64,
366}
367
368impl Default for PerformanceBudget {
369    fn default() -> Self {
370        Self {
371            name: "60 FPS".into(),
372            frame_time_ms: 16.67,
373            cpu_ms: 8.0,
374            gpu_ms: 8.0,
375            draw_calls: 500,
376            triangle_count: 500_000,
377            vram_bytes: 512 * 1024 * 1024,
378            texture_bytes: 256 * 1024 * 1024,
379        }
380    }
381}
382
383impl PerformanceBudget {
384    pub fn mobile() -> Self {
385        Self {
386            name: "30 FPS Mobile".into(),
387            frame_time_ms: 33.33,
388            cpu_ms: 12.0,
389            gpu_ms: 12.0,
390            draw_calls: 100,
391            triangle_count: 100_000,
392            vram_bytes: 128 * 1024 * 1024,
393            texture_bytes: 64 * 1024 * 1024,
394        }
395    }
396
397    pub fn hi_end() -> Self {
398        Self {
399            name: "120 FPS High-End".into(),
400            frame_time_ms: 8.33,
401            cpu_ms: 4.0,
402            gpu_ms: 4.0,
403            draw_calls: 2000,
404            triangle_count: 2_000_000,
405            vram_bytes: 4 * 1024 * 1024 * 1024,
406            texture_bytes: 2 * 1024 * 1024 * 1024,
407        }
408    }
409
410    pub fn check(&self, frame: &FrameProfile) -> BudgetReport {
411        let mut violations = Vec::new();
412        let frame_ms = frame.cpu_duration().unwrap_or(Duration::ZERO).as_ms();
413        if frame_ms > self.frame_time_ms {
414            violations.push(BudgetViolation {
415                item: "Frame Time".into(),
416                actual: frame_ms,
417                budget: self.frame_time_ms,
418                unit: CounterUnit::Milliseconds,
419            });
420        }
421        BudgetReport {
422            budget_name: self.name.clone(),
423            frame_number: frame.frame_number,
424            violations,
425            within_budget: true,
426        }
427    }
428}
429
430#[derive(Debug, Clone)]
431pub struct BudgetViolation {
432    pub item: String,
433    pub actual: f64,
434    pub budget: f64,
435    pub unit: CounterUnit,
436}
437
438impl BudgetViolation {
439    pub fn overage_pct(&self) -> f64 {
440        if self.budget > 0.0 {
441            (self.actual - self.budget) / self.budget * 100.0
442        } else {
443            0.0
444        }
445    }
446}
447
448impl fmt::Display for BudgetViolation {
449    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
450        write!(f, "{}: {} / {} (+{:.1}%)",
451            self.item,
452            self.unit.format(self.actual),
453            self.unit.format(self.budget),
454            self.overage_pct(),
455        )
456    }
457}
458
459#[derive(Debug, Clone)]
460pub struct BudgetReport {
461    pub budget_name: String,
462    pub frame_number: u64,
463    pub violations: Vec<BudgetViolation>,
464    pub within_budget: bool,
465}
466
467impl BudgetReport {
468    pub fn is_clean(&self) -> bool { self.violations.is_empty() }
469}
470
471// ─── Ring buffer stats ────────────────────────────────────────────────────────
472
473#[derive(Debug, Clone)]
474pub struct StatRingBuffer {
475    pub name: String,
476    pub unit: CounterUnit,
477    data: VecDeque<f64>,
478    capacity: usize,
479}
480
481impl StatRingBuffer {
482    pub fn new(name: &str, unit: CounterUnit, capacity: usize) -> Self {
483        Self {
484            name: name.to_string(),
485            unit,
486            data: VecDeque::with_capacity(capacity),
487            capacity,
488        }
489    }
490
491    pub fn push(&mut self, value: f64) {
492        if self.data.len() >= self.capacity {
493            self.data.pop_front();
494        }
495        self.data.push_back(value);
496    }
497
498    pub fn mean(&self) -> f64 {
499        if self.data.is_empty() { return 0.0; }
500        self.data.iter().sum::<f64>() / self.data.len() as f64
501    }
502
503    pub fn min(&self) -> f64 {
504        self.data.iter().cloned().fold(f64::MAX, f64::min)
505    }
506
507    pub fn max(&self) -> f64 {
508        self.data.iter().cloned().fold(f64::MIN, f64::max)
509    }
510
511    pub fn percentile(&self, pct: f64) -> f64 {
512        if self.data.is_empty() { return 0.0; }
513        let mut sorted: Vec<f64> = self.data.iter().cloned().collect();
514        sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
515        let idx = ((pct / 100.0) * (sorted.len() - 1) as f64) as usize;
516        sorted[idx.min(sorted.len() - 1)]
517    }
518
519    pub fn latest(&self) -> f64 {
520        self.data.back().copied().unwrap_or(0.0)
521    }
522
523    pub fn sparkline(&self, width: usize, height: usize) -> Vec<Vec<bool>> {
524        let mut grid = vec![vec![false; width]; height];
525        if self.data.is_empty() { return grid; }
526        let max_val = self.max().max(1e-10);
527        let min_val = self.min();
528        let range = (max_val - min_val).max(1e-10);
529        let n = self.data.len();
530        for (i, &v) in self.data.iter().enumerate() {
531            let x = i * width / n.max(1);
532            let y_norm = (v - min_val) / range;
533            let y = ((1.0 - y_norm) * (height - 1) as f64) as usize;
534            if x < width && y < height {
535                grid[y][x] = true;
536            }
537        }
538        grid
539    }
540
541    pub fn ascii_sparkline(&self, width: usize) -> String {
542        const BARS: &[char] = &[' ', '▁', '▂', '▃', '▄', '▅', '▆', '▇', '█'];
543        if self.data.is_empty() { return " ".repeat(width); }
544        let max_val = self.max().max(1e-10);
545        let n = self.data.len().min(width);
546        let skip = self.data.len().saturating_sub(width);
547        self.data.iter().skip(skip).take(n).map(|&v| {
548            let norm = (v / max_val).clamp(0.0, 1.0);
549            BARS[(norm * (BARS.len() - 1) as f64) as usize]
550        }).collect()
551    }
552
553    pub fn len(&self) -> usize { self.data.len() }
554    pub fn is_empty(&self) -> bool { self.data.is_empty() }
555}
556
557// ─── Profiler ─────────────────────────────────────────────────────────────────
558
559pub struct Profiler {
560    pub enabled: bool,
561    pub frame_history: VecDeque<FrameProfile>,
562    pub max_history: usize,
563    pub current_frame: Option<FrameProfile>,
564    pub frame_counter: u64,
565    next_span_id: u32,
566    open_spans: Vec<SpanId>,
567    span_stack: Vec<SpanId>,
568    pub budget: PerformanceBudget,
569    pub budget_violations: Vec<BudgetReport>,
570    pub stats: HashMap<String, StatRingBuffer>,
571    pub capture_flamegraph: bool,
572    pub flamegraph_history: VecDeque<FlamegraphNode>,
573    max_flamegraph_history: usize,
574    sim_time_us: u64,   // simulated monotonic clock for testing
575}
576
577impl Profiler {
578    pub fn new() -> Self {
579        let mut p = Self {
580            enabled: true,
581            frame_history: VecDeque::new(),
582            max_history: 120,
583            current_frame: None,
584            frame_counter: 0,
585            next_span_id: 1,
586            open_spans: Vec::new(),
587            span_stack: Vec::new(),
588            budget: PerformanceBudget::default(),
589            budget_violations: Vec::new(),
590            stats: HashMap::new(),
591            capture_flamegraph: true,
592            flamegraph_history: VecDeque::new(),
593            max_flamegraph_history: 30,
594            sim_time_us: 0,
595        };
596        p.register_stat("fps",        CounterUnit::Hertz,       128);
597        p.register_stat("frame_ms",   CounterUnit::Milliseconds, 128);
598        p.register_stat("cpu_ms",     CounterUnit::Milliseconds, 128);
599        p.register_stat("gpu_ms",     CounterUnit::Milliseconds, 128);
600        p.register_stat("draw_calls", CounterUnit::DrawCalls,    128);
601        p.register_stat("triangles",  CounterUnit::Triangles,    128);
602        p.register_stat("vram",       CounterUnit::Bytes,        128);
603        p.register_stat("heap",       CounterUnit::Bytes,        128);
604        p.register_stat("particles",  CounterUnit::Count,        128);
605        p
606    }
607
608    pub fn register_stat(&mut self, name: &str, unit: CounterUnit, capacity: usize) {
609        self.stats.insert(name.to_string(), StatRingBuffer::new(name, unit, capacity));
610    }
611
612    fn now(&self) -> Timestamp {
613        Timestamp(self.sim_time_us)
614    }
615
616    pub fn advance_sim_time(&mut self, us: u64) {
617        self.sim_time_us += us;
618    }
619
620    pub fn begin_frame(&mut self) {
621        if !self.enabled { return; }
622        let ts = self.now();
623        self.current_frame = Some(FrameProfile::new(self.frame_counter, ts));
624        self.frame_counter += 1;
625        self.open_spans.clear();
626        self.span_stack.clear();
627    }
628
629    pub fn end_frame(&mut self, frame_ms: f64) {
630        if !self.enabled { return; }
631        self.advance_sim_time((frame_ms * 1000.0) as u64);
632        let ts = self.now();
633        if let Some(frame) = &mut self.current_frame {
634            frame.frame_end = Some(ts);
635        }
636        if let Some(frame) = self.current_frame.take() {
637            // Check budget
638            let report = self.budget.check(&frame);
639            if !report.is_clean() {
640                self.budget_violations.push(report);
641                if self.budget_violations.len() > 100 {
642                    self.budget_violations.remove(0);
643                }
644            }
645            // Build flamegraph
646            if self.capture_flamegraph {
647                let fg = frame.build_flamegraph();
648                if self.flamegraph_history.len() >= self.max_flamegraph_history {
649                    self.flamegraph_history.pop_front();
650                }
651                self.flamegraph_history.push_back(fg);
652            }
653            // Record stats
654            if let Some(cpu_dur) = frame.cpu_duration() {
655                let cpu_ms = cpu_dur.as_ms();
656                if let Some(s) = self.stats.get_mut("frame_ms") { s.push(cpu_ms); }
657                if let Some(s) = self.stats.get_mut("cpu_ms")   { s.push(cpu_ms); }
658                if cpu_ms > 0.0 {
659                    if let Some(s) = self.stats.get_mut("fps") { s.push(1000.0 / cpu_ms); }
660                }
661            }
662            // Add to history
663            if self.frame_history.len() >= self.max_history {
664                self.frame_history.pop_front();
665            }
666            self.frame_history.push_back(frame);
667        }
668    }
669
670    pub fn begin_span(&mut self, name: &str, kind: SpanKind) -> SpanId {
671        if !self.enabled { return SpanId(0); }
672        let id = SpanId(self.next_span_id);
673        self.next_span_id += 1;
674        let ts = self.now();
675        let parent = self.span_stack.last().copied();
676        let depth  = self.span_stack.len() as u32;
677        let span   = ProfileSpan {
678            id, name: name.to_string(), kind,
679            start: ts, end: None,
680            parent, depth, thread: 0, extra: None,
681        };
682        if let Some(frame) = &mut self.current_frame {
683            frame.spans.push(span);
684        }
685        self.span_stack.push(id);
686        self.open_spans.push(id);
687        id
688    }
689
690    pub fn end_span(&mut self, id: SpanId) {
691        if !self.enabled { return; }
692        let ts = self.now();
693        if let Some(frame) = &mut self.current_frame {
694            if let Some(span) = frame.spans.iter_mut().find(|s| s.id == id) {
695                span.end = Some(ts);
696            }
697        }
698        self.span_stack.retain(|&s| s != id);
699        self.open_spans.retain(|&s| s != id);
700    }
701
702    pub fn record_counter(&mut self, name: &str, value: f64, unit: CounterUnit) {
703        if let Some(s) = self.stats.get_mut(name) {
704            s.push(value);
705        } else {
706            self.register_stat(name, unit, 128);
707            if let Some(s) = self.stats.get_mut(name) {
708                s.push(value);
709            }
710        }
711        if let Some(frame) = &mut self.current_frame {
712            frame.counters.insert(name.to_string(), CounterSample {
713                name: name.to_string(),
714                value,
715                unit,
716                min: value,
717                max: value,
718            });
719        }
720    }
721
722    pub fn record_memory(&mut self, mem: MemorySnapshot) {
723        if let Some(s) = self.stats.get_mut("vram") {
724            s.push(mem.gpu_vram_bytes as f64);
725        }
726        if let Some(s) = self.stats.get_mut("heap") {
727            s.push(mem.heap_bytes as f64);
728        }
729        if let Some(frame) = &mut self.current_frame {
730            frame.memory = mem;
731        }
732    }
733
734    pub fn fps(&self) -> f64 {
735        self.stats.get("fps").map(|s| s.latest()).unwrap_or(0.0)
736    }
737
738    pub fn frame_ms(&self) -> f64 {
739        self.stats.get("frame_ms").map(|s| s.latest()).unwrap_or(0.0)
740    }
741
742    pub fn mean_fps(&self) -> f64 {
743        self.stats.get("fps").map(|s| s.mean()).unwrap_or(0.0)
744    }
745
746    pub fn p95_frame_ms(&self) -> f64 {
747        self.stats.get("frame_ms").map(|s| s.percentile(95.0)).unwrap_or(0.0)
748    }
749
750    pub fn latest_frame(&self) -> Option<&FrameProfile> {
751        self.frame_history.back()
752    }
753
754    pub fn latest_flamegraph(&self) -> Option<&FlamegraphNode> {
755        self.flamegraph_history.back()
756    }
757
758    pub fn stat(&self, name: &str) -> Option<&StatRingBuffer> {
759        self.stats.get(name)
760    }
761
762    pub fn summary_line(&self) -> String {
763        let fps = self.fps();
764        let ms  = self.frame_ms();
765        let p95 = self.p95_frame_ms();
766        format!("FPS: {:.0}  avg: {:.2}ms  p95: {:.2}ms", fps, ms, p95)
767    }
768
769    pub fn detailed_summary(&self) -> String {
770        let mut out = String::new();
771        out.push_str(&format!("=== Profiler ({} frames) ===\n", self.frame_history.len()));
772        out.push_str(&format!("FPS: {:.1}  Frame: {}  p95: {}\n",
773            self.fps(),
774            Duration::from_ms(self.frame_ms()),
775            Duration::from_ms(self.p95_frame_ms()),
776        ));
777        for (name, stat) in &self.stats {
778            if stat.is_empty() { continue; }
779            out.push_str(&format!(
780                "  {:<20} latest={:<12} mean={:<12} p95={:<12} {}\n",
781                name,
782                stat.unit.format(stat.latest()),
783                stat.unit.format(stat.mean()),
784                stat.unit.format(stat.percentile(95.0)),
785                stat.ascii_sparkline(20),
786            ));
787        }
788        if !self.budget_violations.is_empty() {
789            let recent = &self.budget_violations[self.budget_violations.len().saturating_sub(3)..];
790            out.push_str(&format!("⚠ {} budget violations (last 3 frames):\n",
791                self.budget_violations.len()));
792            for report in recent {
793                for v in &report.violations {
794                    out.push_str(&format!("  frame {}: {}\n", report.frame_number, v));
795                }
796            }
797        }
798        if let Some(fg) = self.latest_flamegraph() {
799            out.push_str("--- Last Flamegraph ---\n");
800            out.push_str(&fg.ascii_tree(0));
801        }
802        out
803    }
804
805    pub fn reset_stats(&mut self) {
806        for stat in self.stats.values_mut() {
807            stat.data.clear();
808        }
809        self.budget_violations.clear();
810        self.frame_history.clear();
811    }
812}
813
814impl Default for Profiler {
815    fn default() -> Self { Self::new() }
816}
817
818// ─── Scoped span guard ────────────────────────────────────────────────────────
819
820/// RAII guard that automatically closes a span on drop.
821/// Usage: `let _guard = profiler.scoped_span("MyFunc", SpanKind::Cpu);`
822pub struct SpanGuard<'a> {
823    profiler: &'a mut Profiler,
824    id: SpanId,
825}
826
827impl<'a> SpanGuard<'a> {
828    pub fn new(profiler: &'a mut Profiler, name: &str, kind: SpanKind) -> Self {
829        let id = profiler.begin_span(name, kind);
830        Self { profiler, id }
831    }
832}
833
834impl<'a> Drop for SpanGuard<'a> {
835    fn drop(&mut self) {
836        self.profiler.end_span(self.id);
837    }
838}
839
840// ─── Tests ───────────────────────────────────────────────────────────────────
841
842#[cfg(test)]
843mod tests {
844    use super::*;
845
846    #[test]
847    fn duration_display() {
848        assert_eq!(Duration::from_us(500).to_string(), "500µs");
849        assert_eq!(Duration::from_ms(2.5).to_string(), "2.50ms");
850    }
851
852    #[test]
853    fn stat_ring_mean() {
854        let mut buf = StatRingBuffer::new("test", CounterUnit::Count, 4);
855        buf.push(1.0); buf.push(2.0); buf.push(3.0); buf.push(4.0);
856        assert!((buf.mean() - 2.5).abs() < 1e-9);
857    }
858
859    #[test]
860    fn stat_ring_capacity() {
861        let mut buf = StatRingBuffer::new("test", CounterUnit::Count, 3);
862        buf.push(1.0); buf.push(2.0); buf.push(3.0); buf.push(4.0);
863        assert_eq!(buf.len(), 3);
864        assert_eq!(buf.latest(), 4.0);
865    }
866
867    #[test]
868    fn stat_percentile() {
869        let mut buf = StatRingBuffer::new("p", CounterUnit::Count, 100);
870        for i in 0..100 { buf.push(i as f64); }
871        let p95 = buf.percentile(95.0);
872        assert!(p95 >= 90.0 && p95 <= 100.0);
873    }
874
875    #[test]
876    fn profiler_frame_cycle() {
877        let mut p = Profiler::new();
878        p.begin_frame();
879        let id = p.begin_span("RenderScene", SpanKind::Cpu);
880        p.advance_sim_time(8000); // 8ms
881        p.end_span(id);
882        p.end_frame(8.0);
883        assert_eq!(p.frame_history.len(), 1);
884        let frame = p.frame_history.back().unwrap();
885        let dur = frame.cpu_duration().unwrap();
886        assert!(dur.as_ms() > 0.0);
887    }
888
889    #[test]
890    fn flamegraph_tree() {
891        let mut p = Profiler::new();
892        p.begin_frame();
893        let root = p.begin_span("Root", SpanKind::Cpu);
894        p.advance_sim_time(1000);
895        let child = p.begin_span("Child", SpanKind::Cpu);
896        p.advance_sim_time(500);
897        p.end_span(child);
898        p.end_span(root);
899        p.end_frame(1.5);
900        let fg = p.latest_flamegraph().unwrap();
901        assert!(!fg.children.is_empty());
902    }
903
904    #[test]
905    fn budget_violation_overage() {
906        let budget = PerformanceBudget {
907            frame_time_ms: 16.0,
908            ..Default::default()
909        };
910        let mut frame = FrameProfile::new(0, Timestamp(0));
911        frame.frame_end = Some(Timestamp(20_000)); // 20ms > 16ms budget
912        let report = budget.check(&frame);
913        assert!(!report.violations.is_empty());
914        assert!(report.violations[0].overage_pct() > 0.0);
915    }
916
917    #[test]
918    fn format_bytes() {
919        assert_eq!(super::format_bytes(1024), "1 KB");
920        assert_eq!(super::format_bytes(1024*1024), "1.0 MB");
921        assert_eq!(super::format_bytes(512), "512 B");
922    }
923}