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}