diff --git a/crates/benilla-app/src/ui_script/resolve_bench.rs b/crates/benilla-app/src/ui_script/resolve_bench.rs index c8dce186..5dda4de7 100644 --- a/crates/benilla-app/src/ui_script/resolve_bench.rs +++ b/crates/benilla-app/src/ui_script/resolve_bench.rs @@ -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::("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::("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=:` names /// the writer on a live run (`docs/CONTRIBUTING.md`, "Running it unattended"). diff --git a/crates/benilla-ui/src/layout.rs b/crates/benilla-ui/src/layout.rs index 2183555d..2b0c5e2e 100644 --- a/crates/benilla-ui/src/layout.rs +++ b/crates/benilla-ui/src/layout.rs @@ -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 { + 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, 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| 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( diff --git a/crates/benilla-ui/src/script/object/layout_methods.rs b/crates/benilla-ui/src/script/object/layout_methods.rs index f4df294b..f9dc7eac 100644 --- a/crates/benilla-ui/src/script/object/layout_methods.rs +++ b/crates/benilla-ui/src/script/object/layout_methods.rs @@ -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::().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::().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::().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::().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