Compare commits

..

3 Commits

12 changed files with 358 additions and 33 deletions

14
Cargo.lock generated
View File

@@ -528,6 +528,12 @@ version = "2.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0" checksum = "32a282da65faaf38286cf3be983213fcf1d2e2a58700e808f83f4ea9a4804bc0"
[[package]]
name = "micromap"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18c087666f377f857b49564f8791b481260c67825d6b337e1e38ddf54a985a88"
[[package]] [[package]]
name = "nonmax" name = "nonmax"
version = "0.5.5" version = "0.5.5"
@@ -575,6 +581,7 @@ dependencies = [
"glam 0.30.5", "glam 0.30.5",
"lazy_static", "lazy_static",
"libc", "libc",
"micromap",
"once_cell", "once_cell",
"parking_lot", "parking_lot",
"pathfinding", "pathfinding",
@@ -588,6 +595,7 @@ dependencies = [
"strum", "strum",
"strum_macros", "strum_macros",
"thiserror", "thiserror",
"thousands",
"tracing", "tracing",
"tracing-error", "tracing-error",
"tracing-subscriber", "tracing-subscriber",
@@ -992,6 +1000,12 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "thousands"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3bf63baf9f5039dadc247375c29eb13706706cfde997d0330d05aa63a77d8820"
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.1.7" version = "1.1.7"

View File

@@ -27,6 +27,8 @@ phf = { version = "0.12.1", features = ["macros"] }
bevy_ecs = "0.16.1" bevy_ecs = "0.16.1"
bitflags = "2.9.1" bitflags = "2.9.1"
parking_lot = "0.12.3" parking_lot = "0.12.3"
micromap = "0.1.0"
thousands = "0.2.0"
[profile.release] [profile.release]
lto = true lto = true

View File

@@ -5,7 +5,8 @@ use sdl2::render::TextureCreator;
use sdl2::ttf::Sdl2TtfContext; use sdl2::ttf::Sdl2TtfContext;
use sdl2::video::WindowContext; use sdl2::video::WindowContext;
use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem}; use sdl2::{AudioSubsystem, EventPump, Sdl, VideoSubsystem};
use tracing::{field, info, warn}; use thousands::Separable;
use tracing::info;
use crate::error::{GameError, GameResult}; use crate::error::{GameError, GameResult};
@@ -16,6 +17,7 @@ use crate::systems::profiling::SystemTimings;
pub struct App { pub struct App {
pub game: Game, pub game: Game,
last_timings: Instant,
last_tick: Instant, last_tick: Instant,
focused: bool, focused: bool,
_cursor_pos: Vec2, _cursor_pos: Vec2,
@@ -68,6 +70,7 @@ impl App {
game, game,
focused: true, focused: true,
last_tick: Instant::now(), last_tick: Instant::now(),
last_timings: Instant::now() - Duration::from_secs_f32(0.5),
_cursor_pos: Vec2::ZERO, _cursor_pos: Vec2::ZERO,
}) })
} }
@@ -110,15 +113,26 @@ impl App {
return false; return false;
} }
// Show timings if the loop took more than 25% of the loop time if self.last_timings.elapsed() > Duration::from_secs(1) {
let show_timings = start.elapsed() > (LOOP_TIME / 4); // Show timing statistics over the last 90 frames
if show_timings || true {
if let Some(timings) = self.game.world.get_resource::<SystemTimings>() { if let Some(timings) = self.game.world.get_resource::<SystemTimings>() {
let mut timings = timings.timings.lock(); let stats = timings.get_stats();
let total = timings.values().sum::<Duration>(); let (total_avg, total_std) = timings.get_total_stats();
info!("Total: {:?}, Timings: {:?}", total, field::debug(&timings));
timings.clear(); let mut individual_timings = String::new();
for (name, (avg, std_dev)) in stats.iter() {
individual_timings.push_str(&format!("{}={:?} ± {:?} ", name, avg, std_dev));
}
let effective_fps = match 1.0 / total_avg.as_secs_f64() {
f if f > 100.0 => (f as u32).separate_with_commas(),
f if f < 10.0 => format!("{:.1} FPS", f),
f => format!("{:.0} FPS", f),
};
info!("({effective_fps}) {total_avg:?} ± {total_std:?} ({individual_timings})");
} }
self.last_timings = Instant::now();
} }
// Sleep if we still have time left // Sleep if we still have time left

