the autocast shine squares up, and a pill watches the tail

- the shine's stars size by the screen diagonal instead of a frozen 4:3 constant, so a widescreen window stops blobbing them
- each button's shine wears its own authored geometry: the spell book's 36x36 model rect at 1.22 scale, not the pet bar's — and its autocast brackets reach the button's corners with the glow sharing their centre
- one deliberate deviation, recorded as such: the spell book's shine borrows the pet button's rim ratio, because the reference's own washes out there
- the perf HUD gains a pill that measures frame cost and its tail, and latches the spike that would otherwise scroll away

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-17 17:03:18 +07:00
co-authored by Claude Fable 5
parent 2381df5a58
commit 5ce93810b0
17 changed files with 2454 additions and 1220 deletions
@@ -437,7 +437,10 @@
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
<Texture name="$parentShine" file="benilla:autocast-shine" hidden="true">
<!-- The ref's $parentAutoCast <Model> here IS setAllPoints on the 30x30 button,
at scale="1.2" — the suffix carries that scale the way the ref's own XML
does (its sibling in SpellBookFrame.xml writes a different one). -->
<Texture name="$parentShine" file="benilla:autocast-shine:1.2" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
@@ -637,15 +637,43 @@
</Texture>
</Layer>
<Layer level="OVERLAY">
<!-- ref SpellButtonTemplate's $parentAutoCastable: the static ring that says a pet
spell CAN autocast, 58x58 over a 37x37 button. Its animated sibling (the
$parentAutoCast <Model>) is the native shine lane's marker below (decision
1383 — PetActionBar.xml's sparkle header has the story). -->
<!-- The corner brackets that say a pet spell CAN autocast. The art fills only the
middle 33/64 = 0.5156 of UI-AutoCastableOverlay.blp, so where the brackets land
is set entirely by how big the texture is drawn:
pet button ref 58 -> 58 x 0.5156 = 29.9 on a 30 button. Dead in the corners.
spell book ref 60 -> 60 x 0.5156 = 30.9 on a 37 button. THREE UNITS INSIDE
each edge, floating — the ref barely grew the overlay (58->60)
while the button grew 30->37.
DEVIATION 2 of 3 in this widget (decision 1393, extending 1392). 71.53 is the
pet button's own ratio carried over — 37 x 58/30 — so the bracket square lands
on this button's corners exactly as it does on that one. Write 60 to go back to
the reference. -->
<Texture name="$parentAutoCastable" file="Interface\Buttons\UI-AutoCastableOverlay" hidden="true">
<Size><AbsDimension x="58" y="58"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Size><AbsDimension x="71.53" y="71.53"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Texture>
<!-- The animated sibling: the ref's $parentAutoCast <Model>, which on this
template carries its own 36x36 CENTER (1,1) rect at scale="1.22".
DEVIATION 3 of 3 (decision 1393, extending 1392), and both halves are the same
idea — borrow the pet button's proportions so this button reads like that one:
* rect 36 CENTER (1,1) -> 37 CENTER (0,0). The ref's +1,+1 puts the glow a
unit up-and-right of the brackets, which sit dead centre; measured on the
director's capture, the glow's centre really was ~1 unit right of the
button's. Concentric now, so top/right stop reading loose.
* scale 1.22 -> 1.48. 0.02 x 1280 x 1.48 = 37.888 = 1.024 x 37 — the same
1.024 rim/viewport ratio the ref writes on the pet button, which is what
puts the rim ON the viewport edge so the widget's own scissor halves every
star (1387). At the ref's 0.87x the rim floats clear and nothing clips it.
To go back to the reference exactly: 36x36, CENTER (1,1), and 1.22. -->
<Texture name="$parentShine" file="benilla:autocast-shine:1.48" hidden="true">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="$parentShine" file="benilla:autocast-shine" setAllPoints="true" hidden="true"/>
</Layer>
<Layer level="BORDER">
<Texture name="$parentIconTexture" setAllPoints="true" hidden="true"/>
+188 -71
View File
@@ -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 `<Model>` 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 `<Model>` 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 `<Model>`'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 `<Size>36×36</Size>` 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 `<Model>` 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 `:<f32>` suffix carries the `<Model>`'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<f32> {
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=<f>`, 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=<f>`, 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<f32> = 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<Vec<(f32, f32, [f32; 4])>> = 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 `<Model>`'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<Image>,
}
@@ -310,6 +373,7 @@ fn site_quads(site: &ShineSite, ms: u32, out: &mut Vec<UiQuad>) {
}
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<UiQuad>) {
// 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 `<Model>`'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
File diff suppressed because it is too large Load Diff
+108
View File
@@ -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<bevy::asset::AssetEvent<Mesh>>,
time: Res<Time>,
mut acc: Local<(f32, u32, u32, u32, Vec<String>)>,
) {
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::<ArchCensusAt>().0;
if world.resource::<bevy::time::Time>().elapsed_secs() < due {
return;
}
world.resource_mut::<ArchCensusAt>().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<String> = 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<benilla_world::view::WorldCamera>, Changed<Transform>)>,
g_changed: Query<
(),
(
With<benilla_world::view::WorldCamera>,
Changed<GlobalTransform>,
),
>,
time: Res<Time>,
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();
}
}
+132
View File
@@ -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<f64> {
#[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<f64> {
#[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
}
}
+485
View File
@@ -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<ButtonInput<KeyCode>>, mut hud: ResMut<PerfHud>) {
// 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<PerfHud>,
stats: Res<FrameStats>,
time: Res<Time<Real>>,
diagnostics: Res<DiagnosticsStore>,
mut windows: Query<&mut Window, With<PrimaryWindow>>,
cam_msaa: Query<&Msaa, With<WorldCamera>>,
) -> 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<egui::Pos2> = trend
.iter()
.enumerate()
.map(|(i, ms)| egui::pos2(rect.left() + i as f32 * dx, to_y(ms)))
.collect();
let col = if trend.last() > start * 1.15 {
AMBER
} else {
GREEN
};
painter.add(egui::Shape::line(points, egui::Stroke::new(1.0_f32, col)));
}
/// 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<egui::Pos2> = stats
.wall
.iter()
.enumerate()
.map(|(i, ms)| egui::pos2(rect.left() + i as f32 * dx, to_y(ms)))
.collect();
painter.add(egui::Shape::line(points, egui::Stroke::new(1.0_f32, GREEN)));
}
fn present_controls(
ui: &mut egui::Ui,
windows: &mut Query<&mut Window, With<PrimaryWindow>>,
cam_msaa: &Query<&Msaa, With<WorldCamera>>,
) {
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"));
}
}
+167
View File
@@ -0,0 +1,167 @@
//! The FPS JOURNAL (`WOW_FPS_JOURNAL=<csv path>`): 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<f32>,
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<f64>,
/// 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<f64>,
}
/// The journal's residency columns, grouped because `journal_fps` is near Bevy's system-param
/// arity limit.
///
/// The `Assets<T>` 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<benilla_assets::materials::WowModelMaterial>>,
meshes: Res<'w, Assets<Mesh>>,
images: Res<'w, Assets<bevy::image::Image>>,
m2: Res<'w, Assets<benilla_assets::M2Model>>,
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<FpsJournal>,
time: Res<Time<Real>>,
player: Option<Res<crate::player::Player>>,
streamed: Query<(), With<crate::net::NetEntity>>,
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::<f32>() / 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());
}
}
+120
View File
@@ -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::<stats::FrameStats>()
.init_resource::<PerfHud>()
.insert_resource(trace::StreamTrace {
path: std::env::var("WOW_STREAM_TRACE").unwrap_or_default(),
frame: 0,
log_until: 0,
prev_cpu_secs: None,
prev_pipes_created: 0,
})
.add_systems(
Update,
// `toggle_hud` needs no ordering against the UI keyboard feed any more: its dev
// chord can't be typed text, so there's no `UiKeyboardCapture` to read (decision 0585).
(hud::toggle_hud, stats::sample_frame_time),
)
// `Last`, so a row carries everything this frame's Stream chain did — and the reset runs
// whether or not anything is tracing, or the counters would accumulate forever.
.add_systems(Last, trace::trace_stream)
.add_systems(bevy_egui::EguiPrimaryContextPass, hud::perf_hud_ui);
// `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=<secs>` — 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::<f32>().ok())
{
app.insert_resource(census::ArchCensusAt(at));
app.add_systems(Last, census::arch_census);
}
#[cfg(target_os = "macos")]
stall::plugin(app);
}
}
+198
View File
@@ -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=<at_secs>:<ms>` sleeps the main thread mid-run, and
//! `WOW_TEARDOWN_INJECT=<ms>` 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<Instant> = 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<AppExit>) {
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::<f32>().ok()?, m.parse::<u64>().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::<u64>() {
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=<at_secs>:<ms>` — one deliberate main-thread sleep, mid-run.
#[derive(Resource)]
struct StallInject {
at: f32,
ms: u64,
fired: bool,
}
fn stall_inject(mut inject: ResMut<StallInject>, time: Res<Time<Real>>) {
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=<ms>` — 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));
}
}
+639
View File
@@ -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<f32>,
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<Item = f32> + '_ {
self.samples.iter().copied()
}
/// Windowed mean. `None` on an empty window, so a caller can tell "no data" from "zero cost".
pub(super) fn mean(&self) -> Option<f32> {
(!self.samples.is_empty())
.then(|| self.samples.iter().sum::<f32>() / self.samples.len() as f32)
}
/// `(p50, p99, max)` over the window. Sorts a copy — fine for a few-hundred-element dev HUD,
/// and deliberately **not** on the collapsed pill's per-frame path (see [`Baselines`]).
pub(super) fn percentiles(&self) -> (f32, f32, f32) {
if self.samples.is_empty() {
return (0.0, 0.0, 0.0);
}
let mut v: Vec<f32> = self.samples.iter().copied().collect();
v.sort_by(f32::total_cmp);
let at = |q: f32| v[(((v.len() - 1) as f32) * q).round() as usize];
(at(0.50), at(0.99), *v.last().unwrap())
}
/// The window's median alone — the baseline every spike test is relative to.
fn median(&self) -> f32 {
if self.samples.is_empty() {
return 0.0;
}
let mut v: Vec<f32> = self.samples.iter().copied().collect();
v.sort_by(f32::total_cmp);
v[((v.len() - 1) / 2).min(v.len() - 1)]
}
/// `(count, fraction)` of windowed frames above `threshold_ms`.
pub(super) fn frames_over(&self, threshold_ms: f32) -> (usize, f32) {
let n = self.samples.iter().filter(|&&ms| ms > threshold_ms).count();
let frac = if self.samples.is_empty() {
0.0
} else {
n as f32 / self.samples.len() as f32
};
(n, frac)
}
}
/// What a spike was blamed on. The three are distinguishable **without GPU timestamps**, which we
/// do not have in-client on Apple Silicon (see the module header of [`super`]): the question "were
/// we busy, or were we blocked?" is answered by whether the CPU meters moved with the frame.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum SpikeKind {
/// Main-thread CPU ran long — our own systems, serialized, and the frame waited on them. The
/// most actionable kind: it is the one a player feels as a stutter.
Main,
/// Process CPU ran long while the main thread did not — a worker-pool burst (asset decode,
/// collider bake). Real work, but off the frame's critical path.
Worker,
/// The frame ran long while **neither** CPU meter moved: we were blocked, not busy. That is
/// the GPU or the present path. Stated as an inference, because it is one — a blocked
/// present-wait consumes no CPU (0717), so this is what its absence looks like from here.
Stalled,
}
impl SpikeKind {
/// The short tag the 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<Real>` 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<f64>,
prev_main_secs: Option<f64>,
baselines: Baselines,
/// The burst currently accumulating, if this frame and its predecessors are tripping.
run: Option<Spike>,
/// The worst burst inside the hold window — what the pill actually shows.
latched: Option<Spike>,
/// Bursts that have finished inside the hold window, latched or not.
recent_bursts: u32,
recent_since: f32,
}
impl Default for FrameStats {
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<Spike> {
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<Time<Real>>,
mut stats: ResMut<FrameStats>,
) {
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"
);
}
}
+139
View File
@@ -0,0 +1,139 @@
//! The STREAM TRACE (`WOW_STREAM_TRACE=<csv path>`) — 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<f64>,
/// `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<StreamTrace>,
mut activity: ResMut<benilla_world::terrain_stream::StreamActivity>,
time: Res<Time<Real>>,
entities: Query<()>,
mut adt_events: MessageReader<bevy::asset::AssetEvent<benilla_assets::AdtTile>>,
mut mesh_events: MessageReader<bevy::asset::AssetEvent<Mesh>>,
mut image_events: MessageReader<bevy::asset::AssetEvent<bevy::image::Image>>,
pipes: Res<crate::pipe_warm::PipeWatch>,
) {
// 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<A: bevy::asset::Asset>(
events: &mut MessageReader<bevy::asset::AssetEvent<A>>,
) -> (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());
}
}
+1 -1
View File
@@ -307,7 +307,7 @@ mod tests {
"asset_churn.rs",
"dev.rs",
"hover_log.rs",
"perf.rs",
"perf/",
"preflight.rs",
"probe_shield.rs",
"lib.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<ResMut<WorldAssets>>,
images: &mut Assets<Image>,
@@ -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;
@@ -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());
}
@@ -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<f32> {
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<f32>, 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::<f32>().ok())
// No <Size> 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()));
}
}
@@ -488,6 +488,59 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
assert!(!s
.eval::<bool>("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<f32> = [
"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::<f32>(&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<f32> = ["GetWidth", "GetHeight"]
.iter()
.map(|m| {
s.eval::<f32>(&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::<f32>("return SpellButton1Shine:GetLeft() - SpellButton1:GetLeft()")
.unwrap();
let dy = s
.eval::<f32>("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.