Layout writes invalidate as 1.12.1 does; only a reached node re-resolves (#964)

The solver took any node's recomputed rect whenever it resolved, because about 55 layout writes named a node without invalidating it. So a freed region stayed in its dependents' anchors, `SetTexture` re-resolved a failed texture, and a scroll child kept its authored anchors behind a per-solve override.

In 1.12.1 a node re-resolves only when invalidated or a target stores a new rect (`0x768d20`, slot 0 `0x768cf0`). `SetTexture` only dirties the draw (`0x770200`). A destructor frees its dependents' anchors to it (`0x768e20` → `0x767e70`, no invalidate). A scroll child is re-anchored to one TOPLEFT (`0x787100`).

Every write now invalidates exactly where the reference's does, and the solver's guess, the hold and the art latch are gone. The tooltip invalidates only on its relayout (`0x530640`).

Tests bite on the parent; the verifier is green; the stock UI's quads are byte-identical.

Fixes #947
Fixes #949
Fixes #958

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 07:46:24 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a9dd325bb7
commit 51a2ce5586
31 changed files with 998 additions and 497 deletions
@@ -677,6 +677,11 @@ fn structural_change_costs() {
r#"
BenchHost = CreateFrame("Frame", "BenchHost", UIParent)
BenchHost:SetPoint("CENTER", 0, 0); BenchHost:SetWidth(200); BenchHost:SetHeight(100)
BenchText = BenchHost:CreateFontString(nil, "ARTWORK", "GameFontNormal")
BenchText:SetPoint("TOPLEFT", BenchHost, "TOPLEFT", 4, -4); BenchText:SetText("bench")
BenchArt = BenchHost:CreateTexture(nil, "ARTWORK")
BenchArt:SetPoint("BOTTOMLEFT", BenchHost, "BOTTOMLEFT", 4, 4)
BenchArt:SetTexture("Interface\\Buttons\\UI-CheckBox-Check")
bench_n = 0
bench_spec = { bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border", tile = true, tileSize = 16,
@@ -687,8 +692,28 @@ fn structural_change_costs() {
for _ in 0..4 {
app_frame(&mut s);
}
let cases: [(&str, &str); 5] = [
let cases: [(&str, &str); 9] = [
("no-op", "bench_n = bench_n + 1"),
// Setters an OnUpdate calls every frame with the value already held: each is a write the
// reference invalidates for, and must not re-resolve the world.
(
"SetWidth same",
"bench_n = bench_n + 1; BenchHost:SetWidth(200)",
),
(
"SetPoint same",
"bench_n = bench_n + 1; BenchHost:SetPoint('CENTER', 0, 0)",
),
(
"SetText same",
"bench_n = bench_n + 1; BenchText:SetText('bench')",
),
(
"SetTexture swap",
"bench_n = bench_n + 1; BenchArt:SetTexture(math.mod(bench_n, 2) == 0 and \
'Interface\\\\Buttons\\\\UI-CheckBox-Check' or \
'Interface\\\\Buttons\\\\UI-CheckBox-Up')",
),
(
"SetWidth",
"bench_n = bench_n + 1; BenchHost:SetWidth(200 + math.mod(bench_n, 2))",
@@ -712,17 +737,23 @@ fn structural_change_costs() {
];
const N: u32 = 200;
for (name, lua) in cases {
let d0 = s.layout_derivations();
let (d0, s0, r0) = (s.layout_derivations(), s.layout_solves(), s.layout_rounds());
let mut widest = (0, 0);
let t = Instant::now();
for _ in 0..N {
s.run(lua).unwrap();
app_frame(&mut s);
std::hint::black_box(s.extract());
let (f, r) = s.layout_last_scope();
widest = (widest.0.max(f), widest.1.max(r));
}
println!(
"STRUCT {name:<14} {:.3} ms/iter derivations/iter={:.2}",
"STRUCT {name:<15} {:.3} ms/iter derivations/iter={:.2} solves/iter={:.2} \
rounds/iter={:.2} widest scope={widest:?}",
ms(t) / f64::from(N),
(s.layout_derivations() - d0) as f64 / f64::from(N),
(s.layout_solves() - s0) as f64 / f64::from(N),
(s.layout_rounds() - r0) as f64 / f64::from(N),
);
}
}
+34 -85
View File
@@ -232,6 +232,20 @@ impl Anchors {
held
}
/// Empty every slot naming a target in `dying`, returning whether any did: the unhook a dying
/// layout object runs on each dependent (`0x767e70`, from `0x768e20`).
#[inline]
pub fn release_targets(&mut self, dying: &[u32]) -> bool {
let mut freed = false;
for slot in &mut self.0 {
if slot.is_some_and(|a| dying.contains(&a.relative_to)) {
*slot = None;
freed = true;
}
}
freed
}
#[inline]
pub fn is_empty(&self) -> bool {
self.0.iter().all(Option::is_none)
@@ -905,27 +919,15 @@ impl LayoutSolver {
self.live.push(h);
}
/// [`set_frame`](Self::set_frame) with `anchor` as the only anchor: a ScrollFrame child's.
pub fn set_frame_anchored(&mut self, h: Handle, src: &LayoutInput, anchor: Anchor) {
self.ensure(h);
self.touch(h);
let dst = &mut self.input[h as usize];
dst.anchors = Anchors::from([anchor]);
Self::copy_fields(dst, src);
self.is_frame[h as usize] = true;
self.live.push(h);
}
/// The state node `h` was in when this resolve began, its rect while its valid bit is set,
/// and whether it was invalidated since (`0x7680e0`: its own write, a cleared valid bit).
///
/// The reference resolves only what is invalidated: by its own write, or by a target's
/// resolve that stored a new rect (`0x768cf0`). A failed resolve stores nothing and skips that
/// slot (`0x768d55`), and `ClearAllPoints` invalidates nothing (`0x767620`). So a node not
/// reached keeps this state when its resolve fails or reads a target with no rect. Any other
/// node not reached is recomputed, which gives back its state only if every write that moved
/// its inputs since also invalidated it; the solver cannot tell one that did not. The rect is
/// also what the keep test ([`set_keep_eps`](Self::set_keep_eps)) holds a new one against.
/// resolve that stored a new rect (`0x768cf0`). A node the resolve does not reach keeps this
/// state, whatever its inputs now give, as a write that invalidates nothing (`ClearAllPoints`
/// `0x767620`, a dying target's unhook `0x767e70`, `SetTexture` `0x770200`) leaves it. The
/// rect is also what the keep test ([`set_keep_eps`](Self::set_keep_eps)) holds a new one
/// against.
pub fn set_entry(&mut self, h: Handle, rect: Option<Rect>, invalidated: bool) {
self.ensure(h);
self.touch(h);
@@ -951,30 +953,18 @@ impl LayoutSolver {
}
}
/// Whether node `h` takes the rect its inputs give it, by the rule [`set_entry`]
/// documents: when it was invalidated, when an anchor target stored a new rect, or when the
/// rect resolved against targets that all hold one. `reads` lists its anchor targets.
/// Whether the resolve reaches node `h`, by the rule [`set_entry`] documents: it has no entry
/// state, it was invalidated, or an anchor target stored a new rect. Only then does it take
/// the rect its inputs give it, or fail. `reads` lists its anchor targets. Not modelled yet:
/// invalidating a suspended (hidden) object reaches every dependent at once (`0x768178`–
/// `0x76819c`).
///
/// [`set_entry`]: Self::set_entry
pub fn takes_resolve(
&self,
h: Handle,
resolved: bool,
reads: impl Iterator<Item = Handle>,
) -> bool {
pub fn reached(&self, h: Handle, mut reads: impl Iterator<Item = Handle>) -> bool {
let i = h as usize;
if !self.has_entry.get(i).copied().unwrap_or(false) || self.pending[i] {
return true;
}
let mut reads_failed = false;
for t in reads {
if self.rect(t).is_none() {
reads_failed = true;
} else if self.changed(t) {
return true;
}
}
resolved && !reads_failed
!self.has_entry.get(i).copied().unwrap_or(false)
|| self.pending[i]
|| reads.any(|t| self.changed(t))
}
/// The entry rect of node `h`, which it keeps when the resolve does not reach it.
@@ -1060,14 +1050,8 @@ impl LayoutSolver {
fn resolve_one(&mut self, h: Handle) {
let i = h as usize;
let outcome = {
let input = &self.input[i];
let rects = &self.rect;
resolve_rect(input, |t| rects.get(t as usize).copied().flatten())
};
let anchors = self.input[i].anchors;
let resolved = matches!(outcome, ResolveOutcome::Resolved(_));
if !self.takes_resolve(h, resolved, anchors.iter().map(|a| a.relative_to)) {
if !self.reached(h, anchors.iter().map(|a| a.relative_to)) {
// Not reached: the entry state stands, rect or none.
self.rect[i] = self.cached[i];
if self.rect[i].is_none() {
@@ -1075,6 +1059,11 @@ impl LayoutSolver {
}
return;
}
let outcome = {
let input = &self.input[i];
let rects = &self.rect;
resolve_rect(input, |t| rects.get(t as usize).copied().flatten())
};
match outcome {
ResolveOutcome::Resolved(r) => {
let kept = self.cached[i].filter(|&c| keeps_rect(c, r, self.keep_eps));
@@ -1770,44 +1759,4 @@ mod tests {
);
assert!(s.unresolvable().is_empty());
}
/// The override keeps the size and scale but replaces the anchors, and the reused input slot
/// must not leak the last round's.
#[test]
fn set_frame_anchored_replaces_only_the_anchors() {
const A: Handle = 1;
let mut s = LayoutSolver::new();
let input = LayoutInput::sized(
vec![
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 0.0, 0.0),
Anchor::new(Point::BottomRight, SCREEN, Point::BottomRight, 0.0, 0.0),
],
100.0,
40.0,
);
// Plain: both anchors pin all four edges, so the size is ignored.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame(A, &input);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(0.0, 0.0, 600.0, 800.0))
);
// Overridden: one TOPLEFT pin at (10, -5), so the 100x40 size decides the other edges.
s.begin();
s.set_external(SCREEN, screen_rect());
s.set_frame_anchored(
A,
&input,
Anchor::new(Point::TopLeft, SCREEN, Point::TopLeft, 10.0, -5.0),
);
s.solve();
assert_eq!(
bits(s.rect(A).expect("resolved")),
bits(Rect::new(555.0, 10.0, 595.0, 110.0))
);
}
}
+12 -6
View File
@@ -307,10 +307,14 @@ impl Installed {
pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option<Backdrop>) {
// Each freed piece names its node, and each built one below, so the next resolve takes the
// swap through the cached graph.
let old = model.backdrops.remove(&fh);
for rh in old.iter().flat_map(|o| o.pieces).flatten() {
super::region::free_region(model, rh);
}
let old: Vec<RegionHandle> = model
.backdrops
.remove(&fh)
.iter()
.flat_map(|o| o.pieces)
.flatten()
.collect();
super::region::free_regions(model, &old);
let Some(spec) = spec.filter(|_| model.arena.frame(fh).is_some()) else {
return;
};
@@ -365,8 +369,9 @@ pub(crate) fn install(model: &mut Model, fh: FrameHandle, spec: Option<Backdrop>
);
}
}
// Each piece is invalidated after its points are set (`0x77e9ca`..`0x77ec77`, `0x77ed05`..).
for rh in pieces.iter().flatten() {
model.touch_layout_region(*rh);
model.invalidate_region(*rh, true);
}
model.backdrops.insert(
fh,
@@ -425,8 +430,9 @@ pub(crate) fn set_metrics(
};
(d.anchors, d.size) = piece_geometry(owner, layout);
}
// Set as `0x77e8d0` sets them, so invalidated as there.
for rh in pieces.iter().flatten() {
model.touch_layout_region(*rh);
model.invalidate_region(*rh, true);
}
if let Some(rect) = model.resolved.get(&fh).copied() {
run_texcoords(model, fh, rect);
+1 -1
View File
@@ -481,7 +481,7 @@ fn set_slot_texture(
if let Some(blend) = blend {
data.blend = blend;
}
model.touch_layout_art(rh);
model.note_art(rh);
Ok(())
}
+9 -6
View File
@@ -341,10 +341,11 @@ fn lay_out(model: &mut Model, i: usize, g: BubbleGeometry) {
-g.margin,
),
]);
// Once per geometry change: each node it wrote re-derives.
model.touch_layout_frame(frame);
model.touch_layout_region(tail);
model.touch_layout_region(text);
// Once per geometry change: each node it wrote is invalidated, as the bubble build's
// `SetPoint`s with the invalidate flag do (`0x4b0b6b`, `0x4b0b94`).
model.invalidate_frame(frame, true);
model.invalidate_region(tail, true);
model.invalidate_region(text, true);
model.chat_bubbles.bubbles[i].laid_out = Some(g);
}
@@ -393,7 +394,8 @@ fn fit_width(model: &mut Model, i: usize, g: BubbleGeometry) {
d.size = Some((width, 0.0));
}
model.touch_measure(text);
model.touch_layout_region(text);
// `SetWidth`, which invalidates (`0x7683d0`).
model.invalidate_region(text, true);
}
/// The per-frame write, each property only when it moved.
@@ -457,7 +459,8 @@ fn drive(
seat.0,
seat.1,
)]);
model.touch_layout_region(text);
// `SetPoint` with the invalidate flag (`0x4b157a`).
model.invalidate_region(text, true);
model.chat_bubbles.bubbles[i].seat = Some(seat);
}
}
+1 -1
View File
@@ -474,7 +474,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = path;
data.circular = true;
data.portrait_unit = None;
model.touch_layout_art(rh);
model.note_art(rh);
Ok(())
})?,
)?;
@@ -53,9 +53,9 @@ pub(super) fn seat_text_region(model: &mut Model, h: FrameHandle) {
data.anchors = anchors;
}
data.size = size;
if !same_anchors || size_moved {
model.touch_layout_region(rh);
}
// The seat's points and size, each set with an invalidate (`0x77b905`, `0x77b91e`/`0x77b940`,
// `0x77b96b`).
model.invalidate_region(rh, !same_anchors || size_moved);
if size_moved {
// The width is the wrap width, which keys the measure.
model.touch_measure(rh);
@@ -137,10 +137,10 @@ pub(super) fn relayout(lua: &Lua, h: FrameHandle) -> bool {
// `fld top; fadd bottom; fadd st, st(1)`.
let height = (top + bottom) + text_h;
let input = model.layout_inputs.entry(h).or_default();
if input.height.to_bits() != height.to_bits() {
input.height = height;
model.touch_layout_frame(h);
}
let moved = input.height.to_bits() != height.to_bits();
input.height = height;
// `SetHeight`, which invalidates (`0x7683f0`).
model.invalidate_frame(h, moved);
true
}
+3 -1
View File
@@ -189,8 +189,10 @@ fn place(model: &mut Model, h: FrameHandle, rect: Rect, link: &str, text: &str)
input.anchors = pin;
input.width = w;
input.height = ht;
model.touch_layout_frame(h);
model.note_authored_size(h);
}
// The points are cleared (`0x7a3415`), one set with the invalidate flag (`0x7a3441`), and the
// size set through the setters, which invalidate (`0x7a3457`, `0x7a346b`).
model.invalidate_frame(h, changed);
changed
}
+75 -240
View File
@@ -1,7 +1,7 @@
use std::collections::HashMap;
use crate::layout::{self, Anchor, LayoutInput, Point, Rect};
use crate::widget::{FrameHandle, FrameKind, KindState, RegionHandle};
use crate::layout::{self, Anchor, LayoutInput, Rect};
use crate::widget::{FrameHandle, RegionHandle};
use super::types::RegionData;
use super::{MeasureRequest, Model, UiScript, SCREEN};
@@ -90,13 +90,9 @@ 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<Anchor>) -> u64 {
fn frame_node_hash(input: &LayoutInput) -> 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()
}
@@ -111,7 +107,8 @@ pub(super) fn effective_measure(data: &RegionData) -> Option<super::MeasuredText
/// 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.
/// span from it, its effective measure. Not a Texture's art, which only a resolve that reaches the
/// node reads, so art set since moves no node, as `SetTexture` invalidates none (`0x770200`).
#[inline]
pub(super) fn region_node_hash(data: &RegionData, owner: Option<u32>) -> u64 {
let mut node = InputFingerprint::default();
@@ -132,20 +129,6 @@ pub(super) fn region_node_hash(data: &RegionData, owner: Option<u32>) -> u64 {
}
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()
}
@@ -211,16 +194,10 @@ pub(crate) struct LayoutScope {
dirty: Vec<bool>,
/// The closure's worklist.
stack: Vec<u32>,
/// The cached frame roster, `(handle, id, ScrollFrame anchor override)`, kept across resolves.
plan: Vec<(FrameHandle, u32, Option<Anchor>)>,
/// The cached frame roster, `(handle, id)`, kept across resolves.
plan: Vec<(FrameHandle, u32)>,
/// The cached roster of live, anchored regions.
regions: Vec<RegionRow>,
/// Per ScrollFrame id, the live child it held when last derived: the one frame whose anchors
/// its override replaces.
scroll_child: HashMap<u32, FrameHandle>,
/// 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<FrameHandle, Vec<(FrameHandle, u32)>>,
/// 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.
@@ -263,8 +240,6 @@ impl LayoutScope {
self.stack.clear();
self.plan.clear();
self.regions.clear();
self.scroll_child.clear();
self.claims.clear();
self.staged.clear();
}
@@ -384,33 +359,18 @@ impl LayoutScope {
}
}
/// 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<FrameHandle>,
) -> Option<FrameHandle> {
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);
}
/// The nodes this graph records reading `id`'s rect. It describes the model while the ledger
/// is armed (`Model::layout_touched` is `Some`), but for the nodes named since.
pub(crate) fn readers_of(&self, id: u32) -> impl Iterator<Item = u32> + '_ {
let mut e = self.dep_head.get(id as usize).copied().unwrap_or(u32::MAX);
std::iter::from_fn(move || {
if e == u32::MAX {
return None;
}
}
if let Some(c) = child {
self.claims.entry(c).or_default().push((sh, sid));
}
before
let from = self.dep_to[e as usize];
e = self.dep_next[e as usize];
Some(from)
})
}
/// Whether `id` is a node whose converged hash is `hash`: a write that leaves it there moved
@@ -507,11 +467,6 @@ impl LayoutScope {
v.sort_unstable();
v
};
let over_eq = |a: Option<Anchor>, b: Option<Anchor>| match (a, b) {
(None, None) => true,
(Some(a), Some(b)) => a.bits_eq(&b),
_ => false,
};
let n = self
.node_of
.len()
@@ -527,10 +482,7 @@ impl LayoutScope {
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
),
n => format!("frame {:?}", s.plan[n as usize].0),
}
};
let same_node = match (a, b) {
@@ -541,10 +493,7 @@ impl LayoutScope {
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)
}
(a, b) => self.plan[a as usize] == fresh.plan[b as usize],
};
if !same_node {
return Some((
@@ -583,30 +532,7 @@ impl LayoutScope {
));
}
}
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),
)
})
None
}
}
@@ -646,11 +572,12 @@ 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 {:?}",
"frame {:?} {:?}, effective scale {:?}, clamped {:?}, unsettled {}",
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),
model.layout_unsettled.contains(&id),
);
}
let Some(rh) = model.id_to_region.get(&id) else {
@@ -661,13 +588,14 @@ fn node_inputs(model: &Model, id: u32) -> String {
match model.region_data.get(rh) {
Some(d) => format!(
"region owner {owner:?} (id {owner_id:?}), anchors {:?}, size {:?}, measured {:?} \
(text {} bytes), texture {:?}, fill {}",
(text {} bytes), texture {:?}, fill {}, unsettled {}",
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()
d.fill.is_some(),
model.layout_unsettled.contains(&id),
),
None => format!("region owner {owner:?} with no data"),
}
@@ -681,7 +609,7 @@ fn layout_prof_enabled() -> bool {
/// `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 {
pub(super) fn layout_verify_enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| cfg!(test) || std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1"))
}
@@ -697,8 +625,6 @@ struct PreambleProf {
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`.
@@ -741,7 +667,6 @@ impl PreambleProf {
let total = self.tooltip
+ self.watched
+ self.ids
+ self.scroll
+ self.retain
+ self.begin
+ self.fp_frames
@@ -749,14 +674,13 @@ impl PreambleProf {
+ self.seed;
eprintln!(
"[layout-pre] skips={} incr={} frames={frames} anchored={regions} total_us={total} \
tooltip={} watched={} ids={} scroll={} retain={} begin={} \
tooltip={} watched={} ids={} 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,
@@ -958,12 +882,17 @@ impl UiScript {
texture_size_probe,
layout_force,
layout_screen,
layout_unsettled,
..
} = model;
let mut full_scope = false;
// The nodes this resolve stores without the keep test; a write that cleared the resolved
// bit of no node (an unanchored region's measure) leaves nothing to store.
let mut forced = std::mem::take(layout_force);
// Each is reached now, so it holds what its inputs give again.
for id in forced.iter().chain(&pending) {
layout_unsettled.remove(id);
}
// An invalidated region with no anchors is no node, and its resolve can only fail
// (`0x768d55`): its valid bit clears, a Texture keeps its last quad. So does one whose
// owner's scale moved, which the cascade invalidates (`0x76ad06`).
@@ -1169,7 +1098,7 @@ impl UiScript {
let entry_frames: Vec<Option<Rect>> = solve_frames
.iter()
.map(|&i| {
let (h, id, _) = scope.plan[i as usize];
let (h, id) = scope.plan[i as usize];
(!is_forced(id))
.then(|| resolved.get(&h).copied())
.flatten()
@@ -1216,25 +1145,22 @@ impl UiScript {
solver.set_entry(scope.regions[i as usize].id, cached, invalid);
}
for &i in &seed_frames {
let (h, id, _) = scope.plan[i as usize];
let (h, id) = scope.plan[i as usize];
if let Some(r) = resolved.get(&h) {
solver.set_external(id, *r);
}
}
for (&i, &cached) in solve_frames.iter().zip(&entry_frames) {
let (h, id, over) = scope.plan[i as usize];
let (h, id) = 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.set_frame(id, input);
solver.set_entry(id, cached, is_invalid(id));
}
solver.solve();
for &i in &solve_frames {
let (h, id, _) = scope.plan[i as usize];
let (h, id) = scope.plan[i as usize];
match solver.rect(id) {
Some(r) => {
if resolved.get(&h) != Some(&r) {
@@ -1272,6 +1198,25 @@ impl UiScript {
if prof {
n_regions_swept += 1;
}
// Not reached since its entry: it keeps its state, rect or none (see
// `LayoutSolver::set_entry`).
if !solver.reached(region_id, data.anchors.iter().map(|a| a.relative_to)) {
match solver.entry(region_id) {
Some(kept) => {
solver.set_external(region_id, kept);
if region_resolved.get(&rh) != Some(&kept) {
region_resolved.insert(rh, kept);
changed = true;
}
}
None => {
if region_resolved.remove(&rh).is_some() {
changed = true;
}
}
}
continue;
}
// 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
@@ -1297,6 +1242,8 @@ impl UiScript {
// 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.
// The getter runs in the resolve (`0x767579`, `0x7675e9`), loading the art first if
// it must (`0x44b3b0`), so only a resolve that reaches the node reads it.
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 {
@@ -1331,26 +1278,6 @@ impl UiScript {
let vertical = axis(edges[0], edges[2]);
let horizontal = axis(edges[1], edges[3]);
let resolved_edges = vertical.zip(horizontal);
// Not reached since its entry: it keeps its state, rect or none (see
// `LayoutSolver::set_entry`).
let reads = data.anchors.iter().map(|a| a.relative_to);
if !solver.takes_resolve(region_id, resolved_edges.is_some(), reads) {
match solver.entry(region_id) {
Some(kept) => {
solver.set_external(region_id, kept);
if region_resolved.get(&rh) != Some(&kept) {
region_resolved.insert(rh, kept);
changed = true;
}
}
None => {
if region_resolved.remove(&rh).is_some() {
changed = true;
}
}
}
continue;
}
let Some(((bottom, top), (left, right))) = resolved_edges else {
// The failed resolve (`0x768d55`) clears the valid bit, so the getters and a
// FontString's draw lose the rect; a Texture keeps the quad its last good
@@ -1508,40 +1435,6 @@ impl UiScript {
}
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<FrameHandle, Anchor> = 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
@@ -1554,17 +1447,15 @@ impl UiScript {
// ── 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
// converged resolve: the screen; each live frame's id and synced `LayoutInput`; each
// anchored region's node hash (anchors, explicit size, measured
// extent, owner) and liveness. A Texture's art is no input: the sweep reads it afresh only
// for a node a write named or the resolve reaches. Seeded rects are outputs, a
// function of these and of the rects the keep test held them against, which a re-run with
// the same inputs keeps again, 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);
@@ -1572,29 +1463,18 @@ impl UiScript {
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);
*slot = frame_node_hash(input);
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);
}
}
scope.plan.push((h, id));
// A frame reads its anchor targets' rects.
for a in &input.anchors {
scope.link(id, a.relative_to);
}
}
#[allow(clippy::cast_possible_truncation)]
@@ -1614,8 +1494,8 @@ impl UiScript {
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.
// The node hash covers every input the sweep reads (anchors, size, measure, 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);
@@ -1667,8 +1547,7 @@ impl UiScript {
/// 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.
/// a frame leaving the roster.
fn rederive_named(model: &mut Model, mut named: Vec<u32>, pre: &mut PreambleProf) -> bool {
let Model {
arena,
@@ -1686,14 +1565,7 @@ impl UiScript {
..
} = 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.sort_unstable();
named.dedup();
let mut targets = std::mem::take(&mut scope.targets);
let mut at = 0;
@@ -1718,39 +1590,6 @@ impl UiScript {
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();
@@ -1764,21 +1603,17 @@ impl UiScript {
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);
targets.extend(input.anchors.iter().map(|a| a.relative_to));
let hash = frame_node_hash(input);
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.plan.push((h, id));
scope.relink(id, &targets);
true
} else {
scope.plan[n as usize].2 = over;
scope.relink(id, &targets)
};
scope.note(id, hash, relinked);
+2 -3
View File
@@ -140,9 +140,8 @@ impl super::UiScript {
f.user_placed = true;
}
}
if changed {
model.touch_layout_frame(h);
}
// A row applied invalidates the frame (`0x490710`).
model.invalidate_frame(h, changed);
}
}
+1 -1
View File
@@ -134,7 +134,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
data.texture = None;
data.fill = None;
data.circular = true;
model.touch_layout_art(rh);
model.note_art(rh);
Ok(Value::Integer(1))
})?,
)?;
+67 -21
View File
@@ -150,6 +150,12 @@ pub(crate) struct Model {
/// node nothing invalidated keeps a state a recompute could change
/// ([`Model::invalidate_frame`]).
pub(crate) layout_pending: Vec<u32>,
/// Layout ids whose inputs a write moved without invalidating them since they were last
/// invalidated: a dying target's unhook (`0x768e20` → `0x767e70` with a zero flag), new art
/// (`SetTexture` `0x770200`), a re-link into another frame (`0x77fd10`). Each holds a state a
/// re-resolve need not give back, so a write the reference invalidates for is not skipped as
/// a no-op on it ([`Model::invalidate_frame`]).
pub(crate) layout_unsettled: HashSet<u32>,
/// The screen the last converged resolve laid out against: a frame anchored to it is reached
/// when it moves, as by any target's new rect.
pub(crate) layout_screen: Option<Rect>,
@@ -1173,6 +1179,7 @@ impl Model {
text_flips: Vec::new(),
layout_force: Vec::new(),
layout_pending: Vec::new(),
layout_unsettled: HashSet::new(),
layout_screen: None,
errors: Vec::new(),
pending_error_dispatch: Vec::new(),
@@ -1613,7 +1620,9 @@ impl Model {
/// A write to region `rh`'s layout inputs or liveness: its anchors, size, measured extent,
/// art or owner. Its node re-derives at the next resolve, joining or leaving the graph as it
/// gained or lost its anchors. An id is minted only for an anchored region, the only kind that
/// can be a node; an anchorless one with no id has nothing in the graph to move.
/// can be a node; an anchorless one with no id has nothing in the graph to move. Naming
/// invalidates nothing: a write the reference invalidates for goes through
/// [`Self::invalidate_region`].
pub(crate) fn touch_layout_region(&mut self, rh: RegionHandle) {
match self.region_to_id.get(&rh) {
Some(&id) => self.name_layout_node(id),
@@ -1628,20 +1637,6 @@ impl Model {
}
}
/// A write to region `rh`'s art, its texture or solid fill: a layout input only where an
/// anchored region takes an axis authored 0 from it ([`super::layout::content_span`]), and
/// named only when it moved the node's hash, so a repaint of the same art leaves the gate shut.
pub(crate) fn touch_layout_art(&mut self, rh: RegionHandle) {
if self
.region_data
.get(&rh)
.is_some_and(|d| d.size.is_some_and(|(w, h)| w != 0.0 && h != 0.0))
{
return;
}
self.touch_layout_content(rh);
}
/// A write to region `rh`'s text, which was non-empty iff `was_shown`: the sweep reads a
/// FontString's measure only while its text is non-empty
/// ([`super::layout::effective_measure`]), so a flip between empty and not is noted, and the
@@ -1658,7 +1653,9 @@ impl Model {
}
}
/// Names anchored region `rh` unless its node still holds its converged hash.
/// Text that emptied or filled on anchored region `rh`, which moves the measure its resolve
/// reads: a new string clears the valid bit and invalidates (`0x771ea4`), unless the node
/// still holds its converged hash, as when a clear and rebuild restored the same text.
pub(crate) fn touch_layout_content(&mut self, rh: RegionHandle) {
let Some(d) = self.region_data.get(&rh) else {
return;
@@ -1678,10 +1675,10 @@ impl Model {
return;
}
}
self.touch_layout_region(rh);
self.clear_region_valid(rh);
}
/// The same for frame `h`: its anchors, size, scale, clamp or scroll override.
/// The same for frame `h`: its anchors, size, scale or clamp.
pub(crate) fn touch_layout_frame(&mut self, h: FrameHandle) {
match self.frame_to_id.get(&h) {
Some(&id) => self.name_layout_node(id),
@@ -1695,7 +1692,8 @@ impl Model {
/// A write the reference invalidates frame `h` for (`0x7680e0`): `SetPoint`, `SetWidth`,
/// `SetClampedToScreen` and the like, which pass `changed` for whether it moved an input. One
/// that moved nothing is skipped, as a re-resolve gives back the rect the frame holds, unless
/// the frame holds one a re-resolve would not give it ([`Self::frame_holds_stale`]).
/// the frame holds one a re-resolve would not give it ([`Self::frame_holds_stale`]): only an
/// invalidated node, or one whose target stored a new rect, takes a new rect.
pub(crate) fn invalidate_frame(&mut self, h: FrameHandle, changed: bool) {
if !changed && !self.frame_holds_stale(h) {
return;
@@ -1729,9 +1727,50 @@ impl Model {
}
}
/// Whether frame `h` holds a rect its inputs would not resolve to now: it has no anchors, or
/// reads a target with no rect, so the rect stands only because nothing invalidated it since.
/// A write that moved region `rh`'s layout inputs and invalidated nothing (a re-link into
/// another frame, `0x77fd10`; a dying target's unhook, `0x767e70`): its node re-derives, and
/// it keeps its state until the resolve reaches it.
pub(crate) fn unsettle_region(&mut self, rh: RegionHandle) {
self.touch_layout_region(rh);
if let Some(&id) = self.region_to_id.get(&rh) {
self.layout_unsettled.insert(id);
}
}
/// [`Self::unsettle_region`] for frame `h`.
pub(crate) fn unsettle_frame(&mut self, h: FrameHandle) {
self.touch_layout_frame(h);
if let Some(&id) = self.frame_to_id.get(&h) {
self.layout_unsettled.insert(id);
}
}
/// New art on region `rh` (`SetTexture` `0x770200`, the colour form `0x770360`): it marks
/// only the draw dirty (`0x77fdf0`), so the layout is not touched. An anchored texture with an
/// axis sized 0 reads its art in its next reaching resolve, so until then it holds a state
/// that resolve need not give back.
pub(crate) fn note_art(&mut self, rh: RegionHandle) {
let Some(&id) = self.region_to_id.get(&rh) else {
return;
};
if self.region_data.get(&rh).is_some_and(|d| {
!d.anchors.is_empty() && d.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0)
}) {
self.layout_unsettled.insert(id);
}
}
/// Whether frame `h` holds a state its inputs would not resolve to now: a write moved them
/// without invalidating it ([`Self::layout_unsettled`]), or it holds a rect with no anchors
/// or reading a target with no rect, which stands only because nothing invalidated it since.
pub(crate) fn frame_holds_stale(&self, h: FrameHandle) -> bool {
if self
.frame_to_id
.get(&h)
.is_some_and(|id| self.layout_unsettled.contains(id))
{
return true;
}
if !self.resolved.contains_key(&h) {
return false;
}
@@ -1742,6 +1781,13 @@ impl Model {
/// [`Self::frame_holds_stale`] for region `rh`.
pub(crate) fn region_holds_stale(&self, rh: RegionHandle) -> bool {
if self
.region_to_id
.get(&rh)
.is_some_and(|id| self.layout_unsettled.contains(id))
{
return true;
}
if !self.region_resolved.contains_key(&rh) {
return false;
}
+2 -3
View File
@@ -86,9 +86,8 @@ impl Model {
input.width.to_bits() != w.to_bits() || input.height.to_bits() != ht.to_bits();
input.width = w;
input.height = ht;
if changed {
self.touch_layout_frame(h);
}
// The model-ready size write invalidates the frame (`0x76ce70`).
self.invalidate_frame(h, changed);
if let Some(KindState::Model(m)) = self.arena.frame_mut(h).map(|f| &mut f.kind_state) {
m.implicit_size = true;
}
+10 -7
View File
@@ -596,11 +596,12 @@ impl Plate {
Anchors::from([Anchor::new(Point::Right, plate_id, Point::Left, 0.0, 0.0)]);
raid_data.size = Some((g.raid_size, g.raid_size));
// Once per geometry change: each node it wrote re-derives.
model.touch_layout_frame(frame);
model.touch_layout_frame(bar);
// Once per geometry change: each node it wrote is invalidated, as the plate setup's
// `SetPoint`/`SetAllPoints` with the invalidate flag do (`0x7cb456`..`0x7cb806`).
model.invalidate_frame(frame, true);
model.invalidate_frame(bar, true);
for rh in [border, glow, name, level, skull, raid] {
model.touch_layout_region(rh);
model.invalidate_region(rh, true);
}
model.nameplates.plates[i].laid_out = Some(g);
}
@@ -633,7 +634,8 @@ impl Plate {
state.top_centre.0,
state.top_centre.1,
)]);
model.touch_layout_frame(frame);
// `SetPoint` with the invalidate flag (`0x50a044`).
model.invalidate_frame(frame, true);
}
if last.is_none() {
@@ -670,7 +672,8 @@ impl Plate {
if last.as_ref().is_none_or(|l| l.name != state.name) {
model.region_data.entry(name).or_default().text = Some(state.name.clone());
model.touch_measure(name);
model.touch_layout_region(name);
// A new string clears the valid bit and invalidates (`0x771ea4`).
model.clear_region_valid(name);
}
if last.as_ref().is_none_or(|l| l.hovered != state.hovered) {
let c = if state.hovered {
@@ -700,7 +703,7 @@ impl Plate {
data.hidden = false;
model.region_data.entry(skull).or_default().hidden = true;
model.touch_measure(level);
model.touch_layout_region(level);
model.clear_region_valid(level);
}
// No level yet and no skull: the seat stays empty.
(None, false) => {
@@ -410,9 +410,8 @@ fn pin_top_left(
input.anchors = pin;
input.width = w;
input.height = ht;
if changed {
model.touch_layout_frame(h);
}
// The size setters and a closing `0x7680e0(1)` invalidate (`0x768533`).
model.invalidate_frame(h, changed);
// The size it now carries is explicit, as a `SetWidth`/`SetHeight` would leave it.
model.note_authored_size(h);
Some(changed)
@@ -494,7 +493,8 @@ fn drag_step(model: &mut Model, mv: FrameMove, dx: f32, dy: f32) {
})
.is_some();
if moved {
model.touch_layout_frame(h);
// The offsets move and the frame is invalidated (`0x768c31`).
model.invalidate_frame(h, true);
mark_user_placed_change(model, h);
return;
}
+112 -29
View File
@@ -78,8 +78,9 @@ pub(super) fn link_region(
shown: bool,
) {
if model.arena.set_region_owner(rh, Some(owner)) {
// The owner supplies the region's scale to layout, so a re-link is a layout change.
model.touch_layout_region(rh);
// The owner supplies the region's scale to layout; the re-link invalidates nothing, so
// the region keeps its rect until the resolve reaches it.
model.unsettle_region(rh);
}
if let Some(r) = model.arena.region_mut(rh) {
r.draw_layer = layer;
@@ -207,7 +208,7 @@ pub(super) fn set_texture_path(model: &mut Model, rh: RegionHandle, path: &str)
let data = model.region_data.entry(rh).or_default();
data.texture = (!path.is_empty()).then(|| path.to_string());
data.fill = None;
model.touch_layout_art(rh);
model.note_art(rh);
}
/// The loader's install of the texture a `<BarTexture>`, `<ThumbTexture>`, Button state-texture or
@@ -274,7 +275,7 @@ pub(super) fn clear_texture(model: &mut Model, rh: RegionHandle) {
data.texture = None;
data.fill = None;
data.portrait_unit = None;
model.touch_layout_art(rh);
model.note_art(rh);
}
/// Apply the font parts an XML element supplies (`font=`, `<FontHeight>`, `outline=`), any of
@@ -379,7 +380,7 @@ pub(super) fn install_in_game(lua: &Lua) -> mlua::Result<()> {
data.texture = None;
data.fill = None;
data.circular = true;
model.touch_layout_art(rh);
model.note_art(rh);
Ok(())
})?,
)?;
@@ -402,7 +403,7 @@ pub(super) fn install_in_game(lua: &Lua) -> mlua::Result<()> {
data.texture = Some(path);
data.circular = true;
data.portrait_unit = None;
model.touch_layout_art(rh);
model.note_art(rh);
Ok(())
})?,
)?;
@@ -527,8 +528,9 @@ fn install_region_methods(lua: &Lua) -> mlua::Result<()> {
_ => return Err(wrong_type()),
};
if model.arena.set_region_owner(rh, new_owner) {
// The owner supplies the region's scale to layout, so a re-link is a layout change.
model.touch_layout_region(rh);
// The owner supplies the region's scale to layout; the re-link invalidates nothing
// (`0x77fd10`), so the region keeps its rect until the resolve reaches it.
model.unsettle_region(rh);
}
Ok(())
},
@@ -677,29 +679,108 @@ pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str
}
}
/// Free a region and every trace of its identity (arena slot, paint, rect, name, id), so a held
/// wrapper raises as stale; the reference frees too, as `CSimpleButton::SetFontString 0x778d20`
/// deletes the label it replaces.
/// Free a region; see [`free_regions`].
pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
model.region_kept.remove(&rh);
let name = model.arena.region(rh).and_then(|r| r.name.clone());
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
// The lookup entry goes only if this region is the one it holds; a later holder of the
// same name was never in it.
if let Some(name) = name {
if model.region_by_name.get(&name) == Some(&id) {
model.region_by_name.remove(&name);
free_regions(model, &[rh]);
}
/// Free regions and every trace of their identity (arena slot, paint, rect, name, id), so a held
/// wrapper raises as stale. The reference destroys them: `CSimpleButton::SetFontString` the label
/// it replaces (`0x778d3c`), a state-texture setter the texture it displaces (`0x77900a`), a
/// backdrop teardown its pieces (`0x77f4d0`), a SimpleHTML parse its old blocks (`0x78a5aa`). The
/// destructor (`0x767720` → `0x768e20`) unhooks every object anchored to a dying one with
/// `0x767e70(dependent, dying, 0)`: each anchor naming it is freed, and the zero flag queues no
/// resolve, so the dependent keeps its state until the resolve reaches it.
pub(crate) fn free_regions(model: &mut Model, dying: &[RegionHandle]) {
let mut ids = Vec::with_capacity(dying.len());
for &rh in dying {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
model.region_kept.remove(&rh);
let name = model.arena.region(rh).and_then(|r| r.name.clone());
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
model.layout_unsettled.remove(&id);
// The lookup entry goes only if this region is the one it holds; a later holder of the
// same name was never in it.
if let Some(name) = name {
if model.region_by_name.get(&name) == Some(&id) {
model.region_by_name.remove(&name);
}
}
// Its node leaves the cached graph with its edges at the next resolve.
model.name_layout_node(id);
ids.push(id);
}
model.arena.destroy_region(rh);
}
// Only a region with an id can be an anchor target.
if ids.is_empty() {
return;
}
let mut regions: Vec<RegionHandle> = Vec::new();
let mut frames: Vec<crate::widget::FrameHandle> = Vec::new();
match &model.layout_touched {
// The cached graph's readers of each dying id, and every node a write named since it was
// built, are all that can anchor to one.
Some(named) => {
let mut maybe = named.clone();
for &id in &ids {
maybe.extend(model.layout_scope.readers_of(id));
}
maybe.sort_unstable();
maybe.dedup();
for id in maybe {
if let Some(&rh) = model.id_to_region.get(&id) {
if let Some(d) = model.region_data.get_mut(&rh) {
if d.anchors.release_targets(&ids) {
regions.push(rh);
}
}
} else if let Some(&h) = model.id_to_frame.get(&id) {
if let Some(i) = model.layout_inputs.get_mut(&h) {
if i.anchors.release_targets(&ids) {
frames.push(h);
}
}
}
}
}
// No graph to vouch for: every object.
None => {
for (&rh, d) in model.region_data.iter_mut() {
if d.anchors.release_targets(&ids) {
regions.push(rh);
}
}
for (&h, i) in model.layout_inputs.iter_mut() {
if i.anchors.release_targets(&ids) {
frames.push(h);
}
}
}
// Its node leaves the cached graph with its edges at the next resolve. What anchored to
// it is not invalidated (`0x768e20` unhooks each with `0x767e70`, flag 0), so it keeps
// its state.
model.name_layout_node(id);
}
model.arena.destroy_region(rh);
// Under `WOW_LAYOUT_VERIFY` the graph's answer is checked against every object.
if model.layout_touched.is_some() && super::layout::layout_verify_enabled() {
let missed = model
.region_data
.values()
.map(|d| &d.anchors)
.chain(model.layout_inputs.values().map(|i| &i.anchors))
.any(|a| a.iter().any(|a| ids.contains(&a.relative_to)));
assert!(
!missed,
"WOW_LAYOUT_VERIFY: an object anchored to a freed region was not among the cached \
graph's readers of it or the nodes named since"
);
}
// Each dependent re-derives its edges and keeps the state it has.
for rh in regions {
model.unsettle_region(rh);
}
for h in frames {
model.unsettle_frame(h);
}
}
pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 {
@@ -738,7 +819,8 @@ pub(crate) fn implicit_creation_anchor(model: &mut Model, rh: RegionHandle) {
}
RegionKind::Title => return,
}
model.touch_layout_region(rh);
// Both set their points with the invalidate flag (`0x7701ec`, `0x7714bc`).
model.invalidate_region(rh, true);
}
/// The middle-row point a justify word selects, as the FontString post-step (`0x771480`) and the
@@ -767,7 +849,8 @@ pub(crate) fn anchor_unanchored_at(
return;
}
data.anchors = Anchors::from([Anchor::new(point, owner_id, point, 0.0, 0.0)]);
model.touch_layout_region(rh);
// The adopter's `SetPoint` invalidates (`0x778d96`, flag 1).
model.invalidate_region(rh, true);
}
/// [`implicit_creation_anchor`] for the loader, which holds region wrappers.
+15 -7
View File
@@ -199,11 +199,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
};
loaded
};
// Only the art-sized shape can move, and only a new art: touching the layout on
// every repaint would reopen the resolve's change gate each frame.
lua.app_data_mut::<Model>()
.expect("model")
.touch_layout_art(rh);
// The draw only (`0x77fdf0`); an art-sized texture now holds a state its next
// reaching resolve need not give back.
lua.app_data_mut::<Model>().expect("model").note_art(rh);
Ok(if loaded {
Value::Number(1.0)
} else {
@@ -485,8 +483,18 @@ impl crate::script::UiScript {
pub fn set_texture_size_probe(&mut self, probe: crate::script::TextureSizeProbe) {
let mut model = self.model_mut();
model.texture_size_probe = Some(probe);
// Every art-sized region's span may move, and no per-node hash holds a probe's answer: a
// full-scope rebuild, as for a screen move.
// The reference has every art's size at hand (`0x44b3b0` loads it), so a probe installed
// late re-resolves each texture that takes a span from its art, as if it had been there.
let art_sized: Vec<u32> = model
.region_data
.iter()
.filter(|(_, d)| {
!d.anchors.is_empty() && d.size.is_none_or(|(w, h)| w == 0.0 || h == 0.0)
})
.filter_map(|(rh, _)| model.region_to_id.get(rh).copied())
.collect();
model.layout_pending.extend(art_sized);
// No per-node hash holds a probe's answer: a full-scope rebuild, as for a screen move.
model.layout_scope.invalidate();
model.touch_layout();
}
+37 -21
View File
@@ -143,20 +143,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
}
};
// Every call marks the range (`0x786d16`), the same child again included.
let old_child = with_scroll(lua, &this, |s| {
with_scroll(lua, &this, |s| {
s.range_dirty = true;
std::mem::replace(&mut s.child, new_child)
s.child = new_child;
})?;
if old_child != new_child {
// The child takes this frame's override anchor and the old one drops it; the
// ScrollFrame's node re-derives its new child with it.
let h = frame_handle_of(lua, &this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
model.touch_layout_frame(h);
if let Some(old) = old_child {
model.touch_layout_frame(old);
}
}
// The old child keeps its point and is not invalidated (`0x786ce6`–`0x786cf4` only
// flags it); the new one is re-anchored on every call (`0x786d11`).
reanchor_child(lua, &this)?;
Ok(())
})?,
)?;
@@ -192,11 +185,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if !changed {
return Ok(());
}
// The child's override offset: the ScrollFrame's node re-derives its child.
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.touch_layout_frame(h);
// The re-anchor at the new offset (`0x786dd8` → `0x787100`).
reanchor_child(lua, &this)?;
fire_scroll(lua, &this, "OnVerticalScroll", px)
})?,
)?;
@@ -226,10 +216,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if !changed {
return Ok(());
}
let h = frame_handle_of(lua, &this)?;
lua.app_data_mut::<Model>()
.expect("model")
.touch_layout_frame(h);
reanchor_child(lua, &this)?;
fire_scroll(lua, &this, "OnHorizontalScroll", px)
})?,
)?;
@@ -357,3 +344,32 @@ fn see_size(s: &mut ScrollFrameState, rect: Option<crate::layout::Rect>, eps: f6
}
s.seen_size = size;
}
/// The re-anchor (`0x787100`): when the frame holds a child, clear its points (`0x787110` →
/// `0x767620`) and set one TOPLEFT on the frame's TOPLEFT at the raw `(horizontal, vertical)`
/// offsets, y up, with the invalidate flag (`0x787136`). A real write on the child: its own
/// points are gone, and it keeps the new one when the frame lets it go.
fn reanchor_child(lua: &Lua, this: &Table) -> mlua::Result<()> {
let (child, x, y) = with_scroll(lua, this, |s| (s.child, s.horizontal, s.vertical))?;
let Some(child) = child else {
return Ok(());
};
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
if model.arena.frame(child).is_none() {
return Ok(());
}
let sid = model.frame_id(h);
let pin = crate::layout::Anchors::from([crate::layout::Anchor::new(
crate::layout::Point::TopLeft,
sid,
crate::layout::Point::TopLeft,
x,
y,
)]);
let input = model.layout_inputs.entry(child).or_default();
let changed = !input.anchors.bits_eq(&pin);
input.anchors = pin;
model.invalidate_frame(child, changed);
Ok(())
}
+7 -12
View File
@@ -166,16 +166,14 @@ fn resolve_font(model: &Model, ef: &ElementFont) -> BlockPaint {
/// `CSimpleHTML::SetText` (`0x78a3a0`): free the old blocks, parse, build. Returns `usedMarkup`
/// (`0x78a519`), which the Lua shim drops.
pub(crate) fn set_text(model: &mut Model, fh: FrameHandle, raw: &str) -> bool {
// Free the previous parse's regions first, as the reference does, or a book's next page
// draws over the last.
// Free the previous parse's regions first, as the reference does (`0x78a580`), or a book's
// next page draws over the last.
let old = model
.simple_html
.get_mut(&fh)
.map(|s| std::mem::take(&mut s.blocks))
.unwrap_or_default();
for rh in old {
free_block(model, rh);
}
crate::script::region::free_regions(model, &old);
let (frame_name, hyperlink_format) = {
let name = model
@@ -289,7 +287,7 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
else {
break; // unreachable, as above
};
// Minted though nothing anchors to an image; the resolve and `free_block` use it.
// Minted though nothing anchors to an image; the resolve and the free use it.
model.region_id(rh);
// `align` picks the corner; 8, 16 and 32 get no anchor at all (`0x78ac61`, a jump
// to `0x78ace8`), so the image draws nothing, here as there.
@@ -332,9 +330,10 @@ fn build(model: &mut Model, fh: FrameHandle, blocks: &[Block]) {
}
}
// Each new block joins the graph; the old ones' frees named theirs.
// Each new block's `SetPoint` invalidates it (`0x78ae0c`, flag 1); the old ones' frees named
// theirs.
for &rh in &made {
model.touch_layout_region(rh);
model.invalidate_region(rh, true);
}
model.simple_html.entry(fh).or_default().blocks = made;
}
@@ -356,10 +355,6 @@ fn anchor_for(prev_block: Option<u32>, frame_id: u32, point: Point, next_y: f32)
}
}
fn free_block(model: &mut Model, rh: RegionHandle) {
crate::script::region::free_region(model, rh);
}
// ── The loader's seam ────────────────────────────────────────────────────────────────────────
/// Apply the `font=`, `<FontHeight>` and `outline=` an XML `<FontString>` or `<FontStringHeaderN>`
+1 -1
View File
@@ -195,7 +195,7 @@ fn emblem_texture(lua: &Lua, this: Value, texture: Value, upper: bool) -> mlua::
data.fill = None;
data.portrait_unit = None;
data.circular = false;
model.touch_layout_art(rh);
model.note_art(rh);
Ok(())
}
+1 -1
View File
@@ -198,7 +198,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let art = model.taxi.as_ref().map(|t| t.art.clone());
if let Some(art) = art {
model.region_data.entry(rh).or_default().texture = Some(art);
model.touch_layout_art(rh);
model.note_art(rh);
}
Ok(())
})?,
@@ -0,0 +1,211 @@
//! A layout object's destructor (`0x767720` → `0x768e20`) unhooks each object anchored to it with
//! `0x767e70(dependent, dying, 0)`: every anchor slot naming the dying object is freed, and the
//! zero flag skips the invalidate (`0x7680e0`). So `GetNumPoints` (`0x7a2260`) drops and `GetPoint`
//! no longer answers those points, while the dependent keeps its rect until its own next
//! invalidate. A region dies to `Button:SetFontString` (`0x778d3c`), a state-texture setter
//! (`0x77900a`), a backdrop teardown (`0x77f4d0`) and a SimpleHTML re-parse (`0x78a5aa`).
use super::common::script;
use crate::script::UiScript;
/// Resolve, then require the cached graph and its rects to equal a from-scratch solve.
fn resolve_matches_scratch(s: &mut UiScript, what: &str) {
s.resolve();
let mut model = s.model_mut();
if let Some(why) = UiScript::scratch_divergence(&mut model) {
panic!("{what}: {why}");
}
}
fn num(s: &mut UiScript, expr: &str) -> Option<f64> {
s.eval::<Option<f64>>(&format!("return {expr}")).unwrap()
}
/// The issue's sequence: a texture anchored to a button's label and to the button; the label
/// replaced. The anchor to the destroyed label is gone, the other stands, and the texture keeps
/// its rect, which the anchor that is left would move, until its own invalidate.
#[test]
fn a_replaced_label_leaves_its_dependents_anchors() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
b = CreateFrame("Button", "B")
b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
b:SetWidth(200); b:SetHeight(40)
b:SetFontString(b:CreateFontString("Old", "ARTWORK"))
Old:SetWidth(50); Old:SetHeight(10)
t = b:CreateTexture("T", "OVERLAY")
t:SetTexture(1, 1, 1); t:SetWidth(10); t:SetHeight(10)
t:SetPoint("LEFT", b:GetFontString(), "RIGHT")
t:SetPoint("TOP", b, "TOP")
"#,
)
.unwrap();
s.resolve();
assert_eq!(s.eval::<i64>("return t:GetNumPoints()").unwrap(), 2);
assert_eq!(
num(&mut s, "t:GetLeft()"),
Some(225.0),
"right of the label"
);
s.run(r#"b:SetFontString(b:CreateFontString("New", "ARTWORK"))"#)
.unwrap();
assert_eq!(
s.eval::<i64>("return t:GetNumPoints()").unwrap(),
1,
"the anchor to the destroyed label is freed"
);
assert_eq!(
s.eval::<i64>("return table.getn({ t:GetPoint(2) })")
.unwrap(),
0,
"and no second point answers"
);
assert!(s
.eval::<bool>(
r#"local p, r, rp = t:GetPoint(1); return p == "TOP" and r == b and rp == "TOP""#
)
.unwrap());
resolve_matches_scratch(&mut s, "the label replaced");
assert_eq!(
num(&mut s, "t:GetLeft()"),
Some(225.0),
"the unhook queued no resolve"
);
// Its own invalidate resolves it from the anchor left: centred under the button's top.
s.run("t:SetWidth(12)").unwrap();
resolve_matches_scratch(&mut s, "the texture's own invalidate");
assert_eq!(num(&mut s, "t:GetLeft()"), Some(194.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A texture and a frame each anchored to a backdrop piece and to the frame: the teardown frees
/// the piece's anchors only, and both keep their rects.
#[test]
fn a_backdrop_teardown_frees_the_anchors_to_its_pieces() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
f = CreateFrame("Frame")
f:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
f:SetWidth(100); f:SetHeight(100)
f:SetBackdrop({ bgFile = "bg", edgeFile = "edge" })
local piece = ({ f:GetRegions() })[1]
local g = CreateFrame("Frame")
dep = g:CreateTexture(nil, "ARTWORK")
dep:SetTexture("dep"); dep:SetWidth(10); dep:SetHeight(10)
dep:SetPoint("TOPLEFT", piece, "TOPLEFT", 5, -5)
dep:SetPoint("BOTTOM", f, "BOTTOM", 0, 20)
h = CreateFrame("Frame")
h:SetWidth(10); h:SetHeight(10)
h:SetPoint("TOPLEFT", piece, "TOPLEFT", 3, -3)
h:SetPoint("BOTTOM", f, "BOTTOM", 0, 30)
"#,
)
.unwrap();
s.resolve();
let before = (
num(&mut s, "dep:GetLeft()"),
num(&mut s, "dep:GetTop()"),
num(&mut s, "h:GetLeft()"),
num(&mut s, "h:GetTop()"),
);
assert!(before.0.is_some() && before.2.is_some(), "{before:?}");
s.run("f:SetBackdrop(nil)").unwrap();
assert_eq!(s.eval::<i64>("return dep:GetNumPoints()").unwrap(), 1);
assert_eq!(s.eval::<i64>("return h:GetNumPoints()").unwrap(), 1);
assert!(s
.eval::<bool>(r#"local p, r = dep:GetPoint(1); return p == "BOTTOM" and r == f"#)
.unwrap());
resolve_matches_scratch(&mut s, "the backdrop torn down");
let after = (
num(&mut s, "dep:GetLeft()"),
num(&mut s, "dep:GetTop()"),
num(&mut s, "h:GetLeft()"),
num(&mut s, "h:GetTop()"),
);
assert_eq!(after, before, "no resolve was queued for either");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A ScrollFrame's re-anchor clears its child's points (`0x787110`) and sets one on the frame, so
/// the child holds no anchor to a label it was placed on, and the label's destruction leaves it be.
#[test]
fn a_scroll_child_holds_no_anchor_to_a_label_it_was_placed_on() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
b = CreateFrame("Button", "B")
b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
b:SetWidth(200); b:SetHeight(40)
b:SetFontString(b:CreateFontString("Old", "ARTWORK"))
Old:SetWidth(50); Old:SetHeight(10)
sf = CreateFrame("ScrollFrame", "SF")
sf:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10)
sf:SetWidth(50); sf:SetHeight(50)
c = CreateFrame("Frame", "Child")
c:SetWidth(5); c:SetHeight(5)
c:SetPoint("TOPLEFT", Old, "TOPLEFT")
sf:SetScrollChild(c)
"#,
)
.unwrap();
s.resolve();
let left = num(&mut s, "c:GetLeft()");
assert_eq!(left, Some(10.0), "placed by the override");
assert_eq!(s.eval::<i64>("return c:GetNumPoints()").unwrap(), 1);
s.run(r#"b:SetFontString(b:CreateFontString("New", "ARTWORK"))"#)
.unwrap();
assert!(s
.eval::<bool>(r#"local p, r = c:GetPoint(1); return c:GetNumPoints() == 1 and r == sf"#)
.unwrap());
resolve_matches_scratch(&mut s, "the label replaced");
assert_eq!(num(&mut s, "c:GetLeft()"), left);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The re-link into another frame (`0x77fd10`) invalidates nothing: a region moved under a frame of
/// another scale keeps its rect until its own invalidate, which then reads the new owner's scale.
#[test]
fn a_region_reowned_keeps_its_rect_until_its_own_invalidate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
a = CreateFrame("Frame", "A"); a:SetWidth(10); a:SetHeight(10)
a:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0)
b = CreateFrame("Frame", "B"); b:SetWidth(10); b:SetHeight(10)
b:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 0, 0); b:SetScale(2)
x = CreateFrame("Frame", "X"); x:SetWidth(100); x:SetHeight(100)
x:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
t = a:CreateTexture("T", "ARTWORK"); t:SetTexture(1, 1, 1)
t:SetWidth(8); t:SetHeight(8); t:SetPoint("BOTTOMLEFT", x, "BOTTOMLEFT", 0, 0)
"#,
)
.unwrap();
s.resolve();
assert_eq!(num(&mut s, "T:GetRight()"), Some(108.0));
s.run("t:SetParent(b)").unwrap();
resolve_matches_scratch(&mut s, "re-owned");
assert_eq!(
num(&mut s, "T:GetRight()"),
Some(54.0),
"the rect it had, read in the new owner's units"
);
s.run("t:SetWidth(8)").unwrap();
resolve_matches_scratch(&mut s, "its own invalidate");
assert_eq!(
num(&mut s, "T:GetRight()"),
Some(58.0),
"8 units at scale 2, from 100"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -86,10 +86,10 @@ const POINTS: [&str; 9] = [
/// The Lua state the steps act on: `N` every node in creation order, `F` the frames, `R` the
/// regions, `B` the buttons, `S` the ScrollFrames, `owned[child]` the ScrollFrame that holds it.
/// `deps[o][point]` mirrors each anchor's target and `held[c]` each scroll override, so `target`
/// `deps[o][point]` mirrors each anchor's target and `held[c]` each scroll claim, so `target`
/// picks any node, older or newer, whose rect does not already read `o`'s: the graph stays acyclic.
const SETUP: &str = r#"
N, F, R, B, S, at, owned, deps, held = {}, {}, {}, {}, {}, {}, {}, {}, {}
N, F, R, B, S, at, owned, deps, held, pieced = {}, {}, {}, {}, {}, {}, {}, {}, {}, {}
function add(o) table.insert(N, o); at[o] = table.getn(N); deps[o] = {} end
-- Whether `a`'s rect reads `b`'s, through anchors and scroll overrides.
function reaches(a, b)
@@ -181,15 +181,25 @@ fn step(rng: &mut Rng) -> Write {
"local f = F[math.mod({k}, table.getn(F)) + 1]
if f:GetBackdrop() then f:SetBackdrop(nil) else f:SetBackdrop(spec) end"
),
// A node anchored to a backdrop piece, so a teardown frees what something reads.
// A node, a texture or a frame, anchored to a fresh backdrop piece (the last region the
// build links) and, at another point, to any node, so a later teardown frees one of its
// anchors and it stays a node.
5 => format!(
"local f = F[math.mod({k}, table.getn(F)) + 1]
local piece = ({{ f:GetRegions() }})[1]
if piece and at[f] and not at[piece] then
deps[piece] = {{ f }}
local t = f:CreateTexture(nil, 'OVERLAY'); add(t); table.insert(R, t)
t:SetWidth(5); t:SetHeight(5); point(t, '{p}', piece, '{rp}', {x}, {y})
end"
f:SetBackdrop(spec)
local rs = {{ f:GetRegions() }}
local piece = rs[table.getn(rs)]
deps[piece] = {{ f }}
local o
if {k2} < 600 then
o = f:CreateTexture(nil, 'OVERLAY'); add(o); table.insert(R, o)
else
o = CreateFrame('Frame', nil, f); add(o); table.insert(F, o)
end
o:SetWidth(5); o:SetHeight(5)
point(o, '{p}', piece, '{rp}', {x}, {y})
if '{p}' ~= '{rp}' then point(o, '{rp}', target(o, {k2}), '{p}', {y}, {x}) end
table.insert(pieced, {{ o, o:GetNumPoints() }})"
),
// A button's slot region set, or freed by a nil; or two freed in one pass.
6 => format!(
@@ -260,6 +270,7 @@ fn step(rng: &mut Rng) -> Write {
local old = sf:GetScrollChild()
if old then owned[old] = nil; held[old] = nil end
sf:SetScrollChild(c); owned[c] = sf; held[c] = sf
deps[c] = {{ TOPLEFT = sf }}
break
end
end
@@ -348,6 +359,22 @@ fn random_structural_writes_resolve_as_a_scratch_solve_would() {
"return table.getn(N), raised, table.getn(F), table.getn(R), newer",
)
.expect("count");
// Dependents of a backdrop piece that a later teardown or rebuild freed: each lost the
// anchor to it, and the scratch solve above agreed with every pass in between.
let unhooked = s
.eval::<i64>(
"local n = 0
for _, e in pieced do
local ok, now = pcall(e[1].GetNumPoints, e[1])
if ok and now < e[2] then n = n + 1 end
end
return n",
)
.expect("unhooked");
assert!(
unhooked > 10,
"seed {seed:#x}: only {unhooked} dependents of a backdrop piece saw it freed"
);
eprintln!(
"seed {seed:#x}: {writes} writes, {nodes} nodes ({frames} frames, {regions} \
regions), {raised} raised, {newer} anchors to a newer node"
@@ -372,10 +399,10 @@ fn random_structural_writes_resolve_as_a_scratch_solve_would() {
}
}
/// A frame two ScrollFrames claim takes the derivation, whose override the cached graph cannot
/// name; the result still equals a scratch solve, and a lone claim stays incremental.
/// A frame two ScrollFrames claim holds the last one's re-anchor (`0x787100`), one real point,
/// through the cached graph: no derivation.
#[test]
fn a_child_two_scrollframes_claim_falls_back_to_the_derivation() {
fn a_child_two_scrollframes_claim_holds_the_last_re_anchor() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
@@ -394,14 +421,13 @@ fn a_child_two_scrollframes_claim_falls_back_to_the_derivation() {
s.run("a:SetScrollChild(c); a:SetVerticalScroll(5)")
.expect("one claim");
resolve_matches_scratch(&mut s, "one claim");
assert_eq!(s.layout_derivations(), derived, "one claim is named");
s.run("b:SetScrollChild(c)").expect("two claims");
resolve_matches_scratch(&mut s, "two claims");
assert_eq!(
s.layout_derivations(),
derived + 1,
"two claims give up for the derivation"
);
assert_eq!(s.layout_derivations(), derived, "every claim is named");
assert!(s
.eval::<bool>("local p, r = c:GetPoint(1); return c:GetNumPoints() == 1 and r == b")
.unwrap());
assert_eq!(s.eval::<f64>("return c:GetLeft()").unwrap(), 200.0);
}
/// A frame whose id is minted after a region on it joined the graph (the engine builds some
@@ -19,6 +19,7 @@ mod cvar_bindings;
mod dispatch_bench;
mod drag_pin;
mod drag_threshold;
mod dying_target;
mod end_to_end;
mod engine_updates;
mod events;
@@ -65,6 +66,7 @@ mod structure_queries;
mod talent;
mod taxi;
mod texcoord_font;
mod texture_art;
mod tooltip;
mod tooltip_item;
mod tooltip_spell;
@@ -3,7 +3,7 @@
use super::common::script;
use crate::layout::Rect;
use crate::script::QuadContent;
use crate::script::{QuadContent, UiScript};
/// The rect of the first Texture quad whose path is `marker`.
fn marker_rect(quads: &[crate::script::ExtractedQuad], marker: &str) -> Option<Rect> {
@@ -140,15 +140,64 @@ fn scroll_child_top_tracks_vertical_scroll_unclamped_then_restores_on_clear() {
"a negative offset lowers the child: frame top 500 - 30"
);
// Clearing the child stops the override, and its own untouched anchor applies again.
// Clearing the child leaves its one point alone and invalidates nothing (`0x786ce6`): it
// stays at the scroll frame's corner, and follows the frame when the frame moves.
s.run("SF:SetScrollChild(nil)").unwrap();
s.resolve();
let quads = s.extract();
assert_eq!(
marker_rect(&quads, "marker:child").map(|r| r.top),
Some(580.0),
"authored anchor survives: screen top 600 - 20"
Some(470.0),
"the re-anchor's point stands: frame top 500 - 30"
);
s.run(r#"SF:SetPoint("TOPLEFT", 0, -150)"#).unwrap();
s.resolve();
assert_eq!(
marker_rect(&s.extract(), "marker:child").map(|r| r.top),
Some(420.0),
"still on the frame's corner: frame top 450 - 30"
);
}
/// The re-anchor (`0x787100`) is a real write on the child: its points are cleared (`0x787110`)
/// and one TOPLEFT on the scroll frame's TOPLEFT is set at the offsets (`0x787136`), at
/// `SetScrollChild` and at each offset change; the authored points are gone.
#[test]
fn the_scroll_child_holds_one_topleft_on_the_scroll_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
sf = CreateFrame("ScrollFrame", "SF")
sf:SetPoint("TOPLEFT", 0, -100); sf:SetWidth(300); sf:SetHeight(200)
c = CreateFrame("Frame", "Child")
c:SetPoint("CENTER", UIParent); c:SetPoint("BOTTOM", 0, 10)
c:SetWidth(100); c:SetHeight(400)
sf:SetScrollChild(c)
"#,
)
.unwrap();
let point = |s: &mut UiScript| {
s.eval::<(i64, String, bool, String, f64, f64)>(
"local p, r, rp, x, y = c:GetPoint(1); return c:GetNumPoints(), p, r == sf, rp, x, y",
)
.unwrap()
};
assert_eq!(
point(&mut s),
(1, "TOPLEFT".into(), true, "TOPLEFT".into(), 0.0, 0.0)
);
s.run("sf:SetVerticalScroll(30); sf:SetHorizontalScroll(-12)")
.unwrap();
assert_eq!(
point(&mut s),
(1, "TOPLEFT".into(), true, "TOPLEFT".into(), -12.0, 30.0),
"scrolling moves the one point"
);
s.resolve();
assert_eq!(s.eval::<f64>("return c:GetTop()").unwrap(), 530.0);
assert_eq!(s.eval::<f64>("return c:GetLeft()").unwrap(), -12.0);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// `0x786db0` acts only when the offset differs from the stored one; a value past the range is
@@ -0,0 +1,162 @@
//! `SetTexture` (`0x770200`, the colour form `0x770360`) writes the texture slot and marks the
//! draw batch dirty (`0x77fdf0`); it never invalidates the layout (`0x7680e0`). A texture's axis
//! sized 0 takes its texel extent only when the texture resolves: the resolver reads the size
//! through the geometry vtable (`0x767579`, `0x7675e9`), which a Texture answers from its art
//! (`0x770720`, loading it first if it must, `0x44b3b0`). New art moves nothing until the
//! texture's own next invalidate, or a target's new rect.
use super::common::script;
use crate::layout::Rect;
use crate::script::{UiScript, ZTarget};
/// Resolve, then require the cached graph and its rects to equal a from-scratch solve.
fn resolve_matches_scratch(s: &mut UiScript, what: &str) {
s.resolve();
let mut model = s.model_mut();
if let Some(why) = UiScript::scratch_divergence(&mut model) {
panic!("{what}: {why}");
}
}
fn num(s: &mut UiScript, expr: &str) -> Option<f64> {
s.eval::<Option<f64>>(&format!("return {expr}")).unwrap()
}
/// The rect the render list draws region `name` at, if it draws.
fn drawn(s: &UiScript, name: &str) -> Option<Rect> {
let rh = {
let m = s.model_ref();
let id = *m.region_by_name.get(name).expect("a named region");
*m.id_to_region.get(&id).expect("a live region")
};
s.extract()
.into_iter()
.find(|q| q.target == ZTarget::Region(rh))
.and_then(|q| q.rect)
}
/// `Owner` at `(100, 100)`–`(300, 200)`, with art `Art` 64×32 and `Big` 128×64.
fn owner() -> UiScript {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_texture_size_probe(Box::new(|p| match p {
"Art" => Some((64, 32)),
"Big" => Some((128, 64)),
_ => None,
}));
s.run(
r#"
f = CreateFrame("Frame", "Owner")
f:SetPoint("BOTTOMLEFT", nil, "BOTTOMLEFT", 100, 100)
f:SetWidth(200); f:SetHeight(100)
"#,
)
.unwrap();
s
}
/// One anchor and no size: with no art the resolve fails for want of a span. Art set after
/// leaves it failed, the path form and the colour form alike, until its own invalidate.
#[test]
fn a_failed_texture_stays_failed_when_its_art_arrives() {
let mut s = owner();
s.run(
r#"
T = f:CreateTexture("T", "ARTWORK"); T:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
C = f:CreateTexture("C", "ARTWORK"); C:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)
"#,
)
.unwrap();
s.resolve();
assert_eq!(num(&mut s, "T:GetLeft()"), None, "no art, no span");
assert_eq!(num(&mut s, "C:GetLeft()"), None);
s.run(r#"T:SetTexture("Art"); C:SetTexture(1, 1, 1)"#)
.unwrap();
resolve_matches_scratch(&mut s, "art on failed textures");
assert_eq!(
num(&mut s, "T:GetLeft()"),
None,
"SetTexture queued no resolve"
);
assert_eq!(num(&mut s, "C:GetLeft()"), None);
assert_eq!(drawn(&s, "T"), None, "and it draws nothing");
assert_eq!(drawn(&s, "C"), None);
// The size getter reads the art live (`0x770720`), resolved or not.
assert_eq!(num(&mut s, "T:GetWidth()"), Some(64.0));
s.run(
r#"T:SetPoint("TOPLEFT", f, "TOPLEFT", 1, 0); C:SetPoint("TOPLEFT", f, "TOPLEFT", 2, 0)"#,
)
.unwrap();
resolve_matches_scratch(&mut s, "their own invalidates");
assert_eq!(num(&mut s, "T:GetRight()"), Some(165.0), "the art's 64");
assert_eq!(num(&mut s, "T:GetBottom()"), Some(168.0), "and its 32");
assert_eq!(num(&mut s, "C:GetRight()"), Some(110.0), "the solid's 8");
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A texture resolved at its art's size keeps that rect when the art changes, and takes the new
/// art's size when a target stores a new rect (`0x768cf0`).
#[test]
fn a_resolved_texture_keeps_its_rect_when_its_art_changes() {
let mut s = owner();
s.run(
r#"T = f:CreateTexture("T", "ARTWORK"); T:SetTexture("Art")
T:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0)"#,
)
.unwrap();
s.resolve();
assert_eq!(num(&mut s, "T:GetRight()"), Some(164.0));
let before = drawn(&s, "T").expect("drawn");
s.run(r#"T:SetTexture("Big")"#).unwrap();
resolve_matches_scratch(&mut s, "new art");
assert_eq!(num(&mut s, "T:GetRight()"), Some(164.0), "the rect stands");
assert_eq!(drawn(&s, "T"), Some(before), "and draws there");
assert_eq!(
num(&mut s, "T:GetWidth()"),
Some(128.0),
"the getter reads the art"
);
// The owner's new rect reaches the texture, whose resolve reads the art it holds now.
s.run("f:SetHeight(120)").unwrap();
resolve_matches_scratch(&mut s, "the target moved");
assert_eq!(
num(&mut s, "T:GetRight()"),
Some(228.0),
"the new art's 128"
);
assert_eq!(num(&mut s, "T:GetTop()"), Some(220.0));
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A write the reference invalidates for, though it moves no input, re-resolves a texture whose
/// art changed since: `SetWidth` always invalidates (`0x7683d0`), so the new art's span lands.
#[test]
fn a_same_value_invalidate_after_new_art_takes_the_art() {
let mut s = owner();
s.run(
r#"T = f:CreateTexture("T", "ARTWORK"); T:SetTexture("Art")
T:SetPoint("TOPLEFT", f, "TOPLEFT", 0, 0); T:SetWidth(0)"#,
)
.unwrap();
s.resolve();
assert_eq!(num(&mut s, "T:GetRight()"), Some(164.0));
s.run(r#"T:SetTexture("Big")"#).unwrap();
resolve_matches_scratch(&mut s, "new art");
assert_eq!(
num(&mut s, "T:GetRight()"),
Some(164.0),
"nothing reached it"
);
s.run("T:SetWidth(0)").unwrap();
resolve_matches_scratch(&mut s, "the same width");
assert_eq!(
num(&mut s, "T:GetRight()"),
Some(228.0),
"the invalidate read the new art"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
@@ -1174,3 +1174,44 @@ fn the_fade_end_restores_full_alpha_before_its_hide() {
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The tooltip is invalidated by its relayout (`0x530640`, ending in `0x7680e0(1)` at `0x530a52`),
/// which only Show and the line writers run (`0x530aad`, `0x52ad59`, ..): a cleared plate keeps its
/// rect through an unrelated resolve, and the next line's relayout resolves it without points.
#[test]
fn only_the_relayout_invalidates_the_tooltip() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local owner = CreateFrame("Button", "Slot")
owner:SetPoint("TOPLEFT", 100, -100); owner:SetWidth(40); owner:SetHeight(40)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(owner, "ANCHOR_RIGHT")
tt:AddLine("Tough Jerky", 1, 1, 1)
tt:Show()
"#,
)
.unwrap();
s.resolve();
measure_all(&mut s, &[("Tough Jerky", 80.0, 14.0)]);
let left = |s: &mut UiScript| s.eval::<Option<f64>>("return TT:GetLeft()").unwrap();
assert_eq!(left(&mut s), Some(140.0));
s.run(
r#"TT:ClearAllPoints()
local other = CreateFrame("Frame"); other:SetWidth(5); other:SetHeight(5)
other:SetPoint("CENTER", 0, 0)"#,
)
.unwrap();
s.resolve();
s.resolve();
assert_eq!(left(&mut s), Some(140.0), "nothing ran the relayout");
s.run(r#"TT:AddLine("Soulbound")"#).unwrap();
s.resolve();
assert_eq!(
left(&mut s),
None,
"the relayout invalidated a plate with no points"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
+42 -12
View File
@@ -189,7 +189,8 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
}]);
model.region_data.insert(left, d);
model.touch_measure(left); // an adopted cell can arrive text-in-hand
model.touch_layout_region(left); // a line row entered the layout graph
// A line row's points are set with the invalidate flag (`0x530197`).
model.invalidate_region(left, true);
}
let left_id = model.region_id(left);
@@ -220,7 +221,7 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
Anchors::from([Anchor::new(Point::Right, left_id, Point::Left, 0.0, 0.0)]);
model.region_data.insert(right, d);
model.touch_measure(right); // an adopted cell can arrive text-in-hand
model.touch_layout_region(right); // a line row entered the layout graph
model.invalidate_region(right, true);
}
let right_id = model.region_id(right);
@@ -377,6 +378,7 @@ pub(in crate::script) fn show_override(lua: &Lua, h: FrameHandle) {
let live = tip_mut(&mut model, h).is_ok_and(|t| t.owner.is_some() && t.num_lines > 0);
if live {
full_alpha(&mut model, h);
owe_relayout(&mut model, h);
}
live
};
@@ -465,11 +467,11 @@ fn apply_anchor(
let owner_id = model.frame_id(owner);
let new = Anchors::from([Anchor::new(own, owner_id, rel, x, y)]);
let input = model.layout_inputs.entry(h).or_default();
// No-op when unchanged, so the bag hover's per-frame SetOwner does not dirty the layout.
if !input.anchors.bits_eq(&new) {
input.anchors = new;
model.touch_layout_frame(h);
}
// No-op when unchanged, so the bag hover's per-frame SetOwner does not dirty the layout;
// a real one is `SetPoint` with the invalidate flag (`0x52ff12`..`0x52ffb2`).
let changed = !input.anchors.bits_eq(&new);
input.anchors = new;
model.invalidate_frame(h, changed);
}
}
@@ -649,9 +651,9 @@ pub(super) fn append_line(
if let Some(d) = model.region_data.get_mut(&lh) {
if d.size != pin {
d.size = pin;
// A size-only write takes the named touch: a conservative one would re-derive the
// whole layout graph on every hover that turns a line between wrapped and plain.
model.touch_layout_region(lh);
// `SetWidth`, which invalidates (`0x7683d0`), named: a conservative touch would
// re-derive the whole layout graph on every hover that turns a line wrapped or plain.
model.invalidate_region(lh, true);
// The wrap pin is the measure key's wrap-width input.
model.touch_measure(lh);
}
@@ -662,9 +664,21 @@ pub(super) fn append_line(
if let Ok(t) = tip_mut(&mut model, h) {
t.num_lines = n;
}
owe_relayout(&mut model, h);
Ok(())
}
/// A line writer or the show ran the relayout (`0x530640`, from `0x52ad59`, `0x52b029`,
/// `0x52e5ae`, `0x52f869`, `0x530615`, `0x530aad`), which does nothing without an owner
/// (`0x530651`) and else ends in an unconditional invalidate (`0x530a52`).
pub(super) fn owe_relayout(model: &mut Model, h: FrameHandle) {
if let Ok(t) = tip_mut(model, h) {
if t.owner.is_some() {
t.relayout_owed = true;
}
}
}
mod verbs;
pub(super) use verbs::install;
@@ -708,9 +722,10 @@ fn cursor_anchor(model: &mut Model, h: FrameHandle) {
);
let units = model.layout_units();
let input = model.layout_inputs.entry(h).or_default();
// `0x767c70`'s change detect: the BOTTOM slot is replaced only on a real change.
// `0x767c70`'s change detect: the BOTTOM slot is replaced, and the plate invalidated
// (`0x530b97`, flag 1), only on a real change.
if input.anchors.set(new, units) {
model.touch_layout_frame(h);
model.invalidate_frame(h, true);
}
}
@@ -733,6 +748,16 @@ pub(super) fn layout_tooltips(model: &mut Model) {
if cursor_mode {
cursor_anchor(model, h);
}
// The relayout's closing invalidate (`0x530a52`), whatever it computed. Its own writes
// below push when they move the plate; past those, a re-resolve gives back the rect the
// plate holds unless that rect is stale, so only then is the invalidate paid here.
let owed = tip_mut(model, h).is_ok_and(|t| std::mem::take(&mut t.relayout_owed));
if owed && model.frame_holds_stale(h) {
model.note_layout_frame_write(h);
if let Some(&id) = model.frame_to_id.get(&h) {
model.layout_pending.push(id);
}
}
let (num, lefts, rights, min_w, pad_w) = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Tooltip(t)) => (
t.num_lines,
@@ -783,6 +808,8 @@ pub(super) fn layout_tooltips(model: &mut Model) {
// that these inputs changed. The size goes through `SetWidth`/`SetHeight` (`0x530a2e`,
// `0x530a39`), which invalidate the tooltip.
model.note_layout_frame_write(h);
// A size the measures moved since the relayout ran: benilla's measures land a tick after
// the line write, where the reference's relayout measured in the call.
if moved {
if let Some(&id) = model.frame_to_id.get(&h) {
model.layout_pending.push(id);
@@ -805,6 +832,9 @@ pub(super) fn layout_tooltips(model: &mut Model) {
.is_some_and(|d| d.anchors.set(new, units));
if stored {
model.note_layout_region_write(rights[i]);
if let Some(&id) = model.region_to_id.get(&rights[i]) {
model.layout_pending.push(id);
}
}
}
}
@@ -151,6 +151,7 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
None => (String::new(), [1.0, 1.0, 1.0, 1.0]),
};
write_cell(&mut model, lh, &(current + &extra), color);
super::owe_relayout(&mut model, h);
Ok(())
})?,
)?;
@@ -242,8 +243,9 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
d.anchors =
Anchors::from([Anchor::new(Point::Right, left_id, Point::Right, 0.0, 0.0)]);
}
model.touch_layout_region(lh); // two line rows entered the layout graph
model.touch_layout_region(rh);
// Two line rows, their points set with the invalidate flag.
model.invalidate_region(lh, true);
model.invalidate_region(rh, true);
let t = tip_mut(&mut model, h)?;
t.left_lines.push(lh);
t.right_lines.push(rh);
@@ -281,6 +281,9 @@ pub struct TooltipState {
pub padding: f32,
/// Line 1 came from XML (`ShoppingTooltipTemplate`), so later lines clone the previous fonts.
pub xml_declared_lines: bool,
/// A relayout (`0x530640`) is owed: Show and the line writers run it with an owner, and it
/// ends by invalidating the plate (`0x530a52`). The layout pre-pass pays it.
pub relayout_owed: bool,
}
/// The plate's text inset: the stock template seats `TextLeft1` at TOPLEFT (10, −10).
+1 -1
View File
@@ -724,7 +724,7 @@ impl WidgetArena {
Some(d)
}
/// Free one region, as `CSimpleHTML::SetText` pool-frees the last parse's blocks (`0x78adc6`)
/// Free one region, as `CSimpleHTML::SetText` deletes the last parse's blocks (`0x78a580`)
/// and a backdrop's teardown its nine pieces (`0x77f4d0`). The caller drops the region's
/// script-model state too, and never frees a region some state holds by handle.
pub fn destroy_region(&mut self, h: RegionHandle) -> bool {