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>
This commit is contained in:
Sam
2026-08-17 17:00:19 +07:00
co-authored by Claude Fable 5
parent 81b5ed59cb
commit b3e2f68c11
28 changed files with 1654 additions and 562 deletions
+136 -23
View File
@@ -297,6 +297,95 @@ fn layout_verify_enabled() -> bool {
*ON.get_or_init(|| cfg!(test) || std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1"))
}
/// `WOW_LAYOUT_PROF=1`'s **preamble** split — the `[layout-pre]` line, one per LET-THROUGH resolve.
///
/// `[layout-prof]` reports the rounds, which 1350 already made proportional to what moved. This
/// reports everything *before* them: the fixed whole-roster work every let-through solve pays
/// whatever moved, which 1383 priced at ~1.9 ms/solve at a live roster and named as the next cut
/// without ever splitting it. A cut needs to know which of the eight walks it is buying back, so
/// the split is the instrument that has to exist first.
///
/// Off (the default) it takes no clock reads at all — [`Self::lap`] returns on the `on` test, so an
/// unprofiled resolve pays one bool check per phase.
#[derive(Default)]
struct PreambleProf {
on: bool,
last: Option<std::time::Instant>,
/// The GameTooltip auto-size pre-pass (`layout_tooltips`) — a whole-frame-roster filter.
tooltip: u128,
/// `OnSizeChanged`'s "before" snapshot — a whole-`scripts`-map filter.
watched: u128,
/// The ids rebuild + the per-frame scale/clamp sync into `layout_inputs`.
ids: u128,
/// The ScrollFrame override map — another whole-frame-roster walk.
scroll: u128,
/// `region_resolved`'s liveness retain.
retain: u128,
/// The per-call solve plan rebuild.
plan: u128,
/// `LayoutScope::begin` — seven per-id `Vec`s cleared/resized.
begin: u128,
/// The fingerprint + scope walk over every live FRAME (double-hashed: aggregate + per-node).
fp_frames: u128,
/// The same over every anchored REGION — the biggest roster (10,438 anchored at the SW pin,
/// and by far the biggest single phase: 0.45–1.0 ms of the ~1.0–1.4 ms walk).
fp_regions: u128,
}
impl PreambleProf {
fn new(on: bool) -> Self {
Self {
on,
last: on.then(std::time::Instant::now),
..Default::default()
}
}
/// Microseconds since the previous lap (0 when off).
fn lap(&mut self) -> u128 {
if !self.on {
return 0;
}
let now = std::time::Instant::now();
self.last
.replace(now)
.map_or(0, |t| now.duration_since(t).as_micros())
}
/// One line per let-through resolve, printed once the gate's verdict is known — `skips=1` is
/// the walk that concluded "nothing moved" and returns without solving, which costs the same
/// preamble as one that does.
fn report(&self, skips: bool, frames: usize, regions: usize) {
if !self.on {
return;
}
let total = self.tooltip
+ self.watched
+ self.ids
+ self.scroll
+ self.retain
+ self.plan
+ self.begin
+ self.fp_frames
+ self.fp_regions;
eprintln!(
"[layout-pre] skips={} frames={frames} anchored={regions} total_us={total} \
tooltip={} watched={} ids={} scroll={} retain={} plan={} begin={} \
fp_frames={} fp_regions={}",
u8::from(skips),
self.tooltip,
self.watched,
self.ids,
self.scroll,
self.retain,
self.plan,
self.begin,
self.fp_frames,
self.fp_regions
);
}
}
/// `OnSizeChanged`'s "after": turn the entry-vs-now diff of the watched frames into queued
/// `(id, width, height)` fires ([`Model::pending_size_changed`]).
///
@@ -354,10 +443,18 @@ impl UiScript {
return;
}
let epoch_at_entry = model.layout_epoch;
// Past tier 1 ⇒ this call pays the whole-roster preamble, whatever the fingerprint goes on
// to decide. Counted here rather than at the solve, because the walk IS the cost (1385).
// Not gated on `verify`: a verify build re-walks skipped resolves and must say so.
model.layout_gate_walks = model.layout_gate_walks.wrapping_add(1);
// `WOW_LAYOUT_PROF=1`'s preamble split — see [`PreambleProf`]. Started here, so the clock
// covers everything the tier-1 gate above did NOT skip.
let mut pre = PreambleProf::new(layout_prof_enabled());
// The GameTooltip auto-size + right-flush pre-pass (decision 0274): writes tooltip frame
// sizes + right-column anchor offsets from the measure round-trip's cached extents, so
// the graph below solves them like any other frame.
super::tooltip::layout_tooltips(model);
pre.tooltip = pre.lap();
// ── `OnSizeChanged`'s "before" ───────────────────────────────────────────────────────
// The client fires it from `ApplyRect 0x76b580`, per rect application. Ours is a batch
// fixpoint, so the faithful *mechanism* — "this frame's resolved size moved" — is the
@@ -374,6 +471,7 @@ impl UiScript {
.filter(|(_, kinds)| kinds.contains("OnSizeChanged"))
.map(|(&h, _)| (h, model.resolved.get(&h).copied()))
.collect();
pre.watched = pre.lap();
let Model {
arena,
layout_inputs,
@@ -423,6 +521,7 @@ impl UiScript {
input.extent_y = screen.top;
}
}
pre.ids = pre.lap();
// The ScrollFrame mechanism (decision 0112): a live ScrollFrame with a live scroll child
// overrides the child's own anchors for this solve — `SetScrollChild` pins the child TOPLEFT
@@ -453,6 +552,7 @@ impl UiScript {
Anchor::new(Point::TopLeft, id, Point::TopLeft, 0.0, state.vertical),
);
}
pre.scroll = pre.lap();
// Alternating frame/region rounds, run to a CHANGE-DRIVEN FIXPOINT: the real client
// resolves ONE layout graph in which frames and regions anchor each other freely (the
@@ -480,6 +580,7 @@ impl UiScript {
arena.region(*rh).is_some()
&& region_data.get(rh).is_some_and(|d| !d.anchors.is_empty())
});
pre.retain = pre.lap();
// The per-frame solve plan, resolved ONCE per call rather than per round: each live
// frame's id, a borrow of its (already scale/clamp-synced) layout input, and its
// ScrollFrame anchor override if it is a scroll child. The round loop below then walks a
@@ -492,6 +593,7 @@ impl UiScript {
.map(|input| (h, id, input, scroll_child_anchor.get(&h).copied()))
})
.collect();
pre.plan = pre.lap();
// ── The change gate ───────────────────────────────────────────────────────────────────
// Fingerprint exactly what the rounds below read, and skip them outright when nothing has
@@ -507,14 +609,20 @@ impl UiScript {
// which together distil `frame_to_id`, frame liveness,
// `effective_scale`, `clamped_to_screen`, and the
// ScrollFrame child + vertical offset;
// * `id_to_region` × `region_resolved` — the region rects seeded as externals;
// * `region_data` — each region's anchors, explicit size, and measured
// text extent;
// * `arena.region(rh)` — liveness and owner (a dead region drops out of the
// sweep; the owner supplies the fallback edges).
// `resolved` / `region_resolved` are the previous pass's OUTPUT carried forward as the
// 0294 seed; at convergence seed and output are equal, so re-running against an unchanged
// input set is guaranteed to reproduce them — which is precisely what makes the skip safe.
//
// **INPUTS ONLY — the 0294 seeds are deliberately NOT hashed** (decision 1385). They are
// the previous pass's OUTPUT, and 0294's own property ("every rect is recomputed purely
// from (inputs, externals), never from its own prior value") makes a converged pass's
// rects a pure function of the inputs: the seeds change how many ROUNDS convergence
// takes, never what it converges to. Hashing them therefore added no verdict the input
// half does not already give — and it cost two extra whole-roster walks per mutated
// frame, because a solve necessarily outgrows the seeds it hashed, so neither that pass
// nor the settling pass behind it could close tier 1. (`resolved`, the FRAME rects, was
// never in the read set at all — the seed half was asymmetric as well as redundant.)
//
// ── …and, in the same walk, the SCOPE (decision 1350) ─────────────────────────────────
// Each node's own inputs are hashed a second time on their own, into
@@ -526,6 +634,7 @@ impl UiScript {
// Sized past `next_id` because the region walk below MINTS ids (see there) — every id it
// can mint is one more than the anchored regions it walks.
scope.begin(*next_id as usize + region_data.len() + 1);
pre.begin = pre.lap();
let mut fp = InputFingerprint::default();
fp.rect(*screen);
fp.feed(plan.len() as u64);
@@ -560,6 +669,7 @@ impl UiScript {
}
}
}
pre.fp_frames = pre.lap();
let mut fed_regions = 0u64;
// The round loop's region roster, built in this same walk (decision 1350) — see
// [`RegionNode`]. Only LIVE, anchored regions: an anchor-less one is invisible to the
@@ -656,13 +766,9 @@ impl UiScript {
// The count of FED entries (not the map's len — see the vacuous-entry skip above), so an
// entry leaving the anchored set is a change even if a sibling enters the same frame.
fp.feed(fed_regions);
for (&id, rh) in id_to_region.iter() {
if let Some(r) = region_resolved.get(rh) {
fp.feed(u64::from(id));
fp.rect(*r);
}
}
pre.fp_regions = pre.lap();
let gate_skips = *layout_fingerprint == Some(fp);
pre.report(gate_skips, plan.len(), regions.len());
// The tier-1/tier-2 cross-check (only reachable under verify when tier 1 judged quiet):
// the epoch said nothing layout-visible was written, so the fingerprint must agree — a
// divergence is a mutation path missing its `Model::touch_layout()`, named here at the
@@ -674,12 +780,11 @@ impl UiScript {
fingerprint moved — a layout write path is missing its touch_layout()"
);
}
// Tier 1 closes ONLY on a settled frame (the fingerprint verdict `gate_skips`): the fp is
// hashed over the SEEDS (last pass's outputs), so the first resolve after a real change
// stores a fingerprint the very next, mutation-free resolve would not reproduce — the
// seeds grew under it. That next resolve pays one fingerprint (exactly today's price),
// proves the input set has settled, and closes the epoch here; every later quiet frame
// skips at the u64 compare.
// Nothing in the read set moved since the last CONVERGED resolve, so the rects it left are
// still this frame's answer: close tier 1 and skip. (A converged solve closes it too — see
// the convergence arm below. This arm is the one that absorbs an epoch bump whose write
// changed no input: an id mint, an anchor-less region's creation, a re-`SetPoint` the
// setters' own compare let through.)
if gate_skips {
*layout_epoch_resolved = Some(epoch_at_entry);
if !verify {
@@ -1026,13 +1131,21 @@ impl UiScript {
// settled, and seeding the next pass's "unchanged ⇒ unmoved" argument from it
// would be seeding it from a lie.
scope.commit(scope_nodes, *screen);
// Tier 1 closes only on a `gate_skips` frame: a real solve (`!gate_skips`) hashed
// `fp` over now-outgrown seeds — see the gate above. The re-store here is for the
// VERIFY path, whose full re-run of a settled frame passed through the state
// clears; production returned at the gate. Either mode leaves the same state.
if gate_skips {
*layout_epoch_resolved = Some(epoch_at_entry);
}
// **A CONVERGED SOLVE CLOSES TIER 1** (decision 1385) — unconditionally, not only
// on the `gate_skips` re-run. The fingerprint above is hashed over INPUTS alone,
// and the rounds just drove those inputs to their fixpoint, so the fp stored on
// the line above is exactly the one the next mutation-free resolve recomputes:
// there is nothing left for a settling pass to discover. Closing here is what
// makes a per-frame layout write cost ONE whole-roster walk instead of three
// (solve, settle, skip) — the castbar's spark, and every addon that animates a
// region, used to pay all three every frame.
//
// `epoch_at_entry` (not the live epoch) keeps it conservative: anything that
// touched the layout WHILE this pass ran — the tooltip pre-pass, a lazily minted
// region — leaves the epoch ahead of the stored value, so tier 1 re-opens next
// call exactly as it should. The cycle-bail path below never reaches here, so a
// graph left mid-flight still re-resolves.
*layout_epoch_resolved = Some(epoch_at_entry);
queue_size_changes(&watched, resolved, frame_to_id, pending_size_changed);
return;
}
+7 -4
View File
@@ -86,11 +86,14 @@ pub(super) fn ensure_measured(lua: &Lua, rh: RegionHandle) {
key: req.key,
};
if let Some(d) = model.region_data.get_mut(&rh) {
if d.measured != Some(new) {
d.measured = Some(new);
let moved = super::types::MeasuredText::layout_moved(d.measured, new);
// The KEY always lands — this IS the measure cache, and a stale key re-requests forever.
d.measured = Some(new);
if moved {
// Measured extents are the auto-size axes' inputs — the layout gate's read set, the
// same touch `set_measured_text` does. Guarded on a real change so a steady poll does
// not dirty the layout every frame.
// same touch `set_measured_text` does. Guarded on a change the LAYOUT can see: a
// re-measure that returns the same box (a same-width countdown tick) must not open
// tier 1, or it costs a whole-roster hash to conclude nothing moved (decision 1385).
model.touch_layout();
}
}
+11 -1
View File
@@ -906,7 +906,9 @@ impl UiScript {
natural_w,
key,
};
changed |= d.measured != Some(new);
// The KEY always lands (or the region re-requests its own measure forever); the
// EPOCH moves only if the laid-out extent did — see `MeasuredText::layout_moved`.
changed |= MeasuredText::layout_moved(d.measured, new);
d.measured = Some(new);
}
}
@@ -1082,6 +1084,14 @@ impl UiScript {
self.model_ref().layout_solves
}
/// How many resolves got past **tier 1** and paid the whole-roster preamble
/// ([`Model::layout_gate_walks`], decision 1385) — the gate's true cost counter, ≥
/// [`Self::layout_solves`] because a walk that concludes "nothing moved" pays the same
/// preamble and never reaches the solve counter.
pub fn layout_gate_walks(&self) -> u64 {
self.model_ref().layout_gate_walks
}
/// Total fixpoint ROUNDS across every solve ([`Model::layout_rounds`]) — a solve costs
/// rounds × the whole graph, so the ratio against [`Self::layout_solves`] is the per-pass
/// depth.
+30 -5
View File
@@ -89,11 +89,16 @@ pub(crate) struct Model {
/// that MOVED rather than the whole graph (decision 1350; see `script::layout::LayoutScope`).
/// Tiers 1 and 2 above decide *whether* to solve; this decides *what*.
pub(crate) layout_scope: super::layout::LayoutScope,
/// The [`Self::layout_epoch`] value the last SETTLED resolve closed on — one whose
/// fingerprint matched, proving the input set (seeds included) has stopped moving. `None`
/// forces the next resolve through tier 1: the initial state, a cycle-bailed pass, and every
/// real solve (whose stored fingerprint answers for now-outgrown seeds — layout.rs, "Tier 1
/// closes ONLY on a settled frame").
/// The [`Self::layout_epoch`] value the last CONVERGED resolve closed on. `None` forces the
/// next resolve through tier 1: the initial state, a cycle-bailed pass, and
/// [`super::UiScript::force_full_layout_resolve`].
///
/// **Every converged resolve closes tier 1** (decision 1385) — a real solve as much as a
/// skipping one. It could not, while the fingerprint was hashed over the 0294 seeds as well
/// as the inputs: a solve outgrew the value it had just stored, so a mutated frame paid three
/// whole-roster walks (solve, settle, skip) instead of one. Hashing inputs alone makes the
/// stored fingerprint exactly the one the next mutation-free resolve recomputes, so a
/// per-frame layout write — the castbar's spark, any addon that animates a region — costs one.
pub(crate) layout_epoch_resolved: Option<u64>,
/// How many times the change gate has LET A RESOLVE THROUGH. `UiScript::resolve` is called
/// unconditionally every frame, so the gap between call count and this is the gate's whole
@@ -101,6 +106,25 @@ pub(crate) struct Model {
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
pub(crate) layout_solves: u64,
/// How many times a resolve got **past tier 1** — the count of whole-roster preamble WALKS
/// (decision 1385). This, not [`Self::layout_solves`], is the honest cost counter for the
/// gate: a walk that ends in `gate_skips` still rebuilt the ids/plan, re-synced every frame's
/// scale and re-hashed all ~10k anchored regions to conclude "nothing moved" — ~1.0 ms at the
/// Stormwind pin — and `layout_solves` cannot see it, because it counts only the walks that
/// went on to solve.
///
/// The castbar pin is why it exists: one moving spark cost **three** walks per frame and only
/// two of them were solves, so the counter the tests asserted on under-reported the bug by a
/// third. `resolve_bench::a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui`
/// is the guard that reads it.
///
/// **Unlike [`Self::layout_solves`], this is NOT verify-independent.** Under
/// `layout_verify_enabled` a tier-1-clean resolve falls through and walks anyway, so a verify
/// build counts walks production never pays — which is the honest reading (the walk happened)
/// but makes the number useless as an assertion there. Assert on it only from a crate that
/// consumes `benilla-ui` as a dependency, where `cfg!(test)` is false and the count is
/// production's; inside this crate's own tests, assert on `layout_solves`.
pub(crate) layout_gate_walks: u64,
/// The SCOPE of the last solve that ran: `(frames solved, regions swept)` — decision 1350's
/// own meter, and the number its gate asserts on. `layout_solves` says how often the gate let
/// a solve through and `layout_rounds` how deep each went; this says how WIDE, which is the
@@ -1100,6 +1124,7 @@ impl Model {
layout_last_scope: (0, 0),
layout_epoch_resolved: None,
layout_solves: 0,
layout_gate_walks: 0,
layout_rounds: 0,
resolved: HashMap::new(),
link_spans: HashMap::new(),
+11 -1
View File
@@ -428,8 +428,10 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
} else {
0.0
};
let (w0, h0) = (input.width, input.height);
input.width = (input.width + dw).max(1.0);
input.height = (input.height + dh).max(1.0);
let mut moved = input.width.to_bits() != w0.to_bits() || input.height.to_bits() != h0.to_bits();
// The planted edge: a frame anchored by its LEFT that is gripped on the LEFT has to move too,
// or the resize would push the right edge instead. Only the gripped axis shifts.
if !input.anchors.is_empty() && (sz.left || sz.bottom) {
@@ -441,8 +443,16 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
a.y_off += dy;
}
}
moved |= (sz.left && dx != 0.0) || (sz.bottom && dy != 0.0);
}
// The zero-delta return above is on the RAW cursor delta, which is not the same question: a
// single-axis grip dragged purely across its axis gives `dw == dh == 0`, and so does a grip
// held past `.max(1.0)` saturation. Both used to bump the epoch and then hash all ~10k
// anchored regions to conclude nothing had moved — the castbar's bug class in miniature, for
// every frame of such a drag (decision 1385).
if moved {
model.touch_layout();
}
model.touch_layout();
model.sizing = Some(FrameSizing { sample: pos, ..sz });
}
@@ -328,26 +328,106 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Tier 1 of the gate (decision 0740): the mutation epoch. The epoch closes only on a SETTLED
/// resolve (one whose fingerprint matched — the first resolve after a change hashes outgrown
/// seeds, so it may not close), and from then on a quiet frame skips at a `u64` compare without
/// computing the fingerprint at all.
/// 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);
s.resolve(); // the change-driven solve (fingerprint hashed over pre-solve seeds)
s.resolve(); // the settling pass — fingerprint matches, tier 1 closes
// 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 settled resolve must close the epoch"
"a converged resolve must close the epoch by itself"
);
s.run("parent:SetWidth(150)").expect("resize");
@@ -356,8 +436,7 @@ fn a_settled_resolve_closes_tier_one_and_a_real_write_reopens_it() {
"a real layout write must reopen tier 1"
);
s.resolve();
s.resolve();
assert!(tier_one_closed(&s), "and settling closes it again");
assert!(tier_one_closed(&s), "and one resolve closes it again");
}
#[test]
+24
View File
@@ -565,6 +565,30 @@ pub(crate) struct MeasuredText {
pub(crate) key: u64,
}
impl MeasuredText {
/// Did the part of this measurement the **layout** reads move? (decision 1385)
///
/// Only `w`/`h` are layout inputs — they are the auto-size axes, and the only fields
/// `InputFingerprint` feeds (`script::layout`'s region walk hashes `m.w`/`m.h`, nothing else).
/// `key` is the measure CACHE's business and `natural_w` is `GetStringWidth`'s; neither can
/// move a rect.
///
/// The distinction is load-bearing, not tidiness. A measure request is only ever *issued* when
/// the stored key mismatches, and `MeasuredText`'s derived `PartialEq` includes `key` — so
/// `d.measured != Some(new)` is **structurally always true** at both write sites, and touching
/// the epoch on it meant every answered measure opened tier 1. A FontString whose text changes
/// to something the same size does that on a cadence: a buff countdown ticking `"58s" → "57s"`,
/// a colour-code swap, any same-width digit. Tier 1 then went dirty and tier 2 hashed all
/// ~10k anchored regions to conclude nothing had moved — ~1 ms, `solves=0`, per tick, times
/// the number of unsynchronised timers on screen. The same shape 0740's own doc records the
/// bag-hover loop paying, and a sibling of the castbar's (1385).
pub(crate) fn layout_moved(before: Option<Self>, after: Self) -> bool {
before.is_none_or(|b| {
b.w.to_bits() != after.w.to_bits() || b.h.to_bits() != after.h.to_bits()
})
}
}
impl RegionData {
/// The measure-cache key for the region's CURRENT text/font/wrap/outline — the one recipe both
/// sides of the measure round-trip share. [`super::UiScript::fontstrings_needing_measure`]