the layout graph is maintained, and the shine draws for real
- the interface's layout graph is maintained as anchors change instead of rediscovered from scratch: 1.48 ms per mutated frame becomes 34 µs, and end to end a running cast now costs +0.14 ms where it cost +4.33 - the autocast shine draws its authored population — 1200 particles through the star's own projection, spinning as the model says - the shine is clipped to its own widget: the model frame installs its rect as the viewport, so nothing it draws leaves the button Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
//! The **autocast shine** — the four comet trails that run round an autocasting pet button —
|
||||
//! drawn natively on the UI quad APPEND lane (decision 1383, B282).
|
||||
//! The **autocast shine** — the flaming rim that runs round an autocasting pet button — drawn
|
||||
//! natively on the UI quad APPEND lane (decisions 1383, 1386; ledger B282, B228).
|
||||
//!
|
||||
//! ## Why native (B282)
|
||||
//!
|
||||
@@ -43,13 +43,27 @@
|
||||
//! 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.
|
||||
//!
|
||||
//! OUR ONE DEPARTURE is the particle COUNT: 300 live per emitter is 12 000 quads across a full
|
||||
//! bar. We draw a SAMPLE of the same trail — each sample sits where the bone was `age` ago and
|
||||
//! reads the same size/colour ramps at that age — so the trail's shape, speed and colour are the
|
||||
//! file's; only its density is ours. **How many samples is DERIVED, not chosen** (B228/1317):
|
||||
//! spacing the samples evenly left the tail as separated marching dots; spacing them by the
|
||||
//! file's own size ramp — each step advances one star-width / [`OVERLAP`] of arc — keeps the
|
||||
//! streak continuous everywhere for the fewest quads ([`trail_ages`]).
|
||||
//! **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.
|
||||
//!
|
||||
//! **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
|
||||
//! cannot paint one pixel outside the button. With the path running on the rim and the stars
|
||||
//! straddling it, that clip is half the silhouette — the reference's flat outer edge with the
|
||||
//! flames licking inward, against the soft halo an unclipped draw hangs round the whole button.
|
||||
//!
|
||||
//! **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 —
|
||||
//! 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*
|
||||
//! unbroken, and coverage was the wrong invariant: the samples tile the same path at 1/13th the
|
||||
//! intensity, so the band came apart into legible marching sparkles (B228, the director's A/B
|
||||
//! against the reference). Sampling saved cost the script layer was charging us; native, the
|
||||
//! authored count is affordable, so there is nothing left to approximate.
|
||||
//!
|
||||
//! ## The clock — truncation and all
|
||||
//!
|
||||
@@ -77,14 +91,23 @@ 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 (half-extents {0.005, 0.0015, 0.001} × 2,
|
||||
/// projected like [`SIZE`]). The mid key is 0.0015, not 0.002 — `m2part` used to round its
|
||||
/// printout (B228/1317).
|
||||
const STAR: [f32; 3] = [
|
||||
0.010 * 1280.0 * 1.2,
|
||||
0.003 * 1280.0 * 1.2,
|
||||
0.002 * 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).
|
||||
///
|
||||
/// 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];
|
||||
/// 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
|
||||
/// `0x200`, which is CLEAR here, so **every** star turns the same way (wow-re C4: the `test
|
||||
/// bl,0x20` behind it reads slot parity, not a stored field — there is no RNG in it).
|
||||
const SPIN: f32 = 0.1;
|
||||
/// The over-life colour ramp's three keys, `{r, g, b, a}` — the emitter's own.
|
||||
const COLOR: [[f32; 4]; 3] = [
|
||||
[0.976, 0.875, 0.192, 1.0],
|
||||
@@ -95,23 +118,28 @@ const COLOR: [[f32; 4]; 3] = [
|
||||
const PERIOD_MS: u32 = 2000;
|
||||
/// The bone loop in seconds, for the phase/age arithmetic.
|
||||
const PERIOD: f32 = 2.0;
|
||||
/// A particle's lifespan, seconds.
|
||||
/// A particle's lifespan, seconds (`file+0xc0`).
|
||||
const LIFE: f32 = 1.0;
|
||||
/// The authored emission rate, particles per second (`file+0xdc`).
|
||||
const RATE: f32 = 300.0;
|
||||
/// One emitter per bone corner.
|
||||
const EMITTERS: usize = 4;
|
||||
/// Star-widths of cover on every point of the trail. 1 would put neighbours exactly edge to
|
||||
/// edge, and a GlowStar fades toward its own edge, so the seam would show; 2 (centres half a
|
||||
/// width apart) is the cheapest value with no seam (1317).
|
||||
const OVERLAP: f32 = 2.0;
|
||||
/// Where the over-life ramps break from key 0→1 to key 1→2 (`file+0x14c`).
|
||||
const MID: f32 = 0.5;
|
||||
|
||||
/// Sample a 3-key over-life ramp (the M2's `{k0, k1 at mid, k2}` shape, linear between),
|
||||
/// one lane.
|
||||
fn ramp(keys: [f32; 3], age: f32) -> f32 {
|
||||
let (a, b, t) = if age <= 0.5 {
|
||||
(keys[0], keys[1], age * 2.0)
|
||||
/// Sample a 3-key over-life ramp at normalized age `u` — the reference's own two-segment law
|
||||
/// (`age > midPoint·lifespan` picks the segment, `0x7b5aaa`) with the inset it applies to the
|
||||
/// segment-local time (`t = u·0.99 + 0.005`, `0x7b9b10`), so the authored endpoint values are
|
||||
/// **never exactly reached** — a newborn star is already 0.5 % of the way toward the mid key.
|
||||
/// Tiny, and carried because it is free and because a golden that pins the authored endpoints
|
||||
/// pins something the reference never draws.
|
||||
fn ramp(keys: [f32; 3], u: f32) -> f32 {
|
||||
let (a, b, seg) = if u <= MID {
|
||||
(keys[0], keys[1], u / MID)
|
||||
} else {
|
||||
(keys[1], keys[2], (age - 0.5) * 2.0)
|
||||
(keys[1], keys[2], (u - MID) / (1.0 - MID))
|
||||
};
|
||||
let t = seg * 0.99 + 0.005;
|
||||
a + (b - a) * t
|
||||
}
|
||||
|
||||
@@ -136,35 +164,61 @@ fn point(f: f32) -> (f32, f32) {
|
||||
}
|
||||
}
|
||||
|
||||
/// The trail's sample ages, walked once off the size ramp (module doc: derived, not chosen —
|
||||
/// each step advances one local star-width / [`OVERLAP`] of rim at the bone's own rim speed, so
|
||||
/// the spacing tightens as the stars shrink and the streak stays unbroken end to end).
|
||||
fn trail_ages() -> Vec<f32> {
|
||||
let speed = 4.0 * SIZE / PERIOD; // FrameXML units of rim per second
|
||||
let mut ages = Vec::new();
|
||||
let mut age = 0.0f32;
|
||||
while age < LIFE {
|
||||
ages.push(age);
|
||||
let w = ramp(STAR, age / LIFE);
|
||||
// Only positive while the ramp is — a zero in the size table must not hang the walk
|
||||
// (the 1306 failure mode; the Lua original bailed the same way).
|
||||
if w <= 0.0 {
|
||||
break;
|
||||
}
|
||||
age += w / OVERLAP / speed;
|
||||
}
|
||||
ages
|
||||
/// How many particles one emitter keeps alive — the emitter's own steady state, `rate × life`
|
||||
/// (decision 1386: the population IS the look, and 1383 shipped a 23-sample stand-in for it).
|
||||
///
|
||||
/// **Both of the spawn driver's multipliers are byte-confirmed 1.0 for this widget** (wow-re
|
||||
/// `modelframe-particle-density.md`): the camera-distance LOD
|
||||
/// (`clamp(1 − (dist − 50)·0.02, 0.25, 1.0)`) sits at 1.0 *structurally*, because
|
||||
/// `CSimpleModel::Draw` ZEROES the eye it is measured against (`0x76d3da`), leaving
|
||||
/// `dist ≈ 0.02` layout units against a 50-unit inner radius — a UI model would have to be 50
|
||||
/// screen diagonals away to fall off; and `particleDensity` defaults to 1.0 (`0x82e92c`) with no
|
||||
/// UI exemption. The emitter's pool is `trunc(life·rate·1.15)` = 345 slots, so the 300 never hit
|
||||
/// a cap either. `WOW_SHINE_N=<n>` stays as the A/B instrument, not as a pending question.
|
||||
fn live_per_emitter() -> usize {
|
||||
static N: std::sync::LazyLock<usize> = std::sync::LazyLock::new(|| {
|
||||
std::env::var("WOW_SHINE_N")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or((RATE * LIFE).round() as usize)
|
||||
});
|
||||
*N
|
||||
}
|
||||
|
||||
/// [`trail_ages`], computed once. Each entry also pre-bakes everything a sample's fixed age
|
||||
/// fixes: its size, colour and alpha (the per-frame pass moves quads and touches nothing else —
|
||||
/// the same split the Lua pool made at spark creation, 1317).
|
||||
static SPARKS: std::sync::LazyLock<Vec<(f32, f32, [f32; 4])>> = std::sync::LazyLock::new(|| {
|
||||
trail_ages()
|
||||
.into_iter()
|
||||
.map(|age| {
|
||||
let life = age / LIFE;
|
||||
(age, ramp(STAR, life), ramp_color(life))
|
||||
/// 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).
|
||||
/// **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(|| {
|
||||
std::env::var("WOW_SHINE_SIZE")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1.0)
|
||||
});
|
||||
*S
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// 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.
|
||||
static PARTICLES: std::sync::LazyLock<Vec<(f32, f32, [f32; 4])>> = std::sync::LazyLock::new(|| {
|
||||
let size_scale = size_scale();
|
||||
(0..live_per_emitter())
|
||||
.map(|k| {
|
||||
let age = k as f32 / RATE;
|
||||
let u = (age / LIFE).min(1.0);
|
||||
(age, ramp(STAR, u) * size_scale, ramp_color(u))
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
@@ -232,15 +286,43 @@ fn emit_shine(
|
||||
/// One site's sparks at clock `ms`, appended to `out` — [`emit_shine`]'s whole body, pure so the
|
||||
/// tests drive it at exact millisecond clocks the way the old Lua goldens drove the `OnUpdate`.
|
||||
fn site_quads(site: &ShineSite, ms: u32, out: &mut Vec<UiQuad>) {
|
||||
// THE WIDGET IS ITS OWN SCISSOR (decision 1387). A `<Model>` does not draw into the UI's
|
||||
// shared space: `0x76d240` saves the viewport, installs the frame's OWN device rect as the
|
||||
// viewport for the duration of the model's draw, and restores it after
|
||||
// (`76d3cd` save · `76d5d7 0x58af60(left·x1, right·x1, bottom·y1, top·y1, 0, 1)` ·
|
||||
// `76d66c` restore — wow-re `system/ui/scratch/modelframe-camera-law.md` §4 steps 3/6/9),
|
||||
// while the ortho leg maps that same rect to NDC ±1 (§4b). Everything the model draws outside
|
||||
// the rect is therefore cut by the clip volume, with a hard flat boundary at the rect edge.
|
||||
//
|
||||
// That is not cosmetic here, it IS the shine's silhouette: the authored 0.02 square projects
|
||||
// to 30.72 units on a 30-unit button, so the bone path runs ON the rim and each 12.8-unit
|
||||
// newborn star straddles it — half in, half out. Clipped, only the inward half survives, and
|
||||
// the rim reads as the reference's does: a flat outer edge at the button border with ragged
|
||||
// flame tongues bleeding inward over the icon. Unclipped, the same stars spill a soft halo a
|
||||
// fifth of a button wide on all four sides — the director's A/B against the real client,
|
||||
// which shows the stone behind the button completely unlit (B228).
|
||||
let viewport = match site.clip {
|
||||
Some(inherited) => site.rect.intersect(inherited),
|
||||
None => site.rect,
|
||||
};
|
||||
if viewport.is_empty() {
|
||||
return;
|
||||
}
|
||||
let lap = ms as f32 / PERIOD_MS as f32;
|
||||
let s = site.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 {
|
||||
let corner = e as f32 / EMITTERS as f32;
|
||||
for &(age, w, c) in SPARKS.iter() {
|
||||
// The spark'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.
|
||||
// Bone `e` LAGS bone `e-1` by 500 ms — a quarter lap *behind*, not ahead (wow-re's bone
|
||||
// table: bone 0 opens at BL, bone 1 at BR, bone 2 at TR, bone 3 at TL, all walking the
|
||||
// one clockwise cycle BL→TL→TR→BR). Four identical emitters at four phases draw the same
|
||||
// picture whichever way the index runs, which is exactly why the old `+e/4` could be
|
||||
// wrong for two of them and look right; the law is worth being right about anyway.
|
||||
let corner = -(e as f32) / EMITTERS as f32;
|
||||
for &(age, w, c) in PARTICLES.iter() {
|
||||
// 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 (cx, cy) = (ox + x * s, oy - y * s);
|
||||
let half = w * s * 0.5;
|
||||
@@ -250,7 +332,12 @@ fn site_quads(site: &ShineSite, ms: u32, out: &mut Vec<UiQuad>) {
|
||||
texture: Some(site.texture.clone()),
|
||||
color: [c[0], c[1], c[2], c[3] * site.alpha],
|
||||
additive: true,
|
||||
clip: site.clip,
|
||||
// The in-plane billboard spin ([`SPIN`]) — the one field 1321 recorded as live
|
||||
// and left unmodelled for want of a rotation to hang it on. `UiQuad::rotation`
|
||||
// is that rotation, and it turns the corners with their UVs, which is what the
|
||||
// reference's screen-plane rotate does.
|
||||
rotation: SPIN * age,
|
||||
clip: Some(viewport),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
@@ -273,36 +360,39 @@ impl Plugin for AutocastShinePlugin {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The derivation golden: the size-ramp walk lands on 23 samples (1317's number, now pinned
|
||||
/// where the walk lives), strictly increasing, all inside a particle's life.
|
||||
/// 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.
|
||||
#[test]
|
||||
fn the_trail_walk_derives_the_1317_sample_count() {
|
||||
let ages = trail_ages();
|
||||
assert_eq!(ages.len(), 23, "one star-width/2 of arc per step ⇒ 23");
|
||||
assert!(ages.windows(2).all(|w| w[1] > w[0]));
|
||||
assert!(ages.iter().all(|&a| (0.0..LIFE).contains(&a)));
|
||||
// The no-seam law (the old Lua golden's own pin, verbatim): no two neighbours sit
|
||||
// further apart on the rim than the SMALLER of their two stars is wide — the older
|
||||
// sample's, since the size ramp only shrinks. The 8-even-samples version failed exactly
|
||||
// this from mid-life back (7.7 px gaps under 4.6 px stars — B228's marching dots).
|
||||
let speed = 4.0 * SIZE / PERIOD;
|
||||
for w in ages.windows(2) {
|
||||
let gap = (w[1] - w[0]) * speed;
|
||||
let star = ramp(STAR, w[1] / LIFE).min(ramp(STAR, w[0] / LIFE));
|
||||
fn the_population_is_the_files_own_steady_state() {
|
||||
assert_eq!(PARTICLES.len(), 300, "300/s × 1.0 s life");
|
||||
assert_eq!(PARTICLES[0].0, 0.0, "the newest particle is newborn");
|
||||
for w in PARTICLES.windows(2) {
|
||||
let step = w[1].0 - w[0].0;
|
||||
assert!(
|
||||
gap <= star,
|
||||
"gap {gap} exceeds star width {star} at age {}",
|
||||
w[0]
|
||||
(step - 1.0 / RATE).abs() < 1e-6,
|
||||
"spawn interval {step} ≠ 1/{RATE}"
|
||||
);
|
||||
}
|
||||
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;
|
||||
assert!(
|
||||
depth > 50.0,
|
||||
"only {depth:.0} stars deep — that will not saturate"
|
||||
);
|
||||
}
|
||||
|
||||
/// The head spark's quad, generated through the REAL producer body at exact millisecond
|
||||
/// clocks — the old Lua golden's position law on the native lane: origin at the site rect's
|
||||
/// bottom-left, the quarter-edge midpoint exactly half way (LINEAR, `interp == 1`), sizes
|
||||
/// and colours the ramps' own, the site's alpha folded, and 4 × 23 quads per site.
|
||||
/// The newborn particle's quad, generated through the REAL producer body at exact
|
||||
/// millisecond clocks: origin at the site rect's bottom-left, the quarter-edge midpoint
|
||||
/// exactly half way (LINEAR, `interp == 1`), the ramps' own size and colour, the site's
|
||||
/// alpha folded, and `4 × population` quads per site.
|
||||
#[test]
|
||||
fn a_sites_sparks_come_out_where_the_lua_pool_put_them() {
|
||||
fn a_sites_particles_come_out_where_the_bone_walked() {
|
||||
let site = ShineSite {
|
||||
rect: Rect::new(72.0, 56.0, 102.0, 86.0), // the pet bar's button 1, y-down px
|
||||
z: 42,
|
||||
@@ -314,8 +404,8 @@ mod tests {
|
||||
let at = |ms: u32| {
|
||||
let mut out = Vec::new();
|
||||
site_quads(&site, ms, &mut out);
|
||||
assert_eq!(out.len(), EMITTERS * SPARKS.len());
|
||||
// Emitter 0's head spark is the first quad; its centre is the bone's own position.
|
||||
assert_eq!(out.len(), EMITTERS * PARTICLES.len());
|
||||
// Emitter 0's newest particle is the first quad; its centre is the bone itself.
|
||||
let r = out[0].rect;
|
||||
(
|
||||
(r.min.x + r.max.x) * 0.5 - site.rect.min.x,
|
||||
@@ -340,34 +430,169 @@ mod tests {
|
||||
near(at(1000), (SIZE, SIZE), "clock 1000 ms: top-right");
|
||||
near(at(1500), (SIZE, 0.0), "clock 1500 ms: bottom-right");
|
||||
near(at(2000), (0.0, 0.0), "clock 2000 ms wraps onto clock 0");
|
||||
// The head quad wears the ramp's birth values: full width, the first colour key, and
|
||||
// the additive blend the emitter authors; z is the site's own paint order.
|
||||
// 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
|
||||
// site's own paint order.
|
||||
let mut out = Vec::new();
|
||||
site_quads(&site, 0, &mut out);
|
||||
let head = &out[0];
|
||||
assert!((head.rect.max.x - head.rect.min.x - STAR[0]).abs() < 0.01);
|
||||
assert_eq!(head.color, COLOR[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"
|
||||
);
|
||||
assert_eq!(head.color, ramp_color(0.0));
|
||||
assert!(head.additive);
|
||||
assert_eq!(head.z_key, 42);
|
||||
// The site's alpha folds into every spark on top of the ramp's own.
|
||||
// The site's alpha folds into every particle on top of the ramp's own.
|
||||
let dim = ShineSite { alpha: 0.5, ..site };
|
||||
let mut dimmed = Vec::new();
|
||||
site_quads(&dim, 0, &mut dimmed);
|
||||
assert_eq!(dimmed[0].color[3], COLOR[0][3] * 0.5);
|
||||
assert_eq!(dimmed[0].color[3], ramp_color(0.0)[3] * 0.5);
|
||||
}
|
||||
|
||||
/// The corner walk, the old `pet_bar_tests` golden re-pinned on the native producer: the
|
||||
/// head spark of emitter 0 lands on each corner at 0.5 / 1.0 / 1.5 / 2.0 s (lap fractions
|
||||
/// 0.25/0.50/0.75/1.0), walking clockwise on screen from the bottom-left origin.
|
||||
/// The widget's viewport IS the silhouette (1387): every spark carries the site rect as its
|
||||
/// clip, an inherited scroll clip intersects with it rather than replacing it, and — the
|
||||
/// point of the whole thing — the sparks really do straddle the rim, so the clip is load
|
||||
/// bearing and not a decorative no-op.
|
||||
#[test]
|
||||
fn the_head_spark_walks_the_corners_clockwise() {
|
||||
fn nothing_the_model_draws_escapes_the_widgets_rect() {
|
||||
let site = ShineSite {
|
||||
rect: Rect::new(72.0, 56.0, 102.0, 86.0),
|
||||
z: 42,
|
||||
clip: None,
|
||||
alpha: 1.0,
|
||||
scale: 1.0,
|
||||
texture: Handle::default(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
site_quads(&site, 0, &mut out);
|
||||
assert!(out.iter().all(|q| q.clip == Some(site.rect)));
|
||||
|
||||
// Unclipped, a newborn star hangs a long way past the border — half its 12.8 units plus
|
||||
// the 0.36 the 30.72 path already overhangs. That overhang is the halo the reference
|
||||
// does not have; the clip above is what removes it.
|
||||
let escaping = out
|
||||
.iter()
|
||||
.filter(|q| !site.rect.contains(q.rect.min) || !site.rect.contains(q.rect.max))
|
||||
.count();
|
||||
assert!(
|
||||
escaping > out.len() / 2,
|
||||
"only {escaping}/{} sparks cross the rim — the path is not on the rim any more",
|
||||
out.len()
|
||||
);
|
||||
let worst = out
|
||||
.iter()
|
||||
.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,
|
||||
"the newborn star should hang exactly half its width past the left rim, not {worst}"
|
||||
);
|
||||
|
||||
// A scroll-framed ancestor narrows the viewport; it never widens it.
|
||||
let scrolled = ShineSite {
|
||||
clip: Some(Rect::new(0.0, 0.0, 90.0, 200.0)),
|
||||
..site.clone()
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
site_quads(&scrolled, 0, &mut out);
|
||||
assert!(out
|
||||
.iter()
|
||||
.all(|q| q.clip == Some(Rect::new(72.0, 56.0, 90.0, 86.0))));
|
||||
|
||||
// Scrolled fully out of view, the site draws nothing at all.
|
||||
let hidden = ShineSite {
|
||||
clip: Some(Rect::new(0.0, 0.0, 10.0, 10.0)),
|
||||
..site
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
site_quads(&hidden, 0, &mut out);
|
||||
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`.
|
||||
#[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");
|
||||
assert!(
|
||||
(SIZE - 30.72).abs() < 1e-4,
|
||||
"the bone square DOES take the 1.2"
|
||||
);
|
||||
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"
|
||||
);
|
||||
|
||||
let site = ShineSite {
|
||||
rect: Rect::new(0.0, 0.0, 30.0, 30.0),
|
||||
z: 0,
|
||||
clip: None,
|
||||
alpha: 1.0,
|
||||
scale: 1.0,
|
||||
texture: Handle::default(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
site_quads(&site, 0, &mut out);
|
||||
// Rotation is a pure function of age, one direction, reaching 0.1 rad at the end of life.
|
||||
assert_eq!(out[0].rotation, 0.0, "a newborn star has not turned yet");
|
||||
let oldest = &out[PARTICLES.len() - 1];
|
||||
assert!(
|
||||
(oldest.rotation - SPIN * (1.0 - 1.0 / RATE)).abs() < 1e-6,
|
||||
"the oldest star has turned spin × its age"
|
||||
);
|
||||
assert!(
|
||||
out.iter().all(|q| q.rotation >= 0.0),
|
||||
"the sign-flip bit is clear on this asset — nothing counter-rotates"
|
||||
);
|
||||
}
|
||||
|
||||
/// The corner walk and the four bones' phasing — wow-re's own bone table
|
||||
/// (`modelframe-animation-clock.md` §6): one clockwise cycle BL→TL→TR→BR walked by all four,
|
||||
/// each successive bone index LAGGING the last by 500 ms, so at clock 0 bone 0 sits at BL,
|
||||
/// 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");
|
||||
// The four emitters chase one corner apart.
|
||||
assert_eq!(point(0.0 + 0.25), point(0.25));
|
||||
|
||||
// Where each emitter's newborn particle sits at clock 0, straight off the producer.
|
||||
let site = ShineSite {
|
||||
rect: Rect::new(0.0, 0.0, 30.0, 30.0),
|
||||
z: 0,
|
||||
clip: None,
|
||||
alpha: 1.0,
|
||||
scale: 1.0,
|
||||
texture: Handle::default(),
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
site_quads(&site, 0, &mut out);
|
||||
let born = |e: usize| {
|
||||
let r = out[e * PARTICLES.len()].rect;
|
||||
(
|
||||
(r.min.x + r.max.x) * 0.5,
|
||||
site.rect.max.y - (r.min.y + r.max.y) * 0.5,
|
||||
)
|
||||
};
|
||||
let near = |got: (f32, f32), want: (f32, f32), what: &str| {
|
||||
assert!(
|
||||
(got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01,
|
||||
"{what}: at {got:?}, expected {want:?}"
|
||||
);
|
||||
};
|
||||
near(born(0), (0.0, 0.0), "bone 0 opens at BL");
|
||||
near(
|
||||
born(1),
|
||||
(SIZE, 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");
|
||||
}
|
||||
|
||||
/// Decision 1321's clock law, on the native accumulator: at a exact 60 fps the floor of
|
||||
@@ -410,20 +635,34 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The size/colour ramps at the keys and midpoints — the M2's own numbers, so a future
|
||||
/// constant edit can't drift silently.
|
||||
/// The ramps read the M2's keys through the reference's own two-segment sampler — including
|
||||
/// its `t = u·0.99 + 0.005` inset, which means the authored endpoints are approached and
|
||||
/// never reached. Pinned as inequalities plus the inset's exact arithmetic, so the law is
|
||||
/// what is asserted rather than three literals.
|
||||
#[test]
|
||||
fn the_ramps_read_the_m2s_keys() {
|
||||
// The interior key is reached through `a + (b - a) * 1.0`, which rounds once more than
|
||||
// the literal — toleranced, not bit-equal.
|
||||
fn the_ramps_read_the_m2s_keys_through_the_references_inset() {
|
||||
let near = |got: f32, want: f32| assert!((got - want).abs() < 1e-4, "{got} vs {want}");
|
||||
near(ramp(STAR, 0.0), STAR[0]);
|
||||
near(ramp(STAR, 0.5), STAR[1]);
|
||||
near(ramp(STAR, 1.0), STAR[2]);
|
||||
for (key, age) in [(COLOR[0], 0.0), (COLOR[1], 0.5), (COLOR[2], 1.0)] {
|
||||
for (got, want) in ramp_color(age).into_iter().zip(key) {
|
||||
near(got, want);
|
||||
}
|
||||
// Segment-local time 0 lands 0.5 % in; segment-local time 1 lands 0.5 % short. The
|
||||
// reference's own branch is `age > midPoint·lifespan`, so age == mid is still the FIRST
|
||||
// segment — its 0.995 end, not the second segment's 0.005 start.
|
||||
near(ramp(STAR, 0.0), STAR[0] + (STAR[1] - STAR[0]) * 0.005);
|
||||
near(ramp(STAR, MID), STAR[0] + (STAR[1] - STAR[0]) * 0.995);
|
||||
near(ramp(STAR, 1.0), STAR[1] + (STAR[2] - STAR[1]) * 0.995);
|
||||
assert!(ramp(STAR, 0.0) < STAR[0], "the newborn star never hits k0");
|
||||
assert!(ramp(STAR, 1.0) > STAR[2], "the dying star never reaches k2");
|
||||
// The size ramp only ever shrinks, and the alpha only ever fades after the mid key —
|
||||
// the two facts the population's brightness envelope rests on.
|
||||
let mut prev = f32::INFINITY;
|
||||
for k in 0..=100 {
|
||||
let w = ramp(STAR, k as f32 / 100.0);
|
||||
assert!(w < prev, "the size ramp must shrink monotonically");
|
||||
prev = w;
|
||||
}
|
||||
// Alpha holds at the authored 1.0 across the whole first segment (k0 and k1 are both
|
||||
// opaque) and only fades over the second — the reason the band's bright half is its
|
||||
// freshest half and the wave reads as travelling.
|
||||
near(ramp_color(0.0)[3], 1.0);
|
||||
near(ramp_color(MID)[3], 1.0);
|
||||
assert!(ramp_color(1.0)[3] < 0.01, "a dying particle is ~invisible");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,6 +152,7 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
|
||||
app_frame(&mut s);
|
||||
}
|
||||
let before = s.layout_solves();
|
||||
let derives_before = s.layout_derivations();
|
||||
for _ in 0..10 {
|
||||
s.run("gate_change()").unwrap();
|
||||
app_frame(&mut s);
|
||||
@@ -162,6 +163,19 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
|
||||
"10 content changes must cost 10 solves — one each. Two per change means the measure \
|
||||
round-trip is running AFTER the resolve again (extract::drive_script's order)."
|
||||
);
|
||||
// …and none of those ten may DERIVE the graph (decision 1388). This is the second shape of
|
||||
// the same law `a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui`
|
||||
// guards, and it is worth asserting separately because it arrives by a different road: a
|
||||
// hover sweep churns tooltip CONTENT, so its per-frame writes are measure answers and
|
||||
// re-anchors of pooled line regions rather than one moving texture. The bag-hover re-enter
|
||||
// loop is the most common interactive path in the client; a derivation per frame here is
|
||||
// 1.48 ms every frame the cursor sits over a bag.
|
||||
assert_eq!(
|
||||
s.layout_derivations() - derives_before,
|
||||
0,
|
||||
"a settled tooltip whose CONTENT changes must not re-derive the layout graph — the line \
|
||||
pool is already built, so nothing structural is happening (decision 1388)"
|
||||
);
|
||||
// …and the measure answers must have landed in that one solve: a frame that resolved before
|
||||
// measuring leaves the fresh text unmeasured until the next frame, which is the visible half
|
||||
// of the same bug (a tooltip plate one frame behind its content).
|
||||
@@ -299,6 +313,93 @@ fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The castbar law, part two** (decision 1388): those ten gate walks must cost **zero**
|
||||
/// derivations of the layout graph.
|
||||
///
|
||||
/// 1385 got the moving spark from three whole-roster walks per frame down to one, and the test
|
||||
/// above is its guard. It did nothing about what the surviving walk *costs*: measured at the
|
||||
/// Stormwind pin (3,218 frames, 10,438 anchored regions) the preamble ran 1.48 ms, 79% of it in two
|
||||
/// phases — re-hashing every anchored region (919 µs) and re-filtering every seed rect for liveness
|
||||
/// (255 µs) — to rediscover a graph whose SHAPE had not changed. At 144 fps that is a fifth of the
|
||||
/// frame, on every frame of every cast, and it is paid by anything that animates: floating combat
|
||||
/// text moves up to twenty strings a frame, and a frame getter inside an OnUpdate (`GetWidth` calls
|
||||
/// `settle`) buys another one each.
|
||||
///
|
||||
/// So the counter this asserts on is not `layout_gate_walks` but `layout_derivations`. A moving
|
||||
/// region names its node, the ledger vouches for the cached roster and edges, and the resolve
|
||||
/// re-hashes one node instead of 13,656. Zero is the whole claim: **one** derivation per frame
|
||||
/// would mean a write site somewhere fell back to the conservative `touch_layout` and the ledger
|
||||
/// is being poisoned every frame, which reads as a perfectly healthy walk count and costs the
|
||||
/// entire 1.48 ms.
|
||||
///
|
||||
/// The positive control is not optional here. "Zero derivations" is exactly what a permanently
|
||||
/// broken counter also reports, so the test first proves the counter can move — a frame BIRTH is
|
||||
/// structural by construction and must derive.
|
||||
///
|
||||
/// It ticks the VM (`app_frame_ticked`) rather than driving the resolve alone, so the shipped UI's
|
||||
/// own `OnUpdate` handlers run on every one of these frames. That is the difference between "one
|
||||
/// synthetic write can use the ledger" and "the ledger survives the real UI": a single handler
|
||||
/// anywhere in `assets/ui/` falling back to a conservative touch every frame would poison it for
|
||||
/// everything else, and only a ticked frame can see that.
|
||||
#[test]
|
||||
fn a_region_moving_every_frame_costs_no_graph_derivation_on_the_shipped_ui() {
|
||||
let mut s = settled_default_ui();
|
||||
s.run(
|
||||
r#"
|
||||
BenchBar2 = CreateFrame("Frame", "BenchBar2", UIParent)
|
||||
BenchBar2:SetPoint("CENTER", 0, 0); BenchBar2:SetWidth(195); BenchBar2:SetHeight(13)
|
||||
BenchSpark2 = BenchBar2:CreateTexture(nil, "OVERLAY")
|
||||
BenchSpark2:SetWidth(32); BenchSpark2:SetHeight(32)
|
||||
BenchSpark2:SetPoint("CENTER", BenchBar2, "LEFT", 0, 2)
|
||||
bench_spark2_n = 0
|
||||
function bench_spark2_frame()
|
||||
bench_spark2_n = bench_spark2_n + 1
|
||||
BenchSpark2:SetPoint("CENTER", BenchBar2, "LEFT", bench_spark2_n * 1.7, 2)
|
||||
local _ = UIParent:GetWidth()
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// The positive control, taken across the birth above: a new frame and a new region move the
|
||||
// roster, so they MUST derive. If this reads zero the counter is dead and the real assertion
|
||||
// below proves nothing.
|
||||
let born_at = s.layout_derivations();
|
||||
for _ in 0..6 {
|
||||
s.run("bench_spark2_frame()").unwrap();
|
||||
app_frame_ticked(&mut s, 1.0 / 144.0);
|
||||
}
|
||||
assert!(
|
||||
s.layout_derivations() > born_at,
|
||||
"creating a frame and a texture must derive the graph — a birth moves the roster, which \
|
||||
is the one thing the per-node ledger cannot describe. Reading zero here means \
|
||||
`layout_derivations` never moves and the assertion below is vacuous."
|
||||
);
|
||||
|
||||
let derives_before = s.layout_derivations();
|
||||
let walks_before = s.layout_gate_walks();
|
||||
for _ in 0..10 {
|
||||
s.run("bench_spark2_frame()").unwrap();
|
||||
app_frame_ticked(&mut s, 1.0 / 144.0);
|
||||
}
|
||||
let derives = s.layout_derivations() - derives_before;
|
||||
let walks = s.layout_gate_walks() - walks_before;
|
||||
assert!(
|
||||
walks >= 10,
|
||||
"the frames must still be reaching the gate at all — {walks} walks over 10 frames means \
|
||||
this test stopped exercising the path it is guarding"
|
||||
);
|
||||
assert_eq!(
|
||||
derives, 0,
|
||||
"10 frames of one moving region derived the layout graph {derives} times. Each derivation \
|
||||
is a walk of the WHOLE roster — every live frame's scale re-synced, every seed rect \
|
||||
re-filtered for liveness, all 10,438 anchored regions re-hashed and their edges rebuilt: \
|
||||
~1.48 ms of CPU at the Stormwind pin, on every frame anything moves. A write site is \
|
||||
falling back to the conservative `Model::touch_layout` where it could name its node \
|
||||
(decision 1388)."
|
||||
);
|
||||
}
|
||||
|
||||
/// The perf half of decision 1350, asserted as a COUNT: a tooltip content change must cost a
|
||||
/// solve whose SCOPE is tooltip-sized, on a UI of 3,000 frames and 8,800 anchored regions.
|
||||
///
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,17 +85,19 @@ pub(super) fn ensure_measured(lua: &Lua, rh: RegionHandle) {
|
||||
natural_w,
|
||||
key: req.key,
|
||||
};
|
||||
let mut moved = false;
|
||||
if let Some(d) = model.region_data.get_mut(&rh) {
|
||||
let moved = super::types::MeasuredText::layout_moved(d.measured, new);
|
||||
moved = super::types::MeasuredText::layout_moved(d.measured, new);
|
||||
// The KEY always lands — this IS the measure cache, and a stale key re-requests forever.
|
||||
d.measured = Some(new);
|
||||
if moved {
|
||||
// Measured extents are the auto-size axes' inputs — the layout gate's read set, the
|
||||
// same touch `set_measured_text` does. Guarded on a change the LAYOUT can see: a
|
||||
// re-measure that returns the same box (a same-width countdown tick) must not open
|
||||
// tier 1, or it costs a whole-roster hash to conclude nothing moved (decision 1385).
|
||||
model.touch_layout();
|
||||
}
|
||||
}
|
||||
if moved {
|
||||
// Measured extents are the auto-size axes' inputs — the layout gate's read set, the
|
||||
// same touch `set_measured_text` does. Guarded on a change the LAYOUT can see: a
|
||||
// re-measure that returns the same box (a same-width countdown tick) must not open
|
||||
// tier 1, or it costs a whole-roster hash to conclude nothing moved (decision 1385).
|
||||
// A new extent moves no edge and no roster membership, so it names its node (1388).
|
||||
model.touch_layout_region(rh);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -894,11 +894,11 @@ impl UiScript {
|
||||
/// number, which is why [`Self::set_measured_text_unwrapped`] exists for tests.
|
||||
pub fn set_measured_text(&mut self, measures: &[(u32, f32, f32, f32, u64)]) {
|
||||
let mut model = self.model_mut();
|
||||
let mut changed = false;
|
||||
for &(id, w, h, natural_w, key) in measures {
|
||||
let Some(&rh) = model.id_to_region.get(&id) else {
|
||||
continue;
|
||||
};
|
||||
let mut moved = false;
|
||||
if let Some(d) = model.region_data.get_mut(&rh) {
|
||||
let new = MeasuredText {
|
||||
w,
|
||||
@@ -908,13 +908,16 @@ impl UiScript {
|
||||
};
|
||||
// The KEY always lands (or the region re-requests its own measure forever); the
|
||||
// EPOCH moves only if the laid-out extent did — see `MeasuredText::layout_moved`.
|
||||
changed |= MeasuredText::layout_moved(d.measured, new);
|
||||
moved = MeasuredText::layout_moved(d.measured, new);
|
||||
d.measured = Some(new);
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
// Measured extents are the auto-size axes' inputs — the layout gate's read set.
|
||||
model.touch_layout();
|
||||
if moved {
|
||||
// Measured extents are the auto-size axes' inputs — the layout gate's read set.
|
||||
// Touched PER REGION rather than once for the batch (decision 1388): the batch
|
||||
// touch could only say "some extent moved", which is exactly the whole-roster
|
||||
// question the ledger exists to stop asking.
|
||||
model.touch_layout_region(rh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,6 +1095,13 @@ impl UiScript {
|
||||
self.model_ref().layout_gate_walks
|
||||
}
|
||||
|
||||
/// How many times a resolve DERIVED the layout graph from scratch ([`Model::layout_derives`])
|
||||
/// — the whole-roster walk, and since decision 1388 the only expensive thing a resolve can do.
|
||||
/// A UI that merely animates should hold this flat.
|
||||
pub fn layout_derivations(&self) -> u64 {
|
||||
self.model_ref().layout_derives
|
||||
}
|
||||
|
||||
/// Total fixpoint ROUNDS across every solve ([`Model::layout_rounds`]) — a solve costs
|
||||
/// rounds × the whole graph, so the ratio against [`Self::layout_solves`] is the per-pass
|
||||
/// depth.
|
||||
|
||||
@@ -85,6 +85,32 @@ pub(crate) struct Model {
|
||||
/// change-gated — `tooltip::append_line`, and the gate
|
||||
/// `layout_gate::the_hover_re_enter_loop_neither_re_measures_nor_re_solves`.
|
||||
pub(crate) layout_epoch: u64,
|
||||
/// [`Self::layout_epoch`]'s **precise** form: the nodes a write actually NAMED since the last
|
||||
/// converged resolve — tier 1 promoted from "something moved" to "*these* moved" (decision
|
||||
/// 1388).
|
||||
///
|
||||
/// `Some(nodes)` carries a second, stronger claim than its contents: **every** write since the
|
||||
/// cached graph was built named its node *and* left the graph's SHAPE alone, so
|
||||
/// [`super::layout::LayoutScope`]'s roster, edges and per-node hashes still describe the live
|
||||
/// model and a resolve may seed the dirty closure straight from this list. `None` is the
|
||||
/// conservative state — a write that could not name its node, or one that moved the roster or
|
||||
/// an anchor's TARGET — and forces the next resolve to derive the graph from scratch, which is
|
||||
/// exactly what every resolve did before 1388.
|
||||
///
|
||||
/// That asymmetry is the safety argument. A site that keeps calling plain
|
||||
/// [`Model::touch_layout`] is *correct by construction* (it lands in `None`, i.e. today's
|
||||
/// behaviour); only a site that opts into [`Model::touch_layout_region`] /
|
||||
/// [`Model::touch_layout_frame`] can be wrong, and `WOW_LAYOUT_VERIFY` re-resolves every
|
||||
/// incremental pass in full scope and compares rects, so being wrong fails a test rather than
|
||||
/// shipping a stale one.
|
||||
/// The list holds **layout ids**, not handles: the touch sites below already have to prove the
|
||||
/// node is in the cached roster before they may name it, and that proof is a `node_of[id]`
|
||||
/// probe — which hands back the roster row, and with it the handle, for free at resolve time.
|
||||
pub(crate) layout_touched: Option<Vec<u32>>,
|
||||
/// `WOW_LAYOUT_VERIFY`'s flag that the resolve just taken was the incremental one and owes a
|
||||
/// full-derive re-run to prove it (see `layout::UiScript::resolve_layout`). Always `false` in
|
||||
/// production — nothing reads it unless the verify build set it.
|
||||
pub(crate) layout_verify_recheck: bool,
|
||||
/// Per-node input hashes + the dirty-closure scratch — what makes a resolve cost the nodes
|
||||
/// that MOVED rather than the whole graph (decision 1350; see `script::layout::LayoutScope`).
|
||||
/// Tiers 1 and 2 above decide *whether* to solve; this decides *what*.
|
||||
@@ -125,6 +151,20 @@ pub(crate) struct Model {
|
||||
/// consumes `benilla-ui` as a dependency, where `cfg!(test)` is false and the count is
|
||||
/// production's; inside this crate's own tests, assert on `layout_solves`.
|
||||
pub(crate) layout_gate_walks: u64,
|
||||
/// How many times a resolve **derived the layout graph** — the whole-roster walk that rebuilds
|
||||
/// the frame/region roster, the reverse edges and every per-node hash (decision 1388).
|
||||
///
|
||||
/// This is the cost counter [`Self::layout_gate_walks`] used to be. 1385 got a moving castbar
|
||||
/// spark down from three walks per frame to one; 1388 made that one walk *cheap* by seeding the
|
||||
/// dirty closure from a ledger of named nodes instead of rediscovering it, so "walks" no longer
|
||||
/// separates the 1.48 ms case from the 20 µs one and this does. A UI that animates anything
|
||||
/// should hold this at **zero** frame after frame — a non-zero steady-state reading means some
|
||||
/// write site is falling back to the conservative `touch_layout`, and that is the regression.
|
||||
///
|
||||
/// Verify-independent in the direction that matters: the `WOW_LAYOUT_VERIFY` re-run's
|
||||
/// derivation is counted, but `resolve_layout` restores the meter afterwards, so a test reads
|
||||
/// the same number in both modes.
|
||||
pub(crate) layout_derives: u64,
|
||||
/// The SCOPE of the last solve that ran: `(frames solved, regions swept)` — decision 1350's
|
||||
/// own meter, and the number its gate asserts on. `layout_solves` says how often the gate let
|
||||
/// a solve through and `layout_rounds` how deep each went; this says how WIDE, which is the
|
||||
@@ -1120,6 +1160,9 @@ impl Model {
|
||||
solver: LayoutSolver::new(),
|
||||
layout_fingerprint: None,
|
||||
layout_epoch: 0,
|
||||
layout_touched: None,
|
||||
layout_verify_recheck: false,
|
||||
layout_derives: 0,
|
||||
layout_scope: super::layout::LayoutScope::default(),
|
||||
layout_last_scope: (0, 0),
|
||||
layout_epoch_resolved: None,
|
||||
@@ -1424,6 +1467,97 @@ impl Model {
|
||||
/// exactly the failure `WOW_LAYOUT_VERIFY` (on for every benilla-ui test) exists to catch
|
||||
/// loudly, by proving the fingerprint still matches whenever tier 1 claims quiet.
|
||||
pub(crate) fn touch_layout(&mut self) {
|
||||
// The conservative half of the tier-1 ledger (decision 1388): this write did not name a
|
||||
// node, so the cached graph can no longer be trusted to describe the live model and the
|
||||
// next resolve must derive it in full. Every site that has NOT been migrated to a precise
|
||||
// touch lands here, which is why migration can be incremental and a missed one is slow
|
||||
// rather than wrong.
|
||||
self.layout_touched = None;
|
||||
self.bump_layout_epoch();
|
||||
}
|
||||
|
||||
/// A write that moved **one region's** layout inputs and changed nothing else about the graph:
|
||||
/// its anchor OFFSETS, its explicit size, its measured extent. Not its anchor targets, not its
|
||||
/// membership, not its liveness — those move edges or the roster, and belong on
|
||||
/// [`Self::touch_layout`].
|
||||
///
|
||||
/// Falls back to the conservative touch whenever it cannot prove the region is a node of the
|
||||
/// **cached** graph: no minted id, an id past the roster's arrays, or an id the last derive
|
||||
/// left unmapped (an anchor-less region, a region born since). That fallback is what makes the
|
||||
/// call safe to reach for — the worst a mistaken one can do is derive the graph again.
|
||||
pub(crate) fn touch_layout_region(&mut self, rh: RegionHandle) {
|
||||
match self.region_to_id.get(&rh) {
|
||||
Some(&id) => self.touch_layout_node(id),
|
||||
None => self.touch_layout(),
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::touch_layout_region`]'s frame twin — an anchor offset, a width/height, and nothing
|
||||
/// that moves an edge or the roster.
|
||||
pub(crate) fn touch_layout_frame(&mut self, h: FrameHandle) {
|
||||
match self.frame_to_id.get(&h) {
|
||||
Some(&id) => self.touch_layout_node(id),
|
||||
None => self.touch_layout(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Name a node **without opening tier 1** — for the resolve's own pre-pass
|
||||
/// (`tooltip::layout_tooltips`), which writes layout inputs derived from state that already
|
||||
/// arrived through touched paths.
|
||||
///
|
||||
/// It deliberately does not call [`Self::touch_layout`]: bumping the epoch from inside a
|
||||
/// resolve would pin the gate open forever (the pre-pass runs on every let-through resolve, so
|
||||
/// it would re-dirty the very resolve it is running in). But the ledger still has to hear about
|
||||
/// it, or an incremental pass would inherit a node whose hash the pre-pass moved and no write
|
||||
/// site named — the one shape that could ship a stale rect. Naming it is free: the hash it
|
||||
/// recomputes is unchanged on the frames the pre-pass writes idempotently, which is nearly all
|
||||
/// of them, so no dirty seed comes of it.
|
||||
pub(crate) fn note_layout_frame_write(&mut self, h: FrameHandle) {
|
||||
if self.layout_touched.is_some() {
|
||||
match self.frame_to_id.get(&h) {
|
||||
Some(&id) if self.layout_scope.has_node(id) => {
|
||||
self.layout_touched
|
||||
.as_mut()
|
||||
.expect("checked above")
|
||||
.push(id);
|
||||
}
|
||||
_ => self.layout_touched = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::note_layout_frame_write`]'s region twin.
|
||||
pub(crate) fn note_layout_region_write(&mut self, rh: RegionHandle) {
|
||||
if self.layout_touched.is_some() {
|
||||
match self.region_to_id.get(&rh) {
|
||||
Some(&id) if self.layout_scope.has_node(id) => {
|
||||
self.layout_touched
|
||||
.as_mut()
|
||||
.expect("checked above")
|
||||
.push(id);
|
||||
}
|
||||
_ => self.layout_touched = None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Name `id` as the only thing this write moved — if the cached graph has a node for it and no
|
||||
/// earlier write in this frame already gave up on naming.
|
||||
fn touch_layout_node(&mut self, id: u32) {
|
||||
let in_graph = self.layout_scope.has_node(id);
|
||||
match &mut self.layout_touched {
|
||||
// Already conservative: a precise touch cannot un-say an imprecise one.
|
||||
None => {}
|
||||
Some(_) if !in_graph => self.layout_touched = None,
|
||||
Some(list) => list.push(id),
|
||||
}
|
||||
self.bump_layout_epoch();
|
||||
}
|
||||
|
||||
/// Tier 1's counter, shared by every touch above. Bumping it is what re-opens the gate; which
|
||||
/// of [`Self::layout_touched`]'s two states the caller leaves behind is what decides how much
|
||||
/// the re-opened resolve has to derive.
|
||||
fn bump_layout_epoch(&mut self) {
|
||||
self.layout_epoch = self.layout_epoch.wrapping_add(1);
|
||||
// `WOW_LAYOUT_TOUCH_TRACE=<n>` — name the epoch's per-frame toucher: print a backtrace
|
||||
// for the first n touches (a mechanism probe, meaningful in a debuginfo build). Built
|
||||
|
||||
@@ -30,7 +30,7 @@ mod frame_state;
|
||||
mod layout_methods;
|
||||
pub(crate) mod movable;
|
||||
pub(crate) mod toplevel;
|
||||
pub(crate) use layout_methods::anchor_bits_eq;
|
||||
pub(crate) use layout_methods::{anchor_bits_eq, anchor_retarget_is_structural};
|
||||
pub(crate) use movable::{advance_move, advance_size, FrameMove, FrameSizing};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -137,7 +137,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let changed = input.width.to_bits() != w.to_bits();
|
||||
input.width = w;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
// A size write moves no edge and no roster membership (decision 1388).
|
||||
model.touch_layout_frame(h);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -151,7 +152,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let changed = input.height.to_bits() != ht.to_bits();
|
||||
input.height = ht;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
model.touch_layout_frame(h);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -167,7 +168,7 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
input.width = w;
|
||||
input.height = ht;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
model.touch_layout_frame(h);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -423,13 +424,40 @@ fn set_point(lua: &Lua, this: &Table, point: &str, rest: [Value; 4]) -> mlua::Re
|
||||
.iter()
|
||||
.any(|a| a.point == point);
|
||||
if !same_at_tail {
|
||||
// Value-only unless the target moved (decision 1388) — the per-frame `SetPoint` idiom
|
||||
// (a dragged window, a moving spark) re-points the SAME anchor at the SAME target with new
|
||||
// offsets, so it names its node and the cached graph survives the frame.
|
||||
let structural = anchor_retarget_is_structural(&input.anchors, &new);
|
||||
input.anchors.retain(|a| a.point != point);
|
||||
input.anchors.push(new);
|
||||
model.touch_layout();
|
||||
if structural {
|
||||
model.touch_layout();
|
||||
} else {
|
||||
model.touch_layout_frame(h);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Would this `SetPoint` change the node's set of anchor TARGETS — i.e. is it structural?
|
||||
///
|
||||
/// The layout scope's reverse edges are built from `Anchor::relative_to` (decision 1350): "this
|
||||
/// node reads that node's rect". An anchor whose OFFSETS moved is a value change — the node
|
||||
/// re-solves and nothing else does, which is exactly what a precise touch claims. An anchor whose
|
||||
/// TARGET moved is not: an edge has to disappear and another to appear, and no per-node hash can
|
||||
/// say so, so the cached graph must be thrown away instead (decision 1388).
|
||||
///
|
||||
/// Mirrors the `retain(point) + push` the setters do. It is value-only in exactly one shape — one
|
||||
/// existing anchor carries this point and keeps the same target. Zero (an edge appears) and two or
|
||||
/// more (edges disappear) are both structural, and so is any change of target.
|
||||
pub(crate) fn anchor_retarget_is_structural(anchors: &[Anchor], new: &Anchor) -> bool {
|
||||
let mut same_point = anchors.iter().filter(|a| a.point == new.point);
|
||||
match (same_point.next(), same_point.next()) {
|
||||
(Some(old), None) => old.relative_to != new.relative_to,
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bit-exact anchor equality — the same lens the layout gate's fingerprint reads anchors through
|
||||
/// (`InputFingerprint::anchors` feeds `f32::to_bits`), so the setters' no-op detection and the
|
||||
/// gate can never disagree about whether a write "changed" something.
|
||||
|
||||
@@ -451,7 +451,11 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
|
||||
// anchored regions to conclude nothing had moved — the castbar's bug class in miniature, for
|
||||
// every frame of such a drag (decision 1385).
|
||||
if moved {
|
||||
model.touch_layout();
|
||||
// Offsets only — a resize grip never repoints an anchor, so the frame names itself and the
|
||||
// cached graph survives the drag (decision 1388). A drag is per-frame by nature: this is
|
||||
// the difference between a smooth window resize and one that re-derives 13,656 nodes on
|
||||
// every mouse-move.
|
||||
model.touch_layout_frame(sz.frame);
|
||||
}
|
||||
model.sizing = Some(FrameSizing { sample: pos, ..sz });
|
||||
}
|
||||
@@ -481,8 +485,9 @@ pub(crate) fn advance_move(model: &mut Model, pos: (f32, f32)) {
|
||||
}
|
||||
// The one mutation path every layout setter shares — the tier-1 epoch, never the
|
||||
// solver's arrays (see [`super::layout_methods`]'s mutate-only-on-change law; a zero
|
||||
// delta returned above, so this write always moved something).
|
||||
model.touch_layout();
|
||||
// delta returned above, so this write always moved something). Translating every
|
||||
// anchor moves no TARGET, so the frame names itself (decision 1388).
|
||||
model.touch_layout_frame(mv.frame);
|
||||
}
|
||||
}
|
||||
model.moving = Some(FrameMove { sample: pos, ..mv });
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
|
||||
use mlua::{Lua, Table, Value};
|
||||
|
||||
use super::object::{anchor_bits_eq, as_f32, decode_id, id_to_lud, point_from_str};
|
||||
use super::object::{
|
||||
anchor_bits_eq, anchor_retarget_is_structural, as_f32, decode_id, id_to_lud, point_from_str,
|
||||
};
|
||||
use super::{
|
||||
Model, REG_FONTSTRING_META, REG_FONTSTRING_METHODS, REG_REGION_META, REG_REGION_METHODS,
|
||||
REG_TEXTURE_META, REG_TEXTURE_METHODS, REG_TITLE_META, REG_TITLE_METHODS, REG_WRAPPERS, SCREEN,
|
||||
@@ -698,9 +700,17 @@ pub(super) fn region_set_point(
|
||||
.iter()
|
||||
.any(|a| a.point == point);
|
||||
if !same_at_tail {
|
||||
// The frame twin's law (decision 1388): re-pointing the same anchor at the same target is
|
||||
// a VALUE change and names its node; anything that moves the target set is structural.
|
||||
// This is the castbar spark's and every combat-text string's per-frame write.
|
||||
let structural = anchor_retarget_is_structural(&data.anchors, &new);
|
||||
data.anchors.retain(|a| a.point != point);
|
||||
data.anchors.push(new);
|
||||
model.touch_layout();
|
||||
if structural {
|
||||
model.touch_layout();
|
||||
} else {
|
||||
model.touch_layout_region(rh);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -26,7 +26,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let changed = !size_bits_eq(d.size, new);
|
||||
d.size = new;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
// A size write moves no edge and no roster membership (decision 1388).
|
||||
model.touch_layout_region(rh);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -42,7 +43,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let changed = !size_bits_eq(d.size, new);
|
||||
d.size = new;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
// A size write moves no edge and no roster membership (decision 1388).
|
||||
model.touch_layout_region(rh);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -58,7 +60,8 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
let changed = !size_bits_eq(d.size, new);
|
||||
d.size = new;
|
||||
if changed {
|
||||
model.touch_layout();
|
||||
// A size write moves no edge and no roster membership (decision 1388).
|
||||
model.touch_layout_region(rh);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
|
||||
@@ -414,6 +414,13 @@ fn free_block(model: &mut Model, rh: RegionHandle) {
|
||||
model.id_to_region.remove(&id);
|
||||
}
|
||||
model.arena.destroy_region(rh);
|
||||
// A death is the archetypal STRUCTURAL change — it takes a node out of the layout roster and
|
||||
// its reverse edges with it, which is exactly what a per-node ledger cannot describe
|
||||
// (decision 1388). `set_text`, this function's only caller, already ends in the same
|
||||
// conservative touch, so this is redundant today; it is here because the redundancy is the
|
||||
// point. A destruction site that does not invalidate the graph it destroys part of is a
|
||||
// stale rect waiting for a second caller.
|
||||
model.touch_layout();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -720,6 +720,10 @@ pub(super) fn layout_tooltips(model: &mut Model) {
|
||||
// `pad_w` = SetPadding's extra width (ref ItemRefTooltip: room for the close button).
|
||||
input.width = maxw + 2.0 * TOOLTIP_PAD + pad_w;
|
||||
input.height = totalh + 2.0 * TOOLTIP_PAD;
|
||||
// This pre-pass runs INSIDE the resolve, so it must not open tier 1 (see the note method's
|
||||
// own doc) — but the per-node ledger decision 1388's incremental pass seeds from still has
|
||||
// to hear that these two inputs were written by something other than a Lua setter.
|
||||
model.note_layout_frame_write(h);
|
||||
// Right-flush each double line: right edge = left.right + (maxw − left.width)
|
||||
// = frame.left + pad + maxw = the text inset's right.
|
||||
for (i, (l, r)) in rows.iter().enumerate() {
|
||||
@@ -727,11 +731,16 @@ pub(super) fn layout_tooltips(model: &mut Model) {
|
||||
continue;
|
||||
}
|
||||
let lw = l.map_or(0.0, |(w, _)| w);
|
||||
let mut wrote = false;
|
||||
if let Some(d) = model.region_data.get_mut(&rights[i]) {
|
||||
if let Some(a) = d.anchors.first_mut() {
|
||||
a.x_off = maxw - lw;
|
||||
wrote = true;
|
||||
}
|
||||
}
|
||||
if wrote {
|
||||
model.note_layout_region_write(rights[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user