the perf pill leaves egui, and the field store goes flat
- the perf pill is ~25 quads on the player-UI pass — `{fps} fps {cpu} ms` and nothing else — so the egui lane sleeps whenever no dev panel is open, instead of the collapsed pill paying ~1 ms for egui's whole per-frame train
- the pill is the whole HUD: the expanded readout, the spike arrow and the spike-detection engine behind it are removed; a >250 ms hitch still logs
- WOW_CPU_CENSUS=<at>:<secs> — a per-thread CPU census that sums to the process total exactly, user/sys split; in Lower Blackrock Spire the main thread is ~41% of ~14.5 ms, the render thread ~17%, the compute pool ~25%, Metal's own workers ~12%
- the object field store goes flat: the wire's presence mask plus a dense value array replace a BTreeMap, so 5–10k descriptor probes a frame are two array indexings — the main thread ~0.5 ms/frame lighter at the same pin
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -282,7 +282,8 @@ fn spawn_egui_camera(mut commands: Commands) {
|
||||
}
|
||||
|
||||
/// Demand-gate the whole egui lane (decision 1445): when no dev overlay is open — panel closed,
|
||||
/// perf HUD hidden (chord+`P`), inspect off — the primary context goes `run_manually` (which
|
||||
/// inspect off (the perf HUD's pill is quads on the player-UI pass and never needs this lane;
|
||||
/// 1453/1454) — the primary context goes `run_manually` (which
|
||||
/// `bevy_egui`'s context-pass loop honors by skipping it outright: no begin/end pass, no
|
||||
/// tessellate, no `EguiPrimaryContextPass` run) and the overlay camera sleeps (no Core2d graph
|
||||
/// run, no composite, no render-side prep). A hidden dev surface then costs what a player build
|
||||
@@ -293,12 +294,11 @@ fn spawn_egui_camera(mut commands: Commands) {
|
||||
/// the gated pass and would hold the last hover forever.
|
||||
fn gate_egui_lane(
|
||||
debug: Res<DebugState>,
|
||||
hud: Res<crate::perf::PerfHud>,
|
||||
inspect: Res<crate::ui_script::InspectMode>,
|
||||
mut cams: Query<(&mut Camera, &mut EguiContextSettings), With<PrimaryEguiContext>>,
|
||||
mut over: ResMut<EguiPointerOver>,
|
||||
) {
|
||||
let open = debug.open || hud.visible || inspect.enabled;
|
||||
let open = debug.open || inspect.enabled;
|
||||
for (mut cam, mut settings) in &mut cams {
|
||||
if cam.is_active != open {
|
||||
cam.is_active = open;
|
||||
|
||||
@@ -3,6 +3,264 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// `WOW_CPU_CENSUS=<at>:<secs>` — the per-thread CPU census (macOS): where does the pill's
|
||||
/// cpu-ms actually go, thread by thread, summing **exactly** to the process total?
|
||||
///
|
||||
/// Two snapshots bracket a window: at each, `getrusage(RUSAGE_SELF)` (the pill's own clock,
|
||||
/// [`super::clock::process_cpu_secs`]) and then every live thread's cumulative CPU via
|
||||
/// `proc_pidinfo(PROC_PIDLISTTHREADS / PROC_PIDTHREADINFO)`. The report prints each thread's
|
||||
/// delta over the window in ms/frame, then the **residual** = process delta − Σ live-thread
|
||||
/// deltas, which is exactly the CPU of threads that exited inside the window (plus the
|
||||
/// inter-read skew of the sweep itself). Nothing is modeled: every row is a measured delta and
|
||||
/// the rows plus the residual reproduce the process total by construction.
|
||||
///
|
||||
/// Facts this rests on, pinned empirically on this machine (2026-08-20, threadcpu.c probe)
|
||||
/// before this was written:
|
||||
/// - `pth_user_time`/`pth_system_time` are **nanoseconds** (a 200 ms calibrated burn read
|
||||
/// 196.6 ms; the mach-timebase interpretation read 31.8× over) — *not* mach ticks, unlike
|
||||
/// `rusage_info`'s `ri_user_time`.
|
||||
/// - live-thread sums + exited-thread time = `getrusage` total to 0.05 ms over a ~1 s burn
|
||||
/// (a thread that burned 250 ms and exited was absent from the list and present in the
|
||||
/// residual at exactly 250.0 ms).
|
||||
/// - `PROC_PIDLISTTHREADS = 6` (verified in the SDK's `sys/proc_info.h`; libc lacks the const).
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(super) mod cpu_census {
|
||||
use std::collections::HashMap;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::time::Real;
|
||||
|
||||
use crate::perf::clock::process_cpu_secs;
|
||||
|
||||
/// `sys/proc_info.h`; not in the libc crate (its sibling `PROC_PIDTHREADINFO = 5` is).
|
||||
const PROC_PIDLISTTHREADS: libc::c_int = 6;
|
||||
|
||||
/// One cumulative reading per live thread: `tid → (name, user_ns, system_ns)`. User and
|
||||
/// system are kept apart on purpose: real work is user time, while a thread whose CPU is
|
||||
/// mostly *system* time is burning it in the kernel — park/wake churn, semaphores, mach
|
||||
/// calls — which points at the scheduler/driver seam rather than at any system's body.
|
||||
fn thread_snapshot() -> HashMap<u64, (String, u64, u64)> {
|
||||
let pid = std::process::id() as libc::c_int;
|
||||
let mut tids = [0u64; 512];
|
||||
// SAFETY: proc_pidinfo writes at most `buffersize` bytes into the buffer and returns
|
||||
// how many it wrote; the buffer is a plain u64 array.
|
||||
let n = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
pid,
|
||||
PROC_PIDLISTTHREADS,
|
||||
0,
|
||||
tids.as_mut_ptr().cast(),
|
||||
std::mem::size_of_val(&tids) as libc::c_int,
|
||||
)
|
||||
};
|
||||
if n <= 0 {
|
||||
return HashMap::new();
|
||||
}
|
||||
let count = n as usize / std::mem::size_of::<u64>();
|
||||
let mut out = HashMap::with_capacity(count);
|
||||
for &tid in &tids[..count] {
|
||||
// SAFETY: zeroed is a valid proc_threadinfo (plain integers + a char array);
|
||||
// proc_pidinfo fills it and returns the size written.
|
||||
let mut ti: libc::proc_threadinfo = unsafe { std::mem::zeroed() };
|
||||
let r = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
pid,
|
||||
libc::PROC_PIDTHREADINFO,
|
||||
tid,
|
||||
(&raw mut ti).cast(),
|
||||
std::mem::size_of::<libc::proc_threadinfo>() as libc::c_int,
|
||||
)
|
||||
};
|
||||
if r as usize != std::mem::size_of::<libc::proc_threadinfo>() {
|
||||
continue; // the thread died mid-sweep: its time lands in the residual
|
||||
}
|
||||
let name_bytes: Vec<u8> = ti
|
||||
.pth_name
|
||||
.iter()
|
||||
.take_while(|&&c| c != 0)
|
||||
.map(|&c| c as u8)
|
||||
.collect();
|
||||
let name = String::from_utf8_lossy(&name_bytes).into_owned();
|
||||
out.insert(tid, (name, ti.pth_user_time, ti.pth_system_time));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
struct Snap {
|
||||
t: f32,
|
||||
frames: u64,
|
||||
process_secs: f64,
|
||||
threads: HashMap<u64, (String, u64, u64)>,
|
||||
}
|
||||
|
||||
/// Armed from the env at plugin build (on the main thread — which is how `main_tid` is
|
||||
/// known without pinning the census system itself).
|
||||
#[derive(Resource)]
|
||||
pub(in crate::perf) struct CpuCensus {
|
||||
at: f32,
|
||||
window: f32,
|
||||
main_tid: u64,
|
||||
frames: u64,
|
||||
start: Option<Snap>,
|
||||
/// Every thread seen by the once-a-second sweeps inside the window, with its **latest**
|
||||
/// cumulative reading — so a thread that dies mid-window can still be *named* in the
|
||||
/// residual report (its last reading is a lower bound on what it contributed).
|
||||
seen: HashMap<u64, (String, u64, u64)>,
|
||||
next_sweep: f32,
|
||||
done: bool,
|
||||
}
|
||||
|
||||
impl CpuCensus {
|
||||
/// Parse `WOW_CPU_CENSUS=<at>:<secs>`. Must be called on the main thread.
|
||||
pub(in crate::perf) fn from_env() -> Option<Self> {
|
||||
let v = std::env::var("WOW_CPU_CENSUS").ok()?;
|
||||
let (at, window) = v.split_once(':')?;
|
||||
let mut main_tid = 0u64;
|
||||
// SAFETY: null pthread = the calling thread; writes one u64.
|
||||
unsafe { libc::pthread_threadid_np(0, &mut main_tid) };
|
||||
Some(Self {
|
||||
at: at.parse().ok()?,
|
||||
window: window.parse().ok()?,
|
||||
main_tid,
|
||||
frames: 0,
|
||||
start: None,
|
||||
seen: HashMap::new(),
|
||||
next_sweep: 0.0,
|
||||
done: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::perf) fn cpu_census(mut census: ResMut<CpuCensus>, time: Res<Time<Real>>) {
|
||||
if census.done {
|
||||
return;
|
||||
}
|
||||
census.frames += 1;
|
||||
let now = time.elapsed_secs();
|
||||
if census.start.is_none() {
|
||||
if now >= census.at {
|
||||
census.start = Some(Snap {
|
||||
t: now,
|
||||
frames: census.frames,
|
||||
process_secs: process_cpu_secs().unwrap_or(0.0),
|
||||
threads: thread_snapshot(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
if now >= census.next_sweep {
|
||||
census.next_sweep = now + 1.0;
|
||||
for (tid, entry) in thread_snapshot() {
|
||||
census.seen.insert(tid, entry);
|
||||
}
|
||||
}
|
||||
let start = census.start.as_ref().unwrap();
|
||||
if now - start.t < census.window {
|
||||
return;
|
||||
}
|
||||
let end_process = process_cpu_secs().unwrap_or(0.0);
|
||||
let end_threads = thread_snapshot();
|
||||
let frames = (census.frames - start.frames).max(1);
|
||||
let dt = now - start.t;
|
||||
let process_ms = (end_process - start.process_secs) * 1000.0;
|
||||
let per_frame = |ms: f64| ms / frames as f64;
|
||||
|
||||
// Per-thread deltas over the window. A thread born inside the window has no start
|
||||
// reading — its full cumulative time is its delta (it was zero at birth).
|
||||
let mut rows: Vec<(f64, f64, String)> = Vec::new(); // (user_ms, sys_ms, label)
|
||||
let mut live_sum_ms = 0.0f64;
|
||||
for (tid, (name, end_u, end_s)) in &end_threads {
|
||||
let (start_u, start_s) = start.threads.get(tid).map_or((0, 0), |(_, u, s)| (*u, *s));
|
||||
let user_ms = end_u.saturating_sub(start_u) as f64 / 1e6;
|
||||
let sys_ms = end_s.saturating_sub(start_s) as f64 / 1e6;
|
||||
live_sum_ms += user_ms + sys_ms;
|
||||
// Hex tids so a row can be matched against `/usr/bin/sample`'s "Thread 0x…" headers.
|
||||
let label = if *tid == census.main_tid {
|
||||
format!("main (tid 0x{tid:x})")
|
||||
} else if name.is_empty() {
|
||||
format!("(unnamed tid 0x{tid:x})")
|
||||
} else {
|
||||
format!("{name} (tid 0x{tid:x})")
|
||||
};
|
||||
rows.push((user_ms, sys_ms, label));
|
||||
}
|
||||
rows.sort_by(|a, b| (b.0 + b.1).total_cmp(&(a.0 + a.1)));
|
||||
let residual_ms = process_ms - live_sum_ms;
|
||||
|
||||
eprintln!(
|
||||
"[cpu-census] window {dt:.2} s, {frames} frames ({:.1} fps), process {process_ms:.1} ms \
|
||||
= {:.4} ms/frame (the pill's number over this window)",
|
||||
frames as f32 / dt,
|
||||
per_frame(process_ms),
|
||||
);
|
||||
eprintln!(
|
||||
"[cpu-census] {:>9} {:>8} {:>8} {:>6} thread",
|
||||
"ms/frame", "user", "sys", "share"
|
||||
);
|
||||
for (u, s, label) in &rows {
|
||||
let ms = u + s;
|
||||
if per_frame(ms) < 0.0005 {
|
||||
continue; // folded into the "under" line below — still in the printed sum
|
||||
}
|
||||
eprintln!(
|
||||
"[cpu-census] {:>9.4} {:>8.4} {:>8.4} {:>5.1}% {label}",
|
||||
per_frame(ms),
|
||||
per_frame(*u),
|
||||
per_frame(*s),
|
||||
ms / process_ms * 100.0
|
||||
);
|
||||
}
|
||||
let tiny: f64 = rows
|
||||
.iter()
|
||||
.filter(|(u, s, _)| per_frame(u + s) < 0.0005)
|
||||
.map(|(u, s, _)| u + s)
|
||||
.sum();
|
||||
let tiny_n = rows
|
||||
.iter()
|
||||
.filter(|(u, s, _)| per_frame(u + s) < 0.0005)
|
||||
.count();
|
||||
if tiny_n > 0 {
|
||||
eprintln!(
|
||||
"[cpu-census] {:>9.4} {:>5.1}% ({tiny_n} threads under 0.5 µs/frame each)",
|
||||
per_frame(tiny),
|
||||
tiny / process_ms * 100.0
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"[cpu-census] {:>9.4} {:>5.1}% (residual: threads exited in-window + sweep skew)",
|
||||
per_frame(residual_ms),
|
||||
residual_ms / process_ms * 100.0
|
||||
);
|
||||
// Name the churn: threads the per-second sweeps saw that are gone by the end snapshot.
|
||||
// Their last cumulative reading minus their start reading (0 if born in-window) is a
|
||||
// LOWER bound on what they contributed to the residual — they kept burning after the
|
||||
// sweep that last saw them.
|
||||
let mut churned: HashMap<String, (usize, f64)> = HashMap::new();
|
||||
for (tid, (name, last_u, last_s)) in &census.seen {
|
||||
if end_threads.contains_key(tid) {
|
||||
continue;
|
||||
}
|
||||
let (start_u, start_s) = start.threads.get(tid).map_or((0, 0), |(_, u, s)| (*u, *s));
|
||||
let e = churned.entry(name.clone()).or_insert((0, 0.0));
|
||||
e.0 += 1;
|
||||
e.1 += (last_u.saturating_sub(start_u) + last_s.saturating_sub(start_s)) as f64 / 1e6;
|
||||
}
|
||||
for (name, (n, ms)) in &churned {
|
||||
eprintln!(
|
||||
"[cpu-census] churn: {n} thread(s) '{}' died in-window, ≥{:.4} ms/frame of the residual",
|
||||
if name.is_empty() { "(unnamed)" } else { name },
|
||||
per_frame(*ms)
|
||||
);
|
||||
}
|
||||
eprintln!(
|
||||
"[cpu-census] sum check: Σ live {live_sum_ms:.1} + residual {residual_ms:.1} \
|
||||
= {:.1} vs process {process_ms:.1} ms (identity, by construction)",
|
||||
live_sum_ms + residual_ms
|
||||
);
|
||||
census.done = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// `WOW_MESH_EVENTS=1`: per-second Mesh asset-event counts (see the plugin registration). The
|
||||
/// `sample` list names a few mutated asset ids so the writer can be found by grepping who holds
|
||||
/// that handle.
|
||||
|
||||
+140
-584
@@ -1,29 +1,23 @@
|
||||
//! The standing dev HUD: a collapsed cost pill you can read while playing, and the full readout
|
||||
//! behind a click.
|
||||
//! The standing dev HUD: the cost pill, and nothing else.
|
||||
//!
|
||||
//! **The pill is two small numbers and a bare latch arrow** (1448 pared it back). fps dim — the
|
||||
//! familiar anchor, and by construction the number that cannot see cost (0717) — then process-CPU
|
||||
//! cost per frame, the meter vsync cannot rail; both under-size, because the pill sits over the
|
||||
//! game all session. The arrow is the spike latch: a burst is ~250 ms (0610) and the director is
|
||||
//! looking at the game when it happens, so the evidence has to outlive the event — and its
|
||||
//! appearance and colour are the *whole* signal (it once printed `▲261.9 main ×94`, a report
|
||||
//! where an alarm belongs; the numbers wait behind the click). What left the pill entirely: the
|
||||
//! minute-trend sparkline — a wiggle at 72 px; it is the expanded panel's full-width lane now,
|
||||
//! beside the line that names its step.
|
||||
//! **The pill is two small numbers** (1448 pared it back; 1454 made it the whole HUD; 1455
|
||||
//! dropped the spike arrow). fps dim — the familiar anchor, and by construction the number that
|
||||
//! cannot see cost (0717) — then process-CPU cost per frame, the meter vsync cannot rail; both
|
||||
//! under-size, because the pill sits over the game all session. Anything deeper is an
|
||||
//! instrument's job, not a panel's: the journal (`WOW_FPS_JOURNAL`), the probes, Tracy, the
|
||||
//! `frame hitch` log line and the stall self-sampler. The expanded egui readout that used to
|
||||
//! live behind a click is gone (1454) — it duplicated the instruments at a standing cost — and
|
||||
//! the spike-latch arrow went the same way (1455, the director's call).
|
||||
//!
|
||||
//! **Drawn from a 4 Hz snapshot, not the live meters.** A label whose string changes every frame
|
||||
//! re-shapes its galley every frame — cost that scales with every widget the surface carries,
|
||||
//! booked into the very number the pill reports. Between snapshots every string is byte-identical,
|
||||
//! so egui's galley cache absorbs the text; the meters keep sampling every frame — only the *view*
|
||||
//! is quantized, and 250 ms is inside a human read of a number. (1448 *bounds* the price: the
|
||||
//! drawing cost, before and after, sat below the ~0.4 ms noise floor of the interleaved legs —
|
||||
//! 1370's "0.4–1.2 ms est" reads at its floor, and the diet is cheap insurance plus legibility,
|
||||
//! not a measured megawin.)
|
||||
//! **Drawn from a 4 Hz snapshot, not the live meters.** The pill renders from [`PerfHud::snap`];
|
||||
//! the meters keep sampling every frame — only the *view* is quantized, and 250 ms is inside a
|
||||
//! human read of a number.
|
||||
//!
|
||||
//! **Detail lives behind the click, and nowhere else.** The pill briefly carried a hover tooltip
|
||||
//! holding the whole readout, on the theory that hovering costs no screen — but the pill is
|
||||
//! top-center, the mouse crosses it constantly, and a panel-sized popup over the game is worse than
|
||||
//! the panel it was avoiding. One surface, opened deliberately.
|
||||
//! **The pill does not touch egui at all** (1453). Drawing anything through egui wakes the whole
|
||||
//! lane — bevy_egui's per-frame pipeline plus a full-screen compositing camera — which the
|
||||
//! director's live toggle priced at ~1 ms for a 20-glyph pill. So [`pill_quads`] lays the pill
|
||||
//! onto the player-UI quad pass, whose append lane is rebuilt every frame anyway; since 1454 the
|
||||
//! HUD has no egui surface left and [`crate::debug_panel`]'s 1445 gate no longer consults it.
|
||||
//!
|
||||
//! **The HUD does not own settings.** It briefly carried a VSync checkbox and an MSAA readout;
|
||||
//! VSync is a player video option now ([`crate::video`], the Graphics page's `gxVSync` row) and
|
||||
@@ -31,90 +25,40 @@
|
||||
//! panel, and this one is `#[cfg(feature = "dev")]`, so anything a player must reach cannot live
|
||||
//! here at all. The measurement knob the checkbox actually existed for is `$WOW_NOVSYNC=1`.
|
||||
|
||||
use bevy::diagnostic::{DiagnosticsStore, EntityCountDiagnosticsPlugin};
|
||||
use benilla_ui::script::{JustifyH, JustifyV, Outline};
|
||||
use bevy::prelude::*;
|
||||
use bevy::time::Real;
|
||||
use bevy::window::{PresentMode, PrimaryWindow};
|
||||
use bevy_egui::{egui, EguiContexts};
|
||||
use bevy::window::{PrimaryWindow, Window};
|
||||
|
||||
use super::stats::{FrameStats, Series};
|
||||
use super::FRAME_BUDGET_MS;
|
||||
use crate::debug_panel::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT, OVERLAY_TEXT_DIM};
|
||||
use super::stats::FrameStats;
|
||||
use crate::ui_pass::{UiQuad, UiQuads};
|
||||
use crate::ui_text::{layout_text_quads, FontSpec, Justify, UiFontAtlas};
|
||||
|
||||
const RED: egui::Color32 = egui::Color32::from_rgb(240, 120, 120);
|
||||
const AMBER: egui::Color32 = egui::Color32::from_rgb(240, 190, 110);
|
||||
const GREEN: egui::Color32 = egui::Color32::from_rgb(140, 220, 140);
|
||||
|
||||
/// A latched spike is drawn amber until its peak is this many times its own baseline, then red.
|
||||
const SPIKE_RED_RATIO: f32 = 3.0;
|
||||
|
||||
/// The trend lane's height in the expanded panel (its width is the panel's).
|
||||
const TREND_HEIGHT: f32 = 13.0;
|
||||
|
||||
/// The pill's number size — under the 12 pt monospace default. The pill sits over the game all
|
||||
/// session; the director asked for the numbers at a glance-size, not a reading-size (1448).
|
||||
const PILL_NUM_SIZE: f32 = 10.0;
|
||||
// ---- The quad pill (1453): the standing readout, drawn on the player-UI pass. --------------
|
||||
// The old egui overlay palette, restated as client-space sRGB floats for [`UiQuad::color`]
|
||||
// (black alpha 224 fill; gray 235 text; gray 180 dim).
|
||||
const Q_FILL: [f32; 4] = [0.0, 0.0, 0.0, 224.0 / 255.0];
|
||||
const Q_TEXT: [f32; 4] = [0.92, 0.92, 0.92, 1.0];
|
||||
/// `|cAARRGGBB` markup for the dim fps run (gray 180).
|
||||
const Q_DIM_MARKUP: &str = "|cffb4b4b4";
|
||||
/// Paint order: the pill background one under the glyphs, both above every packed WoW z_key
|
||||
/// (frame tuples never fill the top bits).
|
||||
const Z_PILL_BG: u64 = u64::MAX - 1;
|
||||
const Z_PILL: u64 = u64::MAX;
|
||||
/// The quad pill's font height (logical px) and box padding.
|
||||
const PILL_QUAD_PX: f32 = 12.0;
|
||||
const PILL_PAD: Vec2 = Vec2::new(9.0, 4.0);
|
||||
/// Top-center offset.
|
||||
const PILL_TOP: f32 = 8.0;
|
||||
|
||||
/// How often the drawn snapshot advances. Fast enough that the numbers read as live and a latched
|
||||
/// badge appears within a perceptual beat; slow enough that between refreshes every galley is
|
||||
/// byte-identical and egui's shape cache absorbs the whole text layout (see the module doc).
|
||||
/// badge appears within a perceptual beat; slow enough that between refreshes the pill's cached
|
||||
/// quads are reused byte-identical (see [`pill_quads`]).
|
||||
const HUD_REFRESH_SECS: f32 = 0.25;
|
||||
|
||||
/// **The ruler.** The expanded panel is as wide as this string renders in the monospace face —
|
||||
/// or as wide as the pill above it, whichever is more — and every line inside either fits that or
|
||||
/// wraps into it. No line is allowed to decide the width by being long.
|
||||
///
|
||||
/// It is a *measured* string rather than a pixel constant so the panel stays correct at any font
|
||||
/// size or UI scale — and it is written at full precision (`000.0`, not `20.2`) so the widest
|
||||
/// value a field can ever hold still fits. Three tests hold the invariant from both sides: the
|
||||
/// panel never exceeds its bound, always reaches it, and the ruler (not the pill) is what governs
|
||||
/// whenever no spike badge is up.
|
||||
///
|
||||
/// **Why a governed width at all, twice over.** First, the numbers change width as they change
|
||||
/// value (`8.55` → `11.73`), so an auto-sized panel jitters under your eye while you read it.
|
||||
/// Second — and this is what actually shipped broken — auto-sizing here does not merely wobble, it
|
||||
/// blows up:
|
||||
///
|
||||
/// - [`crate::debug_panel::overlay_text`] sets `TextWrapMode::Extend`, so **every label allocates
|
||||
/// its full natural width and ignores `max_rect` entirely.** One long line therefore *defines*
|
||||
/// the window width, and `set_width` alone cannot stop it. (The line that shipped broken was
|
||||
/// `SpikeKind::describe`, ~110 characters; 1448 moved it to hover, but the `Wrap` below stays —
|
||||
/// it is the fence that stops the *next* long label, not a patch for that one.)
|
||||
/// - `Area` runs its sizing pass only on the frame the area is *created*
|
||||
/// (`sizing_pass = state.is_none()`, egui 0.33 `containers/area.rs`). This HUD is born collapsed,
|
||||
/// so that pass measures the pill; every later expand has no sizing pass, and `Separator` falls
|
||||
/// through to `available_size_before_wrap()` — the room it *could* take.
|
||||
///
|
||||
/// Together those are the ragged panel the director reported: prose setting the width, separators
|
||||
/// and the graph stretching to whatever that came out as, and the short numeric lines not. The fix
|
||||
/// is both halves — a ruler, and `TextWrapMode::Wrap` inside the panel so prose wraps instead of
|
||||
/// measuring.
|
||||
const PANEL_RULER: &str = "trend 000.0 → 000.0 ms (+000.0/min)";
|
||||
|
||||
/// The panel's content width: [`PANEL_RULER`], measured in the live monospace face.
|
||||
///
|
||||
/// Measured as *glyph advance × character count* rather than by laying the string out: that is
|
||||
/// what a monospace column actually is, and it costs one cached glyph lookup instead of a full
|
||||
/// text layout on a path that runs every frame the panel is open.
|
||||
fn panel_width(ui: &egui::Ui) -> f32 {
|
||||
let font = egui::TextStyle::Monospace.resolve(ui.style());
|
||||
let advance = ui.ctx().fonts_mut(|f| f.glyph_width(&font, '0'));
|
||||
advance * PANEL_RULER.chars().count() as f32
|
||||
}
|
||||
|
||||
/// Does this present mode sync to the display? Synced, the HUD's wall-clock numbers measure the
|
||||
/// present grant, not our cost — the stats lines switch meaning on it.
|
||||
pub(super) fn synced_mode(mode: PresentMode) -> bool {
|
||||
!matches!(
|
||||
mode,
|
||||
PresentMode::AutoNoVsync | PresentMode::Immediate | PresentMode::Mailbox
|
||||
)
|
||||
}
|
||||
|
||||
/// HUD state. The **dev chord + `P`** toggles `visible` (default on — it's a standing dev surface);
|
||||
/// `expanded` is the click-to-open full readout (default off — the cost pill until clicked).
|
||||
/// `visible` is `pub(crate)` so the capture harness ([`crate::capture`]) can force the overlay off
|
||||
/// for pristine, UI-free screenshots.
|
||||
/// HUD state. The **dev chord + `P`** toggles `visible` (default on — it's a standing dev
|
||||
/// surface). `visible` is `pub(crate)` so the capture harness ([`crate::capture`]) can force the
|
||||
/// overlay off for pristine, UI-free screenshots.
|
||||
///
|
||||
/// **`WOW_PERF_HUD=0` starts it hidden**, which is how the HUD gets priced. 1370 records the open
|
||||
/// gap: every campaign anchor is measured on a binary that is drawing this overlay, at a cost
|
||||
@@ -126,15 +70,12 @@ pub(super) fn synced_mode(mode: PresentMode) -> bool {
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct PerfHud {
|
||||
pub(crate) visible: bool,
|
||||
/// Full stats shown? Toggled by clicking the pill; the cost pill alone when `false`.
|
||||
expanded: bool,
|
||||
/// The snapshot the HUD draws from — a copy of the meters taken every [`HUD_REFRESH_SECS`],
|
||||
/// The snapshot the pill draws from — a copy of the meters taken every [`HUD_REFRESH_SECS`],
|
||||
/// so the strings it formats hold still between refreshes (the module doc's cost argument).
|
||||
/// The live [`FrameStats`] keeps sampling every frame; this is only the view.
|
||||
snap: FrameStats,
|
||||
/// The clock `snap` was taken at. Spike ages and hold windows are computed against this, not
|
||||
/// the live clock, so they hold still with the rest of the view — and it doubles as the
|
||||
/// refresh timer.
|
||||
/// The clock `snap` was taken at. Spike ages are computed against this, not the live clock,
|
||||
/// so they hold still with the rest of the view — and it doubles as the refresh timer.
|
||||
snap_at: f32,
|
||||
}
|
||||
|
||||
@@ -142,7 +83,6 @@ impl Default for PerfHud {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
visible: std::env::var("WOW_PERF_HUD").as_deref() != Ok("0"),
|
||||
expanded: false,
|
||||
snap: FrameStats::default(),
|
||||
// −∞, so the very first frame refreshes rather than drawing an empty snapshot.
|
||||
snap_at: f32::NEG_INFINITY,
|
||||
@@ -169,503 +109,119 @@ pub(super) fn toggle_hud(keys: Res<ButtonInput<KeyCode>>, mut hud: ResMut<PerfHu
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn perf_hud_ui(
|
||||
mut contexts: EguiContexts,
|
||||
/// Advance the HUD's 4 Hz snapshot — its own system (1453), kept separate so the sampling cadence
|
||||
/// never depends on whether anything drew this frame.
|
||||
pub(super) fn refresh_hud_snapshot(
|
||||
mut hud: ResMut<PerfHud>,
|
||||
stats: Res<FrameStats>,
|
||||
time: Res<Time<Real>>,
|
||||
diagnostics: Res<DiagnosticsStore>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
) -> Result {
|
||||
if !hud.visible {
|
||||
return Ok(());
|
||||
}
|
||||
let ctx = contexts.ctx_mut()?;
|
||||
let synced = windows
|
||||
.single()
|
||||
.map(|w| synced_mode(w.present_mode))
|
||||
.unwrap_or(true);
|
||||
hud.maybe_refresh(&stats, time.elapsed_secs());
|
||||
|
||||
// A title-less, anchored window — minimal chrome (no title bar, no resize) but the stable
|
||||
// auto-sizing of a `Window`, so expanding to the full readout doesn't flash a mislaid first
|
||||
// frame the way a raw `Area` does.
|
||||
let hud = &mut *hud;
|
||||
let mut clicked = false;
|
||||
egui::Window::new("perf_hud")
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.movable(false)
|
||||
.anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 8.0))
|
||||
.frame(
|
||||
egui::Frame::NONE
|
||||
.inner_margin(egui::Margin::symmetric(8, 5))
|
||||
.corner_radius(5.0)
|
||||
.fill(OVERLAY_FILL),
|
||||
)
|
||||
.show(ctx, |ui| {
|
||||
clicked = draw_hud(
|
||||
ui,
|
||||
&hud.snap,
|
||||
&diagnostics,
|
||||
synced,
|
||||
hud.snap_at,
|
||||
hud.expanded,
|
||||
);
|
||||
});
|
||||
if clicked {
|
||||
hud.expanded = !hud.expanded;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lay the whole HUD out into `ui`; returns whether the toggle strip was clicked.
|
||||
///
|
||||
/// Split out of [`perf_hud_ui`] so the layout can be driven headless by a real `egui::Context` in
|
||||
/// a test — the width invariant this panel kept breaking is a *layout* property, and the only
|
||||
/// honest way to hold it is to lay it out and measure (see `panel_layout_is_governed_by_the_ruler`).
|
||||
pub(super) fn draw_hud(
|
||||
ui: &mut egui::Ui,
|
||||
stats: &FrameStats,
|
||||
diagnostics: &DiagnosticsStore,
|
||||
synced: bool,
|
||||
now: f32,
|
||||
expanded: bool,
|
||||
) -> bool {
|
||||
overlay_text(ui);
|
||||
let pill = collapsed_pill(ui, stats, now);
|
||||
if expanded {
|
||||
// The wider of the ruler and the pill above it. The pill is drawn first and `set_width`
|
||||
// can only grow an already-allocated `min_rect`, never shrink it — so a pill carrying a
|
||||
// spike badge is genuinely wider than the ruler, and pretending otherwise would just move
|
||||
// the raggedness rather than fix it. A panel narrower than its own header is not a panel.
|
||||
ui.set_width(panel_width(ui).max(pill.width()));
|
||||
// The other half, and the one `set_width` alone could not do: `overlay_text` leaves labels
|
||||
// on `TextWrapMode::Extend`, where they allocate their natural width and ignore `max_rect`
|
||||
// entirely — so prose *measures* the window instead of fitting it (see [`PANEL_RULER`]).
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Wrap);
|
||||
ui.separator();
|
||||
expanded_readout(ui, stats, diagnostics, synced, now);
|
||||
}
|
||||
// The click target is the whole top strip, not the row of glyphs: `min_rect` is now the
|
||||
// laid-out content, so when the readout below is wider than the numbers above, the space
|
||||
// beside them is still the toggle rather than dead pixels. Registered last, so it sits above
|
||||
// the labels it spans.
|
||||
let mut strip = pill;
|
||||
strip.max.x = strip.max.x.max(ui.min_rect().max.x);
|
||||
ui.interact(strip, ui.id().with("pill"), egui::Sense::click())
|
||||
.clicked()
|
||||
}
|
||||
|
||||
/// The collapsed pill: fps (dim), the cost headline — both small — and the bare latch arrow.
|
||||
/// Returns the row's rect; the caller makes the strip that spans it the toggle.
|
||||
///
|
||||
/// The arrow carries no numbers: the badge once printed `▲261.9 main ×94` over the game, which
|
||||
/// is a report, not an alarm (director, 1448). Its appearance and its colour are the whole
|
||||
/// signal; the numbers wait behind the click.
|
||||
///
|
||||
/// **No hover tooltip.** It used to carry the detail so the expanded panel would be unnecessary
|
||||
/// while playing — but the pill sits top-center, the mouse passes through constantly, and the
|
||||
/// result was a panel-sized popup covering the game unbidden. Detail lives behind the click now,
|
||||
/// where it is asked for.
|
||||
fn collapsed_pill(ui: &mut egui::Ui, stats: &FrameStats, now: f32) -> egui::Rect {
|
||||
let spike = stats.spike(now);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 8.0;
|
||||
|
||||
// fps, dimmed: the familiar anchor, and by construction the number that cannot see
|
||||
// any of this (0717).
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{:.0} fps", stats.fps()))
|
||||
.color(OVERLAY_TEXT_DIM)
|
||||
.monospace()
|
||||
.size(PILL_NUM_SIZE),
|
||||
);
|
||||
|
||||
// The headline: process CPU per frame, the meter vsync cannot rail (0717).
|
||||
match stats.cpu.mean() {
|
||||
Some(cpu) => ui.label(
|
||||
egui::RichText::new(format!("{cpu:.1} ms"))
|
||||
.color(OVERLAY_TEXT)
|
||||
.monospace()
|
||||
.strong()
|
||||
.size(PILL_NUM_SIZE),
|
||||
),
|
||||
None => ui.label(
|
||||
egui::RichText::new("-- ms")
|
||||
.color(OVERLAY_TEXT_DIM)
|
||||
.monospace()
|
||||
.size(PILL_NUM_SIZE),
|
||||
),
|
||||
};
|
||||
|
||||
// The latch. Present only when something actually happened, so its mere appearance is
|
||||
// the signal — no scanning a number for a change.
|
||||
if let Some(s) = spike {
|
||||
let col = if s.peak_ms >= s.baseline_ms * SPIKE_RED_RATIO {
|
||||
RED
|
||||
} else {
|
||||
AMBER
|
||||
};
|
||||
ui.label(egui::RichText::new("▲").color(col).strong());
|
||||
}
|
||||
})
|
||||
.response
|
||||
.rect
|
||||
}
|
||||
|
||||
/// The minute-long trend of per-second median CPU cost, at the expanded panel's full width (1448
|
||||
/// moved it off the pill, where 72 px showed a wiggle, not a shape). Flat means nothing changed; a
|
||||
/// step means the scene got more (or less) expensive and stayed there — the only lane that can see
|
||||
/// that, since a sustained cost is its own baseline in every other one.
|
||||
fn trend_sparkline(ui: &mut egui::Ui, trend: &Series, hi: f32) {
|
||||
let (rect, _) = ui.allocate_exact_size(
|
||||
egui::vec2(ui.available_width(), TREND_HEIGHT),
|
||||
egui::Sense::hover(),
|
||||
);
|
||||
if trend.len() < 2 {
|
||||
return;
|
||||
}
|
||||
let painter = ui.painter_at(rect);
|
||||
// Scaled to the window's p90, **not its max** — and the current value always fits, so nothing
|
||||
// that matters right now is ever off the top. One launch sample (a loading frame is ~110 ms
|
||||
// against a settled 8.5) is enough to press an entire minute of real signal into the bottom 7%
|
||||
// of the box, which is what the lane looked like on the director's screen: a flat line. The
|
||||
// clamp in `to_y` lets a true outlier clip instead of flattening its neighbours.
|
||||
let hi = (hi.max(trend.last())).max(0.001) * 1.15;
|
||||
let to_y = |ms: f32| rect.bottom() - (ms / hi).clamp(0.0, 1.0) * rect.height();
|
||||
|
||||
// Where the window started, so a step reads as a departure from something rather than as an
|
||||
// anonymous wiggle.
|
||||
let start = trend.iter().next().unwrap_or(0.0);
|
||||
painter.hline(
|
||||
rect.x_range(),
|
||||
to_y(start),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_gray(90)),
|
||||
);
|
||||
|
||||
let dx = rect.width() / (trend.cap().max(2) - 1) as f32;
|
||||
let points: Vec<egui::Pos2> = trend
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, ms)| egui::pos2(rect.left() + i as f32 * dx, to_y(ms)))
|
||||
.collect();
|
||||
let col = if trend.last() > start * 1.15 {
|
||||
AMBER
|
||||
} else {
|
||||
GREEN
|
||||
};
|
||||
painter.add(egui::Shape::line(points, egui::Stroke::new(1.0_f32, col)));
|
||||
}
|
||||
|
||||
/// The full readout, in two answers: **is it running well** (the interval we're being granted and
|
||||
/// how often we missed it), then **where the time goes** (the percentile table, the trend, the
|
||||
/// graph, and the latched spike in full).
|
||||
///
|
||||
/// It used to be nine lines answering those two questions three times over — a `last` sample the
|
||||
/// graph below already drew, the p50s of `cpu` and `main` restated underneath as means, and a pair
|
||||
/// of controls — with a *second* copy of most of it in a hover tooltip that opened whenever the
|
||||
/// mouse crossed the pill. Everything appears once, here, behind the click.
|
||||
fn expanded_readout(
|
||||
ui: &mut egui::Ui,
|
||||
stats: &FrameStats,
|
||||
diagnostics: &DiagnosticsStore,
|
||||
synced: bool,
|
||||
now: f32,
|
||||
) {
|
||||
let rail = stats.rail_ms();
|
||||
// Synced, a small over is rail jitter, not cost — red only past the missed-interval threshold
|
||||
// there. Uncapped, wall time is our cost and the 60 fps floor is the honest bar.
|
||||
let red_above = if synced {
|
||||
stats.dropped_above_ms()
|
||||
} else {
|
||||
FRAME_BUDGET_MS
|
||||
};
|
||||
|
||||
// Both header lines are monospace and short enough to sit inside [`PANEL_RULER`]; the prose
|
||||
// they used to carry ("observed", "60 fps floor") moved to the hover, which is free.
|
||||
if synced {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"interval {rail:>6.2} ms ({:.0} Hz)",
|
||||
if rail > 0.0 { 1000.0 / rail } else { 0.0 }
|
||||
))
|
||||
.color(OVERLAY_TEXT_DIM)
|
||||
.monospace(),
|
||||
)
|
||||
.on_hover_text(
|
||||
"the present interval as OBSERVED, not assumed — an adaptive panel has no fixed \
|
||||
rail (0294), so the only honest answer is what the display has been granting",
|
||||
);
|
||||
} else {
|
||||
ui.label(
|
||||
egui::RichText::new(format!("uncapped {FRAME_BUDGET_MS:>6.2} ms budget"))
|
||||
.color(OVERLAY_TEXT_DIM)
|
||||
.monospace(),
|
||||
)
|
||||
.on_hover_text("uncapped: wall time is our real frame cost, against the 60 fps floor");
|
||||
}
|
||||
|
||||
let (over_n, over_frac) = stats.wall.frames_over(red_above);
|
||||
let ob_col = if over_frac > 0.0 {
|
||||
RED
|
||||
} else {
|
||||
OVERLAY_TEXT_DIM
|
||||
};
|
||||
// Synced: "missed" = missed present intervals — the felt metric; wall time can't say more
|
||||
// while it rails at the grant. Uncapped: the honest over-budget count against the 16.7 floor.
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"{:<8} {over_n}/{} ({:.0}%)",
|
||||
if synced { "missed" } else { "over" },
|
||||
stats.wall.len(),
|
||||
over_frac * 100.0
|
||||
))
|
||||
.color(ob_col)
|
||||
.monospace(),
|
||||
)
|
||||
.on_hover_text(if synced {
|
||||
"frames past 1.5x the OBSERVED present interval — a missed display interval. \
|
||||
The interval is measured, not assumed at 60 Hz, so this counts a 120 -> 60 drop \
|
||||
(which a fixed 25 ms threshold cannot see)"
|
||||
} else {
|
||||
"frames past the 16.7 ms budget (uncapped: wall time ~= real frame cost)"
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
// One header instead of the labels repeated on every row — the columns are the same three
|
||||
// every time, so naming them once is both shorter and easier to read down.
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{:<5}{:>8}{:>8}{:>8}", "ms", "p50", "p99", "max"))
|
||||
.color(OVERLAY_TEXT_DIM)
|
||||
.monospace(),
|
||||
);
|
||||
for (name, series, why) in [
|
||||
(
|
||||
"cpu",
|
||||
&stats.cpu,
|
||||
"process CPU per frame, summed across every thread — our work, not the display's \
|
||||
present grant; comparable with the probes' cpu_ms and a reporter's CPU %",
|
||||
),
|
||||
(
|
||||
"main",
|
||||
&stats.main,
|
||||
"CPU consumed by the main thread alone (CLOCK_THREAD_CPUTIME_ID) — the serialized \
|
||||
half of the line above. A worker-pool burst inflates `cpu` without touching this \
|
||||
one, and only this one is what a stutter is made of",
|
||||
),
|
||||
(
|
||||
"wall",
|
||||
&stats.wall,
|
||||
"the frame interval itself. While synced this measures the display's grant, not our \
|
||||
cost (0717) — read it for missed intervals, not for how expensive we are",
|
||||
),
|
||||
] {
|
||||
let (p50, p99, max) = series.percentiles();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{name:<5}{p50:>8.2}{p99:>8.2}{max:>8.2}")).monospace(),
|
||||
)
|
||||
.on_hover_text(why);
|
||||
}
|
||||
|
||||
// The creep lane: the sparkline shows the shape, the line under it names the step.
|
||||
if let Some((first, last)) = stats.trend_ends() {
|
||||
ui.add_space(2.0);
|
||||
trend_sparkline(ui, &stats.trend, stats.trend_hi());
|
||||
let delta = last - first;
|
||||
ui.label(
|
||||
egui::RichText::new(format!("trend {first:.1} → {last:.1} ms ({delta:+.1}/min)"))
|
||||
// `.monospace()` is load-bearing, not styling: the proportional face has no glyph for
|
||||
// U+2192 and drew a tofu box on the director's screen. The mono face has it.
|
||||
.monospace()
|
||||
.color(if delta > first * 0.15 {
|
||||
AMBER
|
||||
} else {
|
||||
OVERLAY_TEXT_DIM
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
frame_graph(ui, stats, red_above);
|
||||
|
||||
let entities = diagnostics
|
||||
.get(&EntityCountDiagnosticsPlugin::ENTITY_COUNT)
|
||||
.and_then(|d| d.value())
|
||||
.unwrap_or(0.0);
|
||||
ui.label(
|
||||
egui::RichText::new(format!("{:<8} {entities:.0}", "entities"))
|
||||
.color(OVERLAY_TEXT_DIM)
|
||||
.monospace(),
|
||||
);
|
||||
|
||||
// The latch in full — only when one is latched; a quiet panel says nothing about it (1448
|
||||
// dropped the "no spike" placeholder and the inline prose). The pill's arrow carries no
|
||||
// numbers, so this line is where the spike is actually read: kind, peak against baseline,
|
||||
// age. The frame count, the *meaning* of the kind, and the burst count wait on hover — the
|
||||
// panel stays numbers; the ~110-character `describe()` used to be the widest and least-read
|
||||
// thing on it.
|
||||
if let Some(s) = stats.spike(now) {
|
||||
let mut why = format!("{} frames — {}", s.frames, s.kind.describe());
|
||||
let bursts = stats.recent_bursts(now);
|
||||
if bursts > 1 {
|
||||
why = format!("{why} — {bursts} bursts in the last 10 s; the worst is shown");
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"spike {} {:.1} over {:.1}, {:.0}s ago",
|
||||
s.kind.tag(),
|
||||
s.peak_ms,
|
||||
s.baseline_ms,
|
||||
(now - s.at).max(0.0)
|
||||
))
|
||||
.color(AMBER)
|
||||
.monospace(),
|
||||
)
|
||||
.on_hover_text(why);
|
||||
if hud.visible {
|
||||
let now = time.elapsed_secs();
|
||||
hud.maybe_refresh(&stats, now);
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame-time graph: the windowed wall samples as a polyline with the missed-interval line in
|
||||
/// red. Scaled to at least twice the observed interval so a dropped (doubled) frame reads clearly.
|
||||
fn frame_graph(ui: &mut egui::Ui, stats: &FrameStats, red_above: f32) {
|
||||
let (rect, _) =
|
||||
ui.allocate_exact_size(egui::vec2(ui.available_width(), 52.0), egui::Sense::hover());
|
||||
if stats.wall.is_empty() {
|
||||
/// The pill as ~20 quads on the player-UI pass (decision 1453). The append lane is rebuilt every
|
||||
/// frame anyway, so the marginal cost is the clone of a cached Vec — where the old egui pill woke
|
||||
/// bevy_egui's whole pipeline plus a full-screen compositing camera (~1 ms on the director's live
|
||||
/// toggle). Glyphs are laid out only when the snapshot ticks or the window resizes.
|
||||
pub(super) fn pill_quads(
|
||||
hud: Res<PerfHud>,
|
||||
atlas: Option<Res<UiFontAtlas>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
mut quads: ResMut<UiQuads>,
|
||||
mut cache: Local<Option<PillCache>>,
|
||||
) {
|
||||
if !hud.visible {
|
||||
return;
|
||||
}
|
||||
let painter = ui.painter_at(rect);
|
||||
let floor = (stats.rail_ms() * 2.0).max(33.0);
|
||||
let max_ms = stats.wall.iter().fold(floor, f32::max);
|
||||
let to_y = |ms: f32| rect.bottom() - (ms / max_ms).clamp(0.0, 1.0) * rect.height();
|
||||
let (Some(atlas), Ok(win)) = (atlas, windows.single()) else {
|
||||
return; // headless: nothing draws, nothing to price
|
||||
};
|
||||
let win_w = win.width();
|
||||
let stale = !matches!(&*cache, Some(c) if c.snap_at == hud.snap_at && c.win_w == win_w);
|
||||
if stale {
|
||||
let cpu = hud.snap.cpu.mean();
|
||||
let fps = hud.snap.fps();
|
||||
// One string, the dim run via markup: "59 fps 8.5 ms" — fps dim, cost in full text.
|
||||
let text = match cpu {
|
||||
Some(cpu) => format!("{Q_DIM_MARKUP}{fps:.0} fps|r {cpu:.1} ms"),
|
||||
None => format!("{Q_DIM_MARKUP}-- ms"),
|
||||
};
|
||||
let center = Vec2::new(win_w * 0.5, 0.0); // measured first, then shifted under PILL_TOP
|
||||
let mut e = atlas.lock();
|
||||
let glyphs = layout_text_quads(
|
||||
&mut e,
|
||||
&text,
|
||||
Rect::from_center_size(center, Vec2::ZERO),
|
||||
Q_TEXT,
|
||||
Justify {
|
||||
h: JustifyH::Center,
|
||||
v: JustifyV::Middle,
|
||||
},
|
||||
Z_PILL,
|
||||
FontSpec {
|
||||
path: None,
|
||||
height: Some(PILL_QUAD_PX),
|
||||
outline: Outline::None,
|
||||
alpha_gradient: None,
|
||||
},
|
||||
);
|
||||
drop(e);
|
||||
let bounds = glyphs
|
||||
.iter()
|
||||
.map(|q| q.rect)
|
||||
.reduce(|a, b| a.union(b))
|
||||
.unwrap_or(Rect::from_center_size(center, Vec2::ZERO));
|
||||
let strip = Rect::new(
|
||||
bounds.min.x - PILL_PAD.x,
|
||||
bounds.min.y - PILL_PAD.y,
|
||||
bounds.max.x + PILL_PAD.x,
|
||||
bounds.max.y + PILL_PAD.y,
|
||||
);
|
||||
// Measured about y=0; seat the whole strip at PILL_TOP.
|
||||
let dy = PILL_TOP - strip.min.y;
|
||||
let shift = |r: Rect| Rect::new(r.min.x, r.min.y + dy, r.max.x, r.max.y + dy);
|
||||
let strip = shift(strip);
|
||||
let mut out = vec![UiQuad {
|
||||
rect: strip,
|
||||
z_key: Z_PILL_BG,
|
||||
color: Q_FILL,
|
||||
..Default::default()
|
||||
}];
|
||||
for mut q in glyphs {
|
||||
q.rect = shift(q.rect);
|
||||
out.push(q);
|
||||
}
|
||||
*cache = Some(PillCache {
|
||||
snap_at: hud.snap_at,
|
||||
win_w,
|
||||
quads: out,
|
||||
});
|
||||
}
|
||||
let Some(c) = &*cache else { return };
|
||||
quads.overlays.extend(c.quads.iter().cloned());
|
||||
}
|
||||
|
||||
painter.hline(
|
||||
rect.x_range(),
|
||||
to_y(red_above),
|
||||
egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(200, 80, 80)),
|
||||
);
|
||||
|
||||
let dx = rect.width() / stats.wall.cap() as f32;
|
||||
let points: Vec<egui::Pos2> = stats
|
||||
.wall
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, ms)| egui::pos2(rect.left() + i as f32 * dx, to_y(ms)))
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(points, egui::Stroke::new(1.0_f32, GREEN)));
|
||||
/// [`pill_quads`]' cache: the laid-out pill, valid for one snapshot tick at one window width.
|
||||
pub(super) struct PillCache {
|
||||
snap_at: f32,
|
||||
win_w: f32,
|
||||
quads: Vec<UiQuad>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The panel, laid out by a real `egui::Context`, headless.
|
||||
///
|
||||
/// **It must run more than one pass, and that is the whole point.** `Area` runs its sizing
|
||||
/// pass only on the frame it is created; the width bug lived exclusively on every *later*
|
||||
/// frame, where `Separator` and unwrapped labels reach for room the content never asked for.
|
||||
/// A one-pass test would have passed against the broken build.
|
||||
///
|
||||
/// Returns `(content width, ruler width)` after the final pass.
|
||||
fn lay_out(stats: &FrameStats, now: f32, expanded: bool, passes: usize) -> (f32, f32) {
|
||||
let ctx = egui::Context::default();
|
||||
let diagnostics = DiagnosticsStore::default();
|
||||
let (mut width, mut ruler) = (0.0, 0.0);
|
||||
for _ in 0..passes {
|
||||
let _ = ctx.run(egui::RawInput::default(), |ctx| {
|
||||
egui::Window::new("perf_hud")
|
||||
.title_bar(false)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.movable(false)
|
||||
.show(ctx, |ui| {
|
||||
draw_hud(ui, stats, &diagnostics, true, now, expanded);
|
||||
width = ui.min_rect().width();
|
||||
ruler = panel_width(ui);
|
||||
});
|
||||
});
|
||||
}
|
||||
(width, ruler)
|
||||
}
|
||||
|
||||
/// A settled window plus a latched `Stalled` spike — whose `describe()` is ~110 characters,
|
||||
/// the longest string the panel can ever draw, and precisely what used to measure the window.
|
||||
fn stats_with_a_latched_spike() -> (FrameStats, f32) {
|
||||
let mut stats = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (16.6, 20.0, 5.0)).collect();
|
||||
let mut t = stats.feed_frames(&calm, 0.0, 1.0 / 60.0);
|
||||
t = stats.feed_frames(&[(40.0, 20.0, 5.0)], t, 1.0 / 60.0);
|
||||
t = stats.feed_frames(&[(16.6, 20.0, 5.0)], t, 1.0 / 60.0);
|
||||
assert!(
|
||||
stats.spike(t).is_some(),
|
||||
"the fixture must actually latch — otherwise this measures the easy case"
|
||||
);
|
||||
(stats, t)
|
||||
}
|
||||
|
||||
/// **The invariant the panel kept breaking:** the expanded panel is exactly as wide as the
|
||||
/// wider of [`PANEL_RULER`] and the pill above it — never wider. Prose wraps, numbers fit, and
|
||||
/// no single long line gets to decide the width by being long.
|
||||
///
|
||||
/// Three shipped builds widened this panel, the worst to ~5x the ruler, and each was reported
|
||||
/// by the director rather than caught here. All three fail this test.
|
||||
#[test]
|
||||
fn panel_layout_is_governed_by_the_ruler() {
|
||||
let (stats, t) = stats_with_a_latched_spike();
|
||||
let (pill, _) = lay_out(&stats, t, false, 3);
|
||||
let (width, ruler) = lay_out(&stats, t, true, 3);
|
||||
let governing = ruler.max(pill);
|
||||
assert!(
|
||||
width <= governing + 1.0,
|
||||
"the expanded panel is {width:.1} px against a {governing:.1} px bound \
|
||||
(ruler {ruler:.1}, pill {pill:.1}) — something in it is refusing to wrap \
|
||||
(TextWrapMode::Extend) or reaching for available_width"
|
||||
);
|
||||
}
|
||||
|
||||
/// And it must be genuinely governed, not accidentally narrow: the panel should actually
|
||||
/// reach its bound, or the guard above is vacuous.
|
||||
#[test]
|
||||
fn the_expanded_panel_fills_its_bound() {
|
||||
let (stats, t) = stats_with_a_latched_spike();
|
||||
let (pill, _) = lay_out(&stats, t, false, 3);
|
||||
let (width, ruler) = lay_out(&stats, t, true, 3);
|
||||
let governing = ruler.max(pill);
|
||||
assert!(
|
||||
width >= governing - 1.0,
|
||||
"the expanded panel is only {width:.1} px of a {governing:.1} px bound"
|
||||
);
|
||||
}
|
||||
|
||||
/// With no spike latched the pill is comfortably inside the ruler, so the ruler is what
|
||||
/// governs in the ordinary case — the pill only takes over while a badge is up.
|
||||
#[test]
|
||||
fn the_ruler_governs_when_no_spike_is_latched() {
|
||||
let mut stats = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (16.6, 20.0, 5.0)).collect();
|
||||
let t = stats.feed_frames(&calm, 0.0, 1.0 / 60.0);
|
||||
assert!(stats.spike(t).is_none(), "the fixture must be quiet");
|
||||
|
||||
let (pill, ruler) = lay_out(&stats, t, false, 3);
|
||||
assert!(
|
||||
pill < ruler,
|
||||
"the quiet pill is {pill:.1} px against a {ruler:.1} px ruler — the ruler should \
|
||||
govern the ordinary panel, or its width tracks the badge and jitters"
|
||||
);
|
||||
let (width, _) = lay_out(&stats, t, true, 3);
|
||||
assert!(
|
||||
(width - ruler).abs() <= 1.0,
|
||||
"the quiet panel is {width:.1} px, not the {ruler:.1} px ruler"
|
||||
);
|
||||
}
|
||||
|
||||
/// The view the HUD draws only advances on the refresh interval — between ticks it holds
|
||||
/// still, which is the entire cost argument (identical strings are what the galley cache can
|
||||
/// absorb) — and a refresh adopts the live meters wholesale.
|
||||
/// still, which is the entire cost argument (identical snapshots are what the quad cache can
|
||||
/// reuse) — and a refresh adopts the live meters wholesale.
|
||||
#[test]
|
||||
fn the_snapshot_advances_on_the_interval_not_per_frame() {
|
||||
let mut live = FrameStats::default();
|
||||
let frames: Vec<_> = (0..60).map(|_| (16.6, 20.0, 5.0)).collect();
|
||||
let frames: Vec<_> = (0..60).map(|_| (16.6, 20.0)).collect();
|
||||
let t = live.feed_frames(&frames, 0.0, 1.0 / 60.0);
|
||||
|
||||
let mut hud = PerfHud::default();
|
||||
@@ -677,7 +233,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(hud.snap_at, t);
|
||||
|
||||
let t2 = live.feed_frames(&[(40.0, 20.0, 5.0)], t, 1.0 / 60.0);
|
||||
let t2 = live.feed_frames(&[(40.0, 20.0)], t, 1.0 / 60.0);
|
||||
hud.maybe_refresh(&live, t2);
|
||||
assert_eq!(
|
||||
hud.snap.wall.len(),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
//! `perf` — performance instrumentation + the standing dev HUD ([`PerfPlugin`]).
|
||||
//!
|
||||
//! Owns the **frame-cost measurement layer** that every future subsystem is measured against (the
|
||||
//! standard), and draws the always-on pill (top-center) that you **click to expand** into the full
|
||||
//! readout; the **dev chord + `P`** toggles the whole HUD (`Ctrl+Shift+P` — decisions
|
||||
//! 0585/0867/0870).
|
||||
//! standard), and draws the always-on cost pill (top-center) — the whole HUD since 1454; anything
|
||||
//! deeper is an instrument's job (the journal, the probes, Tracy). The **dev chord + `P`** toggles
|
||||
//! it (`Ctrl+Shift+P` — decisions 0585/0867/0870).
|
||||
//!
|
||||
//! The concerns, one file each:
|
||||
//! - [`clock`] — the three CPU clocks (process, main thread, machine) every number is denominated in,
|
||||
//! - [`stats`] — the rolling windows, their tails, and the spike latch,
|
||||
//! - [`hud`] — the collapsed cost pill and the expanded readout,
|
||||
//! - [`stats`] — the rolling windows the pill reads,
|
||||
//! - [`hud`] — the standing cost pill,
|
||||
//! - [`trace`] / [`journal`] — the two CSV instruments (`WOW_STREAM_TRACE`, `WOW_FPS_JOURNAL`),
|
||||
//! - [`stall`] — the stuck-main-thread self-sampler (macOS),
|
||||
//! - [`census`] — the env-gated premise counters.
|
||||
@@ -36,8 +36,8 @@
|
||||
//! 0.015/0.019/0.049 ms across 256²/1920×1080/4096² clears, 12/12 reproducible. It needs two
|
||||
//! sentinel render-graph nodes at ~0.03 ms/frame, and the query set must be resolved in a
|
||||
//! *different* command buffer than the timed pass or Metal returns zeros. Until that is built, a
|
||||
//! GPU-bound frame is identified rather than timed: it is the one that runs long while both CPU
|
||||
//! meters stay flat ([`stats::SpikeKind::Stalled`]).
|
||||
//! GPU-bound frame is identified rather than timed: it is the one that runs long while the CPU
|
||||
//! meters stay flat (read them side by side in the journal or a probe line).
|
||||
|
||||
mod census;
|
||||
mod clock;
|
||||
@@ -49,7 +49,6 @@ mod stall;
|
||||
mod stats;
|
||||
mod trace;
|
||||
|
||||
use bevy::diagnostic::{EntityCountDiagnosticsPlugin, FrameTimeDiagnosticsPlugin};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::diagnostic::RenderDiagnosticsPlugin;
|
||||
|
||||
@@ -65,32 +64,34 @@ pub struct PerfPlugin;
|
||||
|
||||
impl Plugin for PerfPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins((
|
||||
FrameTimeDiagnosticsPlugin::default(),
|
||||
EntityCountDiagnosticsPlugin::default(),
|
||||
// Render-pass timing (CPU-only on Apple Silicon — see the module header). Harmless if
|
||||
// the backend can't record it; also the hook Tracy GPU uses on Vulkan/DX12.
|
||||
RenderDiagnosticsPlugin,
|
||||
))
|
||||
.init_resource::<stats::FrameStats>()
|
||||
.init_resource::<PerfHud>()
|
||||
.insert_resource(trace::StreamTrace {
|
||||
path: std::env::var("WOW_STREAM_TRACE").unwrap_or_default(),
|
||||
frame: 0,
|
||||
log_until: 0,
|
||||
prev_cpu_secs: None,
|
||||
prev_pipes_created: 0,
|
||||
})
|
||||
.add_systems(
|
||||
Update,
|
||||
// `toggle_hud` needs no ordering against the UI keyboard feed any more: its dev
|
||||
// chord can't be typed text, so there's no `UiKeyboardCapture` to read (decision 0585).
|
||||
(hud::toggle_hud, stats::sample_frame_time),
|
||||
)
|
||||
// `Last`, so a row carries everything this frame's Stream chain did — and the reset runs
|
||||
// whether or not anything is tracing, or the counters would accumulate forever.
|
||||
.add_systems(Last, trace::trace_stream)
|
||||
.add_systems(bevy_egui::EguiPrimaryContextPass, hud::perf_hud_ui);
|
||||
// Render-pass timing (CPU-only on Apple Silicon — see the module header). Harmless if
|
||||
// the backend can't record it; also the hook Tracy GPU uses on Vulkan/DX12.
|
||||
app.add_plugins(RenderDiagnosticsPlugin)
|
||||
.init_resource::<stats::FrameStats>()
|
||||
.init_resource::<PerfHud>()
|
||||
.insert_resource(trace::StreamTrace {
|
||||
path: std::env::var("WOW_STREAM_TRACE").unwrap_or_default(),
|
||||
frame: 0,
|
||||
log_until: 0,
|
||||
prev_cpu_secs: None,
|
||||
prev_pipes_created: 0,
|
||||
})
|
||||
.add_systems(
|
||||
Update,
|
||||
// `toggle_hud` needs no ordering against the UI keyboard feed any more: its dev
|
||||
// chord can't be typed text, so there's no `UiKeyboardCapture` to read (decision 0585).
|
||||
(
|
||||
hud::toggle_hud,
|
||||
hud::refresh_hud_snapshot,
|
||||
stats::sample_frame_time,
|
||||
),
|
||||
)
|
||||
// The pill rides the player-UI quad pass (1453): appended with the other overlay
|
||||
// producers, so the HUD costs a Vec clone and never touches the egui lane (1454).
|
||||
.add_systems(Update, hud::pill_quads.in_set(crate::ui_pass::UiQuadAppend))
|
||||
// `Last`, so a row carries everything this frame's Stream chain did — and the reset runs
|
||||
// whether or not anything is tracing, or the counters would accumulate forever.
|
||||
.add_systems(Last, trace::trace_stream);
|
||||
// `WOW_MESH_EVENTS=1` — who churns Mesh assets per frame? The premise counter behind
|
||||
// the Stormwind trace's `allocate_and_free_meshes` row (0.86 ms/frame at a PARKED pin):
|
||||
// the allocator answers every Modified with a free+realloc, so a steady scene should
|
||||
@@ -126,6 +127,13 @@ impl Plugin for PerfPlugin {
|
||||
{
|
||||
app.add_systems(Update, census::row_bloat);
|
||||
}
|
||||
// `WOW_CPU_CENSUS=<at>:<secs>` — the per-thread CPU census (see its module doc): where
|
||||
// the pill's cpu-ms goes, thread by thread, summing exactly to the process total.
|
||||
#[cfg(target_os = "macos")]
|
||||
if let Some(c) = census::cpu_census::CpuCensus::from_env() {
|
||||
app.insert_resource(c);
|
||||
app.add_systems(Update, census::cpu_census::cpu_census);
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
stall::plugin(app);
|
||||
// `WOW_GPU_MS=1` — the whole-frame GPU meter (its module doc owns the design and the
|
||||
|
||||
@@ -1,89 +1,30 @@
|
||||
//! The frame-cost meters: one rolling window per series, their tails, and the spike latch.
|
||||
//! The frame-cost meters the pill reads: a rolling window each of wall frame time and process
|
||||
//! CPU per frame.
|
||||
//!
|
||||
//! **Why the tail, and why per-series.** The HUD used to carry percentiles on wall frame time and a
|
||||
//! flat *mean* on cost — so the client had a tail with no cost and a cost meter with no tail, and
|
||||
//! `cpu p99` existed nowhere. That is exactly the shape a regression hides in: 0366 measured a run
|
||||
//! whose per-second mean never passed 16.5 ms while nearly every second carried a few 19-29 ms
|
||||
//! frames, and the tail was what the director read as "30 fps". 0610 adds the shape — a spike is a
|
||||
//! burst of ~6 consecutive frames, ~250 ms of degraded time, not one bad frame. Six frames inside a
|
||||
//! 300-sample mean move it by 2 %.
|
||||
//! **The law (0717): while synced, wall frame time measures the display's present grant, not our
|
||||
//! cost.** Only the CPU series measures work — it is the pill's headline. `wall` exists for the
|
||||
//! dim fps anchor and the hitch log below, nothing else.
|
||||
//!
|
||||
//! **Everything is measured against the window's own baseline, never against a constant.** 1355
|
||||
//! records the same slot drifting −3.3 cpu_ms between sessions on thermal state alone, and 1353
|
||||
//! records the pin-to-pin spread; an absolute threshold would be calibrated for one scene on one
|
||||
//! afternoon. A spike here is "much more than *this* scene has been costing", which is what
|
||||
//! noticing a regression actually means — and it works unchanged at 3 ms on an empty map and at
|
||||
//! 16 ms in Stormwind.
|
||||
//! This file used to carry a per-series spike latch (0610's burst detector) and the
|
||||
//! median baselines it tested against; both left with their last surface — the pill's arrow
|
||||
//! (1455, after 1454 removed the expanded panel). The standing stall record is the
|
||||
//! `frame hitch` warn below plus the self-sampler ([`super::stall`]); burst-shaped analysis
|
||||
//! belongs to the instruments (the journal, the probes, Tracy).
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use bevy::ecs::system::NonSendMarker;
|
||||
use bevy::prelude::*;
|
||||
use bevy::time::Real;
|
||||
|
||||
use super::clock::{main_thread_cpu_secs, process_cpu_secs};
|
||||
use super::clock::process_cpu_secs;
|
||||
|
||||
/// Recent frames kept for the percentiles + the graph (~5 s at 60 fps, ~2.5 s at 120).
|
||||
/// Recent frames kept for the pill's windowed means (~5 s at 60 fps, ~2.5 s at 120).
|
||||
pub(super) const SAMPLE_WINDOW: usize = 300;
|
||||
|
||||
/// Frame duration above which a frame is logged as a hitch (a stall the player feels). Far above
|
||||
/// any present interval so it only fires on real stalls (load bursts), not normal jitter.
|
||||
const HITCH_LOG_MS: f32 = 250.0;
|
||||
|
||||
/// A frame past `DROPPED_FACTOR` × the **observed** present interval is a missed interval — the
|
||||
/// metric a synced player actually feels (0717).
|
||||
///
|
||||
/// The factor is 0717's, but the thing it multiplies is not. That record set the threshold at
|
||||
/// 1.5 × a hardcoded 16.7 ms budget, which silently assumes a 60 Hz rail: on the director's
|
||||
/// ProMotion panel granting ~120, a missed interval is 16.7 ms and lands *under* the 25 ms
|
||||
/// threshold, so **every 120→60 drop went uncounted**. Multiplying the interval we can see instead
|
||||
/// keeps 0717's semantics exactly at 60 Hz and restores them at any other refresh — including an
|
||||
/// adaptive panel, which has no fixed rail at all (0294) and where the only honest answer to "what
|
||||
/// is one interval" is "what the display has been giving us lately".
|
||||
pub(super) const DROPPED_FACTOR: f32 = 1.5;
|
||||
|
||||
/// A frame must exceed its series' baseline by this ratio to count as a spike.
|
||||
const SPIKE_FACTOR: f32 = 1.5;
|
||||
|
||||
/// ...**and** by at least this fraction of a present interval. Without the floor the ratio test
|
||||
/// alone latches on noise: a main-thread baseline of 0.4 ms makes any 0.7 ms frame a "2× spike".
|
||||
/// Half an interval is the point where an excess starts being able to cost a frame.
|
||||
const SPIKE_FLOOR_OF_RAIL: f32 = 0.5;
|
||||
|
||||
/// The floor's own floor, for the first second of a run and any pathological rail estimate.
|
||||
const SPIKE_FLOOR_MIN_MS: f32 = 2.0;
|
||||
|
||||
/// How long a finished burst stays on the pill after it ends. The whole point of the latch: a
|
||||
/// spike lasts ~250 ms and the director is looking at the game, not at the HUD, so the evidence
|
||||
/// has to outlive the event by long enough to glance at.
|
||||
const LATCH_HOLD_SECS: f32 = 10.0;
|
||||
|
||||
/// Consecutive frames a [`SpikeKind::Worker`] burst must last before the badge will show it.
|
||||
///
|
||||
/// **Because `cpu` is a sum across threads, not a duration.** A frame in which six pool workers
|
||||
/// happen to overlap carries six threads' milliseconds and trips the ratio test while costing the
|
||||
/// frame nothing — the first live run of this HUD latched `▲31.3 work ×1` against `missed 0/300`,
|
||||
/// which is the instrument reporting the scheduler rather than a regression. A real worker
|
||||
/// regression is *sustained* (0610's burst is ~6 frames), so a few consecutive frames separates
|
||||
/// the two cleanly.
|
||||
///
|
||||
/// [`SpikeKind::Main`] and [`SpikeKind::Stalled`] are deliberately **not** gated: one long
|
||||
/// main-thread frame is itself the stutter, and `Stalled` already had to exceed the
|
||||
/// missed-interval threshold to classify at all.
|
||||
const WORKER_BURST_FRAMES: u32 = 3;
|
||||
|
||||
/// Baselines are re-derived this often, not every frame. A median moves slowly by construction, so
|
||||
/// a one-second-stale baseline changes no verdict — and it keeps the collapsed pill's per-frame
|
||||
/// path free of sorts (1370: the HUD is itself inside every campaign anchor).
|
||||
const BASELINE_REFRESH_SECS: f32 = 1.0;
|
||||
|
||||
/// Seconds of history the slow trend keeps — the timescale creep lives on.
|
||||
///
|
||||
/// The fast window is ~2.5 s at 120 Hz: plenty to catch a burst, useless for noticing that a scene
|
||||
/// which used to cost 15 ms now costs 19. One sample a second for a minute costs 60 floats and
|
||||
/// makes a step change visible as a step.
|
||||
pub(super) const TREND_WINDOW_SECS: usize = 60;
|
||||
|
||||
/// A rolling window of one cost series, in milliseconds, capped at its own length.
|
||||
#[derive(Clone)]
|
||||
pub(super) struct Series {
|
||||
@@ -106,161 +47,18 @@ impl Series {
|
||||
self.samples.push_back(ms);
|
||||
}
|
||||
|
||||
pub(super) fn cap(&self) -> usize {
|
||||
self.cap
|
||||
}
|
||||
|
||||
pub(super) fn last(&self) -> f32 {
|
||||
self.samples.back().copied().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
pub(super) fn len(&self) -> usize {
|
||||
self.samples.len()
|
||||
}
|
||||
|
||||
pub(super) fn is_empty(&self) -> bool {
|
||||
self.samples.is_empty()
|
||||
}
|
||||
|
||||
pub(super) fn iter(&self) -> impl Iterator<Item = f32> + '_ {
|
||||
self.samples.iter().copied()
|
||||
}
|
||||
|
||||
/// Windowed mean. `None` on an empty window, so a caller can tell "no data" from "zero cost".
|
||||
pub(super) fn mean(&self) -> Option<f32> {
|
||||
(!self.samples.is_empty())
|
||||
.then(|| self.samples.iter().sum::<f32>() / self.samples.len() as f32)
|
||||
}
|
||||
|
||||
/// `(p50, p99, max)` over the window. Sorts a copy — fine for a few-hundred-element dev HUD,
|
||||
/// and deliberately **not** on the collapsed pill's per-frame path (see [`Baselines`]).
|
||||
pub(super) fn percentiles(&self) -> (f32, f32, f32) {
|
||||
if self.samples.is_empty() {
|
||||
return (0.0, 0.0, 0.0);
|
||||
}
|
||||
let mut v: Vec<f32> = self.samples.iter().copied().collect();
|
||||
v.sort_by(f32::total_cmp);
|
||||
let at = |q: f32| v[(((v.len() - 1) as f32) * q).round() as usize];
|
||||
(at(0.50), at(0.99), *v.last().unwrap())
|
||||
}
|
||||
|
||||
/// The window's `q`-quantile. Sorts a copy, so like [`Self::percentiles`] it belongs off the
|
||||
/// per-frame path — [`Baselines`] is where its caller reads the answer from.
|
||||
fn quantile(&self, q: f32) -> f32 {
|
||||
if self.samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut v: Vec<f32> = self.samples.iter().copied().collect();
|
||||
v.sort_by(f32::total_cmp);
|
||||
v[(((v.len() - 1) as f32) * q).round() as usize]
|
||||
}
|
||||
|
||||
/// The window's median alone — the baseline every spike test is relative to.
|
||||
fn median(&self) -> f32 {
|
||||
if self.samples.is_empty() {
|
||||
return 0.0;
|
||||
}
|
||||
let mut v: Vec<f32> = self.samples.iter().copied().collect();
|
||||
v.sort_by(f32::total_cmp);
|
||||
v[((v.len() - 1) / 2).min(v.len() - 1)]
|
||||
}
|
||||
|
||||
/// `(count, fraction)` of windowed frames above `threshold_ms`.
|
||||
pub(super) fn frames_over(&self, threshold_ms: f32) -> (usize, f32) {
|
||||
let n = self.samples.iter().filter(|&&ms| ms > threshold_ms).count();
|
||||
let frac = if self.samples.is_empty() {
|
||||
0.0
|
||||
} else {
|
||||
n as f32 / self.samples.len() as f32
|
||||
};
|
||||
(n, frac)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a spike was blamed on. The three are distinguishable **without GPU timestamps**, which we
|
||||
/// do not have in-client on Apple Silicon (see the module header of [`super`]): the question "were
|
||||
/// we busy, or were we blocked?" is answered by whether the CPU meters moved with the frame.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
pub(super) enum SpikeKind {
|
||||
/// Main-thread CPU ran long — our own systems, serialized, and the frame waited on them. The
|
||||
/// most actionable kind: it is the one a player feels as a stutter.
|
||||
Main,
|
||||
/// Process CPU ran long while the main thread did not — a worker-pool burst (asset decode,
|
||||
/// collider bake). Real work, but off the frame's critical path.
|
||||
Worker,
|
||||
/// The frame ran long while **neither** CPU meter moved: we were blocked, not busy. That is
|
||||
/// the GPU or the present path. Stated as an inference, because it is one — a blocked
|
||||
/// present-wait consumes no CPU (0717), so this is what its absence looks like from here.
|
||||
Stalled,
|
||||
}
|
||||
|
||||
impl SpikeKind {
|
||||
/// The short tag the expanded panel's spike line carries.
|
||||
pub(super) fn tag(self) -> &'static str {
|
||||
match self {
|
||||
SpikeKind::Main => "main",
|
||||
SpikeKind::Worker => "work",
|
||||
SpikeKind::Stalled => "wait",
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn describe(self) -> &'static str {
|
||||
match self {
|
||||
SpikeKind::Main => {
|
||||
"main-thread CPU ran long — our systems, on the frame's critical path"
|
||||
}
|
||||
SpikeKind::Worker => {
|
||||
"process CPU ran long while the main thread did not — a worker-pool burst \
|
||||
(asset decode, collider bake), off the critical path"
|
||||
}
|
||||
SpikeKind::Stalled => {
|
||||
"the frame ran long while neither CPU meter moved — blocked, not busy: \
|
||||
the GPU or the present path (inferred, we have no GPU timer on Apple Silicon)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A burst that has finished and must stay visible after it ended.
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct Spike {
|
||||
/// Worst frame of the burst, in the tripping series' milliseconds.
|
||||
pub(super) peak_ms: f32,
|
||||
/// The series' baseline at the time, so the badge can say what "normal" was.
|
||||
pub(super) baseline_ms: f32,
|
||||
/// Consecutive frames the burst lasted. 0610: a spike is a burst, not a frame — the six-frame
|
||||
/// version is what reads as a quarter-second of degraded time.
|
||||
pub(super) frames: u32,
|
||||
pub(super) kind: SpikeKind,
|
||||
/// `Time<Real>` seconds at the burst's last frame.
|
||||
pub(super) at: f32,
|
||||
}
|
||||
|
||||
/// Is this burst worth a badge? See [`WORKER_BURST_FRAMES`] — the one kind that needs to prove
|
||||
/// itself is the one measured by a cross-thread sum.
|
||||
fn reportable(s: &Spike) -> bool {
|
||||
s.kind != SpikeKind::Worker || s.frames >= WORKER_BURST_FRAMES
|
||||
}
|
||||
|
||||
/// Medians re-derived once a second, off the per-frame path.
|
||||
#[derive(Default, Clone)]
|
||||
struct Baselines {
|
||||
wall: f32,
|
||||
cpu: f32,
|
||||
main: f32,
|
||||
/// The trend sparkline's y-scale: the trend window's p90, **not its max**. Derived here rather
|
||||
/// than in the painter because it needs a sort and the painter runs every frame the panel is
|
||||
/// open — and refreshed on exactly the tick that appends the sample it summarises, so it can
|
||||
/// never lag by more than one point.
|
||||
trend_hi: f32,
|
||||
at: f32,
|
||||
}
|
||||
|
||||
/// The per-frame cost meters and everything derived from them.
|
||||
///
|
||||
/// Three series, because they answer three different questions and 0717's law binds all of them:
|
||||
/// while synced, **wall frame time is the display's grant, not our cost**. Only the CPU series
|
||||
/// measure work.
|
||||
/// The per-frame cost meters.
|
||||
///
|
||||
/// `Clone` is for the HUD's 4 Hz snapshot (`PerfHud::maybe_refresh`) — a few hundred floats,
|
||||
/// four times a second.
|
||||
@@ -271,22 +69,7 @@ pub(super) struct FrameStats {
|
||||
/// Process CPU per frame, user+system across every thread (`getrusage`) — the campaign's
|
||||
/// currency (0736) and the meter the rail cannot fool (0717).
|
||||
pub(super) cpu: Series,
|
||||
/// Main-thread CPU per frame. The serialized half of the above: what a hitch is made of.
|
||||
pub(super) main: Series,
|
||||
/// One `cpu` median per second, for [`TREND_WINDOW_SECS`]. The creep lane: the fast window
|
||||
/// cannot see a regression that is merely *sustained*, because a sustained cost IS its own
|
||||
/// baseline there.
|
||||
pub(super) trend: Series,
|
||||
prev_cpu_secs: Option<f64>,
|
||||
prev_main_secs: Option<f64>,
|
||||
baselines: Baselines,
|
||||
/// The burst currently accumulating, if this frame and its predecessors are tripping.
|
||||
run: Option<Spike>,
|
||||
/// The worst burst inside the hold window — what the pill actually shows.
|
||||
latched: Option<Spike>,
|
||||
/// Bursts that have finished inside the hold window, latched or not.
|
||||
recent_bursts: u32,
|
||||
recent_since: f32,
|
||||
}
|
||||
|
||||
impl Default for FrameStats {
|
||||
@@ -294,37 +77,15 @@ impl Default for FrameStats {
|
||||
Self {
|
||||
wall: Series::new(SAMPLE_WINDOW),
|
||||
cpu: Series::new(SAMPLE_WINDOW),
|
||||
main: Series::new(SAMPLE_WINDOW),
|
||||
trend: Series::new(TREND_WINDOW_SECS),
|
||||
prev_cpu_secs: None,
|
||||
prev_main_secs: None,
|
||||
baselines: Baselines::default(),
|
||||
run: None,
|
||||
latched: None,
|
||||
recent_bursts: 0,
|
||||
recent_since: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameStats {
|
||||
/// The display's present interval as observed, not as assumed. See [`DROPPED_FACTOR`].
|
||||
pub(super) fn rail_ms(&self) -> f32 {
|
||||
self.baselines.wall
|
||||
}
|
||||
|
||||
/// The threshold a frame must pass to count as a missed interval.
|
||||
pub(super) fn dropped_above_ms(&self) -> f32 {
|
||||
self.rail_ms() * DROPPED_FACTOR
|
||||
}
|
||||
|
||||
/// The sparkline's y-scale — see [`Baselines::trend_hi`].
|
||||
pub(super) fn trend_hi(&self) -> f32 {
|
||||
self.baselines.trend_hi
|
||||
}
|
||||
|
||||
/// Windowed mean frames per second. A mean, and labelled as one — it is the number that
|
||||
/// cannot see a spike, which is why the pill draws it dim and small, never as the headline.
|
||||
/// cannot see cost (0717), which is why the pill draws it dim and small, never as the
|
||||
/// headline.
|
||||
pub(super) fn fps(&self) -> f32 {
|
||||
match self.wall.mean() {
|
||||
Some(mean) if mean > 0.0 => 1000.0 / mean,
|
||||
@@ -332,166 +93,23 @@ impl FrameStats {
|
||||
}
|
||||
}
|
||||
|
||||
/// The spike still inside its hold window, if any.
|
||||
pub(super) fn spike(&self, now: f32) -> Option<Spike> {
|
||||
self.latched
|
||||
.filter(|s| now - s.at < LATCH_HOLD_SECS)
|
||||
.or(self.run)
|
||||
.filter(reportable)
|
||||
}
|
||||
|
||||
/// Bursts finished inside the hold window — "it happened three times" is a different report
|
||||
/// from "it happened once", and only the tooltip has room to say so.
|
||||
pub(super) fn recent_bursts(&self, now: f32) -> u32 {
|
||||
if now - self.recent_since < LATCH_HOLD_SECS {
|
||||
self.recent_bursts
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a frame must sit above its baseline to count. Both tests must pass: the ratio, so
|
||||
/// the rule travels between a 3 ms scene and a 16 ms one, and the floor, so it does not fire
|
||||
/// inside the noise of a series whose baseline is a fraction of a millisecond.
|
||||
fn spike_excess_ms(&self, baseline: f32) -> f32 {
|
||||
let floor = (self.rail_ms() * SPIKE_FLOOR_OF_RAIL).max(SPIKE_FLOOR_MIN_MS);
|
||||
floor.max(baseline * (SPIKE_FACTOR - 1.0))
|
||||
}
|
||||
|
||||
/// Classify the frame just pushed. `None` on a normal frame.
|
||||
///
|
||||
/// Order is deliberate and is the whole attribution: main-thread cost first (the actionable
|
||||
/// kind), then a worker burst, and only if *neither* CPU meter moved is a long frame read as a
|
||||
/// stall. A GPU-bound frame is precisely the one that is long while the CPU is flat.
|
||||
fn classify(&self) -> Option<(SpikeKind, f32, f32)> {
|
||||
let b = &self.baselines;
|
||||
let tripped = |v: f32, base: f32| v - base > self.spike_excess_ms(base);
|
||||
|
||||
if !self.main.is_empty() {
|
||||
let v = self.main.last();
|
||||
if tripped(v, b.main) {
|
||||
return Some((SpikeKind::Main, v, b.main));
|
||||
}
|
||||
}
|
||||
if !self.cpu.is_empty() {
|
||||
let v = self.cpu.last();
|
||||
if tripped(v, b.cpu) {
|
||||
return Some((SpikeKind::Worker, v, b.cpu));
|
||||
}
|
||||
}
|
||||
let v = self.wall.last();
|
||||
if v > self.dropped_above_ms() && tripped(v, b.wall) {
|
||||
return Some((SpikeKind::Stalled, v, b.wall));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extend or close the current burst. O(1) — this is the whole per-frame cost of the latch.
|
||||
fn observe(&mut self, now: f32) {
|
||||
match self.classify() {
|
||||
Some((kind, peak, baseline)) => {
|
||||
let run = self.run.get_or_insert(Spike {
|
||||
peak_ms: peak,
|
||||
baseline_ms: baseline,
|
||||
frames: 0,
|
||||
kind,
|
||||
at: now,
|
||||
});
|
||||
run.frames += 1;
|
||||
run.at = now;
|
||||
if peak > run.peak_ms {
|
||||
run.peak_ms = peak;
|
||||
// The worst frame of a burst names it: a burst that starts as a worker spill
|
||||
// and peaks on the main thread is a main-thread event.
|
||||
run.kind = kind;
|
||||
run.baseline_ms = baseline;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// The burst (if any) just ended: commit it, unless it never earned a badge — a
|
||||
// filtered burst is not a burst, or the tooltip would count three while the pill
|
||||
// shows one. The latch keeps the WORST burst in the hold window rather than the
|
||||
// most recent, so a small one cannot bury the big one you were meant to see.
|
||||
if let Some(run) = self.run.take().filter(reportable) {
|
||||
if now - self.recent_since >= LATCH_HOLD_SECS {
|
||||
self.recent_bursts = 0;
|
||||
self.recent_since = now;
|
||||
}
|
||||
self.recent_bursts += 1;
|
||||
let expired = self
|
||||
.latched
|
||||
.is_none_or(|l| now - l.at >= LATCH_HOLD_SECS || run.peak_ms >= l.peak_ms);
|
||||
if expired {
|
||||
self.latched = Some(run);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The trend's oldest and newest samples — "it used to cost this, it costs that now". `None`
|
||||
/// until the window has enough history to make the comparison mean anything.
|
||||
pub(super) fn trend_ends(&self) -> Option<(f32, f32)> {
|
||||
(self.trend.len() >= 8).then(|| {
|
||||
let mut it = self.trend.iter();
|
||||
let first = it.next().unwrap_or(0.0);
|
||||
(first, self.trend.last())
|
||||
})
|
||||
}
|
||||
|
||||
/// Drive the meters from a sibling module's test, on the same path [`sample_frame_time`] takes
|
||||
/// minus the clocks. Each frame is `(wall_ms, cpu_ms, main_ms)`; returns the clock it left off
|
||||
/// at. `hud`'s layout test needs a realistically-populated `FrameStats` — including a *latched
|
||||
/// spike*, whose description is the longest string the panel can draw.
|
||||
/// Drive the meters from a sibling module's test, on the same path [`sample_frame_time`]
|
||||
/// takes minus the clocks. Each frame is `(wall_ms, cpu_ms)`; returns the clock it left off
|
||||
/// at. `hud`'s snapshot test needs a realistically-populated `FrameStats`.
|
||||
#[cfg(test)]
|
||||
pub(super) fn feed_frames(&mut self, frames: &[(f32, f32, f32)], start_t: f32, dt: f32) -> f32 {
|
||||
pub(super) fn feed_frames(&mut self, frames: &[(f32, f32)], start_t: f32, dt: f32) -> f32 {
|
||||
let mut t = start_t;
|
||||
for &(wall, cpu, main) in frames {
|
||||
for &(wall, cpu) in frames {
|
||||
self.wall.push(wall);
|
||||
self.cpu.push(cpu);
|
||||
self.main.push(main);
|
||||
self.refresh_baselines(t);
|
||||
if self.wall.len() > 1 && self.baselines.wall > 0.0 {
|
||||
self.observe(t);
|
||||
}
|
||||
t += dt;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
fn refresh_baselines(&mut self, now: f32) {
|
||||
if now - self.baselines.at < BASELINE_REFRESH_SECS && self.baselines.at > 0.0 {
|
||||
return;
|
||||
}
|
||||
self.baselines = Baselines {
|
||||
wall: self.wall.median(),
|
||||
cpu: self.cpu.median(),
|
||||
main: self.main.median(),
|
||||
trend_hi: self.baselines.trend_hi,
|
||||
at: now,
|
||||
};
|
||||
// The trend samples the same median the baseline does, so the two lanes cannot disagree
|
||||
// about what "normal right now" is — the badge and the sparkline read one truth.
|
||||
if !self.cpu.is_empty() {
|
||||
self.trend.push(self.baselines.cpu);
|
||||
self.baselines.trend_hi = self.trend.quantile(0.90);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sample every meter for this frame.
|
||||
///
|
||||
/// **`NonSendMarker` is load-bearing, not decoration.** Bevy's executor runs systems across the
|
||||
/// task pool, and [`main_thread_cpu_secs`] reports *the calling thread*. Without the marker this
|
||||
/// system would drift onto a worker and the `main` series would silently become "whichever thread
|
||||
/// ran the sampler" — noise shaped like a measurement, which is the most expensive kind of
|
||||
/// instrument bug.
|
||||
pub(super) fn sample_frame_time(
|
||||
_pin_to_main_thread: NonSendMarker,
|
||||
time: Res<Time<Real>>,
|
||||
mut stats: ResMut<FrameStats>,
|
||||
) {
|
||||
let now = time.elapsed_secs();
|
||||
/// Sample both meters for this frame.
|
||||
pub(super) fn sample_frame_time(time: Res<Time<Real>>, mut stats: ResMut<FrameStats>) {
|
||||
let wall_ms = time.delta_secs() * 1000.0;
|
||||
stats.wall.push(wall_ms);
|
||||
|
||||
@@ -501,19 +119,6 @@ pub(super) fn sample_frame_time(
|
||||
}
|
||||
stats.prev_cpu_secs = cpu;
|
||||
|
||||
let main = main_thread_cpu_secs();
|
||||
if let (Some(prev), Some(n)) = (stats.prev_main_secs, main) {
|
||||
stats.main.push(((n - prev) * 1000.0) as f32);
|
||||
}
|
||||
stats.prev_main_secs = main;
|
||||
|
||||
stats.refresh_baselines(now);
|
||||
// The first frames have no baseline worth testing against — a cold window would latch the
|
||||
// startup gap as a spike and hold it for ten seconds on every launch.
|
||||
if stats.wall.len() > 1 && stats.baselines.wall > 0.0 {
|
||||
stats.observe(now);
|
||||
}
|
||||
|
||||
// Log hard hitches so a load freeze is attributable from the log alone (one big stall vs many
|
||||
// medium ones, and roughly when). The first frame's delta is the startup gap, not a hitch.
|
||||
if wall_ms > HITCH_LOG_MS && stats.wall.len() > 1 {
|
||||
@@ -525,248 +130,35 @@ pub(super) fn sample_frame_time(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Drive the meters directly, the way a run would. Times are seconds; each tuple is one frame
|
||||
/// as `(wall_ms, cpu_ms, main_ms)`.
|
||||
fn feed(stats: &mut FrameStats, frames: &[(f32, f32, f32)], start_t: f32, dt: f32) -> f32 {
|
||||
let mut t = start_t;
|
||||
for &(wall, cpu, main) in frames {
|
||||
stats.wall.push(wall);
|
||||
stats.cpu.push(cpu);
|
||||
stats.main.push(main);
|
||||
stats.refresh_baselines(t);
|
||||
if stats.wall.len() > 1 && stats.baselines.wall > 0.0 {
|
||||
stats.observe(t);
|
||||
}
|
||||
t += dt;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// A settled 120 Hz scene must latch nothing at all — the instrument's own quiet frame.
|
||||
/// The window is capped and the means are windowed: 300 quiet frames after 100 loud ones
|
||||
/// must read quiet, and fps must be the reciprocal of the windowed wall mean.
|
||||
#[test]
|
||||
fn a_settled_scene_latches_no_spike() {
|
||||
fn the_windows_roll_and_the_means_are_windowed() {
|
||||
let mut s = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400)
|
||||
.map(|i| (8.3 + (i % 3) as f32 * 0.1, 15.0, 4.0))
|
||||
.collect();
|
||||
let t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
|
||||
let loud: Vec<_> = (0..100).map(|_| (33.2, 30.0)).collect();
|
||||
let calm: Vec<_> = (0..SAMPLE_WINDOW).map(|_| (16.6, 8.0)).collect();
|
||||
let mut t = s.feed_frames(&loud, 0.0, 1.0 / 30.0);
|
||||
t = s.feed_frames(&calm, t, 1.0 / 60.0);
|
||||
let _ = t;
|
||||
|
||||
assert_eq!(s.wall.len(), SAMPLE_WINDOW, "the window is capped");
|
||||
let cpu = s.cpu.mean().expect("cpu has samples");
|
||||
assert!(
|
||||
s.spike(t).is_none(),
|
||||
"a flat scene must not read as a spike"
|
||||
);
|
||||
}
|
||||
|
||||
/// The headline case: cost doubles on the main thread while the display keeps granting 120,
|
||||
/// so fps does not move at all. The pill must still catch it.
|
||||
#[test]
|
||||
fn a_main_thread_burst_latches_while_fps_never_moves() {
|
||||
let mut s = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
|
||||
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
|
||||
let fps_before = s.fps();
|
||||
|
||||
// Six consecutive frames of main-thread cost — 0610's burst shape — with the display
|
||||
// still granting its interval, exactly the case the old pill could not see.
|
||||
let burst: Vec<_> = (0..6).map(|_| (8.3, 15.0, 12.0)).collect();
|
||||
t = feed(&mut s, &burst, t, 1.0 / 120.0);
|
||||
t = feed(&mut s, &[(8.3, 15.0, 4.0)], t, 1.0 / 120.0);
|
||||
|
||||
let spike = s
|
||||
.spike(t)
|
||||
.expect("a six-frame main-thread burst must latch");
|
||||
assert_eq!(spike.kind, SpikeKind::Main);
|
||||
assert_eq!(
|
||||
spike.frames, 6,
|
||||
"the badge reports the burst, not one frame"
|
||||
);
|
||||
assert!((spike.peak_ms - 12.0).abs() < 0.01);
|
||||
// The control: the pill's dim fps is still, by construction, unmoved.
|
||||
assert!(
|
||||
(s.fps() - fps_before).abs() < 1.0,
|
||||
"fps must be blind here — that is the whole reason the latch exists"
|
||||
);
|
||||
}
|
||||
|
||||
/// A long frame with both CPU meters flat is a stall, not our work — and must be named as one.
|
||||
#[test]
|
||||
fn a_long_frame_with_flat_cpu_reads_as_stalled_not_busy() {
|
||||
let mut s = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
|
||||
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
|
||||
t = feed(&mut s, &[(40.0, 15.0, 4.0)], t, 1.0 / 120.0);
|
||||
t = feed(&mut s, &[(8.3, 15.0, 4.0)], t, 1.0 / 120.0);
|
||||
|
||||
let spike = s.spike(t).expect("a 40 ms frame must latch");
|
||||
assert_eq!(spike.kind, SpikeKind::Stalled);
|
||||
}
|
||||
|
||||
/// Process CPU jumping while the main thread stays flat is a worker burst, off the critical
|
||||
/// path — the distinction the old all-threads-only meter could not draw.
|
||||
#[test]
|
||||
fn a_worker_burst_is_not_blamed_on_the_main_thread() {
|
||||
let mut s = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
|
||||
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
|
||||
let burst: Vec<_> = (0..WORKER_BURST_FRAMES).map(|_| (8.3, 60.0, 4.0)).collect();
|
||||
t = feed(&mut s, &burst, t, 1.0 / 120.0);
|
||||
t = feed(&mut s, &[(8.3, 15.0, 4.0)], t, 1.0 / 120.0);
|
||||
|
||||
let spike = s.spike(t).expect("a worker burst must latch");
|
||||
assert_eq!(spike.kind, SpikeKind::Worker);
|
||||
}
|
||||
|
||||
/// The noise floor the badge kept tripping on: `cpu` sums every thread, so one frame where
|
||||
/// several pool workers overlap trips the ratio test while costing the frame nothing. It must
|
||||
/// not reach the pill — nor the tooltip's burst count, or the two would disagree.
|
||||
#[test]
|
||||
fn a_one_frame_worker_blip_does_not_latch() {
|
||||
let mut s = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
|
||||
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
|
||||
t = feed(&mut s, &[(8.3, 60.0, 4.0)], t, 1.0 / 120.0);
|
||||
t = feed(&mut s, &[(8.3, 15.0, 4.0)], t, 1.0 / 120.0);
|
||||
|
||||
assert!(
|
||||
s.spike(t).is_none(),
|
||||
"a single-frame cross-thread CPU blip is the scheduler, not a regression"
|
||||
);
|
||||
assert_eq!(s.recent_bursts(t), 0, "and it is not counted as a burst");
|
||||
}
|
||||
|
||||
/// The gate is on `Worker` alone: a one-frame main-thread spike *is* the stutter and must
|
||||
/// still latch immediately.
|
||||
#[test]
|
||||
fn a_one_frame_main_spike_is_not_gated() {
|
||||
let mut s = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
|
||||
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
|
||||
t = feed(&mut s, &[(8.3, 15.0, 30.0)], t, 1.0 / 120.0);
|
||||
t = feed(&mut s, &[(8.3, 15.0, 4.0)], t, 1.0 / 120.0);
|
||||
|
||||
let spike = s.spike(t).expect("one long main-thread frame must latch");
|
||||
assert_eq!(spike.kind, SpikeKind::Main);
|
||||
assert_eq!(spike.frames, 1);
|
||||
}
|
||||
|
||||
/// The 120 Hz calibration bug, pinned. A missed interval on a ProMotion panel is 16.7 ms; the
|
||||
/// old fixed 25 ms threshold (1.5 × an assumed 60 Hz budget) could not see one.
|
||||
#[test]
|
||||
fn a_missed_interval_is_counted_at_120hz() {
|
||||
let mut s = FrameStats::default();
|
||||
let mut frames: Vec<_> = (0..300).map(|_| (8.3, 15.0, 4.0)).collect();
|
||||
frames[150] = (16.7, 15.0, 4.0);
|
||||
let _ = feed(&mut s, &frames, 0.0, 1.0 / 120.0);
|
||||
|
||||
assert!(
|
||||
(s.rail_ms() - 8.3).abs() < 0.2,
|
||||
"the rail must be derived from the panel we are on, not assumed at 60 Hz"
|
||||
);
|
||||
let (n, _) = s.wall.frames_over(s.dropped_above_ms());
|
||||
assert_eq!(
|
||||
n, 1,
|
||||
"the doubled frame is a missed interval and must count"
|
||||
);
|
||||
// What the old rule would have said, kept as the contrast this test exists to prevent.
|
||||
let (old_n, _) = s
|
||||
.wall
|
||||
.frames_over(super::super::FRAME_BUDGET_MS * DROPPED_FACTOR);
|
||||
assert_eq!(old_n, 0, "the assumed-60 Hz threshold is blind to it");
|
||||
}
|
||||
|
||||
/// The sparkline's y-scale must survive the launch spike. A loading second costs ~110 ms
|
||||
/// against a settled ~8.5, and scaling to the window's MAX pressed a whole minute of real
|
||||
/// signal into the bottom 7 % of the box — the lane read as a flat line on the director's
|
||||
/// screen. p90 drops the outlier and keeps the shape.
|
||||
#[test]
|
||||
fn one_loading_second_does_not_flatten_the_trend_lane() {
|
||||
let mut s = FrameStats::default();
|
||||
let mut t = 0.0;
|
||||
// A loading second, then a settled minute — one trend sample per second, so step the
|
||||
// clock a full second between feeds.
|
||||
t = feed(&mut s, &[(111.0, 111.0, 90.0)], t, 1.0);
|
||||
for _ in 0..40 {
|
||||
t = feed(&mut s, &[(16.6, 8.5, 1.7)], t, 1.0);
|
||||
}
|
||||
assert!(
|
||||
s.trend.iter().any(|v| v > 100.0),
|
||||
"the loading sample is still IN the window — this is about the scale, not a filter"
|
||||
(cpu - 8.0).abs() < 0.01,
|
||||
"the loud prefix must have rolled out, got {cpu}"
|
||||
);
|
||||
assert!(
|
||||
s.trend_hi() < 20.0,
|
||||
"p90 must ignore the launch outlier, got {}",
|
||||
s.trend_hi()
|
||||
);
|
||||
// The settled level has to land in a readable part of the box, not squashed at the floor.
|
||||
assert!(
|
||||
8.5 / (s.trend_hi() * 1.15) > 0.5,
|
||||
"the settled level must occupy the upper half of the lane"
|
||||
(s.fps() - 1000.0 / 16.6).abs() < 0.5,
|
||||
"fps is the windowed wall mean's reciprocal, got {}",
|
||||
s.fps()
|
||||
);
|
||||
}
|
||||
|
||||
/// The latch must keep the worst burst in the window, not the most recent — a small wobble
|
||||
/// after a big spike must not bury it.
|
||||
/// An empty cpu window is "no data", not "zero cost" — the pill prints `--` for it.
|
||||
#[test]
|
||||
fn the_latch_keeps_the_worst_burst_not_the_last() {
|
||||
let mut s = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
|
||||
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
|
||||
t = feed(
|
||||
&mut s,
|
||||
&[(8.3, 15.0, 30.0), (8.3, 15.0, 4.0)],
|
||||
t,
|
||||
1.0 / 120.0,
|
||||
);
|
||||
t = feed(
|
||||
&mut s,
|
||||
&[(8.3, 15.0, 11.0), (8.3, 15.0, 4.0)],
|
||||
t,
|
||||
1.0 / 120.0,
|
||||
);
|
||||
|
||||
let spike = s.spike(t).expect("still inside the hold window");
|
||||
assert!(
|
||||
(spike.peak_ms - 30.0).abs() < 0.01,
|
||||
"the 30 ms peak must survive the 11 ms one"
|
||||
);
|
||||
assert_eq!(
|
||||
s.recent_bursts(t),
|
||||
2,
|
||||
"both bursts are counted even though one is shown"
|
||||
);
|
||||
}
|
||||
|
||||
/// And it must let go: a spike outside the hold window is over.
|
||||
#[test]
|
||||
fn the_latch_expires() {
|
||||
let mut s = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 4.0)).collect();
|
||||
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
|
||||
t = feed(
|
||||
&mut s,
|
||||
&[(8.3, 15.0, 30.0), (8.3, 15.0, 4.0)],
|
||||
t,
|
||||
1.0 / 120.0,
|
||||
);
|
||||
assert!(s.spike(t).is_some());
|
||||
assert!(s.spike(t + LATCH_HOLD_SECS + 0.1).is_none());
|
||||
}
|
||||
|
||||
/// The floor exists so a sub-millisecond series cannot manufacture spikes out of its own
|
||||
/// noise: 2× of nearly nothing is still nearly nothing.
|
||||
#[test]
|
||||
fn a_tiny_baseline_does_not_manufacture_spikes() {
|
||||
let mut s = FrameStats::default();
|
||||
let calm: Vec<_> = (0..400).map(|_| (8.3, 15.0, 0.4)).collect();
|
||||
let mut t = feed(&mut s, &calm, 0.0, 1.0 / 120.0);
|
||||
t = feed(
|
||||
&mut s,
|
||||
&[(8.3, 15.0, 0.9), (8.3, 15.0, 0.4)],
|
||||
t,
|
||||
1.0 / 120.0,
|
||||
);
|
||||
assert!(
|
||||
s.spike(t).is_none(),
|
||||
"0.4 -> 0.9 ms is a 2x ratio and still noise; the floor must hold it"
|
||||
);
|
||||
fn no_data_is_not_zero_cost() {
|
||||
let s = FrameStats::default();
|
||||
assert!(s.cpu.mean().is_none());
|
||||
assert_eq!(s.fps(), 0.0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::{self, Read};
|
||||
|
||||
use crate::wire::{read_u32_le, read_u8, Vector3d};
|
||||
@@ -503,11 +502,15 @@ pub struct PlayerSkillSlot {
|
||||
pub perm_bonus: i16,
|
||||
}
|
||||
|
||||
/// A sparse update-field set: a bitmask of `u32` blocks followed by one `u32` per set bit. We keep the
|
||||
/// values in an index→value map and expose **named** accessors for the descriptor fields consumers read
|
||||
/// — the raw `get_*(index)` reads stay module-private, so field-index knowledge (a wire fact) never
|
||||
/// leaves this crate (decision 0061). The ECS mirrors one of these per object and [`Self::merge`]s each
|
||||
/// `Values` delta into it; the codec itself stays stateless (decision 0006).
|
||||
/// A sparse update-field set: a bitmask of `u32` blocks followed by one `u32` per set bit. We keep
|
||||
/// the wire's own mask plus a dense value array — not a map — because the ECS-side pollers
|
||||
/// (animation, equipment, plates) probe thousands of fields **per frame**, so a read must be two
|
||||
/// array indexings, never a tree walk (decision 1457); the wire's `u8` block count bounds the
|
||||
/// arrays at 8160 dwords, and a Unit's real footprint is ~800 B. We expose **named** accessors for
|
||||
/// the descriptor fields consumers read — the raw `get_*(index)` reads stay module-private, so
|
||||
/// field-index knowledge (a wire fact) never leaves this crate (decision 0061). The ECS mirrors
|
||||
/// one of these per object and [`Self::merge`]s each `Values` delta into it; the codec itself
|
||||
/// stays stateless (decision 0006).
|
||||
///
|
||||
/// **Absent-field semantics** hinge on [`Self::descriptor_end`]: a CREATE block is a *complete*
|
||||
/// snapshot — the server masks in only non-zero fields (vmangos `Object::_SetCreateBits`: bit set
|
||||
@@ -527,7 +530,13 @@ pub struct PlayerSkillSlot {
|
||||
/// unstreamed default `1.0` and the tooltip read `inf - inf` / `nan` (director, 2026-08-07).
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ObjectFields {
|
||||
fields: BTreeMap<u16, u32>,
|
||||
/// Presence bitmask, one bit per field index (`index = word * 32 + bit`) — the wire's block
|
||||
/// mask, kept verbatim.
|
||||
present: Vec<u32>,
|
||||
/// Dense value array, invariant `values.len() == present.len() * 32`; a slot is meaningful
|
||||
/// iff its `present` bit is set (unset slots hold `0` but must be read through the mask —
|
||||
/// absent-vs-zero is exactly the distinction the struct doc is about).
|
||||
values: Vec<u32>,
|
||||
/// How long this object's descriptor is, in dwords — [`descriptor_len`] of the CREATE block's
|
||||
/// own `ObjectType` (set by the parser via [`Self::into_created`]). Every index **below** it is
|
||||
/// known: absent = `0`. `0` marks a bare `Values` delta (or a hand-built fixture), where no
|
||||
@@ -558,20 +567,21 @@ fn descriptor_len(object_type: ObjectType) -> u16 {
|
||||
impl ObjectFields {
|
||||
pub(super) fn read(r: &mut impl Read) -> io::Result<Self> {
|
||||
let amount_of_blocks = read_u8(r)?;
|
||||
let mut blocks = Vec::with_capacity(amount_of_blocks as usize);
|
||||
let mut present = Vec::with_capacity(amount_of_blocks as usize);
|
||||
for _ in 0..amount_of_blocks {
|
||||
blocks.push(read_u32_le(r)?);
|
||||
present.push(read_u32_le(r)?);
|
||||
}
|
||||
let mut fields = BTreeMap::new();
|
||||
for (index, block) in blocks.iter().enumerate() {
|
||||
for bit in 0..32u16 {
|
||||
if block & (1 << bit) != 0 {
|
||||
fields.insert(index as u16 * 32 + bit, read_u32_le(r)?);
|
||||
let mut values = vec![0u32; present.len() * 32];
|
||||
for (word, block) in present.iter().enumerate() {
|
||||
for bit in 0..32 {
|
||||
if block & (1u32 << bit) != 0 {
|
||||
values[word * 32 + bit] = read_u32_le(r)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Self {
|
||||
fields,
|
||||
present,
|
||||
values,
|
||||
descriptor_end: 0,
|
||||
})
|
||||
}
|
||||
@@ -590,10 +600,11 @@ impl ObjectFields {
|
||||
/// [`Self::read`]; live code never builds fields by hand. Chain [`Self::into_created`] when
|
||||
/// the fixture stands in for a create snapshot.
|
||||
pub fn from_pairs(pairs: &[(u16, u32)]) -> Self {
|
||||
Self {
|
||||
fields: pairs.iter().copied().collect(),
|
||||
descriptor_end: 0,
|
||||
let mut this = Self::default();
|
||||
for &(index, value) in pairs {
|
||||
this.insert(index, value);
|
||||
}
|
||||
this
|
||||
}
|
||||
|
||||
/// A 2-field guid: `lo | hi << 32`, present iff the low half is (the wire always sends guid
|
||||
@@ -639,19 +650,41 @@ impl ObjectFields {
|
||||
))
|
||||
}
|
||||
|
||||
/// Whether the wire genuinely carried this field (mask bit set) — the raw presence probe
|
||||
/// under every accessor, bypassing the created-store absent-is-zero fold.
|
||||
fn contains(&self, index: u16) -> bool {
|
||||
self.present
|
||||
.get(usize::from(index / 32))
|
||||
.is_some_and(|w| w & (1u32 << (index % 32)) != 0)
|
||||
}
|
||||
|
||||
/// The carried value, `None` when the mask bit is unset — [`Self::contains`]'s value twin.
|
||||
fn get_raw(&self, index: u16) -> Option<u32> {
|
||||
self.contains(index)
|
||||
.then(|| self.values[usize::from(index)])
|
||||
}
|
||||
|
||||
fn insert(&mut self, index: u16, value: u32) {
|
||||
let word = usize::from(index / 32);
|
||||
if word >= self.present.len() {
|
||||
self.present.resize(word + 1, 0);
|
||||
self.values.resize(self.present.len() * 32, 0);
|
||||
}
|
||||
self.present[word] |= 1u32 << (index % 32);
|
||||
self.values[usize::from(index)] = value;
|
||||
}
|
||||
|
||||
fn get_guid(&self, index: u16) -> Option<u64> {
|
||||
// Raw map read on purpose: a guid slot stays "present iff the low half is" even on a
|
||||
// Raw read on purpose: a guid slot stays "present iff the low half is" even on a
|
||||
// created store — every consumer already treats a sent-empty `Some(0)` as no-guid, so the
|
||||
// absent-is-zero fallback would only blur the two identical cases.
|
||||
let lo = self.fields.get(&index).copied()?;
|
||||
let lo = self.get_raw(index)?;
|
||||
let hi = self.get_u32(index + 1).unwrap_or(0);
|
||||
Some(u64::from(lo) | (u64::from(hi) << 32))
|
||||
}
|
||||
|
||||
fn get_u32(&self, index: u16) -> Option<u32> {
|
||||
self.fields
|
||||
.get(&index)
|
||||
.copied()
|
||||
self.get_raw(index)
|
||||
.or((index < self.descriptor_end).then_some(0))
|
||||
}
|
||||
fn get_f32(&self, index: u16) -> Option<f32> {
|
||||
@@ -693,18 +726,27 @@ impl ObjectFields {
|
||||
if delta.descriptor_end != 0 {
|
||||
*self = delta;
|
||||
} else {
|
||||
self.fields.extend(delta.fields);
|
||||
for (index, value) in delta.raw_fields() {
|
||||
self.insert(index, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether this carried no fields — the codec skips emitting an empty `Values` delta.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.fields.is_empty()
|
||||
self.present.iter().all(|&w| w == 0)
|
||||
}
|
||||
/// Every present `(index, value)` pair — the debug/probe affordance for descriptor
|
||||
/// archaeology (named accessors stay the consumer surface; decision 0061).
|
||||
/// Every present `(index, value)` pair in ascending index order — the debug/probe affordance
|
||||
/// for descriptor archaeology (named accessors stay the consumer surface; decision 0061).
|
||||
pub fn raw_fields(&self) -> impl Iterator<Item = (u16, u32)> + '_ {
|
||||
self.fields.iter().map(|(&i, &v)| (i, v))
|
||||
self.present.iter().enumerate().flat_map(move |(word, &w)| {
|
||||
(0..32u16)
|
||||
.filter(move |bit| w & (1u32 << bit) != 0)
|
||||
.map(move |bit| {
|
||||
let index = word as u16 * 32 + bit;
|
||||
(index, self.values[usize::from(index)])
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -585,8 +585,7 @@ impl ObjectFields {
|
||||
/// (`x = y = 0`, the live-data norm) round-trips exactly. Consumed by the type-11 transport
|
||||
/// evaluator (elevators/lifts rotate their keyframe offsets through this).
|
||||
pub fn gameobject_rotation(&self) -> Option<[f32; 4]> {
|
||||
let any_sent =
|
||||
(0..4u16).any(|i| self.fields.contains_key(&(FIELD_GAMEOBJECT_ROTATION + i)));
|
||||
let any_sent = (0..4u16).any(|i| self.contains(FIELD_GAMEOBJECT_ROTATION + i));
|
||||
any_sent.then(|| {
|
||||
[
|
||||
self.get_f32(FIELD_GAMEOBJECT_ROTATION).unwrap_or(0.0),
|
||||
@@ -612,15 +611,15 @@ impl ObjectFields {
|
||||
self.get_f32(FIELD_GAMEOBJECT_FACING).unwrap_or(0.0),
|
||||
))
|
||||
}
|
||||
/// Whether the wire genuinely carried **any** `GAMEOBJECT_POS_*` field — a raw map read on purpose
|
||||
/// Whether the wire genuinely carried **any** `GAMEOBJECT_POS_*` field — a raw presence probe on purpose
|
||||
/// (like `get_guid`'s, bypassing the create block's absent-is-zero fold) so a consumer can tell
|
||||
/// "this GO's create never carried a position" from "it did, and it's `0.0` on some axis". A normal
|
||||
/// GameObject sends at least one non-zero axis virtually always; a boat/zeppelin/elevator sends
|
||||
/// **none** (vmangos never sets `GAMEOBJECT_POS_*` on a transport).
|
||||
pub fn gameobject_pos_sent(&self) -> bool {
|
||||
self.fields.contains_key(&FIELD_GAMEOBJECT_POS_X)
|
||||
|| self.fields.contains_key(&FIELD_GAMEOBJECT_POS_Y)
|
||||
|| self.fields.contains_key(&FIELD_GAMEOBJECT_POS_Z)
|
||||
self.contains(FIELD_GAMEOBJECT_POS_X)
|
||||
|| self.contains(FIELD_GAMEOBJECT_POS_Y)
|
||||
|| self.contains(FIELD_GAMEOBJECT_POS_Z)
|
||||
}
|
||||
/// `GAMEOBJECT_TYPE_ID` — the GameObject sub-type (DOOR/CHEST/SPELL_FOCUS/…). **Absent ⇒ `0` =
|
||||
/// DOOR**, not "unknown": vmangos omits a zero-valued field from the create mask (the same reason an
|
||||
|
||||
Reference in New Issue
Block a user