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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -160,9 +160,16 @@ pub(super) fn drive_script(
|
||||
Option<Res<crate::run_mode::CaptureMode>>,
|
||||
Res<super::UiCostWanted>,
|
||||
),
|
||||
// This frame's `<Minimap>` widget slot, parked for `minimap::emit_minimap` (the UiQuadAppend
|
||||
// producer that fills the hole with tile/arrow quads — decision 0203 phase 1).
|
||||
mut minimap_widget: ResMut<crate::minimap::MinimapWidget>,
|
||||
// The append-lane holes this pass parks for their UiQuadAppend producers, tupled for the
|
||||
// same param-budget squeeze as `run`:
|
||||
// · the `<Minimap>` widget slot (`minimap::emit_minimap` fills it with tile/arrow quads —
|
||||
// decision 0203 phase 1);
|
||||
// · the autocast shine sites (`autocast_shine::emit_shine` draws the spark trails there —
|
||||
// decision 1383, B282).
|
||||
mut parked: (
|
||||
ResMut<crate::minimap::MinimapWidget>,
|
||||
ResMut<crate::autocast_shine::ShineSites>,
|
||||
),
|
||||
// The seam scale the engine's text-metric caches were answered under. When `s` moves (window
|
||||
// resize / fullscreen toggle / uiScale change), every cached measure is stale — see the
|
||||
// invalidation below.
|
||||
@@ -384,6 +391,9 @@ pub(super) fn drive_script(
|
||||
let mut out = Vec::new();
|
||||
let mut assets = world_assets;
|
||||
let mut minimap_slot = None;
|
||||
// This frame's autocast shine sites (decision 1383) — refilled by the loop below like the
|
||||
// minimap slot; the settled and spliced paths above keep last conversion's set.
|
||||
let mut shine_sites: Vec<crate::autocast_shine::ShineSite> = Vec::new();
|
||||
// Hyperlink spans collected while rasterizing message-line Text quads (frame-targeted only —
|
||||
// chat lines; FontString-region links are a later arc), fed back to the engine's click
|
||||
// hit-test after the loop. Rects flip back to the engine's y-up space.
|
||||
@@ -438,8 +448,9 @@ pub(super) fn drive_script(
|
||||
// Every input the conversion below reads, compared against last frame's. Equal inputs make
|
||||
// the loop a pure re-derivation of `UiQuads` the diff at the bottom would discard — at the
|
||||
// LBRS pin that was every single settled frame, ~0.3 ms/frame of glyph re-rasterization for
|
||||
// an identical `Vec`. On a skip, `quads`/`minimap_widget`/the engine's link spans all keep
|
||||
// last frame's values, which the equal inputs prove are this frame's values too.
|
||||
// an identical `Vec`. On a skip, `quads`/the parked minimap slot and shine sites/the
|
||||
// engine's link spans all keep last frame's values, which the equal inputs prove are this
|
||||
// frame's values too.
|
||||
let dims = (w.to_bits(), h.to_bits(), s.to_bits(), dpi.to_bits());
|
||||
let settled = capture.is_none()
|
||||
&& prev.dims == Some(dims)
|
||||
@@ -537,18 +548,19 @@ pub(super) fn drive_script(
|
||||
break 'splice;
|
||||
}
|
||||
// Only entries whose conversion writes quads alone are spliceable — old AND new: an
|
||||
// entry morphing into a side-channel kind must reach the full path's plumbing.
|
||||
let simple = |eq: &benilla_ui::script::ExtractedQuad| {
|
||||
matches!(
|
||||
&eq.content,
|
||||
QuadContent::Frame
|
||||
| QuadContent::Cooldown { .. }
|
||||
| QuadContent::Backdrop { .. }
|
||||
| QuadContent::Texture {
|
||||
portrait_unit: None,
|
||||
..
|
||||
}
|
||||
)
|
||||
// entry morphing into a side-channel kind must reach the full path's plumbing. The
|
||||
// autocast-shine token (decision 1383) is a side-channel kind wearing a Texture's
|
||||
// clothes, so it is excluded by path.
|
||||
let simple = |eq: &benilla_ui::script::ExtractedQuad| match &eq.content {
|
||||
QuadContent::Frame | QuadContent::Cooldown { .. } | QuadContent::Backdrop { .. } => {
|
||||
true
|
||||
}
|
||||
QuadContent::Texture {
|
||||
portrait_unit: None,
|
||||
path,
|
||||
..
|
||||
} => path.as_deref() != Some(crate::autocast_shine::SHINE_TOKEN),
|
||||
_ => false,
|
||||
};
|
||||
if !changed
|
||||
.iter()
|
||||
@@ -563,6 +575,7 @@ pub(super) fn drive_script(
|
||||
let mut scratch_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(changed.len());
|
||||
let mut scratch_links = Vec::new();
|
||||
let mut scratch_slot = None;
|
||||
let mut scratch_shine = Vec::new();
|
||||
for &i in &changed {
|
||||
let at = scratch.len();
|
||||
convert_entry(
|
||||
@@ -578,10 +591,11 @@ pub(super) fn drive_script(
|
||||
&mut scratch,
|
||||
&mut scratch_links,
|
||||
&mut scratch_slot,
|
||||
&mut scratch_shine,
|
||||
);
|
||||
scratch_ranges.push(at..scratch.len());
|
||||
}
|
||||
if !scratch_links.is_empty() || scratch_slot.is_some() {
|
||||
if !scratch_links.is_empty() || scratch_slot.is_some() || !scratch_shine.is_empty() {
|
||||
break 'splice;
|
||||
}
|
||||
let counts_stable = changed.iter().zip(&scratch_ranges).all(|(&i, r)| {
|
||||
@@ -681,6 +695,7 @@ pub(super) fn drive_script(
|
||||
&mut out,
|
||||
&mut link_spans,
|
||||
&mut minimap_slot,
|
||||
&mut shine_sites,
|
||||
);
|
||||
spans.push(out.len() as u32);
|
||||
}
|
||||
@@ -714,9 +729,11 @@ pub(super) fn drive_script(
|
||||
// (`OnHyperlinkClick`) hit-tests against exactly what is on screen.
|
||||
script.set_link_spans(link_spans);
|
||||
|
||||
// Park this frame's Minimap widget slot (or clear it — a hidden cluster extracts nothing);
|
||||
// `minimap::emit_minimap` runs later in the frame (UiQuadAppend) and fills the hole.
|
||||
minimap_widget.0 = minimap_slot;
|
||||
// Park this frame's Minimap widget slot and autocast shine sites (or clear them — a hidden
|
||||
// cluster extracts nothing); `minimap::emit_minimap` / `autocast_shine::emit_shine` run
|
||||
// later in the frame (UiQuadAppend) and fill the holes.
|
||||
parked.0 .0 = minimap_slot;
|
||||
parked.1 .0 = shine_sites;
|
||||
drop(extract_span);
|
||||
let us_exa = lap();
|
||||
|
||||
@@ -779,7 +796,8 @@ pub(super) fn drive_script(
|
||||
|
||||
/// One extracted entry converted to its screen quads, pushed onto `out` — with the side channels
|
||||
/// some arms carry: chat link spans (Text), the minimap widget slot (Minimap), the booth pane
|
||||
/// aspects (portrait-bound Texture). The ONE conversion body: the full pass and the per-entry
|
||||
/// aspects (portrait-bound Texture), the autocast shine sites (the token-path Texture, decision
|
||||
/// 1383). The ONE conversion body: the full pass and the per-entry
|
||||
/// splice both call this, which is what makes the splice's output equal the full path's by
|
||||
/// construction. An arm is splice-eligible only if it writes nothing but `out` — keep
|
||||
/// [`splice_simple`] in agreement when an arm's side effects change.
|
||||
@@ -802,6 +820,7 @@ fn convert_entry(
|
||||
String,
|
||||
)>,
|
||||
minimap_slot: &mut Option<crate::minimap::MinimapSlot>,
|
||||
shine_sites: &mut Vec<crate::autocast_shine::ShineSite>,
|
||||
) {
|
||||
let Some(r) = eq.rect else { return };
|
||||
// WoW UI space is y-up from the bottom-left in 768-virtual units; the quad pass is
|
||||
@@ -846,6 +865,30 @@ fn convert_entry(
|
||||
rotation,
|
||||
desaturated,
|
||||
} => {
|
||||
// The autocast-shine token (decision 1383, B282): a shown marker registers WHERE the
|
||||
// shine plays — rect, paint order, clip, alpha, and the star art resolved through
|
||||
// the same resolver as any texture — and draws nothing itself;
|
||||
// `autocast_shine::emit_shine` (UiQuadAppend) animates the sparks there with zero
|
||||
// per-frame script-layout traffic. A side-channel arm, so deliberately NOT
|
||||
// splice-simple (the `simple` predicate above excludes the token by path).
|
||||
if path.as_deref() == Some(crate::autocast_shine::SHINE_TOKEN) {
|
||||
// A no-assets context (the extract test harness; a capture booted without the
|
||||
// archive) records the site with the default handle — the site's GEOMETRY is
|
||||
// the registration, and the live app always has the resolver.
|
||||
let star = assets
|
||||
.as_mut()
|
||||
.and_then(|a| a.sprite_texture(crate::autocast_shine::STAR_TEXTURE, images))
|
||||
.unwrap_or_default();
|
||||
shine_sites.push(crate::autocast_shine::ShineSite {
|
||||
rect,
|
||||
z: eq.z,
|
||||
clip,
|
||||
alpha: eq.alpha,
|
||||
scale: s,
|
||||
texture: star,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// A live unit portrait (`SetPortraitTexture(region, unit)`): sample this token's
|
||||
// source ([`crate::portrait::PortraitImages`]) — the off-screen model bake, or the
|
||||
// ref's 2D TemporaryPortrait stand-in while the model streams in. Absent entry (no
|
||||
@@ -1186,6 +1229,7 @@ mod clip_plumb_tests {
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<crate::autocast_shine::ShineSites>();
|
||||
app.init_resource::<crate::ui_script::UiFrameCost>();
|
||||
app.init_resource::<crate::ui_script::UiCostWanted>();
|
||||
app.init_resource::<Time>();
|
||||
@@ -1272,6 +1316,7 @@ mod clip_plumb_tests {
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<crate::autocast_shine::ShineSites>();
|
||||
app.init_resource::<crate::ui_script::UiFrameCost>();
|
||||
app.init_resource::<crate::ui_script::UiCostWanted>();
|
||||
app.init_resource::<Time>();
|
||||
@@ -1336,6 +1381,7 @@ mod extract_gate_tests {
|
||||
app.init_resource::<PortraitImages>();
|
||||
app.init_resource::<crate::portrait::BoothPanes>();
|
||||
app.init_resource::<crate::minimap::MinimapWidget>();
|
||||
app.init_resource::<crate::autocast_shine::ShineSites>();
|
||||
app.init_resource::<crate::ui_script::UiFrameCost>();
|
||||
app.init_resource::<crate::ui_script::UiCostWanted>();
|
||||
app.init_resource::<Time>();
|
||||
@@ -1492,6 +1538,85 @@ mod extract_gate_tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The autocast-shine token (decision 1383, B282): a shown marker converts to a parked SITE
|
||||
/// and zero quads; a spliced frame keeps last conversion's sites; hiding the marker reaches
|
||||
/// the full path and clears them. The site's rect/z/scale are the registration — the
|
||||
/// producer (`autocast_shine::emit_shine`) draws there with no script traffic at all.
|
||||
#[test]
|
||||
fn the_shine_token_parks_a_site_and_never_a_quad() {
|
||||
let mut app = app_from_script(
|
||||
r#"
|
||||
local b = CreateFrame("Button", "PetB")
|
||||
b:SetPoint("BOTTOMLEFT", 72, 652)
|
||||
b:SetSize(30, 30)
|
||||
local shine = b:CreateTexture(nil, "OVERLAY")
|
||||
shine:SetTexture("benilla:autocast-shine")
|
||||
shine:SetAllPoints()
|
||||
marker = b:CreateTexture(nil, "ARTWORK")
|
||||
marker:SetTexture(1, 0, 0)
|
||||
marker:SetAllPoints()
|
||||
"#,
|
||||
);
|
||||
app.update();
|
||||
{
|
||||
let sites = app.world().resource::<crate::autocast_shine::ShineSites>();
|
||||
assert_eq!(sites.0.len(), 1, "one shown token, one site");
|
||||
let site = &sites.0[0];
|
||||
// 1024x768 window ⇒ the 768-virtual scale is 1.0 and y flips through 768: the
|
||||
// button's y-up [652, 682] lands at y-down [86, 116].
|
||||
assert_eq!(site.rect, Rect::new(72.0, 86.0, 102.0, 116.0));
|
||||
assert_eq!(site.scale, 1.0);
|
||||
assert!(site.clip.is_none());
|
||||
assert_eq!(site.alpha, 1.0);
|
||||
assert_eq!(
|
||||
app.world().resource::<UiQuads>().quads.len(),
|
||||
1,
|
||||
"the red marker's quad alone — the token itself draws nothing"
|
||||
);
|
||||
}
|
||||
|
||||
// A paint write elsewhere splices — and the parked site survives it untouched.
|
||||
app.world_mut()
|
||||
.resource_mut::<crate::ui_script::UiCostWanted>()
|
||||
.0 = true;
|
||||
app.world_mut().resource_mut::<UiQuads>().dirty = false;
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run("marker:SetTexture(0, 0, 1)")
|
||||
.unwrap();
|
||||
app.update();
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<crate::ui_script::UiFrameCost>()
|
||||
.spliced,
|
||||
1,
|
||||
"the marker write splices; the token entry did not change"
|
||||
);
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.resource::<crate::autocast_shine::ShineSites>()
|
||||
.0
|
||||
.len(),
|
||||
1,
|
||||
"a spliced frame keeps last conversion's sites"
|
||||
);
|
||||
|
||||
// Hiding the marker changes the extracted list's length — the full path runs and the
|
||||
// site set empties: the shine goes out with the region that registered it.
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run("getglobal('PetB'):Hide()")
|
||||
.unwrap();
|
||||
app.update();
|
||||
assert!(
|
||||
app.world()
|
||||
.resource::<crate::autocast_shine::ShineSites>()
|
||||
.0
|
||||
.is_empty(),
|
||||
"a hidden token registers nothing"
|
||||
);
|
||||
}
|
||||
|
||||
/// The stitch half: a write that CHANGES an entry's quad count (a cleared texture emits
|
||||
/// nothing) re-derives the span table — and a later splice against the re-derived spans
|
||||
/// still matches the full conversion, in both directions (1 → 0 quads, then 0 → 1).
|
||||
|
||||
@@ -127,8 +127,7 @@ fn the_shipped_pet_bar_drives_end_to_end() {
|
||||
"no pet, no shelf"
|
||||
);
|
||||
|
||||
// A pet appears. The tick is what runs the bar's OnUpdate, i.e. the sparkle trail — it is an
|
||||
// animation, so it exists from the first frame after the repaint, not from the repaint.
|
||||
// A pet appears.
|
||||
s.set_pet_actions(true, true, true, hunter_slots());
|
||||
s.fire_event("PET_BAR_UPDATE", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
@@ -178,29 +177,19 @@ fn the_shipped_pet_bar_drives_end_to_end() {
|
||||
"Attack + Defensive are lit; Claw is not"
|
||||
);
|
||||
|
||||
// Autocast: the static ring on the one slot that allows it, and the sparkle trail running.
|
||||
// Autocast: the static ring on the one slot that allows it, and the shine MARKER on the one
|
||||
// slot where it is running — the native lane's registration (decision 1383): the extract
|
||||
// carries the token, never sparks, and `autocast_shine::emit_shine` draws the trails at it
|
||||
// on the append lane while the script layer stays settled.
|
||||
assert_eq!(
|
||||
textures(&quads, "Interface\\Buttons\\UI-AutoCastableOverlay"),
|
||||
1,
|
||||
"only Claw can autocast"
|
||||
);
|
||||
// 4 emitters x the DERIVED trail length, on the one autocasting slot — and on that slot only,
|
||||
// which is the lazy pool's own pin: the other nine buttons have no sparks at all, where they
|
||||
// used to carry a full hidden set each.
|
||||
let trail: usize = s.eval("return BENILLA_PET_AUTOCAST_TRAIL").unwrap();
|
||||
assert_eq!(
|
||||
trail, 23,
|
||||
"the size ramp's own spacing (see PetActionBar.xml's header) comes out at 23 samples"
|
||||
);
|
||||
assert_eq!(
|
||||
textures(&quads, "Interface\\Buttons\\GlowStar"),
|
||||
4 * trail,
|
||||
"4 emitters x the trail, on the one autocasting slot"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return PetActionButton1.sparks == nil")
|
||||
.unwrap(),
|
||||
"a button that never autocasts never pools a spark"
|
||||
textures(&quads, "benilla:autocast-shine"),
|
||||
1,
|
||||
"the shine marker on Claw alone — enabled, not merely allowed"
|
||||
);
|
||||
|
||||
// Geometry, quoted from the ref: the bar's TOPLEFT is MainMenuBar's BOTTOMLEFT +(36,97),
|
||||
@@ -214,14 +203,14 @@ fn the_shipped_pet_bar_drives_end_to_end() {
|
||||
(72.0, 56.0, 102.0, 86.0)
|
||||
);
|
||||
|
||||
// The pet goes: the bar hides again, sparkles and all.
|
||||
// The pet goes: the bar hides again, shine marker and all.
|
||||
s.set_pet_actions(false, true, true, Vec::new());
|
||||
s.fire_event("PET_BAR_UPDATE", vec![]);
|
||||
s.tick(0.05);
|
||||
s.resolve();
|
||||
let gone = s.extract();
|
||||
assert_eq!(textures(&gone, "Interface\\PetActionBar\\UI-PetBar"), 0);
|
||||
assert_eq!(textures(&gone, "Interface\\Buttons\\GlowStar"), 0);
|
||||
assert_eq!(textures(&gone, "benilla:autocast-shine"), 0);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -642,160 +631,9 @@ fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **B228 — the autocast trail's motion, MEASURED off the shipped handler.**
|
||||
///
|
||||
/// nazriel reads the shine as "a bit too fast", and the answer turned out to be in the widget's
|
||||
/// CLOCK, which no constants read could have reached. `CSimpleModel`'s per-frame OnUpdate advances
|
||||
/// its private `CM2Scene` by `__ftol(elapsed * 1000.0)` — **truncated, no fractional carry**, where
|
||||
/// the world's driver adds 0.5 first (wow-re `modelframe-animation-clock.md`, §5 cross-checked).
|
||||
/// So the reference's 2000 ms band actually takes `2000 * T / floor(T)` ms at frame time T, and is
|
||||
/// **never faster than 2.000 s**. An exact 2.000 s emulation — which is what a measurement of our
|
||||
/// old wiring confirmed we had — was therefore faster than any real client at any frame rate.
|
||||
///
|
||||
/// This drives the real `<OnUpdate>` with real seconds at real frame times and reads the head
|
||||
/// spark's own SetPoint back. What is pinned: the lap comes out SLOW by exactly the truncation's
|
||||
/// amount at 60 and 144 fps; the geometry is the 30.72 px square the widget's ortho projection puts
|
||||
/// on a 30 px button (0.02 units × 1280 × the ref Model's `SetModelScale` 1.2), origin at the
|
||||
/// button's BOTTOM-LEFT because that is the model's own origin; a corner every quarter lap, LINEAR
|
||||
/// between them (`interp == 1` at the bone record's translation M2Track, so the half-edge sample
|
||||
/// sits at the half-time); and the four emitters one quarter-lap apart.
|
||||
#[test]
|
||||
fn the_autocast_trail_laps_the_rim_the_reference_widgets_slow_way() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_pet_bar(&s);
|
||||
declare_token_strings(&s);
|
||||
s.set_pet_actions(true, true, true, hunter_slots());
|
||||
s.fire_event("PET_BAR_UPDATE", vec![]);
|
||||
s.resolve();
|
||||
|
||||
// Slot 4 is Claw, the one slot in the fixture with autocast RUNNING. `sparks[n]` is the pool
|
||||
// `BenillaPetAutocast_Create` built: emitter-major, so emitter e's HEAD (age 0, the bone's own
|
||||
// position) is index e·TRAIL + 1.
|
||||
let head = |s: &UiScript, emitter: usize| -> (f32, f32) {
|
||||
let expr = |axis: usize| {
|
||||
format!(
|
||||
"local n = {emitter} * BENILLA_PET_AUTOCAST_TRAIL + 1 \
|
||||
local a, b, c, x, y = PetActionButton4.sparks[n]:GetPoint() \
|
||||
return ({axis} == 0) and x or y"
|
||||
)
|
||||
};
|
||||
(
|
||||
s.eval::<f32>(&expr(0)).unwrap(),
|
||||
s.eval::<f32>(&expr(1)).unwrap(),
|
||||
)
|
||||
};
|
||||
let near = |got: (f32, f32), want: (f32, f32), what: &str| {
|
||||
assert!(
|
||||
(got.0 - want.0).abs() < 0.01 && (got.1 - want.1).abs() < 0.01,
|
||||
"{what}: at {got:?}, expected {want:?}"
|
||||
);
|
||||
};
|
||||
|
||||
// 0.02 units × 1280 px/unit × 1.2 = 30.72 px across, laid from the button's BOTTOMLEFT — so
|
||||
// the walk runs 0..30.72 in both axes, not ±half from the centre.
|
||||
const S: f32 = 30.72;
|
||||
let (bl, tl, tr, br) = ((0.0, 0.0), (0.0, S), (S, S), (S, 0.0));
|
||||
|
||||
// ── The CLOCK, at a real frame time. 60 fps ⇒ dt = 16.667 ms ⇒ only **16** banked per frame,
|
||||
// so the 2000 ms band needs 125 frames = **2.083 s of wall clock**. At 2.000 s (120 frames)
|
||||
// the clock has banked 1920 ms = 0.96 of a lap: the head is still on the last edge, running
|
||||
// right-to-left toward home and 0.04 × 122.88 = 4.92 px short of it. That shortfall IS the
|
||||
// bug — it is how much slower than our old exact-2.000 s clock the reference has always been.
|
||||
let frames = |s: &mut UiScript, n: usize, dt: f32| {
|
||||
for _ in 0..n {
|
||||
s.tick(dt);
|
||||
}
|
||||
};
|
||||
const DT60: f32 = 1.0 / 60.0;
|
||||
frames(&mut s, 120, DT60); // 2.000 s of wall clock
|
||||
near(
|
||||
head(&s, 0),
|
||||
(4.9152, 0.0),
|
||||
"2.000 s at 60 fps: NOT home yet — the reference is slower than the authored band",
|
||||
);
|
||||
frames(&mut s, 5, DT60); // 2.083 s
|
||||
near(
|
||||
head(&s, 0),
|
||||
bl,
|
||||
"2.083 s at 60 fps: one lap, the reference's own 2000*T/floor(T)",
|
||||
);
|
||||
|
||||
// 144 fps truncates harder — 6.944 ms banked as 6 — so the same lap takes 2.315 s there. A
|
||||
// frame-rate-independent clock would land both of these at 2.000 s and fail here.
|
||||
let mut fast = UiScript::new().unwrap();
|
||||
fast.set_screen_size(1024.0, 768.0);
|
||||
load_pet_bar(&fast);
|
||||
declare_token_strings(&fast);
|
||||
fast.set_pet_actions(true, true, true, hunter_slots());
|
||||
fast.fire_event("PET_BAR_UPDATE", vec![]);
|
||||
fast.resolve();
|
||||
frames(&mut fast, 333, 1.0 / 144.0); // 2.313 s of wall clock ⇒ 1998 ms banked
|
||||
let (fx, fy) = head(&fast, 0);
|
||||
assert!(
|
||||
fy == 0.0 && fx > 0.0 && fx < 1.0,
|
||||
"2.313 s at 144 fps: still 2 ms short of the lap, at {fx:?},{fy:?}"
|
||||
);
|
||||
|
||||
// ── The position LAW, driven at exact MILLISECOND clocks so no tick accumulation can blur it.
|
||||
// The mid-edge sample is the `interp == 1` pin: linear means the half-time is the half-edge,
|
||||
// and a spline-eased track would sit short of it.
|
||||
let at = |s: &UiScript, ms: u32| -> (f32, f32) {
|
||||
s.run(&format!(
|
||||
"BenillaPetAutocast_Update(PetActionButton4, {ms})"
|
||||
))
|
||||
.unwrap();
|
||||
head(s, 0)
|
||||
};
|
||||
near(at(&s, 0), bl, "clock 0");
|
||||
near(
|
||||
at(&s, 250),
|
||||
(0.0, S / 2.0),
|
||||
"quarter edge in: the LEFT edge's midpoint",
|
||||
);
|
||||
near(at(&s, 500), tl, "clock 500 ms");
|
||||
near(at(&s, 1000), tr, "clock 1000 ms");
|
||||
near(at(&s, 1500), br, "clock 1500 ms");
|
||||
near(at(&s, 2000), bl, "clock 2000 ms wraps onto clock 0");
|
||||
|
||||
// ── The trail is CONTINUOUS, which is what deriving the sample count buys (B228). No two
|
||||
// neighbours in a trail sit further apart than the smaller of their two stars is wide, so the
|
||||
// streak has no seam at any age. The 8-even-samples version failed this from mid-life back —
|
||||
// 7.7 px apart with 4.6 px stars — and a row of separated dots is what reads as marching.
|
||||
let worst_gap: f32 = s
|
||||
.eval(
|
||||
"local worst = 0 \
|
||||
for k = 1, BENILLA_PET_AUTOCAST_TRAIL - 1 do \
|
||||
local a, b = PetActionButton4.sparks[k], PetActionButton4.sparks[k + 1] \
|
||||
local _, _, _, ax, ay = a:GetPoint() \
|
||||
local _, _, _, bx, by = b:GetPoint() \
|
||||
local d = math.sqrt((ax - bx) * (ax - bx) + (ay - by) * (ay - by)) \
|
||||
local w = math.min(a:GetWidth(), b:GetWidth()) \
|
||||
if d / w > worst then worst = d / w end \
|
||||
end \
|
||||
return worst",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
worst_gap <= 1.0,
|
||||
"the widest neighbour gap is {worst_gap:.2} star-widths — the streak has a seam"
|
||||
);
|
||||
|
||||
// ── The four emitters are ONE LAP, a quarter apart — the file's four bones at the four
|
||||
// corners, chasing each other (m2bones: pivots at (-0.001,0), (0.019,0), (0.019,0.02),
|
||||
// (-0.001,0.02), same track, one corner of phase between neighbours).
|
||||
s.run("BenillaPetAutocast_Update(PetActionButton4, 0)")
|
||||
.unwrap();
|
||||
for (e, want, name) in [
|
||||
(0, bl, "emitter 0"),
|
||||
(1, tl, "emitter 1"),
|
||||
(2, tr, "emitter 2"),
|
||||
(3, br, "emitter 3"),
|
||||
] {
|
||||
near(head(&s, e), want, name);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
// The autocast trail's MOTION tests (the B228 corner-walk goldens, the 1321 truncating-clock
|
||||
// law, the no-seam continuity pin) live with the drawing now: `crate::autocast_shine`'s own
|
||||
// test module — the script layer no longer moves a single spark (decision 1383).
|
||||
|
||||
/// The pet bar plus the three files its TOOLTIP needs, in the TOC's own order: Fonts.xml (the
|
||||
/// colour codes that wrap the binding), GameTooltip.xml (the plate), and UIParent.xml — already a
|
||||
|
||||
@@ -66,6 +66,19 @@ fn app_frame(s: &mut UiScript) {
|
||||
s.resolve();
|
||||
}
|
||||
|
||||
/// A frame that also **ticks the VM**, i.e. runs the shipped UI's own `OnUpdate` handlers, in
|
||||
/// `drive_script`'s real order (tick → measure → resolve).
|
||||
///
|
||||
/// [`app_frame`] deliberately models only the measure/resolve half, and the tooltip benches stand
|
||||
/// in for the handler by calling it themselves. That is fine when the test IS the driver — but a
|
||||
/// test asking "is anything in the shipped UI writing layout on its own?" has to let the shipped
|
||||
/// UI actually run, or it answers a question nobody asked (decision 1385).
|
||||
fn app_frame_ticked(s: &mut UiScript, dt: f32) {
|
||||
s.tick(dt);
|
||||
answer_measures(s);
|
||||
s.resolve();
|
||||
}
|
||||
|
||||
/// A tooltip whose lines change width every call — the hover sweep across a bag grid, where each
|
||||
/// slot holds a different item.
|
||||
fn install_changing_tooltip(s: &UiScript, owner: &str, func: &str) {
|
||||
@@ -159,6 +172,133 @@ fn a_tooltip_content_change_costs_exactly_one_layout_solve() {
|
||||
);
|
||||
}
|
||||
|
||||
/// **The idle law** (decision 1385): the settled shipped UI, with nothing happening, must cost
|
||||
/// **zero** gate walks per frame — not one cheap one, zero.
|
||||
///
|
||||
/// The other guards in this file pin what the ENGINE charges for a change. This one pins that
|
||||
/// nothing in the shipped UI is quietly making a change every frame in the first place, which is
|
||||
/// the half a law about the engine cannot see. It is the tripwire for a new always-on toucher
|
||||
/// being added to `assets/ui/` — the worst shape of this bug, because it costs on *every* frame of
|
||||
/// *every* session rather than only while something is animating. The sweep behind 1385 found real
|
||||
/// candidates for it (`TemporaryEnchantFrame`'s OnUpdate is resident for the whole session; the
|
||||
/// measure round-trip used to bump the epoch on every same-size re-measure), so the shape is not
|
||||
/// hypothetical.
|
||||
///
|
||||
/// A failure here does not name the culprit — `WOW_LAYOUT_TOUCH_TRACE=<secs>:<n>` does, by
|
||||
/// backtracing the touch sites on a live run.
|
||||
#[test]
|
||||
fn the_settled_shipped_ui_costs_no_gate_walk_on_a_quiet_frame() {
|
||||
let mut s = settled_default_ui();
|
||||
// Ticked frames, so the shipped UI's own OnUpdate handlers run — the whole point (see
|
||||
// `app_frame_ticked`). A couple of frames of grace first: `settled_default_ui` stops as soon
|
||||
// as the measure round-trip goes quiet, which is one edge earlier than the layout gate closing
|
||||
// behind it, and the first ticked frames arm handlers that have never run.
|
||||
for _ in 0..8 {
|
||||
app_frame_ticked(&mut s, 1.0 / 60.0);
|
||||
}
|
||||
// POSITIVE CONTROL. "Zero walks" only means anything if the shipped UI's handlers actually
|
||||
// ran — a tick that silently fired nothing would also read zero, and would make this test a
|
||||
// green light for exactly the regression it exists to catch. `BuffFrameUpdateTime` is advanced
|
||||
// by `BuffFrame_OnUpdate` on every frame (down by `elapsed`, or up by TOOLTIP_UPDATE_TIME when
|
||||
// it crosses), so one tick must move it.
|
||||
s.run("__probe_bfut = BuffFrameUpdateTime")
|
||||
.expect("read the control");
|
||||
app_frame_ticked(&mut s, 1.0 / 60.0);
|
||||
s.run(
|
||||
"if BuffFrameUpdateTime == __probe_bfut then \
|
||||
error('the VM tick ran no shipped OnUpdate — this test proves nothing') end",
|
||||
)
|
||||
.expect("the shipped UI's OnUpdate handlers must run under app_frame_ticked");
|
||||
|
||||
let before = s.layout_gate_walks();
|
||||
for _ in 0..20 {
|
||||
app_frame_ticked(&mut s, 1.0 / 60.0);
|
||||
}
|
||||
assert_eq!(
|
||||
s.layout_gate_walks() - before,
|
||||
0,
|
||||
"20 idle frames of the shipped UI cost {} whole-roster gate walks — something in \
|
||||
assets/ui/ is writing a layout input every frame with nothing happening. Name it with \
|
||||
WOW_LAYOUT_TOUCH_TRACE=<secs>:<n> on a live run (decision 1385).",
|
||||
s.layout_gate_walks() - before
|
||||
);
|
||||
}
|
||||
|
||||
/// **The castbar law** (decision 1385, ledger B283), on the full shipped UI: a region that moves
|
||||
/// every frame must cost **one** whole-roster gate walk per frame.
|
||||
///
|
||||
/// This is the guard for the bug class 1383 named and the castbar then hit. `CastingBar.xml`'s
|
||||
/// OnUpdate slides `CastingBarSpark` one `SetPoint` per frame for the length of every cast — the
|
||||
/// reference's own architecture, and the classic addon idiom besides. Measured live at the
|
||||
/// Stormwind gates it cost **+4.2 ms of CPU per frame** on a default UI: three walks per frame at
|
||||
/// ~1.0–1.4 ms each (10,438 anchored regions re-hashed every walk), because the fingerprint was
|
||||
/// hashed over the 0294 seeds as well as the inputs and so no walk could ever close tier 1.
|
||||
///
|
||||
/// Three things make this the falsifier rather than a smoke check:
|
||||
/// * it runs on the **shipped** UI, so the whole-roster term is real — a synthetic two-frame model
|
||||
/// shows the same *counts* and none of the cost;
|
||||
/// * the Lua body ends in a frame getter, because that is what a live tick does. A later handler's
|
||||
/// `GetWidth` forces the synchronous mid-tick solve; modelling the frame as a lone `resolve()`
|
||||
/// hides two of the three walks;
|
||||
/// * it asserts on `layout_gate_walks`, not `layout_solves`. A walk that concludes "nothing moved"
|
||||
/// pays the identical preamble and never reaches the solve counter — one of the castbar's three
|
||||
/// walks was exactly that, so the old counter under-reported the bug by a third.
|
||||
#[test]
|
||||
fn a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui() {
|
||||
let mut s = settled_default_ui();
|
||||
s.run(
|
||||
r#"
|
||||
BenchBar = CreateFrame("Frame", "BenchBar", UIParent)
|
||||
BenchBar:SetPoint("CENTER", 0, 0); BenchBar:SetWidth(195); BenchBar:SetHeight(13)
|
||||
BenchSpark = BenchBar:CreateTexture(nil, "OVERLAY")
|
||||
BenchSpark:SetWidth(32); BenchSpark:SetHeight(32)
|
||||
BenchSpark:SetPoint("CENTER", BenchBar, "LEFT", 0, 2)
|
||||
bench_spark_n = 0
|
||||
-- CastingBarFrame_OnUpdate's body, reduced to what touches layout: ride the spark along
|
||||
-- the bar's leading edge. The trailing getter stands in for any LATER handler in the same
|
||||
-- tick reading a frame rect — the thing that forces the mid-tick synchronous solve.
|
||||
function bench_spark_frame()
|
||||
bench_spark_n = bench_spark_n + 1
|
||||
BenchSpark:SetPoint("CENTER", BenchBar, "LEFT", bench_spark_n * 1.7, 2)
|
||||
local _ = UIParent:GetWidth()
|
||||
end
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Settle the newly-created bar and spark — a birth is legitimately a wide, multi-walk solve.
|
||||
for _ in 0..6 {
|
||||
s.run("bench_spark_frame()").unwrap();
|
||||
app_frame(&mut s);
|
||||
}
|
||||
|
||||
let walks_before = s.layout_gate_walks();
|
||||
let solves_before = s.layout_solves();
|
||||
for step in 0..10 {
|
||||
s.run("bench_spark_frame()").unwrap();
|
||||
app_frame(&mut s);
|
||||
let (frames, regions) = s.layout_last_scope();
|
||||
assert!(
|
||||
frames < 50 && regions < 200,
|
||||
"step {step}: moving ONE region solved {frames} frames and swept {regions} regions — \
|
||||
the scope is tracking the graph, not the change (decision 1350)"
|
||||
);
|
||||
}
|
||||
let walks = s.layout_gate_walks() - walks_before;
|
||||
let solves = s.layout_solves() - solves_before;
|
||||
assert_eq!(
|
||||
walks, 10,
|
||||
"10 frames of one moving region must cost 10 whole-roster gate walks — one each. 30 \
|
||||
means the fingerprint is hashing the 0294 seeds again, so every solve outgrows the value \
|
||||
it stores and neither it nor the settling walk behind it can close tier 1 (decision \
|
||||
1385). At the shipped roster each extra walk is ~1 ms of CPU on every frame of every cast."
|
||||
);
|
||||
assert_eq!(
|
||||
solves, 10,
|
||||
"and each of those walks must be a real solve, not a wasted one"
|
||||
);
|
||||
}
|
||||
|
||||
/// The perf half of decision 1350, asserted as a COUNT: a tooltip content change must cost a
|
||||
/// solve whose SCOPE is tooltip-sized, on a UI of 3,000 frames and 8,800 anchored regions.
|
||||
///
|
||||
|
||||
@@ -119,7 +119,11 @@ fn every_shipped_texture_path_resolves_in_the_client_archives() {
|
||||
let archive_path = ["file", "bgfile", "edgefile"]
|
||||
.contains(&key.to_ascii_lowercase().as_str())
|
||||
&& !value.is_empty();
|
||||
if archive_path {
|
||||
// The autocast-shine token (decision 1383) is a REGISTRATION, not an archive path:
|
||||
// conversion intercepts it before the resolver and it draws nothing, so it is
|
||||
// exempt — by EXACT match against the shipped constant, so a typo'd token in XML
|
||||
// still fails this sweep as the white quad it would actually be.
|
||||
if archive_path && value != crate::autocast_shine::SHINE_TOKEN {
|
||||
out.push((file.to_string(), el.tag.clone(), value.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,12 +480,13 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
|
||||
.unwrap(),
|
||||
"a passive is not autocastable"
|
||||
);
|
||||
// …and the sparkle follows the SECOND (is it on).
|
||||
// …and the shine marker follows the SECOND (is it on) — the native lane (decision 1383)
|
||||
// draws the sparkle wherever a shown marker sits, so shown-ness IS the enable.
|
||||
assert!(s
|
||||
.eval::<bool>("return SpellButton1.autocasting == 1")
|
||||
.eval::<bool>("return SpellButton1Shine:IsVisible()")
|
||||
.unwrap());
|
||||
assert!(s
|
||||
.eval::<bool>("return SpellButton3.autocasting == nil")
|
||||
assert!(!s
|
||||
.eval::<bool>("return SpellButton3Shine:IsVisible()")
|
||||
.unwrap());
|
||||
|
||||
// ── The clicks ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -297,6 +297,95 @@ fn layout_verify_enabled() -> bool {
|
||||
*ON.get_or_init(|| cfg!(test) || std::env::var("WOW_LAYOUT_VERIFY").as_deref() == Ok("1"))
|
||||
}
|
||||
|
||||
/// `WOW_LAYOUT_PROF=1`'s **preamble** split — the `[layout-pre]` line, one per LET-THROUGH resolve.
|
||||
///
|
||||
/// `[layout-prof]` reports the rounds, which 1350 already made proportional to what moved. This
|
||||
/// reports everything *before* them: the fixed whole-roster work every let-through solve pays
|
||||
/// whatever moved, which 1383 priced at ~1.9 ms/solve at a live roster and named as the next cut
|
||||
/// without ever splitting it. A cut needs to know which of the eight walks it is buying back, so
|
||||
/// the split is the instrument that has to exist first.
|
||||
///
|
||||
/// Off (the default) it takes no clock reads at all — [`Self::lap`] returns on the `on` test, so an
|
||||
/// unprofiled resolve pays one bool check per phase.
|
||||
#[derive(Default)]
|
||||
struct PreambleProf {
|
||||
on: bool,
|
||||
last: Option<std::time::Instant>,
|
||||
/// The GameTooltip auto-size pre-pass (`layout_tooltips`) — a whole-frame-roster filter.
|
||||
tooltip: u128,
|
||||
/// `OnSizeChanged`'s "before" snapshot — a whole-`scripts`-map filter.
|
||||
watched: u128,
|
||||
/// The ids rebuild + the per-frame scale/clamp sync into `layout_inputs`.
|
||||
ids: u128,
|
||||
/// The ScrollFrame override map — another whole-frame-roster walk.
|
||||
scroll: u128,
|
||||
/// `region_resolved`'s liveness retain.
|
||||
retain: u128,
|
||||
/// The per-call solve plan rebuild.
|
||||
plan: u128,
|
||||
/// `LayoutScope::begin` — seven per-id `Vec`s cleared/resized.
|
||||
begin: u128,
|
||||
/// The fingerprint + scope walk over every live FRAME (double-hashed: aggregate + per-node).
|
||||
fp_frames: u128,
|
||||
/// The same over every anchored REGION — the biggest roster (10,438 anchored at the SW pin,
|
||||
/// and by far the biggest single phase: 0.45–1.0 ms of the ~1.0–1.4 ms walk).
|
||||
fp_regions: u128,
|
||||
}
|
||||
|
||||
impl PreambleProf {
|
||||
fn new(on: bool) -> Self {
|
||||
Self {
|
||||
on,
|
||||
last: on.then(std::time::Instant::now),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Microseconds since the previous lap (0 when off).
|
||||
fn lap(&mut self) -> u128 {
|
||||
if !self.on {
|
||||
return 0;
|
||||
}
|
||||
let now = std::time::Instant::now();
|
||||
self.last
|
||||
.replace(now)
|
||||
.map_or(0, |t| now.duration_since(t).as_micros())
|
||||
}
|
||||
|
||||
/// One line per let-through resolve, printed once the gate's verdict is known — `skips=1` is
|
||||
/// the walk that concluded "nothing moved" and returns without solving, which costs the same
|
||||
/// preamble as one that does.
|
||||
fn report(&self, skips: bool, frames: usize, regions: usize) {
|
||||
if !self.on {
|
||||
return;
|
||||
}
|
||||
let total = self.tooltip
|
||||
+ self.watched
|
||||
+ self.ids
|
||||
+ self.scroll
|
||||
+ self.retain
|
||||
+ self.plan
|
||||
+ self.begin
|
||||
+ self.fp_frames
|
||||
+ self.fp_regions;
|
||||
eprintln!(
|
||||
"[layout-pre] skips={} frames={frames} anchored={regions} total_us={total} \
|
||||
tooltip={} watched={} ids={} scroll={} retain={} plan={} begin={} \
|
||||
fp_frames={} fp_regions={}",
|
||||
u8::from(skips),
|
||||
self.tooltip,
|
||||
self.watched,
|
||||
self.ids,
|
||||
self.scroll,
|
||||
self.retain,
|
||||
self.plan,
|
||||
self.begin,
|
||||
self.fp_frames,
|
||||
self.fp_regions
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// `OnSizeChanged`'s "after": turn the entry-vs-now diff of the watched frames into queued
|
||||
/// `(id, width, height)` fires ([`Model::pending_size_changed`]).
|
||||
///
|
||||
@@ -354,10 +443,18 @@ impl UiScript {
|
||||
return;
|
||||
}
|
||||
let epoch_at_entry = model.layout_epoch;
|
||||
// Past tier 1 ⇒ this call pays the whole-roster preamble, whatever the fingerprint goes on
|
||||
// to decide. Counted here rather than at the solve, because the walk IS the cost (1385).
|
||||
// Not gated on `verify`: a verify build re-walks skipped resolves and must say so.
|
||||
model.layout_gate_walks = model.layout_gate_walks.wrapping_add(1);
|
||||
// `WOW_LAYOUT_PROF=1`'s preamble split — see [`PreambleProf`]. Started here, so the clock
|
||||
// covers everything the tier-1 gate above did NOT skip.
|
||||
let mut pre = PreambleProf::new(layout_prof_enabled());
|
||||
// The GameTooltip auto-size + right-flush pre-pass (decision 0274): writes tooltip frame
|
||||
// sizes + right-column anchor offsets from the measure round-trip's cached extents, so
|
||||
// the graph below solves them like any other frame.
|
||||
super::tooltip::layout_tooltips(model);
|
||||
pre.tooltip = pre.lap();
|
||||
// ── `OnSizeChanged`'s "before" ───────────────────────────────────────────────────────
|
||||
// The client fires it from `ApplyRect 0x76b580`, per rect application. Ours is a batch
|
||||
// fixpoint, so the faithful *mechanism* — "this frame's resolved size moved" — is the
|
||||
@@ -374,6 +471,7 @@ impl UiScript {
|
||||
.filter(|(_, kinds)| kinds.contains("OnSizeChanged"))
|
||||
.map(|(&h, _)| (h, model.resolved.get(&h).copied()))
|
||||
.collect();
|
||||
pre.watched = pre.lap();
|
||||
let Model {
|
||||
arena,
|
||||
layout_inputs,
|
||||
@@ -423,6 +521,7 @@ impl UiScript {
|
||||
input.extent_y = screen.top;
|
||||
}
|
||||
}
|
||||
pre.ids = pre.lap();
|
||||
|
||||
// The ScrollFrame mechanism (decision 0112): a live ScrollFrame with a live scroll child
|
||||
// overrides the child's own anchors for this solve — `SetScrollChild` pins the child TOPLEFT
|
||||
@@ -453,6 +552,7 @@ impl UiScript {
|
||||
Anchor::new(Point::TopLeft, id, Point::TopLeft, 0.0, state.vertical),
|
||||
);
|
||||
}
|
||||
pre.scroll = pre.lap();
|
||||
|
||||
// Alternating frame/region rounds, run to a CHANGE-DRIVEN FIXPOINT: the real client
|
||||
// resolves ONE layout graph in which frames and regions anchor each other freely (the
|
||||
@@ -480,6 +580,7 @@ impl UiScript {
|
||||
arena.region(*rh).is_some()
|
||||
&& region_data.get(rh).is_some_and(|d| !d.anchors.is_empty())
|
||||
});
|
||||
pre.retain = pre.lap();
|
||||
// The per-frame solve plan, resolved ONCE per call rather than per round: each live
|
||||
// frame's id, a borrow of its (already scale/clamp-synced) layout input, and its
|
||||
// ScrollFrame anchor override if it is a scroll child. The round loop below then walks a
|
||||
@@ -492,6 +593,7 @@ impl UiScript {
|
||||
.map(|input| (h, id, input, scroll_child_anchor.get(&h).copied()))
|
||||
})
|
||||
.collect();
|
||||
pre.plan = pre.lap();
|
||||
|
||||
// ── The change gate ───────────────────────────────────────────────────────────────────
|
||||
// Fingerprint exactly what the rounds below read, and skip them outright when nothing has
|
||||
@@ -507,14 +609,20 @@ impl UiScript {
|
||||
// which together distil `frame_to_id`, frame liveness,
|
||||
// `effective_scale`, `clamped_to_screen`, and the
|
||||
// ScrollFrame child + vertical offset;
|
||||
// * `id_to_region` × `region_resolved` — the region rects seeded as externals;
|
||||
// * `region_data` — each region's anchors, explicit size, and measured
|
||||
// text extent;
|
||||
// * `arena.region(rh)` — liveness and owner (a dead region drops out of the
|
||||
// sweep; the owner supplies the fallback edges).
|
||||
// `resolved` / `region_resolved` are the previous pass's OUTPUT carried forward as the
|
||||
// 0294 seed; at convergence seed and output are equal, so re-running against an unchanged
|
||||
// input set is guaranteed to reproduce them — which is precisely what makes the skip safe.
|
||||
//
|
||||
// **INPUTS ONLY — the 0294 seeds are deliberately NOT hashed** (decision 1385). They are
|
||||
// the previous pass's OUTPUT, and 0294's own property ("every rect is recomputed purely
|
||||
// from (inputs, externals), never from its own prior value") makes a converged pass's
|
||||
// rects a pure function of the inputs: the seeds change how many ROUNDS convergence
|
||||
// takes, never what it converges to. Hashing them therefore added no verdict the input
|
||||
// half does not already give — and it cost two extra whole-roster walks per mutated
|
||||
// frame, because a solve necessarily outgrows the seeds it hashed, so neither that pass
|
||||
// nor the settling pass behind it could close tier 1. (`resolved`, the FRAME rects, was
|
||||
// never in the read set at all — the seed half was asymmetric as well as redundant.)
|
||||
//
|
||||
// ── …and, in the same walk, the SCOPE (decision 1350) ─────────────────────────────────
|
||||
// Each node's own inputs are hashed a second time on their own, into
|
||||
@@ -526,6 +634,7 @@ impl UiScript {
|
||||
// Sized past `next_id` because the region walk below MINTS ids (see there) — every id it
|
||||
// can mint is one more than the anchored regions it walks.
|
||||
scope.begin(*next_id as usize + region_data.len() + 1);
|
||||
pre.begin = pre.lap();
|
||||
let mut fp = InputFingerprint::default();
|
||||
fp.rect(*screen);
|
||||
fp.feed(plan.len() as u64);
|
||||
@@ -560,6 +669,7 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
}
|
||||
pre.fp_frames = pre.lap();
|
||||
let mut fed_regions = 0u64;
|
||||
// The round loop's region roster, built in this same walk (decision 1350) — see
|
||||
// [`RegionNode`]. Only LIVE, anchored regions: an anchor-less one is invisible to the
|
||||
@@ -656,13 +766,9 @@ impl UiScript {
|
||||
// The count of FED entries (not the map's len — see the vacuous-entry skip above), so an
|
||||
// entry leaving the anchored set is a change even if a sibling enters the same frame.
|
||||
fp.feed(fed_regions);
|
||||
for (&id, rh) in id_to_region.iter() {
|
||||
if let Some(r) = region_resolved.get(rh) {
|
||||
fp.feed(u64::from(id));
|
||||
fp.rect(*r);
|
||||
}
|
||||
}
|
||||
pre.fp_regions = pre.lap();
|
||||
let gate_skips = *layout_fingerprint == Some(fp);
|
||||
pre.report(gate_skips, plan.len(), regions.len());
|
||||
// The tier-1/tier-2 cross-check (only reachable under verify when tier 1 judged quiet):
|
||||
// the epoch said nothing layout-visible was written, so the fingerprint must agree — a
|
||||
// divergence is a mutation path missing its `Model::touch_layout()`, named here at the
|
||||
@@ -674,12 +780,11 @@ impl UiScript {
|
||||
fingerprint moved — a layout write path is missing its touch_layout()"
|
||||
);
|
||||
}
|
||||
// Tier 1 closes ONLY on a settled frame (the fingerprint verdict `gate_skips`): the fp is
|
||||
// hashed over the SEEDS (last pass's outputs), so the first resolve after a real change
|
||||
// stores a fingerprint the very next, mutation-free resolve would not reproduce — the
|
||||
// seeds grew under it. That next resolve pays one fingerprint (exactly today's price),
|
||||
// proves the input set has settled, and closes the epoch here; every later quiet frame
|
||||
// skips at the u64 compare.
|
||||
// Nothing in the read set moved since the last CONVERGED resolve, so the rects it left are
|
||||
// still this frame's answer: close tier 1 and skip. (A converged solve closes it too — see
|
||||
// the convergence arm below. This arm is the one that absorbs an epoch bump whose write
|
||||
// changed no input: an id mint, an anchor-less region's creation, a re-`SetPoint` the
|
||||
// setters' own compare let through.)
|
||||
if gate_skips {
|
||||
*layout_epoch_resolved = Some(epoch_at_entry);
|
||||
if !verify {
|
||||
@@ -1026,13 +1131,21 @@ impl UiScript {
|
||||
// settled, and seeding the next pass's "unchanged ⇒ unmoved" argument from it
|
||||
// would be seeding it from a lie.
|
||||
scope.commit(scope_nodes, *screen);
|
||||
// Tier 1 closes only on a `gate_skips` frame: a real solve (`!gate_skips`) hashed
|
||||
// `fp` over now-outgrown seeds — see the gate above. The re-store here is for the
|
||||
// VERIFY path, whose full re-run of a settled frame passed through the state
|
||||
// clears; production returned at the gate. Either mode leaves the same state.
|
||||
if gate_skips {
|
||||
*layout_epoch_resolved = Some(epoch_at_entry);
|
||||
}
|
||||
// **A CONVERGED SOLVE CLOSES TIER 1** (decision 1385) — unconditionally, not only
|
||||
// on the `gate_skips` re-run. The fingerprint above is hashed over INPUTS alone,
|
||||
// and the rounds just drove those inputs to their fixpoint, so the fp stored on
|
||||
// the line above is exactly the one the next mutation-free resolve recomputes:
|
||||
// there is nothing left for a settling pass to discover. Closing here is what
|
||||
// makes a per-frame layout write cost ONE whole-roster walk instead of three
|
||||
// (solve, settle, skip) — the castbar's spark, and every addon that animates a
|
||||
// region, used to pay all three every frame.
|
||||
//
|
||||
// `epoch_at_entry` (not the live epoch) keeps it conservative: anything that
|
||||
// touched the layout WHILE this pass ran — the tooltip pre-pass, a lazily minted
|
||||
// region — leaves the epoch ahead of the stored value, so tier 1 re-opens next
|
||||
// call exactly as it should. The cycle-bail path below never reaches here, so a
|
||||
// graph left mid-flight still re-resolves.
|
||||
*layout_epoch_resolved = Some(epoch_at_entry);
|
||||
queue_size_changes(&watched, resolved, frame_to_id, pending_size_changed);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -86,11 +86,14 @@ pub(super) fn ensure_measured(lua: &Lua, rh: RegionHandle) {
|
||||
key: req.key,
|
||||
};
|
||||
if let Some(d) = model.region_data.get_mut(&rh) {
|
||||
if d.measured != Some(new) {
|
||||
d.measured = Some(new);
|
||||
let moved = super::types::MeasuredText::layout_moved(d.measured, new);
|
||||
// The KEY always lands — this IS the measure cache, and a stale key re-requests forever.
|
||||
d.measured = Some(new);
|
||||
if moved {
|
||||
// Measured extents are the auto-size axes' inputs — the layout gate's read set, the
|
||||
// same touch `set_measured_text` does. Guarded on a real change so a steady poll does
|
||||
// not dirty the layout every frame.
|
||||
// same touch `set_measured_text` does. Guarded on a change the LAYOUT can see: a
|
||||
// re-measure that returns the same box (a same-width countdown tick) must not open
|
||||
// tier 1, or it costs a whole-roster hash to conclude nothing moved (decision 1385).
|
||||
model.touch_layout();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,7 +906,9 @@ impl UiScript {
|
||||
natural_w,
|
||||
key,
|
||||
};
|
||||
changed |= d.measured != Some(new);
|
||||
// The KEY always lands (or the region re-requests its own measure forever); the
|
||||
// EPOCH moves only if the laid-out extent did — see `MeasuredText::layout_moved`.
|
||||
changed |= MeasuredText::layout_moved(d.measured, new);
|
||||
d.measured = Some(new);
|
||||
}
|
||||
}
|
||||
@@ -1082,6 +1084,14 @@ impl UiScript {
|
||||
self.model_ref().layout_solves
|
||||
}
|
||||
|
||||
/// How many resolves got past **tier 1** and paid the whole-roster preamble
|
||||
/// ([`Model::layout_gate_walks`], decision 1385) — the gate's true cost counter, ≥
|
||||
/// [`Self::layout_solves`] because a walk that concludes "nothing moved" pays the same
|
||||
/// preamble and never reaches the solve counter.
|
||||
pub fn layout_gate_walks(&self) -> u64 {
|
||||
self.model_ref().layout_gate_walks
|
||||
}
|
||||
|
||||
/// Total fixpoint ROUNDS across every solve ([`Model::layout_rounds`]) — a solve costs
|
||||
/// rounds × the whole graph, so the ratio against [`Self::layout_solves`] is the per-pass
|
||||
/// depth.
|
||||
|
||||
@@ -89,11 +89,16 @@ pub(crate) struct Model {
|
||||
/// that MOVED rather than the whole graph (decision 1350; see `script::layout::LayoutScope`).
|
||||
/// Tiers 1 and 2 above decide *whether* to solve; this decides *what*.
|
||||
pub(crate) layout_scope: super::layout::LayoutScope,
|
||||
/// The [`Self::layout_epoch`] value the last SETTLED resolve closed on — one whose
|
||||
/// fingerprint matched, proving the input set (seeds included) has stopped moving. `None`
|
||||
/// forces the next resolve through tier 1: the initial state, a cycle-bailed pass, and every
|
||||
/// real solve (whose stored fingerprint answers for now-outgrown seeds — layout.rs, "Tier 1
|
||||
/// closes ONLY on a settled frame").
|
||||
/// The [`Self::layout_epoch`] value the last CONVERGED resolve closed on. `None` forces the
|
||||
/// next resolve through tier 1: the initial state, a cycle-bailed pass, and
|
||||
/// [`super::UiScript::force_full_layout_resolve`].
|
||||
///
|
||||
/// **Every converged resolve closes tier 1** (decision 1385) — a real solve as much as a
|
||||
/// skipping one. It could not, while the fingerprint was hashed over the 0294 seeds as well
|
||||
/// as the inputs: a solve outgrew the value it had just stored, so a mutated frame paid three
|
||||
/// whole-roster walks (solve, settle, skip) instead of one. Hashing inputs alone makes the
|
||||
/// stored fingerprint exactly the one the next mutation-free resolve recomputes, so a
|
||||
/// per-frame layout write — the castbar's spark, any addon that animates a region — costs one.
|
||||
pub(crate) layout_epoch_resolved: Option<u64>,
|
||||
/// How many times the change gate has LET A RESOLVE THROUGH. `UiScript::resolve` is called
|
||||
/// unconditionally every frame, so the gap between call count and this is the gate's whole
|
||||
@@ -101,6 +106,25 @@ pub(crate) struct Model {
|
||||
/// merely producing the same rects. Counts the gate's decision, so it reads the same with the
|
||||
/// `WOW_LAYOUT_VERIFY` self-check on (which re-runs skipped resolves) as with it off.
|
||||
pub(crate) layout_solves: u64,
|
||||
/// How many times a resolve got **past tier 1** — the count of whole-roster preamble WALKS
|
||||
/// (decision 1385). This, not [`Self::layout_solves`], is the honest cost counter for the
|
||||
/// gate: a walk that ends in `gate_skips` still rebuilt the ids/plan, re-synced every frame's
|
||||
/// scale and re-hashed all ~10k anchored regions to conclude "nothing moved" — ~1.0 ms at the
|
||||
/// Stormwind pin — and `layout_solves` cannot see it, because it counts only the walks that
|
||||
/// went on to solve.
|
||||
///
|
||||
/// The castbar pin is why it exists: one moving spark cost **three** walks per frame and only
|
||||
/// two of them were solves, so the counter the tests asserted on under-reported the bug by a
|
||||
/// third. `resolve_bench::a_region_moving_every_frame_costs_one_gate_walk_on_the_shipped_ui`
|
||||
/// is the guard that reads it.
|
||||
///
|
||||
/// **Unlike [`Self::layout_solves`], this is NOT verify-independent.** Under
|
||||
/// `layout_verify_enabled` a tier-1-clean resolve falls through and walks anyway, so a verify
|
||||
/// build counts walks production never pays — which is the honest reading (the walk happened)
|
||||
/// but makes the number useless as an assertion there. Assert on it only from a crate that
|
||||
/// consumes `benilla-ui` as a dependency, where `cfg!(test)` is false and the count is
|
||||
/// production's; inside this crate's own tests, assert on `layout_solves`.
|
||||
pub(crate) layout_gate_walks: u64,
|
||||
/// The SCOPE of the last solve that ran: `(frames solved, regions swept)` — decision 1350's
|
||||
/// own meter, and the number its gate asserts on. `layout_solves` says how often the gate let
|
||||
/// a solve through and `layout_rounds` how deep each went; this says how WIDE, which is the
|
||||
@@ -1100,6 +1124,7 @@ impl Model {
|
||||
layout_last_scope: (0, 0),
|
||||
layout_epoch_resolved: None,
|
||||
layout_solves: 0,
|
||||
layout_gate_walks: 0,
|
||||
layout_rounds: 0,
|
||||
resolved: HashMap::new(),
|
||||
link_spans: HashMap::new(),
|
||||
|
||||
@@ -428,8 +428,10 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let (w0, h0) = (input.width, input.height);
|
||||
input.width = (input.width + dw).max(1.0);
|
||||
input.height = (input.height + dh).max(1.0);
|
||||
let mut moved = input.width.to_bits() != w0.to_bits() || input.height.to_bits() != h0.to_bits();
|
||||
// The planted edge: a frame anchored by its LEFT that is gripped on the LEFT has to move too,
|
||||
// or the resize would push the right edge instead. Only the gripped axis shifts.
|
||||
if !input.anchors.is_empty() && (sz.left || sz.bottom) {
|
||||
@@ -441,8 +443,16 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
|
||||
a.y_off += dy;
|
||||
}
|
||||
}
|
||||
moved |= (sz.left && dx != 0.0) || (sz.bottom && dy != 0.0);
|
||||
}
|
||||
// The zero-delta return above is on the RAW cursor delta, which is not the same question: a
|
||||
// single-axis grip dragged purely across its axis gives `dw == dh == 0`, and so does a grip
|
||||
// held past `.max(1.0)` saturation. Both used to bump the epoch and then hash all ~10k
|
||||
// anchored regions to conclude nothing had moved — the castbar's bug class in miniature, for
|
||||
// every frame of such a drag (decision 1385).
|
||||
if moved {
|
||||
model.touch_layout();
|
||||
}
|
||||
model.touch_layout();
|
||||
model.sizing = Some(FrameSizing { sample: pos, ..sz });
|
||||
}
|
||||
|
||||
|
||||
@@ -328,26 +328,106 @@ fn the_hover_re_enter_loop_neither_re_measures_nor_re_solves() {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Tier 1 of the gate (decision 0740): the mutation epoch. The epoch closes only on a SETTLED
|
||||
/// resolve (one whose fingerprint matched — the first resolve after a change hashes outgrown
|
||||
/// seeds, so it may not close), and from then on a quiet frame skips at a `u64` compare without
|
||||
/// computing the fingerprint at all.
|
||||
/// Tier 1 of the gate (decision 0740): the mutation epoch. **Any CONVERGED resolve closes it**
|
||||
/// (decision 1385) — the fingerprint is hashed over inputs alone, so a solve cannot outgrow the
|
||||
/// value it stores — and from then on a quiet frame skips at a `u64` compare without computing
|
||||
/// the fingerprint at all.
|
||||
fn tier_one_closed(s: &UiScript) -> bool {
|
||||
let m = s.lua().app_data_ref::<Model>().expect("model app_data");
|
||||
m.layout_epoch_resolved == Some(m.layout_epoch)
|
||||
}
|
||||
|
||||
/// **The castbar law** (decision 1385, ledger B283): a region that MOVES every frame — the classic
|
||||
/// OnUpdate animation idiom, our own `CastingBarSpark:SetPoint`, and every addon that slides a
|
||||
/// texture — must cost exactly **one** let-through resolve per frame.
|
||||
///
|
||||
/// It used to cost three. The fingerprint was hashed over the 0294 SEEDS as well as the inputs, so
|
||||
/// a solve necessarily outgrew the fingerprint it had just stored: neither that pass nor the
|
||||
/// settling pass behind it could close tier 1, and the frame paid solve + settle + skip — three
|
||||
/// whole-roster walks at ~1.0–1.4 ms each. Measured live at the Stormwind pin: **+4.2 ms of CPU
|
||||
/// per frame for one 32×32 spark**, on a default UI with no addons loaded.
|
||||
///
|
||||
/// This is the regression guard for that whole bug class. `solves` is the honest counter — a
|
||||
/// wasted walk that concludes "nothing moved" still costs the full preamble — so the assertion is
|
||||
/// on the count, never on a duration (0735: milliseconds are not evidence of a scope regression).
|
||||
#[test]
|
||||
fn a_region_moving_every_frame_costs_exactly_one_solve_per_frame() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
s.run(
|
||||
r#"
|
||||
spark = parent:CreateTexture(nil, "OVERLAY")
|
||||
spark:SetWidth(32); spark:SetHeight(32)
|
||||
spark:SetPoint("CENTER", parent, "LEFT", 0, 2)
|
||||
"#,
|
||||
)
|
||||
.expect("spark");
|
||||
s.resolve();
|
||||
assert!(tier_one_closed(&s), "the setup must settle in one resolve");
|
||||
|
||||
// Ten frames of the castbar's own inner loop: one region re-pointed to a NEW offset, then the
|
||||
// host's per-frame resolve. Nothing else in the model moves.
|
||||
//
|
||||
// The offsets start at 1.7, not 0 — a `frame * 1.7` sequence opens with a re-write of the
|
||||
// seed value, which the setters' bit-exact compare absorbs (no epoch bump, no solve at all).
|
||||
// That is the *right* answer and `an_idempotent_setter_call_leaves_tier_one_closed` pins it;
|
||||
// here it would have measured the guard instead of the gate.
|
||||
for frame in 0..10 {
|
||||
let before = solves(&s);
|
||||
s.run(&format!(
|
||||
r#"spark:SetPoint("CENTER", parent, "LEFT", {}, 2)"#,
|
||||
f64::from(frame + 1) * 1.7
|
||||
))
|
||||
.expect("spark move");
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
solves(&s) - before,
|
||||
1,
|
||||
"frame {frame}: a single moving region must cost ONE solve, not the \
|
||||
solve+settle+skip trio the seeds-in-the-fingerprint law used to force"
|
||||
);
|
||||
assert!(
|
||||
tier_one_closed(&s),
|
||||
"frame {frame}: the converged solve must close tier 1, so a second getter in the \
|
||||
same tick skips at the u64 compare instead of re-walking the whole roster"
|
||||
);
|
||||
}
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The other half of the same law: once it has settled, an untouched frame costs NOTHING — no
|
||||
/// solve, and (tier 1) not even the fingerprint walk that would decide so.
|
||||
#[test]
|
||||
fn a_quiet_frame_after_a_move_costs_no_solve_at_all() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
s.resolve();
|
||||
s.run(r#"parent:SetPoint("TOPLEFT", nil, "TOPLEFT", 33, -44)"#)
|
||||
.expect("move");
|
||||
s.resolve();
|
||||
let settled = solves(&s);
|
||||
for _ in 0..5 {
|
||||
s.resolve();
|
||||
assert_eq!(solves(&s), settled, "a quiet frame must not solve");
|
||||
assert!(tier_one_closed(&s), "and must not reopen tier 1");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_settled_resolve_closes_tier_one_and_a_real_write_reopens_it() {
|
||||
let mut s = script();
|
||||
s.set_screen_size(800.0, 600.0);
|
||||
setup(&s);
|
||||
|
||||
s.resolve(); // the change-driven solve (fingerprint hashed over pre-solve seeds)
|
||||
s.resolve(); // the settling pass — fingerprint matches, tier 1 closes
|
||||
// ONE resolve closes the epoch (decision 1385): the fingerprint is hashed over inputs alone,
|
||||
// and the rounds just drove those inputs to their fixpoint, so there is nothing left for a
|
||||
// settling pass to discover. This used to need two.
|
||||
s.resolve();
|
||||
assert!(
|
||||
tier_one_closed(&s),
|
||||
"a settled resolve must close the epoch"
|
||||
"a converged resolve must close the epoch by itself"
|
||||
);
|
||||
|
||||
s.run("parent:SetWidth(150)").expect("resize");
|
||||
@@ -356,8 +436,7 @@ fn a_settled_resolve_closes_tier_one_and_a_real_write_reopens_it() {
|
||||
"a real layout write must reopen tier 1"
|
||||
);
|
||||
s.resolve();
|
||||
s.resolve();
|
||||
assert!(tier_one_closed(&s), "and settling closes it again");
|
||||
assert!(tier_one_closed(&s), "and one resolve closes it again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -565,6 +565,30 @@ pub(crate) struct MeasuredText {
|
||||
pub(crate) key: u64,
|
||||
}
|
||||
|
||||
impl MeasuredText {
|
||||
/// Did the part of this measurement the **layout** reads move? (decision 1385)
|
||||
///
|
||||
/// Only `w`/`h` are layout inputs — they are the auto-size axes, and the only fields
|
||||
/// `InputFingerprint` feeds (`script::layout`'s region walk hashes `m.w`/`m.h`, nothing else).
|
||||
/// `key` is the measure CACHE's business and `natural_w` is `GetStringWidth`'s; neither can
|
||||
/// move a rect.
|
||||
///
|
||||
/// The distinction is load-bearing, not tidiness. A measure request is only ever *issued* when
|
||||
/// the stored key mismatches, and `MeasuredText`'s derived `PartialEq` includes `key` — so
|
||||
/// `d.measured != Some(new)` is **structurally always true** at both write sites, and touching
|
||||
/// the epoch on it meant every answered measure opened tier 1. A FontString whose text changes
|
||||
/// to something the same size does that on a cadence: a buff countdown ticking `"58s" → "57s"`,
|
||||
/// a colour-code swap, any same-width digit. Tier 1 then went dirty and tier 2 hashed all
|
||||
/// ~10k anchored regions to conclude nothing had moved — ~1 ms, `solves=0`, per tick, times
|
||||
/// the number of unsynchronised timers on screen. The same shape 0740's own doc records the
|
||||
/// bag-hover loop paying, and a sibling of the castbar's (1385).
|
||||
pub(crate) fn layout_moved(before: Option<Self>, after: Self) -> bool {
|
||||
before.is_none_or(|b| {
|
||||
b.w.to_bits() != after.w.to_bits() || b.h.to_bits() != after.h.to_bits()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RegionData {
|
||||
/// The measure-cache key for the region's CURRENT text/font/wrap/outline — the one recipe both
|
||||
/// sides of the measure round-trip share. [`super::UiScript::fontstrings_needing_measure`]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user