use std::collections::VecDeque;
use std::time::Duration;
use crate::protocol::Phase;
const ACTIVE_TAIL: Duration = Duration::from_millis(500);
const WINDOW: Duration = Duration::from_secs(1);
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Stats {
pub fps: f32,
pub active_fps: f32,
pub idle_fps: f32,
pub last_response_ms: Option<f32>,
pub max_active_gap_ms: f32,
pub frames: u64,
}
#[derive(Debug, Clone, Copy)]
struct Span {
start: Duration,
end: Option<Duration>,
}
impl Span {
fn contains(&self, t: Duration) -> bool {
t >= self.start && self.end.is_none_or(|e| t <= e)
}
}
#[derive(Debug, Default)]
pub(crate) struct StatsTracker {
frames: u64,
recent: VecDeque<Duration>,
spans: Vec<Span>,
last_frame: Option<Duration>,
moved_since_frame: bool,
max_gap: Duration,
down_at: Option<Duration>,
response: Option<Duration>,
}
impl StatsTracker {
pub(crate) fn new() -> Self {
Self::default()
}
pub(crate) fn on_frame(&mut self, now: Duration) {
self.frames += 1;
if let Some(prev) = self.last_frame
&& self.moved_since_frame
{
self.max_gap = self.max_gap.max(now.saturating_sub(prev));
}
self.last_frame = Some(now);
self.moved_since_frame = false;
if let (Some(down), None) = (self.down_at, self.response) {
self.response = Some(now.saturating_sub(down));
}
self.recent.push_back(now);
self.prune(now);
}
pub(crate) fn on_input(&mut self, phase: Phase, now: Duration) {
match phase {
Phase::Down => {
self.down_at = Some(now);
self.response = None;
match self.spans.last_mut() {
Some(s) if s.end.is_some_and(|e| now <= e) => s.end = None,
Some(s) if s.end.is_none() => {}
_ => self.spans.push(Span {
start: now,
end: None,
}),
}
}
Phase::Move => self.moved_since_frame = true,
Phase::Up => {
if let Some(s) = self.spans.last_mut()
&& s.end.is_none()
{
s.end = Some(now + ACTIVE_TAIL);
}
}
}
self.prune(now);
}
pub(crate) fn snapshot(&self, now: Duration) -> Stats {
let start = now.saturating_sub(WINDOW);
let (mut total, mut active) = (0u32, 0u32);
for &t in self.recent.iter().filter(|&&t| t > start && t <= now) {
total += 1;
if self.spans.iter().any(|s| s.contains(t)) {
active += 1;
}
}
let active_secs: f32 = self
.spans
.iter()
.map(|s| {
let lo = s.start.max(start);
let hi = s.end.unwrap_or(now).min(now);
hi.saturating_sub(lo).as_secs_f32()
})
.sum::<f32>()
.min(1.0);
let idle_secs = 1.0 - active_secs;
let rate = |n: u32, secs: f32| if secs > 0.0 { n as f32 / secs } else { 0.0 };
Stats {
fps: total as f32,
active_fps: rate(active, active_secs),
idle_fps: rate(total - active, idle_secs),
last_response_ms: self.response.map(|d| d.as_secs_f32() * 1000.0),
max_active_gap_ms: self.max_gap.as_secs_f32() * 1000.0,
frames: self.frames,
}
}
fn prune(&mut self, now: Duration) {
let start = now.saturating_sub(WINDOW);
while self.recent.front().is_some_and(|&t| t <= start) {
self.recent.pop_front();
}
self.spans.retain(|s| s.end.is_none_or(|e| e >= start));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ms(n: u64) -> Duration {
Duration::from_millis(n)
}
#[test]
fn fps_counts_frames_in_last_second_only() {
let mut s = StatsTracker::new();
for t in [100, 600, 1100, 1500] {
s.on_frame(ms(t));
}
let st = s.snapshot(ms(1500));
assert_eq!(st.fps, 3.0); assert_eq!(st.frames, 4);
assert_eq!(s.snapshot(ms(1600)).fps, 2.0); }
#[test]
fn active_idle_split_with_500ms_tail() {
let mut s = StatsTracker::new();
s.on_input(Phase::Down, ms(1000));
s.on_frame(ms(1200)); s.on_input(Phase::Up, ms(1400));
s.on_frame(ms(1900)); s.on_frame(ms(1901)); let st = s.snapshot(ms(2000));
assert_eq!(st.fps, 3.0);
assert!((st.active_fps - 2.0 / 0.9).abs() < 1e-3);
assert!((st.idle_fps - 1.0 / 0.1).abs() < 1e-2);
}
#[test]
fn idle_only_window_has_zero_active_fps() {
let mut s = StatsTracker::new();
s.on_frame(ms(100));
s.on_frame(ms(200));
let st = s.snapshot(ms(900));
assert_eq!(st.active_fps, 0.0);
assert_eq!(st.idle_fps, 2.0);
}
#[test]
fn last_response_is_latest_down_to_first_frame_after() {
let mut s = StatsTracker::new();
assert_eq!(s.snapshot(ms(0)).last_response_ms, None);
s.on_input(Phase::Down, ms(100));
assert_eq!(s.snapshot(ms(150)).last_response_ms, None); s.on_frame(ms(116));
s.on_frame(ms(200)); assert_eq!(s.snapshot(ms(300)).last_response_ms, Some(16.0));
s.on_input(Phase::Up, ms(300));
s.on_input(Phase::Down, ms(1000)); assert_eq!(s.snapshot(ms(1100)).last_response_ms, None);
s.on_frame(ms(1040));
assert_eq!(s.snapshot(ms(1100)).last_response_ms, Some(40.0));
}
#[test]
fn max_active_gap_counts_only_gaps_containing_a_move() {
let mut s = StatsTracker::new();
s.on_frame(ms(0));
s.on_frame(ms(500)); s.on_input(Phase::Down, ms(510));
s.on_input(Phase::Move, ms(520));
s.on_frame(ms(570)); s.on_frame(ms(870)); s.on_input(Phase::Move, ms(880));
s.on_input(Phase::Move, ms(890));
s.on_frame(ms(930)); let st = s.snapshot(ms(930));
assert_eq!(st.max_active_gap_ms, 70.0);
assert_eq!(st.frames, 5);
}
#[test]
fn frames_is_total_beyond_window() {
let mut s = StatsTracker::new();
for i in 0..50 {
s.on_frame(ms(i * 100));
}
let st = s.snapshot(ms(4900));
assert_eq!(st.frames, 50);
assert_eq!(st.fps, 10.0);
}
}