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:
@@ -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).
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user