layout: a size the author set is not a question for the solver

`GetWidth`/`GetHeight` answer against current layout, so they resolve first — and
a resolve that follows any `CreateFrame` re-derives the whole layout graph, because
minting a frame id drops the cached one (`Model::frame_id` → `touch_layout`). A UI
load is nothing but creation interleaved with reads, so the player's interface paid
**839 whole-roster derivations in one load, 2.84 ms each: 2.4 s**, against 650
`GetWidth` calls. `WOW_LAYOUT_DERIVE_TRACE` named the giver-up: every sampled one
was `create_frame`.

Most of those reads cannot learn anything from a resolve. An edge takes its anchor
scan first and only then `combineEdge(center, opposite, size · scale)`
(`resolve_left`, `resolve_right`), so an authored size IS the span unless the
anchors pin **both** of that axis's edges. `LayoutInput::authored_span` is that
rule, written beside the solver whose precedence it mirrors, and it refuses the two
cases where only the solver knows: both edges anchored, and a clamped frame, whose
rect the screen can squeeze.

Measured on the player's own 91 addons through the harness's `--together` load,
three passes each:

```
derives per load      839  →  235
preamble per load   2,414  →  560 ms
frame:GetWidth      686 µs →  60 µs per call  (1,241 ms → 118 ms over three loads)
```

**1.85 seconds off every login and every `/reload`**, and the steady state gains
the same way: an addon reading a size it set in an `OnUpdate` no longer walks the
roster for it.

