diff --git a/crates/benilla-app/src/autocast_shine.rs b/crates/benilla-app/src/autocast_shine.rs index 6c325f15..523a1e74 100644 --- a/crates/benilla-app/src/autocast_shine.rs +++ b/crates/benilla-app/src/autocast_shine.rs @@ -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 `` 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 { - 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=` stays as the A/B instrument, not as a pending question. +fn live_per_emitter() -> usize { + static N: std::sync::LazyLock = 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> = 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=`, 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 = 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> = 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) { + // THE WIDGET IS ITS OWN SCISSOR (decision 1387). A `` 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) { 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"); } } diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index c056d8be..9da18ee5 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -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. /// diff --git a/crates/benilla-ui/src/script/layout.rs b/crates/benilla-ui/src/script/layout.rs index 5b8686f4..6a8bc691 100644 --- a/crates/benilla-ui/src/script/layout.rs +++ b/crates/benilla-ui/src/script/layout.rs @@ -122,6 +122,47 @@ impl InputFingerprint { /// rect — the same one-sided failure the fingerprint above is built on. const NO_NODE: u64 = 0; +/// One FRAME's per-node input hash — [`LayoutScope::last`]'s currency. +/// +/// Factored out (decision 1388) because two paths now compute it: the full derive, over every +/// frame, and the incremental pass, over the handful a write named. They must agree *bit for bit* — +/// a hash that differs by path would read as a change on the frame the paths swap, and (worse) as +/// no-change on the frame they swap back. One function is the only way to make that true by +/// construction rather than by matching two listings by eye. +#[inline] +fn frame_node_hash(input: &LayoutInput, over: Option) -> u64 { + let mut node = InputFingerprint::default(); + node.input(input); + match over { + Some(a) => node.anchors(std::slice::from_ref(&a)), + None => node.feed(u64::MAX), + } + node.finish() +} + +/// One REGION's per-node input hash — [`frame_node_hash`]'s twin, over the three things the sweep +/// reads out of `RegionData`: its anchors, its explicit size, and its measured text extent. +#[inline] +fn region_node_hash(data: &RegionData) -> u64 { + let mut node = InputFingerprint::default(); + node.anchors(&data.anchors); + match data.size { + Some((w, h)) => { + node.f32(w); + node.f32(h); + } + None => node.feed(u64::MAX), + } + match data.measured { + Some(m) => { + node.f32(m.w); + node.f32(m.h); + } + None => node.feed(u64::MAX), + } + node.finish() +} + /// [`LayoutScope::node_of`] holds one roster index per id, and the two rosters are separate /// (`plan` for frames, `regions` for regions) — this bit says which one. Node counts run in the /// thousands, so the top bit is free forever. @@ -169,19 +210,41 @@ pub(crate) struct LayoutScope { dirty: Vec, /// The closure's worklist. stack: Vec, + /// The frame roster this cached graph describes: `(handle, id, ScrollFrame anchor override)`, + /// indexed by [`Self::node_of`] (decision 1388). + /// + /// It holds no borrow of `layout_inputs`, and that is the whole point. The borrowed form 1350 + /// introduced had to be rebuilt every call, which forced the *derivation* — the ids walk, the + /// scroll map, the liveness retain, and the two hash sweeps — to be rebuilt with it: ~1.5 ms of + /// whole-roster work per mutated frame, whatever moved. Owning the row instead lets the roster + /// outlive the call, and the `layout_inputs` probe it replaces moves to the round loop, where it + /// is paid once per frame *this solve actually touches* instead of once per frame in the UI. + plan: Vec<(FrameHandle, u32, Option)>, + /// The anchored-region roster — [`Self::plan`]'s twin, and the bigger of the two by a factor of + /// three (10,438 anchored regions against 3,218 frames at the Stormwind pin). + regions: Vec, + /// An incremental pass's staged [`Self::last`] writes, `(id, hash)`. Applied only on + /// convergence, for the reason [`Self::commit`] is: a pass left mid-flight describes a graph + /// that never settled, and adopting its hashes would seed the next pass's + /// "unchanged ⇒ unmoved" argument from a lie. + staged: Vec<(u32, u64)>, } -/// One anchored, live region as the round loop consumes it — resolved ONCE per call instead of -/// re-derived per round (decision 1350). Before this list the sweep walked `region_data` itself -/// every round and paid, per region per round, an `arena.region` probe for liveness/owner/kind and -/// a `region_to_id` probe to publish the result — and it paid them for the anchor-less entries too, -/// which it then `continue`d past. +/// One anchored, live region as the round loop consumes it — resolved ONCE per *derivation* +/// instead of re-derived per call (decision 1350) or per round (before it). Before this list the +/// sweep walked `region_data` itself every round and paid, per region per round, an `arena.region` +/// probe for liveness/owner/kind and a `region_to_id` probe to publish the result — and it paid +/// them for the anchor-less entries too, which it then `continue`d past. /// /// It is also the addressable form the scope needs: a dirty set is a list of INDICES into this, /// and "sweep only what moved" is `for &i in sweep { … regions[i] … }`. -struct RegionNode<'a> { +/// +/// 1388 took the `&RegionData` borrow back out of it, which is what lets the roster outlive the +/// call that built it. The sweep re-probes `region_data` for that half — one lookup per region +/// *swept* rather than one per region in the UI. +#[derive(Clone, Copy)] +struct RegionRow { rh: RegionHandle, - data: &'a RegionData, /// The layout id — the solver's dense array index, and the scope's node key. id: u32, /// The owning frame: supplies the fallback edges for the axes this region's own anchors do @@ -191,9 +254,10 @@ struct RegionNode<'a> { } impl LayoutScope { - /// Grow every per-id array to cover `n` ids and reset the per-pass ones. `last` keeps its - /// contents (it is the memory of the previous converged pass); everything else is scratch. - fn begin(&mut self, n: usize) { + /// Grow every per-id array to cover `n` ids and reset the per-pass ones, ahead of deriving the + /// graph from scratch. `last` keeps its contents (it is the memory of the previous converged + /// pass); everything else — the roster, the edges, `node_of` — is about to be rebuilt. + fn begin_full(&mut self, n: usize) { self.last.resize(n, NO_NODE); self.now.clear(); self.now.resize(n, NO_NODE); @@ -206,6 +270,29 @@ impl LayoutScope { self.dep_to.clear(); self.dep_next.clear(); self.stack.clear(); + self.plan.clear(); + self.regions.clear(); + self.staged.clear(); + } + + /// The incremental entry (decision 1388): the roster, the edges and `node_of` ARE the cached + /// graph and must survive; only the per-pass dirty marks are scratch. This is the whole saving + /// — `begin_full`'s six resizes and the four whole-roster walks that refill them are what a + /// mutated frame used to pay to rediscover a graph that had not changed shape. + fn begin_incremental(&mut self) { + self.dirty.clear(); + self.dirty.resize(self.node_of.len(), false); + self.stack.clear(); + self.staged.clear(); + } + + /// Is `id` a node of the cached graph? The precondition every precise touch site + /// ([`Model::touch_layout_region`]) has to satisfy before it may name a node instead of + /// invalidating the cache. + pub(crate) fn has_node(&self, id: u32) -> bool { + self.node_of + .get(id as usize) + .is_some_and(|&n| n != u32::MAX) } /// Record that node `from` reads `to`'s rect — so a change at `to` reaches `from`. @@ -262,13 +349,31 @@ impl LayoutScope { self.last_screen = Some(screen); } - /// Forget it: the next resolve must run in full scope. Called wherever a pass leaves the - /// rects mid-flight (the cycle bail), because the memory would then describe a graph that - /// never settled. + /// [`Self::commit`] for an incremental pass: only the nodes it re-hashed moved, so only their + /// slots in `last` are rewritten. `last_count` and `last_screen` are untouched by + /// construction — a node entering or leaving the graph, and the screen moving, are structural + /// events, and a structural event never reaches this path (it lands in the conservative + /// `Model::layout_touched = None`). + fn commit_incremental(&mut self) { + for &(id, h) in &self.staged { + self.last[id as usize] = h; + } + self.staged.clear(); + } + + /// Forget it: the next resolve must derive the graph and run in full scope. Called wherever a + /// pass leaves the rects mid-flight (the cycle bail), because the memory would then describe a + /// graph that never settled. pub(crate) fn invalidate(&mut self) { self.last_count = 0; self.last_screen = None; self.last.clear(); + // The roster and `node_of` go with it: `has_node` is the precise touch sites' licence to + // name a node, and it must not outlive the memory that makes naming meaningful. + self.plan.clear(); + self.regions.clear(); + self.node_of.clear(); + self.staged.clear(); } } @@ -330,6 +435,16 @@ struct PreambleProf { /// The same over every anchored REGION — the biggest roster (10,438 anchored at the SW pin, /// and by far the biggest single phase: 0.45–1.0 ms of the ~1.0–1.4 ms walk). fp_regions: u128, + /// The INCREMENTAL pass's whole preamble (decision 1388): re-hash the nodes the ledger named, + /// seed the ones that moved, close. Every phase above is zero on such a pass, and this is what + /// replaced them — read the two side by side to see what a derivation costs. + seed: u128, + /// Was this an incremental pass? Reported as its own flag rather than inferred from + /// `seed != 0`, which is what it was first written as — and which read `incr=0` on every + /// incremental frame of the shipped UI, because the work it times rounds to under a + /// microsecond. An instrument that says "this never happens" about the path it was built to + /// measure is worse than no instrument. + incremental: bool, } impl PreambleProf { @@ -367,12 +482,14 @@ impl PreambleProf { + self.plan + self.begin + self.fp_frames - + self.fp_regions; + + self.fp_regions + + self.seed; eprintln!( - "[layout-pre] skips={} frames={frames} anchored={regions} total_us={total} \ + "[layout-pre] skips={} incr={} frames={frames} anchored={regions} total_us={total} \ tooltip={} watched={} ids={} scroll={} retain={} plan={} begin={} \ - fp_frames={} fp_regions={}", + fp_frames={} fp_regions={} seed={}", u8::from(skips), + u8::from(self.incremental), self.tooltip, self.watched, self.ids, @@ -381,7 +498,8 @@ impl PreambleProf { self.plan, self.begin, self.fp_frames, - self.fp_regions + self.fp_regions, + self.seed ); } } @@ -431,6 +549,64 @@ impl UiScript { /// it fresh. Cheap to call on demand: the fixpoint early-exits in one round on a quiet graph /// (this function's own doc). pub(super) fn resolve_layout(model: &mut Model) { + Self::resolve_layout_inner(model); + if !model.layout_verify_recheck { + return; + } + // ── `WOW_LAYOUT_VERIFY`: the incremental pass's falsifier (decision 1388) ───────────── + // The pass just taken seeded its dirty closure from a LEDGER of named nodes rather than + // from a fresh derivation, and it reused a roster and an edge set built some frames ago. + // Two things can be wrong with that and neither is visible from inside it: a write that + // moved a node's inputs without naming it, and a write that changed an anchor's TARGET + // while claiming to be value-only (which leaves the edges describing a graph that no + // longer exists). Both ship a stale rect, silently, days later. + // + // So: re-run the same frame from scratch — no ledger, no cache, full scope — and require + // the rects to be identical. That subsumes both failure modes and every one we have not + // thought of, because it compares the ANSWER rather than the reasoning. It is forced on + // for this crate's tests, which drive the real shipped FrameXML, so every UI test is a + // standing probe for a mis-migrated touch site. + model.layout_verify_recheck = false; + let before = (model.resolved.clone(), model.region_resolved.clone()); + // The meters are the incremental pass's, not the re-run's: `layout_solves` is documented + // verify-independent and `layout_last_scope` is decision 1350's own gate. Counting the + // falsifier's work in either would make every assertion in the suite read the wrong pass. + let meters = ( + model.layout_solves, + model.layout_rounds, + model.layout_last_scope, + model.layout_gate_walks, + model.layout_derives, + ); + let (queued, warned) = (model.pending_size_changed.len(), model.warnings.len()); + model.layout_touched = None; // no ledger: derive the graph + model.layout_epoch_resolved = None; // past tier 1 + model.layout_fingerprint = None; // past tier 2 + model.layout_scope.invalidate(); // full scope + Self::resolve_layout_inner(model); + model.pending_size_changed.truncate(queued); + model.warnings.truncate(warned); + ( + model.layout_solves, + model.layout_rounds, + model.layout_last_scope, + model.layout_gate_walks, + model.layout_derives, + ) = meters; + assert!( + before.0 == model.resolved && before.1 == model.region_resolved, + "WOW_LAYOUT_VERIFY: an INCREMENTAL layout pass disagreed with a full derivation of \ + the same frame — a write site named the wrong node, named one while changing an \ + anchor's target, or moved layout inputs without naming any. \ + frames {} -> {}, regions {} -> {}", + before.0.len(), + model.resolved.len(), + before.1.len(), + model.region_resolved.len(), + ); + } + + fn resolve_layout_inner(model: &mut Model) { // ── Tier 1: the mutation epoch ──────────────────────────────────────────────────────── // Nothing has called `touch_layout` since the last converged resolve ⇒ the read set the // fingerprint below hashes cannot have moved ⇒ skip everything, including the tooltip @@ -472,6 +648,19 @@ impl UiScript { .map(|(&h, _)| (h, model.resolved.get(&h).copied())) .collect(); pre.watched = pre.lap(); + // ── The graph: the ledger's cache, or a fresh derivation ───────────────────────────── + // `layout_touched` is tier 1's PRECISE form (decision 1388). `Some` carries the claim that + // every write since the cached graph was built named its node and left the graph's shape + // alone — so the roster, the edge set and the per-node hashes in `layout_scope` still + // describe the live model, and this resolve can seed its dirty closure straight from the + // list. That skips the derivation below entirely: 1.48 ms of whole-roster walking at the + // Stormwind pin (3,218 frames, 10,438 anchored regions), which a single moving castbar + // spark used to pay every frame. `None` derives, exactly as every resolve did before 1388. + let touched = model.layout_touched.take(); + let fp = touched + .is_none() + .then(|| Self::derive_layout_graph(model, &mut pre)); + let Model { arena, layout_inputs, @@ -479,301 +668,83 @@ impl UiScript { region_data, region_resolved, frame_to_id, - id_to_region, - region_to_id, - next_id, screen, warnings, solver, layout_scope: scope, layout_fingerprint, layout_epoch_resolved, + layout_touched, + layout_verify_recheck, layout_solves, layout_last_scope, layout_rounds, pending_size_changed, .. } = model; - - // One id per live frame, and its layout input with scale synced from the arena. A flat - // `Vec`, not a map: this list is walked (never probed) by every round below, and it is - // rebuilt on each call anyway — a `HashMap` bought nothing and cost 1881 hashes per call. - let mut ids: Vec<(FrameHandle, u32)> = Vec::with_capacity(frame_to_id.len()); - for (&h, &id) in frame_to_id.iter() { - if arena.frame(h).is_some() { - ids.push((h, id)); - } - } - for &(h, _) in &ids { - let (scale, clamp) = arena - .frame(h) - .map(|f| (f.effective_scale, f.clamped_to_screen)) - .unwrap_or((1.0, false)); - let input = layout_inputs.entry(h).or_default(); - input.scale = scale; - // Clamp-to-screen is a FRAME property (geometry flags bit4 — SetClampedToScreen, the - // XML attribute, the GameTooltip-kind default), synced like scale so EVERY placement - // path — SetOwner's anchor law, a caller's SetPoint, the cursor-seated world plates — - // resolves through the same clamp, with extents tracking the live window size. - input.clamp = clamp; - if clamp { - input.extent_x = screen.right; - input.extent_y = screen.top; - } - } - pre.ids = pre.lap(); - - // The ScrollFrame mechanism (decision 0112): a live ScrollFrame with a live scroll child - // overrides the child's own anchors for this solve — `SetScrollChild` pins the child TOPLEFT - // to the scrollframe's TOPLEFT, offset by the live vertical scroll (`(0, vertical)`; XML - // y-positive-up, so a positive offset lifts the child, bringing content below the fold into - // view — `frame top 500, vertical 40 ⇒ child top 540`). This is a LOCAL map, consulted only - // while building each round's graph below — the child's authored `LayoutInput.anchors` are - // never touched, so `SetScrollChild(nil)` needs no restore: the override just stops being - // computed. The child's own width/height (from its own `LayoutInput`) stay whatever they are; - // only its anchors are replaced. - let mut scroll_child_anchor: HashMap = HashMap::new(); - for &(h, id) in &ids { - let Some(frame) = arena.frame(h) else { - continue; - }; - if frame.kind != FrameKind::ScrollFrame { - continue; - } - let KindState::Scroll(state) = &frame.kind_state else { - continue; - }; - let Some(child) = state.child else { continue }; - if arena.frame(child).is_none() { - continue; // a stale/destroyed child contributes no override - } - scroll_child_anchor.insert( - child, - Anchor::new(Point::TopLeft, id, Point::TopLeft, 0.0, state.vertical), - ); - } - pre.scroll = pre.lap(); - - // Alternating frame/region rounds, run to a CHANGE-DRIVEN FIXPOINT: the real client - // resolves ONE layout graph in which frames and regions anchor each other freely (the - // gossip option rows anchor to the greeting FontString's laid-out bottom; the quest log's - // detail pane chains ~15 regions deep with item BUTTONS anchored to the tail). Our frame - // pass is a batch graph solve, so each round re-solves it with the latest REGION rects as - // externals, re-resolves the regions against the new frame rects, and stops when a full - // round changed nothing. The old fixed budget (2 frame rounds × 3 region rounds) silently - // left chain links past ~6 on owner-edge fallbacks and dropped tail-anchored frames to the - // screen origin — decision 0088 §2's finding, which forced windows into invented fixed - // offsets instead of ref-verbatim chains. The round cap is the node count (an honest round - // binds at least one new link), so only a genuine anchor CYCLE can exhaust it — warned, - // mirroring the client's resolving-flag cycle bail. Convergence compares exact rects: each - // pass recomputes from the same inputs through the same arithmetic, so stabilized values - // repeat bit-for-bit (no epsilon drift). - // - // The fixpoint CARRIES ACROSS FRAMES (decision 0294): every rect is recomputed purely from - // (inputs, externals), never from its own prior value, so last frame's converged rects are a - // legal seed — a quiet frame re-verifies in ONE round instead of re-propagating every anchor - // chain link-by-link (measured: the full default UI held 5–10 rounds × ~7 ms/round at - // opt-level 0, every frame, ~72 ms of a quiet frame — the director's 60→50 fps). Only - // regions that left the model (or lost their anchors) drop; a from-scratch resolve is just - // the empty-seed special case, so first-frame behavior is unchanged. - region_resolved.retain(|rh, _| { - arena.region(*rh).is_some() - && region_data.get(rh).is_some_and(|d| !d.anchors.is_empty()) - }); - pre.retain = pre.lap(); - // The per-frame solve plan, resolved ONCE per call rather than per round: each live - // frame's id, a borrow of its (already scale/clamp-synced) layout input, and its - // ScrollFrame anchor override if it is a scroll child. The round loop below then walks a - // flat slice — no map probes, no input clones. - let plan: Vec<(FrameHandle, u32, &LayoutInput, Option)> = ids - .iter() - .filter_map(|&(h, id)| { - layout_inputs - .get(&h) - .map(|input| (h, id, input, scroll_child_anchor.get(&h).copied())) - }) - .collect(); - pre.plan = pre.lap(); - - // ── The change gate ─────────────────────────────────────────────────────────────────── - // Fingerprint exactly what the rounds below read, and skip them outright when nothing has - // moved since the last converged resolve. Measured on the shipped default UI: the layout - // inputs are byte-identical on 34 of 35 idle frames and 39 of 41 with three windows open, - // because almost all per-frame UI traffic (StatusBar values, cooldown sweeps, chat fades, - // colour changes) is read by EXTRACT, not by the anchor solve. - // - // The read set, in the order the rounds consume it: - // * `screen` — the root external, and the clamp extents; - // * `plan` — every live frame's id, its (already scale/clamp- - // synced) `LayoutInput`, and its ScrollFrame override, - // which together distil `frame_to_id`, frame liveness, - // `effective_scale`, `clamped_to_screen`, and the - // ScrollFrame child + vertical offset; - // * `region_data` — each region's anchors, explicit size, and measured - // text extent; - // * `arena.region(rh)` — liveness and owner (a dead region drops out of the - // sweep; the owner supplies the fallback edges). - // - // **INPUTS ONLY — the 0294 seeds are deliberately NOT hashed** (decision 1385). They are - // the previous pass's OUTPUT, and 0294's own property ("every rect is recomputed purely - // from (inputs, externals), never from its own prior value") makes a converged pass's - // rects a pure function of the inputs: the seeds change how many ROUNDS convergence - // takes, never what it converges to. Hashing them therefore added no verdict the input - // half does not already give — and it cost two extra whole-roster walks per mutated - // frame, because a solve necessarily outgrows the seeds it hashed, so neither that pass - // nor the settling pass behind it could close tier 1. (`resolved`, the FRAME rects, was - // never in the read set at all — the seed half was asymmetric as well as redundant.) - // - // ── …and, in the same walk, the SCOPE (decision 1350) ───────────────────────────────── - // Each node's own inputs are hashed a second time on their own, into - // [`LayoutScope::now`], and its anchor targets are recorded as reverse edges. That turns - // the gate's one verdict ("something moved") into the far more useful one ("*these* moved, - // and here is everything downstream of them"), for the cost of a second accumulator over - // a read set we were walking anyway. Everything the aggregate `fp` feeds still feeds it, - // byte for byte, so tiers 1 and 2 are untouched. - // Sized past `next_id` because the region walk below MINTS ids (see there) — every id it - // can mint is one more than the anchored regions it walks. - scope.begin(*next_id as usize + region_data.len() + 1); - pre.begin = pre.lap(); - let mut fp = InputFingerprint::default(); - fp.rect(*screen); - fp.feed(plan.len() as u64); - for (i, &(_, id, input, over)) in plan.iter().enumerate() { - fp.feed(u64::from(id)); - fp.input(input); - let mut node = InputFingerprint::default(); - node.input(input); - match over { - Some(a) => { - fp.anchors(std::slice::from_ref(&a)); - node.anchors(std::slice::from_ref(&a)); + // ── The incremental seed (decision 1388) ───────────────────────────────────────────── + // Re-hash only the nodes the ledger named, and seed the ones whose hash actually moved. + // This runs BEFORE the gate's verdict because on this path it IS the verdict: an empty + // seed set means no named write moved anything, which is the same conclusion tier 2 + // reaches by hashing ten thousand nodes — reached here by hashing the handful that were + // written, and carrying the scope for the ones that did move as a side effect. + let mut scope_nodes = 0usize; + let mut full_scope = false; + if let Some(touched) = &touched { + scope.begin_incremental(); + for &id in touched { + // The node's roster row — which also says whether it is a frame or a region, and + // where to find the live inputs to re-hash. `has_node` was already true when the + // write named it; re-checking is a formality that costs one bounds check and + // survives a roster the ledger somehow outlived. + let Some(&n) = scope.node_of.get(id as usize) else { + continue; + }; + if n == u32::MAX { + continue; } - None => { - fp.feed(u64::MAX); - node.feed(u64::MAX); - } - } - if let Some(slot) = scope.now.get_mut(id as usize) { - *slot = node.finish(); - scope.node_of[id as usize] = i as u32; - } - // A frame reads the rect of every anchor target it has — its OVERRIDE's target when a - // ScrollFrame supplies one (the authored anchors are not consulted at all in that - // case, exactly as the round loop's `set_frame_anchored` does not consult them). - match over { - Some(a) => scope.edge(id, a.relative_to), - None => { - for a in &input.anchors { - scope.edge(id, a.relative_to); + let now = if n & REGION_TAG != 0 { + let rh = scope.regions[(n & !REGION_TAG) as usize].rh; + match region_data.get(&rh) { + Some(d) => region_node_hash(d), + None => continue, } - } - } - } - pre.fp_frames = pre.lap(); - let mut fed_regions = 0u64; - // The round loop's region roster, built in this same walk (decision 1350) — see - // [`RegionNode`]. Only LIVE, anchored regions: an anchor-less one is invisible to the - // rounds and a destroyed one has already been dropped from `region_resolved` by the retain - // above, so both are exactly what the sweep used to `continue` past. - let mut regions: Vec = Vec::with_capacity(region_data.len()); - for (&rh, data) in region_data.iter() { - // Anchor-less entries are invisible to the rounds — the sweep `continue`s on empty - // anchors and the seed retain drops them — so they are not inputs and must not be - // hashed: a paint-only setter creating a region's entry (`SetTexture`'s - // `or_default()`) would otherwise read as a layout change no touch site can own. - if data.anchors.is_empty() { - continue; - } - fed_regions += 1; - let live = arena.region(rh); - let mut node = InputFingerprint::default(); - fp.feed(rh.fingerprint_bits()); - fp.anchors(&data.anchors); - node.anchors(&data.anchors); - match data.size { - Some((w, h)) => { - fp.f32(w); - fp.f32(h); - node.f32(w); - node.f32(h); - } - None => { - fp.feed(u64::MAX); - node.feed(u64::MAX); - } - } - match data.measured { - Some(m) => { - fp.f32(m.w); - fp.f32(m.h); - node.f32(m.w); - node.f32(m.h); - } - None => { - fp.feed(u64::MAX); - node.feed(u64::MAX); - } - } - if let Some(r) = live { - // Mint the layout id here if the region has never needed one. The sweep used to - // resolve an id-less region and simply not publish its rect (`if let Some(&id) = - // region_to_id.get(..)`) — an EditBox's implicit text FontString is exactly that - // shape, created and anchored by `SetTextInsets` and given an id only when extract - // or a measure first asks. The scope addresses nodes BY id, so it needs one; and - // minting it is pure addressing — it moves no rect, so unlike `Model::region_id` - // this does not touch the layout epoch (doing so would re-dirty the very resolve - // that is running). - let id = match region_to_id.get(&rh) { - Some(&id) => id, - None => { - let id = *next_id; - *next_id += 1; - region_to_id.insert(rh, id); - id_to_region.insert(id, rh); - id + } else { + let (h, _, over) = scope.plan[n as usize]; + match layout_inputs.get(&h) { + Some(i) => frame_node_hash(i, over), + None => continue, } }; - if let Some(slot) = scope.now.get_mut(id as usize) { - *slot = node.finish(); - scope.node_of[id as usize] = regions.len() as u32 | REGION_TAG; + // The same absorption the setters' bit-compares give, one level up: a write that + // named a node but did not move its hash (the tooltip pre-pass rewriting the same + // size, a re-`SetPoint` to the value already there) seeds nothing. + if now != scope.last.get(id as usize).copied().unwrap_or(NO_NODE) { + scope.staged.push((id, now)); + scope.seed(id); } - // A region reads its anchor targets' rects, and — for any axis its anchors do not - // pin — its OWNER's rect and scale. Both are dependencies; the owner one is the - // edge that carries a window moving to every unpinned texture inside it. - for a in &data.anchors { - scope.edge(id, a.relative_to); - } - if let Some(&owner_id) = frame_to_id.get(&r.owner) { - scope.edge(id, owner_id); - } - regions.push(RegionNode { - rh, - data, - id, - owner: r.owner, - is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString), - }); } - // Liveness only. The sweep also reads the region's `owner` and `kind`, but both are - // fixed at creation and can never change under a live handle; the owner's contribution - // to the result — its resolved rect and its `effective_scale` — rides the frame half - // (the scale is synced into `layout_inputs` by the preamble above, and the rect is the - // frame pass's own output). A DESTROYED region does still matter: `WidgetArena::destroy` - // drops the arena entry but leaves `region_data` behind, so membership alone would miss - // it and the sweep's `continue` would go unnoticed. - fp.feed(u64::from(live.is_some())); + scope.close(); + pre.seed = pre.lap(); + pre.incremental = true; } - // The count of FED entries (not the map's len — see the vacuous-entry skip above), so an - // entry leaving the anchored set is a change even if a sibling enters the same frame. - fp.feed(fed_regions); - pre.fp_regions = pre.lap(); - let gate_skips = *layout_fingerprint == Some(fp); - pre.report(gate_skips, plan.len(), regions.len()); + // Tier 2's verdict is a derivation's product, so an incremental pass answers with its own — + // and a strictly finer one. Where the fingerprint says "one of these ten thousand moved", + // the seed set says which; empty means nothing did, and last frame's rects still stand. + // `staged` is exactly the seed set (both are pushed on the same compare), so it is the + // question already answered. + let gate_skips = match fp { + Some(_) => *layout_fingerprint == fp, + None => scope.staged.is_empty(), + }; + pre.report(gate_skips, scope.plan.len(), scope.regions.len()); // The tier-1/tier-2 cross-check (only reachable under verify when tier 1 judged quiet): // the epoch said nothing layout-visible was written, so the fingerprint must agree — a // divergence is a mutation path missing its `Model::touch_layout()`, named here at the - // frame that proves it instead of shipping as a silently stale rect. - if tier1_clean { + // frame that proves it instead of shipping as a silently stale rect. An incremental pass + // has no fingerprint to cross-check; its own, stronger falsifier is the full re-derivation + // `resolve_layout` runs behind it under verify. + if tier1_clean && fp.is_some() { assert!( gate_skips, "WOW_LAYOUT_VERIFY: the layout epoch judged this frame quiet but the input \ @@ -787,6 +758,12 @@ impl UiScript { // setters' own compare let through.) if gate_skips { *layout_epoch_resolved = Some(epoch_at_entry); + // Re-arm the ledger here too (decision 1388). A pass that found nothing to do left the + // graph exactly as it found it — derived a moment ago on one path, untouched on the + // other — so the cache is as trustworthy as it was. Dropping it here would make every + // named-but-idempotent write (the tooltip pre-pass, a re-`SetPoint` to the value + // already there) cost the NEXT real write a full derivation. + *layout_touched = Some(Vec::new()); if !verify { return; } @@ -823,32 +800,38 @@ impl UiScript { // Counted, not searched: if fewer of this pass's nodes carry a `last` hash than the // last pass had nodes, something left; // * verify, which runs the scoped pass and then the full one and compares. - let mut matched = 0usize; - let mut scope_nodes = 0usize; - for (id, &n) in scope.node_of.iter().enumerate() { - if n == u32::MAX { - continue; - } - scope_nodes += 1; - if scope.last.get(id).copied().unwrap_or(NO_NODE) != NO_NODE { - matched += 1; - } - } - let a_node_died = matched != scope.last_count; - let full_scope = scope.last.is_empty() - || scope.last_screen != Some(*screen) - || a_node_died - || gate_skips; // the verify path's re-run: it must reproduce the whole graph - if full_scope { - scope.dirty_all(); - } else { + // + // An INCREMENTAL pass (decision 1388) reaches the same closure by the other end, and has + // already done so above — it re-hashes only the nodes the ledger named and seeds the ones + // whose hash moved. None of the four structural cases can reach it: each is a conservative + // touch, and a conservative touch is what leaves the ledger `None`. + if touched.is_none() { + let mut matched = 0usize; for (id, &n) in scope.node_of.iter().enumerate() { - if n != u32::MAX && scope.now[id] != scope.last[id] { - scope.stack.push(id as u32); - scope.dirty[id] = true; + if n == u32::MAX { + continue; + } + scope_nodes += 1; + if scope.last.get(id).copied().unwrap_or(NO_NODE) != NO_NODE { + matched += 1; } } - scope.close(); + let a_node_died = matched != scope.last_count; + full_scope = scope.last.is_empty() + || scope.last_screen != Some(*screen) + || a_node_died + || gate_skips; // the verify path's re-run: it must reproduce the whole graph + if full_scope { + scope.dirty_all(); + } else { + for (id, &n) in scope.node_of.iter().enumerate() { + if n != u32::MAX && scope.now[id] != scope.last[id] { + scope.stack.push(id as u32); + scope.dirty[id] = true; + } + } + scope.close(); + } } // The rosters the rounds walk. A frame in the closure is SOLVED; a region in it is SWEPT; // anything a closure node anchors to is SEEDED as an external at its cached rect. A dirty @@ -897,7 +880,7 @@ impl UiScript { // graph is the regression and milliseconds are not evidence of it (decision 0735's lesson, // paid for twice). *layout_last_scope = (solve_frames.len(), sweep_regions.len()); - let round_cap = plan.len() + region_data.len() + 2; + let round_cap = scope.plan.len() + region_data.len() + 2; // `WOW_LAYOUT_PROF=1` — the per-solve shape: rounds, the graph's size, and the split // between the frame solve and the region sweep. The numbers that say whether a solve is // expensive because it runs too often or because each pass walks the whole UI. @@ -918,19 +901,25 @@ impl UiScript { solver.begin(); solver.set_external(SCREEN, *screen); for &i in &seed_regions { - let n = ®ions[i as usize]; + let n = scope.regions[i as usize]; if let Some(r) = region_resolved.get(&n.rh) { solver.set_external(n.id, *r); } } for &i in &seed_frames { - let (h, id, _, _) = plan[i as usize]; + let (h, id, _) = scope.plan[i as usize]; if let Some(r) = resolved.get(&h) { solver.set_external(id, *r); } } for &i in &solve_frames { - let (_, id, input, over) = plan[i as usize]; + // The `layout_inputs` probe the borrowed plan used to hoist out of the round loop + // (decision 1350) is back — but paid per frame this solve TOUCHES, not per frame in + // the UI, which is what lets the roster itself outlive the call (1388). + let (h, id, over) = scope.plan[i as usize]; + let Some(input) = layout_inputs.get(&h) else { + continue; + }; match over { Some(a) => solver.set_frame_anchored(id, input, a), None => solver.set_frame(id, input), @@ -938,7 +927,7 @@ impl UiScript { } solver.solve(); for &i in &solve_frames { - let (h, id, _, _) = plan[i as usize]; + let (h, id, _) = scope.plan[i as usize]; match solver.rect(id) { Some(r) => { if resolved.get(&h) != Some(&r) { @@ -971,13 +960,19 @@ impl UiScript { let t_reg = prof.then(std::time::Instant::now); let mut scratch = LayoutInput::default(); for &ri in &sweep_regions { - let &RegionNode { + let RegionRow { rh, - data, id: region_id, owner, is_fontstring, - } = ®ions[ri as usize]; + } = scope.regions[ri as usize]; + // The roster's mutable half, re-probed here rather than borrowed into the row — + // once per region SWEPT instead of once per region in the UI (decision 1388). A + // row whose data has gone is a region the derive will drop on its next run; it + // resolves to nothing in the meantime, exactly as a dead one does. + let Some(data) = region_data.get(&rh) else { + continue; + }; if prof { n_regions_swept += 1; } @@ -1102,9 +1097,9 @@ impl UiScript { "[layout-prof] rounds={} frames={} regions_total={} anchored={} \ solved={} swept={} scope={} regions_swept={} frame_us={} region_us={}", round + 1, - plan.len(), + scope.plan.len(), region_data.len(), - regions.len(), + scope.regions.len(), solve_frames.len(), sweep_regions.len(), if full_scope { "full" } else { "dirty" }, @@ -1125,12 +1120,32 @@ impl UiScript { region_resolved.len(), ); } - *layout_fingerprint = Some(fp); + *layout_fingerprint = fp; // The scope's memory is adopted only HERE, on the converged path, for the same // reason the fingerprint is: a pass left mid-flight describes a graph that never // settled, and seeding the next pass's "unchanged ⇒ unmoved" argument from it // would be seeding it from a lie. - scope.commit(scope_nodes, *screen); + // + // An incremental pass adopts only the nodes it re-hashed — the rest of `last` is + // already the memory of the derivation this pass is standing on (decision 1388). + if touched.is_some() { + scope.commit_incremental(); + } else { + scope.commit(scope_nodes, *screen); + } + // ── Re-arm the ledger ───────────────────────────────────────────────────────── + // The graph in `scope` now describes a CONVERGED model, so the next frame's writes + // may name their nodes against it. Empty, not `None`: `None` is the standing claim + // that the cache is untrustworthy, and it has just been made trustworthy. + // + // Armed only on convergence, and only here — the cycle bail below leaves it `None` + // along with everything else, because a graph that never settled is exactly the + // one a ledger must not vouch for. + *layout_touched = Some(Vec::new()); + // Under verify, tell `resolve_layout` to re-derive this same frame from scratch and + // compare rects — the incremental pass's falsifier. Costs nothing in production, + // where `layout_verify_enabled` is false and nothing ever reads this. + *layout_verify_recheck = touched.is_some() && verify; // **A CONVERGED SOLVE CLOSES TIER 1** (decision 1385) — unconditionally, not only // on the `gate_skips` re-run. The fingerprint above is hashed over INPUTS alone, // and the rounds just drove those inputs to their fixpoint, so the fp stored on @@ -1164,6 +1179,289 @@ impl UiScript { queue_size_changes(&watched, resolved, frame_to_id, pending_size_changed); } + /// Derive the layout graph from scratch — the roster of live frames and anchored regions, the + /// reverse-edge set between them, the per-node input hashes, and the aggregate fingerprint + /// that is tier 2 of the change gate. + /// + /// Every resolve did this, until decision 1388. It is ~1.48 ms at the Stormwind pin, 79% of it + /// in two phases (`fp_regions` 919 µs, `retain` 255 µs) that rediscover a graph which had not + /// changed shape. It now runs only when the ledger cannot vouch for the cache: the first + /// resolve, a cycle-bailed one, and any frame in which a write moved the roster, retargeted an + /// anchor, or could not name what it touched. + fn derive_layout_graph(model: &mut Model, pre: &mut PreambleProf) -> InputFingerprint { + model.layout_derives = model.layout_derives.wrapping_add(1); + let Model { + arena, + layout_inputs, + region_data, + region_resolved, + frame_to_id, + id_to_region, + region_to_id, + next_id, + screen, + layout_scope: scope, + .. + } = model; + + // One id per live frame, and its layout input with scale synced from the arena. A flat + // `Vec`, not a map: this list is walked (never probed) by every round below, and it is + // rebuilt on each call anyway — a `HashMap` bought nothing and cost 1881 hashes per call. + let mut ids: Vec<(FrameHandle, u32)> = Vec::with_capacity(frame_to_id.len()); + for (&h, &id) in frame_to_id.iter() { + if arena.frame(h).is_some() { + ids.push((h, id)); + } + } + for &(h, _) in &ids { + let (scale, clamp) = arena + .frame(h) + .map(|f| (f.effective_scale, f.clamped_to_screen)) + .unwrap_or((1.0, false)); + let input = layout_inputs.entry(h).or_default(); + input.scale = scale; + // Clamp-to-screen is a FRAME property (geometry flags bit4 — SetClampedToScreen, the + // XML attribute, the GameTooltip-kind default), synced like scale so EVERY placement + // path — SetOwner's anchor law, a caller's SetPoint, the cursor-seated world plates — + // resolves through the same clamp, with extents tracking the live window size. + input.clamp = clamp; + if clamp { + input.extent_x = screen.right; + input.extent_y = screen.top; + } + } + pre.ids = pre.lap(); + + // The ScrollFrame mechanism (decision 0112): a live ScrollFrame with a live scroll child + // overrides the child's own anchors for this solve — `SetScrollChild` pins the child TOPLEFT + // to the scrollframe's TOPLEFT, offset by the live vertical scroll (`(0, vertical)`; XML + // y-positive-up, so a positive offset lifts the child, bringing content below the fold into + // view — `frame top 500, vertical 40 ⇒ child top 540`). This is a LOCAL map, consulted only + // while building each round's graph below — the child's authored `LayoutInput.anchors` are + // never touched, so `SetScrollChild(nil)` needs no restore: the override just stops being + // computed. The child's own width/height (from its own `LayoutInput`) stay whatever they are; + // only its anchors are replaced. + let mut scroll_child_anchor: HashMap = HashMap::new(); + for &(h, id) in &ids { + let Some(frame) = arena.frame(h) else { + continue; + }; + if frame.kind != FrameKind::ScrollFrame { + continue; + } + let KindState::Scroll(state) = &frame.kind_state else { + continue; + }; + let Some(child) = state.child else { continue }; + if arena.frame(child).is_none() { + continue; // a stale/destroyed child contributes no override + } + scroll_child_anchor.insert( + child, + Anchor::new(Point::TopLeft, id, Point::TopLeft, 0.0, state.vertical), + ); + } + pre.scroll = pre.lap(); + + // Alternating frame/region rounds, run to a CHANGE-DRIVEN FIXPOINT: the real client + // resolves ONE layout graph in which frames and regions anchor each other freely (the + // gossip option rows anchor to the greeting FontString's laid-out bottom; the quest log's + // detail pane chains ~15 regions deep with item BUTTONS anchored to the tail). Our frame + // pass is a batch graph solve, so each round re-solves it with the latest REGION rects as + // externals, re-resolves the regions against the new frame rects, and stops when a full + // round changed nothing. The old fixed budget (2 frame rounds × 3 region rounds) silently + // left chain links past ~6 on owner-edge fallbacks and dropped tail-anchored frames to the + // screen origin — decision 0088 §2's finding, which forced windows into invented fixed + // offsets instead of ref-verbatim chains. The round cap is the node count (an honest round + // binds at least one new link), so only a genuine anchor CYCLE can exhaust it — warned, + // mirroring the client's resolving-flag cycle bail. Convergence compares exact rects: each + // pass recomputes from the same inputs through the same arithmetic, so stabilized values + // repeat bit-for-bit (no epsilon drift). + // + // The fixpoint CARRIES ACROSS FRAMES (decision 0294): every rect is recomputed purely from + // (inputs, externals), never from its own prior value, so last frame's converged rects are a + // legal seed — a quiet frame re-verifies in ONE round instead of re-propagating every anchor + // chain link-by-link (measured: the full default UI held 5–10 rounds × ~7 ms/round at + // opt-level 0, every frame, ~72 ms of a quiet frame — the director's 60→50 fps). Only + // regions that left the model (or lost their anchors) drop; a from-scratch resolve is just + // the empty-seed special case, so first-frame behavior is unchanged. + region_resolved.retain(|rh, _| { + arena.region(*rh).is_some() + && region_data.get(rh).is_some_and(|d| !d.anchors.is_empty()) + }); + pre.retain = pre.lap(); + + // ── The change gate ─────────────────────────────────────────────────────────────────── + // Fingerprint exactly what the rounds below read, and skip them outright when nothing has + // moved since the last converged resolve. Measured on the shipped default UI: the layout + // inputs are byte-identical on 34 of 35 idle frames and 39 of 41 with three windows open, + // because almost all per-frame UI traffic (StatusBar values, cooldown sweeps, chat fades, + // colour changes) is read by EXTRACT, not by the anchor solve. + // + // The read set, in the order the rounds consume it: + // * `screen` — the root external, and the clamp extents; + // * `plan` — every live frame's id, its (already scale/clamp- + // synced) `LayoutInput`, and its ScrollFrame override, + // which together distil `frame_to_id`, frame liveness, + // `effective_scale`, `clamped_to_screen`, and the + // ScrollFrame child + vertical offset; + // * `region_data` — each region's anchors, explicit size, and measured + // text extent; + // * `arena.region(rh)` — liveness and owner (a dead region drops out of the + // sweep; the owner supplies the fallback edges). + // + // **INPUTS ONLY — the 0294 seeds are deliberately NOT hashed** (decision 1385). They are + // the previous pass's OUTPUT, and 0294's own property ("every rect is recomputed purely + // from (inputs, externals), never from its own prior value") makes a converged pass's + // rects a pure function of the inputs: the seeds change how many ROUNDS convergence + // takes, never what it converges to. Hashing them therefore added no verdict the input + // half does not already give — and it cost two extra whole-roster walks per mutated + // frame, because a solve necessarily outgrows the seeds it hashed, so neither that pass + // nor the settling pass behind it could close tier 1. (`resolved`, the FRAME rects, was + // never in the read set at all — the seed half was asymmetric as well as redundant.) + // + // ── …and, in the same walk, the SCOPE (decision 1350) ───────────────────────────────── + // Each node's own inputs are hashed a second time on their own, into + // [`LayoutScope::now`], and its anchor targets are recorded as reverse edges. That turns + // the gate's one verdict ("something moved") into the far more useful one ("*these* moved, + // and here is everything downstream of them"), for the cost of a second accumulator over + // a read set we were walking anyway. Everything the aggregate `fp` feeds still feeds it, + // byte for byte, so tiers 1 and 2 are untouched. + // Sized past `next_id` because the region walk below MINTS ids (see there) — every id it + // can mint is one more than the anchored regions it walks. + scope.begin_full(*next_id as usize + region_data.len() + 1); + pre.begin = pre.lap(); + let mut fp = InputFingerprint::default(); + fp.rect(*screen); + // The frame roster is built HERE rather than in a walk of its own (decision 1388): it is + // the same `ids` list, the same `layout_inputs` probe and the same `scroll_child_anchor` + // lookup this walk already needs, so a separate pass over every live frame bought nothing + // but a second traversal (77 µs of the old preamble) and a borrow that stopped the roster + // outliving the call. + for &(h, id) in &ids { + let Some(input) = layout_inputs.get(&h) else { + continue; + }; + let over = scroll_child_anchor.get(&h).copied(); + #[allow(clippy::cast_possible_truncation)] + let i = scope.plan.len() as u32; + fp.feed(u64::from(id)); + fp.input(input); + match over { + Some(a) => fp.anchors(std::slice::from_ref(&a)), + None => fp.feed(u64::MAX), + } + if let Some(slot) = scope.now.get_mut(id as usize) { + *slot = frame_node_hash(input, over); + scope.node_of[id as usize] = i; + } + scope.plan.push((h, id, over)); + // A frame reads the rect of every anchor target it has — its OVERRIDE's target when a + // ScrollFrame supplies one (the authored anchors are not consulted at all in that + // case, exactly as the round loop's `set_frame_anchored` does not consult them). + match over { + Some(a) => scope.edge(id, a.relative_to), + None => { + for a in &input.anchors { + scope.edge(id, a.relative_to); + } + } + } + } + #[allow(clippy::cast_possible_truncation)] + { + fp.feed(scope.plan.len() as u64); + } + pre.fp_frames = pre.lap(); + let mut fed_regions = 0u64; + // The round loop's region roster, built in this same walk (decision 1350) — see + // [`RegionRow`]. Only LIVE, anchored regions: an anchor-less one is invisible to the + // rounds and a destroyed one has already been dropped from `region_resolved` by the retain + // above, so both are exactly what the sweep used to `continue` past. + scope.regions.reserve(region_data.len()); + for (&rh, data) in region_data.iter() { + // Anchor-less entries are invisible to the rounds — the sweep `continue`s on empty + // anchors and the seed retain drops them — so they are not inputs and must not be + // hashed: a paint-only setter creating a region's entry (`SetTexture`'s + // `or_default()`) would otherwise read as a layout change no touch site can own. + if data.anchors.is_empty() { + continue; + } + fed_regions += 1; + let live = arena.region(rh); + fp.feed(rh.fingerprint_bits()); + fp.anchors(&data.anchors); + match data.size { + Some((w, h)) => { + fp.f32(w); + fp.f32(h); + } + None => fp.feed(u64::MAX), + } + match data.measured { + Some(m) => { + fp.f32(m.w); + fp.f32(m.h); + } + None => fp.feed(u64::MAX), + } + if let Some(r) = live { + // Mint the layout id here if the region has never needed one. The sweep used to + // resolve an id-less region and simply not publish its rect (`if let Some(&id) = + // region_to_id.get(..)`) — an EditBox's implicit text FontString is exactly that + // shape, created and anchored by `SetTextInsets` and given an id only when extract + // or a measure first asks. The scope addresses nodes BY id, so it needs one; and + // minting it is pure addressing — it moves no rect, so unlike `Model::region_id` + // this does not touch the layout epoch (doing so would re-dirty the very resolve + // that is running). + let id = match region_to_id.get(&rh) { + Some(&id) => id, + None => { + let id = *next_id; + *next_id += 1; + region_to_id.insert(rh, id); + id_to_region.insert(id, rh); + id + } + }; + #[allow(clippy::cast_possible_truncation)] + let idx = scope.regions.len() as u32 | REGION_TAG; + if let Some(slot) = scope.now.get_mut(id as usize) { + *slot = region_node_hash(data); + scope.node_of[id as usize] = idx; + } + // A region reads its anchor targets' rects, and — for any axis its anchors do not + // pin — its OWNER's rect and scale. Both are dependencies; the owner one is the + // edge that carries a window moving to every unpinned texture inside it. + for a in &data.anchors { + scope.edge(id, a.relative_to); + } + if let Some(&owner_id) = frame_to_id.get(&r.owner) { + scope.edge(id, owner_id); + } + scope.regions.push(RegionRow { + rh, + id, + owner: r.owner, + is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString), + }); + } + // Liveness only. The sweep also reads the region's `owner` and `kind`, but both are + // fixed at creation and can never change under a live handle; the owner's contribution + // to the result — its resolved rect and its `effective_scale` — rides the frame half + // (the scale is synced into `layout_inputs` by the preamble above, and the rect is the + // frame pass's own output). A DESTROYED region does still matter: `WidgetArena::destroy` + // drops the arena entry but leaves `region_data` behind, so membership alone would miss + // it and the sweep's `continue` would go unnoticed. + fp.feed(u64::from(live.is_some())); + } + // The count of FED entries (not the map's len — see the vacuous-entry skip above), so an + // entry leaving the anchored set is a change even if a sibling enters the same frame. + fp.feed(fed_regions); + pre.fp_regions = pre.lap(); + fp + } + /// FontStrings whose layout needs a host text measurement (an auto-sized axis — explicit /// width and/or height of 0, the client's size-to-text idiom — text present, cache stale) — /// the engine side of the measure round-trip. Call after [`UiScript::resolve`]; answer with diff --git a/crates/benilla-ui/src/script/measure.rs b/crates/benilla-ui/src/script/measure.rs index 73701654..ac2b5380 100644 --- a/crates/benilla-ui/src/script/measure.rs +++ b/crates/benilla-ui/src/script/measure.rs @@ -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); } } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index e33a381e..c1a9e270 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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. diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 4bf65739..2ff129e0 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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>, + /// `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=` — name the epoch's per-frame toucher: print a backtrace // for the first n touches (a mechanism probe, meaningful in a debuginfo build). Built diff --git a/crates/benilla-ui/src/script/object.rs b/crates/benilla-ui/src/script/object.rs index 7de973c3..51bfe699 100644 --- a/crates/benilla-ui/src/script/object.rs +++ b/crates/benilla-ui/src/script/object.rs @@ -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}; // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index a22612b3..6302698c 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -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. diff --git a/crates/benilla-ui/src/script/object/movable.rs b/crates/benilla-ui/src/script/object/movable.rs index b1d8e3da..00dc66fd 100644 --- a/crates/benilla-ui/src/script/object/movable.rs +++ b/crates/benilla-ui/src/script/object/movable.rs @@ -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 }); diff --git a/crates/benilla-ui/src/script/region.rs b/crates/benilla-ui/src/script/region.rs index ba129791..d7636eda 100644 --- a/crates/benilla-ui/src/script/region.rs +++ b/crates/benilla-ui/src/script/region.rs @@ -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(()) } diff --git a/crates/benilla-ui/src/script/region/layout.rs b/crates/benilla-ui/src/script/region/layout.rs index f62e3817..de67ed07 100644 --- a/crates/benilla-ui/src/script/region/layout.rs +++ b/crates/benilla-ui/src/script/region/layout.rs @@ -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(()) })?, diff --git a/crates/benilla-ui/src/script/simplehtml/mod.rs b/crates/benilla-ui/src/script/simplehtml/mod.rs index d0179dd8..958cef22 100644 --- a/crates/benilla-ui/src/script/simplehtml/mod.rs +++ b/crates/benilla-ui/src/script/simplehtml/mod.rs @@ -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(); } // ───────────────────────────────────────────────────────────────────────────────────────────── diff --git a/crates/benilla-ui/src/script/tooltip/mod.rs b/crates/benilla-ui/src/script/tooltip/mod.rs index 71266cb8..712a0cbb 100644 --- a/crates/benilla-ui/src/script/tooltip/mod.rs +++ b/crates/benilla-ui/src/script/tooltip/mod.rs @@ -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]); + } } } }