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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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`]
|
||||
|
||||
Reference in New Issue
Block a user