Proven as well as measured: six UI captures (character, bag, merchant, quest, quest
log, tooltip) are **pixel-identical** before and after, MAE 0.000, max 0; the
layout suite passes under `WOW_LAYOUT_VERIFY`, which re-derives every frame from
scratch and demands the same rects; and `resolve_bench` now asserts both halves —
an authored, singly-anchored span answers with no gate walk, while one whose
anchors pin both edges still forces the resolve and reports the anchors' distance,
not the authored number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-18 18:51:45 -06:00
co-authored by Claude Opus 5
parent c5911ccd6f
commit 0fe0a99d8a
3 changed files with 183 additions and 6 deletions
@@ -167,6 +167,62 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
);
}
/// `GetWidth` on an authored, singly-anchored frame answers **without a resolve**, and the same
/// read on a frame whose anchors pin both edges still pays for one.
///
/// This is the load's dominant cost in miniature. A geometry read resolves against current
/// layout, and a resolve that follows any `CreateFrame` re-derives the whole graph — the player's
/// interface paid 839 of those in one load, 2.84 ms each, against 650 `GetWidth` calls. Most of
/// those reads were of a size the addon had just set, which the solver could only compute back
/// ([`benilla_ui::layout::LayoutInput::authored_span`]). With the shortcut the same load derives
/// 235 times, 2,414 ms of preamble down to 560 ms.
#[test]
fn an_authored_span_is_read_without_resolving_and_a_derived_one_is_not() {
benilla_formats::wow_data_or_skip!();
let mut s = settled_default_ui();
s.run(
r#"
Authored = CreateFrame("Frame", "ProbeAuthored", UIParent)
Authored:SetPoint("TOPLEFT", UIParent, "TOPLEFT", 10, -10)
Authored:SetWidth(123); Authored:SetHeight(45)
Derived = CreateFrame("Frame", "ProbeDerived", UIParent)
Derived:SetPoint("LEFT", UIParent, "LEFT", 0, 0)
Derived:SetPoint("RIGHT", UIParent, "LEFT", 200, 0)
Derived:SetWidth(123); Derived:SetHeight(20)
"#,
)
.unwrap();
app_frame(&mut s);
// A creation since the last resolve is what drops the cached graph, so this is the state a
// load is in at every read.
s.run(r#"Churn = CreateFrame("Frame", "ProbeChurn", UIParent)"#)
.unwrap();
let before = s.layout_gate_walks();
assert_eq!(
s.eval::<f32>("return Authored:GetWidth()").unwrap(),
123.0,
"the authored width is what the solver would compute back"
);
assert_eq!(
s.layout_gate_walks(),
before,
"reading an authored, unclamped, singly-anchored span must not walk the roster"
);
// The anchors pin both edges, so only the solver knows the span: this one pays.
let before = s.layout_gate_walks();
assert_eq!(
s.eval::<f32>("return Derived:GetWidth()").unwrap(),
200.0,
"both edges anchored: the span is the anchors' distance, not the authored 123"
);
assert!(
s.layout_gate_walks() > before,
"a span only the solver can give must still force the resolve"
);
}
/// With nothing happening, the settled shipped UI costs zero gate walks per frame: this catches a
/// shipped handler writing a layout input every frame. `WOW_LAYOUT_TOUCH_TRACE=<secs>:<n>` names
/// the writer on a live run (`docs/CONTRIBUTING.md`, "Running it unattended").
+101
View File
@@ -176,6 +176,42 @@ impl Default for LayoutInput {
}
impl LayoutInput {
/// The span this axis reports **without resolving anything**, or `None` when only the solver
/// can say.
///
/// `GetWidth`/`GetHeight` answer against current layout, so they resolve first, and during a
/// UI load that is the dominant cost in the whole client: every frame an addon creates drops
/// the cached layout graph, so the next geometry read re-derives it over every frame and
/// region there is. The player's own interface measured **839 of those derives in one load,
/// 2.84 ms each — 2.4 s**, against 650 `GetWidth` calls.
///
/// Most of those reads cannot learn anything from a resolve. An axis is authored (`width`
/// non-zero) and unclamped, so the solver would compute the same number back: an edge takes
/// its anchor scan first and only then `combineEdge(center, opposite, size · scale)`
/// ([`resolve_left`], [`resolve_right`]), so the authored size IS the span unless anchors pin
/// **both** of the axis's edges — the one case this refuses, along with a clamped frame, whose
/// rect the screen can squeeze.
///
/// The value is in the frame's own units, as the getters report: the solver multiplies the
/// span by the scale and the getter divides it back out.
pub fn authored_span(&self, width: bool) -> Option<f32> {
let size = if width { self.width } else { self.height };
if size == 0.0 || self.clamp {
return None;
}
let (lo, hi) = if width {
(IDS_LEFT, IDS_RIGHT)
} else {
(IDS_BOTTOM, IDS_TOP)
};
let pinned = |ids: [usize; 3]| {
self.anchors
.iter()
.any(|a| ids.contains(&(a.point.id() as usize)))
};
(!(pinned(lo) && pinned(hi))).then_some(size)
}
/// A frame with the given anchors and explicit size, scale `1.0`, no clamp.
pub fn sized(anchors: Vec<Anchor>, width: f32, height: f32) -> LayoutInput {
LayoutInput {
@@ -997,6 +1033,71 @@ mod tests {
assert_rect(got, Rect::new(30.0, 10.0, 590.0, 780.0));
}
/// The getters' shortcut must refuse exactly what the solver would decide for itself: an edge
/// takes its anchor scan before the authored span, so a span is the author's only while the
/// anchors leave one of the axis's edges free.
#[test]
fn an_authored_span_is_the_answer_only_when_the_anchors_leave_an_edge_free() {
let at = |p: Point| Anchor {
point: p,
relative_to: 0,
relative_point: p,
x_off: 0.0,
y_off: 0.0,
};
let input = |anchors: Vec<Anchor>| LayoutInput {
anchors,
width: 120.0,
height: 40.0,
..LayoutInput::default()
};
// One edge pinned: the other comes from the authored span.
assert_eq!(
input(vec![at(Point::Left)]).authored_span(true),
Some(120.0)
);
assert_eq!(
input(vec![at(Point::TopLeft)]).authored_span(true),
Some(120.0)
);
assert_eq!(
input(vec![at(Point::Center)]).authored_span(true),
Some(120.0)
);
assert_eq!(input(vec![at(Point::Top)]).authored_span(false), Some(40.0));
// Both edges pinned: the span is the anchors' distance, whatever was authored.
assert_eq!(
input(vec![at(Point::Left), at(Point::Right)]).authored_span(true),
None
);
assert_eq!(
input(vec![at(Point::TopLeft), at(Point::BottomRight)]).authored_span(true),
None,
"corner points pin an edge on both axes"
);
assert_eq!(
input(vec![at(Point::TopLeft), at(Point::BottomRight)]).authored_span(false),
None
);
// The other axis of the same pair is still free.
assert_eq!(
input(vec![at(Point::Left), at(Point::Right)]).authored_span(false),
Some(40.0)
);
// No authored size: the axis derives.
let derived = LayoutInput {
anchors: vec![at(Point::Left)],
..LayoutInput::default()
};
assert_eq!(derived.authored_span(true), None);
// Clamped: the screen can squeeze the rect, so only the solver knows.
let clamped = LayoutInput {
clamp: true,
..input(vec![at(Point::Left)])
};
assert_eq!(clamped.authored_span(true), None);
}
#[test]
fn center_anchor_derives_edges() {
let f = LayoutInput::sized(
@@ -170,15 +170,11 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// No `SetSize`: neither 1.12 method table has it.
set_shared(lua, m, Side::Frame, "GetWidth", |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, true))
Ok(span_read(lua, h, true))
})?;
set_shared(lua, m, Side::Frame, "GetHeight", |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
Ok(size_read(&model, h, false))
Ok(span_read(lua, h, false))
})?;
// GetCenter(): the resolved rect's centre in the frame's own units (screen ÷ effective scale,
@@ -237,6 +233,30 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
Ok(())
}
/// `GetWidth`/`GetHeight`: the authored span when the solver could only compute it back
/// ([`LayoutInput::authored_span`]), else a resolve and the resolved span.
///
/// The shortcut is why it is worth a function: a read with no answer of its own forces a resolve,
/// and during a load a resolve re-derives the whole layout graph, because every frame an addon
/// created since the last one dropped the cached one. The player's interface pays 839 of those in
/// one load against 650 `GetWidth` calls, and an authored, unclamped axis — which most of them
/// are — can be answered from the write that set it.
fn span_read(lua: &Lua, h: FrameHandle, width: bool) -> f32 {
{
let model = lua.app_data_ref::<Model>().expect("model");
if let Some(span) = model
.layout_inputs
.get(&h)
.and_then(|i| i.authored_span(width))
{
return span;
}
}
settle(lua);
let model = lua.app_data_ref::<Model>().expect("model");
size_read(&model, h, width)
}
/// Resolve the layout now if anything moved, as the reference answers a geometry query against
/// current layout; a settled tree costs one epoch compare. Deviation: `OnSizeChanged` fires at the
/// next `UiScript::resolve`, a tick after the reference's (`ApplyRect 0x76b580`), because running