use std::collections::HashMap; use crate::layout::{self, Anchor, LayoutInput, Point, Rect}; use crate::order::ZTarget; use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle}; use super::backdrop; use super::types::RegionData; use super::{ExtractedQuad, MeasureRequest, Model, QuadContent, UiScript, SCREEN}; /// The paint a frame slot's derived quads inherit — its `effective_alpha` and `effective_scale` /// travelling together (backdrop pieces and message-frame ring lines are emitted BY the frame, /// so they wear the frame's own factors exactly like [`ExtractedQuad::alpha`]/`scale` on the /// slot itself). #[derive(Clone, Copy)] pub(super) struct FramePaint { pub(super) alpha: f32, pub(super) scale: f32, } /// A 128-bit rolling fingerprint of everything [`UiScript::resolve_layout`] *reads*, so a resolve /// whose inputs are byte-identical to the last one can be skipped outright. /// /// ## Tier 2 of a two-tier gate (decision 0740) /// /// This fingerprint began as the WHOLE gate, chosen over a dirty flag because a flag has sites to /// miss and a fingerprint is computed FROM the read set — any change moves it by construction. /// That safety cost ~0.6 ms *every idle frame* at a live pin (hashing ~2k inputs to conclude /// "quiet"), which is why the mutation epoch ([`Model::touch_layout`], tier 1) now sits in front: /// a frame with no layout-visible write skips at a `u64` compare and never reaches this hash. The /// fingerprint remains load-bearing where the flag is structurally weak — a *dirty* frame whose /// writes were idempotent (the bag-hover re-enter loop clears and rebuilds identical content /// every frame; the GameTooltip pre-pass rewrites tooltip sizes every call) hashes clean here and /// still skips the solve. And the flag's own failure mode — a missed touch site, a silently stale /// layout — is machine-checked: under [`layout_verify_enabled`] (forced on in this crate's tests) /// a tier-1-quiet frame asserts the fingerprint agrees, so the tiers police each other. /// /// ## Why 128 bits /// /// A collision here is not self-healing: the skip keeps the stored value, so a colliding change /// would stay unresolved until something *else* moved. Two independent lanes put that beyond /// physical concern (~2^-128 per frame) at the cost of one extra multiply per item. /// /// ## Order sensitivity is safe /// /// The accumulator is order-dependent, and it is fed by walking `HashMap`s whose iteration order /// is arbitrary. That order is nevertheless *stable for an unmutated map*, so an unchanged model /// reproduces the value; a mutated map may reorder and merely re-resolves. The failure mode is a /// false DIRTY (a wasted resolve), never a false CLEAN (a stale rect). #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) struct InputFingerprint(u64, u64); impl Default for InputFingerprint { fn default() -> Self { // Distinct non-zero bases (FNV-1a's, and a 64-bit mix constant) so leading zero-valued // items still move both lanes. InputFingerprint(0xcbf2_9ce4_8422_2325, 0x9e37_79b9_7f4a_7c15) } } impl InputFingerprint { #[inline] fn feed(&mut self, v: u64) { self.0 = (self.0 ^ v).wrapping_mul(0x0000_0100_0000_01b3); self.1 = (self.1 ^ v.rotate_left(29)).wrapping_mul(0xff51_afd7_ed55_8ccd); } #[inline] fn f32(&mut self, v: f32) { // Bit pattern, not value: `resolve` is bit-exact float math, so -0.0 and NaN payloads are // distinctions that matter here even where `==` would call them equal (or unequal). self.feed(u64::from(v.to_bits())); } #[inline] fn anchors(&mut self, anchors: &[Anchor]) { self.feed(anchors.len() as u64); for a in anchors { self.feed(u64::from(a.point.id())); self.feed(u64::from(a.relative_to)); self.feed(u64::from(a.relative_point.id())); self.f32(a.x_off); self.f32(a.y_off); } } #[inline] fn input(&mut self, i: &LayoutInput) { self.anchors(&i.anchors); self.f32(i.width); self.f32(i.height); self.f32(i.scale); self.feed(u64::from(i.clamp)); self.f32(i.extent_x); self.f32(i.extent_y); } /// Collapse to the single `u64` [`LayoutScope`] compares per node. Never [`NO_NODE`]: a /// present node reading as *absent* would be a false CLEAN — the one direction that ships a /// stale rect rather than a wasted resolve. #[inline] fn finish(self) -> u64 { let h = self.0 ^ self.1.rotate_left(32); if h == NO_NODE { 1 } else { h } } #[inline] fn rect(&mut self, r: Rect) { self.f32(r.bottom); self.f32(r.left); self.f32(r.top); self.f32(r.right); } } /// The sentinel in [`LayoutScope::last`]/`now` for "this id was not in that pass's graph". A real /// hash colliding with it costs a spurious DIRTY (a node re-solved for nothing), never a stale /// 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. const REGION_TAG: u32 = 0x8000_0000; /// Per-node layout scope — the state that lets a resolve touch only what MOVED (decision 1350, /// the phase 2 decision 0771 left open). /// /// 0771 halved the per-content-change cost by folding two whole-UI solves into one; it also said /// plainly that halving it was not fixing it, because *one* solve is still a fixpoint over every /// frame and every region for a change that touches ten FontStrings inside one frame. Two later /// correctness fixes then tripled what a region costs — `0dd6a559a` (a region whose owner has no /// rect resolves from its own anchors) and 1310 (every anchor-less Texture/FontString gets /// implicit anchors at creation) each moved a large population out of the sweep's cheap /// `continue` and into full resolution. Neither is wrong; both are why the whole-UI shape had to /// go. /// /// Everything here is indexed **densely by layout id** — frames and regions are minted from one /// monotonic counter (`Model::next_id`), so a single array covers both, and the solver's own rect /// array is indexed the same way. /// /// Nothing in here allocates on a steady frame: every buffer is resized once and refilled. #[derive(Default)] pub(crate) struct LayoutScope { /// Per id: the input hash the last CONVERGED resolve saw, [`NO_NODE`] if the id was not in /// that graph. A mismatch is a dirty SEED — and a birth is a mismatch for free. last: Vec, /// How many ids `last` holds a real hash for. Compared against how many of THIS pass's nodes /// were also in the last graph: a shortfall means a node DIED, whose dependents we can no /// longer enumerate (its edges died with it), so that pass falls back to full scope. last_count: usize, /// The screen rect the `last` set was hashed under. It is every clamped frame's extent and the /// root of every chain, so a move is a full-scope event. last_screen: Option, /// This pass's hash per id (parallel to `last`); swapped into `last` on convergence. now: Vec, /// Per id: index into this pass's node roster, or [`u32::MAX`]. node_of: Vec, /// Per id: head index into `dep_to`/`dep_next` of the nodes that DEPEND on it, or /// [`u32::MAX`]. An intrusive list rather than a CSR so the edges are built in one walk. dep_head: Vec, dep_to: Vec, dep_next: Vec, /// Edge slots freed by [`Self::retarget`], for [`Self::edge`] to reuse. Without it a session /// of hovers would grow `dep_to` by one entry per retarget forever — bounded only by the next /// full derivation, which is exactly the event retargeting exists to avoid. dep_free: Vec, /// Per id: in the dirty closure. 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 *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] … }`. /// /// 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, /// 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 /// not pin, and is therefore a layout DEPENDENCY of it. owner: FrameHandle, is_fontstring: bool, } impl LayoutScope { /// 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); self.node_of.clear(); self.node_of.resize(n, u32::MAX); self.dep_head.clear(); self.dep_head.resize(n, u32::MAX); self.dirty.clear(); self.dirty.resize(n, false); self.dep_to.clear(); self.dep_next.clear(); self.dep_free.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`. #[inline] fn edge(&mut self, from: u32, to: u32) { let Some(head) = self.dep_head.get_mut(to as usize) else { return; // an anchor to an id this pass has no node for (the screen root, a dead // target): it cannot become dirty, so it constrains nothing. }; let e = match self.dep_free.pop() { Some(e) => { self.dep_to[e as usize] = from; self.dep_next[e as usize] = *head; e } None => { let e = self.dep_to.len() as u32; self.dep_to.push(from); self.dep_next.push(*head); e } }; *head = e; } /// Re-point node `id`'s outgoing edges after an anchor RETARGET — the write 1388 had to /// classify as structural and answer with a whole-graph derivation. /// /// 1388's reasoning was that "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". The first half is true and the /// second does not follow: the write site knows both edge sets, because a node's outgoing /// edges ARE its anchor targets (see the derive's two `scope.edge` loops). What it could not /// do was *say* so — this is the vocabulary that was missing, not a cost that was unavoidable. /// /// It matters because the hottest retarget in the client is `GameTooltip:SetOwner`, which /// re-points the plate at the button under the cursor: every bag slot and every spellbook /// button crossed in a hover sweep was re-deriving the whole roster. /// /// `old`/`new` are the node's FULL anchor-target lists, before and after the write — not the /// one that moved. That is what makes duplicates exact: a node with two anchors onto the same /// target has two edges, and removing one per old entry leaves precisely the right count. /// /// Returns `false` when it cannot do this safely and the caller must fall back to the /// conservative touch. Removing an edge is the only direction that can be WRONG (a missing /// edge under-dirties, and under-dirtying ships a stale rect), so every uncertainty is /// resolved by refusing: an id the cached graph has no node for, or a frame whose edges come /// from a ScrollFrame override rather than from its own anchors (the derive does not consult /// the authored anchors at all in that case). An old target with no edge to remove is NOT an /// uncertainty — `edge` silently drops an anchor onto an id this pass has no node for, so its /// absence is the expected shape, and skipping it removes nothing that exists. pub(crate) fn retarget(&mut self, id: u32, old: &[u32], new: &[u32]) -> bool { let Some(&n) = self.node_of.get(id as usize) else { return false; }; if n == u32::MAX { return false; } if n & REGION_TAG == 0 && self.plan[n as usize].2.is_some() { return false; // a ScrollFrame override owns this frame's edges, not its anchors } for &t in old { self.unlink_edge(t, id); } for &t in new { self.edge(id, t); } true } /// Unlink ONE `to → from` edge, if the list holds one. The lists are short by construction /// (a node's dependents), and even a hub's is walked in microseconds against the ~1.3 ms /// derivation this is replacing, so no bound is imposed on the walk. fn unlink_edge(&mut self, to: u32, from: u32) { let Some(&head) = self.dep_head.get(to as usize) else { return; }; let mut prev = u32::MAX; let mut e = head; while e != u32::MAX { let next = self.dep_next[e as usize]; if self.dep_to[e as usize] == from { if prev == u32::MAX { self.dep_head[to as usize] = next; } else { self.dep_next[prev as usize] = next; } self.dep_free.push(e); return; } prev = e; e = next; } } /// Mark `id` dirty and queue it, if it is not already. #[inline] fn seed(&mut self, id: u32) { if let Some(d) = self.dirty.get_mut(id as usize) { if !*d { *d = true; self.stack.push(id); } } } /// Close the dirty set under "depends on": everything that reads a dirty node's rect, and /// everything that reads *those*, must resolve again too. fn close(&mut self) { while let Some(id) = self.stack.pop() { let mut e = self.dep_head[id as usize]; while e != u32::MAX { let to = self.dep_to[e as usize]; e = self.dep_next[e as usize]; self.seed(to); } } } /// Everything present is dirty — the fallback for the structural cases a per-node diff cannot /// speak about (a death, a screen move, the first pass, a graph left mid-flight by a cycle). fn dirty_all(&mut self) { for (id, &n) in self.node_of.iter().enumerate() { if n != u32::MAX { self.dirty[id] = true; } } } /// Adopt this pass's hashes as the converged memory. fn commit(&mut self, count: usize, screen: Rect) { std::mem::swap(&mut self.last, &mut self.now); self.last_count = count; self.last_screen = Some(screen); } /// [`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(); } } /// `WOW_LAYOUT_VERIFY=1` — the change gate's self-check (decision 0022's instruments-first /// posture). When set, a resolve the gate wants to SKIP runs in full anyway and asserts it /// reproduced the previous rects exactly; a divergence means the fingerprint's read set is /// incomplete, and it fails loudly at the resolve that proves it rather than silently rendering a /// stale frame days later. It also checks tier 1 against tier 2: a frame the epoch judged quiet /// whose fingerprint nevertheless moved names a mutation path missing its `touch_layout()`. /// /// This is what makes the gates' central claims — "the fingerprint covers everything the rounds /// read" and "every fingerprint-visible write bumps the epoch" — machine-checked instead of /// argued: it is FORCED ON for this crate's own tests, so every UI test (the shipped windows, /// driven through real FrameXML + Lua) is a standing probe for a missed input or a missed touch; /// set the env to extend that to a live run or another crate's suite. Read once; when off it /// costs a relaxed atomic load per resolve. /// `WOW_LAYOUT_PROF=1` — per-solve shape reporting (see the read site in [`UiScript::resolve_layout`]). /// Read once; off, it costs one relaxed atomic load per resolve and no clock reads at all. fn layout_prof_enabled() -> bool { static ON: std::sync::OnceLock = std::sync::OnceLock::new(); *ON.get_or_init(|| std::env::var("WOW_LAYOUT_PROF").as_deref() == Ok("1")) } fn layout_verify_enabled() -> bool { static ON: std::sync::OnceLock = std::sync::OnceLock::new(); *ON.get_or_init(|| cfg!(test) || std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1")) } /// `WOW_LAYOUT_PROF=1`'s **preamble** split — the `[layout-pre]` line, one per LET-THROUGH resolve. /// /// `[layout-prof]` reports the rounds, which 1350 already made proportional to what moved. This /// reports everything *before* them: the fixed whole-roster work every let-through solve pays /// whatever moved, which 1383 priced at ~1.9 ms/solve at a live roster and named as the next cut /// without ever splitting it. A cut needs to know which of the eight walks it is buying back, so /// the split is the instrument that has to exist first. /// /// Off (the default) it takes no clock reads at all — [`Self::lap`] returns on the `on` test, so an /// unprofiled resolve pays one bool check per phase. #[derive(Default)] struct PreambleProf { on: bool, last: Option, /// The GameTooltip auto-size pre-pass (`layout_tooltips`) — a whole-frame-roster filter. tooltip: u128, /// `OnSizeChanged`'s "before" snapshot — a whole-`scripts`-map filter. watched: u128, /// The ids rebuild + the per-frame scale/clamp sync into `layout_inputs`. ids: u128, /// The ScrollFrame override map — another whole-frame-roster walk. scroll: u128, /// `region_resolved`'s liveness retain. retain: u128, /// The per-call solve plan rebuild. plan: u128, /// `LayoutScope::begin` — seven per-id `Vec`s cleared/resized. begin: u128, /// The fingerprint + scope walk over every live FRAME (double-hashed: aggregate + per-node). fp_frames: u128, /// 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 { fn new(on: bool) -> Self { Self { on, last: on.then(std::time::Instant::now), ..Default::default() } } /// Microseconds since the previous lap (0 when off). fn lap(&mut self) -> u128 { if !self.on { return 0; } let now = std::time::Instant::now(); self.last .replace(now) .map_or(0, |t| now.duration_since(t).as_micros()) } /// One line per let-through resolve, printed once the gate's verdict is known — `skips=1` is /// the walk that concluded "nothing moved" and returns without solving, which costs the same /// preamble as one that does. fn report(&self, skips: bool, frames: usize, regions: usize) { if !self.on { return; } let total = self.tooltip + self.watched + self.ids + self.scroll + self.retain + self.plan + self.begin + self.fp_frames + self.fp_regions + self.seed; eprintln!( "[layout-pre] skips={} incr={} frames={frames} anchored={regions} total_us={total} \ tooltip={} watched={} ids={} scroll={} retain={} plan={} begin={} \ fp_frames={} fp_regions={} seed={}", u8::from(skips), u8::from(self.incremental), self.tooltip, self.watched, self.ids, self.scroll, self.retain, self.plan, self.begin, self.fp_frames, self.fp_regions, self.seed ); } } /// `OnSizeChanged`'s "after": turn the entry-vs-now diff of the watched frames into queued /// `(id, width, height)` fires ([`Model::pending_size_changed`]). /// /// The gate is [`crate::layout::size_changed`] — the byte-verified `ApplyRect 0x76b580` test /// (`|Δwidth| ≥ ε ∨ |Δheight| ≥ ε`, `ε = _DAT_008029d4`), not a plain `!=`, so a rect that merely /// *moved* never fires and a sub-ε float wobble never does either. /// /// A frame with no rect at entry is compared against the ZERO rect, which is the client's own /// starting state (`CSimpleFrame`'s ctor zeroes the cached rect, so the first `ApplyRect` is a /// 0×0 → w×h change and does fire). A frame that LOSES its rect queues nothing: no rect was /// applied, so the reference had no `ApplyRect` to fire from. fn queue_size_changes( watched: &[(FrameHandle, Option)], resolved: &HashMap, frame_to_id: &HashMap, out: &mut Vec<(u32, f32, f32)>, ) { for &(h, before) in watched { let Some(&now) = resolved.get(&h) else { continue; }; let before = before.unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0)); if !layout::size_changed(before, now) { continue; } let Some(&id) = frame_to_id.get(&h) else { continue; }; out.push((id, now.right - now.left, now.top - now.bottom)); } } impl UiScript { /// [`UiScript::resolve`]'s body, taking `&mut Model` directly rather than `&mut self` — so a /// Lua binding holding only a `Model` borrow (via `lua.app_data_mut`, no `&Lua`-wrapping /// `UiScript` in scope) can force a fresh resolve too. [`scrollframe`]'s `UpdateScrollChildRect` /// is the motivating caller: it fires `OnScrollRangeChanged` with the CURRENT range, but /// "current" was a lie for its own primary use (the Era idiom of resize-then-notify in one /// script tick) as long as it only read the LAST full [`UiScript::resolve`]'s cached rects — /// those don't yet reflect a `SetHeight` earlier in the SAME tick (Lua runs before the app's own /// per-frame `resolve()`, `ui_script/mod.rs`'s drive loop), so the fired range was one tick /// stale on every "just resized, tell the scrollbar" call — precisely when a caller most needs /// 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 // pre-pass and the fingerprint itself (which was the whole idle cost of this function — // ~0.6 ms/frame at the LBRS pin, hashing ~2k inputs per frame to decide "quiet"). Under // verify, fall through: tier 2 then proves the claim (`gate_skips` must agree). let verify = layout_verify_enabled(); let tier1_clean = model.layout_epoch_resolved == Some(model.layout_epoch); if tier1_clean && !verify { return; } let epoch_at_entry = model.layout_epoch; // Past tier 1 ⇒ this call pays the whole-roster preamble, whatever the fingerprint goes on // to decide. Counted here rather than at the solve, because the walk IS the cost (1385). // Not gated on `verify`: a verify build re-walks skipped resolves and must say so. model.layout_gate_walks = model.layout_gate_walks.wrapping_add(1); // `WOW_LAYOUT_PROF=1`'s preamble split — see [`PreambleProf`]. Started here, so the clock // covers everything the tier-1 gate above did NOT skip. let mut pre = PreambleProf::new(layout_prof_enabled()); // The GameTooltip auto-size + right-flush pre-pass (decision 0274): writes tooltip frame // sizes + right-column anchor offsets from the measure round-trip's cached extents, so // the graph below solves them like any other frame. super::tooltip::layout_tooltips(model); pre.tooltip = pre.lap(); // ── `OnSizeChanged`'s "before" ─────────────────────────────────────────────────────── // The client fires it from `ApplyRect 0x76b580`, per rect application. Ours is a batch // fixpoint, so the faithful *mechanism* — "this frame's resolved size moved" — is the // entry-vs-convergence diff, not a per-round one: an intermediate round's half-solved rect // is an implementation detail of our solver, and firing on it would hand handlers sizes the // reference never produces (and re-fire on the way to the same answer). // // Snapshotted only for frames that actually CARRY a handler (a handful, usually zero), and // only past the tier-1 gate above — an idle frame returns before this line, so the change // costs nothing on the quiet path decision 0740 exists to protect. // // Read off `SetScript`'s maintained list (1446's shape, extended in 1634), NOT by filtering // the whole `scripts` map: "a handful, usually zero" was true of the RESULT and never of // the search, which cost a string compare per frame in a 3,988-frame roster and was the // preamble's biggest phase — larger than everything the graph does with the answer. let watched: Vec<(FrameHandle, Option)> = model .on_size_changed_frames .iter() .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, resolved, region_data, region_resolved, frame_to_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; // ── 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; } 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, } } else { let (h, _, over) = scope.plan[n as usize]; match layout_inputs.get(&h) { Some(i) => frame_node_hash(i, over), None => continue, } }; // 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); } } scope.close(); pre.seed = pre.lap(); pre.incremental = true; } // 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. 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 \ fingerprint moved — a layout write path is missing its touch_layout()" ); } // Nothing in the read set moved since the last CONVERGED resolve, so the rects it left are // still this frame's answer: close tier 1 and skip. (A converged solve closes it too — see // the convergence arm below. This arm is the one that absorbs an epoch bump whose write // changed no input: an id mint, an anchor-less region's creation, a re-`SetPoint` the // 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; } } // Under `WOW_LAYOUT_VERIFY=1` a skippable resolve runs anyway, against a copy of the rects // it is supposed to reproduce — see [`layout_verify_enabled`]. let verify_against = gate_skips.then(|| (resolved.clone(), region_resolved.clone())); // Cleared, not stored, until the fixpoint actually CONVERGES: a run that exhausts the // round cap (a genuine anchor cycle) leaves its rects mid-flight, and the 0294 cross-frame // seed is what lets the next frame carry them further. Storing here would freeze a // pathological graph at whatever partial state it reached. The epoch mirror follows the // same law: a cycle-bailed pass must re-enter tier 1 dirty next frame. *layout_fingerprint = None; *layout_epoch_resolved = None; // Counted on the gate's DECISION, not on the fixpoint running: under // `WOW_LAYOUT_VERIFY=1` a skipped resolve still runs below, and a counter that moved for // it would mean something different in the two modes — exactly the ambiguity the tests // asserting on it exist to remove. if !gate_skips { *layout_solves += 1; } // ── The scope: which nodes this solve is actually allowed to touch (decision 1350) ──── // A node whose own inputs are unchanged, and none of whose dependencies moved, recomputes // to the rect it already holds — the 0294 seed property, stated per node instead of for // the graph as a whole. So the solve below runs over the dirty CLOSURE and leaves every // other rect at its cached value, feeding it to the solver as an external where a dirty // node anchors to it. // // Four structural cases the per-node diff cannot speak about fall back to full scope, // because getting them wrong ships a stale rect rather than a slow frame: // * no memory yet (the first resolve, or a pass invalidated below); // * the SCREEN moved — the root external and every clamped frame's extent; // * a node DIED: its reverse edges died with it, so its dependents are unreachable. // 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. // // 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 { 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; 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 // REGION is seeded as well as swept: the frame pass runs before the sweep that refreshes // it, so it needs last round's value in front of it — which is exactly what the whole-graph // seeding did before. let mut solve_frames: Vec = Vec::new(); let mut sweep_regions: Vec = Vec::new(); let mut seed_frames: Vec = Vec::new(); let mut seed_regions: Vec = Vec::new(); // Is any node that reads `id` in the closure? (Seeding is per-node work too, so a rect // nothing dirty reads must not be seeded.) let read_by_dirty = |scope: &LayoutScope, id: u32| -> bool { let mut e = scope.dep_head[id as usize]; while e != u32::MAX { if scope.dirty[scope.dep_to[e as usize] as usize] { return true; } e = scope.dep_next[e as usize]; } false }; for id in 0..scope.node_of.len() { let n = scope.node_of[id]; if n == u32::MAX { continue; } let (idx, is_region, dirty) = (n & !REGION_TAG, n & REGION_TAG != 0, scope.dirty[id]); #[allow(clippy::cast_possible_truncation)] let id32 = id as u32; if is_region { if dirty { sweep_regions.push(idx); seed_regions.push(idx); } else if read_by_dirty(scope, id32) { seed_regions.push(idx); } } else if dirty { solve_frames.push(idx); } else if read_by_dirty(scope, id32) { seed_frames.push(idx); } } // The meter: how WIDE this solve is, beside `layout_solves` (how often) and // `layout_rounds` (how deep). The gate asserts on it, because a scope that tracks the // 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 = 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. let prof = layout_prof_enabled(); let mut t_frames = std::time::Duration::ZERO; let mut t_regions = std::time::Duration::ZERO; let mut n_regions_swept = 0u64; for round in 0..round_cap { *layout_rounds += 1; let t_round = prof.then(std::time::Instant::now); let mut changed = false; // Seed the solver: the screen root, then every rect this pass is NOT recomputing but // something in it reads (regions are externals to the frame solve — the fixpoint is // what closes the loop between them). Frame ids and region ids come from one monotonic // counter, so both live in the solver's single dense rect array and every anchor-target // lookup is an array index. solver.begin(); solver.set_external(SCREEN, *screen); for &i in &seed_regions { 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, _) = scope.plan[i as usize]; if let Some(r) = resolved.get(&h) { solver.set_external(id, *r); } } for &i in &solve_frames { // 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), } } solver.solve(); for &i in &solve_frames { let (h, id, _) = scope.plan[i as usize]; match solver.rect(id) { Some(r) => { if resolved.get(&h) != Some(&r) { resolved.insert(h, r); changed = true; } } None => { if resolved.remove(&h).is_some() { changed = true; } } } } // Second pass — anchored regions. A region that carries `SetPoint` anchors resolves through // the same [`crate::layout`] leaf math as frames; any edge the anchors + explicit size don't // pin inherits the owner frame's edge (so a single-point FontString gets a real rect, not // `+Inf`). Anchor targets may be frames OR **sibling regions by name** (the real XML does // this everywhere — the merchant label plate anchors to its `$parentSlot` texture; the // owner-fallback that stood in for this was the jutting-plates bug). One region sweep per // outer round: a not-yet-resolved chain target leaves this region on its owner-edge // fallback for now, the fixpoint re-sweeps until every link has settled. // // `scratch` is hoisted out of the loop so the per-region `LayoutInput` refills its // anchors `Vec` in place instead of allocating one per region per round. if let Some(t) = t_round { t_frames += t.elapsed(); } let t_reg = prof.then(std::time::Instant::now); let mut scratch = LayoutInput::default(); for &ri in &sweep_regions { let RegionRow { rh, id: region_id, owner, is_fontstring, } = 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; } // An owner with NO resolved rect does not disqualify its regions. `owner_rect` // is only the fallback for the axes this region's own anchors do not pin (see the // two `axis(..)` calls below) — a region anchored fully to some OTHER frame needs // nothing from its owner, and the reference resolves it. // // Skipping here made a whole shape silently invisible: a bare container frame // (`CreateFrame("Frame", n, UIParent)` with no size and no SetPoint) holding a // region anchored elsewhere. That is ordinary addon code — MapCoords builds three // of them, and its world-map coordinate readout computed the right string every // frame and was never positioned, with no error anywhere. // // It is only the FALLBACK, though, and an owner with no rect cannot supply one: an // unpinned axis on an unpositioned owner has nothing to fall back TO, and the // region is unresolvable exactly as its owner is. Standing a zero rect in there // instead put the region at the SCREEN ORIGIN, where a template's sibling-chained // textures resolve off it into real on-screen geometry — the stray dropdown capsule // at the bottom of the screen when the social pane opened (B264: // `BenillaFriendsDropDown` carries no anchors, exactly as the reference's // `FriendsDropDown` does, and the reference draws nothing). `None` keeps the // MapCoords fix — a region its own anchors fully pin never consults this — without // inventing a position for a frame that has none. let owner_rect = resolved.get(&owner).copied(); let scale = arena.frame(owner).map(|f| f.effective_scale).unwrap_or(1.0); // A FontString with no explicit height takes its host-measured wrapped size // (the measure round-trip — the client's layout↔font-engine seam). // // The stored measure is honoured WITHOUT a key check on purpose // ([`RegionData::measure_key`]): it is the LAST-KNOWN box, held so a line whose // text just changed does not collapse for the one frame the re-measure is in // flight. **Empty text is the case where that promise cannot be kept** — nothing // ever measures an empty string (`fontstrings_needing_measure` and // `stale_measure_key` both filter it out), so a cell that goes from text to `""` // would hold its old box FOREVER. There is no round-trip to wait for: an empty // string's extent is known, and it is zero. // // That forever-stale box is B309. Tooltip line cells are POOLED and reused across // hovers (`clear_content` keeps `measured` deliberately, for the hover re-enter // loop), so the item tooltip's two blank gold SET spacers (`render.rs` §22) landed // on cells that had carried real text on an earlier hover: each drew a full row // out of the dead measure while the plate counted it as zero // (`tooltip::cell` calls empty text `(0,0)`), and the set bonuses hung two rows // below the backdrop. The Lua-visible getters never disagreed — they key-check // (`region::measured_wh`) — which is why only the drawn geometry was wrong. let measured = data .measured .filter(|_| data.text.as_deref().is_some_and(|t| !t.is_empty())); let mut height = data.size.map_or(0.0, |s| s.1); let mut width = data.size.map_or(0.0, |s| s.0); if let Some(m) = &measured { if height == 0.0 { height = m.h; } if width == 0.0 { width = m.w; } } scratch.anchors.clear(); scratch.anchors.extend_from_slice(&data.anchors); scratch.width = width; scratch.height = height; scratch.scale = scale; let edges = layout::resolve_rect_edges(&scratch, |id| { // SCREEN is a FRAME-pass external only. The map-pair lookup this replaces // consulted `id_to_frame` and `id_to_region`, and neither can hold the // reserved id 0 (`next_id` starts at 1) — so a region anchor to the screen // root has always fallen through to the owner-edge fallback below. Preserved // deliberately: the solver's dense array DOES carry the screen rect at 0, and // quietly starting to resolve it here would be a behaviour change riding in // on a performance fix. (id != SCREEN).then(|| solver.rect(id)).flatten() }); // Unpinned edges: textures inherit the owner's edge (the v1 region model, // decision 0068). A FONTSTRING's implicit extent is its measured TEXT, and // empty/unmeasured text measures zero — so an unpinned edge COLLAPSES onto its // pinned opposite (a zero span), never onto the owner's edge. The owner // fallback there stretched an empty tooltip line to the plate's bottom, and // the line chain (each line anchors to the previous line's resolved bottom) // marched every later line out of the plate. An axis with NO pinned edge keeps // the owner fallback (nothing to collapse onto). // // `None` = this axis needs the owner and the owner has no rect (above): the region // is unresolvable, like its owner. // // **The owner fallback is OURS, not the client's** (decision 1349, from wow-re's // byte-verified `region-size-fallback.md`): a complete operand enumeration of the // real resolver `[0x7671a0, 0x76761f)` finds no parent pointer in it at all. The // client reaches the same answer for the case that matters — a region with a parent // and NO anchors — at *attach* time instead, via an implicit `SetAllPoints(parent)` // (`0x7701c0` / `0x771480`), and reaches a DIFFERENT answer for every other shape, // because its size getters are virtual and content-derived (see `layout::size_span`). // That is why an authored-zero-width texture with one anchor resolves to its OWNER's // width here and to 8 points there. 1349 §4 carries the replacement and its scope. let axis = |lo: Option, hi: Option, owner: Option<(f32, f32)>| -> Option<(f32, f32)> { match (is_fontstring, lo, hi) { (true, Some(l), None) => Some((l, l)), (true, None, Some(h)) => Some((h, h)), (_, Some(l), Some(h)) => Some((l, h)), _ => { let (olo, ohi) = owner?; Some((lo.unwrap_or(olo), hi.unwrap_or(ohi))) } } }; let vertical = axis(edges[0], edges[2], owner_rect.map(|o| (o.bottom, o.top))); let horizontal = axis(edges[1], edges[3], owner_rect.map(|o| (o.left, o.right))); let (Some((bottom, top)), Some((left, right))) = (vertical, horizontal) else { // Unresolvable: drop any rect it used to have, the way the frame pass does — // a region that stops resolving must stop drawing, not keep a stale position. if region_resolved.remove(&rh).is_some() { changed = true; } continue; }; let rect = Rect::new(bottom, left, top, right); // Publish into the solver as well as the model: a later region in THIS same sweep // that anchors to this one must see the fresh rect (the sweep has always worked // that way — it read the same `region_resolved` map it was writing). solver.set_external(region_id, rect); if region_resolved.get(&rh) != Some(&rect) { region_resolved.insert(rh, rect); changed = true; } } if let Some(t) = t_reg { t_regions += t.elapsed(); } if !changed { if prof { // `anchored` (live + anchored regions) is reported beside `regions_total` // deliberately: the sweep never touched the difference, so a solve's real // population was invisible in this line — which is exactly why two commits // that moved a large batch of regions from "skipped" to "resolved" (1350's // pin) showed up as a cost with no counter behind it. `solved`/`swept` are // this pass's SCOPE: with the graph settled they should be a handful. eprintln!( "[layout-prof] rounds={} frames={} regions_total={} anchored={} \ solved={} swept={} scope={} regions_swept={} frame_us={} region_us={}", round + 1, scope.plan.len(), region_data.len(), scope.regions.len(), solve_frames.len(), sweep_regions.len(), if full_scope { "full" } else { "dirty" }, n_regions_swept, t_frames.as_micros(), t_regions.as_micros() ); } if let Some((frames_before, regions_before)) = &verify_against { assert!( frames_before == resolved && regions_before == region_resolved, "WOW_LAYOUT_VERIFY: the change gate skipped a resolve that would have \ MOVED something — the fingerprint's read set is incomplete. \ frames {} -> {} changed, regions {} -> {} changed", frames_before.len(), resolved.len(), regions_before.len(), region_resolved.len(), ); } *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. // // 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 // the line above is exactly the one the next mutation-free resolve recomputes: // there is nothing left for a settling pass to discover. Closing here is what // makes a per-frame layout write cost ONE whole-roster walk instead of three // (solve, settle, skip) — the castbar's spark, and every addon that animates a // region, used to pay all three every frame. // // `epoch_at_entry` (not the live epoch) keeps it conservative: anything that // touched the layout WHILE this pass ran — the tooltip pre-pass, a lazily minted // region — leaves the epoch ahead of the stored value, so tier 1 re-opens next // call exactly as it should. The cycle-bail path below never reaches here, so a // graph left mid-flight still re-resolves. *layout_epoch_resolved = Some(epoch_at_entry); queue_size_changes(&watched, resolved, frame_to_id, pending_size_changed); return; } if round + 1 == round_cap { warnings.push(format!( "layout: anchor graph did not converge in {round_cap} rounds — \ an anchor cycle? (rects left at their last pass)" )); } } // The cycle bail (the loop ran out of rounds and warned above): the rects it leaves are // still the ones every reader will see this frame, so the sizes that moved get their // `OnSizeChanged` here exactly as on the converged path — and the scope forgets, so the // next resolve rebuilds the whole graph rather than trusting a half-solved one. scope.invalidate(); 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 /// [`UiScript::set_measured_text`] and resolve again (the second solve is cheap and the cache /// keys keep this empty on quiet frames). A zero WIDTH auto-sizes exactly like a zero height /// (the real client sizes the rect to the unwrapped line — `` labels like /// the mail window's "From:" anchor their RIGHT edge and grow leftward); gating on height /// alone left those rects zero-width and their anchored neighbours overlapping. pub fn fontstrings_needing_measure(&mut self) -> Vec { let mut model = self.model_mut(); // ANY FontString with text — not only the auto-sized ones. A region with both axes // declared needs no measure for its *layout*, but `GetStringWidth` still has to answer // with the string's natural width, and only a measure can supply it (decision 0997: a // kit that reads that number and then SETS a width on the string would otherwise stop // receiving measures the instant it did so, and start reading its own box back). // // Asked off the MEASURE LEDGER, not the roster (the 1388 shape one lane over): the // sweep existed to *discover* silent writes — `SetText` touches no layout until the // extent moves — and discovery cost two whole-roster walks per frame at a city pin // (~300 µs of the `resolve` lap, decision 1370) to conclude, almost always, that // nothing was written. The write sites announce themselves now // ([`Model::touch_measure`]); draining the ledger re-arms it, so the second ask of the // same frame (`fill_measures`, inside `resolve`) sees only what was written *between* // the asks — same-frame semantics unchanged. `None` (a fan-out that could not name its // regions, or the initial state) is the old whole-roster walk, verbatim. let ledger = model.measure_dirty.take(); model.measure_dirty = Some(Vec::new()); let needy: Vec = match ledger { Some(mut dirty) => { dirty.sort_unstable_by_key(|rh| rh.fingerprint_bits()); dirty.dedup(); dirty.retain(|&rh| stale_measure_key(&model, rh).is_some()); // The completeness falsifier (on for every benilla-ui test): the ledger's whole // correctness claim is that its enrolled sites cover every measure-key writer, // and that claim is checked, not argued — the old discovery walk runs beside // the drain and any row it finds that the ledger missed is a stale label // waiting to ship, so it panics with the region named. The other direction is // legal by construction (an enrolled same-value write drops out at the // staleness check above). if layout_verify_enabled() { let roster: Vec = model .region_data .iter() .filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty())) .map(|(&rh, _)| rh) .filter(|&rh| stale_measure_key(&model, rh).is_some()) .collect(); for rh in &roster { assert!( dirty.contains(rh), "measure ledger missed a write: region {rh:?} (id {:?}) needs a \ measure the drained ledger never named — a measure-key write site \ is not enrolled in Model::touch_measure", model.region_to_id.get(rh), ); } } dirty } None => model .region_data .iter() .filter(|(_, d)| d.text.as_deref().is_some_and(|t| !t.is_empty())) .map(|(&rh, _)| rh) .filter(|&rh| stale_measure_key(&model, rh).is_some()) .collect(), }; // Re-enroll everything still stale: a request handed out is not an answer. The old // discovery walk re-found an unanswered region every frame until `set_measured_text` // landed (the no-measurer engine-test path answers a frame later; a dropped request // would otherwise go stale FOREVER now that nothing walks the roster), so the ledger // keeps asking too. With a synchronous measurer the answer lands before the next drain // and the staleness check drops the re-enrolled handle for free. if let Some(list) = &mut model.measure_dirty { list.extend(needy.iter().copied()); } let mut out = Vec::with_capacity(needy.len()); for rh in needy { if let Some(req) = measure_request_for(&mut model, rh) { out.push(req); } } out } /// Push one [`QuadContent::Backdrop`] per piece of frame `fh`'s installed backdrop (no-op if the /// frame has none). Pieces carry the frame slot's `z` — behind the frame's regions (which sort /// after it) and, among themselves, bg-then-border in paint order (the stable z-sort keeps the /// emission order). Each piece's rect is its screen bounding box; the app resolves the texture /// from the piece path and multiplies the tint by the [`FramePaint`]'s alpha. pub(super) fn emit_backdrop( model: &Model, fh: FrameHandle, fr: Rect, z: u64, paint: FramePaint, clip: Option, out: &mut Vec, ) { let Some(bd) = model.backdrops.get(&fh) else { return; }; for piece in backdrop::pieces(fr, bd) { // The piece's screen bounding box (the render is axis-aligned; the BR-inset bug's slant, // invisible for the symmetric insets every shipping backdrop uses, collapses to the box). let xs = piece.corners.map(|c| c[0]); let ys = piece.corners.map(|c| c[1]); let (left, right) = ( xs.iter().copied().fold(f32::MAX, f32::min), xs.iter().copied().fold(f32::MIN, f32::max), ); let (bottom, top) = ( ys.iter().copied().fold(f32::MAX, f32::min), ys.iter().copied().fold(f32::MIN, f32::max), ); let path = if piece.is_bg { bd.bg_file.clone() } else { bd.edge_file.clone() }; let Some(path) = path else { continue }; let color = if piece.is_bg { bd.bg_color } else { bd.border_color }; out.push(ExtractedQuad { target: ZTarget::Frame(fh), z, rect: Some(Rect::new(bottom, left, top, right)), alpha: paint.alpha, content: QuadContent::Backdrop { path, color, uvs: piece.uvs, tile: piece.tile, }, clip, scale: paint.scale, }); } } } /// The [`MeasureRequest`] region `rh` needs right now, or `None` if it needs none — it is not a /// FontString, it holds no text, or its stored measure is already this exact string's. /// /// Hoisted out of [`UiScript::fontstrings_needing_measure`] so the **synchronous** measure /// ([`super::measure::ensure_measured`]) builds its request with the identical recipe. The two must /// agree byte for byte on the cache key, or a same-tick measure and the batch pass would each think /// the other's answer was stale and re-measure forever. pub(super) fn measure_request_for(model: &mut Model, rh: RegionHandle) -> Option { let (key, scale) = stale_measure_key(model, rh)?; let d = model.region_data.get(&rh)?; let text = d.text.clone().filter(|t| !t.is_empty())?; let wrap_width = d.size.map(|s| s.0).filter(|w| *w > 0.0); let font = d.font_path.clone(); let height = d.font_height; let text_height = d.text_height; let outline = d.outline; let id = model.region_id(rh); Some(MeasureRequest { id, font, height, text_height, wrap_width, outline, scale, text, key, }) } /// The staleness half of [`measure_request_for`], allocation-free: `Some((key, scale))` iff `rh` /// is a FontString holding text whose stored measure no longer matches its current key. This is /// the ONE place the "does this region need a measure?" question is answered — the per-frame /// sweep ([`super::UiScript::fontstrings_needing_measure`]) asks it for every FontString, so it /// must not clone; the request build above pays the clones only after this says stale. fn stale_measure_key(model: &Model, rh: RegionHandle) -> Option<(u64, f32)> { let region = model.arena.region(rh)?; if !matches!(region.kind, crate::widget::RegionKind::FontString) { return None; } // The owner's effective_scale: the host measures at the drawn raster size // ([`MeasureRequest::scale`]), and the key carries it so a SetScale re-measures. let scale = model .arena .frame(region.owner) .map(|f| f.effective_scale) .unwrap_or(1.0); let d = model.region_data.get(&rh)?; if d.text.as_deref().is_none_or(|t| t.is_empty()) { return None; } // The shared key recipe ([`RegionData::measure_key`]) — the metric reads (region.rs) check the // stored measure against the same hash. let key = d.measure_key(scale); if d.measured.map(|m| m.key) == Some(key) { return None; } Some((key, scale)) }