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:
@@ -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").
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user