quest timers, honest interiors, and snow rebuilt from the bytes

- timed quests get their countdown — a Quest Timers box under the minimap and a Time Remaining line in the quest log, kept against the server's own clock
- interiors go honest: footsteps stop crunching the terrain's snow through a building's floor, and tavern voices are authored dry, so the fake cave echo is gone
- snow is the reference's again — point-sprite flakes at the era's pixel size, and a spawn slab that leans into your run so a runner no longer outruns the field
- Stormwind stops drawing Elwynn through its walls, a looted quest crate's lid settles, cold zones puff visible breath, and the collect-quest toast fires only on a real advance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-11 18:00:02 +07:00
co-authored by Claude Fable 5
parent 8cc4ffa188
commit 7b4bd8af4a
103 changed files with 7352 additions and 2136 deletions
+16
View File
@@ -48,3 +48,19 @@ opt-level = 1
inherits = "release"
lto = "fat"
codegen-units = 1
# The director's play loop (decision 1157): `release` plus incremental compilation. `benilla-app` is one crate of
# ~243k lines, and release is non-incremental by cargo's default, so a one-line edit re-optimizes
# all of it — measured 2m47s (43s frontend, ~121s LLVM at opt-level 3) against a 1.8s stamp rebuild
# and a 2.5s no-op. Incremental caches both halves per codegen unit, so an edit pays for what it
# touched. Cargo applies `incremental` to workspace members ONLY (verified: `target/debug/incremental`
# holds our 16 crates and zero registry deps), so the ~760 dependencies stay non-incremental and
# sccache-cacheable — 0192's wrapper and the cold-slot build are untouched.
#
# Why a separate profile rather than flipping `release`: incremental raises the codegen-unit count
# and drops cross-unit inlining, which is a real codegen difference. The FPS campaign grades on
# default `release` (0736) and same-day A/B is the law, so the graded profile must not quietly
# change under it. Run it as `cargo play` (the alias in `.cargo/config.toml`).
[profile.play]
inherits = "release"
incremental = true
@@ -325,23 +325,43 @@
-- the full row, not the currently-visible one, exactly as the ref's SetNumTabs(5) is.
PanelTemplates_SetNumTabs(frame, 3)
PanelTemplates_SetTab(frame, 1)
-- The reference LABELS its numerals here (CharacterFrame_OnLoad l.55-57: "Health" on
-- the player's health bar, "XP" on the XP bar, with the power bar's following the
-- resource in UnitFrames.xml). Shipped in 1146 and cut the same day on the director's
-- call — bare numbers read better on our bars (decision 1147). The mechanism is
-- untouched and live: SetTextStatusBarTextPrefix works, and restoring any one of the
-- three is the one line the reference has. If it is ever restored, note the ordering
-- it depends on — a prefix only sticks on a bar that already has its TextString, and
-- this file's seat works only because the manifest runs UnitFrames (52) and ActionBar
-- (76) well ahead of it (149), exactly as FrameXML runs PlayerFrame (44) and
-- MainMenuBar (34) ahead of CharacterFrame (53).
end
-- ref CharacterFrame_OnShow (l.82-93): Show*TextStatusBarText/ShowWatchedReputationBarText
-- OMITTED — no unit-frame bar-text-mode toggle or reputation-bar-text mechanism exists in
-- this engine. UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml); it was
-- omitted only for want of a micro bar. UnitPVPName -> plain UnitName (this file's header
-- comment: PVP rank is a named deferral).
-- ref CharacterFrame_OnShow (l.82-93). The Show*TextStatusBarText calls are LIVE as of
-- decision 1146 — the note that used to sit here ("no unit-frame bar-text-mode toggle
-- exists in this engine") was true when it was written and stopped being true when the
-- numerals landed (1143). Opening the character sheet reveals your own and your pet's
-- numbers for as long as it is open, whatever Status Bar Text says, through the same
-- lockShow refcount the hover uses — so a hover inside an open sheet still balances.
-- ShowWatchedReputationBarText stays OMITTED: no reputation watch bar exists.
-- UnitPVPName -> plain UnitName (this file's header comment: PVP rank is a named deferral).
function BenillaCharacterFrame_OnShow()
if UpdateMicroButtons then UpdateMicroButtons() end
PlaySound("igCharacterInfoOpen")
SetPortraitTexture(getglobal("BenillaCharacterFramePortrait"), "player")
getglobal("BenillaCharacterNameText"):SetText(UnitName("player"))
if ShowTextStatusBarText then -- engine tests load file subsets
ShowTextStatusBarText(BenillaPlayerFrameHealthBar)
ShowTextStatusBarText(BenillaPlayerFramePowerBar)
ShowTextStatusBarText(BenillaExpBar)
ShowTextStatusBarText(BenillaPetFrameHealthBar)
ShowTextStatusBarText(BenillaPetFrameManaBar)
end
end
-- ref CharacterFrame_OnHide (l.95-104): everything past the PlaySound is the OnShow list's
-- Hide-twin — UpdateMicroButtons live (MicroMenu.xml), the bar-text pair still OMITTED for
-- the same reason as above. The subframe hide-loop is OUR OWN addition
-- Hide-twin — UpdateMicroButtons live (MicroMenu.xml), the bar-text calls live too (1146),
-- the reputation one still omitted for the same reason as above. The subframe hide-loop is OUR OWN addition
-- (decision 0437 phase 4): BenillaPaperDollFrame is a true engine child of this frame (its
-- own header comment) so it hides for free on the parent's Hide-cascade, but
-- BenillaSkillFrame is a SEPARATE top-level file — this engine has no cross-file `parent=`
@@ -354,6 +374,13 @@
function BenillaCharacterFrame_OnHide()
if UpdateMicroButtons then UpdateMicroButtons() end
PlaySound("igCharacterInfoClose")
if HideTextStatusBarText then -- engine tests load file subsets
HideTextStatusBarText(BenillaPlayerFrameHealthBar)
HideTextStatusBarText(BenillaPlayerFramePowerBar)
HideTextStatusBarText(BenillaExpBar)
HideTextStatusBarText(BenillaPetFrameHealthBar)
HideTextStatusBarText(BenillaPetFrameManaBar)
end
for _, name in ipairs(BENILLA_CHARACTERFRAME_SUBFRAMES) do
local subFrame = getglobal(name)
if subFrame then subFrame:Hide() end
+86 -9
View File
@@ -84,6 +84,12 @@
-- one rolling pool across every watch, dynamically re-chained each paint — the ref's own
-- model (the old fixed 5×(1+4) per-slot grid predated region Show/Hide/SetPoint).
BENILLA_QUESTWATCH_LINES = 30
-- The timed-quest countdown row (decision 1150). UPDATE_DELAY is the ref's own
-- (QuestLogFrame.lua:5) — the throttle on the "Time Remaining:" repaint, not a benilla
-- dial. TIME_REMAINING is the shipped enUS GlobalString (GlobalStrings.lua:4248), inlined
-- like every other string in these files (we load no GlobalStrings.lua).
BENILLA_QUESTLOG_UPDATE_DELAY = 0.1
BENILLA_TIME_REMAINING = "Time Remaining:"
-- The detail pane's ScrollFrame child-resize sum (`BenillaQuestLogDetail_ResizeChild`'s own
-- comment) — fixed offsets/row heights transcribed from this chain's own FontString/SetPoint
@@ -454,6 +460,14 @@
else
objSpan = BENILLA_QUESTLOG_DETAIL_OBJ_GAP
end
-- The countdown row, when the selection has one (decision 1150): one more -10 hop and
-- one more fixed 12px row ahead of the objectives, whether or not any objective follows
-- it — both branches above start at the same ObjectivesText→next -10, so the delta is
-- the same either way. Read off the ref's own hasTimer flag rather than a second
-- parameter, so the OnUpdate re-check below re-sums it without threading state.
if BenillaQuestLogHasTimer then
objSpan = objSpan + BENILLA_QUESTLOG_DETAIL_OBJ_GAP + BENILLA_QUESTLOG_DETAIL_OBJ_ROW
end
local height = BENILLA_QUESTLOG_DETAIL_TOP
+ BenillaQuestLogDetail_TextHeight(title)
@@ -486,6 +500,24 @@
BenillaQuestLogDetail_ResizeChild(detailObjAnchorIndex, detailRewardHeight)
end
-- ref QuestLog_OnUpdate (QuestLogFrame.lua:96-105), transcribed 1:1 including its
-- UPDATE_DELAY throttle: the countdown is repainted ten times a second, not every frame —
-- SecondsToTime's output only changes once a second, so anything faster is a re-measure of
-- an identical string. The flag/accumulator pair is the ref's own (its two fields on
-- QuestLogFrame; ours are file globals because our detail state already lives that way).
function BenillaQuestLog_OnUpdate(elapsed)
if not BenillaQuestLogHasTimer then
return
end
BenillaQuestLogTimePassed = (BenillaQuestLogTimePassed or 0) + (elapsed or 0)
if BenillaQuestLogTimePassed > BENILLA_QUESTLOG_UPDATE_DELAY then
local left = GetQuestLogTimeLeft()
getglobal("BenillaQuestLogTimerText"):SetText(
BENILLA_TIME_REMAINING .. " " .. SecondsToTime(left or 0))
BenillaQuestLogTimePassed = 0
end
end
-- Steps the detail ScrollFrame by `delta` px — the wheel handler and both scrollbar arrow
-- buttons share this one path; SetVerticalScroll's own clamp (benilla-ui's `scrollframe.rs`)
-- does the range-bounding, this function just supplies the signed step.
@@ -529,6 +561,11 @@
function BenillaQuestLogDetail_Clear()
getglobal("BenillaQuestLogQuestTitle"):SetText("")
getglobal("BenillaQuestLogObjectivesText"):SetText("")
-- The ref clears hasTimer on the empty-log path too (QuestLog_Update, :108) — without
-- it the per-tick repaint keeps writing a countdown into a pane with no selection, and
-- the child height keeps paying for a row that isn't there.
BenillaQuestLogHasTimer = nil
getglobal("BenillaQuestLogTimerText"):SetText("")
getglobal("BenillaQuestLogDescriptionTitle"):SetText("")
getglobal("BenillaQuestLogDescription"):SetText("")
for i = 1, BENILLA_QUESTLOG_MAX_OBJECTIVES do
@@ -758,6 +795,26 @@
-- paragraph (not the leaderboard lines) is the second.
local description, objectivesText = GetQuestLogQuestText()
getglobal("BenillaQuestLogObjectivesText"):SetText(objectivesText or "")
-- pin §3, QuestLog_UpdateQuestDetails (QuestLogFrame.lua:364-374) verbatim, minus the
-- Show/Hide our region model has no verb for: a timed quest gets the "Time Remaining:"
-- row and pushes Objective1 below it; an untimed one blanks the row (our stand-in for
-- the ref's :Hide()) and anchors Objective1 straight onto ObjectivesText, so the row
-- costs an untimed quest no space at all. `hasTimer` is the ref's own flag and drives
-- both the per-tick repaint (BenillaQuestLog_OnUpdate) and the child-height sum.
local questTimer = GetQuestLogTimeLeft()
local timerText = getglobal("BenillaQuestLogTimerText")
local objective1 = getglobal("BenillaQuestLogObjective1")
if questTimer then
BenillaQuestLogHasTimer = 1
BenillaQuestLogTimePassed = 0
timerText:SetText(BENILLA_TIME_REMAINING .. " " .. SecondsToTime(questTimer))
objective1:SetPoint("TOPLEFT", timerText, "BOTTOMLEFT", 0, -10)
else
BenillaQuestLogHasTimer = nil
timerText:SetText("")
objective1:SetPoint("TOPLEFT", getglobal("BenillaQuestLogObjectivesText"), "BOTTOMLEFT", 0, -10)
end
getglobal("BenillaQuestLogDescription"):SetText(description or "")
-- Lua-managed header (ref text=QUEST_DESCRIPTION is static since the ref always has a real
-- selection whenever this pane is even visible; our flat window keeps the same FontString
@@ -791,13 +848,15 @@
-- Objective10 — a quest with fewer than 10 objectives would otherwise drag this header
-- down by however much dead space the unused tail slots add (this window's own comment
-- on the FontString). No RequiredMoney branch here (this file's documented v1 gap: no
-- widget backs GetQuestLogRequiredMoney yet); no timer branch (no timed-quest countdown,
-- same v1 gap as QuestLogObjective1's own fixed anchor) — those two ref branches collapse
-- to the ref's own final "else" (anchor to ObjectivesText) whenever they don't apply.
-- widget backs GetQuestLogRequiredMoney yet) — that ref branch collapses into the rest.
-- The objective-less TIMED branch (ref :432-437) is transcribed: a timed quest with no
-- leaderboard lines hangs the header off the countdown row, not off ObjectivesText.
local objAnchorIndex = math.min(numObjectives, BENILLA_QUESTLOG_MAX_OBJECTIVES)
local descTitle = getglobal("BenillaQuestLogDescriptionTitle")
if objAnchorIndex > 0 then
descTitle:SetPoint("TOPLEFT", getglobal("BenillaQuestLogObjective" .. objAnchorIndex), "BOTTOMLEFT", 0, -10)
elseif questTimer then
descTitle:SetPoint("TOPLEFT", getglobal("BenillaQuestLogTimerText"), "BOTTOMLEFT", 0, -10)
else
descTitle:SetPoint("TOPLEFT", getglobal("BenillaQuestLogObjectivesText"), "BOTTOMLEFT", 0, -10)
end
@@ -1480,15 +1539,28 @@
<Size><AbsDimension x="275" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaQuestLogQuestTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor></Anchors>
</FontString>
<!-- The timed-quest countdown row (decision 1150) — the ref's QuestLogTimerText,
QuestLogFrame.xml:648-659, at its own anchor and offset. Untimed quests are the
overwhelming majority, so the row must cost them NOTHING: the ref hides it and
re-SetPoints Objective1 straight onto ObjectivesText (QuestLogFrame.lua:370/374),
and `BenillaQuestLogDetail_Update` transcribes exactly that pair of branches. Our
region model has no Show/Hide for a bare FontString (the recurring comment
below), so the untimed case leaves this string EMPTY — it draws nothing, and
nothing anchors through it, so its 12px sits harmlessly behind Objective1's row
rather than displacing anything. -->
<FontString name="BenillaQuestLogTimerText" inherits="QuestFontNormalSmall" justifyH="LEFT">
<Size><AbsDimension x="285" y="12"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="BenillaQuestLogObjectivesText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</FontString>
<!-- No `hidden=` on these 10 (a no-op on a region anyway — same reasoning as the
empty-state text above): each starts textless (no `text=` attribute) and stays
that way until `BenillaQuestLogDetail_Update` fills the ones in use.
Objective1's (0,-10) COLLAPSES the ref's two hops — ObjectivesText→QuestLogTimerText
(0,-10) then →QuestLogObjective1 (0,-10), QuestLogFrame.xml:648-670 — into one: we
ship no timer text (no timed-quest countdown yet, this file's header comment), so
there's no intermediate row to occupy that first -10, and stacking both would
double the gap the ref never shows for an untimed quest. Revisit (re-insert the
timer row at its own -10 slot) once GetQuestLogTimeLeft() is wired.
Objective1's XML anchor is the UNTIMED one (straight onto ObjectivesText) rather
than the ref's static hop through the timer row, because in this engine that
row cannot be hidden — the timed branch re-SetPoints it through
BenillaQuestLogTimerText, which is the ref's own runtime behaviour either way
(QuestLogFrame.lua:370/374; only the XML seed differs, and the ref re-points it
on every populate before anything is drawn).
Height is EXPLICIT 12 (GameFontNormalSmall is 10px, `Fonts.xml`), NOT the ref's
auto "y=0" — a deliberate v1 deviation: a FontString anchored by a single point
@@ -1720,6 +1792,11 @@
<Scripts>
<OnLoad>BenillaQuestLogFrame_OnLoad(self)</OnLoad>
<OnEvent>BenillaQuestLogFrame_OnEvent(self)</OnEvent>
<!-- The countdown's own repaint (ref QuestLog_OnUpdate, QuestLogFrame.lua:96-105) —
the ONLY thing the ref's window OnUpdate does. Gated on hasTimer, so it costs an
untimed selection nothing, and gated on the window being effectively visible by
`UiScript::tick`'s own OnUpdate gate, so it costs a closed window nothing. -->
<OnUpdate>BenillaQuestLog_OnUpdate(arg1)</OnUpdate>
<!-- The list wheel lives on the WINDOW, not just the child catcher: the engine bubbles a
spin up the PARENT chain (script/pointer.rs mouse_wheel), so a spin over a quest-title
row (no OnMouseWheel of its own) must reach this window to scroll — a sibling catcher
@@ -0,0 +1,255 @@
<Ui>
<!--
The quest-timer window — the small dialog under the minimap that counts a timed quest down
(decision 1150, B234). Geometry, art, layout and behaviour are QUOTED from the real 1.12
QuestTimerFrame (extracted Interface\FrameXML\QuestTimerFrame.xml + .lua): a 160x72
UI-DialogBox backdrop with the 256x64 header art and the QUEST_TIMERS title on it, twenty
140x16 rows stacked flush from TOP -30, and a height of 45 + 16*n for n live timers.
It is a LIST OF TIMES, not a bar. The report this closes guessed a mirror-timer-style bar,
and the reference file settles it: one `SecondsToTime` line per timed quest ("14 Mins 20
Secs"), clickable to open the quest log at that quest, hovering to name it. The mirror
timers (MirrorTimer.xml, 0874) are a different mechanism entirely — those are server-pushed
values with a rate to integrate, this reads the quest log's own deadlines.
The feed is the engine's GetQuestTimers()/GetQuestIndexForTimer(), which subtract the
server's wall clock (SMSG_QUERY_TIME_RESPONSE) from each quest-log slot's absolute deadline
per call — the reference's own division of labour, and why the OnUpdate below can repaint a
ticking number without the quest-log snapshot changing under it. No timed quests → the
bindings return no values at all → the frame hides, which is the ref's own signal.
Departures, each named:
- BARE frame names (no `Benilla` prefix), like DurabilityFrame and GroupLootFrame1 before
it (decision 0591 §3): UIParent_ManageFramePositions resolves its stack through a literal
getglobal(name), and the ref's own Lua below reaches its rows by
getglobal("QuestTimer"..i.."Text"). Prefixing would silently unseat the frame and break
the row lookup at once.
- Lua 5.0 `arg`/`arg.n` varargs replaced with select("#", ...)/select(i, ...) —
LUA_COMPAT_VARARG isn't shipped (TalentFrame.xml / WorldMapFrame.xml's precedent).
- The ref's XML anchor (TOPRIGHT of MinimapCluster +10,0) is transcribed and, like every
managed HUD frame, DEAD ON ARRIVAL: UIParent_ManageFramePositions re-seats it at
-CONTAINER_OFFSET_X on every pass. Kept verbatim anyway so the file reads as the ref's.
- `toplevel="true"` is dropped: it is the ref's raise-on-click behaviour for movable
dialogs, which this engine has no verb for and this frame never uses (it is not movable
and its click opens the quest log rather than raising anything).
- QuestTimerFrame_OnShow/OnHide call the manage pass, as the ref does — that is what lets
the durability guy and the quest tracker drop below this window when a timer appears.
- The ref's own commented-out QuestTimerFrame_UpdatePosition (its pre-manage-pass
positioning) is left out rather than transcribed as dead code.
NOT a departure, though it looks like one: the row buttons are laid out flush (0,0 between
them) while each is 16 tall and the frame grows by exactly 16 per timer. That is the ref's
own arithmetic (45 + 16*n against a 72-tall empty frame), not a rounding of ours.
-->
<Script><![CDATA[
-- ref GlobalStrings.lua:3241 — the window's title, under its real key so the ref's own
-- `text="QUEST_TIMERS"` below resolves through the loader's global-string lookup verbatim
-- (loader/mod.rs `resolve_text`). Declared here because we load no GlobalStrings.lua;
-- guarded, in case a later file ships one.
QUEST_TIMERS = QUEST_TIMERS or "Quest Timers"
-- ref QuestLogFrame.lua:2. Declared here too because the ref's hide loop below runs to it
-- and this file may load before the quest log's own copy.
MAX_QUESTS = MAX_QUESTS or 20
-- ref QuestTimerFrame_OnLoad (QuestTimerFrame.lua:1-6).
function QuestTimerFrame_OnLoad(frame)
frame:RegisterEvent("QUEST_LOG_UPDATE")
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
QuestTimerFrameNumTimers = 0
QuestTimerFrameUpdating = nil
end
-- ref QuestTimerFrame_OnEvent (:8-14). The `updating` latch is the ref's own re-entrancy
-- guard: QuestTimerFrame_Update's Show() fires OnShow → the manage pass, and a
-- QUEST_LOG_UPDATE arriving inside that would re-enter this.
function QuestTimerFrame_OnEvent()
if not QuestTimerFrameUpdating then
QuestTimerFrame_Update(GetQuestTimers())
end
end
-- ref QuestTimerFrame_Update (:16-32), with select() for the 5.0 vararg table.
function QuestTimerFrame_Update(...)
QuestTimerFrameUpdating = 1
local n = select("#", ...)
for i = 1, n do
-- `select(i, ...)` expands to EVERY value from i on, and in a call's last argument
-- position they all get passed — so an inlined `SecondsToTime(select(i, ...))`
-- hands timer i+1 to SecondsToTime's `noSeconds` and silently drops the seconds
-- off every row but the last ("14 Mins" instead of "14 Mins 20 Secs"). The local
-- takes the first value only. The ref's `arg[i]` had no such hazard; this is the
-- 5.0→5.1 vararg swap's own trap, caught by the test that expected the seconds.
local seconds = select(i, ...)
getglobal("QuestTimer" .. i .. "Text"):SetText(SecondsToTime(seconds))
getglobal("QuestTimer" .. i):Show()
end
for i = n + 1, MAX_QUESTS do
getglobal("QuestTimer" .. i):Hide()
end
QuestTimerFrameNumTimers = n
local frame = getglobal("QuestTimerFrame")
if n > 0 then
frame:SetHeight(45 + (16 * n))
frame:Show()
else
frame:Hide()
end
QuestTimerFrameUpdating = nil
end
-- ref QuestTimerFrame_OnUpdate (:34-38): re-read every frame while any timer is live —
-- that, not an event, is what makes the number tick. Costs nothing while hidden (the
-- engine's OnUpdate gate is effective-visibility) and nothing while empty (numTimers 0).
function QuestTimerFrame_OnUpdate()
if QuestTimerFrameNumTimers > 0 then
QuestTimerFrame_Update(GetQuestTimers())
end
end
-- ref QuestTimerButton_OnClick (:40-44): open the log at that quest.
function QuestTimerButton_OnClick(id)
local index = GetQuestIndexForTimer(id)
if not index then
return
end
ShowUIPanel(getglobal("BenillaQuestLogFrame"))
SelectQuestLogEntry(index)
BenillaQuestLogFrame_Update()
end
]]></Script>
<Button name="QuestTimerTemplate" virtual="true">
<Size>
<AbsDimension x="140" y="16"/>
</Size>
<Layers>
<Layer level="BACKGROUND">
<FontString name="$parentText" inherits="GameFontHighlightSmall">
<Anchors>
<Anchor point="TOP"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<!-- ref QuestTimerFrame.xml:18-27. The tooltip names the quest the row belongs to;
GetQuestLogTitle's FIRST return only (the rest of its 6-tuple is level/tag/state). -->
<OnEnter>
local index = GetQuestIndexForTimer(self:GetID())
if index then
GameTooltip:SetOwner(self)
GameTooltip:SetText(GetQuestLogTitle(index), 1.0, 1.0, 1.0)
GameTooltip:Show()
end
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
<OnClick>QuestTimerButton_OnClick(self:GetID())</OnClick>
</Scripts>
</Button>
<Frame name="QuestTimerFrame" enableMouse="true" frameStrata="BACKGROUND" hidden="true">
<Size>
<AbsDimension x="160" y="72"/>
</Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="MinimapCluster" relativePoint="BOTTOMRIGHT">
<Offset>
<AbsDimension x="10" y="0"/>
</Offset>
</Anchor>
</Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<Texture name="QuestTimerHeader" file="Interface\DialogFrame\UI-DialogBox-Header">
<Size><AbsDimension x="256" y="64"/></Size>
<Anchors>
<Anchor point="TOP"><Offset><AbsDimension x="1" y="12"/></Offset></Anchor>
</Anchors>
</Texture>
<FontString inherits="GameFontNormal" text="QUEST_TIMERS">
<Anchors>
<Anchor point="TOP" relativeTo="QuestTimerHeader"><Offset><AbsDimension x="0" y="-13"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The ref's twenty rows (MAX_QUESTS), first at TOP -30 and the rest flush beneath it.
Named/id'd 1..20 because both the ref's row lookup and its timer→quest mapping are
by ordinal. -->
<Button name="QuestTimer1" inherits="QuestTimerTemplate" hidden="true" id="1">
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="0" y="-30"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer2" inherits="QuestTimerTemplate" hidden="true" id="2">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer3" inherits="QuestTimerTemplate" hidden="true" id="3">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer2" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer4" inherits="QuestTimerTemplate" hidden="true" id="4">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer3" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer5" inherits="QuestTimerTemplate" hidden="true" id="5">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer4" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer6" inherits="QuestTimerTemplate" hidden="true" id="6">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer5" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer7" inherits="QuestTimerTemplate" hidden="true" id="7">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer6" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer8" inherits="QuestTimerTemplate" hidden="true" id="8">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer7" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer9" inherits="QuestTimerTemplate" hidden="true" id="9">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer8" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer10" inherits="QuestTimerTemplate" hidden="true" id="10">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer9" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer11" inherits="QuestTimerTemplate" hidden="true" id="11">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer10" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer12" inherits="QuestTimerTemplate" hidden="true" id="12">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer11" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer13" inherits="QuestTimerTemplate" hidden="true" id="13">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer12" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer14" inherits="QuestTimerTemplate" hidden="true" id="14">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer13" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer15" inherits="QuestTimerTemplate" hidden="true" id="15">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer14" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer16" inherits="QuestTimerTemplate" hidden="true" id="16">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer15" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer17" inherits="QuestTimerTemplate" hidden="true" id="17">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer16" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer18" inherits="QuestTimerTemplate" hidden="true" id="18">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer17" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer19" inherits="QuestTimerTemplate" hidden="true" id="19">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer18" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestTimer20" inherits="QuestTimerTemplate" hidden="true" id="20">
<Anchors><Anchor point="TOP" relativeTo="QuestTimer19" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
</Button>
</Frames>
<Scripts>
<OnLoad>QuestTimerFrame_OnLoad(self)</OnLoad>
<OnUpdate>QuestTimerFrame_OnUpdate()</OnUpdate>
<OnEvent>QuestTimerFrame_OnEvent()</OnEvent>
<!-- ref :46-52 — the stack under the minimap re-flows when this window appears or goes. -->
<OnShow>UIParent_ManageFramePositions()</OnShow>
<OnHide>UIParent_ManageFramePositions()</OnHide>
</Scripts>
</Frame>
</Ui>
+76 -5
View File
@@ -75,6 +75,67 @@
-- read by every `Model_OnUpdate` transcription (the character window's + inspect's).
ROTATIONS_PER_SECOND = .5
-- ref SecondsToTime (UIParent.lua:1004-1031) and its GlobalStrings, in the ref's own home
-- file — two consumers now (the quest-timer window and the quest log's "Time Remaining:"
-- row, decision 1150), so it lives here rather than being redefined per file the way
-- BuffFrame.xml had to for SecondsToTimeAbbrev when it was the only caller.
--
-- Departures, both named:
-- - `GetText(TOKEN, nil, n)` is the ref's plural-aware GlobalString lookup, a C binding we
-- don't ship. Its enUS rule is the singular token for exactly 1 and the `_P1` twin
-- otherwise — the same rule already codified in `tooltip_item/render.rs` and MailFrame.xml
-- — so it collapses to the local `plural()` below and the four shipped string pairs
-- (GlobalStrings.lua:895-896, 2296-2297, 2693-2694, 3404-3405).
-- - Lua 5.1 dialect: `math.mod`/`math.floor` for the 5.0 bare `mod`/`floor`.
--
-- Everything else is verbatim, INCLUDING the two behaviours that look like bugs and are
-- not: the thresholds are strict `>` (86400 s reads as hours, not "1 Day"), and `count < 2`
-- stops after two units, so a 3-day quest reads "3 Days 4 Hrs" and never mentions minutes.
DAYS_ABBR = DAYS_ABBR or "Day"
DAYS_ABBR_P1 = DAYS_ABBR_P1 or "Days"
HOURS_ABBR = HOURS_ABBR or "Hr"
HOURS_ABBR_P1 = HOURS_ABBR_P1 or "Hrs"
MINUTES_ABBR = MINUTES_ABBR or "Min"
MINUTES_ABBR_P1 = MINUTES_ABBR_P1 or "Mins"
SECONDS_ABBR = SECONDS_ABBR or "Sec"
SECONDS_ABBR_P1 = SECONDS_ABBR_P1 or "Secs"
if not SecondsToTime then
local function plural(singular, pluralForm, n)
if n == 1 then return singular end
return pluralForm
end
function SecondsToTime(seconds, noSeconds)
local time = ""
local count = 0
local tempTime
seconds = math.floor(seconds)
if seconds > 86400 then
tempTime = math.floor(seconds / 86400)
time = tempTime .. " " .. plural(DAYS_ABBR, DAYS_ABBR_P1, tempTime) .. " "
seconds = math.mod(seconds, 86400)
count = count + 1
end
if seconds > 3600 then
tempTime = math.floor(seconds / 3600)
time = time .. tempTime .. " " .. plural(HOURS_ABBR, HOURS_ABBR_P1, tempTime) .. " "
seconds = math.mod(seconds, 3600)
count = count + 1
end
if count < 2 and seconds >= 60 then
tempTime = math.floor(seconds / 60)
time = time .. tempTime .. " " .. plural(MINUTES_ABBR, MINUTES_ABBR_P1, tempTime) .. " "
seconds = math.mod(seconds, 60)
count = count + 1
end
if count < 2 and seconds > 0 and not noSeconds then
time = time .. seconds .. " " .. plural(SECONDS_ABBR, SECONDS_ABBR_P1, seconds) .. " "
end
return time
end
end
UIPARENT_MANAGED_FRAME_POSITIONS = {}
-- Frames
UIPARENT_MANAGED_FRAME_POSITIONS["MultiBarBottomLeft"] = {baseY = 17, reputation = 9, maxLevel = -5, anchorTo = "ActionButton1", point = "BOTTOMLEFT", rpoint = "TOPLEFT"}
@@ -247,14 +308,24 @@
-- The under-minimap right stack (ref l.1740-1770): anchorY walks down capture bars →
-- quest timer → durability → quest watch, each shown frame pushing the next one down
-- by its own height. benilla ships the durability + quest-watch arms (below); the two
-- feeders above them (capture bars, quest timer) don't exist yet, so anchorY starts at
-- the ref's initial 0. The reference never leaves DurabilityFrame on its static XML
-- anchor (whose +40 hangs the side glyphs off-screen — director-caught): every pass
-- seats it at -CONTAINER_OFFSET_X, minus 20 more when a showSeparate glyph
-- by its own height. benilla ships the quest-timer, durability and quest-watch arms
-- (below); the capture bars above them don't exist yet, so anchorY starts at the ref's
-- initial 0. The reference never leaves DurabilityFrame on its static XML anchor
-- (whose +40 hangs the side glyphs off-screen — director-caught): every pass seats it
-- at -CONTAINER_OFFSET_X, minus 20 more when a showSeparate glyph
-- (shield/off-weapon/ranged) extends the art to the right — then drops the quest
-- tracker to the running anchorY so the two never collide at the same corner.
local anchorY = 0
-- ref l.1753-1757 verbatim (decision 1150). Seated whether shown or not — the ref
-- SetPoints unconditionally and only the height accrual is gated — so the frame is
-- already in place the frame it appears. Nil-guarded like every row here: the
-- durability-only test harness ships no quest-timer frame.
if ( QuestTimerFrame ) then
QuestTimerFrame:SetPoint("TOPRIGHT", "MinimapCluster", "BOTTOMRIGHT", -CONTAINER_OFFSET_X, anchorY)
if ( QuestTimerFrame:IsShown() ) then
anchorY = anchorY - QuestTimerFrame:GetHeight()
end
end
if ( DurabilityFrame ) then
local durabilityOffset = 0
if ( DurabilityShield:IsShown() or DurabilityOffWeapon:IsShown() or DurabilityRanged:IsShown() ) then
+110 -17
View File
@@ -162,6 +162,15 @@
frame.powerBar.textLockable = 1
end
end
-- The hover seam (1146): who this bar belongs to, and what its click means.
local click = BenillaTargetFrame_OnClick
if unit == "player" then
click = BenillaPlayerFrame_OnClick
end
frame.healthBar.owner = frame
frame.powerBar.owner = frame
frame.healthBar.ownerClick = click
frame.powerBar.ownerClick = click
frame.nameText = getglobal(tf .. "NameText")
frame.levelText = getglobal(tf .. "LevelText")
frame.deadText = getglobal(tf .. "DeadText")
@@ -268,6 +277,41 @@
-- over a UI element in 1.12, and it is authored here in Lua rather than in the engine
-- (wow-re item-target-cursor-and-dropitemonunit.md 4.3). Bag and paper-doll slots have no
-- such branch, which is why they show the ordinary pointer.
-- A BAR's own hover (decision 1146; ref TextStatusBar's OnEnter/OnLeave on a mouse-enabled
-- bar). Two jobs, and the second is the one a naive transcription drops: reveal the numbers
-- while the pointer is on the bar, then hand the hover ON to the frame underneath, because
-- a mouse-enabled child swallows the parent's OnEnter — and with it the unit tooltip AND
-- the cast-cursor split that only exists over unit frames. Same for the click: the ref
-- routes its bars' OnMouseUp straight back to the frame's own handler (PlayerFrame.xml
-- l.304-306), which is what keeps a bar click targeting.
--
-- `owner`/`ownerClick` are stamped at load rather than read from GetParent(), because the
-- pet's bars are siblings of its TextureFrame, not children of the frame they belong to.
function BenillaUnitFrameBar_OnEnter(bar)
if TextStatusBar_UpdateTextString then
TextStatusBar_UpdateTextString(bar)
ShowTextStatusBarText(bar)
end
if bar.owner and BenillaUnitFrame_OnEnter and bar.owner.unit then
BenillaUnitFrame_OnEnter(bar.owner)
end
end
function BenillaUnitFrameBar_OnLeave(bar)
if HideTextStatusBarText then
HideTextStatusBarText(bar)
end
if bar.owner and BenillaUnitFrame_OnLeave and bar.owner.unit then
BenillaUnitFrame_OnLeave(bar.owner)
end
end
function BenillaUnitFrameBar_OnMouseUp(bar, button)
if bar.ownerClick then
bar.ownerClick(button)
end
end
function BenillaUnitFrame_OnEnter(frame)
local unit = frame.unit
if SpellIsTargeting() then
@@ -533,6 +577,12 @@
local _, token = UnitPowerType(unit)
local c = BENILLA_POWER_COLORS[token] or BENILLA_POWER_COLORS.MANA
frame.powerBar:SetStatusBarColor(c.r, c.g, c.b)
-- NO label. 1.12 names the resource here (ref UnitFrame_UpdateManaType l.129 →
-- "Rage 0 / 100" off ManaBarColor's own prefix field, and CharacterFrame_OnLoad
-- labels health and XP the same way). Shipped in 1146, cut on the director's call
-- the same day: bare numbers read better on these bars (decision 1147). The
-- mechanism stays — SetTextStatusBarTextPrefix is live and one call brings any
-- label back — only the calls are gone.
frame.powerBar:SetMinMaxValues(0, maxPower)
frame.powerBar:SetValue(UnitPower(unit))
else
@@ -928,7 +978,7 @@
</Layers>
<Frames>
<!-- Health bar 119×12 TOPLEFT (106,-41), UI-StatusBar, green (ref l.288-315). -->
<StatusBar name="$parentHealthBar">
<StatusBar name="$parentHealthBar" enableMouse="true">
<Size><AbsDimension x="119" y="12"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="106" y="-41"/></Offset></Anchor>
@@ -938,10 +988,13 @@
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
<OnLeave>BenillaUnitFrameBar_OnLeave(self)</OnLeave>
<OnMouseUp>BenillaUnitFrameBar_OnMouseUp(self, arg1)</OnMouseUp>
</Scripts>
</StatusBar>
<!-- Power bar 119×12 TOPLEFT (106,-52), UI-StatusBar; color set at runtime (ref l.316-341). -->
<StatusBar name="$parentPowerBar">
<StatusBar name="$parentPowerBar" enableMouse="true">
<Size><AbsDimension x="119" y="12"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="106" y="-52"/></Offset></Anchor>
@@ -951,6 +1004,9 @@
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
<OnLeave>BenillaUnitFrameBar_OnLeave(self)</OnLeave>
<OnMouseUp>BenillaUnitFrameBar_OnMouseUp(self, arg1)</OnMouseUp>
</Scripts>
</StatusBar>
<!-- The ring art + text, over the bars (ref TargetFrameTextureFrame). 232×100 fills the
@@ -1216,7 +1272,7 @@
</Layers>
<Frames>
<!-- Health bar 119×12 TOPRIGHT (-106,-41), UI-StatusBar, green (ref l.252-273). -->
<StatusBar name="$parentHealthBar">
<StatusBar name="$parentHealthBar" enableMouse="true">
<Size><AbsDimension x="119" y="12"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-106" y="-41"/></Offset></Anchor>
@@ -1226,10 +1282,13 @@
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
<OnLeave>BenillaUnitFrameBar_OnLeave(self)</OnLeave>
<OnMouseUp>BenillaUnitFrameBar_OnMouseUp(self, arg1)</OnMouseUp>
</Scripts>
</StatusBar>
<!-- Power bar 119×12 TOPRIGHT (-106,-52), UI-StatusBar; color set at runtime (ref l.274-292). -->
<StatusBar name="$parentPowerBar">
<StatusBar name="$parentPowerBar" enableMouse="true">
<Size><AbsDimension x="119" y="12"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-106" y="-52"/></Offset></Anchor>
@@ -1239,6 +1298,9 @@
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
<OnLeave>BenillaUnitFrameBar_OnLeave(self)</OnLeave>
<OnMouseUp>BenillaUnitFrameBar_OnMouseUp(self, arg1)</OnMouseUp>
</Scripts>
</StatusBar>
<!-- The ring art + text, over the bars (ref TargetFrameTextureFrame); it draws over the
@@ -1301,6 +1363,23 @@
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-50" y="3"/></Offset></Anchor>
</Anchors>
</FontString>
<!-- The target's numerals (decision 1146), mirrored from the player's seats
the way the whole frame is: x = −50 against the player's +50, the same
1 px optical drop (1145). 1143 wired these bars and left the regions
out because nothing could ever paint them — 1.12 never makes the target
bars textLockable, so Status Bar Text does not pin them. The HOVER is
what they were waiting for, and it is the reference's behaviour whole:
on the target, the numbers are something you go and look at. -->
<FontString name="$parentHealthBarText" inherits="TextStatusBarText">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-50" y="3"/></Offset></Anchor>
</Anchors>
</FontString>
<FontString name="$parentPowerBarText" inherits="TextStatusBarText">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-50" y="-9"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
@@ -1455,6 +1534,10 @@
frame.healthBar.textLockable = 1
frame.manaBar.textLockable = 1
end
-- The pet's bars hover for their numbers, but hand nothing on: the pet frame has no
-- OnEnter of its own, and its click is its own handler (1146).
frame.healthBar.ownerClick = BenillaPetFrame_OnClick
frame.manaBar.ownerClick = BenillaPetFrame_OnClick
frame.attackModeCounter = 0
frame.attackModeSign = -1
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
@@ -1716,7 +1799,7 @@
</Scripts>
</Frame>
<!-- Bars first, pushed one frame level under the TextureFrame at load (see OnLoad). -->
<StatusBar name="$parentHealthBar">
<StatusBar name="$parentHealthBar" enableMouse="true">
<Size><AbsDimension x="70" y="8"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="47" y="-22"/></Offset></Anchor></Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
@@ -1724,9 +1807,12 @@
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
<OnLeave>BenillaUnitFrameBar_OnLeave(self)</OnLeave>
<OnMouseUp>BenillaUnitFrameBar_OnMouseUp(self, arg1)</OnMouseUp>
</Scripts>
</StatusBar>
<StatusBar name="$parentManaBar">
<StatusBar name="$parentManaBar" enableMouse="true">
<Size><AbsDimension x="70" y="8"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="47" y="-29"/></Offset></Anchor></Anchors>
<BarTexture file="Interface\TargetingFrame\UI-StatusBar"/>
@@ -1734,6 +1820,9 @@
<Scripts>
<OnEvent>if TextStatusBar_OnEvent then TextStatusBar_OnEvent(arg1, arg2) end</OnEvent>
<OnValueChanged>if TextStatusBar_OnValueChanged then TextStatusBar_OnValueChanged() end</OnValueChanged>
<OnEnter>BenillaUnitFrameBar_OnEnter(self)</OnEnter>
<OnLeave>BenillaUnitFrameBar_OnLeave(self)</OnLeave>
<OnMouseUp>BenillaUnitFrameBar_OnMouseUp(self, arg1)</OnMouseUp>
</Scripts>
</StatusBar>
<Frame name="$parentTextureFrame">
@@ -1769,20 +1858,24 @@
<FontString name="$parentName" inherits="GameFontNormalSmall" justifyH="LEFT">
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="50" y="33"/></Offset></Anchor></Anchors>
</FontString>
<!-- The pet's numerals (decision 1143), same font as the ref's
(PetFrame.xml l.87-104). ONE divergence, and it is why these anchor to
the bars instead of carrying the ref's literal offsets: the ref's are
CENTER-from-TOPLEFT (82,-27)/(82,-38) against ITS small-frame bar
seats, and ours sit at (47,-22)/(47,-29) — the same x=82 centre, a
different pitch. Anchoring each string to its own bar lands it right by
construction and cannot drift if the bars move again. The -1 is the
same optical nudge the player's pair carries (1145), so the two frames
agree about where a number sits in its bar. -->
<!-- The pet's numerals: the reference's LITERAL seats, CENTER-from-TOPLEFT
(82,-27) and (82,-38) (PetFrame.xml l.87-104). 1143 anchored these to
the bars' own centres instead, on a comparison that was simply wrong —
it read the ref's TEXT offsets against OUR BAR offsets and called the
pitch different. The bars are byte-identical to the reference's, 70x8
at (47,-22) and (47,-29), so the ref's text literals apply unchanged.
The overlap that produced (director report, 2026-08-08, decision 1147)
is what the literals exist to avoid: a 7 px bar pitch has no room for
two centred ~12 px strings, so the reference drops the SECOND one clear
of its bar (the mana bar ends at -37; its numerals sit at -38, just
under it) rather than centring it. Centring both is what collided.
No 1145 nudge here: that pixel was for a string centred in a bar, and
the reference has already placed these deliberately off-centre. -->
<FontString name="$parentHealthBarText" inherits="TextStatusBarText">
<Anchors><Anchor point="CENTER" relativeTo="BenillaPetFrameHealthBar" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="82" y="-27"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentManaBarText" inherits="TextStatusBarText">
<Anchors><Anchor point="CENTER" relativeTo="BenillaPetFrameManaBar" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
<Anchors><Anchor point="CENTER" relativePoint="TOPLEFT"><Offset><AbsDimension x="82" y="-38"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
+50
View File
@@ -83,6 +83,56 @@ pub fn background_run() -> bool {
})
}
/// Background-run envs whose run produces **no image** — nothing reads the framebuffer, so the
/// window is pure plumbing. Prefix-matched, and the rule in [`no_pixel_run`] is **all-of**: a run
/// counts as no-pixel only when *every* background env it sets is in this list, so a
/// `WOW_PROBE_CHAT=… WOW_LIVE_SHOT=…` pairing (drive there, then shoot) keeps its full-size window.
///
/// Erring here is cheap in one direction only: a missing entry just means a big window, which is
/// the old behaviour. A *wrong* entry shrinks a window someone is photographing — so a new
/// `WOW_PROBE…`-named env that captures pixels has to be excluded by name here, since the bare
/// `WOW_PROBE` prefix would otherwise sweep it in.
const NO_PIXEL_ENV_PREFIXES: &[&str] = &[
"WOW_FPS_",
"WOW_LIVE_FPS",
"WOW_PARTICLE_CENSUS",
"WOW_PROBE",
"WOW_RIG",
];
/// Does this run read no pixels — i.e. may its window be small and parked out of the way?
///
/// The point is the director's screen. A probe window is asserted `AlwaysOnTop` for its whole life
/// ([`crate::capture::ProbeFocusPlugin`], decision 0906 — an occluded macOS window throttles to
/// ~1 fps and a wall-clock probe schedule then fires the wrong steps), which silently defeats
/// everything this module does to keep instrumented runs behind the director's work: the level is
/// re-asserted every frame, over the `AlwaysOnBottom` birth cage AND over the `Normal` this module
/// hands back at release. That assertion is correct and stays. What was wrong is pairing it with
/// the **full 1600×900 default**, so every agent probe planted a screen-filling window over
/// whatever they were doing — six of them in one session, which is how it got reported
/// (decision 1148). Un-occludable and unobtrusive were never in conflict: a small window in a
/// corner is both.
///
/// `WOW_BG=1` (forced background, no run-driving env) is deliberately NOT no-pixel — that is the
/// hand-driven case, and shrinking a window the director asked for is the opposite of the point.
pub fn no_pixel_run() -> bool {
if !background_run() {
return false;
}
let mut saw_one = false;
for (name, _) in std::env::vars_os() {
let Some(name) = name.to_str() else { continue };
if !BG_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) {
continue;
}
if !NO_PIXEL_ENV_PREFIXES.iter().any(|p| name.starts_with(p)) {
return false;
}
saw_one = true;
}
saw_one
}
/// On a [`background_run`], undo winit's launch-time app activation and window ordering (macOS;
/// no-op elsewhere) for the length of the launch window, then get out of the way. The
/// `focused: false` half lives where the window is built, in `main`.
+1
View File
@@ -493,6 +493,7 @@ fn push_shadows(
anchor: key.feet,
bias: SHADOW_SORT_BIAS,
raster_bias: SHADOW_RASTER_BIAS,
cam_relative: false,
main_entity: entity,
light: None,
},
+1 -1
View File
@@ -2,7 +2,7 @@
//! with the two validity gates that make its output *evidence*: the death gate (a dead/ghost
//! avatar renders the whole world through the death filter) and the subject gate
//! (`WOW_SHOT_REQUIRE` — a frame that cannot contain the reported subject is not a measurement of
//! it). Split from `probes.rs` when the gates outgrew it; the sibling probe one-shots stay there.
//! it). Split from `probes` when the gates outgrew it; the sibling probe one-shots stay there.
use bevy::prelude::*;
use bevy::render::view::screenshot::{save_to_disk, Screenshot};
+1 -1
View File
@@ -401,7 +401,7 @@ fn resize_request() -> Option<(u32, u32)> {
Some((w.parse().ok()?, h.parse().ok()?))
}
/// The present mode a perf probe uncaps to (also the live probe's — see `probes.rs`).
/// The present mode a perf probe uncaps to (also the live probe's — see `probes/live_fps.rs`).
///
/// `AutoNoVsync`, measured, not assumed: explicit `Immediate` on macOS/Metal is a trap — A/B'd
/// 2026-07-27 (overlook-noon, release, twice each), it *rails* near 16.6 ms AND takes 1.0–1.5 s
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,325 @@
//! The **actuation** probes: the three headless channels into a live session — scripted chat
//! sends ([`ProbeChatPlugin`]), synthetic key taps ([`ProbeKeyPlugin`]), and a Lua chunk in the
//! live UI VM ([`ProbeLuaPlugin`]). Each is env-gated, waits for the session to be in-world, and
//! schedules on [`ProbeClock`]; together they are "do what the director would do, unattended".
use bevy::prelude::*;
use super::ProbeClock;
/// The PROBE CHAT one-shot (`WOW_PROBE_CHAT="<line>[;<line>…]"`, delay via `WOW_PROBE_CHAT_AT`
/// seconds, default 8): send each `;`-separated line as Say once we are in-world — the "park the
/// probe character anywhere" instrument. The probe account (gmlevel 6) makes `.go xyz …`, `.gm on`,
/// `.additem` etc. work headlessly, which a direct `characters` DB edit does NOT (the live world
/// server's logout save overwrites it, and the row is only re-read at login). Pair with
/// [`LiveShotPlugin`] at a later `WOW_LIVE_SHOT_AT` so the destination has streamed in.
/// `WOW_PROBE_CHAT_EVERY=<secs>` spaces the lines apart instead of sending them in one burst —
/// the "do X, wait, then do Y" probe (a mount-then-dismount transition, a buff-then-cancel):
/// two field flips inside one drain merge to a no-op, so time-separated sends are what actually
/// exercise a transition (decision 0441's teardown verification).
pub(crate) struct ProbeChatPlugin;
impl Plugin for ProbeChatPlugin {
fn build(&self, app: &mut App) {
let lines = std::env::var("WOW_PROBE_CHAT").unwrap_or_default();
let at = std::env::var("WOW_PROBE_CHAT_AT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(8.0);
let every = std::env::var("WOW_PROBE_CHAT_EVERY")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(0.0);
app.insert_resource(ProbeChat {
lines,
at,
every,
sent: 0,
})
.add_systems(Update, fire_probe_chat);
}
}
/// [`ProbeChatPlugin`] state: the `;`-separated lines, the first-fire time, the per-line spacing
/// (`0` = one burst), and how many lines have gone out.
#[derive(Resource)]
struct ProbeChat {
lines: String,
at: f32,
every: f32,
sent: usize,
}
/// Submit the probe lines once the delay has elapsed AND the session is in-world (the self player
/// exists) — a `.go` sent before world-enter would be dropped server-side.
///
/// Lines go in through the **chat EditBox seam**, not straight to the wire: a probe line is "what
/// the director would type", so a client-side slash command (`/duel`, `/reaction`) is parsed by
/// the same drain that serves the real chat box, while plain text and `.gm`/`.go` server commands
/// still leave as Say exactly as before. Sending them as Say instead — the original shape — meant
/// every client-side command silently went out as public chat and did nothing (decision 0637).
fn fire_probe_chat(
mut probe: ResMut<ProbeChat>,
time: ProbeClock,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
) {
if probe.lines.is_empty() {
return;
}
if self_player.is_empty() {
return; // not in-world yet — keep waiting past the delay
}
let Some(mut script) = script else {
return;
};
// With no spacing every line goes in the first eligible frame (the original burst); with
// `every`, line N waits until `at + N·every` — the "do X, wait, then do Y" cadence.
loop {
let Some(line) = probe
.lines
.split(';')
.map(str::trim)
.filter(|l| !l.is_empty())
.nth(probe.sent)
else {
return; // all sent
};
let due = probe.at + probe.every * probe.sent as f32;
if time.elapsed_secs() < due {
return;
}
info!("probe-chat: sending {line:?}");
script.push_chat_input(line.to_string());
probe.sent += 1;
}
}
/// The PROBE KEY one-shots (`WOW_PROBE_KEY="<key>@<secs>[:<hold>][;…]"`): synthesize a key press
/// at each given time once in-world, released `<hold>` seconds later ([`PROBE_KEY_TAP_SECS`] when
/// omitted — the tap this instrument shipped with). The "press space headlessly" instrument for
/// input-gated behavior (the mounted flourish, a jump, the X/Z toggles), which neither a chat
/// command nor a Lua chunk can reach (1.12 has no jump Lua API; the gate lives in the
/// controller's key read).
///
/// The optional hold is what makes *sustained* locomotion reachable headlessly: a 0.25 s W tap
/// travels ~1.2 yd, far too little to cross a liquid surface's own slope, so a swim defect that
/// only appears while moving over water could not be reproduced without asking the director to
/// drive (decision 0644 — the gap `WOW_PROBE_LOOK` closed for mouse-turns, on the key side).
///
/// Runs in `PreUpdate`
/// after winit's input processing ([`bevy::input::InputSystems`]) so the synthetic
/// `just_pressed` is visible to every `Update` reader that same frame — a press from inside
/// `Update` would be cleared at the next frame's input pass before an earlier-ordered
/// controller ever saw it.
pub(crate) struct ProbeKeyPlugin;
impl Plugin for ProbeKeyPlugin {
fn build(&self, app: &mut App) {
let spec = std::env::var("WOW_PROBE_KEY").unwrap_or_default();
let taps = spec
.split(';')
.map(str::trim)
.filter(|s| !s.is_empty())
.filter_map(|s| {
let (key, rest) = s.split_once('@')?;
let (at, hold) = match rest.split_once(':') {
Some((at, hold)) => (at, hold.trim().parse::<f32>().ok()?),
None => (rest, PROBE_KEY_TAP_SECS),
};
match (probe_key_by_name(key.trim()), at.trim().parse::<f32>()) {
(Some(key), Ok(at)) => Some(ProbeKeyTap {
key,
at,
hold,
pressed: false,
released: false,
}),
_ => {
warn!("probe-key: unparseable tap {s:?} (want e.g. Space@14 or W@20:6) — skipped");
None
}
}
})
.collect();
app.insert_resource(ProbeKeys { taps }).add_systems(
bevy::app::PreUpdate,
fire_probe_key.after(bevy::input::InputSystems),
);
}
}
/// How long a probe press stays held when the spec gives no `:<hold>`. Long enough that a
/// `pressed`-reader (a held-key gate) sees it across several frames; short enough to stay a tap.
const PROBE_KEY_TAP_SECS: f32 = 0.25;
/// The key names [`ProbeKeyPlugin`] accepts — the controller-read set; extend as probes need.
fn probe_key_by_name(name: &str) -> Option<KeyCode> {
Some(match name {
"Space" => KeyCode::Space,
"W" => KeyCode::KeyW,
"A" => KeyCode::KeyA,
"S" => KeyCode::KeyS,
"D" => KeyCode::KeyD,
"Q" => KeyCode::KeyQ,
"E" => KeyCode::KeyE,
"X" => KeyCode::KeyX,
"Z" => KeyCode::KeyZ,
"Tab" => KeyCode::Tab,
// The detached free-fly toggle (`player.rs`) and its Ctrl speed boost (`camera.rs`, ×5).
// Added because the harness could not reach the leg that broke decision 0793: the director's
// first real run was a boosted free-fly, whose camera crosses the art radius in ~5 s, and
// reproducing it needed a held `Ctrl` + `W` behind an `F`.
//
// The toggle is the dev chord + `F` since decision 1043 (it was a bare `F`), so a probe that
// wants free-fly holds `Ctrl` and `Shift` across the `F` tap — hence `Shift` here. `Ctrl`
// stays held afterwards for the ×5 boost, which is unchanged.
"F" => KeyCode::KeyF,
"Ctrl" => KeyCode::ControlLeft,
"Shift" => KeyCode::ShiftLeft,
// The text-editing keys. Added for decision 1077's hyperlink-atomicity law, whose whole
// observable — "one BACKSPACE removes a whole item link" — is a keypress no chat command
// and no Lua chunk can reach (`EditBox` has no Lua deletion API; the law lives behind the
// host's chord table). Without these a caret/deletion defect could only be reproduced by
// asking the director to type, which is the asymmetry probes exist to remove.
"Backspace" => KeyCode::Backspace,
"Delete" => KeyCode::Delete,
"Left" => KeyCode::ArrowLeft,
"Right" => KeyCode::ArrowRight,
"Home" => KeyCode::Home,
"End" => KeyCode::End,
_ => return None,
})
}
/// [`ProbeKeyPlugin`] state: one entry per scheduled tap.
#[derive(Resource)]
struct ProbeKeys {
taps: Vec<ProbeKeyTap>,
}
struct ProbeKeyTap {
key: KeyCode,
at: f32,
/// Seconds the key stays down — the spec's `:<hold>`, else [`PROBE_KEY_TAP_SECS`].
hold: f32,
pressed: bool,
released: bool,
}
/// Press each due tap (in-world gated, like the chat probe) and release it after its hold window.
///
/// Both input currencies, deliberately (0997): `ButtonInput` for every held-state reader (and
/// the binding dispatch's stuck-latch sweep, which treats "latched but not pressed" as a missed
/// release), plus the raw [`KeyboardInput`] message the binding dispatch's press/release edges
/// actually consume — a state-only synthetic press was invisible to the chord latcher, which
/// would have silently killed this instrument the day the dispatch landed.
fn fire_probe_key(
mut probe: ResMut<ProbeKeys>,
time: ProbeClock,
self_player: Query<(), With<crate::net::SelfPlayer>>,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
) {
if probe.taps.is_empty() || self_player.is_empty() {
return;
}
let now = time.elapsed_secs();
let mut synth = |key: KeyCode, state: bevy::input::ButtonState| {
events.write(bevy::input::keyboard::KeyboardInput {
key_code: key,
logical_key: bevy::input::keyboard::Key::Unidentified(
bevy::input::keyboard::NativeKey::Unidentified,
),
state,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
};
for tap in &mut probe.taps {
if !tap.pressed && now >= tap.at {
info!(
"probe-key: {:?} down ({now:.1}s, hold {:.2}s)",
tap.key, tap.hold
);
keys.press(tap.key);
synth(tap.key, bevy::input::ButtonState::Pressed);
tap.pressed = true;
} else if tap.pressed && !tap.released && now >= tap.at + tap.hold {
keys.release(tap.key);
synth(tap.key, bevy::input::ButtonState::Released);
tap.released = true;
}
}
}
/// The PROBE LUA one-shot (`WOW_PROBE_LUA="<chunk>"`, delay via `WOW_PROBE_LUA_AT` seconds,
/// default 10): run one Lua chunk in the live UI VM once we are in-world — the "press the button
/// headlessly" instrument. The chunk drives the REAL FrameXML API surface (`CastSpell`,
/// `UseAction`, `TargetUnit`, …), so whatever it triggers takes the exact app path a click
/// takes — a headless wire probe can measure the server, but only the live VM exercises the
/// button feed and the widget clock.
pub(crate) struct ProbeLuaPlugin;
impl Plugin for ProbeLuaPlugin {
fn build(&self, app: &mut App) {
let chunk = std::env::var("WOW_PROBE_LUA").unwrap_or_default();
let at = std::env::var("WOW_PROBE_LUA_AT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10.0);
app.insert_resource(ProbeLua {
chunk,
at,
fired: false,
})
.add_systems(Update, fire_probe_lua);
}
}
/// [`ProbeLuaPlugin`] state: the chunk, the fire time, and the once-latch.
#[derive(Resource)]
struct ProbeLua {
chunk: String,
at: f32,
fired: bool,
}
/// Run the probe chunk once the delay has elapsed AND the session is in-world.
fn fire_probe_lua(
mut probe: ResMut<ProbeLua>,
time: ProbeClock,
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
) {
if probe.fired || probe.chunk.is_empty() || time.elapsed_secs() < probe.at {
return;
}
if self_player.is_empty() {
return; // not in-world yet — keep waiting past the delay
}
let Some(script) = script else {
return;
};
probe.fired = true;
// `ProbeLog(text)` — the chunk's data channel OUT of the VM (greppable `probe-log:` lines);
// until now a probe could only report through screenshots or by erroring. Installed only
// when a probe chunk actually fires — never part of the shipping API surface.
let install = script.lua().create_function(|_, text: String| {
info!("probe-log: {text}");
Ok(())
});
match install {
Ok(f) => {
if let Err(e) = script.lua().globals().set("ProbeLog", f) {
error!("probe-lua: installing ProbeLog: {e}");
}
}
Err(e) => error!("probe-lua: creating ProbeLog: {e}"),
}
info!("probe-lua: running {:?}", probe.chunk);
if let Err(e) = script.run(&probe.chunk) {
error!("probe-lua: {e}");
}
}
@@ -0,0 +1,583 @@
//! The live frame-time sample ([`LiveFpsPlugin`]) and everything printed from the same sample
//! window: the `FPS_PROBE` line itself, the visible-submesh census (`VisCensus` —
//! `VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`), the asset-churn ratchet (`ChurnCensus` —
//! `MAT_CHURN`) and the residency meter (`ResidencyMeter` — `ASSET_DUMP`). One file because
//! one system, `drive_live_fps`, builds all four from the same frame's queries at the same
//! instant — they are one measurement with four line families, not four instruments.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use crate::capture::PROBE_WARMUP_FRAMES;
/// The LIVE FPS probe (`WOW_LIVE_FPS=<frames>`, delay via `WOW_LIVE_FPS_AT` seconds, default 25;
/// `WOW_LIVE_FPS_MOVE=1` holds W through warmup + sampling, so the probe measures RUNNING through
/// the scene — streaming, spawns, re-classification — not a parked camera; the 0366 hunt's
/// "running around SW" gap):
/// the [`crate::capture::CapturePlugin`] probe's numbers on a NORMAL connected run — streamed units, net
/// apply, quest markers, everything the server-less harness deliberately excludes. Built for the
/// 0362 residual: the serverless stormwind probe pinned 60 while the director's live session read
/// 20, so the gap IS the live world — this instrument measures it. Waits for in-world + the delay
/// (park the character first with [`super::ProbeChatPlugin`]), uncaps vsync, warms
/// [`PROBE_WARMUP_FRAMES`], samples, prints the same machine-greppable `FPS_PROBE` line
/// (scenario=`live`), and exits.
pub(crate) struct LiveFpsPlugin;
impl Plugin for LiveFpsPlugin {
fn build(&self, app: &mut App) {
let frames = std::env::var("WOW_LIVE_FPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(300);
let at = std::env::var("WOW_LIVE_FPS_AT")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(25.0);
app.insert_resource(LiveFps {
frames,
at,
run: std::env::var("WOW_LIVE_FPS_MOVE").as_deref() == Ok("1"),
phase: LiveFpsPhase::Waiting,
samples: Vec::new(),
cpu_at_start: None,
sys_at_start: None,
occluded_now: false,
occluded_frames: 0,
})
.init_resource::<ChurnCensus>()
.add_systems(Update, drive_live_fps)
.add_systems(
First,
(
(
churn_counter::<bevy::image::Image>("image"),
churn_counter::<Mesh>("mesh"),
churn_counter::<StandardMaterial>("std"),
churn_counter::<crate::terrain::TerrainMaterial>("terrain"),
churn_counter::<crate::terrain::WowModelMaterial>("model"),
churn_counter::<crate::terrain::WdlMaterial>("wdl"),
churn_counter::<crate::terrain::LiquidMaterial>("liquid"),
),
(
churn_counter::<crate::sky::SkyMaterial>("sky"),
churn_counter::<crate::sun::CelestialMaterial>("celestial"),
churn_counter::<crate::sun::StarMaterial>("star"),
churn_counter::<crate::clouds::CloudMaterial>("cloud"),
churn_counter::<crate::ui_pass::UiQuadMaterial>("uiquad"),
),
),
);
}
}
#[derive(Clone, Copy, PartialEq)]
enum LiveFpsPhase {
Waiting,
Warmup(u32),
Sampling,
Done,
}
/// [`LiveFpsPlugin`] state.
#[derive(Resource)]
struct LiveFps {
frames: usize,
at: f32,
/// Hold W while measuring (`WOW_LIVE_FPS_MOVE=1`) — the moving-workload probe.
run: bool,
phase: LiveFpsPhase,
samples: Vec<f32>,
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the
/// baseline for the window's `cpu_ms`/`cpu_pct`.
cpu_at_start: Option<f64>,
/// Machine-wide CPU ticks at the first sampled frame ([`crate::perf::system_cpu_ticks`]) — the
/// baseline for `sys_busy_pct`, which says whether anyone ELSE was competing for the cores
/// while this leg ran. `cpu_ms` alone cannot: see that function's header for the 25.93-vs-18.8
/// leg that motivated it (1157).
sys_at_start: Option<(u64, u64)>,
/// The window's occlusion state, maintained from `WindowOccluded` transitions. macOS
/// throttles a FULLY covered window to ~1 fps (any covering window, not just the lock
/// screen — the director's correction to 0729/0730's lock-screen reading): a probe launched
/// detached spawns unfocused and can land completely behind other windows, and its leg then
/// measures the throttle, not the client.
occluded_now: bool,
/// Sampled frames taken while occluded — `occluded_frames=` on the probe line, so a
/// throttled leg names itself instead of being inferred from a ~1 s hitch signature.
occluded_frames: usize,
}
/// Asset residency — the leak meter (the #bugs teleport leak: caches hold strong handles, so maps
/// visited earlier keep their materials/meshes/images resident, and every uv/tint registry survivor
/// is re-uploaded per frame). A tour probe reading the same counts as a fresh control is what
/// "torn down" means, machine-checked. One struct because `drive_live_fps` is at Bevy's
/// system-param arity limit.
#[derive(bevy::ecs::system::SystemParam)]
struct ResidencyMeter<'w> {
mats: Res<'w, Assets<crate::terrain::WowModelMaterial>>,
meshes: Res<'w, Assets<Mesh>>,
images: Res<'w, Assets<bevy::image::Image>>,
uv_reg: Res<'w, crate::doodad_anim::UvAnimMaterials>,
tint_reg: Res<'w, crate::doodad_anim::TintAnimMaterials>,
models: Res<'w, Assets<benilla_assets::M2Model>>,
server: Res<'w, AssetServer>,
churn: ResMut<'w, ChurnCensus>,
}
/// The asset-churn census (the probe's ratchet meter): `AssetEvent::Modified` counts per asset
/// type across the sample window, printed as one `MAT_CHURN` line beside `FPS_PROBE`. A modified
/// material re-creates its uniform buffers + bind group that frame (the Metal non-bindless path),
/// and a modified image/mesh re-uploads — the teleport leak's CPU engine was exactly a per-frame
/// ratchet of this shape, so the floor hunt names them instead of guessing suspects one at a
/// time. Counters register only under `WOW_LIVE_FPS` ([`LiveFpsPlugin`]) — a normal run carries
/// none of this.
#[derive(Resource, Default)]
struct ChurnCensus(std::collections::BTreeMap<&'static str, usize>);
/// One census counter for asset type `A`, folding this frame's `Modified` events in under
/// `label` (a short stable name — the `type_name` of an `ExtendedMaterial` alias is unreadable).
fn churn_counter<A: bevy::asset::Asset>(
label: &'static str,
) -> impl FnMut(MessageReader<bevy::asset::AssetEvent<A>>, ResMut<ChurnCensus>) {
move |mut reader, mut census| {
let n = reader
.read()
.filter(|e| matches!(e, bevy::asset::AssetEvent::Modified { .. }))
.count();
if n > 0 {
*census.0.entry(label).or_default() += n;
}
}
}
impl ResidencyMeter<'_> {
/// `WOW_ASSET_DUMP=1`: one `ASSET_DUMP` line per resident image/mesh at sample time, path
/// via the asset server (runtime-built assets have no path and print as `<unpathed>` counts).
/// Diffing a tour probe's dump against a fresh control's names exactly which files a
/// teardown left behind — the leak meter's magnifying glass.
fn dump(&self) {
let mut lines: Vec<String> = Vec::new();
let mut unpathed = [0usize; 3];
for (kind, ids) in [
(
"image",
self.images.ids().map(|i| i.untyped()).collect::<Vec<_>>(),
),
(
"mesh",
self.meshes.ids().map(|i| i.untyped()).collect::<Vec<_>>(),
),
(
"model",
self.models.ids().map(|i| i.untyped()).collect::<Vec<_>>(),
),
] {
let slot = match kind {
"image" => 0,
"mesh" => 1,
_ => 2,
};
for id in ids {
match self.server.get_path(id) {
Some(p) => lines.push(format!("ASSET_DUMP {kind} {p}")),
None => unpathed[slot] += 1,
}
}
}
lines.sort();
for l in &lines {
println!("{l}");
}
println!(
"ASSET_DUMP <unpathed> images={} meshes={} models={} materials={}",
unpathed[0],
unpathed[1],
unpathed[2],
self.mats.len()
);
}
}
/// Where — and in what scene state — the sample was taken. Bundled because [`drive_live_fps`] sits
/// at Bevy's 16-param ceiling, and because these four are read together or not at all.
///
/// The `room`/`windows` half is the exterior-scene gate's two terms (decision 0774), the same pair
/// the debug panel's World section shows. Without them a `drawn=` reading taken indoors cannot be
/// read at all: a big number means either "we claimed a room and the cull let everything through" or
/// "we never claimed a room, so nothing was gated" — opposite bugs with identical numbers, and the
/// difference cost a measurement (0780).
#[derive(SystemParam)]
struct SamplePin<'w> {
/// The map the sample landed on (0705's prove-the-run law): a probe number is evidence only once
/// the body is known to be at the pin, and `WOW_PROBE_CHAT`'s `.go` can silently fail (a bad map
/// id, a refused command) leaving the run measuring the login spot.
map: Option<Res<'w, crate::world_map::CurrentMap>>,
body: Option<Res<'w, crate::player::Player>>,
room: Option<Res<'w, crate::wmo_portal::CameraInteriorClaim>>,
windows: Option<Res<'w, crate::wmo_portal::ExteriorWindows>>,
/// What the cull DID, not just what it was told — see [`crate::exterior_cull::ExteriorCullVerdict`].
verdict: Option<Res<'w, crate::exterior_cull::ExteriorCullVerdict>>,
}
/// Wait for in-world + the delay, uncap, warm, sample, print, exit — the live twin of the
/// harness probe's `Phase::ProbeWarmup`/`Probing` arms.
#[allow(clippy::too_many_arguments)]
fn drive_live_fps(
mut probe: ResMut<LiveFps>,
time: Res<Time<bevy::time::Real>>,
self_player: Query<(), With<crate::net::SelfPlayer>>,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
particles: Query<&crate::particles::ParticleEmitter>,
// Every spawned model submesh, with the two facts that make a visible one accountable: which
// subsystem it belongs to, and whether it is gated by the exterior-scene cull. See
// [`VisCensus`] for why "visible" alone was not enough to diagnose anything.
parts: Query<CensusData>,
streamed: Query<(), With<crate::net::NetEntity>>,
// The animation-LOD gate's effect, machine-readable per probe (decision 0448): how many
// streamed rigs sat parked at sample end.
parked: Query<(), With<crate::creature_anim::AnimParked>>,
entities: Query<()>,
pin: SamplePin,
// The owned skin-palette occupancy (decision 0720) — `rigs=live/peak bones=live/peak` on the
// probe line proves the palette lane is actually populated (an all-zero table renders
// origin-collapsed rigs, which no other probe number would catch).
palettes: Option<Res<crate::rig_palette::RigPalettes>>,
mut residency: ResidencyMeter,
mut keys: ResMut<ButtonInput<KeyCode>>,
mut key_events: MessageWriter<bevy::input::keyboard::KeyboardInput>,
mut exit: MessageWriter<AppExit>,
mut occlusions: MessageReader<bevy::window::WindowOccluded>,
) {
// Drain every frame so the state is current whichever phase we're in — the window can be
// occluded before sampling ever starts (a detached launch spawns behind whatever is open).
for o in occlusions.read() {
probe.occluded_now = o.occluded;
}
match probe.phase {
LiveFpsPhase::Done => {}
LiveFpsPhase::Waiting => {
// (The un-occludable window that keeps the SETTLE phase from streaming the world at
// ~1 fps is [`ProbeFocusPlugin`]'s now — every probe needs it, not just this one.)
if time.elapsed_secs() < probe.at || self_player.is_empty() {
return;
}
if let Ok(mut w) = windows.single_mut() {
w.present_mode = crate::capture::probe_uncap_mode();
}
info!(
"live-fps: in-world + settled; vsync off, warming {PROBE_WARMUP_FRAMES} frames{}",
if probe.run { ", holding W" } else { "" }
);
if probe.run {
// A synthetic held key: `ButtonInput` persists a press until its release, and the
// winit feed only releases keys it saw go down, so this holds across frames. The
// raw KeyboardInput message rides along for the binding dispatch's press edge
// (0997 — MOVEFORWARD latches off the event, holds off the state).
keys.press(KeyCode::KeyW);
key_events.write(bevy::input::keyboard::KeyboardInput {
key_code: KeyCode::KeyW,
logical_key: bevy::input::keyboard::Key::Unidentified(
bevy::input::keyboard::NativeKey::Unidentified,
),
state: bevy::input::ButtonState::Pressed,
text: None,
repeat: false,
window: Entity::PLACEHOLDER,
});
}
probe.phase = LiveFpsPhase::Warmup(0);
}
LiveFpsPhase::Warmup(n) => {
probe.phase = if n + 1 >= PROBE_WARMUP_FRAMES {
LiveFpsPhase::Sampling
} else {
LiveFpsPhase::Warmup(n + 1)
};
}
LiveFpsPhase::Sampling => {
if probe.samples.is_empty() {
probe.cpu_at_start = crate::perf::process_cpu_secs();
probe.sys_at_start = crate::perf::system_cpu_ticks();
// The churn census restarts with the window — warmup noise (streaming, shader
// warms) would otherwise read as steady-state ratchets.
residency.churn.0.clear();
probe.occluded_frames = 0;
}
if probe.occluded_now {
probe.occluded_frames += 1;
}
let ms = time.delta_secs() * 1000.0;
probe.samples.push(ms);
if probe.samples.len() < probe.frames {
return;
}
let mut v = probe.samples.clone();
v.sort_by(f32::total_cmp);
let at = |q: f32| v[(((v.len() - 1) as f32) * q).round() as usize];
let mean = v.iter().sum::<f32>() / v.len() as f32;
let (emitters, active, live) = particles
.iter()
.fold((0usize, 0usize, 0usize), |(e, a, l), p| {
(e + 1, a + usize::from(p.live() > 0), l + p.live())
});
let mut census = VisCensus {
own_instance: pin.room.as_ref().and_then(|r| r.0).map(|c| c.room.instance),
..Default::default()
};
let (submeshes, drawn) = parts.iter().fold((0usize, 0usize), |(n, d), row| {
census.add(row);
(n + 1, d + usize::from(row.0.get()))
});
let px = windows
.single()
.map(|w| (w.physical_width(), w.physical_height()))
.unwrap_or((0, 0));
// The present mode actually measured under — an uncap that silently rails (0362) is
// only diagnosable if the line says what was asked for.
let present = windows
.single()
.map(|w| format!(" present={:?}", w.present_mode))
.unwrap_or_default();
// CPU cost per frame across every thread — the load-robust half of the measurement
// (`perf::process_cpu_secs`), and directly comparable with a reporter's CPU %.
let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) {
(Some(t0), Some(t1)) => {
let per_frame_ms = (t1 - t0) * 1000.0 / v.len() as f64;
format!(
" cpu_ms={per_frame_ms:.2} cpu_pct={:.0}",
per_frame_ms / mean as f64 * 100.0
)
}
_ => String::new(),
};
// How busy the WHOLE machine was across the same window — every core, every process.
// `cpu_ms` is load-ROBUST, not load-immune (1157: the same pin leg read 25.93 with two
// other slots compiling and 18.80-20.12 quiet), so a leg that does not say this cannot
// be compared with one taken at a different time. A stamp, never a gate: legs at
// similar `sys_busy_pct` are comparable, legs far apart are not.
let sys = match (probe.sys_at_start, crate::perf::system_cpu_ticks()) {
(Some((b0, t0)), Some((b1, t1))) if t1 > t0 => {
format!(
" sys_busy_pct={:.0}",
(b1 - b0) as f64 / (t1 - t0) as f64 * 100.0
)
}
_ => String::new(),
};
// The pin the number belongs to, in the `.go xyz` order, so a probe line can be
// matched against the report's coordinates without a second instrument — followed by
// the exterior-scene gate's state, which is what makes `drawn=` legible indoors.
let at_pin = match (pin.map.as_ref(), pin.body.as_ref().filter(|b| b.active)) {
(Some(m), Some(b)) => {
let [x, y, z] = benilla_assets::coords::bevy_to_wow(b.pos);
format!(" map={} pos={x:.1},{y:.1},{z:.1}", m.0)
}
_ => String::new(),
};
let gate = {
let room = match pin.room.as_ref().and_then(|r| r.0) {
Some(claim) => format!("g{:02}", claim.room.group),
None => "none".to_string(),
};
match pin.windows.as_deref() {
Some(crate::wmo_portal::ExteriorWindows::Windows(rects)) => {
format!(" room={room} windows={}", rects.len())
}
Some(crate::wmo_portal::ExteriorWindows::Unrestricted) => {
format!(" room={room} windows=unrestricted")
}
None => String::new(),
}
};
let culled = match pin.verdict.as_deref() {
Some(v) => format!(
" cull_windows={} cull_frusta={} cull_tested={} cull_hidden={} cull_unbounded={}",
v.windows
.map_or("unrestricted".to_string(), |n| n.to_string()),
v.frusta,
v.tested,
v.hidden,
v.unbounded
),
None => String::new(),
};
let rigs = palettes
.map(|p| {
let (s, b, ps, pb) = p.occupancy();
format!(
" rigs={s}/{ps} rig_bones={b}/{pb} rig_computed={}",
p.computed_rigs()
)
})
.unwrap_or_default();
let residency_line = format!(
" mats={} meshes={} images={} uv={} tint={}",
residency.mats.len(),
residency.meshes.len(),
residency.images.len(),
residency.uv_reg.0.len(),
residency.tint_reg.0.len(),
);
println!(
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={emitters} active={active} particles={live} submeshes={submeshes} drawn={drawn} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present} occluded_frames={}{at_pin}{gate}{culled}",
v.len(),
at(0.50),
at(0.95),
at(0.99),
v[v.len() - 1],
1000.0 / mean,
streamed.iter().len(),
parked.iter().len(),
entities.iter().len(),
px.0,
px.1,
probe.occluded_frames,
);
census.print();
// The window's Modified-event totals per asset type — a type at ~1×/frame here is a
// per-frame re-upload ratchet (see [`ChurnCensus`]); absent means quiet.
if !residency.churn.0.is_empty() {
let churn = residency
.churn
.0
.iter()
.map(|(k, n)| format!("{k}={n}"))
.collect::<Vec<_>>()
.join(" ");
println!("MAT_CHURN frames={} {churn}", v.len());
}
if std::env::var_os("WOW_ASSET_DUMP").is_some() {
residency.dump();
}
if probe.run {
keys.release(KeyCode::KeyW);
}
probe.phase = LiveFpsPhase::Done;
exit.write(AppExit::Success);
}
}
}
/// **What is on the screen, and who is accountable for it** — the `VIS_CENSUS` line beside
/// `FPS_PROBE`, plus a per-model breakdown under `WOW_VIS_DUMP=1`.
///
/// `drawn=` alone cannot answer "why can I still see that from in here?". It is one number over
/// every model submesh, and the interesting split is not visible-vs-not: it is which *subsystem* the
/// visible thing belongs to and whether anything is gating it at all. A tree that draws through a
/// wall is a completely different defect depending on whether it carries
/// [`crate::exterior_cull::ExteriorScene`] (tagged but admitted — the cull or the bound is wrong) or
/// does not (nothing is gating it — the wrong lane spawned it). Naming which took a screenshot, an
/// asset dig and a wrong guess; this line answers it in one run.
///
/// `WOW_VIS_DUMP=1` then names the models: one `VIS_DUMP` line per distinct visible label, ungated
/// first, most-drawn first — which is the "so WHICH trees are they?" question.
#[derive(Default)]
struct VisCensus {
/// Per [`ModelKind`] index: `(visible, visible-and-gated)`.
kinds: [(usize, usize); 4],
/// `label -> (visible count, gated)`, for the dump.
labels: std::collections::HashMap<(String, bool), usize>,
/// Of every `ExteriorScene`-tagged submesh: how many the cull actually wrote `Hidden` on, and
/// how many carry **no `Aabb`** — the cull's fail-open arm, which admits them unconditionally.
/// A tagged-but-drawn object is one of those two, and they are opposite bugs.
gated_total: usize,
gated_hidden: usize,
gated_no_aabb: usize,
/// Tagged, bounded, NOT exempt, and yet NOT `Hidden` — the escapees, by `(label, is a
/// billboard card)`. **Exempt** means the piece belongs to the placement the camera is standing
/// in, which is not exterior scene to itself (decision 0784) and is *supposed* to draw; without
/// that subtraction this list is all room-you-are-in furniture and says nothing.
escaped: std::collections::HashMap<(String, bool), usize>,
/// The placement the camera is inside, for that subtraction.
own_instance: Option<Entity>,
/// How many tagged pieces were exempt — the number that explains the `tagged` vs `hidden` gap.
exempt: usize,
}
/// What [`VisCensus`] reads off every model submesh — the query shape and its fetched row.
type CensusData = (
&'static ViewVisibility,
&'static crate::debug_panel::ModelPart,
Has<crate::exterior_cull::ExteriorScene>,
Option<&'static crate::interact::WorldObject>,
&'static Visibility,
Option<&'static bevy::camera::primitives::Aabb>,
Has<crate::billboard::BillboardCard>,
Option<&'static crate::wmo_portal::WmoGroupVis>,
);
type CensusRow<'a> = (
&'a ViewVisibility,
&'a crate::debug_panel::ModelPart,
bool,
Option<&'a crate::interact::WorldObject>,
&'a Visibility,
Option<&'a bevy::camera::primitives::Aabb>,
bool,
Option<&'a crate::wmo_portal::WmoGroupVis>,
);
impl VisCensus {
fn add(&mut self, (vis, part, gated, object, want, aabb, card, group): CensusRow) {
if gated {
let exempt = group.is_some_and(|g| Some(g.instance) == self.own_instance);
self.gated_total += 1;
self.gated_hidden += usize::from(*want == Visibility::Hidden);
self.gated_no_aabb += usize::from(aabb.is_none());
self.exempt += usize::from(exempt);
if *want != Visibility::Hidden && aabb.is_some() && !exempt {
let label = object.map_or("<unlabelled>", |o| o.label.as_str());
*self.escaped.entry((label.to_string(), card)).or_default() += 1;
}
}
if !vis.get() {
return;
}
let slot = &mut self.kinds[kind_index(part.kind)];
slot.0 += 1;
slot.1 += usize::from(gated);
if let Some(o) = object {
*self.labels.entry((o.label.clone(), gated)).or_default() += 1;
}
}
fn print(&self) {
let line = ["doodad", "wmo", "creature", "gameobject"]
.iter()
.zip(&self.kinds)
.map(|(name, (vis, gated))| format!("{name}={vis}/gated{gated}"))
.collect::<Vec<_>>()
.join(" ");
println!(
"VIS_CENSUS visible-submeshes {line} | tagged={} hidden={} exempt={} no_aabb={}",
self.gated_total, self.gated_hidden, self.exempt, self.gated_no_aabb
);
// The escapees always print: a tagged, bounded object the cull left un-hidden is a defect
// by construction, and burying it behind a flag is how it stays unnoticed.
let mut escapees: Vec<_> = self.escaped.iter().collect();
escapees.sort_by(|a, b| b.1.cmp(a.1));
for ((label, card), n) in escapees {
let c = if *card { " BILLBOARD-CARD" } else { "" };
println!("VIS_ESCAPED {n}{c} {label}");
}
if std::env::var_os("WOW_VIS_DUMP").is_none() {
return;
}
let mut rows: Vec<_> = self.labels.iter().collect();
// Ungated first (the leak candidates), then most-drawn first.
rows.sort_by(|a, b| a.0 .1.cmp(&b.0 .1).then(b.1.cmp(a.1)));
for ((label, gated), n) in rows {
let g = if *gated { "gated" } else { "UNGATED" };
println!("VIS_DUMP {g} {n} {label}");
}
}
}
/// [`ModelKind`] has a private `index`; the census needs its own (and pins the column order).
fn kind_index(kind: crate::debug_panel::ModelKind) -> usize {
match kind {
crate::debug_panel::ModelKind::Doodad => 0,
crate::debug_panel::ModelKind::Wmo => 1,
crate::debug_panel::ModelKind::Creature => 2,
crate::debug_panel::ModelKind::GameObject => 3,
}
}
@@ -0,0 +1,137 @@
//! The LIVE-run probe instruments — every plugin here rides a NORMAL connected session (unlike
//! the parent's server-less [`super::CapturePlugin`] harness): scripted chat sends
//! ([`ProbeChatPlugin`]), synthetic key taps ([`ProbeKeyPlugin`]), a Lua chunk in the live UI VM
//! ([`ProbeLuaPlugin`]), the bounded-lifetime self-exit ([`ProbeExitPlugin`]), and the live
//! frame-time sampler ([`LiveFpsPlugin`]). The live screenshot and its validity gates live in the
//! sibling [`super::live_shot`]. Each is env-gated and registered by `main`; compose them for
//! unattended "park, act, observe" probes.
use bevy::prelude::*;
/// The **actuation** probes — the three channels that make a live session do something
/// headlessly: chat/slash lines, synthetic key presses, and a Lua chunk in the UI VM.
mod act;
pub(crate) use act::{ProbeChatPlugin, ProbeKeyPlugin, ProbeLuaPlugin};
/// The probe **run** itself — its bounded lifetime, and the window it lives in (level,
/// parking, size). Nothing here measures anything; it is the shell every other probe rides.
mod run;
pub(crate) use run::{ProbeExitPlugin, ProbeFocusPlugin, ProbeResizePlugin};
/// The live frame-time sample — `FPS_PROBE` and the companion lines emitted from the same
/// sample window (`VIS_CENSUS`/`VIS_ESCAPED`/`VIS_DUMP`, `MAT_CHURN`, `ASSET_DUMP`).
mod live_fps;
pub(crate) use live_fps::LiveFpsPlugin;
/// The particle census — per-emitter state plus the draw-distance accounting behind B39.
mod particle_census;
pub(crate) use particle_census::ParticleCensusPlugin;
/// 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;
pub(crate) use world_census::{EntityCensusPlugin, NodeProbePlugin};
/// The clock **every probe schedule reads** — real time, never the virtual clock (decision 0789).
///
/// A probe knob is a wall-clock instruction: "send this at 20 s", "sample 300 frames from 25 s",
/// "resize at 12 s", "exit at 480 s". `Time<Virtual>` cannot honour one — it clamps every frame delta
/// to `max_delta` (250 ms, `bevy_time`'s default), so on any leg that hitches (a streaming burst, an
/// occluded window, a loaded machine) it falls behind real time and drags the whole schedule with it.
/// The knob then means something the operator never asked for, *silently*.
///
/// **Third time this clock has cost us a measurement.** 0615 moved the relayed-move replay off it
/// (and cites the UI script clock as the same lesson before that); then B131's first causal leg was
/// destroyed by it here — the probe-chat hops drifted 40 s → 75 s apart, so windows labelled
/// "parked, ticks off" in fact contained a teleport and live ticks, and an eight-minute leg had to be
/// thrown away (decision 0785's discarded run). A named alias is what makes the next probe get it
/// right without knowing the story: type this, and the mistake is unavailable.
///
/// The one deliberate virtual clock in the harness is the fixture **age** in
/// [`super::drive_capture`] — an age on the clock the effect animates on (and which the capture
/// freezes at save time), which is not a schedule at all.
pub(crate) type ProbeClock<'w> = Res<'w, Time<bevy::time::Real>>;
#[cfg(test)]
mod tests {
/// **The invariant, checked instead of remembered** (decision 0789).
///
/// Naming the right clock [`ProbeClock`] makes it easy to reach for; it does not make the wrong
/// one unavailable, and `Res<Time>` is the shorter, prelude-blessed, obvious spelling. That
/// asymmetry is precisely why this same clock has now cost three lanes a correctness bug
/// (0615's replay clock, the UI script clock, and B131's discarded probe leg) — each time it was
/// fixed where it was found and left available everywhere else. So the fix is not another
/// convention: it is this test, in the suite the gates already run.
///
/// Adding a virtual clock to the probe harness now means adding yourself to [`ALLOWED`] with a
/// reason, which is a much better conversation than discovering the drift in a thrown-away leg.
#[test]
fn probe_schedules_read_the_wall_clock() {
/// `(file, system, why it is genuinely an age or a delta on the animating clock)`.
const ALLOWED: &[(&str, &str, &str)] = &[(
"capture/mod.rs",
"drive_capture",
"the fixture AGE runs on the clock the effect animates on, and the capture freezes that \
same clock at save time — an age, not a schedule",
)];
// The needles are assembled at runtime so the checker does not flag **its own source** —
// which is exactly what it did on its first run, and is the cheapest possible proof that it
// has teeth.
let bare = format!(": Res<{}>,", "Time");
let bare_last = format!(": Res<{}>", "Time");
let explicit = format!("Res<{}<{}>>", "Time", "Virtual");
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
let mut offenders = Vec::new();
let mut allowed_seen = 0usize;
// The probe harness: everything under `capture/`, plus the one probe that lives beside the
// player controller because it writes `face_yaw` directly.
let mut stack = vec![src.join("capture")];
let mut files = vec![src.join("player/probe_look.rs")];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).expect("probe harness dir is readable") {
let path = entry.expect("dir entry").path();
if path.is_dir() {
stack.push(path);
} else if path.extension().is_some_and(|e| e == "rs") {
files.push(path);
}
}
}
for path in files {
let rel = path
.strip_prefix(&src)
.expect("under src")
.to_string_lossy()
.replace('\\', "/");
let text = std::fs::read_to_string(&path).expect("source is readable");
for (n, line) in text.lines().enumerate() {
let t = line.trim();
// A system param binding the virtual clock. `Time<Real>` is the whole point, and
// `ResMut<Time<Virtual>>` is the capture's own clock *control*, not a read of it.
let virtual_clock =
(t.ends_with(&bare) || t.ends_with(&bare_last)) || t.contains(&explicit);
if !virtual_clock {
continue;
}
match ALLOWED.iter().find(|(f, _, _)| *f == rel) {
Some(_) => allowed_seen += 1,
None => offenders.push(format!("{rel}:{} {t}", n + 1)),
}
}
}
assert!(
offenders.is_empty(),
"the probe harness must schedule on the wall clock (`ProbeClock`), not the virtual \
clock — it is clamped to max_delta (250 ms), so any hitching leg silently drifts every \
`<secs>` knob out from under the operator (decision 0789). Offenders:\n {}",
offenders.join("\n "),
);
assert!(
allowed_seen > 0,
"the ALLOWED exception list is stale — nothing matched it. If the fixture-age clock \
moved or went away, drop its entry rather than leaving a rule guarding nothing.",
);
}
}
@@ -0,0 +1,210 @@
//! The particle census ([`ParticleCensusPlugin`]) — one `PARTICLE_CENSUS_EMITTER` line per live
//! emitter plus the `PARTICLE_CENSUS` summary, including the draw-distance accounting that is
//! B39's numeric form.
use bevy::prelude::*;
use super::ProbeClock;
/// The particle census (`WOW_PARTICLE_CENSUS=<secs>`): once, `t` seconds in, print one line per
/// live emitter (blend, file flags, sampled rate keys, texture, live count) plus a machine-
/// readable total — the like-for-like number to put beside a reference-trace quad count (the
/// login whirlpool investigation: the real client draws 793 particle quads across 23 draws in
/// one `UI_MainMenu` frame). Works at any state — the glue screens included, unlike the
/// in-world-gated FPS probe.
///
/// It also measures **draw distance** (decision 0678): each emitter's planar depth along
/// camera-forward — the coordinate the far-clip wall uses — and the draw-set gate's verdict, with
/// `drawn_beyond_wall` on the summary line. That is the numeric form of "effects render at
/// unlimited distance" (bug B39): emitters still ticking and drawing past the wall that has already
/// discarded the terrain beneath them. **It must read 0**; a non-zero value is the bug, live.
///
/// **`WOW_PARTICLE_CENSUS=+<secs>` fires that long after the world is first SHOWN** (the loading
/// screen dropping) instead of after app start. Everything that rides the appear ramp — decision
/// 0827/0833's `alpha` column above all — lives in a 2-second window whose start moves by *seconds*
/// between a warm and a cold load, and a wall-clock timer either lands in it or does not: three
/// runs in a row missed it while the question was "does a weapon glow ramp with its wearer?". A
/// probe should not be a dice roll, and the ramp's own trigger is the thing to time from.
pub(crate) struct ParticleCensusPlugin;
impl Plugin for ParticleCensusPlugin {
fn build(&self, app: &mut App) {
let spec = std::env::var("WOW_PARTICLE_CENSUS").unwrap_or_default();
let after_shown = spec.starts_with('+');
let at = spec
.trim_start_matches('+')
.parse()
.unwrap_or(if after_shown { 1.0 } else { 10.0 });
app.insert_resource(ParticleCensus {
at,
after_shown,
fired: false,
})
.add_systems(Update, fire_particle_census);
}
}
/// [`ParticleCensusPlugin`] state: the fire time and the once-latch.
#[derive(Resource)]
struct ParticleCensus {
at: f32,
/// `at` is measured from the world being shown, not from app start — and is rewritten into an
/// absolute time the first frame the loading screen stops covering.
after_shown: bool,
fired: bool,
}
fn fire_particle_census(
mut probe: ResMut<ParticleCensus>,
screen: Res<crate::loading_screen::LoadingScreen>,
time: ProbeClock,
view: Res<crate::view::ViewDistance>,
cam: Query<&GlobalTransform, With<crate::player::WorldCamera>>,
emitters: Query<(
&crate::particles::ParticleEmitter,
Option<&crate::particles::EmitterFade>,
Option<&bevy::camera::visibility::RenderLayers>,
)>,
) {
// Latch the shown-relative deadline the first frame the screen drops (and only then: while it
// still covers there is no ramp to be relative to).
if probe.after_shown {
if screen.covering() {
return;
}
probe.at += time.elapsed_secs();
probe.after_shown = false;
}
if probe.fired || time.elapsed_secs() < probe.at {
return;
}
probe.fired = true;
let mut total = 0usize;
let mut n = 0usize;
// The B39 columns (decision 0678). Per emitter: its planar depth along camera-forward (the
// coordinate the far-clip wall is measured in) and the draw-set gate's live verdict.
//
// **`drawn_beyond_wall` is the number that names the bug.** It counts emitters the gate is
// still ticking and drawing at a depth where the detailed world — the terrain under them
// included — has already been discarded by the wall. Before 0678 it was routinely non-zero,
// because `doodad_fade_alpha` admits any owner over `NEVER_FADE_RADIUS` at *every* distance
// and nothing else bounded depth; that is precisely "all effects render at unlimited
// distance", and precisely the reporter's "the terrain is not even rendered that far".
// It must now be **0**: past the wall the gate hides the emitter and freezes its pool.
//
// `beyond_wall` (verdict ignored) stays as the denominator — emitters *exist* out there and
// should, they are simply frozen. A fix that despawned them would be the wrong fix.
//
// **Booth-layered emitters are excluded from the distance accounting** — the same layer filter
// `simulate_particles` uses to pick a booth's camera. The portrait/glue scenes are parked
// thousands of yards from the world and drawn by their OWN camera, so the world camera's wall
// says nothing about them; counting them read as 28 phantom "effects past the wall" (all of
// them Karazahn braziers and night-elf glows at ~7080 yd) on a build where the world was
// already clean. Measuring the right subject is the instrument's job, not the reader's.
let cam_tf = cam.iter().next();
let mut beyond_wall = 0usize;
let mut drawn_beyond_wall = 0usize;
let mut drawn_beyond_wall_live = 0usize;
let mut booth = 0usize;
let mut max_drawn_depth = f32::NEG_INFINITY;
for (e, fade, layers) in &emitters {
let world_layer =
layers.is_none_or(|l| l.intersects(&bevy::camera::visibility::RenderLayers::default()));
// Depth to the OWNER's fade sphere where there is one (the gate's own subject), else the
// emitter's live anchor — so the number always names what the gate actually tests.
let depth = cam_tf.map(|t| {
let center = fade.map_or_else(|| e.anchor_world(), |f| f.center);
let radius = fade.map_or(0.0, |f| f.radius);
(center - t.translation()).dot(Vec3::from(t.forward())) - radius
});
let drawn = e.drawn();
if !world_layer {
booth += 1;
}
if let Some(d) = depth.filter(|_| world_layer) {
if drawn {
max_drawn_depth = max_drawn_depth.max(d);
}
if d > view.farclip {
beyond_wall += 1;
if drawn {
drawn_beyond_wall += 1;
drawn_beyond_wall_live += e.live();
}
}
}
let dist = depth
.map(|d| {
let lane = if world_layer { "world" } else { "booth" };
let c = fade.map_or_else(|| e.anchor_world(), |f| f.center);
format!(
" depth={d:.1} drawn={drawn} lane={lane} gated={} at=({:.0},{:.0},{:.0})",
fade.is_some(),
c.x,
c.y,
c.z
)
})
.unwrap_or_default();
let d = e.def();
// The rate summary: the constant (the common shape), else each slot's key count — the
// full per-sequence choreography lives in `benilla-extract m2anim`, not a census line.
let rate_keys: Vec<String> = match d.timing.constant_rate() {
Some(r) => vec![format!("{r:.1}")],
None => d
.timing
.slot_views()
.iter()
.enumerate()
.map(|(s, (_, r, _))| format!("s{s}:{}k", r.map_or(0, <[(f32, f32)]>::len)))
.collect(),
};
// The orientation fingerprint (world plane normal + thickness/radius) is the numeric
// "which way does this cloud face" — the flat-vs-standing question a screenshot can
// only suggest (the InstancePortal swirl-plane investigation).
let plane = e
.cloud_fingerprint()
.map(|(c, nrm, thick, radius)| {
format!(
" ctr=({:.1},{:.1},{:.1}) normal=({:+.2},{:+.2},{:+.2}) thick={thick:.2} radius={radius:.2}",
c.x, c.y, c.z, nrm.x, nrm.y, nrm.z
)
})
.unwrap_or_default();
println!(
"PARTICLE_CENSUS_EMITTER blend={:?} flags={:#06x} rate=[{}] life={:.2} tex={} live={} alpha={:.2}{dist}{plane}",
d.blend,
d.flags,
rate_keys.join(","),
d.params.sample(None, 0.0, 0.0).lifespan,
d.texture.as_deref().unwrap_or("-"),
e.live(),
// The frame's composed MODEL alpha (decision 0827/0833) — the number that answers
// "this cloud is drawing, why can't I see it / why is it full strength?". An effect on
// a unit that has not appeared yet reads ~0; one with no model above it reads 1.
e.render_alpha(),
);
total += e.live();
n += 1;
}
let max_drawn_depth = if max_drawn_depth.is_finite() {
max_drawn_depth
} else {
0.0
};
// The camera pose goes on the line so a census is self-describing: every distance number here
// is measured from it, and a probe whose `.go` silently failed otherwise reports crisp numbers
// about the wrong place.
let where_ = cam_tf
.map(|t| {
let p = t.translation();
format!(" cam=({:.1},{:.1},{:.1})", p.x, p.y, p.z)
})
.unwrap_or_else(|| " cam=none".into());
println!(
"PARTICLE_CENSUS emitters={n} booth={booth} live_total={total} farclip={:.0} \
beyond_wall={beyond_wall} drawn_beyond_wall={drawn_beyond_wall} \
drawn_beyond_wall_live={drawn_beyond_wall_live} max_drawn_depth={max_drawn_depth:.1}{where_}",
view.farclip,
);
}
@@ -0,0 +1,233 @@
//! The probe **run's own shell** — everything about the process and its window rather than
//! about what is being measured: the un-occludable, parked window ([`ProbeFocusPlugin`]), the
//! bounded lifetime ([`ProbeExitPlugin`]), and the mid-run resize ([`ProbeResizePlugin`]).
//! Every scripted probe rides these, whatever instrument it is running.
use bevy::prelude::*;
use super::ProbeClock;
/// Keep a probe run's window **un-occludable, and out of the director's way** — the one defence
/// against macOS's ~1 fps throttle for a fully covered window (decisions 0713/0777, method.md's
/// `caffeinate` note), at the smallest footprint that still buys it.
///
/// The on-top half used to live inside the FPS probe alone, which reads as "a frame-rate concern".
/// It is not: **every** scripted probe schedule is wall-clock ([`ProbeClock`]), so a throttled run
/// doesn't just measure slowly, it *executes the wrong script* — one session's mounted-jump run
/// fired `W@16` and `Space@19` in the SAME frame at ~1 fps, i.e. it jumped from a standstill
/// instead of mid-run, and the leg had to be re-read to notice (decision 0906). Any probe env arms
/// it, so a key/chat/Lua probe defends itself exactly like the FPS one.
///
/// **The parking half is the other side of that bill** (decision 1148). Asserting `AlwaysOnTop`
/// every frame silently defeats [`crate::bgwin`]'s whole design — it overrides the
/// `AlwaysOnBottom` birth cage *and* the `Normal` handed back at release — so an instrumented run
/// sits on top for its entire life however politely it was launched. At the full-size default that
/// is a screen-filling window over whatever the director is doing, and a session that fires six
/// probes fires six of them (which is how it got reported). The answer is not to drop the
/// assertion — the throttle is real — but to shrink what is being asserted:
/// [`crate::bgwin::no_pixel_run`] sizes such a run at 640×360 and this parks it in the top-right
/// corner. A run that photographs pixels is excluded from both and keeps the full window.
///
/// Write-gated on both counts: re-marking `Window` every frame would re-apply its whole state
/// through winit, and the park is one-shot, latched once a monitor is actually readable (the
/// monitor entities do not exist on frame 1).
pub(crate) struct ProbeFocusPlugin;
impl Plugin for ProbeFocusPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Update, keep_probe_window_on_top);
}
}
/// Where a no-pixel probe window goes, and whether it is pinned on top — `WOW_PROBE_PARK`.
///
/// This is a **dial, not a preference**, and it exists because the thing it trades against is
/// recorded as unproven. `AlwaysOnTop` costs the director screen for every probe a session fires;
/// what it buys is immunity from the macOS occlusion throttle, whose evidence is decision 0713's
/// *"one suspect survives, unproven"* (stall magnitudes of 1026–1053 ms sitting on
/// `CAMetalLayer.nextDrawable`'s 1 s timeout) promoted to fact by 0777 and built on by 0906. The
/// probe line already stamps `occluded_frames=`, so each setting is measurable against the others
/// on the same pin — which is the only way to retire the on-top assertion honestly.
#[derive(Clone, Copy, PartialEq)]
enum Park {
/// Top-right corner, `AlwaysOnTop`. Today's behaviour.
Corner,
/// Mostly past the right edge — a [`PARK_EDGE_SLIVER`]-wide strip left on screen, at the
/// NORMAL level. macOS marks a window `NSWindowOcclusionStateVisible` if *any* part of it is
/// visible, so a sliver should be enough to dodge the throttle at ~1% of the screen cost.
Edge,
/// Wholly past the right edge, NORMAL level. The best case if AppKit allows it — AppKit
/// constrains ordinary titled windows back onto a screen (`constrainFrameRect:toScreen:`), so
/// this may simply not stick; that is the assumption the dial exists to test.
Off,
}
/// How much of an [`Park::Edge`] window stays on screen (logical px).
const PARK_EDGE_SLIVER: f32 = 32.0;
/// Gap (logical px) a [`Park::Corner`] window keeps from the screen's top and right edges — clear
/// of the menu bar, which a window at `y = 0` would sit under.
const PROBE_WINDOW_MARGIN: f32 = 36.0;
fn park_mode() -> Park {
match std::env::var("WOW_PROBE_PARK").as_deref() {
Ok("edge") => Park::Edge,
Ok("off") => Park::Off,
_ => Park::Corner,
}
}
fn keep_probe_window_on_top(
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
monitors: Query<&bevy::window::Monitor>,
mut parked: Local<bool>,
) {
let Ok(mut w) = windows.single_mut() else {
return;
};
let no_pixel = crate::bgwin::no_pixel_run();
let mode = park_mode();
// Only `Corner` needs the pin: the other two get out of the way by *position*, which is the
// point of measuring them — an edge/offscreen window that never reports `occluded_frames` has
// retired the assertion on evidence instead of preference.
let want_top = !no_pixel || mode == Park::Corner;
let level = if want_top {
bevy::window::WindowLevel::AlwaysOnTop
} else {
bevy::window::WindowLevel::Normal
};
if w.window_level != level {
w.window_level = level;
}
if *parked || !no_pixel {
return;
}
// `WindowPosition::At` is in PHYSICAL pixels while the window's own resolution is logical, so
// the width has to be scaled before it is subtracted.
let Some(m) = monitors.iter().next() else {
return; // no monitor entity yet — try again next frame
};
let scale = m.scale_factor as f32;
let width = (w.resolution.width() * scale) as i32;
let screen = m.physical_width as i32;
let margin = (PROBE_WINDOW_MARGIN * scale) as i32;
let pos = match mode {
Park::Corner => IVec2::new((screen - width - margin).max(0), margin),
Park::Edge => IVec2::new(screen - (PARK_EDGE_SLIVER * scale) as i32, margin),
Park::Off => IVec2::new(screen + margin, margin),
};
w.position = bevy::window::WindowPosition::At(pos);
*parked = true;
info!(
"probe window: {}×{} logical, parked at {pos:?} physical ({})",
w.resolution.width(),
w.resolution.height(),
match mode {
Park::Corner => "corner, AlwaysOnTop",
Park::Edge => "edge sliver, normal level",
Park::Off => "offscreen, normal level",
}
);
}
/// The probe self-termination as its own plugin, registered whenever `WOW_PROBE_EXIT_AT` is set
/// — it used to ride inside [`super::ProbeLuaPlugin`], so a chat/key-only probe's exit knob silently
/// did nothing (the 0441 flourish probe hung past its window on exactly that).
pub(crate) struct ProbeExitPlugin;
impl Plugin for ProbeExitPlugin {
fn build(&self, app: &mut App) {
app.insert_resource(ProbeExit {
at: std::env::var("WOW_PROBE_EXIT_AT")
.ok()
.and_then(|v| v.parse().ok()),
fired: false,
})
.add_systems(Update, fire_probe_exit);
}
}
/// The probe run's clean self-termination (`WOW_PROBE_EXIT_AT=<secs>`, off when unset): exit the
/// app after N wall seconds, so a scripted live probe (`WOW_PROBE_LUA`/`WOW_PROBE_CHAT`) is one
/// foreground command with a bounded lifetime — no external kill, no orphaned window (0437's
/// probe rounds prompted it; generic to every future live probe).
#[derive(Resource)]
struct ProbeExit {
at: Option<f32>,
fired: bool,
}
fn fire_probe_exit(
mut probe: ResMut<ProbeExit>,
time: ProbeClock,
mut exit: MessageWriter<AppExit>,
) {
let Some(at) = probe.at else { return };
if !probe.fired && time.elapsed_secs() >= at {
info!("probe-exit: {at}s elapsed — exiting");
probe.fired = true;
exit.write(AppExit::Success);
// The hard backstop rides its own OS thread: the polite AppExit stops the Update
// schedule, so an in-schedule backstop can never fire — exactly the hang it existed
// for (a winit/net-thread teardown hang leaves a zombie client holding the account;
// the 0451 probe reproduced it). A probe run has nothing to lose.
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("probe-exit: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
/// The window-resize probe (`WOW_PROBE_RESIZE="<secs>:<W>x<H>"`, logical units): resize the
/// primary window mid-run — the headless stand-in for a mac fullscreen toggle or a window drag,
/// so resize-reactive layout (the glue screens' rescale rebuild) is verifiable in one scripted
/// run: open, resize at `t`, shoot after (`WOW_LOGIN_SHOT_OUT` fires at 8 s).
pub(crate) struct ProbeResizePlugin;
impl Plugin for ProbeResizePlugin {
fn build(&self, app: &mut App) {
let spec = std::env::var("WOW_PROBE_RESIZE").unwrap_or_default();
let parsed = spec.split_once(':').and_then(|(t, wh)| {
let (w, h) = wh.split_once('x')?;
Some((t.parse().ok()?, w.parse().ok()?, h.parse().ok()?))
});
match parsed {
Some((at, w, h)) => {
app.insert_resource(ProbeResize {
at,
size: Vec2::new(w, h),
fired: false,
})
.add_systems(Update, fire_probe_resize);
}
None => warn!("WOW_PROBE_RESIZE: expected \"<secs>:<W>x<H>\", got {spec:?}"),
}
}
}
/// [`ProbeResizePlugin`] state: the fire time, the target logical size, and the once-latch.
#[derive(Resource)]
struct ProbeResize {
at: f32,
size: Vec2,
fired: bool,
}
fn fire_probe_resize(
mut probe: ResMut<ProbeResize>,
time: ProbeClock,
mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
) {
if probe.fired || time.elapsed_secs() < probe.at {
return;
}
probe.fired = true;
if let Ok(mut w) = windows.single_mut() {
w.resolution.set(probe.size.x, probe.size.y);
info!(
"probe-resize: window -> {}x{} logical",
probe.size.x, probe.size.y
);
}
}
@@ -0,0 +1,202 @@
//! The two exclusive-`World` reflection dumps — the pair of one-shots that answer "what is
//! resident right now, and what is it made of" by walking the live `World` itself: the bevy_ui
//! node inventory ([`NodeProbePlugin`]) and the archetype census ([`EntityCensusPlugin`]).
//! They share the shape (fire once at `t`, take `&mut World`, print with the ubiquitous
//! plumbing components filtered out) as well as the question.
use bevy::prelude::*;
/// The bevy_ui node census (`WOW_NODE_PROBE=<secs>`): once, `t` seconds in, print one line per
/// live `ComputedNode` entity — resolved rect (logical px, y-down), visibility, and the entity's
/// full component list — the "who owns this rectangle" instrument for UI drawn OUTSIDE the
/// FrameXML quad pass (the glue widgets, loading screen, overlays), which `WOW_UI_PROBE`'s quad
/// dump can't see. Born hunting a phantom gold-bordered box over the mail window's send tab.
pub(crate) struct NodeProbePlugin;
impl Plugin for NodeProbePlugin {
fn build(&self, app: &mut App) {
let at = std::env::var("WOW_NODE_PROBE")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10.0);
app.insert_resource(NodeProbe { at, fired: false })
.add_systems(Update, fire_node_probe);
}
}
/// [`NodeProbePlugin`] state: the fire time and the once-latch.
#[derive(Resource)]
struct NodeProbe {
at: f32,
fired: bool,
}
fn fire_node_probe(world: &mut World) {
{
let time = world.resource::<Time>().elapsed_secs();
let probe = world.resource::<NodeProbe>();
if probe.fired || time < probe.at {
return;
}
}
world.resource_mut::<NodeProbe>().fired = true;
let scale = world
.query::<&bevy::window::Window>()
.iter(world)
.next()
.map_or(1.0, bevy::window::Window::scale_factor);
let mut q = world.query::<(
Entity,
&bevy::ui::ComputedNode,
&GlobalTransform,
Option<&InheritedVisibility>,
)>();
let rows: Vec<(Entity, Vec2, Vec3, bool)> = q
.iter(world)
.map(|(e, node, gt, vis)| {
(
e,
node.size(),
gt.translation(),
vis.is_none_or(|v| v.get()),
)
})
.collect();
info!("node probe: {} nodes, scale {scale}", rows.len());
for (e, size, center, vis) in rows {
let comps: Vec<String> = world.inspect_entity(e).map_or_else(
|_| Vec::new(),
|it| {
it.map(|c| c.name().shortname().to_string())
.filter(|n| {
// Drop the ubiquitous plumbing components — the signal is the rest.
!matches!(
n.as_str(),
"Transform"
| "GlobalTransform"
| "Visibility"
| "InheritedVisibility"
| "ViewVisibility"
| "ChildOf"
| "Children"
)
})
.collect()
},
);
// ComputedNode is physical px; translation is the node's center, also physical.
info!(
"node probe: [{:.0},{:.0} {:.0}x{:.0}] vis={} {:?}",
(center.x - size.x * 0.5) / scale,
(center.y - size.y * 0.5) / scale,
size.x / scale,
size.y / scale,
vis,
comps
);
}
}
/// The entity census (`WOW_ENTITY_CENSUS=<secs>`, REAL seconds): once, `t` seconds in, print one
/// line per live archetype — entity count plus its signal components, largest first — and a machine-readable
/// summary. The "what IS the entity count made of" instrument: the standing HUD reads tens of
/// thousands of entities, and every per-frame cost that scales with *residency* (0362's
/// change-tick sweeps, transform propagation, render extraction) is only attributable once
/// residency itself has names. Born with the cost-ledger campaign.
pub(crate) struct EntityCensusPlugin;
impl Plugin for EntityCensusPlugin {
fn build(&self, app: &mut App) {
let at = std::env::var("WOW_ENTITY_CENSUS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(10.0);
app.insert_resource(EntityCensus { at, fired: false })
.add_systems(Update, fire_entity_census);
}
}
/// [`EntityCensusPlugin`] state: the fire time and the once-latch.
#[derive(Resource)]
struct EntityCensus {
at: f32,
fired: bool,
}
/// Archetype lines the census prints; everything smaller folds into the summary's `other_n`.
const ENTITY_CENSUS_ROWS: usize = 60;
/// Signal components shown per archetype line — enough to name what the entities are without
/// drowning the line in a 30-component render archetype.
const ENTITY_CENSUS_COMPS: usize = 14;
fn fire_entity_census(world: &mut World) {
{
// REAL seconds, not virtual: the census is timed to compose with `WOW_LIVE_FPS_AT`
// (also real), and virtual time lags real by the load stalls — a virtual-timed one-shot
// scheduled "just before sampling" fires after the probe has already exited.
let time = world.resource::<Time<bevy::time::Real>>().elapsed_secs();
let probe = world.resource::<EntityCensus>();
if probe.fired || time < probe.at {
return;
}
}
world.resource_mut::<EntityCensus>().fired = true;
let components = world.components();
let mut rows: Vec<(usize, String)> = world
.archetypes()
.iter()
.filter(|a| !a.is_empty())
.map(|a| {
let full: Vec<String> = a
.components()
.iter()
.filter_map(|id| components.get_info(*id))
.map(|c| c.name().shortname().to_string())
.collect();
let signal: Vec<String> = full
.iter()
.filter(|n| {
// Drop the ubiquitous plumbing components — the signal is the rest.
!matches!(
n.as_str(),
"Transform"
| "GlobalTransform"
| "Visibility"
| "InheritedVisibility"
| "ViewVisibility"
| "ChildOf"
| "Children"
)
})
.cloned()
.collect();
// A bare transform node has no signal left after the filter — and two such
// archetypes differing only in plumbing (Children vs not) would print as identical
// rows. For those, the plumbing IS the signal: print the full list.
let names = if signal.len() <= 1 { full } else { signal };
let shown = names.len().min(ENTITY_CENSUS_COMPS);
let more = names.len() - shown;
let mut comps = names[..shown].join(", ");
if more > 0 {
comps.push_str(&format!(" +{more}"));
}
(a.len() as usize, comps)
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.0));
let (total_arch, total_n) = (rows.len(), rows.iter().map(|r| r.0).sum::<usize>());
let other_n = rows
.iter()
.skip(ENTITY_CENSUS_ROWS)
.map(|r| r.0)
.sum::<usize>();
for (n, comps) in rows.iter().take(ENTITY_CENSUS_ROWS) {
println!("ENTITY_CENSUS_ARCH n={n} comps=[{comps}]");
}
println!(
"ENTITY_CENSUS total={total_n} archetypes={total_arch} \
rows={} other_n={other_n}",
rows.len().min(ENTITY_CENSUS_ROWS),
);
}
+15
View File
@@ -680,6 +680,10 @@ pub(crate) use events::{
advance_track, footfall_side, is_footstep_sound, scan_events, AnimSoundEvent,
};
/// The `$BTH` breath puffs — a unit's visible cold vapour in a snow zone (B233, decision 1149).
mod breath;
use breath::{classify_breath, fire_breath};
mod impact;
use impact::route_swing_impacts;
pub(crate) use impact::{DefenseAnim, PendingImpacts, SwingFlush, SwingImpact, SwingSlowdown};
@@ -978,6 +982,14 @@ impl Plugin for CreatureAnimPlugin {
lod::plugin(app);
pose::plugin(app);
compose::plugin(app);
breath::register(app);
// The breath classifier is a per-unit environment resolve, not an animation step: it runs
// off the area authority, at its own 10 s cadence, and only publishes the component
// `fire_breath` reads inside the chain below.
app.add_systems(
Update,
classify_breath.after(crate::terrain_stream::AreaAuthoritySet),
);
app.add_message::<AnimSoundEvent>()
.add_message::<SwingMessage>()
.add_message::<SwingImpact>()
@@ -1045,6 +1057,9 @@ impl Plugin for CreatureAnimPlugin {
// entity-visuals chain so the arrow appears/vanishes the frame the keyframe
// lands, not one behind.
drive_nock_latch,
// …and the `$BTH` puff off the same scan: another SpellKitFx writer, so it
// belongs ahead of the entity-visuals chain like its `arm_*_fx` siblings.
fire_breath,
)
.chain()
.after(WorldStage::Net)
@@ -0,0 +1,308 @@
//! **Cold breath** — the visible vapour a unit puffs from its mouth in a cold zone (B233,
//! decision 1149; wow-re `object-layer/scratch/cold-breath-law.md`, §5 two independent pairs +
//! byte arbitration).
//!
//! The client hangs three mutually-exclusive puffs off one animation event, and they are the
//! `$BTH` family: cold vapour, underwater bubbles, and the drunk's bubbles. This module owns the
//! **cold** one — the only one of the three that was reported missing, and the only one whose
//! every input benilla already has.
//!
//! ## The mechanism, in the order the client runs it
//!
//! 1. **The classifier** `0x607710` — `CGUnit_C` vtable slot 14, run per frame from the
//! WorldFrame's visible-object callback, on **every unit with a loaded model** (NPCs and
//! creatures too; there is no local-player gate anywhere in it). It **clears both**
//! `[unit+0xc58]` bits at entry and then sets **at most one**: `0x20` submerged, `0x40` cold.
//! It stamps `[unit+0xc18] = now + 10 s` on every leg, so **the classification is cached for
//! ten seconds per unit** — crossing a zone border does not take effect instantly.
//! [`classify_breath`].
//! 2. **The cold test** `0x67e9c0` — `AreaTable.Flags` bit `0x1`, inherited ONE hop from the
//! parent zone unless the leaf sets bit `0x2`. That single hop is the whole mechanism: only
//! four rows in 5875 author the bit (Dun Morogh, Winterspring, Razorfen Downs, Naxxramas) and
//! every sub-area a player actually stands in carries `0x40`, so a leaf-only read finds
//! nothing anywhere. [`benilla_formats::AreaTableCatalog::is_cold`].
//! It is **independent of the weather** — snowfall and breath share no input — and there is no
//! indoor suppression: the Thunderbrew Distillery is as cold as the road outside.
//! 3. **The handler** `0x5ffbd0` — on the `$BTH` anim event, the ladder
//! `drunk ≥ 50 → underwater → cold → nothing`, strictly exclusive. [`fire_breath`].
//! 4. **The puff** — `SpellVisualEffectName` row 107 `"HARDCODED Breath Cold"` →
//! `Particles\ColdBreath.m2`, attached at **M2 AttachmentID `0x11`** (a bone under the head —
//! key bone 21, whose pivot is the mouth), inheriting the bone's full orientation. The asset
//! is emitter-only (no mesh): one non-looping 1.5 s sequence, one continuous particle emitter,
//! zero gravity, a ~1.6° cone — a tight puff, not a spray. It self-terminates at the end of
//! its own clip (the `0x5fbf50` one-shot terminator, the same lifetime the mount poof uses).
//!
//! **`$BTH` is authored on the whole idle family**, so the cadence is the animation's, not a
//! timer's: `Stand` and its variants, every `Ready*`, `Stun`, `Sleep`, `KneelLoop`,
//! `StealthStand`, `SwimIdle`, several emotes and `Drown` — on every player race/gender (HumanMale
//! `Stand` keys one at 667 ms), and on 148 of the 430 creature models. There is no fire-once
//! latch, so it re-fires every loop of the carrying clip.
//!
//! ## Stated divergences
//!
//! - **The underwater rung is not modelled** — but it is now fully specified rather than open:
//! what is missing is the work, not the law (`cold-breath-law.md` §4b). The test is
//!
//! ```text
//! underwater ⟺ 5.0 + SCALE_X · boxHeight < liquidSurfaceZ − unitZ
//! ```
//!
//! where `boxHeight` is the M2 header's **box A** — `bounding_box_max.z − bounding_box_min.z`,
//! the loose all-animation render box at MD20 `0xb4`, raw model space
//! (`benilla_m2::M2Header::bounding_box_min`/`_max`). Emphatically **not** the collision box and
//! not `CreatureModelData.CollisionHeight`: those are the *other* box (`0xd0`) and agree with
//! each other, not with this one. `SCALE_X` is the only multiplier — no DBC scale enters.
//! `unitZ` is the unit's **feet** (its movement-block position), and `liquidSurfaceZ` is the
//! ordinary liquid query's own cached surface, not a second sample.
//!
//! For a scale-1 HumanMale that threshold is **8.84 yd** of water above the feet (box A `Δz` =
//! 3.8415); DwarfMale 8.03, GnomeFemale 7.11, TaurenMale 10.21; a unit whose model has not
//! loaded uses the ctor default `1.0`, i.e. 6.0. **So it is a diving test, not a swimming one**,
//! and that is what bounds the omission: a unit in a cold zone puffs vapour where the reference
//! would puff bubbles only once genuinely deep. The bubbles themselves
//! (`Particles\Bubbles.m2` — 3.3 s and *looping*, so they need the replace-on-respawn dedup
//! this module's one-shot does not) are a separate, unreported feature. Two details for whoever
//! builds it: while **mounted** the box is the MOUNT's model, and `SCALE_X` runs through a 2 s
//! cosine ease after a scale change (bit-exact at rest, so only that window differs).
//! - **The drunk rung gates but does not spawn.** `drunk ≥ 50` correctly suppresses cold breath
//! (the ladder is exclusive); the inebriated bubbles themselves are unreported and their clip
//! semantics are unpinned, so nothing is drawn there.
//! - **A remote unit's area is the terrain's alone.** The player's area comes from
//! [`CurrentArea`], which races a WMO interior claim ahead of the terrain chunk exactly as the
//! client's `GetAreaID` does; there is no per-unit equivalent of that claim, so other units
//! read [`area_id_under`]. It differs only inside a WMO whose `WMOAreaTable` row names a
//! different area than the ground beneath it.
//! - **Overlap is refused rather than replaced.** The client's `0x6208e0` destroys a still-running
//! puff when a new one spawns on the same unit at the same tag; our one-shot instances carry no
//! reap key, so [`fire_breath`] declines a new puff while the last one is still inside its clip
//! instead. Same observable — never two overlapping puffs — with the running one finishing
//! rather than restarting.
use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use benilla_assets::AdtTile;
use crate::area::AreaTableRes;
use crate::entities::Creatures;
use crate::net::{NetEntity, ObjectStore, SelfPlayer};
use crate::terrain_stream::{area_id_under, CurrentArea, TerrainStreamer};
use super::events::AnimSoundEvent;
use super::spell_visual::{FxClass, FxStage, SpellKitFx, SpellVisuals};
/// The event that asks "what does this unit breathe?" — `0x5fffad cmp eax,0x48544224`.
const BTH: [u8; 4] = *b"$BTH";
/// Hardcoded-effect index **3**'s baked lookup name (the `0x8617b8` string table, matched by the
/// boot name-resolve `0x61f5b0`) → `SpellVisualEffectName` row 107 → `Particles\ColdBreath.m2`.
const COLD_BREATH_EFFECT: &str = "HARDCODED Breath Cold";
/// The `$BTH` family's attach tag — `DAT_0080c968[2..=3]` and `[7]` are all `0x11`, where the loot
/// art / ding / mount poof are `0x13`. A **raw M2 `AttachmentID`**, resolved through the model's
/// own `attachment_lookup` (the index it lands on differs per model: 17 on HumanMale, 13 on
/// HumanFemale, 8 on GnomeFemale, 2 on Wolf — never use the tag as an array index).
const BREATH_ATTACH: u16 = 0x11;
/// `[unit+0xc18] = now + 0x2710` — the classifier re-runs at most every 10 s per unit.
const RECLASSIFY_SECS: f32 = 10.0;
/// `PLAYER_BYTES_3` byte 1, clamped 100, `×0.01 ≥ 0.5` (`0x5ffbd0`) — i.e. the raw byte at 50.
/// Read on **players only** (`OBJECT_FIELD_TYPE` bit 4, `0x600018`).
const DRUNK_THRESHOLD: u8 = 50;
/// `Particles\ColdBreath.m2`'s only sequence: 3333 → 4833 ms, `flags = 0x1` (non-looping). The
/// window inside which a second `$BTH` is declined — see the module docs' overlap divergence.
const COLD_BREATH_CLIP: f32 = 1.5;
/// What a unit's environment says it breathes — the client's `[unit+0xc58]` bits `0x20`/`0x40`,
/// which the classifier clears together and sets at most one of.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Breath {
/// Bit `0x40` — a cold area. The puff.
Cold,
/// Neither bit: nothing to see. (Bit `0x20`, submerged, is the unmodelled rung — module docs.)
None,
}
/// A unit's cached breath classification and when it next goes stale — the client's
/// `[unit+0xc58]` bits behind their `[unit+0xc18]` 10-second stamp.
#[derive(Component)]
pub(crate) struct BreathEnv {
kind: Breath,
/// `Time::elapsed_secs` at which [`classify_breath`] re-resolves this unit.
stale_at: f32,
}
/// When the last cold-breath puff started on this unit — the overlap guard standing in for the
/// client's `0x6208e0` replace-on-respawn dedup (module docs).
#[derive(Resource, Default)]
pub(super) struct LastPuff(EntityHashMap<f32>);
/// Resolve `[unit+0xc58]`'s bits for every unit whose 10-second stamp has expired — the client's
/// `0x607710`, which runs on every unit with a loaded model and caches for 10 s.
///
/// A unit seen for the first time has no [`BreathEnv`] and is classified at once, so a unit that
/// streams in inside a cold zone breathes on its next idle loop rather than up to 10 s later.
#[allow(clippy::too_many_arguments)] // one system's full input set
pub(super) fn classify_breath(
mut commands: Commands,
time: Res<Time>,
units: Query<(Entity, &GlobalTransform, Option<&BreathEnv>), With<NetEntity>>,
self_area: Res<CurrentArea>,
self_units: Query<(), With<SelfPlayer>>,
areas: Option<Res<AreaTableRes>>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
) {
let Some(areas) = areas else {
return; // no client data — the DBC-resource degrade shape, as everywhere else
};
let now = time.elapsed_secs();
for (entity, transform, env) in &units {
if env.is_some_and(|e| now < e.stale_at) {
continue;
}
// The player's area is `CurrentArea` — the client's own `GetAreaID`, WMO-interior claim
// included. Every other unit gets the outdoor leg alone (module docs).
let area = if self_units.contains(entity) {
self_area.0
} else {
area_id_under(&streamer, &adt_tiles, transform.translation())
};
let kind = match area {
Some(id) if areas.0.is_cold(id) => Breath::Cold,
_ => Breath::None,
};
commands.entity(entity).insert(BreathEnv {
kind,
stale_at: now + RECLASSIFY_SECS,
});
}
}
/// The `$BTH` handler `0x5ffbd0`: run the exclusive ladder and puff.
///
/// The event fires on the model that authored it, which for a mounted rider is the body hanging
/// off the mount — so the **puff goes on the event's own entity** (its mouth), while the unit
/// state the ladder reads (drunk, the environment) comes from the composite's **root**, exactly
/// as the footfall visuals split them.
#[allow(clippy::too_many_arguments)] // one system's full input set
pub(super) fn fire_breath(
mut events: MessageReader<AnimSoundEvent>,
time: Res<Time>,
models: Query<&NetEntity>,
parents: Query<&ChildOf>,
roots: Query<(Option<&ObjectStore>, Option<&BreathEnv>)>,
visuals: Option<Res<SpellVisuals>>,
creatures: Option<Res<Creatures>>,
mut last: ResMut<LastPuff>,
mut fx: MessageWriter<SpellKitFx>,
) {
if events.is_empty() {
return;
}
let (Some(visuals), Some(creatures)) = (visuals, creatures) else {
return;
};
let Some(path) = visuals.0.hardcoded_effect(COLD_BREATH_EFFECT) else {
return; // no such row — no breath (the DBC-resource degrade shape)
};
let path = path.to_string();
let now = time.elapsed_secs();
for ev in events.read() {
if ev.ident != BTH {
continue;
}
// `CreatureModelData.Flags & 0x2` suppresses the whole `$BTH` family — skeletons, ghosts,
// elementals, golems, slimes, totems: 99 of the 430 shipped rows. Read off the model that
// fired the event, which is the model that would wear the puff.
let breathes = models
.get(ev.entity)
.ok()
.and_then(|n| n.display_id)
.is_none_or(|d| creatures.breathes(d));
if !breathes {
continue;
}
let mut root = ev.entity;
while let Ok(child_of) = parents.get(root) {
root = child_of.parent();
}
let Ok((store, env)) = roots.get(root) else {
continue;
};
// The ladder, strictly exclusive (`0x5ffbd0`): drunk ≻ underwater ≻ cold ≻ nothing. The
// drunk rung reads PLAYER_BYTES_3 byte 1 and is a PLAYER-only field, so a creature never
// takes it; the underwater rung is the unmodelled one (module docs).
if store.is_some_and(|s| {
s.0.player_drunk_byte()
.is_some_and(|b| b >= DRUNK_THRESHOLD)
}) {
continue;
}
if env.map(|e| e.kind) != Some(Breath::Cold) {
continue;
}
// The overlap guard: one puff at a time on a unit (module docs).
if last
.0
.get(&ev.entity)
.is_some_and(|&t| now - t < COLD_BREATH_CLIP)
{
continue;
}
last.0.insert(ev.entity, now);
debug!(
"anim: $BTH in a cold area — the breath puffs ({})",
ev.entity
);
fx.write(SpellKitFx::Begin {
entity: ev.entity,
spell_id: 0,
persistent: false,
class: FxClass::Hold,
// `0x5fbf50` — destroy at the first completion; the shipped clip runs 1.5 s.
stage: FxStage::OneShot,
effects: vec![(BREATH_ATTACH, path.clone())],
});
}
// Streamed units despawn on range-out — drop their puff memory with them.
last.0.retain(|e, _| models.contains(*e));
}
/// The resource the overlap guard lives in — the systems themselves are wired into
/// `creature_anim`'s own chain beside every other `$`-tag consumer.
pub(super) fn register(app: &mut App) {
app.init_resource::<LastPuff>();
}
#[cfg(test)]
mod tests {
use super::*;
/// The 10-second stamp: a freshly-seen unit classifies at once, and a classified one is not
/// re-resolved until its stamp expires — the client's `[unit+0xc18] = now + 0x2710`.
#[test]
fn classification_is_cached_for_ten_seconds() {
let env = BreathEnv {
kind: Breath::Cold,
stale_at: 10.0,
};
assert!(9.99 < env.stale_at, "still fresh at 9.99 s");
assert!(10.01 >= env.stale_at, "stale at 10.01 s");
assert!(
(RECLASSIFY_SECS - 10.0).abs() < f32::EPSILON,
"0x2710 ms = 10 s"
);
}
/// The tag is `0x11`, not the loot/ding/poof `0x13` — the one number that decides whether the
/// vapour comes out of the mouth or the unit's base.
#[test]
fn breath_attaches_at_the_mouth_tag() {
assert_eq!(BREATH_ATTACH, 0x11);
assert_eq!(BTH, *b"$BTH");
assert_eq!(DRUNK_THRESHOLD, 50);
}
}
+13 -1
View File
@@ -55,6 +55,12 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
("MasterSoundEffects", "1"),
("EnableMusic", "1"),
("EnableAmbience", "1"),
// Zone reverb (1153). The binary registers this one `"1"` (`0x4573be`) and we register it
// `"0"` — the only row here that knowingly leaves the registrar's default, because the
// reference's reverb is EAX-over-hardware and that hardware has not existed since Vista:
// `"1"` would ship audio the real client has never actually produced (bug B236).
// `SoundConfig::reverb` carries the evidence.
("SoundReverb", "0"),
("uiScale", "0.9"),
("farclip", "777"),
// The Controls-page trio (0961). `deselectOnClick`/`mouseInvertPitch` are 1.12's own
@@ -177,6 +183,8 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"mastersoundeffects" => knobs.sound.enabled = v != 0.0,
"enablemusic" => knobs.sound.music_enabled = v != 0.0,
"enableambience" => knobs.sound.ambience_enabled = v != 0.0,
// The client's own parse for this one is literally `!= 0` too (`0x4574d0`: `setne al`).
"soundreverb" => knobs.sound.reverb = v != 0.0,
"uiscale" => knobs.scale.0 = v.clamp(0.5, 1.5),
"farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()),
"deselectonclick" => knobs.click.deselect_on_click = v != 0.0,
@@ -324,7 +332,7 @@ fn sync_cvars(
if !persist.registered {
script.register_cvars(REGISTERED.iter().copied());
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 22] = [
let session: [(&str, String); 23] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -332,6 +340,7 @@ fn sync_cvars(
("MasterSoundEffects", flag(sound.enabled)),
("EnableMusic", flag(sound.music_enabled)),
("EnableAmbience", flag(sound.ambience_enabled)),
("SoundReverb", flag(sound.reverb)),
("uiScale", scale.0.to_string()),
("farclip", view.farclip.to_string()),
("deselectOnClick", flag(click.deselect_on_click)),
@@ -477,6 +486,9 @@ mod tests {
assert_eq!(d["MasterSoundEffects"] != 0.0, sound.enabled);
assert_eq!(d["EnableMusic"] != 0.0, sound.music_enabled);
assert_eq!(d["EnableAmbience"] != 0.0, sound.ambience_enabled);
// Welded like the rest — and deliberately NOT the binary's registrar "1" (1153).
assert_eq!(d["SoundReverb"] != 0.0, sound.reverb);
assert!(!sound.reverb, "zone reverb ships off (decision 1153)");
assert_eq!(d["uiScale"], DEFAULT_UI_SCALE);
// ViewDistance::default() reads $WOW_FARCLIP; the registered default mirrors the
// env-less 777 literal (view.rs doc: "Default 777").
+7
View File
@@ -222,6 +222,13 @@ impl Creatures {
self.catalog.footprint(display_id)
}
/// Does this display's model breathe — may it wear the `$BTH` puffs (cold vapour, bubbles)?
/// `CreatureModelData.Flags & 0x2` suppresses the family: skeletons, ghosts, elementals,
/// golems, slimes, totems. See [`benilla_formats::CreatureCatalog::breathes`].
pub(crate) fn breathes(&self, display_id: u32) -> bool {
self.catalog.breathes(display_id)
}
/// A display's **base render alpha** (`CreatureDisplayInfo.CreatureModelAlpha / 255`) — the
/// `baseAlpha` factor of the per-unit alpha product the aura CharProc nodes multiply into
/// (`crate::aura_visual`). `None` for an unknown display.
@@ -537,6 +537,7 @@ pub(crate) fn simulate_chain_beams(
// No owner rung: a beam is not a model's emitter (module docs, approximation (d)).
bias: 0.0,
raster_bias: 0,
cam_relative: false,
main_entity: entity,
light: None,
},
+21 -14
View File
@@ -7,8 +7,11 @@
//!
//! **The mechanism — wow-re §5 cross-checked** (`footprint-decals.md`, folded back in 1012):
//! - **Surface gate**: `TerrainType.Flags & 1` (`0x699eb6`) — set on exactly **Snow** and
//! **Sand** — resolved under the unit through the same ground-effect → TerrainType chain the
//! footstep sounds ride ([`benilla_formats::FootstepCatalog::leaves_footprints`]).
//! **Sand** — on the surface [`crate::surface`] resolves under the unit, the *same* value the
//! footstep sound uses. The reference resolves it once per unit into `CGUnit+0xc60` and the
//! decal, the spray and `$FSD` all read that one dword (wow-re `wmo-footstep-surface.md` F3), so
//! indoors the gate reads the building's own floor: a tavern's floorboards take no prints while
//! the snow outside its door does (decision 1161).
//! - **Trigger**: each per-foot animation event tag (`$xL*`/`$xR*`; the same [`AnimSoundEvent`]
//! stream the sounds read). **`$FSD` is sound-only** — a quadruped whose run authors only
//! `$FSD` leaves no prints, faithfully. Position = the event record's authored offset through
@@ -45,8 +48,6 @@ use avian3d::prelude::Collider;
use bevy::platform::collections::HashMap;
use bevy::prelude::*;
use benilla_assets::AdtTile;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::blob_shadow::SHADOW_RASTER_BIAS;
use crate::collision::GroundDecalSurface;
@@ -60,7 +61,6 @@ use crate::particles::buffer::{
use crate::player::WorldCamera;
use crate::schedule::WorldStage;
use crate::sound::footsteps::Footsteps;
use crate::terrain_stream::{ground_effect_under, TerrainStreamer};
/// Print lifetime, spawn to gone — the reference's 6000 ms (byte-verified, wow-re
/// `footprint-decals.md`: `t = 1 − age/6000`, die at `t < 0`).
@@ -169,7 +169,12 @@ fn spawn_footprints(
time: Res<Time>,
// GlobalTransform for the same reason as the footstep sounds (0441): a mounted unit's steps
// are the MOUNT child's tags, whose local Transform is the seat-relative ~origin.
units: Query<(&NetEntity, &GlobalTransform, Option<&BoneAttach>)>,
units: Query<(
&NetEntity,
&GlobalTransform,
Option<&BoneAttach>,
Option<&crate::wmo_portal::UnitWmoRoom>,
)>,
// The spawner's ROOT (the rider for a mount child): the pool select, the print scale (the
// rider's SCALE_X — RE-corrected), and the state gates all read the root.
parents: Query<&ChildOf>,
@@ -179,8 +184,7 @@ fn spawn_footprints(
footsteps: Option<Res<Footsteps>>,
creatures: Option<Res<Creatures>>,
ink: Option<Res<FootprintInk>>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
surface: crate::surface::SurfaceUnderfoot,
surfaces: Query<&Collider, With<GroundDecalSurface>>,
mut prints: ResMut<Footprints>,
) {
@@ -198,7 +202,7 @@ fn spawn_footprints(
let Some(side) = footfall_side(&ev.ident) else {
continue;
};
let Ok((net, transform, attach)) = units.get(ev.entity) else {
let Ok((net, transform, attach, room)) = units.get(ev.entity) else {
continue;
};
// The ink + dims come from the EVENT's model (the mount for a mounted composite); the
@@ -243,11 +247,13 @@ fn spawn_footprints(
{
continue;
}
// The surface gate, read under the foot itself (a beach edge prints per-foot).
if !footsteps
.0
.leaves_footprints(ground_effect_under(&streamer, &adt_tiles, foot))
{
// The surface gate, on the SAME terrain type the footstep sound used: the reference
// resolves it once per unit (`CGUnit+0xc60`) and the decal, the spray and `$FSD` all read
// that one dword (wow-re `wmo-footstep-surface.md` F3). So this reads the UNIT's surface,
// not a per-foot sample of the ground — indoors that is the building's own floor, which is
// why a tavern's floorboards take no prints while the snow outside its door does.
let terrain = surface.terrain_type(&footsteps.0, room, transform.translation());
if !terrain.is_some_and(|t| footsteps.0.terrain_leaves_footprints(t)) {
continue;
}
// Print frame: length along the unit's facing, width across, yawed to the facing.
@@ -375,6 +381,7 @@ fn push_footprints(
anchor: print.anchor,
bias: PRINT_SORT_BIAS,
raster_bias: SHADOW_RASTER_BIAS,
cam_relative: false,
main_entity: lane.0,
light: None,
},
+349 -36
View File
@@ -38,8 +38,21 @@
//! joints + `AnimationPlayer` + graph + [`ModelAnimations`] a creature gets ([`crate::entities::attach`]),
//! but tagged with [`GoAnim`] instead of `AnimDriver`, so this driver — not `creature_anim` — owns it.
//! Clips are keyed by AnimationData.dbc id, so a resolved id becomes a clip by a scan of
//! [`ModelAnimations::clips`], exactly as `creature_anim` does. A one-shot motion held at its end frame
//! *is* the destination rest pose, so playing the motion and holding needs no explicit settle step.
//! [`ModelAnimations::clips`], exactly as `creature_anim` does.
//!
//! **A transition motion is a TRANSIENT substate, and the settle is explicit** (decision 1151,
//! wow-re `gameobject-anim-arm.md` §2d/§3). The kernel's `flags` bit 0 says nothing about how long
//! a transition lasts: bit 0 clear means the *pose* wraps its band for ever, and the whole
//! door family (`G_Crate01`, every `World\Goober\` prop, the books) authors Close/Open/Destroy
//! that way. What ends a swing is the **object layer**: the model's completion callback fires once
//! at the arm's baked window end — span × replay, bit 0 ignored — and slot 14 `0x5f4120` advances
//! the machine off the motion substate onto its rest one (2 Open → 3 Opened, 4 Close → 1 Closed,
//! 5 Destroy → 6 Destroyed, 7 Rebuild → 1 Closed), arming that pose over the motion. So benilla
//! arms a motion for exactly ONE window ([`RepeatAnimation::Never`]) and
//! [`retire_transient_anim`] re-runs the state machine at its end — the same endpoint, and the
//! same shape the Custom channel already uses (decisions 1099/1100). Honouring the loop bit
//! instead is a chest lid that springs open and slams shut ~1.5×/s for ever, which is the report
//! this record closes.
//!
//! The §243 **missing-sequence fallback** is no longer deferred: [`remap_missing`] implements the
//! four-way remap, including the two legs that freeze a *motion* clip at frame 0 to stand in for an
@@ -94,13 +107,17 @@ pub(crate) struct GoAnim {
/// so the bobber's same-frame pair (the forced `READY → ACTIVE` flip + the splash) resolves
/// with the splash on top (decision 1086).
custom: Option<u16>,
/// The Custom clip currently ARMED (set when [`drive_go_anim`] actually plays one) — the
/// completion-retire's watch (decision 1100, wow-re `go-display-sound-events.md` §6-8): the
/// reference's per-model completion callback fires ONCE at the clip's window end (span ×
/// replay-count, the loop bit ignored) and re-runs the GO state machine, which re-arms the
/// state's own pose over the Custom block. [`retire_custom_anim`] models exactly that; without
/// it the bobber's splash looped ~1.3 s forever (the director's 2-3 audible splashes).
custom_active: Option<u16>,
/// The clip armed for the current **TRANSIENT substate**, if any — the completion-retire's
/// watch (decisions 1100/1151, wow-re `gameobject-anim-arm.md` §2d + `go-display-sound-events.md`
/// §6-8). The reference keeps exactly one current substate in `[handler+0x10]`, and its
/// per-model completion callback fires ONCE at the arm's baked window end (span × replay, the
/// loop bit ignored); slot 14 `0x5f4120` then advances a transient substate onto its rest one
/// and arms that pose over the transient clip. **Two families reach it, and they share this
/// one slot exactly as the reference's does**: the §243 transition motions (2 Open / 4 Close /
/// 5 Destroy / 7 Rebuild) and the Custom0..3 block (8..11). [`retire_transient_anim`] models
/// the advance for both; without it a bit-0-clear clip runs for ever — the bobber's splash
/// looping ~1.3 s (1100's 2-3 audible splashes) and the crate lid never settling shut (1151).
transient: Option<u16>,
}
/// The GameObject's **stored** state — the binary's `go+0x27c`, which is what every consumer reads
@@ -114,6 +131,37 @@ pub(crate) fn go_state(anim: Option<&GoAnim>, store: &ObjectStore) -> u32 {
.unwrap_or(GO_STATE_ACTIVE)
}
/// The inspector's GameObject **animation** readout (decision 1151): which sequence the §243 arm
/// is actually playing on the object under the cursor — its `AnimationData` id, whether it is the
/// state's held **rest** pose or a **transient** one (a transition motion, or a Custom block, which
/// [`retire_transient_anim`]'s §2d advance ends at its window end), and the repeat the player is
/// running it under. `None` when the object renders as a static mesh, or nothing is armed yet.
///
/// The line that closes the loop on this whole class of report. "The crate is stuck open/closing"
/// and "the lid settles shut" are indistinguishable from every card line we had: the GO line said
/// `state 1 closed(READY)`, which was **true**, while a `Forever` Close motion swung underneath
/// it. `anim Close(146) · transition · loops` is that bug stated in one hover; `anim Closed(147) ·
/// rest` is the fix, read the same way.
///
/// The newest arm is the **smallest seek** — a cross-fade's fading source is older by construction
/// — the same pick [`fire_go_anim_events`] makes for the event scan.
pub(crate) fn armed_anim(
go: &GoAnim,
player: &AnimationPlayer,
anims: &ModelAnimations,
) -> Option<(u16, bool, RepeatAnimation)> {
let (clip, active) = anims
.clips
.iter()
.filter_map(|c| player.animation(c.node).map(|a| (c, a)))
.min_by(|a, b| a.1.seek_time().total_cmp(&b.1.seek_time()))?;
Some((
clip.anim_id,
go.transient == Some(clip.anim_id),
active.repeat_mode(),
))
}
/// Which GameObject types get the state-driven **animated** instance (skinned lid/door + §243
/// sequences) — **the byte-verified type census**, not a guess (wow-re `gameobject-anim-arm.md` §2f).
///
@@ -418,42 +466,57 @@ fn close_go_lid(
*last_source = current;
}
/// Play the §243 sequence for a change of the client-side [`GoAnim::state`] (written by any of the three
/// callers). Mirrors the state-transition detection of [`crate::sound::gameobject`] (first sight silent),
/// but points it at the model instead of the mixer — one system owns the visual, the other the audio.
/// The **completion-driven retire** of an armed Custom clip (decision 1100; wow-re
/// `go-display-sound-events.md` §6-8, §5-verified): the reference registers a per-model completion
/// callback at GO model attach (`0x5f7d43` → `[M2+0x70]`) that fires ONCE when a sequence reaches
/// its baked window end — span × replay-count, the **loop bit ignored** — and, for the transient
/// substates (the Custom family 8..11 among them), re-runs the GO state machine at the current
/// `GAMEOBJECT_STATE`, arming its pose through the playable-lookup fallback. For the bobber that
/// resolves 149 → 151 → the lookup's all-fallback rows → **Stand**, landing one frame after the
/// 1333 ms window — before the looping kernel's second `$GC0` crossing at 1533 ms. Net law: one
/// splash per 0xB3, then the state pose.
/// The **completion-driven retire** of the current TRANSIENT substate — the §2d advance
/// (decisions 1100/1151; wow-re `gameobject-anim-arm.md` §2d/§3 + `go-display-sound-events.md`
/// §6-8, §5-verified). The reference registers a per-model completion callback at GO model attach
/// (`0x5f7d43` → `[M2+0x70]`) which the driver `0x719370` fires ONCE when the armed sequence
/// reaches its baked window end — span × replay-count, the **loop bit ignored** — and slot 14
/// `0x5f4120` dispatches on the current substate:
///
/// Benilla's custom arm plays the clip `Never`-repeat (one window, the same endpoint), so "the
/// | completed substate | advances to | i.e. |
/// |--------------------|-------------|------|
/// | 2 Open | 3 Opened | the swing settles open |
/// | 4 Close | 1 Closed | the lid settles shut |
/// | 5 Destroy | 6 Destroyed | |
/// | 7 Rebuild | 1 Closed | |
/// | 0 Spawn, 8..11 Custom0-3 | re-run at the current state (`0x5f4190`) | |
/// | 1/3/6 (a rest pose) | nothing (`0x5f4167`) | a held pose never advances |
///
/// **This — not the animation kernel — is what turns a transition clip into a resting state**, and
/// it is why the kernel's loop bit is not the transition's duration: `G_Crate01`'s Close is
/// `flags` bit 0 clear, so the *pose* would wrap for ever, and only this advance ends it. For the
/// bobber's Custom0 the re-run resolves 149 → 151 → the lookup's all-fallback rows → **Stand**,
/// landing one frame after the 1333 ms window — before the looping kernel's second `$GC0` crossing
/// at 1533 ms. Net law: one splash per 0xB3, one swing per state change, then the state pose.
///
/// Benilla arms every transient clip `Never`-repeat (one window, the same endpoint), so "the
/// window ended" is the player's finished flag; the retire then clears `shown`, which makes the
/// state arm of [`drive_go_anim`] re-resolve the current state as a fresh rest pose — our
/// state-machine re-run. Runs before [`drive_go_anim`] in the chain so the re-arm lands the same
/// frame. Reads never deref-mut, so a quiet GO stays out of the Changed stream.
fn retire_custom_anim(mut gos: Query<(&mut GoAnim, &AnimationPlayer, &ModelAnimations)>) {
/// state-machine re-run, and (for a motion) exactly the table above, since `rest_anim(state)` is
/// the destination row of whichever motion that state's change armed. Runs before
/// [`drive_go_anim`] in the chain so the re-arm lands the same frame. Reads never deref-mut, so a
/// quiet GO stays out of the Changed stream.
fn retire_transient_anim(mut gos: Query<(&mut GoAnim, &AnimationPlayer, &ModelAnimations)>) {
for (mut go, player, anims) in &mut gos {
let Some(id) = go.custom_active else {
let Some(id) = go.transient else {
continue;
};
let done = anims
.find(id)
.is_none_or(|clip| player.animation(clip.node).is_none_or(|a| a.is_finished()));
if done {
go.custom_active = None;
go.transient = None;
// The completion's state-machine re-run: forget the shown pose so the state arm
// re-resolves it (a silent rest snap — `resolve(None, state)`), exactly the
// reference's re-arm-over-the-Custom-block.
// reference's re-arm over the finished transient block.
go.shown = None;
}
}
}
/// Play the §243 sequence for a change of the client-side [`GoAnim::state`] (written by any of the three
/// callers). Mirrors the state-transition detection of [`crate::sound::gameobject`] (first sight silent),
/// but points it at the model instead of the mixer — one system owns the visual, the other the audio.
fn drive_go_anim(
mut gos: Query<
(
@@ -471,6 +534,10 @@ fn drive_go_anim(
if go.shown != Some(state) {
let prev = go.shown;
go.shown = Some(state);
// A fresh substate replaces whatever transient one was live — the reference keeps
// exactly ONE (`[handler+0x10]`), so an old motion's completion can never fire
// over the pose that superseded it.
go.transient = None;
if let Some(play) = resolve(prev, state) {
// Resolve the id to this model's clip (keyed by AnimationData.dbc id, as
// `creature_anim` does), through the §2c remap for a model that doesn't author
@@ -498,11 +565,26 @@ fn drive_go_anim(
// clock but keeps `speed` — so re-arming a node a frozen leg previously
// parked at rate 0 (the same Open clip serves both) would stay stuck.
active.set_speed(1.0);
active.set_repeat(if clip.looping {
RepeatAnimation::Forever
} else {
RepeatAnimation::Never
});
// **The transition motion is ONE window, whatever the loop bit says**
// (decision 1151): it is a transient substate, ended by the object
// layer's §2d advance ([`retire_transient_anim`]), never by the
// kernel — whose bit-0-clear branch wraps the band for ever and would
// make the crate lid spring open and slam shut ~1.5×/s. A *rest* pose
// is the opposite: nothing advances off it (slot 14's `0x5f4167`), so
// it holds or loops exactly as the model authored it.
match play {
Play::Motion(_) => {
active.set_repeat(RepeatAnimation::Never);
go.transient = Some(want);
}
Play::Rest(_) => {
active.set_repeat(if clip.looping {
RepeatAnimation::Forever
} else {
RepeatAnimation::Never
});
}
}
}
}
// else: nothing playable even after the remap — hold whatever the loader
@@ -518,7 +600,7 @@ fn drive_go_anim(
// re-fires its events per pass, but the reference's COMPLETION callback fires at window
// end (span × replay-count; the bobber's replay pair is 0..0 ⇒ one 1333 ms pass) and
// re-runs the state machine, overwriting the Custom block before a second pass begins.
// `RepeatAnimation::Never` + [`retire_custom_anim`] reproduce that endpoint exactly: one
// `RepeatAnimation::Never` + [`retire_transient_anim`] reproduce that endpoint exactly: one
// pass, one `$GC0` splash, then the state pose — never a churning loop.
// (`is_some` pre-check: an unconditional `take()` mut-derefs the `Mut` and re-marks the
// component Changed every frame, keeping this Changed-filtered query hot forever.)
@@ -533,7 +615,7 @@ fn drive_go_anim(
);
active.set_speed(1.0);
active.set_repeat(RepeatAnimation::Never);
go.custom_active = Some(id);
go.transient = Some(id);
}
}
}
@@ -639,7 +721,7 @@ pub(crate) fn plugin(app: &mut App) {
// The completion retire reads last frame's finished flags and must clear
// `shown` BEFORE the drive, so its state re-arm lands this frame — the
// reference's own one-frame-after-window-end timing.
retire_custom_anim,
retire_transient_anim,
),
(drive_go_anim, drive_go_collision),
fire_go_anim_events,
@@ -705,6 +787,237 @@ mod tests {
assert_eq!(custom_anim_id(u32::MAX), None);
}
/// The delta [`step_clock`] uses for the next frame — the same device as the creature
/// driver's harness (`creature_anim/driver/tests.rs`) and for the same reason: with
/// `TimePlugin` live, `Time`'s delta is the REAL gap between two `app.update()` calls, so a
/// stalled frame on a loaded machine runs a whole clip out in one step and the assertions
/// below become a coin flip. Here the clock is a number the test writes.
#[derive(Resource, Default)]
struct NextStep(Option<Duration>);
fn step_clock(mut time: ResMut<Time>, mut next: ResMut<NextStep>) {
time.advance_by(next.0.take().unwrap_or(Duration::from_millis(1)));
}
/// Run one frame whose delta is exactly `ms`.
fn advance(app: &mut App, ms: u64) {
app.world_mut().resource_mut::<NextStep>().0 = Some(Duration::from_millis(ms));
app.update();
}
/// `G_Crate01`'s door family, as the bytes have it (pinned against the shipped asset by
/// `benilla-formats/tests/m2_go_crate_lid.rs`): Open/Opened/Close/Closed, **all four `looping`**
/// — `flags` bit 0 clear — with an empty replay range. Blend times are zeroed so the arm is a
/// cut and the assertions read the armed clip, not a fade.
const CRATE_FAMILY: [(u16, f32); 4] = [
(0x94, 0.666), // 148 Open — the lid sweeps 0° → 75°
(0x95, 0.100), // 149 Opened — holds 75°
(0x92, 0.667), // 146 Close — sweeps 75° → 0°
(0x93, 0.167), // 147 Closed — holds 0°
];
/// An app running the two systems this file's law lives in, plus one GameObject wearing a
/// crate's animation set: REAL `AnimationClip` assets and a real graph, so Bevy's own
/// `advance_animations` ticks the completions [`retire_transient_anim`] watches.
fn crate_app(extra_ids: &[(u16, f32)]) -> (App, Entity) {
use bevy::animation::graph::{AnimationGraph, AnimationGraphHandle};
use bevy::animation::AnimationClip;
let mut app = App::new();
app.add_plugins((
MinimalPlugins.build().disable::<bevy::time::TimePlugin>(),
AssetPlugin::default(),
bevy::animation::AnimationPlugin,
));
app.init_resource::<Time>();
app.init_resource::<NextStep>();
app.add_systems(bevy::app::First, step_clock);
app.add_systems(Update, (retire_transient_anim, drive_go_anim).chain());
let authored: Vec<(u16, f32)> = CRATE_FAMILY.iter().chain(extra_ids).copied().collect();
let handles: Vec<_> = authored
.iter()
.map(|&(_, dur)| {
let mut c = AnimationClip::default();
c.set_duration(dur);
app.world_mut()
.resource_mut::<Assets<AnimationClip>>()
.add(c)
})
.collect();
let (graph, nodes) = AnimationGraph::from_clips(handles);
let graph = app
.world_mut()
.resource_mut::<Assets<AnimationGraph>>()
.add(graph);
let mut lookup = vec![0xffffu16; 160];
for (slot, &(id, _)) in authored.iter().enumerate() {
lookup[id as usize] = slot as u16;
}
let anims = ModelAnimations {
graph: graph.clone(),
clips: authored
.iter()
.zip(&nodes)
.map(|(&(id, dur), &node)| benilla_assets::AnimClip {
anim_id: id,
seq_index: 0,
node,
// The asset fact this whole record turns on: the crate's transitions are
// bit-0-clear bands, i.e. the kernel loops them.
looping: true,
duration: dur,
move_speed: 0.0,
blend_time: 0.0,
bounds_center: Vec3::ZERO,
bounds_radius: 0.0,
bounds_min: Vec3::ZERO,
bounds_max: Vec3::ZERO,
events: Vec::new().into(),
arm_nodes: None,
upper_node: None,
frequency: 0,
replay: (0, 0),
poses_bones: true,
})
.collect(),
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: lookup,
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
};
// First sight is a closed chest, exactly as it streams in.
let go = app
.world_mut()
.spawn((
anims,
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimationGraphHandle(graph),
GoAnim {
state: Some(GO_STATE_READY),
..Default::default()
},
))
.id();
(app, go)
}
/// What the object is actually playing: the armed `AnimationData` id and its repeat mode.
fn armed(app: &App, go: Entity) -> Option<(u16, RepeatAnimation)> {
let e = app.world().entity(go);
let node = e.get::<AnimationTransitions>()?.get_main_animation()?;
let id = e
.get::<ModelAnimations>()?
.clips
.iter()
.find(|c| c.node == node)?
.anim_id;
Some((
id,
e.get::<AnimationPlayer>()?.animation(node)?.repeat_mode(),
))
}
/// **The report** (decision 1151): loot the crate, close the loot window, and the lid must
/// settle SHUT — not spring open and slam once a window, for ever.
///
/// The whole door family is `flags` bit 0 clear, so a driver that arms a transition by the
/// clip's loop bit arms Close on `Forever`: the lid jumps back to 75° every 667 ms. What ends
/// a transition in the reference is the object layer's §2d advance (slot 14 `0x5f4120`:
/// substate 4 Close → 1 Closed), driven by the completion callback at the arm's baked window
/// — the loop bit ignored. This runs the whole click-to-settle cycle through the real systems
/// and real clips, and then keeps running: two seconds past the swing, three Close windows
/// wide, the crate must still be holding Closed.
#[test]
fn the_looted_crate_settles_shut_instead_of_flapping_for_ever() {
let (mut app, go) = crate_app(&[]);
let state = |app: &mut App, s: u32| {
app.world_mut()
.entity_mut(go)
.get_mut::<GoAnim>()
.unwrap()
.state = Some(s);
};
// Streamed in closed: the rest pose, snapped, and it keeps the loop the model authored —
// nothing advances off a held pose (`0x5f4167`), so this leg must NOT be narrowed to one
// window along with the motions.
app.update();
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Forever)));
// The open-lock cast lands: the lid swings open, ONE window.
state(&mut app, GO_STATE_ACTIVE);
app.update();
assert_eq!(
armed(&app, go),
Some((0x94, RepeatAnimation::Never)),
"the Open motion is a transient substate — one window, whatever its loop bit says"
);
// …and at its window end the machine advances 2 Open → 3 Opened on its own.
advance(&mut app, 700);
app.update();
assert_eq!(
armed(&app, go),
Some((0x95, RepeatAnimation::Forever)),
"the completion advance settles the swing onto the Opened rest pose"
);
// The loot window closes (`CMSG_LOOT_RELEASE`): the lid swings shut, ONE window.
state(&mut app, GO_STATE_READY);
app.update();
assert_eq!(armed(&app, go), Some((0x92, RepeatAnimation::Never)));
// 4 Close → 1 Closed, and then it STAYS there. Pre-1151 the Close clip was armed
// `Forever`, so this is the assertion the director's report failed at.
advance(&mut app, 700);
app.update();
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
"the lid must settle on Closed"
);
for _ in 0..20 {
advance(&mut app, 100);
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
"two seconds on — three Close windows — the crate is still shut"
);
}
}
/// The Custom channel shares the ONE transient slot with the motions (the reference's
/// `[handler+0x10]`), so 1100's bobber law has to keep holding through it: a Custom0 arms over
/// the state pose, runs exactly one window, and the completion re-runs the machine back onto
/// the state's own pose.
#[test]
fn a_custom_block_still_runs_one_window_and_hands_back_to_the_state() {
let (mut app, go) = crate_app(&[(153, 0.667)]); // Custom0 — the crate authors one
app.update();
assert_eq!(armed(&app, go), Some((0x93, RepeatAnimation::Forever)));
app.world_mut()
.entity_mut(go)
.get_mut::<GoAnim>()
.unwrap()
.custom = Some(153);
app.update();
assert_eq!(armed(&app, go), Some((153, RepeatAnimation::Never)));
advance(&mut app, 700);
app.update();
assert_eq!(
armed(&app, go),
Some((0x93, RepeatAnimation::Forever)),
"one Custom window, then the state pose — never a churning loop"
);
}
/// A `ModelAnimations` that owns exactly `ids` — only the lookup table matters to the remap.
fn owning(ids: &[u16]) -> ModelAnimations {
let hi = ids.iter().copied().max().unwrap_or(0) as usize;
+1
View File
@@ -261,6 +261,7 @@ pub(crate) fn update_ground_fx_decals(
anchor: decal.center,
bias: GROUND_FX_DEPTH_BIAS,
raster_bias: GROUND_FX_DEPTH_BIAS as i32,
cam_relative: false,
main_entity: entity,
light: None,
},
+38 -5
View File
@@ -69,6 +69,19 @@ pub(super) struct InspectStores<'w, 's> {
/// The ask-once GO template cache — the readable head a TEXT object's line reports
/// (decision 1105).
go_templates: Res<'w, crate::go_templates::GameObjectTemplates>,
/// The GameObject **animation** readout (decision 1151) — what the §243 arm is playing right
/// now, for the card's `anim` line. Its own query rather than a `collision` member because it
/// needs the model components: they sit on the same entity as [`crate::go_anim::GoAnim`], but
/// a GO whose model authors no skeleton renders as a static mesh and has none of them.
go_anims: Query<
'w,
's,
(
&'static crate::go_anim::GoAnim,
&'static AnimationPlayer,
&'static benilla_assets::ModelAnimations,
),
>,
}
/// The inspector overlay, drawn only while armed: a weak top-centre "armed" pill (so it's obvious the
@@ -143,11 +156,12 @@ pub(super) fn inspect_ui(
.map(|c| c.parent());
let (factions, self_store) = (stores.factions.as_deref(), stores.self_store.single().ok());
let go_templates = &*stores.go_templates;
let (stores, kinds, collision, lit) = (
let (stores, kinds, collision, lit, go_anims) = (
&stores.stores,
&stores.kinds,
&stores.collision,
&stores.lit,
&stores.go_anims,
);
let store = net_entity.and_then(|p| stores.get(p).ok());
// The unit's server name through the query cache — asks on first hover, fills on a later frame
@@ -294,13 +308,15 @@ pub(super) fn inspect_ui(
None => format!("casting {}", c.spell_id),
}
});
// An `AnimationData` id as the card names it — shared by the creature and GameObject anim
// lines below, which read the same id space.
let fmt = |id: u16| match anim_data.as_ref().and_then(|a| a.0.name(id)) {
Some(name) => format!("{name}({id})"),
None => format!("{id}"),
};
// The animation slots this frame (requested `AnimationData` ids — the selector's choice,
// before missing-clip substitution): the full-body base + any masked upper-body overlay.
let anim_line = net_entity.and_then(|p| drivers.get(p).ok()).map(|d| {
let fmt = |id: u16| match anim_data.as_ref().and_then(|a| a.0.name(id)) {
Some(name) => format!("{name}({id})"),
None => format!("{id}"),
};
let (base, overlay) = d.playing();
let base = base.map(&fmt).unwrap_or_else(|| "—".into());
// The base slot's live playback rate (decision 0903) — `speed / (moveSpeed · modelScale)`
@@ -318,6 +334,23 @@ pub(super) fn inspect_ui(
None => format!("anim {base}{rate}"),
}
});
// The same line for a **GameObject** (decision 1151), which is driven by `GoAnim` rather than
// `AnimDriver` and so never reached the branch above: the sequence the §243 arm is playing,
// whether it is a transient one (a transition motion / a Custom block — something the §2d
// completion advance must end) or the state's held rest pose, and the repeat it is running
// under. A `transition · loops` reading is the "stuck open/closing" bug, stated.
let anim_line = anim_line.or_else(|| {
let (id, transient, repeat) = net_entity
.and_then(|p| go_anims.get(p).ok())
.and_then(|(go, player, anims)| crate::go_anim::armed_anim(go, player, anims))?;
let kind = if transient { "transition" } else { "rest" };
let repeat = match repeat {
bevy::animation::RepeatAnimation::Forever => " · loops".to_string(),
bevy::animation::RepeatAnimation::Never => String::new(),
bevy::animation::RepeatAnimation::Count(n) => format!(" · ×{n}"),
};
Some(format!("anim {} · {kind}{repeat}", fmt(id)))
});
// The light lane + the attach that found it (decision 0776). Absent until the classifier has
// resolved the anchor once — a freshly streamed object shows no line rather than a wrong one.
let light_line = net_entity
+14 -1
View File
@@ -101,6 +101,7 @@ mod sky_order;
mod smart_rect;
mod sound;
mod sun;
mod surface;
mod target;
mod terrain;
mod terrain_stream;
@@ -375,7 +376,19 @@ pub fn run(build: BuildId) -> AppExit {
let (w, h) = v.split_once('x')?;
Some(UVec2::new(w.parse().ok()?, h.parse().ok()?))
})
.unwrap_or(UVec2::new(1600, 900))
// A run that reads no pixels gets a SMALL window. It is held
// `AlwaysOnTop` for its whole life so it can never be occluded into
// the ~1 fps throttle (`capture::ProbeFocusPlugin`, decision 0906) —
// at the full default that meant every agent probe planted a
// screen-filling window over the director's work. Small + cornered
// (`ProbeFocusPlugin` parks it) is un-occludable AND out of the way;
// anything photographing pixels keeps the full size, and `WOW_WIN`
// overrides either way (decision 1148).
.unwrap_or(if bgwin::no_pixel_run() {
UVec2::new(640, 360)
} else {
UVec2::new(1600, 900)
})
.into()
},
// `$WOW_NOVSYNC=1`: uncap presentation so a headless FPS-journal run measures
@@ -236,6 +236,7 @@ mod tests {
lights: Vec::new(),
group_bounds: Vec::new(),
group_footprints: Vec::new(),
material_ground_type: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
@@ -322,6 +323,7 @@ mod tests {
lights: Vec::new(),
group_bounds: Vec::new(),
group_footprints: Vec::new(),
material_ground_type: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
+92 -2
View File
@@ -15,7 +15,7 @@
//! covers in one frame. Coordinates cross from raw WoW into Bevy space here (`wow_to_bevy`).
use std::collections::{HashMap, VecDeque};
use std::time::Instant;
use std::time::{Duration, Instant};
use benilla_protocol::{
messages::WhoRequest, EntityKind, JumpInfo, MoveMode, MoveSpeeds, ObjectFields, SessionEvent,
@@ -78,6 +78,7 @@ impl Plugin for NetPlugin {
.init_resource::<NetStatus>()
.init_resource::<DroppedOpcodes>()
.init_resource::<ServerTime>()
.init_resource::<ServerWallClock>()
.init_resource::<Reputations>()
.init_resource::<HomeBind>()
.init_resource::<Proficiencies>()
@@ -125,7 +126,9 @@ impl Plugin for NetPlugin {
)
.chain()
.in_set(WorldStage::Net),
);
)
// Not part of the movement chain above: one send on the world-enter message.
.add_systems(Update, send_query_time.in_set(WorldStage::Net));
}
}
@@ -347,6 +350,88 @@ pub(crate) struct DropTally {
#[derive(Resource, Default)]
pub(crate) struct ServerTime(pub(crate) Option<GameTime>);
/// The server's **wall clock** (`SMSG_QUERY_TIME_RESPONSE`, asked for on entering the world),
/// advanced monotonically from the sample. `None` until the first answer lands.
///
/// Not [`ServerTime`] just above: that is the *in-game* day/night clock the lighting reads, a
/// different quantity in a different unit which says nothing about the epoch here. This one is
/// unix-epoch seconds, and it exists because the server writes **absolute** stamps in that epoch
/// into descriptor fields — a timed quest's deadline is `time(nullptr) + limitTime` (vmangos
/// `Player::AddQuest`) and no packet ever restates it as a duration. So a countdown is
/// `deadline − now_unix()`, and the local machine's own wall clock is deliberately never consulted:
/// a player whose clock is a minute off would see every countdown a minute wrong, silently and only
/// on their screen (decision 1150).
#[derive(Resource, Default)]
pub(crate) struct ServerWallClock(pub(crate) Option<WallClockSample>);
/// One `SMSG_QUERY_TIME_RESPONSE` sample plus the monotonic instant it arrived — the [`GameTime`]
/// pattern, for the same reason: the clock advances between packets without re-reading a local wall
/// clock that an NTP step or a manual change can move under us.
#[derive(Debug, Clone, Copy)]
pub(crate) struct WallClockSample {
/// The server's `time(nullptr)` at `received`.
base_unix: u32,
/// When the answer arrived (monotonic).
received: Instant,
}
impl ServerWallClock {
/// Take a fresh sample (the `SMSG_QUERY_TIME_RESPONSE` handler).
///
/// The reply is one round trip old, so the clock reads ~RTT/2 behind the server's; against a
/// countdown displayed to the second that is below the noise floor, which is why there is no
/// correction term here.
pub(crate) fn sample(&mut self, unix_time: u32) {
self.0 = Some(WallClockSample {
base_unix: unix_time,
received: Instant::now(),
});
}
/// Server unix seconds now (fractional), or `None` before the first sample.
pub(crate) fn now_unix(&self) -> Option<f64> {
self.0
.map(|s| f64::from(s.base_unix) + s.received.elapsed().as_secs_f64())
}
/// Whether the sample is old enough to re-ask (or absent entirely).
fn stale(&self) -> bool {
self.0.is_none_or(|s| s.received.elapsed() >= RESYNC_AFTER)
}
}
/// How long a wall-clock sample stands before the client asks again — the reference's own hour
/// (wow-re: the resync site `0x4de836` is gated on `now > [0xbb749c]`, armed `= now + 0xe10` at
/// `0x4def11`; decision 1154).
const RESYNC_AFTER: Duration = Duration::from_secs(3600);
/// Ask for the server's wall clock (decision 1150) — on entering the world (login, worldport and
/// instance transfer alike, the same cascade the mail arc's `QueryNextMailTime` hangs off), and
/// hourly thereafter.
///
/// The hourly leg is the reference's, and it is not about our own drift: a monotonic base does not
/// wander against a wall clock at any rate a countdown could show. What it tracks is the **server**
/// being re-clocked under us — the one direction no local reasoning can see — for the price of four
/// bytes an hour (decision 1154). It also self-heals a session that entered the world before the
/// socket could answer.
fn send_query_time(
mut entered: MessageReader<EnteredWorldMessage>,
commands: Res<NetCommands>,
clock: Res<ServerWallClock>,
status: Res<NetStatus>,
mut asked_at: Local<Option<Instant>>,
) {
let entering = entered.read().next().is_some();
// Only chase the cadence while a session exists — a command with no live writer evaporates
// with a warn, and the world-enter send covers the reconnect.
let due =
status.connected && clock.stale() && asked_at.is_none_or(|t| t.elapsed() >= RESYNC_AFTER);
if entering || due {
*asked_at = Some(Instant::now());
let _ = commands.0.send(ClientCommand::QueryTime);
}
}
/// Our player's reputation store (`SMSG_INITIALIZE_FACTIONS`, once at login): `(flags, standing)`
/// per reputation-list slot, indexed by `Faction.dbc`'s `reputationIndex`. The standing excludes the
/// DBC race/class base. Read by the reaction decode (targeting): a reputation faction's NPCs colour
@@ -969,6 +1054,11 @@ pub(crate) enum ClientCommand {
/// login to seed `HasNewMail()`/the minimap letter icon (decision 0544 P3, sent by
/// `crate::ui_mail`'s world-enter one-shot).
QueryNextMailTime,
/// Ask for the server's wall clock (`CMSG_QUERY_TIME`, empty body) — sent on every world
/// entry, answered by `SMSG_QUERY_TIME_RESPONSE` into [`ServerWallClock`]. That clock is the
/// only way to read the absolute deadlines the server writes into descriptor fields, which is
/// how the timed-quest countdown gets its number (decision 1150).
QueryTime,
/// Ask to inspect a player (`CMSG_INSPECT`, `u64 target`) — the UnitPopup INSPECT row
/// (decision 0631). Fire-and-forget: the reply echoes the guid and nothing else, and the window
/// paints from the already-streamed PUBLIC `PLAYER_VISIBLE_ITEM_*` fields. Sent anyway because
+10 -3
View File
@@ -12,7 +12,7 @@ use super::{
AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage,
Guid, GuidIndex, LoggedOutMessage, NetCommands, NetEvents, NetStatus, ObjectStore,
PendingTransfer, RemoteMotion, Reputations, SelfGuid, SelfPlayer, ServerSoundMessage,
ServerTime, TeleportMessage, WeatherMessage, WorldportMessage,
ServerTime, ServerWallClock, TeleportMessage, WeatherMessage, WorldportMessage,
};
mod anim;
@@ -94,7 +94,11 @@ pub(super) fn apply_net_updates(
mut index: ResMut<GuidIndex>,
mut self_guid: ResMut<SelfGuid>,
mut status: ResMut<NetStatus>,
mut server_time: ResMut<ServerTime>,
// The two server clocks, paired in one param (the 16-SystemParam ceiling this signature
// already lives against): the in-game day/night clock the lighting reads, and the wall clock
// the absolute descriptor stamps are dated in (decision 1150). Different quantities, same
// handler file.
mut clocks: (ResMut<ServerTime>, ResMut<ServerWallClock>),
mut reputations: ResMut<Reputations>,
mut transforms: Query<&mut Transform>,
mut stores: Query<&mut ObjectStore>,
@@ -588,7 +592,10 @@ pub(super) fn apply_net_updates(
minutes,
day_serial,
timescale,
} => session::time_speed(hours, minutes, day_serial, timescale, &mut server_time),
} => session::time_speed(hours, minutes, day_serial, timescale, &mut clocks.0),
SessionEvent::ServerUnixTime { unix_time } => {
session::server_unix_time(unix_time, &mut clocks.1)
}
SessionEvent::Reputations { standings } => {
session::reputations(standings, &mut reputations)
}
+22 -1
View File
@@ -23,7 +23,7 @@ use super::super::{
CharActionResultMessage, CharListMessage, ClientCommand, DisconnectedMessage, DroppedOpcodes,
EnteredWorldMessage, GameTime, GuidIndex, LoggedOutMessage, LoginFailedMessage,
LoginStageMessage, NetCommands, NetStatus, PendingTransfer, PingShared, Reputations, SelfGuid,
ServerTime, TeleportMessage, WorldportMessage,
ServerTime, ServerWallClock, TeleportMessage, WorldportMessage,
};
/// The pre-logon handshake reached a new stage (decision 0539) — the login screen's dialog reads it.
@@ -335,6 +335,27 @@ pub(super) fn time_speed(
server_time.0 = Some(GameTime::new(hours, minutes, day_serial, timescale));
}
/// The server **wall** clock (`SMSG_QUERY_TIME_RESPONSE`, answering the world-enter
/// `CMSG_QUERY_TIME`) — the epoch the absolute descriptor stamps are dated in, and so the origin of
/// every countdown drawn from one (today: the timed-quest timer, decision 1150). Nothing to do with
/// [`time_speed`] above, which is the in-game day/night clock.
///
/// Logged once per session at info, with the skew against our own clock: that number is exactly
/// what a "the countdown is wrong by a constant" report would be about, and it costs one line.
pub(super) fn server_unix_time(unix_time: u32, clock: &mut ServerWallClock) {
if clock.0.is_none() {
let local = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
info!(
"net: server wall clock {unix_time} (local clock differs by {}s)",
local - i64::from(unix_time)
);
}
clock.sample(unix_time);
}
/// The login reputation store (`SMSG_INITIALIZE_FACTIONS`).
pub(super) fn reputations(standings: Vec<(u8, i32)>, reputations: &mut Reputations) {
info!("net: reputation store ({} slots)", standings.len());
+1
View File
@@ -835,6 +835,7 @@ fn writer_loop(
w.item_text_query(text_id, mail_id)
}
ClientCommand::QueryNextMailTime => w.query_next_mail_time(),
ClientCommand::QueryTime => w.query_time(),
// The inspect request (decision 0631) — no reply is awaited; see the writer.
ClientCommand::Inspect { target } => w.inspect(target),
// The player-trade arc (decision 0592) — the CMSG verbs onto the P0 writers.
+30 -1
View File
@@ -22,6 +22,11 @@
//! rungs (owner-last 0719/0721, the decal biases, foam's water tie-break) moved from material
//! `depth_bias` into the item key (`sky_order`'s sign law); the rasterizer half of the old
//! material `depth_bias` lives on as [`EffectDraw::raster_bias`] (the coplanar decals need it).
//!
//! The rebase is a *late* subtraction, which is fine until a producer's geometry is smaller than
//! an f32 ULP at the position it writes — the rounding has already happened by then. The snow
//! flake reached that (millimetre quads at ~5600-yd coordinates), so a draw may declare
//! [`EffectDrawSpec::cam_relative`] and do the subtraction itself.
use std::ops::Range;
@@ -32,7 +37,9 @@ use bevy::render::render_resource::Buffer;
/// One vertex of the shared lane. **World-space** position in the stream; the render-world
/// prepare pass rebases it camera-relative before upload (0733 §2), so instruments reading the
/// stream (depth probe, depth dump) always see world coordinates.
/// stream (depth probe, depth dump) always see world coordinates — with one declared exception,
/// [`EffectDrawSpec::cam_relative`], whose producer has already done the subtraction because its
/// geometry is too small to survive being written in absolute-world f32 at all.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
pub struct EffectVertex {
@@ -207,6 +214,8 @@ pub struct EffectDraw {
/// the draw's verts skip the cam-relative rebase and run the world-mesh `clip_from_world`,
/// so the depth tie the bias settles is against the same arithmetic.
pub raster_bias: i32,
/// Are this draw's vertices **already camera-relative**? See [`EffectDrawSpec::cam_relative`].
pub cam_relative: bool,
/// Vertex range in [`EffectQuads::verts`] (a multiple of 4 for quads, 3 for tris).
pub range: Range<u32>,
/// The producing entity — the phase probe's identity for this item (`item.entity.1`, so a
@@ -275,6 +284,24 @@ pub struct EffectDrawSpec {
pub anchor: Vec3,
pub bias: f32,
pub raster_bias: i32,
/// Set when the producer has **already** written camera-relative vertices, so
/// [`super::render`]'s rebase must skip this draw. The default (`false`) is the lane's
/// contract: producers write ABSOLUTE world positions and the rebase subtracts the camera
/// on the upload copy.
///
/// It exists for **precision**, and precisely one family needs it. `EffectVertex::pos` is
/// f32, WoW world coordinates run to ±17066 yd, and the rebase happens *after* the producer's
/// rounding — so a vertex offset smaller than an ULP at the writing position is simply lost.
/// Every other family's geometry is centimetre-scale or bigger and never notices. The snow
/// flake is the lane's first **sub-centimetre** geometry: reproducing the reference's point
/// sprite (14 px at the eye) puts a near flake's half-extent at ~1.6 mm, which at Kharanos's
/// ~5600-yd coordinates is **3 ULPs** and at a map corner **0.8** — measured: a 14 px sprite
/// 0.3 yd from the eye loses 2.4 px of width there and 6+ px at the map edge, i.e. visible
/// size flicker and dropouts on exactly the flakes the eye is drawn to. Writing the offsets
/// camera-relative keeps every term small and the arithmetic exact.
///
/// The sort [`Self::anchor`] stays absolute either way — it is not a vertex.
pub cam_relative: bool,
pub main_entity: Entity,
pub light: Option<Buffer>,
}
@@ -321,6 +348,7 @@ impl EffectQuads {
anchor: spec.anchor,
bias: spec.bias,
raster_bias: spec.raster_bias,
cam_relative: spec.cam_relative,
range: start..end,
main_entity: spec.main_entity,
light: spec.light,
@@ -434,6 +462,7 @@ mod tests {
anchor: Vec3::ZERO,
bias: 0.0,
raster_bias: 0,
cam_relative: false,
main_entity: Entity::PLACEHOLDER,
light: None,
},
@@ -84,6 +84,9 @@ struct ExtractedDraw {
anchor: Vec3,
bias: f32,
raster_bias: i32,
/// Vertices are already camera-relative — the prepare rebase skips this draw. See
/// [`super::buffer::EffectDrawSpec::cam_relative`].
cam_relative: bool,
/// Vertex range in the shared stream.
range: Range<u32>,
/// A booth's scene-light override (0539 §5); `None` = the world's shared light buffer.
@@ -355,6 +358,7 @@ fn extract_effects(
anchor: d.anchor,
bias: d.bias,
raster_bias: d.raster_bias,
cam_relative: d.cam_relative,
range: d.range.clone(),
light: d.light.clone(),
}));
@@ -458,6 +462,12 @@ fn prepare_effects(
if draw.raster_bias != 0 {
continue;
}
// …and so does a draw whose producer already wrote camera-relative vertices: subtracting
// again would shift it a whole camera position off. That flag exists for f32 precision on
// sub-centimetre geometry — see [`super::buffer::EffectDrawSpec::cam_relative`].
if draw.cam_relative {
continue;
}
let Some(cam) = cams.get(&MainEntity::from(draw.cam)) else {
continue;
};
+2
View File
@@ -1106,6 +1106,7 @@ pub(super) fn simulate_particles(
anchor,
bias,
raster_bias: 0,
cam_relative: false,
main_entity: entity,
light: light_override.map(|l| l.0.clone()),
},
@@ -1140,6 +1141,7 @@ pub(super) fn simulate_particles(
anchor,
bias,
raster_bias: 0,
cam_relative: false,
main_entity: entity,
light: light_override.map(|l| l.0.clone()),
},
+61
View File
@@ -792,6 +792,67 @@ pub(crate) fn process_cpu_secs() -> Option<f64> {
}
}
/// Cumulative **machine-wide** CPU ticks as `(busy, total)` — every core, every process, us
/// included (`host_statistics64(HOST_CPU_LOAD_INFO)`). Diff two samples and `busy/total` is the
/// fraction of all cores that were doing work between them.
///
/// Why the probes need it, when [`process_cpu_secs`] above already promises load-independence:
/// that promise is **weaker than it reads**, and 1157 caught it. `cpu_ms` is per-frame work rather
/// than wall time, so it shrugs off the scheduling delay that makes frame time useless under load
/// — but it is not immune. The same LBRS pin leg, same binary, same day, read **25.93** while two
/// other slots ran `cargo test --workspace` (load 20-32 on 14 cores) and **18.80-20.12** on a quiet
/// machine: a ~35 % inflation, far outside the ±0.4 band the campaign reasons in (0736). Cache and
/// memory-bandwidth contention is work we genuinely do; `getrusage` counts it and cannot tell us
/// it was someone else's fault.
///
/// So the leg has to say how loaded the machine was, and the reader compares. Deliberately a
/// **stamp, not a gate** (the director's call): no threshold is invented here. Two legs at similar
/// `sys_busy_pct` are comparable; two legs far apart are not, whatever their `cpu_ms` says. That
/// is a relative rule, which is the only kind the four calibration points behind 1157 support.
///
/// **Do not reach for `uptime` instead.** Load average is a 1-minute *decaying* mean and lags
/// badly: a leg stamped 99 % here was taken at load 5.55, seconds after a build burst the average
/// had not caught up with. Cross-checked against an independent out-of-process sampler over the
/// same window — 44 % vs 44.5 % — so where the two disagree, this is the one that is right.
///
/// Non-macOS returns `None`: the probes print the field only where the platform answers.
///
/// The `deprecated` allow is `libc::mach_host_self`, whose deprecation note says "use the `mach2`
/// crate instead". Checked, and it does not apply here: `mach2` 0.5.0 carries `mach_host_self` and
/// **nothing else this call needs** — no `host_statistics64`, no `host_cpu_load_info`, no
/// `HOST_CPU_LOAD_INFO`. Taking the advice would split one call across two crates and add a direct
/// dependency to get the *port* while `libc` still supplies the call it is passed to. Revisit if
/// `mach2` ever grows the host-statistics surface.
#[allow(deprecated)]
pub(crate) fn system_cpu_ticks() -> Option<(u64, u64)> {
#[cfg(target_os = "macos")]
{
// SAFETY: `host_statistics64` fills at most `count` u32s into the out-param and reads
// nothing from it; `host_cpu_load_info` is plain integers, so zeroed is a valid starting
// value. `mach_host_self` returns the global host port — no send right to deallocate.
unsafe {
let mut info: libc::host_cpu_load_info = std::mem::zeroed();
let mut count = libc::HOST_CPU_LOAD_INFO_COUNT;
if libc::host_statistics64(
libc::mach_host_self(),
libc::HOST_CPU_LOAD_INFO,
(&raw mut info).cast(),
&mut count,
) != libc::KERN_SUCCESS
{
return None;
}
let total: u64 = info.cpu_ticks.iter().map(|&x| u64::from(x)).sum();
let idle = u64::from(info.cpu_ticks[libc::CPU_STATE_IDLE as usize]);
Some((total - idle, total))
}
}
#[cfg(not(target_os = "macos"))]
{
None
}
}
/// The stuck-main-thread self-sampler (`WOW_STALL_SAMPLE=0` to disable; macOS only): a watchdog
/// thread notices the main-thread heartbeat has gone stale and shells the stock profiler
/// (`/usr/bin/sample`) at our own PID, so an intermittent stall or teardown hang **diagnoses
+1
View File
@@ -601,6 +601,7 @@ fn warm_effect_lane(
anchor: Vec3::ZERO,
bias: 0.0,
raster_bias,
cam_relative: false,
main_entity: cam,
light: None,
},
+23
View File
@@ -686,6 +686,29 @@ impl Player {
self.move_flags
}
/// The **commanded planar speed** in yd/s — the reference's `[[player+0x118]+0x84]`, the
/// CMovement speed scalar its producer `0x7c4c90` returns:
///
/// ```text
/// mov edx,[ecx+0x40] ; movement flags
/// test dl,0xf ; the four DIRECTION bits
/// jne … ; moving → the speed
/// fld dword ptr [0x7ffd74] ; = 0.0
/// ```
///
/// So it is **exactly zero with no direction key held** — not a measured velocity that decays,
/// and not the weather wind's 149 ms positional average (`0x67c150`): it is live, and tracks a
/// speed change on the same frame. The precipitation spawn slab's tilt keys on it through
/// `mgr+0x7c` (wow-re `wx-snow-placement-law.md` §9), which is why the distinction earns an
/// accessor: an averaged stand-in would leave the slab leaning for 150 ms after a stop, and a
/// raw `horiz_vel` would keep it leaning through an entire fall on take-off momentum.
pub(crate) fn planar_speed(&self) -> f32 {
if self.move_flags & 0xf == 0 {
return 0.0;
}
self.horiz_vel.with_y(0.0).length()
}
/// The transport we're standing on (its guid), if any — the platform-frame attachment
/// (decision 0438 phase 2). For instruments (the crossing probe watches the ride survive
/// the map seam).
+1
View File
@@ -503,6 +503,7 @@ pub(crate) fn simulate_ribbons(
0.0
},
raster_bias: 0,
cam_relative: false,
main_entity: entity,
light: None, // trails never carry a light override (world lane only)
},
+24 -7
View File
@@ -1,7 +1,13 @@
//! Footsteps (decision 0070 slice 3): the `$FSD` anim tag resolved through the terrain-type
//! chain — `ground effect under the unit → TerrainType → × the unit's footstep class →
//! chain — `surface under the unit → TerrainType → × the unit's footstep class →
//! FootstepTerrainLookup → SoundEntries`.
//!
//! **The surface is [`crate::surface`]'s to answer, not this module's** (decision 1161). It is the
//! client's two-leg down-ray: a building that owns the column supplies its own floor's material,
//! and only outdoors does the ADT ground-effect layer decide. Reading the ADT unconditionally is
//! what put a snow crunch under the Kharanos inn's floorboards. `None` there is the reference's
//! `−1` and means silent — never "ask the other leg".
//!
//! **Trigger: `$FSD` and nothing else** (decision 1080). A footfall is two disjoint channels in
//! the client's event dispatcher `0x5ffbd0` — `$FSD → 0x623390` is the *sound*, the per-foot side
//! tags (`$FL/$FR/$RL/$RR/$SL/$SR/$BL/$BR/$WL/$WR`) `→ 0x5fbf70` are the *visual* footfall (the
@@ -33,7 +39,6 @@
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use benilla_assets::AdtTile;
use benilla_formats::FootstepCatalog;
use crate::assets::{AssetSet, LockRecover, WorldAssets};
@@ -43,7 +48,6 @@ use crate::liquid::{unit_claim, water_surface_at, WaterChunkInfo};
use crate::net::{NetEntity, ObjectStore};
use crate::player::swim_enter_depth;
use crate::schedule::WorldStage;
use crate::terrain_stream::{ground_effect_under, TerrainStreamer};
use crate::wmo_portal::UnitWmoRoom;
use super::creature::CreatureVoices;
@@ -90,8 +94,7 @@ fn footstep_sounds(
root_state: Query<(Option<&ObjectStore>, Option<&MovementState>)>,
footsteps: Option<Res<Footsteps>>,
voices: Option<Res<CreatureVoices>>,
streamer: Res<TerrainStreamer>,
adt_tiles: Res<Assets<AdtTile>>,
surface: crate::surface::SurfaceUnderfoot,
water: Query<&WaterChunkInfo>,
placements: crate::liquid::RoomPlacements,
kits: Option<ResMut<SoundKits>>,
@@ -147,8 +150,14 @@ fn footstep_sounds(
if depth.is_some_and(|d| d > wade_max) {
continue;
}
let effect = ground_effect_under(&streamer, &adt_tiles, transform.translation());
let Some((dry, splash)) = footsteps.0.resolve(class, effect) else {
// What is under the foot — the WMO leg when a building owns this column, the ADT ground
// effect otherwise ([`crate::surface`]). `None` is the client's −1: silent, and never a
// reason to fall back to the ground beneath a floor.
let Some(terrain) = surface.terrain_type(&footsteps.0, room, transform.translation())
else {
continue;
};
let Some((dry, splash)) = footsteps.0.resolve_terrain(class, terrain) else {
continue; // no row for this class/terrain: silent (ethereal classes)
};
let kit = match depth {
@@ -158,6 +167,14 @@ fn footstep_sounds(
if kit == 0 {
continue;
}
// Which leg answered, and what it said. The wrong-surface family is invisible in the kit
// name alone once two legs can produce one (a `*Dirt` step is right indoors and wrong on a
// dirt road ten yards away), so the next report should not need a DBC dump to triage.
debug!(
"footstep: {} terrain {terrain} class {class} kit {kit}",
room.and_then(UnitWmoRoom::room)
.map_or_else(|| "adt".to_string(), |r| format!("wmo g{}", r.group))
);
if let Err(e) = play_kit(
&mut kits,
&assets,
+15 -4
View File
@@ -160,12 +160,13 @@ pub(super) fn play_kit_ext(
}
.ok_or_else(|| anyhow!("unknown sound kit"))?;
let (id, volume, flags, min_dist, cutoff) = (
let (id, volume, flags, min_dist, cutoff, eax_def) = (
kit.id,
kit.volume,
kit.flags,
kit.min_distance,
kit.distance_cutoff,
kit.eax_def,
);
// Selection-time audibility (0x45cdf0): positional kits out of cutoff never allocate —
// checked first, before the weight pool, so a per-frame retry driver (the creature body-loop
@@ -231,14 +232,20 @@ pub(super) fn play_kit_ext(
let mixer = out.mixer.as_mut().context("no audio device")?;
let (track, handle) = match pos {
Some(p) => {
let (t, h) = mixer.play_3d(data, p)?;
// `EAXDef 0` = no `SoundSamplePreferences` row = the reference's NULL-slot skip at
// `0x45cdc0`/`0x7a5bf0`: the channel never gets reverb properties, so it stays dry
// however wet the zone is. That is what keeps NPC voice lines (all 275 `SoundType 17`
// rows are `EAXDef 0`) out of an interior's reverb — decision 1155, bug B236.
let (t, h) = mixer.play_3d(data, p, eax_def != 0)?;
(Some(t), h)
}
None => (None, mixer.play_2d(data)?),
};
// Every actual play, named. The one question the sound subsystem could not answer about itself
// was "what just made that noise?" — a report of an unexpected sound had no trace to read, only
// a guess at which trigger fired. `RUST_LOG=benilla_app::sound=debug` now answers it.
// a guess at which trigger fired. `RUST_LOG=benilla_app::sound=debug` now answers it, and since
// 1155 it also answers "…and does it take the interior's reverb?" — the `EAXDef` wet/dry class
// is invisible in the audio itself, so a report of an unexpected echo needs it in the trace.
debug!(
"sound: play kit {id} ({}) {} {}",
kits.catalog.get(id).map_or("?", |k| k.name.as_str()),
@@ -247,7 +254,11 @@ pub(super) fn play_kit_ext(
SoundCategory::Music => "music",
SoundCategory::Ambience => "ambience",
},
if pos.is_some() { "3d" } else { "2d" },
match pos {
Some(_) if eax_def != 0 => "3d wet",
Some(_) => "3d dry",
None => "2d",
},
);
out.channels.push(ActiveChannel {
kit: id,
+18 -6
View File
@@ -250,11 +250,27 @@ impl Mixer {
/// **disabled** — gain over distance is WoW's own math (decision 0070; the kit player's pump
/// computes `rolloff · near_field` and drives the channel volume). The backend contributes
/// pan only. The returned track handle must live as long as the sound (drop unloads it).
///
/// `reverb_send` is the kit's `SoundEntries.EAXDef` reduced to "does this sound take the wet
/// send at all" (decision 1155). 3D-open is **necessary but not sufficient** in the reference:
/// `0x458f1c` hands `EAXDef` to the slot lookup `0x45cdc0`, and because
/// `SoundSamplePreferences.dbc` holds only ids 1 and 2 — **there is no id 0** — an `EAXDef 0`
/// kit resolves a NULL slot and `FSOUND_Reverb_SetChannelProperties` (`0x7a5bf0`) skips before
/// it ever tests the 3D flag. Those channels are dry no matter what the zone preset says.
/// benilla models neither row's per-channel EAX properties, so the projection is the binary's
/// own branch and nothing more: populated slot ⇒ send at unity, NULL slot ⇒ no send.
pub(crate) fn play_3d(
&mut self,
data: StaticSoundData,
pos: Vec3,
reverb_send: bool,
) -> Result<(SpatialTrackHandle, StaticSoundHandle)> {
// The zone wet level lives on the send's own volume, so per-zone reverb stays one knob
// and never a per-track update; the per-KIT half is this route existing or not.
let mut builder = SpatialTrackBuilder::new().attenuation_function(None);
if reverb_send {
builder = builder.with_send(&self.reverb_send, Decibels(0.0));
}
let mut track = self
.manager
.add_spatial_sub_track(
@@ -264,10 +280,7 @@ impl Mixer {
y: pos.y,
z: pos.z,
},
// Route into the reverb send at unity: the zone wet level lives on the send's own
// volume, so per-zone reverb is one knob, not a per-track update.
SpatialTrackBuilder::new()
.attenuation_function(None)
builder
// Outlive the handle by exactly as long as the sound needs (decision 1026).
// kira defaults this to `false` (`track/sub/spatial_builder.rs`), which makes
// `should_be_removed()` fire the instant the handle drops — so a channel that
@@ -277,8 +290,7 @@ impl Mixer {
// set, the drop only *marks* the track and kira keeps it until its sounds
// finish. Cannot leak: every path that drops a channel stops its sound first,
// and a stopped sound finishes.
.persist_until_sounds_finish(true)
.with_send(&self.reverb_send, Decibels(0.0)),
.persist_until_sounds_finish(true),
)
.map_err(|e| anyhow::anyhow!("spatial track alloc: {e}"))?;
let handle = track
+23
View File
@@ -84,6 +84,28 @@ pub(crate) struct SoundConfig {
/// the stream alive at zero, so re-enabling resumes mid-track where the reference re-picks.
pub music_enabled: bool,
pub ambience_enabled: bool,
/// Zone reverb — 1.12's `SoundReverb` CVar (`0x4573be` registration, callback `0x4574d0`,
/// flag byte `[0x835a4c]`). The flag gates **both** EAX paths: the zone/environment preset
/// (`0x45a75b`: flag zero ⇒ `FSOUND_Reverb_SetProperties` is never called) and the
/// per-channel wet send (`0x458f13` ⇒ `FSOUND_Reverb_SetChannelProperties`). Read by
/// [`reverb::zone_reverb`].
///
/// **Registrar default is `"1"`; ours is `false`** — the one place benilla's CVar defaults
/// leave the binary's (decisions 1153, 1155). The reference *emits* both calls on a stock
/// boot — VERIFIED, and its three writers of `[0x835a4c]` all write 1 — but they are FMOD 3's
/// EAX API, and its own header says `ONLY SUPPORTED ON WIN32 W/ FSOUND_HW3D FLAG`. The
/// reference client's `Logs/Sound.log` on this machine reports
/// `Driver: 0 'Primary Sound Driver' 00000000` (caps 0 — no `HARDWARE`/`EAX2`/`EAX3`) and
/// `0 3D hardware` channels, and DirectSound lost hardware mixing in Vista. So "emitted, and
/// rendered as nothing" — **the render half is INFERRED**, not byte-verified (we cannot read
/// `fmod.dll` from `WoW.exe`); the live capture that would settle it is named in 1155.
/// benilla is the first 1.12 client to render this DSP in software, so `false` is what the
/// reference is heard to produce rather than what its registrar says. **This is not what
/// fixes B236** — that is the `EAXDef` dryness on [`Mixer::play_3d`], which holds whichever
/// way this flag sits. Turn it on with `/run SetCVar("SoundReverb", 1)` — applies live and
/// persists — to hear what the DBC data asks for. (The reference's route is
/// `/console SoundReverb 1`; benilla has no `ConsoleExec` yet.)
pub reverb: bool,
}
impl SoundConfig {
@@ -112,6 +134,7 @@ impl Default for SoundConfig {
ambience: 0.6,
music_enabled: true,
ambience_enabled: true,
reverb: false,
}
}
}
+19 -5
View File
@@ -8,10 +8,19 @@
//! projection, applied **instantly** on change (A2). Submerged ([`Underwater`]) the underwater
//! column wins — pref 11 on 568 areas; dry land uses the dry column (8 dungeon floors in 1.12).
//!
//! Only 3D world SFX carry the wet signal — the client's per-channel gate is set exclusively on
//! 3D-open (channel flag bit 27, A3, VERIFIED); 2D/UI/music/ambience are structurally dry.
//! `WMOAreaTable` carries the big interior payload (~4 000 group rows, CAVE/AUDITORIUM/ARENA);
//! it joins this chain when WMO interior containment exists — the preset side is already here.
//! Which sounds the wet signal reaches is **not** this module's business and is not "every 3D
//! sound": 3D-open (channel flag bit 27, A3) is necessary but not sufficient — the kit's
//! `SoundEntries.EAXDef` decides, and `0` means a NULL `SoundSamplePreferences` slot and a
//! permanently dry channel (decision 1155; the gate lives at [`Mixer::play_3d`]). 2D/UI/music/
//! ambience are structurally dry on top of that. `WMOAreaTable` carries the big interior payload
//! (~4 000 group rows, CAVE/AUDITORIUM/ARENA — 3 687 of them CAVE, which is what the Thunderbrew
//! Distillery's interior groups say).
//!
//! **The whole chain is gated on the `SoundReverb` CVar, and benilla defaults it OFF** —
//! [`SoundConfig::reverb`] carries the why and the confidence (decisions 1153, 1155): the
//! reference emits the calls but its EAX API has had no hardware to render on since Vista.
//! Everything below stays built and correct — this module resolves the same preset the binary
//! would — but nothing reaches the mixer until the CVar says so.
use bevy::prelude::*;
@@ -68,7 +77,12 @@ fn zone_reverb(
return;
};
let column = usize::from(underwater.0.is_water());
let pref = if config.enabled {
// `SoundReverb` off ⇒ no preset reaches the backend at all — the client's `0x45a75b` gate,
// which returns before the marshal rather than applying a silent one. Off is our default and
// the reference's audible truth (decision 1153): its EAX path needs hardware no machine has
// had since DirectSound lost hardware mixing in Vista. Flipping the CVar re-applies here,
// like the client's callback (`0x4574d0`).
let pref = if config.enabled && config.reverb {
interior
.0
.map(|i| i.sound_provider[column])
+82
View File
@@ -0,0 +1,82 @@
//! **What you are standing on** — the `TerrainType` id under a unit's feet, for every consumer of
//! it (decision 1161).
//!
//! The reference keeps this as one cached dword per unit, `CGUnit+0xc60`: an environment-node
//! down-ray (`0x6a8a20`) resolves it on movement, and four readers consume that single value — the
//! `$FSD` footstep sound (`0x62341d`), the footprint decal (`0x5fc06e`), the footstep spray
//! (`0x5fc20f`) and `0x623749`. They cannot disagree about the ground, and neither can ours: this
//! module is the one place the question is answered.
//!
//! **Two legs, arbitrated by nearest hit.** The client races a terrain probe (`0x69c320`) against a
//! WMO probe (`0x6a8840`) over the building's COLLISION faces; whichever surface is nearer under
//! the foot supplies the type. That arbitration is already ours — it is the same faces-only,
//! terrain-raced down-ray that publishes [`UnitWmoRoom`], so a room claim *is* "the building won
//! this column".
//!
//! - **WMO leg.** The claim names a group; [`surface_terrain_sample`] re-casts the column over that
//! group's RENDER faces and reads the hit face's `MOPY.material_id → MOMT+0x20` — a
//! `TerrainType.dbc` id carried by the surface itself, with no `GroundEffectTexture` hop.
//! - **ADT leg.** No claim: the MCNK dominant layer → `GroundEffectTexture` → `TerrainType`, the
//! chain that was already here.
//!
//! `None` is the client's `−1` sentinel and means **silent, and no print** — not "try the other
//! leg". A building that won the column but whose group has no render face under the probe is
//! silent; falling back to the ground beneath the floor is exactly the bug this module exists to
//! kill (B236's sequel: snow crunching inside the Kharanos inn, and snow prints on its floorboards,
//! because the ADT leg answered indoors).
//!
//! **Known gap — exterior groups.** Both the claim and the render-face set are built for INTERIOR
//! groups only, so a WMO surface authored on an *exterior* group (bridges and docks, `Wood`) still
//! falls to the ADT leg. Wrong in the same direction as before this module, never newly wrong; the
//! fix is widening the claim, not special-casing here.
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use benilla_assets::{AdtTile, WmoModel};
use benilla_formats::FootstepCatalog;
use crate::terrain_stream::{ground_effect_under, TerrainStreamer};
use crate::wmo_portal::{
surface_terrain_sample, UnitWmoRoom, WmoPortalInstance, POSITION_PROBE_LIFT,
};
/// The world state the surface question needs, as one `SystemParam` — the placements it rays, the
/// terrain it races them against. Threaded as a query rather than read into a resource for the
/// same reason `liquid::RoomPlacements` is: the answer must not outlive the placement.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct SurfaceUnderfoot<'w, 's> {
wmos: Res<'w, Assets<WmoModel>>,
instances: Query<'w, 's, &'static WmoPortalInstance>,
streamer: Res<'w, TerrainStreamer>,
adt_tiles: Res<'w, Assets<AdtTile>>,
}
impl SurfaceUnderfoot<'_, '_> {
/// The `TerrainType` id under `pos` (Bevy space) for a unit holding `room`, or `None` for the
/// client's `−1` — silent, no print. See the module docs for why `None` never retries.
pub(crate) fn terrain_type(
&self,
cat: &FootstepCatalog,
room: Option<&UnitWmoRoom>,
pos: Vec3,
) -> Option<u32> {
match room.and_then(UnitWmoRoom::room) {
Some(room) => {
let inst = self.instances.get(room.instance).ok()?;
let model = self.wmos.get(&inst.handle)?;
// The SAME origin lift the claim's own ray used: feet rest on the floor plane, and
// a coplanar `z <= probe.z` test must not lose the floor to a rounding hair. Using
// a different origin here could resolve a different face than the one that won.
let probe_world = pos + Vec3::Y * POSITION_PROBE_LIFT;
let local = inst
.world_from_local
.inverse()
.transform_point3(probe_world);
surface_terrain_sample(model, usize::from(room.group), bevy_to_wow(local))
}
None => ground_effect_under(&self.streamer, &self.adt_tiles, pos)
.and_then(|e| cat.terrain_of(e)),
}
}
}
+1
View File
@@ -210,6 +210,7 @@ pub(super) fn push_reticle(
anchor: state.key.center,
bias: RETICLE_DEPTH_BIAS,
raster_bias: RETICLE_DEPTH_BIAS as i32,
cam_relative: false,
main_entity: Entity::PLACEHOLDER,
light: None,
},
+1
View File
@@ -526,6 +526,7 @@ pub(super) fn push_ring(
anchor: state.key.feet,
bias: RING_DEPTH_BIAS,
raster_bias: RING_DEPTH_BIAS as i32,
cam_relative: false,
main_entity: Entity::PLACEHOLDER,
light: None,
},
+2 -2
View File
@@ -60,8 +60,8 @@ pub(crate) use spawn::{m2_fade, point_light, spawn_model_entities};
// The position queries + area authority (their home is `queries`; paths stay `terrain_stream::X`).
use queries::update_current_area;
pub(crate) use queries::{
doodad_ground_shade, ground_effect_under, terrain_height_under, AreaAuthoritySet, CurrentArea,
ShadeResolve,
area_id_under, doodad_ground_shade, ground_effect_under, terrain_height_under,
AreaAuthoritySet, CurrentArea, ShadeResolve,
};
/// Wall-clock spent per frame spawning streamed-in geometry (terrain tiles in [`stream_terrain`],
@@ -61,6 +61,25 @@ pub(crate) fn doodad_ground_shade(
}
}
/// The MCNK `areaId` under a **Bevy-space** position on the resident terrain — the OUTDOOR leg of
/// the client's `GetAreaID 0x670250`, for any unit rather than the player.
///
/// [`CurrentArea`] is the player's authority and races a WMO interior claim ahead of this
/// ([`update_current_area`]); there is no per-unit equivalent of that claim, so a caller asking
/// about a *remote* unit gets the terrain answer alone. `None` off-terrain / mid-stream, and an
/// `areaId` of 0 (unassigned in the data) reads as a miss, same as the player's resolver.
pub(crate) fn area_id_under(
streamer: &TerrainStreamer,
adt_tiles: &Assets<AdtTile>,
bevy_pos: Vec3,
) -> Option<u32> {
let wow = bevy_to_wow(bevy_pos);
let (tx, ty) = world_to_tile(wow[0], wow[1]);
let ts = streamer.tiles.get(&(tx as i32, ty as i32))?;
let adt = adt_tiles.get(&ts.handle)?;
benilla_formats::area_id_at(&adt.chunks, wow).filter(|&id| id != 0)
}
/// The `GroundEffectTexture` id under a **Bevy-space** position on the resident terrain — the
/// footstep terrain-type source (decision 0070 slice 3; the same global position→tile→chunk
/// lookup shape as [`doodad_ground_shade`]). `None` off-terrain / mid-stream / no-effect cell —
+3 -2
View File
@@ -16,6 +16,7 @@
//! absent key shows nothing (the reference's data-suppression face) and localization rides
//! for free.
use crate::ui_items::{count_of, InventoryScope};
use bevy::prelude::*;
use crate::net::ObjectStore;
@@ -214,7 +215,7 @@ pub(super) fn first_missing_totem(
.iter()
.copied()
.filter(|&t| t != 0)
.find(|&t| crate::ui_items::count_of(&store.0, items, t) == 0)
.find(|&t| count_of(&store.0, items, t, InventoryScope::CARRIED) == 0)
}
/// The first reagent slot whose owned count falls short — the `0x6e4000` reagent loop's failing
@@ -228,7 +229,7 @@ pub(super) fn first_short_reagent(
.iter()
.copied()
.filter(|&(id, _)| id != 0)
.find(|&(id, n)| crate::ui_items::count_of(&store.0, items, id) < n)
.find(|&(id, n)| count_of(&store.0, items, id, InventoryScope::CARRIED) < n)
.map(|(id, _)| id)
}
+3 -2
View File
@@ -22,6 +22,7 @@
//! shapeshift-form byte indexed into `SpellShapeshiftForm.dbc`'s BonusActionBar column, wow-re
//! byte-verified, firing `UPDATE_BONUS_ACTIONBAR` on change.
use crate::ui_items::{count_of, InventoryScope};
use std::collections::{HashMap, HashSet};
use bevy::prelude::*;
@@ -293,7 +294,7 @@ pub(super) fn feed_actions(
.and_then(|t| icons.as_ref()?.catalog.get(t.display_info_id)?.icon.clone())
.unwrap_or_else(|| MISSING_ITEM_ICON.to_string());
let count = store
.map(|s| crate::ui_items::count_of(&s.0, &items, button.action))
.map(|s| count_of(&s.0, &items, button.action, InventoryScope::CARRIED))
.unwrap_or(0);
(Some(texture), count)
}
@@ -357,7 +358,7 @@ pub(super) fn feed_actions(
for (&action, slot) in memory.pushed.iter_mut() {
let changed = match slot.kind {
ACTION_KIND_ITEM => {
let fresh = crate::ui_items::count_of(&store.0, &items, slot.action);
let fresh = count_of(&store.0, &items, slot.action, InventoryScope::CARRIED);
let changed = fresh != slot.count;
if changed {
slot.count = fresh;
+4 -1
View File
@@ -23,6 +23,7 @@
//! §2a fold-back: reagents, forms, stealth, aura states (the Execute-family target dependence),
//! the works.
use crate::ui_items::{count_of, InventoryScope};
use std::collections::HashMap;
use std::time::Instant;
@@ -460,7 +461,9 @@ pub(super) fn feed_action_state(
// put a stack number under a mount (Class 15) on the bar.
st.consumable = template.as_ref().is_some_and(|t| t.is_consumable());
let count = me
.map(|(s, _, _, _)| crate::ui_items::count_of(&s.0, &items, button.action))
.map(|(s, _, _, _)| {
count_of(&s.0, &items, button.action, InventoryScope::CARRIED)
})
.unwrap_or(0);
// Worn on any equipment slot (0..18) — the green border's IsEquippedAction.
st.equipped = me.is_some_and(|(s, _, _, _)| {
+3 -2
View File
@@ -19,6 +19,7 @@
//! durability model), and the ghost state beyond plain death. CanAssist inside 10b is the
//! reaction-rank stand-in the ring/`can_attack` share, pending the true `0x6066f0` walk.
use crate::ui_items::{count_of, InventoryScope};
use benilla_formats::{
SpellDisplay, ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED,
SPELL_EFFECT_TRADE_SKILL,
@@ -98,12 +99,12 @@ pub(crate) fn spell_usable(
}
// Leg 3 (`0x6e4000`): every reagent pair in bag counts; every totem tool present.
for &(entry, count) in &d.reagents {
if entry != 0 && crate::ui_items::count_of(&ctx.store.0, items, entry) < count {
if entry != 0 && count_of(&ctx.store.0, items, entry, InventoryScope::CARRIED) < count {
return (false, false);
}
}
for &totem in &d.totems {
if totem != 0 && crate::ui_items::count_of(&ctx.store.0, items, totem) == 0 {
if totem != 0 && count_of(&ctx.store.0, items, totem, InventoryScope::CARRIED) == 0 {
return (false, false);
}
}
+3 -3
View File
@@ -30,7 +30,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{cast_target, CastCommit, CastLadder, PlayerActions, Spells};
use crate::ui_items::{count_of, item_icon};
use crate::ui_items::{count_of, item_icon, InventoryScope};
use crate::ui_script::UiInput;
use crate::ui_spellbook::SkillLines;
use crate::ui_tradeskill::SpellFocus;
@@ -192,7 +192,7 @@ fn feed_craft(
let mut reagents = Vec::new();
let mut num_available = u32::MAX;
for &(entry, need) in d.reagents.iter().filter(|&&(e, n)| e != 0 && n != 0) {
let have = count_of(&store.0, &items, entry);
let have = count_of(&store.0, &items, entry, InventoryScope::CARRIED);
// A reagent is an ITEM row, so it terminates in the one genuinely shared chain
// (wow-re §5): ItemTemplate → ItemDisplayInfo → icon. Unlike the *recipe* icon
// above, there is nothing per-binding about this one.
@@ -217,7 +217,7 @@ fn feed_craft(
}
let mut tools = Vec::new();
for &t in d.totems.iter().filter(|&&t| t != 0) {
let have = count_of(&store.0, &items, t) > 0;
let have = count_of(&store.0, &items, t, InventoryScope::CARRIED) > 0;
if let Some(info) = items.template(t, 0, &commands) {
tools.push((info.name.clone(), have));
}
+365 -111
View File
@@ -274,47 +274,205 @@ pub(crate) fn item_icon(
.and_then(|d| d.icon.clone())
}
/// Count of item `entry` across the backpack + equipped bags. The quest-log feed
/// ([`crate::ui_quest_log`]) needs this for an item-collection objective: unlike a creature/GO
/// objective, item-objective progress is *not* one of the `PLAYER_QUEST_LOG` slot's 6-bit counters
/// — the wire pin's finding is that the real client counts bag items itself, so this walks the same
/// slot arrays the feed does and sums matching entries' stack counts. An unresolved slot (its item
/// template still in flight) can't be matched to an entry and is skipped this frame; it counts once
/// the answer lands and the feed reruns.
pub(crate) fn count_of(store: &ObjectFields, items: &Items, entry: u32) -> u32 {
let mut total = 0u32;
for i in 0..PACK_SLOTS {
let guid = store.player_pack_slot(i).unwrap_or(0);
if guid == 0 {
continue;
/// Which sections of the player's flat slot array a walk visits — the reference walker's own
/// **section mask** (`0x622420`'s `ebx`; wow-re `ui/scratch/quest-leaderboard-law.md` §3.1 and
/// `action-item-slot.md` §8.2). The reference has ONE walker over one contiguous 113-guid slot
/// space (`PLAYER_FIELD_INV_SLOT_HEAD` through the end of the keyring) and parameterises it here;
/// benilla had grown two hand-rolled walks that had already drifted apart, which is what decision
/// 1158 collapsed back into [`walk_inventory`].
///
/// A container met in an ENABLED section is always recursed into — the reference gates sections
/// only at the player's own root descriptor ("inside a recursed container every slot is visited
/// unfiltered"), and its recursion bit `0x10` is clear in every mask any caller passes. Buyback
/// (slots 69–80) has no bit at all and is unreachable at any mask.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct InventoryScope {
/// `0x01` — worn equipment, slots 0–18.
equipment: bool,
/// `0x02` — the four equipped bag slots 19–22, and their contents.
bags: bool,
/// `0x04` — the backpack, slots 23–38.
backpack: bool,
/// `0x08` — bank items (39–62) and the six bank-bag slots (63–68) with their contents.
bank: bool,
/// `0x40` — the keyring band.
keyring: bool,
}
impl InventoryScope {
/// `0x47` — what every caller that passes mask `0` actually gets, because `0x622420` rewrites
/// `mask |= 0x47` whenever the descriptor is the local player's root (`0x622434`–`0x622439`,
/// and the CGPlayer_C ctor sets that flag unconditionally at `0x5dd44d`). Gear + bags and
/// their contents + backpack + keyring; **no bank**.
pub(crate) const DEFAULT: Self = Self {
equipment: true,
bags: true,
backpack: true,
bank: false,
keyring: true,
};
/// `0x01` — the equip-vs-use fork's first stage, "is a copy of this already worn?"
/// (`0x4e5fe7`'s `push 1`). No expansion, no recursion.
pub(crate) const EQUIPMENT_ONLY: Self = Self {
equipment: true,
bags: false,
backpack: false,
bank: false,
keyring: false,
};
/// `0x4F` — [`Self::DEFAULT`] **plus the bank**, the mask the quest surfaces pass (`8`, which
/// the rewrite turns into `0x4F`). wow-re's call-site census of `0x622130` finds six such
/// sites and every one is a quest surface: `GetQuestLogLeaderBoard` (`0x4e0579`/`0x4e0592`),
/// the ADD_ITEM toast (`0x5dd0f5`), the whole-quest turn-in predicate (`0x4df778`), and
/// `GetAbandonQuestItems` (`0x4dfc8a`). **Quest item objectives count banked copies; nothing
/// else does.**
pub(crate) const QUEST_ITEMS: Self = Self {
bank: true,
..Self::DEFAULT
};
/// Bags + backpack only — **narrower than any reference mask**, and named so the divergence is
/// visible at each call site rather than buried in a walk. This is what benilla's item counts
/// have always used; every non-quest caller keeps it verbatim under decision 1158 so that
/// record changes exactly one surface. Closing it (to [`Self::DEFAULT`], adding worn gear and
/// the keyring) moves action-bar counts and reagent counts and is its own change.
pub(crate) const CARRIED: Self = Self {
equipment: false,
bags: true,
backpack: true,
bank: false,
keyring: false,
};
}
/// The reference's inventory walk, once: a single ascending pass over the player's flat slot array,
/// recursing depth-first into each container as it is passed, with `scope` gating the sections.
/// `visit` sees every occupied slot as the wire `(bag_index, 0-based slot)` pair plus the instance
/// guid, in walk order, and returns `Some` to stop the walk (a search) or `None` to continue (a
/// count).
///
/// **The order is the reference's and is load-bearing** (decision 0666 pinned it for the search
/// leg; 1158 made it the only copy): equipment 0–18 → bag slot 19 and all of bag 1's contents → 20
/// (+contents) → 21 (+contents) → 22 (+contents) → backpack 23–38 → bank 39–62 → bank bag 63 and
/// its contents → … → 68 (+contents) → keyring. Equipment is searched FIRST, and a bag's contents
/// come before the backpack.
fn walk_inventory<T>(
store: &ObjectFields,
items: &Items,
scope: InventoryScope,
mut visit: impl FnMut(u8, u8, u64) -> Option<T>,
) -> Option<T> {
// A container's own contents, addressed by the container's slot number as the wire bag byte
// (true for equipped bags and bank bags alike — `BANK_BAG_SLOT_FIRST`'s note).
let contents =
|bag_slot: u8, bag_guid: u64, visit: &mut dyn FnMut(u8, u8, u64) -> Option<T>| {
let bag_fields = items.object(bag_guid)?;
let num_slots = bag_fields.container_num_slots().unwrap_or(0).min(36) as u8;
(0..num_slots).find_map(|j| {
let guid = bag_fields.container_slot(j).unwrap_or(0);
(guid != 0).then(|| visit(bag_slot, j, guid)).flatten()
})
};
if scope.equipment {
for i in 0..EQUIPMENT_SLOTS {
let guid = store.player_inv_slot(i).unwrap_or(0);
if guid != 0 {
if let Some(hit) = visit(BAG_PLAYER_INVENTORY, i, guid) {
return Some(hit);
}
}
}
}
if scope.bags {
for bag in 0..BAGS {
let bag_slot = BAG_SLOT_FIRST + bag;
let bag_guid = store.player_inv_slot(bag_slot).unwrap_or(0);
if bag_guid == 0 {
continue;
}
// The bag OBJECT is a candidate in its own right before its contents are.
if let Some(hit) = visit(BAG_PLAYER_INVENTORY, bag_slot, bag_guid) {
return Some(hit);
}
if let Some(hit) = contents(bag_slot, bag_guid, &mut visit) {
return Some(hit);
}
}
}
if scope.backpack {
for i in 0..PACK_SLOTS {
let guid = store.player_pack_slot(i).unwrap_or(0);
if guid != 0 {
if let Some(hit) = visit(BAG_PLAYER_INVENTORY, SLOT_PACK_FIRST + i, guid) {
return Some(hit);
}
}
}
}
if scope.bank {
for i in 0..BANK_SLOTS {
let guid = store.player_bank_slot(i).unwrap_or(0);
if guid != 0 {
if let Some(hit) = visit(BAG_PLAYER_INVENTORY, BANK_SLOT_FIRST + i, guid) {
return Some(hit);
}
}
}
for bag in 0..BANK_BAGS {
let bag_slot = BANK_BAG_SLOT_FIRST + bag;
let bag_guid = store.player_bank_bag_slot(bag).unwrap_or(0);
if bag_guid == 0 {
continue;
}
if let Some(hit) = visit(BAG_PLAYER_INVENTORY, bag_slot, bag_guid) {
return Some(hit);
}
if let Some(hit) = contents(bag_slot, bag_guid, &mut visit) {
return Some(hit);
}
}
}
if scope.keyring {
// The ADDRESSABLE 16, not the descriptor array's 32 (the reference walks all 32): 16.. is
// not a valid position on this wire, so it can never hold anything and the extra reads
// would only cost time.
for i in 0..KEYRING_SLOTS {
let guid = store.player_keyring_slot(i).unwrap_or(0);
if guid != 0 {
if let Some(hit) = visit(BAG_PLAYER_INVENTORY, KEYRING_SLOT_FIRST + i, guid) {
return Some(hit);
}
}
}
}
None
}
/// How many of item `entry` the player holds within `scope`, summing each matching copy's
/// `ITEM_FIELD_STACK_COUNT` — the reference's `0x622130(itemId, mask)` (`0x622160` is the
/// per-item predicate: `OBJECT_FIELD_ENTRY` equality at `0x622166`, then `+= [+0x20]` at
/// `0x622177`).
///
/// **The scope is the caller's, and it is not cosmetic**: a quest item objective counts banked
/// copies ([`InventoryScope::QUEST_ITEMS`], the reference's mask `8` → `0x4F`) while an action
/// button's count and a reagent count do not. An unresolved slot (its item template still in
/// flight) can't be matched to an entry and is skipped this frame; it counts once the answer lands
/// and the feed reruns.
pub(crate) fn count_of(
store: &ObjectFields,
items: &Items,
entry: u32,
scope: InventoryScope,
) -> u32 {
let mut total = 0u32;
walk_inventory::<()>(store, items, scope, |_, _, guid| {
if let Some(fields) = items.object(guid) {
if fields.object_entry() == Some(entry) {
total += fields.item_stack_count().unwrap_or(1);
}
}
}
for bag in 1..=BAGS {
let bag_guid = store.player_inv_slot(BAG_SLOT_FIRST + bag - 1).unwrap_or(0);
if bag_guid == 0 {
continue;
}
let Some(bag_fields) = items.object(bag_guid) else {
continue;
};
let num_slots = bag_fields.container_num_slots().unwrap_or(0).min(36) as u8;
for j in 0..num_slots {
let guid = bag_fields.container_slot(j).unwrap_or(0);
if guid == 0 {
continue;
}
if let Some(fields) = items.object(guid) {
if fields.object_entry() == Some(entry) {
total += fields.item_stack_count().unwrap_or(1);
}
}
}
}
None
});
total
}
@@ -335,91 +493,34 @@ pub(crate) struct ItemSearch {
/// output shape) plus the **instance guid** that occupies it, since the use fork needs it
/// ([`item_use_command`]). This is the reference's inventory search, byte-verified (wow-re
/// `action-item-slot.md` §8.2: the walker `0x622420` over `PLAYER_FIELD_INV_SLOT_HEAD`, predicate
/// `OBJECT_FIELD_ENTRY` equality).
///
/// **Order is load-bearing and is the reference's, not a guess** (it was one until 2026-07-26 —
/// decision 0666 supersedes 0216 §7's "unverified but necessary" note): a single ascending pass
/// over the player's own slot array, recursing depth-first into each container as it is passed —
///
/// > equipment 0–18 → bag slot 19 (then all of bag 1's contents) → 20 (+contents) → 21 (+contents)
/// > → 22 (+contents) → backpack 23–38 → keyring 81–112.
///
/// Two things that fall out and both matter: **equipment is searched FIRST** (the old walk never
/// looked at it at all, so an equipped trinket's action button was inert), and **a bag's contents
/// come before the backpack** (the old walk had the backpack first). Bank and buyback are never
/// searched — the walker's default mode expands to `0x47`, which omits the bank's `0x08`, and no
/// bit exists for buyback at all. The **keyring** (mode bit `0x40`, in that `0x47`) is last in the
/// order and is walked here now that benilla models it (decision 0765) — a key with an on-use
/// spell dropped on the action bar has to find its copy somewhere, and the keyring is the only
/// place the server ever puts one.
/// `OBJECT_FIELD_ENTRY` equality) — the first hit of [`walk_inventory`], whose doc carries the
/// order and why it is load-bearing (decision 0666; bank and buyback are not in this scope).
pub(crate) fn find_item(
store: &ObjectFields,
items: &Items,
entry: u32,
search: ItemSearch,
) -> Option<(u8, u8, u64)> {
// One candidate: the entry must match, and — under the charges filter — the instance must
// have uses left. A container is exempt from the charge test (the walker's own carve-out).
let hit = |guid: u64| -> bool {
if guid == 0 {
return false;
}
let Some(f) = items.object(guid) else {
return false;
};
if f.object_entry() != Some(entry) {
return false;
}
if search.live_charges_only && f.container_num_slots().is_none_or(|n| n == 0) {
return f.item_spell_charges(0).is_none_or(|c| c != 0);
}
true
let scope = if search.equipment_only {
InventoryScope::EQUIPMENT_ONLY
} else {
InventoryScope::DEFAULT
};
// Equipment 0–18, then the four equipped-bag slots 19–22 — the bag OBJECT is a candidate in
// its own right before its contents are (a bag on the bar is a real, placeable action).
for i in 0..EQUIPMENT_SLOTS {
let guid = store.player_inv_slot(i).unwrap_or(0);
if hit(guid) {
return Some((BAG_PLAYER_INVENTORY, i, guid));
walk_inventory(store, items, scope, |bag, slot, guid| {
let f = items.object(guid)?;
if f.object_entry() != Some(entry) {
return None;
}
}
if search.equipment_only {
return None;
}
for bag in 0..BAGS {
let bag_slot = BAG_SLOT_FIRST + bag;
let bag_guid = store.player_inv_slot(bag_slot).unwrap_or(0);
if hit(bag_guid) {
return Some((BAG_PLAYER_INVENTORY, bag_slot, bag_guid));
// Under the charges filter, the instance must have uses left. A container is exempt
// (the walker's own carve-out).
if search.live_charges_only
&& f.container_num_slots().is_none_or(|n| n == 0)
&& f.item_spell_charges(0).is_some_and(|c| c == 0)
{
return None;
}
let Some(bag_fields) = items.object(bag_guid) else {
continue;
};
let num_slots = bag_fields.container_num_slots().unwrap_or(0).min(36) as u8;
for j in 0..num_slots {
let guid = bag_fields.container_slot(j).unwrap_or(0);
if hit(guid) {
return Some((bag_slot, j, guid));
}
}
}
for i in 0..PACK_SLOTS {
let guid = store.player_pack_slot(i).unwrap_or(0);
if hit(guid) {
return Some((BAG_PLAYER_INVENTORY, SLOT_PACK_FIRST + i, guid));
}
}
// The keyring band, last (mode bit 0x40). Walked over the ADDRESSABLE 16, not the level-gated
// count: a slot the level hasn't unlocked can't hold anything, so the extra reads cost nothing
// and the walk needs no level in hand.
for i in 0..KEYRING_SLOTS {
let guid = store.player_keyring_slot(i).unwrap_or(0);
if hit(guid) {
return Some((BAG_PLAYER_INVENTORY, KEYRING_SLOT_FIRST + i, guid));
}
}
None
Some((bag, slot, guid))
})
}
/// The reference's **`HasKey()`** (`0x48ae90`) — "does this player own a key at all?", the one
@@ -1350,3 +1451,156 @@ mod find_item_tests {
ObjectFields::from_pairs(&pairs)
}
}
/// [`count_of`]'s **scope** — decision 1158. The reference has one walker parameterised by a
/// section mask, and the mask a caller passes is not cosmetic: the quest surfaces pass `8` (which
/// the walker rewrites to `0x4F`) and so count **banked** copies; everything else passes `0` (→
/// `0x47`) and does not. Every case here is a slot band that separates the two scopes.
#[cfg(test)]
mod count_of_tests {
use super::{count_of, InventoryScope};
use crate::items::Items;
use benilla_protocol::ObjectFields;
// Descriptor indices, raw (the module's own consts are private; the codebase's test idiom).
const ENTRY: u16 = 3; // OBJECT_FIELD_ENTRY
const STACK: u16 = 14; // ITEM_FIELD_STACK_COUNT
const NUM_SLOTS: u16 = 48; // CONTAINER_FIELD_NUM_SLOTS
const SLOT_1: u16 = 50; // CONTAINER_FIELD_SLOT_1 (2 fields per guid)
const INV_SLOT_HEAD: u16 = 486; // player slots 0..22
const PACK_SLOT_1: u16 = 532; // player slots 23..38
const BANK_SLOT_1: u16 = 564; // player slots 39..62
const BANK_BAG_SLOT_1: u16 = 612; // player slots 63..68
const KEYRING_SLOT_1: u16 = 648; // player slots 81..112
const AMMO: u32 = 3_030; // the collect-quest item under test
/// A player whose flat slot array points at the given `(absolute player slot, guid)` pairs —
/// every band this module needs, each its own descriptor array on the wire.
fn player(slots: &[(u16, u64)]) -> ObjectFields {
let mut pairs = Vec::new();
for &(slot, guid) in slots {
let base = match slot {
0..=22 => INV_SLOT_HEAD + 2 * slot,
23..=38 => PACK_SLOT_1 + 2 * (slot - 23),
39..=62 => BANK_SLOT_1 + 2 * (slot - 39),
63..=68 => BANK_BAG_SLOT_1 + 2 * (slot - 63),
81..=112 => KEYRING_SLOT_1 + 2 * (slot - 81),
_ => panic!("slot {slot} is buyback or out of range"),
};
pairs.push((base, guid as u32));
pairs.push((base + 1, (guid >> 32) as u32));
}
ObjectFields::from_pairs(&pairs)
}
/// An item instance of `entry` holding `stack` copies.
fn stack(items: &mut Items, guid: u64, entry: u32, stack: u32) {
items.insert_object(
guid,
ObjectFields::from_pairs(&[(ENTRY, entry), (STACK, stack)]),
);
}
/// A container instance holding `contents` at its own inner slots.
fn bag(items: &mut Items, guid: u64, contents: &[(u8, u64)]) {
let mut pairs = vec![(ENTRY, 4_500), (NUM_SLOTS, 16)];
for &(slot, held) in contents {
pairs.push((SLOT_1 + 2 * u16::from(slot), held as u32));
pairs.push((SLOT_1 + 2 * u16::from(slot) + 1, (held >> 32) as u32));
}
items.insert_object(guid, ObjectFields::from_pairs(&pairs));
}
/// The headline: bank the quest's items and a quest objective still counts them. This is the
/// whole of the mask-`8` finding (wow-re `ui/scratch/quest-leaderboard-law.md` §3.1 — `8` is
/// exactly the bit that *adds the bank*, and every one of the six mask-`8` call sites in the
/// reference is a quest surface).
#[test]
fn a_quest_objective_counts_banked_copies_and_nothing_else_does() {
let store = player(&[(23, 0xA1), (39, 0xB1)]); // one stack in the backpack, one in the bank
let mut items = Items::default();
stack(&mut items, 0xA1, AMMO, 3);
stack(&mut items, 0xB1, AMMO, 5);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
8,
"mask 0x4F sees the bank"
);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::CARRIED),
3,
"an action-bar/reagent count must NOT see the bank"
);
}
/// Bank BAGS are recursed into as well — the walker's container recursion is gated by mask bit
/// `0x10`, which is clear in every mask any caller passes, and section gating applies only at
/// the player's own root descriptor.
#[test]
fn a_quest_objective_counts_the_contents_of_bank_bags() {
let store = player(&[(63, 0xBB)]); // a bag in bank-bag slot 1
let mut items = Items::default();
bag(&mut items, 0xBB, &[(0, 0xC1), (4, 0xC2)]);
stack(&mut items, 0xC1, AMMO, 2);
stack(&mut items, 0xC2, AMMO, 6);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
8
);
assert_eq!(count_of(&store, &items, AMMO, InventoryScope::CARRIED), 0);
}
/// The other two bands `0x47` carries that benilla's own [`InventoryScope::CARRIED`] does not:
/// worn gear and the keyring. Both are in the quest scope.
#[test]
fn the_quest_scope_also_reaches_worn_gear_and_the_keyring() {
let store = player(&[(5, 0xE1), (81, 0xF1)]); // a worn copy and one in the keyring
let mut items = Items::default();
stack(&mut items, 0xE1, AMMO, 1);
stack(&mut items, 0xF1, AMMO, 1);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
2
);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::CARRIED),
0,
"benilla's pre-1158 count reached neither band — the named narrowing"
);
}
/// A carried bag's contents are in BOTH scopes — the change must not disturb the band every
/// caller already relied on.
#[test]
fn carried_bags_are_unchanged_in_both_scopes() {
let store = player(&[(19, 0xBA), (25, 0xA2)]);
let mut items = Items::default();
bag(&mut items, 0xBA, &[(2, 0xC1)]);
stack(&mut items, 0xC1, AMMO, 4);
stack(&mut items, 0xA2, AMMO, 1);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
5
);
assert_eq!(count_of(&store, &items, AMMO, InventoryScope::CARRIED), 5);
}
/// Buyback (slots 69–80) has no mask bit at all and is unreachable at every scope — an item
/// sold to a vendor is gone from every count the client makes.
#[test]
fn buyback_is_never_counted() {
// The buyback band has no accessor in the walk by construction; assert the neighbouring
// bands still resolve so this is a real coverage statement, not a vacuous one.
let store = player(&[(38, 0xA1), (68, 0xBB)]);
let mut items = Items::default();
stack(&mut items, 0xA1, AMMO, 1);
bag(&mut items, 0xBB, &[(0, 0xC1)]);
stack(&mut items, 0xC1, AMMO, 1);
assert_eq!(
count_of(&store, &items, AMMO, InventoryScope::QUEST_ITEMS),
2,
"last backpack slot + last bank bag, with buyback between them untouched"
);
}
}
+179 -28
View File
@@ -34,6 +34,7 @@
//! quests that are never in the log: auto-complete turn-ins. The state that backed it is gone
//! rather than left dead, so the retired law can't be reached for by accident.
use crate::ui_items::{count_of, InventoryScope};
use std::collections::{HashMap, HashSet};
use bevy::prelude::*;
@@ -142,6 +143,9 @@ impl Plugin for UiQuestLogPlugin {
Update,
(
feed_quest_log.in_set(UnitFeed).before(UiInput),
// Before the script tick, so a QuestTimerFrame OnUpdate this frame reads this
// frame's clock (the `minimap::feed_game_time` shape).
feed_server_clock.before(UiInput),
drain_quest_log_abandons.after(UiInput),
drain_quest_log_collapses.after(UiInput),
),
@@ -157,15 +161,22 @@ impl Plugin for UiQuestLogPlugin {
/// live backpack+bags count, meaningful only for an item objective — [`crate::ui_items::count_of`]).
///
/// The custom `text` field, when non-empty, REPLACES the auto-generated name in the line but keeps
/// the "cur/req" suffix — INTERIM: the real client's exact custom-objective-text formatting is
/// unpinned; a wow-re follow-up owns it.
/// the "cur/req" suffix — CONFIRMED since (wow-re `ui/scratch/quest-leaderboard-law.md`: a
/// creature objective carrying `ObjectiveText[i]` formats with `QUEST_OBJECTS_FOUND "%s: %d/%d"`,
/// not the "slain" key, and that text IS the name — while `type` still answers `"monster"`).
///
/// **Known-wrong (decision 1156, not yet corrected):** `finished` must be `cur >= req` and NOTHING
/// else. The whole-quest COMPLETE bit is read exactly once in the reference binding, by the `event`
/// line, where it is that line's only predicate (`0x4e02a2`); or-ing it in here marks every
/// objective finished the moment the quest completes. The fix must land WITH [`count_of`]'s scope
/// widening (1156) — alone, it would render a banked-item quest's lines unfinished under a
/// COMPLETE quest.
fn objective_line(
obj: &QuestObjective,
counter: u8,
creature_name: Option<&str>,
item_name: Option<&str>,
bag_count: u32,
quest_complete: bool,
) -> Option<QuestLogObjectiveView> {
const PLACEHOLDER: &str = "...";
if obj.creature_or_go != 0 && obj.required_count > 0 {
@@ -187,7 +198,9 @@ fn objective_line(
return Some(QuestLogObjectiveView {
text,
kind: kind.into(),
finished: quest_complete || cur >= req,
finished: cur >= req,
cur,
req,
});
}
if obj.item_id != 0 && obj.item_count > 0 {
@@ -201,7 +214,9 @@ fn objective_line(
return Some(QuestLogObjectiveView {
text,
kind: "item".into(),
finished: quest_complete || cur >= req,
finished: cur >= req,
cur,
req,
});
}
None
@@ -268,7 +283,6 @@ fn build_objectives(
names: &mut NameCache,
commands: &NetCommands,
) -> Vec<QuestLogObjectiveView> {
let quest_complete = log_slot.state & quest_slot_state::COMPLETE != 0;
let mut objectives = Vec::with_capacity(template.objectives.len());
for (i, obj) in template.objectives.iter().enumerate() {
let is_go = obj.creature_or_go & GO_OBJECTIVE_BIT != 0;
@@ -287,7 +301,7 @@ fn build_objectives(
})
.flatten();
let bag_count = if obj.item_id != 0 {
crate::ui_items::count_of(store, items, obj.item_id)
count_of(store, items, obj.item_id, InventoryScope::QUEST_ITEMS)
} else {
0
};
@@ -297,7 +311,6 @@ fn build_objectives(
creature_name.as_deref(),
item_name.as_deref(),
bag_count,
quest_complete,
) {
objectives.push(line);
}
@@ -373,6 +386,27 @@ fn remap_selection(old: &[QuestLogEntryView], new: &[QuestLogEntryView], sel: u3
.unwrap_or(0)
}
/// Push the server's wall clock into the VM every frame (decision 1150) — the number every
/// countdown binding subtracts against.
///
/// Every frame, not on change: it is a *clock*, so "changed" is always, and the whole point is that
/// `GetQuestTimers()` answers a fresh value on each of the reference `QuestTimerFrame`'s OnUpdate
/// calls. Cheap by construction — one scalar write, no event, no diff, nothing downstream unless
/// some frame actually reads it.
///
/// Silent before the first `SMSG_QUERY_TIME_RESPONSE` (the world-enter query answers within a round
/// trip). The bindings then report *no* timer rather than a guessed one — see
/// `benilla_ui::script::quest_log::seconds_left`.
fn feed_server_clock(
script: Option<NonSendMut<UiScript>>,
clock: Res<crate::net::ServerWallClock>,
) {
let (Some(mut script), Some(now)) = (script, clock.now_unix()) else {
return;
};
script.set_server_unix_time(now);
}
/// Read the self player's `PLAYER_QUEST_LOG` descriptor slots each frame, resolve entries/detail,
/// and push a [`QuestLogState`] snapshot on change (diffed against a `Local`, the crate's standard
/// feed shape). Also refreshes [`QuestLog::active_quest_ids`]/`entry_slots` for the greeting split
@@ -467,6 +501,7 @@ fn feed_quest_log(
is_header: true,
collapsed,
complete: 0,
timer: 0, // a header row is not a quest and never carries a timer
objectives: Vec::new(),
});
entry_slots.push(None);
@@ -501,6 +536,11 @@ fn feed_quest_log(
is_header: false,
collapsed: false,
complete,
// The slot's raw deadline (absolute unix seconds; 0 = untimed) — carried, never
// converted here. The countdown is subtracted per Lua call against the server
// clock, so this snapshot stays stable while the number on screen ticks
// (decision 1150).
timer: r.log_slot.timer,
objectives,
});
entry_slots.push(Some(r.slot));
@@ -557,7 +597,8 @@ fn feed_quest_log(
// (named divergence): the real client fires from the SMSG handlers and SKIPS a toast
// whose name is uncached (peek-only lookups); ours rides the log diff that feeds the
// lines, so the toast always agrees with the log (an in-flight name shows the log's own
// placeholder, and its resolution can re-announce a line once).
// placeholder). The diff announces an objective only when it ADVANCED — never on a
// regression or a re-render ([`advanced`]; decision 1152 / B237).
// - QUEST_WATCH_UPDATE with the byte law's arg: the quest's **1-based WATCH-LIST position,
// 0 when unwatched** (`0x703f50(0x221)` ← `0x4df880` — NOT the quest-log index the ref
// FrameXML's `AutoQuestWatch_Update(arg1)` treats it as; the shipped 1.12 auto-watch
@@ -599,7 +640,37 @@ fn feed_quest_log(
*last = fresh;
}
/// One quest whose objectives moved between log states — the announce unit of [`feed_quest_log`].
/// Did one objective **advance** between two log states — the announce predicate, and the whole of
/// decision 1152's fix for B237.
///
/// The verified law (wow-re `object-layer/scratch/quest-update-ui-feedback-law.md`, handler
/// `0x5e5ad0`) is that the progress toast fires from the server's *additive* announcements alone —
/// `SMSG_QUESTUPDATE_ADD_KILL` (`ERR_QUEST_ADD_KILL_SII`) and `_ADD_ITEM`
/// (`ERR_QUEST_ADD_ITEM_SII`, via `0x5dd060`). There is no removal opcode and no client-side
/// re-announce, so the reference **cannot** toast on an objective going backwards. Ours rides the
/// log diff (0340's named divergence — the same state feeds the toast and the log, so they always
/// agree), which made the predicate "the rendered line changed" — strictly wider than the law, and
/// wrong in exactly one director-visible way: a **turn-in** destroys the required items via an
/// inline `SMSG_DESTROY_OBJECT` while the quest-log slot clears only in the tick's batched
/// `SMSG_UPDATE_OBJECT` (vmangos `Player::RewardQuest` — `DestroyItemCount` at the top, the
/// `SetQuestSlot(log_slot, 0)` ~40 lines later), so for a frame or two the quest is still in the
/// log with an empty bag and the line dips to `0/req`. The old predicate read that dip as progress
/// and popped "Rumbleshot's Ammo: 0/1" in the middle of the screen (B237).
///
/// Comparing the numbers rather than the string also drops the re-announce 0340 named as INTERIM
/// (an item/creature **name** landing late rewrote `text` with `cur` unmoved, toasting the same
/// line twice) — the reference skips a toast whose name is uncached outright, so suppressing the
/// second is a step toward it, not away.
///
/// The whole-quest COMPLETE flip is deliberately NOT part of this: it is the reference's own
/// separate `SMSG_QUESTUPDATE_COMPLETE` message (`ERR_QUEST_OBJECTIVE_COMPLETE_S`, one line for
/// the quest), carried by [`QuestProgress::completed`].
fn advanced(now: &QuestLogObjectiveView, was: &QuestLogObjectiveView) -> bool {
now.cur > was.cur
}
/// One quest whose objectives advanced between log states — the announce unit of
/// [`feed_quest_log`].
struct QuestProgress {
/// The quest id (stable identity — the watch list is id-keyed).
quest_id: u32,
@@ -607,13 +678,13 @@ struct QuestProgress {
index: u32,
/// The quest title (the COMPLETE toast's `%s`; empty falls to the UNKNOWN form).
title: String,
/// The fresh text of every objective line that moved, in objective order.
/// The fresh text of every objective line that ADVANCED ([`advanced`]), in objective order.
changed_lines: Vec<String>,
/// The whole-quest COMPLETE state flipped on this diff (the `0x198` toast).
completed: bool,
}
/// The quests present in BOTH states with a same-position objective line whose view changed, or
/// The quests present in BOTH states with a same-position objective line that **advanced**, or
/// whose whole-quest COMPLETE state flipped on — "achieved a quest objective", the trigger for
/// the progress toasts and the auto-watch. A quest appearing (fresh accept — or its template
/// resolving late and growing lines from zero) or leaving (turn-in/abandon) does NOT count: the
@@ -637,7 +708,7 @@ fn quests_with_progressed_objectives(
.objectives
.iter()
.zip(prev.objectives.iter())
.filter(|(now, was)| now != was)
.filter(|(now, was)| advanced(now, was))
.map(|(now, _)| now.text.clone())
.collect();
let completed = e.complete == 1 && prev.complete != 1;
@@ -742,6 +813,7 @@ mod tests {
is_header: true,
collapsed: false,
complete: 0,
timer: 0,
objectives: Vec::new(),
}
}
@@ -755,6 +827,7 @@ mod tests {
is_header: false,
collapsed: false,
complete: 0,
timer: 0,
objectives: Vec::new(),
}
}
@@ -815,7 +888,7 @@ mod tests {
#[test]
fn creature_objective_with_resolved_name() {
let o = obj(100, 10, 0, 0, "");
let line = objective_line(&o, 3, Some("Kobold Vermin"), None, 0, false).unwrap();
let line = objective_line(&o, 3, Some("Kobold Vermin"), None, 0).unwrap();
assert_eq!(line.text, "Kobold Vermin slain: 3/10");
assert_eq!(line.kind, "monster");
assert!(!line.finished);
@@ -824,7 +897,7 @@ mod tests {
#[test]
fn creature_objective_in_flight_shows_a_placeholder() {
let o = obj(100, 10, 0, 0, "");
let line = objective_line(&o, 0, None, None, 0, false).unwrap();
let line = objective_line(&o, 0, None, None, 0).unwrap();
assert_eq!(line.text, "... slain: 0/10");
}
@@ -832,7 +905,7 @@ mod tests {
fn go_objective_falls_back_without_a_name_cache() {
let go_id = ((-57i32) as u32) | 0x8000_0000;
let o = obj(go_id, 1, 0, 0, "");
let line = objective_line(&o, 1, None, None, 0, false).unwrap();
let line = objective_line(&o, 1, None, None, 0).unwrap();
assert_eq!(line.text, "Objective: 1/1");
assert_eq!(line.kind, "object");
assert!(line.finished); // cur (1) >= req (1)
@@ -842,7 +915,7 @@ mod tests {
fn go_objective_prefers_its_custom_text_over_the_fallback() {
let go_id = ((-57i32) as u32) | 0x8000_0000;
let o = obj(go_id, 4, 0, 0, "Destroy the barricade");
let line = objective_line(&o, 1, None, None, 0, false).unwrap();
let line = objective_line(&o, 1, None, None, 0).unwrap();
assert_eq!(line.text, "Destroy the barricade: 1/4");
assert_eq!(line.kind, "object");
}
@@ -851,7 +924,7 @@ mod tests {
fn item_objective_counts_bags_and_clamps_to_required() {
let o = obj(0, 0, 2000, 5, "");
// 8 in the bags, but required is only 5 — the line clamps, doesn't overshoot.
let line = objective_line(&o, 0, None, Some("Kobold Ear"), 8, false).unwrap();
let line = objective_line(&o, 0, None, Some("Kobold Ear"), 8).unwrap();
assert_eq!(line.text, "Kobold Ear: 5/5");
assert_eq!(line.kind, "item");
assert!(line.finished);
@@ -860,31 +933,37 @@ mod tests {
#[test]
fn item_objective_in_flight_shows_a_placeholder() {
let o = obj(0, 0, 2000, 5, "");
let line = objective_line(&o, 0, None, None, 2, false).unwrap();
let line = objective_line(&o, 0, None, None, 2).unwrap();
assert_eq!(line.text, "...: 2/5");
}
#[test]
fn custom_text_overrides_the_creature_auto_line() {
let o = obj(100, 10, 0, 0, "Slay the vermin");
let line = objective_line(&o, 4, Some("Kobold Vermin"), None, 0, false).unwrap();
let line = objective_line(&o, 4, Some("Kobold Vermin"), None, 0).unwrap();
assert_eq!(line.text, "Slay the vermin: 4/10");
assert_eq!(line.kind, "monster");
}
/// Decision 1158, correcting 0109: a line's `finished` is `cur >= req` and **nothing else**.
/// The reference reads the whole-quest COMPLETE bit exactly once in `GetQuestLogLeaderBoard`
/// (`0x4e02a2`), in the `event` branch, where it is that line's only predicate — it is never
/// or-ed into a counted line's verdict (wow-re `ui/scratch/quest-leaderboard-law.md` §7). Only
/// the whole-quest turn-in predicate `0x4df580` reads the bit and the counts together.
#[test]
fn whole_quest_complete_marks_every_line_finished_regardless_of_progress() {
fn a_complete_quest_does_not_mark_a_short_objective_finished() {
let o = obj(100, 10, 0, 0, "");
// counter (1) is well short of required (10), but the quest's own state byte is COMPLETE.
let line = objective_line(&o, 1, Some("Kobold"), None, 0, true).unwrap();
// The counter (1) is well short of required (10). Whatever the quest's state byte says,
// THIS line is not finished.
let line = objective_line(&o, 1, Some("Kobold"), None, 0).unwrap();
assert_eq!(line.text, "Kobold slain: 1/10");
assert!(line.finished);
assert!(!line.finished);
}
#[test]
fn empty_objective_slot_emits_no_line() {
let o = obj(0, 0, 0, 0, "");
assert!(objective_line(&o, 0, None, None, 0, false).is_none());
assert!(objective_line(&o, 0, None, None, 0).is_none());
}
// ── build_objectives: every occupied slot gets its own lines, not just the selection ────────────
@@ -955,8 +1034,11 @@ mod tests {
assert_eq!(objectives[1].kind, "item");
}
/// The 0109-era behaviour this inverts: a COMPLETE slot state used to mark every line
/// finished. Decision 1158 — the reference's per-line verdict never reads that bit
/// (`0x4e0110` §7); only the whole-quest turn-in predicate (`0x4df580`) combines the two.
#[test]
fn build_objectives_marks_every_line_finished_when_the_slot_state_is_complete() {
fn a_complete_slot_state_does_not_finish_a_short_line() {
let template = quest_template([
obj(100, 10, 0, 0, ""),
obj(0, 0, 0, 0, ""),
@@ -979,7 +1061,11 @@ mod tests {
&template, &log_slot, &store, &mut items, &mut names, &commands,
);
assert_eq!(objectives.len(), 1);
assert!(objectives[0].finished);
assert!(
!objectives[0].finished,
"the counter is 1/10 — the quest's COMPLETE bit is not this line's business"
);
assert_eq!(objectives[0].text, "... slain: 1/10");
}
// ── money_split ──────────────────────────────────────────────────────────────────────────────
@@ -1007,13 +1093,24 @@ mod tests {
// ── quests_with_progressed_objectives: the QUEST_WATCH_UPDATE per-quest trigger ─────────────────
/// A one-objective entry built the way [`objective_line`] builds one: the `cur`/`req` carried
/// on the view are READ BACK OUT of the line's own trailing `%d/%d`, so a fixture can never
/// claim a count its text disagrees with (the production invariant — the text is formatted
/// FROM the numbers).
fn entry(quest_id: u32, objective_text: &str) -> QuestLogEntryView {
let (cur, req) = objective_text
.rsplit_once(' ')
.and_then(|(_, tail)| tail.split_once('/'))
.map(|(c, r)| (c.parse().unwrap(), r.parse().unwrap()))
.expect("fixture line must end in `cur/req`");
QuestLogEntryView {
quest_id,
objectives: vec![QuestLogObjectiveView {
text: objective_text.into(),
kind: "monster".into(),
finished: false,
finished: cur >= req,
cur,
req,
}],
..Default::default()
}
@@ -1082,4 +1179,58 @@ mod tests {
// A turn-in/abandon that removes the quest fires nothing either.
assert!(quests_with_progressed_objectives(&with_entry, &empty).is_empty());
}
/// B237 / decision 1152 — the turn-in flash. `Player::RewardQuest` destroys the required items
/// at the top (an inline `SMSG_DESTROY_OBJECT`) and clears the log slot ~40 lines later (the
/// tick's batched `SMSG_UPDATE_OBJECT`), so the client holds a complete collect quest over an
/// empty bag for a frame or two and the line dips to `0/req`. That dip must announce NOTHING:
/// the reference's toast is `SMSG_QUESTUPDATE_ADD_ITEM` only, and no removal opcode exists.
#[test]
fn a_regressing_objective_never_announces() {
let held = state(vec![entry(313, "Rumbleshot's Ammo: 1/1")]);
let emptied = state(vec![entry(313, "Rumbleshot's Ammo: 0/1")]);
assert!(
quests_with_progressed_objectives(&held, &emptied).is_empty(),
"the bag emptying under a still-logged quest is not progress"
);
// The slot clearing on the next frame was already silent (the quest leaves the log).
assert!(quests_with_progressed_objectives(&emptied, &state(vec![])).is_empty());
// The control: the same quest actually being collected still announces.
let looted = state(vec![entry(313, "Rumbleshot's Ammo: 1/1")]);
assert_eq!(
quests_with_progressed_objectives(&emptied, &looted)[0].changed_lines,
["Rumbleshot's Ammo: 1/1"]
);
}
/// The ask-once name caches answer a frame or more after the line first renders. That rewrite
/// moves `text` with the count standing still — the reference skips a toast whose name is
/// uncached outright, so re-announcing the line when the name lands (0340's named INTERIM) is
/// a toast the reference never fires.
#[test]
fn a_name_landing_late_does_not_re_announce() {
let placeholder = state(vec![entry(5, "...: 3/5")]);
let named = state(vec![entry(5, "Small Barnacled Clam: 3/5")]);
assert!(quests_with_progressed_objectives(&placeholder, &named).is_empty());
}
/// The whole-quest COMPLETE flip is its own reference message (`SMSG_QUESTUPDATE_COMPLETE` →
/// `ERR_QUEST_OBJECTIVE_COMPLETE_S`, one line for the quest) — it must not also re-announce
/// every objective line just because their `finished` flag flipped with it.
#[test]
fn the_complete_flip_announces_once_not_per_objective() {
let mut before = entry(9, "Kobold Vermin slain: 10/10");
before.complete = 0;
before.objectives[0].finished = false;
let mut after = entry(9, "Kobold Vermin slain: 10/10");
after.complete = 1;
after.title = "A Threat Within".into();
let fired = quests_with_progressed_objectives(&state(vec![before]), &state(vec![after]));
assert_eq!(fired.len(), 1);
assert!(fired[0].completed);
assert!(
fired[0].changed_lines.is_empty(),
"the objective count did not move — only the quest's COMPLETE state did"
);
}
}
@@ -0,0 +1,152 @@
//! **The reference file is the test oracle** (decision 0675) — the shared half of it.
//!
//! A transcribed window's geometry errors are invisible to every behavioural test: the frames
//! load, the buttons click, the lists populate, and only the *look* is wrong. 0675 built the
//! detector for `FriendsFrame` after two rounds of the director's eye caught what a one-number
//! diff would have caught on its first run, and closed with "for the next transcribed window,
//! write the diff test first". This is that scraper, lifted out of `friends_tests` so the next
//! window costs a call rather than a copy (`QuestTimerFrame` is the first to pay the lower price).
//!
//! Deliberately narrow: it does not try to understand the XML, only to notice that a number moved.
//! Two properties make it a guard rather than a comfort, and both are the caller's to keep —
//! **known-benign differences are an explicit list, never a pattern**, so a new difference cannot
//! hide inside a tolerance; and **it must be verified to fail** (perturb one number by 1 px and
//! watch it name the element).
use std::collections::HashMap;
use std::path::{Path, PathBuf};
/// The extracted reference FrameXML file of that name, or `None` when the install isn't there
/// (these tests skip cleanly without it — it is a gitignored Blizzard asset).
pub(super) fn reference(file: &str) -> Option<PathBuf> {
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../WoW/_extracted_framexml")
.join(file);
path.is_file().then_some(path)
}
/// Named element → the `<AbsDimension>` pairs inside it, up to the next named element.
///
/// Hand-rolled rather than a regex: this crate has no regex dependency, and adding one for a scan
/// of two files is a worse trade than twenty lines of `find`.
///
/// `$parent*` names repeat across templates (every row template has a `$parentText`), so a bare
/// name would compare one template's column against another's. Each is qualified by the template
/// it lives in — the nearest preceding real name.
fn scrape(text: &str) -> Vec<(String, Vec<(f32, f32)>)> {
const TAGS: &[&str] = &[
"<Button name=\"",
"<Frame name=\"",
"<EditBox name=\"",
"<ScrollFrame name=\"",
"<FontString name=\"",
"<Texture name=\"",
"<CheckButton name=\"",
];
// Every (offset, name) where a named element starts, in document order.
let mut marks: Vec<(usize, String)> = Vec::new();
for tag in TAGS {
let mut from = 0;
while let Some(hit) = text[from..].find(tag) {
let at = from + hit;
let name_start = at + tag.len();
let Some(len) = text[name_start..].find('"') else {
break;
};
marks.push((at, text[name_start..name_start + len].to_string()));
from = name_start + len;
}
}
marks.sort_by_key(|(at, _)| *at);
let mut owner = String::new();
marks
.iter()
.enumerate()
.map(|(i, (at, name))| {
let end = marks.get(i + 1).map_or(text.len(), |(next, _)| *next);
let key = if let Some(child) = name.strip_prefix("$parent") {
format!("{owner}/{child}")
} else {
owner = name.clone();
name.clone()
};
(key, dimensions(&text[*at..end]))
})
.collect()
}
/// Every `<AbsDimension x=".." y=".."/>` in `chunk`, in order.
fn dimensions(chunk: &str) -> Vec<(f32, f32)> {
const OPEN: &str = "<AbsDimension x=\"";
let mut out = Vec::new();
let mut rest = chunk;
while let Some(hit) = rest.find(OPEN) {
rest = &rest[hit + OPEN.len()..];
let Some((x, tail)) = rest.split_once('"') else {
break;
};
let Some(y_at) = tail.find("y=\"") else { break };
let Some((y, tail)) = tail[y_at + 3..].split_once('"') else {
break;
};
if let (Ok(x), Ok(y)) = (x.parse(), y.parse()) {
out.push((x, y));
}
rest = tail;
}
out
}
/// Assert every element benilla's `ours` file shares with the reference `reference` file carries
/// the reference's `<AbsDimension>` numbers.
///
/// - `ours` is a path under `assets/ui/`; names are matched to the reference's after stripping the
/// house `Benilla` prefix (a window whose frames keep the bare reference names — decision 0591
/// §3 — matches unchanged).
/// - `expected` lists the *deliberate* deviations by their REFERENCE name. Every entry earns a
/// comment at the call site naming its reason; that is what keeps this a guard.
/// - `min_compared` fails the test if fewer elements paired up than that, so a scrape or a naming
/// change that quietly stops comparing anything is caught rather than reported as a pass.
///
/// Elements ours has and the reference does not (our own template faces) are skipped, not failed.
pub(super) fn assert_geometry_matches(
ours: &str,
reference: &Path,
expected: &[&str],
min_compared: usize,
) {
let ours_text = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(ours),
)
.unwrap();
let theirs: HashMap<String, Vec<(f32, f32)>> =
scrape(&std::fs::read_to_string(reference).unwrap())
.into_iter()
.collect();
let mut compared = 0;
let mut drifted = Vec::new();
for (name, dims) in scrape(&ours_text) {
let key = name.replace("Benilla", "");
let Some(ref_dims) = theirs.get(&key) else {
continue; // ours alone (the local template faces) — nothing to compare against
};
compared += 1;
if dims != *ref_dims && !expected.contains(&key.as_str()) {
drifted.push(format!("{key}: ours {dims:?} != ref {ref_dims:?}"));
}
}
assert!(
compared >= min_compared,
"only {compared} elements of {ours} matched by name (expected >= {min_compared}) — the \
scrape or the naming broke"
);
assert!(
drifted.is_empty(),
"{ours} geometry differs from the reference FrameXML:\n {}",
drifted.join("\n ")
);
}
@@ -727,83 +727,10 @@ fn selecting_a_row_reads_back_in_the_same_tick() {
/// exemption. Skips without the extracted reference.
#[test]
fn the_window_geometry_matches_the_reference_framexml() {
let reference = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../WoW/_extracted_framexml/FriendsFrame.xml");
if !reference.is_file() {
eprintln!("skipping: no extracted FrameXML at {}", reference.display());
let Some(reference) = super::framexml_diff::reference("FriendsFrame.xml") else {
eprintln!("skipping: no extracted FrameXML");
return;
}
/// Named element → the `AbsDimension` pairs inside it, up to the next named element.
/// Hand-rolled rather than a regex: this crate has no regex dependency, and adding one for a
/// test that scans two files is a worse trade than twenty lines of `find`.
fn scrape(text: &str) -> Vec<(String, Vec<(f32, f32)>)> {
const TAGS: &[&str] = &[
"<Button name=\"",
"<Frame name=\"",
"<EditBox name=\"",
"<ScrollFrame name=\"",
"<FontString name=\"",
"<Texture name=\"",
"<CheckButton name=\"",
];
// Every (offset, name) where a named element starts, in document order.
let mut marks: Vec<(usize, String)> = Vec::new();
for tag in TAGS {
let mut from = 0;
while let Some(hit) = text[from..].find(tag) {
let at = from + hit;
let name_start = at + tag.len();
let Some(len) = text[name_start..].find('"') else {
break;
};
marks.push((at, text[name_start..name_start + len].to_string()));
from = name_start + len;
}
}
marks.sort_by_key(|(at, _)| *at);
// `$parent*` names repeat across templates (every row template has a `$parentName`), so a
// bare name would compare one template's column against another's. Qualify each by the
// template it lives in — the nearest preceding real name.
let mut owner = String::new();
marks
.iter()
.enumerate()
.map(|(i, (at, name))| {
let end = marks.get(i + 1).map_or(text.len(), |(next, _)| *next);
let key = if let Some(child) = name.strip_prefix("$parent") {
format!("{owner}/{child}")
} else {
owner = name.clone();
name.clone()
};
(key, dimensions(&text[*at..end]))
})
.collect()
}
/// Every `<AbsDimension x=".." y=".."/>` in `chunk`, in order.
fn dimensions(chunk: &str) -> Vec<(f32, f32)> {
const OPEN: &str = "<AbsDimension x=\"";
let mut out = Vec::new();
let mut rest = chunk;
while let Some(hit) = rest.find(OPEN) {
rest = &rest[hit + OPEN.len()..];
let Some((x, tail)) = rest.split_once('"') else {
break;
};
let Some(y_at) = tail.find("y=\"") else { break };
let Some((y, tail)) = tail[y_at + 3..].split_once('"') else {
break;
};
if let (Ok(x), Ok(y)) = (x.parse(), y.parse()) {
out.push((x, y));
}
rest = tail;
}
out
}
};
// Differences that are ours on purpose. Each is a *deliberate* deviation with a reason, not a
// tolerance: the list is short and every entry names why.
@@ -877,36 +804,5 @@ fn the_window_geometry_matches_the_reference_framexml() {
"FriendsFrameCloseButton",
];
let ours = scrape(
&std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/FriendsFrame.xml"),
)
.unwrap(),
);
let theirs: std::collections::HashMap<String, Vec<(f32, f32)>> =
scrape(&std::fs::read_to_string(&reference).unwrap())
.into_iter()
.collect();
let mut compared = 0;
let mut drifted = Vec::new();
for (name, dims) in &ours {
let key = name.replace("Benilla", "");
let Some(ref_dims) = theirs.get(&key) else {
continue; // ours alone (the local template faces) — nothing to compare against
};
compared += 1;
if dims != ref_dims && !EXPECTED.contains(&key.as_str()) {
drifted.push(format!("{key}: ours {dims:?} != ref {ref_dims:?}"));
}
}
assert!(
compared > 60,
"only {compared} elements matched by name — the scrape or the naming broke"
);
assert!(
drifted.is_empty(),
"geometry differs from the reference FrameXML:\n {}",
drifted.join("\n ")
);
super::framexml_diff::assert_geometry_matches("FriendsFrame.xml", &reference, EXPECTED, 61);
}
@@ -255,6 +255,11 @@ const UI_MANIFEST: &[&str] = &[
// NPC session — loaded after MerchantFrame (the BenillaMoney coin helpers it reuses) and
// alongside QuestFrame (same arc); the 'L' binding below calls its ToggleQuestLog().
"QuestLogFrame.xml",
// The timed-quest countdown window under the minimap (decision 1150): the reference's own
// file split. After MinimapCluster (its anchor), UIParent (the manage pass that re-seats it,
// and SecondsToTime), GameTooltip (its row hover), and QuestLogFrame — whose window a row
// click opens, and whose MAX_QUESTS it shares.
"QuestTimerFrame.xml",
"ChatFrame.xml",
// The macro window (decision 0983): the editor + its name/icon popup, driven over the
// engine's OWN macro table (`benilla_ui::script::macros` — 1.12 macros have no server
+7
View File
@@ -718,12 +718,19 @@ mod duel_tests;
#[cfg(test)]
mod enchant_confirm_tests;
/// The shared reference-geometry diff (decision 0675) every transcribed window's test calls.
#[cfg(test)]
mod framexml_diff;
#[cfg(test)]
mod friends_tests;
#[cfg(test)]
mod quest_tests;
#[cfg(test)]
mod quest_timer_tests;
#[cfg(test)]
mod durability_tests;
@@ -0,0 +1,192 @@
//! The timed-quest countdown window against the transcribed reference behaviour (decision 1150,
//! closing B234): the extracted 1.12 `QuestTimerFrame.lua` is the spec — one `SecondsToTime` line
//! per timed quest, the frame's height `45 + 16·n`, hidden entirely at zero timers, and every row
//! mapping back to its quest for the click and the hover.
//!
//! Three halves, and the last two are the load-bearing ones (`mirror_timer_tests`' lesson, and
//! 0675's): the state tests read the Lua back, which is blind to what actually *paints*; the
//! [`draw list`](UiScript::extract) test is what would catch a window that is shown, correctly
//! filled and invisible; and the geometry diff is what would catch every number being wrong while
//! all of the above passes.
use benilla_ui::script::{
QuestLogEntryView, QuestLogObjectiveView, QuestLogState, ScriptValue, UiScript,
};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// Fonts + UIParent (SecondsToTime lives there) + the window itself. Deliberately NOT the whole
/// manifest: the window must stand up on its own dependencies, and a harness that loads everything
/// hides a missing one.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "QuestTimerFrame.xml");
s
}
/// A quest-log row. `timer` is the descriptor slot's absolute deadline (0 = untimed).
fn quest(id: u32, title: &str, timer: u32) -> QuestLogEntryView {
QuestLogEntryView {
quest_id: id,
title: title.into(),
level: 10,
timer,
objectives: vec![QuestLogObjectiveView {
text: "Bloodscalp Scout slain: 0/8".into(),
kind: "monster".into(),
finished: false,
cur: 0,
req: 8,
}],
..Default::default()
}
}
fn log(entries: Vec<QuestLogEntryView>) -> QuestLogState {
QuestLogState {
num_quests: entries.len() as u32,
entries,
detail: None,
}
}
fn shown(s: &UiScript, frame: &str) -> bool {
s.eval::<bool>(&format!("return {frame}:IsShown()"))
.unwrap()
}
fn row_text(s: &UiScript, i: u32) -> String {
s.eval::<String>(&format!("return QuestTimer{i}Text:GetText()"))
.unwrap()
}
/// The reference's whole visible contract in one pass: nothing at all with no timed quest; the
/// window with one line per timed quest, in log order, captioned by `SecondsToTime`; the height
/// `45 + 16·n`; and back to hidden when the last timer goes.
#[test]
fn the_window_follows_the_timed_quests() {
let mut s = harness();
// An untimed log: QUEST_LOG_UPDATE fires, the frame stays down.
s.set_quest_log(log(vec![quest(783, "A Threat Within", 0)]));
s.set_server_unix_time(1_000_000.0);
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(!shown(&s, "QuestTimerFrame"), "no timed quest, no window");
// Two timed quests either side of an untimed one. 14:20 and 45 s out.
s.set_quest_log(log(vec![
quest(1, "Deliver the Message", 1_000_860),
quest(2, "A Threat Within", 0),
quest(3, "Escort the Caravan", 1_000_045),
]));
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(shown(&s, "QuestTimerFrame"));
assert!(shown(&s, "QuestTimer1") && shown(&s, "QuestTimer2"));
assert!(!shown(&s, "QuestTimer3"), "only two timers are live");
// SecondsToTime's shipped spelling, plural rule and trailing space, all of it (ref
// UIParent.lua:1004-1031) — over the reference's own `−1` (decision 1154), so a 860-second
// gap reads 859 and a 45-second one 44.
assert_eq!(row_text(&s, 1), "14 Mins 19 Secs ");
assert_eq!(row_text(&s, 2), "44 Secs ");
assert_eq!(
s.eval::<f64>("return QuestTimerFrame:GetHeight()").unwrap(),
45.0 + 16.0 * 2.0
);
// Row 2 belongs to the THIRD log entry — the untimed row in the middle must not shift it.
assert_eq!(s.eval::<i64>("return GetQuestIndexForTimer(2)").unwrap(), 3);
// The clock alone moves and the OnUpdate repaints — no QUEST_LOG_UPDATE, which is the whole
// point of the engine owning the subtraction.
s.set_server_unix_time(1_000_030.0);
s.tick(0.2);
assert_eq!(row_text(&s, 2), "14 Secs ");
// The last timer expires out of the list: the window goes with it.
s.set_quest_log(log(vec![quest(2, "A Threat Within", 0)]));
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(!shown(&s, "QuestTimerFrame"));
}
/// The countdown must actually reach the screen. The state test above passes just as happily for a
/// window that is shown, correctly filled, and drawing nothing — the failure mode `cast_tests`
/// and `mirror_timer_tests` were both written for.
#[test]
fn the_countdown_reaches_the_draw_list() {
let mut s = harness();
s.set_quest_log(log(vec![quest(1, "Deliver the Message", 1_000_860)]));
s.set_server_unix_time(1_000_000.0);
s.fire_event("QUEST_LOG_UPDATE", vec![]);
s.tick(0.0);
let quads = s.extract();
let text: Vec<String> = quads
.iter()
.filter_map(|q| match &q.content {
benilla_ui::script::QuadContent::Text { text, .. } => Some(text.clone()),
_ => None,
})
.flatten()
.collect();
assert!(
text.iter().any(|t| t.contains("14 Mins")),
"the countdown never reached the draw list: {text:?}"
);
assert!(
text.iter().any(|t| t == "Quest Timers"),
"the QUEST_TIMERS title never reached the draw list: {text:?}"
);
}
/// PLAYER_ENTERING_WORLD is the other event the reference registers (`QuestTimerFrame.lua:3`) —
/// a world entry with a timed quest already in the log must paint without waiting for the log to
/// change again.
#[test]
fn a_world_entry_paints_a_quest_already_running() {
let mut s = harness();
s.set_quest_log(log(vec![quest(1, "Deliver the Message", 1_000_090)]));
s.set_server_unix_time(1_000_000.0);
s.fire_event("PLAYER_ENTERING_WORLD", vec![ScriptValue::Bool(true)]);
assert!(shown(&s, "QuestTimerFrame"));
// Singular at exactly one minute — SecondsToTime's `_P1` plural rule, the ref's own; 90 s of
// gap reads 89 through the `−1`.
assert_eq!(row_text(&s, 1), "1 Min 29 Secs ");
}
/// **The reference file is the test oracle** (decision 0675). Scrapes both this window and the
/// extracted reference `QuestTimerFrame.xml` for `<AbsDimension>` pairs per named element and
/// asserts every shared element's numbers match — the guard that catches a transcription whose
/// frames all load, click and populate while every number is wrong.
///
/// Verified to fail: perturbing `QuestTimer1`'s TOP offset from -30 to -29 reports
/// `QuestTimer1: ours [(140.0, 16.0), (0.0, -29.0)] != ref [(140.0, 16.0), (0.0, -30.0)]`.
#[test]
fn the_window_geometry_matches_the_reference_framexml() {
let Some(reference) = super::framexml_diff::reference("QuestTimerFrame.xml") else {
eprintln!("skipping: no extracted FrameXML");
return;
};
// No exemptions: this window's frames keep the reference's bare names (decision 0591 §3 — the
// manage pass and the ref's own row lookup both resolve by literal name), and every number in
// it is the reference's. If an entry ever needs to go here, it names its reason.
super::framexml_diff::assert_geometry_matches("QuestTimerFrame.xml", &reference, &[], 22);
}
@@ -57,6 +57,8 @@ fn eight_entries() -> QuestLogState {
text: "Kobold Vermin slain: 3/10".into(),
kind: "monster".into(),
finished: false,
cur: 3,
req: 10,
}]
} else {
vec![]
@@ -734,6 +736,8 @@ fn overflowing_entry() -> QuestLogState {
text: format!("Objective {i} of 10 slain: 0/5"),
kind: "monster".into(),
finished: false,
cur: 0,
req: 5,
})
.collect();
QuestLogState {
@@ -1505,27 +1505,57 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
);
assert_eq!(
text(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
"72 / 100"
"72 / 100",
"bare numbers: the reference's \"Health\"/\"Rage\" labels were cut on the \
director's call (1147)"
);
assert_eq!(
text(&s, "BenillaPlayerFrameTextureFramePowerBarText"),
"45 / 80",
"the power bar reads its own resource — rage here"
"the power bar shows the number alone, whatever resource it is"
);
// The target has no numerals to pin: 1.12 never makes its bars textLockable, so its numbers
// are hover-only there — and our bars have no hover yet, which is why the text regions are not
// declared at all rather than declared and permanently dark. The wiring is unconditional though (the
// shared OnLoad runs for the target too), so the day a hover lands they are one pair of
// FontStrings away.
// The target's stay dark with the switch ON: 1.12 never makes its bars textLockable, so the
// option does not reach them.
assert!(
s.eval::<bool>(
"return BenillaTargetFrameTextureFrameHealthBarText == nil and BenillaTargetFrameHealthBar.lockShow == 0"
)
.unwrap(),
"no target numerals, but the target's bars are wired for them"
!shown(&s, "BenillaTargetFrameTextureFrameHealthBarText"),
"the switch pins your own numbers, never the target's"
);
// ...but the HOVER reveals them, which is how you read a target's health in 1.12 (1146).
s.run("BenillaUnitFrameBar_OnEnter(BenillaTargetFrameHealthBar)")
.unwrap();
assert!(shown(&s, "BenillaTargetFrameTextureFrameHealthBarText"));
assert_eq!(
text(&s, "BenillaTargetFrameTextureFrameHealthBarText"),
"50 / 100"
);
s.run("BenillaUnitFrameBar_OnLeave(BenillaTargetFrameHealthBar)")
.unwrap();
assert!(
!shown(&s, "BenillaTargetFrameTextureFrameHealthBarText"),
"and go away again — the lockShow refcount balances"
);
// The same hover works with the switch OFF, which is the point of it on YOUR frame too: the
// numbers are there when you go looking, without living on the bar.
s.run("SetCVar(\"statusBarText\", \"0\", \"STATUS_BAR_TEXT\")")
.unwrap();
s.tick(0.0);
assert!(!shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"));
s.run("BenillaUnitFrameBar_OnEnter(BenillaPlayerFrameHealthBar)")
.unwrap();
assert!(
shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
"hover shows them even with the option off"
);
s.run("BenillaUnitFrameBar_OnLeave(BenillaPlayerFrameHealthBar)")
.unwrap();
assert!(!shown(&s, "BenillaPlayerFrameTextureFrameHealthBarText"));
s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
.unwrap();
s.tick(0.0);
// A health change repaints through the bar's own OnValueChanged, like the reference's.
s.set_unit("player", Some(alive(31, 45, 1)));
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
@@ -1533,5 +1563,102 @@ fn status_bar_text_paints_the_player_numerals_but_not_the_targets() {
text(&s, "BenillaPlayerFrameTextureFrameHealthBarText"),
"31 / 100"
);
// A power-type change repaints the same way (it used to re-LABEL the bar too — 1147 cut that).
s.set_unit("player", Some(alive(31, 60, 3)));
s.fire_event("UNIT_DISPLAYPOWER", vec![ScriptValue::Str("player".into())]);
assert_eq!(
text(&s, "BenillaPlayerFrameTextureFramePowerBarText"),
"60 / 80"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The numerals do not collide** (decision 1147) — the pin for a bug the director caught on
/// screen: the pet frame's two numeral strings overlapped each other and its name.
///
/// The cause was a bad transcription, not a bad seat. 1.12's pet bars are 70×8 at `(47,-22)` and
/// `(47,-29)` — a **7 px pitch** for strings taller than that — so the reference does not centre the
/// second one: it drops it clear, to `(82,-38)`, just under a bar that ends at −37. 1143 replaced
/// those literals with "anchor each string to its own bar's centre", on a comparison that read the
/// reference's TEXT offsets against our BAR offsets and concluded the geometry differed. It does
/// not: our bars are byte-identical to the reference's, so its literals apply unchanged.
///
/// This asserts the PROPERTY, not the numbers, so it survives a future nudge (1145 moved the
/// player's pair the day before this). Text metrics come from the harness's own measurer — a
/// FontString with a single CENTER anchor and no measured size cannot pin either edge, so it falls
/// back to its owner's rect and every string would appear to sit in the same box.
#[test]
fn no_two_numeral_strings_overlap_on_any_frame() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_unit_frames(&s);
let alive = |health: u32, power: u32| UnitState {
exists: true,
name: Some("Onehunter".into()),
health,
max_health: health,
level: 40,
power_type: 0,
power,
max_power: power,
dead: false,
reaction: 4,
..UnitState::default()
};
s.set_unit("player", Some(alive(4122, 3300)));
s.set_unit("target", Some(alive(4122, 3300)));
s.set_unit("pet", Some(alive(256, 100)));
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
s.register_cvars([("statusBarText", "0")]);
s.run("SetCVar(\"statusBarText\", \"1\", \"STATUS_BAR_TEXT\")")
.unwrap();
s.tick(0.0);
// The host's half of the measure round-trip, at the numerals' real font size: NumberFontNormal
// is ~14 px tall, and a digit runs ~7 px wide. The round-trip answers a frame late, so settle.
for _ in 0..4 {
let answers: Vec<(u32, f32, f32, u64)> = s
.fontstrings_needing_measure()
.into_iter()
.map(|r| (r.id, r.text.chars().count() as f32 * 7.0, 14.0, r.key))
.collect();
s.set_measured_text_unwrapped(&answers);
s.tick(0.05);
s.resolve();
}
// The pin is the SEPARATION between the two numeral seats, which is the thing that broke and
// the thing the reference authored deliberately. Not "the line boxes must not overlap": at a
// 14 px NumberFontNormal the reference's own player seats are 12 px apart, so the boxes DO
// overlap there while the ink (a digit's ~10 px cap height, centred) does not. Only a seat
// pitch well under the font is a real collision — the pet's was 7 px.
for (frame, upper, lower, want) in [
(
"player",
"BenillaPlayerFrameTextureFrameHealthBarText",
"BenillaPlayerFrameTextureFramePowerBarText",
12.0,
),
(
"pet",
"BenillaPetFrameTextureFrameHealthBarText",
"BenillaPetFrameTextureFrameManaBarText",
11.0,
),
] {
let mid = |s: &UiScript, n: &str| -> f64 {
s.eval::<f64>(&format!("return ({n}:GetTop() + {n}:GetBottom()) / 2"))
.unwrap()
};
let apart = mid(&s, upper) - mid(&s, lower);
assert!(
(apart - want).abs() < 0.51,
"{frame}: the numerals sit {apart:.1} px apart, the reference authors {want:.0}"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+10 -7
View File
@@ -11,6 +11,7 @@
//! Lock.dbc requirement lines ("Requires <key item>" / "Requires <skill>") — the verified
//! `0x52aa20` law. The standalone-corpse builder joins when corpse objects stream.
use crate::ui_items::{count_of, InventoryScope};
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
@@ -233,9 +234,9 @@ fn spell_tooltip_view(
} else {
name
};
let short = vctx
.store
.is_some_and(|s| crate::ui_items::count_of(&s.0, vctx.items, entry) < count);
let short = vctx.store.is_some_and(|s| {
count_of(&s.0, vctx.items, entry, InventoryScope::CARRIED) < count
});
parts.push(if short {
format!("|cffff2020{text}|r")
} else {
@@ -468,7 +469,9 @@ fn feed_spell_tooltips(
.into_iter()
.map(|entry| {
let named = items.template(entry, 0, &commands).is_some();
let owned = self_store.map_or(0, |s| crate::ui_items::count_of(&s.0, &items, entry));
let owned = self_store.map_or(0, |s| {
count_of(&s.0, &items, entry, InventoryScope::CARRIED)
});
(entry, (owned, named))
})
.collect();
@@ -494,9 +497,9 @@ fn feed_spell_tooltips(
memory.reagents.entry(entry)
{
let named = vctx.items.template(entry, 0, vctx.commands).is_some();
let owned = vctx
.store
.map_or(0, |s| crate::ui_items::count_of(&s.0, vctx.items, entry));
let owned = vctx.store.map_or(0, |s| {
count_of(&s.0, vctx.items, entry, InventoryScope::CARRIED)
});
slot.insert((owned, named));
}
}
+3 -3
View File
@@ -38,7 +38,7 @@ use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::ui_action::{cast_target, CastCommit, CastLadder, PlayerActions, Spells};
use crate::ui_items::{count_of, item_icon};
use crate::ui_items::{count_of, item_icon, InventoryScope};
use crate::ui_script::UiInput;
use crate::ui_spellbook::SkillLines;
use crate::ui_unit::UnitFeed;
@@ -259,7 +259,7 @@ fn resolve_recipe(
let mut reagents = Vec::new();
let mut num_available = u32::MAX;
for &(entry, need) in d.reagents.iter().filter(|&&(e, n)| e != 0 && n != 0) {
let have = count_of(&store.0, items, entry);
let have = count_of(&store.0, items, entry, InventoryScope::CARRIED);
let (name, icon) = match items.template(entry, 0, commands) {
Some(t) => (Some(t.name.clone()), item_icon(icons, t.display_info_id)),
None => (None, None),
@@ -314,7 +314,7 @@ fn resolve_recipe(
// (Anvil/Forge/Cooking Fire; never red — module doc INTERIM).
let mut tools = Vec::new();
for &t in d.totems.iter().filter(|&&t| t != 0) {
let have = count_of(&store.0, items, t) > 0;
let have = count_of(&store.0, items, t, InventoryScope::CARRIED) > 0;
if let Some(info) = items.template(t, 0, commands) {
tools.push((info.name.clone(), have));
}
+1
View File
@@ -568,6 +568,7 @@ fn push_water_foam(
anchor: centroid / n as f32,
bias: FOAM_BIAS,
raster_bias: 0,
cam_relative: false,
// NEVER the chunk entity: a draw's probe identity must not own a registered
// mesh, or bevy's sorted-phase batcher claims the item and rewrites its
// `batch_range` (gpu_preprocessing.rs keys purely on `item.main_entity()`) —
+64 -1
View File
@@ -142,6 +142,32 @@ fn first_invalid_group(path: &[f32]) -> Option<usize> {
None
}
/// The mist's spawn accumulator — `acc := min(acc + dt·rate, MIST_CAP)` (`0x67b141`–`0x67b1aa`).
///
/// **This is the one precip layer whose density survives a frame-rate drop**, and the reason is
/// this function. The drop kernels budget `rate · min(dt, 1/60)` and *discard* the remainder
/// (verified: the constant at `0x80ffcc` has exactly six sites, one `fcomp`/`fld` pair per effect,
/// and the count lands in a stack local that never persists) — so streaks and flakes thin in
/// proportion to `fps/60`. The mist instead multiplies the **raw, uncapped** dt (`0xc62510` at
/// `0x67b141`, absent from that six-site census), adds the carry, and stores it back
/// (`0x67b172`), spending exactly one whole node per free slot (`0x67b261`: `fsub 1.0`) and
/// keeping the fraction. A hitch therefore shifts the storm's **composition**, not just its
/// density — the mist holds while the flakes thin.
///
/// The ceiling is `[mist+0x48]`, the **size field of the node container** at `mist+0x44`, written
/// once from the ctor's 8th argument (`0x67a724`) which is `push 0x80` = **128 at all three call
/// sites** (`0x674645` rain, `0x677448` snow, `0x679149` sand). It is a spiral-of-death guard and
/// never a budget cap: the drain can only spend into a free slot and there are [`MIST_CAP`] of
/// them, so 128 is the *smallest* ceiling that discards nothing the array could have absorbed.
///
/// benilla carried a bare, uncited `8.0` here. At the shader leg's 38 nodes/s that saturates after
/// a **0.21 s** hitch (0.32 s at the CVar's default gain), against the binary's 3.4 s — quietly
/// re-introducing the very frame-rate coupling the accumulator exists to prevent, and *only* on
/// hitches. Decision 1159's follow-up.
fn accrue(budget: f32, rate: f32, dt: f32) -> f32 {
(budget + rate * dt).min(MIST_CAP as f32)
}
/// The mist companion's frame: spawn-rate accumulator `budget += dt·2·max(density−0.5, 0)·K·Q`
/// (the arg is the RAMPED effect density — the same `effect+0xd0` value the drop kinematics
/// read, so the knee sits on the density, not the raw wire grade), one node per unit, cap 128;
@@ -181,7 +207,7 @@ pub(super) fn run_mist(
WeatherKind::Snow => MIST_R_SNOW,
_ => MIST_R_RAIN,
};
mist.budget = (mist.budget + rate * dt).min(8.0);
mist.budget = accrue(mist.budget, rate, dt);
let yaw = wind.mist_yaw();
while mist.budget >= 1.0 && mist.nodes.len() < MIST_CAP {
mist.budget -= 1.0;
@@ -304,6 +330,43 @@ pub(super) fn push_mist(
mod tests {
use super::*;
/// The mist accumulator carries a hitch's whole backlog, where the drop kernels would have
/// thrown it away — the one place precip density is frame-rate-independent.
///
/// The old uncited `8.0` ceiling clipped that at a fifth of a second of stall. Pinned in the
/// units that matter: the shader leg's 38 nodes/s against the byte ceiling of [`MIST_CAP`].
#[test]
fn a_hitch_keeps_its_mist_backlog_up_to_the_node_capacity() {
let rate = 2.0 * (1.0 - 0.5) * 1.0 * MIST_Q; // full density, weatherDensity 3 → 38/s
// A 1 s stall: every node owed is still owed afterwards. The retired `8.0` kept 8 of 38.
let after = accrue(0.0, rate, 1.0);
assert!(
(after - rate).abs() < 1e-3,
"a 1 s stall owes {rate} nodes, accumulator kept {after}"
);
// The ceiling is the node capacity, and it is a spiral guard, not a budget cap: nothing
// above `MIST_CAP` could ever be spent, because the drain needs a free slot per node.
assert_eq!(accrue(0.0, rate, 60.0), MIST_CAP as f32);
assert!(
accrue(0.0, rate, 3.0) < MIST_CAP as f32,
"a 3 s stall must still be carried whole — the binary's ceiling is 3.4 s at this rate"
);
// The fraction is kept across frames rather than truncated (`0x67b172` stores it back).
let dt = 1.0 / 60.0;
let (mut budget, mut spawned) = (0.0f32, 0u32);
for _ in 0..60 {
budget = accrue(budget, rate, dt);
while budget >= 1.0 {
budget -= 1.0;
spawned += 1;
}
}
assert_eq!(
spawned, 38,
"a second at 38 nodes/s must spawn 38, not 30-odd"
);
}
/// The slope-validity walk (`0x67a8e1–0x67a933`): flat terrain passes whole; a roof-height
/// jump within 3 cells of spawn stillbirths (the k=3 delta trips at m=0); farther jumps
/// truncate at the first group that sees them.
+194 -40
View File
@@ -6,17 +6,31 @@
//! (constants, rates, gates, blend states) are the reference's, the x87 quirks are not.
//!
//! The verified structure:
//! - **Rain**: drops scatter on the CAMERA-EYE plane across 130×130 yd, then **back-project up
//! the velocity to the box top** — `T = −37.5/Vz`, spawn = seed − T·V, so every drop starts
//! **37.5 yd above the eye** and passes its eye-plane scatter point at t = T (`0x674df6–e53`;
//! - **Placement** is one law for both kinds ([`pool::spawn_particle`], wow-re
//! `wx-snow-placement-law.md`): `pos = R·(O − T·V) + 1.75·W + C`. The scatter `O` lies on the
//! plane the particle *arrives* on; `−T·V` back-projects it up its own velocity; **`R` leans the
//! whole slab into the direction of travel** by `65°·sat(speed/18)`; then the wind lead and the
//! live camera eye. `R` was missing until decision 1159 and its absence IS B233's second half —
//! without it a particle needs its entire fall to reach eye height, which a running player
//! outpaces (snow at grade 0.6: 7.8 s of fall against 54 yd of running, versus a 45 yd box).
//! - **Rain**: drops scatter across 130×130 yd, `T = −37.5/Vz` (`0x674df6–e53`;
//! rf-weather-emission-timeline Q5 — the old "spawns AT eye height" read killed only the box's
//! z-RANDOM, not the constant lift, and made density camera-angle-dependent). Each drop dies
//! at the **terrain** under its spawn column and leaves a 0.25 s *patter* splash **1:1** (calm
//! wind only). Streaks draw as fixed-size comet-tail TRIANGLES (0.1 wide × 2.0 long) with
//! **no vertex colour** — the look is `RainDrop01.blp` (authored grey-128-neutral) under
//! **Mod2x** (`2·src·dst`) and a **forced grey fog** over 70..75 yd that IS the distance fade.
//! - **Snow**: same rails, 90×90 box, spawn plane +30, slow wandering kinematics, alpha-blended
//! flake quads (half 1/12), fog off.
//! - **Snow**: same rails, 90×90 box, slab lift +30, slow wandering kinematics — but a
//! completely different DRAW. The leg that runs is the ARB **point sprite** `0x678610`
//! (`glDrawArrays(GL_POINTS)`, `snowpoint.bls`), so a flake is sized in **pixels**,
//! `max(1, 14·clamp01(1 − 0.02·d))` — denominated in the ERA's screen height so the angle, not
//! the pixel count, carries to a modern display ([`SNOW_PX_REF_HEIGHT`], decision 1162) — with
//! alpha `clamp01(t − f1)` falling (a 1 s fade-in) and
//! `clamp01(1 − 4·(t − f2))` settled. Blend mode 2 (SrcAlpha, 1−SrcAlpha), fog OFF,
//! depth-write off, RGB white. The `1/12` world-space triangle of `0x678960` is the
//! fixed-function FALLBACK and never runs on real hardware — benilla drew it (as a *quad*,
//! times an invented size jitter) while taking the shader leg's population, which is B233's
//! "flakes seem too big" (decision 1149; wow-re `rf-snow-flake-render.md`).
//! - **Mist**: every precip type carries a companion mist — its own object in the reference
//! (ctor `0x67a5b0`, spawn `0x67a990`, render `0x67ae20`); the law lives in [`mist`].
//! - Ground heights come from a **lazy height cache** of the reference's weather ground
@@ -70,8 +84,8 @@ mod pool;
mod render;
mod wind;
use mist::{push_mist, run_mist, Mist};
use pool::{run_kind, HeightCache, Pool};
use render::{push_flakes, push_patters, push_streaks};
use pool::{census, run_kind, HeightCache, Pool};
use render::{push_flakes, push_patters, push_streaks, FlakeView};
pub(crate) use wind::{wow_azimuth_to_bevy, WeatherWind};
// ===== The reference's constants (byte-cited; render laws from wow-re rf-weather-render.md,
@@ -93,8 +107,36 @@ const SHADER_LEG: bool = true;
/// `effect+0x70`), so the steady population is demand-bounded (`rate · fall_time`).
const PACKET_CAP: usize = 0x1800;
/// Benilla's live+pending bound per effect (the reference has none — allocator-bounded): the
/// leg's steady demand plus one packet of pipeline lag, with headroom.
const POOL: usize = if SHADER_LEG { 0xC000 } else { 0x4000 };
/// leg's steady demand `rate · fall_time` plus one packet of pipeline lag, with headroom.
///
/// **Per kind, because the two demands differ by 1.7×** and one shared number silently clipped
/// snow. Rain falls at ~28 yd/s from +37.5, so `35000 · 1.34 s ≈ 47k`; snow *sinks* at 5.5–6.5
/// from +30, so `14000 · ~5.5 s ≈ 76k` — against the old shared `0xC000` = 49152 the snow field
/// saturated, emission stalled at the bound, and the sky held ~64% of the reference's flakes.
/// The vertex cost is the reason to keep these tight: the reference draws one *point* per flake
/// (`glDrawArrays(GL_POINTS)`, 32 B), while benilla's shared effect stream draws a quad, so every
/// flake costs 4 vertices here.
const fn pool_bound(kind: WeatherKind) -> usize {
match kind {
WeatherKind::Snow => {
if SHADER_LEG {
0x18000
} else {
0x4000
}
}
_ => {
if SHADER_LEG {
0xC000
} else {
0x4000
}
}
}
}
/// The largest pool bound — the `take(…)` guard on a push, where the kind is already implied by
/// which pool is being drawn.
const POOL: usize = pool_bound(WeatherKind::Snow);
// Density gain `K` (`rate = K·P·grade` per second) is the live `weatherDensity` setting's table
// entry — `WeatherState::density_gain()` (`0x67b870`: 0.1/0.33/0.66/1.0 for setting 0–3). The
// transcriptions carried quality 2's 0.66 as a baked constant; the reference runs at 3 (K=1.0).
@@ -142,13 +184,18 @@ const PATTER_UP: f32 = 1.0 / 6.0;
/// snow 90×90 (`0x42b40000`).
const RAIN_HALF_XY: f32 = 65.0;
const SNOW_HALF_XY: f32 = 45.0;
/// Spawn-plane lift above the camera eye — HALF the ctor vertical extent (rain 75 → +37.5,
/// The slab's lift in **slab-local** space — HALF the ctor vertical extent (rain 75 → +37.5,
/// snow 60 → +30). Q5-CORRECTED (wow-re rf-weather-emission-timeline): the box's z-RANDOM is
/// dead (`·0.0`) but the constant lift is not — `0x674df6–e53` builds `T = −37.5/Vz` and seeds
/// `local.z = −T·Vz = +37.5`, back-projecting the drop so its trajectory passes the eye-plane
/// `local.z = −T·Vz = +37.5`, back-projecting the drop so its trajectory passes the arrival-plane
/// scatter point at t = T. The earlier "spawns AT eye height" read (rf-weather-render Q4) was
/// the bug behind camera-angle-dependent density, the precip-free view above the horizon, and
/// uphill terrain getting no rain (director-caught, 2026-07-12).
///
/// **It is not a world height.** The slab tilt rotates the local offset, so the realised spawn
/// heights fan out to `z_off·cos α ∓ half_xy·sin α` — ~8..46 yd for snow at a 7 yd/s run, and the
/// leading edge dips *below* the eye entirely past ~13 yd/s. Reading this as "the plane the
/// particles are born on" is what made the untilted model look self-consistent (decision 1159).
const RAIN_Z_OFF: f32 = 37.5;
const SNOW_Z_OFF: f32 = 30.0;
/// Snow fall speed: `vz = −2 − 3.5m − m·r` (`wx_snowspawn.rs` R3c: 2.0/3.5 bit-cited) — calm
@@ -161,15 +208,62 @@ const SNOW_DRIFT_EPS: f32 = 0.015;
/// Snow heading spread: `2π − 5.934·m` (R3a, `0x80fff4`) — calm snow wanders in ANY direction,
/// a blizzard's flakes align to ±10° of the wind.
const SNOW_SPREAD_W: f32 = f32::from_bits(0x40bd_e44f); // ≈ 5.9341197
/// Snow flake quad half-size — the snow quad pass builds its corner basis ·(1/12) (`0x678960`
/// region 1; the 0.05 previously here was the RAIN streak half-width — the ledger's rain/snow
/// mislabel, corrected by rf-weather-render).
const SNOW_HALF: f32 = 1.0 / 12.0;
/// Snow flake size — **`snowpoint.bls`'s point-size law, in WINDOW PIXELS** (wow-re
/// `rf-snow-flake-render.md` §2.4, read at the shipped shader's bytes):
///
/// ```text
/// pointsize(d) = max(1.0, 14.0 · clamp01(1 − 0.02·d)) pixels, d = |flake − eye| in yards
/// ```
///
/// The snow leg that RUNS is `0x678610`, the ARB **point-sprite** pass
/// (`glDrawArrays(GL_POINTS)`, one vertex per flake, `GL_COORD_REPLACE` texcoords,
/// `GL_VERTEX_PROGRAM_POINT_SIZE_ARB`); `0x678960`'s 1/12 world-space triangle is the
/// fixed-function fallback, dead on any GPU since ~2002 (ctor `0x677420` clears the leg flag only
/// when `snowpoint.bls` fails to compile, `GL_ARB_point_sprite` is missing, or
/// `useWeatherShaders` is 0). benilla drew the *fallback's* geometry while taking the *shader*
/// leg's population — the two mutually exclusive sides of `0x6790c1` — and inflated it further
/// with an invented per-flake size jitter; that pairing is what made the flakes read as far too
/// big (B233, decision 1149).
///
/// **This is not a world size, and it is not `∝ 1/d`.** A flake 1 yd away is 14 px and one 30 yd
/// away is 5.6 px — near flakes far smaller, and distant flakes far larger, than any fixed
/// world-space quad. wgpu has no point-sprite size (WebGPU pins `PointList` at 1 px), so
/// [`render::push_flakes`] reproduces the law by inverting the projection: a quad of world
/// half-extent `px · z_view · tan(fovY/2) / viewport_height_px` covers exactly `px` pixels.
const SNOW_PX_AT_EYE: f32 = 14.0;
/// The screen height those pixels were authored against — **the resolution the size law is
/// implicitly denominated in**, and the one term that has to be ours rather than the reference's.
///
/// `snowpoint.bls` sizes a flake in *framebuffer pixels*, so its **angular** size is
/// `14 / framebuffer_height`. That was never a choice about looks — it is simply how
/// `GL_VERTEX_PROGRAM_POINT_SIZE_ARB` works — and on 2004 hardware the framebuffer *was* the
/// screen. Obey the number literally on a modern display and the flakes shrink in proportion to
/// how good the monitor is: at the reference install's own `gxResolution 1280x800` a near flake
/// spans `14/800` = **1.75%** of frame height, while benilla on a scale-factor-2 4K panel
/// (physical ≈ 2144 tall) spans `14/2144` = **0.65%** — **2.7× smaller**, from resolution alone.
/// That is B233's "the size of the snow is way too small" (director A/B, decision 1162), and it is
/// the same failure mode `lib.rs` already records for FFXGlow's texel-pinned blur geometry.
///
/// So the law is evaluated in **era pixels** and converted to an angle, which makes the sprite
/// resolution-independent: the live viewport height cancels out of
/// `px · z · tan(fovY/2) / height` entirely. At an 800-px-tall window benilla draws exactly the
/// reference's 14 physical pixels; at 4K it draws 37 physical pixels covering the same angle.
///
/// **This is the number to turn if the flakes still read wrong.** It is a look constant, not a
/// byte-cited one — 800 is this install's `Config.wtf`, i.e. the resolution the director's own
/// side-by-side was captured at. A different era resolution is a different (equally faithful)
/// look: 640×480 would make them 1.67× bigger again.
const SNOW_PX_REF_HEIGHT: f32 = 800.0;
/// The falloff slope: `1 − 0.02·d` reaches the 1 px floor at `d = 13/0.28 ≈ 46.4` yd.
const SNOW_PX_FALLOFF: f32 = 0.02;
/// `max(1.0, …)` — the floor the ARB program applies last.
const SNOW_PX_MIN: f32 = 1.0;
/// A settled flake fades over 0.25 s (`snow_patter_value`: `max(t,0) + 0.25`).
const SNOW_SETTLE_LIFE: f32 = 0.25;
/// Snow blend = mode 2 (SrcAlpha, 1−SrcAlpha), FOG OFF (rf-weather-render Q3) — the alpha blend
/// benilla first gave rain belongs to snow. Flake alpha is the texture's own (vertex white).
const SNOW_ALPHA: f32 = 1.0;
/// A FALLING flake fades **in** over its first second — the vertex program's
/// `alpha = clamp01(t − f1)` (rf-snow-flake-render §2.4). benilla drew every falling flake at
/// alpha 1.0, so the top of the column was a hard-edged sheet instead of a soft one.
const SNOW_FADE_IN: f32 = 1.0;
/// The precipitation state: per-kind pools + the mist companion + the shared xorshift RNG.
#[derive(Resource)]
@@ -238,18 +332,24 @@ fn setup_precip(
if existing.is_some() || cam.single().is_err() {
return;
}
// Streaks keep the authored mips (a solid bar survives minification); the flake + splash
// cut-outs load mip-0-only (`BlpVariant::Effect`) — their thin arms collapse to near-zero
// alpha under the mip chain and vanish at a few pixels (the reference's point-sprite path
// never minifies them).
// Streaks keep the authored mips (a solid bar survives minification); the splash + mist
// cut-outs load mip-0-only (`BlpVariant::Effect`) — they draw as world-space quads that are
// magnified far more often than minified, and their thin arms collapse under the mip chain.
// The FLAKE is the exception (`BlpVariant::PointSprite`): the reference's point sprite is
// 14 px at the eye and 1 px past 46 yd — a 4.5×–64× minification of a 64×64 texture, which
// is why the asset ships 7 mips. Mip-0-only there is flickering speckle, not crispness.
let effect = |s: &mut benilla_assets::BlpLoaderSettings| {
s.variant = benilla_assets::BlpVariant::Effect;
};
let point_sprite = |s: &mut benilla_assets::BlpLoaderSettings| {
s.variant = benilla_assets::BlpVariant::PointSprite;
};
commands.insert_resource(PrecipAssets {
streak: asset_server.load("mpq://textures/weather/raindrop01.blp"),
splash: asset_server
.load_with_settings("mpq://textures/weather/raindropsplash01.blp", effect),
flake: asset_server.load_with_settings("mpq://textures/weather/snowflake01.blp", effect),
flake: asset_server
.load_with_settings("mpq://textures/weather/snowflake01.blp", point_sprite),
mist: asset_server.load_with_settings("mpq://textures/weather/snowmist01.blp", effect),
});
commands.insert_resource(Precip {
@@ -268,12 +368,18 @@ fn simulate_precip(
weather: Res<WeatherState>,
cam: Query<&GlobalTransform, With<WorldCamera>>,
player: Query<&Transform, (With<crate::net::SelfPlayer>, Without<WorldCamera>)>,
// The commanded planar speed the spawn slab's tilt keys on (`mgr+0x7c`) — live, not the
// wind's 149 ms average.
avatar: Res<crate::player::Player>,
spatial: SpatialQuery,
indoors: Res<WeatherIndoors>,
mut wind: ResMut<WeatherWind>,
mut heights: ResMut<HeightCache>,
mut precip: Option<ResMut<Precip>>,
mut last_cut: Local<u32>,
// Frames since the previous census — the spawn budget scales with frame rate, so two census
// lines are only comparable at equal frame counts (see [`pool::census`]).
mut census_frames: Local<u32>,
) {
let Some(precip) = precip.as_deref_mut() else {
return;
@@ -292,9 +398,14 @@ fn simulate_precip(
let now = time.elapsed_secs();
let cam_pos = cam_tf.translation();
// The wind tracks the local PLAYER's motion (object 0x8fe) — orbiting the camera does not
// stir it. The camera stands in only when no player entity exists (captures, data-less).
let wind_pos = player.single().map_or(cam_pos, |t| t.translation);
wind.update(wind_pos, dt);
// stir it. The camera stands in only when no player entity exists (captures, data-less), and
// then its own forward is the heading's sub-1-yd/s source.
let (wind_pos, facing) = player
.single()
.map_or((cam_pos, cam_tf.forward().as_vec3()), |t| {
(t.translation, t.forward().as_vec3())
});
wind.update(wind_pos, facing, avatar.planar_speed(), dt);
let filter = player_query_filter();
let Precip {
@@ -345,6 +456,7 @@ fn simulate_precip(
// Throttled 1 Hz state log — the capture/live diagnosis instrument (debug builds only
// chatter when something is falling or queued in the packet pipeline). Second-resolution
// deliberately: the pipeline's visible-onset instant is what look-checks time against.
*census_frames += 1;
if (weather.effect_density > 0.0
|| !rain.drops.is_empty()
|| !snow.drops.is_empty()
@@ -362,6 +474,20 @@ fn simulate_precip(
snow.patters.len(),
weather.effect_density,
);
// …and WHERE those drops are relative to the player, which the counts cannot say. The
// split axis is the player's heading while they move and their view heading while they
// stand, so the standing reading is the ~50/50 baseline the moving one is read against.
let motion = wind.vel.with_y(0.0);
let axis = motion
.try_normalize()
.or_else(|| cam_tf.forward().as_vec3().with_y(0.0).try_normalize())
.unwrap_or(Vec3::Z);
for (name, pool) in [("rain", &*rain), ("snow", &*snow)] {
if let Some(c) = census(&pool.drops, cam_pos, axis, motion.length(), *census_frames) {
debug!("weather field ({name}): {c}");
}
}
*census_frames = 0;
}
// ===== the mist companion (rf-weather-render Q6) =====
@@ -395,7 +521,7 @@ fn push_precip(
indoors: Res<WeatherIndoors>,
lighting: Res<WowLighting>,
wind: Res<WeatherWind>,
cam: Query<(Entity, &GlobalTransform), With<WorldCamera>>,
cam: Query<(Entity, &GlobalTransform, &Camera, &Projection), With<WorldCamera>>,
mut quads: ResMut<EffectQuads>,
) {
let (Some(precip), Some(assets)) = (precip, assets) else {
@@ -404,12 +530,31 @@ fn push_precip(
if indoors.0 {
return;
}
let Ok((cam_entity, cam_tf)) = cam.single() else {
let Ok((cam_entity, cam_tf, camera, proj)) = cam.single() else {
return;
};
let cam_pos = cam_tf.translation();
let cam_right = cam_tf.right().as_vec3();
let cam_up = cam_tf.up().as_vec3();
// Snow's size law is in ERA PIXELS (`snowpoint.bls` sizes in framebuffer pixels; benilla
// denominates them in the era's screen height so the *angle* carries over — see
// [`SNOW_PX_REF_HEIGHT`]). The live viewport is still required, but only as the "is this camera
// actually drawing?" gate: a camera with no sized target draws nothing this frame anyway.
let flake_view = camera
.physical_viewport_size()
.filter(|vp| vp.y > 0)
.map(|_| FlakeView {
eye: cam_pos,
forward: cam_tf.forward().as_vec3(),
right: cam_right,
up: cam_up,
world_per_px: match proj {
Projection::Perspective(p) => (p.fov * 0.5).tan(),
// An orthographic world camera has no perspective divide; the reference has no
// such mode, so there is nothing to be faithful to — keep the sprites sane.
_ => (crate::player::CAM_FOVY * 0.5).tan(),
} / SNOW_PX_REF_HEIGHT,
});
let spec = |texture: &Handle<Image>, blend: EffectBlend, fog: EffectFog| EffectDrawSpec {
cam: cam_entity,
texture: texture.id(),
@@ -421,6 +566,9 @@ fn push_precip(
anchor: cam_pos,
bias: 0.0,
raster_bias: 0,
// Streaks, patters and mist are all centimetre-scale or bigger, so absolute world verts
// cost them nothing; the flake draw below overrides this (its quads are millimetres).
cam_relative: false,
main_entity: Entity::PLACEHOLDER,
light: None,
};
@@ -436,18 +584,24 @@ fn push_precip(
start,
spec(&assets.splash, EffectBlend::Mod2x, EffectFog::Rain),
);
let start = quads.begin();
push_flakes(
&mut quads.verts,
&precip.snow.drops,
&precip.snow.patters,
cam_right,
cam_up,
);
quads.commit_quads(
start,
spec(&assets.flake, EffectBlend::Alpha, EffectFog::Off),
);
if let Some(view) = &flake_view {
let start = quads.begin();
push_flakes(
&mut quads.verts,
&precip.snow.drops,
&precip.snow.patters,
view,
);
quads.commit_quads(
start,
EffectDrawSpec {
// [`push_flakes`] wrote camera-relative offsets — the only family in the lane
// whose geometry is small enough for absolute-world f32 to lose it.
cam_relative: true,
..spec(&assets.flake, EffectBlend::Alpha, EffectFog::Off)
},
);
}
let start = quads.begin();
push_mist(
&mut quads.verts,
+325 -40
View File
@@ -21,8 +21,11 @@ pub(super) struct Drop {
pub(super) land_y: f32,
/// The grid cell `land_y` was sampled for (see [`HeightCache`]).
pub(super) cell: (i32, i32),
/// Per-particle size jitter (flake quads; 1.0 for rain streaks).
pub(super) size: f32,
/// Seconds since this particle became VISIBLE — the reference's `t − f1`, where `f1` is the
/// flake's birth stamp on its packet's clock. Snow's fade-in reads it (`rf-snow-flake-render`
/// §2.4); rain's streaks have no age term and ignore it. A record that replays late starts
/// at its own lag, exactly as `c_currentTime − f1` would.
pub(super) age: f32,
}
/// A recorded drop waiting in the packet pipeline: replays at `at` (its packet's `baseTime`
@@ -179,6 +182,71 @@ impl HeightCache {
}
}
/// The eye band a census splits on: ±3 yd of the camera's height — the slice a player actually
/// looks *through*, as opposed to the column of sky above them.
const CENSUS_BAND: f32 = 3.0;
/// The near-field radius a census counts: the sphere whose flakes dominate the look (the point
/// sprite is 14 px at the eye and under 5 px past 30 yd).
const CENSUS_NEAR: f32 = 15.0;
/// A one-line **spatial** census of a live drop field, relative to the camera — the instrument
/// for *"can you outrun the weather?"*, which the pool COUNTS alone cannot answer.
///
/// `axis` is a horizontal unit vector: the player's motion heading while they move, their view
/// heading while they stand.
///
/// **`fwd`/`bwd` is the metric — read it first.** It splits the eye band about the plane through
/// the camera ⊥ `axis`, reads ~50/50 on a centred field whatever the heading, and is what actually
/// answers "is there weather in front of me". Measured across the 1159 fix: 3200/3350 standing,
/// collapsing to 0 while running before the spawn-slab tilt and holding ~2115 after it.
///
/// **`centroid` is a weak second, and is easy to misread — it was, once.** Two biases sit on it:
/// the drift heading is a fixed WORLD azimuth, so even a standing field's centroid sits
/// `drift · mean_age` off along it (−5.5 yd at wire grade 0.6, −14 at 1.0, depending on which way
/// the player faces); and it is *count-weighted*, so long-lived particles born high and to the rear
/// dominate it. Under the slab tilt those two effects nearly cancel the forward shift at low grade:
/// a Monte-Carlo of [`spawn_particle`] predicts the running centroid improving only 9% at grade 0.6
/// against 49% at 1.0, and the live client measured 11% and 61% — agreeing with the law while
/// looking, on the centroid alone, like the fix had barely worked. Never conclude from it alone.
///
/// `frames` is the count since the previous census, and it is **not decoration**: `run_kind`'s
/// budget is `rate · min(dt, 1/60)` with the remainder dropped, so emission per second scales with
/// frame rate and a sub-60 fps leg measures a genuinely thinner field than a 60 fps one on
/// identical code (measured: ~43 fps legs read 48% of ~59 fps legs). Two census lines are only
/// comparable at equal `frames`.
pub(super) fn census(
drops: &[Drop],
cam: Vec3,
axis: Vec3,
speed: f32,
frames: u32,
) -> Option<String> {
if drops.is_empty() {
return None;
}
let (mut sum, mut near, mut fwd, mut bwd) = (Vec3::ZERO, 0usize, 0usize, 0usize);
for d in drops {
let rel = d.pos - cam;
sum += rel;
if rel.length_squared() <= CENSUS_NEAR * CENSUS_NEAR {
near += 1;
}
if rel.y.abs() <= CENSUS_BAND {
if rel.dot(axis) > 0.0 {
fwd += 1;
} else {
bwd += 1;
}
}
}
let along = (sum / drops.len() as f32).dot(axis);
Some(format!(
"{} live, eye-band fwd {fwd} / bwd {bwd}, near({CENSUS_NEAR:.0} yd) {near}, \
centroid {along:+.1} yd along heading (moving {speed:.1} yd/s, {frames} fps)",
drops.len(),
))
}
/// The reference's per-frame record count (`0x6754cc–ef`): `fistp(min(space, quota) − 0.5)`
/// under round-nearest-EVEN — the x87 default. The parity matters: at `space = 1` this is
/// `RNE(0.5) = 0` FOREVER, so a packet that lands on 6143 records can never full-close and
@@ -188,6 +256,76 @@ fn frame_count(space: usize, quota: f32) -> usize {
((space as f32).min(quota) - 0.5).round_ties_even().max(0.0) as usize
}
/// One kind's spawn box: `(half_xy, z_off)` — the horizontal half-extent of the scatter plane
/// and the slab's local lift above it (HALF the ctor's vertical extent; see [`RAIN_Z_OFF`]).
pub(super) const fn spawn_box(kind: WeatherKind) -> (f32, f32) {
match kind {
WeatherKind::Rain => (RAIN_HALF_XY, RAIN_Z_OFF),
_ => (SNOW_HALF_XY, SNOW_Z_OFF),
}
}
/// One particle's **placement law**, as a pure function of its five RNG draws — the reference's
/// composed spawn (wow-re `wx-snow-placement-law.md`, `0x677750` snow / `0x674c50` rain):
///
/// ```text
/// pos = R(α, ĥ×ŷ)·(O − T·V) + 1.75·W + C
/// ```
///
/// - `O` — the scatter, uniform over `±half_xy` on a **flat plane through the eye**, its vertical
/// random dead (`·0.0` at `0x6777ce`). This is the plane the particle *arrives* on, not the one
/// it is born on.
/// - `T = z_off/|V.y|`, `O − T·V` — the back-projection up the particle's own velocity, so it
/// starts `z_off` above the plane **in slab-local space** and passes its scatter point at `t = T`.
/// - **`R` — the slab tilt into the direction of travel, and the term benilla was missing.**
/// `α = 65°·sat(|W|/18)` about the horizontal axis ⊥ the heading ([`WeatherWind::slab`]).
/// It rotates the **local offset only**: the velocity is written once at spawn and never
/// re-touched, so drift stays world-fixed (`0x677a41` copies 3 dwords — position, not velocity).
/// - `1.75·W + C` — the wind lead and the live camera eye, added AFTER the rotation, so the
/// volume's origin rides the player while its *shape* leans.
///
/// Why the missing `R` was the whole of B233's "I can outrun the snow": untilted, every particle
/// is born a flat `z_off` up and needs the full `T` to reach eye height — 7.8 s for snow at grade
/// 0.6, during which a running player covers 54 yd against a 45 yd box. Tilted at a 7 yd/s run
/// (α = 25.3°) the slab's leading corner is born ~8 yd up and ~53 yd ahead instead, arriving in
/// ~2 s — so snow keeps meeting the runner head-on. Rain never showed the symptom because its
/// `T` is 1.24 s, but it takes the same rotation.
///
/// Returns `(spawn, velocity)` in Bevy world space.
pub(super) fn spawn_particle(
kind: WeatherKind,
w: f32,
origin: Vec3,
slab: Quat,
r: [f32; 5],
) -> (Vec3, Vec3) {
let (half_xy, z_off) = spawn_box(kind);
let scatter = Vec3::new(
(r[0] - 0.5) * 2.0 * half_xy,
0.0,
(r[1] - 0.5) * 2.0 * half_xy,
);
// The byte kinematics (w = density): the drift heading centres on the WORLD-FIXED −1.57
// azimuth (0x80ffbc) ± a grade-scaled spread (the FULL width — `(r−0.5)` halves it) — rain
// stays a coherent sheet (±7.5° at grade 1); calm snow wanders anywhere (2π at grade 0).
let (vy, drift_mag, spread) = match kind {
WeatherKind::Rain => (
-(RAIN_VZ_BASE + RAIN_VZ_W * w + RAIN_VZ_RNG * w * r[2]),
((2.0 * r[3] - 1.0) + RAIN_DRIFT_BASE) * w + RAIN_DRIFT_EPS,
RAIN_SPREAD_W * w + RAIN_SPREAD_BIAS,
),
_ => (
-(SNOW_VZ_BASE + SNOW_VZ_W * w + w * r[2]),
((r[3] - 0.5) + SNOW_DRIFT_OFF) * w + SNOW_DRIFT_EPS,
std::f32::consts::TAU - SNOW_SPREAD_W * w,
),
};
let heading = DRIFT_AZ_CENTER + (r[4] - 0.5) * spread;
// `vy` is strictly negative for both kinds, so `T` is positive and finite.
let vel = wow_azimuth_to_bevy(heading) * drift_mag + Vec3::Y * vy;
(origin + slab * (scatter - vel * (z_off / -vy)), vel)
}
/// One kind's frame: record (RNE-counted, packet-stamped), activate sealed cohorts,
/// integrate, land into the ground layer.
#[allow(clippy::too_many_arguments)]
@@ -205,10 +343,16 @@ pub(super) fn run_kind(
cam_pos: Vec3,
) {
let density = weather.density_for(kind);
let (p, half_xy, z_off) = match kind {
WeatherKind::Rain => (RAIN_P, RAIN_HALF_XY, RAIN_Z_OFF),
_ => (SNOW_P, SNOW_HALF_XY, SNOW_Z_OFF),
let p = match kind {
WeatherKind::Rain => RAIN_P,
_ => SNOW_P,
};
let z_off = spawn_box(kind).1;
// Every ground probe this frame casts from ONE plane. It has to be a constant: the slab tilt
// spreads the spawn heights across `z_off·(cos α ∓ (half_xy/z_off)·sin α)` — ~8..46 yd at a
// 7 yd/s run — and [`HeightCache`] throws its whole grid away when the cast height moves 8 yd,
// so probing from each particle's own height would nuke the cache every few particles.
let cast_plane = cam_pos.y + z_off;
// ===== record (the emit gate + the RNE frame count) =====
// The per-frame emit gate (`0x6754a0`, threshold `[0x8015b8]` = 1.0): a frame records only
@@ -219,45 +363,22 @@ pub(super) fn run_kind(
let close_age = p / PACKET_CAP as f32;
let (space, replay_at) = pool.open_for(now, rate, close_age);
let n = frame_count(space, quota)
.min(POOL.saturating_sub(pool.drops.len() + pool.pending_len()));
.min(pool_bound(kind).saturating_sub(pool.drops.len() + pool.pending_len()));
// The emission volume's origin: the live camera eye carried forward by the wind lead
// (`+1.75·W + C`, added AFTER the tilt — the volume rides the player, its shape leans).
let anchor = cam_pos + wind.vel * WIND_LEAD;
let origin = Vec3::new(anchor.x, cam_pos.y, anchor.z);
let w = density;
for _ in 0..n {
let (r1, r2, r3, r4, r5) = (
let r = [
rand01(rng),
rand01(rng),
rand01(rng),
rand01(rng),
rand01(rng),
);
// R2: the rand scatter lives on a flat plane at EYE height — the SEED point. (R10's
// plane tilt — `lerp(0, 65°, sat(speed/18))` about the ⊥-heading axis — keys on the
// ZONE AMBIENT wind speed (manager+0x84 → +0x7c), which benilla doesn't model yet;
// at zone wind 0 the tilt is identity, so flat IS the byte behaviour here.)
let scatter = Vec3::new((r1 - 0.5) * 2.0 * half_xy, 0.0, (r2 - 0.5) * 2.0 * half_xy);
let seed = Vec3::new(anchor.x, cam_pos.y, anchor.z) + scatter;
// The byte kinematics (w = density): the drift heading centres on the WORLD-FIXED
// −1.57 azimuth (0x80ffbc) ± a grade-scaled spread — rain stays a coherent sheet
// (±7.5° at grade 1); calm snow wanders anywhere (spread 2π at grade 0).
let (vy, drift_mag, spread) = match kind {
WeatherKind::Rain => (
-(RAIN_VZ_BASE + RAIN_VZ_W * w + RAIN_VZ_RNG * w * r3),
((2.0 * r4 - 1.0) + RAIN_DRIFT_BASE) * w + RAIN_DRIFT_EPS,
RAIN_SPREAD_W * w + RAIN_SPREAD_BIAS,
),
_ => (
-(SNOW_VZ_BASE + SNOW_VZ_W * w + w * r3),
((r4 - 0.5) + SNOW_DRIFT_OFF) * w + SNOW_DRIFT_EPS,
std::f32::consts::TAU - SNOW_SPREAD_W * w,
),
};
let heading = DRIFT_AZ_CENTER + (r5 - 0.5) * spread;
let vel = wow_azimuth_to_bevy(heading) * drift_mag + Vec3::Y * vy;
// Back-project the drop up its own velocity to the box top (`0x674df6–e53`):
// `T = −z_off/Vz`, spawn = seed − T·V — it starts z_off above the eye and passes
// its eye-plane scatter point at t = T. `vy` is strictly negative for both kinds.
let spawn = seed - vel * (z_off / -vy);
let land_y = heights.ground_y(spawn.x, spawn.z, spawn.y, spatial, filter);
];
let (spawn, vel) = spawn_particle(kind, w, origin, wind.slab, r);
let land_y = heights.ground_y(spawn.x, spawn.z, cast_plane, spatial, filter);
// Ground above the whole spawn column (a mountainside or roof over the box top) —
// the oracle reports ground ≥ spawn z and the drop dies at birth (`0x675051`).
if land_y >= spawn.y {
@@ -268,7 +389,7 @@ pub(super) fn run_kind(
vel,
land_y,
cell: HeightCache::key(spawn.x, spawn.z),
size: 0.7 + 0.6 * rand01(rng),
age: 0.0,
};
if let Some(pk) = &mut pool.open {
pk.count += 1;
@@ -284,7 +405,6 @@ pub(super) fn run_kind(
// A record whose replay instant passed while its packet was still baking appears mid-
// window: fast-forward its fall (drift included — re-sample the ground at the cell it
// drifted to) and skip it if it already landed (no splash — an unseen landing never drew).
let cast_plane = cam_pos.y + z_off;
let drops = &mut pool.drops;
for pk in &mut pool.sealed {
let mut i = 0;
@@ -294,6 +414,7 @@ pub(super) fn run_kind(
let lag = now - rec.at;
let mut drop = rec.drop;
drop.pos += drop.vel * lag;
drop.age = lag;
let cell = HeightCache::key(drop.pos.x, drop.pos.z);
if cell != drop.cell {
drop.cell = cell;
@@ -332,6 +453,7 @@ pub(super) fn run_kind(
{
pool.drops.retain_mut(|d| {
d.pos += d.vel * dt;
d.age += dt;
let cell = HeightCache::key(d.pos.x, d.pos.z);
if cell != d.cell {
d.cell = cell;
@@ -365,6 +487,169 @@ pub(super) fn run_kind(
mod tests {
use super::*;
/// A converged [`WeatherWind`] for a player running Bevy +X at `speed` — driven through the
/// real 149 ms window rather than hand-built, so these tests also pin the wiring.
fn wind_at(speed: f32) -> WeatherWind {
let mut wind = WeatherWind::default();
let dt = 1.0 / 60.0;
for i in 0..30 {
wind.update(Vec3::X * (speed * dt * i as f32), Vec3::X, speed, dt);
}
wind
}
/// The wire grade the director ran, through the published knee (`0x67bcc8`,
/// `max(0, (grade−0.25)·4/3)`) — the density the spawn kernel actually sees.
fn grade(wire: f32) -> f32 {
((wire - 0.25) * (4.0 / 3.0)).max(0.0)
}
/// The slab **leans into the direction of travel** (`α = 65°·sat(|W|/18)`, `0x677965`–
/// `0x677a55`): at a 7 yd/s run the leading corner is born ~8 yd up and ~53 yd ahead instead
/// of a flat 30 up / 45 ahead, and the trailing corner rides up to ~46. Reproduces wow-re's
/// own worked numbers for `wx-snow-placement-law.md`.
#[test]
fn the_spawn_slab_leans_into_the_run() {
let wind = wind_at(7.0);
let (half_xy, z_off) = spawn_box(WeatherKind::Snow);
let lead = wind.slab * Vec3::new(half_xy, z_off, 0.0);
let trail = wind.slab * Vec3::new(-half_xy, z_off, 0.0);
assert!(
(lead.x - 53.5).abs() < 0.5 && (lead.y - 7.9).abs() < 0.5,
"leading corner {lead:?} — wow-re's worked value is (53.5, 7.9)"
);
assert!(
(trail.x + 27.9).abs() < 0.5 && (trail.y - 46.3).abs() < 0.5,
"trailing corner {trail:?} — wow-re's worked value is (−27.9, 46.3)"
);
// Standing still there is no heading to lean into, and the slab is flat.
assert_eq!(wind_at(0.0).slab, Quat::IDENTITY);
}
/// The two wind rotations are **separate ramps off the same axis**, not one constant reused:
/// at 18 yd/s the slab has saturated at 65° while the streak apex is only at 27°. Collapsing
/// them would silently halve the slab's lean at every running speed.
#[test]
fn the_slab_and_streak_tilts_are_distinct_ramps() {
let wind = wind_at(18.0);
let deg = |q: Quat| q.angle_between(Quat::IDENTITY).to_degrees();
assert!(
(deg(wind.slab) - 65.0).abs() < 0.5,
"slab {}",
deg(wind.slab)
);
assert!(
(deg(wind.tilt) - 27.0).abs() < 0.5,
"streak {}",
deg(wind.tilt)
);
}
/// **B233's pin — a running player must keep meeting snow head-on.**
///
/// For each particle, ask where it crosses EYE HEIGHT and where the camera is at that
/// instant. Untilted, a snowflake at the director's grade needs its whole ~7.8 s fall to get
/// there, and a 7 yd/s runner covers 54 yd in that time against a 45 yd box — so nearly every
/// arrival lands *behind* them. That is exactly what the live probe measured: the forward eye
/// band collapsed 25× and hit literally zero, and the field's centroid sat 36 yd back.
///
/// The slab tilt is the term that fixes it, so this test asserts the *contrast*: the fix has
/// to be worth several times the broken behaviour, not merely nonzero.
#[test]
fn a_running_player_still_meets_snow_head_on() {
let ahead_share = |speed: f32, slab: Quat| {
let m = grade(0.6);
let origin = Vec3::X * (speed * WIND_LEAD);
let mut rng = 0x9E37_79B9_u32;
let (mut ahead, mut total) = (0u32, 0u32);
for _ in 0..40_000 {
let r = [
rand01(&mut rng),
rand01(&mut rng),
rand01(&mut rng),
rand01(&mut rng),
rand01(&mut rng),
];
let (spawn, vel) = spawn_particle(WeatherKind::Snow, m, origin, slab, r);
// Born at or below the eye: it never crosses the plane, so it is not an arrival.
if spawn.y <= 0.0 {
continue;
}
// The camera runs +X from the origin; eye height is y = 0 and the ground is flat.
let tau = spawn.y / -vel.y;
total += 1;
if (spawn.x + vel.x * tau) - speed * tau > 0.0 {
ahead += 1;
}
}
f64::from(ahead) / f64::from(total)
};
let standing = ahead_share(0.0, Quat::IDENTITY);
assert!(
(standing - 0.5).abs() < 0.05,
"a standing player's arrivals should split ~50/50, got {standing:.3}"
);
let running = wind_at(7.0);
let tilted = ahead_share(7.0, running.slab);
let flat = ahead_share(7.0, Quat::IDENTITY);
eprintln!(
"snow arrivals ahead of the player: standing {standing:.3}, \
running tilted {tilted:.3}, running flat (the B233 shape) {flat:.3}"
);
assert!(
flat < 0.10,
"the untilted slab is supposed to reproduce B233 (nearly nothing arrives ahead of a \
runner); got {flat:.3} — if this rose, the symptom's cause moved"
);
assert!(
tilted > 0.25,
"with the slab tilt a runner should still meet a quarter or more of the arrivals \
head-on (wow-re's worked value is ~0.34); got {tilted:.3}"
);
assert!(
tilted > flat * 3.0,
"the tilt must dominate, not nudge: tilted {tilted:.3} vs flat {flat:.3}"
);
}
/// Rain takes the same rotation and must not change character: its fall is 1.24 s, so it
/// never had B233's problem, and the tilt shifts its arrival band forward without emptying
/// either side. (wow-re Q5: `[−61,+69]` → `[−45,+86]`.)
#[test]
fn the_tilt_leaves_rain_balanced() {
let m = grade(0.6);
let speed = 7.0;
let slab = wind_at(speed).slab;
let origin = Vec3::X * (speed * WIND_LEAD);
let mut rng = 0x1234_5678_u32;
let (mut ahead, mut total) = (0u32, 0u32);
for _ in 0..40_000 {
let r = [
rand01(&mut rng),
rand01(&mut rng),
rand01(&mut rng),
rand01(&mut rng),
rand01(&mut rng),
];
let (spawn, vel) = spawn_particle(WeatherKind::Rain, m, origin, slab, r);
if spawn.y <= 0.0 {
continue;
}
let tau = spawn.y / -vel.y;
total += 1;
if (spawn.x + vel.x * tau) - speed * tau > 0.0 {
ahead += 1;
}
}
let share = f64::from(ahead) / f64::from(total);
assert!(
(0.35..0.75).contains(&share),
"rain should stay balanced fore/aft under the tilt, got {share:.3}"
);
}
/// The RNE frame count (`0x6754cc–ef`, x87 round-nearest-even): normal quotas round to
/// the nearest count, and `space = 1` yields `RNE(0.5) = 0` — the parity stick that keeps
/// a 6143-record packet from ever full-closing.
@@ -404,7 +689,7 @@ mod tests {
vel: Vec3::NEG_Y,
land_y: -100.0,
cell: (0, 0),
size: 1.0,
age: 0.0,
};
let mut pool = Pool {
open: Some(Packet {
@@ -468,7 +753,7 @@ mod tests {
vel: Vec3::NEG_Y,
land_y: -100.0,
cell: (0, 0),
size: 1.0,
age: 0.0,
},
});
}
+289 -17
View File
@@ -1,6 +1,7 @@
//! The precip **stream pushers** — streak/patter/flake geometry emitted each frame from the
//! live pools straight into the shared effect stream (0733; world-space — the lane rebases
//! camera-relative render-side). Split from `precip`'s root; geometry only, no sim state.
//! live pools straight into the shared effect stream (0733; world-space, and the lane rebases
//! camera-relative render-side — except the FLAKE draw, which writes camera-relative itself for
//! f32 precision, see [`push_flakes`]). Split from `precip`'s root; geometry only, no sim state.
//! Empty pools push nothing, so an idle sky costs zero here — the structural replacement for
//! the old fixed-capacity meshes' `WriteGate` (the 0353 fps hunt).
@@ -68,25 +69,73 @@ pub(super) fn push_patters(
}
}
/// Snow flakes: camera-facing quads (the byte pass builds a face-camera basis; half-size 1/12,
/// jittered per flake), pushed in the stream's perimeter corner order. `drops` = falling
/// flakes; `settled` = landed ones fading out over the `+0.25 s` window.
/// The camera terms [`push_flakes`] needs to turn the reference's **pixel** point size into a
/// world-space quad. `snowpoint.bls` sizes a flake in window coordinates, so the size a flake
/// gets is a property of the projection, not of the world.
pub(super) struct FlakeView {
pub(super) eye: Vec3,
/// The camera's forward axis — a flake's *view depth* (`z`, the perspective divisor), which
/// is what the pixel↔world conversion keys on. Distinct from `|flake − eye|`, which is what
/// the size LAW keys on; off-axis flakes differ in the two.
pub(super) forward: Vec3,
pub(super) right: Vec3,
pub(super) up: Vec3,
/// `tan(fovY/2) / SNOW_PX_REF_HEIGHT` — world units per **era pixel**, per unit of view depth.
/// A world length `L` at view depth `z` covers `L / (z · this)` era pixels, so a `px`-era-pixel
/// sprite wants half-extent `px · z · this`.
///
/// The denominator is deliberately **not** the live viewport height — see
/// [`super::SNOW_PX_REF_HEIGHT`]. Dividing by the live height reproduces the reference's pixel
/// *count* and destroys its apparent size on any screen taller than the era's.
pub(super) world_per_px: f32,
}
/// Snow flakes — the ARB point-sprite leg `0x678610`, reproduced as screen-aligned quads because
/// wgpu has no point size (WebGPU pins `PointList` at 1 px).
///
/// Per flake (wow-re `rf-snow-flake-render.md` §2.4, the shipped `snowpoint.bls` read verbatim):
/// - size `max(1, 14·clamp01(1 − 0.02·d))` **era pixels**, `d = |flake − eye|` in yards — inverted
/// through the projection into a world half-extent, so the on-screen footprint matches the
/// reference's *angular* size at any resolution or fov (see [`super::SNOW_PX_REF_HEIGHT`] for
/// why the era's screen height, and not the live one, is the denominator);
/// - RGB white, alpha `clamp01(t − f1)` while falling (a 1 s linear fade-IN from spawn) and
/// `clamp01(1 − 4·(t − f2))` once settled (the 0.25 s fade-out);
/// - the whole texture per flake (`GL_COORD_REPLACE`), which the 0..1 UVs below reproduce.
///
/// There is **no per-flake size**: the 32-byte flake record has no spare field, the spawn draws
/// exactly 5 RNG values (none a size), and both legs' size terms are per-draw constants.
///
/// `drops` = falling flakes; `settled` = landed ones fading out over the `+0.25 s` window.
pub(super) fn push_flakes(
out: &mut Vec<EffectVertex>,
drops: &[Drop],
settled: &[Patter],
cam_right: Vec3,
cam_up: Vec3,
view: &FlakeView,
) {
let mut quad = |center: Vec3, half: f32, alpha: f32| {
let r = cam_right * half;
let u = cam_up * half;
let mut sprite = |center: Vec3, alpha: f32| {
// CAMERA-RELATIVE from here down (the draw sets `EffectDrawSpec::cam_relative`, so the
// lane's rebase skips it). This is not a convenience: a near flake's half-extent is
// ~1.6 mm, which is 3 f32 ULPs at Kharanos's ~5600-yd coordinates and under 1 at a map
// corner — written absolutely, a 14 px sprite loses 2–7 px of width and flickers. Every
// term below is small, so the arithmetic is exact wherever in the world we are.
let to_flake = center - view.eye;
let z = to_flake.dot(view.forward);
if z <= 0.0 {
return; // behind the eye — clipped anyway, and the pixel↔world map is undefined
}
// The size law, in pixels, off the RADIAL distance; then pixels → world at this flake's
// view depth.
let px = (SNOW_PX_AT_EYE * (1.0 - SNOW_PX_FALLOFF * to_flake.length()).clamp(0.0, 1.0))
.max(SNOW_PX_MIN);
let half = px * z * view.world_per_px;
let r = view.right * half;
let u = view.up * half;
// Perimeter order (bl, br, tr, tl) — the stream's quad-index pattern closes it.
for (pos, uv) in [
(center - r - u, [0.0, 1.0]),
(center + r - u, [1.0, 1.0]),
(center + r + u, [1.0, 0.0]),
(center - r + u, [0.0, 0.0]),
(to_flake - r - u, [0.0, 1.0]),
(to_flake + r - u, [1.0, 1.0]),
(to_flake + r + u, [1.0, 0.0]),
(to_flake - r + u, [0.0, 0.0]),
] {
out.push(EffectVertex {
pos: pos.to_array(),
@@ -96,10 +145,233 @@ pub(super) fn push_flakes(
}
};
for d in drops.iter().take(POOL) {
quad(d.pos, SNOW_HALF * d.size, SNOW_ALPHA);
sprite(d.pos, (d.age / SNOW_FADE_IN).clamp(0.0, 1.0));
}
for s in settled.iter().take(GROUND_CAP) {
let t = (s.age / SNOW_SETTLE_LIFE).clamp(0.0, 1.0);
quad(s.pos, SNOW_HALF, SNOW_ALPHA * (1.0 - t));
sprite(s.pos, 1.0 - (s.age / SNOW_SETTLE_LIFE).clamp(0.0, 1.0));
}
}
#[cfg(test)]
mod tests {
use super::*;
const FOVY: f32 = std::f32::consts::FRAC_PI_4;
/// A camera at `eye` looking down −Z (Bevy's convention), 45° vertical fov — carrying the
/// production `world_per_px`, which denominates in the ERA height and not the render height.
fn view_at(eye: Vec3) -> FlakeView {
FlakeView {
eye,
forward: Vec3::NEG_Z,
right: Vec3::X,
up: Vec3::Y,
world_per_px: (FOVY * 0.5).tan() / SNOW_PX_REF_HEIGHT,
}
}
fn view() -> FlakeView {
view_at(Vec3::ZERO)
}
/// `snowpoint.bls`'s law, restated independently of the implementation.
fn reference_px(d: f32) -> f32 {
(14.0 * (1.0 - 0.02 * d).clamp(0.0, 1.0)).max(1.0)
}
fn flake(pos: Vec3, age: f32) -> Drop {
Drop {
pos,
vel: Vec3::NEG_Y,
land_y: -1000.0,
cell: (0, 0),
age,
}
}
/// A flake's footprint, in real pixels, at a given render height — measured by pushing the
/// quad and projecting its corners through a real perspective matrix, never by re-deriving
/// the half-extent formula.
fn footprint_px(d: f32, render_h: f32) -> f32 {
// The RH projection Bevy builds: clip.w = −z_view, so a flake at z_view = −d has w = d.
let proj = Mat4::perspective_rh(FOVY, 16.0 / 9.0, 0.1, 1000.0);
let window_y = |p: Vec3| {
let clip = proj * p.extend(1.0);
(clip.y / clip.w + 1.0) * 0.5 * render_h
};
let mut out = Vec::new();
push_flakes(
&mut out,
&[flake(Vec3::new(0.0, 0.0, -d), 5.0)],
&[],
&view(),
);
assert_eq!(out.len(), 4, "one flake = one quad");
// Perimeter order (bl, br, tr, tl): corner 0 is the bottom edge, corner 3 the top.
window_y(Vec3::from(out[3].pos)) - window_y(Vec3::from(out[0].pos))
}
/// **At the era's own screen height benilla draws the reference's pixels exactly** —
/// `max(1, 14·clamp01(1 − 0.02·d))`. This is the fidelity anchor; the test below is what
/// keeps it from being read as "14 px, always".
#[test]
fn flake_covers_the_reference_pixel_footprint() {
for d in [1.0f32, 5.0, 7.14, 20.0, 46.43, 60.0, 100.0] {
let px = footprint_px(d, SNOW_PX_REF_HEIGHT);
let want = reference_px(d);
assert!(
(px - want).abs() < 0.01,
"at {d} yd the flake covers {px:.3} px, the reference gives {want:.3}"
);
}
}
/// …and on a taller screen it grows to hold the same **angle**, rather than staying at 14 px
/// and shrinking away.
///
/// `snowpoint.bls` sizes in framebuffer pixels because that is what
/// `GL_VERTEX_PROGRAM_POINT_SIZE_ARB` does, not as a statement about apparent size; on 2004
/// hardware the framebuffer *was* the screen. Obeyed literally, the flake's angular size falls
/// as `1/height` — the director's A/B put benilla's flakes 2.7× under the reference's purely
/// because a 4K scale-2 framebuffer is 2.7× taller than the reference install's 800
/// (decision 1162). Pin the invariant that fixed it: the fraction of the screen a flake covers
/// does not depend on the resolution.
#[test]
fn the_flake_holds_its_angle_across_resolutions() {
for d in [1.0f32, 12.0, 30.0, 60.0] {
let era = footprint_px(d, SNOW_PX_REF_HEIGHT) / SNOW_PX_REF_HEIGHT;
for h in [480.0f32, 800.0, 1080.0, 1440.0, 2144.0, 4320.0] {
let share = footprint_px(d, h) / h;
assert!(
(share - era).abs() < 1e-6,
"at {d} yd a flake covers {:.4}% of a {h}-px screen but {:.4}% of the era's \
— the size law has gone back to being resolution-dependent",
share * 100.0,
era * 100.0,
);
}
// And the absolute pixel count really does scale, which is the whole change.
let tall = footprint_px(d, 2144.0);
let want = reference_px(d) * 2144.0 / SNOW_PX_REF_HEIGHT;
assert!(
(tall - want).abs() < 0.01,
"at {d} yd: {tall:.2} vs {want:.2}"
);
}
}
/// The two ends of the law, stated as absolutes so a regression is unmistakable: 14 px at the
/// eye, and the 1 px floor from 46.43 yd out — **not** a `1/d` world-space size, which would
/// blow up at the eye and vanish in the distance.
#[test]
fn point_size_is_linear_in_distance_not_inverse() {
assert!((reference_px(0.0) - 14.0).abs() < 1e-6);
assert!((reference_px(7.142_857) - 12.0).abs() < 1e-4);
assert!((reference_px(46.428_57) - 1.0).abs() < 1e-4);
assert!(
(reference_px(200.0) - 1.0).abs() < 1e-6,
"flat past the floor"
);
// A fixed world-size quad's pixel span is ∝ 1/d; this one FALLS OFF LINEARLY, so the
// ratio between 10 yd and 40 yd is 11.2/2.8 = 4, not 4 by coincidence of 1/d (which would
// also be 4) — pin the shape at a third point where the two laws disagree.
let (near, far) = (reference_px(10.0), reference_px(40.0));
assert!((near - 11.2).abs() < 1e-4 && (far - 2.8).abs() < 1e-4);
assert!(
(reference_px(25.0) - 7.0).abs() < 1e-4,
"linear midpoint; a 1/d law would give {}",
near * 10.0 / 25.0
);
}
/// Alpha: `clamp01(t − f1)` falling — a 1 s linear fade-IN from spawn — then
/// `clamp01(1 − 4·(t − f2))` settled. benilla drew every falling flake at 1.0.
#[test]
fn flake_alpha_fades_in_then_out() {
let view = view();
let at = Vec3::new(0.0, 0.0, -10.0);
let alpha_of = |drops: &[Drop], settled: &[Patter]| {
let mut out = Vec::new();
push_flakes(&mut out, drops, settled, &view);
out[0].color[3]
};
assert!(
(alpha_of(&[flake(at, 0.0)], &[]) - 0.0).abs() < 1e-6,
"born invisible"
);
assert!((alpha_of(&[flake(at, 0.5)], &[]) - 0.5).abs() < 1e-6);
assert!((alpha_of(&[flake(at, 1.0)], &[]) - 1.0).abs() < 1e-6);
assert!(
(alpha_of(&[flake(at, 4.0)], &[]) - 1.0).abs() < 1e-6,
"held, not overshooting"
);
let settled = |age| {
vec![Patter {
pos: at,
age,
variant: 0,
}]
};
assert!((alpha_of(&[], &settled(0.0)) - 1.0).abs() < 1e-6);
assert!((alpha_of(&[], &settled(0.125)) - 0.5).abs() < 1e-6);
assert!((alpha_of(&[], &settled(0.25)) - 0.0).abs() < 1e-6);
}
/// **The precision pin.** The same footprint, with the camera where the game actually puts it:
/// Kharanos (~5600 yd out) and the far corner of a 17066-yd map. `EffectVertex::pos` is f32,
/// and a near flake's half-extent is ~1.6 mm — 3 ULPs at 5600, under 1 at 17066. Written in
/// ABSOLUTE world coordinates (the lane's default, which subtracts the camera only later, on
/// the upload copy) a 14 px sprite loses 2–7 px of width and flickers; written camera-relative
/// it is exact anywhere. This test fails on the absolute form and passes on the relative one,
/// which is the only reason to have it.
#[test]
fn the_footprint_survives_being_far_from_the_world_origin() {
let proj = Mat4::perspective_rh(FOVY, 16.0 / 9.0, 0.1, 1000.0);
for eye in [
Vec3::new(529.48, 399.67, 5595.89), // Kharanos, in Bevy space
Vec3::splat(17066.0), // the far corner of a map
] {
let view = view_at(eye);
for d in [0.3f32, 0.5, 1.0, 5.0, 30.0] {
let mut out = Vec::new();
push_flakes(
&mut out,
&[flake(eye + Vec3::new(0.0, 0.0, -d), 5.0)],
&[],
&view,
);
// The emitted verts are camera-relative, so they project through the same matrix
// a camera at the origin would use — that IS the rebase the lane would have done.
// Rendered at the era height, so the expected footprint is the reference's own
// pixel count (this test is about precision, not about resolution).
let window_y = |p: Vec3| {
let clip = proj * p.extend(1.0);
(clip.y / clip.w + 1.0) * 0.5 * SNOW_PX_REF_HEIGHT
};
let px = window_y(Vec3::from(out[3].pos)) - window_y(Vec3::from(out[0].pos));
let want = reference_px(d);
assert!(
(px - want).abs() < 0.01,
"eye {eye:?}, {d} yd: {px:.3} px vs the reference's {want:.3}"
);
}
}
}
/// A flake behind the eye emits nothing — the pixel↔world map has no meaning at or behind the
/// projection plane, and the quad would be mirrored through the camera.
#[test]
fn flakes_behind_the_eye_are_dropped() {
let mut out = Vec::new();
push_flakes(
&mut out,
&[
flake(Vec3::new(0.0, 0.0, 5.0), 2.0), // behind
flake(Vec3::new(0.0, 0.0, -5.0), 2.0), // in front
],
&[],
&view(),
);
assert_eq!(out.len(), 4, "only the flake in front is drawn");
}
}
+136 -32
View File
@@ -1,6 +1,6 @@
//! The weather **wind** — the local player's trailing-average velocity (`0x67c150`) and the
//! frames derived from it: the streak apex tilt and the mist spawn yaw. Split from `precip`'s
//! root; the sim/spawn economics stay there.
//! three frames derived from it: the **spawn-slab tilt**, the streak apex tilt, and the mist
//! spawn yaw. Split from `precip`'s root; the sim/spawn economics stay there.
use bevy::prelude::*;
@@ -9,12 +9,25 @@ use bevy::prelude::*;
const WIND_TILT_DIV: f32 = 30.0;
const WIND_TILT_MAX_DEG: f32 = 45.0;
/// **Spawn-slab tilt**: `α = lerp(0°, 65°, sat(|wind|/18))` (`0x677965`–`0x677a55`). Shares the
/// ⊥-heading axis with the streak tilt but is a *separate* rotation with its own ramp — steeper
/// (65° vs 45°) and reaching full tilt at a lower speed (18 vs 30 yd/s). See
/// [`super::pool::spawn_particle`] for what it does and why its absence was B233.
const SLAB_TILT_DIV: f32 = 18.0;
const SLAB_TILT_MAX_DEG: f32 = 65.0;
/// Wind = the **local player's** trailing-average velocity (`0x67c150`, verified: the list is
/// per-frame position deltas of object 0x8fe — the player, not the camera — trimmed to a
/// **~149 ms** window; `wind = Σ(Δpos)/((Σ(Δms)+1)·0.001)`, a true yd/s velocity). Orbiting
/// the camera therefore does NOT stir the wind. The streak apex tilt derives from it here; the
/// wind heading (manager+0x78) updates only while `|wind.xy| ≥ 1` (`wx_wind_angle`'s gate) and
/// holds its last value through calm.
/// the camera therefore does NOT stir the wind. The streak apex tilt and the spawn-box lead
/// derive from it here.
///
/// **The spawn-slab tilt does not.** It keys on `mgr+0x7c`, which merges two sources at one store
/// (`0x67bf91`): the ridden transport's averaged planar speed when `|mgr+0x5c|² > 2`, else the
/// local player's own **live** CMovement speed `[[player+0x118]+0x84]`. benilla has no ridden
/// transports yet, so it is always the second — [`crate::player::Player::planar_speed`], passed
/// into [`Self::update`]. Averaging it would be wrong in both directions: late to lean in, and
/// still leaning 150 ms after the player stops.
#[derive(Resource)]
pub(crate) struct WeatherWind {
pub(super) last_pos: Option<Vec3>,
@@ -22,10 +35,25 @@ pub(crate) struct WeatherWind {
window: Vec<(f32, Vec3)>,
/// The windowed planar velocity (Bevy x/z, y forced 0), yd/s.
pub(super) vel: Vec3,
/// The held wind heading (WoW frame, `manager+0x78`) — updated only at |wind| ≥ 1.
/// The wind heading (WoW frame, `manager+0x78`) — the wind's own azimuth at |wind| ≥ 1, the
/// unit's facing below it. Rewritten every frame; it does **not** hold through calm.
heading: f32,
/// The same heading as a **Bevy planar unit direction**. Kept alongside the angle rather than
/// re-derived from it, because the two angle conventions in this file are 90° apart and only
/// compose correctly as a *rotation*: [`heading`] is `atan2(y, x)` in the WoW frame, while
/// [`wow_azimuth_to_bevy`] reads its argument as the kernels' `(vx, vy) = (sin a, cos a)`
/// pair — i.e. `atan2(x, y)`. [`Self::mist_yaw`] composes them as a rotation and is fine;
/// feeding `heading` straight into `wow_azimuth_to_bevy` to get a *direction* would silently
/// yield an axis a quarter turn off.
heading_dir: Vec3,
/// Streak-field tilt (rotates the fall axis toward the motion), from `weather_wind_tilt`.
pub(super) tilt: Quat,
/// The **spawn-slab** tilt — rotates each particle's slab-local offset into the direction of
/// travel, so the volume's leading edge is born low and close instead of a flat `z_off` up.
/// `α = 65°·sat(live_speed/18)`, linear from zero with **no dead zone** (`0x674ba0` is a plain
/// clamped lerp and `0x677965`–`0x677a41` has no branch at all): 9° at a 2.5 yd/s walk, 25.3°
/// at a 7 yd/s run, saturated from 18 up. Exactly identity at rest, on the same frame.
pub(super) slab: Quat,
}
impl Default for WeatherWind {
@@ -35,7 +63,10 @@ impl Default for WeatherWind {
window: Vec::new(),
vel: Vec3::ZERO,
heading: 0.0,
// The direction `heading = 0` names: `atan2(−x, −z) = 0` ⇒ `−z = 1`.
heading_dir: Vec3::NEG_Z,
tilt: Quat::IDENTITY,
slab: Quat::IDENTITY,
}
}
}
@@ -45,7 +76,10 @@ impl Default for WeatherWind {
const WIND_WINDOW_S: f32 = 0.149;
impl WeatherWind {
pub(super) fn update(&mut self, pos: Vec3, dt: f32) {
/// `pos` — the local player's feet; `facing` — their aim as a Bevy direction (the heading's
/// source below 1 yd/s); `live_speed` — their commanded planar speed in yd/s
/// ([`crate::player::Player::planar_speed`], the reference's `mgr+0x7c`).
pub(super) fn update(&mut self, pos: Vec3, facing: Vec3, live_speed: f32, dt: f32) {
if dt <= 0.0 {
return;
}
@@ -75,25 +109,52 @@ impl WeatherWind {
self.vel = sum_dpos / (total.mul_add(1000.0, 1.0) * 0.001);
let mag2 = self.vel.length_squared();
// wx_wind_angle's gate: the heading only re-derives while |wind| ≥ 1; through calm it
// HOLDS (the mist frame doesn't spin on idle noise).
if mag2 >= 1.0 {
// wind_wow = (−vel.z, −vel.x) per the bevy→wow basis; heading = atan2(y, x).
self.heading = (-self.vel.x).atan2(-self.vel.z);
}
// `mgr+0x78` (`0x67be40`): the `|W_xy| ≥ 1` test selects the heading's SOURCE — it does
// NOT suppress the write. All three of its legs store `[esi+0x78]` (`0x67bee0`/`0x67bef6`/
// `0x67bf02`), and below 1 yd/s the heading is overwritten with the **unit's own facing**
// (`0x67beff call [vtbl+0x18]`). The earlier "it HOLDS through calm" reading was wrong —
// corrected by wow-re alongside the slab tilt, `wx-snow-placement-law.md` §9.
self.heading_dir = if mag2 >= 1.0 {
self.vel / mag2.sqrt()
} else {
facing
.with_y(0.0)
.try_normalize()
.unwrap_or(self.heading_dir)
};
// wind_wow = (−dir.z, −dir.x) per the bevy→wow basis; heading = atan2(y, x).
self.heading = (-self.heading_dir.x).atan2(-self.heading_dir.z);
// weather_wind_tilt (0x674a70): tilt = lerp(0°, 45°, sat(|wind.xy|/30)) about the
// horizontal axis ⊥ the wind; below |wind|² < 0.001 no tilt at all. The handedness is
// verified: the apex tips DOWNWIND (+wind = motion heading) — rain rushes into a
// moving player.
// Both rotations turn about `ŷ × ĥ` = `(h.z, 0, −h.x)`: a positive angle about it carries
// `ŷ` toward `ĥ` (`R·ŷ = ŷcos + ĥsin`), i.e. leans into the heading. The handedness is
// verified for the streak — the apex tips DOWNWIND, rain rushing into a moving player —
// and wow-re re-derived it independently for the slab.
let lean = |dir: Vec3, speed: f32, div: f32, max_deg: f32| {
Vec3::new(dir.z, 0.0, -dir.x)
.try_normalize()
.map_or(Quat::IDENTITY, |axis| {
Quat::from_axis_angle(
axis,
((speed / div).clamp(0.0, 1.0) * max_deg).to_radians(),
)
})
};
// The streak apex (`weather_wind_tilt 0x674a70`) keys on the AVERAGED wind, and dies below
// |wind|² < 0.001.
self.tilt = if mag2 < 0.001 {
Quat::IDENTITY
} else {
let mag = mag2.sqrt();
let tilt_deg = (mag / WIND_TILT_DIV).clamp(0.0, 1.0) * WIND_TILT_MAX_DEG;
let axis = Vec3::new(self.vel.z, 0.0, -self.vel.x) / mag;
Quat::from_axis_angle(axis, tilt_deg.to_radians())
lean(self.vel, mag2.sqrt(), WIND_TILT_DIV, WIND_TILT_MAX_DEG)
};
// The slab keys on `mgr+0x7c` — the LIVE commanded speed, not the averaged wind, and not
// lagged by the 149 ms window. It is exactly 0 with no direction bit held, so this is
// identity at rest with no epsilon needed.
self.slab = lean(
self.heading_dir,
live_speed,
SLAB_TILT_DIV,
SLAB_TILT_MAX_DEG,
);
}
/// The held wind heading (`manager+0x78`, WoW frame). Starts 0 = world north.
@@ -140,30 +201,73 @@ mod tests {
}
}
/// Calm start ⇒ heading 0 (the fallback facing) ⇒ an identity mist frame; walking
/// re-derives the heading once `|wind| ≥ 1` (`wx_wind_angle`'s gate); standing still again
/// HOLDS it — the mist frame must not snap back or spin on idle noise.
/// `mgr+0x78`'s `|W| ≥ 1` test picks the heading's **source**, it does not gate the store
/// (`0x67be40`: all three legs write `[esi+0x78]`). Moving, the heading is the wind's own
/// azimuth; on stopping it is **overwritten with the unit's facing** rather than held.
///
/// This test previously asserted the opposite ("standing still again HOLDS it"). That reading
/// was wrong — corrected by wow-re in the same round that found the spawn-slab tilt. The
/// visible consequence is the mist frame: it re-aims to where the player is looking when they
/// stop, instead of staying pinned to the direction they last ran.
#[test]
fn heading_gates_and_holds() {
fn heading_takes_the_facing_below_a_yard_per_second() {
let dt = 1.0 / 60.0;
let mut wind = WeatherWind::default();
assert_eq!(wind.heading_wow(), 0.0);
assert!(wind.mist_yaw().angle_between(Quat::IDENTITY) < 1e-6);
// Walk Bevy +X at 8 yd/s for 0.3 s — the ~149 ms window reads ~8 yd/s.
let dt = 1.0 / 60.0;
for i in 0..18 {
wind.update(Vec3::new(8.0 * dt * i as f32, 0.0, 0.0), dt);
wind.update(Vec3::X * (8.0 * dt * i as f32), Vec3::X, 8.0, dt);
}
assert!(wind.vel.length() > 1.0, "window should read ~8 yd/s");
let moving = wind.heading_wow();
// Bevy +X = WoW −Y ⇒ wind_wow = (0, −8) ⇒ heading −π/2.
let moving = wind.heading_wow();
assert!((moving + std::f32::consts::FRAC_PI_2).abs() < 1e-3);
// Stand still: the wind decays through the window, the heading holds.
// Stop, and turn to face Bevy −Z. The wind decays out of the window and the heading
// follows the FACING to 0 — it does not stay at the direction of travel.
let last = wind.last_pos.unwrap();
for _ in 0..60 {
wind.update(last, dt);
wind.update(last, Vec3::NEG_Z, 0.0, dt);
}
assert!(wind.vel.length() < 0.01);
assert_eq!(wind.heading_wow(), moving);
assert!(
wind.heading_wow().abs() < 1e-3,
"stopped and facing −Z, the heading should read 0, got {}",
wind.heading_wow()
);
// …and the slab levels the moment the commanded speed is 0, with no 149 ms tail.
assert_eq!(wind.slab, Quat::IDENTITY);
}
/// The slab's tilt keys on the LIVE commanded speed (`mgr+0x7c`), not the averaged wind: on
/// the very first frame of a run it is already at its full angle, and on the first frame of a
/// stop it is already flat — while `vel`, the 149 ms average, is still catching up in both
/// directions. Keying the tilt off `vel` would lean the slab in late and hold it late.
#[test]
fn the_slab_tracks_the_commanded_speed_not_the_averaged_wind() {
let dt = 1.0 / 60.0;
let mut wind = WeatherWind::default();
// One frame of running: the average has barely moved, the slab is already fully leaned.
wind.update(Vec3::X * (7.0 * dt), Vec3::X, 7.0, dt);
let deg = |q: Quat| q.angle_between(Quat::IDENTITY).to_degrees();
assert!(
wind.vel.length() < 7.0,
"the 149 ms average should still be catching up, got {}",
wind.vel.length()
);
assert!(
(deg(wind.slab) - 25.28).abs() < 0.1,
"slab {} — 65°·(7/18) is 25.28°",
deg(wind.slab)
);
// Keep running so the average converges, then stop dead.
for i in 2..40 {
wind.update(Vec3::X * (7.0 * dt * i as f32), Vec3::X, 7.0, dt);
}
assert!(wind.vel.length() > 6.0, "the average has caught up");
wind.update(Vec3::X * (7.0 * dt * 39.0), Vec3::X, 0.0, dt);
assert!(wind.vel.length() > 1.0, "the average still carries the run");
assert_eq!(wind.slab, Quat::IDENTITY, "but the slab is flat that frame");
}
/// The mist stream is ANTI-player-motion (`0x67a990`: `−heading` through the negated
@@ -176,7 +280,7 @@ mod tests {
let dt = 1.0 / 60.0;
// Walk Bevy +X (east) at 8 yd/s.
for i in 0..18 {
wind.update(Vec3::new(8.0 * dt * i as f32, 0.0, 0.0), dt);
wind.update(Vec3::X * (8.0 * dt * i as f32), Vec3::X, 8.0, dt);
}
let motion = Vec3::X;
// The base azimuth −1.57 is the stream's centre; spin it by the mist frame.
@@ -280,6 +280,7 @@ fn load_subject(internal: &str, site: Option<&Site>) -> Subject {
group_footprint_bounds: benilla_assets::footprint_tri_bounds(&group_footprints),
group_footprint_grids: benilla_assets::footprint_tri_grids(&group_footprints),
group_footprints,
material_ground_type: Vec::new(),
group_light_refs: Vec::new(),
group_liquids,
doodad_base: Vec::new(),
+86 -11
View File
@@ -538,7 +538,35 @@ pub(super) fn footprint_sample(
model: &WmoModel,
probe_local: [f32; 3],
) -> Option<(usize, [u8; 3], bool)> {
footprint_scan(model, probe_local, false)
footprint_scan(model, probe_local, false, None).map(|(g, c, m, _)| (g, c, m))
}
/// The footstep surface's **material ray** (decision 1161) — the second of the client's two rays.
/// The first (the collision-face down-ray that races terrain) has already picked `group`; this
/// re-casts the same column over that group's RENDER faces and reads the winning face's
/// `TerrainType` id: `MOPY[face].material_id → MOMT[id].ground_type` (`0x6a26c0`).
///
/// It is the *same* candidate set the lighting sample walks, and not by coincidence: both are the
/// client's `0x88`-reject face walk ([`FOOTPRINT_REJECT`](benilla_formats::FootprintTris) — a
/// COLLISION or VISITED face is invisible to either). The two rays deliberately see different
/// geometry, which is why a floor authored as a render sheet over a separate collision sheet
/// resolves its *material* off the render layer while the arbitration used the collision one.
///
/// `None` = the client's `−1`: no accepted render face under the probe in that group, or a
/// material id past the root's MOMT. The caller must read that as **silent**, never as a reason to
/// fall back to the terrain leg — the building won the column either way.
pub(crate) fn surface_terrain_sample(
model: &WmoModel,
group: usize,
probe_local: [f32; 3],
) -> Option<u32> {
let (_, _, _, material) = footprint_scan(model, probe_local, false, Some(group))?;
// The client indexes MOMT unchecked; the assets hold the invariant (every `0xFF` face carries
// MOPY `0x08` and was dropped at parse). We bounds-check rather than inherit a latent OOB.
model
.material_ground_type
.get(usize::from(material))
.copied()
}
/// The containment attach's **upward retry** (`6a908d`: the down leg missed, so the segment is
@@ -549,20 +577,26 @@ pub(super) fn footprint_sample_above(
model: &WmoModel,
probe_local: [f32; 3],
) -> Option<(usize, [u8; 3], bool)> {
footprint_scan(model, probe_local, true)
footprint_scan(model, probe_local, true, None).map(|(g, c, m, _)| (g, c, m))
}
/// The shared body of the two footprint legs: `up` flips which side of the probe a face must lie
/// on and which of the candidates wins (nearest, in the cast's own direction).
/// The shared body of the footprint/material legs: `up` flips which side of the probe a face must
/// lie on and which of the candidates wins (nearest, in the cast's own direction); `only_group`
/// restricts the scan to a single group, which is what the material ray needs — the arbitration
/// has already chosen the group, and the nearest render face *overall* can belong to another one.
fn footprint_scan(
model: &WmoModel,
probe_local: [f32; 3],
up: bool,
) -> Option<(usize, [u8; 3], bool)> {
only_group: Option<usize>,
) -> Option<(usize, [u8; 3], bool, u8)> {
let (px, py, pz) = (probe_local[0], probe_local[1], probe_local[2]);
let mut best: Option<(f32, usize, [f32; 3], bool)> = None;
let mut best: Option<(f32, usize, [f32; 3], bool, u8)> = None;
for (gi, fp) in model.group_footprints.iter().enumerate() {
let Some(fp) = fp else { continue };
if only_group.is_some_and(|g| g != gi) {
continue;
}
// Broad phase on the load-derived face bounds (the 0330 rule — from the faces, never an
// authored box, so the cull is exact): a down-ray at (px, py) can hit no face of a group
// whose XY face bounds exclude the column, and no face whose LOWEST vertex is already
@@ -621,14 +655,22 @@ fn footprint_scan(
wa * f32::from(ca[2]) + wb * f32::from(cb[2]) + wc * f32::from(cc[2]),
];
let mopy = fp.mopy_flags.get(ti).is_some_and(|f| f & 0x1 != 0);
best = Some((z, gi, mocv, mopy));
let material = fp.mopy_material.get(ti).copied().unwrap_or(0xFF);
best = Some((z, gi, mocv, mopy, material));
};
match model.group_footprint_grids.get(gi).and_then(Option::as_ref) {
Some(grid) => grid.candidates(px, py).for_each(&mut consider),
None => (0..fp.indices.len() / 3).for_each(&mut consider),
}
}
best.map(|(_, gi, mocv, mopy)| (gi, mocv.map(|v| v.round().clamp(0.0, 255.0) as u8), mopy))
best.map(|(_, gi, mocv, mopy, material)| {
(
gi,
mocv.map(|v| v.round().clamp(0.0, 255.0) as u8),
mopy,
material,
)
})
}
/// Whether a probe column (model-local WoW coords) can hit any of a building's collision faces:
@@ -692,6 +734,7 @@ mod tests {
lights: Vec::new(),
group_bounds: Vec::new(),
group_footprints: Vec::new(),
material_ground_type: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
@@ -706,7 +749,7 @@ mod tests {
/// sits far away in XY with a HIGHER face (z = 5) that would shadow group 0's hit if a broken
/// cull ever let its column test slip.
fn two_group_footprints() -> Vec<Option<FootprintTris>> {
let face = |offset: [f32; 3]| FootprintTris {
let face = |offset: [f32; 3], material: u8| FootprintTris {
positions: vec![
[offset[0], offset[1], offset[2]],
[offset[0] + 10.0, offset[1], offset[2]],
@@ -715,10 +758,11 @@ mod tests {
indices: vec![0, 1, 2],
mocv: vec![[10, 20, 30]; 3],
mopy_flags: vec![0],
mopy_material: vec![material],
};
vec![
Some(face([-5.0, -5.0, 0.0])),
Some(face([100.0, 100.0, 5.0])),
Some(face([-5.0, -5.0, 0.0], 1)),
Some(face([100.0, 100.0, 5.0], 2)),
]
}
@@ -728,6 +772,7 @@ mod tests {
fn slab_field(n: usize) -> FootprintTris {
let (mut positions, mut indices, mut mocv, mut mopy_flags) =
(Vec::new(), Vec::new(), Vec::new(), Vec::new());
let mut mopy_material = Vec::new();
for iy in 0..n {
for ix in 0..n {
let (x, y) = (ix as f32 * 4.0, iy as f32 * 4.0);
@@ -744,6 +789,7 @@ mod tests {
mocv.extend_from_slice(&[[shade, shade / 2, shade / 3]; 4]);
indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
mopy_flags.extend_from_slice(&[0, 0]);
mopy_material.extend_from_slice(&[0, 0]);
}
}
FootprintTris {
@@ -751,9 +797,38 @@ mod tests {
indices,
mocv,
mopy_flags,
mopy_material,
}
}
/// **The material ray reads the hit FACE's material, inside the group the arbitration chose.**
/// The group restriction is the whole point (decision 1161): the first ray already picked the
/// group off the *collision* faces, so a nearer render face in a different group must not steal
/// the answer. And a group with no face under the column is the client's `−1` — silent — not an
/// invitation to go looking elsewhere.
#[test]
fn material_ray_resolves_per_group_and_bounds_checks() {
let mut model = bare_model();
model.group_footprints = two_group_footprints();
model.group_footprint_bounds = footprint_tri_bounds(&model.group_footprints);
// A root MOMT: material 0 unauthored ("None"), 1 = Wood (TerrainType 4), 2 = Snow (3).
model.material_ground_type = vec![10, 4, 3];
// Over group 0's floor, asking about group 0: its own face's material.
assert_eq!(surface_terrain_sample(&model, 0, [0.0, 0.0, 2.0]), Some(4));
// The same column asked about group 1 — whose only face is 100 yd away — is silent, and
// must NOT wander into group 0's face.
assert_eq!(surface_terrain_sample(&model, 1, [0.0, 0.0, 2.0]), None);
// Group 1's own column resolves group 1's material.
assert_eq!(
surface_terrain_sample(&model, 1, [103.0, 103.0, 7.0]),
Some(3)
);
// A material id past the root's MOMT is the bounds check the client doesn't do: silent,
// never an out-of-range index.
model.material_ground_type = vec![10];
assert_eq!(surface_terrain_sample(&model, 0, [0.0, 0.0, 2.0]), None);
}
/// **The column index never changes a verdict.** Same model, same columns, indexed vs not:
/// identical `(group, MOCV, MOPY)` every time — including the exact-z ties the sawtooth field
/// manufactures, where the answer depends on the "later face wins" rule the index's ascending
+169 -61
View File
@@ -40,6 +40,7 @@ pub(crate) use interior::{
indoor_verdict_at, indoors_at, terrain_z_local, IndoorVerdict, LightAttach,
INTERIOR_PROBE_HEIGHT,
};
pub(crate) use interior::{surface_terrain_sample, POSITION_PROBE_LIFT};
use interior::{track_area_interior, track_current_interior, track_unit_interiors};
pub use interior::{CurrentAreaInterior, CurrentWmoInterior, PlayerWmoRoom, UnitWmoRoom};
pub use probe::WmoCullProbe;
@@ -50,7 +51,7 @@ pub(crate) use seed::{down_ray_seeds, floor_z_at, DownRaySeeds};
use crate::player::WorldCamera;
use crate::terrain_stream::{terrain_height_under, TerrainStreamer};
use benilla_assets::coords::{bevy_to_wow, wow_to_bevy};
use benilla_assets::{AdtTile, WmoModel, WmoPortalInfo};
use benilla_assets::{AdtTile, WmoGroupNav, WmoModel, WmoPortalInfo};
use bevy::camera::Projection;
use bevy::math::{Affine3A, Vec4};
use bevy::prelude::*;
@@ -333,67 +334,64 @@ fn compute_wmo_pvs(
let local_from_world = world_from_local.inverse();
let eye_local = bevy_to_wow(local_from_world.transform_point3(eye_world));
let terrain_local = terrain.map(|z| terrain_z_local(&local_from_world, eye_world, z));
if let Some(text) = &mut dump_text {
let mut trace = TraceLog::new(model, eye_local, terrain_local);
inst.visible = compute_pvs_traced(
model,
eye_local,
terrain_local,
&clip_from_world,
&world_from_local,
&mut trace,
);
text.push_str(&trace.text);
// ONE flood, with whichever recorders are attached — the dump must never change the answer
// it is photographing. It used to be an `if dump { trace } else { tap }`, so `WOW_CULLDUMP=1`
// skipped the claim/fog/window computation outright and every dumped run reported
// `room=none windows=unrestricted` no matter where the camera stood; the trace file said
// `seed: in Some(102)` on the same frame. That cost a wrong reading while diagnosing 1148.
let mut tap = SeedTap::default();
let mut log = dump_text
.is_some()
.then(|| TraceLog::new(model, eye_local, terrain_local));
inst.visible = compute_pvs_traced(
model,
eye_local,
terrain_local,
&clip_from_world,
&world_from_local,
&mut (&mut tap, &mut log),
);
if let (Some(text), Some(log)) = (&mut dump_text, &log) {
text.push_str(&log.text);
text.push_str(&format!("visible: {:?}\n\n", inst.visible));
} else {
let mut tap = SeedTap::default();
inst.visible = compute_pvs_traced(
model,
eye_local,
terrain_local,
&clip_from_world,
&world_from_local,
&mut tap,
);
// Interior-fog + weather claims off the seed already computed. The claim mask is
// bit 0x8 ALONE (`[0xc7b748]` writer `0x6be451/77` tests only EXTERIOR — round 6
// Q-H(c)): an EXTERIOR_LIT-only porch/courtyard (`0x40`) still claims the camera
// and engages this WMO's MFOG fog; the weather gate reads whether the placement's
// flood reached an exterior group (already view-clipped — the deferred-window gate
// fires only when the doorway's screen rect survives, the carved pass-1 law).
if claim.is_none() {
if let Some(gi) = tap.in_group {
if let Some(nav) = model.group_nav.get(gi).filter(|n| n.flags & 0x8 == 0) {
fog_target = select_wmo_fog(&model.fogs, nav.fog_indices, eye_local);
claim = Some(InteriorClaim {
room: WmoRoom {
instance: entity,
group: gi as u16,
},
exterior_visible: model
.group_nav
.iter()
.zip(&inst.visible)
.any(|(n, v)| *v && n.flags & 0x148 != 0),
});
// The deferred exterior-window list is this claiming placement's alone: the
// reference floods the CONTAINING map object (`[0xc7b748]`) and defers that
// flood's windows. A step whose destination is EXTERIOR-flagged
// (`0x148`, the same mask the weather gate uses) IS a window onto the
// outdoor world, and the rect it was entered on is the accumulated clip.
windows = Some(
tap.entered
.iter()
.filter(|(to, _)| {
model
.group_nav
.get(*to)
.is_some_and(|n| n.flags & 0x148 != 0)
})
.map(|(_, rect)| *rect)
.collect(),
);
}
}
// Interior-fog + weather claims off the seed already computed. The claim mask is
// bit 0x8 ALONE (`[0xc7b748]` writer `0x6be451/77` tests only EXTERIOR — round 6
// Q-H(c)): an EXTERIOR_LIT-only porch/courtyard (`0x40`) still claims the camera
// and engages this WMO's MFOG fog; the weather gate reads whether the placement's
// flood reached an exterior group (already view-clipped — the deferred-window gate
// fires only when the doorway's screen rect survives, the carved pass-1 law).
if claim.is_none() {
if let Some(gi) = tap.in_group {
if let Some(nav) = model.group_nav.get(gi).filter(|n| n.flags & EXTERIOR == 0) {
fog_target = select_wmo_fog(&model.fogs, nav.fog_indices, eye_local);
claim = Some(InteriorClaim {
room: WmoRoom {
instance: entity,
group: gi as u16,
},
exterior_visible: model
.group_nav
.iter()
.zip(&inst.visible)
.any(|(n, v)| *v && n.flags & 0x148 != 0),
});
// The deferred exterior-window list is this claiming placement's alone: the
// reference floods the CONTAINING map object (`[0xc7b748]`) and defers that
// flood's windows. The push test is [`opens_a_window`] — `0x8` ALONE, NOT the
// `0x148` on the line above. Those two are neighbours in the binary and
// neighbours here, and 0774 recorded them as *correlated, not equivalent*;
// the port read `0x148` anyway, which made every `0x40`-without-`0x8` group a
// doorway onto the open world. Stormwind's streets are 115 of its 306 groups
// (decision 0475), so one Trade District room opened eleven windows onto
// Elwynn — decision 1148.
windows = Some(
tap.entered
.iter()
.filter(|(to, _)| opens_a_window(&model.group_nav, *to))
.map(|(_, rect)| *rect)
.collect(),
);
}
}
}
@@ -454,6 +452,17 @@ impl FloodTrace for SeedTap {
}
}
/// **The deferred-window push test.** A flood step opens a window onto the OPEN WORLD exactly when
/// its DESTINATION group carries `0x8` — the recursion resolves the neighbour and tests
/// `0x6b44f8 test byte [eax+0x10],0x8; 0x6b44fc je <skip-defer>` before pushing the clipped rect into
/// `0xcbe310`/`0xcbe320` (wow-re `models/scratch/wmo-insideleg-phase3.md`, "Worklist provenance" —
/// VERIFIED at the bytes). Nothing else qualifies: not [`EXTERIOR_LIT`], not the weather global's
/// `0x148` (that mask lives at `0x6b42d0` and drives `[0xca80c4]`, a *correlated but different*
/// signal — decision 0774 said so and the port read it anyway; decision 1148).
fn opens_a_window(nav: &[WmoGroupNav], to: usize) -> bool {
nav.get(to).is_some_and(|n| n.flags & EXTERIOR != 0)
}
/// A tap on the flood's per-portal decisions. The `()` impl is a no-op the per-frame path uses; the
/// probe dump and the audit harness pass a real recorder — one flood implementation, no trace copy to
/// drift.
@@ -466,6 +475,62 @@ pub(crate) trait FloodTrace {
}
impl FloodTrace for () {}
/// **Two recorders on one flood.** The per-frame path always needs the [`SeedTap`]; the dump wants a
/// [`TraceLog`] as well. Pairing them keeps that a single flood — the alternative (branch on "are we
/// dumping" and run one or the other) is what let `WOW_CULLDUMP=1` silently drop the claim/window
/// computation and report a room the same trace file contradicted.
impl<A: FloodTrace, B: FloodTrace> FloodTrace for (&mut A, &mut B) {
fn seed(&mut self, seeds: DownRaySeeds) {
self.0.seed(seeds);
self.1.seed(seeds);
}
fn side_fail(&mut self, from: usize, portal: u16, to: usize, d: f32) {
self.0.side_fail(from, portal, to, d);
self.1.side_fail(from, portal, to, d);
}
fn rect_none(&mut self, from: usize, portal: u16, to: usize) {
self.0.rect_none(from, portal, to);
self.1.rect_none(from, portal, to);
}
fn rect_collapse(&mut self, from: usize, portal: u16, to: usize) {
self.0.rect_collapse(from, portal, to);
self.1.rect_collapse(from, portal, to);
}
fn entered(&mut self, from: usize, portal: u16, to: usize, rect: Rect, on_plane: bool) {
self.0.entered(from, portal, to, rect, on_plane);
self.1.entered(from, portal, to, rect, on_plane);
}
}
/// An absent recorder records nothing — so "attach the dump only when asked" costs no second flood.
impl<T: FloodTrace> FloodTrace for Option<T> {
fn seed(&mut self, seeds: DownRaySeeds) {
if let Some(t) = self {
t.seed(seeds);
}
}
fn side_fail(&mut self, from: usize, portal: u16, to: usize, d: f32) {
if let Some(t) = self {
t.side_fail(from, portal, to, d);
}
}
fn rect_none(&mut self, from: usize, portal: u16, to: usize) {
if let Some(t) = self {
t.rect_none(from, portal, to);
}
}
fn rect_collapse(&mut self, from: usize, portal: u16, to: usize) {
if let Some(t) = self {
t.rect_collapse(from, portal, to);
}
}
fn entered(&mut self, from: usize, portal: u16, to: usize, rect: Rect, on_plane: bool) {
if let Some(t) = self {
t.entered(from, portal, to, rect, on_plane);
}
}
}
/// The portal flood. Returns the per-group visible set (indexed by absolute group index). `eye_local` is
/// the camera in WMO model space (WoW axes): it seeds the flood (the group whose floor it stands over —
/// the down-ray) and drives the per-portal front-side test. `clip_from_world` / `world_from_local`
@@ -790,6 +855,48 @@ mod tests {
assert!(key(&[9]).drawn_by(&inst), "index past the set fails open");
}
/// **Only `0x8` opens a window onto the open world** (`0x6b44f8`), and the flag values here are
/// Stormwind's own, read off a live cull trace at the director's `.go xyz -8798.70 478.64 95.79`
/// (decision 1148). The city is 306 groups of which **exactly one** — `g46`, `0x08809` — carries
/// `0x8`; the streets and rooms are `0x40`-without-`0x8` (decision 0475). Testing the weather
/// global's `0x148` instead turned all eleven rooms the flood reached from that spot into
/// doorways onto Elwynn, one of them 74% of the screen wide. If this test ever goes green on
/// `0x148` again, the whole exterior cull is off in every city.
#[test]
fn only_an_exterior_group_opens_a_window_onto_the_open_world() {
let g = |flags: u32| WmoGroupNav {
flags,
bbox_min: [0.0; 3],
bbox_max: [0.0; 3],
ref_start: 0,
ref_count: 0,
area_table_id: 0,
fog_indices: [0; 4],
group_liquid: 0xf,
};
// Stormwind's real group flags: the rooms/streets the flood reached from the reported spot…
let nav = [g(0x0b841), g(0x0a841), g(0x0ba41), g(0x08809), g(0x02a05)];
for to in [0, 1, 2] {
assert!(
!opens_a_window(&nav, to),
"g{to} is EXTERIOR_LIT (0x40) without EXTERIOR (0x8) — a lit indoor room, not a \
doorway onto Elwynn"
);
}
assert!(!opens_a_window(&nav, 4), "0x02a05 carries neither bit");
// …and g46, the city's single exterior shell, which is the only real window.
assert!(
opens_a_window(&nav, 3),
"0x08809 carries 0x8 — this one IS the open world"
);
// A destination past the nav (a corrupt MOPR ref) fails CLOSED: an unknown group is not
// evidence of a doorway, and failing open here is the bug this test exists to catch.
assert!(
!opens_a_window(&nav, 99),
"an out-of-range group is no window"
);
}
#[test]
fn eye_on_portal_only_within_the_band_and_polygon() {
// A vertical doorway portal in the x=0 plane, spanning y ∈ [-2,2], z ∈ [0,4].
@@ -946,6 +1053,7 @@ mod tests {
lights: Vec::new(),
group_bounds: Vec::new(),
group_footprints: Vec::new(),
material_ground_type: Vec::new(),
group_footprint_bounds: Vec::new(),
group_footprint_grids: Vec::new(),
group_light_refs: Vec::new(),
+52 -5
View File
@@ -1,6 +1,7 @@
//! BLP → Bevy [`Image`] loader.
//!
//! Decodes a WoW BLP texture into an `Image` in one of three variants (selected by loader settings):
//! Decodes a WoW BLP texture into an `Image` in one of several variants (selected by loader
//! settings):
//! - [`BlpVariant::WorldArt`] — tiling world/model albedo: `Rgba8Unorm` (the RE'd gamma-space
//! invariant — the GPU does *not* linearize albedo on sample, so shader math stays in WoW's
//! gamma/byte space), the BLP's **authored** mip pyramid used verbatim (the real 1.12 client cannot
@@ -28,11 +29,20 @@ pub enum BlpVariant {
WorldArt,
/// Emissive billboard (celestial disc) — `Rgba8UnormSrgb`, clamp, mip 0 only.
Sprite,
/// Gamma-lane effect sprite (weather flakes/splashes) — `Rgba8Unorm` like [`Self::WorldArt`]
/// but **mip 0 only** + clamp: thin cut-out shapes (a snowflake's arms) collapse to
/// near-zero alpha under the authored mip chain when a small world-space quad minifies —
/// the reference draws these through point-sprite/near-mip-0 paths and keeps them crisp.
/// Gamma-lane effect sprite (rain splashes, weather mist) — `Rgba8Unorm` like
/// [`Self::WorldArt`] but **mip 0 only** + clamp: these draw as world-space quads that are
/// magnified far more often than minified, and their thin cut-out arms collapse to near-zero
/// alpha under the authored mip chain.
Effect,
/// A **point sprite** — [`Self::Effect`]'s clamp and gamma lane, but with the BLP's
/// **authored mip pyramid** kept (trilinear, no anisotropy). The snow flake's lane: the
/// reference draws it through `glDrawArrays(GL_POINTS)` with `GL_COORD_REPLACE`, so the whole
/// texture maps across a sprite that is **14 px at the eye and 1 px past 46 yd** — a 4.5×–64×
/// *minification* of a 64×64 texture, which is why the asset ships 7 mip levels. Mip-0-only
/// here is not "crisp", it is aliased: a 1-px flake samples one arbitrary texel of a dendrite
/// and the field becomes flickering speckle.
/// (wow-re `system/lighting/scratch/rf-snow-flake-render.md` §2.1/§6.)
PointSprite,
/// OS cursor image — `Rgba8UnormSrgb`, single mip.
Cursor,
}
@@ -76,6 +86,9 @@ impl AssetLoader for BlpImageLoader {
let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?;
effect_image(w, h, rgba)
}
BlpVariant::PointSprite => {
point_sprite_image(blp_bytes_to_mip_chain(&bytes).map_err(to_io)?)
}
BlpVariant::Cursor => {
let (w, h, rgba) = blp_to_rgba(&bytes).map_err(to_io)?;
cursor_image(w, h, rgba)
@@ -183,6 +196,40 @@ fn effect_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
image
}
/// Point sprite: [`effect_image`]'s gamma lane and clamp, with the **authored mip pyramid** —
/// see [`BlpVariant::PointSprite`]. No anisotropy: a point sprite is always screen-axis-aligned
/// and square, so there is no anisotropic footprint to correct for.
fn point_sprite_image(chain: BlpMipChain) -> Image {
let levels = chain.mips.len() as u32;
let mip0 = chain.mips[0].clone(); // satisfies Image::new's length assert; full chain set below
let mut data = Vec::with_capacity(chain.mips.iter().map(Vec::len).sum());
for mip in &chain.mips {
data.extend_from_slice(mip);
}
let mut image = Image::new(
Extent3d {
width: chain.width,
height: chain.height,
depth_or_array_layers: 1,
},
TextureDimension::D2,
mip0,
color_format(),
RenderAssetUsages::RENDER_WORLD,
);
image.data = Some(data);
image.texture_descriptor.mip_level_count = levels;
image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor {
address_mode_u: ImageAddressMode::ClampToEdge,
address_mode_v: ImageAddressMode::ClampToEdge,
mag_filter: ImageFilterMode::Linear,
min_filter: ImageFilterMode::Linear,
mipmap_filter: ImageFilterMode::Linear,
..Default::default()
});
image
}
/// OS cursor image: sRGB, single mip (cursors are tiny and need neither a mip chain nor repeat).
fn cursor_image(width: u32, height: u32, rgba: Vec<u8>) -> Image {
Image::new(
+5
View File
@@ -169,6 +169,10 @@ pub struct WmoModel {
/// object and bakes the hit's barycentric MOCV, floor-168/cap-96, on the fixed interior axis
/// (`0x69e4c0`; wow-re `wmo-lit-selector.md`, trace-decisive on the abbey benches).
pub group_footprints: Vec<Option<FootprintTris>>,
/// Root MOMT `ground_type` per material — the `TerrainType.dbc` id a face's
/// `FootprintTris::mopy_material` resolves to. Shared by every group (MOMT lives on the root),
/// and the tail of the footstep chain's WMO leg (decision 1161).
pub material_ground_type: Vec<u32>,
/// Per-group AABB of the faces [`Self::group_footprints`] actually references (parallel;
/// `None` = no footprint faces), computed at load from the triangles themselves — the broad
/// phase for the footprint down-ray, the same exact-cull argument as
@@ -722,6 +726,7 @@ impl AssetLoader for WmoModelLoader {
doodad_owner,
doodad_groups,
group_footprints,
material_ground_type: root.material_ground_types(),
group_footprint_bounds,
group_footprint_grids,
group_light_refs,
@@ -0,0 +1,148 @@
//! What surface the footstep chain resolves under a patch of world:
//! `cargo run -p benilla-formats --example surface_here -- <map> <x> <y> [radius_yd] [class]`
//! e.g. `surface_here Azeroth -5595.89 -529.48 24`.
//!
//! A footstep bug is reported as a *sound* ("I'm walking on snow indoors") but it is decided far
//! upstream, by which surface the down-ray claims under the foot. This prints that claim as a
//! top-down map — north up, west left — so the answer is a picture of the ground rather than an
//! impression of the audio: per cell the `TerrainType` the chain lands on, and at the pin the full
//! walk through to the `SoundEntries` kit that would actually play.
//!
//! **This is the ADT leg only** — deliberately. The client (and, since decision 1161, benilla)
//! arbitrates this against a WMO probe and the nearer surface wins, so inside a building the live
//! answer comes from the floor, not from here. What this map shows indoors is the ground *under*
//! that floor: the counterfactual. That is what makes a wrong-surface report legible — B236's
//! sequel was "snow indoors", and this printed 1681 of 1681 cells Snow beneath the Kharanos inn.
//! To see which leg actually answered in a live run, use `RUST_LOG=benilla_app::sound=debug` and
//! read the `footstep: wmo|adt terrain N` line.
//!
//! Output is Blizzard data — pipe it to the scratchpad, never into the repo.
use std::collections::HashMap;
/// `TerrainType.SoundID` → its authored name and map glyph. The domain is closed and verified
/// against build 5875 (`benilla_formats::footsteps` module docs): the eleven rows collapse to ten
/// sound classes, `DustyGrass` sharing `Grass`'s.
fn class_glyph(sound_class: u32) -> (char, &'static str) {
match sound_class {
0 => ('.', "None"),
1 => ('d', "Dirt"),
2 => ('m', "Metallic"),
3 => ('S', "Stone"),
4 => ('*', "Snow"),
5 => ('w', "Wood"),
6 => ('g', "Grass"),
7 => ('l', "Leaves"),
8 => ('n', "Sand"),
9 => ('q', "Soggy"),
_ => ('?', "unknown"),
}
}
fn main() -> anyhow::Result<()> {
let mut args = std::env::args().skip(1);
let usage =
|| anyhow::anyhow!("usage: surface_here <map> <x> <y> [radius_yd] [footstep_class]");
let map = args.next().ok_or_else(usage)?;
let x: f32 = args.next().ok_or_else(usage)?.parse()?;
let y: f32 = args.next().ok_or_else(usage)?.parse()?;
let radius: f32 = args.next().map_or(Ok(20.0), |r| r.parse())?;
// 7 is the humanoid/character class (its ten rows are the `CharacterMediumLarge*` kits); 8 is
// the small-character class. Both reach it as ordinary data, never a code default.
let class: u32 = args.next().map_or(Ok(7), |c| c.parse())?;
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let cat = benilla_formats::load_footstep_catalog(&mut chain)?;
let tiles = benilla_formats::MapTiles::load(&mut chain, &map)?;
// One sample per cell of a square centred on the pin, north (+x) up and west (+y) left — the
// orientation the in-game map uses, so a spot on this grid is a spot on their screen.
let cols = 41usize;
let step = (radius * 2.0) / (cols - 1) as f32;
let mut meshes: HashMap<(u32, u32), Option<benilla_formats::TileMesh>> = HashMap::new();
let mut census: HashMap<Option<u32>, usize> = HashMap::new();
let sample = |chain: &mut _,
meshes: &mut HashMap<(u32, u32), Option<benilla_formats::TileMesh>>,
sx: f32,
sy: f32|
-> Option<u32> {
let (tx, ty) = tiles.tile_at(sx, sy);
let mesh = meshes
.entry((tx, ty))
.or_insert_with(|| benilla_formats::load_tile_mesh(chain, &map, tx, ty).ok());
let mesh = mesh.as_ref()?;
benilla_formats::ground_effect_at(&mesh.chunks, [sx, sy, 0.0])
};
println!("{map}: footstep surface around ({x}, {y}), r={radius} yd, {step:.2} yd/cell");
println!("(ADT leg only — inside a building this is the ground UNDER the floor)\n");
let mut rows = Vec::with_capacity(cols);
for r in 0..cols {
let sx = x + radius - r as f32 * step;
let mut line = String::with_capacity(cols);
for c in 0..cols {
let sy = y + radius - c as f32 * step;
let effect = sample(&mut chain, &mut meshes, sx, sy);
let terrain = effect.and_then(|e| cat.terrain_of(e));
*census.entry(terrain).or_default() += 1;
let glyph = match terrain.and_then(|t| cat.sound_class_of(t)) {
Some(sc) => class_glyph(sc).0,
// No effect layer / unknown terrain — the client's −1 sentinel, a SILENT footfall.
None => ' ',
};
line.push(glyph);
}
rows.push(line);
}
for (r, line) in rows.iter().enumerate() {
let mark = if r == cols / 2 { " <- pin row" } else { "" };
println!(" {line}{mark}");
}
println!("\n {:>width$}^ pin column", "", width = cols / 2 + 2);
let mut census: Vec<_> = census.into_iter().collect();
census.sort_by_key(|&(_, n)| std::cmp::Reverse(n));
println!("\ncensus over {} cells:", cols * cols);
for (terrain, n) in census {
let label = match terrain {
Some(t) => {
let sc = cat.sound_class_of(t);
let name = sc.map_or("?", |sc| class_glyph(sc).1);
format!("TerrainType {t} ({name}, sound class {})", sc.unwrap_or(0))
}
None => "no effect layer (silent footfall)".to_string(),
};
println!(" {n:5} {label}");
}
// The pin itself, walked end to end — the line a bug report can be checked against.
let effect = sample(&mut chain, &mut meshes, x, y);
println!("\nat the pin exactly:");
println!(" GroundEffectTexture: {effect:?}");
match effect.and_then(|e| cat.terrain_of(e)) {
Some(t) => {
let sc = cat.sound_class_of(t);
println!(
" TerrainType: {t} ({}), SoundID {:?}",
sc.map_or("?", |sc| class_glyph(sc).1),
sc
);
}
None => println!(" TerrainType: none — silent footfall"),
}
let kits = benilla_formats::load_sound_kit_catalog(&mut chain)?;
let name = |id: u32| kits.get(id).map_or("?".into(), |k| k.name.clone());
match cat.resolve(class, effect) {
Some((dry, splash)) => println!(
" class {class} kits: dry {dry} {}, splash {splash} {}",
name(dry),
name(splash)
),
None => println!(" class {class}: no lookup row — silent"),
}
println!(" leaves footprints: {}", cat.leaves_footprints(effect));
Ok(())
}
+82
View File
@@ -76,6 +76,36 @@ impl AreaTableCatalog {
None
}
/// Is `area_id` **cold** — does a unit standing here puff visible breath?
///
/// The client's `0x67e9c0` (wow-re `object-layer/scratch/cold-breath-law.md` Q2), byte-exact:
///
/// ```text
/// cold ⟺ ((leaf.Flags & 0x2) || no valid parent ? leaf.Flags : parent.Flags) & 0x1
/// ```
///
/// **One hop, never a chain walk** ([`Self::top_zone`] is a different question with a
/// different answer). Bit `0x1` is authored on exactly four leaf rows in 5875 — Dun Morogh,
/// Winterspring, Razorfen Downs, Naxxramas — and the single-hop inheritance is what spreads it
/// to the 45 areas that are actually cold: every sub-area of Dun Morogh and Winterspring
/// carries `0x40` (bit `0x2` clear) and therefore reads its zone's flags instead. Bit `0x2` is
/// the opt-out that makes a sub-area answer for itself.
///
/// This is **independent of the weather**: snowfall and breath vapour share no input, and
/// there is no indoor suppression — standing in the Thunderbrew Distillery is as cold as the
/// road outside, because indoor-ness only changes *which row* resolves, not this test.
pub fn is_cold(&self, area_id: u32) -> bool {
let Some(leaf) = self.by_id.get(&area_id) else {
return false;
};
let row = if leaf.flags & 0x2 != 0 {
leaf
} else {
self.by_id.get(&leaf.zone_id).unwrap_or(leaf)
};
row.flags & 0x1 != 0
}
/// The id of the zone **named** `name`, case-insensitively — the reverse of [`Self::name`],
/// for the one caller that has a name and needs the id: `/who`'s `z-"Elwynn Forest"` term,
/// which goes on the wire as a zone id (decision 0668).
@@ -198,4 +228,56 @@ mod tests {
assert_ne!(cat.get(2177).expect("Battle Ring").flags & 0x80, 0);
assert_eq!(cat.get(1741).expect("Gurubashi Arena").flags & 0x80, 0);
}
/// The cold-breath predicate on the shipped table (`0x67e9c0`): bit `0x1` is authored on
/// exactly four rows, and the ONE-HOP parent inheritance is what makes the sub-areas cold.
/// This is the test that would have caught reading the leaf alone — every place a player
/// actually stands in Dun Morogh is a sub-area whose own flags are `0x40`.
#[test]
fn cold_areas_inherit_one_hop_from_their_zone() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_area_table_catalog(&mut chain).expect("load AreaTable");
// The four authored rows.
for (id, label) in [
(1, "Dun Morogh"),
(618, "Winterspring"),
(722, "Razorfen Downs"),
(3456, "Naxxramas"),
] {
assert_ne!(cat.get(id).expect(label).flags & 0x1, 0, "{label} authored");
assert!(cat.is_cold(id), "{label} is cold");
}
// The sub-areas: own flags `0x40` (bit 0x1 clear, bit 0x2 clear) → they read the zone's.
for (id, label) in [
(131, "Kharanos"),
(132, "Coldridge Valley"),
(136, "The Grizzled Den"),
(2255, "Everlook"),
] {
let row = cat.get(id).expect(label);
assert_eq!(row.flags & 0x3, 0, "{label} authors neither bit itself");
assert!(cat.is_cold(id), "{label} inherits its zone's cold");
}
// Negative controls, including the sharp one: Gnomeregan the Dun Morogh sub-area (133) is
// cold; Gnomeregan the dungeon row (721), which is its own top-level zone, is not.
assert!(cat.is_cold(133), "Gnomeregan the sub-area");
for (id, label) in [
(721, "Gnomeregan (dungeon)"),
(12, "Elwynn Forest"),
(1519, "Stormwind City"),
(36, "Alterac Mountains"),
(267, "Hillsbrad Foothills"),
] {
assert!(!cat.is_cold(id), "{label} is not cold");
}
assert!(!cat.is_cold(0), "an unknown area is not cold");
}
}
@@ -27,6 +27,11 @@ const SUBSTATE_ANIM: [u16; 13] = [
157, // 12 Despawn
];
/// The **transient** (transition-motion) substates among the reachable ones — the rows slot 14
/// `0x5f4120` advances off at the arm's window end (§2d: 2 Open → 3 Opened, 4 Close → 1 Closed,
/// 5 Destroy → 6 Destroyed). Their duration is the object layer's, never the clip's loop bit.
const MOTION_SUBSTATES: [usize; 3] = [2, 4, 5];
/// The six substates a `GAMEOBJECT_STATE` × `GAMEOBJECT_ANIMPROGRESS` pair can actually produce
/// (§2b). Substate 0 (Spawn) has no producer at all, and 8..12 come from other opcodes entirely.
const REACHABLE: [(usize, &str); 6] = [
@@ -122,6 +127,11 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
}
let (mut parsed, mut no_seq, mut blind, mut sensitive, mut needs_remap, mut rate0) =
(0u32, 0u32, 0u32, 0u32, 0u32, 0u32);
// The transition half (decision 1151): a MOTION substate whose resolved sequence is bit-0-clear
// is one the kernel wraps for ever — so it is bounded only by the object layer's §2d completion
// advance, and a consumer that arms it by the loop bit instead flaps. And `replay` decides
// whether that window is one band or several.
let (mut looping_motion, mut looping_motion_sensitive, mut multi_replay) = (0u32, 0u32, 0u32);
for (path, displays) in &models {
let Ok(bytes) = chain.read_file(path) else {
continue;
@@ -139,6 +149,7 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
let seed = go_loader_seed(m, &seqs);
let mut lines = Vec::new();
let (mut differs, mut remapped, mut froze) = (false, false, false);
let (mut flaps, mut replays) = (false, false);
for (sub, label) in REACHABLE {
let lut = SUBSTATE_ANIM[sub];
let (req, r0) = go_remap(m, lut);
@@ -146,6 +157,15 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
remapped |= req != lut;
froze |= r0;
differs |= armed.map(|(_, s)| s) != seed.map(|(_, s)| s);
// The played sequence's own kernel law. `slot` is the M2's FILE slot, which is not this
// list's index (zero-duration sequences are dropped), so match on `seq_index`.
let played =
armed.and_then(|(_, slot)| seqs.iter().find(|s| s.seq_index == slot as usize));
// A transient substate (a transition motion) armed on a band the kernel wraps: what
// ends it is §2d, never the clip.
let motion = MOTION_SUBSTATES.contains(&sub);
flaps |= motion && !r0 && played.is_some_and(|s| s.looping);
replays |= played.is_some_and(|s| (s.min_replay, s.max_replay) != (0, 0));
lines.push(format!(
" {label} sub{sub} lut {lut}{} -> {}{}",
if req == lut {
@@ -157,9 +177,30 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
Some((id, slot)) => format!("id {id} slot {slot}"),
None => "NOTHING".to_string(),
},
if r0 { " [rate 0 — frozen]" } else { "" },
match played {
None => String::new(),
Some(s) => format!(
" {}{}{}",
if s.looping { "loop " } else { "clamp" },
if r0 {
" [rate 0 — frozen]"
} else if motion {
" MOTION"
} else {
""
},
if (s.min_replay, s.max_replay) == (0, 0) {
String::new()
} else {
format!(" replay {}..{}", s.min_replay, s.max_replay)
}
),
},
));
}
flaps.then(|| looping_motion += 1);
(flaps && differs).then(|| looping_motion_sensitive += 1);
replays.then(|| multi_replay += 1);
if differs {
sensitive += 1;
} else {
@@ -196,6 +237,16 @@ pub fn goanimscan(chain: &mut Chain) -> Result<()> {
models a GO type that skips the arm renders in the wrong pose"
);
println!(" needing the §2c remap on some substate: {needs_remap}");
println!(
" arming a LOOPING band on a transition (motion) substate: {looping_motion} \
({looping_motion_sensitive} of them state-SENSITIVE, i.e. the transition is a clip the \
rest pose isn't) — the §2d completion advance is the only thing that ends these; read \
as \"should this clip repeat?\" they swing for ever (decision 1151)"
);
println!(
" authoring a non-empty replay range on a reachable substate: {multi_replay} — R > 1 \
would make the transition several band lengths, so 0 here means one window IS the swing"
);
println!(" hitting a rate-0 freeze leg (a motion clip standing in for a missing rest pose): {rate0}");
Ok(())
}
+65
View File
@@ -114,6 +114,8 @@ struct DisplayRow {
struct ModelRow {
path: String,
scale: f32,
/// `Flags` (field 1) — see [`CreatureCatalog::breathes`] for the one bit we read.
flags: u32,
/// `BloodID` — see [`CreatureModel::blood`]. Reads signed: `−1` marks a bloodless model.
blood: i32,
/// `FootprintTextureID` (field 6) — the `FootprintTextures.dbc` key. Reads signed: `−1`
@@ -187,6 +189,24 @@ impl CreatureCatalog {
Some(self.models.get(&row.model_id)?.collision_height)
}
/// Does this display's model **breathe** — i.e. may it wear the `$BTH` hardcoded effects
/// (cold vapour, underwater bubbles, inebriated bubbles)?
///
/// `CreatureModelData.Flags & 0x2` suppresses the whole family (wow-re
/// `object-layer/scratch/cold-breath-law.md` Q4, at `[unit+0xb3c]`): 99 of the 430 shipped
/// rows carry it — skeletons, ghosts, ghouls, zombies, banshees, every elemental, golems,
/// slimes, infernals, voidwalkers, succubi, spiders, frogs, crocodiles, turtles, totems. The
/// things that have no breath to see. Every player row is `0x4`, so players pass.
///
/// An unknown display breathes — this catalog's degrade shape is "fall back to the common
/// case", and the common case is 331 of the 430 rows.
pub fn breathes(&self, display_id: u32) -> bool {
self.display
.get(&display_id)
.and_then(|row| self.models.get(&row.model_id))
.is_none_or(|m| m.flags & 0x2 == 0)
}
/// A display's **footprint decal** parameters — `CreatureModelData` fields 6..=8 through the
/// display→model chain, sizes converted to **yards** (the client's own ×(1/36) inches→yards
/// cache, byte-verified at `0x607a00` for the mounted getter `0x607920`). `None` when either
@@ -334,6 +354,7 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
ModelRow {
path: name,
scale: f32_at(r, 4).unwrap_or(1.0),
flags: u32_at(r, 1).unwrap_or(0),
// BloodID (field 5) reads signed: −1 marks a bloodless model in the real data.
blood: u32_at(r, 5).map_or(0, |v| v as i32),
// FootprintTextureID reads signed too: −1 = no prints (see ModelRow docs).
@@ -567,6 +588,50 @@ mod tests {
/// were authored post-`modelScale` the box would read 2.111/1.25 = 1.689, so this row alone
/// refutes the "divide the model scale out" reading (which is what vmangos's server-side
/// `Unit::UpdateModelData` does).
/// `CreatureModelData.Flags & 0x2` — the `$BTH` suppression (B233, decision 1149). The census
/// on the shipped table is 99 of 430 rows, and the split is semantic, not arbitrary: the
/// things with no breath to see. **Every player row passes** (they carry `0x4`), which is what
/// makes the flag safe to gate the reported case on.
#[test]
fn the_breathless_models_are_the_ones_with_no_breath() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
assert_eq!(
cat.models.values().filter(|m| m.flags & 0x2 != 0).count(),
99,
"the shipped census: 99 of {} models suppress breath",
cat.models.len()
);
// Every playable race's own display breathes — the reported case.
for (display_id, label) in [
(49, "HumanMale"),
(50, "HumanFemale"),
(53, "DwarfMale"),
(59, "TaurenMale"),
(1564, "GnomeFemale"),
] {
assert!(cat.breathes(display_id), "{label} breathes");
}
// …and the breathless: a skeleton, a water elemental, an infernal.
for (display_id, label) in [
(158, "Skeleton"),
(110, "WaterElemental"),
(169, "Infernal"),
] {
assert!(!cat.breathes(display_id), "{label} has no breath to see");
}
assert!(
cat.breathes(0),
"an unknown display falls to the common case"
);
}
#[test]
fn collision_height_is_the_m2_collision_box() {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
+34 -2
View File
@@ -49,7 +49,17 @@ impl FootstepCatalog {
/// ground-effect layer. `None` = silence: no/unknown effect layer (the client's −1 sentinel,
/// module docs), or no lookup row for `(class, terrain)`.
pub fn resolve(&self, footstep_class: u32, effect_id: Option<u32>) -> Option<(u32, u32)> {
let terrain = self.effect_terrain.get(&effect_id?).copied()?;
self.resolve_terrain(
footstep_class,
self.effect_terrain.get(&effect_id?).copied()?,
)
}
/// The same `(dry, splash)` answer from a `TerrainType` id **directly**, skipping the
/// `GroundEffectTexture` hop. This is the tail both legs of the client's down-ray share: the
/// ADT leg reaches a terrain id through the ground-effect layer, the WMO leg carries one in
/// the surface itself.
pub fn resolve_terrain(&self, footstep_class: u32, terrain: u32) -> Option<(u32, u32)> {
let sound_class = self.terrain_sound.get(&terrain).copied()?;
self.lookup.get(&(footstep_class, sound_class)).copied()
}
@@ -61,10 +71,32 @@ impl FootstepCatalog {
pub fn leaves_footprints(&self, effect_id: Option<u32>) -> bool {
effect_id
.and_then(|e| self.effect_terrain.get(&e))
.and_then(|t| self.terrain_flags.get(t))
.is_some_and(|&t| self.terrain_leaves_footprints(t))
}
/// The same `TerrainType.Flags` bit 0 gate from a terrain id **directly** — the form both legs
/// of the down-ray share, since the WMO leg carries a terrain id rather than an effect layer.
/// Set on exactly Snow (3) and Sand (7) in the shipped data; the unauthored WMO default
/// `10 "None"` is clear, which is why a building's floor takes no prints.
pub fn terrain_leaves_footprints(&self, terrain: u32) -> bool {
self.terrain_flags
.get(&terrain)
.is_some_and(|flags| flags & 1 != 0)
}
/// The `TerrainType` id under a ground-effect layer — the chain's first hop on its own.
/// Exposed for the `surface_here` probe, which has to show *where* the chain lands, and where
/// it falls off, not just the kit it ends at.
pub fn terrain_of(&self, effect_id: u32) -> Option<u32> {
self.effect_terrain.get(&effect_id).copied()
}
/// A `TerrainType`'s `SoundID` — the `FootstepTerrainLookup` axis the kit is chosen on.
/// Companion to [`Self::terrain_of`]; the same map [`Self::resolve`] walks.
pub fn sound_class_of(&self, terrain: u32) -> Option<u32> {
self.terrain_sound.get(&terrain).copied()
}
pub fn len(&self) -> usize {
self.lookup.len()
}
@@ -132,6 +132,15 @@ pub struct FootprintTris {
/// day/night exterior colours as the object's base instead of the MOCV sample (§11's binary
/// selector).
pub mopy_flags: Vec<u8>,
/// Per-TRIANGLE MOPY **material id** (parallel to [`Self::mopy_flags`]) — the index into the
/// ROOT's MOMT, and through it the face's `TerrainType` (`WmoRoot::material_ground_types`).
/// This is the footstep surface's WMO leg: the client's material ray reads exactly this byte
/// off the hit face (`0x6a26fc`, wow-re `wmo-footstep-surface.md`).
///
/// `0xFF` occurs on disk and is not an index — those faces all carry MOPY flag `0x08` and are
/// already dropped by [`FOOTPRINT_REJECT`] before anything can sample them. Consumers still
/// bounds-check: the client does not, the shipped assets simply never break the invariant.
pub mopy_material: Vec<u8>,
}
/// The footprint ray's per-face MOPY reject mask (wow-re `unit-light-combine-storm.md`, the BSP
@@ -155,6 +164,7 @@ pub fn wmo_group_footprint_tris(group_bytes: &[u8]) -> Option<FootprintTris> {
}
let mut indices = Vec::new();
let mut mopy_flags = Vec::new();
let mut mopy_material = Vec::new();
for (ti, tri) in group.vertex_indices.chunks_exact(3).enumerate() {
let flags = group.material_info.get(ti).map_or(0, |m| m.flags);
if flags & FOOTPRINT_REJECT != 0 {
@@ -162,6 +172,7 @@ pub fn wmo_group_footprint_tris(group_bytes: &[u8]) -> Option<FootprintTris> {
}
indices.extend_from_slice(tri);
mopy_flags.push(flags);
mopy_material.push(group.material_info.get(ti).map_or(0xFF, |m| m.material_id));
}
Some(FootprintTris {
positions: group
@@ -176,6 +187,7 @@ pub fn wmo_group_footprint_tris(group_bytes: &[u8]) -> Option<FootprintTris> {
.map(|c| [c.r, c.g, c.b])
.collect(),
mopy_flags,
mopy_material,
})
}
@@ -106,6 +106,20 @@ impl WmoRoot {
&self.group_infos
}
/// Per-material **`TerrainType.dbc` id** (MOMT+0x20), indexed by the MOPY per-face material id
/// — what walking on each material sounds like, shared root-wide across every group.
///
/// The footstep chain's WMO leg (decision 1161): the client's down-ray arbitrates terrain
/// against a building's collision faces, and when the building wins it re-rays that group's
/// RENDER faces and resolves `MOPY[face].material_id → MOMT[id]+0x20` (`0x6a26c0`, wow-re
/// `wmo-footstep-surface.md`).
pub fn material_ground_types(&self) -> Vec<u32> {
match &self.parsed {
ParsedWmo::Root(r) => r.materials.iter().map(|m| m.ground_type).collect(),
_ => Vec::new(),
}
}
/// The root's portal graph (MOPV/MOPT/MOPR) — the data that drives per-group visibility culling
/// (see [`WmoPortals`]). Empty for WMOs with no portals (most single-group props).
pub fn portals(&self) -> &WmoPortals {
+52 -1
View File
@@ -54,7 +54,13 @@ pub struct SoundKit {
/// Selection/cull radius: the `d² < cutoff²` audibility gate + per-frame virtualization
/// (wow-re `0x45cdf0`/`0x7a5000`). `0` = non-positional (no 3D cull).
pub distance_cutoff: f32,
/// Per-kit EAX class (0/1/2 in the data) — reverb-send tier, consumed with the reverb slice.
/// `SoundSamplePreferences.dbc` FK — the per-channel EAX wet send (0/1/2 in the data;
/// 2 072 kits carry 0, 2 549 carry 2, 2 carry 1). **`0` means dry, not "default"**: that DBC
/// holds only ids 1 and 2, so the client's id-indexed slot lookup (`0x45cdc0`) returns NULL
/// and `FSOUND_Reverb_SetChannelProperties` (`0x7a5bf0`) skips before it even tests the
/// 3D-open flag. Authored dryness — and it is how NPC voice lines stay out of an interior's
/// reverb: **all 275 `SoundType 17` rows are `EAXDef 0`** (creature barks split 706 wet /
/// 285 dry). wow-re `reverb-default-and-eax-hardware.md`; benilla decision 1155.
pub eax_def: u32,
}
@@ -215,4 +221,49 @@ mod tests {
bytes.len()
);
}
/// The `EAXDef` census the reverb send is gated on (decision 1155, bug B236). `EAXDef 0` is a
/// NULL `SoundSamplePreferences` slot in the client, i.e. a channel that never receives reverb
/// properties — so this is the authored line between wet and dry, and it must not drift.
/// Skips without client data.
#[test]
fn real_sound_entries_eaxdef_census() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_sound_kit_catalog(&mut chain).expect("load sound kits");
// Only three values exist, and `SoundSamplePreferences.dbc` has rows 1 and 2 only — so 0
// is "no row", never "row zero".
let mut n = [0usize; 3];
for k in cat.kits.values() {
assert!(k.eax_def <= 2, "kit {} has EAXDef {}", k.id, k.eax_def);
n[k.eax_def as usize] += 1;
}
assert_eq!(
(n[0], n[1], n[2]),
(2072, 2, 2549),
"the 5875 EAXDef census"
);
// The load-bearing one: NPC voice lines (`SoundType 17`, what `NPCSounds.dbc` references)
// are dry to a kit — this is why the Thunderbrew Distillery's NPCs carry no echo.
let voices: Vec<_> = cat.kits.values().filter(|k| k.sound_type == 17).collect();
assert_eq!(voices.len(), 275, "the type-17 NPC voice rows");
assert!(
voices.iter().all(|k| k.eax_def == 0),
"every NPC voice kit is authored dry"
);
// The control: creature barks are a genuine mix, so the gate is not a no-op that happens
// to silence everything.
let barks: Vec<_> = cat.kits.values().filter(|k| k.sound_type == 10).collect();
assert!(
barks.iter().any(|k| k.eax_def != 0) && barks.iter().any(|k| k.eax_def == 0),
"creature barks split wet/dry"
);
}
}
+98
View File
@@ -1338,3 +1338,101 @@ fn reads_the_wmo_skybox_and_its_per_group_gate() {
"and not one of its groups asks for one"
);
}
/// **The footstep chain's WMO leg, on the shipped bytes** (decision 1161, bug B236's sequel).
///
/// `MOMT+0x20` is a `TerrainType.dbc` id, and this is the evidence for that claim rather than a
/// format doc: across every root WMO in the archive the dword only ever takes ids the table
/// actually has, and `10 "None"` — the unauthored default — dominates. The Kharanos inn is the
/// reported case: it authors `None` on all 20 materials, so the reference gives a walker its
/// generic dry kit and NO footprint, where reading the ADT beneath the floor gives snow and snow
/// prints.
#[test]
fn wmo_ground_type_is_a_terrain_type_id() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let cat = benilla_formats::load_footstep_catalog(&mut chain).expect("footstep catalog");
// The Kharanos inn ("Thunderbrew Distillery" is the AREA name; the model is snow_Inn).
let inn = chain
.read_file("World\\wmo\\KhazModan\\Buildings\\Dwarven_Inn\\snow_Inn\\Snow_Inn.wmo")
.expect("the Kharanos inn root");
let inn = benilla_formats::parse_wmo_root(&inn).expect("inn root parses");
let ground = inn.material_ground_types();
assert_eq!(ground.len(), 20, "the inn's material count");
assert!(
ground.iter().all(|&g| g == 10),
"every Kharanos inn material is the unauthored `None`: {ground:?}"
);
// `None` is not silence — it is a real row, and it is the quiet generic step.
assert_eq!(
cat.sound_class_of(10),
Some(0),
"TerrainType 10 -> SoundID 0"
);
assert!(
!cat.terrain_leaves_footprints(10),
"a WMO floor takes no prints"
);
assert!(
cat.terrain_leaves_footprints(3) && cat.terrain_leaves_footprints(7),
"Snow and Sand are the print surfaces"
);
assert_eq!(
cat.resolve_terrain(7, 10).map(|(dry, _)| dry),
Some(560),
"class 7 indoors: CharacterMediumLargeDirt"
);
assert_eq!(
cat.resolve_terrain(7, 3).map(|(dry, _)| dry),
Some(563),
"class 7 on snow: CharacterMediumLargeSnow — what the ADT-only chain wrongly played inside"
);
// The value-domain argument, over every root WMO in the archive: nothing outside the table.
let roots: Vec<String> = chain
.list()
.expect("chain listing")
.into_iter()
.map(|e| e.name)
.filter(|n| {
let l = n.to_lowercase();
l.ends_with(".wmo")
&& !l
.strip_suffix(".wmo")
.and_then(|s| s.rsplit('_').next())
.is_some_and(|t| t.len() == 3 && t.chars().all(|c| c.is_ascii_digit()))
})
.collect();
assert_eq!(roots.len(), 815, "root WMOs in the 5875 archive");
let (mut total, mut none, mut multi) = (0usize, 0usize, 0usize);
for name in &roots {
let Ok(bytes) = chain.read_file(name) else {
continue;
};
let Ok(root) = benilla_formats::parse_wmo_root(&bytes) else {
continue;
};
let g = root.material_ground_types();
for &v in &g {
assert!(
cat.sound_class_of(v).is_some(),
"{name}: ground_type {v} is not a TerrainType id"
);
total += 1;
none += usize::from(v == 10);
}
multi += usize::from(g.iter().collect::<std::collections::HashSet<_>>().len() > 1);
}
assert_eq!(
(total, none),
(10_299, 10_075),
"the 5875 ground_type census"
);
assert_eq!(multi, 121, "roots authoring more than one surface");
}
@@ -0,0 +1,125 @@
//! The cold-breath puff, pinned against the shipped assets (`#bugs` B233, decision 1149).
//!
//! B233 was reported as a **player character in Dun Morogh** with no visible breath, and the fix
//! rests on three asset facts that a mechanism note alone cannot establish — the trap decision
//! 0705 names, where a verified mechanism turns out not to *apply* to the thing in front of you:
//!
//! 1. the trigger exists on the reported model — `HumanMale.m2` authors a **`$BTH` event on
//! animation id 0 (Stand)**, so an idle player fires one every loop with no timer of ours;
//! 2. the attach tag the client passes, **`0x11` = AttachmentID 17**, resolves on that model to a
//! bone at the **mouth**, not the base — the difference between vapour at the face and a cloud
//! at the feet;
//! 3. the asset the DBC names is a **one-shot emitter**, so the client's end-of-clip terminator is
//! the right lifetime for it (`FxStage::OneShot`, as the mount poof uses).
//!
//! Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
use std::path::PathBuf;
use benilla_formats::{open_chain, parse_m2_animations, parse_m2_attachments};
fn vanilla_data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
const HUMAN_MALE: &str = "Character\\Human\\Male\\HumanMale.m2";
/// `SpellVisualEffectName` row 107 (`"HARDCODED Breath Cold"`) names `Particles\ColdBreath.mdl`;
/// the loader's own extension swap makes that the shipped `.m2`.
const COLD_BREATH: &str = "Particles\\ColdBreath.m2";
/// The `$BTH` family's attach tag — `DAT_0080c968[3]`, a raw M2 `AttachmentID`.
const BREATH_ATTACH: u16 = 0x11;
/// `AnimationData.dbc` id 0 = Stand.
const STAND: u16 = 0;
#[test]
fn the_idle_player_keys_bth_and_attaches_it_at_the_mouth() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain
.read_file(HUMAN_MALE)
.expect("HumanMale is in the chain");
// (1) The trigger, on the animation an idle player is actually in.
let anims = parse_m2_animations(&bytes);
let stand = anims
.iter()
.find(|a| a.anim_id == STAND)
.expect("HumanMale authors Stand");
let bth: Vec<f32> = stand
.events
.iter()
.filter(|e| e.ident == *b"$BTH")
.map(|e| e.time)
.collect();
assert_eq!(
bth.len(),
1,
"Stand keys exactly one $BTH; got {bth:?} over a {:.3}s clip",
stand.duration
);
// 0.667 s into a 2.667 s loop: the clip is comfortably longer than ColdBreath's own 1.5 s, so
// consecutive puffs never overlap on an idling player and the overlap guard never bites.
assert!(
(bth[0] - 0.667).abs() < 0.01,
"the $BTH key sits at 0.667s, got {:.3}s",
bth[0]
);
assert!(
stand.duration > 1.5,
"Stand ({:.3}s) outlasts the 1.5s puff",
stand.duration
);
// (2) The attach point: tag 0x11 through the model's OWN AttachLookup (the `0x710310`
// indirection — the tag is an id, never an array index), landing at the mouth.
let attachments = parse_m2_attachments(&bytes).expect("parse attachments");
let breath = attachments
.iter()
.find(|a| a.id == BREATH_ATTACH)
.expect("HumanMale authors attachment 0x11");
// The head attach (id 11) is the neighbour that makes "mouth" mean something: the breath point
// sits just below it and forward of it, on the face — not at the unit base (z ≈ 0).
let head = attachments
.iter()
.find(|a| a.id == 11)
.expect("HumanMale authors the head attachment");
assert!(
breath.position[2] > 1.5 && breath.position[2] < head.position[2],
"breath z {:.3} sits on the face, below the head attach z {:.3}",
breath.position[2],
head.position[2]
);
assert!(
breath.position[0] > head.position[0],
"breath x {:.3} is forward of the head attach x {:.3} — out of the mouth",
breath.position[0],
head.position[0]
);
assert_ne!(
breath.bone, 0,
"the breath rides a real bone, not the model root"
);
// (3) The puff itself: a single non-looping sequence, so the end-of-clip terminator is the
// right lifetime. (`Particles\Bubbles.m2`, the underwater sibling, LOOPS — which is why it
// needs the client's replace-on-respawn dedup and this one does not.)
let breath_bytes = chain
.read_file(COLD_BREATH)
.expect("ColdBreath.m2 is in the chain");
let clips = parse_m2_animations(&breath_bytes);
assert_eq!(clips.len(), 1, "ColdBreath authors one sequence");
assert!(
!clips[0].looping,
"ColdBreath's sequence is one-shot (flags bit 0 set) — it ENDS, which is what lets the \
client's end-of-clip terminator own its lifetime"
);
assert!(
(clips[0].duration - 1.5).abs() < 0.01,
"ColdBreath runs 1.5s, got {:.3}s",
clips[0].duration
);
}
@@ -0,0 +1,130 @@
//! The ammo crate the director looted was **stuck open/closing** — the lid springing to 75° and
//! swinging shut, ~1.5 times a second, for ever (decision 1151). The model is why the symptom is
//! that loud, and why "hold the motion's last frame" was never enough on its own.
//!
//! `G_Crate01.m2` is the door family, textbook: **bone 8** is the lid, and the four sequences are
//! Open(148) 0° → 75°, Opened(149) holding 75°, Close(146) 75° → 0°, Closed(147) holding 0°. Every
//! one of them carries `flags` bit 0 **clear**, i.e. the kernel wraps its band for ever (wow-re
//! `gameobject-anim-arm.md` §3, `0x714585`) — so the loop bit says nothing about how long a
//! transition lasts, and a consumer that reads it as "should this clip repeat?" arms the Close
//! sweep on an endless loop the moment the loot window closes.
//!
//! What ends a swing in the real client is the **object layer**: the completion callback fires once
//! at the arm's baked window (span × replay `R`, the loop bit ignored) and slot 14 `0x5f4120`
//! advances substate 4 Close → 1 Closed (§2d). This file pins the asset facts that law rests on —
//! the loop bits, `R = 1`, and that Close's last frame *is* the Closed pose — so the fix can't be
//! undone by "the clip says it loops".
//!
//! The `G_BookOpenMediumBrown` test next door pins the seed law on the same family; this one pins
//! the **transition** law. Skips (passes) when the client isn't present at `<repo>/WoW/Data`.
use std::path::PathBuf;
use benilla_formats::{open_chain, parse_m2_animation_lookup, parse_m2_animations};
fn vanilla_data_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data")
}
/// The quest ammo crate the report came off — GameObject type 3 CHEST, `world/goober/g_crate01.m2`.
const CRATE: &str = "World\\Goober\\G_Crate01.m2";
/// `AnimationData.dbc`: 146 Close (motion), 147 Closed (rest), 148 Open (motion), 149 Opened (rest).
const CLOSE: u16 = 146;
const CLOSED: u16 = 147;
const OPEN: u16 = 148;
const OPENED: u16 = 149;
/// The crate's **lid** bone — the only bone the door family moves (bones 0/1 are the static body,
/// 9.. the destruction shards). Read off `benilla-extract m2bones`.
const LID_BONE: u16 = 8;
#[test]
fn the_crate_lid_transition_is_bounded_by_the_window_not_the_loop_bit() {
let data = vanilla_data_dir();
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return;
}
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let bytes = chain
.read_file(CRATE)
.expect("the crate M2 is in the chain");
let anims = parse_m2_animations(&bytes);
let seq = |id: u16| {
anims
.iter()
.find(|a| a.anim_id == id)
.unwrap_or_else(|| panic!("the crate authors animation id {id}"))
};
// 1. The whole door family is authored, so no §2c remap leg is reachable: the arm plays the
// LUT id directly and the test below is about the *transition*, nothing else.
let lookup = parse_m2_animation_lookup(&bytes).expect("animation lookup");
for id in [CLOSE, CLOSED, OPEN, OPENED] {
assert!(
lookup.get(id as usize).is_some_and(|&s| s != 0xffff),
"the crate owns animation id {id}"
);
}
// 2. **Every** one of the four has `flags` bit 0 clear — the kernel loops them all, rest poses
// and motions alike. So "does this clip loop?" cannot be the question a transition asks:
// reading the bit here arms the Close sweep for ever, which IS the report.
for id in [CLOSE, CLOSED, OPEN, OPENED] {
assert!(
seq(id).looping,
"id {id} is a bit-0-clear band — the kernel wraps it, so only the object layer's \
§2d advance can end a transition"
);
}
// 3. …and the window that advance fires at is exactly ONE pass: `R = max(1, min + roll)` with
// an empty replay range rolls to 1 (wow-re §3, `0x712692..0x7126cd`). One window is the
// whole swing — there is no second pass to model.
for id in [CLOSE, CLOSED, OPEN, OPENED] {
let s = seq(id);
assert_eq!(
(s.min_replay, s.max_replay),
(0, 0),
"id {id} rolls R = 1, so the completion lands at one band length"
);
}
// 4. The poses themselves, as the viewer sees them. The lid's rotation is a pure −Y quaternion:
// |y| ≈ 0 is shut, |y| ≈ 0.61 (75°) is fully open.
let lid = |id: u16| -> Vec<[f32; 4]> {
seq(id)
.bones
.iter()
.find(|b| b.bone == LID_BONE)
.map(|b| b.rotation.iter().map(|(_, q)| *q).collect())
.unwrap_or_default()
};
let span = |q: &[[f32; 4]]| {
q.iter().fold((f32::MAX, f32::MIN), |(lo, hi), q| {
(lo.min(q[1].abs()), hi.max(q[1].abs()))
})
};
// Close sweeps open → shut. Looping THAT is a lid that springs to 75° and slams, 1.5×/s.
let (lo, hi) = span(&lid(CLOSE));
assert!(
lo < 1e-3 && hi > 0.5,
"Close must sweep from open (|y| {hi}) to shut (|y| {lo}) — that sweep, looped, is the \
reported bug"
);
// Open is the mirror sweep — the other half of the cycle the loot window straddles.
let (lo, hi) = span(&lid(OPEN));
assert!(
lo < 1e-3 && hi > 0.5,
"Open must sweep from shut (|y| {lo}) to open (|y| {hi})"
);
// The rest poses genuinely rest, and each is the pose its motion lands on — which is why
// holding a finished motion's last frame reads correctly, and why the §2d advance onto the
// rest sequence is invisible rather than a pop.
for q in lid(CLOSED) {
assert!(q[1].abs() < 1e-3, "Closed holds the SHUT lid, got {}", q[1]);
}
for q in lid(OPENED) {
assert!(q[1].abs() > 0.5, "Opened holds the OPEN lid, got {}", q[1]);
}
}
@@ -23,8 +23,8 @@ use clap::Parser;
use probes::{
Attack, Aura, Charge, Ctx, Death, EquipPackSlot, GiverStatus, GroundFx, Loot, OpenItem, Probe,
QueryNames, Quest, QuestItem, QuestLog, Speed, Spells, Spirit, SwapPackSlots, UsePackSlot,
Vendor, WorldState,
QueryNames, Quest, QuestItem, QuestLog, QuestTimer, Speed, Spells, Spirit, SwapPackSlots,
UsePackSlot, Vendor, WorldState,
};
use world::{DeathArc, Tracked, World};
@@ -153,6 +153,14 @@ struct Cli {
/// deploy's probes are gmlevel 6).
#[arg(long)]
questlog: bool,
/// Live-verify the timed-quest COUNTDOWN chain (decision 1150, B234): GM-add a quest with a
/// `LimitTime`, read its `PLAYER_QUEST_LOG` slot's raw timer field (an absolute unix stamp,
/// not a duration), ask `CMSG_QUERY_TIME` for the server's own clock, and require the
/// subtraction to land inside the template's own `limit_time` window. The one leg no offline
/// test can cover: two independent packets whose epochs must agree. Needs a GM account (the
/// deploy's probes are gmlevel 6).
#[arg(long)]
questtimer: bool,
/// Live-verify the quest-STARTER item wire (decision 0664): `.additem` the Northshire Gift
/// Voucher (entry 14646, starts quest 5805 "Welcome!"), find it in the backpack, and run the
/// fork a bag right-click makes for an item whose template carries a non-zero `StartQuest` —
@@ -379,6 +387,9 @@ fn main() -> Result<()> {
if cli.questlog {
probes.push(Box::new(QuestLog));
}
if cli.questtimer {
probes.push(Box::new(QuestTimer::default()));
}
if cli.loot {
probes.push(Box::new(Loot {
loot_guid: cli.loot_guid,
@@ -72,6 +72,7 @@ mod query_names;
mod quest;
mod quest_item;
mod questlog;
mod questtimer;
mod speed;
mod spells;
mod spirit;
@@ -93,6 +94,7 @@ pub(crate) use query_names::QueryNames;
pub(crate) use quest::Quest;
pub(crate) use quest_item::QuestItem;
pub(crate) use questlog::QuestLog;
pub(crate) use questtimer::QuestTimer;
pub(crate) use speed::Speed;
pub(crate) use spells::Spells;
pub(crate) use spirit::Spirit;
@@ -0,0 +1,191 @@
//! `--questtimer`: the timed-quest countdown's whole wire chain, live (decision 1150, B234).
//!
//! The countdown is not on the wire as a duration anywhere. The server writes a timed quest's
//! deadline into the quest-log slot's third field as an **absolute unix stamp** — `time(nullptr) +
//! limitTime`, vmangos `Player::AddQuest` — and the client is expected to subtract its own sample
//! of the server's clock, taken with `CMSG_QUERY_TIME`. Two independent readings, one subtraction,
//! and nothing in the client can tell you it got either one wrong: a mis-decoded field or a missing
//! clock both produce a number that *looks* like a countdown.
//!
//! So this probe reads both ends against the running server and checks the subtraction lands where
//! the quest template says it must:
//!
//! 1. `.quest remove` then `.quest add` [`TIMED_QUEST`] (idempotent across re-runs).
//! 2. Poll the descriptor for the slot the quest landed in and read its **raw** timer field.
//! 3. `CMSG_QUERY_TIME` for the server's wall clock.
//! 4. Assert the subtraction implies the quest was added *just now*, and print the countdown.
//!
//! Step 4 is what makes this more than a decode check. The probe added the quest itself seconds
//! ago, so the remaining time must sit just under the template's full limit — not merely somewhere
//! inside it. A wrong epoch misses that window by decades, a seconds/milliseconds slip by 1000×,
//! and a clock that never arrived by more than the run's own length.
use std::time::{Duration, Instant};
use anyhow::{bail, Context, Result};
use benilla_protocol::events::decode;
use benilla_protocol::SessionEvent;
use crate::probes::{Ctx, Probe, FIELD_PLAYER_QUEST_LOG_1_1};
/// "Iverron's Antidote" (Teldrassil) — `quest_template.LimitTime = 300`, min level 2, and no
/// prerequisite a GM `.quest add` cares about. Chosen because 5 minutes is long enough that the
/// remaining time is unambiguously mid-window while the probe runs, and short enough that a
/// seconds/milliseconds mix-up would fall outside it immediately.
const TIMED_QUEST: u32 = 3522;
/// [`TIMED_QUEST`]'s `quest_template.LimitTime` (read from the running deploy's `mangos` DB). Not
/// on the wire in 1.12 — `SMSG_QUEST_QUERY_RESPONSE` has no such field, and vmangos reads
/// `GetLimitTime()` only server-side in `Player::AddQuest` — so it is a constant here rather than a
/// second packet's answer.
const LIMIT_TIME_SECS: u32 = 300;
/// How much wall time the probe allows between its `.quest add` and the read below. Generous
/// against a slow login and a full stream window, and still ~100× tighter than any of the failure
/// modes it is bounding (a wrong epoch, a seconds/milliseconds slip, a clock that never arrived).
const ADD_TO_READ_SLACK: f64 = 120.0;
#[derive(Default)]
pub(crate) struct QuestTimer {
/// The server's wall clock, and when we received it (`SMSG_QUERY_TIME_RESPONSE`).
clock: Option<(u32, Instant)>,
}
impl Probe for QuestTimer {
fn stage(&mut self, cx: &mut Ctx) -> Result<()> {
cx.session
.send_chat(&format!(".quest remove {TIMED_QUEST}"))?;
cx.session.send_chat(&format!(".quest add {TIMED_QUEST}"))?;
println!("questtimer: GM .quest remove/add {TIMED_QUEST}");
Ok(())
}
fn on_event(&mut self, ev: &SessionEvent, _cx: &mut Ctx) -> Result<()> {
if let SessionEvent::ServerUnixTime { unix_time } = ev {
self.clock = Some((*unix_time, Instant::now()));
}
Ok(())
}
fn verify(&mut self, cx: &mut Ctx) -> Result<()> {
let Ctx { session, world } = cx;
let self_guid = world.self_guid;
// 1) The slot. Poll the descriptor the same way `--questlog` does — the accept's field
// update can trail the chat ack by a beat.
let find_slot = |sf: &Option<benilla_protocol::messages::ObjectFields>| {
sf.as_ref().and_then(|sf| {
(0..benilla_protocol::messages::PLAYER_QUEST_LOG_SLOTS)
.find(|&i| sf.player_quest_log(i).map(|s| s.quest_id) == Some(TIMED_QUEST))
})
};
let mut slot = find_slot(&world.self_fields);
for _ in 0..6 {
if slot.is_some() {
break;
}
let drain_until = Instant::now() + Duration::from_secs(1);
while Instant::now() < drain_until {
let Ok(msg) = session.recv() else { continue };
for ev in decode(msg) {
match ev {
SessionEvent::ObjectValues { guid: g, fields } if g == self_guid => {
if let Some(sf) = &mut world.self_fields {
sf.merge(fields);
}
}
SessionEvent::ServerUnixTime { unix_time } => {
self.clock = Some((unix_time, Instant::now()));
}
_ => {}
}
}
}
slot = find_slot(&world.self_fields);
}
let slot = slot.context(
"--questtimer: the timed quest never landed in a PLAYER_QUEST_LOG slot within the \
poll window (is `.quest add` refused for this character?)",
)?;
// The timer field raw (id-field + 2), beside the decoded view, so a decode that reads the
// wrong field of the triple is visible as a mismatch rather than a plausible number.
let raw_timer = world
.self_fields
.as_ref()
.and_then(|sf| {
sf.raw_fields()
.find(|&(idx, _)| idx == FIELD_PLAYER_QUEST_LOG_1_1 + 3 * u16::from(slot) + 2)
.map(|(_, v)| v)
})
.unwrap_or(0);
let decoded = world
.self_fields
.as_ref()
.and_then(|sf| sf.player_quest_log(slot))
.context("--questtimer: the slot vanished between the find and the read")?;
println!(
"quest {TIMED_QUEST} occupies slot {slot}; timer field raw {raw_timer} \
(decoded {}), state byte {:#04x}",
decoded.timer, decoded.state
);
if raw_timer == 0 || decoded.timer != raw_timer {
bail!(
"--questtimer: timer field raw {raw_timer}, decoded {} — a timed quest must carry \
a nonzero absolute deadline and the decode must agree with the raw word",
decoded.timer
);
}
// 2) The server's wall clock. Asked for here rather than leaning on a login-time send, so
// the probe is self-contained and the round trip itself is what's under test.
session.query_time()?;
let deadline = Instant::now() + Duration::from_secs(5);
let asked_at = Instant::now();
while Instant::now() < deadline {
let Ok(msg) = session.recv() else { continue };
for ev in decode(msg) {
if let SessionEvent::ServerUnixTime { unix_time } = ev {
self.clock = Some((unix_time, Instant::now()));
}
}
if self.clock.is_some_and(|(_, at)| at > asked_at) {
break;
}
}
let (base, at) = self
.clock
.context("--questtimer: no SMSG_QUERY_TIME_RESPONSE within 5s")?;
let now = f64::from(base) + at.elapsed().as_secs_f64();
println!(
"server wall clock {base} (+{:.1}s since the answer)",
at.elapsed().as_secs_f64()
);
// 3) The subtraction, against a window the probe's own actions bound tightly on BOTH
// sides. `.quest add` ran seconds ago in `stage`, so the server wrote
// `now + LIMIT_TIME_SECS` seconds ago — the remaining time must therefore sit just under
// the full limit, not merely somewhere inside it. That tight upper bound is what makes
// this a real check: a wrong epoch is out by decades, a seconds/milliseconds slip by
// 1000×, and an absent or stale clock by more than the run's own length.
//
// (The limit is a constant because 1.12's `SMSG_QUEST_QUERY_RESPONSE` does not carry
// `LimitTime` — verified against the parsed body's field list and vmangos, which reads
// `GetLimitTime()` only in `Player::AddQuest`. Its provenance is the DB row cited on
// [`TIMED_QUEST`]; if content ever changes it, this probe fails loudly rather than
// quietly passing.)
let remaining = f64::from(raw_timer) - now;
let elapsed_since_add = f64::from(LIMIT_TIME_SECS) - remaining;
if !(0.0..=ADD_TO_READ_SLACK).contains(&elapsed_since_add) {
bail!(
"--questtimer: {remaining:.1}s remaining of a {LIMIT_TIME_SECS}s limit implies \
{elapsed_since_add:.1}s between the `.quest add` and this read — expected \
0..{ADD_TO_READ_SLACK}s. The deadline and the clock disagree: wrong field, wrong \
epoch, wrong unit, or no clock at all."
);
}
println!(
"✅ countdown: {remaining:.1}s remaining of {LIMIT_TIME_SECS}s — deadline {raw_timer} \
minus server now {now:.0}, i.e. {elapsed_since_add:.1}s since the `.quest add`."
);
Ok(())
}
}
+6
View File
@@ -304,6 +304,12 @@ pub enum SessionEvent {
day_serial: u32,
timescale: f32,
},
/// The server's **wall clock** in unix-epoch seconds (`SMSG_QUERY_TIME_RESPONSE`, answering our
/// `CMSG_QUERY_TIME`) — not [`Self::TimeSpeed`]'s in-game day/night clock, a different quantity
/// with a different consumer. This one dates the absolute stamps the server writes into
/// descriptor fields: a timed quest's deadline is `time(nullptr) + limitTime`, so the quest
/// countdown is a subtraction against *this* number (decision 1150).
ServerUnixTime { unix_time: u32 },
/// The player's hearthstone bind point (`SMSG_BINDPOINTUPDATE`): the AreaTable id the
/// `$z` token names ("Returns you to <area>.").
BindPoint { area: u32 },
@@ -577,6 +577,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
day_serial,
timescale,
}],
ServerPacket::QueryTimeResponse { unix_time } => {
vec![SessionEvent::ServerUnixTime { unix_time }]
}
ServerPacket::BindPoint { area, .. } => vec![SessionEvent::BindPoint { area }],
ServerPacket::SetProficiency {
item_class,
@@ -215,6 +215,15 @@ pub fn played_time() -> Vec<u8> {
Vec::new()
}
/// `CMSG_QUERY_TIME` body — empty (VERIFIED vmangos: `HandleQueryTimeOpcode` takes a
/// `NullClientPacket`, `Handlers/QueryHandler.cpp:107`). Asks for the server's wall clock; the
/// answer is `SMSG_QUERY_TIME_RESPONSE`, one `u32` of unix-epoch seconds. Decision 1150 — a timed
/// quest's deadline is an absolute stamp in *that* epoch, so the countdown is only as right as our
/// sample of the server's clock.
pub fn query_time() -> Vec<u8> {
Vec::new()
}
/// `MSG_RANDOM_ROLL` client→server request body (VERIFIED vmangos
/// `WorldPackets::Group::RandomRoll::ReadFromWorldPacket`, `Server/Packets/Group.cpp:39-43`): `u32
/// minimum + u32 maximum`. The server validates `minimum <= maximum <= 10000` and broadcasts the
+1 -1
View File
@@ -79,7 +79,7 @@ pub use client::{
channel_set_owner, channel_unban, channel_unmoderator, channel_unmute, char_create,
creature_query, force_speed_ack, full_guid, join_channel, leave_channel, messagechat,
messagechat_channel, messagechat_kind, messagechat_whisper, move_flag_ack, move_spline_done,
movement, pet_name_query, ping, played_time, random_roll, teleport_ack, text_emote,
movement, pet_name_query, ping, played_time, query_time, random_roll, teleport_ack, text_emote,
};
pub use combat_log::{
DamageShield, EnvironmentalDamageLog, PeriodicAuraLog, PeriodicTick, SpellDamageLog,
@@ -378,6 +378,18 @@ pub const SMSG_NOTIFICATION: u16 = 0x01CB; // 459
/// [`super::chat::read_played_time`]).
pub const CMSG_PLAYED_TIME: u16 = 0x01CC; // 460
pub const SMSG_PLAYED_TIME: u16 = 0x01CD; // 461
/// The server's wall clock (VERIFIED vmangos `Opcodes_1_12_1.h`: 462/463): empty CMSG body
/// (`NullClientPacket`), SMSG body one `u32` = the server's `time(nullptr)`, i.e. **unix-epoch
/// seconds** (`WorldSession::SendQueryTimeResponse`, `Handlers/QueryHandler.cpp:418-423`).
///
/// This is the epoch the quest-log slot's timer field is expressed in — a timed quest's deadline
/// is written as `time(nullptr) + limitTime` (`Player::AddQuest`), an absolute stamp with no
/// relative form anywhere on the wire. So a countdown needs the *server's* now, not ours: that is
/// what this pair is for, and why benilla sends it (decision 1150). The CMSG body is
/// [`super::query_time`]; the response is decoded inline beside `SMSG_LOGIN_SETTIMESPEED`, the
/// other clock packet.
pub const CMSG_QUERY_TIME: u16 = 0x01CE; // 462
pub const SMSG_QUERY_TIME_RESPONSE: u16 = 0x01CF; // 463
/// `/random` (VERIFIED vmangos `Opcodes_1_12_1.h`: 507) — same opcode both directions, different
/// bodies: client sends `u32 min + u32 max` ([`super::client::random_roll`],
/// `WorldPackets::Group::RandomRoll::ReadFromWorldPacket`, `Server/Packets/Group.cpp:39-43`); the
@@ -191,6 +191,14 @@ pub enum ServerPacket {
day_serial: u32,
timescale: f32,
},
/// `SMSG_QUERY_TIME_RESPONSE`: the server's wall clock in **unix-epoch seconds**, answering our
/// `CMSG_QUERY_TIME`. Not the in-game clock — that is [`Self::TimeSpeed`]'s packed date, a
/// different quantity entirely. This one exists so the client can read the *absolute* stamps
/// the server writes into descriptor fields: today the quest-log slot's timed-quest deadline
/// (decision 1150).
QueryTimeResponse {
unix_time: u32,
},
/// The player's hearthstone bind point (`SMSG_BINDPOINTUPDATE`, at login + on re-bind):
/// position + map + the AreaTable id the `$z` token names.
BindPoint {
@@ -1112,6 +1120,7 @@ impl ServerPacket {
ServerPacket::TransferAborted { .. } => "SMSG_TRANSFER_ABORTED".into(),
ServerPacket::LoginVerifyWorld { .. } => "SMSG_LOGIN_VERIFY_WORLD".into(),
ServerPacket::TimeSpeed { .. } => "SMSG_LOGIN_SETTIMESPEED".into(),
ServerPacket::QueryTimeResponse { .. } => "SMSG_QUERY_TIME_RESPONSE".into(),
ServerPacket::BindPoint { .. } => "SMSG_BINDPOINTUPDATE".into(),
ServerPacket::SetProficiency { .. } => "SMSG_SET_PROFICIENCY".into(),
ServerPacket::InitializeFactions { .. } => "SMSG_INITIALIZE_FACTIONS".into(),
@@ -266,6 +266,15 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
timescale,
}
}
// The OTHER clock (decision 1150) — kept beside SETTIMESPEED precisely because they are
// easy to confuse: this one is wall-clock UNIX seconds (vmangos
// `WorldSession::SendQueryTimeResponse`, `Handlers/QueryHandler.cpp:418-423` — a bare
// `(uint32)time(nullptr)`), the epoch every absolute stamp the server writes into a
// descriptor field is expressed in. SETTIMESPEED above is the in-game day/night clock and
// says nothing about it.
opcode::SMSG_QUERY_TIME_RESPONSE => ServerPacket::QueryTimeResponse {
unix_time: read_u32_le(&mut r)?,
},
opcode::SMSG_BINDPOINTUPDATE => {
// vmangos BindpointUpdate::AppendBodyTo (Packets/Misc.cpp): x, y, z, mapId, areaId.
let position = Vector3d::read(&mut r)?;
@@ -548,6 +548,14 @@ impl WorldSession {
self.send(opcode::CMSG_QUEST_QUERY, &messages::quest_query(quest_id))
}
/// Ask the server for its wall clock (`CMSG_QUERY_TIME`) — the unsplit twin of
/// [`WorldWriter::query_time`]; answered by `SMSG_QUERY_TIME_RESPONSE`, one `u32` of
/// unix-epoch seconds. That is the epoch a timed quest's deadline is written in, so no
/// countdown can be read off the descriptor without it (decision 1150).
pub fn query_time(&mut self) -> Result<()> {
self.send(opcode::CMSG_QUERY_TIME, &messages::query_time())
}
/// Abandon a quest-log slot (`CMSG_QUESTLOG_REMOVE_QUEST`) — the unsplit twin of
/// [`WorldWriter::questlog_remove_quest`]; no ack SMSG, the server clears the
/// `PLAYER_QUEST_LOG` slot fields directly.
@@ -39,6 +39,18 @@ impl WorldWriter {
self.send(opcode::CMSG_LOGOUT_CANCEL, &[])
}
/// Ask the server for its wall clock (`CMSG_QUERY_TIME`, empty body); answered with
/// `SMSG_QUERY_TIME_RESPONSE`, one `u32` of unix-epoch seconds.
///
/// A cadence/obligation send like [`ping`](Self::ping), not a player intent: the server writes
/// *absolute* stamps in its own epoch into descriptor fields — a timed quest's deadline is
/// `time(nullptr) + limitTime` — and nothing on the wire ever restates them as a duration. So
/// every countdown the client draws is only as right as its last sample of this clock; benilla
/// takes one on entering the world (decision 1150).
pub fn query_time(&mut self) -> Result<()> {
self.send(opcode::CMSG_QUERY_TIME, &messages::query_time())
}
/// Acknowledge a triggered cinematic as finished (`CMSG_COMPLETE_CINEMATIC`, empty body) — the
/// packet the real client sends when the cinematic ends or the player ESCs out. Must answer
/// every `SMSG_TRIGGER_CINEMATIC` ([`SessionEvent::CinematicTriggered`]
@@ -52,6 +52,45 @@ fn simple_server_bodies_parse() {
}
}
/// The server wall clock (`CMSG_QUERY_TIME` 462 / `SMSG_QUERY_TIME_RESPONSE` 463, decision 1150):
/// an empty request body, and a response of one LE `u32` of unix-epoch seconds — the epoch a timed
/// quest's descriptor deadline is written in.
///
/// Byte-exact against the vmangos sender (`WorldSession::SendQueryTimeResponse`,
/// `Handlers/QueryHandler.cpp:418-423` — `packet->time = (uint32)time(nullptr)`, that field and
/// nothing else). The sample is a real present-day stamp with all four bytes distinct, so a byte
/// order or width slip reads as a wildly wrong date rather than a plausible one.
#[test]
fn query_time_round_trips_the_server_wall_clock() {
assert!(
messages::query_time().is_empty(),
"CMSG_QUERY_TIME is a NullClientPacket"
);
// 2026-08-13T12:00:00Z = 1_786_622_400 = 0x6a7db1c0.
assert_eq!(u32::from_le_bytes([0xc0, 0xb1, 0x7d, 0x6a]), 1_786_622_400);
let p = messages::parse_server(messages::opcode::SMSG_QUERY_TIME_RESPONSE, &hx("c0b17d6a"))
.unwrap();
assert!(matches!(
p,
ServerPacket::QueryTimeResponse {
unix_time: 1_786_622_400
}
));
assert!(matches!(
decode(p)[..],
[SessionEvent::ServerUnixTime {
unix_time: 1_786_622_400
}]
));
// A short body is an error, not a silent zero: every countdown drawn from this clock would
// otherwise be wrong rather than absent.
assert!(
messages::parse_server(messages::opcode::SMSG_QUERY_TIME_RESPONSE, &hx("c0b17d")).is_err()
);
}
/// The cinematic trigger (opcode 250, body a bare `CinematicSequences.dbc` id u32 per the vmangos
/// `SendCinematicStart` sender) parses and decodes to the event the Net drain must ack — and the
/// char-delete result (opcode 60, one result byte) parses.

Some files were not shown because too many files have changed in this diff Show More