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
+147 -22
View File
@@ -160,9 +160,16 @@ pub(super) fn drive_script(
Option<Res<crate::run_mode::CaptureMode>>,
Res<super::UiCostWanted>,
),
// This frame's `<Minimap>` widget slot, parked for `minimap::emit_minimap` (the UiQuadAppend
// producer that fills the hole with tile/arrow quads — decision 0203 phase 1).
mut minimap_widget: ResMut<crate::minimap::MinimapWidget>,
// The append-lane holes this pass parks for their UiQuadAppend producers, tupled for the
// same param-budget squeeze as `run`:
// · the `<Minimap>` widget slot (`minimap::emit_minimap` fills it with tile/arrow quads —
// decision 0203 phase 1);
// · the autocast shine sites (`autocast_shine::emit_shine` draws the spark trails there —
// decision 1383, B282).
mut parked: (
ResMut<crate::minimap::MinimapWidget>,
ResMut<crate::autocast_shine::ShineSites>,
),
// The seam scale the engine's text-metric caches were answered under. When `s` moves (window
// resize / fullscreen toggle / uiScale change), every cached measure is stale — see the
// invalidation below.
@@ -384,6 +391,9 @@ pub(super) fn drive_script(
let mut out = Vec::new();
let mut assets = world_assets;
let mut minimap_slot = None;
// This frame's autocast shine sites (decision 1383) — refilled by the loop below like the
// minimap slot; the settled and spliced paths above keep last conversion's set.
let mut shine_sites: Vec<crate::autocast_shine::ShineSite> = Vec::new();
// Hyperlink spans collected while rasterizing message-line Text quads (frame-targeted only —
// chat lines; FontString-region links are a later arc), fed back to the engine's click
// hit-test after the loop. Rects flip back to the engine's y-up space.
@@ -438,8 +448,9 @@ pub(super) fn drive_script(
// Every input the conversion below reads, compared against last frame's. Equal inputs make
// the loop a pure re-derivation of `UiQuads` the diff at the bottom would discard — at the
// LBRS pin that was every single settled frame, ~0.3 ms/frame of glyph re-rasterization for
// an identical `Vec`. On a skip, `quads`/`minimap_widget`/the engine's link spans all keep
// last frame's values, which the equal inputs prove are this frame's values too.
// an identical `Vec`. On a skip, `quads`/the parked minimap slot and shine sites/the
// engine's link spans all keep last frame's values, which the equal inputs prove are this
// frame's values too.
let dims = (w.to_bits(), h.to_bits(), s.to_bits(), dpi.to_bits());
let settled = capture.is_none()
&& prev.dims == Some(dims)
@@ -537,18 +548,19 @@ pub(super) fn drive_script(
break 'splice;
}
// Only entries whose conversion writes quads alone are spliceable — old AND new: an
// entry morphing into a side-channel kind must reach the full path's plumbing.
let simple = |eq: &benilla_ui::script::ExtractedQuad| {
matches!(
&eq.content,
QuadContent::Frame
| QuadContent::Cooldown { .. }
| QuadContent::Backdrop { .. }
| QuadContent::Texture {
portrait_unit: None,
..
}
)
// entry morphing into a side-channel kind must reach the full path's plumbing. The
// autocast-shine token (decision 1383) is a side-channel kind wearing a Texture's
// clothes, so it is excluded by path.
let simple = |eq: &benilla_ui::script::ExtractedQuad| match &eq.content {
QuadContent::Frame | QuadContent::Cooldown { .. } | QuadContent::Backdrop { .. } => {
true
}
QuadContent::Texture {
portrait_unit: None,
path,
..
} => path.as_deref() != Some(crate::autocast_shine::SHINE_TOKEN),
_ => false,
};
if !changed
.iter()
@@ -563,6 +575,7 @@ pub(super) fn drive_script(
let mut scratch_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(changed.len());
let mut scratch_links = Vec::new();
let mut scratch_slot = None;
let mut scratch_shine = Vec::new();
for &i in &changed {
let at = scratch.len();
convert_entry(
@@ -578,10 +591,11 @@ pub(super) fn drive_script(
&mut scratch,
&mut scratch_links,
&mut scratch_slot,
&mut scratch_shine,
);
scratch_ranges.push(at..scratch.len());
}
if !scratch_links.is_empty() || scratch_slot.is_some() {
if !scratch_links.is_empty() || scratch_slot.is_some() || !scratch_shine.is_empty() {
break 'splice;
}
let counts_stable = changed.iter().zip(&scratch_ranges).all(|(&i, r)| {
@@ -681,6 +695,7 @@ pub(super) fn drive_script(
&mut out,
&mut link_spans,
&mut minimap_slot,
&mut shine_sites,
);
spans.push(out.len() as u32);
}
@@ -714,9 +729,11 @@ pub(super) fn drive_script(
// (`OnHyperlinkClick`) hit-tests against exactly what is on screen.
script.set_link_spans(link_spans);
// Park this frame's Minimap widget slot (or clear it — a hidden cluster extracts nothing);
// `minimap::emit_minimap` runs later in the frame (UiQuadAppend) and fills the hole.
minimap_widget.0 = minimap_slot;
// Park this frame's Minimap widget slot and autocast shine sites (or clear them — a hidden
// cluster extracts nothing); `minimap::emit_minimap` / `autocast_shine::emit_shine` run
// later in the frame (UiQuadAppend) and fill the holes.
parked.0 .0 = minimap_slot;
parked.1 .0 = shine_sites;
drop(extract_span);
let us_exa = lap();
@@ -779,7 +796,8 @@ pub(super) fn drive_script(
/// One extracted entry converted to its screen quads, pushed onto `out` — with the side channels
/// some arms carry: chat link spans (Text), the minimap widget slot (Minimap), the booth pane
/// aspects (portrait-bound Texture). The ONE conversion body: the full pass and the per-entry
/// aspects (portrait-bound Texture), the autocast shine sites (the token-path Texture, decision
/// 1383). The ONE conversion body: the full pass and the per-entry
/// splice both call this, which is what makes the splice's output equal the full path's by
/// construction. An arm is splice-eligible only if it writes nothing but `out` — keep
/// [`splice_simple`] in agreement when an arm's side effects change.
@@ -802,6 +820,7 @@ fn convert_entry(
String,
)>,
minimap_slot: &mut Option<crate::minimap::MinimapSlot>,
shine_sites: &mut Vec<crate::autocast_shine::ShineSite>,
) {
let Some(r) = eq.rect else { return };
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
@@ -846,6 +865,30 @@ fn convert_entry(
rotation,
desaturated,
} => {
// The autocast-shine token (decision 1383, B282): a shown marker registers WHERE the
// shine plays — rect, paint order, clip, alpha, and the star art resolved through
// the same resolver as any texture — and draws nothing itself;
// `autocast_shine::emit_shine` (UiQuadAppend) animates the sparks there with zero
// per-frame script-layout traffic. A side-channel arm, so deliberately NOT
// splice-simple (the `simple` predicate above excludes the token by path).
if path.as_deref() == Some(crate::autocast_shine::SHINE_TOKEN) {
// A no-assets context (the extract test harness; a capture booted without the
// archive) records the site with the default handle — the site's GEOMETRY is
// the registration, and the live app always has the resolver.
let star = assets
.as_mut()
.and_then(|a| a.sprite_texture(crate::autocast_shine::STAR_TEXTURE, images))
.unwrap_or_default();
shine_sites.push(crate::autocast_shine::ShineSite {
rect,
z: eq.z,
clip,
alpha: eq.alpha,
scale: s,
texture: star,
});
return;
}
// A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's
// source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the
// ref's 2D TemporaryPortrait stand-in while the model streams in. Absent entry (no
@@ -1186,6 +1229,7 @@ mod clip_plumb_tests {
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
@@ -1272,6 +1316,7 @@ mod clip_plumb_tests {
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
@@ -1336,6 +1381,7 @@ mod extract_gate_tests {
app.init_resource::<PortraitImages>();
app.init_resource::<crate::portrait::BoothPanes>();
app.init_resource::<crate::minimap::MinimapWidget>();
app.init_resource::<crate::autocast_shine::ShineSites>();
app.init_resource::<crate::ui_script::UiFrameCost>();
app.init_resource::<crate::ui_script::UiCostWanted>();
app.init_resource::<Time>();
@@ -1492,6 +1538,85 @@ mod extract_gate_tests {
);
}
/// The autocast-shine token (decision 1383, B282): a shown marker converts to a parked SITE
/// and zero quads; a spliced frame keeps last conversion's sites; hiding the marker reaches
/// the full path and clears them. The site's rect/z/scale are the registration — the
/// producer (`autocast_shine::emit_shine`) draws there with no script traffic at all.
#[test]
fn the_shine_token_parks_a_site_and_never_a_quad() {
let mut app = app_from_script(
r#"
local b = CreateFrame("Button", "PetB")
b:SetPoint("BOTTOMLEFT", 72, 652)
b:SetSize(30, 30)
local shine = b:CreateTexture(nil, "OVERLAY")
shine:SetTexture("benilla:autocast-shine")
shine:SetAllPoints()
marker = b:CreateTexture(nil, "ARTWORK")
marker:SetTexture(1, 0, 0)
marker:SetAllPoints()
"#,
);
app.update();
{
let sites = app.world().resource::<crate::autocast_shine::ShineSites>();
assert_eq!(sites.0.len(), 1, "one shown token, one site");
let site = &sites.0[0];
// 1024x768 window ⇒ the 768-virtual scale is 1.0 and y flips through 768: the
// button's y-up [652, 682] lands at y-down [86, 116].
assert_eq!(site.rect, Rect::new(72.0, 86.0, 102.0, 116.0));
assert_eq!(site.scale, 1.0);
assert!(site.clip.is_none());
assert_eq!(site.alpha, 1.0);
assert_eq!(
app.world().resource::<UiQuads>().quads.len(),
1,
"the red marker's quad alone — the token itself draws nothing"
);
}
// A paint write elsewhere splices — and the parked site survives it untouched.
app.world_mut()
.resource_mut::<crate::ui_script::UiCostWanted>()
.0 = true;
app.world_mut().resource_mut::<UiQuads>().dirty = false;
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("marker:SetTexture(0, 0, 1)")
.unwrap();
app.update();
assert_eq!(
app.world()
.resource::<crate::ui_script::UiFrameCost>()
.spliced,
1,
"the marker write splices; the token entry did not change"
);
assert_eq!(
app.world()
.resource::<crate::autocast_shine::ShineSites>()
.0
.len(),
1,
"a spliced frame keeps last conversion's sites"
);
// Hiding the marker changes the extracted list's length — the full path runs and the
// site set empties: the shine goes out with the region that registered it.
app.world_mut()
.non_send_resource_mut::<UiScript>()
.run("getglobal('PetB'):Hide()")
.unwrap();
app.update();
assert!(
app.world()
.resource::<crate::autocast_shine::ShineSites>()
.0
.is_empty(),
"a hidden token registers nothing"
);
}
/// The stitch half: a write that CHANGES an entry's quad count (a cleared texture emits
/// nothing) re-derives the span table — and a later splice against the re-derived spans
/// still matches the full conversion, in both directions (1 → 0 quads, then 0 → 1).
+13 -175
View File
@@ -127,8 +127,7 @@ fn the_shipped_pet_bar_drives_end_to_end() {
"no pet, no shelf"
);
// A pet appears. The tick is what runs the bar's OnUpdate, i.e. the sparkle trail — it is an
// animation, so it exists from the first frame after the repaint, not from the repaint.
// A pet appears.
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -178,29 +177,19 @@ fn the_shipped_pet_bar_drives_end_to_end() {
"Attack + Defensive are lit; Claw is not"
);
// Autocast: the static ring on the one slot that allows it, and the sparkle trail running.
// Autocast: the static ring on the one slot that allows it, and the shine MARKER on the one
// slot where it is running — the native lane's registration (decision 1383): the extract
// carries the token, never sparks, and `autocast_shine::emit_shine` draws the trails at it
// on the append lane while the script layer stays settled.
assert_eq!(
textures(&quads, "Interface\\Buttons\\UI-AutoCastableOverlay"),
1,
"only Claw can autocast"
);
// 4 emitters x the DERIVED trail length, on the one autocasting slot — and on that slot only,
// which is the lazy pool's own pin: the other nine buttons have no sparks at all, where they
// used to carry a full hidden set each.
let trail: usize = s.eval("return BENILLA_PET_AUTOCAST_TRAIL").unwrap();
assert_eq!(
trail, 23,
"the size ramp's own spacing (see PetActionBar.xml's header) comes out at 23 samples"
);
assert_eq!(
textures(&quads, "Interface\\Buttons\\GlowStar"),
4 * trail,
"4 emitters x the trail, on the one autocasting slot"
);
assert!(
s.eval::<bool>("return PetActionButton1.sparks == nil")
.unwrap(),
"a button that never autocasts never pools a spark"
textures(&quads, "benilla:autocast-shine"),
1,
"the shine marker on Claw alone — enabled, not merely allowed"
);
// Geometry, quoted from the ref: the bar's TOPLEFT is MainMenuBar's BOTTOMLEFT +(36,97),
@@ -214,14 +203,14 @@ fn the_shipped_pet_bar_drives_end_to_end() {
(72.0, 56.0, 102.0, 86.0)
);
// The pet goes: the bar hides again, sparkles and all.
// The pet goes: the bar hides again, shine marker and all.
s.set_pet_actions(false, true, true, Vec::new());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.tick(0.05);
s.resolve();
let gone = s.extract();
assert_eq!(textures(&gone, "Interface\\PetActionBar\\UI-PetBar"), 0);
assert_eq!(textures(&gone, "Interface\\Buttons\\GlowStar"), 0);
assert_eq!(textures(&gone, "benilla:autocast-shine"), 0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -642,160 +631,9 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **B228 — the autocast trail's motion, MEASURED off the shipped handler.**
///
/// nazriel reads the shine as "a bit too fast", and the answer turned out to be in the widget's
/// CLOCK, which no constants read could have reached. `CSimpleModel`'s per-frame OnUpdate advances
/// its private `CM2Scene` by `__ftol(elapsed * 1000.0)` — **truncated, no fractional carry**, where
/// the world's driver adds 0.5 first (wow-re `modelframe-animation-clock.md`, §5 cross-checked).
/// So the reference's 2000 ms band actually takes `2000 * T / floor(T)` ms at frame time T, and is
/// **never faster than 2.000 s**. An exact 2.000 s emulation — which is what a measurement of our
/// old wiring confirmed we had — was therefore faster than any real client at any frame rate.
///
/// This drives the real `<OnUpdate>` with real seconds at real frame times and reads the head
/// spark's own SetPoint back. What is pinned: the lap comes out SLOW by exactly the truncation's
/// amount at 60 and 144 fps; the geometry is the 30.72 px square the widget's ortho projection puts
/// on a 30 px button (0.02 units × 1280 × the ref Model's `SetModelScale` 1.2), origin at the
/// button's BOTTOM-LEFT because that is the model's own origin; a corner every quarter lap, LINEAR
/// between them (`interp == 1` at the bone record's translation M2Track, so the half-edge sample
/// sits at the half-time); and the four emitters one quarter-lap apart.
#[test]
fn the_autocast_trail_laps_the_rim_the_reference_widgets_slow_way() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
// Slot 4 is Claw, the one slot in the fixture with autocast RUNNING. `sparks[n]` is the pool
// `BenillaPetAutocast_Create` built: emitter-major, so emitter e's HEAD (age 0, the bone's own
// position) is index e·TRAIL + 1.
let head = |s: &UiScript, emitter: usize| -> (f32, f32) {
let expr = |axis: usize| {
format!(
"local n = {emitter} * BENILLA_PET_AUTOCAST_TRAIL + 1 \
local a, b, c, x, y = PetActionButton4.sparks[n]:GetPoint() \
return ({axis} == 0) and x or y"
)
};
(
s.eval::<f32>(&expr(0)).unwrap(),
s.eval::<f32>(&expr(1)).unwrap(),
)
};
let near = |got: (f32, f32), want: (f32, f32), what: &str| {
assert!(
(got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01,
"{what}: at {got:?}, expected {want:?}"
);
};
// 0.02 units × 1280 px/unit × 1.2 = 30.72 px across, laid from the button's BOTTOMLEFT — so
// the walk runs 0..30.72 in both axes, not ±half from the centre.
const S: f32 = 30.72;
let (bl, tl, tr, br) = ((0.0, 0.0), (0.0, S), (S, S), (S, 0.0));
// ── The CLOCK, at a real frame time. 60 fps ⇒ dt = 16.667 ms ⇒ only **16** banked per frame,
// so the 2000 ms band needs 125 frames = **2.083 s of wall clock**. At 2.000 s (120 frames)
// the clock has banked 1920 ms = 0.96 of a lap: the head is still on the last edge, running
// right-to-left toward home and 0.04 × 122.88 = 4.92 px short of it. That shortfall IS the
// bug — it is how much slower than our old exact-2.000 s clock the reference has always been.
let frames = |s: &mut UiScript, n: usize, dt: f32| {
for _ in 0..n {
s.tick(dt);
}
};
const DT60: f32 = 1.0 / 60.0;
frames(&mut s, 120, DT60); // 2.000 s of wall clock
near(
head(&s, 0),
(4.9152, 0.0),
"2.000 s at 60 fps: NOT home yet — the reference is slower than the authored band",
);
frames(&mut s, 5, DT60); // 2.083 s
near(
head(&s, 0),
bl,
"2.083 s at 60 fps: one lap, the reference's own 2000*T/floor(T)",
);
// 144 fps truncates harder — 6.944 ms banked as 6 — so the same lap takes 2.315 s there. A
// frame-rate-independent clock would land both of these at 2.000 s and fail here.
let mut fast = UiScript::new().unwrap();
fast.set_screen_size(1024.0, 768.0);
load_pet_bar(&fast);
declare_token_strings(&fast);
fast.set_pet_actions(true, true, true, hunter_slots());
fast.fire_event("PET_BAR_UPDATE", vec![]);
fast.resolve();
frames(&mut fast, 333, 1.0 / 144.0); // 2.313 s of wall clock ⇒ 1998 ms banked
let (fx, fy) = head(&fast, 0);
assert!(
fy == 0.0 && fx > 0.0 && fx < 1.0,
"2.313 s at 144 fps: still 2 ms short of the lap, at {fx:?},{fy:?}"
);
// ── The position LAW, driven at exact MILLISECOND clocks so no tick accumulation can blur it.
// The mid-edge sample is the `interp == 1` pin: linear means the half-time is the half-edge,
// and a spline-eased track would sit short of it.
let at = |s: &UiScript, ms: u32| -> (f32, f32) {
s.run(&format!(
"BenillaPetAutocast_Update(PetActionButton4, {ms})"
))
.unwrap();
head(s, 0)
};
near(at(&s, 0), bl, "clock 0");
near(
at(&s, 250),
(0.0, S / 2.0),
"quarter edge in: the LEFT edge's midpoint",
);
near(at(&s, 500), tl, "clock 500 ms");
near(at(&s, 1000), tr, "clock 1000 ms");
near(at(&s, 1500), br, "clock 1500 ms");
near(at(&s, 2000), bl, "clock 2000 ms wraps onto clock 0");
// ── The trail is CONTINUOUS, which is what deriving the sample count buys (B228). No two
// neighbours in a trail sit further apart than the smaller of their two stars is wide, so the
// streak has no seam at any age. The 8-even-samples version failed this from mid-life back —
// 7.7 px apart with 4.6 px stars — and a row of separated dots is what reads as marching.
let worst_gap: f32 = s
.eval(
"local worst = 0 \
for k = 1, BENILLA_PET_AUTOCAST_TRAIL - 1 do \
local a, b = PetActionButton4.sparks[k], PetActionButton4.sparks[k + 1] \
local _, _, _, ax, ay = a:GetPoint() \
local _, _, _, bx, by = b:GetPoint() \
local d = math.sqrt((ax - bx) * (ax - bx) + (ay - by) * (ay - by)) \
local w = math.min(a:GetWidth(), b:GetWidth()) \
if d / w > worst then worst = d / w end \
end \
return worst",
)
.unwrap();
assert!(
worst_gap <= 1.0,
"the widest neighbour gap is {worst_gap:.2} star-widths — the streak has a seam"
);
// ── The four emitters are ONE LAP, a quarter apart — the file's four bones at the four
// corners, chasing each other (m2bones: pivots at (-0.001,0), (0.019,0), (0.019,0.02),
// (-0.001,0.02), same track, one corner of phase between neighbours).
s.run("BenillaPetAutocast_Update(PetActionButton4, 0)")
.unwrap();
for (e, want, name) in [
(0, bl, "emitter 0"),
(1, tl, "emitter 1"),
(2, tr, "emitter 2"),
(3, br, "emitter 3"),
] {
near(head(&s, e), want, name);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
// The autocast trail's MOTION tests (the B228 corner-walk goldens, the 1321 truncating-clock
// law, the no-seam continuity pin) live with the drawing now: `crate::autocast_shine`'s own
// test module — the script layer no longer moves a single spark (decision 1383).
/// The pet bar plus the three files its TOOLTIP needs, in the TOC's own order: Fonts.xml (the
/// colour codes that wrap the binding), GameTooltip.xml (the plate), and UIParent.xml — already a
@@ -66,6 +66,19 @@ fn app_frame(s: &mut UiScript) {
s.resolve();
}
/// A frame that also **ticks the VM**, i.e. runs the shipped UI's own `OnUpdate` handlers, in
/// `drive_script`'s real order (tick → measure → resolve).
///
/// [`app_frame`] deliberately models only the measure/resolve half, and the tooltip benches stand
/// in for the handler by calling it themselves. That is fine when the test IS the driver — but a
/// test asking "is anything in the shipped UI writing layout on its own?" has to let the shipped
/// UI actually run, or it answers a question nobody asked (decision 1385).
fn app_frame_ticked(s: &mut UiScript, dt: f32) {
s.tick(dt);
answer_measures(s);
s.resolve();
}
/// A tooltip whose lines change width every call — the hover sweep across a bag grid, where each
/// slot holds a different item.
fn install_changing_tooltip(s: &UiScript, owner: &str, func: &str) {
@@ -159,6 +172,133 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
);
}
/// **The idle law** (decision 1385): the settled shipped UI, with nothing happening, must cost
/// **zero** gate walks per frame — not one cheap one, zero.
///
/// The other guards in this file pin what the ENGINE charges for a change. This one pins that
/// nothing in the shipped UI is quietly making a change every frame in the first place, which is
/// the half a law about the engine cannot see. It is the tripwire for a new always-on toucher
/// being added to `assets/ui/` — the worst shape of this bug, because it costs on *every* frame of
/// *every* session rather than only while something is animating. The sweep behind 1385 found real
/// candidates for it (`TemporaryEnchantFrame`'s OnUpdate is resident for the whole session; the
/// measure round-trip used to bump the epoch on every same-size re-measure), so the shape is not
/// hypothetical.
///
/// A failure here does not name the culprit — `WOW_LAYOUT_TOUCH_TRACE=<secs>:<n>` does, by
/// backtracing the touch sites on a live run.
#[test]
fn the_settled_shipped_ui_costs_no_gate_walk_on_a_quiet_frame() {
let mut s = settled_default_ui();
// Ticked frames, so the shipped UI's own OnUpdate handlers run — the whole point (see
// `app_frame_ticked`). A couple of frames of grace first: `settled_default_ui` stops as soon
// as the measure round-trip goes quiet, which is one edge earlier than the layout gate closing
// behind it, and the first ticked frames arm handlers that have never run.
for _ in 0..8 {
app_frame_ticked(&mut s, 1.0 / 60.0);
}
// POSITIVE CONTROL. "Zero walks" only means anything if the shipped UI's handlers actually
// ran — a tick that silently fired nothing would also read zero, and would make this test a
// green light for exactly the regression it exists to catch. `BuffFrameUpdateTime` is advanced
// by `BuffFrame_OnUpdate` on every frame (down by `elapsed`, or up by TOOLTIP_UPDATE_TIME when
// it crosses), so one tick must move it.
s.run("__probe_bfut = BuffFrameUpdateTime")
.expect("read the control");
app_frame_ticked(&mut s, 1.0 / 60.0);
s.run(
"if BuffFrameUpdateTime == __probe_bfut then \
error('the VM tick ran no shipped OnUpdate — this test proves nothing') end",
)
.expect("the shipped UI's OnUpdate handlers must run under app_frame_ticked");
let before = s.layout_gate_walks();
for _ in 0..20 {
app_frame_ticked(&mut s, 1.0 / 60.0);
}
assert_eq!(
s.layout_gate_walks() - before,
0,
"20 idle frames of the shipped UI cost {} whole-roster gate walks — something in \
assets/ui/ is writing a layout input every frame with nothing happening. Name it with \
WOW_LAYOUT_TOUCH_TRACE=<secs>:<n> on a live run (decision 1385).",
s.layout_gate_walks() - before
);
}
/// **The castbar law** (decision 1385, ledger B283), on the full shipped UI: a region that moves
/// every frame must cost **one** whole-roster gate walk per frame.
///
/// This is the guard for the bug class 1383 named and the castbar then hit. `CastingBar.xml`'s
/// OnUpdate slides `CastingBarSpark` one `SetPoint` per frame for the length of every cast — the
/// reference's own architecture, and the classic addon idiom besides. Measured live at the
/// Stormwind gates it cost **+4.2 ms of CPU per frame** on a default UI: three walks per frame at
/// ~1.0–1.4 ms each (10,438 anchored regions re-hashed every walk), because the fingerprint was
/// hashed over the 0294 seeds as well as the inputs and so no walk could ever close tier 1.
///
/// Three things make this the falsifier rather than a smoke check:
/// * it runs on the **shipped** UI, so the whole-roster term is real — a synthetic two-frame model
/// shows the same *counts* and none of the cost;
/// * the Lua body ends in a frame getter, because that is what a live tick does. A later handler's
/// `GetWidth` forces the synchronous mid-tick solve; modelling the frame as a lone `resolve()`
/// hides two of the three walks;
/// * it asserts on `layout_gate_walks`, not `layout_solves`. A walk that concludes "nothing moved"
/// pays the identical preamble and never reaches the solve counter — one of the castbar's three
/// walks was exactly that, so the old counter under-reported the bug by a third.
#[test]
fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
let mut s = settled_default_ui();
s.run(
r#"
BenchBar = CreateFrame("Frame", "BenchBar", UIParent)
BenchBar:SetPoint("CENTER", 0, 0); BenchBar:SetWidth(195); BenchBar:SetHeight(13)
BenchSpark = BenchBar:CreateTexture(nil, "OVERLAY")
BenchSpark:SetWidth(32); BenchSpark:SetHeight(32)
BenchSpark:SetPoint("CENTER", BenchBar, "LEFT", 0, 2)
bench_spark_n = 0
-- CastingBarFrame_OnUpdate's body, reduced to what touches layout: ride the spark along
-- the bar's leading edge. The trailing getter stands in for any LATER handler in the same
-- tick reading a frame rect — the thing that forces the mid-tick synchronous solve.
function bench_spark_frame()
bench_spark_n = bench_spark_n + 1
BenchSpark:SetPoint("CENTER", BenchBar, "LEFT", bench_spark_n * 1.7, 2)
local _ = UIParent:GetWidth()
end
"#,
)
.unwrap();
// Settle the newly-created bar and spark — a birth is legitimately a wide, multi-walk solve.
for _ in 0..6 {
s.run("bench_spark_frame()").unwrap();
app_frame(&mut s);
}
let walks_before = s.layout_gate_walks();
let solves_before = s.layout_solves();
for step in 0..10 {
s.run("bench_spark_frame()").unwrap();
app_frame(&mut s);
let (frames, regions) = s.layout_last_scope();
assert!(
frames < 50 && regions < 200,
"step {step}: moving ONE region solved {frames} frames and swept {regions} regions — \
the scope is tracking the graph, not the change (decision 1350)"
);
}
let walks = s.layout_gate_walks() - walks_before;
let solves = s.layout_solves() - solves_before;
assert_eq!(
walks, 10,
"10 frames of one moving region must cost 10 whole-roster gate walks — one each. 30 \
means the fingerprint is hashing the 0294 seeds again, so every solve outgrows the value \
it stores and neither it nor the settling walk behind it can close tier 1 (decision \
1385). At the shipped roster each extra walk is ~1 ms of CPU on every frame of every cast."
);
assert_eq!(
solves, 10,
"and each of those walks must be a real solve, not a wasted one"
);
}
/// The perf half of decision 1350, asserted as a COUNT: a tooltip content change must cost a
/// solve whose SCOPE is tooltip-sized, on a UI of 3,000 frames and 8,800 anchored regions.
///
@@ -119,7 +119,11 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
let archive_path = ["file", "bgfile", "edgefile"]
.contains(&key.to_ascii_lowercase().as_str())
&& !value.is_empty();
if archive_path {
// The autocast-shine token (decision 1383) is a REGISTRATION, not an archive path:
// conversion intercepts it before the resolver and it draws nothing, so it is
// exempt — by EXACT match against the shipped constant, so a typo'd token in XML
// still fails this sweep as the white quad it would actually be.
if archive_path && value != crate::autocast_shine::SHINE_TOKEN {
out.push((file.to_string(), el.tag.clone(), value.clone()));
}
}
@@ -480,12 +480,13 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
.unwrap(),
"a passive is not autocastable"
);
// …and the sparkle follows the SECOND (is it on).
// …and the shine marker follows the SECOND (is it on) — the native lane (decision 1383)
// draws the sparkle wherever a shown marker sits, so shown-ness IS the enable.
assert!(s
.eval::<bool>("return SpellButton1.autocasting == 1")
.eval::<bool>("return SpellButton1Shine:IsVisible()")
.unwrap());
assert!(s
.eval::<bool>("return SpellButton3.autocasting == nil")
assert!(!s
.eval::<bool>("return SpellButton3Shine:IsVisible()")
.unwrap());
// ── The clicks ────────────────────────────────────────────────────────────────────────────