diff --git a/crates/benilla-app/assets/ui/PetActionBar.xml b/crates/benilla-app/assets/ui/PetActionBar.xml
index b5561265..baab8ebf 100644
--- a/crates/benilla-app/assets/ui/PetActionBar.xml
+++ b/crates/benilla-app/assets/ui/PetActionBar.xml
@@ -437,7 +437,10 @@
-
+
+
diff --git a/crates/benilla-app/assets/ui/SpellBookFrame.xml b/crates/benilla-app/assets/ui/SpellBookFrame.xml
index 0d367392..d7642b29 100644
--- a/crates/benilla-app/assets/ui/SpellBookFrame.xml
+++ b/crates/benilla-app/assets/ui/SpellBookFrame.xml
@@ -637,15 +637,43 @@
-
+
-
-
+
+
+
+
+
+
+
-
diff --git a/crates/benilla-app/src/autocast_shine.rs b/crates/benilla-app/src/autocast_shine.rs
index 523a1e74..7c7d9ffb 100644
--- a/crates/benilla-app/src/autocast_shine.rs
+++ b/crates/benilla-app/src/autocast_shine.rs
@@ -38,14 +38,25 @@
//! MODEL UNITS → FrameXML units is the widget's own projection, not a calibration (wow-re
//! `system/ui/scratch/modelframe-animation-clock.md`, decision 1321's fold-back): the ortho leg
//! collapses to **1280 × modelScale × layoutScale** FrameXML units per model unit, independent of
-//! aspect and resolution; `scale="1.2"` on the ref's `` is SetModelScale, so the 0.02 path
-//! is **30.72 units** across a 30-unit button — 1.024×, overhanging its top and right by 2.4% —
-//! and the model ORIGIN is the frame rect's BOTTOM-LEFT, which is why a square authored 0..0.02
-//! lands across the whole button instead of in one quadrant.
+//! aspect and resolution; `scale=` on the ref's `` is SetModelScale, and the model ORIGIN
+//! is the frame rect's BOTTOM-LEFT, which is why a square authored 0..0.02 lands across the whole
+//! rect instead of in one quadrant.
//!
-//! **The stars on that path do NOT take the same projection** ([`STAR`], decision 1386): their
-//! half-extent is added after the transform, in 1280-unit space with no `modelScale`, so the
-//! square is scaled by 1.2 and the stars standing on it are not.
+//! **The ``'s rect and scale are per-TEMPLATE, and the reference does not repeat itself**
+//! (decision 1391) — which is why they ride the [`ShineSite`] and not a constant:
+//!
+//! - **pet button** — `setAllPoints` on 30×30 at `scale="1.2"`: the path is **30.72 units**,
+//! 1.024× the button, overhanging its top and right by 2.4% and clipped there.
+//! - **spell book button** — its own `36×36 ` at `CENTER (1,1)` on a 37×37 button, at
+//! `scale="1.22"`: the path is **31.232 units** inside a 36-unit rect that itself sits 1.5 units
+//! in from the button's bottom-left.
+//!
+//! **The stars on that path do NOT take the same projection** ([`STAR`], decisions 1386/1390):
+//! their half-extent is an EYE-SPACE length added after the transform, so it misses `modelScale`
+//! — the square is scaled by 1.2 and the stars standing on it are not — and, unlike the square,
+//! it keeps the ortho leg's aspect term: `2 · half · √(W² + H²)` **window px**, free of UI scale
+//! and frame scale. 12.80 px at 1024×768; 14.69 at 1280×720. 1386 froze the 4:3 case into a
+//! constant, which is why our 16:9 stars were 22 % small until 1390.
//!
//! **And the widget is its own scissor** ([`site_quads`], decision 1387): a `` installs its
//! frame rect as the VIEWPORT for the duration of its draw and restores it after, so the model
@@ -55,8 +66,9 @@
//!
//! **We draw the authored population, not a sample of it** (decision 1386). `rate × life` is
//! 300 live particles per emitter, 1200 for the model, and that number IS the look: the bone
-//! lays one down every 0.205 units of rim while a newborn star is 12.8 units across, so ~62 of
-//! them pile onto any point of the band and ADD saturates it into one continuous flaming rim —
+//! lays one down every 0.205 units of rim while a newborn star is 12.8 units across at 4:3 (more
+//! on a widescreen — [`STAR`]), so ~62 of them pile onto any point of the band and ADD saturates
+//! it into one continuous flaming rim —
//! unevenly by channel (the ramp is gold, so R and G clip where B does not, which is why it
//! reads gold and not white), and what travels is the brightness envelope, not any one star.
//! 1383 shipped a 23-sample stand-in for that population, chosen to keep the *coverage*
@@ -80,28 +92,69 @@ use bevy::prelude::*;
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads};
-/// The script layer's registration token: a `Texture` region with this path converts to **no
-/// quads** — the conversion records a [`ShineSite`] at its rect/z instead, and [`emit_shine`]
-/// draws there. Ours, not the install's — the string never reaches the asset resolver.
+/// The script layer's registration token: a `Texture` region whose path starts with this converts
+/// to **no quads** — the conversion records a [`ShineSite`] at its rect/z instead, and
+/// [`emit_shine`] draws there. Ours, not the install's — the string never reaches the resolver.
+///
+/// An optional `:` suffix carries the ``'s **`scale=`** ([`token_model_scale`]), which
+/// is `SetModelScale` and is not the same on every button that wears this shine (1391).
pub(crate) const SHINE_TOKEN: &str = "benilla:autocast-shine";
+/// Read a token's model scale, or `None` if `path` is not a shine token at all — the single
+/// definition of what the token's grammar is, shared by the conversion (which records the site)
+/// and the shipped-XML sweep (which must exempt exactly this and nothing that merely resembles
+/// it, so a typo still fails the archive check).
+pub(crate) fn token_model_scale(path: &str) -> Option {
+ match path.strip_prefix(SHINE_TOKEN)? {
+ "" => Some(1.2),
+ rest => rest.strip_prefix(':')?.parse().ok(),
+ }
+}
+
/// The spark art — the M2's own emitter texture, resolved once per site at conversion time
/// through the same resolver every UI texture uses.
pub(crate) const STAR_TEXTURE: &str = "Interface\\Buttons\\GlowStar";
-/// The rim square's side in FrameXML units: 0.02 model units × 1280 × the ref Model's 1.2.
-const SIZE: f32 = 0.02 * 1280.0 * 1.2;
-/// The size ramp's FULL widths in FrameXML units — **and NOT projected like [`SIZE`]**
-/// (decision 1386, wow-re `modelframe-particle-density.md`, correcting 1321's C2).
+/// FrameXML units per model unit — `768 · 5/3`, **aspect- and resolution-independent** (wow-re
+/// `modelframe-clip-and-scale.md` C2, confirmed exactly as 1321 recorded it): 1 model unit is
+/// *not* 1 layout unit, because `0x76d1a0` always folds in `G48 · 5/3` (`76d1c6 0x41ae70(1.0)` =
+/// G48 · `76d1cb fmul [0x80655c]` = f32(5/3)), and that `G48` cancels the `768·√(a²+1)` in the
+/// FrameXML-unit definition. The star's half-extent ([`STAR`]) escapes that fold, which is the
+/// whole reason the two constants no longer live in the same space.
+const PER_MODEL_UNIT: f32 = 1280.0;
+
+/// The rim square's side in FrameXML units at a given `SetModelScale`: the M2's authored
+/// 0.02-unit bone square through [`PER_MODEL_UNIT`]. **30.72 at the pet bar's `scale="1.2"`,
+/// 31.23 at the spell book's `scale="1.22"`** — the ref writes a different number on each
+/// template and 1391 stopped averaging them into one constant.
+fn side(model_scale: f32) -> f32 {
+ 0.02 * PER_MODEL_UNIT * model_scale
+}
+/// The size ramp's authored **half-extents, in MODEL units** (`file+0x104`) — deliberately not
+/// pre-projected like [`side`], because they do not take [`side`]'s projection (decision 1390;
+/// wow-re `modelframe-clip-and-scale.md` C3, which corrects the FrameXML-unit constant 1386 wrote
+/// here and confirms everything else in that record).
///
-/// A particle quad is not built in model space and transformed with the model: `0x7b2a50` adds a
-/// bare `±half` from the `±1.0` corner table to the ALREADY-TRANSFORMED position, and file flag
-/// `+0x04` bit `0x200` gates off the one multiply by the emitter scale (`0x7b2bab fmul
-/// [edi+0x264]`, which holds a live and correct 1.2 that is never read). So the authored
-/// half-extent lands in POST-transform units — `768·√(a²+1)` = 1280 FrameXML units at 4:3 — and
-/// the ref Model's `scale="1.2"` reaches the bone square but **not** the stars on it. Widths are
-/// `{0.005, 0.0015, 0.001} × 2 × 1280` = 12.8 → 3.84 → 2.56.
-const STAR: [f32; 3] = [0.010 * 1280.0, 0.003 * 1280.0, 0.002 * 1280.0];
+/// A particle quad is not built in model space and transformed with the model: `0x7b3d20`
+/// installs an **identity view** for the whole of the emitter's vertex generation (`7b3e41` save
+/// / `7b3e46` install / restored at the tail), so the `±half` that `0x7b2a50` adds from the
+/// `±1.0` corner table to the already-transformed position is an **eye-space length**; and file
+/// flag `+0x04` bit `0x200` gates off the one multiply by the emitter scale (`0x7b2ba6 je`, taken
+/// because `0x7b2ba3 test ah,0x2` finds the bit clear — the two *ungated* readers of
+/// `[emitter+0x264]` are the bounding sphere). So `SetModelScale(1.2)` reaches the bone square
+/// but **not** the stars standing on it.
+///
+/// The consequence, and the reason this is a model-unit constant now: an eye-space length is
+/// projected by the ortho leg, which maps the frame's *diagonal-normalized* layout rect to NDC
+/// ±1 — so a star's size is **aspect-dependent where the path's is not**.
+///
+/// > full width = `2 · half · 768·√(a²+1)` FrameXML units = **`2 · half · √(W² + H²)` window px**
+///
+/// The pixel form is the one [`site_quads`] uses: no UI scale, no frame scale, just the screen
+/// diagonal. That is 12.80 px at 1024×768 — the number 1386 froze into a constant — but **14.69
+/// px at 1280×720**, 22 % larger against a button 4 % *smaller*. The reference's shine really is
+/// fatter on a widescreen; the 1280 that 1321 folded back is the 4:3 case of a law, not the law.
+const STAR: [f32; 3] = [0.005, 0.0015, 0.001];
/// The in-plane billboard spin, radians per second of age (`file+0x198`, `angle = spin · age`,
/// closed form at `0x7b2ddc`) — 0.1 rad = 5.73° over a particle's whole life. The per-particle
/// sign flip that `0x7b2dea` would apply is gated by runtime bit `0x8000` ← file `+0x04` bit
@@ -151,16 +204,16 @@ fn ramp_color(age: f32) -> [f32; 4] {
/// Where an emitter sits at lap fraction `f` (wrapped into 0..1), as a FrameXML-unit offset from
/// the button's BOTTOM-LEFT (the model's own origin — module doc). The bones walk the square
/// clockwise on screen from that corner, one edge per quarter-lap.
-fn point(f: f32) -> (f32, f32) {
+fn point(f: f32, side: f32) -> (f32, f32) {
let f = f - f.floor();
let edge = f * 4.0;
let leg = edge.floor();
let t = edge - leg;
match leg as u32 {
- 0 => (0.0, t * SIZE), // bottom-left → top-left
- 1 => (t * SIZE, SIZE), // top-left → top-right
- 2 => (SIZE, SIZE - t * SIZE), // top-right → bottom-right
- _ => (SIZE - t * SIZE, 0.0), // bottom-right → bottom-left
+ 0 => (0.0, t * side), // bottom-left → top-left
+ 1 => (t * side, side), // top-left → top-right
+ 2 => (side, side - t * side), // top-right → bottom-right
+ _ => (side - t * side, 0.0), // bottom-right → bottom-left
}
}
@@ -185,12 +238,13 @@ fn live_per_emitter() -> usize {
*N
}
-/// A multiplier on the authored star width — the second axis of the same open question
-/// (`WOW_SHINE_SIZE=`, default 1.0). The band's saturated THICKNESS is set by the quad width
-/// and its brightness by width × count, so the two trade off and neither can be read off a
-/// capture alone; this exists to bracket them against the director's reference shots while
-/// wow-re settles which projection a particle quad actually takes (claim C2 of the dispatch —
-/// whether the authored 0.005 is a half-extent, and whether `SetModelScale` multiplies it).
+/// A multiplier on the authored star width (`WOW_SHINE_SIZE=`, default 1.0) — an A/B
+/// instrument, not a pending question: the projection it used to bracket is settled (1390).
+///
+/// The band's saturated THICKNESS is set by the quad width and its brightness by width × count,
+/// so the two trade off and neither can be read off a capture alone — which is exactly why this
+/// stays. `WOW_SHINE_SIZE=0.817` reproduces the pre-1390 4:3 sizing on a 16:9 window, so a
+/// reference shot captured at 4:3 can be compared against ours without rebuilding.
/// **Never a tuning knob to leave dialled**: the shipped value is the one the bytes say.
fn size_scale() -> f32 {
static S: std::sync::LazyLock = std::sync::LazyLock::new(|| {
@@ -203,15 +257,16 @@ fn size_scale() -> f32 {
}
/// The live population, computed once: one entry per particle, oldest last, carrying everything
-/// its FIXED age fixes — its age, its width, and its colour+alpha. Only the position moves per
+/// its FIXED age fixes — its age, its **model-unit half-extent** ([`STAR`]; the site's screen
+/// diagonal turns it into pixels at emit), and its colour+alpha. Only the position moves per
/// frame, which is what keeps a 1200-quad emitter cheap.
///
/// The ages are the steady state of a `rate`/s emitter: one particle born every `1/rate` seconds,
/// none older than `life`. **Our one departure from the reference's own timeline**: it spawns in
/// per-frame batches (≈5 coincident particles per frame at 60 fps, at whatever position the bone
/// held that frame), where this spreads the same population evenly along the path. At the
-/// authored 15.4-unit star width against a 0.2-unit spawn pitch the two are the same picture;
-/// unlike a sample count, it costs nothing to revisit, because the population is the file's.
+/// authored star width against a 0.2-unit spawn pitch the two are the same picture; unlike a
+/// sample count, it costs nothing to revisit, because the population is the file's.
static PARTICLES: std::sync::LazyLock> = std::sync::LazyLock::new(|| {
let size_scale = size_scale();
(0..live_per_emitter())
@@ -239,8 +294,16 @@ pub(crate) struct ShineSite {
/// The token's effective UI alpha (parent chain folded), multiplied into every spark.
pub(crate) alpha: f32,
/// FrameXML-units → window-pixels factor at the conversion that recorded this site (the
- /// extract's `s`); a resize re-extracts and re-records.
+ /// extract's `s`); a resize re-extracts and re-records. Scales the bone PATH ([`side`]).
pub(crate) scale: f32,
+ /// The ``'s `SetModelScale`, read off the token ([`token_model_scale`]) — the ref
+ /// writes `1.2` on the pet button and `1.22` on the spell book's, so it belongs to the SITE
+ /// and not to a module constant (1391). Scales the bone path only; never the stars.
+ pub(crate) model_scale: f32,
+ /// The window's diagonal in the same pixels, `√(W² + H²)` — what scales the STARS, and
+ /// nothing else does (decision 1390; [`STAR`]'s law is in screen px, free of UI scale and
+ /// frame scale). Recorded beside `scale` so a resize re-answers both at once.
+ pub(crate) diag: f32,
/// `Interface\Buttons\GlowStar`, resolved at record time.
pub(crate) texture: Handle,
}
@@ -310,6 +373,7 @@ fn site_quads(site: &ShineSite, ms: u32, out: &mut Vec) {
}
let lap = ms as f32 / PERIOD_MS as f32;
let s = site.scale;
+ let side = side(site.model_scale);
// The model origin: the button rect's bottom-left, in y-down px.
let (ox, oy) = (site.rect.min.x, site.rect.max.y);
for e in 0..EMITTERS {
@@ -323,9 +387,11 @@ fn site_quads(site: &ShineSite, ms: u32, out: &mut Vec) {
// The particle's fixed place in the loop: its emitter's quarter-lap offset, less how
// far the bone has moved on since this star was born (it is baked into the model
// frame at birth and never rides the bone — wow-re §7, flags bit 0x10 clear).
- let (x, y) = point(lap + corner - age / PERIOD);
+ let (x, y) = point(lap + corner - age / PERIOD, side);
let (cx, cy) = (ox + x * s, oy - y * s);
- let half = w * s * 0.5;
+ // The path took `s`; the star takes the screen diagonal instead ([`STAR`]) — the two
+ // are different projections of the same model, not a shared one.
+ let half = w * site.diag;
out.push(UiQuad {
rect: Rect::new(cx - half, cy - half, cx + half, cy + half),
z_key: site.z,
@@ -360,6 +426,40 @@ impl Plugin for AutocastShinePlugin {
mod tests {
use super::*;
+ /// The pet button's rim square — the geometry every golden below was taken at
+ /// (`scale="1.2"`, the ref's own value on that template).
+ fn s12() -> f32 {
+ side(1.2)
+ }
+
+ /// The token's grammar (1391): it carries the ``'s `scale=`, because the reference
+ /// writes a different one on each template that wears this shine — and it still refuses
+ /// anything that merely looks like the token, which is what keeps the shipped-XML archive
+ /// sweep honest about a typo.
+ #[test]
+ fn the_token_carries_the_models_scale_and_nothing_else_parses_as_one() {
+ assert_eq!(token_model_scale("benilla:autocast-shine:1.2"), Some(1.2));
+ assert_eq!(token_model_scale("benilla:autocast-shine:1.22"), Some(1.22));
+ assert_eq!(
+ token_model_scale(SHINE_TOKEN),
+ Some(1.2),
+ "bare = the pet bar's"
+ );
+ for bad in [
+ "benilla:autocast-shin",
+ "benilla:autocast-shine2",
+ "benilla:autocast-shine:",
+ "benilla:autocast-shine:x",
+ "benilla:autocast-shine1.22",
+ "Interface\\Buttons\\GlowStar",
+ ] {
+ assert_eq!(token_model_scale(bad), None, "{bad} must not parse");
+ }
+ // The two rim squares the shipped templates actually ask for.
+ assert!((side(1.2) - 30.72).abs() < 1e-4, "pet button");
+ assert!((side(1.22) - 31.232).abs() < 1e-4, "spell book");
+ }
+
/// The population golden (1386): the emitter keeps `rate × life` particles alive, aged one
/// spawn interval apart, none past its life — the file's own numbers, so a constant edit
/// cannot quietly re-open the sampling era.
@@ -376,11 +476,12 @@ mod tests {
}
assert!(PARTICLES.iter().all(|p| p.0 < LIFE));
// Density is what the look rests on: the bone lays a particle every 0.20 units of rim
- // while a newborn star is 15.3 units wide, so ~75 stars pile onto any point of the
- // band. Additive, that saturates — the reference's thick continuous rim, and exactly
- // what 1383's 23 samples (7.7 units apart, one star deep) could not produce.
- let pitch = 4.0 * SIZE / PERIOD / RATE;
- let depth = ramp(STAR, 0.0) / pitch;
+ // while a newborn star is 12.8 units wide at 4:3, so ~60 stars pile onto any point of
+ // the band. Additive, that saturates — the reference's thick continuous rim, and exactly
+ // what 1383's 23 samples (7.7 units apart, one star deep) could not produce. Both terms
+ // are in FrameXML units here, so the star takes the 4:3 case of its own law (1390).
+ let pitch = 4.0 * s12() / PERIOD / RATE;
+ let depth = 2.0 * ramp(STAR, 0.0) * 1280.0 / pitch;
assert!(
depth > 50.0,
"only {depth:.0} stars deep — that will not saturate"
@@ -399,6 +500,8 @@ mod tests {
clip: None,
alpha: 1.0,
scale: 1.0,
+ model_scale: 1.2,
+ diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
texture: Handle::default(),
};
let at = |ms: u32| {
@@ -423,12 +526,12 @@ mod tests {
near(at(0), (0.0, 0.0), "clock 0: the bottom-left origin");
near(
at(250),
- (0.0, SIZE / 2.0),
+ (0.0, s12() / 2.0),
"quarter edge in: the LEFT edge's midpoint (LINEAR)",
);
- near(at(500), (0.0, SIZE), "clock 500 ms: top-left");
- near(at(1000), (SIZE, SIZE), "clock 1000 ms: top-right");
- near(at(1500), (SIZE, 0.0), "clock 1500 ms: bottom-right");
+ near(at(500), (0.0, s12()), "clock 500 ms: top-left");
+ near(at(1000), (s12(), s12()), "clock 1000 ms: top-right");
+ near(at(1500), (s12(), 0.0), "clock 1500 ms: bottom-right");
near(at(2000), (0.0, 0.0), "clock 2000 ms wraps onto clock 0");
// The newborn quad wears the ramp's birth values (the inset included — it is 0.5 % of
// the way to the mid key already), the additive blend the emitter authors, and the
@@ -436,11 +539,9 @@ mod tests {
let mut out = Vec::new();
site_quads(&site, 0, &mut out);
let head = &out[0];
- assert!((head.rect.max.x - head.rect.min.x - ramp(STAR, 0.0)).abs() < 0.01);
- assert!(
- head.rect.max.x - head.rect.min.x < STAR[0],
- "the inset bites"
- );
+ let width = head.rect.max.x - head.rect.min.x;
+ assert!((width - 2.0 * ramp(STAR, 0.0) * site.diag).abs() < 0.01);
+ assert!(width < 2.0 * STAR[0] * site.diag, "the inset bites");
assert_eq!(head.color, ramp_color(0.0));
assert!(head.additive);
assert_eq!(head.z_key, 42);
@@ -463,6 +564,8 @@ mod tests {
clip: None,
alpha: 1.0,
scale: 1.0,
+ model_scale: 1.2,
+ diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
texture: Handle::default(),
};
let mut out = Vec::new();
@@ -486,7 +589,7 @@ mod tests {
.map(|q| site.rect.min.x - q.rect.min.x)
.fold(f32::MIN, f32::max);
assert!(
- (worst - ramp(STAR, 0.0) / 2.0).abs() < 0.01,
+ (worst - ramp(STAR, 0.0) * site.diag).abs() < 0.01,
"the newborn star should hang exactly half its width past the left rim, not {worst}"
);
@@ -511,20 +614,30 @@ mod tests {
assert!(out.is_empty());
}
- /// The two laws decision 1386 took off the bytes, pinned where they can't drift back: the
- /// star's projection is the POST-transform one (1280, no `modelScale`) while the bone square
- /// keeps the model's 1.2, and every particle spins the same way at `spin · age`.
+ /// The two projections and the spin, pinned where they can't drift back into one another
+ /// (1386, corrected by 1390): the bone square is 1280/model-unit × the Model's 1.2 at every
+ /// aspect, the star is an eye-space length that takes the screen diagonal and neither scale,
+ /// and every particle spins the same way at `spin · age`.
#[test]
fn the_star_skips_the_models_scale_and_every_spin_turns_one_way() {
- assert!((STAR[0] - 12.8).abs() < 1e-4, "2 × 0.005 × 1280 — no 1.2");
+ // The bone square: 1280 per model unit and the Model's 1.2, at EVERY aspect (C2).
assert!(
- (SIZE - 30.72).abs() < 1e-4,
+ (s12() - 30.72).abs() < 1e-4,
"the bone square DOES take the 1.2"
);
+ // The star: `2 · half · √(W² + H²)` window px, no 1.2 and no UI scale — so unlike the
+ // square it MOVES with the window's shape (C3). 1024×768 is the case 1386 mistook for
+ // the whole law; 1280×720 is 22 % wider on a button 4 % narrower.
+ let width = |w: f32, h: f32| 2.0 * STAR[0] * (w * w + h * h).sqrt();
+ assert!((width(1024.0, 768.0) - 12.80).abs() < 0.01, "4:3");
+ assert!((width(1280.0, 720.0) - 14.69).abs() < 0.01, "16:9");
assert!(
- (SIZE / STAR[0] - 2.4).abs() < 1e-5,
- "the square is 2.4 star-widths across; 1321's 2.0 was the 1.2 leaking into the star"
+ width(1280.0, 720.0) > 1.14 * width(1024.0, 768.0),
+ "the aspect term is what 1386 froze out; it must be live"
);
+ // The square is 2.4 star-widths across at 4:3 — 1321's 2.0 was the 1.2 leaking into the
+ // star. It is only 2.1 at 16:9, and that drift IS the law, not a rounding.
+ assert!((s12() / width(1024.0, 768.0) - 2.4).abs() < 1e-3);
let site = ShineSite {
rect: Rect::new(0.0, 0.0, 30.0, 30.0),
@@ -532,6 +645,8 @@ mod tests {
clip: None,
alpha: 1.0,
scale: 1.0,
+ model_scale: 1.2,
+ diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
texture: Handle::default(),
};
let mut out = Vec::new();
@@ -555,11 +670,11 @@ mod tests {
/// bone 1 at BR, bone 2 at TR and bone 3 at TL.
#[test]
fn the_bones_walk_one_clockwise_cycle_each_lagging_the_last() {
- assert_eq!(point(0.0), (0.0, 0.0), "bottom-left at 0 s");
- assert_eq!(point(0.25), (0.0, SIZE), "top-left at 0.5 s");
- assert_eq!(point(0.5), (SIZE, SIZE), "top-right at 1.0 s");
- assert_eq!(point(0.75), (SIZE, 0.0), "bottom-right at 1.5 s");
- assert_eq!(point(1.0), (0.0, 0.0), "home at 2.0 s");
+ assert_eq!(point(0.0, s12()), (0.0, 0.0), "bottom-left at 0 s");
+ assert_eq!(point(0.25, s12()), (0.0, s12()), "top-left at 0.5 s");
+ assert_eq!(point(0.5, s12()), (s12(), s12()), "top-right at 1.0 s");
+ assert_eq!(point(0.75, s12()), (s12(), 0.0), "bottom-right at 1.5 s");
+ assert_eq!(point(1.0, s12()), (0.0, 0.0), "home at 2.0 s");
// Where each emitter's newborn particle sits at clock 0, straight off the producer.
let site = ShineSite {
@@ -568,6 +683,8 @@ mod tests {
clip: None,
alpha: 1.0,
scale: 1.0,
+ model_scale: 1.2,
+ diag: 1280.0, // 1024×768: s = 1 and the diagonal is 1280 — the law's 4:3 case
texture: Handle::default(),
};
let mut out = Vec::new();
@@ -588,11 +705,11 @@ mod tests {
near(born(0), (0.0, 0.0), "bone 0 opens at BL");
near(
born(1),
- (SIZE, 0.0),
+ (s12(), 0.0),
"bone 1 opens at BR — a quarter lap BEHIND bone 0",
);
- near(born(2), (SIZE, SIZE), "bone 2 opens at TR");
- near(born(3), (0.0, SIZE), "bone 3 opens at TL");
+ near(born(2), (s12(), s12()), "bone 2 opens at TR");
+ near(born(3), (0.0, s12()), "bone 3 opens at TL");
}
/// Decision 1321's clock law, on the native accumulator: at a exact 60 fps the floor of
diff --git a/crates/benilla-app/src/perf.rs b/crates/benilla-app/src/perf.rs
deleted file mode 100644
index 3f27dcb4..00000000
--- a/crates/benilla-app/src/perf.rs
+++ /dev/null
@@ -1,1133 +0,0 @@
-//! `perf.rs` — performance instrumentation + the standing dev HUD ([`PerfPlugin`]).
-//!
-//! Owns the **frame-time measurement layer** that every future subsystem is measured against (the
-//! standard). Registers
-//! Bevy's diagnostics (frame time / FPS, entity count, render-pass timing), keeps a rolling window of
-//! recent real frame durations, and draws a **minimal, always-on FPS 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). Expanded, it shows:
-//! - FPS + last frame ms,
-//! - **p50 / p99 / worst** frame ms over the window (worst-frame is the metric that matters, not the
-//! average — averages hide the hitch you feel),
-//! - **dropped** frames (synced: intervals actually missed) / **over-budget** frames (uncapped) —
-//! synced wall time rails at the display's present grant, so the two readings mean different things,
-//! - **cpu ms/frame** — process CPU, all threads: the cost meter the rail *can't* fool, the HUD twin
-//! of the probes' `cpu_ms` (decision 0711),
-//! - a frame-time graph with the budget line,
-//! - a **VSync toggle** — the "uncap and see" headroom test. Trust it only when it moves: the uncap
-//! is `AutoNoVsync` (explicit `Immediate` rails AND takes 1 s `nextDrawable` stalls on Metal —
-//! measured, see `capture::probe_uncap_mode`), and a macOS power state can withhold the >60 grant
-//! entirely (0362), in which case no present mode uncaps and `cpu ms` is the number to read.
-//!
-//! The budget is a **60 fps floor = 16.7 ms; no frame should exceed it.** Deep per-system attribution
-//! is via Tracy (`cargo run --features tracy`) — the `info_span!` markers on the hot systems feed it.
-//!
-//! ⚠️ **macOS/Metal:** `RenderDiagnosticsPlugin` records render-pass **CPU** time only — GPU timestamp
-//! queries are Vulkan/DX12-only (bevy_render `diagnostic/mod.rs` §Supported platforms). True GPU
-//! timing here needs an Xcode/Instruments Metal frame capture; in-client, use the VSync-off headroom
-//! test + Tracy for the CPU-vs-GPU picture.
-
-use std::collections::VecDeque;
-
-use bevy::diagnostic::{
- DiagnosticsStore, EntityCountDiagnosticsPlugin, FrameTimeDiagnosticsPlugin,
-};
-use bevy::prelude::*;
-use bevy::render::diagnostic::RenderDiagnosticsPlugin;
-use bevy::render::view::Msaa;
-use bevy::time::Real;
-use bevy::window::{PresentMode, PrimaryWindow};
-use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
-
-use crate::debug_panel::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT_DIM};
-use benilla_world::view::WorldCamera;
-
-/// The frame budget: a 60 fps floor. No frame should exceed this.
-pub const FRAME_BUDGET_MS: f32 = 1000.0 / 60.0;
-
-/// Recent frames kept for the percentiles + the graph (~5 s at 60 fps).
-const SAMPLE_WINDOW: usize = 300;
-
-/// Frame duration above which a frame is logged as a hitch (a stall the player feels). Far above the
-/// 16.7 ms budget so it only fires on real stalls (load bursts), not normal over-budget frames.
-const HITCH_LOG_MS: f32 = 250.0;
-
-/// Synced, wall frame time rails at the display's present interval and jitters ±1 ms around it, so
-/// counting frames over the 16.7 ms budget mostly counts jitter (a perfectly healthy 60 Hz rail
-/// reads "48% over budget"). What a synced player *feels* is a missed interval: 1.5× budget (25 ms)
-/// sits past any rail jitter and under the doubled frame (33 ms), so the synced HUD counts
-/// **dropped** frames against this instead.
-const DROPPED_FACTOR: f32 = 1.5;
-
-/// 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.
-fn synced_mode(mode: PresentMode) -> bool {
- !matches!(
- mode,
- PresentMode::AutoNoVsync | PresentMode::Immediate | PresentMode::Mailbox
- )
-}
-
-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 Metal — see the module header). Harmless if the backend
- // can't record it; also the hook Tracy GPU uses on Vulkan/DX12.
- RenderDiagnosticsPlugin,
- ))
- .init_resource::()
- .init_resource::()
- .insert_resource(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).
- (toggle_hud, 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_stream)
- .add_systems(EguiPrimaryContextPass, perf_hud_ui);
- // `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
- // show ~zero here. Printed once a second with the top mutated ids' first sighting.
- if std::env::var_os("WOW_MESH_EVENTS").is_some() {
- app.add_systems(Update, count_mesh_events);
- }
- // `WOW_CAM_CHANGED=1` — is the world camera's transform bit-stable at a parked pin?
- // The premise counter for gating the per-submesh visibility sweep on frame-stable
- // camera inputs: if the controller rewrites an equal transform every frame, change
- // detection fires anyway and the gate can never hold — the controller's no-op write
- // is then the first fix, not the sweep's.
- if std::env::var_os("WOW_CAM_CHANGED").is_some() {
- app.add_systems(bevy::app::PostUpdate, count_camera_changes);
- }
- // `WOW_ARCH_CENSUS=` — one archetype census at t=secs: every non-empty
- // archetype's entity count beside its component set, largest first. The exact by-lane
- // entity picture (1354's anchor census generalized to every lane at once), for sizing
- // which populations are worth collapsing before designing any collapse.
- if let Some(at) = std::env::var("WOW_ARCH_CENSUS")
- .ok()
- .and_then(|v| v.parse::().ok())
- {
- app.insert_resource(ArchCensusAt(at));
- app.add_systems(Last, arch_census);
- }
- #[cfg(target_os = "macos")]
- stall_sample::plugin(app);
- }
-}
-
-/// A frame past this is logged even with no streamer activity — catches a spike whose cost lands
-/// outside the Stream chain (command application, render extraction, asset frees frames later).
-const TRACE_FRAME_MS: f32 = 25.0;
-/// Frames still logged after the last streamer event: the despawn commands apply after the system,
-/// the asset frees land when the last handle drops, and the render world reacts a frame later —
-/// the tail is where the cost shows, so the window must outlive the event.
-const TRACE_TAIL_FRAMES: u64 = 10;
-
-/// The STREAM TRACE (`WOW_STREAM_TRACE=`) — B181's instrument. One row per frame in
-/// which the terrain streamer did anything (plus a [`TRACE_TAIL_FRAMES`] tail, plus any frame over
-/// [`TRACE_FRAME_MS`]): what was dropped/requested/spawned, the Stream chain's own self-times, and
-/// the frame's wall delta beside it. **The off-by-one to remember reading it: `delta_ms` on a row
-/// is the interval that *ended* at this frame's start, so frame N's cost appears in row N+1.**
-#[derive(Resource)]
-struct StreamTrace {
- path: String,
- frame: u64,
- log_until: u64,
- /// [`process_cpu_secs`] at the previous frame — the row's `cpu_ms` is this frame's process-CPU
- /// delta (user+system, all threads): the load-robust cost meter beside the wall delta, because
- /// on this machine wall frame time moves with whoever else is compiling (0711; a parallel
- /// build polluted this instrument's first A/B).
- prev_cpu_secs: Option,
- /// `PipeWatch::created` at the previous frame, so `pipes_new` is per-frame like every other
- /// counter (the render world bumps the shared atomic; ±1 frame skew is inherent and fine).
- prev_pipes_created: usize,
-}
-
-const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,furnish_ms,mfurnish_ms,\
- spawn_ms,collider_ms,tiles_dropped,pl_dropped,pl_ents_dropped,\
- tiles_req,tiles_spawned,cells_spawned,mmeshes_built,pl_spawned,\
- attached,adt_freed,meshes_freed,images_freed,adt_added,\
- meshes_added,images_added,pipes_new,pipes_pending\n";
-
-#[allow(clippy::too_many_arguments)]
-fn trace_stream(
- mut trace: ResMut,
- mut activity: ResMut,
- time: Res>,
- entities: Query<()>,
- mut adt_events: MessageReader>,
- mut mesh_events: MessageReader>,
- mut image_events: MessageReader>,
- pipes: Res,
-) {
- // Taken every frame — the counters are per-frame by contract, tracing or not.
- let a = std::mem::take(&mut *activity);
- let pipes_created = pipes.0.created.load(std::sync::atomic::Ordering::Relaxed);
- let pipes_settled = pipes.0.settled.load(std::sync::atomic::Ordering::Relaxed);
- let pipes_new = pipes_created.saturating_sub(trace.prev_pipes_created);
- let pipes_pending = pipes_created.saturating_sub(pipes_settled);
- trace.prev_pipes_created = pipes_created;
- trace.frame += 1;
- if trace.path.is_empty() {
- return;
- }
- let cpu_now = process_cpu_secs();
- let cpu_ms = match (trace.prev_cpu_secs, cpu_now) {
- (Some(t0), Some(t1)) => format!("{:.2}", (t1 - t0) * 1000.0),
- _ => String::new(),
- };
- trace.prev_cpu_secs = cpu_now;
- // One pass per reader: `Added` beside `Unused` — B181's recurring spike was the free wave's
- // family, but the first-contact head frame is the ADD wave (a landed tile's cell meshes +
- // texture arrays hitting the render world's prepare at once), invisible until counted.
- //
- // The freed columns count `Unused` (last strong handle dropped), not `Removed`: a
- // `RENDER_WORLD`-only asset (chunk-cell meshes, the tile arrays — decision 0832) leaves the
- // main store at *extract* via the untracked path, so `Removed` never fires for it; `Unused`
- // is the release signal both usage kinds emit exactly once, and it is what actually frees
- // the GPU copy.
- fn count_events(
- events: &mut MessageReader>,
- ) -> (u32, u32) {
- let (mut added, mut unused) = (0u32, 0u32);
- for e in events.read() {
- match e {
- bevy::asset::AssetEvent::Added { .. } => added += 1,
- bevy::asset::AssetEvent::Unused { .. } => unused += 1,
- _ => {}
- }
- }
- (added, unused)
- }
- let (added_adt, removed_adt) = count_events(&mut adt_events);
- let (added_mesh, removed_mesh) = count_events(&mut mesh_events);
- let (added_img, removed_img) = count_events(&mut image_events);
- let delta_ms = time.delta_secs() * 1000.0;
- if a.any_event() || removed_adt > 0 || added_adt > 0 || pipes_new > 0 {
- trace.log_until = trace.frame + TRACE_TAIL_FRAMES;
- }
- if !(a.any_event()
- || removed_adt > 0
- || added_adt > 0
- || pipes_new > 0
- || delta_ms > TRACE_FRAME_MS
- || trace.frame <= trace.log_until)
- {
- return;
- }
- if trace.frame == 1 || !std::path::Path::new(&trace.path).exists() {
- let _ = std::fs::write(&trace.path, STREAM_TRACE_HEADER);
- }
- let line = format!(
- "{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img},{added_adt},{added_mesh},{added_img},{pipes_new},{pipes_pending}\n",
- trace.frame,
- time.elapsed_secs(),
- entities.iter().len(),
- a.stream_ms,
- a.furnish_ms,
- a.mfurnish_ms,
- a.spawn_ms,
- a.collider_ms,
- a.tiles_dropped,
- a.placements_dropped,
- a.placement_entities_dropped,
- a.tiles_requested,
- a.tiles_spawned,
- a.cells_spawned,
- a.model_meshes_built,
- a.placements_spawned,
- a.colliders_attached,
- );
- use std::io::Write;
- if let Ok(mut f) = std::fs::OpenOptions::new()
- .create(true)
- .append(true)
- .open(&trace.path)
- {
- let _ = f.write_all(line.as_bytes());
- }
-}
-
-/// Rolling window of recent **real** frame durations (ms) + the derived hitch metrics.
-#[derive(Resource)]
-struct FrameStats {
- samples: VecDeque,
- /// Per-frame process-CPU deltas (ms, **user+system across every thread** —
- /// [`process_cpu_secs`]), same window. A blocked present-wait consumes no CPU, so this is the
- /// frame-cost meter vsync cannot rail: the HUD twin of the probes' `cpu_ms` (decision 0711),
- /// directly comparable with a reporter's "N % CPU".
- cpu_samples: VecDeque,
- /// [`process_cpu_secs`] at the previous sample, for the per-frame delta. `None` until the
- /// first frame (and forever on platforms where `getrusage` is unavailable).
- prev_cpu_secs: Option,
-}
-
-impl Default for FrameStats {
- fn default() -> Self {
- Self {
- samples: VecDeque::with_capacity(SAMPLE_WINDOW),
- cpu_samples: VecDeque::with_capacity(SAMPLE_WINDOW),
- prev_cpu_secs: None,
- }
- }
-}
-
-impl FrameStats {
- fn push(&mut self, ms: f32) {
- if self.samples.len() == SAMPLE_WINDOW {
- self.samples.pop_front();
- }
- self.samples.push_back(ms);
- }
-
- fn push_cpu(&mut self, ms: f32) {
- if self.cpu_samples.len() == SAMPLE_WINDOW {
- self.cpu_samples.pop_front();
- }
- self.cpu_samples.push_back(ms);
- }
-
- /// Windowed mean CPU ms/frame, all threads. `None` where the platform can't answer.
- fn cpu_ms(&self) -> Option {
- (!self.cpu_samples.is_empty())
- .then(|| self.cpu_samples.iter().sum::() / self.cpu_samples.len() as f32)
- }
-
- fn last(&self) -> f32 {
- self.samples.back().copied().unwrap_or(0.0)
- }
-
- /// `(p50, p99, max)` over the window. Sorts a copy — fine for a few-hundred-element dev HUD.
- fn percentiles(&self) -> (f32, f32, f32) {
- if self.samples.is_empty() {
- return (0.0, 0.0, 0.0);
- }
- let mut v: Vec = 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())
- }
-
- fn fps(&self) -> f32 {
- if self.samples.is_empty() {
- return 0.0;
- }
- let mean = self.samples.iter().sum::() / self.samples.len() as f32;
- if mean > 0.0 {
- 1000.0 / mean
- } else {
- 0.0
- }
- }
-
- /// `(count, fraction)` of windowed frames above `threshold_ms`.
- 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)
- }
-}
-
-/// 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 — a minimal FPS pill until
-/// clicked). `visible` is
-/// `pub(crate)` so the capture harness ([`crate::capture`]) can force the overlay off for pristine,
-/// UI-free screenshots.
-#[derive(Resource)]
-pub(crate) struct PerfHud {
- pub(crate) visible: bool,
- /// Full stats shown? Toggled by clicking the FPS pill; minimal (FPS only) when `false`.
- expanded: bool,
-}
-
-impl Default for PerfHud {
- fn default() -> Self {
- Self {
- visible: true,
- expanded: false,
- }
- }
-}
-
-fn toggle_hud(keys: Res>, mut hud: ResMut) {
- // The dev chord + `P`, not a bare `p` — `P` is the reference's TOGGLESPELLBOOK, and a dev
- // doesn't get to squat on a game binding (decision 0585). The chord can't be mistaken for typed
- // text, so unlike the old bare key it needs no chat-bar/EditBox gate.
- if benilla_world::modkeys::dev_chord(&keys, KeyCode::KeyP) {
- hud.visible = !hud.visible;
- }
-}
-
-fn sample_frame_time(time: Res>, mut stats: ResMut) {
- let ms = time.delta_secs() * 1000.0;
- stats.push(ms);
- let cpu = process_cpu_secs();
- if let (Some(prev), Some(now)) = (stats.prev_cpu_secs, cpu) {
- stats.push_cpu(((now - prev) * 1000.0) as f32);
- }
- stats.prev_cpu_secs = cpu;
- // 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 ms > HITCH_LOG_MS && stats.samples.len() > 1 {
- warn!("frame hitch: {ms:.0} ms (main thread blocked this long)");
- }
-}
-
-fn perf_hud_ui(
- mut contexts: EguiContexts,
- mut hud: ResMut,
- stats: Res,
- diagnostics: Res,
- mut windows: Query<&mut Window, With>,
- cam_msaa: Query<&Msaa, With>,
-) -> Result {
- if !hud.visible {
- return Ok(());
- }
- let ctx = contexts.ctx_mut()?;
-
- let fps = stats.fps();
- let last = stats.last();
- let synced = windows
- .single()
- .map(|w| synced_mode(w.present_mode))
- .unwrap_or(true);
- // Synced, a small over is rail jitter, not cost — red only past the dropped threshold there.
- let red_above = if synced {
- FRAME_BUDGET_MS * DROPPED_FACTOR
- } else {
- FRAME_BUDGET_MS
- };
-
- let red = egui::Color32::from_rgb(240, 120, 120);
- let green = egui::Color32::from_rgb(140, 220, 140);
- // The pill colour reads *sustained* health off the windowed mean, not the per-frame `last` (which
- // would flicker red on every isolated spike): green at/above the 60 fps floor, red when under it.
- let pill = if fps < 58.0 { red } else { green };
-
- // 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. `overlay_text` brightens the text and stops "60 fps" from wrapping.
- 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| {
- overlay_text(ui);
- // Minimal by default: just a clickable FPS number. Click toggles the full readout.
- let label = egui::RichText::new(format!("{fps:.0} fps"))
- .color(pill)
- .monospace()
- .strong();
- let hint = if hud.expanded {
- "click to collapse · P to hide"
- } else {
- "click to expand · P to hide"
- };
- if ui
- .add(egui::Button::new(label).frame(false))
- .on_hover_text(hint)
- .clicked()
- {
- hud.expanded = !hud.expanded;
- }
- if !hud.expanded {
- return;
- }
-
- ui.separator();
- let (p50, p99, max) = stats.percentiles();
- let (over_n, over_frac) = stats.frames_over(red_above);
- let win_len = stats.samples.len();
- let entities = diagnostics
- .get(&EntityCountDiagnosticsPlugin::ENTITY_COUNT)
- .and_then(|d| d.value())
- .unwrap_or(0.0);
-
- let last_col = if last > red_above { red } else { green };
- ui.colored_label(last_col, format!("last {last:>6.2} ms"));
- ui.label(
- egui::RichText::new(format!("budget {FRAME_BUDGET_MS:.2} ms · 60 fps floor"))
- .color(OVERLAY_TEXT_DIM),
- );
- ui.label(format!(
- "p50 {p50:>6.2} p99 {p99:>6.2} max {max:>6.2} ms"
- ));
- let ob_col = if over_frac > 0.0 {
- red
- } else {
- OVERLAY_TEXT_DIM
- };
- // Synced: "dropped" = missed present intervals (> DROPPED_FACTOR × budget) — 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.colored_label(
- ob_col,
- format!(
- "{} {over_n}/{win_len} ({:.0}%)",
- if synced { "dropped" } else { "over budget" },
- over_frac * 100.0
- ),
- )
- .on_hover_text(if synced {
- "frames past 1.5× budget — a missed display interval; \
- small overs while synced are present-grant jitter, not cost"
- } else {
- "frames past the 16.7 ms budget (uncapped: wall time ≈ real frame cost)"
- });
- // The vsync-immune cost meter: process CPU per frame, every thread summed (the probes'
- // `cpu_ms`, decision 0711). ~6 ms is the 1.12.1 reference client's whole-scene cost —
- // the long-term bar.
- if let Some(cpu) = stats.cpu_ms() {
- let mean_ms = if fps > 0.0 { 1000.0 / fps } else { 0.0 };
- let pct = if mean_ms > 0.0 {
- cpu / mean_ms * 100.0
- } else {
- 0.0
- };
- ui.label(format!("cpu {cpu:>6.2} ms · {pct:.0}% (all threads)"))
- .on_hover_text(
- "process CPU consumed per frame — measures our work, not the display's \
- present grant; comparable with the probes' cpu_ms / a reporter's CPU %",
- );
- }
- ui.label(format!("entities {entities:.0}"));
-
- frame_graph(ui, &stats);
-
- if let Ok(mut window) = windows.single_mut() {
- ui.separator();
- let mut vsync = !matches!(
- window.present_mode,
- PresentMode::AutoNoVsync | PresentMode::Immediate | PresentMode::Mailbox
- );
- // "Sync to display", not "cap at 60": on the ProMotion panel the display is
- // ADAPTIVE (up to 120 Hz — grants ~119 in High Power Mode, pinned to 60 only by
- // macOS power state), so synced fps floats with frame cost. A floating 70–119
- // with this ON is vsync working, not broken (decision 0294's companion finding;
- // measure the current grant with WOW_PROBE_VSYNC=1).
- if ui
- .checkbox(
- &mut vsync,
- "VSync — sync to display (120 adaptive; off = uncap)",
- )
- .changed()
- {
- // AutoNoVsync, never Immediate: on Metal, explicit Immediate both rails and
- // takes ~1 s nextDrawable stalls (measured — `capture::probe_uncap_mode`).
- window.present_mode = if vsync {
- PresentMode::AutoVsync
- } else {
- PresentMode::AutoNoVsync
- };
- }
- }
- if let Ok(m) = cam_msaa.single() {
- // Read-only: switching MSAA at runtime breaks our post-process graph (glow/egui)
- // and froze the view, so MSAA is a startup knob now ($WOW_MSAA=off/2/4); A/B by
- // restarting.
- let s = m.samples();
- let txt = if s <= 1 {
- "off".to_string()
- } else {
- format!("{s}×")
- };
- ui.label(format!("MSAA {txt} · set $WOW_MSAA to change"));
- }
- });
- Ok(())
-}
-
-/// A tiny frame-time graph: the windowed samples as a polyline with the budget line in red. Scaled to
-/// at least 33 ms so a dropped (doubled) frame reads clearly against the 16.7 ms budget.
-fn frame_graph(ui: &mut egui::Ui, stats: &FrameStats) {
- let (rect, _) = ui.allocate_exact_size(egui::vec2(232.0, 52.0), egui::Sense::hover());
- if stats.samples.is_empty() {
- return;
- }
- let painter = ui.painter_at(rect);
- let max_ms = stats.samples.iter().copied().fold(33.0_f32, f32::max);
- let to_y = |ms: f32| rect.bottom() - (ms / max_ms).clamp(0.0, 1.0) * rect.height();
-
- let budget_y = to_y(FRAME_BUDGET_MS);
- painter.hline(
- rect.x_range(),
- budget_y,
- egui::Stroke::new(1.0_f32, egui::Color32::from_rgb(200, 80, 80)),
- );
-
- let dx = rect.width() / SAMPLE_WINDOW as f32;
- let points: Vec = stats
- .samples
- .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, egui::Color32::from_rgb(140, 220, 140)),
- ));
-}
-
-/// The FPS JOURNAL (`WOW_FPS_JOURNAL=`): once a second, append one row of where the
-/// player is, what the frame cost, and **what is resident** (see [`JOURNAL_HEADER`] for the column
-/// order, written into every fresh file) — the "where does it dip" instrument for a
-/// director-driven run. They play normally; the journal turns "it drops in the Dwarven District"
-/// into coordinates a headless probe can tele straight back to. Negligible cost: one line of IO a
-/// second, samples reused from [`FrameStats`].
-///
-/// The residency columns make it the **leak curve** instrument too (B131): `FPS_PROBE`'s residency
-/// meter samples once per run, which can only compare two runs at one point each — it cannot tell
-/// "grows with distance streamed" from "grows with time elapsed", and cannot show *where* on a
-/// route the cost arrives. A per-second row of `cpu_ms` beside `mats/images/uv/tint` plots the
-/// per-frame cost directly against residency along one continuous leg, on the same time axis as
-/// the position — so a same-map traverse (no `MapChange`, so no map-scoped eviction) shows its
-/// accumulation as a slope instead of a before/after pair.
-pub(crate) struct FpsJournalPlugin;
-
-/// The journal's column order, written as the first line of a fresh file. Appended-to files keep
-/// whatever header they were created with — the columns only ever grow at the end.
-const JOURNAL_HEADER: &str = "t,x,y,z,mean_ms,p95_ms,streamed,entities,cpu_ms,mats,meshes,images,\
- m2,uv,tint,pmat,emat,skin,cmat,tex,cgeo,evicted,fx,fy,fz\n";
-
-impl Plugin for FpsJournalPlugin {
- fn build(&self, app: &mut App) {
- let path = std::env::var("WOW_FPS_JOURNAL").unwrap_or_default();
- // The header goes in exactly once, at creation: the rows are appended for the life of the
- // run (and across runs, deliberately — a journal accumulates legs).
- if !path.is_empty() && !std::path::Path::new(&path).exists() {
- let _ = std::fs::write(&path, JOURNAL_HEADER);
- }
- app.insert_resource(FpsJournal {
- path,
- window: Vec::new(),
- last_flush: 0.0,
- cpu_at_flush: process_cpu_secs(),
- })
- .add_systems(Update, journal_fps);
- }
-}
-
-#[derive(Resource)]
-struct FpsJournal {
- path: String,
- window: Vec,
- last_flush: f32,
- /// Process CPU seconds at the previous flush — the row's `cpu_ms` is this second's CPU cost
- /// per frame, the load-robust half of the measurement (see [`process_cpu_secs`]).
- cpu_at_flush: Option,
-}
-
-/// The journal's residency columns, grouped because `journal_fps` is near Bevy's system-param
-/// arity limit.
-///
-/// The `Assets` counts are the totals — what the process holds. The [`ArtCensus`] half is the
-/// same population **broken down by the cache that holds it** (decision 0793), which is what turns
-/// "materials are growing" into a named holder in one row instead of a run-length probe. `evicted`
-/// is the running total dropped by distance: on a same-map traverse it was structurally zero before
-/// 0793, because nothing but a `MapChange` evicted anything (0729).
-#[derive(bevy::ecs::system::SystemParam)]
-struct JournalResidency<'w> {
- mats: Res<'w, Assets>,
- meshes: Res<'w, Assets>,
- images: Res<'w, Assets>,
- m2: Res<'w, Assets>,
- uv_reg: Res<'w, benilla_world::doodad_anim::UvAnimMaterials>,
- tint_reg: Res<'w, benilla_world::doodad_anim::TintAnimMaterials>,
- art: Res<'w, benilla_world::art_scope::ArtCensus>,
- /// The **view focus** — where art is actually being asked for. Distinct from the row's `x,y,z`,
- /// which is the avatar: through a detached free-fly the body stands still while the camera covers
- /// kilometres, so on that leg the position columns describe nothing that is happening. The
- /// director's first run was exactly that leg, and reading it needed this column.
- scope: Res<'w, benilla_world::art_scope::ArtScopeState>,
-}
-
-fn journal_fps(
- mut journal: ResMut,
- time: Res>,
- player: Option>,
- streamed: Query<(), With>,
- entities: Query<()>,
- residency: JournalResidency,
-) {
- let now = time.elapsed_secs();
- journal.window.push(time.delta_secs() * 1000.0);
- if now - journal.last_flush < 1.0 {
- return;
- }
- journal.last_flush = now;
- let mut v = std::mem::take(&mut journal.window);
- if v.is_empty() {
- return;
- }
- v.sort_by(f32::total_cmp);
- let mean = v.iter().sum::() / v.len() as f32;
- let p95 = v[((v.len() - 1) as f32 * 0.95).round() as usize];
- // Raw WoW coords, so the line pastes straight into a `.go xyz` probe.
- let pos = player
- .filter(|p| p.active)
- .map(|p| benilla_assets::coords::bevy_to_wow(p.pos))
- .unwrap_or([0.0; 3]);
- // CPU per frame over this second — the number the reporter's "CPU %" compares against, and
- // the one that does not move with whatever else is compiling on this machine.
- let cpu_now = process_cpu_secs();
- let cpu_ms = match (journal.cpu_at_flush, cpu_now) {
- (Some(t0), Some(t1)) => format!("{:.2}", (t1 - t0) * 1000.0 / v.len() as f64),
- _ => String::new(),
- };
- journal.cpu_at_flush = cpu_now;
- let mut line = format!(
- "{now:.1},{:.1},{:.1},{:.1},{mean:.2},{p95:.2},{},{},{cpu_ms},{},{},{},{},{},{}",
- pos[0],
- pos[1],
- pos[2],
- streamed.iter().len(),
- entities.iter().len(),
- residency.mats.len(),
- residency.meshes.len(),
- residency.images.len(),
- residency.m2.len(),
- residency.uv_reg.0.len(),
- residency.tint_reg.0.len(),
- );
- // The per-cache breakdown, in `ArtSlot::ALL` order — which IS the header's column order.
- for slot in benilla_world::art_scope::ArtSlot::ALL {
- line.push_str(&format!(",{}", residency.art.live(slot)));
- }
- line.push_str(&format!(",{}", residency.art.dropped_total()));
- match residency.scope.focus() {
- Some(f) => line.push_str(&format!(",{:.1},{:.1},{:.1}\n", f[0], f[1], f[2])),
- None => line.push_str(",,,\n"),
- }
- use std::io::Write;
- if let Ok(mut f) = std::fs::OpenOptions::new()
- .create(true)
- .append(true)
- .open(&journal.path)
- {
- let _ = f.write_all(line.as_bytes());
- }
-}
-
-/// Whole-process CPU seconds consumed so far — **user + system, summed across every thread**
-/// (`getrusage(RUSAGE_SELF)`).
-///
-/// The perf probes report wall-clock frame time, which on this machine is not a usable regression
-/// instrument: parallel session worktrees build on the same 14 cores, and two identical probe runs
-/// of the same pin came back 49.6 ms and 28.6 ms apart purely on machine load. CPU-per-frame moves
-/// with the work we actually do, not with who else is compiling — and it is the metric the Mac
-/// report is written in ("250 % CPU at 59 fps" against 1.12.1's "100 % at 160"), so a probe that
-/// prints it can be compared against a reporter's number directly.
-///
-/// Non-unix returns `None`: the probes print the field only where the platform answers.
-pub(crate) fn process_cpu_secs() -> Option {
- #[cfg(unix)]
- {
- // SAFETY: `getrusage` writes a fully-initialized `rusage` into the out-param and reads
- // nothing from it; zeroed is a valid starting value for a C struct of plain integers.
- unsafe {
- let mut ru: libc::rusage = std::mem::zeroed();
- if libc::getrusage(libc::RUSAGE_SELF, &mut ru) != 0 {
- return None;
- }
- let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 * 1e-6;
- Some(secs(ru.ru_utime) + secs(ru.ru_stime))
- }
- }
- #[cfg(not(unix))]
- {
- None
- }
-}
-
-/// Cumulative **machine-wide** CPU ticks as `(busy, total)` — every core, every process, us
-/// included (`host_statistics64(HOST_CPU_LOAD_INFO)`). Diff two samples and `busy/total` is the
-/// fraction of all cores that were doing work between them.
-///
-/// Why the probes need it, when [`process_cpu_secs`] above already promises load-independence:
-/// that promise is **weaker than it reads**, and 1157 caught it. `cpu_ms` is per-frame work rather
-/// than wall time, so it shrugs off the scheduling delay that makes frame time useless under load
-/// — but it is not immune. The same LBRS pin leg, same binary, same day, read **25.93** while two
-/// other slots ran `cargo test --workspace` (load 20-32 on 14 cores) and **18.80-20.12** on a quiet
-/// machine: a ~35 % inflation, far outside the ±0.4 band the campaign reasons in (0736). Cache and
-/// memory-bandwidth contention is work we genuinely do; `getrusage` counts it and cannot tell us
-/// it was someone else's fault.
-///
-/// So the leg has to say how loaded the machine was, and the reader compares. Deliberately a
-/// **stamp, not a gate** (the director's call): no threshold is invented here. Two legs at similar
-/// `sys_busy_pct` are comparable; two legs far apart are not, whatever their `cpu_ms` says. That
-/// is a relative rule, which is the only kind the four calibration points behind 1157 support.
-///
-/// **Do not reach for `uptime` instead.** Load average is a 1-minute *decaying* mean and lags
-/// badly: a leg stamped 99 % here was taken at load 5.55, seconds after a build burst the average
-/// had not caught up with. Cross-checked against an independent out-of-process sampler over the
-/// same window — 44 % vs 44.5 % — so where the two disagree, this is the one that is right.
-///
-/// Non-macOS returns `None`: the probes print the field only where the platform answers.
-///
-/// The `deprecated` allow is `libc::mach_host_self`, whose deprecation note says "use the `mach2`
-/// crate instead". Checked, and it does not apply here: `mach2` 0.5.0 carries `mach_host_self` and
-/// **nothing else this call needs** — no `host_statistics64`, no `host_cpu_load_info`, no
-/// `HOST_CPU_LOAD_INFO`. Taking the advice would split one call across two crates and add a direct
-/// dependency to get the *port* while `libc` still supplies the call it is passed to. Revisit if
-/// `mach2` ever grows the host-statistics surface.
-#[allow(deprecated)]
-pub(crate) fn system_cpu_ticks() -> Option<(u64, u64)> {
- #[cfg(target_os = "macos")]
- {
- // SAFETY: `host_statistics64` fills at most `count` u32s into the out-param and reads
- // nothing from it; `host_cpu_load_info` is plain integers, so zeroed is a valid starting
- // value. `mach_host_self` returns the global host port — no send right to deallocate.
- unsafe {
- let mut info: libc::host_cpu_load_info = std::mem::zeroed();
- let mut count = libc::HOST_CPU_LOAD_INFO_COUNT;
- if libc::host_statistics64(
- libc::mach_host_self(),
- libc::HOST_CPU_LOAD_INFO,
- (&raw mut info).cast(),
- &mut count,
- ) != libc::KERN_SUCCESS
- {
- return None;
- }
- let total: u64 = info.cpu_ticks.iter().map(|&x| u64::from(x)).sum();
- let idle = u64::from(info.cpu_ticks[libc::CPU_STATE_IDLE as usize]);
- Some((total - idle, total))
- }
- }
- #[cfg(not(target_os = "macos"))]
- {
- None
- }
-}
-
-/// The stuck-main-thread self-sampler (`WOW_STALL_SAMPLE=0` to disable; macOS only): a watchdog
-/// thread notices the main-thread heartbeat has gone stale and shells the stock profiler
-/// (`/usr/bin/sample`) at our own PID, so an intermittent stall or teardown hang **diagnoses
-/// itself the next time it happens** — on anyone's run, no reproduction needed. Two bugs that
-/// each lost their reproduction motivated it (decision 0713): the ~1 s silent frame stalls at
-/// the BWL pin, and the on-close beachball the director had to force-quit — where even the probe
-/// backstop's `process::exit(0)` can wedge, because libc `exit(3)` runs the same atexit teardown
-/// the hang may own. Samples land in `~/Library/Logs/benilla/` and the path prints on stderr
-/// (the tracing subscriber may already be gone during teardown).
-///
-/// After a teardown sample, an [`EXIT_KILL_MS`](stall_sample::EXIT_KILL_MS) backstop `_exit(0)`
-/// ends the wedged process — diagnosis first, then the force-quit the director would otherwise
-/// perform by hand. `libc::_exit`, never `process::exit`: it skips the atexit handlers.
-///
-/// Verification is end-to-end via two injectors (used by the 0713 probe rounds, kept as standing
-/// test affordances): `WOW_STALL_INJECT=:` sleeps the main thread mid-run, and
-/// `WOW_TEARDOWN_INJECT=` wedges World drop via a resource whose `Drop` sleeps — the
-/// director's beachball, synthetically.
-#[cfg(target_os = "macos")]
-mod stall_sample {
- use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
- use std::sync::OnceLock;
- use std::time::{Duration, Instant};
-
- use bevy::prelude::*;
- use bevy::time::Real;
-
- /// The monotonic epoch every heartbeat is measured against. `Instant`, not `SystemTime`:
- /// a wall-clock step (NTP) must not fake a stall — an instrument that lies once is poison.
- static START: OnceLock = OnceLock::new();
- /// Last main-thread heartbeat, ms since [`START`]. 0 = no full frame yet, watchdog stays
- /// quiet — startup (shader compiles, first loads) never false-positives.
- static HEARTBEAT_MS: AtomicU64 = AtomicU64::new(0);
- /// Latched the frame `AppExit` is written: switches the watchdog to the teardown threshold
- /// and arms the post-sample `_exit` backstop.
- static EXITING: AtomicBool = AtomicBool::new(false);
-
- /// In-frame staleness that means a real stall (ms) — above the ~250 ms loading-screen
- /// hitches, below the ~1030 ms stall class it exists to catch.
- const STALL_MS: u64 = 600;
- /// In-frame sampling stays disarmed this long after launch: startup/login legitimately
- /// stalls past [`STALL_MS`] (a 683 ms hitch at 1.7 s uptime, caught by the 0713 pristine-run
- /// gate), and the classes this instrument hunts are steady-state. Teardown is unaffected.
- const STARTUP_GRACE_MS: u64 = 15_000;
- /// Post-`AppExit` staleness that means teardown is wedged, not merely slow (ms). A clean
- /// teardown is sub-second (26/26 measured exit cycles); the probe hard-exit backstop sits
- /// at 5 s, so a 2.5 s trigger + 2 s capture completes before it.
- const EXIT_STALL_MS: u64 = 2_500;
- /// Teardown backstop: still alive this long after `AppExit` (sample already taken) → `_exit`.
- const EXIT_KILL_MS: u64 = 8_000;
- /// Rate limit: at most this many samples per run, at least [`SAMPLE_GAP_MS`] apart.
- const SAMPLE_CAP: u32 = 5;
- const SAMPLE_GAP_MS: u64 = 20_000;
-
- fn since_start_ms() -> u64 {
- START.get().map_or(0, |s| s.elapsed().as_millis() as u64)
- }
-
- /// `Last`-schedule heartbeat: stamp the clock; latch [`EXITING`] the frame the app decides
- /// to quit (no later frame will run to unlatch anything).
- fn beat(mut exits: MessageReader) {
- if exits.read().next().is_some() {
- EXITING.store(true, Ordering::SeqCst);
- }
- HEARTBEAT_MS.store(since_start_ms().max(1), Ordering::SeqCst);
- }
-
- pub(super) fn plugin(app: &mut App) {
- if std::env::var("WOW_STALL_SAMPLE").is_ok_and(|v| v == "0") {
- return;
- }
- let Ok(home) = std::env::var("HOME") else {
- return;
- };
- let dir = std::path::PathBuf::from(home).join("Library/Logs/benilla");
- let _ = std::fs::create_dir_all(&dir);
- START
- .set(Instant::now())
- .expect("stall_sample plugin built twice");
- app.add_systems(Last, beat);
- std::thread::Builder::new()
- .name("stall-sample".into())
- .spawn(move || watchdog(&dir))
- .expect("spawn stall-sample watchdog");
-
- // The injectors (doc above): a mid-run main-thread sleep, and a wedged World drop.
- if let Some((at, ms)) = std::env::var("WOW_STALL_INJECT")
- .ok()
- .and_then(|v| v.split_once(':').map(|(a, m)| (a.to_owned(), m.to_owned())))
- .and_then(|(a, m)| Some((a.parse::().ok()?, m.parse::().ok()?)))
- {
- app.insert_resource(StallInject {
- at,
- ms,
- fired: false,
- })
- .add_systems(Update, stall_inject);
- }
- if let Ok(ms) = std::env::var("WOW_TEARDOWN_INJECT") {
- if let Ok(ms) = ms.parse::() {
- app.insert_resource(TeardownWedge(ms));
- }
- }
- }
-
- fn watchdog(dir: &std::path::Path) {
- let pid = std::process::id().to_string();
- let mut taken = 0u32;
- let mut last_sample_ms = 0u64;
- let mut exit_seen_ms = 0u64;
- loop {
- std::thread::sleep(Duration::from_millis(100));
- let hb = HEARTBEAT_MS.load(Ordering::SeqCst);
- if hb == 0 {
- continue;
- }
- let now = since_start_ms();
- let exiting = EXITING.load(Ordering::SeqCst);
- if exiting && exit_seen_ms == 0 {
- exit_seen_ms = now;
- }
- if exiting && now.saturating_sub(exit_seen_ms) > EXIT_KILL_MS {
- eprintln!(
- "stall-sample: teardown still wedged {} ms after AppExit — _exit(0)",
- now.saturating_sub(exit_seen_ms)
- );
- use std::io::Write;
- let _ = std::io::stdout().flush();
- // SAFETY: terminates the process immediately; no locks, no atexit — the whole
- // point, since the wedge may own both.
- unsafe { libc::_exit(0) };
- }
- let age = now.saturating_sub(hb);
- if !exiting && now < STARTUP_GRACE_MS {
- continue;
- }
- let threshold = if exiting { EXIT_STALL_MS } else { STALL_MS };
- if age > threshold
- && taken < SAMPLE_CAP
- // The gap gates *between* samples only — gating the first one against
- // `last_sample_ms = 0` would silently forbid any sample in the first
- // [`SAMPLE_GAP_MS`] of process uptime (caught by the 0713 injector runs).
- && (taken == 0 || now.saturating_sub(last_sample_ms) > SAMPLE_GAP_MS)
- {
- taken += 1;
- last_sample_ms = now;
- let file = dir.join(format!(
- "stall-{}{}.txt",
- std::process::id(),
- if exiting {
- format!("-teardown-{now}")
- } else {
- format!("-{now}")
- }
- ));
- eprintln!(
- "stall-sample: main thread stale {age} ms{} — sampling to {}",
- if exiting { " (teardown)" } else { "" },
- file.display()
- );
- // 1 s in-frame so the stall dominates its own capture (a ~1 s stall in a 2 s
- // window is half idle); 2 s for teardown, where the wedge holds the whole time.
- let _ = std::process::Command::new("/usr/bin/sample")
- .arg(&pid)
- .arg(if exiting { "2" } else { "1" })
- .arg("-file")
- .arg(&file)
- .status();
- }
- }
- }
-
- /// `WOW_STALL_INJECT=:` — one deliberate main-thread sleep, mid-run.
- #[derive(Resource)]
- struct StallInject {
- at: f32,
- ms: u64,
- fired: bool,
- }
-
- fn stall_inject(mut inject: ResMut, time: Res>) {
- if !inject.fired && time.elapsed_secs() >= inject.at {
- inject.fired = true;
- warn!("stall-inject: sleeping the main thread {} ms", inject.ms);
- std::thread::sleep(Duration::from_millis(inject.ms));
- }
- }
-
- /// `WOW_TEARDOWN_INJECT=` — wedge World drop: this resource's `Drop` runs on the main
- /// thread during `App` teardown and sleeps there, reproducing the beachball synthetically.
- #[derive(Resource)]
- struct TeardownWedge(u64);
-
- impl Drop for TeardownWedge {
- fn drop(&mut self) {
- eprintln!("teardown-inject: wedging World drop {} ms", self.0);
- std::thread::sleep(Duration::from_millis(self.0));
- }
- }
-}
-
-/// `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.
-fn count_mesh_events(
- mut events: MessageReader>,
- time: Res,
- mut acc: Local<(f32, u32, u32, u32, Vec)>,
-) {
- let (last, added, modified, removed, sample) = &mut *acc;
- for e in events.read() {
- match e {
- bevy::asset::AssetEvent::Added { .. } => *added += 1,
- bevy::asset::AssetEvent::Modified { id } => {
- *modified += 1;
- if sample.len() < 4 {
- sample.push(format!("{id:?}"));
- }
- }
- bevy::asset::AssetEvent::Removed { .. } | bevy::asset::AssetEvent::Unused { .. } => {
- *removed += 1;
- }
- bevy::asset::AssetEvent::LoadedWithDependencies { .. } => {}
- }
- }
- if time.elapsed_secs() - *last >= 1.0 {
- eprintln!(
- "[mesh-events] added={added} modified={modified} removed={removed}/s sample={sample:?}"
- );
- (*added, *modified, *removed) = (0, 0, 0);
- sample.clear();
- *last = time.elapsed_secs();
- }
-}
-
-/// When the one-shot archetype census fires (seconds of `Time` elapsed; `f32::MAX` = spent).
-#[derive(Resource)]
-struct ArchCensusAt(f32);
-
-/// The census itself (`WOW_ARCH_CENSUS`): exclusive, so it sees every archetype of the live
-/// world in one stop. Component paths are trimmed to their last two segments — the census reads
-/// as lanes, not as imports.
-fn arch_census(world: &mut World) {
- let due = world.resource::().0;
- if world.resource::().elapsed_secs() < due {
- return;
- }
- world.resource_mut::().0 = f32::MAX;
- let short = |full: &str| -> String {
- let base = full.split('<').next().unwrap_or(full);
- let segs: Vec<&str> = base.split("::").collect();
- segs[segs.len().saturating_sub(2)..].join("::")
- };
- let mut rows: Vec<(u32, String)> = world
- .archetypes()
- .iter()
- .filter(|a| !a.is_empty())
- .map(|a| {
- let mut names: Vec = a
- .components()
- .iter()
- .filter_map(|&c| world.components().get_info(c))
- .map(|i| short(&i.name().to_string()))
- .collect();
- names.sort();
- (a.len(), names.join("+"))
- })
- .collect();
- rows.sort_by_key(|r| std::cmp::Reverse(r.0));
- let total: u32 = rows.iter().map(|r| r.0).sum();
- eprintln!(
- "[census] {} entities across {} archetypes",
- total,
- rows.len()
- );
- for (n, sig) in rows.iter().take(60) {
- eprintln!("[census] {n:>7} {sig}");
- }
-}
-
-/// `WOW_CAM_CHANGED=1`: per-second count of frames whose world-camera `Transform` /
-/// `GlobalTransform` registered as changed (see the plugin registration).
-fn count_camera_changes(
- t_changed: Query<(), (With, Changed)>,
- g_changed: Query<
- (),
- (
- With,
- Changed,
- ),
- >,
- time: Res,
- mut acc: Local<(f32, u32, u32, u32)>,
-) {
- let (last, frames, t_n, g_n) = &mut *acc;
- *frames += 1;
- *t_n += u32::from(!t_changed.is_empty());
- *g_n += u32::from(!g_changed.is_empty());
- if time.elapsed_secs() - *last >= 1.0 {
- eprintln!("[cam-changed] frames={frames} transform={t_n} global={g_n}/s");
- (*frames, *t_n, *g_n) = (0, 0, 0);
- *last = time.elapsed_secs();
- }
-}
diff --git a/crates/benilla-app/src/perf/census.rs b/crates/benilla-app/src/perf/census.rs
new file mode 100644
index 00000000..96cc6409
--- /dev/null
+++ b/crates/benilla-app/src/perf/census.rs
@@ -0,0 +1,108 @@
+//! Env-gated one-shot and per-second counters: the premise-checkers a sizing question needs before
+//! anyone designs a fix for it.
+
+use bevy::prelude::*;
+
+/// `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.
+pub(super) fn count_mesh_events(
+ mut events: MessageReader>,
+ time: Res,
+ mut acc: Local<(f32, u32, u32, u32, Vec)>,
+) {
+ let (last, added, modified, removed, sample) = &mut *acc;
+ for e in events.read() {
+ match e {
+ bevy::asset::AssetEvent::Added { .. } => *added += 1,
+ bevy::asset::AssetEvent::Modified { id } => {
+ *modified += 1;
+ if sample.len() < 4 {
+ sample.push(format!("{id:?}"));
+ }
+ }
+ bevy::asset::AssetEvent::Removed { .. } | bevy::asset::AssetEvent::Unused { .. } => {
+ *removed += 1;
+ }
+ bevy::asset::AssetEvent::LoadedWithDependencies { .. } => {}
+ }
+ }
+ if time.elapsed_secs() - *last >= 1.0 {
+ eprintln!(
+ "[mesh-events] added={added} modified={modified} removed={removed}/s sample={sample:?}"
+ );
+ (*added, *modified, *removed) = (0, 0, 0);
+ sample.clear();
+ *last = time.elapsed_secs();
+ }
+}
+
+/// When the one-shot archetype census fires (seconds of `Time` elapsed; `f32::MAX` = spent).
+#[derive(Resource)]
+pub(super) struct ArchCensusAt(pub(super) f32);
+
+/// The census itself (`WOW_ARCH_CENSUS`): exclusive, so it sees every archetype of the live
+/// world in one stop. Component paths are trimmed to their last two segments — the census reads
+/// as lanes, not as imports.
+pub(super) fn arch_census(world: &mut World) {
+ let due = world.resource::().0;
+ if world.resource::().elapsed_secs() < due {
+ return;
+ }
+ world.resource_mut::().0 = f32::MAX;
+ let short = |full: &str| -> String {
+ let base = full.split('<').next().unwrap_or(full);
+ let segs: Vec<&str> = base.split("::").collect();
+ segs[segs.len().saturating_sub(2)..].join("::")
+ };
+ let mut rows: Vec<(u32, String)> = world
+ .archetypes()
+ .iter()
+ .filter(|a| !a.is_empty())
+ .map(|a| {
+ let mut names: Vec = a
+ .components()
+ .iter()
+ .filter_map(|&c| world.components().get_info(c))
+ .map(|i| short(&i.name().to_string()))
+ .collect();
+ names.sort();
+ (a.len(), names.join("+"))
+ })
+ .collect();
+ rows.sort_by_key(|r| std::cmp::Reverse(r.0));
+ let total: u32 = rows.iter().map(|r| r.0).sum();
+ eprintln!(
+ "[census] {} entities across {} archetypes",
+ total,
+ rows.len()
+ );
+ for (n, sig) in rows.iter().take(60) {
+ eprintln!("[census] {n:>7} {sig}");
+ }
+}
+
+/// `WOW_CAM_CHANGED=1`: per-second count of frames whose world-camera `Transform` /
+/// `GlobalTransform` registered as changed (see the plugin registration).
+pub(super) fn count_camera_changes(
+ t_changed: Query<(), (With, Changed)>,
+ g_changed: Query<
+ (),
+ (
+ With,
+ Changed,
+ ),
+ >,
+ time: Res,
+ mut acc: Local<(f32, u32, u32, u32)>,
+) {
+ let (last, frames, t_n, g_n) = &mut *acc;
+ *frames += 1;
+ *t_n += u32::from(!t_changed.is_empty());
+ *g_n += u32::from(!g_changed.is_empty());
+ if time.elapsed_secs() - *last >= 1.0 {
+ eprintln!("[cam-changed] frames={frames} transform={t_n} global={g_n}/s");
+ (*frames, *t_n, *g_n) = (0, 0, 0);
+ *last = time.elapsed_secs();
+ }
+}
diff --git a/crates/benilla-app/src/perf/clock.rs b/crates/benilla-app/src/perf/clock.rs
new file mode 100644
index 00000000..ddfd2893
--- /dev/null
+++ b/crates/benilla-app/src/perf/clock.rs
@@ -0,0 +1,132 @@
+//! The CPU clocks the instruments are denominated in — process, main thread, and machine.
+//!
+//! Three different questions, three different calls. [`process_cpu_secs`] is the campaign's
+//! currency (decisions 0711/0736/0717): *work per frame*, immune to the present grant.
+//! [`main_thread_cpu_secs`] narrows that to the serialized half — the part a hitch is actually made
+//! of. [`system_cpu_ticks`] says how loaded the machine was while we measured, so two legs can be
+//! told apart from two moods of the same leg (1157).
+
+/// Whole-process CPU seconds consumed so far — **user + system, summed across every thread**
+/// (`getrusage(RUSAGE_SELF)`).
+///
+/// The perf probes report wall-clock frame time, which on this machine is not a usable regression
+/// instrument: parallel session worktrees build on the same 14 cores, and two identical probe runs
+/// of the same pin came back 49.6 ms and 28.6 ms apart purely on machine load. CPU-per-frame moves
+/// with the work we actually do, not with who else is compiling — and it is the metric the Mac
+/// report is written in ("250 % CPU at 59 fps" against 1.12.1's "100 % at 160"), so a probe that
+/// prints it can be compared against a reporter's number directly.
+///
+/// Non-unix returns `None`: the probes print the field only where the platform answers.
+pub(crate) fn process_cpu_secs() -> Option {
+ #[cfg(unix)]
+ {
+ // SAFETY: `getrusage` writes a fully-initialized `rusage` into the out-param and reads
+ // nothing from it; zeroed is a valid starting value for a C struct of plain integers.
+ unsafe {
+ let mut ru: libc::rusage = std::mem::zeroed();
+ if libc::getrusage(libc::RUSAGE_SELF, &mut ru) != 0 {
+ return None;
+ }
+ let secs = |t: libc::timeval| t.tv_sec as f64 + t.tv_usec as f64 * 1e-6;
+ Some(secs(ru.ru_utime) + secs(ru.ru_stime))
+ }
+ }
+ #[cfg(not(unix))]
+ {
+ None
+ }
+}
+
+/// CPU seconds consumed by **the calling thread** (`CLOCK_THREAD_CPUTIME_ID`).
+///
+/// The twin [`process_cpu_secs`] cannot answer the question a hitch actually poses. It sums every
+/// thread, so a frame in which an asset worker spent 12 ms decompressing reads identically to a
+/// frame in which the main thread blocked for 12 ms — the first costs nothing the player can see,
+/// the second *is* the stutter. Narrowing to one thread separates them.
+///
+/// **Only meaningful when called from the thread you mean.** Bevy's executor runs systems across
+/// the task pool, so a caller that wants the *main* thread's number must pin itself there with a
+/// [`NonSendMarker`](bevy::ecs::system::NonSendMarker) param; without it the reading silently
+/// becomes "whichever worker happened to run this system", which is noise shaped like a
+/// measurement.
+///
+/// Non-unix returns `None`, like its twin.
+pub(crate) fn main_thread_cpu_secs() -> Option {
+ #[cfg(unix)]
+ {
+ // SAFETY: `clock_gettime` writes a fully-initialized `timespec` into the out-param and
+ // reads nothing from it; zeroed is a valid starting value for two plain integers.
+ unsafe {
+ let mut ts: libc::timespec = std::mem::zeroed();
+ if libc::clock_gettime(libc::CLOCK_THREAD_CPUTIME_ID, &mut ts) != 0 {
+ return None;
+ }
+ Some(ts.tv_sec as f64 + ts.tv_nsec as f64 * 1e-9)
+ }
+ }
+ #[cfg(not(unix))]
+ {
+ None
+ }
+}
+
+/// Cumulative **machine-wide** CPU ticks as `(busy, total)` — every core, every process, us
+/// included (`host_statistics64(HOST_CPU_LOAD_INFO)`). Diff two samples and `busy/total` is the
+/// fraction of all cores that were doing work between them.
+///
+/// Why the probes need it, when [`process_cpu_secs`] above already promises load-independence:
+/// that promise is **weaker than it reads**, and 1157 caught it. `cpu_ms` is per-frame work rather
+/// than wall time, so it shrugs off the scheduling delay that makes frame time useless under load
+/// — but it is not immune. The same LBRS pin leg, same binary, same day, read **25.93** while two
+/// other slots ran `cargo test --workspace` (load 20-32 on 14 cores) and **18.80-20.12** on a quiet
+/// machine: a ~35 % inflation, far outside the ±0.4 band the campaign reasons in (0736). Cache and
+/// memory-bandwidth contention is work we genuinely do; `getrusage` counts it and cannot tell us
+/// it was someone else's fault.
+///
+/// So the leg has to say how loaded the machine was, and the reader compares. Deliberately a
+/// **stamp, not a gate** (the director's call): no threshold is invented here. Two legs at similar
+/// `sys_busy_pct` are comparable; two legs far apart are not, whatever their `cpu_ms` says. That
+/// is a relative rule, which is the only kind the four calibration points behind 1157 support.
+///
+/// **Do not reach for `uptime` instead.** Load average is a 1-minute *decaying* mean and lags
+/// badly: a leg stamped 99 % here was taken at load 5.55, seconds after a build burst the average
+/// had not caught up with. Cross-checked against an independent out-of-process sampler over the
+/// same window — 44 % vs 44.5 % — so where the two disagree, this is the one that is right.
+///
+/// Non-macOS returns `None`: the probes print the field only where the platform answers.
+///
+/// The `deprecated` allow is `libc::mach_host_self`, whose deprecation note says "use the `mach2`
+/// crate instead". Checked, and it does not apply here: `mach2` 0.5.0 carries `mach_host_self` and
+/// **nothing else this call needs** — no `host_statistics64`, no `host_cpu_load_info`, no
+/// `HOST_CPU_LOAD_INFO`. Taking the advice would split one call across two crates and add a direct
+/// dependency to get the *port* while `libc` still supplies the call it is passed to. Revisit if
+/// `mach2` ever grows the host-statistics surface.
+#[allow(deprecated)]
+pub(crate) fn system_cpu_ticks() -> Option<(u64, u64)> {
+ #[cfg(target_os = "macos")]
+ {
+ // SAFETY: `host_statistics64` fills at most `count` u32s into the out-param and reads
+ // nothing from it; `host_cpu_load_info` is plain integers, so zeroed is a valid starting
+ // value. `mach_host_self` returns the global host port — no send right to deallocate.
+ unsafe {
+ let mut info: libc::host_cpu_load_info = std::mem::zeroed();
+ let mut count = libc::HOST_CPU_LOAD_INFO_COUNT;
+ if libc::host_statistics64(
+ libc::mach_host_self(),
+ libc::HOST_CPU_LOAD_INFO,
+ (&raw mut info).cast(),
+ &mut count,
+ ) != libc::KERN_SUCCESS
+ {
+ return None;
+ }
+ let total: u64 = info.cpu_ticks.iter().map(|&x| u64::from(x)).sum();
+ let idle = u64::from(info.cpu_ticks[libc::CPU_STATE_IDLE as usize]);
+ Some((total - idle, total))
+ }
+ }
+ #[cfg(not(target_os = "macos"))]
+ {
+ None
+ }
+}
diff --git a/crates/benilla-app/src/perf/hud.rs b/crates/benilla-app/src/perf/hud.rs
new file mode 100644
index 00000000..5ef7524a
--- /dev/null
+++ b/crates/benilla-app/src/perf/hud.rs
@@ -0,0 +1,485 @@
+//! The standing dev HUD: a collapsed cost pill you can read while playing, and the full readout
+//! behind a click.
+//!
+//! **The collapsed pill covers three timescales, because a regression can arrive on any of them.**
+//! *Now* is the cost number. *The last few seconds* is the latched spike badge — 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. *The last minute* is the sparkline, which is the only lane that can see cost merely
+//! sitting higher than it used to.
+//!
+//! **What is deliberately no longer the headline: fps.** 0717 established that while synced, wall
+//! frame time measures the display's present grant rather than our cost — but it only ever applied
+//! that to the expanded lines, and the pill went on reading framerate. On a 120 Hz-adaptive panel
+//! that hides a doubling: cost can go 3 → 6 ms with the grant unchanged and the number unmoved, and
+//! the old red threshold (fps < 58) sat ~5.7× above a healthy frame's cost. fps stays on the pill,
+//! dimmed, as the familiar anchor it is — not as the thing being watched.
+//!
+//! **Detail lives in the hover tooltip, not in the expanded panel.** Hovering costs zero screen,
+//! which is the whole point: the expanded view covers the game's own UI, so anything you might want
+//! *while playing* has to be reachable without it.
+
+use bevy::diagnostic::{DiagnosticsStore, EntityCountDiagnosticsPlugin};
+use bevy::prelude::*;
+use bevy::render::view::Msaa;
+use bevy::time::Real;
+use bevy::window::{PresentMode, PrimaryWindow};
+use bevy_egui::{egui, EguiContexts};
+
+use super::stats::{FrameStats, Series};
+use super::FRAME_BUDGET_MS;
+use crate::debug_panel::{overlay_text, OVERLAY_FILL, OVERLAY_TEXT, OVERLAY_TEXT_DIM};
+use benilla_world::view::WorldCamera;
+
+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 sparkline's footprint on the collapsed pill.
+const TREND_SIZE: egui::Vec2 = egui::vec2(72.0, 13.0);
+
+/// 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.
+///
+/// **`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
+/// booked as "est 0.4–1.2 ms CPU + unquantified GPU" — an estimate, never a measurement, because
+/// nothing could turn the fixture off without also changing the binary. One env var makes it an
+/// interleaved A/B on *one* binary instead (`scripts/leg.sh`), so the constant baked into every
+/// anchor becomes a number. The meters keep sampling either way: only the drawing stops, which is
+/// the half being priced.
+#[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,
+}
+
+impl Default for PerfHud {
+ fn default() -> Self {
+ Self {
+ visible: std::env::var("WOW_PERF_HUD").as_deref() != Ok("0"),
+ expanded: false,
+ }
+ }
+}
+
+pub(super) fn toggle_hud(keys: Res>, mut hud: ResMut) {
+ // The dev chord + `P`, not a bare `p` — `P` is the reference's TOGGLESPELLBOOK, and a dev
+ // doesn't get to squat on a game binding (decision 0585). The chord can't be mistaken for typed
+ // text, so unlike the old bare key it needs no chat-bar/EditBox gate.
+ if benilla_world::modkeys::dev_chord(&keys, KeyCode::KeyP) {
+ hud.visible = !hud.visible;
+ }
+}
+
+pub(super) fn perf_hud_ui(
+ mut contexts: EguiContexts,
+ mut hud: ResMut,
+ stats: Res,
+ time: Res>,
+ diagnostics: Res,
+ mut windows: Query<&mut Window, With>,
+ cam_msaa: Query<&Msaa, With>,
+) -> Result {
+ if !hud.visible {
+ return Ok(());
+ }
+ let ctx = contexts.ctx_mut()?;
+ let now = time.elapsed_secs();
+ let synced = windows
+ .single()
+ .map(|w| synced_mode(w.present_mode))
+ .unwrap_or(true);
+
+ // 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.
+ 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| {
+ overlay_text(ui);
+ if collapsed_pill(ui, &stats, now).clicked() {
+ hud.expanded = !hud.expanded;
+ }
+ if !hud.expanded {
+ return;
+ }
+ ui.separator();
+ expanded_readout(ui, &stats, &diagnostics, synced);
+ ui.separator();
+ present_controls(ui, &mut windows, &cam_msaa);
+ });
+ Ok(())
+}
+
+/// The collapsed pill: fps (dim), the cost headline, the minute-long trend, and the latched spike.
+/// The whole row is one click target.
+fn collapsed_pill(ui: &mut egui::Ui, stats: &FrameStats, now: f32) -> egui::Response {
+ let spike = stats.spike(now);
+ let row = ui
+ .horizontal(|ui| {
+ ui.spacing_mut().item_spacing.x = 8.0;
+
+ // fps stays, dimmed: the familiar anchor, and by construction the number that cannot
+ // see any of this.
+ ui.label(
+ egui::RichText::new(format!("{:.0} fps", stats.fps()))
+ .color(OVERLAY_TEXT_DIM)
+ .monospace(),
+ );
+
+ // 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(),
+ ),
+ None => ui.label(
+ egui::RichText::new("-- ms")
+ .color(OVERLAY_TEXT_DIM)
+ .monospace(),
+ ),
+ };
+
+ trend_sparkline(ui, &stats.trend);
+
+ // 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(format!(
+ "▲{:.1} {} ×{}",
+ s.peak_ms,
+ s.kind.tag(),
+ s.frames
+ ))
+ .color(col)
+ .monospace()
+ .strong(),
+ );
+ }
+ })
+ .response
+ .interact(egui::Sense::click());
+
+ row.on_hover_ui(|ui| pill_tooltip(ui, stats, now))
+}
+
+/// The minute-long trend of per-second median CPU cost. 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) {
+ let (rect, _) = ui.allocate_exact_size(TREND_SIZE, egui::Sense::hover());
+ if trend.len() < 2 {
+ return;
+ }
+ let painter = ui.painter_at(rect);
+ let hi = trend.iter().fold(f32::MIN, f32::max).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 = 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)));
+}
+
+/// Everything the pill cannot fit, on hover — the affordance that makes the expanded panel
+/// unnecessary while playing.
+fn pill_tooltip(ui: &mut egui::Ui, stats: &FrameStats, now: f32) {
+ ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
+
+ let row = |ui: &mut egui::Ui, name: &str, s: &Series| {
+ let (p50, p99, max) = s.percentiles();
+ ui.label(
+ egui::RichText::new(format!(
+ "{name:<5} p50 {p50:>6.2} p99 {p99:>6.2} max {max:>6.2} ms"
+ ))
+ .monospace(),
+ );
+ };
+ ui.label(
+ egui::RichText::new("per-frame cost over the last ~300 frames")
+ .color(OVERLAY_TEXT_DIM)
+ .monospace(),
+ );
+ row(ui, "cpu", &stats.cpu);
+ row(ui, "main", &stats.main);
+ row(ui, "wall", &stats.wall);
+
+ ui.separator();
+ let rail = stats.rail_ms();
+ let (dropped, frac) = stats.wall.frames_over(stats.dropped_above_ms());
+ ui.label(
+ egui::RichText::new(format!(
+ "interval {rail:.2} ms (observed) · missed {dropped}/{} ({:.0}%)",
+ stats.wall.len(),
+ frac * 100.0
+ ))
+ .monospace(),
+ );
+
+ if let Some((first, last)) = stats.trend_ends() {
+ let delta = last - first;
+ ui.label(
+ egui::RichText::new(format!(
+ "trend {first:.1} → {last:.1} ms ({delta:+.1}) over the last minute"
+ ))
+ .color(if delta > first * 0.15 {
+ AMBER
+ } else {
+ OVERLAY_TEXT
+ })
+ .monospace(),
+ );
+ }
+
+ match stats.spike(now) {
+ Some(s) => {
+ ui.separator();
+ ui.label(
+ egui::RichText::new(format!(
+ "spike {:.2} ms peak over a {:.2} ms baseline, {} frame(s), {:.1}s ago",
+ s.peak_ms,
+ s.baseline_ms,
+ s.frames,
+ (now - s.at).max(0.0)
+ ))
+ .color(AMBER)
+ .monospace(),
+ );
+ ui.label(egui::RichText::new(s.kind.describe()).color(OVERLAY_TEXT_DIM));
+ let bursts = stats.recent_bursts(now);
+ if bursts > 1 {
+ ui.label(
+ egui::RichText::new(format!(
+ "{bursts} bursts in the last 10 s — the worst is shown"
+ ))
+ .color(OVERLAY_TEXT_DIM),
+ );
+ }
+ }
+ None => {
+ ui.separator();
+ ui.label(egui::RichText::new("no spike in the last 10 s").color(OVERLAY_TEXT_DIM));
+ }
+ }
+
+ ui.separator();
+ ui.label(egui::RichText::new("click to expand · Ctrl+Shift+P to hide").color(OVERLAY_TEXT_DIM));
+}
+
+fn expanded_readout(
+ ui: &mut egui::Ui,
+ stats: &FrameStats,
+ diagnostics: &DiagnosticsStore,
+ synced: bool,
+) {
+ 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
+ };
+ let last = stats.wall.last();
+ let last_col = if last > red_above { RED } else { GREEN };
+ ui.colored_label(last_col, format!("last {last:>6.2} ms"));
+
+ if synced {
+ ui.label(
+ egui::RichText::new(format!(
+ "present interval {rail:.2} ms observed ({:.0} Hz)",
+ if rail > 0.0 { 1000.0 / rail } else { 0.0 }
+ ))
+ .color(OVERLAY_TEXT_DIM),
+ );
+ } else {
+ ui.label(
+ egui::RichText::new(format!("budget {FRAME_BUDGET_MS:.2} ms · 60 fps floor"))
+ .color(OVERLAY_TEXT_DIM),
+ );
+ }
+
+ for (name, series) in [
+ ("cpu ", &stats.cpu),
+ ("main", &stats.main),
+ ("wall", &stats.wall),
+ ] {
+ let (p50, p99, max) = series.percentiles();
+ ui.label(format!(
+ "{name} p50 {p50:>6.2} p99 {p99:>6.2} max {max:>6.2} ms"
+ ));
+ }
+
+ 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: "dropped" = 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.colored_label(
+ ob_col,
+ format!(
+ "{} {over_n}/{} ({:.0}%)",
+ if synced { "dropped" } else { "over budget" },
+ stats.wall.len(),
+ over_frac * 100.0
+ ),
+ )
+ .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)"
+ });
+
+ // ~6 ms is the 1.12.1 reference client's whole-scene cost — the long-term bar (0718).
+ if let Some(cpu) = stats.cpu.mean() {
+ let mean_ms = stats.wall.mean().unwrap_or(0.0);
+ let pct = if mean_ms > 0.0 {
+ cpu / mean_ms * 100.0
+ } else {
+ 0.0
+ };
+ ui.label(format!("cpu {cpu:>6.2} ms · {pct:.0}% (all threads)"))
+ .on_hover_text(
+ "process CPU consumed per frame — measures our work, not the display's \
+ present grant; comparable with the probes' cpu_ms / a reporter's CPU %",
+ );
+ }
+ if let Some(main) = stats.main.mean() {
+ ui.label(format!("main {main:>5.2} ms (main thread only)"))
+ .on_hover_text(
+ "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",
+ );
+ }
+ let entities = diagnostics
+ .get(&EntityCountDiagnosticsPlugin::ENTITY_COUNT)
+ .and_then(|d| d.value())
+ .unwrap_or(0.0);
+ ui.label(format!("entities {entities:.0}"));
+
+ frame_graph(ui, stats, red_above);
+}
+
+/// 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(232.0, 52.0), egui::Sense::hover());
+ if stats.wall.is_empty() {
+ 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();
+
+ 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 = 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)));
+}
+
+fn present_controls(
+ ui: &mut egui::Ui,
+ windows: &mut Query<&mut Window, With>,
+ cam_msaa: &Query<&Msaa, With>,
+) {
+ if let Ok(mut window) = windows.single_mut() {
+ let mut vsync = synced_mode(window.present_mode);
+ // "Sync to display", not "cap at 60": on the ProMotion panel the display is ADAPTIVE (up
+ // to 120 Hz — grants ~119 in High Power Mode, pinned to 60 only by macOS power state), so
+ // synced fps floats with frame cost. A floating 70–119 with this ON is vsync working, not
+ // broken (decision 0294's companion finding; measure the grant with WOW_PROBE_VSYNC=1).
+ if ui
+ .checkbox(
+ &mut vsync,
+ "VSync — sync to display (120 adaptive; off = uncap)",
+ )
+ .changed()
+ {
+ // AutoNoVsync, never Immediate: on Metal, explicit Immediate both rails and takes
+ // ~1 s nextDrawable stalls (measured — `capture::probe_uncap_mode`).
+ window.present_mode = if vsync {
+ PresentMode::AutoVsync
+ } else {
+ PresentMode::AutoNoVsync
+ };
+ }
+ }
+ if let Ok(m) = cam_msaa.single() {
+ // Read-only: switching MSAA at runtime breaks our post-process graph (glow/egui) and froze
+ // the view, so MSAA is a startup knob now ($WOW_MSAA=off/2/4); A/B by restarting.
+ let s = m.samples();
+ let txt = if s <= 1 {
+ "off".to_string()
+ } else {
+ format!("{s}×")
+ };
+ ui.label(format!("MSAA {txt} · set $WOW_MSAA to change"));
+ }
+}
diff --git a/crates/benilla-app/src/perf/journal.rs b/crates/benilla-app/src/perf/journal.rs
new file mode 100644
index 00000000..1558a201
--- /dev/null
+++ b/crates/benilla-app/src/perf/journal.rs
@@ -0,0 +1,167 @@
+//! The FPS JOURNAL (`WOW_FPS_JOURNAL=`): once a second, append one row of where the
+//! player is, what the frame cost, and **what is resident** (see [`JOURNAL_HEADER`] for the column
+//! order, written into every fresh file) — the "where does it dip" instrument for a
+//! director-driven run. They play normally; the journal turns "it drops in the Dwarven District"
+//! into coordinates a headless probe can tele straight back to. Negligible cost: one line of IO a
+//! second, samples reused from the frame meters.
+//!
+//! The residency columns make it the **leak curve** instrument too (B131): `FPS_PROBE`'s residency
+//! meter samples once per run, which can only compare two runs at one point each — it cannot tell
+//! "grows with distance streamed" from "grows with time elapsed", and cannot show *where* on a
+//! route the cost arrives. A per-second row of `cpu_ms` beside `mats/images/uv/tint` plots the
+//! per-frame cost directly against residency along one continuous leg, on the same time axis as
+//! the position — so a same-map traverse (no `MapChange`, so no map-scoped eviction) shows its
+//! accumulation as a slope instead of a before/after pair.
+
+use bevy::prelude::*;
+use bevy::time::Real;
+
+use super::clock::{main_thread_cpu_secs, process_cpu_secs};
+
+pub(crate) struct FpsJournalPlugin;
+
+/// The journal's column order, written as the first line of a fresh file. Appended-to files keep
+/// whatever header they were created with — the columns only ever grow at the end.
+const JOURNAL_HEADER: &str = "t,x,y,z,mean_ms,p95_ms,streamed,entities,cpu_ms,mats,meshes,images,\
+ m2,uv,tint,pmat,emat,skin,cmat,tex,cgeo,evicted,fx,fy,fz,main_ms\n";
+
+impl Plugin for FpsJournalPlugin {
+ fn build(&self, app: &mut App) {
+ let path = std::env::var("WOW_FPS_JOURNAL").unwrap_or_default();
+ // The header goes in exactly once, at creation: the rows are appended for the life of the
+ // run (and across runs, deliberately — a journal accumulates legs).
+ if !path.is_empty() && !std::path::Path::new(&path).exists() {
+ let _ = std::fs::write(&path, JOURNAL_HEADER);
+ }
+ app.insert_resource(FpsJournal {
+ path,
+ window: Vec::new(),
+ last_flush: 0.0,
+ cpu_at_flush: process_cpu_secs(),
+ main_at_flush: None,
+ })
+ .add_systems(Update, journal_fps);
+ }
+}
+
+#[derive(Resource)]
+struct FpsJournal {
+ path: String,
+ window: Vec,
+ last_flush: f32,
+ /// Process CPU seconds at the previous flush — the row's `cpu_ms` is this second's CPU cost
+ /// per frame, the load-robust half of the measurement.
+ cpu_at_flush: Option,
+ /// Main-thread CPU seconds at the previous flush, for the row's `main_ms`. Exactly parallel to
+ /// `cpu_at_flush`, so the two columns are the same measurement at two scopes: `cpu_ms` is every
+ /// thread's work, `main_ms` the serialized part of it. A leg where they diverge is a leg whose
+ /// cost moved off (or onto) the critical path — which the all-threads column alone cannot say.
+ main_at_flush: Option,
+}
+
+/// The journal's residency columns, grouped because `journal_fps` is near Bevy's system-param
+/// arity limit.
+///
+/// The `Assets` counts are the totals — what the process holds. The [`ArtCensus`] half is the
+/// same population **broken down by the cache that holds it** (decision 0793), which is what turns
+/// "materials are growing" into a named holder in one row instead of a run-length probe. `evicted`
+/// is the running total dropped by distance: on a same-map traverse it was structurally zero before
+/// 0793, because nothing but a `MapChange` evicted anything (0729).
+///
+/// [`ArtCensus`]: benilla_world::art_scope::ArtCensus
+#[derive(bevy::ecs::system::SystemParam)]
+struct JournalResidency<'w> {
+ mats: Res<'w, Assets>,
+ meshes: Res<'w, Assets>,
+ images: Res<'w, Assets>,
+ m2: Res<'w, Assets>,
+ uv_reg: Res<'w, benilla_world::doodad_anim::UvAnimMaterials>,
+ tint_reg: Res<'w, benilla_world::doodad_anim::TintAnimMaterials>,
+ art: Res<'w, benilla_world::art_scope::ArtCensus>,
+ /// The **view focus** — where art is actually being asked for. Distinct from the row's `x,y,z`,
+ /// which is the avatar: through a detached free-fly the body stands still while the camera covers
+ /// kilometres, so on that leg the position columns describe nothing that is happening. The
+ /// director's first run was exactly that leg, and reading it needed this column.
+ scope: Res<'w, benilla_world::art_scope::ArtScopeState>,
+}
+
+/// `NonSendMarker` pins this to the main thread, which the `main_ms` column requires:
+/// [`main_thread_cpu_secs`] reports *the calling thread*, so on a worker it would silently log
+/// whichever pool thread ran the flush.
+fn journal_fps(
+ _pin_to_main_thread: bevy::ecs::system::NonSendMarker,
+ mut journal: ResMut,
+ time: Res>,
+ player: Option>,
+ streamed: Query<(), With>,
+ entities: Query<()>,
+ residency: JournalResidency,
+) {
+ let now = time.elapsed_secs();
+ journal.window.push(time.delta_secs() * 1000.0);
+ if now - journal.last_flush < 1.0 {
+ return;
+ }
+ journal.last_flush = now;
+ let mut v = std::mem::take(&mut journal.window);
+ if v.is_empty() {
+ return;
+ }
+ v.sort_by(f32::total_cmp);
+ let mean = v.iter().sum::() / v.len() as f32;
+ let p95 = v[((v.len() - 1) as f32 * 0.95).round() as usize];
+ // Raw WoW coords, so the line pastes straight into a `.go xyz` probe.
+ let pos = player
+ .filter(|p| p.active)
+ .map(|p| benilla_assets::coords::bevy_to_wow(p.pos))
+ .unwrap_or([0.0; 3]);
+ // CPU per frame over this second — the number the reporter's "CPU %" compares against, and
+ // the one that does not move with whatever else is compiling on this machine.
+ let cpu_now = process_cpu_secs();
+ let cpu_ms = match (journal.cpu_at_flush, cpu_now) {
+ (Some(t0), Some(t1)) => format!("{:.2}", (t1 - t0) * 1000.0 / v.len() as f64),
+ _ => String::new(),
+ };
+ journal.cpu_at_flush = cpu_now;
+ let mut line = format!(
+ "{now:.1},{:.1},{:.1},{:.1},{mean:.2},{p95:.2},{},{},{cpu_ms},{},{},{},{},{},{}",
+ pos[0],
+ pos[1],
+ pos[2],
+ streamed.iter().len(),
+ entities.iter().len(),
+ residency.mats.len(),
+ residency.meshes.len(),
+ residency.images.len(),
+ residency.m2.len(),
+ residency.uv_reg.0.len(),
+ residency.tint_reg.0.len(),
+ );
+ // The per-cache breakdown, in `ArtSlot::ALL` order — which IS the header's column order.
+ for slot in benilla_world::art_scope::ArtSlot::ALL {
+ line.push_str(&format!(",{}", residency.art.live(slot)));
+ }
+ line.push_str(&format!(",{}", residency.art.dropped_total()));
+ match residency.scope.focus() {
+ Some(f) => line.push_str(&format!(",{:.1},{:.1},{:.1}", f[0], f[1], f[2])),
+ None => line.push_str(",,,"),
+ }
+ // Appended at the end, per this file's own rule: the columns only ever grow there, so an
+ // existing journal keeps parsing against the header it was created with.
+ let main_now = main_thread_cpu_secs();
+ match (journal.main_at_flush, main_now) {
+ (Some(t0), Some(t1)) => {
+ line.push_str(&format!(",{:.2}\n", (t1 - t0) * 1000.0 / v.len() as f64))
+ }
+ _ => line.push_str(",\n"),
+ }
+ journal.main_at_flush = main_now;
+ use std::io::Write;
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&journal.path)
+ {
+ let _ = f.write_all(line.as_bytes());
+ }
+}
diff --git a/crates/benilla-app/src/perf/mod.rs b/crates/benilla-app/src/perf/mod.rs
new file mode 100644
index 00000000..4094e098
--- /dev/null
+++ b/crates/benilla-app/src/perf/mod.rs
@@ -0,0 +1,120 @@
+//! `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).
+//!
+//! 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,
+//! - [`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.
+//!
+//! **The law the whole surface obeys (0717): while synced, wall frame time measures the display's
+//! present grant, not our cost.** Only the CPU series measure work. That is why the pill's headline
+//! is `cpu ms` and not fps — on a 120 Hz-adaptive panel, cost can double with the granted interval
+//! unchanged and framerate unmoved, and the pill's old red threshold (fps < 58) sat ~5.7× above a
+//! healthy frame. fps is still there, dimmed, as the familiar anchor.
+//!
+//! The budget is a **60 fps floor = 16.7 ms; no frame should exceed it** — but note that the
+//! *missed-interval* threshold is derived from the interval we actually observe, not from this
+//! constant (see [`stats::DROPPED_FACTOR`]). Deep per-system attribution is via Tracy
+//! (`cargo run --features tracy`) — the `info_span!` markers on the hot systems feed it.
+//!
+//! ⚠️ **macOS/Metal — GPU timing.** We have `Features::TIMESTAMP_QUERY` on this machine, but
+//! **`TIMESTAMP_QUERY_INSIDE_PASSES` is `false`**: Apple GPUs sample counters only at stage
+//! boundaries (`MTLCounterSamplingPoint::AtStageBoundary`). Bevy's `RenderDiagnosticsPlugin` writes
+//! its timestamps *inside* passes, so on Apple Silicon every span falls through to the CPU-only
+//! branch and the store carries **zero** `elapsed_gpu` paths — verified on a live run: 14 diagnostic
+//! paths, all `elapsed_cpu`. (bevy_render's own "Vulkan and DX12 only" comment is stale as a
+//! statement about the *platform* — an Intel Mac would report `INSIDE_PASSES` and emit GPU spans.)
+//! Pass-boundary `timestamp_writes` **does** work here and is the route to real GPU-ms — measured
+//! 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`]).
+
+mod census;
+mod clock;
+mod hud;
+mod journal;
+#[cfg(target_os = "macos")]
+mod stall;
+mod stats;
+mod trace;
+
+use bevy::diagnostic::{EntityCountDiagnosticsPlugin, FrameTimeDiagnosticsPlugin};
+use bevy::prelude::*;
+use bevy::render::diagnostic::RenderDiagnosticsPlugin;
+
+pub(crate) use clock::{process_cpu_secs, system_cpu_ticks};
+pub(crate) use hud::PerfHud;
+pub(crate) use journal::FpsJournalPlugin;
+
+/// The frame budget: a 60 fps floor. No frame should exceed this.
+pub const FRAME_BUDGET_MS: f32 = 1000.0 / 60.0;
+
+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::()
+ .init_resource::()
+ .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);
+ // `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
+ // show ~zero here. Printed once a second with the top mutated ids' first sighting.
+ if std::env::var_os("WOW_MESH_EVENTS").is_some() {
+ app.add_systems(Update, census::count_mesh_events);
+ }
+ // `WOW_CAM_CHANGED=1` — is the world camera's transform bit-stable at a parked pin?
+ // The premise counter for gating the per-submesh visibility sweep on frame-stable
+ // camera inputs: if the controller rewrites an equal transform every frame, change
+ // detection fires anyway and the gate can never hold — the controller's no-op write
+ // is then the first fix, not the sweep's.
+ if std::env::var_os("WOW_CAM_CHANGED").is_some() {
+ app.add_systems(bevy::app::PostUpdate, census::count_camera_changes);
+ }
+ // `WOW_ARCH_CENSUS=` — one archetype census at t=secs: every non-empty
+ // archetype's entity count beside its component set, largest first. The exact by-lane
+ // entity picture (1354's anchor census generalized to every lane at once), for sizing
+ // which populations are worth collapsing before designing any collapse.
+ if let Some(at) = std::env::var("WOW_ARCH_CENSUS")
+ .ok()
+ .and_then(|v| v.parse::().ok())
+ {
+ app.insert_resource(census::ArchCensusAt(at));
+ app.add_systems(Last, census::arch_census);
+ }
+ #[cfg(target_os = "macos")]
+ stall::plugin(app);
+ }
+}
diff --git a/crates/benilla-app/src/perf/stall.rs b/crates/benilla-app/src/perf/stall.rs
new file mode 100644
index 00000000..2c34f3e7
--- /dev/null
+++ b/crates/benilla-app/src/perf/stall.rs
@@ -0,0 +1,198 @@
+//! The stuck-main-thread self-sampler (`WOW_STALL_SAMPLE=0` to disable; macOS only): a watchdog
+//! thread notices the main-thread heartbeat has gone stale and shells the stock profiler
+//! (`/usr/bin/sample`) at our own PID, so an intermittent stall or teardown hang **diagnoses
+//! itself the next time it happens** — on anyone's run, no reproduction needed. Two bugs that
+//! each lost their reproduction motivated it (decision 0713): the ~1 s silent frame stalls at
+//! the BWL pin, and the on-close beachball the director had to force-quit — where even the probe
+//! backstop's `process::exit(0)` can wedge, because libc `exit(3)` runs the same atexit teardown
+//! the hang may own. Samples land in `~/Library/Logs/benilla/` and the path prints on stderr
+//! (the tracing subscriber may already be gone during teardown).
+//!
+//! After a teardown sample, an [`EXIT_KILL_MS`] backstop `_exit(0)` ends the wedged process —
+//! diagnosis first, then the force-quit the director would otherwise perform by hand.
+//! `libc::_exit`, never `process::exit`: it skips the atexit handlers.
+//!
+//! Verification is end-to-end via two injectors (used by the 0713 probe rounds, kept as standing
+//! test affordances): `WOW_STALL_INJECT=:` sleeps the main thread mid-run, and
+//! `WOW_TEARDOWN_INJECT=` wedges World drop via a resource whose `Drop` sleeps — the
+//! director's beachball, synthetically.
+
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
+use std::sync::OnceLock;
+use std::time::{Duration, Instant};
+
+use bevy::prelude::*;
+use bevy::time::Real;
+
+/// The monotonic epoch every heartbeat is measured against. `Instant`, not `SystemTime`:
+/// a wall-clock step (NTP) must not fake a stall — an instrument that lies once is poison.
+static START: OnceLock = OnceLock::new();
+/// Last main-thread heartbeat, ms since [`START`]. 0 = no full frame yet, watchdog stays
+/// quiet — startup (shader compiles, first loads) never false-positives.
+static HEARTBEAT_MS: AtomicU64 = AtomicU64::new(0);
+/// Latched the frame `AppExit` is written: switches the watchdog to the teardown threshold
+/// and arms the post-sample `_exit` backstop.
+static EXITING: AtomicBool = AtomicBool::new(false);
+
+/// In-frame staleness that means a real stall (ms) — above the ~250 ms loading-screen
+/// hitches, below the ~1030 ms stall class it exists to catch.
+const STALL_MS: u64 = 600;
+/// In-frame sampling stays disarmed this long after launch: startup/login legitimately
+/// stalls past [`STALL_MS`] (a 683 ms hitch at 1.7 s uptime, caught by the 0713 pristine-run
+/// gate), and the classes this instrument hunts are steady-state. Teardown is unaffected.
+const STARTUP_GRACE_MS: u64 = 15_000;
+/// Post-`AppExit` staleness that means teardown is wedged, not merely slow (ms). A clean
+/// teardown is sub-second (26/26 measured exit cycles); the probe hard-exit backstop sits
+/// at 5 s, so a 2.5 s trigger + 2 s capture completes before it.
+const EXIT_STALL_MS: u64 = 2_500;
+/// Teardown backstop: still alive this long after `AppExit` (sample already taken) → `_exit`.
+const EXIT_KILL_MS: u64 = 8_000;
+/// Rate limit: at most this many samples per run, at least [`SAMPLE_GAP_MS`] apart.
+const SAMPLE_CAP: u32 = 5;
+const SAMPLE_GAP_MS: u64 = 20_000;
+
+fn since_start_ms() -> u64 {
+ START.get().map_or(0, |s| s.elapsed().as_millis() as u64)
+}
+
+/// `Last`-schedule heartbeat: stamp the clock; latch [`EXITING`] the frame the app decides
+/// to quit (no later frame will run to unlatch anything).
+fn beat(mut exits: MessageReader) {
+ if exits.read().next().is_some() {
+ EXITING.store(true, Ordering::SeqCst);
+ }
+ HEARTBEAT_MS.store(since_start_ms().max(1), Ordering::SeqCst);
+}
+
+pub(super) fn plugin(app: &mut App) {
+ if std::env::var("WOW_STALL_SAMPLE").is_ok_and(|v| v == "0") {
+ return;
+ }
+ let Ok(home) = std::env::var("HOME") else {
+ return;
+ };
+ let dir = std::path::PathBuf::from(home).join("Library/Logs/benilla");
+ let _ = std::fs::create_dir_all(&dir);
+ START
+ .set(Instant::now())
+ .expect("stall_sample plugin built twice");
+ app.add_systems(Last, beat);
+ std::thread::Builder::new()
+ .name("stall-sample".into())
+ .spawn(move || watchdog(&dir))
+ .expect("spawn stall-sample watchdog");
+
+ // The injectors (doc above): a mid-run main-thread sleep, and a wedged World drop.
+ if let Some((at, ms)) = std::env::var("WOW_STALL_INJECT")
+ .ok()
+ .and_then(|v| v.split_once(':').map(|(a, m)| (a.to_owned(), m.to_owned())))
+ .and_then(|(a, m)| Some((a.parse::().ok()?, m.parse::().ok()?)))
+ {
+ app.insert_resource(StallInject {
+ at,
+ ms,
+ fired: false,
+ })
+ .add_systems(Update, stall_inject);
+ }
+ if let Ok(ms) = std::env::var("WOW_TEARDOWN_INJECT") {
+ if let Ok(ms) = ms.parse::() {
+ app.insert_resource(TeardownWedge(ms));
+ }
+ }
+}
+
+fn watchdog(dir: &std::path::Path) {
+ let pid = std::process::id().to_string();
+ let mut taken = 0u32;
+ let mut last_sample_ms = 0u64;
+ let mut exit_seen_ms = 0u64;
+ loop {
+ std::thread::sleep(Duration::from_millis(100));
+ let hb = HEARTBEAT_MS.load(Ordering::SeqCst);
+ if hb == 0 {
+ continue;
+ }
+ let now = since_start_ms();
+ let exiting = EXITING.load(Ordering::SeqCst);
+ if exiting && exit_seen_ms == 0 {
+ exit_seen_ms = now;
+ }
+ if exiting && now.saturating_sub(exit_seen_ms) > EXIT_KILL_MS {
+ eprintln!(
+ "stall-sample: teardown still wedged {} ms after AppExit — _exit(0)",
+ now.saturating_sub(exit_seen_ms)
+ );
+ use std::io::Write;
+ let _ = std::io::stdout().flush();
+ // SAFETY: terminates the process immediately; no locks, no atexit — the whole
+ // point, since the wedge may own both.
+ unsafe { libc::_exit(0) };
+ }
+ let age = now.saturating_sub(hb);
+ if !exiting && now < STARTUP_GRACE_MS {
+ continue;
+ }
+ let threshold = if exiting { EXIT_STALL_MS } else { STALL_MS };
+ if age > threshold
+ && taken < SAMPLE_CAP
+ // The gap gates *between* samples only — gating the first one against
+ // `last_sample_ms = 0` would silently forbid any sample in the first
+ // [`SAMPLE_GAP_MS`] of process uptime (caught by the 0713 injector runs).
+ && (taken == 0 || now.saturating_sub(last_sample_ms) > SAMPLE_GAP_MS)
+ {
+ taken += 1;
+ last_sample_ms = now;
+ let file = dir.join(format!(
+ "stall-{}{}.txt",
+ std::process::id(),
+ if exiting {
+ format!("-teardown-{now}")
+ } else {
+ format!("-{now}")
+ }
+ ));
+ eprintln!(
+ "stall-sample: main thread stale {age} ms{} — sampling to {}",
+ if exiting { " (teardown)" } else { "" },
+ file.display()
+ );
+ // 1 s in-frame so the stall dominates its own capture (a ~1 s stall in a 2 s
+ // window is half idle); 2 s for teardown, where the wedge holds the whole time.
+ let _ = std::process::Command::new("/usr/bin/sample")
+ .arg(&pid)
+ .arg(if exiting { "2" } else { "1" })
+ .arg("-file")
+ .arg(&file)
+ .status();
+ }
+ }
+}
+
+/// `WOW_STALL_INJECT=:` — one deliberate main-thread sleep, mid-run.
+#[derive(Resource)]
+struct StallInject {
+ at: f32,
+ ms: u64,
+ fired: bool,
+}
+
+fn stall_inject(mut inject: ResMut, time: Res>) {
+ if !inject.fired && time.elapsed_secs() >= inject.at {
+ inject.fired = true;
+ warn!("stall-inject: sleeping the main thread {} ms", inject.ms);
+ std::thread::sleep(Duration::from_millis(inject.ms));
+ }
+}
+
+/// `WOW_TEARDOWN_INJECT=` — wedge World drop: this resource's `Drop` runs on the main
+/// thread during `App` teardown and sleeps there, reproducing the beachball synthetically.
+#[derive(Resource)]
+struct TeardownWedge(u64);
+
+impl Drop for TeardownWedge {
+ fn drop(&mut self) {
+ eprintln!("teardown-inject: wedging World drop {} ms", self.0);
+ std::thread::sleep(Duration::from_millis(self.0));
+ }
+}
diff --git a/crates/benilla-app/src/perf/stats.rs b/crates/benilla-app/src/perf/stats.rs
new file mode 100644
index 00000000..af86f802
--- /dev/null
+++ b/crates/benilla-app/src/perf/stats.rs
@@ -0,0 +1,639 @@
+//! The frame-cost meters: one rolling window per series, their tails, and the spike latch.
+//!
+//! **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 %.
+//!
+//! **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.
+
+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};
+
+/// Recent frames kept for the percentiles + the graph (~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;
+
+/// 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.
+pub(super) struct Series {
+ samples: VecDeque,
+ cap: usize,
+}
+
+impl Series {
+ fn new(cap: usize) -> Self {
+ Self {
+ samples: VecDeque::with_capacity(cap),
+ cap,
+ }
+ }
+
+ fn push(&mut self, ms: f32) {
+ if self.samples.len() == self.cap {
+ self.samples.pop_front();
+ }
+ 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- + '_ {
+ 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
{
+ (!self.samples.is_empty())
+ .then(|| self.samples.iter().sum::() / 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 = 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 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 = 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 pill's badge 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` seconds at the burst's last frame.
+ pub(super) at: f32,
+}
+
+/// Medians re-derived once a second, off the per-frame path.
+#[derive(Default)]
+struct Baselines {
+ wall: f32,
+ cpu: f32,
+ main: 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.
+#[derive(Resource)]
+pub(super) struct FrameStats {
+ /// Wall frame interval. The grant while synced; our real cost only when uncapped.
+ pub(super) wall: Series,
+ /// 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,
+ prev_main_secs: Option,
+ baselines: Baselines,
+ /// The burst currently accumulating, if this frame and its predecessors are tripping.
+ run: Option,
+ /// The worst burst inside the hold window — what the pill actually shows.
+ latched: Option,
+ /// Bursts that have finished inside the hold window, latched or not.
+ recent_bursts: u32,
+ recent_since: f32,
+}
+
+impl Default for FrameStats {
+ fn default() -> Self {
+ 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
+ }
+
+ /// Windowed mean frames per second. A mean, and labelled as one — it is the number that cannot
+ /// see a spike, which is why it is no longer the pill's headline.
+ pub(super) fn fps(&self) -> f32 {
+ match self.wall.mean() {
+ Some(mean) if mean > 0.0 => 1000.0 / mean,
+ _ => 0.0,
+ }
+ }
+
+ /// The spike still inside its hold window, if any.
+ pub(super) fn spike(&self, now: f32) -> Option {
+ self.latched
+ .filter(|s| now - s.at < LATCH_HOLD_SECS)
+ .or(self.run)
+ }
+
+ /// 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. 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() {
+ 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())
+ })
+ }
+
+ 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(),
+ 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);
+ }
+ }
+}
+
+/// 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>,
+ mut stats: ResMut,
+) {
+ let now = time.elapsed_secs();
+ let wall_ms = time.delta_secs() * 1000.0;
+ stats.wall.push(wall_ms);
+
+ let cpu = process_cpu_secs();
+ if let (Some(prev), Some(n)) = (stats.prev_cpu_secs, cpu) {
+ stats.cpu.push(((n - prev) * 1000.0) as f32);
+ }
+ 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 {
+ warn!("frame hitch: {wall_ms:.0} ms (main thread blocked this long)");
+ }
+}
+
+#[cfg(test)]
+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.
+ #[test]
+ fn a_settled_scene_latches_no_spike() {
+ 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);
+ 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 number the pill used to show 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);
+ 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);
+
+ let spike = s.spike(t).expect("a worker burst must latch");
+ assert_eq!(spike.kind, SpikeKind::Worker);
+ }
+
+ /// 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 latch must keep the worst burst in the window, not the most recent — a small wobble
+ /// after a big spike must not bury 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"
+ );
+ }
+}
diff --git a/crates/benilla-app/src/perf/trace.rs b/crates/benilla-app/src/perf/trace.rs
new file mode 100644
index 00000000..01f1d573
--- /dev/null
+++ b/crates/benilla-app/src/perf/trace.rs
@@ -0,0 +1,139 @@
+//! The STREAM TRACE (`WOW_STREAM_TRACE=`) — B181's instrument.
+
+use bevy::prelude::*;
+use bevy::time::Real;
+
+use super::clock::process_cpu_secs;
+
+/// A frame past this is logged even with no streamer activity — catches a spike whose cost lands
+/// outside the Stream chain (command application, render extraction, asset frees frames later).
+const TRACE_FRAME_MS: f32 = 25.0;
+/// Frames still logged after the last streamer event: the despawn commands apply after the system,
+/// the asset frees land when the last handle drops, and the render world reacts a frame later —
+/// the tail is where the cost shows, so the window must outlive the event.
+const TRACE_TAIL_FRAMES: u64 = 10;
+
+/// One row per frame in which the terrain streamer did anything (plus a [`TRACE_TAIL_FRAMES`]
+/// tail, plus any frame over [`TRACE_FRAME_MS`]): what was dropped/requested/spawned, the Stream
+/// chain's own self-times, and the frame's wall delta beside it. **The off-by-one to remember
+/// reading it: `delta_ms` on a row is the interval that *ended* at this frame's start, so frame N's
+/// cost appears in row N+1.**
+#[derive(Resource)]
+pub(super) struct StreamTrace {
+ pub(super) path: String,
+ pub(super) frame: u64,
+ pub(super) log_until: u64,
+ /// [`process_cpu_secs`] at the previous frame — the row's `cpu_ms` is this frame's process-CPU
+ /// delta (user+system, all threads): the load-robust cost meter beside the wall delta, because
+ /// on this machine wall frame time moves with whoever else is compiling (0711; a parallel
+ /// build polluted this instrument's first A/B).
+ pub(super) prev_cpu_secs: Option,
+ /// `PipeWatch::created` at the previous frame, so `pipes_new` is per-frame like every other
+ /// counter (the render world bumps the shared atomic; ±1 frame skew is inherent and fine).
+ pub(super) prev_pipes_created: usize,
+}
+
+const STREAM_TRACE_HEADER: &str = "frame,t,delta_ms,cpu_ms,ents,stream_ms,furnish_ms,mfurnish_ms,\
+ spawn_ms,collider_ms,tiles_dropped,pl_dropped,pl_ents_dropped,\
+ tiles_req,tiles_spawned,cells_spawned,mmeshes_built,pl_spawned,\
+ attached,adt_freed,meshes_freed,images_freed,adt_added,\
+ meshes_added,images_added,pipes_new,pipes_pending\n";
+
+#[allow(clippy::too_many_arguments)]
+pub(super) fn trace_stream(
+ mut trace: ResMut,
+ mut activity: ResMut,
+ time: Res>,
+ entities: Query<()>,
+ mut adt_events: MessageReader>,
+ mut mesh_events: MessageReader>,
+ mut image_events: MessageReader>,
+ pipes: Res,
+) {
+ // Taken every frame — the counters are per-frame by contract, tracing or not.
+ let a = std::mem::take(&mut *activity);
+ let pipes_created = pipes.0.created.load(std::sync::atomic::Ordering::Relaxed);
+ let pipes_settled = pipes.0.settled.load(std::sync::atomic::Ordering::Relaxed);
+ let pipes_new = pipes_created.saturating_sub(trace.prev_pipes_created);
+ let pipes_pending = pipes_created.saturating_sub(pipes_settled);
+ trace.prev_pipes_created = pipes_created;
+ trace.frame += 1;
+ if trace.path.is_empty() {
+ return;
+ }
+ let cpu_now = process_cpu_secs();
+ let cpu_ms = match (trace.prev_cpu_secs, cpu_now) {
+ (Some(t0), Some(t1)) => format!("{:.2}", (t1 - t0) * 1000.0),
+ _ => String::new(),
+ };
+ trace.prev_cpu_secs = cpu_now;
+ // One pass per reader: `Added` beside `Unused` — B181's recurring spike was the free wave's
+ // family, but the first-contact head frame is the ADD wave (a landed tile's cell meshes +
+ // texture arrays hitting the render world's prepare at once), invisible until counted.
+ //
+ // The freed columns count `Unused` (last strong handle dropped), not `Removed`: a
+ // `RENDER_WORLD`-only asset (chunk-cell meshes, the tile arrays — decision 0832) leaves the
+ // main store at *extract* via the untracked path, so `Removed` never fires for it; `Unused`
+ // is the release signal both usage kinds emit exactly once, and it is what actually frees
+ // the GPU copy.
+ fn count_events(
+ events: &mut MessageReader>,
+ ) -> (u32, u32) {
+ let (mut added, mut unused) = (0u32, 0u32);
+ for e in events.read() {
+ match e {
+ bevy::asset::AssetEvent::Added { .. } => added += 1,
+ bevy::asset::AssetEvent::Unused { .. } => unused += 1,
+ _ => {}
+ }
+ }
+ (added, unused)
+ }
+ let (added_adt, removed_adt) = count_events(&mut adt_events);
+ let (added_mesh, removed_mesh) = count_events(&mut mesh_events);
+ let (added_img, removed_img) = count_events(&mut image_events);
+ let delta_ms = time.delta_secs() * 1000.0;
+ if a.any_event() || removed_adt > 0 || added_adt > 0 || pipes_new > 0 {
+ trace.log_until = trace.frame + TRACE_TAIL_FRAMES;
+ }
+ if !(a.any_event()
+ || removed_adt > 0
+ || added_adt > 0
+ || pipes_new > 0
+ || delta_ms > TRACE_FRAME_MS
+ || trace.frame <= trace.log_until)
+ {
+ return;
+ }
+ if trace.frame == 1 || !std::path::Path::new(&trace.path).exists() {
+ let _ = std::fs::write(&trace.path, STREAM_TRACE_HEADER);
+ }
+ let line = format!(
+ "{},{:.2},{delta_ms:.2},{cpu_ms},{},{:.2},{:.2},{:.2},{:.2},{:.2},{},{},{},{},{},{},{},{},{},{removed_adt},{removed_mesh},{removed_img},{added_adt},{added_mesh},{added_img},{pipes_new},{pipes_pending}\n",
+ trace.frame,
+ time.elapsed_secs(),
+ entities.iter().len(),
+ a.stream_ms,
+ a.furnish_ms,
+ a.mfurnish_ms,
+ a.spawn_ms,
+ a.collider_ms,
+ a.tiles_dropped,
+ a.placements_dropped,
+ a.placement_entities_dropped,
+ a.tiles_requested,
+ a.tiles_spawned,
+ a.cells_spawned,
+ a.model_meshes_built,
+ a.placements_spawned,
+ a.colliders_attached,
+ );
+ use std::io::Write;
+ if let Ok(mut f) = std::fs::OpenOptions::new()
+ .create(true)
+ .append(true)
+ .open(&trace.path)
+ {
+ let _ = f.write_all(line.as_bytes());
+ }
+}
diff --git a/crates/benilla-app/src/run_mode.rs b/crates/benilla-app/src/run_mode.rs
index f3a7cab4..93ea7c53 100644
--- a/crates/benilla-app/src/run_mode.rs
+++ b/crates/benilla-app/src/run_mode.rs
@@ -307,7 +307,7 @@ mod tests {
"asset_churn.rs",
"dev.rs",
"hover_log.rs",
- "perf.rs",
+ "perf/",
"preflight.rs",
"probe_shield.rs",
"lib.rs",
diff --git a/crates/benilla-app/src/ui_script/extract/mod.rs b/crates/benilla-app/src/ui_script/extract/mod.rs
index 6a4e1ca9..153d6dab 100644
--- a/crates/benilla-app/src/ui_script/extract/mod.rs
+++ b/crates/benilla-app/src/ui_script/extract/mod.rs
@@ -559,7 +559,9 @@ pub(super) fn drive_script(
portrait_unit: None,
path,
..
- } => path.as_deref() != Some(crate::autocast_shine::SHINE_TOKEN),
+ } => !path
+ .as_deref()
+ .is_some_and(|p| crate::autocast_shine::token_model_scale(p).is_some()),
_ => false,
};
if !changed
@@ -581,6 +583,7 @@ pub(super) fn drive_script(
convert_entry(
extracted[i].clone(),
s,
+ w,
h,
&mut assets,
&mut images,
@@ -685,6 +688,7 @@ pub(super) fn drive_script(
convert_entry(
eq,
s,
+ w,
h,
&mut assets,
&mut images,
@@ -805,6 +809,10 @@ pub(super) fn drive_script(
fn convert_entry(
eq: benilla_ui::script::ExtractedQuad,
s: f32,
+ // The window, logical px. `h` flips y-up WoW space into the y-down quad pass; `w` is here for
+ // the one producer whose law is in SCREEN pixels rather than FrameXML units — the autocast
+ // shine's particle size, which the reference projects by the screen diagonal (decision 1390).
+ w: f32,
h: f32,
assets: &mut Option>,
images: &mut Assets,
@@ -871,7 +879,10 @@ fn convert_entry(
// `autocast_shine::emit_shine` (UiQuadAppend) animates the sparks there with zero
// per-frame script-layout traffic. A side-channel arm, so deliberately NOT
// splice-simple (the `simple` predicate above excludes the token by path).
- if path.as_deref() == Some(crate::autocast_shine::SHINE_TOKEN) {
+ if let Some(model_scale) = path
+ .as_deref()
+ .and_then(crate::autocast_shine::token_model_scale)
+ {
// A no-assets context (the extract test harness; a capture booted without the
// archive) records the site with the default handle — the site's GEOMETRY is
// the registration, and the live app always has the resolver.
@@ -885,6 +896,8 @@ fn convert_entry(
clip,
alpha: eq.alpha,
scale: s,
+ model_scale,
+ diag: (w * w + h * h).sqrt(),
texture: star,
});
return;
diff --git a/crates/benilla-app/src/ui_script/pet_bar_tests.rs b/crates/benilla-app/src/ui_script/pet_bar_tests.rs
index 35e555e4..295a26e5 100644
--- a/crates/benilla-app/src/ui_script/pet_bar_tests.rs
+++ b/crates/benilla-app/src/ui_script/pet_bar_tests.rs
@@ -187,7 +187,7 @@ fn the_shipped_pet_bar_drives_end_to_end() {
"only Claw can autocast"
);
assert_eq!(
- textures(&quads, "benilla:autocast-shine"),
+ textures(&quads, "benilla:autocast-shine:1.2"),
1,
"the shine marker on Claw alone — enabled, not merely allowed"
);
@@ -210,7 +210,7 @@ fn the_shipped_pet_bar_drives_end_to_end() {
s.resolve();
let gone = s.extract();
assert_eq!(textures(&gone, "Interface\\PetActionBar\\UI-PetBar"), 0);
- assert_eq!(textures(&gone, "benilla:autocast-shine"), 0);
+ assert_eq!(textures(&gone, "benilla:autocast-shine:1.2"), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
diff --git a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
index d7ea50a3..b38e4aca 100644
--- a/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
+++ b/crates/benilla-app/src/ui_script/shipped_xml_tests.rs
@@ -97,6 +97,171 @@ fn loading_the_shipped_ui_queues_no_sounds() {
/// a white slab over its autocast ring and another under its tab row. Every gate was green.
///
/// The shape half runs everywhere; the resolve half needs client data and skips without it (a
+/// The autocast **corner brackets** (`UI-AutoCastableOverlay`), per template — the sibling pin to
+/// the shine's, and the same invariant: not a magic size, but the **bracket square against its
+/// button**, which is the thing an eye actually reads.
+///
+/// The art fills only the middle `33/64` of the texture, so the drawn size decides where the
+/// brackets land. The reference gets this right on the pet button (58 x 0.5156 = 29.9 on 30) and
+/// loose on the spell book (60 x 0.5156 = 30.9 on 37 — three units inside each edge). Decision
+/// 1393 carries the pet button's ratio across; this test is what keeps it carried, and what will
+/// catch a third template added with a borrowed number.
+#[test]
+fn the_autocast_brackets_reach_each_buttons_corners() {
+ use benilla_ui::framexml::{Element, TopLevel};
+
+ /// The fraction of `UI-AutoCastableOverlay.blp` the bracket art actually covers, measured off
+ /// the shipped BLP (`benilla-extract blp`): art bbox 15..47 of 64.
+ const ART: f32 = 33.0 / 64.0;
+
+ fn dim(el: &Element, tag: &str, axis: &str) -> Option {
+ el.children
+ .iter()
+ .find(|c| c.tag.eq_ignore_ascii_case(tag))
+ .and_then(|n| n.children.first())
+ .and_then(|d| {
+ d.attrs()
+ .iter()
+ .find(|(k, _)| k.eq_ignore_ascii_case(axis))
+ .map(|(_, v)| v.clone())
+ })
+ .and_then(|v| v.parse().ok())
+ }
+
+ /// Walk a template, carrying the nearest enclosing button size down to the overlay.
+ fn walk(el: &Element, button: Option, out: &mut Vec<(String, f32, f32)>) {
+ let button = dim(el, "Size", "x")
+ .filter(|_| el.tag.ends_with("Button"))
+ .or(button);
+ let name = el
+ .attrs()
+ .iter()
+ .find(|(k, _)| k.eq_ignore_ascii_case("name"))
+ .map(|(_, v)| v.clone())
+ .unwrap_or_default();
+ if name.ends_with("AutoCastable") {
+ if let (Some(size), Some(btn)) = (dim(el, "Size", "x"), button) {
+ out.push((name.clone(), size * ART, btn));
+ }
+ }
+ for child in &el.children {
+ walk(child, button, out);
+ }
+ }
+
+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
+ let mut found = Vec::new();
+ for entry in std::fs::read_dir(&dir).expect("assets/ui") {
+ let path = entry.expect("entry").path();
+ if path.extension().is_none_or(|e| e != "xml") {
+ continue;
+ }
+ let src = std::fs::read_to_string(&path).expect("read");
+ for item in benilla_ui::framexml::parse(&src).expect("parse").items {
+ match item {
+ TopLevel::Font(el) | TopLevel::Template(el) | TopLevel::Instance(el) => {
+ walk(&el, None, &mut found)
+ }
+ TopLevel::Include(_) | TopLevel::Script(_) => {}
+ }
+ }
+ }
+ assert_eq!(found.len(), 2, "expected two autocast overlays: {found:?}");
+ for (name, brackets, button) in &found {
+ assert!(
+ (brackets / button - 0.997).abs() < 0.01,
+ "{name}: a {brackets:.1}-unit bracket square on a {button}-unit button is {:.3}x — the \
+ pet button's is 0.997x, which is what puts brackets IN the corners (decision 1393)",
+ brackets / button
+ );
+ }
+}
+
+/// Every shipped autocast-shine token, and the rim/viewport ratio each one asks for — the one
+/// place the widget's geometry is decided, so the one place to pin it.
+///
+/// The pet button is the REFERENCE's own numbers (`setAllPoints` on 30x30 at `scale="1.2"`), and
+/// its ratio is why that button reads as a rim: the rim square is 1.024x its viewport, so it runs
+/// ON the edge and the widget's scissor halves every star (1387/1391).
+///
+/// The spell book is **one deliberate deviation** (decision 1392). The reference writes
+/// `scale="1.22"` into a 36-unit viewport — a 0.87x rim that floats clear of the edge, is never
+/// clipped, and washes the icon; the real 1.12 client looks the same way (director-checked), so
+/// this is taste, not fidelity. We write 1.44 to borrow the pet button's ratio. This test is what
+/// stops that drifting, or being "corrected" back to 1.22 by someone who only read the ref.
+#[test]
+fn the_shine_tokens_ask_for_the_rims_we_meant() {
+ use benilla_ui::framexml::{Element, TopLevel};
+
+ fn walk(el: &Element, out: &mut Vec<(String, f32, f32)>) {
+ for (key, value) in el.attrs() {
+ let scale = crate::autocast_shine::token_model_scale(value);
+ let is_token = key.eq_ignore_ascii_case("file")
+ && value.starts_with(crate::autocast_shine::SHINE_TOKEN);
+ if let (true, Some(scale)) = (is_token, scale) {
+ let view = el
+ .children
+ .iter()
+ .find(|c| c.tag.eq_ignore_ascii_case("Size"))
+ .and_then(|sz| sz.children.first())
+ .and_then(|d| {
+ d.attrs()
+ .iter()
+ .find(|(k, _)| k.eq_ignore_ascii_case("x"))
+ .map(|(_, v)| v.to_string())
+ })
+ .and_then(|v| v.parse::().ok())
+ // No means setAllPoints on the pet button, which is 30x30.
+ .unwrap_or(30.0);
+ out.push((
+ el.attrs()
+ .iter()
+ .find(|(k, _)| k.eq_ignore_ascii_case("name"))
+ .map(|(_, v)| v.to_string())
+ .unwrap_or_default(),
+ 0.02 * 1280.0 * scale,
+ view,
+ ));
+ }
+ }
+ for child in &el.children {
+ walk(child, out);
+ }
+ }
+
+ let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
+ let mut found = Vec::new();
+ for entry in std::fs::read_dir(&dir).expect("assets/ui") {
+ let path = entry.expect("entry").path();
+ if path.extension().is_none_or(|e| e != "xml") {
+ continue;
+ }
+ let src = std::fs::read_to_string(&path).expect("read");
+ for item in benilla_ui::framexml::parse(&src).expect("parse").items {
+ match item {
+ TopLevel::Font(el) | TopLevel::Template(el) | TopLevel::Instance(el) => {
+ walk(&el, &mut found)
+ }
+ TopLevel::Include(_) | TopLevel::Script(_) => {}
+ }
+ }
+ }
+ assert_eq!(
+ found.len(),
+ 2,
+ "expected exactly two shine tokens: {found:?}"
+ );
+ for (name, rim, view) in &found {
+ assert!(
+ (rim / view - 1.024).abs() < 1e-3,
+ "{name}: a {rim}-unit rim in a {view}-unit viewport is {:.3}x, not the pet button's \
+ 1.024x — a rim that does not reach its own viewport edge is never clipped, and reads \
+ as a wash rather than a rim (decisions 1387/1392)",
+ rim / view
+ );
+ }
+}
+
/// hand-kept list of real file names would rot into agreeing with itself — the `text=` sweep's
/// argument below). Resolution goes through the renderer's own `sprite_candidates`, not a copy of it —
/// including its `.blp`/`.tga` fallback, so the sweep accepts exactly what the renderer accepts.
@@ -121,9 +286,9 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
&& !value.is_empty();
// The autocast-shine token (decision 1383) is a REGISTRATION, not an archive path:
// conversion intercepts it before the resolver and it draws nothing, so it is
- // exempt — by EXACT match against the shipped constant, so a typo'd token in XML
- // still fails this sweep as the white quad it would actually be.
- if archive_path && value != crate::autocast_shine::SHINE_TOKEN {
+ // exempt — through the token's OWN parser, so a typo'd token (or an unparseable
+ // `scale=` suffix) still fails this sweep as the white quad it would actually be.
+ if archive_path && crate::autocast_shine::token_model_scale(value).is_none() {
out.push((file.to_string(), el.tag.clone(), value.clone()));
}
}
diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs
index 3d7385c3..69c9ca82 100644
--- a/crates/benilla-app/src/ui_script/spellbook_tests.rs
+++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs
@@ -488,6 +488,59 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
assert!(!s
.eval::("return SpellButton3Shine:IsVisible()")
.unwrap());
+ // The corner brackets' RECT, through the live widget. Asserted on the resolved rect rather
+ // than the XML, so an anchor bug between the two is still caught — and CENTERED with no
+ // offset, which is what makes them concentric with the shine below (1393).
+ let br: Vec = [
+ "GetWidth()",
+ "GetHeight()",
+ "GetLeft() + SpellButton1AutoCastable:GetWidth() / 2 - (SpellButton1:GetLeft() + SpellButton1:GetWidth() / 2)",
+ "GetBottom() + SpellButton1AutoCastable:GetHeight() / 2 - (SpellButton1:GetBottom() + SpellButton1:GetHeight() / 2)",
+ ]
+ .iter()
+ .map(|e| {
+ s.eval::(&format!("return SpellButton1AutoCastable:{e}"))
+ .unwrap()
+ })
+ .collect();
+ assert!(
+ (br[0] - 71.53).abs() < 0.01 && (br[1] - 71.53).abs() < 0.01,
+ "brackets are {}x{}, 1393 draws them at 71.53 so the art reaches this button's corners",
+ br[0],
+ br[1]
+ );
+ assert!(
+ br[2].abs() < 0.01 && br[3].abs() < 0.01,
+ "brackets sit ({}, {}) off the button's centre; the ref centres them exactly",
+ br[2],
+ br[3]
+ );
+
+ // The marker's RECT: 1391 gave it the ref's own 36x36 at CENTER (1,1); 1393 squares it on the
+ // button instead, so the glow and the brackets share a centre. Checked here rather than
+ // trusted to the XML, because the whole spell-book thread turns on where this viewport sits.
+ let geom: Vec = ["GetWidth", "GetHeight"]
+ .iter()
+ .map(|m| {
+ s.eval::(&format!("return SpellButton1Shine:{m}()"))
+ .unwrap()
+ })
+ .collect();
+ assert!(
+ (geom[0] - 37.0).abs() < 0.01 && (geom[1] - 37.0).abs() < 0.01,
+ "shine marker is {geom:?}, expected 37x37 (1393 squares it on the button)"
+ );
+ let dx = s
+ .eval::("return SpellButton1Shine:GetLeft() - SpellButton1:GetLeft()")
+ .unwrap();
+ let dy = s
+ .eval::("return SpellButton1Shine:GetBottom() - SpellButton1:GetBottom()")
+ .unwrap();
+ assert!(
+ dx.abs() < 0.01 && dy.abs() < 0.01,
+ "shine marker sits at ({dx}, {dy}) inside the button; 1393 squares it on the button so it \
+ is concentric with the brackets — the ref's +1,+1 is what read as a top/right bias"
+ );
// ── The clicks ────────────────────────────────────────────────────────────────────────────
// Left: a pet cast, on the pet queue and NOT the player's.