View File

@@ -223,6 +223,19 @@ impl Graph {
self.nodes.len() self.nodes.len()
} }
/// Returns an iterator over all nodes in the graph.
pub fn nodes(&self) -> impl Iterator<Item = &Node> {
self.nodes.iter()
}
/// Returns an iterator over all edges in the graph.
pub fn edges(&self) -> impl Iterator<Item = (NodeId, Edge)> + '_ {
self.adjacency_list
.iter()
.enumerate()
.flat_map(|(node_id, intersection)| intersection.edges().map(move |edge| (node_id, edge)))
}
/// Finds a specific edge from a source node to a target node. /// Finds a specific edge from a source node to a target node.
pub fn find_edge(&self, from: NodeId, to: NodeId) -> Option<Edge> { pub fn find_edge(&self, from: NodeId, to: NodeId) -> Option<Edge> {
self.adjacency_list.get(from)?.edges().find(|edge| edge.target == to) self.adjacency_list.get(from)?.edges().find(|edge| edge.target == to)

View File

@@ -17,6 +17,7 @@ use crate::systems::{
PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, Score, ScoreResource, PacmanCollider, PlayerBundle, PlayerControlled, RenderDirty, Renderable, Score, ScoreResource,
}, },
control::player_system, control::player_system,
debug::{debug_render_system, DebugState, DebugTextureResource},
input::input_system, input::input_system,
movement::movement_system, movement::movement_system,
profiling::{profile, SystemTimings}, profiling::{profile, SystemTimings},
@@ -24,7 +25,14 @@ use crate::systems::{
}; };
use crate::texture::animated::AnimatedTexture; use crate::texture::animated::AnimatedTexture;
use bevy_ecs::schedule::IntoScheduleConfigs; use bevy_ecs::schedule::IntoScheduleConfigs;
use bevy_ecs::{event::EventRegistry, observer::Trigger, schedule::Schedule, system::ResMut, world::World}; use bevy_ecs::system::NonSendMut;
use bevy_ecs::{
event::EventRegistry,
observer::Trigger,
schedule::Schedule,
system::{Res, ResMut},
world::World,
};
use sdl2::image::LoadTexture; use sdl2::image::LoadTexture;
use sdl2::render::{Canvas, ScaleMode, TextureCreator}; use sdl2::render::{Canvas, ScaleMode, TextureCreator};
use sdl2::video::{Window, WindowContext}; use sdl2::video::{Window, WindowContext};
@@ -72,6 +80,13 @@ impl Game {
.map_err(|e| GameError::Sdl(e.to_string()))?; .map_err(|e| GameError::Sdl(e.to_string()))?;
map_texture.set_scale_mode(ScaleMode::Nearest); map_texture.set_scale_mode(ScaleMode::Nearest);
// Create debug texture at output resolution for crisp debug rendering
let output_size = canvas.output_size().unwrap();
let mut debug_texture = texture_creator
.create_texture_target(None, output_size.0, output_size.1)
.map_err(|e| GameError::Sdl(e.to_string()))?;
debug_texture.set_scale_mode(ScaleMode::Nearest);
// Load atlas and create map texture // Load atlas and create map texture
let atlas_bytes = get_asset_bytes(Asset::Atlas)?; let atlas_bytes = get_asset_bytes(Asset::Atlas)?;
let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| { let atlas_texture = texture_creator.load_texture_bytes(&atlas_bytes).map_err(|e| {
@@ -157,7 +172,7 @@ impl Game {
}, },
entity_type: EntityType::Player, entity_type: EntityType::Player,
collider: Collider { collider: Collider {
size: constants::CELL_SIZE as f32 * 1.25, size: constants::CELL_SIZE as f32 * 1.1,
layer: CollisionLayer::PACMAN, layer: CollisionLayer::PACMAN,
}, },
pacman_collider: PacmanCollider, pacman_collider: PacmanCollider,
@@ -168,6 +183,7 @@ impl Game {
world.insert_non_send_resource(canvas); world.insert_non_send_resource(canvas);
world.insert_non_send_resource(BackbufferResource(backbuffer)); world.insert_non_send_resource(BackbufferResource(backbuffer));
world.insert_non_send_resource(MapTextureResource(map_texture)); world.insert_non_send_resource(MapTextureResource(map_texture));
world.insert_non_send_resource(DebugTextureResource(debug_texture));
world.insert_resource(map); world.insert_resource(map);
world.insert_resource(GlobalState { exit: false }); world.insert_resource(GlobalState { exit: false });
@@ -176,6 +192,7 @@ impl Game {
world.insert_resource(Bindings::default()); world.insert_resource(Bindings::default());
world.insert_resource(DeltaTime(0f32)); world.insert_resource(DeltaTime(0f32));
world.insert_resource(RenderDirty::default()); world.insert_resource(RenderDirty::default());
world.insert_resource(DebugState::default());
world.add_observer( world.add_observer(
|event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| match *event { |event: Trigger<GameEvent>, mut state: ResMut<GlobalState>, _score: ResMut<ScoreResource>| match *event {
@@ -188,7 +205,6 @@ impl Game {
GameEvent::Collision(_a, _b) => {} GameEvent::Collision(_a, _b) => {}
}, },
); );
schedule.add_systems( schedule.add_systems(
( (
profile("input", input_system), profile("input", input_system),
@@ -197,11 +213,29 @@ impl Game {
profile("collision", collision_system), profile("collision", collision_system),
profile("blinking", blinking_system), profile("blinking", blinking_system),
profile("directional_render", directional_render_system), profile("directional_render", directional_render_system),
profile("dirty_render", dirty_render_system),
profile("render", render_system),
profile("debug_render", debug_render_system),
profile(
"present",
|mut canvas: NonSendMut<&mut Canvas<Window>>,
backbuffer: NonSendMut<BackbufferResource>,
debug_state: Res<DebugState>,
mut dirty: ResMut<RenderDirty>| {
if dirty.0 {
// Only copy backbuffer to main canvas if debug rendering is off
// (debug rendering draws directly to main canvas)
if *debug_state == DebugState::Off {
canvas.copy(&backbuffer.0, None, None).unwrap();
}
dirty.0 = false;
canvas.present();
}
},
),
) )
.chain(), .chain(),
); );
schedule.add_systems((profile("dirty_render", dirty_render_system), profile("render", render_system)).chain());
// Spawn player // Spawn player
world.spawn(player); world.spawn(player);

View File

@@ -9,6 +9,7 @@ use crate::{
error::GameError, error::GameError,
events::{GameCommand, GameEvent}, events::{GameCommand, GameEvent},
systems::components::{GlobalState, PlayerControlled}, systems::components::{GlobalState, PlayerControlled},
systems::debug::DebugState,
systems::movement::Movable, systems::movement::Movable,
}; };
@@ -16,6 +17,7 @@ use crate::{
pub fn player_system( pub fn player_system(
mut events: EventReader<GameEvent>, mut events: EventReader<GameEvent>,
mut state: ResMut<GlobalState>, mut state: ResMut<GlobalState>,
mut debug_state: ResMut<DebugState>,
mut players: Query<&mut Movable, With<PlayerControlled>>, mut players: Query<&mut Movable, With<PlayerControlled>>,
mut errors: EventWriter<GameError>, mut errors: EventWriter<GameError>,
) { ) {
@@ -38,6 +40,9 @@ pub fn player_system(
GameCommand::Exit => { GameCommand::Exit => {
state.exit = true; state.exit = true;
} }
GameCommand::ToggleDebug => {
*debug_state = debug_state.next();
}
_ => {} _ => {}
} }
} }

143
src/systems/debug.rs Normal file
View File

@@ -0,0 +1,143 @@
//! Debug rendering system
use crate::constants::BOARD_PIXEL_OFFSET;
use crate::map::builder::Map;
use crate::systems::components::Collider;
use crate::systems::movement::Position;
use crate::systems::render::BackbufferResource;
use bevy_ecs::prelude::*;
use sdl2::pixels::Color;
use sdl2::rect::Rect;
use sdl2::render::{Canvas, Texture};
use sdl2::video::Window;
#[derive(Resource, Default, Debug, Copy, Clone, PartialEq)]
pub enum DebugState {
#[default]
Off,
Graph,
Collision,
}
impl DebugState {
pub fn next(&self) -> Self {
match self {
DebugState::Off => DebugState::Graph,
DebugState::Graph => DebugState::Collision,
DebugState::Collision => DebugState::Off,
}
}
}
/// Resource to hold the debug texture for persistent rendering
pub struct DebugTextureResource(pub Texture<'static>);
/// Transforms a position from logical canvas coordinates to output canvas coordinates
fn transform_position(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) {
let scale_x = output_size.0 as f32 / logical_size.0 as f32;
let scale_y = output_size.1 as f32 / logical_size.1 as f32;
let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio
let x = (pos.0 * scale) as i32;
let y = (pos.1 * scale) as i32;
(x, y)
}
/// Transforms a position from logical canvas coordinates to output canvas coordinates (with board offset)
fn transform_position_with_offset(pos: (f32, f32), output_size: (u32, u32), logical_size: (u32, u32)) -> (i32, i32) {
let scale_x = output_size.0 as f32 / logical_size.0 as f32;
let scale_y = output_size.1 as f32 / logical_size.1 as f32;
let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio
let x = ((pos.0 + BOARD_PIXEL_OFFSET.x as f32) * scale) as i32;
let y = ((pos.1 + BOARD_PIXEL_OFFSET.y as f32) * scale) as i32;
(x, y)
}
/// Transforms a size from logical canvas coordinates to output canvas coordinates
fn transform_size(size: f32, output_size: (u32, u32), logical_size: (u32, u32)) -> u32 {
let scale_x = output_size.0 as f32 / logical_size.0 as f32;
let scale_y = output_size.1 as f32 / logical_size.1 as f32;
let scale = scale_x.min(scale_y); // Use the smaller scale to maintain aspect ratio
(size * scale) as u32
}
pub fn debug_render_system(
mut canvas: NonSendMut<&mut Canvas<Window>>,
backbuffer: NonSendMut<BackbufferResource>,
mut debug_texture: NonSendMut<DebugTextureResource>,
debug_state: Res<DebugState>,
map: Res<Map>,
colliders: Query<(&Collider, &Position)>,
) {
if *debug_state == DebugState::Off {
return;
}
// Get canvas sizes for coordinate transformation
let output_size = canvas.output_size().unwrap();
let logical_size = canvas.logical_size();
// Copy the current backbuffer to the debug texture
canvas
.with_texture_canvas(&mut debug_texture.0, |debug_canvas| {
// Clear the debug canvas
debug_canvas.set_draw_color(Color::BLACK);
debug_canvas.clear();
// Copy the backbuffer to the debug canvas
debug_canvas.copy(&backbuffer.0, None, None).unwrap();
})
.unwrap();
// Draw debug info on the high-resolution debug texture
canvas
.with_texture_canvas(&mut debug_texture.0, |debug_canvas| match *debug_state {
DebugState::Graph => {
debug_canvas.set_draw_color(Color::RED);
for (start_node, end_node) in map.graph.edges() {
let start_node = map.graph.get_node(start_node).unwrap().position;
let end_node = map.graph.get_node(end_node.target).unwrap().position;
// Transform positions using common method
let (start_x, start_y) =
transform_position_with_offset((start_node.x, start_node.y), output_size, logical_size);
let (end_x, end_y) = transform_position_with_offset((end_node.x, end_node.y), output_size, logical_size);
debug_canvas.draw_line((start_x, start_y), (end_x, end_y)).unwrap();
}
debug_canvas.set_draw_color(Color::BLUE);
for node in map.graph.nodes() {
let pos = node.position;
// Transform position using common method
let (x, y) = transform_position_with_offset((pos.x, pos.y), output_size, logical_size);
let size = transform_size(4.0, output_size, logical_size);
debug_canvas
.fill_rect(Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size))
.unwrap();
}
}
DebugState::Collision => {
debug_canvas.set_draw_color(Color::GREEN);
for (collider, position) in colliders.iter() {
let pos = position.get_pixel_pos(&map.graph).unwrap();
// Transform position and size using common methods
let (x, y) = transform_position((pos.x, pos.y), output_size, logical_size);
let size = transform_size(collider.size, output_size, logical_size);
// Center the collision box on the entity
let rect = Rect::new(x - (size as i32 / 2), y - (size as i32 / 2), size, size);
debug_canvas.draw_rect(rect).unwrap();
}
}
_ => {}
})
.unwrap();
// Draw the debug texture directly onto the main canvas at full resolution
canvas.copy(&debug_texture.0, None, None).unwrap();
}

View File

@@ -48,7 +48,6 @@ pub fn input_system(bindings: Res<Bindings>, mut writer: EventWriter<GameEvent>,
Event::KeyDown { keycode: Some(key), .. } => { Event::KeyDown { keycode: Some(key), .. } => {
let command = bindings.key_bindings.get(&key).copied(); let command = bindings.key_bindings.get(&key).copied();
if let Some(command) = command { if let Some(command) = command {
tracing::info!("triggering command: {:?}", command);
writer.write(GameEvent::Command(command)); writer.write(GameEvent::Command(command));
} }
} }

View File

@@ -7,6 +7,7 @@ pub mod blinking;
pub mod collision; pub mod collision;
pub mod components; pub mod components;
pub mod control; pub mod control;
pub mod debug;
pub mod input; pub mod input;
pub mod movement; pub mod movement;
pub mod profiling; pub mod profiling;

View File

@@ -1,12 +1,82 @@
use bevy_ecs::prelude::Resource; use bevy_ecs::prelude::Resource;
use bevy_ecs::system::{IntoSystem, System}; use bevy_ecs::system::{IntoSystem, System};
use micromap::Map;
use parking_lot::Mutex; use parking_lot::Mutex;
use std::collections::HashMap; use std::collections::VecDeque;
use std::time::Duration; use std::time::Duration;
const TIMING_WINDOW_SIZE: usize = 90; // 1.5 seconds at 60 FPS
#[derive(Resource, Default, Debug)] #[derive(Resource, Default, Debug)]
pub struct SystemTimings { pub struct SystemTimings {
pub timings: Mutex<HashMap<&'static str, Duration>>, pub timings: Mutex<Map<&'static str, VecDeque<Duration>, 15>>,
}
impl SystemTimings {
pub fn add_timing(&self, name: &'static str, duration: Duration) {
let mut timings = self.timings.lock();
let queue = timings.entry(name).or_insert_with(VecDeque::new);
queue.push_back(duration);
if queue.len() > TIMING_WINDOW_SIZE {
queue.pop_front();
}
}
pub fn get_stats(&self) -> Map<&'static str, (Duration, Duration), 10> {
let timings = self.timings.lock();
let mut stats = Map::new();
for (name, queue) in timings.iter() {
if queue.is_empty() {
continue;
}
let durations: Vec<f64> = queue.iter().map(|d| d.as_secs_f64() * 1000.0).collect();
let count = durations.len() as f64;
let sum: f64 = durations.iter().sum();
let mean = sum / count;
let variance = durations.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / count;
let std_dev = variance.sqrt();
stats.insert(
*name,
(
Duration::from_secs_f64(mean / 1000.0),
Duration::from_secs_f64(std_dev / 1000.0),
),
);
}
stats
}
pub fn get_total_stats(&self) -> (Duration, Duration) {
let timings = self.timings.lock();
let mut all_durations = Vec::new();
for queue in timings.values() {
all_durations.extend(queue.iter().map(|d| d.as_secs_f64() * 1000.0));
}
if all_durations.is_empty() {
return (Duration::ZERO, Duration::ZERO);
}
let count = all_durations.len() as f64;
let sum: f64 = all_durations.iter().sum();
let mean = sum / count;
let variance = all_durations.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / count;
let std_dev = variance.sqrt();
(
Duration::from_secs_f64(mean / 1000.0),
Duration::from_secs_f64(std_dev / 1000.0),
)
}
} }
pub fn profile<S, M>(name: &'static str, system: S) -> impl FnMut(&mut bevy_ecs::world::World) pub fn profile<S, M>(name: &'static str, system: S) -> impl FnMut(&mut bevy_ecs::world::World)
@@ -25,8 +95,8 @@ where
system.run((), world); system.run((), world);
let duration = start.elapsed(); let duration = start.elapsed();
if let Some(mut timings) = world.get_resource_mut::<SystemTimings>() { if let Some(timings) = world.get_resource::<SystemTimings>() {
timings.timings.lock().insert(name, duration); timings.add_timing(name, duration);
} }
} }
} }

View File

@@ -65,17 +65,13 @@ pub fn render_system(
mut backbuffer: NonSendMut<BackbufferResource>, mut backbuffer: NonSendMut<BackbufferResource>,
mut atlas: NonSendMut<SpriteAtlas>, mut atlas: NonSendMut<SpriteAtlas>,
map: Res<Map>, map: Res<Map>,
mut dirty: ResMut<RenderDirty>, dirty: Res<RenderDirty>,
renderables: Query<(Entity, &Renderable, &Position)>, renderables: Query<(Entity, &Renderable, &Position)>,
mut errors: EventWriter<GameError>, mut errors: EventWriter<GameError>,
) { ) {
if !dirty.0 { if !dirty.0 {
return; return;
} }
// Clear the main canvas first
canvas.set_draw_color(sdl2::pixels::Color::BLACK);
canvas.clear();
// Render to backbuffer // Render to backbuffer
canvas canvas
.with_texture_canvas(&mut backbuffer.0, |backbuffer_canvas| { .with_texture_canvas(&mut backbuffer.0, |backbuffer_canvas| {
@@ -90,6 +86,10 @@ pub fn render_system(
// Render all entities to the backbuffer // Render all entities to the backbuffer
for (_, renderable, position) in renderables.iter() { for (_, renderable, position) in renderables.iter() {
if !renderable.visible {
continue;
}
let pos = position.get_pixel_pos(&map.graph); let pos = position.get_pixel_pos(&map.graph);
match pos { match pos {
Ok(pos) => { Ok(pos) => {
@@ -112,14 +112,4 @@ pub fn render_system(
}) })
.err() .err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into())); .map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
// Copy backbuffer to main canvas and present
canvas
.copy(&backbuffer.0, None, None)
.err()
.map(|e| errors.write(TextureError::RenderFailed(e.to_string()).into()));
canvas.present();
dirty.0 = false;
} }

40
tests/profiling.rs Normal file
View File

@@ -0,0 +1,40 @@
use pacman::systems::profiling::SystemTimings;
use std::time::Duration;
#[test]
fn test_timing_statistics() {
let timings = SystemTimings::default();
// Add some test data
timings.add_timing("test_system", Duration::from_millis(10));
timings.add_timing("test_system", Duration::from_millis(12));
timings.add_timing("test_system", Duration::from_millis(8));
let stats = timings.get_stats();
let (avg, std_dev) = stats.get("test_system").unwrap();
// Average should be 10ms, standard deviation should be small
assert!((avg.as_millis() as f64 - 10.0).abs() < 1.0);
assert!(std_dev.as_millis() > 0);
let (total_avg, total_std) = timings.get_total_stats();
assert!((total_avg.as_millis() as f64 - 10.0).abs() < 1.0);
assert!(total_std.as_millis() > 0);
}
#[test]
fn test_window_size_limit() {
let timings = SystemTimings::default();
// Add more than 90 timings to test window size limit
for i in 0..100 {
timings.add_timing("test_system", Duration::from_millis(i));
}
let stats = timings.get_stats();
let (avg, _) = stats.get("test_system").unwrap();
// Should only keep the last 90 values, so average should be around 55ms
// (average of 10-99)
assert!((avg.as_millis() as f64 - 55.0).abs() < 5.0);
}