Skip to main content

pfx_live/
stats.rs

1use std::collections::VecDeque;
2use std::ffi::OsStr;
3use std::fmt::Write as _;
4use std::fs::OpenOptions;
5use std::io::{BufWriter, Write};
6use std::path::Path;
7use std::sync::atomic::{AtomicU64, Ordering};
8use std::sync::mpsc::{Receiver, SyncSender, TryRecvError, TrySendError, sync_channel};
9use std::sync::{Arc, Mutex, MutexGuard};
10use std::thread::JoinHandle;
11use std::time::Instant;
12
13use pfx_gpu::screens::{Device, SystemSource, detect};
14
15pub const SCHEMA_VERSION: u32 = 1;
16pub const ENV: &str = "PFX_FRAME_STATS";
17pub const CHANNEL: usize = 256;
18const KEPT_FRAMES: usize = 16;
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum FrameKind {
22    Full,
23    Partial,
24    Reproject,
25    Idle,
26}
27
28impl FrameKind {
29    pub fn name(self) -> &'static str {
30        match self {
31            Self::Full => "full",
32            Self::Partial => "partial",
33            Self::Reproject => "reproject",
34            Self::Idle => "idle",
35        }
36    }
37}
38
39#[derive(Clone, Copy, Debug, PartialEq)]
40pub struct Sample {
41    pub tick: u64,
42    pub t_ms: f64,
43    pub sim_ms: Option<f64>,
44    pub submit_ms: f64,
45    pub gpu_ms: Option<f64>,
46    pub interval_ms: Option<f64>,
47    pub kind: FrameKind,
48    pub size: [u32; 2],
49    pub hud_ms: Option<f64>,
50}
51
52struct Line {
53    sample: Sample,
54    gpu: Option<(u64, f64)>,
55    device: Option<&'static str>,
56}
57
58fn number(out: &mut String, value: f64) {
59    if value.is_finite() {
60        let _ = write!(out, "{:.3}", value.max(0.0));
61    } else {
62        out.push_str("null");
63    }
64}
65
66fn optional(out: &mut String, value: Option<f64>) {
67    match value {
68        Some(value) => number(out, value),
69        None => out.push_str("null"),
70    }
71}
72
73impl Line {
74    fn json(&self) -> String {
75        let sample = &self.sample;
76        let mut out = format!(
77            "{{\"schema_version\":{SCHEMA_VERSION},\"tick\":{},\"t_ms\":",
78            sample.tick
79        );
80        number(&mut out, sample.t_ms);
81        out.push_str(",\"sim_ms\":");
82        optional(&mut out, sample.sim_ms);
83        out.push_str(",\"submit_ms\":");
84        number(&mut out, sample.submit_ms);
85        out.push_str(",\"gpu_ms\":");
86        optional(&mut out, self.gpu.map(|(_, ms)| ms));
87        out.push_str(",\"gpu_tick\":");
88        match self.gpu {
89            Some((tick, _)) => {
90                let _ = write!(out, "{tick}");
91            }
92            None => out.push_str("null"),
93        }
94        out.push_str(",\"present_interval_ms\":");
95        optional(&mut out, sample.interval_ms);
96        let _ = write!(
97            out,
98            ",\"kind\":\"{}\",\"size\":[{},{}],\"device\":",
99            sample.kind.name(),
100            sample.size[0],
101            sample.size[1]
102        );
103        match self.device {
104            Some(label) => {
105                let _ = write!(out, "\"{label}\"");
106            }
107            None => out.push_str("null"),
108        }
109        out.push_str(",\"hud_ms\":");
110        optional(&mut out, sample.hud_ms);
111        out.push('}');
112        out
113    }
114}
115
116fn end_json(frames: u64, dropped: u64) -> String {
117    format!(
118        "{{\"schema_version\":{SCHEMA_VERSION},\"end\":true,\"frames\":{frames},\"dropped\":{dropped}}}"
119    )
120}
121
122enum Message {
123    Frame(Line),
124    End(String),
125}
126
127fn write_lines(receiver: Receiver<Message>, mut out: Box<dyn Write + Send>) {
128    loop {
129        let message = match receiver.try_recv() {
130            Ok(message) => message,
131            Err(TryRecvError::Empty) => {
132                if out.flush().is_err() {
133                    return;
134                }
135                match receiver.recv() {
136                    Ok(message) => message,
137                    Err(_) => return,
138                }
139            }
140            Err(TryRecvError::Disconnected) => {
141                let _ = out.flush();
142                return;
143            }
144        };
145        let (text, last) = match message {
146            Message::Frame(line) => (line.json(), false),
147            Message::End(text) => (text, true),
148        };
149        if writeln!(out, "{text}").is_err() {
150            return;
151        }
152        if last {
153            let _ = out.flush();
154            return;
155        }
156    }
157}
158
159struct Open {
160    begin: Instant,
161    kind: FrameKind,
162}
163
164struct State {
165    sender: Option<SyncSender<Message>>,
166    thread: Option<JoinHandle<()>>,
167    epoch: Option<Instant>,
168    open: Option<Open>,
169    sim_start: Option<Instant>,
170    sim_total: Option<f64>,
171    last_submit: Option<Instant>,
172    pending: Option<Line>,
173    frames: u64,
174    ticks: VecDeque<(u64, u64)>,
175    gpu: Option<(u64, f64)>,
176    gpu_reported: bool,
177    hud_start: Option<Instant>,
178    keep: usize,
179    recent: VecDeque<Sample>,
180    device: Option<&'static str>,
181}
182
183struct Shared {
184    state: Mutex<State>,
185    dropped: AtomicU64,
186}
187
188impl Shared {
189    fn lock(&self) -> MutexGuard<'_, State> {
190        self.state.lock().unwrap_or_else(|error| error.into_inner())
191    }
192
193    fn emit(&self, state: &mut State, mut line: Line) {
194        if !state.gpu_reported {
195            line.gpu = state.gpu;
196            state.gpu_reported = state.gpu.is_some();
197        }
198        let Some(sender) = &state.sender else {
199            return;
200        };
201        match sender.try_send(Message::Frame(line)) {
202            Ok(()) => {}
203            Err(TrySendError::Full(_) | TrySendError::Disconnected(_)) => {
204                self.dropped.fetch_add(1, Ordering::Relaxed);
205            }
206        }
207    }
208
209    fn finish(&self) {
210        let (thread, sender) = {
211            let mut state = self.lock();
212            if let Some(line) = state.pending.take() {
213                self.emit(&mut state, line);
214            }
215            let end = end_json(state.frames, self.dropped.load(Ordering::Relaxed));
216            if let Some(sender) = &state.sender {
217                let _ = sender.send(Message::End(end));
218            }
219            (state.thread.take(), state.sender.take())
220        };
221        drop(sender);
222        if let Some(thread) = thread {
223            let _ = thread.join();
224        }
225    }
226}
227
228impl Drop for Shared {
229    fn drop(&mut self) {
230        self.finish();
231    }
232}
233
234#[derive(Clone, Default)]
235pub struct FrameStats {
236    shared: Option<Arc<Shared>>,
237}
238
239impl std::fmt::Debug for FrameStats {
240    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
241        f.debug_struct("FrameStats")
242            .field("enabled", &self.enabled())
243            .finish()
244    }
245}
246
247impl FrameStats {
248    pub fn off() -> Self {
249        Self::default()
250    }
251
252    pub fn from_env() -> Self {
253        Self::from_value(std::env::var_os(ENV).as_deref())
254    }
255
256    pub fn from_value(value: Option<&OsStr>) -> Self {
257        let Some(path) = value.filter(|value| !value.is_empty()) else {
258            return Self::off();
259        };
260        match Self::to_file(Path::new(path)) {
261            Ok(stats) => stats,
262            Err(error) => {
263                eprintln!("pfx: {ENV}: {error}");
264                Self::off()
265            }
266        }
267    }
268
269    pub fn to_file(path: &Path) -> std::io::Result<Self> {
270        let file = OpenOptions::new().create(true).append(true).open(path)?;
271        let stats = Self::to_writer(Box::new(BufWriter::new(file)), CHANNEL);
272        stats.set_device(Some(detect(&SystemSource).device));
273        Ok(stats)
274    }
275
276    pub fn set_device(&self, device: Option<Device>) {
277        if let Some(shared) = &self.shared {
278            shared.lock().device = device.map(Device::label);
279        }
280    }
281
282    pub fn device(&self) -> Option<&'static str> {
283        self.shared.as_ref().and_then(|shared| shared.lock().device)
284    }
285
286    pub fn to_writer(out: Box<dyn Write + Send>, channel: usize) -> Self {
287        let (sender, receiver) = sync_channel(channel.max(1));
288        let thread = std::thread::Builder::new()
289            .name("pfx frame stats".into())
290            .spawn(move || write_lines(receiver, out))
291            .ok();
292        let sender = thread.is_some().then_some(sender);
293        Self::with(sender, thread)
294    }
295
296    pub fn measure() -> Self {
297        Self::with(None, None)
298    }
299
300    fn with(sender: Option<SyncSender<Message>>, thread: Option<JoinHandle<()>>) -> Self {
301        Self {
302            shared: Some(Arc::new(Shared {
303                state: Mutex::new(State {
304                    sender,
305                    thread,
306                    epoch: None,
307                    open: None,
308                    sim_start: None,
309                    sim_total: None,
310                    last_submit: None,
311                    pending: None,
312                    frames: 0,
313                    ticks: VecDeque::new(),
314                    gpu: None,
315                    gpu_reported: true,
316                    hud_start: None,
317                    keep: 0,
318                    recent: VecDeque::new(),
319                    device: None,
320                }),
321                dropped: AtomicU64::new(0),
322            })),
323        }
324    }
325
326    pub fn enabled(&self) -> bool {
327        self.shared.is_some()
328    }
329
330    pub fn writes(&self) -> bool {
331        self.shared
332            .as_ref()
333            .is_some_and(|shared| shared.lock().sender.is_some())
334    }
335
336    pub fn keep_recent(&self, frames: usize) {
337        if let Some(shared) = &self.shared {
338            let mut state = shared.lock();
339            state.keep = state.keep.max(frames);
340        }
341    }
342
343    pub fn recent(&self) -> Vec<Sample> {
344        self.with_recent(<[Sample]>::to_vec)
345    }
346
347    pub(crate) fn with_recent<R>(&self, read: impl FnOnce(&[Sample]) -> R) -> R {
348        match &self.shared {
349            Some(shared) => read(shared.lock().recent.make_contiguous()),
350            None => read(&[]),
351        }
352    }
353
354    pub fn hud_begin(&self) {
355        if self.shared.is_some() {
356            self.hud_begin_at(Instant::now());
357        }
358    }
359
360    pub fn hud_end(&self) {
361        if self.shared.is_some() {
362            self.hud_end_at(Instant::now());
363        }
364    }
365
366    pub fn hud_begin_at(&self, now: Instant) {
367        if let Some(shared) = &self.shared {
368            shared.lock().hud_start = Some(now);
369        }
370    }
371
372    pub fn hud_end_at(&self, now: Instant) {
373        let Some(shared) = &self.shared else {
374            return;
375        };
376        let mut state = shared.lock();
377        let Some(start) = state.hud_start.take() else {
378            return;
379        };
380        let span = millis(start, now);
381        let Some(line) = state.pending.as_mut() else {
382            return;
383        };
384        let total = line.sample.hud_ms.unwrap_or(0.0) + span;
385        line.sample.hud_ms = Some(total);
386        let tick = line.sample.tick;
387        if let Some(sample) = state.recent.back_mut().filter(|sample| sample.tick == tick) {
388            sample.hud_ms = Some(total);
389        }
390    }
391
392    pub fn frames(&self) -> u64 {
393        self.shared
394            .as_ref()
395            .map_or(0, |shared| shared.lock().frames)
396    }
397
398    pub fn dropped(&self) -> u64 {
399        self.shared
400            .as_ref()
401            .map_or(0, |shared| shared.dropped.load(Ordering::Relaxed))
402    }
403
404    pub fn finish(&self) {
405        if let Some(shared) = &self.shared {
406            shared.finish();
407        }
408    }
409
410    pub fn sim_begin(&self) {
411        if self.shared.is_some() {
412            self.sim_begin_at(Instant::now());
413        }
414    }
415
416    pub fn sim_end(&self) {
417        if self.shared.is_some() {
418            self.sim_end_at(Instant::now());
419        }
420    }
421
422    pub fn sim_begin_at(&self, now: Instant) {
423        if let Some(shared) = &self.shared {
424            shared.lock().sim_start = Some(now);
425        }
426    }
427
428    pub fn sim_end_at(&self, now: Instant) {
429        if let Some(shared) = &self.shared {
430            let mut state = shared.lock();
431            if let Some(start) = state.sim_start.take() {
432                let span = millis(start, now);
433                state.sim_total = Some(state.sim_total.unwrap_or(0.0) + span);
434            }
435        }
436    }
437
438    pub fn begin(&self, kind: FrameKind) {
439        if self.shared.is_some() {
440            self.begin_at(kind, Instant::now());
441        }
442    }
443
444    pub fn begin_at(&self, kind: FrameKind, now: Instant) {
445        if let Some(shared) = &self.shared {
446            let mut state = shared.lock();
447            state.epoch.get_or_insert(now);
448            state.open = Some(Open { begin: now, kind });
449        }
450    }
451
452    pub fn discard(&self) {
453        if let Some(shared) = &self.shared {
454            shared.lock().open = None;
455        }
456    }
457
458    pub fn submitted(&self, frame: u64, size: [u32; 2]) {
459        if self.shared.is_some() {
460            self.submitted_at(frame, size, Instant::now());
461        }
462    }
463
464    pub fn submitted_at(&self, frame: u64, size: [u32; 2], now: Instant) {
465        let Some(shared) = &self.shared else {
466            return;
467        };
468        let mut state = shared.lock();
469        let Some(open) = state.open.take() else {
470            return;
471        };
472        if let Some(line) = state.pending.take() {
473            shared.emit(&mut state, line);
474        }
475        let epoch = state.epoch.unwrap_or(open.begin);
476        let tick = state.frames;
477        state.frames += 1;
478        let interval_ms = state.last_submit.map(|last| millis(last, now));
479        state.last_submit = Some(now);
480        let sim_ms = state.sim_total.take();
481        state.ticks.push_back((frame, tick));
482        while state.ticks.len() > KEPT_FRAMES {
483            state.ticks.pop_front();
484        }
485        let sample = Sample {
486            tick,
487            t_ms: millis(epoch, open.begin),
488            sim_ms,
489            submit_ms: millis(open.begin, now),
490            gpu_ms: None,
491            interval_ms,
492            kind: open.kind,
493            size,
494            hud_ms: None,
495        };
496        if state.keep > 0 {
497            state.recent.push_back(sample);
498            while state.recent.len() > state.keep {
499                state.recent.pop_front();
500            }
501        }
502        state.pending = Some(Line {
503            sample,
504            gpu: None,
505            device: state.device,
506        });
507    }
508
509    pub fn gpu(&self, frame: u64, milliseconds: f64) {
510        let Some(shared) = &self.shared else {
511            return;
512        };
513        let mut state = shared.lock();
514        let Some(tick) = state
515            .ticks
516            .iter()
517            .find(|(submitted, _)| *submitted == frame)
518            .map(|(_, tick)| *tick)
519        else {
520            return;
521        };
522        if state.gpu.is_none_or(|(latest, _)| latest < tick) {
523            state.gpu = Some((tick, milliseconds));
524            state.gpu_reported = false;
525        }
526        if let Some(sample) = state
527            .recent
528            .iter_mut()
529            .rev()
530            .find(|sample| sample.tick == tick)
531        {
532            sample.gpu_ms = Some(milliseconds);
533        }
534    }
535}
536
537fn millis(from: Instant, to: Instant) -> f64 {
538    to.saturating_duration_since(from).as_secs_f64() * 1000.0
539}
540
541#[cfg(test)]
542mod tests {
543    use super::*;
544    use std::sync::mpsc;
545    use std::time::Duration;
546
547    #[derive(Clone, Default)]
548    struct Sink(Arc<Mutex<Vec<u8>>>);
549
550    impl Write for Sink {
551        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
552            self.0.lock().unwrap().extend_from_slice(bytes);
553            Ok(bytes.len())
554        }
555
556        fn flush(&mut self) -> std::io::Result<()> {
557            Ok(())
558        }
559    }
560
561    impl Sink {
562        fn lines(&self) -> Vec<serde_json::Value> {
563            String::from_utf8(self.0.lock().unwrap().clone())
564                .unwrap()
565                .lines()
566                .map(|line| serde_json::from_str(line).unwrap())
567                .collect()
568        }
569    }
570
571    struct Gate {
572        sink: Sink,
573        open: mpsc::Receiver<()>,
574        passed: bool,
575    }
576
577    impl Write for Gate {
578        fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> {
579            if !self.passed {
580                let _ = self.open.recv();
581                self.passed = true;
582            }
583            self.sink.write(bytes)
584        }
585
586        fn flush(&mut self) -> std::io::Result<()> {
587            Ok(())
588        }
589    }
590
591    fn ms(base: Instant, milliseconds: u64) -> Instant {
592        base + Duration::from_millis(milliseconds)
593    }
594
595    fn frame(stats: &FrameStats, base: Instant, number: u64, at: u64, kind: FrameKind) {
596        stats.begin_at(kind, ms(base, at));
597        stats.submitted_at(number, [1280, 800], ms(base, at + 2));
598    }
599
600    #[test]
601    fn lines_parse_in_order_and_the_end_line_closes_the_file() {
602        let sink = Sink::default();
603        let stats = FrameStats::to_writer(Box::new(sink.clone()), 16);
604        let base = Instant::now();
605        frame(&stats, base, 0, 0, FrameKind::Full);
606        frame(&stats, base, 1, 17, FrameKind::Partial);
607        frame(&stats, base, 2, 33, FrameKind::Reproject);
608        frame(&stats, base, 3, 50, FrameKind::Idle);
609        stats.finish();
610        let lines = sink.lines();
611        assert_eq!(lines.len(), 5);
612        for (index, line) in lines[..4].iter().enumerate() {
613            assert_eq!(line["schema_version"], 1);
614            assert_eq!(line["tick"], index as u64);
615            assert_eq!(line["size"], serde_json::json!([1280, 800]));
616            assert!(line["device"].is_null());
617            assert!(line["hud_ms"].is_null());
618            assert!(line["sim_ms"].is_null());
619            assert!(line["gpu_ms"].is_null());
620            assert!(line["gpu_tick"].is_null());
621            assert_eq!(line["submit_ms"], 2.0);
622        }
623        let kinds: Vec<_> = lines[..4]
624            .iter()
625            .map(|line| line["kind"].as_str().unwrap())
626            .collect();
627        assert_eq!(kinds, ["full", "partial", "reproject", "idle"]);
628        assert_eq!(lines[0]["t_ms"], 0.0);
629        assert_eq!(lines[3]["t_ms"], 50.0);
630        assert!(lines[0]["present_interval_ms"].is_null());
631        assert_eq!(lines[1]["present_interval_ms"], 17.0);
632        assert_eq!(lines[3]["present_interval_ms"], 17.0);
633        assert_eq!(
634            lines[4],
635            serde_json::json!({"schema_version": 1, "end": true, "frames": 4, "dropped": 0})
636        );
637    }
638
639    #[test]
640    fn sim_time_goes_to_the_next_frame_and_gpu_time_to_the_next_line() {
641        let sink = Sink::default();
642        let stats = FrameStats::to_writer(Box::new(sink.clone()), 16);
643        let base = Instant::now();
644        stats.sim_begin_at(ms(base, 0));
645        stats.sim_end_at(ms(base, 3));
646        stats.sim_begin_at(ms(base, 4));
647        stats.sim_end_at(ms(base, 5));
648        frame(&stats, base, 10, 6, FrameKind::Full);
649        frame(&stats, base, 11, 20, FrameKind::Full);
650        stats.gpu(10, 4.5);
651        frame(&stats, base, 12, 40, FrameKind::Full);
652        frame(&stats, base, 13, 60, FrameKind::Full);
653        stats.gpu(12, 5.5);
654        stats.finish();
655        let lines = sink.lines();
656        assert_eq!(lines[0]["sim_ms"], 4.0);
657        assert!(lines[1]["sim_ms"].is_null());
658        assert!(lines[0]["gpu_ms"].is_null());
659        assert_eq!(lines[1]["gpu_ms"], 4.5);
660        assert_eq!(lines[1]["gpu_tick"], 0);
661        assert!(lines[2]["gpu_ms"].is_null());
662        assert_eq!(lines[3]["gpu_ms"], 5.5);
663        assert_eq!(lines[3]["gpu_tick"], 2);
664    }
665
666    #[test]
667    fn gpu_time_of_a_frame_that_was_never_presented_is_ignored() {
668        let sink = Sink::default();
669        let stats = FrameStats::to_writer(Box::new(sink.clone()), 16);
670        let base = Instant::now();
671        frame(&stats, base, 4, 0, FrameKind::Full);
672        stats.gpu(3, 9.0);
673        frame(&stats, base, 5, 20, FrameKind::Full);
674        stats.finish();
675        let lines = sink.lines();
676        assert!(lines[0]["gpu_ms"].is_null());
677        assert!(lines[1]["gpu_ms"].is_null());
678    }
679
680    #[test]
681    fn a_full_channel_drops_lines_and_counts_them() {
682        let sink = Sink::default();
683        let (open, gate) = mpsc::channel();
684        let stats = FrameStats::to_writer(
685            Box::new(Gate {
686                sink: sink.clone(),
687                open: gate,
688                passed: false,
689            }),
690            2,
691        );
692        let base = Instant::now();
693        for number in 0..10 {
694            frame(&stats, base, number, number * 17, FrameKind::Full);
695        }
696        let dropped = stats.dropped();
697        assert!(dropped >= 5, "dropped {dropped}");
698        open.send(()).unwrap();
699        stats.finish();
700        let lines = sink.lines();
701        let end = lines.last().unwrap();
702        assert_eq!(end["end"], true);
703        assert_eq!(end["frames"], 10);
704        let dropped = end["dropped"].as_u64().unwrap();
705        assert!(dropped >= 5);
706        assert_eq!(lines.len() as u64 - 1 + dropped, 10);
707    }
708
709    #[test]
710    fn nothing_is_written_when_unset() {
711        let stats = FrameStats::from_value(None);
712        assert!(!stats.enabled());
713        let blank = FrameStats::from_value(Some(OsStr::new("")));
714        assert!(!blank.enabled());
715        let base = Instant::now();
716        frame(&stats, base, 0, 0, FrameKind::Full);
717        stats.sim_begin_at(base);
718        stats.sim_end_at(ms(base, 1));
719        stats.gpu(0, 1.0);
720        stats.finish();
721        assert_eq!(stats.frames(), 0);
722        assert_eq!(stats.dropped(), 0);
723    }
724
725    #[test]
726    fn a_frame_without_a_begin_is_not_counted() {
727        let sink = Sink::default();
728        let stats = FrameStats::to_writer(Box::new(sink.clone()), 4);
729        let base = Instant::now();
730        stats.submitted_at(0, [8, 8], ms(base, 1));
731        stats.begin_at(FrameKind::Full, ms(base, 2));
732        stats.discard();
733        stats.submitted_at(1, [8, 8], ms(base, 3));
734        stats.finish();
735        let lines = sink.lines();
736        assert_eq!(lines.len(), 1);
737        assert_eq!(lines[0]["frames"], 0);
738    }
739
740    #[test]
741    fn hud_time_lands_on_the_frame_it_drew_over_and_is_null_otherwise() {
742        let sink = Sink::default();
743        let stats = FrameStats::to_writer(Box::new(sink.clone()), 16);
744        let base = Instant::now();
745        frame(&stats, base, 0, 0, FrameKind::Full);
746        stats.hud_begin_at(ms(base, 3));
747        stats.hud_end_at(ms(base, 4));
748        frame(&stats, base, 1, 17, FrameKind::Full);
749        frame(&stats, base, 2, 33, FrameKind::Full);
750        stats.hud_begin_at(ms(base, 36));
751        stats.hud_end_at(ms(base, 38));
752        stats.hud_end_at(ms(base, 40));
753        stats.finish();
754        let lines = sink.lines();
755        assert_eq!(lines[0]["hud_ms"], 1.0);
756        assert!(lines[1]["hud_ms"].is_null());
757        assert_eq!(lines[2]["hud_ms"], 2.0);
758        let text = String::from_utf8(sink.0.lock().unwrap().clone()).unwrap();
759        let first = text.lines().next().unwrap();
760        assert!(
761            first.ends_with(",\"device\":null,\"hud_ms\":1.000}"),
762            "{first}"
763        );
764    }
765
766    #[test]
767    fn measuring_keeps_recent_frames_without_writing() {
768        let stats = FrameStats::measure();
769        assert!(stats.enabled());
770        assert!(!stats.writes());
771        let base = Instant::now();
772        frame(&stats, base, 0, 0, FrameKind::Full);
773        assert!(stats.recent().is_empty());
774        stats.keep_recent(3);
775        stats.keep_recent(2);
776        stats.sim_begin_at(ms(base, 10));
777        stats.sim_end_at(ms(base, 12));
778        frame(&stats, base, 1, 17, FrameKind::Partial);
779        stats.hud_begin_at(ms(base, 20));
780        stats.hud_end_at(ms(base, 21));
781        frame(&stats, base, 2, 33, FrameKind::Idle);
782        stats.gpu(1, 3.5);
783        frame(&stats, base, 3, 50, FrameKind::Full);
784        frame(&stats, base, 4, 67, FrameKind::Full);
785        let recent = stats.recent();
786        assert_eq!(
787            recent.iter().map(|sample| sample.tick).collect::<Vec<_>>(),
788            [2, 3, 4]
789        );
790        assert_eq!(recent[0].kind, FrameKind::Idle);
791        assert_eq!(recent[0].interval_ms, Some(16.0));
792        assert!(recent[0].gpu_ms.is_none());
793        stats.gpu(3, 4.25);
794        let recent = stats.recent();
795        assert_eq!(recent[1].gpu_ms, Some(4.25));
796        assert_eq!(recent[1].submit_ms, 2.0);
797        let early = FrameStats::measure();
798        early.keep_recent(4);
799        frame(&early, base, 0, 0, FrameKind::Full);
800        early.sim_begin_at(ms(base, 5));
801        early.sim_end_at(ms(base, 7));
802        frame(&early, base, 1, 17, FrameKind::Full);
803        early.hud_begin_at(ms(base, 20));
804        early.hud_end_at(ms(base, 23));
805        early.gpu(0, 1.5);
806        let recent = early.recent();
807        assert_eq!(recent[0].gpu_ms, Some(1.5));
808        assert!(recent[0].hud_ms.is_none());
809        assert_eq!(recent[1].sim_ms, Some(2.0));
810        assert_eq!(recent[1].hud_ms, Some(3.0));
811        stats.finish();
812        assert_eq!(stats.frames(), 5);
813    }
814
815    #[test]
816    fn the_env_value_opens_the_file_and_appends() {
817        let dir = Path::new(env!("CARGO_MANIFEST_DIR"))
818            .join("../../tmp")
819            .join(format!("frame-stats-test-{}", std::process::id()));
820        std::fs::create_dir_all(&dir).unwrap();
821        let path = dir.join("stats.jsonl");
822        let _ = std::fs::remove_file(&path);
823        for _ in 0..2 {
824            let stats = FrameStats::from_value(Some(path.as_os_str()));
825            assert!(stats.enabled());
826            let base = Instant::now();
827            frame(&stats, base, 0, 0, FrameKind::Full);
828        }
829        let text = std::fs::read_to_string(&path).unwrap();
830        let lines: Vec<serde_json::Value> = text
831            .lines()
832            .map(|line| serde_json::from_str(line).unwrap())
833            .collect();
834        assert_eq!(lines.len(), 4);
835        assert_eq!(lines[1]["end"], true);
836        assert_eq!(lines[3]["end"], true);
837        assert_eq!(lines[0]["device"], detect(&SystemSource).device.label());
838        let _ = std::fs::remove_dir_all(&dir);
839    }
840
841    #[test]
842    fn the_device_label_fills_every_line_once_set() {
843        let sink = Sink::default();
844        let stats = FrameStats::to_writer(Box::new(sink.clone()), 16);
845        assert_eq!(stats.device(), None);
846        let base = Instant::now();
847        frame(&stats, base, 0, 0, FrameKind::Full);
848        stats.set_device(Some(Device::SteamDeck {
849            model: pfx_gpu::screens::DeckModel::Oled,
850        }));
851        assert_eq!(stats.device(), Some("steam-deck-oled"));
852        frame(&stats, base, 1, 17, FrameKind::Full);
853        frame(&stats, base, 2, 33, FrameKind::Full);
854        stats.finish();
855        let lines = sink.lines();
856        assert!(lines[0]["device"].is_null());
857        assert_eq!(lines[1]["device"], "steam-deck-oled");
858        assert_eq!(lines[2]["device"], "steam-deck-oled");
859        assert!(FrameStats::off().device().is_none());
860    }
861}