a body lands with a thud, and twelve lists scroll again

- a dying body lands with a sound: the `$DTH` marker makes three calls in the reference (`0x624a94`) and benilla had built only the middle one, so the screen shook and nothing landed; the thud is a two-axis join — size class against terrain sound, through `DeathThudLookups.dbc` to a land or water kit — behind exactly three gates: more than 2.0 yd of liquid over the feet is complete silence, a size class outside 0..=4, and no terrain, no row or a kit of 0; it plays on bus 0, uncapped, so a battlefield's worth of bodies all land — the shake is the rare thing (49 of 430 models name a preset), the thud the universal one (329 key a `$DTH`, players included); the size axis had sat in our `CreatureDisplayInfo` schema labelled a string column 5875 does not have, and `StringLookups` stays unparsed on purpose — 9 rows benilla already resolves (1866)
- twelve faux-scroll lists showed their bar, moved their thumb and never moved their list: the reference's kit writes `frame.offset` from one handler the OWNER declares, and ours were wired to a `frame.updateFunc` field the reference does not have — Skill, Craft, TradeSkill, ScriptLog, the auction and the social panes are rewired, and a gate fails any faux frame without a handler of its own, because a list only scrolls when something drives it (1868); and the macro window's "Change Name/Icon" raised on a nil offset: `MacroPopupScrollFrame` inherits a template the manifest never loaded, and a missing template is a warning, so the frame was built BARE — no scroll bar, no scripts, no `offset = 0` — and the reference's offset read has no `or 0` cushion; the line is in the manifest, and a static gate fails any `inherits=` the manifest declares nowhere (1862)
- the character geoset selector's missing eighth branch is the tabard designer: B6 of `0x477520` is gated on "the guild registrar's tabard designer is open", not an ItemDisplayInfo column, and wears the tabard flap with an EMPTY tabard slot; dormant until `TabardFrame` migrates, but reading the whole function turned up three branches wrong since their first pass: B4-short never disabled [501,599], so the bare foot — a real submesh on 11 of the 18 character models — rendered inside every boot; B3 asked "is a chest equipped?" where the reference asks whether anything but the shirt paints the ArmLower tile, wrong more often than right (533 of 637 chest displays leave it empty, 566 of 570 wrist displays fill it); and B7 had no tabard guard, an ungated doublet arm, and a leg arm read off chest-or-legs where the reference reads the chest's bit alone (1864)
- a GameObject's display sounds have one trigger and two lanes: `GameObjectDisplayInfo.Sound[0..9]` has one reader (`0x5f4010`), reached only from the anim-event dispatcher, so benilla's state-transition arm — 19 Open and 4 Close rows the reference leaves silent, 80 Open and 38 Close doubled against the clip's own `$GO1`/`$GO3` — is deleted; and the kit's `0x200` flag is a LANE SELECT, not a loop flag: set means the ambient pool, which every campfire, brazier, torch and fountain names (37 live slots), and which had been starting untracked forever-channels (1867); and `WOW_PROBE_SERVICE=1` reads the NPC-service ladder back off a live server — four real NPCs, reporting the arm taken, the opcode sent and the window opened — with every hop inside the reference's own service reach, after a first run parked ~29 yd from Deputy Willem and blamed the client for a dropped packet (1865)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:48:52 +07:00
co-authored by Claude Fable 5.1
parent a8c9bc37a2
commit 0535fe9097
37 changed files with 2759 additions and 264 deletions
+30 -8
View File
@@ -231,10 +231,6 @@
BuyoutPrice.onValueChanged = AuctionsFrameAuctions_ValidateAuction
StartPriceGold:SetMaxLetters(6)
-- Each faux list repaints through its own tab's Update.
BrowseScrollFrame.updateFunc = AuctionFrameBrowse_Update
BrowseFilterScrollFrame.updateFunc = AuctionFrameFilters_Update
BidScrollFrame.updateFunc = AuctionFrameBid_Update
AuctionsScrollFrame.updateFunc = AuctionFrameAuctions_Update
end
function AuctionFrame_OnShow()
@@ -783,6 +779,7 @@
end
end
FauxScrollFrame_Update(BrowseFilterScrollFrame, total, BENILLA_FILTER_ROWS, BENILLA_FILTER_ROW_HEIGHT)
BenillaScrollTrough_Follow("BrowseFilterScrollFrame")
end
-- Clicking a filter row NEVER queries — it only moves the selection, which the next Search
@@ -940,6 +937,7 @@
local rows = auctionPagerUpdate("Browse", AuctionFrameBrowse.page, numBatch, total, lastSlotEmpty)
FauxScrollFrame_Update(BrowseScrollFrame, rows, BENILLA_BROWSE_ROWS, BENILLA_AUCTION_ROW_HEIGHT)
BenillaScrollTrough_Follow("BrowseScrollFrame")
end
-- The two Browse turners re-SEARCH rather than scroll: the next 50 rows live on the server
@@ -2056,7 +2054,13 @@
<ScrollFrame name="BrowseFilterScrollFrame" inherits="BenillaAuctionScrollTemplate">
<Size><AbsDimension x="160" y="305"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPLEFT"><Offset><AbsDimension x="158" y="-105"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_FILTER_ROW_HEIGHT, AuctionFrameFilters_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<Button name="BrowseQualitySort" inherits="AuctionSortButtonTemplate" text="RARITY">
<Size><AbsDimension x="214" y="19"/></Size>
@@ -2097,7 +2101,13 @@
<ScrollFrame name="BrowseScrollFrame" inherits="BenillaAuctionScrollTemplate">
<Size><AbsDimension x="465" y="306"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="39" y="-105"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_AUCTION_ROW_HEIGHT, AuctionFrameBrowse_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<Button name="BrowsePrevPageButton" inherits="BenillaAuctionPrevPageTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BrowseButton8" relativePoint="LEFT"><Offset><AbsDimension x="5" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BrowsePageButton_OnClick(-1)</OnClick></Scripts>
@@ -2267,7 +2277,13 @@
<ScrollFrame name="BidScrollFrame" inherits="BenillaAuctionScrollTemplate">
<Size><AbsDimension x="625" y="335"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="40" y="-74"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_AUCTION_ROW_HEIGHT, AuctionFrameBid_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<Button name="BidPrevPageButton" inherits="BenillaAuctionPrevPageTemplate">
<Anchors><Anchor point="LEFT" relativeTo="BidButton9" relativePoint="LEFT"><Offset><AbsDimension x="5" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BidPageButton_OnClick(-1)</OnClick></Scripts>
@@ -2384,7 +2400,13 @@
<ScrollFrame name="AuctionsScrollFrame" inherits="BenillaAuctionScrollTemplate">
<Size><AbsDimension x="435" y="339"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="40" y="-72"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_AUCTION_ROW_HEIGHT, AuctionFrameAuctions_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<Button name="AuctionsPrevPageButton" inherits="BenillaAuctionPrevPageTemplate">
<Anchors><Anchor point="LEFT" relativeTo="AuctionsButton9" relativePoint="LEFT"><Offset><AbsDimension x="5" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnClick>AuctionsPageButton_OnClick(-1)</OnClick></Scripts>
+14 -2
View File
@@ -311,6 +311,12 @@
-- ── The list page (ref CraftFrame_Update, l.99-283 — the header/expand-icon/training-points
-- tail is dropped: deviations 1/6, never reached in v1) ────────────────────────────────────────
-- `<OnVerticalScroll>` is its own chunk and cannot see this file's
-- `local CRAFT_SKILL_HEIGHT`, so the pitch is handed over here (1868).
function BenillaCraftList_OnVerticalScroll()
FauxScrollFrame_OnVerticalScroll(CRAFT_SKILL_HEIGHT, BenillaCraftFrame_Update)
end
function BenillaCraftFrame_Update()
getglobal("BenillaCraftFrameTitleText"):SetText(GetCraftName())
getglobal("BenillaCraftCreateButton"):SetText(getglobal(GetCraftButtonToken()))
@@ -326,6 +332,7 @@
local scrollFrame = getglobal("BenillaCraftListScrollFrame")
FauxScrollFrame_Update(scrollFrame, numCrafts, CRAFTS_DISPLAYED, CRAFT_SKILL_HEIGHT)
BenillaScrollTrough_Follow("BenillaCraftListScrollFrame")
local craftOffset = FauxScrollFrame_GetOffset(scrollFrame)
-- The skill-rank StatusBar (ref l.107-123): Show/Hide are real here (real widgets, not
@@ -518,7 +525,6 @@
frame:RegisterEvent("UNIT_PORTRAIT_UPDATE")
local scrollFrame = getglobal("BenillaCraftListScrollFrame")
scrollFrame.updateFunc = BenillaCraftFrame_Update
end
-- Fresh session paint (ref CraftFrame_Show, l.26-49 — the header index-select branch is
@@ -877,7 +883,13 @@
<ScrollFrame name="BenillaCraftListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-96"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>BenillaCraftList_OnVerticalScroll()</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The detail icon (ref CraftIcon, l.540-577): a 37×37 button over a 64×64 UI-EmptySlot
ring (TrainerFrame.xml's own BenillaTrainerSkillIcon shape, TradeSkillFrame.xml's own
+28 -8
View File
@@ -2219,9 +2219,14 @@
`ClassTrainerFrameTemplates.xml:111` both open the
same way. Decision 1846. -->
ScrollFrame_OnLoad()
this.updateFunc = FriendsList_Update
</OnLoad></Scripts>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(FRIENDS_FRAME_FRIEND_HEIGHT, FriendsList_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
</Frames>
<Scripts>
<OnMouseWheel>BenillaFauxScrollFrame_OnMouseWheel(FriendsFrameFriendsScrollFrame, arg1)</OnMouseWheel>
@@ -2341,9 +2346,14 @@
`ClassTrainerFrameTemplates.xml:111` both open the
same way. Decision 1846. -->
ScrollFrame_OnLoad()
this.updateFunc = IgnoreList_Update
</OnLoad></Scripts>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(FRIENDS_FRAME_IGNORE_HEIGHT, IgnoreList_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
</Frames>
<Scripts>
<OnMouseWheel>BenillaFauxScrollFrame_OnMouseWheel(FriendsFrameIgnoreScrollFrame, arg1)</OnMouseWheel>
@@ -2489,9 +2499,14 @@
`ClassTrainerFrameTemplates.xml:111` both open the
same way. Decision 1846. -->
ScrollFrame_OnLoad()
this.updateFunc = WhoList_Update
</OnLoad></Scripts>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(FRIENDS_FRAME_WHO_HEIGHT, WhoList_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
</Frames>
<Scripts>
<!-- SetWhoToUI decides where the NEXT answer goes: this frame, or the chat
@@ -2759,9 +2774,14 @@
`ClassTrainerFrameTemplates.xml:111` both open the
same way. Decision 1846. -->
ScrollFrame_OnLoad()
this.updateFunc = GuildStatus_Update
</OnLoad></Scripts>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(FRIENDS_FRAME_GUILD_HEIGHT, GuildStatus_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The page button that flips the two views. Its LEFT anchor is re-set by
`GuildStatus_Update` to 284 or 307 depending on whether the scroll bar is
@@ -330,6 +330,7 @@
-- trailing arguments stay off.)
local listScroll = getglobal("QuestLogListScrollFrame")
FauxScrollFrame_Update(listScroll, numEntries, QUESTS_DISPLAYED, QUESTLOG_QUEST_HEIGHT)
BenillaScrollTrough_Follow("QuestLogListScrollFrame")
local questLogOffset = FauxScrollFrame_GetOffset(listScroll)
local selection = GetQuestLogSelection()
@@ -1148,7 +1149,6 @@
-- The faux kit's owner hook (ScrollTemplates.xml l.37): a bar drag or arrow click sets
-- the frame's offset and then calls this to repaint the rows. SkillFrame.xml wires its
-- own the same way.
getglobal("QuestLogListScrollFrame").updateFunc = BenillaQuestLogList_Update
end
function BenillaQuestLogFrame_OnEvent(frame)
@@ -90,6 +90,7 @@
local scrollFrame = getglobal("BenillaScriptLogListScrollFrame")
FauxScrollFrame_Update(scrollFrame, shown, BENILLA_SCRIPTLOG_ROWS, BENILLA_SCRIPTLOG_ROW_HEIGHT)
BenillaScrollTrough_Follow("BenillaScriptLogListScrollFrame")
local offset = FauxScrollFrame_GetOffset(scrollFrame)
local selectedRow = nil
@@ -172,7 +173,6 @@
end
function BenillaScriptLogFrame_OnLoad(self)
getglobal("BenillaScriptLogListScrollFrame").updateFunc = BenillaScriptLog_Update
-- The ring's icon, through the portrait verb rather than left at its `file=` — see the
-- texture's own note. MailFrame.xml's open-letter icon is the same fix.
SetPortraitToTexture("BenillaScriptLogFramePortrait",
@@ -312,7 +312,13 @@
<ScrollFrame name="BenillaScriptLogListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="208"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-94"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>FauxScrollFrame_OnVerticalScroll(BENILLA_SCRIPTLOG_ROW_HEIGHT, BenillaScriptLog_Update)</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The detail pane: the REAL scroll kit, because a message carries its `stack
traceback:` tail and is genuinely variable-height. GossipFrame's greeting pane is
+20 -3
View File
@@ -333,10 +333,20 @@
-- `UnitCharacterPoints("player")`/`SkillFrame.UpdatedSkillPoints` read, l.437-440, whose
-- result nothing downstream ever consumes even in the ref itself, is dropped — the
-- TradeSkillFrame.xml "ref's own dead code is dropped" precedent).
-- The scroll frame's `<OnVerticalScroll>` is its own chunk and cannot see this file's
-- `local SKILLFRAME_SKILL_HEIGHT`, so the pitch is handed over here rather than written as
-- a literal in the XML (1868; the trainer paid for this lesson first, in 1860).
function BenillaSkillList_OnVerticalScroll()
FauxScrollFrame_OnVerticalScroll(SKILLFRAME_SKILL_HEIGHT, BenillaSkillFrame_Update)
end
function BenillaSkillFrame_Update()
local numRows = GetNumSkillLines()
local scrollFrame = getglobal("SkillListScrollFrame")
FauxScrollFrame_Update(scrollFrame, numRows, SKILLS_TO_DISPLAY, SKILLFRAME_SKILL_HEIGHT)
-- Our trough follows the bar the reference's `_Update` just decided about; nothing in
-- its kit shows one, because the reference has no trough at all (1860).
BenillaScrollTrough_Follow("SkillListScrollFrame")
local offset = FauxScrollFrame_GetOffset(scrollFrame)
local highlight = getglobal("SkillHighlight")
@@ -468,7 +478,6 @@
function SkillFrame_OnLoad()
local frame = this
frame:RegisterEvent("SKILL_LINES_CHANGED")
getglobal("SkillListScrollFrame").updateFunc = BenillaSkillFrame_Update
end
function SkillFrame_OnShow()
@@ -711,11 +720,19 @@
<!-- The faux-scroll frame (FauxScrollFrameTemplate, ScrollTemplates.xml): invisible
container over the 12 rows; only its right-hand Slider scroll bar renders. Sized to the
12-row span (SKILLS_TO_DISPLAY × 18px pitch), TrainerFrame.xml's own
same-TOPLEFT-as-row1 convention. updateFunc set in SkillFrame_OnLoad. -->
same-TOPLEFT-as-row1 convention. Its `<OnVerticalScroll>` re-binds the rows (1868). -->
<ScrollFrame name="SkillListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="296" y="216"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-79"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here: the bar's
OnValueChanged calls `SetVerticalScroll`, which lands in this handler.
Our transcription used a `frame.updateFunc` field instead, which the
reference does not have — without this the bar moves and the list does
not follow (1860). -->
<OnVerticalScroll>BenillaSkillList_OnVerticalScroll()</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- ═══ THE DETAIL PANE ═══ ref SkillFrame.xml l.513-747, transcribed as the ref
declares it (header: the FLAT-layout claim this file used to carry is retracted,
@@ -411,6 +411,12 @@
-- ── The list page (ref TradeSkillFrame_Update, l.75-178 — the header/expand-icon tail,
-- l.118-127, is restored below at the row loop; the CollapseAll Enable/Disable (l.86/94) and
-- the tail texture logic (l.155-178) are restored too, deviation 1 closed this session) ──────
-- `<OnVerticalScroll>` is its own chunk and cannot see this file's
-- `local TRADE_SKILL_HEIGHT`, so the pitch is handed over here (1868).
function BenillaTradeSkillList_OnVerticalScroll()
FauxScrollFrame_OnVerticalScroll(TRADE_SKILL_HEIGHT, BenillaTradeSkillFrame_Update)
end
function BenillaTradeSkillFrame_Update()
local numTradeSkills = GetNumTradeSkills()
if numTradeSkills == 0 then
@@ -422,6 +428,7 @@
local scrollFrame = getglobal("BenillaTradeSkillListScrollFrame")
FauxScrollFrame_Update(scrollFrame, numTradeSkills, TRADE_SKILLS_DISPLAYED, TRADE_SKILL_HEIGHT)
BenillaScrollTrough_Follow("BenillaTradeSkillListScrollFrame")
local skillOffset = FauxScrollFrame_GetOffset(scrollFrame)
-- The FRAME is what shows/hides/positions (ref l.99/142-143); its texture is only ever
@@ -757,8 +764,6 @@
frame:RegisterEvent("UNIT_PORTRAIT_UPDATE")
local scrollFrame = getglobal("BenillaTradeSkillListScrollFrame")
scrollFrame.updateFunc = BenillaTradeSkillFrame_Update
-- The EditBox:SetNumber engine-gap shim (this file's header comment, deviation 7): patched
-- onto this ONE instance so every call site above reads exactly like the reference.
local box = getglobal("BenillaTradeSkillInputBox")
@@ -1171,7 +1176,13 @@
<ScrollFrame name="BenillaTradeSkillListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="293" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-96"/></Offset></Anchor></Anchors>
</ScrollFrame>
<Scripts>
<!-- The reference's kit writes `frame.offset` ONLY from here; its
`updateFunc` field does not exist, so without this the bar moves and
the list does not follow (1868). -->
<OnVerticalScroll>BenillaTradeSkillList_OnVerticalScroll()</OnVerticalScroll>
</Scripts>
</ScrollFrame>
<!-- The detail product icon (ref TradeSkillSkillIcon, l.539-591): a 37×37 button over a
64×64 UI-EmptySlot ring (TrainerFrame.xml's own BenillaTrainerSkillIcon shape) at the
+11
View File
@@ -951,6 +951,17 @@ ChatFrame.xml
# panel kit on the chain that shadow is gone, so this window went from blocked to a plain swap.
#
# `ShowMacroFrame` moved to UIParent.xml, the reference's own home for it — see 1848.
#
# **`ClassTrainerFrameTemplates.xml` is not optional, and this header used to name the dependency
# without listing the file.** `MacroPopupScrollFrame` — the icon chooser's list — inherits its
# `ClassTrainerListScrollFrameTemplate`, and a missing template is a loader WARNING, not an error
# (1833): the frame is still built, just BARE, with no scroll bar and no `<OnLoad>`. So
# `ScrollFrame_OnLoad` never ran, `this.offset` was never seeded, and the reference's
# `FauxScrollFrame_GetOffset` (`return frame.offset`, no `or 0` — our deleted copy had that
# fallback and was hiding this) handed `MacroPopupFrame_Update` a nil to multiply. Clicking
# "Change Name/Icon" raised. The reference's own `FrameXML.toc` loads this at its line 92, well
# before any addon; ours takes it here, immediately above its one consumer. Decision 1862.
Interface\FrameXML\ClassTrainerFrameTemplates.xml
Interface\AddOns\Blizzard_MacroUI\Blizzard_MacroUI.xml
# The script error log (decision 1495): the window `/errors` opens — the list B293's two
# reporters asked for, which the reference's one-message-at-a-time ScriptErrors modal cannot
+2
View File
@@ -103,6 +103,7 @@ mod probe_mail;
mod probe_melee;
mod probe_partner;
mod probe_rig;
mod probe_service;
mod probe_taxi;
mod probes;
mod scenarios;
@@ -128,6 +129,7 @@ pub(crate) use probe_mail::ProbeMailPlugin;
pub(crate) use probe_melee::ProbeMeleePlugin;
pub(crate) use probe_partner::ProbePartnerPlugin;
pub(crate) use probe_rig::ProbeRigPlugin;
pub(crate) use probe_service::ProbeServicePlugin;
pub(crate) use probe_taxi::ProbeTaxiPlugin;
pub(crate) use probes::{
fx_draw_census_plugin, DressCensusPlugin, EntityCensusPlugin, GroundCensusPlugin,
@@ -0,0 +1,544 @@
//! The **NPC-service ladder** live probe (`WOW_PROBE_SERVICE=1`) — decision 1861's end-to-end
//! instrument, and the answer to "does right-clicking a trainer or a quest giver really open their
//! window instead of a gossip menu?".
//!
//! 1861 replaced a cursor-kind dispatch with the reference's own first-match-wins walk over
//! `UNIT_NPC_FLAGS` ([`crate::target::click::service_arm`]; wow-re
//! `object-layer/scratch/interact-dead-fork-and-npc-service-ladder.md` §C). **Bit 0 (GOSSIP) is
//! tested first**, so the flag — not the profession — decides: a trainer or questgiver that also
//! carries GOSSIP still opens a gossip menu, and only a flagless one opens its own window. That
//! precedence is the whole question, and it is invisible to a unit test: the bits come off the
//! wire, and what appears on screen is the *server's* answer to the opcode the ladder picked.
//!
//! So the probe walks four real Northshire/Stormwind NPCs, one per interesting flag shape, and for
//! each one reports the wire's `UNIT_NPC_FLAGS`, the arm the shipped ladder takes, the opcode it
//! sends, and which window actually opened:
//!
//! | NPC | `npc_flags` | arm | window |
//! |---|---|---|---|
//! | Marshal McBride (197) | `0x03` GOSSIP\|QUESTGIVER | Gossip | gossip menu |
//! | Llane Beshere (911) | `0x13` GOSSIP\|QUESTGIVER\|TRAINER | Gossip | gossip menu |
//! | *any pure questgiver with an offer* | `0x02`, GOSSIP clear | Questgiver | quest frame |
//! | Alma Jainrose (812) | `0x10` TRAINER only | Trainer | trainer frame |
//!
//! The flag column is live-DB verified this session (`vmangos-deploy` → `mangos`,
//! `SELECT entry, name, npc_flags FROM creature_template`), and reported again from the wire on
//! every run — a template edit shows up as a note beside the reading rather than as a mystery FAIL.
//!
//! **The ladder is called, never re-implemented.** The probe reads the same two inputs the click
//! reads (`ObjectStore::unit_npc_flags` and [`QuestGiver::status`]) and calls the shipped
//! `service_arm`/`service_action`; only the mouse hit-test and the cursor's range gray sit outside
//! it. A private copy of the bit table is exactly how a probe goes quietly stale (the B249 icon
//! map), so there isn't one.
//!
//! **The pure-questgiver row is found by predicate, not by entry.** Whether Deputy Willem has
//! anything for *this* probe character depends on what it has already turned in, and the bit-1 arm
//! is gated on that status (`[unit+0xcb8] ∉ {0,1}`). So that leg hops into Northshire and takes the
//! first streamed unit that is QUESTGIVER, not GOSSIP, and actually has an offer — reporting which
//! one it picked. If the character has cleared the whole valley it SKIPs and says so.
//!
//! One `PROBE_SERVICE: <leg> PASS/FAIL/SKIP <detail>` line per leg, then a final
//! `PROBE_SERVICE: DONE pass=<n> fail=<m> skip=<k>`. A wrong arm or a wrong window is a FAIL; an
//! environmental problem (the `.go` refused, the NPC never streamed, no offer left in the valley)
//! is a SKIP with the reason.
//!
//! ## The run recipe
//!
//! ```text
//! WOW_DATA=WoW/Data WOW_USER=probe4 WOW_PASS=pprobe4 WOW_CHAR=Probefour \
//! WOW_UNATTENDED=1 WOW_PROBE_SERVICE=1 cargo run -q -p benilla
//! ```
//! (the slot-keyed probe identity — method.md "The local vmangos server"). Non-combat, GM mode
//! left as found, nothing bought and nothing turned in: every leg opens a window and closes it
//! client-side, which is what the reference's own close does (`ui_gossip`: there is no
//! `CMSG_GOSSIP_CLOSE` in 1.12).
use bevy::prelude::*;
use benilla_protocol::EntityKind;
use super::probes::ProbeClock;
use crate::net::{ChatKind, ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, SelfPlayer};
use crate::player::Player;
use crate::target::click::{service_action, service_arm, ServiceAction, ServiceArm};
use crate::target::cursor_mode::npc_flags as f;
use crate::ui_gossip::GossipState;
use crate::ui_quest::QuestGiver;
use crate::ui_trainer::TrainerOpen;
/// How wide to look around a `.go` landing for the leg's NPC — **and no wider than the reference's
/// own service reach**, because the click cannot act past it either: beyond
/// [`crate::target::SERVICE_RANGE_SQ`] (5.5556 yd, `0xc4c28c`/`0xb4b32c`) the cursor goes gray, the
/// click sends nothing, and vmangos's `GetNPCIfCanInteractWith` refuses the opcode silently.
///
/// This constant is the reason the questgiver leg first read as a client defect: its hop point was
/// an invented Northshire "hub" ~29 yd from Deputy Willem, so the probe called a ladder the click
/// would never have reached, the server dropped the packet on its own distance check, and the
/// timeout printed a FAIL about the client (method.md §6 — prove the run before reading the
/// result). Every hop point is now an NPC's own spawn, and the scan re-checks the reach.
const SCAN_RANGE_SQ: f32 = crate::target::SERVICE_RANGE_SQ;
/// Let the hop land and the tile stream before the first scan.
const SETTLE_SECS: f64 = 3.0;
/// How long a leg waits for its NPC to stream in before calling the hop environmental.
const SCAN_TIMEOUT_SECS: f64 = 20.0;
/// How long a leg waits for its window after the opcode goes out. Generous for the same reason the
/// binder probe's is: the observer is starved during a terrain load, never the wire.
const WINDOW_TIMEOUT_SECS: f64 = 20.0;
/// Which NPC a leg is looking for.
enum Ident {
/// A specific `creature_template.entry` — the identity check that cannot be confused by a
/// neighbour who happens to share a flag.
Entry(u32),
/// The bit-1 arm's own shape: QUESTGIVER set, GOSSIP clear, and an offer live for THIS
/// character (`SMSG_QUESTGIVER_STATUS` ∉ {0,1}) — see the module doc.
PureQuestgiverWithOffer,
}
/// Which window the leg's arm must put on screen.
#[derive(Clone, Copy, PartialEq, Debug)]
enum Window {
Gossip,
Quest,
Trainer,
}
/// One leg: hop here, find this NPC, run the ladder on it, expect this arm and this window.
struct Leg {
/// What the log calls it.
name: &'static str,
ident: Ident,
map: u32,
/// The spawn points to try, in order — each an NPC's own `creature.position_*`, so the landing
/// is inside the service reach. A leg with several is one whose subject depends on what this
/// character has already turned in (the questgiver leg); it moves to the next candidate when
/// the one it hopped to has nothing to offer.
at: &'static [[f32; 3]],
/// `creature_template.npc_flags` as the world DB held it when this probe was written — printed
/// beside the wire's value so a template edit reads as a note rather than a mystery.
db_flags: u32,
want_arm: ServiceArm,
want_window: Window,
}
/// The four flag shapes worth a live reading (module doc's table). Northshire first — three of the
/// four legs share one hop's worth of terrain — then the Stormwind first-aid trainer.
const LEGS: &[Leg] = &[
Leg {
name: "Marshal McBride — GOSSIP|QUESTGIVER",
ident: Ident::Entry(197),
map: 0,
at: &[[-8902.59, -162.606, 82.0223]],
db_flags: 0x3,
want_arm: ServiceArm::Gossip,
want_window: Window::Gossip,
},
Leg {
name: "Llane Beshere — GOSSIP|QUESTGIVER|TRAINER",
ident: Ident::Entry(911),
map: 0,
at: &[[-8918.36, -208.411, 82.3088]],
db_flags: 0x13,
want_arm: ServiceArm::Gossip,
want_window: Window::Gossip,
},
Leg {
name: "a pure questgiver with an offer — QUESTGIVER, no GOSSIP",
ident: Ident::PureQuestgiverWithOffer,
map: 0,
// Deputy Willem (823), Eagan Peltskinner (196), Falkhaan Isenstrider (6774) — the three
// pure questgivers of Northshire, each at its own spawn (live-DB verified this session).
at: &[
[-8933.54, -136.523, 83.4466],
[-8869.22, -163.237, 80.9719],
[-9044.56, -45.9817, 88.4193],
],
db_flags: 0x2,
want_arm: ServiceArm::Questgiver,
want_window: Window::Quest,
},
Leg {
name: "Alma Jainrose — TRAINER, no GOSSIP",
ident: Ident::Entry(812),
map: 0,
at: &[[-9237.78, -2041.65, 78.1678]],
db_flags: 0x10,
want_arm: ServiceArm::Trainer,
want_window: Window::Trainer,
},
];
pub(crate) struct ProbeServicePlugin;
impl Plugin for ProbeServicePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ServiceProbe>()
.add_systems(Update, service_probe);
}
}
#[derive(Resource, Default)]
struct ServiceProbe {
phase: Phase,
passes: u32,
fails: u32,
skips: u32,
/// Latched once [`Phase::Done`] has fired its exit (never re-fire on a later frame).
exited: bool,
}
#[derive(Default, Clone, Copy, PartialEq)]
enum Phase {
/// Not in-world yet, or between legs: `.go` for leg `i` still to send.
#[default]
Start,
Hop {
i: usize,
/// Which of the leg's spawn points we hopped to.
hop: usize,
sent_at: f64,
},
/// The arm's action has gone out; waiting for the window it must open.
Await {
i: usize,
since: f64,
guid: u64,
},
Done,
}
/// A flag word as the log wants it — the hex plus the bit names, so a reader never has to decode
/// `0x13` by hand.
fn flag_names(flags: u32) -> String {
const NAMES: [(u32, &str); 14] = [
(f::GOSSIP, "GOSSIP"),
(f::QUESTGIVER, "QUESTGIVER"),
(f::VENDOR, "VENDOR"),
(f::FLIGHTMASTER, "FLIGHTMASTER"),
(f::TRAINER, "TRAINER"),
(f::SPIRITHEALER, "SPIRITHEALER"),
(f::SPIRITGUIDE, "SPIRITGUIDE"),
(f::INNKEEPER, "INNKEEPER"),
(f::BANKER, "BANKER"),
(f::PETITIONER, "PETITIONER"),
(f::TABARDDESIGNER, "TABARDDESIGNER"),
(f::BATTLEMASTER, "BATTLEMASTER"),
(f::AUCTIONEER, "AUCTIONEER"),
(f::STABLEMASTER, "STABLEMASTER"),
];
let set: Vec<&str> = NAMES
.iter()
.filter(|(bit, _)| flags & bit != 0)
.map(|(_, n)| *n)
.collect();
match set.is_empty() {
true => format!("{flags:#x} (none)"),
false => format!("{flags:#x} {}", set.join("|")),
}
}
/// Which window is open on `guid`, if any — the observation every leg is judged on.
fn open_window(
guid: u64,
gossip: &GossipState,
giver: &QuestGiver,
trainer: &TrainerOpen,
) -> Option<Window> {
// The gossip frame does not open on the packet: it holds closed until the greeting's
// `CMSG_NPC_TEXT_QUERY` answers (B292's hold, `ui_gossip`), so the greeting is part of "open".
if gossip.npc == Some(guid) && gossip.greeting.is_some() {
return Some(Window::Gossip);
}
if giver.npc == Some(guid) && giver.view.is_some() {
return Some(Window::Quest);
}
if trainer.trainer == Some(guid) {
return Some(Window::Trainer);
}
None
}
#[allow(clippy::too_many_arguments)]
fn service_probe(
time: ProbeClock,
mut probe: ResMut<ServiceProbe>,
mut gossip: ResMut<GossipState>,
mut giver: ResMut<QuestGiver>,
mut trainer: ResMut<TrainerOpen>,
self_player: Query<(), With<SelfPlayer>>,
player: Res<Player>,
units: Query<(&Guid, &NetEntity, &ObjectStore, &Transform), Without<SelfPlayer>>,
net: Res<NetCommands>,
mut exit: MessageWriter<AppExit>,
) {
if self_player.is_empty() {
return; // not in-world yet
}
let now = time.elapsed_secs_f64();
match probe.phase {
Phase::Start => {
hop(&mut probe, &net, 0, 0, now);
}
Phase::Hop { i, hop: h, sent_at } => {
if now - sent_at < SETTLE_SECS {
return;
}
let leg = &LEGS[i];
let me = player.pos;
let found = units.iter().find(|(guid, net_e, store, tf)| {
// The click's own gate: `dist² > SERVICE_RANGE_SQ` is the cursor's `unable`, and
// the arm never runs. Acting outside it would measure nothing.
if net_e.kind != EntityKind::Unit
|| tf.translation.distance_squared(me) > SCAN_RANGE_SQ
{
return false;
}
let flags = store.0.unit_npc_flags();
match leg.ident {
Ident::Entry(entry) => store.0.object_entry() == Some(entry),
Ident::PureQuestgiverWithOffer => {
flags & f::QUESTGIVER != 0
&& flags & f::GOSSIP == 0
&& crate::target::cursor_mode::questgiver_has_quest(
giver.status(guid.0),
)
}
}
});
let Some((guid, _, store, _)) = found else {
if now - sent_at > SCAN_TIMEOUT_SECS {
if h + 1 < leg.at.len() {
info!(
"PROBE_SERVICE: ({}) nothing eligible at spawn {}/{} — trying the next",
leg.name,
h + 1,
leg.at.len()
);
hop(&mut probe, &net, i, h + 1, now);
return;
}
warn!(
"PROBE_SERVICE: SKIP ({}) — nothing matching streamed inside the \
{:.4}yd service reach of any of this leg's {} spawn point(s) in \
{SCAN_TIMEOUT_SECS}s each (the `.go` may have been refused, the tile may \
not have streamed, or — for the questgiver leg — this character has \
nothing left to pick up in the valley). Environmental.",
leg.name,
SCAN_RANGE_SQ.sqrt(),
leg.at.len()
);
probe.skips += 1;
next(&mut probe, &net, i, now);
}
return;
};
let guid = guid.0;
let flags = store.0.unit_npc_flags();
let entry = store.0.object_entry().unwrap_or(0);
let status = giver.status(guid);
// The shipped ladder, on the shipped inputs.
let arm = service_arm(flags, status);
let note = match flags == leg.db_flags {
true => String::new(),
false => format!(
" [note: the world DB read {} when this leg was written]",
flag_names(leg.db_flags)
),
};
let Some(arm) = arm else {
error!(
"PROBE_SERVICE: FAIL ({}) — entry {entry} guid {guid:#x} flags {} \
status={status:?}: the ladder matched NO bit, so the click sends nothing. \
Wanted {:?}.{note}",
leg.name,
flag_names(flags),
leg.want_arm
);
probe.fails += 1;
next(&mut probe, &net, i, now);
return;
};
if arm != leg.want_arm {
error!(
"PROBE_SERVICE: FAIL ({}) — entry {entry} guid {guid:#x} flags {} \
status={status:?}: the ladder took {arm:?}, wanted {:?}. First-match-wins \
low→high over UNIT_NPC_FLAGS is the reference's own order \
(`0x5f0289`…`0x5f05bc`); a disagreement here is the dispatch, not the \
server.{note}",
leg.name,
flag_names(flags),
leg.want_arm
);
probe.fails += 1;
next(&mut probe, &net, i, now);
return;
}
// A stale window from the previous leg would answer this one's question. The
// reference's own close sends no packet, so a local clear is the whole of it.
gossip.clear();
giver.clear();
trainer.clear();
let sent = match service_action(arm, guid, false) {
ServiceAction::Send(cmd) => {
let named = format!("{cmd:?}");
let _ = net.0.send(cmd);
named
}
// No leg here takes a dialog arm; if one ever does, say so rather than hang.
other => {
let why: &str = match other {
ServiceAction::AskBinder => "CONFIRM_BINDER, no packet",
ServiceAction::AskSpiritHealer => "CONFIRM_XP_LOSS, no packet",
ServiceAction::Silent(w) => w,
ServiceAction::Send(_) => unreachable!(),
};
warn!(
"PROBE_SERVICE: SKIP ({}) — arm {arm:?} opens no window from a packet \
({why}); this probe's legs are the four that do.{note}",
leg.name
);
probe.skips += 1;
next(&mut probe, &net, i, now);
return;
}
};
info!(
"PROBE_SERVICE: ({}) entry {entry} guid {guid:#x} flags {} status={status:?} \
→ arm {arm:?} → {sent}{note}",
leg.name,
flag_names(flags)
);
probe.phase = Phase::Await {
i,
since: now,
guid,
};
}
Phase::Await { i, since, guid } => {
let leg = &LEGS[i];
match open_window(guid, &gossip, &giver, &trainer) {
Some(open) if open == leg.want_window => {
info!(
"PROBE_SERVICE: PASS ({}) — {:?} window open on {guid:#x}{}",
leg.name,
open,
match open {
Window::Gossip => format!(
" ({} option(s), {} quest row(s), greeting {:?})",
gossip.options.len(),
gossip.quests.len(),
gossip.greeting.as_deref().unwrap_or("")
),
Window::Quest => format!(" ({:?})", giver.view.as_ref().map(discr)),
Window::Trainer => format!(
" ({} service(s), type {})",
trainer.services.len(),
trainer.trainer_type
),
}
);
probe.passes += 1;
next(&mut probe, &net, i, now);
}
Some(open) => {
error!(
"PROBE_SERVICE: FAIL ({}) — {open:?} window opened on {guid:#x}, wanted \
{:?}",
leg.name, leg.want_window
);
probe.fails += 1;
next(&mut probe, &net, i, now);
}
None if now - since > WINDOW_TIMEOUT_SECS => {
// Distinguish the one near-miss that is a real defect from a dead wire: the
// gossip session latched but its greeting never resolved, so the frame stayed
// shut (`GossipState::resolve_greeting`'s "Missing gossip text!" path).
let held = gossip.npc == Some(guid) && gossip.greeting.is_none();
error!(
"PROBE_SERVICE: FAIL ({}) — no {:?} window on {guid:#x} within \
{WINDOW_TIMEOUT_SECS}s of the send{}",
leg.name,
leg.want_window,
match held {
true => format!(
" (the gossip session latched on text_id {} but the greeting never \
resolved — the frame never opens on that path)",
gossip.text_id
),
false => String::new(),
}
);
probe.fails += 1;
next(&mut probe, &net, i, now);
}
None => {}
}
}
Phase::Done => {
if probe.exited {
return;
}
probe.exited = true;
info!(
"PROBE_SERVICE: DONE pass={} fail={} skip={}",
probe.passes, probe.fails, probe.skips
);
// The probe self-exit pattern (`ProbeExitPlugin::fire_probe_exit`): a polite AppExit
// plus a hard backstop thread, so a net/winit teardown hang can't leave a zombie
// client holding the probe account.
exit.write(AppExit::Success);
std::thread::spawn(|| {
std::thread::sleep(std::time::Duration::from_secs(5));
warn!("PROBE_SERVICE: still alive 5s after AppExit — hard exit");
std::process::exit(0);
});
}
}
}
/// The open quest panel's shape, for the PASS line — `Greeting` (the multi-quest list) vs
/// `Detail` (the single quest's own frame) is exactly the distinction a reader wants here.
fn discr(view: &crate::ui_quest::QuestView) -> &'static str {
use crate::ui_quest::QuestView as V;
match view {
V::Greeting(_) => "Greeting",
V::Detail(_) => "Detail",
V::Progress(_) => "Progress",
V::Reward(_) => "Reward",
}
}
/// Send leg `i`'s `.go` for spawn point `h` and enter [`Phase::Hop`].
fn hop(probe: &mut ServiceProbe, net: &NetCommands, i: usize, h: usize, now: f64) {
let leg = &LEGS[i];
let [x, y, z] = leg.at[h];
info!(
"PROBE_SERVICE: hopping to {} at ({x}, {y}, {z}) map {} (spawn {}/{})",
leg.name,
leg.map,
h + 1,
leg.at.len()
);
let _ = net.0.send(ClientCommand::Chat {
kind: ChatKind::Say,
target: None,
text: format!(".go xyz {x} {y} {z} {}", leg.map),
});
probe.phase = Phase::Hop {
i,
hop: h,
sent_at: now,
};
}
/// Advance past leg `i`, or finish.
fn next(probe: &mut ServiceProbe, net: &NetCommands, i: usize, now: f64) {
match i + 1 < LEGS.len() {
true => hop(probe, net, i + 1, 0, now),
false => probe.phase = Phase::Done,
}
}
+8
View File
@@ -327,6 +327,14 @@ impl Plugin for DevProbesPlugin {
if std::env::var("WOW_PROBE_BINDER").is_ok() {
app.add_plugins(crate::capture::ProbeBinderPlugin);
}
// The NPC-service ladder live probe: `WOW_PROBE_SERVICE=1` walks four real NPCs, one per
// interesting `UNIT_NPC_FLAGS` shape, and reports the arm the shipped ladder takes and the
// window that actually opened — decision 1861's end-to-end instrument, and the standing
// answer to "does right-clicking a trainer open the trainer window or a gossip menu?"
// (see `capture::ProbeServicePlugin`).
if std::env::var("WOW_PROBE_SERVICE").is_ok() {
app.add_plugins(crate::capture::ProbeServicePlugin);
}
// The GM trouble-ticket live probe: `WOW_PROBE_GMTICKET=1` drives the whole five-opcode
// ticket wire through the live VM's own bindings — queue status, clean slate, file, edit,
// abandon — and prints the row the server must have stored so the operator can check the
+7
View File
@@ -322,6 +322,13 @@ impl Creatures {
self.catalog.death_thud_shake(display_id)
}
/// A display's **audible size class** (`0 Small`..`4 Colossal`) — the axis the body-fall
/// sample is picked on (`crate::sound::death_thud`). The display's own column overrides the
/// model's; see [`benilla_formats::CreatureCatalog::size_class`].
pub(crate) fn size_class(&self, display_id: u32) -> Option<u32> {
self.catalog.size_class(display_id)
}
/// A display's **spawned-creature render scale** — see
/// [`benilla_formats::CreatureCatalog::model_scale`] for why almost nothing reads it and why
/// the glue screens' pet must. `None` for an unknown display.
@@ -190,10 +190,15 @@ pub(super) fn resolve_worn_equip(
}
}
/// The worn geoset selectors for a set of equipment display ids (decision 0074's B1–B8 branches +
/// the cloak group + the helm's RF-0083 hide-mask row pair): each non-zero display resolves its
/// ItemDisplayInfo row's geoset columns. One helper for the world attach path and the glue-preview
/// The worn geoset selectors for a set of equipment display ids (decisions 0074/1864's B1–B8
/// branches + the cloak group + the helm's RF-0083 hide-mask row pair): each non-zero display
/// resolves its ItemDisplayInfo row's geoset columns, and B3's forearm gate comes off the same
/// composite plan the atlas blits. One helper for the world attach path and the glue-preview
/// builder — the selection law can't fork (decision 0465).
///
/// [`EquipGeosets::tabard_preview`](benilla_formats::EquipGeosets::tabard_preview) (B6) is left
/// `false` here: its only setter in the reference is the guild registrar's tabard designer, a
/// window benilla has not migrated yet (decision 1864).
pub(in crate::entities) fn equip_geosets(
displays: Option<&super::super::ItemDisplays>,
bodyslots: &[u32; 8],
@@ -202,11 +207,16 @@ pub(in crate::entities) fn equip_geosets(
) -> benilla_formats::EquipGeosets {
let mut eg = benilla_formats::EquipGeosets::default();
if let Some(d) = displays {
let mut worn: [Option<&benilla_formats::ItemDisplay>; 8] = [None; 8];
for (i, id) in bodyslots.iter().enumerate() {
if *id != 0 {
eg.bodyslots[i] = d.catalog.get(*id).map(|row| row.geoset_groups);
worn[i] = d.catalog.get(*id);
eg.bodyslots[i] = worn[i].map(|row| row.geoset_groups);
}
}
// B3's gate is the ArmLower tile's own occupancy, not "is a chest equipped" — the same
// plan the composite blits (decision 1864).
eg.forearm_dressed = benilla_formats::forearm_dressed(&worn);
if cloak != 0 {
eg.cloak = d.catalog.get(cloak).map(|row| row.geoset_groups[0]);
}
@@ -660,7 +660,7 @@ pub(super) fn attach_entity_visuals(
// display's CreatureDisplayInfoExtra (decision 0041). Both then drive the same geoset filter
// + skin/hair materials below; a beast NPC / GameObject has no look and is unaffected.
let look = resolve_char_look(net, dm, entity, &stores);
// The worn geoset selectors (decision 0074, the B1–B8 branches): a player's from the
// The worn geoset selectors (decisions 0074/1864, the B1–B8 branches): a player's from the
// resolved equipment display rows; an NPC / naked default otherwise.
// (The helm's hide-mask row pair, RF-0083: hair/facial/ears tuck under it. For a
// character-model NPC the helm id is its CreatureDisplayInfoExtra head column.)
+155 -3
View File
@@ -139,6 +139,14 @@ pub(crate) struct GoAnim {
/// completes), and once the §2c already-playing check has refused the re-arm and the pose has
/// settled for good.
rest_window: Option<AnimationNodeIndex>,
/// The state the machine last **dispatched** — the reference's `0x5f3cb0(old, new)` entry,
/// distinct from [`Self::shown`] because the completion retire clears `shown` to force a
/// re-resolve and that re-resolve is emphatically *not* a dispatch (slot 14 routes a resting
/// substate through `0x5f4167` → `0x5f3930`, straight past it). Only a genuine change of
/// [`Self::state`] — and the completion of a Spawn/Custom substate, slot 14's `0x5f4190` leg —
/// re-enters the dispatch, whose first act is `0x5f3cc8 call 0x5f40c0`: drop the object's
/// display-sound loop registration ([`GoStateDispatch`]).
dispatched: Option<u32>,
/// The **requested** animation id currently armed — the reference's `[block0+0xf8]`, written by
/// op4 (`0x71252f`) and read back by `0x712090(model, -1)`. The §2c *remap* legs consult it
/// (`0x5f39fa: cmp esi,eax; je 0x5f3b32`) and refuse an arm that would request what is already
@@ -148,6 +156,29 @@ pub(crate) struct GoAnim {
armed_id: Option<u16>,
}
/// The GameObject **state-machine dispatch** ran on this entity — the reference's `0x5f3cb0`,
/// whose first act is `0x5f3cc8 call 0x5f40c0`: **release whatever display-sound loop the object
/// holds** (wow-re `go-display-sound-events.md` §2/§6c). Consumed by
/// [`crate::sound::gameobject`], which owns that registration; the animation half of the dispatch
/// is [`drive_go_anim`]'s own work and needs no message.
///
/// Emitted on a genuine `SetGoState` and on the completion of a **Spawn or Custom** substate
/// (slot 14's `0x5f4190` leg re-enters the dispatch with `old == new`) — and deliberately *not*
/// on a resting substate's own re-arm, which slot 14 sends to `0x5f4167` instead, past the
/// dispatch entirely. That distinction is the whole reason a brazier's `TorchLoop` hums
/// continuously instead of restarting every band pass.
#[derive(Message)]
pub(crate) struct GoStateDispatch(pub(crate) Entity);
/// Which completed substates re-enter the state dispatch — slot 14's table
/// `[0]=0x5f4190 … [8..11]=0x5f4190`, i.e. **Spawn (145) and Custom0..3 (153..156)**, as
/// `AnimationData.dbc` ids. Every other completed substate is either a transition motion (which
/// advances to its rest pose through `0x5f413d`/`0x5f414b`/`0x5f4159`) or a rest pose re-arming
/// itself (`0x5f4167`); neither touches the display-sound loop.
fn completion_redispatches(id: u16) -> bool {
matches!(id, 145 | 153..=156)
}
/// The **armed** transient clip — its `AnimationData.dbc` id *and* the graph node the arm actually
/// landed on. The id alone was enough while every arm took a model's **head** variation; it stops
/// being enough now that the GameObject arm rolls one (`variationIdx = -1`, see [`drive_go_anim`]),
@@ -631,8 +662,11 @@ fn close_go_lid(
/// 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)>) {
for (mut go, player) in &mut gos {
fn retire_transient_anim(
mut gos: Query<(Entity, &mut GoAnim, &AnimationPlayer)>,
mut dispatch: MessageWriter<GoStateDispatch>,
) {
for (entity, mut go, player) in &mut gos {
// The ARMED node, not `find(id)`'s head variation — see [`Transient::node`].
let done = |n| {
player
@@ -646,6 +680,14 @@ fn retire_transient_anim(mut gos: Query<(&mut GoAnim, &AnimationPlayer)>) {
// re-resolves it (a silent rest snap — `resolve(None, state)`), exactly the
// reference's re-arm over the finished transient block.
go.shown = None;
// A Spawn/Custom completion re-enters `0x5f3cb0` with `old == new`, and that
// dispatch drops the object's display-sound loop — which is what ends the
// GnomeMachine's Custom0 hum at the end of its clip rather than leaving it
// droning. A transition motion's completion takes a different slot-14 row and
// does not.
if completion_redispatches(t.id) {
dispatch.write(GoStateDispatch(entity));
}
}
continue;
}
@@ -668,6 +710,7 @@ fn retire_transient_anim(mut gos: Query<(&mut GoAnim, &AnimationPlayer)>) {
fn drive_go_anim(
mut gos: Query<
(
Entity,
&mut GoAnim,
&mut AnimationPlayer,
&mut AnimationTransitions,
@@ -675,16 +718,25 @@ fn drive_go_anim(
),
Changed<GoAnim>,
>,
mut dispatch: MessageWriter<GoStateDispatch>,
// The client's single `_rand` stream (wow-re `rf36-rand-stub.md`), as `creature_anim` keeps
// one: op4's variation roll draws from it on every GameObject arm below.
mut rng: Local<u32>,
) {
for (mut go, mut player, mut tr, anims) in &mut gos {
for (entity, mut go, mut player, mut tr, anims) in &mut gos {
// ── The §243 state arm ─────────────────────────────────────────────────────────────────
if let Some(state) = go.state {
if go.shown != Some(state) {
let prev = go.shown;
go.shown = Some(state);
// `0x5f3cb0` proper — announced before anything below can refuse the arm, because
// the reference releases the display-sound loop at the head of the dispatch and
// *then* picks a substate. A re-resolve driven by [`retire_transient_anim`]
// leaves `dispatched` alone and so announces nothing (see [`GoAnim::dispatched`]).
if go.dispatched != Some(state) {
go.dispatched = Some(state);
dispatch.write(GoStateDispatch(entity));
}
// 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.
@@ -971,6 +1023,7 @@ fn fire_go_anim_events(
pub(crate) fn plugin(app: &mut App) {
app.add_message::<GoLidOpen>()
.add_message::<GoCustomAnim>()
.add_message::<GoStateDispatch>()
.add_systems(
Update,
(
@@ -1132,6 +1185,10 @@ mod tests {
));
app.init_resource::<Time>();
app.init_resource::<NextStep>();
// The state dispatch's audio consumer lives in `sound::gameobject`; here it is recorded
// instead, so a test can assert *which* edges re-enter `0x5f3cb0`.
app.add_message::<GoStateDispatch>();
app.init_resource::<Dispatched>();
app.add_systems(bevy::app::First, step_clock);
app.add_systems(
Update,
@@ -1139,6 +1196,7 @@ mod tests {
(arm_despawn_anim, retire_transient_anim),
drive_go_anim,
release_despawn_pin,
record_dispatches,
)
.chain(),
);
@@ -1220,6 +1278,15 @@ mod tests {
(app, go)
}
/// Every [`GoStateDispatch`] the run has produced, in order — the audio consumer's input,
/// stood in for.
#[derive(Resource, Default)]
struct Dispatched(Vec<Entity>);
fn record_dispatches(mut msgs: MessageReader<GoStateDispatch>, mut out: ResMut<Dispatched>) {
out.0.extend(msgs.read().map(|d| d.0));
}
/// 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);
@@ -1425,6 +1492,74 @@ mod tests {
assert!(matches!(armed(&app, go), Some((0x93, _))));
}
/// **The state dispatch is an EDGE, and a rest pose re-arming itself is not one** (wow-re
/// `go-display-sound-events.md` §6c: slot 14 sends a resting substate to `0x5f4167`, which
/// calls `0x5f3930` directly and never re-enters `0x5f3cb0`). That is the whole reason a
/// brazier's `TorchLoop` — a looping display-slot kit registered by a `$GO2` on the Opened
/// rest pose — hums continuously instead of being released and restarted every band pass,
/// because the dispatch's first act is `0x5f3cc8 call 0x5f40c0`.
///
/// What *does* dispatch: a genuine `SetGoState`, and a Custom substate's completion (slot
/// 14's `0x5f4190` row, `old == new`) — which is what ends a Gnome machine's Custom0 hum.
#[test]
fn only_a_state_change_and_a_custom_completion_re_enter_the_dispatch() {
let (mut app, go) = crate_app(&[(153, 0.667, 0)]); // Custom0 — the crate authors one
app.update();
let seen = |app: &App| app.world().resource::<Dispatched>().0.len();
assert_eq!(seen(&app), 1, "the spawn's own SetGoState is a dispatch");
// Fifty rest windows. The pose re-arms every one of them (the Onyxia law) and not one of
// those re-arms may announce a dispatch.
for _ in 0..50 {
advance(&mut app, 200);
}
assert!(matches!(armed(&app, go), Some((0x93, _))), "still Closed");
assert_eq!(seen(&app), 1, "a rest pose's own re-arm is not a dispatch");
// A genuine SetGoState — the lid opens.
app.world_mut()
.entity_mut(go)
.get_mut::<GoAnim>()
.unwrap()
.state = Some(GO_STATE_ACTIVE);
app.update();
assert_eq!(seen(&app), 2);
// …and the Open motion settling onto Opened is slot 14's `0x5f413d` row, not the dispatch.
// (Two ticks: the first runs the clip past its window, the next is where the retire sees
// the finished flag — the shape every completion test in this file uses.)
advance(&mut app, 700);
app.update();
assert_eq!(
seen(&app),
2,
"a transition motion advancing to its rest pose does not re-enter the dispatch"
);
// A Custom0 block, whose completion does.
app.world_mut()
.entity_mut(go)
.get_mut::<GoAnim>()
.unwrap()
.one_shot = Some(153);
app.update();
assert_eq!(seen(&app), 2, "arming a Custom is not itself a dispatch");
advance(&mut app, 700);
app.update();
assert_eq!(
seen(&app),
3,
"its completion re-runs the machine through `0x5f3cb0`"
);
assert!(
app.world()
.resource::<Dispatched>()
.0
.iter()
.all(|&e| e == go),
"every dispatch names the object it ran on"
);
}
/// 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
@@ -1586,6 +1721,23 @@ mod tests {
assert_eq!(remap_missing(&owning(&[0]), 149), (151, false));
}
/// Slot 14's dispatch table (`0x5f41e4`): rows 0 and 8..11 land at `0x5f4190`, which re-runs
/// the state machine **through `0x5f3cb0`** and so drops the object's display-sound loop; the
/// motion rows (2/4/5/7) advance to their rest pose and the rest rows (1/3/6) re-arm
/// themselves, neither of which touches it. Read as `AnimationData.dbc` ids, the key this
/// file uses everywhere.
#[test]
fn only_spawn_and_the_custom_block_re_enter_the_state_dispatch() {
for id in [145, 153, 154, 155, 156] {
assert!(completion_redispatches(id), "substate id {id}");
}
// The four transition motions, the three rest poses — and Despawn (157), whose substate
// 12 is past slot 14's own `cmp 0xb` bound and dispatches nowhere at all.
for id in [146, 147, 148, 149, 150, 151, 152, 157] {
assert!(!completion_redispatches(id), "substate id {id}");
}
}
#[test]
fn ids_outside_the_door_group_get_no_remap() {
// `lea eax,[esi-0x92]; cmp eax,3; ja` — only 146..149 index the jump table.
+45 -14
View File
@@ -1,9 +1,9 @@
//! Route M2 animation event tags (`crate::creature_anim::AnimSoundEvent`) to audio — the
//! anim-driven trigger surface (decision 0070 slice 3).
//!
//! Routed here: `$SND`/`$DSO` (one-shot kit `data` at the model), `$DSL`/`$DSE` (a placed
//! doodad's ambient loop — **registered into, and released from, the emitter pool**
//! [`super::doodad_pool`], which owns everything about how it sounds),
//! Routed here: `$SND`/`$DSO` (one-shot kit `data` at the model), `$DSL`/`$DSE` (an ambient loop
//! — **registered into, and released from, the emitter pool** [`super::emitter_pool`], which owns
//! everything about how it sounds; the two dispatchers' arms differ and both are modelled below),
//! `$CSD` — the **character emote clips' embedded voice** (HumanMale EmoteLaugh 70 carries
//! `$CSD 6923` = the SoundEntries kit literally named `HumanMaleEmoteLaugh`; Cry 77 → 6921,
//! Chicken 78 → 6919, Applaud 80 → 4× `ClapSounds` 6576 — probe-verified on the real 5875 M2 +
@@ -39,7 +39,7 @@ use crate::net::ObjectStore;
use benilla_assets::WorldAssets;
use benilla_world::schedule::WorldStage;
use super::doodad_pool::DoodadEmitterPool;
use super::emitter_pool::AmbientEmitterPool;
use super::emote::EmoteSounds;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
@@ -51,6 +51,10 @@ pub(super) fn route_anim_events(
// whose local Transform is not a world position (0441 fold-back).
transforms: Query<&GlobalTransform, Without<Camera3d>>,
units: Query<(Option<&ObjectStore>, Option<&CastHold>)>,
// Which dispatcher this entity's events arrive on. A family-A GameObject carries
// [`crate::go_anim::GoAnim`] — the same population the reference registers `0x5f3e20` for —
// and that dispatcher's DS-family arms differ from the placed-M2 handler `0x6951e0`'s.
go_lane: Query<(), With<crate::go_anim::GoAnim>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
emotes: Option<Res<EmoteSounds>>,
@@ -68,7 +72,7 @@ pub(super) fn route_anim_events(
// real bug and silence would hide it.
mut complained: Local<std::collections::HashSet<u32>>,
// The ambient emitter pool — `$DSL`/`$DSE`'s whole destination.
mut pool: ResMut<DoodadEmitterPool>,
mut pool: ResMut<AmbientEmitterPool>,
) {
if events.is_empty() {
return;
@@ -127,16 +131,34 @@ pub(super) fn route_anim_events(
// registration (`0x462000`) — which is why there is no wrap retrigger, and why
// `NightElfStreetLampLoop` (4.000 s of sample on a 3.333 s sequence) is not chopped
// every cycle. Whether anything is audible, from where, and how many at once are the
// pool pump's questions, not this scanner's: see [`super::doodad_pool`].
// pool pump's questions, not this scanner's: see [`super::emitter_pool`].
b"$DSL" if ev.data != 0 => {
if let Ok(t) = transforms.get(ev.entity) {
super::doodad_pool::register(
&mut pool,
ev.entity,
ev.data,
t.translation(),
listener,
);
// **The two lanes' `$DSL` arms differ, and only here** (wow-re
// `doodad-sound-emitters.md` §13). The placed-M2 handler `0x6951e0` compares
// the id and swaps; the GameObject dispatcher's arm `0x5f3fe5` does **not
// compare it at all** — a live handle is only repositioned, whatever id the
// marker names. Onyxia's lava trap is the case that makes it observable:
// `ONYZIASLAIRLAVATRAP.M2` (208 spawns in the lair) authors `$DSL(8681)` on
// its chained Stand variation and `$DSL(8682)` on Custom0, so on the
// GameObject lane the Custom0 hum never displaces the Stand one.
if go_lane.contains(ev.entity) {
super::emitter_pool::register_keeping_first(
&mut pool,
ev.entity,
ev.data,
t.translation(),
listener,
);
} else {
super::emitter_pool::register(
&mut pool,
ev.entity,
ev.data,
t.translation(),
listener,
);
}
}
}
// `$DSE` — the doodad sound **STOP** token (`0x45534424`), VERIFIED in the same note:
@@ -146,7 +168,16 @@ pub(super) fn route_anim_events(
// the Undercity and Thunder Bluff lifts, the zeppelin), where the loop is authored to
// run for one leg of the animation and stop. Releasing a record is not stopping a
// sound: the id keeps sounding while any *other* doodad still names it.
b"$DSE" => super::doodad_pool::release(&mut pool, ev.entity),
// …and `$DSE` has **no arm on the GameObject dispatcher at all** — the token falls to
// `0x5f4004 ret`. Seven shipped GO display models author one anyway (the Maraudon
// corrupted plants on their Destroy clip, the Blackrock door mechanism on Closed, the
// Maraudon teleporter on Open); on that lane what actually drops the registration is
// the state-machine dispatch those very clips are armed BY
// ([`crate::go_anim::GoStateDispatch`]), so honouring the authored intent and
// honouring the bytes agree — but only one of them is the mechanism.
b"$DSE" if !go_lane.contains(ev.entity) => {
super::emitter_pool::release(&mut pool, ev.entity);
}
b"$SND" | b"$DSO" | b"$CSD" if ev.data != 0 => {
ring(&mut kits, &mut out, ev.data, ev.entity, &mut complained);
}
+259
View File
@@ -0,0 +1,259 @@
//! **The death thud** — the body-fall impact as a corpse lands, fired on the `$DTH` animation
//! event (`0x6236e0`). The *other* half of the same event: its camera shake is
//! [`crate::camera_shake`]'s (`0x625c30`), and the two are siblings, not one system — the shake
//! is authored per model in `CreatureModelData` and only 49 of the 430 shipped models carry one,
//! while the **sound is universal**: **329 of the 407 creature-model files the archives hold** key
//! a `$DTH` (`benilla-extract thudcensus`; 430 rows collapse to 410 distinct paths, 407 of which
//! ship), and only the *sample* scales with the body. **Players included** — every character model keys one,
//! and `[unit+0xb34]` is filled from `UNIT_FIELD_DISPLAYID` for a player exactly as for a creature
//! (wow-re `base-render-alpha.md` §3), so your own body lands audibly too.
//!
//! ```text
//! $DTH → sizeClass = CreatureDisplayInfo.SizeClass ?? CreatureModelData.SizeClass
//! terrain = the surface under the unit → TerrainType → TerrainType.SoundID
//! DeathThudLookups[sizeClass][terrainSound] → SoundEntries (land | water)
//! ```
//!
//! The terrain leg is **exactly the footstep sound's** ([`super::footsteps`]) — the reference
//! keeps one cached terrain-type dword per unit (`CGUnit+0xc60`) and the `$DTH` handler reads that
//! same dword at `0x623749`, as the `$FSD` handler does at `0x62341d`. So a giant that dies on the
//! Kharanos inn's floorboards thuds on wood for the same reason its steps creaked on it, and
//! `None` (the client's `−1`) is silence here too, never "ask the other leg".
//!
//! **Three gates, and no others.** The handler's early-returns in order (`0x6236e0`):
//!
//! 1. **In liquid, and more than `2.0` yd of it over the corpse's feet** (`0x62372a`, the float
//! at `0x801628` — the same dword the armor foley lifts its emitter by) ⇒ **completely
//! silent**. Not "play the land one": a body that sinks makes no sound at all.
//! 2. **Size class outside `0..=4`** (`0x623744`, an *unsigned* `>= 5`, so a `−1` from both DBC
//! rows lands here) ⇒ silent. No shipped display reaches it.
//! 3. **No terrain, or no lookup row** ⇒ silent, and a resolved kit of `0` is silence too.
//!
//! And that is the whole list. Unlike the footstep it does **not** gate on hover, stealth,
//! player-ghost, a CVar or distance — the same "no gates at all" the sibling camera shake has.
//! Nor does it walk to a root unit: a `$DTH` is a corpse's own collapse, and nothing rides a
//! dying mount.
//!
//! **One deliberate deviation, on gate 1's *other* half.** The reference picks the water column
//! on "the unit's liquid node reports liquid" alone (`[node+0x90] & 0x20`, `0x670630`) — a flag
//! with no depth test of its own, so a body lying on a beach beside a lake would splash. benilla
//! asks the question the footstep splash already asks: is the surface **above the feet**
//! (`depth > 0`). Same answer wherever the reference is right, and the shore case is the one
//! place they differ. (`0x670630`'s own semantics are out at the RE; if the node flag turns out
//! to already mean "submerged", the two collapse into one.)
//!
//! The play is `0x458870` — **bus 0, uncapped, volume 1.0**, 3D at the unit's own position
//! origin (its feet, `vtbl+0x14`), with no Z lift. That is the same call the armor foley makes,
//! and deliberately *not* the capped [`Bus::FOOTSTEP`] the step itself takes: a battlefield's
//! worth of bodies all land.
use bevy::prelude::*;
use benilla_assets::coords::bevy_to_wow;
use benilla_formats::{DeathThudCatalog, FootstepCatalog};
use crate::creature_anim::AnimSoundEvent;
use crate::entities::Creatures;
use crate::net::NetEntity;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use super::footsteps::Footsteps;
use super::kit::{play_kit_ext, KitRef, PlayExtras, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
/// **The depth that silences the thud** — `0x62372a fcomp [0x801628]`, the float `2.0`. Read as
/// `surfaceZ − feetZ`, the same depth the swim decision measures (wow-re
/// `collision/scratch/swim-transition.md`), so this is "two yards of water over the body's feet",
/// not two yards of body under the surface.
const DROWNED_DEPTH: f32 = 2.0;
/// **The whole decision**, given what the world has already answered: the drowned gate, the
/// `TerrainType.SoundID` hop, and the join. `depth` is `surfaceZ − feetZ` and `None` when the
/// corpse is not in liquid — the same shape [`super::footsteps`] wades on.
///
/// Pure, so the three gates can be exercised against the real tables without a running client.
fn pick_kit(
thuds: &DeathThudCatalog,
steps: &FootstepCatalog,
size_class: u32,
terrain: u32,
depth: Option<f32>,
) -> Option<u32> {
if depth.is_some_and(|d| d > DROWNED_DEPTH) {
return None; // gate 1 — the body sank
}
let terrain_sound = steps.sound_class_of(terrain)?; // gate 3a — no such TerrainType row
thuds.kit(size_class, terrain_sound, depth.is_some()) // gate 3b — no row, or a kit of 0
}
/// `DeathThudLookups.dbc` + the `TerrainTypeSounds.dbc` domain, loaded once.
#[derive(Resource)]
pub(crate) struct DeathThuds(pub(crate) DeathThudCatalog);
fn load_death_thuds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_death_thud_catalog(&mut chain)
};
match loaded {
Ok(cat) => {
info!("sound: {} death-thud lookup rows", cat.len());
commands.insert_resource(DeathThuds(cat));
}
Err(e) => warn!("sound: death thud catalog failed to load: {e:#}"),
}
}
#[allow(clippy::too_many_arguments)] // the standard sound-route param set + the two catalogs
fn death_thud_sounds(
mut events: MessageReader<AnimSoundEvent>,
// GlobalTransform for the same reason every other anim-event consumer takes one: the tag can
// arrive on a parented child whose local Transform is not a world position (0441).
units: Query<(&NetEntity, &GlobalTransform)>,
thuds: Option<Res<DeathThuds>>,
// The terrain leg's own catalog — `world.terrain_type` resolves the ground-effect hop through
// it, and `sound_class_of` is the `TerrainType.SoundID` step the reference does inline.
footsteps: Option<Res<Footsteps>>,
creatures: Option<Res<Creatures>>,
world: benilla_world::world_point::WorldPoint,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
mut out: NonSendMut<SoundOutput>,
config: Res<SoundConfig>,
listener: Res<AudioListener>,
) {
if events.is_empty() {
return;
}
let (Some(thuds), Some(footsteps), Some(creatures), Some(mut kits), Some(assets)) =
(thuds, footsteps, creatures, kits, assets)
else {
return;
};
let listener = listener.pos;
for ev in events.read() {
if &ev.ident != b"$DTH" {
continue;
}
let Ok((net, transform)) = units.get(ev.entity) else {
continue;
};
// Gate 2 — the size class. The display's column overrides the model's; both `−1` (or a
// missing row) is silence, as is anything past Colossal.
let Some(size_class) = net.display_id.and_then(|d| creatures.size_class(d)) else {
continue;
};
// The liquid over the body (gate 1's input). `water_surface_at` is the unit's own room
// claim (0696), so a corpse on an indoor floor under an ADT lake is not "in water".
let who = benilla_world::world_point::Subject::Unit(ev.entity);
let wow = bevy_to_wow(transform.translation());
let depth = world
.water_surface_at(who, wow)
.map(|s| s - wow[2])
.filter(|d| *d > 0.0);
// Gate 3's input — the terrain leg. `None` is the client's `−1`: silent, and never a
// reason to fall back to the ground beneath a floor.
let Some(terrain) = world.terrain_type(&footsteps.0, who, transform.translation()) else {
continue;
};
let Some(kit) = pick_kit(&thuds.0, &footsteps.0, size_class, terrain, depth) else {
continue;
};
// Which leg answered and what it said — the same triage line the footstep prints, because
// the wrong-surface family is invisible in the kit name alone once two legs can produce one.
debug!(
"death thud: {} terrain {terrain} size {size_class}{} kit {kit}",
world
.room_group(who)
.map_or_else(|| "adt".to_string(), |g| format!("wmo g{g}")),
if depth.is_some() { " in water" } else { "" },
);
if let Err(e) = play_kit_ext(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit),
Some(transform.translation()),
SoundCategory::Sfx,
PlayExtras::default(), // bus 0, uncapped, volume 1.0 — `0x458870`'s own
) {
warn!("death thud (kit {kit}): {e:#}");
}
}
}
/// Registration hook for [`super::SoundPlugin`].
pub(super) fn plugin(app: &mut App) {
app.add_systems(Startup, load_death_thuds.after(AssetSet::Open))
.add_systems(Update, death_thud_sounds.in_set(WorldStage::Present));
}
#[cfg(test)]
mod tests {
use super::*;
/// The three gates on the real shipped tables — the join a corpse actually walks.
///
/// Anchors, all read off `benilla-extract thudcensus`: `TerrainType 5` is Grass, whose
/// `SoundID` is 6; `TerrainType 4` is Wood; `TerrainType 1` is Metallic (`SoundID` 2), the
/// row whose water column is 0; `TerrainType 10` is the unauthored `"None"`, `SoundID` 0.
#[test]
fn the_three_gates_on_real_tables() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let thuds = benilla_formats::load_death_thud_catalog(&mut chain).expect("thud catalog");
let steps = benilla_formats::load_footstep_catalog(&mut chain).expect("footstep catalog");
let pick = |size, terrain, depth| pick_kit(&thuds, &steps, size, terrain, depth);
// Dry land: a Colossal body on grass → `DeathThudColossalGrass`; a Small one → the Small
// kit. The size axis is the whole reason the sound is not one sample.
assert_eq!(pick(4, 5, None), Some(928));
assert_eq!(pick(0, 5, None), Some(907 + 1));
// Wading — up to and including 2.0 yd over the feet — takes the water column.
assert_eq!(pick(4, 5, Some(0.1)), Some(1269), "a splash, not a thud");
assert_eq!(
pick(4, 5, Some(DROWNED_DEPTH)),
Some(1269),
"exactly 2.0 still sounds"
);
// Deeper, the body sank: silent outright, NOT the land kit.
assert_eq!(pick(4, 5, Some(2.01)), None);
assert_eq!(pick(4, 5, Some(20.0)), None);
// A terrain whose water column is 0 is silent in water and audible out of it — the same
// "0 is silence, never a fallback" the table half of the census prints as `—`.
assert_eq!(
pick(0, 1, None),
Some(910),
"Metallic borrows the Stone kit"
);
assert_eq!(pick(0, 1, Some(0.5)), None, "and nothing in the water");
// `TerrainType "None"` (a WMO floor with no material) resolves to sound class 0, which is
// not a `TerrainTypeSounds` row: silent, on any body.
assert_eq!(pick(4, 10, None), None);
// A terrain id off the table entirely, and a size class past Colossal.
assert_eq!(pick(4, 99, None), None);
assert_eq!(pick(5, 5, None), None);
}
/// Wood, indoors — the case the terrain leg exists for. A giant dying on the Kharanos inn's
/// floorboards must take `DeathThudGiantWood`, not the dirt under the building.
#[test]
fn a_body_on_floorboards_thuds_on_wood() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = benilla_formats::open_chain(&data).expect("open chain");
let thuds = benilla_formats::load_death_thud_catalog(&mut chain).expect("thud catalog");
let steps = benilla_formats::load_footstep_catalog(&mut chain).expect("footstep catalog");
assert_eq!(
pick_kit(&thuds, &steps, 3, 4, None),
Some(926),
"DeathThudGiantWood"
);
}
}
@@ -1,10 +1,20 @@
//! The **placed-doodad ambient emitter pool** — the reference's registration table at `0xb06dd8`
//! and its per-frame pump `0x461990` (wow-re `sound/scratch/doodad-sound-emitters.md`, §7/§8/§17).
//! The **ambient emitter pool** — the reference's registration table at `0xb06dd8` and its
//! per-frame pump `0x461990` (wow-re `sound/scratch/doodad-sound-emitters.md`, §7/§8/§17).
//!
//! **Two tenants share it, because the reference's one pool has two registrars** (decision 1867).
//! A *placed doodad* registers through its `$DSL` marker (`0x6951e0`, handle in
//! `[CMapDoodadDef+0x168]`); a *GameObject* registers through **both** its own `$DSL` arm
//! (`0x5f3fe5`) and the display-slot sound player `0x5f4010`'s **loop lane** — the branch
//! `SoundEntries` flag `0x200` selects — with the handle in `[handler+0x18]`, one per object. All
//! of them call the same `0x461d80`/`0x461f80`/`0x462000` register/release/reposition family, so a
//! campfire GameObject's `CampFireLargeLoop` and a placed brazier's share an entry and a channel
//! exactly as two lamps do. The registrars differ only in how they treat a *second* id arriving on
//! a live handle — see [`register`] and [`register_keeping_first`].
//!
//! B345's fix gave every world-placed doodad a clock and routed its `$DSL` key to audio, which is
//! what made a lamp hum at all. It played that hum the obvious way — one channel per doodad, tagged
//! to the doodad — and that is **not the shape the reference has**. The client does not voice
//! doodads; it voices **sound ids**, and a doodad is only a *position* it can put one at:
//! doodads; it voices **sound ids**, and an emitter is only a *position* it can put one at:
//!
//! - `$DSL` registers the doodad's position as one **record** inside the pool entry that holds its
//! SoundEntries id (`0x461d80`, handle `((entry+1) << 16) | record` kept in
@@ -39,7 +49,7 @@
//! **Deliberate divergences**, both documented at their site: a failed kit is flagged in place
//! rather than by negating the entry's id, and a channel the frame pump culls past the kit's own
//! `DistanceCutoff` is stopped rather than left running at zero gain. Neither changes what is
//! audible; see the notes on [`Entry::failed`] and [`pump_doodad_emitters`].
//! audible; see the notes on [`Entry::failed`] and [`pump_emitters`].
use std::collections::HashSet;
@@ -76,9 +86,10 @@ const FADE_SECS: f32 = 3.0;
struct PoolEmitter;
/// One registered emitter: the reference's `[entry + 12·rec]` position, with presence in the list
/// standing in for its `[+0xC00+rec]` active byte.
/// standing in for its `[+0xC00+rec]` active byte. `owner` is the entity holding the registration
/// — a placed doodad's anim host, or a GameObject.
struct Record {
doodad: Entity,
owner: Entity,
pos: Vec3,
}
@@ -134,10 +145,13 @@ struct Fading {
/// The pool itself.
#[derive(Resource)]
pub(super) struct DoodadEmitterPool {
pub(super) struct AmbientEmitterPool {
entries: [Entry; POOL_ENTRIES],
/// Which entry each doodad is registered in — the reference's per-doodad handle
/// `[CMapDoodadDef+0x168]`, minus the record half (a record *is* its doodad here).
/// Which entry each owner is registered in — the reference's per-owner handle
/// (`[CMapDoodadDef+0x168]` for a placed doodad, `[handler+0x18]` for a GameObject), minus the
/// record half (a record *is* its owner here). One handle per owner is the reason a
/// GameObject's `$DSL` and its looping display slot contend for the same registration, exactly
/// as they do in the reference.
handles: EntityHashMap<usize>,
fading: Vec<Fading>,
/// Kit ids already warned about. A `$DSL` re-fires every animation cycle, so an unresolvable
@@ -151,7 +165,7 @@ pub(super) struct DoodadEmitterPool {
last_census: Vec<(u32, bool)>,
}
impl Default for DoodadEmitterPool {
impl Default for AmbientEmitterPool {
fn default() -> Self {
Self {
entries: std::array::from_fn(|_| Entry::default()),
@@ -163,9 +177,11 @@ impl Default for DoodadEmitterPool {
}
}
impl DoodadEmitterPool {
/// `$DSL`'s registration step — `0x69521d`'s compare followed by `0x462000` (reposition) or
/// `0x461f80`+`0x461d80` (re-register).
impl AmbientEmitterPool {
/// The **compare-and-swap** registrar — the placed-doodad `$DSL` arm (`0x69521d`'s compare
/// followed by `0x462000` reposition or `0x461f80`+`0x461d80` re-register), and, observably,
/// the display-slot loop lane's arm too (`0x5f4083`: same id returns without even
/// repositioning, which for a fixed emitter is the same thing).
///
/// Crossing the marker again with the **same** id only moves the record; that is the whole
/// reason there is no wrap retrigger, and why `NightElfStreetLampLoop` (4.000 s of sample on a
@@ -173,19 +189,16 @@ impl DoodadEmitterPool {
/// registration and takes a new one, which is what makes `bellows.m2`'s `$DSL` pair —
/// `BellowOut` at t=0.000, `BellowIN` at t=1.100 on one looping 2 s sequence — *alternate*
/// through one slot instead of droning together.
fn register(&mut self, doodad: Entity, id: u32, pos: Vec3, listener: Vec3) {
if let Some(&e) = self.handles.get(&doodad) {
///
/// The GameObject `$DSL` arm is the one that does **not** compare — see
/// [`register_keeping_first`].
fn register(&mut self, owner: Entity, id: u32, pos: Vec3, listener: Vec3) {
if let Some(&e) = self.handles.get(&owner) {
if self.entries[e].id == id {
if let Some(r) = self.entries[e]
.records
.iter_mut()
.find(|r| r.doodad == doodad)
{
r.pos = pos;
}
self.reposition(e, owner, pos);
return;
}
self.release(doodad);
self.release(owner);
}
// `0x461e60`: the entry already holding this id, else the first free one.
let Some(e) = self
@@ -213,26 +226,60 @@ impl DoodadEmitterPool {
return;
};
let gone = self.entries[e].records.remove(victim);
self.handles.remove(&gone.doodad);
self.handles.remove(&gone.owner);
}
self.entries[e].records.push(Record { doodad, pos });
self.handles.insert(doodad, e);
self.entries[e].records.push(Record { owner, pos });
self.handles.insert(owner, e);
}
/// `0x461f80` → `0x461d20` — release `doodad`'s record. When it was the entry's **last**, the
/// The **GameObject** lane's `$DSL` arm (`0x5f3fe5`): register when the object holds no
/// registration at all, else only *reposition* the one it holds. **It never compares the id,
/// so it never swaps** — the one place the two registrars actually differ (wow-re
/// `doodad-sound-emitters.md` §13, which corrects the GO note's own table).
///
/// It is not a curiosity: Onyxia's lava trap (`ONYZIASLAIRLAVATRAP.M2`, 208 spawns in the
/// lair) and Stratholme's spore trap author **two** ids — `$DSL(8681)` on the chained Stand
/// variation and `$DSL(8682)` on Custom0 — so on the compare-and-swap arm the Custom0 hum
/// would displace the Stand one, and on this arm it never plays at all. What clears the handle
/// on this lane is not a second marker but the state-machine dispatch
/// ([`super::gameobject`]'s release) or the object's despawn; there is no `$DSE` arm on the
/// GameObject dispatcher at all.
fn register_keeping_first(&mut self, owner: Entity, id: u32, pos: Vec3, listener: Vec3) {
if let Some(&e) = self.handles.get(&owner) {
self.reposition(e, owner, pos);
return;
}
self.register(owner, id, pos, listener);
}
/// `0x462000` — move `owner`'s record inside entry `e`, leaving the entry's id and channel
/// alone. The reference writes the position only when it actually changed; ours writes it
/// unconditionally, which is the same value.
fn reposition(&mut self, e: usize, owner: Entity, pos: Vec3) {
if let Some(r) = self.entries[e]
.records
.iter_mut()
.find(|r| r.owner == owner)
{
r.pos = pos;
}
}
/// `0x461f80` → `0x461d20` — release `owner`'s record. When it was the entry's **last**, the
/// id is cleared and the channel starts its 3.0 s fade as an orphan.
///
/// Reached from `$DSE` (the authored stop token — the elevator and machinery family, where the
/// loop runs for one leg of the animation) and from the host's despawn (the doodad's own
/// teardown, §9). There is deliberately **no** map-change reset: the reference has none either
/// (`0x461a20`'s only caller is the process-shutdown chain), and a streamed-out tile releases
/// its doodads one by one, which is the same thing done honestly.
fn release(&mut self, doodad: Entity) {
let Some(e) = self.handles.remove(&doodad) else {
/// Reached from a placed doodad's `$DSE` (the authored stop token — the elevator and machinery
/// family, where the loop runs for one leg of the animation), from a GameObject's state-machine
/// dispatch (`0x5f3cc8 call 0x5f40c0`, [`super::gameobject`]) and from the host's despawn (the
/// owner's own teardown, §9). There is deliberately **no** map-change reset: the reference has
/// none either (`0x461a20`'s only caller is the process-shutdown chain), and a streamed-out
/// tile releases its doodads one by one, which is the same thing done honestly.
fn release(&mut self, owner: Entity) {
let Some(e) = self.handles.remove(&owner) else {
return;
};
let entry = &mut self.entries[e];
entry.records.retain(|r| r.doodad != doodad);
entry.records.retain(|r| r.owner != owner);
if !entry.records.is_empty() {
return;
}
@@ -290,8 +337,8 @@ fn admitted(entries: &[Entry]) -> Vec<usize> {
/// The pump (`0x461990`): fade the orphans, mark unresolvable kits, then service the admitted
/// entries — start a channel where there is none, and otherwise only *move* the one there is.
#[allow(clippy::too_many_arguments)] // the standard sound-driver param set
fn pump_doodad_emitters(
mut pool: ResMut<DoodadEmitterPool>,
fn pump_emitters(
mut pool: ResMut<AmbientEmitterPool>,
mut emitters: Query<&mut Transform, With<PoolEmitter>>,
time: Res<Time>,
kits: Option<ResMut<SoundKits>>,
@@ -459,7 +506,7 @@ fn pump_doodad_emitters(
})
.collect();
debug!(
"doodad pool: admitted [{}] — {} sounding, {entitled} entitled entr{}, {} withheld by \
"emitter pool: admitted [{}] — {} sounding, {entitled} entitled entr{}, {} withheld by \
the cap, {} fading",
named.join(", "),
census.iter().filter(|(_, live)| *live).count(),
@@ -471,49 +518,68 @@ fn pump_doodad_emitters(
}
}
/// Register `doodad`'s emitter for kit `id` at `pos` — `$DSL`'s handler, called from
/// [`super::anim_events`].
/// Register `owner`'s emitter for kit `id` at `pos` — the **compare-and-swap** arm: a placed
/// doodad's `$DSL` (`0x6951e0`) and a GameObject's looping display slot (`0x5f4010`), both called
/// from their own routers.
pub(super) fn register(
pool: &mut DoodadEmitterPool,
doodad: Entity,
pool: &mut AmbientEmitterPool,
owner: Entity,
id: u32,
pos: Vec3,
listener: Vec3,
) {
pool.register(doodad, id, pos, listener);
pool.register(owner, id, pos, listener);
}
/// Release `doodad`'s emitter — `$DSE`'s handler.
pub(super) fn release(pool: &mut DoodadEmitterPool, doodad: Entity) {
pool.release(doodad);
/// Register `owner`'s emitter, **keeping whatever id it already holds** — a GameObject's `$DSL`
/// arm (`0x5f3fe5`), which never compares the id. See
/// [`AmbientEmitterPool::register_keeping_first`].
pub(super) fn register_keeping_first(
pool: &mut AmbientEmitterPool,
owner: Entity,
id: u32,
pos: Vec3,
listener: Vec3,
) {
pool.register_keeping_first(owner, id, pos, listener);
}
/// Release the emitters of doodads whose host has gone (`0x7133a0`'s teardown leg, §9).
/// Release `owner`'s emitter — a placed doodad's `$DSE`, and a GameObject's state-machine
/// dispatch (`0x5f40c0`).
pub(super) fn release(pool: &mut AmbientEmitterPool, owner: Entity) {
pool.release(owner);
}
/// Release the emitters of owners that have gone — a placed doodad's host (`0x7133a0`'s teardown
/// leg, §9) or a GameObject (whose registration lives on the type handler that goes with it).
///
/// This replaces B345's channel-scoped reaper. A doodad no longer *owns* a channel, so stopping
/// This replaces B345's channel-scoped reaper. An owner no longer *owns* a channel, so stopping
/// "its" channel is the wrong verb: what a despawn retires is one **record**, and the sound only
/// stops when the last emitter of that id in the world is gone. Streaming out one of thirty lamps
/// must not silence the other twenty-nine.
fn release_doodad_emitters_on_despawn(
mut gone: RemovedComponents<benilla_world::doodad_anim::DoodadAnimHost>,
mut pool: ResMut<DoodadEmitterPool>,
fn release_emitters_on_despawn(
mut doodads: RemovedComponents<benilla_world::doodad_anim::DoodadAnimHost>,
mut gos: RemovedComponents<crate::go_anim::GoAnim>,
mut pool: ResMut<AmbientEmitterPool>,
) {
for entity in gone.read() {
for entity in doodads.read().chain(gos.read()) {
pool.release(entity);
}
}
pub(super) fn plugin(app: &mut App) {
app.init_resource::<DoodadEmitterPool>().add_systems(
app.init_resource::<AmbientEmitterPool>().add_systems(
Update,
(
// Registration (`$DSL`/`$DSE` in `anim_events`) and the despawn release both write the
// pool; the pump reads it and starts channels; the frame pump then applies this
// Registration (`$DSL`/`$DSE` in `anim_events`, the display-slot loop lane in
// `gameobject`) and the despawn release both write the pool; the pump reads it and
// starts channels; the frame pump then applies this
// frame's positions and gains. Ordered explicitly rather than left to the scheduler,
// because an arbitrary order costs a frame of latency on a stop.
release_doodad_emitters_on_despawn.before(pump_doodad_emitters),
pump_doodad_emitters
release_emitters_on_despawn.before(pump_emitters),
pump_emitters
.after(super::anim_events::route_anim_events)
.after(super::gameobject::go_display_sounds)
.before(kit::pump_channels),
)
.in_set(WorldStage::Present),
@@ -532,13 +598,13 @@ mod tests {
.collect()
}
fn pool_with(entries: &[(u32, usize)]) -> DoodadEmitterPool {
let mut pool = DoodadEmitterPool::default();
fn pool_with(entries: &[(u32, usize)]) -> AmbientEmitterPool {
let mut pool = AmbientEmitterPool::default();
let ds = doodads(entries.len() as u32 * 2);
for (i, (id, records)) in entries.iter().enumerate() {
for r in 0..*records {
pool.entries[i].records.push(Record {
doodad: ds[r % ds.len()],
owner: ds[r % ds.len()],
pos: Vec3::ZERO,
});
}
@@ -551,7 +617,7 @@ mod tests {
/// pump run one channel for the lot.
#[test]
fn every_doodad_naming_one_kit_shares_a_single_entry() {
let mut pool = DoodadEmitterPool::default();
let mut pool = AmbientEmitterPool::default();
let ds = doodads(30);
for (i, d) in ds.iter().enumerate() {
pool.register(*d, 3378, Vec3::new(i as f32, 0.0, 0.0), Vec3::ZERO);
@@ -572,7 +638,7 @@ mod tests {
/// `0x461cf6` discards a candidate that is not *strictly* closer.
#[test]
fn a_tie_for_nearest_keeps_the_first_record() {
let mut pool = DoodadEmitterPool::default();
let mut pool = AmbientEmitterPool::default();
let ds = doodads(2);
pool.register(ds[0], 7, Vec3::new(0.0, 0.0, 5.0), Vec3::ZERO);
pool.register(ds[1], 7, Vec3::new(0.0, 0.0, -5.0), Vec3::ZERO);
@@ -583,7 +649,7 @@ mod tests {
/// record and never releases. This is the no-wrap-retrigger property, in the data.
#[test]
fn re_firing_the_same_id_only_moves_the_record() {
let mut pool = DoodadEmitterPool::default();
let mut pool = AmbientEmitterPool::default();
let d = doodads(1)[0];
pool.register(d, 3378, Vec3::ZERO, Vec3::ZERO);
pool.register(d, 3378, Vec3::new(1.0, 2.0, 3.0), Vec3::ZERO);
@@ -591,11 +657,44 @@ mod tests {
assert_eq!(pool.entries[0].records[0].pos, Vec3::new(1.0, 2.0, 3.0));
}
/// **The GameObject lane never swaps** (`0x5f3fe5`, no id compare) — the one place the two
/// registrars differ. Onyxia's lava trap authors `$DSL(8681)` on its Stand variation and
/// `$DSL(8682)` on Custom0; on this arm the second never displaces the first, and what
/// eventually clears the handle is the state dispatch, not another marker.
#[test]
fn the_gameobject_arm_keeps_the_id_it_first_registered() {
let mut pool = AmbientEmitterPool::default();
let go = doodads(1)[0];
pool.register_keeping_first(go, 8681, Vec3::ZERO, Vec3::ZERO);
pool.register_keeping_first(go, 8682, Vec3::new(1.0, 0.0, 0.0), Vec3::ZERO);
assert_eq!(pool.entries.iter().filter(|e| e.id != 0).count(), 1);
assert_eq!(pool.entries[0].id, 8681, "8682 must not displace it");
// …and the second marker still MOVED the record — `0x462000` runs on both legs.
assert_eq!(pool.entries[0].records[0].pos, Vec3::new(1.0, 0.0, 0.0));
// The release is what frees it, and then the next marker takes the entry it names.
pool.release(go);
pool.register_keeping_first(go, 8682, Vec3::ZERO, Vec3::ZERO);
assert_eq!(pool.entries[0].id, 8682);
}
/// The two lanes share one handle per owner, so a GameObject's looping display slot and its
/// own `$DSL` contend for it exactly as `[handler+0x18]` does — and the compare-and-swap arm
/// the display slot uses does displace.
#[test]
fn the_display_slot_lane_shares_the_owners_one_handle() {
let mut pool = AmbientEmitterPool::default();
let go = doodads(1)[0];
pool.register_keeping_first(go, 3880, Vec3::ZERO, Vec3::ZERO); // its `$DSL`
pool.register(go, 4694, Vec3::ZERO, Vec3::ZERO); // a looping display slot
assert_eq!(pool.entries.iter().filter(|e| e.id != 0).count(), 1);
assert_eq!(pool.entries[0].id, 4694);
}
/// `bellows.m2`: two `$DSL` keys on one looping sequence alternate through the doodad's single
/// handle, rather than droning together.
#[test]
fn a_different_id_moves_the_doodads_one_registration() {
let mut pool = DoodadEmitterPool::default();
let mut pool = AmbientEmitterPool::default();
let d = doodads(1)[0];
pool.register(d, 1000, Vec3::ZERO, Vec3::ZERO);
pool.register(d, 2000, Vec3::ZERO, Vec3::ZERO);
@@ -606,7 +705,7 @@ mod tests {
/// Releasing the last record frees the entry (`0x461d20`) so another id can claim it.
#[test]
fn the_last_release_frees_the_entry() {
let mut pool = DoodadEmitterPool::default();
let mut pool = AmbientEmitterPool::default();
let ds = doodads(2);
pool.register(ds[0], 42, Vec3::ZERO, Vec3::ZERO);
pool.register(ds[1], 42, Vec3::ZERO, Vec3::ZERO);
@@ -621,7 +720,7 @@ mod tests {
/// an incumbent. `$DSL` re-fires every cycle, so it takes an entry the moment one frees.
#[test]
fn a_thirty_third_distinct_id_is_untracked_until_an_entry_frees() {
let mut pool = DoodadEmitterPool::default();
let mut pool = AmbientEmitterPool::default();
let ds = doodads(POOL_ENTRIES as u32 + 1);
for (i, d) in ds.iter().enumerate() {
pool.register(*d, 100 + i as u32, Vec3::ZERO, Vec3::ZERO);
@@ -639,7 +738,7 @@ mod tests {
/// farthest: the FIRST record farther than the incoming one goes.
#[test]
fn the_two_hundred_and_fifty_seventh_record_evicts_the_first_farther_one() {
let mut pool = DoodadEmitterPool::default();
let mut pool = AmbientEmitterPool::default();
let ds = doodads(RECORDS_PER_ENTRY as u32 + 1);
// Records 0..255 at x = 255 down to 0: record 0 is the farthest, record 255 the nearest.
for (i, d) in ds.iter().take(RECORDS_PER_ENTRY).enumerate() {
@@ -667,7 +766,7 @@ mod tests {
/// …and one farther than every incumbent is REJECTED, not swapped in.
#[test]
fn a_record_farther_than_all_of_them_is_rejected() {
let mut pool = DoodadEmitterPool::default();
let mut pool = AmbientEmitterPool::default();
let ds = doodads(RECORDS_PER_ENTRY as u32 + 1);
for d in ds.iter().take(RECORDS_PER_ENTRY) {
pool.register(*d, 9, Vec3::new(1.0, 0.0, 0.0), Vec3::ZERO);
@@ -698,7 +797,7 @@ mod tests {
/// stays silent behind four claimed earlier and far away.
#[test]
fn distance_never_promotes_a_fifth_entry_over_an_earlier_one() {
let mut pool = DoodadEmitterPool::default();
let mut pool = AmbientEmitterPool::default();
let ds = doodads(5);
for (i, d) in ds.iter().enumerate().take(4) {
pool.register(*d, 100 + i as u32, Vec3::new(400.0, 0.0, 0.0), Vec3::ZERO);
+91 -93
View File
@@ -1,29 +1,52 @@
//! GameObject state sounds — doors/chests/buttons play their display's kit on state transitions
//! (decision 0070 slice 2). The data was always on the wire and in the DBC; this wires the two:
//! `GAMEOBJECT_STATE` transitions (0 active/open · 1 ready/closed · 2 active-alt, vmangos
//! `GOState`) → `GameObjectDisplayInfo.Sound[10]` slots — a `→0` transition plays **Open**
//! (slot 1), a `→1` transition plays **Close** (slot 3), positioned at the object. First sight
//! of an object records its state silently (a door streamed in already open must not bang).
//! GameObject **display-slot** sounds — `GameObjectDisplayInfo.Sound[0..9]`, the ten kit columns a
//! door/chest/goober plays as its animation runs.
//!
//! Loop/Destroy/Opened/Custom slots (2/4/5/6..9) wait for their triggers (destruction anims,
//! `$GO*` M2 events — slice 3); the survey's slot table is wowdev vanilla-family, flagged there.
//! **One function in the reference reads those columns, and one thing reaches it.** `0x5f4010` is
//! the binary's only reader of `Sound[slot]` (whole-image census of the store `0xc0dce4` and its
//! index `0xc0dcec`: six references, four gameplay readers, three of them the ModelName column),
//! and its only caller is the GameObject M2 **animation-event** dispatcher `0x5f3e20` — registered
//! per object at create (`0x5f7d1f` → vtable `+0x30`) on the **family-A** types only, which is
//! exactly benilla's [`crate::go_anim::GoAnim`] population. `$GO0..5` address slots 0..5
//! (Stand/Open/Loop/Close/Destroy/Opened), `$GC0..3` the four Custom slots 6..9. So a display slot
//! is audible only when the object's own model authors the matching event keyframe *and* the clip
//! carrying it is playing: **there is no state-transition sound path** (wow-re
//! `object-layer/scratch/go-display-sound-events.md`, §5-cross-checked; benilla decisions
//! 1090/1867).
//!
//! **The kit's `SoundEntries` flag `0x200` picks the lane** — `0x5f4051 call 0x458830`, whose only
//! other consumer image-wide is a spell-visual path (wow-re `sound/scratch/doodad-sound-emitters.md`
//! §11's flag table). Clear ⇒ a positioned **one-shot** (`0x458870`). Set ⇒ the **ambient emitter
//! pool** (`0x461d80`), the same 32-entry table placed doodads' `$DSL` registers into, with the
//! handle cached in `[handler+0x18]` — one per object, shared with that object's own `$DSL` arm.
//! The loop is dropped again by `0x5f40c0`, called from the state-machine dispatch `0x5f3cb0`
//! ([`crate::go_anim::GoStateDispatch`]) and by the object's teardown; nothing else stops it, and
//! there is no `$DSE` arm on this dispatcher at all.
//!
//! **What the corpus actually contains** (`benilla-extract goslotscan`, the instrument this module
//! is checked against): 213 display rows carry a kit; per slot, *filled → reached by an authored
//! tag* is Stand 24→18, Open 99→80, Loop 28→14, Close 42→38, Destroy 26→14, **Opened 0→0**,
//! Custom0 77→56, Custom1 10→3, Custom2 5→1, Custom3 1→0. Thirty-seven live pairs name a looping
//! kit — every campfire, brazier, torch, fountain, hologram and Stratholme portal — which is the
//! whole reason the loop lane is not optional. Slot 5 Opened is empty data in 1.12.1: no display
//! row fills it, so it costs nothing and can never be heard.
use bevy::ecs::entity::EntityHashMap;
use bevy::prelude::*;
use benilla_formats::{go_sound_slot, GameObjectSounds};
use benilla_formats::GameObjectSounds;
use benilla_protocol::EntityKind;
use crate::net::{NetEntity, ObjectStore};
use crate::go_anim::GoStateDispatch;
use crate::net::NetEntity;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use super::kit::{play_kit, KitRef, SoundCategory, SoundKits};
use super::emitter_pool::AmbientEmitterPool;
use super::kit::{kit_looping, play_kit, KitRef, SoundCategory, SoundKits};
use super::{AudioListener, SoundConfig, SoundOutput};
/// The display→sound-slots table (only displays with any non-zero slot; ~a third of the 1638).
#[derive(Resource)]
struct GoSounds(GameObjectSounds);
pub(super) struct GoSounds(GameObjectSounds);
fn load_go_sounds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
@@ -40,67 +63,6 @@ fn load_go_sounds(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
}
}
/// Watch streamed GameObjects' descriptor stores for `GAMEOBJECT_STATE` transitions and play the
/// matching slot kit at the object. `known` carries each entity's last-seen state; the map only
/// grows with distinct streamed GOs in a session and despawned entity ids are never re-observed
/// (Bevy generational ids), so stale entries are inert.
#[allow(clippy::too_many_arguments)]
fn go_state_sounds(
changed: Query<(Entity, &NetEntity, &ObjectStore, &Transform), Changed<ObjectStore>>,
mut known: Local<EntityHashMap<u32>>,
go_sounds: Option<Res<GoSounds>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
mut out: NonSendMut<SoundOutput>,
config: Res<SoundConfig>,
listener: Res<AudioListener>,
) {
let (Some(go_sounds), Some(mut kits), Some(assets)) = (go_sounds, kits, assets) else {
return;
};
let listener = listener.pos;
for (entity, net, store, transform) in &changed {
if net.kind != EntityKind::GameObject {
continue;
}
let Some(state) = store.0.gameobject_state() else {
continue;
};
let prev = known.insert(entity, state);
let Some(prev) = prev else {
continue; // first sight: record silently
};
if prev == state {
continue;
}
let slot = match state {
0 => go_sound_slot::OPEN,
1 => go_sound_slot::CLOSE,
_ => continue, // active-alt: no recorded slot mapping
};
let kit = net
.display_id
.and_then(|d| go_sounds.0.slots(d))
.map(|s| s[slot])
.unwrap_or(0);
if kit == 0 {
continue;
}
if let Err(e) = play_kit(
&mut kits,
&assets,
&mut out,
&config,
listener,
KitRef::Id(kit),
Some(transform.translation),
SoundCategory::Sfx,
) {
warn!("GO state sound (kit {kit}): {e:#}");
}
}
}
/// The GO display-slot an M2 animation-event tag addresses — the reference's GO event dispatcher
/// `0x5f3e20` (wow-re `go-display-sound-events.md`, byte-verified; the 1086 fold-back): `$GO0..5`
/// → `Sound[0..5]`, `$GC0..3` → the Custom slots `Sound[6..9]`. Every other tag is not this
@@ -114,26 +76,49 @@ fn go_event_slot(ident: &[u8; 4]) -> Option<usize> {
}
}
/// Play the display-slot kits a GameObject's animation events name — the audio half of the GO
/// M2 event kernel ([`crate::go_anim`]'s scanner feeds the stream; wow-re
/// `go-display-sound-events.md`: `0x5f4010` is the binary's ONLY reader of the display row's
/// sound columns, reached solely from the anim-event dispatcher). The load-bearing tenant is the
/// fishing bobber's bite: Custom0's `$GC0` at t≈3.87 s → display 668 `Sound6` = kit 3355
/// "Fishing Hooked" — fired **once per 0xB3** (the completion retire re-arms Stand before a
/// second pass, decision 1100), beside the server's explicit `SMSG_PLAY_OBJECT_SOUND(3355)` ~200
/// ms earlier — the client issues exactly two starts of the kit, then silence (no same-kit dedup
/// exists anywhere in the reference's play chain).
/// Play the display-slot kits a GameObject's animation events name, and drop its ambient loop when
/// the state machine dispatches — the two halves of `0x5f4010`'s lifecycle, in one system so the
/// release can never land *after* the register that a state change is about to produce.
///
/// That ordering is structural, not scheduled: the release rides
/// [`crate::go_anim::GoStateDispatch`], written the frame the machine arms a new clip, while the
/// new clip's own `$GOn` cannot fire before the frame *after* its arm ([`crate::creature_anim`]'s
/// scan rule — an arm frame fires nothing). Releases are drained first here, so whichever order
/// the scheduler picks for this system against [`crate::go_anim`], a release always precedes the
/// register it precedes in the reference.
///
/// The load-bearing tenants: the fishing bobber's bite — Custom0's `$GC0` at t≈3.87 s → display
/// 668 `Sound6` = kit 3355 "Fishing Hooked", fired **once per 0xB3** (the completion retire
/// re-arms Stand before a second pass, decision 1100), beside the server's explicit
/// `SMSG_PLAY_OBJECT_SOUND(3355)` ~200 ms earlier — and every lit prop in the world, whose
/// `CampFireSmallLoop`/`TorchLoop`/`ElvenFountainSmallA` take the pool lane and hum until the
/// object's state changes under them.
#[allow(clippy::too_many_arguments)]
fn go_event_sounds(
pub(super) fn go_display_sounds(
mut dispatched: MessageReader<GoStateDispatch>,
mut events: MessageReader<crate::creature_anim::AnimSoundEvent>,
gos: Query<(&NetEntity, &Transform)>,
// `GlobalTransform`: a GameObject is a root entity today, but the event's position is the
// model's placement in the reference either way, and reading the world pose cannot be wrong.
gos: Query<(&NetEntity, &GlobalTransform)>,
go_sounds: Option<Res<GoSounds>>,
kits: Option<ResMut<SoundKits>>,
assets: Option<Res<WorldAssets>>,
mut out: NonSendMut<SoundOutput>,
config: Res<SoundConfig>,
listener: Res<AudioListener>,
mut pool: ResMut<AmbientEmitterPool>,
// Kit ids already complained about. A `$GOn` on a looping rest clip re-fires every band pass
// — the centaur teleporter's Closed band is 0.334 s — so an unresolvable id would warn three
// times a second for the object's whole life. The same flood [`super::anim_events`] learned
// to avoid at 420 lines in one run past Darnassus.
mut complained: Local<std::collections::HashSet<u32>>,
) {
// `0x5f3cb0`'s first act is `0x5f3cc8 call 0x5f40c0` — release the object's display-sound loop
// — and it runs before the machine picks the new substate's animation. Draining it here even
// when nothing else is resolvable keeps the two halves from drifting apart.
for d in dispatched.read() {
super::emitter_pool::release(&mut pool, d.0);
}
if events.is_empty() {
return;
}
@@ -158,9 +143,21 @@ fn go_event_sounds(
.and_then(|d| go_sounds.0.slots(d))
.map(|s| s[slot])
.unwrap_or(0);
// `0x458830` fails a null id and `0x5f4010` returns — an unfilled column is silence, not a
// fallback. 97 shipped models author a `$GO0` against a zero Stand column (the bobber
// among them); that is the reference being quiet, not a miss.
if kit == 0 {
continue;
}
let pos = transform.translation();
// The lane select. A looping kit is NOT a looping channel here: it is a *registration* in
// the shared emitter pool, which is what makes one hum follow you down a row of braziers
// instead of thirty channels stacking — and what makes a re-crossing of the marker (the
// per-pass event re-fire of an armed looping clip) a no-op instead of a restart.
if kit_looping(&kits, kit) {
super::emitter_pool::register(&mut pool, ev.entity, kit, pos, listener);
continue;
}
if let Err(e) = play_kit(
&mut kits,
&assets,
@@ -168,10 +165,15 @@ fn go_event_sounds(
&config,
listener,
KitRef::Id(kit),
Some(transform.translation),
Some(pos),
SoundCategory::Sfx,
) {
warn!("GO event sound (slot {slot}, kit {kit}): {e:#}");
if complained.insert(kit) {
warn!(
"GO display sound (slot {slot}, kit {kit}): {e:#} (further reports for this \
kit suppressed)"
);
}
}
}
}
@@ -179,12 +181,8 @@ fn go_event_sounds(
/// Registration hook for [`super::SoundPlugin`].
pub(super) fn plugin(app: &mut App) {
app.add_systems(Startup, load_go_sounds.after(AssetSet::Open))
// After the Net drain wrote this frame's descriptor deltas (the state watcher), and the
// event consumer after the GO scanner's write in the same frame's Present set.
.add_systems(
Update,
(go_state_sounds, go_event_sounds).in_set(WorldStage::Present),
);
// In `Present`, with the GO scanner that feeds it and the state machine that dispatches.
.add_systems(Update, go_display_sounds.in_set(WorldStage::Present));
}
#[cfg(test)]
+1 -1
View File
@@ -500,7 +500,7 @@ pub(super) struct PlayExtras {
/// [`SAME_KIT_MAX`]. Those are the **one-shot lane's** gate (`0x458f40` lifting `SoundEntries`
/// bit 0x20 into the FMOD flags word, consumed by `0x7a66a0`), and a caller that already
/// guarantees one channel per kit by construction must not be held to it a second time. The
/// ambient emitter pool ([`super::doodad_pool`]) is that caller: it dedupes **structurally**,
/// ambient emitter pool ([`super::emitter_pool`]) is that caller: it dedupes **structurally**,
/// one entry per SoundEntries id (wow-re `doodad-sound-emitters.md` §15), and its opens go
/// through `0x7a5680` → `0x7a54d0`, which never reaches that gate at all.
///
+4 -2
View File
@@ -18,7 +18,8 @@ mod anim_events;
mod cinematic;
mod combat;
mod creature;
mod doodad_pool;
mod death_thud;
mod emitter_pool;
mod emote;
pub(crate) mod footsteps;
mod gameobject;
@@ -545,12 +546,13 @@ impl Plugin for SoundPlugin {
cinematic::plugin(app);
gameobject::plugin(app);
anim_events::plugin(app);
doodad_pool::plugin(app);
emitter_pool::plugin(app);
spell::plugin(app);
missile::plugin(app);
creature::plugin(app);
combat::plugin(app);
footsteps::plugin(app);
death_thud::plugin(app);
mount::plugin(app);
water::plugin(app);
weather::plugin(app);
+2 -2
View File
@@ -1174,7 +1174,7 @@ pub(crate) enum ServiceArm {
/// The reference re-reads the flags field for a redundant `bits 9 AND 10` block before the plain
/// bit-9 test; it routes to the same handler bit 9 alone reaches, so it is dead in effect and is
/// not transcribed (recording it would be a distinction with no outcome).
fn service_arm(npc_flags: u32, quest_status: Option<u32>) -> Option<ServiceArm> {
pub(crate) fn service_arm(npc_flags: u32, quest_status: Option<u32>) -> Option<ServiceArm> {
use cursor_mode::npc_flags as f;
let bit = |m: u32| npc_flags & m != 0;
Some(if bit(f::GOSSIP) {
@@ -1237,7 +1237,7 @@ pub(crate) enum ServiceAction {
/// vmangos still puts a usable menu on screen; sending the faithful opcode into a reply we drop
/// would trade a working affordance for a wire detail nobody can see. Each retires the moment its
/// window exists — that is the condition, written down (decision 1861).
fn service_action(arm: ServiceArm, guid: u64, ghost: bool) -> ServiceAction {
pub(crate) fn service_action(arm: ServiceArm, guid: u64, ghost: bool) -> ServiceAction {
match arm {
ServiceArm::Gossip => ServiceAction::Send(ClientCommand::GossipHello { guid }),
ServiceArm::Questgiver => ServiceAction::Send(ClientCommand::QuestgiverHello { npc: guid }),
+1 -1
View File
@@ -655,7 +655,7 @@ fn go_cursor_kind(type_id: i32, lock_cursor: Option<CursorKind>) -> CursorKind {
///
/// `None` (no status ever sent) reads as no quest: the server sends the status unprompted for every
/// questgiver in range, so its absence means the unit isn't offering us one.
pub(super) fn questgiver_has_quest(quest_status: Option<u32>) -> bool {
pub(crate) fn questgiver_has_quest(quest_status: Option<u32>) -> bool {
use benilla_protocol::messages::dialog_status::{NONE, UNAVAILABLE};
!matches!(quest_status, None | Some(NONE) | Some(UNAVAILABLE))
}
@@ -672,3 +672,37 @@ fn the_tab_highlight_is_exactly_its_tab() {
no_errors(&s, "tab highlight");
}
}
/// Clicking "Change Name/Icon" opens the icon picker.
///
/// **This harness cannot catch the defect that made it raise in-game**, and that is worth saying
/// out loud: a harness lists its own dependencies, so it had
/// `ClassTrainerFrameTemplates.xml` while the shipped manifest did not. The window worked here and
/// was broken in the client. What catches THAT is
/// `every_template_the_manifest_inherits_is_declared_by_the_manifest`, plus the end-to-end drive in
/// `shipped_xml_tests`. Decision 1862.
///
/// The raise it reproduced:
///
/// ```text
/// Blizzard_MacroUI.lua:233: attempt to perform arithmetic on local 'macroPopupOffset' (a nil value)
/// MacroPopupFrame_Update <- MacroPopupFrame_OnShow <- MacroEditButton_OnClick
/// ```
#[test]
fn the_icon_picker_opens_without_raising() {
let _data = benilla_formats::wow_data_or_skip!();
let s = harness();
select_first(&s);
let _ = s.errors();
s.run("MacroEditButton:Click()").unwrap();
assert!(
s.errors().is_empty(),
"opening the icon picker must not raise: {:?}",
s.errors()
);
assert!(
s.eval::<bool>("return MacroPopupFrame:IsShown() and true or false")
.unwrap(),
"the picker is up"
);
}
@@ -1498,6 +1498,16 @@ mod tests {
keyring tooltips through `ContainerFrameAdapters.xml`'s wrapper (0765), which is \
why nothing has hit it — the wrapper answers first for our own rows.",
),
(
"ClassTrainerFrameTemplates.xml",
"ClassTrainerSkillButton_OnClick",
"defined by `Blizzard_TrainerUI`, a LoadOnDemand addon we do not load yet. The \
file is on the manifest for ONE of its templates — the icon chooser's \
`ClassTrainerListScrollFrameTemplate` (1862) — and the skill-button template that \
carries this handler is never instantiated, so no click can reach it. The \
reference is in the same state until its trainer addon loads on demand. It \
resolves when that window migrates.",
),
(
"DurabilityFrame.xml",
"UpdateInventoryAlertStatus",
@@ -1813,4 +1823,218 @@ mod tests {
shadowed.join("\n ")
);
}
/// **A frame that inherits a template the manifest never loads is a WARNING, and warnings are
/// invisible.** The frame is still built — bare. It gets none of the template's regions, none
/// of its children, and none of its `<Scripts>`, so whatever that `<OnLoad>` was going to
/// initialise silently stays nil.
///
/// This shipped. `Blizzard_MacroUI.xml`'s `MacroPopupScrollFrame` inherits FrameXML's
/// `ClassTrainerListScrollFrameTemplate`; the manifest's own header for that window NAMES that
/// dependency and then never lists the file declaring it. So the icon picker's scroll frame
/// came up with no `<OnLoad>`, `ScrollFrame_OnLoad` never ran, `this.offset` was never seeded,
/// and the reference's `FauxScrollFrame_GetOffset` — `return frame.offset`, with no `or 0`
/// fallback of the kind our deleted copy had — handed `MacroPopupFrame_Update` a nil to
/// multiply. Clicking "Change Name/Icon" raised. Decision 1862.
///
/// Static rather than a load probe: it needs no VM, no client state and no player, so it
/// answers for every entry including the ones a running load would never reach.
#[test]
fn every_template_the_manifest_inherits_is_declared_by_the_manifest() {
let _data = benilla_formats::wow_data_or_skip!();
let toc = &super::super::addons::Addon::builtin().toc.files;
let mut declared: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut wanted: Vec<(String, String)> = Vec::new();
for entry in toc.iter() {
// **`<Include>` counts.** The reference's own `FrameXML.toc` lists only two of its
// `*Templates.xml` files; the rest are pulled in by the window that needs them
// (`HonorFrame.xml` -> `HonorFrameTemplates.xml`, and so on), and the loader follows
// that against the including document's own directory (1186). A walk that reads only
// the manifest's own lines reports thirteen templates missing that are not — which is
// exactly what the first run of this gate did.
let mut text = String::new();
for src in entry_sources(entry) {
text.push_str(&src);
text.push('\n');
}
if text.is_empty() {
continue;
}
for chunk in text.split('<').skip(1) {
let Some(end) = chunk.find('>') else { continue };
let tag = &chunk[..end];
if tag.contains("virtual=\"true\"") {
if let Some(n) = tag_attr(tag, "name") {
declared.insert(n);
}
}
if let Some(list) = tag_attr(tag, "inherits") {
for name in list.split(',').map(str::trim).filter(|s| !s.is_empty()) {
wanted.push((name.to_string(), entry.clone()));
}
}
}
}
let mut missing: Vec<String> = wanted
.into_iter()
.filter(|(n, _)| !declared.contains(n))
.map(|(n, e)| format!("{n} (inherited in {e})"))
.collect();
missing.sort();
missing.dedup();
assert!(
missing.is_empty(),
"the manifest inherits templates it never loads — the frames are built BARE, with no \
scripts, and nothing errors:\n {}",
missing.join("\n ")
);
}
/// One `name="…"`-style attribute out of a raw tag, matched only at a token boundary.
fn tag_attr(tag: &str, key: &str) -> Option<String> {
let pat = format!("{key}=\"");
let mut from = 0;
while let Some(i) = tag[from..].find(&pat) {
let at = from + i;
let boundary = at == 0
|| tag[..at]
.chars()
.next_back()
.is_some_and(|c| c.is_whitespace());
let rest = &tag[at + pat.len()..];
if boundary {
return rest.find('"').map(|j| rest[..j].to_string());
}
from = at + pat.len();
}
None
}
/// A manifest entry's source text and everything it `<Include>`s, transitively.
///
/// The chain for a path, `assets/ui` for a bare name — and an include resolves against the
/// INCLUDING document's own directory in its own source's path space, which is the rule the
/// loader follows (1186).
fn entry_sources(entry: &str) -> Vec<String> {
fn read_one(path: &str, chain: bool) -> Option<String> {
if chain {
let bytes = super::read(&path.replace('\\', "/"))?;
return Some(String::from_utf8_lossy(&bytes).into_owned());
}
std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(path),
)
.ok()
}
let chain = super::is_chain_entry(entry);
let dir = {
let p = entry.replace('\\', "/");
p.rsplit_once('/').map(|(d, _)| d.to_string())
};
let mut out = Vec::new();
let mut queue = vec![entry.to_string()];
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
while let Some(path) = queue.pop() {
if !seen.insert(path.clone()) {
continue;
}
let Some(text) = read_one(&path, chain) else {
continue;
};
for chunk in text.split('<').skip(1) {
let Some(end) = chunk.find('>') else { continue };
let tag = &chunk[..end];
if !tag.trim_start().starts_with("Include") {
continue;
}
if let Some(file) = tag_attr(tag, "file") {
let next = match &dir {
Some(d) => format!("{d}/{}", file.replace('\\', "/")),
None => file.replace('\\', "/"),
};
queue.push(next);
}
}
out.push(text);
}
out
}
/// **Every faux list must declare its own `<OnVerticalScroll>`, and no file may still write
/// `frame.updateFunc`.**
///
/// `FauxScrollFrame_OnVerticalScroll` is the ONLY thing that writes `frame.offset`, and it runs
/// from a handler the OWNER declares — the reference has no `updateFunc` field, which is what
/// our retired kit used. A window that inherits `FauxScrollFrameTemplate` without that handler
/// loads clean, shows its bar, moves its thumb, and never scrolls its list. Nothing errors.
///
/// A gate rather than a test per window, because what makes it silent is structural: a list
/// only scrolls when something drives it, and most window tests never do. Decision 1868.
#[test]
fn every_faux_scroll_frame_declares_its_own_on_vertical_scroll() {
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut unwired: Vec<String> = Vec::new();
let mut stale: Vec<String> = Vec::new();
for file in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
let path = file.path();
if path.extension().is_none_or(|e| e != "xml") {
continue;
}
let name = path.file_name().unwrap().to_string_lossy().into_owned();
let text = std::fs::read_to_string(&path).unwrap_or_default();
for line in text.lines() {
let code = line.trim_start();
if code.starts_with("--") || code.starts_with("<!--") {
continue;
}
if code.contains(".updateFunc") && code.contains('=') && !code.contains("==") {
stale.push(format!("{name}: {}", code.trim()));
}
}
// Each `<ScrollFrame … inherits="…FauxScrollFrameTemplate">` up to its close.
let mut from = 0;
while let Some(i) = text[from..].find("<ScrollFrame ") {
let start = from + i;
let Some(gt) = text[start..].find('>') else {
break;
};
let tag = &text[start..start + gt];
from = start + gt;
if !tag.contains("FauxScrollFrameTemplate") {
continue;
}
// A VIRTUAL template is not a list. `BenillaAuctionScrollTemplate` is the auction
// window's shared shape and legitimately carries no handler: its four instances
// each repaint a different pane, so each declares its own. What must be wired is
// the instance.
if tag.contains("virtual=\"true\"") {
continue;
}
let Some(end) = text[start..].find("</ScrollFrame>") else {
continue;
};
let body = &text[start..start + end];
if !body.contains("OnVerticalScroll") {
let who = tag_attr(tag, "name").unwrap_or_else(|| "?".into());
unwired.push(format!("{name}: {who}"));
}
}
}
assert!(
unwired.is_empty(),
"a faux list with no <OnVerticalScroll> — its bar moves and the list never follows:\n {}",
unwired.join("\n ")
);
assert!(
stale.is_empty(),
"`frame.updateFunc` is our retired kit's field; the reference has none:\n {}",
stale.join("\n ")
);
}
}
@@ -1442,3 +1442,60 @@ pub(super) fn shipped_frame_names() -> Vec<String> {
names.dedup();
names
}
/// **The icon picker opens, against the SHIPPED manifest.** Not a harness list — the manifest, the
/// way the client loads it.
///
/// The distinction is the whole point. `macro_tests` stands this window up from its own file list
/// and passed throughout, because a harness names the dependencies it needs; the manifest had not
/// listed `ClassTrainerFrameTemplates.xml`, so in the client `MacroPopupScrollFrame` inherited a
/// template nothing had loaded, came up bare, and `MacroPopupFrame_Update` multiplied a nil offset
/// (decision 1862). A window is only as loaded as the manifest says.
#[test]
fn the_shipped_manifest_opens_the_macro_icon_picker() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.set_unit(
"player",
Some(benilla_ui::script::UnitState {
exists: true,
name: Some("Probefive".into()),
level: 60,
..Default::default()
}),
);
// One macro, so the edit path has something selected — the director's own flow was a saved
// macro and the "Change Name/Icon" button beside it.
s.set_macros(benilla_ui::script::MacroState {
account: vec![benilla_ui::script::MacroView {
name: "die".into(),
texture: Some(r"Interface\Icons\Ability_Ambush".into()),
body: ".die".into(),
..Default::default()
}],
character: Vec::new(),
});
s.set_macro_icons(vec![
r"Interface\Icons\Ability_Ambush".into(),
r"Interface\Icons\Ability_Backstab".into(),
]);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.resolve();
let _ = s.errors();
s.run("ShowMacroFrame()").unwrap();
s.run("MacroButton1:Click()").unwrap();
s.run("MacroEditButton:Click()").unwrap();
assert!(
s.errors().is_empty(),
"opening the icon picker off the shipped manifest must not raise: {:?}",
s.errors()
);
assert!(
s.eval::<bool>("return MacroPopupFrame:IsShown() and true or false")
.unwrap(),
"the picker is up"
);
}
@@ -24,8 +24,8 @@
use anyhow::{Context, Result};
use benilla_formats::{
equip_blits, equip_tile, load_item_display_catalog, BlitSource, Chain, CharSections,
CharacterGeosets, EmblemLayer, EquipGeosets, GuildEmblem, ItemDisplay,
equip_blits, equip_tile, forearm_dressed, load_item_display_catalog, BlitSource, Chain,
CharSections, CharacterGeosets, EmblemLayer, EquipGeosets, GuildEmblem, ItemDisplay,
};
/// The ten atlas tiles by group, for the per-tile report — the five head/left-column ones included,
@@ -242,10 +242,10 @@ pub fn charatlas(chain: &mut Chain, look: &Look, out: Option<&std::path::Path>)
eq.bodyslots[i] = Some(d.geoset_groups);
}
}
let mut ids =
geosets.visible_geosets(look.race, look.sex, look.hair_style, look.facial_hair, &eq);
ids.sort_unstable();
ids.dedup();
// B3's gate is the ArmLower tile's occupancy — read off the same plan printed above (1864).
eq.forearm_dressed = forearm_dressed(&equipment);
// Sorted + deduplicated at the source (`visible_geosets`).
let ids = geosets.visible_geosets(look.race, look.sex, look.hair_style, look.facial_hair, &eq);
println!("\nvisible geosets: {ids:?}");
if let Some(path) = out {
@@ -23,6 +23,7 @@ mod m2dump;
mod scan;
mod shakecensus;
mod spellvis;
mod thudcensus;
/// Read WoW 1.12.1 asset archives (MPQ).
#[derive(Parser)]
@@ -137,6 +138,13 @@ enum Command {
/// shakes no screen"), and the check that fields 11/12 really are `CameraShakes` keys — every
/// live value must land on a real row.
Shakecensus,
/// Census the **death thud** — the body-fall sound a corpse makes on landing (`$DTH` →
/// `0x6236e0`, the sibling of the camera shake above). Two halves: the `DeathThudLookups.dbc`
/// matrix in full (`SizeClass × TerrainTypeSoundID` → the named land/water `SoundEntries`
/// kits), and the population sweep of which creature M2s key a `$DTH` at all, with the size
/// class their displays resolve to. The scope instrument for "a big corpse hits the ground
/// silently": it separates authored silence (an empty water column) from a real gap.
Thudcensus,
/// Census the `SpellVisualKit` **CharProc** columns (a kit's effect on the BODY — its alpha,
/// its tint): which proc types the shipped table carries, which lifecycle stage reaches each
/// from a live spell, and every state-stage (aura-lifetime) proc in full. The scope instrument
@@ -322,6 +330,16 @@ enum Command {
/// Also counts the models whose pose depends on the §2c remap (benilla plays nothing there today,
/// i.e. bind pose) and the ones reaching a rate-0 freeze leg.
Goanimscan,
/// Census the **GameObject display sound slots** (`GameObjectDisplayInfo.Sound[0..9]`) against
/// the only thing that can reach them. Exactly one function in the reference reads those
/// columns (`0x5f4010`) and it is called only from the GO M2 anim-event dispatcher
/// (`0x5f3e20`): `$GO0..5` -> slots 0..5, `$GC0..3` -> slots 6..9 (wow-re
/// `go-display-sound-events.md` §1/§3). So a filled column is audible only when the display's
/// own model authors the matching event tag AND that tag sits on a sequence the GameObject
/// animation arm can actually play. Reports, per slot: columns filled, of those how many are
/// tagged, how many of those are on an armable sequence, and how many name a LOOPING (0x200)
/// kit — the flag that selects `0x5f4010`'s emitter-pool lane over its one-shot lane.
Goslotscan,
/// Sweep every `.m2` (optionally under a path prefix) and census the models whose batch
/// visibility is PER SEQUENCE — geometry the reference draws in one animation and skips in
/// another (the verified `A <= 0` alpha cull). The population instrument for "a single-sequence
@@ -487,8 +505,8 @@ enum Command {
prefix: Option<String>,
},
/// Sweep every `.m2` and census the **animation-driven sound emitters**: the models whose
/// sequences carry a `$DSL` (doodad sound loop) / `$DSO` (doodad sound one-shot) / `$SND`
/// (generic one-shot) marker, the `SoundEntries` kit each names with its 3D parameters, and —
/// sequences carry a `$DSL` (doodad sound loop) / `$DSE` (its release token) / `$DSO` (doodad
/// sound one-shot) / `$SND` (generic one-shot) marker, the `SoundEntries` kit each names with its 3D parameters, and —
/// the column this exists for — whether the carrying sequence is REST-posed, i.e. one the
/// render content gate (decision 0130) never builds a rig for. A placed lamp's hum is a single
/// `$DSL` on a sequence that keys no bone at all, so the whole class is unreachable through an
@@ -852,6 +870,7 @@ fn main() -> Result<()> {
Command::Alphascan { prefix } => scan::alphascan(&mut chain, prefix.as_deref())?,
Command::Fxlifescan { prefix } => scan::fxlifescan(&mut chain, prefix.as_deref())?,
Command::Goanimscan => scan::goanimscan(&mut chain)?,
Command::Goslotscan => scan::goslotscan(&mut chain)?,
Command::Bonescan { prefix } => scan::bonescan(&mut chain, prefix.as_deref())?,
Command::Partcensus { prefix } => scan::partcensus(&mut chain, prefix.as_deref())?,
Command::Partslotscan { prefix } => scan::partslotscan(&mut chain, prefix.as_deref())?,
@@ -928,6 +947,7 @@ fn main() -> Result<()> {
Command::Spellvis { spell_id } => spellvis::run(&mut chain, spell_id)?,
Command::Chaincensus => chaincensus::run(&mut chain)?,
Command::Shakecensus => shakecensus::shakecensus(&mut chain)?,
Command::Thudcensus => thudcensus::thudcensus(&mut chain)?,
Command::Charprocs => charprocs::run(&mut chain)?,
}
@@ -35,7 +35,9 @@ pub use material::{alphascan, blendscan, envmapscan, texmodescan, uvslotscan, uv
pub use particles::{
cellscan, fxordercensus, partcensus, partscan, partslotscan, ribbonscan, shardcensus,
};
pub use sequence::{fxlifescan, goanimscan, idleslotscan, seqclockscan, soundeventscan};
pub use sequence::{
fxlifescan, goanimscan, goslotscan, idleslotscan, seqclockscan, soundeventscan,
};
pub use skeleton::{attachscan, bonescan};
pub use world::{doodadscan, placescan, skyboxscan, wmodoodads};
@@ -603,9 +603,14 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
}
/// Sweep every `.m2` and census the **animation-driven sound emitters**: the models whose
/// sequences carry a `$DSL` (doodad sound loop) / `$DSO` (doodad sound one-shot) / `$SND` (generic
/// one-shot) event marker, the `SoundEntries` kit each names, and — the column this exists for —
/// whether the carrying sequence is **rest-posed**.
/// sequences carry a `$DSL` (doodad sound loop) / `$DSE` (its release token) / `$DSO` (doodad
/// sound one-shot) / `$SND` (generic one-shot) event marker, the `SoundEntries` kit each names,
/// and — the column this exists for — whether the carrying sequence is **rest-posed**.
///
/// `$DSE` is in the set because it is half of `$DSL`'s lifecycle and it is **lane-specific**: the
/// placed-doodad handler `0x6951e0` has an arm for it, the GameObject dispatcher `0x5f3e20` has
/// none at all (wow-re `doodad-sound-emitters.md` §13), so which models author it decides whether
/// a consumer may route the token uniformly.
///
/// Why that column decides everything: `benilla_assets`' render content gate
/// (`idle_pose_differs` → [`benilla_formats::ModelAnimation::is_rest_pose`], decision 0130) skips
@@ -617,7 +622,7 @@ pub fn seqclockscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
///
/// Reports the per-model rows, then a tag/kit histogram and the `REST`-gated share.
pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
const SOUND_TAGS: [&[u8; 4]; 3] = [b"$DSL", b"$DSO", b"$SND"];
const SOUND_TAGS: [&[u8; 4]; 4] = [b"$DSL", b"$DSE", b"$DSO", b"$SND"];
let kits = benilla_formats::load_sound_kit_catalog(chain).ok();
let names = super::m2_names(chain, prefix)?;
let (mut scanned, mut carriers, mut rest_gated, mut hostless) = (0u32, 0u32, 0u32, 0u32);
@@ -745,3 +750,215 @@ pub fn soundeventscan(chain: &mut Chain, prefix: Option<&str>) -> Result<()> {
);
Ok(())
}
/// The ten `GameObjectDisplayInfo.Sound[n]` slots, in column order — the names the reference's
/// own substate family gives them (`gameobject-anim-arm.md` §2c's LUT order).
const GO_SLOT_NAMES: [&str; 10] = [
"Stand", "Open", "Loop", "Close", "Destroy", "Opened", "Custom0", "Custom1", "Custom2",
"Custom3",
];
/// The event tag that reaches each display slot (wow-re `go-display-sound-events.md` §3, the
/// dispatcher `0x5f3e20`'s ten call sites): `$GO0..5` → slots 0..5, `$GC0..3` → slots 6..9.
fn go_slot_tag(slot: usize) -> [u8; 4] {
if slot < 6 {
[b'$', b'G', b'O', b'0' + slot as u8]
} else {
[b'$', b'G', b'C', b'0' + (slot - 6) as u8]
}
}
/// The model sequence **file slots** the GameObject arm can ever put on screen: the loader seed
/// (§1) plus every substate any client path produces — the six `GAMEOBJECT_STATE` ×
/// `GAMEOBJECT_ANIMPROGRESS` ones ([`REACHABLE`]), the four Custom ones (opcode `0xb3`) and
/// Despawn (`0x215`) — each through the §2c remap and op4's `playableAnimationLookup` resolve.
///
/// This is the reachability half of [`goslotscan`]: an authored `$GOn` on a sequence outside this
/// set is a marker no arm can ever cross, so its display slot is dead however the DBC is filled.
fn go_armable_slots(
m: &benilla_m2::M2Model,
seqs: &[benilla_formats::ModelAnimation],
) -> HashSet<u16> {
let mut out = HashSet::new();
out.extend(go_loader_seed(m, seqs).map(|(_, slot)| slot));
let substates = REACHABLE.iter().map(|(s, _)| *s).chain(8..=12);
for sub in substates {
let (req, _) = go_remap(m, SUBSTATE_ANIM[sub]);
out.extend(go_resolve_slot(m, req).map(|(_, slot)| slot));
}
out
}
/// Census the **GameObject display sound slots** against the model events that are the only thing
/// that can reach them — see the `Goslotscan` command doc for what the join proves.
pub fn goslotscan(chain: &mut Chain) -> Result<()> {
let catalog =
benilla_formats::load_gameobject_catalog(chain).context("GameObjectDisplayInfo.dbc")?;
let sounds =
benilla_formats::load_gameobject_sounds(chain).context("GameObjectDisplayInfo.dbc")?;
let kits = benilla_formats::load_sound_kit_catalog(chain).ok();
// displayId → model path, so the same model parsed once serves every display that names it.
let mut per_model: BTreeMap<String, Vec<u32>> = BTreeMap::new();
for (id, path) in catalog.iter() {
if sounds.slots(id).is_some() {
per_model.entry(model_key(path)).or_default().push(id);
}
}
for ids in per_model.values_mut() {
ids.sort_unstable();
}
// Per slot: displays carrying a kit · of those, models authoring the tag · of those, on an
// armable sequence · and how many of the live ones name a LOOPING (0x200) kit — the lane
// select `0x458830` hands `0x5f4010`, which is the emitter-pool arm rather than a one-shot.
let mut filled = [0u32; 10];
let mut tagged = [0u32; 10];
let mut reachable = [0u32; 10];
let mut looping = [0u32; 10];
// Filled columns whose model authors no reaching tag at all — dead data in the reference, and
// exactly what a state-transition-driven consumer would play that the reference never does.
let mut dead = [0u32; 10];
// Markers authored where the display's column is 0 — the tag fires and `0x458830` fails a null
// id, so the reference is silent. Counted per tag so "authored but silent" is visible.
let mut silent_tag = [0u32; 10];
let mut unarmable_tag = [0u32; 10];
let (mut models_seen, mut wmo_or_missing) = (0u32, 0u32);
let mut rows: Vec<String> = Vec::new();
let mut loop_rows: Vec<String> = Vec::new();
for (path, displays) in &per_model {
if !path.ends_with(".m2") {
wmo_or_missing += displays.len() as u32;
continue;
}
let Ok(bytes) = chain.read_file(path) else {
wmo_or_missing += displays.len() as u32;
continue;
};
let Ok(fmt) = benilla_m2::parse_m2(&mut std::io::Cursor::new(&bytes)) else {
wmo_or_missing += displays.len() as u32;
continue;
};
let m = fmt.model();
let seqs = benilla_formats::parse_m2_animations(&bytes);
models_seen += 1;
let armable = go_armable_slots(m, &seqs);
// slot → the sequences authoring its tag, as `(seq_index, anim_id, time, armable)`.
let mut authored: [Vec<(usize, u16, f32, bool)>; 10] = Default::default();
for a in &seqs {
for e in &a.events {
for (slot, list) in authored.iter_mut().enumerate() {
if e.ident == go_slot_tag(slot) {
list.push((
a.seq_index,
a.anim_id,
e.time,
armable.contains(&(a.seq_index as u16)),
));
}
}
}
}
for &display in displays {
let slots = sounds.slots(display).copied().unwrap_or([0; 10]);
let mut line: Vec<String> = Vec::new();
for slot in 0..10 {
let kit = slots[slot];
let auth = &authored[slot];
if kit == 0 {
silent_tag[slot] += u32::from(!auth.is_empty());
continue;
}
filled[slot] += 1;
let live = auth.iter().any(|(_, _, _, armable)| *armable);
if auth.is_empty() {
dead[slot] += 1;
} else {
tagged[slot] += 1;
if live {
reachable[slot] += 1;
} else {
unarmable_tag[slot] += 1;
}
}
let k = kits.as_ref().and_then(|c| c.get(kit));
let flags = k.map_or(0, |k| k.flags);
let is_loop = flags & benilla_formats::sound_kit_flags::LOOPING != 0;
if live && is_loop {
looping[slot] += 1;
loop_rows.push(format!(
" display {display:>5} {path} slot {slot} {} kit {kit} {} flags 0x{flags:03x}",
GO_SLOT_NAMES[slot],
k.map_or("MISSING", |k| k.name.as_str()),
));
}
let where_ = if auth.is_empty() {
"DEAD: model authors no tag".to_string()
} else {
auth.iter()
.map(|(seq, id, t, armable)| {
format!(
"seq{seq}/anim{id}@{t:.3}s{}",
if *armable { "" } else { "!" }
)
})
.collect::<Vec<_>>()
.join(",")
};
line.push(format!(
"{}={kit}/0x{flags:03x}{}[{where_}]",
GO_SLOT_NAMES[slot],
if is_loop { " LOOP" } else { "" },
));
}
if !line.is_empty() {
rows.push(format!(
" display {display:>5} {path}\n {}",
line.join(" ")
));
}
}
}
println!("=== every display row with a filled sound column, and what reaches it ===");
for r in &rows {
println!("{r}");
}
println!(
"\n=== per-slot census ({} display rows carry any sound kit) ===",
sounds.len()
);
println!(
" slot name filled reached DEAD(no tag) tag-unarmable looping-kit \
tag-on-zero-slot"
);
for slot in 0..10 {
println!(
" {slot:>4} {:<9} {:>6} {:>7} {:>12} {:>13} {:>11} {:>16}",
GO_SLOT_NAMES[slot],
filled[slot],
reachable[slot],
dead[slot],
unarmable_tag[slot],
looping[slot],
silent_tag[slot],
);
}
debug_assert!(filled
.iter()
.zip(&tagged)
.zip(&dead)
.all(|((f, t), d)| *f == *t + *d));
if !loop_rows.is_empty() {
println!(
"\n=== live slots whose kit is LOOPING (0x200) — the `0x461d80` emitter-pool lane ==="
);
for r in &loop_rows {
println!("{r}");
}
}
eprintln!(
"{models_seen} distinct M2 models named by a sound-carrying display scanned \
({wmo_or_missing} display rows name a .wmo or an unreadable model). `filled` counts \
display rows with a non-zero column; `tagged` those whose model authors the reaching \
$GOn/$GCn tag; `reached` those where that tag sits on a sequence the GO arm can play."
);
Ok(())
}
@@ -0,0 +1,206 @@
//! `thudcensus`: the whole-table view of the **death thud** — the body-fall sound a corpse makes
//! as it lands (`$DTH` → `0x6236e0`), the sibling of the same event's camera shake.
//!
//! Two halves, because the report it exists for ("a big corpse hits the ground silently") has two
//! possible causes and they look identical in game:
//!
//! 1. **The table** — the `DeathThudLookups.dbc` matrix in full, `SizeClass × TerrainTypeSoundID`
//! → the land and water `SoundEntries` kits, named. An empty cell here is *authored* silence
//! (Sand and Soggy have no water column at all), and telling that apart from a bug is the
//! whole point of printing the grid rather than the rows.
//! 2. **The population** — which creature M2s actually key a `$DTH` at all, on which sequences,
//! and what size class each resolves to. A model that keys none is silent in the reference
//! too; a model that keys one and still sounds wrong is a runtime question, not a data one.
//!
//! The cross-check that validates the column map: every live `DeathThudLookups` sound id must
//! land on a real `SoundEntries` row, every terrain axis value on a real `TerrainTypeSounds` row,
//! and every model's `SizeClass` inside `0..=4` — a shifted field would scatter across the id
//! space and hit holes, exactly as `shakecensus` checks fields 11/12.
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use benilla_formats::Chain;
/// A model path reduced to a comparison key: lowercase, back-slashed, extension dropped. The DBCs
/// name models `.mdx`; the archives hold them as `.m2`.
fn mdx_key(path: &str) -> String {
let p = path.to_ascii_lowercase().replace('/', "\\");
match p.rsplit_once('.') {
Some((stem, _)) => stem.to_string(),
None => p,
}
}
/// The `SizeClass` names the shipped kits give themselves (`DeathThudSmallDirt` …
/// `DeathThudColossalWood`).
const SIZE_NAMES: [&str; 5] = ["Small", "Medium", "Large", "Giant", "Colossal"];
fn size_name(class: i32) -> String {
match usize::try_from(class).ok().and_then(|c| SIZE_NAMES.get(c)) {
Some(n) => format!("{class} {n}"),
None => format!("{class} ??"),
}
}
pub fn thudcensus(chain: &mut Chain) -> Result<()> {
let thuds = benilla_formats::load_death_thud_catalog(chain)?;
let steps = benilla_formats::load_footstep_catalog(chain)?;
let kits = benilla_formats::load_sound_kit_catalog(chain)?;
let creatures = benilla_formats::load_creature_catalog(chain)?;
// ── 1 · the table ────────────────────────────────────────────────────────────────────────
// The terrain axis, with the TerrainType rows that reach each id — a terrain sound nothing
// names is unreachable however well-populated its column is.
let mut terrain_names: BTreeMap<u32, Vec<u32>> = BTreeMap::new();
for terrain in 0..64 {
if let Some(sound) = steps.sound_class_of(terrain) {
terrain_names.entry(sound).or_default().push(terrain);
}
}
let axis: Vec<u32> = thuds.terrain_sounds().collect();
let classes: BTreeSet<u32> = thuds.size_classes();
println!(
"DeathThudLookups.dbc — {} rows over {} size class(es) × {} TerrainTypeSounds id(s)\n",
thuds.len(),
classes.len(),
axis.len()
);
let mut dangling: Vec<String> = Vec::new();
let name_of = |id: u32, dangling: &mut Vec<String>, what: &str| -> String {
if id == 0 {
return "—".to_string();
}
match kits.get(id) {
Some(k) => format!("{id} {}", k.name),
None => {
dangling.push(format!("{what}: SoundEntries {id} does not exist"));
format!("{id} DANGLING")
}
}
};
for &sound in &axis {
let reached = match terrain_names.get(&sound) {
None => " ** no TerrainType names it **".to_string(),
Some(t) => format!(
" TerrainType {}",
t.iter().map(u32::to_string).collect::<Vec<_>>().join(", ")
),
};
println!("terrain sound {sound}{reached}");
for &class in &classes {
let cell = match thuds.resolve(class, sound) {
None => "— NO ROW".to_string(),
Some((land, water)) => format!(
"{:<28} water {}",
name_of(land, &mut dangling, &format!("({class}, {sound}) land")),
name_of(water, &mut dangling, &format!("({class}, {sound}) water")),
),
};
println!(" {:<12} {cell}", size_name(class as i32));
}
}
// ── 2 · the population ───────────────────────────────────────────────────────────────────
// Which creature models key a `$DTH`, and the size class(es) the displays that reach each one
// resolve to. A model whose displays disagree is normal — 2 971 displays override their
// model's column — and the census prints the whole set so a mis-sized thud is visible.
let mut resolved: BTreeMap<u32, BTreeSet<u32>> = BTreeMap::new();
for (display, model) in creatures.display_models() {
if let Some(class) = creatures.size_class(display) {
resolved.entry(model).or_default().insert(class);
}
}
let mut by_key: BTreeMap<String, Vec<(u32, i32)>> = BTreeMap::new();
let mut off_axis = Vec::new();
for (id, path, class) in creatures.sized_models() {
if !(0..=4).contains(&class) {
off_axis.push(format!("{path} ({id}): SizeClass = {class}"));
}
by_key.entry(mdx_key(path)).or_default().push((id, class));
}
println!("\n$DTH animation events — which creature models thud at all\n");
let mut keyed = 0u32;
let mut marks = 0u32;
let mut silent: Vec<String> = Vec::new();
let mut keyed_classes: BTreeMap<u32, u32> = BTreeMap::new();
for name in crate::scan::m2_names(chain, None)? {
let key = mdx_key(&name);
let Some(models) = by_key.get(&key) else {
continue; // not a creature model: `CGUnit_C::HandleAnimEvent` never sees its events
};
let Ok(bytes) = chain.read_file(&name) else {
continue;
};
let mut rows = Vec::new();
for a in benilla_formats::parse_m2_animations(&bytes) {
for e in a.events.iter().filter(|e| &e.ident == b"$DTH") {
marks += 1;
rows.push(format!(
" seq {:>2} anim {:>3} {:>7.3}s",
a.seq_index, a.anim_id, e.time
));
}
}
let sizes: BTreeSet<u32> = models
.iter()
.flat_map(|(id, _)| resolved.get(id).into_iter().flatten().copied())
.collect();
let label = format!(
"{name} [{}]",
if sizes.is_empty() {
"no display reaches it".to_string()
} else {
sizes
.iter()
.map(|c| size_name(*c as i32))
.collect::<Vec<_>>()
.join(" · ")
}
);
if rows.is_empty() {
silent.push(label);
continue;
}
keyed += 1;
for c in &sizes {
*keyed_classes.entry(*c).or_default() += 1;
}
println!(" {label}");
for r in rows {
println!("{r}");
}
}
println!(
"\n{keyed} creature model(s) key a $DTH ({marks} marker(s)); {} key none",
silent.len()
);
for c in classes {
println!(
" {:<12} {} keyed model(s)",
size_name(c as i32),
keyed_classes.get(&c).copied().unwrap_or(0)
);
}
if !silent.is_empty() {
println!("\nkeying no $DTH — silent in the reference too:");
for s in &silent {
println!(" {s}");
}
}
for d in &dangling {
println!(" DANGLING {d}");
}
for o in &off_axis {
println!(" OFF-AXIS {o} (the reference's unsigned `>= 5` gate silences this model)");
}
println!(
"\n{} dangling sound id(s); {} model(s) off the 0..=4 size axis",
dangling.len(),
off_axis.len()
);
Ok(())
}
+210 -28
View File
@@ -30,24 +30,40 @@ pub struct CharacterGeosets {
helmet_vis: HashMap<u32, [u32; 5]>,
}
/// The worn geoset selectors the RF-0038 equipment branches read (decision 0074): per bodyslot 2–9
/// (shirt, chest, belt, pants, boots, wrist, gloves, tabard) the item's `geosetGroup[0..2]` (`None`
/// = the slot is empty), plus the cloak's `geosetGroup[0]`, plus the worn helm's
/// `HelmetGeosetVisData` row pair (`[male, female]` — ItemDisplayInfo cols 12/13). Default = naked.
/// The worn geoset selectors the RF-0038 equipment branches read (decisions 0074, 1864): per
/// bodyslot 2–9 (shirt, chest, belt, pants, boots, wrist, gloves, tabard) the item's
/// `geosetGroup[0..2]` (`None` = the slot is empty), plus the cloak's `geosetGroup[0]`, plus the
/// worn helm's `HelmetGeosetVisData` row pair (`[male, female]` — ItemDisplayInfo cols 12/13).
/// Default = naked.
///
/// **Two of the eight branches are not fed by an ItemDisplayInfo column at all** and take their own
/// field here (decision 1864): B3 reads the ArmLower *composite grid*, and B6 reads a client flag.
#[derive(Default, Clone, Copy)]
pub struct EquipGeosets {
pub bodyslots: [Option<[u32; 3]>; 8],
pub cloak: Option<u32>,
pub helm_vis: Option<[u32; 2]>,
/// B3's gate: does anything **other than the shirt** dress the forearm? — the six dwords
/// `cc+0x26c..cc+0x280` (ArmLower cells 1..6). Fill it from
/// [`forearm_dressed`](crate::forearm_dressed), which reads the one composite plan.
pub forearm_dressed: bool,
/// B6's gate: `[cc+0xc]`, "the guild registrar's tabard designer is open on this character"
/// (wow-re RF-0089 §7c). Set while that window is up so the body wears a previewable tabard
/// with an **empty** tabard slot; nothing else in the client ever sets it.
pub tabard_preview: bool,
}
impl CharacterGeosets {
/// The geoset IDs a character of this appearance renders — RF-0038's opening block (the naked
/// set) plus the equipment branches B1–B8 (decision 0074 — implemented from the transcription's
/// *arithmetic*; its prose labels are known-mislabeled). A body submesh whose `skinSectionId` is
/// in this set is drawn; all others hidden. A branch's gate is its `geosetGroup` field being
/// non-zero ("section present"); the recorded disables are the ranges shown — the boot/kneepad
/// enables deliberately leave their naked defaults on, as the client does.
/// set) plus **all eight** equipment branches B1–B8 of `0x477520` (decisions 0074, 1864 —
/// implemented from the transcription's *arithmetic*; its prose labels were mislabels wow-re
/// corrected in RF-0088/RF-0089, and the branch comments below name each one at its address).
/// A body submesh whose `skinSectionId` is in this set is drawn; all others hidden. Most
/// branches gate on an ItemDisplayInfo `geosetGroup` field being non-zero ("section present");
/// B3 and B6 do not, and read [`EquipGeosets::forearm_dressed`] / [`EquipGeosets::tabard_preview`].
///
/// Returned **sorted and deduplicated**: the client toggles a per-submesh flag, where several
/// branches re-enable a base the opening loop already set, so a list needs normalising once.
pub fn visible_geosets(
&self,
race: u8,
@@ -97,24 +113,31 @@ impl CharacterGeosets {
.filter(|v| *v != 0)
};
let disable = |set: &mut Vec<u16>, lo: u16, hi: u16| set.retain(|id| *id < lo || *id > hi);
// The robe bit: chest or pants geosetGroup[2] (B4's guard) — it also suppresses the tabard
// and pant-leg branches below.
// The robe bit: chest or pants geosetGroup[2] (B4's guard) — it also suppresses B5's tabard
// flap and B6's preview. B7 is NOT gated on it: that branch reads the chest's bit alone.
let robe = g(1, 2).or_else(|| g(3, 2));
// B1: gloves replace the glove group (401+v, own range disabled); else the chest's sleeves.
// B1/B2 (`0x477564`): gloves replace the glove group (401+v, own range disabled); else the
// chest's sleeves (the `else` arm wow-re numbers B2).
if let Some(v) = g(6, 0) {
disable(&mut set, 401, 499);
set.push(401 + v as u16);
} else if let Some(v) = g(1, 0) {
set.push(801 + v as u16);
}
// B3: shirt sleeves, only with no chest item over them.
if equip.bodyslots[1].is_none() {
// B3 (`0x4775bd`): the shirt's sleeve cuff — only when nothing ELSE dresses the forearm.
// The reference's gate is the ArmLower equip row's cells 1..6, not "is a chest equipped":
// most chest pieces leave that tile empty and the cuff survives them, while a bracer or a
// glove takes it and the cuff goes (decision 1864; [`EquipGeosets::forearm_dressed`]).
if !equip.forearm_dressed {
if let Some(v) = g(0, 0) {
set.push(801 + v as u16);
}
}
// B4: a robe hides boots/kneepads/pant-legs/trousers and shows its skirt (1301+v); else
// boots enable kneepad base + their boot geoset; else the pants' kneepads.
// B4 (`0x4775fb`): a robe hides boots/kneepads/pant-legs/trousers and shows its skirt
// (1301+v); else the boots replace the boot group; else the pants' kneepads. The bare `901`
// enables are the reference's own (`0x477648`, `0x477685`) and are no-ops here — the 16-base
// loop already carries 901 and nothing on this path disables group 9 — kept so the sequence
// stays the reference's rather than resting on that invariant.
if let Some(v) = robe {
disable(&mut set, 501, 599);
disable(&mut set, 902, 999);
@@ -122,30 +145,60 @@ impl CharacterGeosets {
disable(&mut set, 1300, 1399);
set.push(1301 + v as u16);
} else if let Some(v) = g(4, 0) {
set.push(501 + v as u16); // the naked 501 stays on, as recorded
// `0x477639` disables the WHOLE boot group before enabling the boot's own geoset — the
// naked foot comes off with the boot on. 501 is a real submesh on 11 of the 18 character
// models (64 vertices on Human male), so leaving it on — as benilla did until decision
// 1864, on a misread of this same note — put a bare ankle inside every boot.
disable(&mut set, 501, 599);
set.push(901);
set.push(501 + v as u16);
} else if let Some(v) = g(3, 1) {
set.push(901 + v as u16);
} else {
set.push(901);
}
// B5: the tabard flap (robes hide it).
// B5 (`0x477706`): the tabard flap (robes hide it).
if robe.is_none() {
if let Some(v) = g(7, 0) {
set.push(1201 + v as u16);
}
}
// B7: the shirt's doublet + the pants' leg geoset (the recorded 1102 base — not 1101).
if let Some(v) = g(0, 1) {
set.push(1001 + v as u16);
// B6 (`0x477752`): the tabard-designer preview. **No ItemDisplayInfo column feeds this
// branch** — its gate is the flag `[cc+0xc]`, set only while the guild registrar's tabard
// designer is open (wow-re RF-0089 §7c; the branch's old "helm-skirt" label was a mislabel,
// and no shipped head display carries a geoset group at all). It forces the tabard group on
// with an EMPTY tabard slot: 1201, plus 1202 when neither the chest nor the legs carries the
// robe bit. On shipped character models 1201 has no submesh (it is the group's "no tabard"
// base) and 1202 is the flap — so the observable effect is exactly "wear the tabard so the
// design can be previewed" (decision 1864).
if equip.tabard_preview {
set.push(1201); // already on from the opening loop; the reference enables it anyway
if robe.is_none() {
set.push(1202);
}
}
if robe.is_none() {
// B7 (`0x477799`): the shirt's doublet + the pants' leg geoset (the recorded 1102 base — not
// 1101). Skipped ENTIRELY when the CHEST is a robe (`0x4777a4` — the legs' own robe bit does
// NOT gate here, unlike B4/B5/B6) or when a TABARD is worn (`0x4777b8`, bodyslot 9 sub 0;
// RF-0088 corrected this guard from "glove" to "tabard"). Decision 1864.
if g(1, 2).is_none() && g(7, 0).is_none() {
if let Some(v) = g(0, 1) {
set.push(1001 + v as u16);
}
if let Some(v) = g(3, 0) {
set.push(1102 + v as u16);
}
}
// B8: a cloak replaces the cloak group (1501+v, range disabled).
// B8 (`0x47780a`): a cloak replaces the cloak group (1501+v, range disabled).
if let Some(v) = equip.cloak.filter(|v| *v != 0) {
disable(&mut set, 1500, 1599);
set.push(1501 + v as u16);
}
// The client's `0x7110d0` writes a flag per submesh, so enabling an already-enabled geoset
// is idempotent there; ours is a list, and several branches re-enable a base the opening
// loop already added. Normalise once at the end so the result is a canonical set.
set.sort_unstable();
set.dedup();
set
}
@@ -290,9 +343,8 @@ pub(crate) fn char_facial_hair_schema() -> Schema {
mod tests {
use super::*;
/// The equipment geoset branches (decision 0074, from the RF-0038 arithmetic): gloves/robe/cloak
/// replace their groups, boots stack over the naked default, and a robe suppresses the tabard +
/// pant-leg branches.
/// The equipment geoset branches (decisions 0074/1864, from the RF-0038 arithmetic):
/// gloves/boots/robe/cloak **replace** their groups, and a robe suppresses the tabard branch.
#[test]
fn equipment_geoset_branches() {
let cg = CharacterGeosets {
@@ -309,11 +361,12 @@ mod tests {
let set = cg.visible_geosets(1, 0, 0, 0, &eq);
assert!(set.contains(&402) && !set.contains(&401));
// Boots v=2: 503 stacks; the naked 501 deliberately stays (the recorded no-disable).
// Boots v=2: the boot group is replaced — 503 in, the naked foot 501 OUT (`0x477639`
// disables [501,599] first). benilla stacked them until decision 1864.
let mut eq = EquipGeosets::default();
eq.bodyslots[4] = Some([2, 0, 0]);
let set = cg.visible_geosets(1, 0, 0, 0, &eq);
assert!(set.contains(&503) && set.contains(&501));
assert!(set.contains(&503) && !set.contains(&501));
// A robe (chest group[2]=1) shows its skirt (1302) and hides boots/pant-legs/trousers —
// including a worn tabard's flap and the pants' own branches.
@@ -341,6 +394,135 @@ mod tests {
assert!(set.contains(&1505) && !set.contains(&1501));
}
/// **B3's real gate** (`0x4775bd`, decision 1864): the shirt's sleeve cuff survives or dies by
/// what dresses the *forearm tile*, not by whether a chest is equipped. Both directions matter —
/// 533 of the 637 shipped chest displays leave `ArmLowerTexture` empty, and 566 of 570 wrist
/// displays fill it — and benilla had both backwards until this record.
#[test]
fn b3_shirt_sleeve_follows_the_forearm_tile_not_the_chest_slot() {
let cg = CharacterGeosets {
hair: HashMap::new(),
facial: HashMap::new(),
helmet_vis: HashMap::new(),
};
let shirt = |dressed: bool| EquipGeosets {
bodyslots: [Some([1, 0, 0]), None, None, None, None, None, None, None],
forearm_dressed: dressed,
..Default::default()
};
assert!(
cg.visible_geosets(1, 0, 0, 0, &shirt(false)).contains(&802),
"bare forearm: the shirt's cuff shows"
);
assert!(
!cg.visible_geosets(1, 0, 0, 0, &shirt(true)).contains(&802),
"something else paints the forearm: the cuff goes"
);
// And a chest in the slot is NOT the gate: a chest whose ArmLower art is empty leaves the
// tile free, and the cuff stays. (The chest's own sleeve geoset is B1's `else`, group 8 too,
// so give this one none.)
let mut eq = shirt(false);
eq.bodyslots[1] = Some([0, 0, 0]);
assert!(
cg.visible_geosets(1, 0, 0, 0, &eq).contains(&802),
"an equipped chest alone does not hide the shirt's cuff"
);
}
/// **B6 — the tabard-designer preview** (`0x477752`, wow-re RF-0089 §7c; decision 1864). The one
/// branch of the eight with no ItemDisplayInfo column behind it: its gate is `[cc+0xc]`, "the
/// guild registrar's tabard designer is open", and it wears the tabard flap (1202) with an
/// **empty** tabard slot — unless a robe is on, which suppresses it exactly as it suppresses B5.
#[test]
fn b6_tabard_preview_wears_the_flap_with_an_empty_slot() {
let cg = CharacterGeosets {
hair: HashMap::new(),
facial: HashMap::new(),
helmet_vis: HashMap::new(),
};
// Off: an empty tabard slot shows no flap.
let set = cg.visible_geosets(1, 0, 0, 0, &EquipGeosets::default());
assert!(!set.contains(&1202), "no tabard, no designer: no flap");
// On: the flap appears with nothing in the slot.
let preview = EquipGeosets {
tabard_preview: true,
..Default::default()
};
let set = cg.visible_geosets(1, 0, 0, 0, &preview);
assert!(set.contains(&1202), "the designer forces the flap on");
assert!(set.contains(&1201), "and the group base with it");
// A robe suppresses it, from either the chest's or the legs' robe bit.
for slot in [1usize, 3] {
let mut eq = preview;
eq.bodyslots[slot] = Some([0, 0, 1]);
assert!(
!cg.visible_geosets(1, 0, 0, 0, &eq).contains(&1202),
"a robe (slot {slot}) hides the previewed flap"
);
}
}
/// **B7's two guards** (`0x4777a4` / `0x4777b8`, decision 1864): the shirt-doublet + pant-leg arm
/// is skipped whole when the CHEST is a robe or when a TABARD is worn — and, unlike B4/B5/B6,
/// the *legs'* own robe bit does not gate it.
#[test]
fn b7_is_gated_by_the_chest_robe_and_the_tabard() {
let cg = CharacterGeosets {
hair: HashMap::new(),
facial: HashMap::new(),
helmet_vis: HashMap::new(),
};
// Shirt doublet g1=1 → 1002; pants g0=1 → 1103.
let base = EquipGeosets {
bodyslots: [
Some([0, 1, 0]),
None,
None,
Some([1, 0, 0]),
None,
None,
None,
None,
],
..Default::default()
};
let set = cg.visible_geosets(1, 0, 0, 0, &base);
assert!(
set.contains(&1002) && set.contains(&1103),
"ungated: both on"
);
// A worn tabard skips the whole branch (RF-0088 corrected this guard from "glove").
let mut eq = base;
eq.bodyslots[7] = Some([1, 0, 0]);
let set = cg.visible_geosets(1, 0, 0, 0, &eq);
assert!(set.contains(&1202), "the tabard's own flap is on");
assert!(
!set.contains(&1002) && !set.contains(&1103),
"a tabard skips B7 whole"
);
// A chest robe skips it too — and takes the doublet with it, which the pre-1864 code left on.
let mut eq = base;
eq.bodyslots[1] = Some([0, 0, 1]);
let set = cg.visible_geosets(1, 0, 0, 0, &eq);
assert!(
!set.contains(&1002) && !set.contains(&1103),
"a chest robe skips B7 whole"
);
// The LEGS' robe bit does not: B4-long still hides the pant-leg group, but the doublet —
// enabled after that disable — stays on. This is the asymmetry the old `robe` shortcut lost.
let mut eq = base;
eq.bodyslots[3] = Some([1, 0, 1]);
let set = cg.visible_geosets(1, 0, 0, 0, &eq);
assert!(set.contains(&1002), "leg robes do not gate B7");
assert!(
set.contains(&1103),
"and B7 re-enables the pant leg after B4-long"
);
}
/// The helm-vis force (VERIFIED wow-re RF-0083): each vis column is a **race** bitmask; a set
/// `1 << race` bit forces the slot back to its group base — hair to the bare scalp (1), facial
/// groups to 101/201/301, ears to 701 — dropping the styled geosets. The row is sex-selected
+2 -2
View File
@@ -26,8 +26,8 @@ mod sections;
pub use customization::{CharCreateCatalog, DialRanges, StartOutfitItem};
pub use geosets::{CharacterGeosets, EquipGeosets};
pub use sections::{
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, BlitSource,
CharSections, EmblemLayer, EquipBlit, GuildEmblem,
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, forearm_dressed,
BlitSource, CharSections, EmblemLayer, EquipBlit, GuildEmblem,
};
// The appearance tables' schemas, for the `benilla-extract dbc` CSV dump (`crate::schema_for`) —
@@ -645,6 +645,29 @@ pub fn equip_blits<'a>(
plan
}
/// The **ArmLower** layer — the tile whose occupancy gates the shirt's sleeve geoset (below).
const LAYER_ARM_LOWER: usize = 1;
/// Whether anything **other than the shirt** dresses the forearm — the byte-true gate on the geoset
/// dispatch's B3 arm (`0x4775bd`, the six dwords `cc+0x26c..cc+0x280` = cells 1..6 of the ArmLower
/// equip row; the shirt's own default cell is 0, which the loop skips).
///
/// The shirt carries a sleeve geoset (802/803, the cuff flare) that must not survive under a
/// garment painting the same skin: a chest with sleeve art, a bracer, or a glove takes the tile and
/// the cuff underneath it is dropped. Reading the *grid* rather than "is a chest equipped" is what
/// makes both directions right — 533 of the 637 shipped chest displays leave `ArmLowerTexture`
/// empty (the cuff survives them), while 566 of 570 wrist displays fill it (the cuff goes).
///
/// Off [`equip_blits`], so the promotion rules stay in one place: a sleeved chest and a
/// glove-geoset glove are lifted to cells 5 and 6, still inside the tested range. The guild emblem
/// is passed as `None` because it only ever writes layers 3 and 4 ([`EMBLEM_LAYERS`]) — this row
/// reads the same either way.
pub fn forearm_dressed(equipment: &[Option<&ItemDisplay>; 8]) -> bool {
equip_blits(equipment, None)
.iter()
.any(|b| b.layer == LAYER_ARM_LOWER && (1..=6).contains(&b.column))
}
/// The atlas rect layer `layer`'s equipment contributions composite into — `(x, y, w, h)` in pixels
/// of the 256² body atlas (the RF-0062 bbox table). Out of range past layer 7.
pub fn equip_tile(layer: usize) -> Option<(u32, u32, u32, u32)> {
+107 -1
View File
@@ -104,6 +104,10 @@ struct DisplayRow {
/// 10534 shipped displays, and `0` is not a row of that table, so it falls through to the
/// model's `BloodID` (see [`CreatureModel::blood_display`]).
blood_level: u32,
/// `SizeClass` (field 9, @+0x24) — the display's **size-class override**, read signed:
/// `−1` (1 791 of the 10 534 shipped rows) defers to the model's own
/// [`ModelRow::size_class`]. See [`CreatureCatalog::size_class`].
size_class: i32,
/// `CreatureModelAlpha` (field 5, @+0x14) — the display's **base render opacity**, 0..=255.
/// This is the `baseAlpha` of the reference's per-unit alpha product (`0x60d2d0`, the CGUnit
/// vtbl+0x6c getter: `CreatureDisplayInfo+0x14 × (1/255)`; wow-re `ghost-death-visuals.md`
@@ -120,6 +124,11 @@ struct ModelRow {
scale: f32,
/// `Flags` (field 1) — see [`CreatureCatalog::breathes`] for the one bit we read.
flags: u32,
/// `SizeClass` (field 3, @+0x0c) — the model's own audible size, `0 Small · 1 Medium ·
/// 2 Large · 3 Giant · 4 Colossal`, and the fallback arm of [`CreatureCatalog::size_class`].
/// Reads signed for symmetry with the display override; every one of the 430 shipped rows is
/// in `0..=4`, so the fallback always answers.
size_class: i32,
/// `BloodID` — see [`CreatureModel::blood_model`]. Reads signed: `−1` in 122 of the 430
/// shipped rows, which the reference treats as a tier-2 miss, not as bloodlessness.
blood: i32,
@@ -199,6 +208,26 @@ impl CreatureCatalog {
(id != 0).then_some(id)
}
/// A display's **audible size class** — `0 Small · 1 Medium · 2 Large · 3 Giant ·
/// 4 Colossal`, the axis [`crate::DeathThudCatalog`] picks the body-fall sample on.
///
/// The reference's own two-step (`0x625500`): the **display's** `SizeClass` column wins, and
/// only its `−1` sentinel defers to the **model's**. That order matters — 8 743 of the 10 534
/// shipped displays override their model, so reading the model alone would mis-size most of
/// the world (every Small display sharing a Medium model, for one).
///
/// `None` when either lookup misses, or when **both** rows read `−1`. No shipped row does, so
/// that arm exists to keep a broken/absent table silent rather than Small — the same thing
/// the reference's own unsigned `sizeClass >= 5` gate (`0x623744`) does with a `−1`.
pub fn size_class(&self, display_id: u32) -> Option<u32> {
let row = self.display.get(&display_id)?;
let class = match row.size_class {
-1 => self.models.get(&row.model_id)?.size_class,
c => c,
};
u32::try_from(class).ok()
}
/// Every `CreatureModelData` row that names a camera-shake preset: `(model id, path,
/// footstep id, death-thud id)`. The **census** view — the runtime reads
/// [`Self::footstep_shake`] by display id instead, because that is what a unit carries.
@@ -216,6 +245,21 @@ impl CreatureCatalog {
self.models.values().map(|m| m.path.as_str())
}
/// Every `CreatureModelData` row as `(model id, path, its own SizeClass)` — the **census**
/// view of the death-thud size axis. The runtime reads [`Self::size_class`] by *display* id
/// instead, because most displays override this column.
pub fn sized_models(&self) -> impl Iterator<Item = (u32, &str, i32)> + '_ {
self.models
.iter()
.map(|(id, m)| (*id, m.path.as_str(), m.size_class))
}
/// Every display as `(display id, its model id)` — the census hop that lets an instrument roll
/// a per-display answer (a resolved size class, say) up onto the model that carries the M2.
pub fn display_models(&self) -> impl Iterator<Item = (u32, u32)> + '_ {
self.display.iter().map(|(id, r)| (*id, r.model_id))
}
/// A display's **spawned-creature render scale** — the product
/// `CreatureModelData.modelScale × CreatureDisplayInfo.creatureModelScale`, the same number
/// [`CreatureModel::scale`] carries, without the row's string clones.
@@ -391,7 +435,11 @@ pub(crate) fn creature_display_info_schema() -> Schema {
("TextureVariation0", FieldType::String),
("TextureVariation1", FieldType::String),
("TextureVariation2", FieldType::String),
("PortraitTextureName", FieldType::String),
// **NOT `PortraitTextureName`** — 5875 has no such column, and reading field 9 as a
// string would resolve a size class into the string block. The client reads it as a
// signed int at `+0x24` and compares it to `-1` (`0x625509`); the shipped column takes
// exactly `{-1, 0, 1, 2, 3, 4}` across all 10 534 rows, `-1` on 1 791 of them.
("SizeClass", FieldType::UInt32),
("BloodLevel", FieldType::UInt32),
// Labeled BloodID in some third-party maps, but the 5875 values (33..188, dense) are the
// NPC sound-kit range, not blood ids — the blood override is BloodLevel above.
@@ -443,6 +491,7 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
path: name,
scale: f32_at(r, 4).unwrap_or(1.0),
flags: u32_at(r, 1).unwrap_or(0),
size_class: u32_at(r, 3).map_or(-1, |v| v as i32),
// BloodID (field 5) reads signed: −1 in 122 of the 430 shipped rows.
// That is a tier-2 MISS, not bloodlessness — the resolve falls through
// to the records base (1850). Kept signed so the miss is visible.
@@ -482,6 +531,8 @@ pub fn load_creature_catalog(chain: &mut Chain) -> Result<CreatureCatalog> {
scale: f32_at(r, 4).unwrap_or(1.0),
textures: [str_at(&rs, r, 6), str_at(&rs, r, 7), str_at(&rs, r, 8)],
blood_level: u32_at(r, 10).unwrap_or(0),
// Signed: −1 is "no override", not a size class (see the field docs).
size_class: u32_at(r, 9).map_or(-1, |v| v as i32),
model_alpha: u32_at(r, 5).unwrap_or(255),
},
);
@@ -804,6 +855,61 @@ mod tests {
/// pauldron pair (slot 1), and boot/glove/tabard geosets (slots 6/8/9), with empty chest/wrist
/// (slots 3/7). These ids are the real `CreatureDisplayInfoExtra` values read off the build-5875
/// DBC; a shifted column or a wrong field offset would misread them. Skips without the client data.
/// **The size-class column, pinned against the shipped client.** The death-thud sound
/// (`benilla-app`'s `sound::death_thud`) picks its sample on this axis alone, so a shifted
/// field would re-size the whole world's body-falls silently.
///
/// The three arms of `0x625500`, each on a row that exercises it:
/// - the **display override** wins — display 792 is a Gorilla (model 136, class `0` Small)
/// overridden to `3` Giant, which is the whole reason the override exists;
/// - `−1` **defers to the model** — display 170 is a Sea Giant with no override and the
/// model's `4` Colossal;
/// - and an ordinary agreeing row (the Ancient Protector, `3` Giant on both).
///
/// The control is the human male at `1` Medium: a schema shift onto a neighbouring column
/// would have to keep *all four* of these, and the string-block column that used to be
/// misread here (field 9 was labelled `PortraitTextureName`) cannot.
#[test]
fn the_size_class_column_resolves_its_three_arms() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_creature_catalog(&mut chain).expect("load creature catalog");
assert_eq!(
cat.size_class(792),
Some(3),
"Gorilla display overrides to Giant"
);
assert_eq!(
cat.size_class(170),
Some(4),
"Sea Giant falls back to Colossal"
);
assert_eq!(
cat.size_class(1921),
Some(3),
"Ancient Protector agrees at Giant"
);
assert_eq!(cat.size_class(49), Some(1), "HumanMale is Medium");
assert_eq!(cat.size_class(0), None, "no such display");
// Every shipped display resolves, and inside the reference's own `0..=4` bound — so its
// unsigned `>= 5` gate never fires on this data, and no body-fall is silent for want of
// a size.
let mut n = 0;
for &id in cat.display.keys() {
let class = cat
.size_class(id)
.expect("every display resolves a size class");
assert!(
class <= 4,
"display {id} resolves to {class}, past Colossal"
);
n += 1;
}
assert_eq!(n, 10_534, "the whole shipped display table");
}
/// **The shake columns, pinned against the shipped client** (B298, decision 1540). Fields 11
/// and 12 are `CameraShakes.dbc` row ids, and the evidence that the map is right is not that
/// the names look plausible — it is that the census is *semantic*: 25 of 430 rows carry a
+211
View File
@@ -0,0 +1,211 @@
//! The **death thud** — the body-fall impact a corpse makes as it lands, fired on the `$DTH`
//! animation event (`0x6236e0`, the sibling of the same event's camera shake `0x625c30`).
//!
//! It rides the **same terrain chain the footsteps do** ([`crate::FootstepCatalog`]) — the
//! surface under the unit → `TerrainType` → `TerrainType.SoundID` — and joins it against the
//! creature's **size class** rather than its footstep class:
//!
//! ```text
//! (CreatureDisplayInfo.SizeClass ?? CreatureModelData.SizeClass) × TerrainType.SoundID
//! → DeathThudLookups → SoundEntries (land | water)
//! ```
//!
//! Layouts — VERIFIED against build 5875 (headers + row decodes, 2026-09-02):
//! - `DeathThudLookups` **45 × 5 × 20 B**: `ID, SizeClass, TerrainTypeSoundID, SoundEntryID,
//! SoundEntryIDWater`.
//! - `TerrainTypeSounds` **9 × 1 × 4 B**: a bare id enum, `1..=9`, and nothing else — the axis
//! `TerrainType.SoundID` and `FootstepTerrainLookup.TerrainSoundID` are both keyed on. It is
//! parsed for its **domain**: the reference bakes this table into a per-size-class array
//! dimensioned by it and bounds-checks `terrainSoundId >= count` before the lookup
//! (`0x623771`), and the census wants the empty columns as much as the full ones.
//!
//! **The five size classes are the five audible sizes**, and their kits name themselves:
//! `0 Small · 1 Medium · 2 Large · 3 Giant · 4 Colossal` (`DeathThudSmallDirt` … through
//! `DeathThudColossalWood`, all in `Sound\Effects\DeathImpacts`, one file each). So this is
//! **not** a big-creature-only effect the way the camera shake is — every creature that carries a
//! `$DTH` key thuds, and only the *sample* scales with the body. The water column is coarser: four
//! kits (`DeathThudWaterSmall/Medium/Giant/Colossal`, `…\DeathImpacts\InWater`) shared across the
//! whole terrain axis, since a splash does not care what is under the water.
//!
//! Only five of the nine terrain sounds carry the full class sweep (Dirt 1, Stone 3, Snow 4,
//! Wood 5, Grass 6); the rest (Metallic 2, Leaves 7, Sand 8, Soggy 9) were filled in later against
//! the *same* 25 kits — Metallic borrows Stone, Leaves borrows Dirt — and those rows carry a
//! **water column of 0**, which is silence and not a fallback to land (see [`DeathThudCatalog::kit`]).
use std::collections::{BTreeSet, HashMap};
use crate::Chain;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, u32_at};
/// `DeathThudLookups.dbc` joined with the `TerrainTypeSounds.dbc` id domain.
pub struct DeathThudCatalog {
/// `(SizeClass, TerrainTypeSoundID)` → `(land kit, water kit)`.
lookup: HashMap<(u32, u32), (u32, u32)>,
/// Every `TerrainTypeSounds.dbc` id, ascending — the lookup's terrain axis in full, including
/// the ids no `DeathThudLookups` row names.
terrain_sounds: BTreeSet<u32>,
}
impl DeathThudCatalog {
/// The `(land, water)` `SoundEntries` kits for a size class landing on a terrain-sound class.
/// `None` when the pair has no row — the reference's own answer for an out-of-domain index,
/// and silence.
pub fn resolve(&self, size_class: u32, terrain_sound: u32) -> Option<(u32, u32)> {
self.lookup.get(&(size_class, terrain_sound)).copied()
}
/// The one kit that actually plays: the water column when the corpse is in liquid, the land
/// column otherwise. **A zero is silence, never a fallback to the other column** — the
/// reference plays whatever dword it read, and `SoundEntries` has no row 0, so the 18 rows
/// whose water column is 0 are simply mute in water.
pub fn kit(&self, size_class: u32, terrain_sound: u32, in_water: bool) -> Option<u32> {
let (land, water) = self.resolve(size_class, terrain_sound)?;
let kit = if in_water { water } else { land };
(kit != 0).then_some(kit)
}
/// Every `TerrainTypeSounds` id, ascending — the census's column headings.
pub fn terrain_sounds(&self) -> impl Iterator<Item = u32> + '_ {
self.terrain_sounds.iter().copied()
}
/// Every size class a `DeathThudLookups` row names, ascending — the census's rows. The
/// shipped data is exactly `0..=4`, which is also why the reference's `sizeClass >= 5` gate
/// (`0x623744`) never fires on it.
pub fn size_classes(&self) -> BTreeSet<u32> {
self.lookup.keys().map(|(sc, _)| *sc).collect()
}
pub fn len(&self) -> usize {
self.lookup.len()
}
pub fn is_empty(&self) -> bool {
self.lookup.is_empty()
}
}
fn n_u32_schema(name: &str, n: usize) -> Schema {
let mut s = Schema::new(name);
for i in 0..n {
s.add_field(SchemaField::new(format!("f{i}"), FieldType::UInt32));
}
s
}
/// Read the two tables off the patch chain.
pub fn load_death_thud_catalog(chain: &mut Chain) -> Result<DeathThudCatalog> {
let bytes = chain
.read_file("DBFilesClient\\TerrainTypeSounds.dbc")
.context("reading TerrainTypeSounds.dbc")?;
let rs = parse(
&bytes,
n_u32_schema("TerrainTypeSounds", 1),
"TerrainTypeSounds",
)?;
let terrain_sounds: BTreeSet<u32> = rs.records().iter().filter_map(|r| u32_at(r, 0)).collect();
let bytes = chain
.read_file("DBFilesClient\\DeathThudLookups.dbc")
.context("reading DeathThudLookups.dbc")?;
let rs = parse(
&bytes,
n_u32_schema("DeathThudLookups", 5),
"DeathThudLookups",
)?;
let mut lookup = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(size_class), Some(ts)) = (u32_at(r, 1), u32_at(r, 2)) else {
continue;
};
lookup.insert(
(size_class, ts),
(u32_at(r, 3).unwrap_or(0), u32_at(r, 4).unwrap_or(0)),
);
}
Ok(DeathThudCatalog {
lookup,
terrain_sounds,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The shipped 5875 tables, decoded: the full 45-row join, the 9-id terrain domain, the five
/// size classes, and the two spot-checks that pin the column order (a swapped SizeClass /
/// TerrainTypeSoundID pair would still load, and would still be 45 rows).
#[test]
fn real_death_thud_chain_resolves() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_death_thud_catalog(&mut chain).expect("load death thud catalog");
assert_eq!(cat.len(), 45, "all DeathThudLookups rows load");
assert_eq!(
cat.terrain_sounds().collect::<Vec<_>>(),
(1..=9).collect::<Vec<_>>(),
"TerrainTypeSounds is the bare 1..=9 enum"
);
assert_eq!(cat.size_classes(), (0..=4).collect(), "Small..Colossal");
// Size class 0 (Small) on terrain sound 1 (Dirt) → `DeathThudSmallDirt` 907 /
// `DeathThudWaterSmall` 1266. Size class 4 (Colossal) on 6 (Grass) →
// `DeathThudColossalGrass` 928 / `DeathThudWaterColossal` 1269.
assert_eq!(cat.resolve(0, 1), Some((907, 1266)));
assert_eq!(cat.resolve(4, 6), Some((928, 1269)));
assert_eq!(cat.kit(4, 6, false), Some(928));
assert_eq!(cat.kit(4, 6, true), Some(1269));
// The later-filled rows: Metallic (2) borrows the Stone kits, and its water column is 0 —
// silence in water, NOT a fall back to the land kit.
assert_eq!(
cat.resolve(0, 2),
Some((910, 0)),
"Small Metallic → Stone kit"
);
assert_eq!(cat.kit(0, 2, true), None, "and nothing in water");
assert_eq!(cat.kit(0, 2, false), Some(910));
// Out of domain both ways: terrain sound 0 is `TerrainType "None"`'s SoundID and is not a
// TerrainTypeSounds row at all; size class 5 is past the table.
assert_eq!(cat.resolve(0, 0), None);
assert_eq!(cat.resolve(5, 1), None);
}
/// Every terrain-sound id a `TerrainType` row names is a real `TerrainTypeSounds` row (or the
/// `0` of `"None"`), and every `DeathThudLookups` terrain axis value is one too — the join the
/// reference does by array index, checked as data.
#[test]
fn the_terrain_axis_agrees_across_the_three_tables() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let thuds = load_death_thud_catalog(&mut chain).expect("load death thud catalog");
let steps = crate::load_footstep_catalog(&mut chain).expect("load footstep catalog");
let domain: BTreeSet<u32> = thuds.terrain_sounds().collect();
for (_, terrain_sound) in thuds.lookup.keys() {
assert!(
domain.contains(terrain_sound),
"DeathThudLookups names terrain sound {terrain_sound}, which TerrainTypeSounds lacks"
);
}
// `TerrainType 10 "None"` is the unauthored default and its SoundID is 0 — not a row, and
// the reason a building's floor with no material makes no thud.
for terrain in 0..=10 {
let sound = steps
.sound_class_of(terrain)
.expect("every TerrainType row");
assert!(
sound == 0 || domain.contains(&sound),
"TerrainType {terrain} names terrain sound {sound}"
);
}
assert_eq!(steps.sound_class_of(10), Some(0), r#"TerrainType "None""#);
}
}
+5 -3
View File
@@ -29,9 +29,9 @@ mod install;
pub use install::{candidates, wow_data};
mod characters;
pub use characters::{
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, BlitSource,
CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EmblemLayer, EquipBlit,
EquipGeosets, GuildEmblem, StartOutfitItem,
equip_blits, equip_column, equip_region_candidates, equip_tex_dir, equip_tile, forearm_dressed,
BlitSource, CharCreateCatalog, CharSections, CharacterGeosets, DialRanges, EmblemLayer,
EquipBlit, EquipGeosets, GuildEmblem, StartOutfitItem,
};
mod camera_shakes;
mod cinematics;
@@ -163,6 +163,8 @@ mod environmental_damage;
pub use environmental_damage::{load_environmental_damage, EnvironmentalDamageTable};
mod footsteps;
pub use footsteps::{load_footprint_textures, load_footstep_catalog, FootstepCatalog};
mod death_thud;
pub use death_thud::{load_death_thud_catalog, DeathThudCatalog};
mod sound_entries;
pub use sound_entries::{load_sound_kit_catalog, sound_kit_flags, SoundKit, SoundKitCatalog};
mod sound_provider;