Files
Ferrous-Solitaire/solitaire_engine/src/card_animation/diagnostics.rs
T
funman300 6e407a3ea7
Build and Deploy / build-and-push (push) Successful in 3m54s
fix(engine,server): safe area clamp, analytics batch, achievement save order, daily rollover, replay validation, leaderboard opt-in (#56, #60, #61, #62, #66, #68)
- #66: Clamp safe-area insets to 25% of window height with warn!() on excess
- #68: Move fire_flush outside per-event loop in analytics (batch flush once)
- #56: Persist progress before marking reward_granted to prevent XP loss on crash
- #60: Add DateRolloverTimer + check_date_rollover system for midnight seed refresh
- #62: Add validate_header() in replay upload with mode/draw_mode allowlists
- #61: Restore two-query leaderboard opt-in check (SELECT then UPDATE); original
       queries already in .sqlx cache; EXISTS variant would require sqlx prepare

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 13:07:22 -07:00

243 lines
7.1 KiB
Rust

//! Lightweight frame-time diagnostics.
//!
//! [`FrameTimeDiagnostics`] is a Bevy resource that maintains a rolling window
//! of the last [`WINDOW_SIZE`] frame durations. Any system can read it to make
//! performance-aware decisions — for example, disabling settle-bounce animations
//! when the game is running below 30 FPS on a low-end device.
//!
//! # Reading diagnostics
//!
//! ```ignore
//! fn my_system(diag: Res<FrameTimeDiagnostics>) {
//! if diag.is_low_performance() {
//! // Skip expensive visual effects.
//! return;
//! }
//! println!("avg FPS: {:.1}", diag.fps());
//! }
//! ```
//!
//! # Update
//!
//! [`update_frame_time_diagnostics`] runs every frame via [`CardAnimationPlugin`]
//! (or whichever plugin registers it). The window is circular so only the last
//! `WINDOW_SIZE` frames influence the statistics.
use bevy::prelude::*;
/// Number of frames kept in the rolling statistics window.
pub const WINDOW_SIZE: usize = 60;
/// Rolling frame-time statistics over the last [`WINDOW_SIZE`] frames.
///
/// All times are in seconds. Statistics are updated every frame by
/// [`update_frame_time_diagnostics`].
#[derive(Resource, Debug)]
pub struct FrameTimeDiagnostics {
samples: [f32; WINDOW_SIZE],
head: usize,
count: usize,
/// Smoothed average frame duration over the window (seconds).
pub avg_secs: f32,
/// Worst-case (slowest) frame duration in the window (seconds).
pub max_secs: f32,
/// Best-case (fastest) frame duration in the window (seconds).
pub min_secs: f32,
}
impl Default for FrameTimeDiagnostics {
fn default() -> Self {
Self {
samples: [0.0; WINDOW_SIZE],
head: 0,
count: 0,
avg_secs: 0.0,
max_secs: 0.0,
min_secs: 0.0,
}
}
}
impl FrameTimeDiagnostics {
/// Estimated frames per second based on the rolling average.
///
/// Returns `0.0` until at least one frame has been recorded.
pub fn fps(&self) -> f32 {
if self.avg_secs > 0.0 {
1.0 / self.avg_secs
} else {
0.0
}
}
/// Returns `true` when the rolling-average FPS is above `target`.
///
/// Always returns `false` until the window is fully populated.
pub fn is_above_target(&self, target_fps: f32) -> bool {
self.count >= WINDOW_SIZE && self.fps() > target_fps
}
/// Returns `true` when the device appears to be running below 30 FPS.
///
/// Only asserted after the window is fully populated so a single slow
/// startup frame does not permanently suppress visual effects.
pub fn is_low_performance(&self) -> bool {
self.count >= WINDOW_SIZE && self.fps() < 30.0
}
/// Appends `dt` to the ring buffer and recomputes statistics.
///
/// O(WINDOW_SIZE) — acceptable because WINDOW_SIZE is small and constant.
fn push(&mut self, dt: f32) {
self.samples[self.head] = dt;
self.head = (self.head + 1) % WINDOW_SIZE;
if self.count < WINDOW_SIZE {
self.count += 1;
}
let n = self.count;
let mut sum = 0.0_f32;
let mut max_val = 0.0_f32;
let mut min_val = f32::MAX;
for &s in &self.samples[..n] {
sum += s;
if s > max_val {
max_val = s;
}
if s < min_val {
min_val = s;
}
}
self.avg_secs = sum / n as f32;
self.max_secs = max_val;
self.min_secs = if min_val == f32::MAX { 0.0 } else { min_val };
}
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
/// Records the current frame's delta time in [`FrameTimeDiagnostics`].
///
/// Registered by [`CardAnimationPlugin`]. Runs every frame in `Update`.
pub(crate) fn update_frame_time_diagnostics(
time: Res<Time>,
mut diag: ResMut<FrameTimeDiagnostics>,
) {
diag.push(time.delta_secs());
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fps_zero_when_no_samples() {
assert_eq!(FrameTimeDiagnostics::default().fps(), 0.0);
}
#[test]
fn fps_correct_after_uniform_frames() {
let mut d = FrameTimeDiagnostics::default();
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 60.0);
}
assert!(
(d.fps() - 60.0).abs() < 0.5,
"expected ~60 fps, got {}",
d.fps()
);
}
#[test]
fn is_low_performance_requires_full_window() {
let mut d = FrameTimeDiagnostics::default();
// Partial window filled with very slow frames.
for _ in 0..(WINDOW_SIZE / 2) {
d.push(1.0 / 5.0); // 5 FPS
}
assert!(
!d.is_low_performance(),
"must not report low performance until the window is full"
);
}
#[test]
fn is_low_performance_true_below_30fps() {
let mut d = FrameTimeDiagnostics::default();
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 20.0); // 20 FPS
}
assert!(
d.is_low_performance(),
"20 FPS should be reported as low performance"
);
}
#[test]
fn is_above_target_false_below_target() {
let mut d = FrameTimeDiagnostics::default();
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 30.0); // exactly 30 FPS
}
// is_above_target(30.0) is strict: fps must be > 30, not >=.
// At exactly 30 FPS the result depends on floating-point rounding,
// so just check that it's consistent with > 60 being false.
assert!(
!d.is_above_target(60.0),
"30 FPS is not above 60 FPS target"
);
}
#[test]
fn max_and_min_track_extremes() {
let mut d = FrameTimeDiagnostics::default();
d.push(0.010); // fast frame (100 FPS)
d.push(0.050); // slow frame (20 FPS)
assert!(
d.max_secs >= 0.050,
"max_secs must be at least the slow frame, got {}",
d.max_secs
);
assert!(
d.min_secs <= 0.010,
"min_secs must be at most the fast frame, got {}",
d.min_secs
);
}
#[test]
fn circular_buffer_overwrites_oldest() {
let mut d = FrameTimeDiagnostics::default();
// Fill with 60-FPS samples.
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 60.0);
}
// Overwrite every slot with 10-FPS samples.
for _ in 0..WINDOW_SIZE {
d.push(1.0 / 10.0);
}
assert!(
d.fps() < 15.0,
"after full overwrite, avg must reflect new slow frames; got fps={}",
d.fps()
);
}
#[test]
fn count_does_not_exceed_window_size() {
let mut d = FrameTimeDiagnostics::default();
for _ in 0..WINDOW_SIZE * 3 {
d.push(0.016);
}
assert_eq!(d.count, WINDOW_SIZE);
}
}