use crate::components::{StatHud, TextLabel};
use crate::ecs::asset_id::AssetId;
use crate::ecs::{HudPrefs, PipelineContext, StepResult, System};
use std::time::Instant;
const EMIT_INTERVAL_SECS: f32 = 0.5;
fn fps_text(frames: u32, elapsed_secs: f32) -> String {
let fps = if elapsed_secs > 0.0 {
frames as f32 / elapsed_secs
} else {
0.0
};
format!("FPS {fps:.0}")
}
fn vram_text(bytes: u64) -> String {
format!("VRAM {} MB", bytes / (1024 * 1024))
}
fn ram_text(rss: Option<u64>, budget_mib: Option<u64>) -> String {
let Some(rss) = rss else {
return String::new();
};
let rss_mib = rss / (1024 * 1024);
match budget_mib {
Some(budget) => format!("RAM {rss_mib} / {budget} MB"),
None => format!("RAM {rss_mib} MB"),
}
}
fn ev_text(ev: Option<f32>) -> String {
match ev {
Some(v) if v.is_finite() => format!("EV {v:+.2}"),
_ => String::new(),
}
}
fn edr_text(max_edr: Option<f32>) -> String {
match max_edr {
Some(v) if v.is_finite() && v > 0.0 => format!("EDR x{v:.1}"),
_ => String::new(),
}
}
#[derive(Debug)]
pub(crate) struct StatHudSystem {
fps_label: Option<AssetId>,
vram_label: Option<AssetId>,
ram_label: Option<AssetId>,
ev_label: Option<AssetId>,
edr_label: Option<AssetId>,
last_emit: Instant,
frames: u32,
vram_bytes: u64,
ram_bytes: Option<u64>,
ev: Option<f32>,
max_edr: Option<f32>,
}
impl StatHudSystem {
pub(crate) fn new(config: StatHud) -> Self {
Self {
fps_label: config.fps_label,
vram_label: config.vram_label,
ram_label: config.ram_label,
ev_label: config.ev_label,
edr_label: config.edr_label,
last_emit: Instant::now(),
frames: 0,
vram_bytes: 0,
ram_bytes: None,
ev: None,
max_edr: None,
}
}
fn write_chip(ctx: &mut PipelineContext, id: Option<AssetId>, text: String) {
crate::ecs::by_asset_id::update::<TextLabel>(ctx, id, |l| l.content = text);
}
}
impl System for StatHudSystem {
fn access(&self) -> crate::ecs::Access {
crate::ecs::Access::new()
.writes_components(crate::component_mask![crate::components::TextLabel])
.reads_resources(crate::resource_mask![
crate::ecs::HudPrefs,
crate::app::budget::MemoryBudget,
])
}
fn step(&mut self, ctx: &mut PipelineContext) -> StepResult {
let (show_fps, show_vram) = ctx
.resource::<HudPrefs>()
.map_or((true, true), |p| (p.show_fps, p.show_vram));
self.frames += 1;
self.vram_bytes = ctx.profile.render.vram_bytes;
self.ev = ctx.profile.render.auto_exposure_ev;
self.max_edr = ctx.profile.render.max_edr;
let now = Instant::now();
let elapsed = now.duration_since(self.last_emit).as_secs_f32();
if elapsed >= EMIT_INTERVAL_SECS {
self.ram_bytes = crate::app::sysmem::process_resident_bytes();
let budget_mib = ctx
.resource::<crate::app::budget::MemoryBudget>()
.map(|b| b.budget_mib());
Self::write_chip(
ctx,
self.fps_label,
if show_fps {
fps_text(self.frames, elapsed)
} else {
String::new()
},
);
Self::write_chip(
ctx,
self.vram_label,
if show_vram {
vram_text(self.vram_bytes)
} else {
String::new()
},
);
Self::write_chip(ctx, self.ram_label, ram_text(self.ram_bytes, budget_mib));
Self::write_chip(ctx, self.ev_label, ev_text(self.ev));
Self::write_chip(ctx, self.edr_label, edr_text(self.max_edr));
self.frames = 0;
self.last_emit = now;
}
StepResult::Continue
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::ecs::SYSTEMS;
#[test]
fn fps_text_averages_frames_over_window() {
assert_eq!(fps_text(60, 1.0), "FPS 60");
assert_eq!(fps_text(75, 0.5), "FPS 150");
}
#[test]
fn fps_text_handles_zero_window() {
assert_eq!(fps_text(0, 0.0), "FPS 0");
}
#[test]
fn vram_text_reports_whole_megabytes() {
assert_eq!(vram_text(0), "VRAM 0 MB");
assert_eq!(vram_text(512 * 1024 * 1024), "VRAM 512 MB");
assert_eq!(vram_text(1024 * 1024 + 1), "VRAM 1 MB");
}
#[test]
fn ram_text_reports_rss_with_budget_when_known() {
assert_eq!(
ram_text(Some(512 * 1024 * 1024), Some(16384)),
"RAM 512 / 16384 MB"
);
}
#[test]
fn ram_text_reports_bare_rss_without_a_budget() {
assert_eq!(ram_text(Some(256 * 1024 * 1024), None), "RAM 256 MB");
}
#[test]
fn ram_text_blanks_when_rss_unavailable() {
assert_eq!(ram_text(None, Some(16384)), "");
assert_eq!(ram_text(None, None), "");
}
#[test]
fn ev_text_formats_signed_value_with_two_decimals() {
assert_eq!(ev_text(Some(1.25)), "EV +1.25");
assert_eq!(ev_text(Some(-0.5)), "EV -0.50");
assert_eq!(ev_text(Some(0.0)), "EV +0.00");
}
#[test]
fn ev_text_blanks_when_auto_exposure_off() {
assert_eq!(ev_text(None), "");
}
#[test]
fn ev_text_blanks_on_non_finite_values() {
assert_eq!(ev_text(Some(f32::NAN)), "");
assert_eq!(ev_text(Some(f32::INFINITY)), "");
}
#[test]
fn edr_text_formats_multiplier_with_one_decimal() {
assert_eq!(edr_text(Some(2.0)), "EDR x2.0");
assert_eq!(edr_text(Some(8.5)), "EDR x8.5");
}
#[test]
fn edr_text_blanks_when_sdr() {
assert_eq!(edr_text(None), "");
}
#[test]
fn edr_text_blanks_on_invalid_values() {
assert_eq!(edr_text(Some(f32::NAN)), "");
assert_eq!(edr_text(Some(f32::INFINITY)), "");
assert_eq!(edr_text(Some(0.0)), "");
assert_eq!(edr_text(Some(-1.0)), "");
}
#[test]
fn stat_hud_component_spawns_internal_system() {
use crate::components::StatHud;
use crate::ecs::World;
let mut world = World::new();
world.add_component(StatHud::default());
world.start(SYSTEMS).unwrap();
let names: Vec<&str> = world.systems().iter().map(|s| s.name()).collect();
assert_eq!(names, ["StatHud"]);
}
#[test]
fn no_stat_hud_no_system() {
use crate::ecs::World;
let mut world = World::new();
world.start(SYSTEMS).unwrap();
assert!(world.systems().is_empty());
}
fn hud_world() -> crate::ecs::World {
let mut world = crate::ecs::World::new();
world.add_component(StatHud {
fps_label: Some(AssetId(1)),
vram_label: Some(AssetId(2)),
ram_label: Some(AssetId(3)),
..StatHud::default()
});
for id in [1u32, 2, 3] {
world.add_component(TextLabel {
asset_id: AssetId(id),
..Default::default()
});
}
world
}
fn force_emit_due(world: &mut crate::ecs::World) {
use std::time::Duration;
for system in world.systems_mut() {
if let Some(s) = system.downcast_mut::<StatHudSystem>() {
s.last_emit = Instant::now() - Duration::from_secs(1);
}
}
}
fn chip(world: &crate::ecs::World, id: u32) -> String {
world
.query::<TextLabel>()
.find(|l| l.asset_id == AssetId(id))
.map(|l| l.content.clone())
.unwrap_or_default()
}
#[test]
fn emit_window_writes_fps_and_vram_chips() {
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
force_emit_due(&mut world);
world.step();
assert!(chip(&world, 1).starts_with("FPS "), "{}", chip(&world, 1));
assert_eq!(chip(&world, 2), "VRAM 0 MB");
}
#[test]
fn emit_window_writes_ram_chip_with_budget() {
use crate::app::budget::MemoryBudget;
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
let budget = MemoryBudget::compute(Some(16 * 1024 * 1024 * 1024), 0);
world.insert_resource(budget);
force_emit_due(&mut world);
world.step();
if cfg!(any(
target_os = "macos",
target_os = "linux",
target_os = "windows"
)) {
let ram = chip(&world, 3);
assert!(ram.starts_with("RAM "), "{ram}");
assert!(
ram.ends_with(&format!(" / {} MB", budget.budget_mib())),
"{ram}"
);
}
}
#[test]
fn hud_prefs_hide_fps_and_vram_chips() {
use crate::ecs::HudPrefs;
let mut world = hud_world();
world.start(SYSTEMS).unwrap();
world.insert_resource(HudPrefs {
show_fps: false,
show_vram: false,
});
force_emit_due(&mut world);
world.step();
assert_eq!(chip(&world, 1), "", "fps chip hidden");
assert_eq!(chip(&world, 2), "", "vram chip hidden");
}
}