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:
@@ -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"/>
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user