1use core::fmt;
13
14use crate::digest::StateHash;
15use crate::schedule::FramePlan;
16use crate::time::Nanos;
17
18#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct FrameStats {
24 frames: u64,
25 ticks: u64,
26 steps_dropped: u64,
27 hash: StateHash,
28}
29
30impl FrameStats {
31 #[must_use]
32 pub const fn new() -> Self {
33 Self {
34 frames: 0,
35 ticks: 0,
36 steps_dropped: 0,
37 hash: StateHash::new(),
38 }
39 }
40
41 pub fn absorb(&mut self, plan: &FramePlan) {
49 self.frames = self.frames.saturating_add(1);
50 self.ticks = self.ticks.saturating_add(u64::from(plan.step_count()));
51 self.steps_dropped = self.steps_dropped.saturating_add(plan.dropped());
52 self.hash = self.hash.absorb_plan(plan);
53 }
54
55 #[must_use]
56 pub const fn frames(&self) -> u64 {
57 self.frames
58 }
59
60 #[must_use]
61 pub const fn ticks(&self) -> u64 {
62 self.ticks
63 }
64
65 #[must_use]
66 pub const fn steps_dropped(&self) -> u64 {
67 self.steps_dropped
68 }
69
70 #[must_use]
72 pub const fn schedule_hash(&self) -> u64 {
73 self.hash.finish()
74 }
75
76 #[must_use]
77 pub const fn json(&self) -> FrameStatsJson<'_> {
78 FrameStatsJson(self)
79 }
80}
81
82impl Default for FrameStats {
83 fn default() -> Self {
84 Self::new()
85 }
86}
87
88#[derive(Clone, Copy, Debug)]
95pub struct FrameStatsJson<'a>(&'a FrameStats);
96
97impl fmt::Display for FrameStatsJson<'_> {
98 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99 write!(
100 f,
101 "{{\"frames\":{},\"ticks\":{},\"steps_dropped\":{},\"schedule_hash\":\"{:#018x}\"}}",
102 self.0.frames,
103 self.0.ticks,
104 self.0.steps_dropped,
105 self.0.schedule_hash()
106 )
107 }
108}
109
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
117pub struct FrameTiming {
118 count: u64,
119 min: u64,
120 max: u64,
121 sum: u64,
122 drawn: u64,
123 skipped: u64,
124}
125
126impl FrameTiming {
127 #[must_use]
128 pub const fn new() -> Self {
129 Self {
130 count: 0,
131 min: u64::MAX,
134 max: 0,
135 sum: 0,
136 drawn: 0,
137 skipped: 0,
138 }
139 }
140
141 pub fn record(&mut self, cpu_frame: Nanos, drawn: bool) {
148 let nanos = cpu_frame.get();
149 self.count = self.count.saturating_add(1);
150 self.min = self.min.min(nanos);
151 self.max = self.max.max(nanos);
152 self.sum = self.sum.saturating_add(nanos);
153 if drawn {
154 self.drawn = self.drawn.saturating_add(1);
155 } else {
156 self.skipped = self.skipped.saturating_add(1);
157 }
158 }
159
160 #[must_use]
161 pub const fn json(&self) -> FrameTimingJson<'_> {
162 FrameTimingJson(self)
163 }
164
165 const fn reported_min(&self) -> u64 {
168 if self.count == 0 { 0 } else { self.min }
169 }
170}
171
172impl Default for FrameTiming {
173 fn default() -> Self {
174 Self::new()
175 }
176}
177
178#[derive(Clone, Copy, Debug)]
182pub struct FrameTimingJson<'a>(&'a FrameTiming);
183
184impl fmt::Display for FrameTimingJson<'_> {
185 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186 write!(
187 f,
188 "{{\"count\":{},\"min_ns\":{},\"max_ns\":{},\"sum_ns\":{},\"drawn\":{},\"skipped\":{}}}",
189 self.0.count,
190 self.0.reported_min(),
191 self.0.max,
192 self.0.sum,
193 self.0.drawn,
194 self.0.skipped
195 )
196 }
197}
198
199#[cfg(test)]
200mod tests {
201 use super::{FrameStats, FrameTiming};
202 use crate::digest::StateHash;
203 use crate::schedule::FrameLoop;
204 use crate::time::{Nanos, StepBudget, Timestamp, Timestep};
205
206 fn stalling_loop() -> FrameLoop {
207 FrameLoop::new(
208 Timestep::HZ_60,
209 StepBudget::DEFAULT,
210 Timestamp::from_nanos(0),
211 )
212 }
213
214 #[test]
215 fn a_fresh_tally_is_empty_and_holds_the_untouched_digest() {
216 let stats = FrameStats::new();
217 assert_eq!(stats, FrameStats::default());
218 assert_eq!(stats.frames(), 0);
219 assert_eq!(stats.ticks(), 0);
220 assert_eq!(stats.steps_dropped(), 0);
221 assert_eq!(stats.schedule_hash(), StateHash::new().finish());
222 assert_eq!(
223 stats.json().to_string(),
224 "{\"frames\":0,\"ticks\":0,\"steps_dropped\":0,\"schedule_hash\":\"0xcbf29ce484222325\"}"
225 );
226 }
227
228 #[test]
229 fn absorbing_frames_tallies_steps_and_drops_and_moves_the_digest() {
230 let mut frame = stalling_loop();
231 let mut stats = FrameStats::new();
232 for now in [16_666_667_u64, 33_333_334, 233_333_334] {
234 stats.absorb(&frame.begin_frame(Timestamp::from_nanos(now)));
235 }
236 assert_eq!(stats.frames(), 3);
237 assert_eq!(stats.ticks(), 7, "one, one, then the budgeted five");
238 assert!(stats.steps_dropped() > 0, "the stall was refused");
239 assert_ne!(stats.schedule_hash(), StateHash::new().finish());
240
241 let json = stats.json().to_string();
244 let prefix = "{\"frames\":3,\"ticks\":7,\"steps_dropped\":6,\"schedule_hash\":\"0x";
245 assert!(json.starts_with(prefix), "{json}");
246 assert!(json.ends_with("\"}"), "{json}");
247 assert_eq!(
248 json.len(),
249 prefix.len() + 16 + 2,
250 "sixteen hex digits: {json}"
251 );
252 }
253
254 #[test]
257 fn two_runs_of_one_schedule_produce_identical_statistics() {
258 let run = || {
259 let mut frame = stalling_loop();
260 let mut stats = FrameStats::new();
261 for k in 1..=32u64 {
262 stats.absorb(&frame.begin_frame(Timestamp::from_nanos(k * 12_000_000)));
263 }
264 stats
265 };
266 assert_eq!(run(), run());
267 }
268
269 #[test]
270 fn a_fresh_timing_summary_reports_a_zero_minimum_rather_than_the_sentinel() {
271 let timing = FrameTiming::new();
272 assert_eq!(timing, FrameTiming::default());
273 assert_eq!(
274 timing.json().to_string(),
275 "{\"count\":0,\"min_ns\":0,\"max_ns\":0,\"sum_ns\":0,\"drawn\":0,\"skipped\":0}"
276 );
277 }
278
279 #[test]
280 fn recording_frames_tracks_the_extremes_the_total_and_the_presented_split() {
281 let mut timing = FrameTiming::new();
282 timing.record(Nanos::from_nanos(4_000_000), true);
283 timing.record(Nanos::from_nanos(1_000_000), true);
284 timing.record(Nanos::from_nanos(9_000_000), false);
285 assert_eq!(
286 timing.json().to_string(),
287 "{\"count\":3,\"min_ns\":1000000,\"max_ns\":9000000,\"sum_ns\":14000000,\
288 \"drawn\":2,\"skipped\":1}"
289 );
290 }
291
292 #[test]
293 fn the_measured_total_saturates_rather_than_wrapping() {
294 let mut timing = FrameTiming::new();
295 timing.record(Nanos::from_nanos(u64::MAX), true);
296 timing.record(Nanos::from_nanos(u64::MAX), true);
297 let json = timing.json().to_string();
298 assert!(json.contains(&format!("\"sum_ns\":{}", u64::MAX)), "{json}");
299 assert!(json.contains(&format!("\"min_ns\":{}", u64::MAX)), "{json}");
300 }
301}