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
+28 -252
View File
@@ -92,241 +92,20 @@
-- ── The autocast sparkle ──────────────────────────────────────────────────────────────
--
-- The reference's "autocast is on" mark is a <Model> playing
-- Interface\Buttons\UI-AutoCastButton.mdx, and what that model actually is came out of the
-- shipped .m2 with `benilla-extract m2bones`/`m2part`:
--
-- 4 bones, pivoting at the corners of a 0.02 x 0.02 unit square, each keyed with the
-- SAME 5-key 2.000 s translation loop walking the square edge by edge (0.5 s per edge) —
-- the four phase-shifted by one corner each, so they chase one another round it. The
-- tracks are `interp == 1`, LINEAR (read at the bone record's M2Track word, not assumed):
-- constant speed along each edge, no easing into the corners, which is what the walk
-- below reproduces.
-- 4 particle emitters, one per bone: 300/s, life 1.000 s, Add blend, unlit, texture
-- Interface\Buttons\GlowStar.blp, size half-extent 0.005 -> 0.0015 -> 0.001 over life,
-- colour (0.976,0.875,0.192,1) -> (0.996,0.945,0.745,1) -> (1,1,1,0). Its spline is one
-- cubic Bezier segment whose 4 control points are ALL AT THE ORIGIN — checked, all four,
-- because the two INTERIOR handles are what would bend the path and reading only the
-- endpoints proves nothing — so a particle never travels: it is born where the bone is
-- and shrinks away there. The effect is therefore four comet trails running round the
-- button's rim, not a rotating ring.
--
-- MODEL UNITS -> PIXELS is the widget's own projection, not a calibration
-- (wow-re `system/ui/scratch/modelframe-animation-clock.md`, §1317's fold-back). There is
-- no fixed px/unit: the ortho leg gives
--
-- px per model unit = (5/3) * modelScale * layoutScale * screenHeightPx
--
-- which in FrameXML units collapses to **1280 * modelScale * layoutScale**, independent of
-- aspect and resolution. `scale="1.2"` on the ref's <Model> is SetModelScale, NOT
-- Frame:SetScale (`0x76cb36`, stored at `+0x3a0`), so 1 model unit = 1536 FrameXML units
-- and the 0.02 path is **30.72** across — 1.024x a 30-unit button, overhanging its top and
-- right by 2.4%. The model ORIGIN is the frame rect's BOTTOM-LEFT, not its centre, which is
-- why a square authored 0..0.02 lands across the whole button instead of in one quadrant.
--
-- This corrects the 1200 px/unit figure this header carried until then, which was the right
-- formula evaluated at a 720 px screen **with the 1.2 left out** — two errors that nearly
-- cancelled (28.8 vs 30.72) and made the wrong number look like it cross-checked. BagFrame's
-- Cooldown calibration is the same coincidence and is flagged there.
--
-- OUR ONE DEPARTURE is the particle COUNT: 300 live per emitter is 12 000 quads across a
-- full bar. We draw a SAMPLE of the same trail — each sample sits where the bone was `age`
-- ago and reads the same position/size/colour ramps at that age — so the trail's shape,
-- speed and colour are the file's; only its density is ours.
--
-- **How many samples is DERIVED, not chosen** (B228). It was 8, evenly spaced in age, on
-- the claim that the trail "reads identically at a fraction" of 300. Nobody had checked
-- that claim, and the arithmetic says it is false. The rim is 4 x 30.72 = 122.9 px round
-- and the lap is 2.0 s, so the bone travels 61.4 px/s and a particle's 1.0 s life spans
-- 61.4 px of rim — two whole edges. Eight even samples therefore sit 7.7 px apart, while
-- the stars behind the head are 4.6 px and then 3.1 px across: the back half of every trail
-- was a row of SEPARATED DOTS where the file draws one continuous streak. Marching dots are
-- legible motion in a way a smooth streak is not, so it read as speed on a loop that runs
-- at the authored rate (nazriel, B228).
--
-- So the samples are spaced by the file's own SIZE ramp rather than evenly: each step
-- advances one star-width / OVERLAP of arc. That spends samples in the tail where the
-- stars are small and saves them at the head where a 15 px star covers everything —
-- continuous everywhere, and cheaper than a uniform spacing fine enough for the tail.
BENILLA_PET_AUTOCAST_PX_PER_UNIT = 1280 -- the ortho leg's
BENILLA_PET_AUTOCAST_SCALE = 1.2 -- the ref Model's
BENILLA_PET_AUTOCAST_PERIOD = 2.0 -- seq0 loop, seconds
BENILLA_PET_AUTOCAST_LIFE = 1.0 -- particle lifespan
BENILLA_PET_AUTOCAST_EMITTERS = 4
-- Star-widths of cover on every point of the trail. 1 would put neighbours exactly edge to
-- edge, and a GlowStar fades out toward its own edge, so the seam would show; 2 (centres
-- half a width apart) is the cheapest value with no seam.
BENILLA_PET_AUTOCAST_OVERLAP = 2
-- 0.02 units square, in pixels.
BENILLA_PET_AUTOCAST_SIZE = 0.02 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE
-- Half-extent -> full width, in pixels: {0.005, 0.0015, 0.001} * 2 * K * scale.
-- The MID key is 0.0015, not the 0.002 this table carried until B228: `m2part` printed the
-- ramp at 3 decimals and rounded it up, and the transcription took the printout at its
-- word. (Fixed at the instrument too — that column prints 4 decimals now.) 33% of the
-- mid-life star, so the trail was reading thicker than the file authors it.
BENILLA_PET_AUTOCAST_STAR = {
0.010 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
0.003 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
0.002 * BENILLA_PET_AUTOCAST_PX_PER_UNIT * BENILLA_PET_AUTOCAST_SCALE,
}
-- The over-life colour ramp's three keys (the file's), as {r, g, b, a}.
BENILLA_PET_AUTOCAST_COLOR = {
{0.976, 0.875, 0.192, 1.0},
{0.996, 0.945, 0.745, 1.0},
{1.000, 1.000, 1.000, 0.0},
}
-- ── The widget's CLOCK, truncation and all ────────────────────────────────────────────
--
-- The reference does NOT play this loop at wall-clock rate, and that is the whole of what
-- was left of B228. `CSimpleModel` overrides `CSimpleFrame`'s per-frame OnUpdate slot
-- (vtable `0x81c608` idx14 -> `0x76d7f0`) and advances its private `CM2Scene` clock by
-- `__ftol(elapsed * 1000.0)` — **truncated, with no fractional carry**. The WORLD's driver
-- does the same conversion with a `+0.5` first (`0x48366b`); the widget path omits it
-- (`0x76d846`). So the widget throws away `frac(elapsed * 1000)` every single frame, and a
-- 2000 ms band takes `2000 * T / floor(T)` ms at frame time T ms:
--
-- 30 fps -> 2020 ms · 60 fps -> 2083 ms · 75 fps -> 2051 · 144 fps -> 2315
--
-- It is **never faster than 2.000 s**. Our old exact-2.000 s clock was therefore faster
-- than any real 1.12 client at any frame rate — the floor of the range rather than a point
-- in it — which is exactly nazriel's "a bit too fast". (wow-re
-- `system/ui/scratch/modelframe-animation-clock.md`, §5 cross-checked.)
--
-- So the clock is an integer MILLISECOND counter here too, advanced the reference's way.
-- This is the mechanism, not a tuned constant: it tracks the player's own frame rate the
-- way theirs did. Two knowing limits. The reference's `elapsed` is an **f32**, so
-- `f32(0.01) * 1000 = 9.99999978` truncates to 9 — a whole millisecond lost at a nominally
-- clean frame rate, and the exact hazard the world path's `+0.5` cancels; we do not model
-- f32 rounding, so we lose marginally less than it does. And above 1000 fps the reference
-- freezes outright (every frame truncates to 0 ms) — reproduced rather than guarded,
-- because a guard would be inventing behaviour, but no 2006 client ever reached it and a
-- frozen sparkle at four-figure frame rates is this line's fault, not a new bug.
BENILLA_PET_AUTOCAST_PERIOD_MS = 2000
function BenillaPetAutocast_Advance(frame, elapsed)
local ms = (frame.clock or 0) + math.floor(elapsed * 1000)
frame.clock = math.mod(ms, BENILLA_PET_AUTOCAST_PERIOD_MS)
return frame.clock
end
-- Where an emitter sits at lap fraction `f` (0..1), as an offset from the button's
-- BOTTOMLEFT in pixels — the model's own origin (see the header), so this is the authored
-- square placed exactly where the widget places it: 0..30.72 across a 30 px button, its
-- corner on the button's corner, overhanging the top and right by 0.72 px. The bone tracks
-- walk it clockwise on screen from that bottom-left corner, one edge per quarter-lap.
function BenillaPetAutocast_Point(f)
local s = BENILLA_PET_AUTOCAST_SIZE
f = f - math.floor(f) -- wrap into 0..1
local edge = f * 4
local leg = math.floor(edge)
local t = edge - leg -- 0..1 along this edge
if leg == 0 then return 0, t * s -- bottom-left -> top-left
elseif leg == 1 then return t * s, s -- top-left -> top-right
elseif leg == 2 then return s, s - t * s -- top-right -> bottom-right
else return s - t * s, 0 -- bottom-right -> bottom-left
end
end
-- Sample a 3-key over-life ramp (the M2's own {k0, k1 at mid, k2} shape, linear between).
function BenillaPetAutocast_Ramp(keys, age)
local a, b, t
if age <= 0.5 then a, b, t = keys[1], keys[2], age * 2
else a, b, t = keys[2], keys[3], (age - 0.5) * 2 end
if type(a) == "number" then return a + (b - a) * t end
local out = {}
for i = 1, table.getn(a) do out[i] = a[i] + (b[i] - a[i]) * t end
return out
end
-- The trail's sample AGES, walked once off the size ramp above (the header has the why).
-- Each step advances by one local star-width / OVERLAP of rim, converted to seconds at the
-- bone's own rim speed — so the spacing tightens as the stars shrink and the streak stays
-- unbroken end to end. The count falls out of the file's numbers; nothing here picks it.
function BenillaPetAutocast_TrailAges()
local speed = 4 * BENILLA_PET_AUTOCAST_SIZE / BENILLA_PET_AUTOCAST_PERIOD -- px/s of rim
local ages, age = {}, 0
while age < BENILLA_PET_AUTOCAST_LIFE do
table.insert(ages, age)
local w = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_STAR, age / BENILLA_PET_AUTOCAST_LIFE)
-- The step is only positive while the ramp is. This runs at FILE LOAD, so a future
-- edit that put a zero in the size table would hang the whole UI before a frame
-- ever drew — the failure mode decision 1306 exists for. Bail instead.
if w <= 0 then break end
age = age + w / BENILLA_PET_AUTOCAST_OVERLAP / speed
end
return ages
end
BENILLA_PET_AUTOCAST_AGES = BenillaPetAutocast_TrailAges()
BENILLA_PET_AUTOCAST_TRAIL = table.getn(BENILLA_PET_AUTOCAST_AGES)
-- Build one button's sparkle pool: EMITTERS x TRAIL textures, hidden until autocast is on.
--
-- Everything about a sample EXCEPT where it is right now is fixed by its age — size,
-- colour and alpha are reads of the over-life ramps at an age that never changes for a
-- given slot — so all of it is set HERE, once. The per-frame pass moves the texture and
-- touches nothing else, which is why tripling the sample count still costs less per frame
-- than the eight-sample version did (it re-derived all five values for every spark, every
-- frame). Each spark also carries its fixed place in the loop: its emitter's quarter-lap
-- offset, less how far the bone has moved on since this star was born.
--
-- Built LAZILY, on the first frame the button actually autocasts. Most never do, and a
-- pool that is only ever hidden is pure cost — 22 buttons' worth of it across the bar and
-- the spell book.
function BenillaPetAutocast_Create(button)
button.sparks = {}
for e = 0, BENILLA_PET_AUTOCAST_EMITTERS - 1 do
for k = 1, BENILLA_PET_AUTOCAST_TRAIL do
local age = BENILLA_PET_AUTOCAST_AGES[k]
local life = age / BENILLA_PET_AUTOCAST_LIFE
local size = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_STAR, life)
local c = BenillaPetAutocast_Ramp(BENILLA_PET_AUTOCAST_COLOR, life)
local spark = button:CreateTexture(nil, "OVERLAY")
spark:SetTexture("Interface\\Buttons\\GlowStar")
spark:SetBlendMode("ADD")
spark:SetWidth(size)
spark:SetHeight(size)
spark:SetVertexColor(c[1], c[2], c[3])
spark:SetAlpha(c[4])
spark:Hide()
spark.phase = e / BENILLA_PET_AUTOCAST_EMITTERS
- age / BENILLA_PET_AUTOCAST_PERIOD
table.insert(button.sparks, spark)
end
end
end
-- Advance one button's sparkles to the bar's shared clock. Shared on purpose: the four
-- emitters are one model, and every autocasting button plays the same loop in step, which
-- is what the reference's per-button Models do too (each starts at sequence time 0 and
-- they never drift, the loop being exact).
--
-- Show and hide are EDGE work, not per-frame work: this runs for all ten buttons every
-- frame and all but one or two of them are dark, so the old unconditional Hide loop was
-- spending most of the bar's frame budget re-hiding what was already hidden.
function BenillaPetAutocast_Update(button, clockMs)
if not button.autocasting then
if button.sparksLit then
for i = 1, table.getn(button.sparks) do button.sparks[i]:Hide() end
button.sparksLit = nil
end
return
end
if not button.sparks then BenillaPetAutocast_Create(button) end
local sparks = button.sparks
local lap = clockMs / BENILLA_PET_AUTOCAST_PERIOD_MS
for i = 1, table.getn(sparks) do
local spark = sparks[i]
-- SetPoint on the SAME point replaces it, so no ClearAllPoints is needed. The
-- anchor is the button's BOTTOMLEFT because that is the model's own origin.
local x, y = BenillaPetAutocast_Point(lap + spark.phase)
spark:SetPoint("CENTER", button, "BOTTOMLEFT", x, y)
end
if not button.sparksLit then
for i = 1, table.getn(sparks) do sparks[i]:Show() end
button.sparksLit = 1
end
end
-- NATIVE since decision 1383 (B282). The reference's "autocast is on" mark is a <Model>
-- playing Interface\Buttons\UI-AutoCastButton.mdx — a widget OUTSIDE the FrameXML layout
-- tree — and our old emulation here (a per-button pool of ~92 OVERLAY textures SetPoint'ed
-- every frame from OnUpdate) made the most expensive possible use of the script layer:
-- every frame it dirtied the layout epoch and forced two whole-roster anchor solves,
-- +4.25 ms/frame for ONE autocasting button at a default roster, scaling with the whole
-- UI (B282, nazriel's ~11 ms under addons). What a button carries now is ONE hidden
-- marker texture, $parentShine, whose path is the `benilla:autocast-shine` token: the
-- extract's conversion records its rect/layer as a shine site and draws nothing, and
-- `autocast_shine::emit_shine` (benilla-app) animates the four comet trails there on the
-- UI quad append lane — zero per-frame script traffic, the reference's own architecture
-- on our substrate. The M2 derivation (bones, emitters, ramps, the 30.72-unit projection,
-- decision 1321's truncating millisecond clock) moved whole into that module's header;
-- this file's only job is showing the marker exactly while autocast is enabled.
-- ── The buttons ───────────────────────────────────────────────────────────────────────
@@ -347,8 +126,7 @@
button.icon = getglobal(button:GetName() .. "Icon")
button.cooldown = getglobal(button:GetName() .. "Cooldown")
button.autocastable = getglobal(button:GetName() .. "AutoCastable")
-- No sparkle pool here: BenillaPetAutocast_Update builds it on the first frame this
-- button actually autocasts, and most buttons never do.
button.shine = getglobal(button:GetName() .. "Shine")
end
-- Ref PetActionButton_OnClick. The CheckButton widget toggles itself BEFORE OnClick fires,
@@ -502,7 +280,9 @@
if isActive then button:SetChecked(1) else button:SetChecked(0) end
if autoCastAllowed then button.autocastable:Show() else button.autocastable:Hide() end
button.autocasting = autoCastEnabled
-- The shine marker (decision 1383, header above): showing it IS the whole
-- enable — the native producer draws the trails wherever a shown marker sits.
if autoCastEnabled then button.shine:Show() else button.shine:Hide() end
-- An unnamed slot hides — UNLESS the drop grid is up, which is the whole point of
-- the grid: while a pet action rides the cursor every slot shows, so you can see
@@ -538,7 +318,6 @@
frame:RegisterEvent("PET_BAR_SHOWGRID")
frame:RegisterEvent("PET_BAR_HIDEGRID")
frame.showgrid = 0
frame.clock = 0
PetActionBar_OnEvent()
end
@@ -565,15 +344,6 @@
end
end
-- The shared sparkle clock. One OnUpdate for the whole bar rather than ten: the four
-- emitters are one looping model, so every autocasting button is in step by construction.
function BenillaPetActionBar_OnUpdate(frame, elapsed)
local clockMs = BenillaPetAutocast_Advance(frame, elapsed)
for i = 1, NUM_PET_ACTION_SLOTS do
BenillaPetAutocast_Update(getglobal("PetActionButton" .. i), clockMs)
end
end
-- ── The pet menu's two dialogs (ref StaticPopup.lua l.738-748 + l.1069-1102 + l.365-374,
-- decision 1066) ────────────────────────────────────────────────────────────────────
-- They live here rather than with the rows that raise them (UnitPopup.xml) for the same
@@ -649,9 +419,10 @@
<!--
One pet button (ref PetActionButtonTemplate: 30x30, inherits ActionButtonTemplate's art
set, plus the AutoCastable ring on OVERLAY at 58x58 CENTER). The sparkle textures are
created in OnLoad rather than declared: there are 32 of them per button, and they are the
one thing here whose count is ours rather than the reference's.
set, plus the AutoCastable ring on OVERLAY at 58x58 CENTER). The Shine texture is the
ref's $parentAutoCast <Model> re-expressed as the native lane's marker (decision 1383,
the sparkle header above): its token path draws nothing itself — a shown marker is where
`autocast_shine` plays the trails, at exactly this OVERLAY layer's paint order.
-->
<CheckButton name="BenillaPetActionButtonTemplate" virtual="true">
<Size><AbsDimension x="30" y="30"/></Size>
@@ -666,6 +437,12 @@
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
<Texture name="$parentShine" file="benilla:autocast-shine" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
@@ -771,7 +548,6 @@
<Scripts>
<OnLoad>PetActionBar_OnLoad()</OnLoad>
<OnEvent>PetActionBar_OnEvent()</OnEvent>
<OnUpdate>BenillaPetActionBar_OnUpdate(self, arg1)</OnUpdate>
<OnShow>UIParent_ManageFramePositions()</OnShow>
<OnHide>UIParent_ManageFramePositions()</OnHide>
</Scripts>
+12 -27
View File
@@ -233,22 +233,6 @@
tabButton:Show()
end
-- The shared sparkle clock — PetActionBar.xml's own pattern, one OnUpdate for the twelve
-- buttons rather than twelve: the emitters are one model and every autocasting button
-- plays the same loop in step, which is what the ref's per-button Models do too. Only runs
-- while the window is shown, so it costs nothing the rest of the time.
function BenillaSpellBookFrame_OnUpdate(frame, elapsed)
if not BenillaPetAutocast_Update then return end
-- The clock advance is the pet bar's too, and SHARED rather than copied: it is the
-- reference Model widget's own truncating millisecond accumulator (that file's header
-- carries the bytes), and a second hand-rolled copy is exactly how the two surfaces
-- would drift apart the next time it is corrected.
local clockMs = BenillaPetAutocast_Advance(frame, elapsed)
for i = 1, SPELLS_PER_PAGE do
BenillaPetAutocast_Update(getglobal("SpellButton" .. i), clockMs)
end
end
-- ref SpellBookFrame_OnHide (l.186-204): MultiActionBar_HideAllGrids and the tab-flash
-- cleanup OMITTED (header comment); always the spell-book close kit (no pet book to branch
-- on). UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml).
@@ -287,11 +271,11 @@
button:RegisterForDrag("LeftButton")
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
button.autocastable = getglobal(button:GetName() .. "AutoCastable")
-- The ref's `$parentAutoCast` is a <Model> playing UI-AutoCastButton.mdx; we have no
-- Model widget, so the pet bar's byte-derived sparkle emitter stands in for it
-- (decision 0982 — PetActionBar.xml owns the numbers and both surfaces share them).
-- Nothing to build here: that emitter pools itself lazily, on the first frame a
-- button autocasts, which on this page is a pet-book spell and never a player one.
-- The ref's `$parentAutoCast` is a <Model> playing UI-AutoCastButton.mdx; ours is
-- the native shine lane (decision 1383 — PetActionBar.xml's sparkle header has the
-- story, `autocast_shine` the numbers): $parentShine is the token marker, and
-- showing it is the whole enable.
button.shine = getglobal(button:GetName() .. "Shine")
BenillaSpellButton_UpdateButton(button)
end
@@ -442,7 +426,7 @@
subSpellString:Hide()
cooldown:Hide()
autocastable:Hide()
button.autocasting = nil
button.shine:Hide()
if not outOfTab then
highlightTexture:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
end
@@ -459,10 +443,11 @@
iconTexture:SetVertexColor(brightness, brightness, brightness)
-- ref l.367-377: the autocast pair. `GetSpellAutocast` answers (nil, nil) for the
-- player book by the engine's own short-circuit, so this needs no isPet guard — the
-- overlay and the sparkle simply never come on outside the pet page.
-- overlay and the sparkle simply never come on outside the pet page. Showing the
-- shine marker IS the sparkle (decision 1383 — the native lane draws at it).
local autoCastAllowed, autoCastEnabled = GetSpellAutocast(id, SpellBookFrame.bookType)
if autoCastAllowed then autocastable:Show() else autocastable:Hide() end
button.autocasting = autoCastEnabled
if autoCastEnabled then button.shine:Show() else button.shine:Hide() end
local spellName, subSpellName = GetSpellName(id, SpellBookFrame.bookType)
local isPassive = IsSpellPassive(id, SpellBookFrame.bookType)
-- A passive slot wears a whole different dress — the button ring blacked right out,
@@ -654,12 +639,13 @@
<Layer level="OVERLAY">
<!-- ref SpellButtonTemplate's $parentAutoCastable: the static ring that says a pet
spell CAN autocast, 58x58 over a 37x37 button. Its animated sibling (the
$parentAutoCast <Model>) has no widget here — PetActionBar.xml's byte-derived
sparkle emitter stands in, created in OnLoad. -->
$parentAutoCast <Model>) is the native shine lane's marker below (decision
1383 — PetActionBar.xml's sparkle header has the story). -->
<Texture name="$parentAutoCastable" file="Interface\Buttons\UI-AutoCastableOverlay" hidden="true">
<Size><AbsDimension x="58" y="58"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="$parentShine" file="benilla:autocast-shine" setAllPoints="true" hidden="true"/>
</Layer>
<Layer level="BORDER">
<Texture name="$parentIconTexture" setAllPoints="true" hidden="true"/>
@@ -946,7 +932,6 @@
<OnEvent>SpellBookFrame_OnEvent()</OnEvent>
<OnShow>SpellBookFrame_OnShow()</OnShow>
<OnHide>SpellBookFrame_OnHide()</OnHide>
<OnUpdate>BenillaSpellBookFrame_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Ui>
+429
View File
@@ -0,0 +1,429 @@
//! The **autocast shine** — the four comet trails that run round an autocasting pet button —
//! drawn natively on the UI quad APPEND lane (decision 1383, B282).
//!
//! ## Why native (B282)
//!
//! Until 1383 the shine was ~92 script-layer `Texture` regions per button, `SetPoint`ed every
//! frame from a Lua `OnUpdate`. Moving a region dirties the layout epoch, so the first layout
//! getter later in the same tick forced a real anchor solve — whose cost is the whole-roster
//! preamble + fingerprint walk, NOT the ~92 moved leaves (decision 1350 scopes the *rounds*, not
//! the gate walk) — and the seeds-grown law forced a second full walk before the gate could
//! close. Measured at the SW gates pin on a default roster: **+4.25 ms cpu/frame for ONE
//! autocasting button**, spark-count-independent (23 sparks cost the same as 92), scaling with
//! the whole UI (an addon-laden roster pays multiples — nazriel's ~11 ms, B282). The reference
//! never pays any of this: its shine is a `<Model>` widget playing an M2 *outside* the FrameXML
//! layout tree. This module is that architecture on our substrate: the script layer carries one
//! **token texture** per button ([`SHINE_TOKEN`], shown/hidden on the autocast *edge*), the
//! extract's conversion records where it sits ([`ShineSite`] — the minimap-slot pattern), and
//! [`emit_shine`] appends the spark quads per frame with zero layout traffic: a shine-only frame
//! leaves the script pipeline fully settled.
//!
//! ## What is drawn — the M2's own law
//!
//! The reference's "autocast is on" mark is a `<Model>` playing
//! `Interface\Buttons\UI-AutoCastButton.mdx`; what that model actually is came out of the shipped
//! .m2 with `benilla-extract m2bones`/`m2part` (this block moved whole from `PetActionBar.xml`,
//! which owned the drawing until 1383):
//!
//! - 4 bones, pivoting at the corners of a 0.02 × 0.02 model-unit square, each keyed with the
//! SAME 5-key 2.000 s translation loop walking the square edge by edge (0.5 s per edge), the
//! four phase-shifted by one corner each so they chase one another round it. The tracks are
//! `interp == 1`, LINEAR: constant speed along each edge, no easing into the corners.
//! - 4 particle emitters, one per bone: 300/s, life 1.000 s, Add blend, unlit, texture
//! `Interface\Buttons\GlowStar.blp`, size half-extent 0.005 → 0.0015 → 0.001 over life, colour
//! (0.976,0.875,0.192,1) → (0.996,0.945,0.745,1) → (1,1,1,0). The spline's 4 control points are
//! ALL at the origin — a particle never travels: it is born where the bone is and shrinks away
//! there. The effect is four comet trails running round the button's rim, not a rotating ring.
//!
//! MODEL UNITS → FrameXML units is the widget's own projection, not a calibration (wow-re
//! `system/ui/scratch/modelframe-animation-clock.md`, decision 1321's fold-back): the ortho leg
//! collapses to **1280 × modelScale × layoutScale** FrameXML units per model unit, independent of
//! aspect and resolution; `scale="1.2"` on the ref's `<Model>` is SetModelScale, so the 0.02 path
//! is **30.72 units** across a 30-unit button — 1.024×, overhanging its top and right by 2.4% —
//! and the model ORIGIN is the frame rect's BOTTOM-LEFT, which is why a square authored 0..0.02
//! lands across the whole button instead of in one quadrant.
//!
//! OUR ONE DEPARTURE is the particle COUNT: 300 live per emitter is 12 000 quads across a full
//! bar. We draw a SAMPLE of the same trail — each sample sits where the bone was `age` ago and
//! reads the same size/colour ramps at that age — so the trail's shape, speed and colour are the
//! file's; only its density is ours. **How many samples is DERIVED, not chosen** (B228/1317):
//! spacing the samples evenly left the tail as separated marching dots; spacing them by the
//! file's own size ramp — each step advances one star-width / [`OVERLAP`] of arc — keeps the
//! streak continuous everywhere for the fewest quads ([`trail_ages`]).
//!
//! ## The clock — truncation and all
//!
//! The reference does NOT play this loop at wall-clock rate (decision 1321, B228's second round):
//! `CSimpleModel` advances its private scene clock by `__ftol(elapsed * 1000.0)` — truncated,
//! no fractional carry (`0x76d846`; the WORLD driver adds 0.5 first, `0x48366b`) — so a 2000 ms
//! band takes `2000·T/floor(T)` ms at frame time T ms: 2083 ms at 60 fps, 2315 at 144, never
//! less than 2000. [`ShineClock`] is that integer-millisecond accumulator, advanced the
//! reference's way. All shine sites share one clock: the ref's per-button Models are all created
//! at UI load with `SetSequence(0)` and never drift, the loop being exact — one shared clock IS
//! that behaviour.
use bevy::prelude::*;
use crate::ui_pass::{UiQuad, UiQuadAppend, UiQuads};
/// The script layer's registration token: a `Texture` region with this path converts to **no
/// quads** — the conversion records a [`ShineSite`] at its rect/z instead, and [`emit_shine`]
/// draws there. Ours, not the install's — the string never reaches the asset resolver.
pub(crate) const SHINE_TOKEN: &str = "benilla:autocast-shine";
/// The spark art — the M2's own emitter texture, resolved once per site at conversion time
/// through the same resolver every UI texture uses.
pub(crate) const STAR_TEXTURE: &str = "Interface\\Buttons\\GlowStar";
/// The rim square's side in FrameXML units: 0.02 model units × 1280 × the ref Model's 1.2.
const SIZE: f32 = 0.02 * 1280.0 * 1.2;
/// The size ramp's FULL widths in FrameXML units (half-extents {0.005, 0.0015, 0.001} × 2,
/// projected like [`SIZE`]). The mid key is 0.0015, not 0.002 — `m2part` used to round its
/// printout (B228/1317).
const STAR: [f32; 3] = [
0.010 * 1280.0 * 1.2,
0.003 * 1280.0 * 1.2,
0.002 * 1280.0 * 1.2,
];
/// The over-life colour ramp's three keys, `{r, g, b, a}` — the emitter's own.
const COLOR: [[f32; 4]; 3] = [
[0.976, 0.875, 0.192, 1.0],
[0.996, 0.945, 0.745, 1.0],
[1.000, 1.000, 1.000, 0.0],
];
/// The bone loop, milliseconds (seq 0's authored 2.000 s band).
const PERIOD_MS: u32 = 2000;
/// The bone loop in seconds, for the phase/age arithmetic.
const PERIOD: f32 = 2.0;
/// A particle's lifespan, seconds.
const LIFE: f32 = 1.0;
/// One emitter per bone corner.
const EMITTERS: usize = 4;
/// Star-widths of cover on every point of the trail. 1 would put neighbours exactly edge to
/// edge, and a GlowStar fades toward its own edge, so the seam would show; 2 (centres half a
/// width apart) is the cheapest value with no seam (1317).
const OVERLAP: f32 = 2.0;
/// Sample a 3-key over-life ramp (the M2's `{k0, k1 at mid, k2}` shape, linear between),
/// one lane.
fn ramp(keys: [f32; 3], age: f32) -> f32 {
let (a, b, t) = if age <= 0.5 {
(keys[0], keys[1], age * 2.0)
} else {
(keys[1], keys[2], (age - 0.5) * 2.0)
};
a + (b - a) * t
}
/// [`ramp`] over the colour keys, all four lanes.
fn ramp_color(age: f32) -> [f32; 4] {
std::array::from_fn(|i| ramp([COLOR[0][i], COLOR[1][i], COLOR[2][i]], age))
}
/// Where an emitter sits at lap fraction `f` (wrapped into 0..1), as a FrameXML-unit offset from
/// the button's BOTTOM-LEFT (the model's own origin — module doc). The bones walk the square
/// clockwise on screen from that corner, one edge per quarter-lap.
fn point(f: f32) -> (f32, f32) {
let f = f - f.floor();
let edge = f * 4.0;
let leg = edge.floor();
let t = edge - leg;
match leg as u32 {
0 => (0.0, t * SIZE), // bottom-left → top-left
1 => (t * SIZE, SIZE), // top-left → top-right
2 => (SIZE, SIZE - t * SIZE), // top-right → bottom-right
_ => (SIZE - t * SIZE, 0.0), // bottom-right → bottom-left
}
}
/// The trail's sample ages, walked once off the size ramp (module doc: derived, not chosen —
/// each step advances one local star-width / [`OVERLAP`] of rim at the bone's own rim speed, so
/// the spacing tightens as the stars shrink and the streak stays unbroken end to end).
fn trail_ages() -> Vec<f32> {
let speed = 4.0 * SIZE / PERIOD; // FrameXML units of rim per second
let mut ages = Vec::new();
let mut age = 0.0f32;
while age < LIFE {
ages.push(age);
let w = ramp(STAR, age / LIFE);
// Only positive while the ramp is — a zero in the size table must not hang the walk
// (the 1306 failure mode; the Lua original bailed the same way).
if w <= 0.0 {
break;
}
age += w / OVERLAP / speed;
}
ages
}
/// [`trail_ages`], computed once. Each entry also pre-bakes everything a sample's fixed age
/// fixes: its size, colour and alpha (the per-frame pass moves quads and touches nothing else —
/// the same split the Lua pool made at spark creation, 1317).
static SPARKS: std::sync::LazyLock<Vec<(f32, f32, [f32; 4])>> = std::sync::LazyLock::new(|| {
trail_ages()
.into_iter()
.map(|age| {
let life = age / LIFE;
(age, ramp(STAR, life), ramp_color(life))
})
.collect()
});
/// One autocasting button's shine site, recorded by the extract's conversion when it meets a
/// shown [`SHINE_TOKEN`] texture (the minimap-slot pattern): everything [`emit_shine`] needs to
/// draw there without asking the script layer anything.
#[derive(Clone, PartialEq)]
pub(crate) struct ShineSite {
/// The token region's resolved rect — the BUTTON's rect (the token is SetAllPoints on it) —
/// in window pixels, y-down.
pub(crate) rect: Rect,
/// The token's paint-order key, verbatim — sparks draw exactly where the old spark textures
/// drew (the button's OVERLAY layer).
pub(crate) z: u64,
/// The token's clip rect, if any (a scroll-framed ancestor), window px y-down.
pub(crate) clip: Option<Rect>,
/// The token's effective UI alpha (parent chain folded), multiplied into every spark.
pub(crate) alpha: f32,
/// FrameXML-units → window-pixels factor at the conversion that recorded this site (the
/// extract's `s`); a resize re-extracts and re-records.
pub(crate) scale: f32,
/// `Interface\Buttons\GlowStar`, resolved at record time.
pub(crate) texture: Handle<Image>,
}
/// This frame's shine sites. Refilled by every FULL conversion (and left alone by the settled /
/// spliced paths — the token entries didn't change, so last conversion's sites are still true;
/// the token arm is deliberately not splice-simple, so any token edge reaches the full path).
#[derive(Resource, Default)]
pub(crate) struct ShineSites(pub(crate) Vec<ShineSite>);
/// The shared integer-millisecond loop clock (module doc: the reference's truncating
/// accumulator, decision 1321 — `floor(elapsed_ms)` added per frame, no fractional carry, so the
/// lap tracks the player's frame rate exactly as the reference widget's does).
#[derive(Resource, Default)]
pub(crate) struct ShineClock {
ms: u32,
}
impl ShineClock {
/// Advance by one frame's wall delta (seconds) and return the clock, in `0..PERIOD_MS`.
fn advance(&mut self, elapsed: f64) -> u32 {
self.ms = (self.ms + (elapsed * 1000.0).floor() as u32) % PERIOD_MS;
self.ms
}
}
/// Append every site's sparks to the UI quad overlay lane — [`UiQuadAppend`], beside the minimap
/// and the V-plates: re-emitted each frame, diffed by the mesh rebuild, never touching the
/// script layout. Runs (and advances the clock) unconditionally; with no sites it is two reads
/// and an add.
fn emit_shine(
mut clock: ResMut<ShineClock>,
sites: Res<ShineSites>,
time: Res<Time<Real>>,
mut quads: ResMut<UiQuads>,
) {
let ms = clock.advance(time.delta_secs_f64());
for site in &sites.0 {
site_quads(site, ms, &mut quads.overlays);
}
}
/// One site's sparks at clock `ms`, appended to `out` — [`emit_shine`]'s whole body, pure so the
/// tests drive it at exact millisecond clocks the way the old Lua goldens drove the `OnUpdate`.
fn site_quads(site: &ShineSite, ms: u32, out: &mut Vec<UiQuad>) {
let lap = ms as f32 / PERIOD_MS as f32;
let s = site.scale;
// The model origin: the button rect's bottom-left, in y-down px.
let (ox, oy) = (site.rect.min.x, site.rect.max.y);
for e in 0..EMITTERS {
let corner = e as f32 / EMITTERS as f32;
for &(age, w, c) in SPARKS.iter() {
// The spark's fixed place in the loop: its emitter's quarter-lap offset, less how
// far the bone has moved on since this star was born.
let (x, y) = point(lap + corner - age / PERIOD);
let (cx, cy) = (ox + x * s, oy - y * s);
let half = w * s * 0.5;
out.push(UiQuad {
rect: Rect::new(cx - half, cy - half, cx + half, cy + half),
z_key: site.z,
texture: Some(site.texture.clone()),
color: [c[0], c[1], c[2], c[3] * site.alpha],
additive: true,
clip: site.clip,
..Default::default()
});
}
}
}
/// Resource + producer registration (the [`UiQuadAppend`] window — `ui_pass` sorts the lanes
/// together by `z_key`, so the sparks interleave exactly at the button's overlay layer).
pub(crate) struct AutocastShinePlugin;
impl Plugin for AutocastShinePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ShineSites>()
.init_resource::<ShineClock>()
.add_systems(Update, emit_shine.in_set(UiQuadAppend));
}
}
#[cfg(test)]
mod tests {
use super::*;
/// The derivation golden: the size-ramp walk lands on 23 samples (1317's number, now pinned
/// where the walk lives), strictly increasing, all inside a particle's life.
#[test]
fn the_trail_walk_derives_the_1317_sample_count() {
let ages = trail_ages();
assert_eq!(ages.len(), 23, "one star-width/2 of arc per step ⇒ 23");
assert!(ages.windows(2).all(|w| w[1] > w[0]));
assert!(ages.iter().all(|&a| (0.0..LIFE).contains(&a)));
// The no-seam law (the old Lua golden's own pin, verbatim): no two neighbours sit
// further apart on the rim than the SMALLER of their two stars is wide — the older
// sample's, since the size ramp only shrinks. The 8-even-samples version failed exactly
// this from mid-life back (7.7 px gaps under 4.6 px stars — B228's marching dots).
let speed = 4.0 * SIZE / PERIOD;
for w in ages.windows(2) {
let gap = (w[1] - w[0]) * speed;
let star = ramp(STAR, w[1] / LIFE).min(ramp(STAR, w[0] / LIFE));
assert!(
gap <= star,
"gap {gap} exceeds star width {star} at age {}",
w[0]
);
}
}
/// The head spark's quad, generated through the REAL producer body at exact millisecond
/// clocks — the old Lua golden's position law on the native lane: origin at the site rect's
/// bottom-left, the quarter-edge midpoint exactly half way (LINEAR, `interp == 1`), sizes
/// and colours the ramps' own, the site's alpha folded, and 4 × 23 quads per site.
#[test]
fn a_sites_sparks_come_out_where_the_lua_pool_put_them() {
let site = ShineSite {
rect: Rect::new(72.0, 56.0, 102.0, 86.0), // the pet bar's button 1, y-down px
z: 42,
clip: None,
alpha: 1.0,
scale: 1.0,
texture: Handle::default(),
};
let at = |ms: u32| {
let mut out = Vec::new();
site_quads(&site, ms, &mut out);
assert_eq!(out.len(), EMITTERS * SPARKS.len());
// Emitter 0's head spark is the first quad; its centre is the bone's own position.
let r = out[0].rect;
(
(r.min.x + r.max.x) * 0.5 - site.rect.min.x,
site.rect.max.y - (r.min.y + r.max.y) * 0.5,
)
};
// The old Lua golden's own 0.01-px tolerance: the centre is reconstructed from the
// quad's f32 edges, so exact-bit equality is not the claim — the corner is.
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:?}"
);
};
near(at(0), (0.0, 0.0), "clock 0: the bottom-left origin");
near(
at(250),
(0.0, SIZE / 2.0),
"quarter edge in: the LEFT edge's midpoint (LINEAR)",
);
near(at(500), (0.0, SIZE), "clock 500 ms: top-left");
near(at(1000), (SIZE, SIZE), "clock 1000 ms: top-right");
near(at(1500), (SIZE, 0.0), "clock 1500 ms: bottom-right");
near(at(2000), (0.0, 0.0), "clock 2000 ms wraps onto clock 0");
// The head quad wears the ramp's birth values: full width, the first colour key, and
// the additive blend the emitter authors; z is the site's own paint order.
let mut out = Vec::new();
site_quads(&site, 0, &mut out);
let head = &out[0];
assert!((head.rect.max.x - head.rect.min.x - STAR[0]).abs() < 0.01);
assert_eq!(head.color, COLOR[0]);
assert!(head.additive);
assert_eq!(head.z_key, 42);
// The site's alpha folds into every spark on top of the ramp's own.
let dim = ShineSite { alpha: 0.5, ..site };
let mut dimmed = Vec::new();
site_quads(&dim, 0, &mut dimmed);
assert_eq!(dimmed[0].color[3], COLOR[0][3] * 0.5);
}
/// The corner walk, the old `pet_bar_tests` golden re-pinned on the native producer: the
/// head spark of emitter 0 lands on each corner at 0.5 / 1.0 / 1.5 / 2.0 s (lap fractions
/// 0.25/0.50/0.75/1.0), walking clockwise on screen from the bottom-left origin.
#[test]
fn the_head_spark_walks_the_corners_clockwise() {
assert_eq!(point(0.0), (0.0, 0.0), "bottom-left at 0 s");
assert_eq!(point(0.25), (0.0, SIZE), "top-left at 0.5 s");
assert_eq!(point(0.5), (SIZE, SIZE), "top-right at 1.0 s");
assert_eq!(point(0.75), (SIZE, 0.0), "bottom-right at 1.5 s");
assert_eq!(point(1.0), (0.0, 0.0), "home at 2.0 s");
// The four emitters chase one corner apart.
assert_eq!(point(0.0 + 0.25), point(0.25));
}
/// Decision 1321's clock law, on the native accumulator: at a exact 60 fps the floor of
/// every 16.666… ms delta is 16 ms, so the 2000 ms band takes ⌈2000/16⌉ = 125 frames —
/// 2083 ms of wall time, never less than authored.
#[test]
fn the_clock_truncates_like_the_reference_widget() {
let mut clock = ShineClock::default();
let dt = 1.0 / 60.0;
let mut frames = 0;
loop {
frames += 1;
let before = clock.ms;
let now = clock.advance(dt);
if now < before {
break; // wrapped: one full lap
}
assert!(frames < 1000, "clock never wrapped");
}
assert_eq!(
frames, 125,
"a 2000 ms band takes 125 frames at 60 fps (2083 ms)"
);
// 144 fps truncates harder — 6.944 ms banked as 6 — so the same lap takes 334 frames
// (2315 ms of wall clock). A frame-rate-independent clock would land both at 2000 ms.
let mut clock = ShineClock::default();
let dt = 1.0 / 144.0;
let mut frames = 0;
loop {
frames += 1;
let before = clock.ms;
if clock.advance(dt) < before {
break;
}
assert!(frames < 1000, "clock never wrapped");
}
assert_eq!(
frames, 334,
"the same band takes 334 frames at 144 fps (2315 ms)"
);
}
/// The size/colour ramps at the keys and midpoints — the M2's own numbers, so a future
/// constant edit can't drift silently.
#[test]
fn the_ramps_read_the_m2s_keys() {
// The interior key is reached through `a + (b - a) * 1.0`, which rounds once more than
// the literal — toleranced, not bit-equal.
let near = |got: f32, want: f32| assert!((got - want).abs() < 1e-4, "{got} vs {want}");
near(ramp(STAR, 0.0), STAR[0]);
near(ramp(STAR, 0.5), STAR[1]);
near(ramp(STAR, 1.0), STAR[2]);
for (key, age) in [(COLOR[0], 0.0), (COLOR[1], 0.5), (COLOR[2], 1.0)] {
for (got, want) in ramp_color(age).into_iter().zip(key) {
near(got, want);
}
}
}
}
+1 -1
View File
@@ -112,7 +112,7 @@ pub(crate) use probe_partner::ProbePartnerPlugin;
pub(crate) use probe_rig::ProbeRigPlugin;
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probes::{
fx_draw_census_plugin, EntityCensusPlugin, LiveFpsPlugin, NodeProbePlugin,
fx_draw_census_plugin, EntityCensusPlugin, GroundCensusPlugin, LiveFpsPlugin, NodeProbePlugin,
ParticleCensusPlugin, ProbeChatPlugin, ProbeClock, ProbeExitPlugin, ProbeFocusPlugin,
ProbeKeyPlugin, ProbeLuaPlugin, ProbeResizePlugin,
};
@@ -0,0 +1,215 @@
//! The **under-floor census** (`WOW_GROUND_CENSUS=<secs>[,<every>]`) — the instrument that closes
//! B197's symptom: *"±half the NPCs in one building sit below the floor"*.
//!
//! Every reported site of that bug was a picture. A picture cannot say whether a unit is below the
//! floor **because the server put it there** (then it is the server's ledger) or because *we* pulled
//! it down off the pose the server sent (then it is ours), and it cannot say whether the floor the
//! unit belongs on is even collidable from where it stands. Both questions are one line of numbers
//! per unit:
//!
//! ```text
//! UGD 0x… z=5.02 seat=9.14 drop=+4.12 terrain=5.02 above=9.07 (+4.05) spline=0 swim=0
//! ```
//!
//! - **`seat`** is the server's own Z for this unit — the pose the create block / a move packet /
//! its spline last wrote ([`GroundClamped::seat_y`]), before the ground clamp had its say.
//! - **`drop`** is `seat − z`: how far the clamp pulled the unit *below* the server's pose. This is
//! the number that names the bug; on a healthy scene it is within a yard either way (the clamp's
//! whole job is a small correction), and `sunk` counts the ones past [`SUNK_YD`].
//! - **`above`** is the lowest walkable surface strictly above the unit's feet, found by walking a
//! one-sided down-ray through the stack. A unit with a floor right there and a big `drop` is
//! standing under a floor it could be standing on — the exact shape of the report.
//! - **`terrain`** is the MCNK height under it, which is what the clamp finds when a building's own
//! collider has not attached yet.
//!
//! Pair it with the slot-keyed probe identity and a `.go` to the reported spot:
//!
//! ```text
//! WOW_USER=probeN WOW_PASS=pprobeN WOW_CHAR=Probe<n> WOW_NOSOUND=1 \
//! WOW_PROBE_CHAT=".go xyz 6558.80 457.23 9.07 1" \
//! WOW_GROUND_CENSUS="30,15" WOW_PROBE_EXIT_AT=75 cargo run -q -p benilla
//! ```
//!
//! The repeat form is the load-race half: a census at 30 s and again at 45 s says whether a sunk
//! unit *stays* sunk once the world has finished arriving, which is what separates a transient from
//! the ratchet decision 1384 removed.
use benilla_assets::coords::bevy_to_wow;
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use super::ProbeClock;
use crate::net::{CreatureSwimming, GroundClamped, Guid, NetEntity, SelfPlayer, Spline};
/// A drop past this (yd) counts as **sunk** — the headline count. The clamp's honest corrections are
/// the small float off a slightly-high wire Z and the little hill a straight spline cuts through;
/// half a yard is well clear of both and well under a storey.
const SUNK_YD: f32 = 0.5;
/// How far above a unit's feet the stack walk starts looking for the floor it should be on. A
/// storey and a bit: high enough to clear any building floor a unit could be sunk beneath, low
/// enough that it doesn't report the roof of an unrelated structure.
const CEILING_YD: f32 = 12.0;
/// Feet-clearance for "strictly above" in the stack walk — below this the surface *is* the one the
/// unit is standing on, not one above it.
const ABOVE_EPS: f32 = 0.05;
pub(crate) struct GroundCensusPlugin;
impl Plugin for GroundCensusPlugin {
fn build(&self, app: &mut App) {
let raw = std::env::var("WOW_GROUND_CENSUS").unwrap_or_default();
let mut parts = raw
.split(',')
.map(|s| s.trim().parse::<f32>().unwrap_or(0.0));
let at = parts.next().filter(|v| *v > 0.0).unwrap_or(30.0);
let every = parts.next().unwrap_or(0.0);
let radius = std::env::var("WOW_GROUND_CENSUS_RADIUS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(80.0);
app.insert_resource(GroundCensus {
next: at,
every,
radius,
})
.add_systems(Update, fire_ground_census);
}
}
/// [`GroundCensusPlugin`] state: when the next census fires, how often after that (`0` = once), and
/// how far from the body to look.
#[derive(Resource)]
struct GroundCensus {
next: f32,
every: f32,
radius: f32,
}
/// What the census reads per unit: identity, kind, pose, the clamp's memo (the seat), whether a
/// path is driving it, and whether it is swimming (which exempts it from the clamp).
type CensusQuery = (
&'static Guid,
&'static NetEntity,
&'static Transform,
Option<&'static GroundClamped>,
Option<&'static Spline>,
Has<CreatureSwimming>,
);
/// One census line per streamed `Unit` within [`GroundCensus::radius`] of the body, worst drop
/// first, under a summary line naming the count that matters.
fn fire_ground_census(
mut probe: ResMut<GroundCensus>,
time: ProbeClock,
world: benilla_world::collision::WorldCollision,
point: benilla_world::world_point::WorldPoint,
body: Query<&Transform, With<SelfPlayer>>,
units: Query<CensusQuery>,
) {
let now = time.elapsed_secs();
if probe.next <= 0.0 || now < probe.next {
return;
}
probe.next = if probe.every > 0.0 {
now + probe.every
} else {
-1.0
};
let Ok(body) = body.single() else {
println!("GROUND_CENSUS t={now:.1} NO BODY — not in world, nothing measured");
return;
};
let radius2 = probe.radius * probe.radius;
let mut rows: Vec<(f32, bool, String)> = Vec::new();
for (guid, net, t, clamped, spline, swimming) in &units {
if net.kind != EntityKind::Unit
|| t.translation.distance_squared(body.translation) > radius2
{
continue;
}
let z = t.translation.y;
let seat = clamped.map_or(z, |c| c.seat_y);
let drop = seat - z;
let terrain = point.terrain_height_under(t.translation);
let above = lowest_surface_above(&world, t.translation);
let wow = bevy_to_wow(t.translation);
rows.push((
drop,
swimming,
format!(
"UGD {:#018x} pos=({:.2},{:.2},{:.2}) z={z:.2} seat={seat:.2} drop={drop:+.2} \
terrain={} above={} spline={} swim={}",
guid.0,
wow[0],
wow[1],
wow[2],
terrain.map_or_else(|| "none".into(), |v| format!("{v:.2}")),
above.map_or_else(|| "none".into(), |v| format!("{v:.2} ({:+.2})", v - z)),
u8::from(spline.is_some()),
u8::from(swimming),
),
));
}
rows.sort_by(|a, b| b.0.total_cmp(&a.0));
// A swimmer is EXEMPT from the clamp by design (its wire Z is its swim depth), so its seat is
// simply the last Z the clamp saw before it entered the water — a stale memo, not a drop. It
// stays in the listing and out of the count.
let sunk = rows
.iter()
.filter(|(d, swim, _)| *d > SUNK_YD && !swim)
.count();
let worst = rows
.iter()
.find(|(_, swim, _)| !swim)
.map_or(0.0, |(d, _, _)| *d);
let at = bevy_to_wow(body.translation);
println!(
"GROUND_CENSUS t={now:.1} units={} sunk={sunk} worst={worst:+.2} radius={:.0} \
body=({:.2},{:.2},{:.2})",
rows.len(),
probe.radius,
at[0],
at[1],
at[2],
);
for (_, _, line) in &rows {
println!("{line}");
}
}
/// The lowest walkable surface **strictly above** `feet` — the floor a sunk unit should be standing
/// on. Walks the one-sided down-ray stack from [`CEILING_YD`] overhead: each hit above the feet
/// becomes the new candidate and the next cast starts just under it, so a unit beneath three
/// storeys reports the one immediately over its head rather than the roof. `None` when nothing is
/// overhead — the healthy outdoor case.
fn lowest_surface_above(
world: &benilla_world::collision::WorldCollision,
feet: Vec3,
) -> Option<f32> {
let mut from = feet.y + CEILING_YD;
let mut found = None;
for _ in 0..8 {
let reach = from - feet.y;
if reach <= ABOVE_EPS {
break;
}
let origin = Vec3::new(feet.x, from, feet.z);
// A miss ENDS the walk with what it already has — it never discards it. `?` here read as
// "no floor above this unit" for three of B197's four sunk NPCs, whose second cast (from
// just under the floor it had already found, down to feet sitting on the terrain) missed
// that terrain by a float hair. The instrument's own first finding was its own bug.
let Some(hit) = world.ray_body(origin, Dir3::NEG_Y, reach) else {
break;
};
let y = origin.y - hit.distance;
if y <= feet.y + ABOVE_EPS {
break;
}
found = Some(y);
from = y - 0.02;
}
found
}
@@ -29,6 +29,11 @@ pub(crate) use live_fps::LiveFpsPlugin;
mod particle_census;
pub(crate) use particle_census::ParticleCensusPlugin;
/// The under-floor census — per-unit "where the server put it vs where we drew it", the instrument
/// that turns a screenshot of an NPC below a floor into a number (B197, decision 1384).
mod ground_census;
pub(crate) use ground_census::GroundCensusPlugin;
/// The two exclusive-`World` reflection dumps: the bevy_ui node inventory and the archetype
/// census — "what is resident right now, and what is it made of".
mod world_census;
+8
View File
@@ -178,6 +178,14 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PARTICLE_CENSUS").is_ok() {
app.add_plugins(crate::capture::ParticleCensusPlugin);
}
// The under-floor census: `WOW_GROUND_CENSUS=<secs>[,<every>]` prints one line per
// streamed unit near the body — the server's Z for it, the Z we drew it at, the drop
// between them, and the floor over its head. The instrument B197 was missing: it says
// whether a unit below a floor was put there by the server or pulled there by us (see
// `capture::GroundCensusPlugin`).
if std::env::var("WOW_GROUND_CENSUS").is_ok() {
app.add_plugins(crate::capture::GroundCensusPlugin);
}
// The entity census: `WOW_ENTITY_CENSUS=<secs>` prints per-archetype entity counts once —
// what the resident entity count is made of (see `capture::EntityCensusPlugin`).
if std::env::var("WOW_ENTITY_CENSUS").is_ok() {
@@ -271,6 +271,9 @@ pub(super) fn attach_entity_visuals(
),
// The owned skin-palette table (decision 0720): every skinned instance claims a rig slot.
mut palettes: ResMut<benilla_world::rig_palette::RigPalettes>,
// The collider-set stamp (1384): the GameObject hull insert below is the one collider lane
// outside the streamer's attach queue, so it dates the world itself.
mut collider_epoch: ResMut<benilla_world::collision::ColliderEpoch>,
time: Res<Time>,
) {
let (sections, world_assets, mut images, mut skin_composites, asset_server, mut mats) =
@@ -865,6 +868,11 @@ pub(super) fn attach_entity_visuals(
RigidBody::Static
};
commands.entity(entity).insert((body, col));
// This lane bypasses the streamer's attach queue entirely (0761), so it stamps
// the collider set itself — otherwise a creature standing where a GameObject
// hull lands frames later keeps the answer it took before there was one
// (decision 1384).
collider_epoch.bump();
}
}
} else {
+4
View File
@@ -37,6 +37,7 @@ mod area_trigger;
#[cfg(feature = "dev")]
mod asset_churn;
mod aura_visual;
mod autocast_shine;
mod bindings;
mod blob_shadow;
mod bowstring;
@@ -506,6 +507,9 @@ pub fn run(build: BuildId) -> AppExit {
// The HUD minimap (decision 0203 phase 1): fills the `<Minimap>` widget's extracted hole with
// the streamed tile window + mask + player arrow, and feeds the zone text.
.add_plugins(minimap::MinimapPlugin)
// The pet-bar / spellbook autocast shine, drawn on the append lane from the conversion's
// parked sites — zero per-frame script-layout traffic (decision 1383, B282).
.add_plugins(autocast_shine::AutocastShinePlugin)
// The shared AreaTable catalog + the ZONE_CHANGED event family / zone-text host globals
// behind GetZoneText & co. (the zone-entry splash arc, decision 0287).
.add_plugins(area::AreaPlugin)
+1 -1
View File
@@ -41,7 +41,7 @@ use motion::{
mark_swimming_creatures, sample_splines,
};
pub(crate) use motion::{
jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline, SplineStopped,
jump_seed, CreatureSwimming, FacingStep, GroundClamped, RemoteMotion, Spline, SplineStopped,
};
/// The net subsystem: spawns the background IO threads and drives the per-frame event drain.
+1 -1
View File
@@ -40,7 +40,7 @@ pub(super) use spline::{
create_spline, ground_clamp_creatures, mark_swimming_creatures, monster_move_spline,
sample_splines, trace_create_spline, trace_move_snap,
};
pub(crate) use spline::{CreatureSwimming, Spline, SplineStopped};
pub(crate) use spline::{CreatureSwimming, GroundClamped, Spline, SplineStopped};
/// A full [`Transform`] for a freshly-spawned entity from a raw-WoW pose. WoW orientation (yaw about
/// +Z) maps straight to a Bevy +Y yaw under `wow_to_bevy` — no 180° offset (unlike doodad/WMO
+250 -33
View File
@@ -422,14 +422,24 @@ const GROUND_CLAMP_DOWN: f32 = 4.0;
/// keeps its own Z, and so does a **swimming** creature ([`CreatureSwimming`]): its wire Z *is* its
/// swim depth — vmangos paths a water creature in 3D through the volume with a plain non-FLYING
/// spline (verified `MoveSplineInit`/`WaypointMovementGenerator.cpp`: only `CanFly()` sets the flag),
/// so ground-clamping it dragged murlocs to the lakebed. A probe **miss** — the unit is genuinely
/// airborne (ground out of reach) or the tile's collider hasn't streamed in yet — leaves the current
/// Z untouched (no pop). Runs right after [`sample_splines`] so a walker's freshly-sampled XY+Z is
/// what we re-ground; an idle unit (no spline) is re-grounded in place each frame, which also catches
/// it once its terrain collider loads.
/// so ground-clamping it dragged murlocs to the lakebed. Runs right after [`sample_splines`] so a
/// walker's freshly-sampled XY+Z is what we re-ground.
///
/// **The probe measures from the unit's SEAT, never from the clamp's own last answer** (decision
/// 1384). The seat is the Z whoever owns this unit's position last wrote — the create block, an
/// `SMSG_UPDATE_OBJECT` move, its spline, a transport deck composing its rider — and it is what the
/// server means by "where this unit is". Feeding the previous *output* back in as the next input is
/// what made a single wrong answer permanent: a unit created inside a building at world entry, in
/// the window before that building's own floor collider had attached, found the terrain under the
/// building instead, dropped onto it, and from down there the floor was above the probe's reach
/// forever after. Deriving from the seat makes every frame's answer a pure function of (the
/// server's pose, the colliders) — so the moment the floor lands, the unit is back on it, with no
/// movement needed to shake it loose. A probe **miss** — genuinely airborne, or the ground here
/// hasn't streamed in — puts the unit at its seat, which is exactly where an unclamped unit belongs.
#[allow(clippy::type_complexity)] // one Bevy system's full input set
pub(in crate::net) fn ground_clamp_creatures(
world: benilla_world::collision::WorldCollision,
epoch: Res<benilla_world::collision::ColliderEpoch>,
mut commands: Commands,
mut q: Query<(
Entity,
@@ -441,9 +451,14 @@ pub(in crate::net) fn ground_clamp_creatures(
)>,
) {
let cost = clamp_cost_enabled();
let legacy = clamp_seat_disabled();
let t0 = cost.then(std::time::Instant::now);
let (mut visited, mut skipped, mut held, mut cast, mut hit_n, mut moved) =
(0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
// What re-armed each cast (1384): the unit's own seat moved, or the world's colliders changed
// under a unit that didn't. The second must be ~0 in a settled scene — a nonzero steady-state
// `armed` means the collider set is churning and the gate is holding nothing.
let (mut reseat, mut armed) = (0u32, 0u32);
for (entity, net, spline, mut t, mut clamped, swimming) in &mut q {
visited += 1;
@@ -459,44 +474,74 @@ pub(in crate::net) fn ground_clamp_creatures(
skipped += 1;
continue; // in-liquid: the wire Z is the creature's swim depth
}
// The cast gate (decision 1357): a unit standing exactly where its last cast stood, with
// a Y nothing has touched since that cast wrote it, cannot get a different answer — the
// ray's inputs are its position and the walking colliders, and a *moving* floor (a lift,
// a transport deck) reaches its rider through the wire's own position writes, which break
// the bit-equality below and re-arm the cast. A MISS never caches (`hit` gates it), so a
// unit standing on a tile whose collider hasn't streamed in keeps asking until it lands —
// the original re-ground-in-place behaviour, kept exactly where it is load-bearing.
let xz = [t.translation.x, t.translation.z];
// **Re-seat on any write that wasn't ours.** The Y standing here differs from the one this
// unit's last clamp left behind exactly when somebody who owns this unit's position moved
// it — the wire, its spline, a transport deck — and *that* Y is the authority the probe
// measures from. With no memo at all (a unit's first frame), the pose it spawned at is the
// seat, which is the create block's own Z.
let seat_y = match clamped.as_deref() {
_ if legacy => t.translation.y, // the pre-1384 leg: measure from our own last answer
Some(c) if c.y_written == t.translation.y => c.seat_y,
_ => t.translation.y,
};
// The cast gate (decision 1357): a unit whose seat and XZ are bit-identical to the cast
// that produced its cached hit, in a world whose collider set has not changed since, cannot
// get a different answer — those three ARE the ray's inputs. A moving floor (a lift, a
// transport deck) reaches its rider through the wire's own position writes, which move the
// seat and re-arm the cast; a building whose floor collider attaches a few frames after the
// unit was created moves the epoch and re-arms it (decision 1384 — before the epoch was in
// the gate, that unit's wrong answer was cached for the session). A MISS never caches, so a
// unit standing on a tile whose collider hasn't streamed in keeps asking until it lands.
if let Some(c) = clamped.as_deref() {
if c.hit && c.xz == xz && c.y_written == t.translation.y {
let same_question = if legacy {
c.y_written == t.translation.y
} else {
c.seat_y == seat_y && c.epoch == epoch.get()
};
if c.hit && c.xz == xz && same_question {
held += 1;
continue;
}
if cost && !legacy {
if c.xz == xz && c.seat_y == seat_y {
armed += 1; // the world changed under a unit that didn't move
} else {
reseat += 1; // the unit moved, or was moved
}
}
}
let origin = t.translation + Vec3::Y * GROUND_CLAMP_UP;
let origin = Vec3::new(t.translation.x, seat_y + GROUND_CLAMP_UP, t.translation.z);
let reach = GROUND_CLAMP_UP + GROUND_CLAMP_DOWN;
cast += 1;
// The one-sided down-ray (decision 0970): a creature grounds like the player grounds — a
// face whose winding points away is no floor, or an idle NPC would stand mid-air on the
// very shell face the player mover now falls through.
let hit = world.ray_body(origin, Dir3::NEG_Y, reach);
if let Some(hit) = &hit {
hit_n += 1;
let y = origin.y - hit.distance; // the down-ray's hit point Y = feet on the surface
// Exact bit equality is deliberate, not a sloppy float compare: the question is
// "would the write change anything" — Bevy's change detection fires on the
// DerefMut regardless of value, so writing an equal Y every frame marked every
// standing creature's transform subtree dirty. An epsilon would answer a
// different question: a real sub-epsilon ground shift must still land.
if y != t.translation.y {
moved += 1;
t.translation.y = y;
// The down-ray's hit point Y = feet on the surface; no surface in reach = the seat, i.e.
// where the server put it.
let y = match &hit {
Some(h) => {
hit_n += 1;
origin.y - h.distance
}
None => seat_y,
};
// Exact bit equality is deliberate, not a sloppy float compare: the question is "would the
// write change anything" — Bevy's change detection fires on the DerefMut regardless of
// value, so writing an equal Y every frame marked every standing creature's transform
// subtree dirty. An epsilon would answer a different question: a real sub-epsilon ground
// shift must still land.
if y != t.translation.y {
moved += 1;
t.translation.y = y;
}
let state = GroundClamped {
xz,
seat_y,
y_written: t.translation.y,
hit: hit.is_some(),
epoch: epoch.get(),
};
match clamped.as_deref_mut() {
Some(c) => *c = state,
@@ -523,7 +568,7 @@ pub(in crate::net) fn ground_clamp_creatures(
// Per 0734's law (~10.5 ns per row visit), the walk itself is never the cost here: at ~800
// units it is ~8 µs. Only `ms` justifies the slice — quote it, not the counts.
eprintln!(
"[clamp-cost] visited={visited} skipped={skipped} held={held} cast={cast} hit={hit_n} moved={moved} ms={:.3}",
"[clamp-cost] visited={visited} skipped={skipped} held={held} cast={cast} reseat={reseat} armed={armed} hit={hit_n} moved={moved} ms={:.3}",
t0.elapsed().as_secs_f32() * 1000.0
);
}
@@ -535,6 +580,18 @@ fn clamp_cost_enabled() -> bool {
*ON.get_or_init(|| std::env::var_os("WOW_CLAMP_COST").is_some())
}
/// The A/B switch behind decision 1384: `WOW_CLAMP_SEAT=off` restores the pre-1384 clamp, which
/// measured from its own previous answer and cached a hit without dating it against the collider
/// set. That is the leg where a unit created inside a building before the building's floor collider
/// attached stays under the floor for the session (B197) — kept as the lever that reproduces the bug
/// on the fixed binary, so the fix's evidence never depends on two different builds.
fn clamp_seat_disabled() -> bool {
static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*OFF.get_or_init(|| {
std::env::var("WOW_CLAMP_SEAT").is_ok_and(|v| matches!(v.as_str(), "off" | "0"))
})
}
/// What [`mark_swimming_creatures`] reads per unit: identity, kind, pose, its own collision height
/// (`None` only on a unit's first frame, before the stamp), its own WMO-room claim (whose liquid may
/// answer for it — decision 0696), and whether it is already marked.
@@ -567,19 +624,27 @@ const CREATURE_SWIM_EXIT_BAND: f32 = 1.0 / 36.0;
#[derive(Component)]
pub(crate) struct CreatureSwimming;
/// The last ground clamp this unit took (decision 1357) — [`ground_clamp_creatures`]' cast gate.
/// All three fields compare by bit: the gate's question is "are the ray's inputs identical to the
/// cast that produced this", never "close enough".
#[derive(Component)]
pub(in crate::net) struct GroundClamped {
/// The last ground clamp this unit took (decisions 1357 + 1384) — [`ground_clamp_creatures`]' seat
/// and its cast gate. Every field compares by bit: the gate's question is "are the ray's inputs
/// identical to the cast that produced this", never "close enough".
#[derive(Component, Clone, Copy)]
pub(crate) struct GroundClamped {
/// Where the last cast stood.
xz: [f32; 2],
/// The Y standing after that cast (written or left) — an external write (the wire moving the
/// unit, a lift carrying it) breaks equality and re-arms the cast.
/// **The authoritative Y the answer was derived from** — the pose whoever owns this unit's
/// position last wrote, never the clamp's own output. This is what makes the clamp a pure
/// function of (server pose, colliders) instead of a ratchet that can only ever fall (1384).
pub(crate) seat_y: f32,
/// The Y standing after that cast (written or left). It differs from [`Self::seat_y`] by
/// exactly the clamp's own correction, so an *external* write is detectable as "the Y here is
/// not the one I left", which re-seats.
y_written: f32,
/// Only a HIT caches: a miss keeps casting, so a tile whose collider streams in late still
/// catches its standing units.
hit: bool,
/// The collider-set stamp the answer was computed against — a cached answer outlives neither
/// the unit's own pose nor the world it described.
epoch: u64,
}
/// Maintain [`CreatureSwimming`] on `Unit` creatures from the water over their feet (module docs on
@@ -626,6 +691,158 @@ pub(in crate::net) fn mark_swimming_creatures(
}
}
/// B197's mechanism, in a world small enough to assert on: a unit created inside a building whose
/// floor collider has not attached yet, and what happens to it when the floor lands.
///
/// The real thing is a race between two async collider builds during a loading screen; here it is
/// two `spawn`s and a `bump`, which is the same three facts — the terrain is under the unit, the
/// floor is not there yet, and it arrives later. Both halves of decision 1384 are load-bearing in
/// the second assertion: the seat is what lets the probe reach back up to the floor at all, and the
/// epoch is what makes anything re-ask after the world changed under a unit standing still.
#[cfg(test)]
mod under_floor {
use avian3d::prelude::*;
use benilla_protocol::EntityKind;
use bevy::ecs::system::RunSystemOnce;
use bevy::prelude::*;
use super::{ground_clamp_creatures, GroundClamped};
use crate::net::NetEntity;
use benilla_world::collision::ColliderEpoch;
/// Auberdine's geometry, to the yard: terrain at 6.98, the building's floor 2.08 above it, and
/// the server's Z for the NPCs inside 0.08 above *that* (the small float every wire Z carries).
const TERRAIN_Y: f32 = 6.98;
const FLOOR_Y: f32 = 9.06;
const WIRE_Y: f32 = 9.14;
/// A 10×10 up-wound quad at `y` — a floor the one-sided down-ray will stand on.
fn floor_at(app: &mut App, y: f32) -> Entity {
let verts = vec![
Vec3::new(-5.0, y, -5.0),
Vec3::new(5.0, y, -5.0),
Vec3::new(5.0, y, 5.0),
Vec3::new(-5.0, y, 5.0),
];
app.world_mut()
.spawn((
RigidBody::Static,
Collider::trimesh(verts, vec![[0u32, 2, 1], [0, 3, 2]]),
Transform::default(),
))
.id()
}
/// A world with the terrain in and the building's floor still building, plus one idle NPC
/// standing at the Z the server sent for it.
fn half_arrived_world() -> (App, Entity) {
let mut app = App::new();
app.add_plugins((
MinimalPlugins,
bevy::transform::TransformPlugin,
bevy::asset::AssetPlugin::default(),
bevy::scene::ScenePlugin,
PhysicsPlugins::new(bevy::app::PostUpdate),
));
app.init_asset::<Mesh>().init_resource::<ColliderEpoch>();
// `update()` never runs plugin `finish()`, where avian seats its diagnostics resources —
// and the second `update()` below (the one that lands the late floor) does step physics.
app.finish();
app.cleanup();
floor_at(&mut app, TERRAIN_Y);
let npc = app
.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
Transform::from_xyz(0.0, WIRE_Y, 0.0),
))
.id();
app.update(); // seats Position/Rotation and the collider trees
(app, npc)
}
fn clamp(app: &mut App) {
app.world_mut()
.run_system_once(ground_clamp_creatures)
.expect("run the clamp");
}
fn y_of(app: &App, e: Entity) -> f32 {
app.world().get::<Transform>(e).unwrap().translation.y
}
#[test]
fn a_unit_sunk_by_a_late_floor_stands_back_up_when_it_lands() {
let (mut app, npc) = half_arrived_world();
// The window B197 lives in: the only surface under this NPC is the terrain the building
// sits on, so that is what the clamp finds. This drop is CORRECT given what exists.
clamp(&mut app);
assert_eq!(
y_of(&app, npc),
TERRAIN_Y,
"with only terrain built, the clamp grounds onto terrain"
);
assert_eq!(
app.world().get::<GroundClamped>(npc).unwrap().seat_y,
WIRE_Y,
"the seat is the server's Z, not the answer the clamp just wrote"
);
// Nothing moves the NPC. The building's floor collider attaches, and stamps the world.
floor_at(&mut app, FLOOR_Y);
app.world_mut().resource_mut::<ColliderEpoch>().bump();
app.update();
// The fix: it comes back up on its own. Before 1384 it stayed on the terrain for the rest
// of the session — the cached hit was never re-asked, and even re-asked it would have
// measured from down there.
clamp(&mut app);
assert_eq!(
y_of(&app, npc),
FLOOR_Y,
"the floor arrived under an NPC that never moved; it belongs on it"
);
}
#[test]
fn a_settled_unit_is_not_re_cast_while_the_world_holds_still() {
// 1357's saving, still intact: the gate holds an answer whose three inputs are unchanged.
let (mut app, npc) = half_arrived_world();
clamp(&mut app);
let before = *app.world().get::<GroundClamped>(npc).unwrap();
clamp(&mut app);
let after = *app.world().get::<GroundClamped>(npc).unwrap();
assert_eq!(
(before.seat_y, before.y_written, before.epoch),
(after.seat_y, after.y_written, after.epoch),
"a held unit's memo is untouched — nothing re-asked the ground"
);
}
#[test]
fn a_unit_with_no_ground_in_reach_sits_where_the_server_put_it() {
// The miss branch: an airborne/unstreamed unit is left at its seat, never at whatever the
// clamp last wrote. `GROUND_CLAMP_UP + GROUND_CLAMP_DOWN` below the seat is empty air here.
let (mut app, npc) = half_arrived_world();
app.world_mut()
.get_mut::<Transform>(npc)
.unwrap()
.translation
.y = TERRAIN_Y + 40.0;
clamp(&mut app);
assert_eq!(
y_of(&app, npc),
TERRAIN_Y + 40.0,
"no surface in reach ⇒ the wire's Z stands"
);
}
}
#[cfg(test)]
mod tests {
use super::*;
+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 ────────────────────────────────────────────────────────────────────────────
+11 -1
View File
@@ -209,6 +209,16 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// count. The items were always crossing; the measurement improved. That is the only kind of raise
/// that is not a retreat — a raise for a NEW leak is the failure this number exists to catch.
///
/// And 157 → 158: `collision::ColliderEpoch`, a PUBLISH by 1164's test (decision 1384). It is the
/// stamp on the world's collider set — "the geometry you last asked has changed" — and the whole
/// point of it is that a *cached* collision answer must not outlive the world it described. The
/// engine owns the fact (the streamer's attach queue is what changes the set), the game owns two of
/// the three parties: the creature ground clamp reads it, and the GameObject hull lane
/// (`entities::attach`) is the one collider insert outside the streamer's queue, so it must be able
/// to stamp. Keeping it engine-private would mean either a game-side collider that no cache can see
/// arrive — which is B197's bug with a different collider class — or an engine system reaching into
/// game components to invalidate them, which crosses this line the other way and worse.
///
/// And 156 → 157: `mat_anim_table::MatAnimTable`, a PUBLISH by 1164's test (decision 1381). The
/// mat-anim delta table replaced per-frame material mutation, and registration must happen where
/// materials are BUILT — which for WMO GameObject props (transport interiors) is a game-side
@@ -237,7 +247,7 @@ fn is_instrument_consumer(rel: &str) -> bool {
/// input, which is game-side; a one-field resource is the smallest honest expression of that. The
/// alternative was ordering a game system against an engine system, which crosses this same line
/// *and* publishes a schedule point to do it.
const CEILING: usize = 157;
const CEILING: usize = 158;
/// How far under [`CEILING`] the real count may sit before this test asks for the ceiling to be
/// lowered. Slack, not tolerance: it keeps a single closure from failing the gate, while making it
+136 -23
View File
@@ -297,6 +297,95 @@ fn layout_verify_enabled() -> bool {
*ON.get_or_init(|| cfg!(test) || std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1"))
}
/// `WOW_LAYOUT_PROF=1`'s **preamble** split — the `[layout-pre]` line, one per LET-THROUGH resolve.
///
/// `[layout-prof]` reports the rounds, which 1350 already made proportional to what moved. This
/// reports everything *before* them: the fixed whole-roster work every let-through solve pays
/// whatever moved, which 1383 priced at ~1.9 ms/solve at a live roster and named as the next cut
/// without ever splitting it. A cut needs to know which of the eight walks it is buying back, so
/// the split is the instrument that has to exist first.
///
/// Off (the default) it takes no clock reads at all — [`Self::lap`] returns on the `on` test, so an
/// unprofiled resolve pays one bool check per phase.
#[derive(Default)]
struct PreambleProf {
on: bool,
last: Option<std::time::Instant>,
/// The GameTooltip auto-size pre-pass (`layout_tooltips`) — a whole-frame-roster filter.
tooltip: u128,
/// `OnSizeChanged`'s "before" snapshot — a whole-`scripts`-map filter.
watched: u128,
/// The ids rebuild + the per-frame scale/clamp sync into `layout_inputs`.
ids: u128,
/// The ScrollFrame override map — another whole-frame-roster walk.
scroll: u128,
/// `region_resolved`'s liveness retain.
retain: u128,
/// The per-call solve plan rebuild.
plan: u128,
/// `LayoutScope::begin` — seven per-id `Vec`s cleared/resized.
begin: u128,
/// The fingerprint + scope walk over every live FRAME (double-hashed: aggregate + per-node).
fp_frames: u128,
/// The same over every anchored REGION — the biggest roster (10,438 anchored at the SW pin,
/// and by far the biggest single phase: 0.45–1.0 ms of the ~1.0–1.4 ms walk).
fp_regions: u128,
}
impl PreambleProf {
fn new(on: bool) -> Self {
Self {
on,
last: on.then(std::time::Instant::now),
..Default::default()
}
}
/// Microseconds since the previous lap (0 when off).
fn lap(&mut self) -> u128 {
if !self.on {
return 0;
}
let now = std::time::Instant::now();
self.last
.replace(now)
.map_or(0, |t| now.duration_since(t).as_micros())
}
/// One line per let-through resolve, printed once the gate's verdict is known — `skips=1` is
/// the walk that concluded "nothing moved" and returns without solving, which costs the same
/// preamble as one that does.
fn report(&self, skips: bool, frames: usize, regions: usize) {
if !self.on {
return;
}
let total = self.tooltip
+ self.watched
+ self.ids
+ self.scroll
+ self.retain
+ self.plan
+ self.begin
+ self.fp_frames
+ self.fp_regions;
eprintln!(
"[layout-pre] skips={} frames={frames} anchored={regions} total_us={total} \
tooltip={} watched={} ids={} scroll={} retain={} plan={} begin={} \
fp_frames={} fp_regions={}",
u8::from(skips),
self.tooltip,
self.watched,
self.ids,
self.scroll,
self.retain,
self.plan,
self.begin,
self.fp_frames,
self.fp_regions
);
}
}
/// `OnSizeChanged`'s "after": turn the entry-vs-now diff of the watched frames into queued
/// `(id, width, height)` fires ([`Model::pending_size_changed`]).
///
@@ -354,10 +443,18 @@ impl UiScript {
return;
}
let epoch_at_entry = model.layout_epoch;
// Past tier 1 ⇒ this call pays the whole-roster preamble, whatever the fingerprint goes on
// to decide. Counted here rather than at the solve, because the walk IS the cost (1385).
// Not gated on `verify`: a verify build re-walks skipped resolves and must say so.
model.layout_gate_walks = model.layout_gate_walks.wrapping_add(1);
// `WOW_LAYOUT_PROF=1`'s preamble split — see [`PreambleProf`]. Started here, so the clock
// covers everything the tier-1 gate above did NOT skip.
let mut pre = PreambleProf::new(layout_prof_enabled());
// The GameTooltip auto-size + right-flush pre-pass (decision 0274): writes tooltip frame
// sizes + right-column anchor offsets from the measure round-trip's cached extents, so
// the graph below solves them like any other frame.
super::tooltip::layout_tooltips(model);
pre.tooltip = pre.lap();
// ── `OnSizeChanged`'s "before" ───────────────────────────────────────────────────────
// The client fires it from `ApplyRect 0x76b580`, per rect application. Ours is a batch
// fixpoint, so the faithful *mechanism* — "this frame's resolved size moved" — is the
@@ -374,6 +471,7 @@ impl UiScript {
.filter(|(_, kinds)| kinds.contains("OnSizeChanged"))
.map(|(&h, _)| (h, model.resolved.get(&h).copied()))
.collect();
pre.watched = pre.lap();
let Model {
arena,
layout_inputs,
@@ -423,6 +521,7 @@ impl UiScript {
input.extent_y = screen.top;
}
}
pre.ids = pre.lap();
// The ScrollFrame mechanism (decision 0112): a live ScrollFrame with a live scroll child
// overrides the child's own anchors for this solve — `SetScrollChild` pins the child TOPLEFT
@@ -453,6 +552,7 @@ impl UiScript {
Anchor::new(Point::TopLeft, id, Point::TopLeft, 0.0, state.vertical),
);
}
pre.scroll = pre.lap();
// Alternating frame/region rounds, run to a CHANGE-DRIVEN FIXPOINT: the real client
// resolves ONE layout graph in which frames and regions anchor each other freely (the
@@ -480,6 +580,7 @@ impl UiScript {
arena.region(*rh).is_some()
&& region_data.get(rh).is_some_and(|d| !d.anchors.is_empty())
});
pre.retain = pre.lap();
// The per-frame solve plan, resolved ONCE per call rather than per round: each live
// frame's id, a borrow of its (already scale/clamp-synced) layout input, and its
// ScrollFrame anchor override if it is a scroll child. The round loop below then walks a
@@ -492,6 +593,7 @@ impl UiScript {
.map(|input| (h, id, input, scroll_child_anchor.get(&h).copied()))
})
.collect();
pre.plan = pre.lap();
// ── The change gate ───────────────────────────────────────────────────────────────────
// Fingerprint exactly what the rounds below read, and skip them outright when nothing has
@@ -507,14 +609,20 @@ impl UiScript {
// which together distil `frame_to_id`, frame liveness,
// `effective_scale`, `clamped_to_screen`, and the
// ScrollFrame child + vertical offset;
// * `id_to_region` × `region_resolved` — the region rects seeded as externals;
// * `region_data` — each region's anchors, explicit size, and measured
// text extent;
// * `arena.region(rh)` — liveness and owner (a dead region drops out of the
// sweep; the owner supplies the fallback edges).
// `resolved` / `region_resolved` are the previous pass's OUTPUT carried forward as the
// 0294 seed; at convergence seed and output are equal, so re-running against an unchanged
// input set is guaranteed to reproduce them — which is precisely what makes the skip safe.
//
// **INPUTS ONLY — the 0294 seeds are deliberately NOT hashed** (decision 1385). They are
// the previous pass's OUTPUT, and 0294's own property ("every rect is recomputed purely
// from (inputs, externals), never from its own prior value") makes a converged pass's
// rects a pure function of the inputs: the seeds change how many ROUNDS convergence
// takes, never what it converges to. Hashing them therefore added no verdict the input
// half does not already give — and it cost two extra whole-roster walks per mutated
// frame, because a solve necessarily outgrows the seeds it hashed, so neither that pass
// nor the settling pass behind it could close tier 1. (`resolved`, the FRAME rects, was
// never in the read set at all — the seed half was asymmetric as well as redundant.)
//
// ── …and, in the same walk, the SCOPE (decision 1350) ─────────────────────────────────
// Each node's own inputs are hashed a second time on their own, into
@@ -526,6 +634,7 @@ impl UiScript {
// Sized past `next_id` because the region walk below MINTS ids (see there) — every id it
// can mint is one more than the anchored regions it walks.
scope.begin(*next_id as usize + region_data.len() + 1);
pre.begin = pre.lap();
let mut fp = InputFingerprint::default();
fp.rect(*screen);
fp.feed(plan.len() as u64);
@@ -560,6 +669,7 @@ impl UiScript {
}
}
}
pre.fp_frames = pre.lap();
let mut fed_regions = 0u64;
// The round loop's region roster, built in this same walk (decision 1350) — see
// [`RegionNode`]. Only LIVE, anchored regions: an anchor-less one is invisible to the
@@ -656,13 +766,9 @@ impl UiScript {
// The count of FED entries (not the map's len — see the vacuous-entry skip above), so an
// entry leaving the anchored set is a change even if a sibling enters the same frame.
fp.feed(fed_regions);
for (&id, rh) in id_to_region.iter() {
if let Some(r) = region_resolved.get(rh) {
fp.feed(u64::from(id));
fp.rect(*r);
}
}
pre.fp_regions = pre.lap();
let gate_skips = *layout_fingerprint == Some(fp);
pre.report(gate_skips, plan.len(), regions.len());
// The tier-1/tier-2 cross-check (only reachable under verify when tier 1 judged quiet):
// the epoch said nothing layout-visible was written, so the fingerprint must agree — a
// divergence is a mutation path missing its `Model::touch_layout()`, named here at the
@@ -674,12 +780,11 @@ impl UiScript {
fingerprint moved — a layout write path is missing its touch_layout()"
);
}
// Tier 1 closes ONLY on a settled frame (the fingerprint verdict `gate_skips`): the fp is
// hashed over the SEEDS (last pass's outputs), so the first resolve after a real change
// stores a fingerprint the very next, mutation-free resolve would not reproduce — the
// seeds grew under it. That next resolve pays one fingerprint (exactly today's price),
// proves the input set has settled, and closes the epoch here; every later quiet frame
// skips at the u64 compare.
// Nothing in the read set moved since the last CONVERGED resolve, so the rects it left are
// still this frame's answer: close tier 1 and skip. (A converged solve closes it too — see
// the convergence arm below. This arm is the one that absorbs an epoch bump whose write
// changed no input: an id mint, an anchor-less region's creation, a re-`SetPoint` the
// setters' own compare let through.)
if gate_skips {
*layout_epoch_resolved = Some(epoch_at_entry);
if !verify {
@@ -1026,13 +1131,21 @@ impl UiScript {
// settled, and seeding the next pass's "unchanged ⇒ unmoved" argument from it
// would be seeding it from a lie.
scope.commit(scope_nodes, *screen);
// Tier 1 closes only on a `gate_skips` frame: a real solve (`!gate_skips`) hashed
// `fp` over now-outgrown seeds — see the gate above. The re-store here is for the
// VERIFY path, whose full re-run of a settled frame passed through the state
// clears; production returned at the gate. Either mode leaves the same state.
if gate_skips {
*layout_epoch_resolved = Some(epoch_at_entry);
}
// **A CONVERGED SOLVE CLOSES TIER 1** (decision 1385) — unconditionally, not only
// on the `gate_skips` re-run. The fingerprint above is hashed over INPUTS alone,
// and the rounds just drove those inputs to their fixpoint, so the fp stored on
// the line above is exactly the one the next mutation-free resolve recomputes:
// there is nothing left for a settling pass to discover. Closing here is what
// makes a per-frame layout write cost ONE whole-roster walk instead of three
// (solve, settle, skip) — the castbar's spark, and every addon that animates a
// region, used to pay all three every frame.
//
// `epoch_at_entry` (not the live epoch) keeps it conservative: anything that
// touched the layout WHILE this pass ran — the tooltip pre-pass, a lazily minted
// region — leaves the epoch ahead of the stored value, so tier 1 re-opens next
// call exactly as it should. The cycle-bail path below never reaches here, so a
// graph left mid-flight still re-resolves.
*layout_epoch_resolved = Some(epoch_at_entry);
queue_size_changes(&watched, resolved, frame_to_id, pending_size_changed);
return;
}
+7 -4
View File
@@ -86,11 +86,14 @@ pub(super) fn ensure_measured(lua: &Lua, rh: RegionHandle) {
key: req.key,
};
if let Some(d) = model.region_data.get_mut(&rh) {
if d.measured != Some(new) {
d.measured = Some(new);
let moved = super::types::MeasuredText::layout_moved(d.measured, new);
// The KEY always lands — this IS the measure cache, and a stale key re-requests forever.
d.measured = Some(new);
if moved {
// Measured extents are the auto-size axes' inputs — the layout gate's read set, the
// same touch `set_measured_text` does. Guarded on a real change so a steady poll does
// not dirty the layout every frame.
// same touch `set_measured_text` does. Guarded on a change the LAYOUT can see: a
// re-measure that returns the same box (a same-width countdown tick) must not open
// tier 1, or it costs a whole-roster hash to conclude nothing moved (decision 1385).
model.touch_layout();
}
}
+11 -1
View File
@@ -906,7 +906,9 @@ impl UiScript {
natural_w,
key,
};
changed |= d.measured != Some(new);
// The KEY always lands (or the region re-requests its own measure forever); the
// EPOCH moves only if the laid-out extent did — see `MeasuredText::layout_moved`.
changed |= MeasuredText::layout_moved(d.measured, new);
d.measured = Some(new);
}
}
@@ -1082,6 +1084,14 @@ impl UiScript {
self.model_ref().layout_solves
}
/// How many resolves got past **tier 1** and paid the whole-roster preamble
/// ([`Model::layout_gate_walks`], decision 1385) — the gate's true cost counter, ≥
/// [`Self::layout_solves`] because a walk that concludes "nothing moved" pays the same
/// preamble and never reaches the solve counter.
pub fn layout_gate_walks(&self) -> u64 {
self.model_ref().layout_gate_walks
}
/// Total fixpoint ROUNDS across every solve ([`Model::layout_rounds`]) — a solve costs
/// rounds × the whole graph, so the ratio against [`Self::layout_solves`] is the per-pass
/// depth.
+30 -5
View File
@@ -89,11 +89,16 @@ pub(crate) struct Model {
/// that MOVED rather than the whole graph (decision 1350; see `script::layout::LayoutScope`).
/// Tiers 1 and 2 above decide *whether* to solve; this decides *what*.
pub(crate) layout_scope: super::layout::LayoutScope,
/// The [`Self::layout_epoch`] value the last SETTLED resolve closed on — one whose
/// fingerprint matched, proving the input set (seeds included) has stopped moving. `None`
/// forces the next resolve through tier 1: the initial state, a cycle-bailed pass, and every
/// real solve (whose stored fingerprint answers for now-outgrown seeds — layout.rs, "Tier 1
/// closes ONLY on a settled frame").
/// The [`Self::layout_epoch`] value the last CONVERGED resolve closed on. `None` forces the
/// next resolve through tier 1: the initial state, a cycle-bailed pass, and
/// [`super::UiScript::force_full_layout_resolve`].
///
/// **Every converged resolve closes tier 1** (decision 1385) — a real solve as much as a
/// skipping one. It could not, while the fingerprint was hashed over the 0294 seeds as well
/// as the inputs: a solve outgrew the value it had just stored, so a mutated frame paid three
/// whole-roster walks (solve, settle, skip) instead of one. Hashing inputs alone makes the
/// stored fingerprint exactly the one the next mutation-free resolve recomputes, so a
/// per-frame layout write — the castbar's spark, any addon that animates a region — costs one.
pub(crate) layout_epoch_resolved: Option<u64>,
/// How many times the change gate has LET A RESOLVE THROUGH. `UiScript::resolve` is called
/// unconditionally every frame, so the gap between call count and this is the gate's whole
@@ -101,6 +106,25 @@ pub(crate) struct Model {
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
pub(crate) layout_solves: u64,
/// How many times a resolve got **past tier 1** — the count of whole-roster preamble WALKS
/// (decision 1385). This, not [`Self::layout_solves`], is the honest cost counter for the
/// gate: a walk that ends in `gate_skips` still rebuilt the ids/plan, re-synced every frame's
/// scale and re-hashed all ~10k anchored regions to conclude "nothing moved" — ~1.0 ms at the
/// Stormwind pin — and `layout_solves` cannot see it, because it counts only the walks that
/// went on to solve.
///
/// The castbar pin is why it exists: one moving spark cost **three** walks per frame and only
/// two of them were solves, so the counter the tests asserted on under-reported the bug by a
/// third. `resolve_bench::a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui`
/// is the guard that reads it.
///
/// **Unlike [`Self::layout_solves`], this is NOT verify-independent.** Under
/// `layout_verify_enabled` a tier-1-clean resolve falls through and walks anyway, so a verify
/// build counts walks production never pays — which is the honest reading (the walk happened)
/// but makes the number useless as an assertion there. Assert on it only from a crate that
/// consumes `benilla-ui` as a dependency, where `cfg!(test)` is false and the count is
/// production's; inside this crate's own tests, assert on `layout_solves`.
pub(crate) layout_gate_walks: u64,
/// The SCOPE of the last solve that ran: `(frames solved, regions swept)` — decision 1350's
/// own meter, and the number its gate asserts on. `layout_solves` says how often the gate let
/// a solve through and `layout_rounds` how deep each went; this says how WIDE, which is the
@@ -1100,6 +1124,7 @@ impl Model {
layout_last_scope: (0, 0),
layout_epoch_resolved: None,
layout_solves: 0,
layout_gate_walks: 0,
layout_rounds: 0,
resolved: HashMap::new(),
link_spans: HashMap::new(),
+11 -1
View File
@@ -428,8 +428,10 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
} else {
0.0
};
let (w0, h0) = (input.width, input.height);
input.width = (input.width + dw).max(1.0);
input.height = (input.height + dh).max(1.0);
let mut moved = input.width.to_bits() != w0.to_bits() || input.height.to_bits() != h0.to_bits();
// The planted edge: a frame anchored by its LEFT that is gripped on the LEFT has to move too,
// or the resize would push the right edge instead. Only the gripped axis shifts.
if !input.anchors.is_empty() && (sz.left || sz.bottom) {
@@ -441,8 +443,16 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
a.y_off += dy;
}
}
moved |= (sz.left && dx != 0.0) || (sz.bottom && dy != 0.0);
}
// The zero-delta return above is on the RAW cursor delta, which is not the same question: a
// single-axis grip dragged purely across its axis gives `dw == dh == 0`, and so does a grip
// held past `.max(1.0)` saturation. Both used to bump the epoch and then hash all ~10k
// anchored regions to conclude nothing had moved — the castbar's bug class in miniature, for
// every frame of such a drag (decision 1385).
if moved {
model.touch_layout();
}
model.touch_layout();
model.sizing = Some(FrameSizing { sample: pos, ..sz });
}
@@ -328,26 +328,106 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// Tier 1 of the gate (decision 0740): the mutation epoch. The epoch closes only on a SETTLED
/// resolve (one whose fingerprint matched — the first resolve after a change hashes outgrown
/// seeds, so it may not close), and from then on a quiet frame skips at a `u64` compare without
/// computing the fingerprint at all.
/// Tier 1 of the gate (decision 0740): the mutation epoch. **Any CONVERGED resolve closes it**
/// (decision 1385) — the fingerprint is hashed over inputs alone, so a solve cannot outgrow the
/// value it stores — and from then on a quiet frame skips at a `u64` compare without computing
/// the fingerprint at all.
fn tier_one_closed(s: &UiScript) -> bool {
let m = s.lua().app_data_ref::<Model>().expect("model app_data");
m.layout_epoch_resolved == Some(m.layout_epoch)
}
/// **The castbar law** (decision 1385, ledger B283): a region that MOVES every frame — the classic
/// OnUpdate animation idiom, our own `CastingBarSpark:SetPoint`, and every addon that slides a
/// texture — must cost exactly **one** let-through resolve per frame.
///
/// It used to cost three. The fingerprint was hashed over the 0294 SEEDS as well as the inputs, so
/// a solve necessarily outgrew the fingerprint it had just stored: neither that pass nor the
/// settling pass behind it could close tier 1, and the frame paid solve + settle + skip — three
/// whole-roster walks at ~1.0–1.4 ms each. Measured live at the Stormwind pin: **+4.2 ms of CPU
/// per frame for one 32×32 spark**, on a default UI with no addons loaded.
///
/// This is the regression guard for that whole bug class. `solves` is the honest counter — a
/// wasted walk that concludes "nothing moved" still costs the full preamble — so the assertion is
/// on the count, never on a duration (0735: milliseconds are not evidence of a scope regression).
#[test]
fn a_region_moving_every_frame_costs_exactly_one_solve_per_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.run(
r#"
spark = parent:CreateTexture(nil, "OVERLAY")
spark:SetWidth(32); spark:SetHeight(32)
spark:SetPoint("CENTER", parent, "LEFT", 0, 2)
"#,
)
.expect("spark");
s.resolve();
assert!(tier_one_closed(&s), "the setup must settle in one resolve");
// Ten frames of the castbar's own inner loop: one region re-pointed to a NEW offset, then the
// host's per-frame resolve. Nothing else in the model moves.
//
// The offsets start at 1.7, not 0 — a `frame * 1.7` sequence opens with a re-write of the
// seed value, which the setters' bit-exact compare absorbs (no epoch bump, no solve at all).
// That is the *right* answer and `an_idempotent_setter_call_leaves_tier_one_closed` pins it;
// here it would have measured the guard instead of the gate.
for frame in 0..10 {
let before = solves(&s);
s.run(&format!(
r#"spark:SetPoint("CENTER", parent, "LEFT", {}, 2)"#,
f64::from(frame + 1) * 1.7
))
.expect("spark move");
s.resolve();
assert_eq!(
solves(&s) - before,
1,
"frame {frame}: a single moving region must cost ONE solve, not the \
solve+settle+skip trio the seeds-in-the-fingerprint law used to force"
);
assert!(
tier_one_closed(&s),
"frame {frame}: the converged solve must close tier 1, so a second getter in the \
same tick skips at the u64 compare instead of re-walking the whole roster"
);
}
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The other half of the same law: once it has settled, an untouched frame costs NOTHING — no
/// solve, and (tier 1) not even the fingerprint walk that would decide so.
#[test]
fn a_quiet_frame_after_a_move_costs_no_solve_at_all() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve();
s.run(r#"parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 33, -44)"#)
.expect("move");
s.resolve();
let settled = solves(&s);
for _ in 0..5 {
s.resolve();
assert_eq!(solves(&s), settled, "a quiet frame must not solve");
assert!(tier_one_closed(&s), "and must not reopen tier 1");
}
}
#[test]
fn a_settled_resolve_closes_tier_one_and_a_real_write_reopens_it() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
setup(&s);
s.resolve(); // the change-driven solve (fingerprint hashed over pre-solve seeds)
s.resolve(); // the settling pass — fingerprint matches, tier 1 closes
// ONE resolve closes the epoch (decision 1385): the fingerprint is hashed over inputs alone,
// and the rounds just drove those inputs to their fixpoint, so there is nothing left for a
// settling pass to discover. This used to need two.
s.resolve();
assert!(
tier_one_closed(&s),
"a settled resolve must close the epoch"
"a converged resolve must close the epoch by itself"
);
s.run("parent:SetWidth(150)").expect("resize");
@@ -356,8 +436,7 @@ fn a_settled_resolve_closes_tier_one_and_a_real_write_reopens_it() {
"a real layout write must reopen tier 1"
);
s.resolve();
s.resolve();
assert!(tier_one_closed(&s), "and settling closes it again");
assert!(tier_one_closed(&s), "and one resolve closes it again");
}
#[test]
+24
View File
@@ -565,6 +565,30 @@ pub(crate) struct MeasuredText {
pub(crate) key: u64,
}
impl MeasuredText {
/// Did the part of this measurement the **layout** reads move? (decision 1385)
///
/// Only `w`/`h` are layout inputs — they are the auto-size axes, and the only fields
/// `InputFingerprint` feeds (`script::layout`'s region walk hashes `m.w`/`m.h`, nothing else).
/// `key` is the measure CACHE's business and `natural_w` is `GetStringWidth`'s; neither can
/// move a rect.
///
/// The distinction is load-bearing, not tidiness. A measure request is only ever *issued* when
/// the stored key mismatches, and `MeasuredText`'s derived `PartialEq` includes `key` — so
/// `d.measured != Some(new)` is **structurally always true** at both write sites, and touching
/// the epoch on it meant every answered measure opened tier 1. A FontString whose text changes
/// to something the same size does that on a cadence: a buff countdown ticking `"58s" → "57s"`,
/// a colour-code swap, any same-width digit. Tier 1 then went dirty and tier 2 hashed all
/// ~10k anchored regions to conclude nothing had moved — ~1 ms, `solves=0`, per tick, times
/// the number of unsynchronised timers on screen. The same shape 0740's own doc records the
/// bag-hover loop paying, and a sibling of the castbar's (1385).
pub(crate) fn layout_moved(before: Option<Self>, after: Self) -> bool {
before.is_none_or(|b| {
b.w.to_bits() != after.w.to_bits() || b.h.to_bits() != after.h.to_bits()
})
}
}
impl RegionData {
/// The measure-cache key for the region's CURRENT text/font/wrap/outline — the one recipe both
/// sides of the measure round-trip share. [`super::UiScript::fontstrings_needing_measure`]
+50
View File
@@ -16,6 +16,9 @@ use avian3d::character_controller::move_and_slide::{
};
use avian3d::prelude::*;
use bevy::ecs::component::Component;
use bevy::ecs::lifecycle::RemovedComponents;
use bevy::ecs::resource::Resource;
use bevy::ecs::system::ResMut;
use bevy::math::{Dir3, Quat, Vec3};
mod one_sided;
@@ -46,6 +49,53 @@ pub(crate) fn camera_layers() -> CollisionLayers {
CollisionLayers::new(CollisionLayer::Camera, LayerMask::ALL)
}
/// **How many times the world's collider set has changed** — bumped when [`crate::terrain_stream`]
/// attaches a batch of built colliders, and when any collider is removed (a tile or placement
/// streaming out, an entity despawning).
///
/// It exists because a cached collision *answer* is only as good as the world it was computed
/// against. The one consumer today is the creature ground clamp's cast gate (decision 1357), which
/// holds a hit while the unit's own inputs are unchanged: the unit's position and the colliders
/// under it. It could see the first half change and not the second, so a unit that took its answer
/// from a half-arrived world — the terrain under a building, before the building's own floor
/// collider had attached — held that answer forever and stood under the floor for the rest of the
/// session. A stamp on the collider set closes that: an answer computed at one stamp is re-asked at
/// the next.
///
/// A `u64` counter, not a dirty flag: the gate compares its own recorded stamp, so it never has to
/// coordinate with other readers about who clears it.
#[derive(Resource, Default)]
pub struct ColliderEpoch(u64);
impl ColliderEpoch {
/// The current stamp — recorded alongside a cached collision answer, compared to decide whether
/// that answer still describes this world.
pub fn get(&self) -> u64 {
self.0
}
/// **The collider set changed** — call this from any lane that inserts a collider outside the
/// streamer's own attach queue (the GameObject hull lane is the one such lane today, decision
/// 0761). A lane that forgets leaves a cached answer dated against a world that no longer
/// exists; the cost of an over-eager bump is one re-cast.
pub fn bump(&mut self) {
self.0 = self.0.wrapping_add(1);
}
}
/// Bump [`ColliderEpoch`] for the *removal* half (the attach half is published by the streamer's
/// own attach loop, which already counts what it did). Despawning an entity emits removal events
/// for its components, so this covers tile/placement stream-out and object destroys alike; reading
/// the queue is O(removals), which is zero on almost every frame.
pub(crate) fn track_collider_removals(
mut removed: RemovedComponents<Collider>,
mut epoch: ResMut<ColliderEpoch>,
) {
if removed.read().count() > 0 {
epoch.bump();
}
}
/// Marks a static trimesh collider whose triangles **receive ground decals** (the selection ring).
/// The reference's decal collector is byte-verified (wow-re selection-circle RE, §5-cross-checked):
/// its box query gathers **terrain triangles + WMO group faces** — M2 doodads/GameObjects/units are
@@ -155,6 +155,14 @@ pub(super) fn finish_colliders(
activity.colliders_attached += attached;
activity.collider_ms += t0.elapsed().as_secs_f32() * 1000.0;
}
// The world a cached collision answer was computed against just changed — see
// [`crate::collision::ColliderEpoch`]. This is the attach half; removals are stamped by
// `track_collider_removals`.
if attached > 0 {
if let Some(mut epoch) = world.get_resource_mut::<crate::collision::ColliderEpoch>() {
epoch.bump();
}
}
}
/// `WOW_NO_DOODAD_BODIES=1` — spawn NO doodad/prop hulls at all (decision 1367's premise
+6 -1
View File
@@ -207,7 +207,12 @@ impl Plugin for WorldFoundation {
// The dev state (decision 0026): the always-present config layer eight subsystems
// read, whose defaults ARE the player behaviour. The debug panel is only its editor
// and may not be installed at all.
.init_resource::<crate::dev_state::DebugState>();
.init_resource::<crate::dev_state::DebugState>()
// The collider-set stamp every cached collision answer is dated against
// (`collision::ColliderEpoch`). Tracked in `First` so a removal is stamped before any
// consumer runs; the attach half is stamped by the streamer's own attach loop.
.init_resource::<crate::collision::ColliderEpoch>()
.add_systems(First, crate::collision::track_collider_removals);
// Avian's pre-step copy of Bevy's transform propagation is OFF by default (the 1370
// bracket surfaced the lane; the 3-round SW split then measured the skip at −0.40
// cpu_ms, negative in every round): `PhysicsTransformPlugin` re-registers