use std::collections::HashMap; use crate::layout::{self, Anchor, LayoutInput, Point, Rect}; use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle}; use super::types::RegionData; use super::{MeasureRequest, Model, UiScript, SCREEN}; /// A frame's `effective_alpha` and `effective_scale`, worn by the quads it emits itself. #[derive(Clone, Copy)] pub(super) struct FramePaint { pub(super) alpha: f32, pub(super) scale: f32, } /// A 128-bit fingerprint of what [`UiScript::resolve_layout`] reads: tier 2 of the change gate, /// behind the mutation epoch ([`Model::touch_layout`]). Two lanes, as a collision keeps a stale /// rect; fed in `HashMap` order, stable for an unmutated map, so a reorder only costs a resolve. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) struct InputFingerprint(u64, u64); impl Default for InputFingerprint { fn default() -> Self { // Non-zero bases (FNV-1a's and a mix constant), so leading zeros 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: the solve is bit-exact, so -0.0 and NaN payloads are distinct. 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); } /// The per-node `u64`; never [`NO_NODE`], or a present node would read as absent. #[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`] and `now` for an id not in that pass's graph. const NO_NODE: u64 = 0; /// A frame's per-node hash; the full derive and the incremental pass must agree bit for bit. #[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() } /// The measured extent the region sweep sizes a FontString by: the stored measure while the text /// is non-empty, used without a key check, so changed text keeps its last box while the /// re-measure is in flight; empty text is never measured, so it has none, never a stale box. #[inline] pub(super) fn effective_measure(data: &RegionData) -> Option { data.measured .filter(|_| data.text.as_deref().is_some_and(|t| !t.is_empty())) } /// A region's per-node input hash: its anchors, explicit size, owner, whose effective scale the /// sweep reads (`None` for an owner with no layout id), and, where an axis authored 0 takes its /// span from them, its effective measure and its art: exactly what the sweep reads. #[inline] pub(super) fn region_node_hash(data: &RegionData, owner: Option) -> u64 { let mut node = InputFingerprint::default(); node.anchors(&data.anchors); node.feed(owner.map_or(u64::MAX, u64::from)); match data.size { Some((w, h)) => { node.f32(w); node.f32(h); } None => node.feed(u64::MAX), } let unsized_axis = data.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0); match effective_measure(data).filter(|_| unsized_axis) { Some(m) => { node.f32(m.w); node.f32(m.h); } None => node.feed(u64::MAX), } // The art (`content_span`): a new texture moves such a region, while a sized region's art // cannot move its rect. if !data.anchors.is_empty() && unsized_axis { node.feed(data.fill.is_some() as u64); match &data.texture { Some(path) => { node.feed(path.len() as u64); for b in path.as_bytes() { node.feed(u64::from(*b)); } } None => node.feed(u64::MAX), } } node.finish() } /// A texture's content-derived span in FrameXML units. `CSimpleTexture::GetWidth` (`0x770720`, /// height `0x770790`) returns the authored value unless it is exactly 0.0, else the texel extent /// through the `` converter, so one texel is one unit; a `` texture spans 8. /// `None` when there is nothing to measure: the axis stays 0, as in the reference with no texture. pub(super) fn content_span( data: &RegionData, probe: Option<&crate::script::TextureSizeProbe>, ) -> Option<(f32, f32)> { if data.fill.is_some() { return Some((SOLID_TEXTURE_TEXELS, SOLID_TEXTURE_TEXELS)); } texel_span(probe, data.texture.as_deref()?) } /// [`content_span`] for a path: SimpleHTML reserves an image's height the same way (`0x770790`). pub(super) fn texel_span( probe: Option<&crate::script::TextureSizeProbe>, path: &str, ) -> Option<(f32, f32)> { let (w, h) = probe?(path)?; Some((w as f32, h as f32)) } /// The edge of the 8×8 surface the colour `SetTexture` makes (`0x770360`, sized by `0x44a900`). const SOLID_TEXTURE_TEXELS: f32 = 8.0; /// The floor under a FontString's derived span: `CSimpleFontString::GetWidth` (`0x772930`, height /// `0x772a60`) returns at least one FrameXML unit, not one line, so empty text reads back 1, not 0. pub(super) const FONTSTRING_MIN_SPAN: f32 = 1.0; /// Marks a [`LayoutScope::node_of`] index as one into `regions` rather than `plan`. const REGION_TAG: u32 = 0x8000_0000; /// Per-node layout scope, so a resolve touches only what moved. Indexed densely by layout id, as /// frames and regions share one counter (`Model::next_id`); nothing allocates on a steady frame. /// /// The graph is derived in full once, then kept: a write names the ids it touched /// ([`Model::touch_layout_frame`]), and the next resolve re-derives just those nodes against the /// model ([`UiScript::rederive_named`]), joining, leaving or re-pointing them in place, the way a /// retained layout tree marks a node dirty rather than rebuilding itself. #[derive(Default)] pub(crate) struct LayoutScope { /// Per id: the hash the last converged resolve saw, or [`NO_NODE`]; a mismatch seeds dirty. last: Vec, /// The screen `last` was hashed under, every chain's root, so a move is full scope. 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: the head of its dependents' list in `dep_to`/`dep_next`, or [`u32::MAX`]. dep_head: Vec, dep_to: Vec, dep_next: Vec, /// Edge slots [`Self::unlink_edge`] freed, for [`Self::edge`] to reuse rather than grow `dep_to`. dep_free: Vec, /// Per id: the targets of the edges it recorded, in order, so a re-derive unlinks exactly those. out: Vec>, /// Per id: in the dirty closure. dirty: Vec, /// The closure's worklist. stack: Vec, /// The cached frame roster, `(handle, id, ScrollFrame anchor override)`, kept across resolves. plan: Vec<(FrameHandle, u32, Option)>, /// The cached roster of live, anchored regions. regions: Vec, /// Per ScrollFrame id, the live child it held when last derived: the one frame whose anchors /// its override replaces. scroll_child: HashMap, /// Per child, the ScrollFrames holding it, `(handle, id)`: [`Self::scroll_child`] inverted, so /// a re-derived frame finds its override without a scan. claims: HashMap>, /// An incremental pass's `(id, hash)` writes to [`Self::last`], applied only on convergence. staged: Vec<(u32, u64)>, /// A re-derive's target list, reused. targets: Vec, } /// One live, anchored region as the round loop consumes it, a row of the cached roster. #[derive(Clone, Copy, PartialEq)] struct RegionRow { rh: RegionHandle, /// The layout id: the solver's array index and the scope's node key. id: u32, /// The owning frame, which supplies the region's scale and so is a layout dependency. owner: FrameHandle, is_fontstring: bool, } impl LayoutScope { /// Size the per-id arrays for `n` ids and clear all but `last`, ahead of a full derivation. fn begin_full(&mut self, n: usize) { // `last` only grows: a dead node past the new count keeps its hash, so the scope still // seeds what anchored to it. if self.last.len() < n { 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); // Cleared in place, so a re-derivation reuses each list's allocation. self.out.iter_mut().for_each(Vec::clear); self.out.resize_with(n, Vec::new); 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.scroll_child.clear(); self.claims.clear(); self.staged.clear(); } /// The incremental entry: the cached graph survives, grown to `n` ids, and only the dirty /// marks reset. fn begin_incremental(&mut self, n: usize) { if self.node_of.len() < n { self.node_of.resize(n, u32::MAX); self.dep_head.resize(n, u32::MAX); self.out.resize_with(n, Vec::new); } if self.last.len() < self.node_of.len() { self.last.resize(self.node_of.len(), NO_NODE); } self.dirty.clear(); self.dirty.resize(self.node_of.len(), false); self.stack.clear(); self.staged.clear(); } /// 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 id with no slot can never turn 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; } /// [`Self::edge`], remembered as one of `from`'s own, for a later [`Self::relink`]. #[inline] fn link(&mut self, from: u32, to: u32) { self.edge(from, to); if let Some(out) = self.out.get_mut(from as usize) { out.push(to); } } /// Point node `id`'s edges at `targets`, unlinking the ones it recorded; `true` when they /// differed, which the caller seeds, as the node now reads other rects. fn relink(&mut self, id: u32, targets: &[u32]) -> bool { let Some(out) = self.out.get_mut(id as usize) else { return false; }; if out.as_slice() == targets { return false; } let old = std::mem::take(out); for &t in &old { self.unlink_edge(t, id); } let mut old = old; old.clear(); old.extend_from_slice(targets); self.out[id as usize] = old; for &t in targets { self.edge(id, t); } true } /// Unlink one `to → from` edge, if the list holds one. 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); } } } /// A re-derived node's verdict: stage and seed it when its hash or its edges moved. Every edge /// source (anchor targets, the owner, the override) is in the hash, so `relinked` only guards /// against a hash that stops covering one. #[inline] fn note(&mut self, id: u32, hash: u64, relinked: bool) { if relinked || hash != self.last.get(id as usize).copied().unwrap_or(NO_NODE) { self.staged.push((id, hash)); self.seed(id); } } /// Record that ScrollFrame `sid` (`sh`) now holds `child`; the child it held before, if it /// changed, so the caller re-derives both. fn hold( &mut self, sh: FrameHandle, sid: u32, child: Option, ) -> Option { let before = match child { Some(c) => self.scroll_child.insert(sid, c), None => self.scroll_child.remove(&sid), }; if before == child { return None; } if let Some(old) = before { if let Some(v) = self.claims.get_mut(&old) { v.retain(|&(_, s)| s != sid); if v.is_empty() { self.claims.remove(&old); } } } if let Some(c) = child { self.claims.entry(c).or_default().push((sh, sid)); } before } /// Whether `id` is a node whose converged hash is `hash`: a write that leaves it there moved /// nothing the resolve reads. pub(crate) fn holds(&self, id: u32, hash: u64) -> bool { self.node_of .get(id as usize) .is_some_and(|&n| n != u32::MAX) && self.last.get(id as usize) == Some(&hash) } /// Drop region node `id` from the roster with its edges, seeding it so what read its rect /// re-resolves without it. Returns the row it held. fn remove_region(&mut self, id: u32, n: u32) -> RegionRow { let idx = (n & !REGION_TAG) as usize; let row = self.regions.swap_remove(idx); if let Some(moved) = self.regions.get(idx) { #[allow(clippy::cast_possible_truncation)] { self.node_of[moved.id as usize] = idx as u32 | REGION_TAG; } } self.node_of[id as usize] = u32::MAX; self.relink(id, &[]); self.staged.push((id, NO_NODE)); self.seed(id); row } /// Close the dirty set under "depends on": whatever reads a dirty rect, transitively. 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); } } } /// Mark every node dirty, for what a per-node diff cannot see (no memory, a screen move). 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, screen: Rect) { std::mem::swap(&mut self.last, &mut self.now); self.last_screen = Some(screen); } /// [`Self::commit`] for an incremental pass: only the re-hashed slots. `last_screen` stays, /// since a screen move always takes the derivation. fn commit_incremental(&mut self) { for &(id, h) in &self.staged { self.last[id as usize] = h; } self.staged.clear(); } /// Forget the memory, so the next resolve derives the graph in full scope. pub(crate) fn invalidate(&mut self) { self.last_screen = None; self.last.clear(); self.plan.clear(); self.regions.clear(); self.node_of.clear(); self.staged.clear(); } /// `WOW_LAYOUT_VERIFY`'s graph check: where this cached graph differs from `fresh`, derived /// from scratch on the same model, in roster, per-node hash, edges or ScrollFrames. fn diverges_from(&self, fresh: &LayoutScope) -> Option<(u32, String)> { let at = |v: &[u32], id: usize| v.get(id).copied().unwrap_or(u32::MAX); let hash = |s: &LayoutScope, id: usize| s.last.get(id).copied().unwrap_or(NO_NODE); let sorted = |v: Option<&Vec>| { let mut v = v.cloned().unwrap_or_default(); v.sort_unstable(); v }; let incoming = |s: &LayoutScope, id: usize| { let mut v = Vec::new(); let mut e = at(&s.dep_head, id); while e != u32::MAX { v.push(s.dep_to[e as usize]); e = s.dep_next[e as usize]; } v.sort_unstable(); v }; let over_eq = |a: Option, b: Option| match (a, b) { (None, None) => true, (Some(a), Some(b)) => super::object::anchor_bits_eq(&a, &b), _ => false, }; let n = self .node_of .len() .max(fresh.node_of.len()) .max(self.last.len()) .max(fresh.last.len()); for id in 0..n { let (a, b) = (at(&self.node_of, id), at(&fresh.node_of, id)); let row = |s: &LayoutScope, n: u32| -> String { match n { u32::MAX => "absent".into(), n if n & REGION_TAG != 0 => { let r = s.regions[(n & !REGION_TAG) as usize]; format!("region {:?} owner {:?}", r.rh, r.owner) } n => format!( "frame {:?} override {:?}", s.plan[n as usize].0, s.plan[n as usize].2 ), } }; let same_node = match (a, b) { (u32::MAX, u32::MAX) => true, (u32::MAX, _) | (_, u32::MAX) => false, (a, b) if (a ^ b) & REGION_TAG != 0 => false, (a, b) if a & REGION_TAG != 0 => { self.regions[(a & !REGION_TAG) as usize] == fresh.regions[(b & !REGION_TAG) as usize] } (a, b) => { let (x, y) = (self.plan[a as usize], fresh.plan[b as usize]); x.0 == y.0 && x.1 == y.1 && over_eq(x.2, y.2) } }; if !same_node { return Some(( id as u32, format!( "id {id}: the cached graph holds {}, a fresh derivation {}", row(self, a), row(fresh, b) ), )); } if hash(self, id) != hash(fresh, id) { return Some(( id as u32, format!( "id {id} ({}): the cached hash is stale against a fresh derivation", row(self, a) ), )); } let (oa, ob) = (sorted(self.out.get(id)), sorted(fresh.out.get(id))); if a != u32::MAX && oa != ob { return Some(( id as u32, format!( "id {id} ({}): reads {oa:?} in the cached graph, {ob:?} fresh", row(self, a) ), )); } let (ia, ib) = (incoming(self, id), incoming(fresh, id)); if ia != ib { return Some(( id as u32, format!("id {id}: read by {ia:?} in the cached graph, {ib:?} fresh"), )); } } let held = |s: &LayoutScope| { let mut v: Vec<(u32, FrameHandle)> = s.scroll_child.iter().map(|(&id, &c)| (id, c)).collect(); v.sort_unstable_by_key(|e| e.0); v }; let (sa, sb) = (held(self), held(fresh)); if sa != sb { return Some((0, format!("ScrollFrames hold {sa:?} cached, {sb:?} fresh"))); } // The inverse must agree with its source, entry for entry. let claimed: usize = self.claims.values().map(Vec::len).sum(); let inverse_ok = claimed == sa.len() && sa.iter().all(|&(id, c)| { self.claims .get(&c) .is_some_and(|v| v.iter().any(|&(_, s)| s == id)) }); (!inverse_ok).then(|| { ( 0, format!("ScrollFrame claims {:?} disagree with {sa:?}", self.claims), ) }) } } /// The layout id of the first frame or region whose rect differs between the model and `frames` /// and `regions`, in id order. fn first_rect_diff( model: &Model, frames: &HashMap, regions: &HashMap, ) -> Option { let mut ids: Vec = Vec::new(); for (h, r) in &model.resolved { if frames.get(h) != Some(r) { ids.extend(model.frame_to_id.get(h)); } } for h in frames.keys().filter(|h| !model.resolved.contains_key(h)) { ids.extend(model.frame_to_id.get(h)); } for (h, r) in &model.region_resolved { if regions.get(h) != Some(r) { ids.extend(model.region_to_id.get(h)); } } for h in regions .keys() .filter(|h| !model.region_resolved.contains_key(h)) { ids.extend(model.region_to_id.get(h)); } let differs = frames != &model.resolved || regions != &model.region_resolved; ids.into_iter().min().or(differs.then_some(u32::MAX)) } /// A node's layout inputs as the model holds them, for `WOW_LAYOUT_VERIFY`'s graph report. fn node_inputs(model: &Model, id: u32) -> String { if let Some(h) = model.id_to_frame.get(&id) { let f = model.arena.frame(*h); return format!( "frame {:?} {:?}, effective scale {:?}, clamped {:?}", f.and_then(|f| f.name.clone()), model.layout_inputs.get(h), f.map(|f| f.effective_scale), f.map(|f| f.clamped_to_screen), ); } let Some(rh) = model.id_to_region.get(&id) else { return "no live frame or region".into(); }; let owner = model.arena.region(*rh).map(|r| r.owner); let owner_id = owner.and_then(|o| model.frame_to_id.get(&o)); match model.region_data.get(rh) { Some(d) => format!( "region owner {owner:?} (id {owner_id:?}), anchors {:?}, size {:?}, measured {:?} \ (text {} bytes), texture {:?}, fill {}", d.anchors, d.size, d.measured.map(|m| (m.w, m.h)), d.text.as_deref().map_or(0, str::len), d.texture, d.fill.is_some() ), None => format!("region owner {owner:?} with no data"), } } /// `WOW_LAYOUT_PROF=1`: the per-solve `[layout-prof]` and `[layout-pre]` lines on stderr. 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")) } /// `WOW_LAYOUT_VERIFY=1`, on in this crate's tests: every shortcut of the resolve (a gate skip, a /// tier-1-quiet frame, an incremental pass, the measure ledger) is checked against the full answer. 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 `[layout-pre]` line: µs per preamble phase, per resolve past tier 1. #[derive(Default)] struct PreambleProf { on: bool, last: Option, /// The GameTooltip auto-size pre-pass (`layout_tooltips`). tooltip: u128, /// `OnSizeChanged`'s "before" snapshot. watched: u128, /// The ids rebuild and the scale and clamp sync into `layout_inputs`. ids: u128, /// The ScrollFrame override map. scroll: u128, /// `region_resolved`'s liveness retain. retain: u128, /// `LayoutScope::begin_full`. begin: u128, /// The fingerprint and scope walk over every live frame. fp_frames: u128, /// The same over every anchored region. fp_regions: u128, /// An incremental pass's whole preamble: re-hash the named nodes, seed the moved ones, close. seed: u128, /// A flag of its own, since `seed` rounds to 0 µs on most incremental passes. 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()) } /// Print the line once the gate's verdict is known; `skips=1` returned without solving. 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.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={} begin={} \ fp_frames={} fp_regions={} seed={}", u8::from(skips), u8::from(self.incremental), self.tooltip, self.watched, self.ids, self.scroll, self.retain, self.begin, self.fp_frames, self.fp_regions, self.seed ); } } /// `OnSizeChanged`'s "after": queue `(id, width, height)` for each watched frame whose size moved /// by the `ApplyRect 0x76b580` test ([`crate::layout::size_changed`] at [`Model::size_eps`]), so a /// pure move never fires. A frame with no rect at entry compares against the zero rect /// `CSimpleFrame`'s ctor leaves; one that loses its rect had no `ApplyRect` and fires nothing. fn queue_size_changes( watched: &[(FrameHandle, Option)], resolved: &HashMap, frame_to_id: &HashMap, eps: f64, 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, eps) { 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 on a bare `Model`, so a Lua binding can force a fresh resolve, /// as a scroll frame's range recompute must reflect a same-tick `SetHeight`. pub(super) fn resolve_layout(model: &mut Model) { let solves = model.layout_solves; Self::resolve_layout_inner(model); // A solve may have resized a frame: its backdrop re-runs its texcoords (`0x76b580`). if model.layout_solves != solves { super::backdrop::after_resolve(model); } if !model.layout_verify_recheck { return; } // ── `WOW_LAYOUT_VERIFY`: the incremental pass's falsifier ───────────── model.layout_verify_recheck = false; if let Some(why) = Self::scratch_divergence(model) { panic!("WOW_LAYOUT_VERIFY: {why}"); } } /// Re-solve the model from scratch on a fresh graph and compare it with the converged cached /// one: the rects, then the graph itself (roster, per-node hashes, edges). This catches a write /// that moved a node without naming it, and a re-derive that joined, dropped or re-pointed one /// wrong. The cached graph is put back after, so a run of incremental passes is checked step /// by step, not reset at each; the meters keep the incremental pass's values. pub(super) fn scratch_divergence(model: &mut Model) -> Option { let cached = std::mem::take(&mut model.layout_scope); let before = (model.resolved.clone(), model.region_resolved.clone()); 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 Self::resolve_layout_inner(model); // a fresh scope: full scope let graph = cached.diverges_from(&model.layout_scope); model.layout_scope = cached; 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; // The first node whose rect differs, with its inputs; the graph's divergence, if any, // usually names the cause. let report = |id: u32| format!("id {id}, inputs now: {}", node_inputs(model, id)); let graph = graph.map(|(id, why)| format!("{why} ({})", report(id))); if let Some(id) = first_rect_diff(model, &before.0, &before.1) { let rect = |frames: &HashMap, regions: &HashMap| { match (model.id_to_frame.get(&id), model.id_to_region.get(&id)) { (Some(h), _) => frames.get(h).copied(), (_, Some(rh)) => regions.get(rh).copied(), _ => None, } }; return Some(format!( "an INCREMENTAL layout pass disagreed with a full derivation of the same frame — \ a write site named the wrong node or moved layout inputs without naming any. \ First rect to differ: {}, incremental {:?}, fresh {:?}. Graph: {}", report(id), rect(&before.0, &before.1), rect(&model.resolved, &model.region_resolved), graph.as_deref().unwrap_or("same as fresh"), )); } graph.map(|why| { format!( "the cached layout graph diverged from a fresh derivation after an INCREMENTAL \ pass — a write changed a node's roster, edges or inputs without naming it, or \ the re-derive mis-applied one. {why}" ) }) } fn resolve_layout_inner(model: &mut Model) { // Text that emptied or filled since the last resolve: named now where its node's hash // moved, ahead of the epoch's read, as if named at the write. if !model.text_flips.is_empty() { for rh in std::mem::take(&mut model.text_flips) { model.touch_layout_content(rh); } } // ── Tier 1: the mutation epoch ──────────────────────────────────────────────────────── // No layout touch since the last converged resolve: nothing the fingerprint reads moved, // so skip everything. Under verify, fall through so tier 2 checks the claim. 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; // Every call past tier 1 counts, verify's re-walks included: the walk is the cost. model.layout_gate_walks = model.layout_gate_walks.wrapping_add(1); let mut pre = PreambleProf::new(layout_prof_enabled()); // The GameTooltip pre-pass writes tooltip sizes and offsets from cached measures. super::tooltip::layout_tooltips(model); pre.tooltip = pre.lap(); // ── `OnSizeChanged`'s "before" ─────────────────────────────────────────────────────── // The reference fires it per rect application (`ApplyRect 0x76b580`); our batch fixpoint // fires on the entry-vs-convergence diff, never on a half-solved round. Only frames with // a handler are snapshotted, from `SetScript`'s list. let watched: Vec<(FrameHandle, Option)> = model .on_size_changed_frames .iter() .map(|&h| (h, model.resolved.get(&h).copied())) .collect(); let size_eps = model.size_eps(); pre.watched = pre.lap(); // ── The graph: the ledger's cache, or a fresh derivation ───────────────────────────── // A `Some` ledger claims every write since the graph was built named its node and kept // the graph's shape, so the cached roster, edges and hashes stand; `None` derives. // The named nodes re-derive in place; a change the cached graph cannot take derives. let incremental = match model.layout_touched.take() { Some(named) => Self::rederive_named(model, named, &mut pre), None => false, }; let fp = (!incremental).then(|| Self::derive_layout_graph(model, &mut pre)); let Model { arena, layout_inputs, resolved, region_data, region_resolved, frame_to_id, screen, warnings, diagnostics, solver, layout_scope: scope, layout_fingerprint, layout_epoch_resolved, layout_touched, layout_verify_recheck, layout_solves, layout_last_scope, layout_rounds, pending_size_changed, texture_size_probe, .. } = model; let mut full_scope = false; // An incremental pass has no fingerprint; its verdict is `staged`, exactly the seed set. let gate_skips = match fp { Some(_) => *layout_fingerprint == fp, None => scope.staged.is_empty(), }; pre.report(gate_skips, scope.plan.len(), scope.regions.len()); // Under verify, a frame tier 1 judged quiet must pass tier 2 too; a divergence is a write // path missing its `touch_layout()`. 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 read moved since the last converged resolve, whose rects stand: close tier 1. if gate_skips { *layout_epoch_resolved = Some(epoch_at_entry); // Re-arm the ledger: the graph is as it was, so the cache is still trustworthy. *layout_touched = Some(Vec::new()); if !verify { return; } } // Under verify a skippable resolve runs anyway, against the rects it must reproduce. let verify_against = gate_skips.then(|| (resolved.clone(), region_resolved.clone())); // Cleared until the fixpoint converges: a cycle-bailed pass leaves its rects mid-flight // for the next frame to carry further, so it must re-enter both tiers dirty. *layout_fingerprint = None; *layout_epoch_resolved = None; // Counted on the gate's decision, so verify's forced runs leave it alone. if !gate_skips { *layout_solves += 1; } // ── The scope: which nodes this solve may touch ──── // A node with unchanged inputs and no moved dependency recomputes to the rect it holds, so // the solve runs over the dirty closure and feeds other rects in as externals. Full scope // when there is no memory yet, the screen moved, or under verify. An incremental pass // seeded its closure in `rederive_named`. if !incremental { // A node that died past the new id count has no edges recorded (the derivation sized // them to the count), so only a full scope reaches what anchored to it. let died_past_end = scope .last .iter() .skip(scope.node_of.len()) .any(|&h| h != NO_NODE); full_scope = scope.last.is_empty() || scope.last_screen != Some(*screen) || died_past_end || 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; } } // A node that died has no hash left to differ, so its id is seeded instead: the // derivation recorded the edges of whatever still anchors to it, and the closure // re-resolves those against its missing rect, as a full scope would. for id in 0..scope.node_of.len() { if scope.last[id] != NO_NODE && scope.node_of[id] == u32::MAX && !scope.dirty[id] { scope.stack.push(id as u32); scope.dirty[id] = true; } } scope.close(); } } // A dirty frame is solved, a dirty region swept, and whatever they read is seeded at its // cached rect. A dirty region is seeded too, since the frame pass runs before the sweep. 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(); // Whether a node in the closure reads `id`; a rect nothing dirty reads is not 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); } } // This solve's width, beside `layout_solves` (how often) and `layout_rounds` (how deep). *layout_last_scope = (solve_frames.len(), sweep_regions.len()); let round_cap = scope.plan.len() + region_data.len() + 2; // `WOW_LAYOUT_PROF=1`: rounds, graph size, and the frame-solve and region-sweep split. 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 screen root and every read rect this pass does not recompute. 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 { 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, through the same `crate::layout` math as frames. An // anchor may target a frame or a sibling region; one whose target has no rect yet pins // nothing this round, and the fixpoint re-sweeps until every link settles. 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]; // A row whose data has gone is skipped until the next derivation drops it. let Some(data) = region_data.get(&rh) else { continue; }; if prof { n_regions_swept += 1; } // The owner supplies the region's scale and nothing else: no fallback edges. let scale = arena.frame(owner).map(|f| f.effective_scale).unwrap_or(1.0); // An axis authored 0, width or height, takes the host-measured extent: the // reference sizes a zero width to the unwrapped line. let measured = effective_measure(data); 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; } } // Then the floor: `CSimpleFontString::GetWidth 0x772930` (height `0x772a60`) // returns at least one unit on both legs, past the `jp 0x772957` that skips the // measure, so a FontString's span is never 0 and a single anchor resolves it. if is_fontstring { width = width.max(FONTSTRING_MIN_SPAN); height = height.max(FONTSTRING_MIN_SPAN); } // A texture's unset axis takes its texel extent (`content_span`): the reference's // size getter is virtual for both region classes. It must apply before the // resolve, as `combine_edge` reads a zero span as no size and leaves an edge unset. if !is_fontstring && (width == 0.0 || height == 0.0) { if let Some((cw, ch)) = content_span(data, texture_size_probe.as_ref()) { if width == 0.0 { width = cw; } if height == 0.0 { height = ch; } } } 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| { // The screen root is a frame-pass external only: a region anchor to id 0 finds // no rect here, though the solver holds one. (id != SCREEN).then(|| solver.rect(id)).flatten() }); // An axis resolves, or the region does not. The reference resolver core // `[0x7671a0, 0x76761f)` reads no parent or owner pointer, only the region's own // anchors and `combineEdge`/`combineCenter` over its own size; `assemble 0x767a20` // fails on any unset edge, and `0x768d20` latches the region unresolvable. An // anchor-less region is anchored at creation (`region::implicit_creation_anchor`), // and a texture with no art has a zero span and resolves to nothing (`0x767440`). let axis = |lo: Option, hi: Option| -> Option<(f32, f32)> { match (lo, hi) { (Some(l), Some(h)) => Some((l, h)), _ => None, } }; let vertical = axis(edges[0], edges[2]); let horizontal = axis(edges[1], edges[3]); let (Some((bottom, top)), Some((left, right))) = (vertical, horizontal) else { // Unresolvable: drop its rect, so it stops drawing instead of going stale. if region_resolved.remove(&rh).is_some() { changed = true; } continue; }; let rect = Rect::new(bottom, left, top, right); // Into the solver too: a later region in this sweep that anchors here needs it. 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` is the sweep's roster, `solved` and `swept` this pass's scope. 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; // Adopted only on convergence; an incremental pass adopts only what it re-hashed. if incremental { scope.commit_incremental(); } else { scope.commit(*screen); } // ── Re-arm the ledger ───────────────────────────────────────────────────────── // The graph now describes a converged model, so writes may name nodes against it. // Only here: the cycle bail below leaves it `None`. *layout_touched = Some(Vec::new()); // Under verify, `resolve_layout` re-derives this frame from scratch and compares. *layout_verify_recheck = incremental && verify; // A converged solve closes tier 1, at `epoch_at_entry` rather than the live epoch, // so a touch during this pass (the tooltip pre-pass, a minted id) re-opens it. The // fingerprint hashes inputs only, so the next unmutated resolve would match it. *layout_epoch_resolved = Some(epoch_at_entry); queue_size_changes( &watched, resolved, frame_to_id, size_eps, pending_size_changed, ); return; } if round + 1 == round_cap { // `Model::record_warning` inlined, as the model is destructured. Verify's re-run // truncates `warnings` but not the log, so a verify build may count a row twice. let msg = format!( "layout: anchor graph did not converge in {round_cap} rounds — \ an anchor cycle? (rects left at their last pass)" ); diagnostics.record(super::diagnostics::DiagnosticKind::Warning, &msg); warnings.push(msg); } } // The cycle bail: its rects are this frame's, so their size changes still fire, and the // scope forgets a graph that never settled. scope.invalidate(); queue_size_changes( &watched, resolved, frame_to_id, size_eps, pending_size_changed, ); } /// Derive the layout graph from scratch: the roster, the reverse edges, the per-node hashes and /// the tier-2 fingerprint. Runs only when the ledger cannot vouch for the cache. 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, its layout input's scale and clamp synced from the arena. 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 bit 4), so every placement path // clamps alike, against the live window size. input.clamp = clamp; if clamp { input.extent_x = screen.right; input.extent_y = screen.top; } } pre.ids = pre.lap(); // A live ScrollFrame's live child is anchored, for this solve only, TOPLEFT to its TOPLEFT // at the raw, unnegated `(horizontal, vertical)` offsets, as the reference's re-anchor // `0x787100` passes them to `SetPoint`: y up, so a positive vertical lifts the child. Its // authored anchors and size are untouched, so `SetScrollChild(nil)` needs no restore. let mut scroll_child_anchor: HashMap = HashMap::new(); let mut scroll_holds: Vec<(FrameHandle, u32, FrameHandle)> = Vec::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_holds.push((h, id, child)); scroll_child_anchor.insert( child, Anchor::new( Point::TopLeft, id, Point::TopLeft, state.horizontal, state.vertical, ), ); } pre.scroll = pre.lap(); // The reference resolves one graph in which frames and regions anchor each other; ours // alternates a frame solve and a region sweep until a round changes no rect, compared // exactly. The round cap is the node count, so only a cycle exhausts it, warned like the // reference's resolving-flag bail. Every rect is recomputed from inputs and externals, so // last frame's rects are a legal seed: only dead or unanchored regions drop. 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 what the rounds read, to skip them when nothing moved since the last // converged resolve: the screen; each live frame's id, synced `LayoutInput` and // ScrollFrame override; each anchored region's node hash (anchors, explicit size, measured // extent, the art of an unsized axis, owner) and liveness. Seeded rects are outputs, a pure // function of these, so they are not hashed. // The same walk hashes each node alone into `LayoutScope::now` and records its edges. // Sized past `next_id` for the ids the region walk below may mint. scope.begin_full(*next_id as usize + region_data.len() + 1); for (h, id, child) in scroll_holds { scope.hold(h, id, Some(child)); } pre.begin = pre.lap(); let mut fp = InputFingerprint::default(); fp.rect(*screen); 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 its anchor targets' rects, or only its ScrollFrame override's target, // as the round loop's `set_frame_anchored` does. match over { Some(a) => scope.link(id, a.relative_to), None => { for a in &input.anchors { scope.link(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 region roster, built in the same walk: live, anchored regions only. scope.regions.reserve(region_data.len()); for (&rh, data) in region_data.iter() { // Anchor-less entries are not layout inputs: hashing them would make a paint-only // setter that creates one (`SetTexture`'s `or_default()`) read as a layout change. if data.anchors.is_empty() { continue; } fed_regions += 1; let live = arena.region(rh); let owner_id = live.and_then(|r| frame_to_id.get(&r.owner).copied()); // The node hash covers every input the sweep reads (anchors, size, measure, the art // of an unsized axis, the owner), so the two tiers read one set. let node = region_node_hash(data, owner_id); fp.feed(rh.fingerprint_bits()); fp.feed(node); if let Some(r) = live { // Mint an id for a region that never needed one, without `Model::region_id`'s // epoch bump, which would re-dirty the running resolve. 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 = node; scope.node_of[id as usize] = idx; } // A region reads its anchor targets' rects and its owner's scale. for a in &data.anchors { scope.link(id, a.relative_to); } if let Some(owner_id) = owner_id { scope.link(id, owner_id); } scope.regions.push(RegionRow { rh, id, owner: r.owner, is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString), }); } // Liveness only (`owner` and `kind` never change): `WidgetArena::destroy` leaves // `region_data` behind, so membership alone would miss a destroyed region. fp.feed(u64::from(live.is_some())); } // The count of anchored entries fed, not the map's len. fp.feed(fed_regions); pre.fp_regions = pre.lap(); fp } /// Re-derive each named node against the model, in the cached graph: a node joins the roster /// with its edges (a frame or region born, a region anchored), leaves it with its edges /// unlinked (a region freed or cleared), or keeps its slot with its edges re-pointed and its /// inputs re-synced, exactly as [`Self::derive_layout_graph`] would build it. Each node whose /// hash or edges moved is seeded, and the closure taken. `false` gives up for the derivation: /// a frame leaving the roster, or a frame two ScrollFrames claim, whose override the /// derivation picks by map order. fn rederive_named(model: &mut Model, mut named: Vec, pre: &mut PreambleProf) -> bool { let Model { arena, layout_inputs, region_data, region_resolved, frame_to_id, id_to_frame, id_to_region, next_id, screen, layout_scope: scope, .. } = model; scope.begin_incremental(*next_id as usize); // ScrollFrames first, so a child re-derived after them reads the claims they recorded. let is_scroller = |id: u32| { id_to_frame .get(&id) .and_then(|&h| arena.frame(h)) .is_some_and(|f| f.kind == FrameKind::ScrollFrame) }; named.sort_unstable_by_key(|&id| (!is_scroller(id), id)); named.dedup(); let mut targets = std::mem::take(&mut scope.targets); let mut at = 0; while at < named.len() { let id = named[at]; at += 1; let Some(&n) = scope.node_of.get(id as usize) else { continue; // past every minted id: never a node }; let is_region_node = n != u32::MAX && n & REGION_TAG != 0; // A frame: every live frame with an id is a node. if let Some(&h) = id_to_frame.get(&id) { let Some(frame) = arena.frame(h).filter(|_| frame_to_id.get(&h) == Some(&id)) else { if n != u32::MAX { scope.targets = targets; return false; } continue; }; if is_region_node { scope.targets = targets; return false; } // A ScrollFrame records the live child it holds, and that child re-derives after // it, as the override is the child's; so does a child it let go. if frame.kind == FrameKind::ScrollFrame { let child = match &frame.kind_state { KindState::Scroll(state) => { state.child.filter(|&c| arena.frame(c).is_some()) } _ => None, }; let let_go = scope.hold(h, id, child); for c in child.into_iter().chain(let_go) { named.extend(frame_to_id.get(&c)); } } // The override of the one ScrollFrame holding this frame; a child two hold gives // up, as the derivation picks between them by map order. let over = match scope.claims.get(&h).map(Vec::as_slice) { None | Some([]) => None, Some(&[(sh, sid)]) => match arena.frame(sh).map(|f| &f.kind_state) { Some(KindState::Scroll(state)) => Some(Anchor::new( Point::TopLeft, sid, Point::TopLeft, state.horizontal, state.vertical, )), _ => None, }, Some(_) => { scope.targets = targets; return false; } }; // The derivation's sync of the arena's scale and clamp into the layout input. let (scale, clamp) = (frame.effective_scale, frame.clamped_to_screen); let input = layout_inputs.entry(h).or_default(); input.scale = scale; input.clamp = clamp; if clamp { input.extent_x = screen.right; input.extent_y = screen.top; } targets.clear(); match over { Some(a) => targets.push(a.relative_to), None => targets.extend(input.anchors.iter().map(|a| a.relative_to)), } let hash = frame_node_hash(input, over); let relinked = if n == u32::MAX { #[allow(clippy::cast_possible_truncation)] { scope.node_of[id as usize] = scope.plan.len() as u32; } scope.plan.push((h, id, over)); scope.relink(id, &targets); true } else { scope.plan[n as usize].2 = over; scope.relink(id, &targets) }; scope.note(id, hash, relinked); continue; } // A region: a node while it lives and has an anchor. let live = id_to_region.get(&id).and_then(|&rh| { let r = arena.region(rh)?; let data = region_data.get(&rh).filter(|d| !d.anchors.is_empty())?; Some((rh, r, data)) }); let Some((rh, r, data)) = live else { if is_region_node { let row = scope.remove_region(id, n); region_resolved.remove(&row.rh); } else if n != u32::MAX { scope.targets = targets; return false; } continue; }; if n != u32::MAX && !is_region_node { scope.targets = targets; return false; } let owner_id = frame_to_id.get(&r.owner).copied(); targets.clear(); targets.extend(data.anchors.iter().map(|a| a.relative_to)); targets.extend(owner_id); let hash = region_node_hash(data, owner_id); let row = RegionRow { rh, id, owner: r.owner, is_fontstring: matches!(r.kind, crate::widget::RegionKind::FontString), }; let relinked = if n == u32::MAX { #[allow(clippy::cast_possible_truncation)] { scope.node_of[id as usize] = scope.regions.len() as u32 | REGION_TAG; } scope.regions.push(row); scope.relink(id, &targets); true } else { scope.regions[(n & !REGION_TAG) as usize] = row; scope.relink(id, &targets) }; scope.note(id, hash, relinked); } scope.targets = targets; scope.close(); pre.seed = pre.lap(); pre.incremental = true; true } /// FontStrings with text whose stored measure is stale, for the host to measure: call after /// [`UiScript::resolve`], answer with [`UiScript::set_measured_text`], and resolve again. pub fn fontstrings_needing_measure(&mut self) -> Vec { let mut model = self.model_mut(); // Every FontString with text, sized or not: `GetStringWidth` answers the natural width. // The ledger (`Model::touch_measure`) re-arms empty, so a second ask this frame sees only // the writes since the first; `None` walks every region. 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()); // Under verify, the full walk runs beside the drain and panics on any stale region // the ledger missed. 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 every stale region: a request is not an answer, and nothing else asks again. 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 } } /// The [`MeasureRequest`] region `rh` needs now, if any; shared with the synchronous measure /// ([`super::measure::ensure_measured`]), as both must build the same cache key. 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, }) } /// `Some((key, scale))` iff `rh` is a FontString with text whose stored measure no longer matches /// its key: the one staleness test, allocation-free as the sweep asks it of every FontString. 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 host measures at the drawn size, so the key carries the owner's scale. 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 metric reads in `region.rs` check the stored measure against this same key. let key = d.measure_key(scale); if d.measured.map(|m| m.key) == Some(key) { return None; } Some((key, scale)) }