Files
benilla-octo/crates/benilla-ui/src/script/tests/layout_gate.rs
T
SamandClaude Fable 5 b3e2f68c11 the autocast shine stops eating frames, sunken NPCs surface
- the pet bar's autocast shine cost +4.25 ms a frame for one button: its 92 script-side SetPoints forced two whole-roster anchor solves every frame — the shine now draws from a native lane, re-measured at +0.03 ms with what is drawn bit-faithful to before
- an NPC that spawned under its own floor — a cellar wolf, a pet at login — now measures its ground clamp from the server's seat instead of its own last answer, and climbs out
- a running cast cost ~4 ms a frame in layout gate walks; a mutated frame now takes one walk, not three, and the settled interface is pinned at zero

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 17:00:19 +07:00

493 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The layout change gate: a resolve whose inputs are byte-identical to the last converged one is
//! skipped outright (`script::layout::InputFingerprint`).
//!
//! These assert on `Model::layout_solves` — the count of times the fixpoint actually ran — rather
//! than on the rects, because rect equality alone cannot distinguish "the gate skipped" from "the
//! gate re-solved and got the same answer". The value of the gate is precisely the difference.
use super::common::script;
use crate::script::{Model, UiScript};
/// The mutation epoch (tier 1's input): bumped by every write into the layout read set.
fn epoch(s: &UiScript) -> u64 {
s.lua()
.app_data_ref::<Model>()
.expect("model app_data")
.layout_epoch
}
/// How many times the fixpoint has run.
fn solves(s: &UiScript) -> u64 {
s.lua()
.app_data_ref::<Model>()
.expect("model app_data")
.layout_solves
}
/// A frame anchored to the screen, plus a child hanging off it — enough that a move has to
/// propagate, so a wrongly-skipped resolve would be visible in the child too.
fn setup(s: &UiScript) {
s.run(
r#"
parent = CreateFrame("Frame", "Parent", nil)
parent:SetWidth(100); parent:SetHeight(40)
parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10)
child = CreateFrame("Frame", "Child", parent)
child:SetWidth(20); child:SetHeight(20)
child:SetPoint("TOPLEFT", parent, "BOTTOMRIGHT", 0, 0)
"#,
)
.expect("setup");
}
#[test]
fn an_unchanged_resolve_does_not_run_the_fixpoint() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
let after_first = solves(&s);
assert_eq!(after_first, 1, "the first resolve must run");
// Nothing touched in between: every further resolve is a no-op.
for _ in 0..5 {
s.resolve();
}
assert_eq!(
solves(&s),
after_first,
"resolves with identical inputs must be skipped, not re-run"
);
}
#[test]
fn moving_a_frame_reopens_the_gate_and_propagates() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve(); // settle: the gate is now closed
let before = solves(&s);
let child_left = |s: &UiScript| -> f32 { s.eval::<f32>("return Child:GetLeft()").unwrap() };
let first = child_left(&s);
s.run("parent:SetPoint(\"TOPLEFT\", nil, \"TOPLEFT\", 60, -10)")
.expect("move");
s.resolve();
assert_eq!(solves(&s), before + 1, "a SetPoint must reopen the gate");
assert!(
(child_left(&s) - (first + 50.0)).abs() < 0.001,
"the move must propagate to the child: {} -> {}",
first,
child_left(&s)
);
// And it closes again once the move has settled.
let after = solves(&s);
s.resolve();
assert_eq!(
solves(&s),
after,
"the gate must close again after the move"
);
}
/// Resizing the window moves every top-level frame — the gate must never swallow it.
#[test]
fn a_screen_resize_reopens_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
let before = solves(&s);
s.set_screen_size(1024.0, 768.0);
s.resolve();
assert_eq!(
solves(&s),
before + 1,
"a screen-size change must reopen the gate"
);
}
/// A region's own `SetPoint` writes `region_data`, not `layout_inputs` — the half of the read set
/// that is easiest to leave out of a change gate, and the one the region sweep consumes.
#[test]
fn moving_a_region_reopens_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.run(
r#"
tex = parent:CreateTexture(nil, "ARTWORK")
tex:SetWidth(10); tex:SetHeight(10)
tex:SetPoint("TOPLEFT", parent, "TOPLEFT", 0, 0)
"#,
)
.expect("region");
s.resolve();
s.resolve();
let before = solves(&s);
s.run("tex:SetPoint(\"TOPLEFT\", parent, \"TOPLEFT\", 25, 0)")
.expect("move region");
s.resolve();
assert_eq!(
solves(&s),
before + 1,
"a region SetPoint must reopen the gate"
);
}
/// Hiding a frame does NOT move any rect (the client resolves hidden frames too — visibility is an
/// extract-time filter), so the gate is right to stay closed. Pinned deliberately: it is the one
/// place where "nothing to re-solve" is surprising, and a future reader tempted to dirty on
/// show/hide should see that the current model does not need it.
#[test]
fn hiding_a_frame_does_not_reopen_the_gate() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
let before = solves(&s);
s.run("parent:Hide()").expect("hide");
s.resolve();
assert_eq!(
solves(&s),
before,
"visibility does not move rects, so the gate stays closed"
);
}
/// The hover re-enter loop must be FREE while the content is unchanged.
///
/// `ContainerFrameItemButton_OnUpdate` re-runs `OnEnter` every frame while the tooltip is the
/// button's own — faithful, and UNTHROTTLED in 1.12 (its `updateTooltip` throttle is commented
/// out; `BagFrame.xml`'s `BenillaBagSlot_OnUpdate` ships the same loop). So a bag hover clears and
/// rebuilds the SAME tooltip 60×/sec, and the engine must absorb that: identical content re-derives
/// an identical model, so the measure cache re-validates on its content key and the gate stays shut.
///
/// This is the regression gate for the live report (a bag hover cost +10 CPU ms/frame, ~2 full
/// arena solves + a re-shape of every line, every frame): `clear_content` wiped
/// `RegionData::measured` — an invalidation the content-hash key already performs — so every
/// re-enter re-measured every line, and the non-empty measure list forced a second full resolve
/// that the gate could never close over.
///
/// It drives the app's real per-frame order (`ui_script::extract::drive_script`): Lua tick →
/// resolve → measure round-trip → resolve.
#[test]
fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
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:SetSize(40, 40)
tt = CreateFrame("GameTooltip", "TT")
-- One bag-slot OnEnter: the clear (SetOwner) + the content rebuild, verbatim in shape.
function reenter()
TT:SetOwner(Slot, "ANCHOR_RIGHT")
TT:AddLine("Small Shield", 0, 1, 0)
TT:AddDoubleLine("Shield", "Off Hand", 1, 1, 1, 1, 1, 1)
TT:AddLine("85 Armor")
-- A WRAP-flagged line: `clear_content` still drops the wrap-pinned width, which feeds
-- the measure key, so the re-pin at append time has to restore it byte-identically or
-- this line alone re-shapes forever (the real item tooltip's long green "Use:" line).
TT:AddLine("Restores 243 health over 21 sec.", 0, 1, 0, 1)
TT:AddLine("Durability 45 / 45")
TT:Show()
end
"#,
)
.expect("setup");
// The host's font engine: every distinct string has one deterministic size. Counts every
// string it is asked to shape, so the test can assert on shaping work directly.
let sizes: &[(&str, f32, f32)] = &[
("Small Shield", 80.0, 14.0),
("Shield", 50.0, 12.0),
("Off Hand", 45.0, 12.0),
("85 Armor", 60.0, 12.0),
("Restores 243 health over 21 sec.", 118.0, 24.0),
("Durability 45 / 45", 96.0, 12.0),
];
// One frame of `drive_script`: tick (the re-enter) → resolve → measure round-trip → resolve.
// Returns how many strings the font engine was asked to shape this frame.
let frame = |s: &mut UiScript| -> usize {
s.run("reenter()").expect("re-enter");
s.resolve();
let reqs = s.fontstrings_needing_measure();
let shaped = reqs.len();
if !reqs.is_empty() {
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| {
let (w, h) = sizes
.iter()
.find(|(t, _, _)| *t == r.text)
.map(|&(_, w, h)| (w, h))
.unwrap_or_else(|| panic!("unexpected string measured: {:?}", r.text));
(r.id, w, h, r.key)
})
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
}
shaped
};
// Settle: the first frames legitimately shape the five strings and re-solve as the auto-size
// pre-pass converges on the fresh measures.
for _ in 0..4 {
frame(&mut s);
}
// Steady state: the hover is parked on an item whose tooltip has not changed.
let solves_before = solves(&s);
let mut shaped = 0;
for _ in 0..10 {
shaped += frame(&mut s);
}
assert_eq!(
shaped, 0,
"a re-enter with identical content must re-shape NO text: the measure cache keys on \
content, so rebuilding the same lines re-validates it"
);
assert_eq!(
solves(&s),
solves_before,
"a re-enter with identical content must not reopen the layout gate"
);
// …and it must not even reach tier 2. Tier 1 (the mutation epoch) is what makes a quiet frame
// FREE; tier 2 still hashes the whole UI's read set to decide "quiet" (~0.65 ms/frame measured
// with `WOW_UI_COST=1` on `WOW_CAPTURE=ui-tooltip`, at solves=0). The re-enter loop used to
// pay that on every single hover frame, because `clear_content` wiped each line's wrap pin and
// `append_line` re-pinned the same width — a round trip inside one frame, two epoch bumps.
// Idempotent content must be epoch-silent, not merely fingerprint-absorbed.
let epoch_before = epoch(&s);
for _ in 0..10 {
frame(&mut s);
}
assert_eq!(
epoch(&s),
epoch_before,
"a re-enter with identical content must not touch the layout epoch at all — tier 1 has to \
hold, or every hover frame pays the whole-UI fingerprint"
);
// The gate is shut because nothing MOVED — not because the tooltip went stale. Changed content
// must still re-measure exactly its own line, and a measure that resizes the auto-sized plate
// (this one overtakes the double line as the widest) must reopen the gate.
s.run(
r#"function reenter()
TT:SetOwner(Slot, "ANCHOR_RIGHT")
TT:AddLine("Small Shield", 0, 1, 0)
TT:AddDoubleLine("Shield", "Off Hand", 1, 1, 1, 1, 1, 1)
TT:AddLine("85 Armor")
TT:AddLine("Restores 243 health over 21 sec.", 0, 1, 0, 1)
TT:AddLine("Durability 44 / 45")
TT:Show()
end"#,
)
.expect("damage the shield");
// Wider than the 120 the double line contributes, so the plate itself has to grow.
let sizes: &[(&str, f32, f32)] = &[("Durability 44 / 45", 150.0, 12.0)];
s.run("reenter()").expect("re-enter");
s.resolve();
let reqs = s.fontstrings_needing_measure();
assert_eq!(
reqs.len(),
1,
"only the line whose text changed re-measures, got {:?}",
reqs.iter().map(|r| &r.text).collect::<Vec<_>>()
);
assert_eq!(reqs[0].text, "Durability 44 / 45");
let answers: Vec<(u32, f32, f32, u64)> = reqs
.iter()
.map(|r| {
let (w, h) = sizes
.iter()
.find(|(t, _, _)| *t == r.text)
.map(|&(_, w, h)| (w, h))
.expect("known string");
(r.id, w, h, r.key)
})
.collect();
s.set_measured_text_unwrapped(&answers);
s.resolve();
assert!(
solves(&s) > solves_before,
"changed content must reopen the gate"
);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Tier 1 of the gate (decision 0740): the mutation epoch. **Any CONVERGED resolve closes it**
/// (decision 1385) — the fingerprint is hashed over inputs alone, so a solve cannot outgrow the
/// value it stores — and from then on a quiet frame skips at a `u64` compare without computing
/// the fingerprint at all.
fn tier_one_closed(s: &UiScript) -> bool {
let m = s.lua().app_data_ref::<Model>().expect("model app_data");
m.layout_epoch_resolved == Some(m.layout_epoch)
}
/// **The castbar law** (decision 1385, ledger B283): a region that MOVES every frame — the classic
/// OnUpdate animation idiom, our own `CastingBarSpark:SetPoint`, and every addon that slides a
/// texture — must cost exactly **one** let-through resolve per frame.
///
/// It used to cost three. The fingerprint was hashed over the 0294 SEEDS as well as the inputs, so
/// a solve necessarily outgrew the fingerprint it had just stored: neither that pass nor the
/// settling pass behind it could close tier 1, and the frame paid solve + settle + skip — three
/// whole-roster walks at ~1.0–1.4 ms each. Measured live at the Stormwind pin: **+4.2 ms of CPU
/// per frame for one 32×32 spark**, on a default UI with no addons loaded.
///
/// This is the regression guard for that whole bug class. `solves` is the honest counter — a
/// wasted walk that concludes "nothing moved" still costs the full preamble — so the assertion is
/// on the count, never on a duration (0735: milliseconds are not evidence of a scope regression).
#[test]
fn a_region_moving_every_frame_costs_exactly_one_solve_per_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.run(
r#"
spark = parent:CreateTexture(nil, "OVERLAY")
spark:SetWidth(32); spark:SetHeight(32)
spark:SetPoint("CENTER", parent, "LEFT", 0, 2)
"#,
)
.expect("spark");
s.resolve();
assert!(tier_one_closed(&s), "the setup must settle in one resolve");
// Ten frames of the castbar's own inner loop: one region re-pointed to a NEW offset, then the
// host's per-frame resolve. Nothing else in the model moves.
//
// The offsets start at 1.7, not 0 — a `frame * 1.7` sequence opens with a re-write of the
// seed value, which the setters' bit-exact compare absorbs (no epoch bump, no solve at all).
// That is the *right* answer and `an_idempotent_setter_call_leaves_tier_one_closed` pins it;
// here it would have measured the guard instead of the gate.
for frame in 0..10 {
let before = solves(&s);
s.run(&format!(
r#"spark:SetPoint("CENTER", parent, "LEFT", {}, 2)"#,
f64::from(frame + 1) * 1.7
))
.expect("spark move");
s.resolve();
assert_eq!(
solves(&s) - before,
1,
"frame {frame}: a single moving region must cost ONE solve, not the \
solve+settle+skip trio the seeds-in-the-fingerprint law used to force"
);
assert!(
tier_one_closed(&s),
"frame {frame}: the converged solve must close tier 1, so a second getter in the \
same tick skips at the u64 compare instead of re-walking the whole roster"
);
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The other half of the same law: once it has settled, an untouched frame costs NOTHING — no
/// solve, and (tier 1) not even the fingerprint walk that would decide so.
#[test]
fn a_quiet_frame_after_a_move_costs_no_solve_at_all() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.run(r#"parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 33, -44)"#)
.expect("move");
s.resolve();
let settled = solves(&s);
for _ in 0..5 {
s.resolve();
assert_eq!(solves(&s), settled, "a quiet frame must not solve");
assert!(tier_one_closed(&s), "and must not reopen tier 1");
}
}
#[test]
fn a_settled_resolve_closes_tier_one_and_a_real_write_reopens_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
// ONE resolve closes the epoch (decision 1385): the fingerprint is hashed over inputs alone,
// and the rounds just drove those inputs to their fixpoint, so there is nothing left for a
// settling pass to discover. This used to need two.
s.resolve();
assert!(
tier_one_closed(&s),
"a converged resolve must close the epoch by itself"
);
s.run("parent:SetWidth(150)").expect("resize");
assert!(
!tier_one_closed(&s),
"a real layout write must reopen tier 1"
);
s.resolve();
assert!(tier_one_closed(&s), "and one resolve closes it again");
}
#[test]
fn an_idempotent_setter_call_leaves_tier_one_closed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.resolve();
assert!(tier_one_closed(&s));
// The classic per-frame OnUpdate idiom: re-assert the exact same geometry every frame. The
// setters' compare-before-write keeps the epoch untouched, so an idle UI never re-enters
// the fingerprint path at all.
s.run(
r#"
parent:SetWidth(100); parent:SetHeight(40)
parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 10, -10)
child:SetPoint("TOPLEFT", parent, "BOTTOMRIGHT", 0, 0)
"#,
)
.expect("idempotent re-set");
assert!(
tier_one_closed(&s),
"value-identical setter calls must not dirty the epoch"
);
let before = solves(&s);
s.resolve();
assert_eq!(solves(&s), before, "and the resolve stays skipped");
}
#[test]
fn a_paint_only_write_leaves_tier_one_closed() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
// A paint region with NO anchors: its region_data entry is created by a paint setter and
// must stay invisible to the layout gate (the resolve sweep skips anchor-less entries).
s.run(r#"tex = parent:CreateTexture(nil, "ARTWORK")"#)
.expect("region");
s.resolve();
s.resolve();
assert!(tier_one_closed(&s));
s.run(r#"tex:SetTexture(1, 0, 0)"#).expect("paint");
assert!(
tier_one_closed(&s),
"creating/painting an anchor-less region is not a layout change"
);
let before = solves(&s);
s.resolve();
assert_eq!(solves(&s), before);
}