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:
+16
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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").
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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()),
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -601,6 +601,7 @@ fn warm_effect_lane(
|
||||
anchor: Vec3::ZERO,
|
||||
bias: 0.0,
|
||||
raster_bias,
|
||||
cam_relative: false,
|
||||
main_entity: cam,
|
||||
light: None,
|
||||
},
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, _, _, _)| {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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()`) —
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user