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:
Sam
2026-08-17 17:01:46 +07:00
co-authored by Claude Fable 5
parent e4b6776b81
commit 2381df5a58
13 changed files with 1307 additions and 461 deletions
+348 -109
View File
@@ -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
+10 -8
View File
@@ -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);
}
}
+16 -6
View File
@@ -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.
+134
View File
@@ -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
+1 -1
View File
@@ -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 });
+12 -2
View File
@@ -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]);
}
}
}
}