a disarmed fighter punches, and the quest log tags its elite rows

- a disarmed fighter swings bare-fisted: `UNIT_FLAG_DISARMED` was read nowhere but a dev banner, and the reference applies it inside `GetWeapon` on the `visFlag == 0` path, so the swing lands on AttackUnarmed; the RE cross-check (nine workers converging byte-for-byte) corrected the first reading twice — disarm hides exactly ONE weapon, main hand first (`5ec28d je 0x5ec2aa`), so a disarmed dual-wielder keeps swinging its dagger; and the weapon comes OFF the hand: the field-change reflex `0x5ff580` detaches a drawn main-hand weapon when the bit goes up, while a stowed one stays frozen on the back; one pure ladder feeds every unarmed-view site of the 82-site `visFlag` census — parry, the sheath machine, the melee sounds (a disarmed punch does not clang like a sword), Heroic Strike greying out, and the Attack button showing `Spell-Reset` with the weapon still equipped (1863)
- a quest-log row reads `(Elite)`, `(Dungeon)`, `(Raid)` or `(PvP)`: `QuestInfo.dbc` was never parsed and `GetQuestLogTitle`'s third return was a literal nil; the table is seven rows read out of `patch-2.MPQ`, SPARSE (1 Elite · 21 Life · 41 PvP · 62 Raid · 81 Dungeon · 82 World Event · 83 Legendary — vanilla's 1 is "Elite", not TBC's "Group"), and the base `dbc.MPQ` copy has only four, so a chain stopping short would lose Dungeon, World Event and Legendary; the row gains the reference's own right-flush tag string and its precedence — a finished quest's FAILED/COMPLETE word OVERWRITES the tag — replacing the " (Complete)" we appended to the title; the RE round confirmed the tag path and found the arity bug: six values ALWAYS (`mov eax,6`), the level a NUMBER 0 even on a header, where we gave one nil; the reference's title shrink stays out: an explicit width is a WRAP width here (1873)
- the death thud is silent indoors, and that is the content: BOTH the footstep and the thud lookups key on `TerrainType.SoundID`, and TerrainType 10 "None" has SoundID 0 — the footstep table has a row there for 17 classes and `DeathThudLookups` has none, so the floor that creaks underfoot swallows the body that lands on it; 10 075 of 10 299 WMO materials (97.8%) are TerrainType 10 and 694 of 815 WMO roots have no surface that can thud, every inn and tavern among them, the Kharanos inn included — the code was always right and the prose was not; the RE round also confirmed the strict `depth > 2.0` sense and the size-class column, pinned a sound id of 0 as silence, and placed `0x6005d0` as the loot sparkle, not a second sound; `thudcensus` prints all 121 buildings that do thud rather than a head, because a truncated list cannot answer "does THIS one" (1870)
- a questgiver's overhead marker follows its rider on and off the mount: the reference re-runs the attach from the `UNIT_FIELD_MOUNTDISPLAYID` watch handler, and benilla picked the slot once at build, relying on a teardown the reseat rework no longer does (B199), so the seat rode at the WRONG slot; `overhead_slot` is now the one definition of the pick (`0x6074c0`) and the marker rebuilds when it moves (1871); and the chat menu's kit was ours overwriting the reference's — `UIMenu.xml` loads earlier on the chain and our `ChatFrame.xml` carried the same kit under the SAME ten names, faithful enough that deleting it changes nothing; nothing ever opened the chat menu, and now a test does; an event census holds the gap between registered and fired events at exactly eighteen, failing on a surprise AND on a quietly gained producer — features not yet built, none a split pair (1869)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:49:33 +07:00
co-authored by Claude Fable 5.1
parent 0535fe9097
commit 766395116c
31 changed files with 2146 additions and 283 deletions
+5 -106
View File
@@ -1204,112 +1204,11 @@
end
end
-- ── BenillaUIMenu: the 1.12 UIMenu kit, re-expressed (ref UIMenu.lua — the chat menu's
-- row engine: fixed 16px rows in a tooltip-backdrop box, a 2 s no-hover timeout that
-- walks up parentMenu chains, nested submenus opened on row hover). Constants quoted.
-- Scoped here because the chat menu is its only consumer; a second consumer graduates
-- it to its own file.
BENILLA_UIMENU_BUTTON_HEIGHT = 16
BENILLA_UIMENU_BUTTON_WIDTH = 104
BENILLA_UIMENU_BORDER_HEIGHT = 12
BENILLA_UIMENU_BORDER_WIDTH = 12
BENILLA_UIMENU_NUMBUTTONS = 32
BENILLA_UIMENU_TIMEOUT = 2.0
function UIMenu_Initialize()
local menu = this
menu.numButtons = 0
menu.subMenu = ""
local name = menu:GetName()
for i = 1, BENILLA_UIMENU_NUMBUTTONS do
local button = getglobal(name .. "Button" .. i)
button:SetWidth(BENILLA_UIMENU_BUTTON_WIDTH)
button:SetHeight(BENILLA_UIMENU_BUTTON_HEIGHT)
button:Hide()
getglobal(button:GetName() .. "ShortcutText"):Hide()
end
menu:SetWidth(BENILLA_UIMENU_BUTTON_WIDTH + BENILLA_UIMENU_BORDER_WIDTH * 2)
menu:SetHeight(BENILLA_UIMENU_BORDER_HEIGHT * 2)
end
-- Rows past the button pool are dropped (the ref message()s; nothing we author overflows).
function UIMenu_AddButton(text, shortcut, func, nested)
local menu = this
local id = menu.numButtons + 1
if id > BENILLA_UIMENU_NUMBUTTONS then return end
menu.numButtons = id
local button = getglobal(menu:GetName() .. "Button" .. id)
if text then button:SetText(text) end
button.func = func
button.nested = nested
button:Show()
if shortcut then
local s = getglobal(button:GetName() .. "ShortcutText")
s:SetText(shortcut)
s:Show()
end
menu:SetHeight(id * BENILLA_UIMENU_BUTTON_HEIGHT + BENILLA_UIMENU_BORDER_HEIGHT * 2)
end
function UIMenu_OnShow()
this.timeleft = BENILLA_UIMENU_TIMEOUT
this.counting = 1
end
function UIMenu_OnUpdate(elapsed)
if this.counting == 1 then
local timeleft = (this.timeleft or BENILLA_UIMENU_TIMEOUT) - elapsed
if timeleft <= 0 then
this:Hide()
return
end
this.timeleft = timeleft
end
end
function UIMenu_StartCounting(menu)
menu.counting = 1
if menu.parentMenu then UIMenu_StartCounting(getglobal(menu.parentMenu)) end
end
function UIMenu_StopCounting(menu)
menu.counting = 0
menu.timeleft = BENILLA_UIMENU_TIMEOUT
if menu.parentMenu then UIMenu_StopCounting(getglobal(menu.parentMenu)) end
end
function UIMenuButton_OnLoad()
this:SetPoint("TOP", this:GetParent():GetName(), "TOP", 0,
-((this:GetID() - 1) * BENILLA_UIMENU_BUTTON_HEIGHT) - BENILLA_UIMENU_BORDER_HEIGHT)
end
function UIMenuButton_OnClick()
if this.func then this.func() end
this:GetParent():Hide()
PlaySound("UChatScrollButton")
end
function UIMenuButton_OnEnter()
local nested = this.nested
if nested then
local menu = getglobal(nested)
if not menu:IsVisible() then
local oldMenu = getglobal(this:GetParent().subMenu or "")
if oldMenu then oldMenu:Hide() end
this:GetParent().subMenu = nested
menu:SetPoint("BOTTOMLEFT", this:GetName(), "BOTTOMRIGHT", 10, -12)
menu:Show()
end
UIMenu_StopCounting(menu)
else
UIMenu_StopCounting(this:GetParent())
end
end
function UIMenuButton_OnLeave()
UIMenu_StartCounting(this:GetParent())
if this.nested then UIMenu_StartCounting(getglobal(this.nested)) end
end
-- The UIMenu kit — the chat menu's row engine — is the REFERENCE's, off the player's
-- chain: `Interface\FrameXML\UIMenu.xml` at manifest line 194 sources `UIMenu.lua` at its
-- own line 3, and this file loads at 939. Our transcription kept the reference's names, so
-- it OVERWROTE the chain's ten verbs from the day that entry landed — the manifest header
-- claimed the swap "retires the transcription", and only the XML half had been (1869).
-- ── The chat menu rows (ref ChatMenu_* in ChatFrame.lua, adapted): every type row
-- prefixes the box with the type's slash and lets the LIVE PARSE convert — the ref's
+63 -26
View File
@@ -205,12 +205,10 @@
--
-- The insert (l.480) is `gsub(this:GetText(), " *(.*)", "%1")` — the row LABEL with its
-- leading indent trimmed, i.e. the bare quest name; note it is plain text, NOT a
-- hyperlink (1.12 has no |Hquest: link). We read `GetQuestLogTitle(index)` instead of
-- the label, because our label is not the ref's: this window appends the Complete/Failed
-- tag onto the ONE title string (the v1 simplification in BenillaQuestLogList_Update,
-- where the ref keeps a separate right-justified $parentTag FontString), so the ref's
-- gsub over our label would post "Quest 1 (Complete)". The API title is exactly the
-- string the ref's own trim yields — same result, different expression. `ChatFrameEditBox
-- hyperlink (1.12 has no |Hquest: link). We read `GetQuestLogTitle(index)` rather than
-- gsub'ing the label: the API title IS the string the ref's trim yields (our label is
-- that title behind the same two-space indent, and in both windows the tag lives on its
-- own FontString, not on the label) — same result, one fewer expression. `ChatFrameEditBox
-- and` guards a window test that hasn't loaded ChatFrame.xml (BenillaPaperDollSlot_OnClick's
-- precedent).
--
@@ -356,24 +354,52 @@
text = " " .. text
button:SetNormalTexture("")
button.foldHighlight:SetTexture("")
if isComplete == 1 then
text = text .. " (Complete)"
elseif isComplete == -1 then
text = text .. " (Failed)"
end
end
-- pin §2: the ref recolors via a SEPARATE right-side tag FontString; one combined
-- string (title + tag suffix, above) is our v1 simplification — see this widget's
-- own template comment.
-- The TAG, ref l.187-195 verbatim, INCLUDING the precedence: a finished quest's
-- Failed/Complete word OVERWRITES the engine's tag — one row never shows both,
-- and the state word wins. `questTag` is the bare word from the engine
-- (`Elite`, `Dungeon`, `Raid`, `PvP`, `Life`, `World Event`, `Legendary` —
-- QuestInfo.dbc keyed by the quest template's Type); the parentheses are the
-- ref's, added here. Headers arrive with a nil tag and a nil isComplete, so
-- this clears their string without a branch of its own (ref l.187 is likewise
-- outside the isHeader arm).
--
-- KNOWN DEVIATION, and the only one left on this row: the ref also SHRINKS the
-- title to keep it out from under the tag (l.196-203, `275 - 15 - tagWidth`,
-- re-seating the watch check off the capped width at l.205-213). We don't cap:
-- an explicit width on a FontString is a WRAP width here
-- (`RegionData::measure_key`), so capping a 33+ character title would wrap it
-- onto a second line inside a 16px row — worse than the overlap it prevents.
-- Our check needs no reseat either: it anchors off the title's own ink
-- (template comment), which is what the ref's dummy-measure arithmetic
-- reconstructs.
if isComplete and isComplete < 0 then
tag = FAILED
elseif isComplete and isComplete > 0 then
tag = COMPLETE
end
if tag then
button.tag:SetText("(" .. tag .. ")")
else
button.tag:SetText("")
end
button.text:SetText(text)
button.questIndex = index
-- Ref l.239-246: the tag takes the SAME difficulty colour as the title (the
-- ref colours title, tag and the group-mate count together). What we don't do
-- is the ref's SELECTED-row recolour (l.253-256/l.336: highlight-white on both
-- title and tag) — our title can't take it either, since the ref gets it from
-- LockHighlight on a ButtonText and ours is a plain FontString. Whole-row, not
-- tag-only, so the two stay in step; the selection already reads off
-- QuestLogHighlightFrame.
local c
if isHeader then
local c = QuestDifficultyColor["header"]
button.text:SetTextColor(c.r, c.g, c.b)
c = QuestDifficultyColor["header"]
else
local c = GetDifficultyColor(level)
button.text:SetTextColor(c.r, c.g, c.b)
c = GetDifficultyColor(level)
end
button.text:SetTextColor(c.r, c.g, c.b)
button.tag:SetTextColor(c.r, c.g, c.b)
-- pin §2's $parentCheck: the per-row watch checkbox. SetTexture(path)/
-- SetTexture(nil) as the show/hide toggle — the icon-slot blanking pattern
-- (the icon-clearing every slot repaint does). (Regions DO have Show/Hide these days —
@@ -1310,10 +1336,9 @@
One list row (pin §2's QuestLogTitleButtonTemplate, QuestLogFrame.xml:4-107, ~300×16). Text
lives on a NAMED `$parentText` FontString (not the auto ButtonText slot — same convention
`GossipFrame.xml`'s `GossipTitleButtonTemplate` uses for its `$parentLabel`), so it can carry
an explicit LEFT justify. v1 simplification: the ref's Complete/Failed tag rides a SEPARATE
right-justified `$parentTag` FontString the client shrinks the title to make room for; we
append the tag straight onto the one title string instead (`BenillaQuestLogList_Update`) —
simpler, no dynamic width math, costs only the tag not being right-flush.
an explicit LEFT justify. The tag rides its own right-flush `$parentTag` string, as the
ref's does — see that FontString's own comment, and `BenillaQuestLogList_Update` for the
one thing the ref does with it that we don't (the title width cap).
State art is now ref-verbatim (QuestLogTitleButtonTemplate, ref QuestLogFrame.xml:68-107):
the 16x16 plus/minus fold icon slot at LEFT+3 is the button's NormalTexture (the Lua swaps
Plus/Minus per header fold state and CLEARS it on quest rows — SetNormalTexture("")), the
@@ -1333,10 +1358,10 @@
and with the ref's text inset of 20 that nets to `ink end + 4`. We get the same geometry
from the anchor graph instead of the Lua dummy-measure hack: the title FontString is
width-less (its resolved rect hugs its host-measured ink — the measure round-trip), and
the check anchors LEFT → the title's RIGHT at +4. Known deviation, unchanged: the ref
shrinks the title width to fit the right-justified tag (its `textWidth` cap); our combined
title+tag string means a "(Complete)" row seats the check after the tag, not between
title and tag.
the check anchors LEFT → the title's RIGHT at +4. That IS the ref's seat: its
`textWidth+24` off the row's left, with `textWidth` the un-capped title ink, is ink-end + 4
given the 20-unit text inset — the ref only computes it because its own `GetWidth` returns
whatever width it last capped the title to.
-->
<Button name="QuestLogTitleButtonTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="300" y="16"/></Size>
@@ -1345,6 +1370,17 @@
<FontString name="$parentText" inherits="GameFontNormal" justifyH="LEFT">
<Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="20" y="0"/></Offset></Anchor></Anchors>
</FontString>
<!-- The quest TAG (ref QuestLogFrame.xml:10-21, verbatim geometry): a right-flush
GameFontNormal string at RIGHT-2, height 16, width 0 so it hugs its own ink.
It carries "(Elite)"/"(Dungeon)"/"(Raid)"/"(PvP)" — and the Complete/Failed
state, which in the reference rides this same string, not the title. Sits in
ARTWORK beside the title (the ref parks it in BACKGROUND, where its own title
is the Button's ButtonText slot rather than a layer region — ours is a named
FontString, so the two live in one layer). -->
<FontString name="$parentTag" inherits="GameFontNormal" justifyH="RIGHT">
<Size><AbsDimension x="0" y="16"/></Size>
<Anchors><Anchor point="RIGHT" relativePoint="RIGHT"><Offset><AbsDimension x="-2" y="0"/></Offset></Anchor></Anchors>
</FontString>
<Texture name="$parentCheck">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentText" relativePoint="RIGHT"><Offset><AbsDimension x="4" y="0"/></Offset></Anchor></Anchors>
@@ -1362,6 +1398,7 @@
<Scripts>
<OnLoad>
self.text = getglobal(self:GetName() .. "Text")
self.tag = getglobal(self:GetName() .. "Tag")
self.check = getglobal(self:GetName() .. "Check")
-- Via the state-texture getter (era API), not getglobal: the loader doesn't yet
-- publish a named state texture's `name=` as a global (known gap — Layers regions
+7 -2
View File
@@ -185,8 +185,13 @@ Interface\FrameXML\UIDropDownMenu.xml
# The kit was a block inside our ChatFrame.xml — a transcription that kept the reference's names
# for the addon surface and said so, and whose own note recorded a divergence it had to make (the
# row label authored LEFT-anchored, because the reference leans on 1.12's ButtonText default rect
# and our lazy label slot did not reproduce it). Sourcing the file retires the transcription and
# the divergence together.
# and our lazy label slot did not reproduce it).
#
# **Sourcing the file retired only the XML half, and this header claimed both for months.** The ten
# `UIMenu_*`/`UIMenuButton_*` functions stayed in ChatFrame.xml under the reference's own names, and
# that file loads at 939 — so OURS overwrote the chain's from the day this entry landed, the reverse
# of 1855's direction and just as silent. They are gone now; the kit is the reference's end to end
# (1869).
#
# THE POSITION is the reference's own (FrameXML.toc l.12, straight after GameTooltip.xml, which it
# needs for the tooltip backdrop its menu wears). Far above ChatFrame.xml, which is the one thing
+5 -2
View File
@@ -490,13 +490,16 @@ pub(super) fn seed_ui_fixture(
};
// Entry 1 (selected by first-valid auto-selection): in progress, one creature
// objective at 3/10 (the slot counter below) + one item objective (bags are empty in
// capture → 0/5), choice + fixed rewards, money.
// capture → 0/5), choice + fixed rewards, money. `quest_type: 1` is QuestInfo.dbc's
// "Elite" — the real 783 is a plain quest, but this fixture is the only thing that
// puts a row TAG in front of the capture, and the tag and the "(Complete)" state word
// are different branches of the same row string (entry 2 covers the other).
quest_log.insert_template(QuestTemplate {
quest_id: 783,
method: 2,
level: 2,
zone_or_sort: 12,
quest_type: 0,
quest_type: 1,
rep_objective_faction: 0,
rep_objective_value: 0,
next_quest_in_chain: 0,
+192
View File
@@ -69,6 +69,15 @@ mod lod;
/// (or non-item) hand. Written by the held-item resolution ([`crate::entities`], decision 0072) from
/// the same descriptor data that places the weapon models; read by the swing/ready animation
/// selectors (decision 0073 — the byte-verified `GetWeapon` byte pair `0x605e30`).
///
/// **The two readings of a hand** (decision 1863). The reference's accessor takes a `visFlag`:
/// `GetWeapon(slot, 1)` is what is *worn* there, `GetWeapon(slot, 0)` is what the unit can
/// *fight* with — and the two differ by exactly one live descriptor bit, [`UNIT_FLAG_DISARMED`].
/// The fields below are the `visFlag == 1` reading — what the paperdoll and the dress-up model
/// show, the two surfaces that keep the weapon while disarmed because they read the flag nowhere.
/// Everything that decides how the unit *behaves* — swing, Ready idle, parry, the sheath machine,
/// the swing sound, the attached model — goes through [`Wielded::armed_main`] /
/// [`Wielded::armed_off`], which apply the ladder.
#[derive(Component, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Wielded {
pub(crate) main: Option<(u8, u8)>,
@@ -99,6 +108,87 @@ pub(crate) struct Wielded {
/// for players, the `UNIT_VIRTUAL_ITEM_INFO` byte triple for creatures — so nothing here is a
/// guess. Indexed by held slot: 0 mainhand · 1 offhand · 2 ranged.
pub(crate) materials: [u8; 3],
/// `UNIT_FIELD_FLAGS & `[`UNIT_FLAG_DISARMED`] — a rogue's Disarm, up on the unit's own
/// descriptor and therefore replicated for **every** unit, not just ours (CGUnit's ladder is
/// byte-identical to CGPlayer's, so a disarmed creature behaves exactly like a disarmed
/// player). Resolved beside the hands because that is where the reference reads it: inside
/// `GetWeapon` itself (`[[unit+0x110]+0xa0] & 0x200000`), off the same descriptor these pairs
/// come from.
pub(crate) disarmed: bool,
}
/// `UNIT_FIELD_FLAGS` bit 21 — **`UNIT_FLAG_DISARMED`** (vmangos `UnitDefines.h`), read by the
/// reference inside `GetWeapon` at `0x5ec2b8` (CGPlayer) and `0x605e58`/`0x605e98` (CGUnit):
/// `test dword ptr [ecx+0xa0], 0x200000`. `0xa0 = 4 × 0x28` pins it to field `UNIT_FIELD_FLAGS`
/// off the descriptor base `[unit+0x110]`.
pub(crate) const UNIT_FLAG_DISARMED: u32 = 0x0020_0000;
/// `ItemClass` 2 — **WEAPON**. The class byte the reference's disarm ladder tests, and the whole
/// of its test: no subclass, no inventory type, nothing else. Disarm takes weapons, so a shield or
/// a held-in-off-hand trinket is never the hand it hides.
pub(crate) const ITEM_CLASS_WEAPON: u8 = 2;
/// **The disarm ladder** — which single held slot `UNIT_FLAG_DISARMED` hides, given what each
/// melee hand holds (decision 1863; wow-re `disarm-weapon-gate-law.md` §2, byte-verified by a
/// trio including a cold decoder-first read, both class bodies identical).
///
/// The reference does not "empty a disarmed unit's hands". `GetWeapon(slot, visFlag = 0)` runs a
/// **two-probe ladder with main-hand precedence**, and the off-hand probe's polarity is the
/// opposite of the main hand's:
///
/// ```text
/// ; slot 0 (MAIN HAND) — one probe, 0x5ec2ae
/// 5ec2b8: test dword ptr [ecx+0xa0],0x200000 ; disarmed?
/// 5ec2cc: call [edx+0x98] ; GetWeapon(0, 1) — vis=1 skips the gate
/// 5ec2d6: cmp byte ptr [eax],2 ; je 0x5ec346 ; class 2 -> RETURN NULL
///
/// ; slot 1 (OFF HAND) — two probes, 0x5ec262, MAIN HAND asked FIRST
/// 5ec280: call [edx+0x98] ; GetWeapon(0, 1) <<< slot 0, not 1
/// 5ec28a: cmp byte ptr [eax],2 ; je 0x5ec2aa ; main hand IS a weapon -> PROCEED (keep the off hand)
/// 5ec297: call [eax+0x98] ; GetWeapon(1, 1)
/// 5ec2a1: cmp byte ptr [eax],2 ; je 0x5ec346 ; off hand IS a weapon -> RETURN NULL
/// ```
///
/// So **exactly one weapon is hidden — the first one found scanning main hand → off hand** — and
/// a disarmed dual-wielder keeps swinging their off-hand weapon, model and all. vmangos agrees
/// from the other side: `Unit::CanUseEquippedWeapon` returns `!DISARMED` for `BASE_ATTACK` and an
/// unconditional `true` for `OFF_ATTACK` and `RANGED_ATTACK`. The **ranged slot is never gated**
/// (`0x5ec25e` pushes equip index `0x11` and jumps past both tests).
///
/// Stated over classes rather than over [`Wielded`] because two layers need the same law from two
/// different data sources — the animation layer off the resolved hands, the action bar's
/// equipped-item test off the raw inventory (`0x5ea5d0`'s slot 0/1/2 loop).
pub(crate) fn disarmed_hand(main_class: Option<u8>, off_class: Option<u8>) -> Option<usize> {
if main_class == Some(ITEM_CLASS_WEAPON) {
return Some(0);
}
if off_class == Some(ITEM_CLASS_WEAPON) {
return Some(1);
}
None
}
impl Wielded {
/// The held slot this unit's disarm hides right now — [`disarmed_hand`] over its own hands,
/// `None` while the flag is down. The equipment layer reads it to know which weapon the
/// reference's flag reflex takes off the hand ([`crate::entities`]).
pub(crate) fn disarmed_hand(&self) -> Option<usize> {
self.disarmed
.then(|| disarmed_hand(self.main.map(|(c, _)| c), self.off.map(|(c, _)| c)))
.flatten()
}
/// The **mainhand** as `GetWeapon(0, 0)` sees it — the reading the swing (`0x6246a0`), the
/// Ready idle (`0x5fcdc0`), the parry LUT, the sheath machine and the swing sound all take.
pub(crate) fn armed_main(&self) -> Option<(u8, u8)> {
self.main.filter(|_| self.disarmed_hand() != Some(0))
}
/// The **offhand** as `GetWeapon(1, 0)` sees it. Nulled only when the main hand did *not*
/// claim the disarm — see [`disarmed_hand`].
pub(crate) fn armed_off(&self) -> Option<(u8, u8)> {
self.off.filter(|_| self.disarmed_hand() != Some(1))
}
}
/// The unit is engaged in melee auto-attack (`SMSG_ATTACKSTART` .. `ATTACKSTOP`, decision 0073):
@@ -1247,3 +1337,105 @@ mod nock_latch_tests {
);
}
}
#[cfg(test)]
mod disarm_tests {
use super::*;
/// **The ladder, hand by hand** (decision 1863; wow-re `disarm-weapon-gate-law.md` §2c). The
/// reference hides **exactly one weapon**, main hand first — so a disarmed dual-wielder
/// punches with the main hand and still swings its off-hand weapon. Every selector downstream
/// reaches its own unarmed leg by its own table; there is no disarm case in any of them.
#[test]
fn disarm_hides_one_weapon_main_hand_first() {
let worn = Wielded {
main: Some((2, 0x7)), // 1H sword
off: Some((2, 0xf)), // dagger
ranged: Some((2, 0x2)), // bow
..Default::default()
};
let disarmed = Wielded {
disarmed: true,
..worn
};
// CONTROL — the weapon tables, untouched while the flag is down.
assert_eq!(worn.disarmed_hand(), None);
assert_eq!(select::swing_anim_main(worn.armed_main()), 17); // Attack1H
assert_eq!(select::swing_anim_off(worn.armed_off()), 88); // AttackOffPierce
assert_eq!(select::ready_anim(worn.armed_main()), 26); // Ready1H
// Disarmed, both hands full: the MAIN HAND is the one hidden, and its claim CANCELS the
// off-hand probe (`5ec28d je 0x5ec2aa`) — the dagger keeps swinging as a dagger.
assert_eq!(disarmed.disarmed_hand(), Some(0));
assert_eq!(disarmed.armed_main(), None);
assert_eq!(disarmed.armed_off(), Some((2, 0xf)), "the off hand is KEPT");
assert_eq!(select::swing_anim_main(disarmed.armed_main()), 16); // AttackUnarmed
assert_eq!(select::swing_anim_off(disarmed.armed_off()), 88); // still the dagger
assert_eq!(select::ready_anim(disarmed.armed_main()), 25); // ReadyUnarmed
// The RANGED slot has no gate at all: `0x5ec25e` pushes equip index `0x11` and jumps
// straight past both probes, so a disarmed hunter still nocks.
assert_eq!(disarmed.ranged, Some((2, 0x2)));
assert_eq!(select::ranged_load_anim(disarmed.ranged), 105); // LoadBow
// …and the WORN reading is untouched throughout — it is what the paperdoll shows.
assert_eq!(disarmed.main, worn.main);
}
/// The off hand is hidden only when the main hand does **not** claim the disarm — an empty or
/// non-weapon main hand. That is the one case that reaches AttackUnarmedOff(117), whose
/// `AnimationData.dbc` row (id 117, fallback 87) is a clip distinct from AttackUnarmed(16).
#[test]
fn an_empty_main_hand_hands_the_disarm_to_the_off_hand() {
let off_only = Wielded {
main: None,
off: Some((2, 0xf)),
disarmed: true,
..Default::default()
};
assert_eq!(off_only.disarmed_hand(), Some(1));
assert_eq!(off_only.armed_off(), None);
assert_eq!(select::swing_anim_off(off_only.armed_off()), 117);
// A NON-WEAPON main hand does not claim it either — the test is `ItemClass == 2` only.
let torch = Wielded {
main: Some((15, 0)), // class 15 = miscellaneous
off: Some((2, 0xf)),
disarmed: true,
..Default::default()
};
assert_eq!(torch.disarmed_hand(), Some(1));
assert_eq!(
torch.armed_main(),
Some((15, 0)),
"a non-weapon is not taken"
);
assert_eq!(torch.armed_off(), None);
}
/// Neither hand holds a weapon: the flag hides nothing at all, and a shield stays a shield.
#[test]
fn the_ladder_takes_weapons_and_leaves_the_rest() {
let shielded = Wielded {
main: None,
off: Some((4, 6)), // class 4 = ARMOR, subclass 6 = shield
disarmed: true,
..Default::default()
};
assert_eq!(shielded.disarmed_hand(), None);
assert_eq!(shielded.armed_off(), Some((4, 6)));
}
/// The pure ladder, over the classes alone — the form the action bar's equipped-item test
/// (`0x5ea5d0`'s slot 0/1/2 loop) reads off the raw inventory.
#[test]
fn the_ladder_over_classes() {
assert_eq!(disarmed_hand(Some(2), Some(2)), Some(0));
assert_eq!(disarmed_hand(Some(2), None), Some(0));
assert_eq!(disarmed_hand(None, Some(2)), Some(1));
assert_eq!(disarmed_hand(Some(4), Some(2)), Some(1));
assert_eq!(disarmed_hand(None, None), None);
assert_eq!(disarmed_hand(Some(4), Some(4)), None);
}
}
+22 -3
View File
@@ -911,7 +911,10 @@ pub(super) fn drive_animations(
if dead {
return None;
}
let id = defense_anim(vs, wielded.and_then(|w| w.main));
// The parry LUT `0x60ec00` reads the victim's mainhand through `GetWeapon(0, 0)`
// (1863), so a disarmed victim's parry finds no weapon and the client bails — no
// clip, which is this function's own empty-hand leg.
let id = defense_anim(vs, wielded.and_then(|w| w.armed_main()));
if let Some(id) = id {
debug!("defense: unit {entity} anim {id} (victimState {vs})");
}
@@ -928,10 +931,15 @@ pub(super) fn drive_animations(
.map(|(req, _)| match req {
OneShotReq::Swing(hit_info) => {
let w = wielded.copied().unwrap_or_default();
// The COMBAT reading of the hand ([`Wielded::armed_main`]): the
// reference's swing selector `0x6246a0` calls `GetWeapon(slot, 0)`,
// so a disarmed attacker's weapon reads as absent and this lands on
// AttackUnarmed(16) / AttackUnarmedOff(117) — with the weapon model
// still in the fist (decision 1863).
let id = if hit_info & 0x4 != 0 {
swing_anim_off(w.off)
swing_anim_off(w.armed_off())
} else {
swing_anim_main(w.main)
swing_anim_main(w.armed_main())
};
debug!("swing: unit {entity} anim {id} (hitInfo {hit_info:#x})");
id
@@ -942,6 +950,17 @@ pub(super) fn drive_animations(
})
.unwrap_or_default();
requests.extend(defense);
// The play-time substitution ([`select::unarmed_special`], `0x5fe2f0`): a Special1H/2H
// asked for by a unit whose hands both read empty comes out as SpecialUnarmed(118). It
// lives at the PLAY seam in the reference, so it is applied to every request here rather
// than inside any one selector — a disarmed Eviscerate and a weaponless one are the same
// case to it (decision 1863).
{
let w = wielded.copied().unwrap_or_default();
for id in &mut requests {
*id = select::unarmed_special(*id, w.armed_main(), w.armed_off());
}
}
// The deferred-cache consumer (the client's `+0xd60` read at the base recompute,
// decision 0406): the moment no one-shot is live, the parked combat clip plays — the
// swing the Eviscerate spin deferred fires once the spin ends. A frame with fresh
@@ -377,7 +377,10 @@ pub(super) fn run(
// the flags still agree and cross-fades normally when they changed mid-air.
// Normal gait: select, cross-fade on change, keep the rate synced each frame.
// The engaged standing idle: the weapon-class Ready pick (decision 0073).
let ready = (engaged && !moving).then(|| ready_anim(wielded.and_then(|w| w.main)));
// `GetWeapon(0, 0)` again ([`Wielded::armed_main`]) — `0x5fcdc0` passes
// visFlag 0, so a disarmed unit stands in ReadyUnarmed(25) (decision 1863).
let ready =
(engaged && !moving).then(|| ready_anim(wielded.and_then(|w| w.armed_main())));
// The ranged standing idle (0099 phase 5): the byte-verified entry gate
// ([`select::ranged_idle_gate`]) → the ranged weapon's Load clip, played
// ONCE, then promoted to the Hold by its own completion. ENTRY is the local
@@ -20,6 +20,22 @@ use super::super::{
use super::drive_animations;
use crate::net::NetCommands;
/// A hand holding something that is **not a weapon** — a torch, a held-in-off-hand trinket.
///
/// Several fixtures below want a unit whose auto-attack swing is AttackUnarmed(16) *and* whose
/// spell-kit clip is the armed Special1H(57). Since decision 1863 that combination needs a
/// non-EMPTY hand: the reference's play-time substitution (`0x5fe2f0` @ `0x5fe3cc`, and our
/// [`super::super::select::unarmed_special`]) keys on `GetWeapon(slot, 0)` being **NULL**, not on
/// the item failing to be a weapon — so a held non-weapon keeps the armed special while the swing
/// table still sends it to 16. An empty-handed unit plays SpecialUnarmed(118), which has its own
/// test.
fn holding_a_non_weapon() -> Wielded {
Wielded {
main: Some((15, 0)), // ItemClass 15 = miscellaneous
..Default::default()
}
}
fn clip(anim_id: u16, node: u32, looping: bool) -> AnimClip {
AnimClip {
anim_id,
@@ -595,6 +611,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
holding_a_non_weapon(),
))
.id();
let swinger = app
@@ -604,6 +621,7 @@ fn whiff_slowdown_spares_a_non_swing_oneshot() {
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
holding_a_non_weapon(),
))
.id();
app.update(); // settle: Stand holds both gait slots
@@ -681,6 +699,7 @@ fn same_frame_collision_fast_paths_the_second_combat_clip() {
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
holding_a_non_weapon(),
))
.id()
};
@@ -1374,6 +1393,7 @@ fn jumper(app: &mut App) -> Entity {
AnimationTransitions::new(),
AnimDriver::default(),
MovementState::default(),
holding_a_non_weapon(),
))
.id()
}
@@ -2203,6 +2223,7 @@ fn a_melee_to_ranged_toggle_stows_both_hands_before_it_reaches_for_the_bow() {
ranged_sheath: 1,
ranged_inv: 0x0f,
materials: [1, 6, 2],
disarmed: false,
},
))
.id();
@@ -3447,3 +3468,266 @@ fn the_interaction_face_me_shuffles_its_feet_for_the_whole_turn() {
assert!(held, "the swing back is ShuffleRight, got {:?}", gait(&app));
assert!(peak > 0.95, "and blends the whole way in: {peak}");
}
/// **A disarmed attacker fights bare-handed** (decision 1863) — the whole of `UNIT_FLAG_DISARMED`
/// on the animation side, run through the real driver against an armed control on the same frame.
/// The reference gets here with no disarm case in any selector: `GetWeapon(slot, 0)` hands the
/// swing (`0x6246a0`) and the Ready idle (`0x5fcdc0`) a NULL hand, and their existing unarmed legs
/// do the rest. So the pass is *unarmed clips*, and the failure this pins is the sword's Attack1H
/// coming out of a hand the server says is empty.
#[test]
fn a_disarmed_attacker_swings_and_stands_unarmed() {
fn model() -> ModelAnimations {
ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, 1, true), // Stand
clip(17, 2, false), // Attack1H — the sword swing
clip(16, 3, false), // AttackUnarmed — the fist
clip(26, 4, true), // Ready1H — the sword stance
clip(25, 5, true), // ReadyUnarmed — the bare-handed stance
clip(88, 6, false), // AttackOffPierce — the offhand dagger
clip(117, 7, false), // AttackUnarmedOff — the offhand fist
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
}
}
// Sword and dagger, engaged: the same loadout twice, differing only in the descriptor bit.
fn fighter(app: &mut App, disarmed: bool) -> Entity {
app.world_mut()
.spawn((
model(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
Engaged,
Wielded {
main: Some((2, 0x7)), // 1H sword
off: Some((2, 0xf)), // dagger
main_sheath: 3,
off_sheath: 3,
disarmed,
..Default::default()
},
))
.id()
}
let gait = |app: &App, unit: Entity| app.world().entity(unit).get::<AnimDriver>().unwrap().gait;
let playing = |app: &App, unit: Entity, node: u32| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(AnimationNodeIndex::new(node as usize))
.is_some()
};
// The engaged standing idle: `0x5fcdc0`'s weapon-class Ready, or ReadyUnarmed for the hand
// the disarm emptied.
let mut stand = app();
let armed = fighter(&mut stand, false);
let disarmed = fighter(&mut stand, true);
stand.update();
assert_eq!(gait(&stand, armed), Some(26), "the sword stands Ready1H");
assert_eq!(
gait(&stand, disarmed),
Some(25),
"the disarmed hand stands ReadyUnarmed, sword or no sword"
);
// One swing per hand, each on its own pair — a second swing over a live one is the combat
// fast-path's deferral (`0x5fcc10`), which has nothing to say about disarm.
let swings = |hit_info: u32| {
let mut app = app();
let armed = fighter(&mut app, false);
let disarmed = fighter(&mut app, true);
app.update();
for (unit, seq) in [(armed, 1u64), (disarmed, 2)] {
app.world_mut().write_message(SwingMessage {
attacker: unit,
victim: None,
hit_info,
victim_state: 1,
damage: 7,
seq,
});
}
app.update();
let nodes = |unit| {
(1..8u32)
.filter(|n| playing(&app, unit, *n))
.collect::<Vec<_>>()
};
(nodes(armed), nodes(disarmed))
};
// The MAINHAND swing (`0x6246a0`, HitInfo bit 0x4 clear): Attack1H(17) vs AttackUnarmed(16).
let (armed_nodes, disarmed_nodes) = swings(0);
assert!(
armed_nodes.contains(&2),
"the armed control swings Attack1H: {armed_nodes:?}"
);
assert!(
disarmed_nodes.contains(&3) && !disarmed_nodes.contains(&2),
"the disarmed attacker swings AttackUnarmed(16), never the sword's clip: {disarmed_nodes:?}"
);
// …and the OFFHAND swing (HitInfo bit 0x4). This is the half the ladder decides: the
// off-hand gate's FIRST probe asks about the MAIN hand, and a main hand holding a weapon
// CANCELS it (`5ec28d je 0x5ec2aa`). A disarmed dual-wielder therefore keeps stabbing with
// the dagger — disarm hides exactly one weapon.
let (armed_nodes, disarmed_nodes) = swings(0x4);
assert!(
armed_nodes.contains(&6),
"the armed control stabs AttackOffPierce: {armed_nodes:?}"
);
assert!(
disarmed_nodes.contains(&6) && !disarmed_nodes.contains(&7),
"the disarmed off hand KEEPS its dagger — 88, not 117: {disarmed_nodes:?}"
);
}
/// The other rung of the ladder: with **no weapon in the main hand** the disarm falls to the off
/// hand, and that — not the dual-wield case — is what reaches AttackUnarmedOff(117) (decision
/// 1863; wow-re `disarm-weapon-gate-law.md` §7's table).
#[test]
fn an_off_hand_only_fighter_is_the_case_that_punches_off_hand() {
let mut app = app();
let unit = app
.world_mut()
.spawn((
ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, 1, true),
clip(16, 3, false), // AttackUnarmed
clip(88, 6, false), // AttackOffPierce
clip(117, 7, false), // AttackUnarmedOff
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
},
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
Wielded {
main: None, // nothing in the main hand to claim the disarm
off: Some((2, 0xf)), // …so the dagger is the weapon it hides
disarmed: true,
..Default::default()
},
))
.id();
app.update();
app.world_mut().write_message(SwingMessage {
attacker: unit,
victim: None,
hit_info: 0x4,
victim_state: 1,
damage: 7,
seq: 1,
});
app.update();
let playing = |node: u32| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(AnimationNodeIndex::new(node as usize))
.is_some()
};
assert!(playing(7), "AttackUnarmedOff(117)");
assert!(!playing(6), "not the dagger's own clip");
}
/// The **play-time** substitution ([`super::super::select::unarmed_special`], `0x5fe2f0`): a
/// spell kit's Special1H(57) becomes SpecialUnarmed(118) when both hands read empty — which a
/// disarmed dual-wielder is NOT (it keeps its off hand), and a disarmed single-wielder is.
#[test]
fn a_special_goes_unarmed_only_when_both_hands_are_empty() {
let model = || ModelAnimations {
graph: Handle::default(),
clips: vec![
clip(0, 1, true),
clip(57, 2, false), // Special1H — the kit's weapon spin
clip(118, 3, false), // SpecialUnarmed
],
hand_close: [None, None],
playable_animation_lookup: Vec::new(),
animation_lookup: Vec::new(),
global_bones: Vec::new(),
first_seq: None,
pose: Default::default(),
};
let mut app = app();
let spin = |app: &mut App, w: Wielded| {
let unit = app
.world_mut()
.spawn((
model(),
AnimationPlayer::default(),
AnimationTransitions::new(),
AnimDriver::default(),
w,
))
.id();
app.update();
app.world_mut().write_message(EmoteAnim {
entity: unit,
anim_id: 57,
seq: 1,
});
unit
};
let armed = spin(
&mut app,
Wielded {
main: Some((2, 0xf)),
..Default::default()
},
);
let dual = spin(
&mut app,
Wielded {
main: Some((2, 0x7)),
off: Some((2, 0xf)),
disarmed: true,
..Default::default()
},
);
let alone = spin(
&mut app,
Wielded {
main: Some((2, 0x7)),
disarmed: true,
..Default::default()
},
);
app.update();
let playing = |app: &App, unit: Entity, node: u32| {
app.world()
.entity(unit)
.get::<AnimationPlayer>()
.unwrap()
.animation(AnimationNodeIndex::new(node as usize))
.is_some()
};
assert!(playing(&app, armed, 2), "control: the weapon spin");
assert!(
playing(&app, dual, 2),
"a disarmed dual-wielder still has a hand full — the weapon spin"
);
assert!(
playing(&app, alone, 3) && !playing(&app, alone, 2),
"both hands empty to the gate — SpecialUnarmed(118)"
);
}
@@ -726,6 +726,25 @@ pub(super) fn defense_anim(victim_state: u32, main: Option<(u8, u8)>) -> Option<
}
}
/// The **play-time unarmed-special substitution** — the third weapon substitution in the client,
/// and the only one that lives inside `PlayAnimation` itself rather than in a selector
/// (`0x5fe2f0` @ `0x5fe3cc`–`0x5fe3e9`, byte-verified: wow-re `disarm-weapon-gate-law.md` §7).
/// A requested **Special1H(57) / Special2H(58)** — the weapon-remapped spin a spell kit asks for,
/// Eviscerate's among them — becomes **SpecialUnarmed(118)** when `GetWeapon(0, 0)` *and*
/// `GetWeapon(1, 0)` are both NULL, i.e. both hands are empty to the combat reading.
/// `SpecialUnarmed`'s own `AnimationData.dbc` fallback column is 57, closing the ring.
///
/// The test is emptiness, not weapon-ness: a hand holding a non-weapon is non-NULL and keeps the
/// armed clip. Because it sits at the play seam it covers every requester — a genuinely
/// weaponless rogue and a disarmed one reach it by the same route (decision 1863).
pub(super) fn unarmed_special(id: u16, main: Option<(u8, u8)>, off: Option<(u8, u8)>) -> u16 {
if matches!(id, 57 | 58) && main.is_none() && off.is_none() {
118
} else {
id
}
}
/// The per-packet melee swing one-shot ids (decision 0073's tables) — what the whiff slow-down
/// (decision 0279) is allowed to touch when it finds them on the masked overlay.
pub(super) fn is_swing_id(id: u16) -> bool {
@@ -178,8 +178,10 @@ fn ranged_arm(w: &Wielded) -> Option<usize> {
/// `None` = that arm has nothing to draw for this state, and is released to its idle.
fn draw_leg(arm: usize, cur: u8, w: &Wielded) -> Option<ArmLeg> {
let (item, sheath) = match (cur, arm) {
(1, ARM_RIGHT) => (w.main, w.main_sheath),
(1, ARM_LEFT) => (w.off, w.off_sheath),
// The drawers read `GetWeapon(slot, 0)` like the rest of the sheath machine (1863), so a
// disarmed hand has nothing to draw — the reference's `0x5eb480` takes its unarmed branch.
(1, ARM_RIGHT) => (w.armed_main(), w.main_sheath),
(1, ARM_LEFT) => (w.armed_off(), w.off_sheath),
// The ranged weapon draws on exactly one arm; the other has no leg at all.
(2, _) if ranged_arm(w) == Some(arm) => (w.ranged, w.ranged_sheath),
_ => return None,
@@ -242,11 +244,11 @@ pub(super) fn sheath_phase1(prev: u8, cur: u8, w: &Wielded) -> [Option<ArmLeg>;
// and no phase 2 can follow (the drawers' `PREV == 0` refusal).
0 => [draw_leg(ARM_RIGHT, cur, w), draw_leg(ARM_LEFT, cur, w)],
1 => [
match w.main {
match w.armed_main() {
Some(_) => stow(w.main_sheath),
None => sheath_phase2(ARM_RIGHT, prev, cur, w),
},
match w.off {
match w.armed_off() {
Some(_) => stow(w.off_sheath),
None => sheath_phase2(ARM_LEFT, prev, cur, w),
},
@@ -485,6 +487,7 @@ mod tests {
ranged_sheath: 1,
ranged_inv: 0x0f, // INVTYPE_RANGED — the left arm
materials: [1, 6, 2], // metal sword, plate shield, wood bow (real 5875 values)
disarmed: false,
}
}
+70 -7
View File
@@ -140,6 +140,29 @@ pub(crate) const ATTACH_OVERHEAD: u16 = 18;
/// mount's bulk). A rider model without it falls back to 18 like the client.
pub(crate) const ATTACH_OVERHEAD_MOUNTED: u16 = 29;
/// The overhead attachment slot to use for a unit **right now** — the reference's own pick inside
/// the marker attach `0x6074c0` (VERIFIED, wow-re object-layer `questgiver-marker.md` Q2), always
/// queried on the unit's *body* model: slot **29** when a mount MODEL exists (`unit+0xdc != 0` —
/// our [`mount::MountChild`]) *and* the body authors 29, else slot **18**; `None` when the body
/// authors neither, which is the reference's "marker created but never parented" (invisible) and
/// this file's bbox fallback for the overhead readers.
///
/// **The pick is LIVE, not a bake.** `0x6074c0`'s five call sites include `0x5ffae7` inside
/// `0x5ffa50` — the `UNIT_FIELD_MOUNTDISPLAYID` field-watch handler (`0x604330 mov edx,0x1fc; mov
/// ecx,3`), the very handler [`mount::reseat_mounts`] ports — so the reference re-runs the whole
/// attach, slot choice included, on every mount and dismount. A per-frame *reader*
/// ([`overhead_anchor`]) gets that for free; a consumer that PARENTS something at the slot
/// ([`crate::quest_markers`]) has to notice the move and re-parent itself (decision 1871).
pub(crate) fn overhead_slot(attach: &BoneAttach, mounted: bool) -> Option<u16> {
if mounted && attach.points.contains_key(&ATTACH_OVERHEAD_MOUNTED) {
Some(ATTACH_OVERHEAD_MOUNTED)
} else if attach.points.contains_key(&ATTACH_OVERHEAD) {
Some(ATTACH_OVERHEAD)
} else {
None
}
}
/// The `0x608640` fallback multiplier (`0x80c5d0` = 1.25): a unit whose model has no PlayerName
/// attachment anchors overhead content at `feet + scale × bbox_z × 1.25`.
const OVERHEAD_FALLBACK_FACTOR: f32 = 1.25;
@@ -209,13 +232,7 @@ pub(crate) fn overhead_anchor<F: bevy::ecs::query::QueryFilter>(
.get(entity)
.ok()
.and_then(|a| {
let slot = if mounts.contains(entity) && a.points.contains_key(&ATTACH_OVERHEAD_MOUNTED)
{
ATTACH_OVERHEAD_MOUNTED
} else {
ATTACH_OVERHEAD
};
let &(bone, offset) = a.points.get(&slot)?;
let &(bone, offset) = a.points.get(&overhead_slot(a, mounts.contains(entity))?)?;
let pose = poses.get(entity).ok()?;
let own;
let root = if pose.joints_root == entity {
@@ -1557,3 +1574,49 @@ mod display_mirror_tests {
assert!(c.display_mirror(1).is_none(), "unknown display");
}
}
#[cfg(test)]
mod overhead_slot_tests {
use super::*;
/// A body model authoring exactly `slots` (the bone/offset payload is immaterial here).
fn body(slots: &[u16]) -> BoneAttach {
BoneAttach {
points: slots.iter().map(|&s| (s, (0u16, Vec3::ZERO))).collect(),
markers: HashMap::new(),
}
}
/// `0x6074c0`'s pick and only it: 29 is *preferred*, never required, and never taken by an
/// unmounted unit. So a character body — which authors both (wow-re's table: HumanMale 18 at
/// z 2.2123, 29 at z 1.4029) — moves 18 ↔ 29 across a mount, a creature that authors only 18
/// (AncientOfLore, Kobold) stays at 18 whether or not it is mounted, and a body authoring
/// neither anchors nothing: the reference creates the marker and never parents it.
#[test]
fn twenty_nine_needs_both_a_mount_model_and_the_authored_point() {
let both = body(&[ATTACH_OVERHEAD, ATTACH_OVERHEAD_MOUNTED]);
assert_eq!(overhead_slot(&both, false), Some(ATTACH_OVERHEAD));
assert_eq!(overhead_slot(&both, true), Some(ATTACH_OVERHEAD_MOUNTED));
let plain = body(&[ATTACH_OVERHEAD]);
assert_eq!(overhead_slot(&plain, false), Some(ATTACH_OVERHEAD));
assert_eq!(
overhead_slot(&plain, true),
Some(ATTACH_OVERHEAD),
"no 29 on the body ⇒ the mounted leg falls back to 18, like the client"
);
let mounted_only = body(&[ATTACH_OVERHEAD_MOUNTED]);
assert_eq!(
overhead_slot(&mounted_only, false),
None,
"29 is never the unmounted fallback — the fallback is 18 or nothing"
);
assert_eq!(
overhead_slot(&mounted_only, true),
Some(ATTACH_OVERHEAD_MOUNTED)
);
assert_eq!(overhead_slot(&body(&[]), true), None, "never parented");
}
}
@@ -182,6 +182,57 @@ pub(super) struct HeldItems {
/// worn quiver (self-only, ranged-drawn — wow-re `nocked-ammo-cancel.md` §H).
pub(super) const ATTACH_SLOTS: usize = 8;
/// **What the disarm reflex left attached** (decision 1863; wow-re `disarm-weapon-gate-law.md`
/// §6, byte-verified). The reference's attachment state is built by *events*, not recomputed from
/// the descriptor, and disarm is where that difference becomes visible — so this is the one piece
/// of latched attachment state the equipment layer keeps.
///
/// `UNIT_FIELD_FLAGS`' change reflex `0x5ff580` runs when the DISARM bit **changes**
/// (`0x5ff619 test edi,eax`), for observed remote units as well as our own body, and when the bit
/// goes up it takes the hidden hand's weapon **off the hand** — `0x5ff676 call
/// 0x47a310(model, 0xf, 0, 0)`, an unlink of every child at that attachment plus an `OpenHand` —
/// but only while the sheath state `[unit+0xd40]` is non-zero, i.e. while the weapon is drawn.
/// Then nothing puts it back: every attach site (`0x60b770`, `0x605da0`, `0x60b590`) is dominated
/// by a `GetWeapon(slot, 0)` that now reads NULL, so the weapon can be neither re-attached nor
/// moved until the flag clears.
///
/// The observable shape, and why a plain "hide it while disarmed" is wrong in both directions:
///
/// | when the flag went up | what stays on screen |
/// |---|---|
/// | weapon **drawn** | nothing — it leaves the hand |
/// | weapon **stowed** | it stays on the back/hip, and stays there even if the unit then engages |
/// | we never saw the edge (the unit streamed in already disarmed) | nothing was ever attached |
#[derive(Component, Clone, Copy, PartialEq, Eq)]
pub(super) struct DisarmFreeze {
/// The held slot the ladder hid at the edge — 0 mainhand, 1 offhand.
slot: u8,
/// The display id that was in that hand. A *different* item has no edge of its own and so was
/// never attached (the reference's attach sites all decline while the flag is up).
display: u32,
/// Where the reflex left it, or `None` when it was in the hand and got detached.
attach: Option<u16>,
}
impl DisarmFreeze {
/// Record what the reflex left, at the edge.
pub(super) fn new(slot: u8, display: u32, attach: Option<u16>) -> Self {
DisarmFreeze {
slot,
display,
attach,
}
}
/// Where `display` still hangs in `slot`, or `None` — including when this freeze is about a
/// different hand or a different item, neither of which the reflex ever attached.
pub(super) fn attach_for(&self, slot: u8, display: u32) -> Option<u16> {
(self.slot == slot && self.display == display)
.then_some(self.attach)
.flatten()
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct HeldSlot {
display: u32,
@@ -8,15 +8,17 @@
use benilla_protocol::EntityKind;
use bevy::prelude::*;
use crate::creature_anim::{HandGrip, NockLatch, NockedAmmo, VisualSheath, Wielded};
use crate::creature_anim::{
HandGrip, NockLatch, NockedAmmo, VisualSheath, Wielded, UNIT_FLAG_DISARMED,
};
use crate::items::Items;
use crate::net::{NetCommands, NetEntity, ObjectStore};
use super::super::item_glow::{self, ItemGlows};
use super::super::Creatures;
use super::{
attach_id, ensure_item_model, Equipment, HeldItems, HeldSlot, ItemDisplays, ItemModelKind,
ATTACH_SLOTS, COMPOSITE_SLOTS, HELD_SLOTS, NO_GLOW, PLAYER_HELD_SLOTS,
attach_id, ensure_item_model, DisarmFreeze, Equipment, HeldItems, HeldSlot, ItemDisplays,
ItemModelKind, ATTACH_SLOTS, COMPOSITE_SLOTS, HELD_SLOTS, NO_GLOW, PLAYER_HELD_SLOTS,
};
/// The drawn/stowed attachment point for one held slot, or `None` when the item shows nothing (empty
@@ -186,6 +188,7 @@ pub(in crate::entities) fn resolve_equipment(
Has<NockLatch>,
Option<&ResolveKey>,
Option<&DressKey>,
Option<&DisarmFreeze>,
)>,
held: Option<ResMut<ItemDisplays>>,
mut templates: ResMut<Items>,
@@ -238,6 +241,7 @@ pub(in crate::entities) fn resolve_equipment(
nock_latched,
current_key,
current_dress,
freeze,
) in &units
{
if !matches!(net_entity.kind, EntityKind::Unit | EntityKind::Player) {
@@ -358,14 +362,25 @@ pub(in crate::entities) fn resolve_equipment(
.and_then(|d| creatures.as_deref()?.models.get(&d))
.is_none_or(|dm| dm.is_character_body);
let mut slots: [Option<HeldSlot>; ATTACH_SLOTS] = [None; ATTACH_SLOTS];
let mut wielded = Wielded::default();
let mut wielded = Wielded {
// The disarm bit, read off this unit's own descriptor exactly where the reference
// reads it — inside `GetWeapon` (`[[unit+0x110]+0xa0] & 0x200000`, `0x5ec2b8`). It
// decides the COMBAT reading of the hands ([`Wielded::armed_main`]) and, through
// [`DisarmFreeze`] below, what the hidden hand still shows (decision 1863).
disarmed: s.unit_flags() & UNIT_FLAG_DISARMED != 0,
..Wielded::default()
};
let mut ranged_inv_type = None;
// The DRESS, gathered as we go: what the unit *wears* in the three weapon slots, recorded
// before `placement` decides whether any of it is currently rendered ([`DressKey`]).
let mut worn: [Option<(u32, ItemModelKind, i32)>; HELD_SLOTS] = [None; HELD_SLOTS];
// **Pass 1 — what each hand HOLDS** (`GetWeapon(slot, 1)`). All three slots first, because
// the disarm ladder is a question about BOTH hands at once (`Wielded::disarmed_hand`) and
// cannot be asked while they are still being filled in.
let mut resolved: [Option<(u32, u32, u8, u8, u8, u8)>; HELD_SLOTS] = [None; HELD_SLOTS];
for slot in 0..HELD_SLOTS {
// (display id, inventory type, item sheath type, class, subclass, material) per slot.
let resolved: Option<(u32, u32, u8, u8, u8, u8)> = match net_entity.kind {
resolved[slot] = match net_entity.kind {
EntityKind::Unit => {
let display = s.unit_virtual_item_display(slot as u8).filter(|d| *d != 0);
display.map(|d| {
@@ -392,7 +407,7 @@ pub(in crate::entities) fn resolve_equipment(
}),
_ => None,
};
let Some((display, inv_type, item_sheath, class, subclass, material)) = resolved else {
let Some((_, inv_type, item_sheath, class, subclass, material)) = resolved[slot] else {
continue;
};
// The item's Material — the draw/stow sound's only key (decision 0882). Both wire
@@ -422,6 +437,29 @@ pub(in crate::entities) fn resolve_equipment(
}
_ => {}
}
}
// **The ladder** (decision 1863) — which single hand `UNIT_FLAG_DISARMED` hides, now that
// both are known. `None` while the flag is down, or when neither hand holds a weapon.
let hidden = wielded.disarmed_hand();
// The rising edge, as SEEN HERE: the reference's reflex `0x5ff580` fires on the bit
// *changing*, so a unit that streamed in already disarmed never had its weapon attached at
// all, and one whose edge we watched keeps whatever the reflex left (see [`DisarmFreeze`]).
let disarm_edge = hidden.is_some() && current_wielded.is_some_and(|w| !w.disarmed);
let mut next_freeze = match hidden {
// Flag down, or neither hand holds a weapon: no freeze at all, and the falling edge
// therefore clears it — `0x5ff67d`'s re-attach, which puts the weapon back wherever
// the live sheath state says it belongs.
None => None,
// The edge itself writes it below, from the placement the weapon had at that moment.
Some(_) if disarm_edge => None,
// Steady state: keep what the reflex left.
Some(_) => freeze.copied(),
};
// **Pass 2 — the models.**
for slot in 0..HELD_SLOTS {
let Some((display, inv_type, item_sheath, _, _, _)) = resolved[slot] else {
continue;
};
if !char_component {
continue; // wielded resolved; the model never attaches on a non-character body
}
@@ -436,8 +474,25 @@ pub(in crate::entities) fn resolve_equipment(
// because that one is only computed for a slot that is actually placed — and an
// enchant change is a model-event producer whether or not the weapon is drawn.
worn[slot] = Some((display, kind, enchant_fold(s, net_entity.kind, slot)));
let Some(attach) = placement(slot, inv_type, item_sheath, sheath_of(slot, inv_type))
else {
let live = placement(slot, inv_type, item_sheath, sheath_of(slot, inv_type));
// The disarm reflex (`0x5ff580`, decision 1863). The hidden hand's weapon is NOT
// placed from the live sheath state — while the flag is up nothing may attach it or
// move it, so it is wherever the reflex left it at the edge: gone if it was in the
// hand (`0x5ff676`'s detach), still at its body point if it was stowed, and gone if
// we never saw the edge because nothing ever attached it.
let attach = if hidden == Some(slot) {
if disarm_edge {
let kept = live
.filter(|a| !matches!(*a, attach_id::HAND_RIGHT | attach_id::HAND_LEFT));
next_freeze = Some(DisarmFreeze::new(slot as u8, display, kept));
kept
} else {
next_freeze.and_then(|f| f.attach_for(slot as u8, display))
}
} else {
live
};
let Some(attach) = attach else {
continue;
};
ensure_item_model(&mut held, display, kind, &asset_server);
@@ -637,6 +692,17 @@ pub(in crate::entities) fn resolve_equipment(
if current_wielded != Some(&wielded) {
commands.entity(entity).insert(wielded);
}
// The freeze rides the same diff as the hands. Absent is a real state — "nothing is
// attached to that hand" — so the falling edge REMOVES it rather than storing an empty.
match next_freeze {
Some(f) if freeze != Some(&f) => {
commands.entity(entity).insert(f);
}
None if freeze.is_some() => {
commands.entity(entity).remove::<DisarmFreeze>();
}
_ => {}
}
}
}
@@ -1085,4 +1151,203 @@ mod tests {
);
drop(rx);
}
/// **What a disarm does to the weapon MODEL** (decision 1863; wow-re
/// `disarm-weapon-gate-law.md` §6). The reference's attachment state is built by events, and
/// `UNIT_FIELD_FLAGS`' change reflex `0x5ff580` is one of them: when the DISARM bit goes up it
/// calls `0x47a310(model, 0xf, 0, 0)` — an unlink of the main-hand attachment — **but only
/// while the weapon is drawn**, and afterwards every attach site declines because its
/// `GetWeapon(slot, 0)` reads NULL. So the weapon does NOT stay in the fist (the older note
/// said it did, from a partial read of `0x60b590`), and it does NOT vanish off the back
/// either. Three cases, one body each.
#[test]
fn a_disarm_takes_a_drawn_weapon_off_the_hand_and_leaves_a_stowed_one() {
use crate::creature_anim::Wielded;
/// `UNIT_FIELD_FLAGS`, `UNIT_FIELD_BYTES_2` (byte 0 = the sheath state) and the
/// mainhand's public entry — raw wire indices, spelled out like every other descriptor
/// fixture in this file.
const UNIT_FLAGS: u16 = 46;
const UNIT_BYTES_2: u16 = 164;
const VISIBLE_ITEM_MAINHAND_ENTRY: u16 = 258 + 2 + 12 * 15;
/// `UNIT_FLAG_DISARMED`.
const DISARMED: u32 = 0x0020_0000;
// A one-handed sword worn on the hip: class 2 subclass 7, INVTYPE_WEAPONMAINHAND, sheath
// type 3 — so the stowed placement has a real body point to land on and the drawn one is
// the right hand.
let store = |flags: u32, sheath: u8| {
ObjectStore(ObjectFields::from_pairs(&[
(BYTES_0, 1 | 1 << 8), // race 1 (human), class 1, male
(UNIT_FLAGS, flags),
(UNIT_BYTES_2, u32::from(sheath)),
(VISIBLE_ITEM_MAINHAND_ENTRY, 500),
]))
};
let spawn = |flags: u32, sheath: u8| {
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()));
let mut items = Items::default();
items.insert_template(
500,
Some(ItemInfo {
class: 2,
subclass: 7,
display_info_id: 950,
inventory_type: 21,
sheath: 3,
..crate::items::test_template("Worn Shortsword")
}),
);
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
app.insert_resource(items);
app.insert_resource(NetCommands(tx));
app.insert_resource(ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(
std::collections::HashMap::new(),
),
));
let player = app
.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Player,
display_id: Some(49),
scale: 1.0,
},
store(flags, sheath),
))
.id();
app.add_systems(Update, resolve_equipment);
app.update();
(app, player, rx)
};
/// Where the mainhand's model hangs this frame, or `None` for "not on screen at all".
fn mainhand(app: &App, player: Entity) -> Option<u16> {
app.world()
.get::<HeldItems>(player)
.and_then(|h| h.slots[0].as_ref())
.map(|slot| slot.attach)
}
// (1) DRAWN when the flag goes up — the reflex detaches it. It is not in the hand, and it
// does not fall back to the hip either: nothing re-attaches while the flag is up.
let (mut app, player, rx) = spawn(0, 1);
assert_eq!(
mainhand(&app, player),
Some(attach_id::HAND_RIGHT),
"control: drawn, in the right hand"
);
*app.world_mut().get_mut::<ObjectStore>(player).unwrap() = store(DISARMED, 1);
app.update();
assert!(
app.world().get::<Wielded>(player).unwrap().disarmed,
"the flag reached the hands"
);
assert_eq!(
mainhand(&app, player),
None,
"a drawn weapon leaves the hand on the disarm edge"
);
// …and comes back when the flag clears (`0x5ff67d` → `0x60b770(0)`).
*app.world_mut().get_mut::<ObjectStore>(player).unwrap() = store(0, 1);
app.update();
assert_eq!(
mainhand(&app, player),
Some(attach_id::HAND_RIGHT),
"re-armed: the weapon is put back"
);
drop(rx);
// (2) STOWED when the flag goes up — the reflex's detach is gated on the sheath state
// being non-zero, so nothing happens and the sword stays on the hip. It stays there
// even once the unit draws: while disarmed the transition moves nothing.
let (mut app, player, rx) = spawn(0, 0);
assert_eq!(mainhand(&app, player), Some(attach_id::HIP_MAIN));
*app.world_mut().get_mut::<ObjectStore>(player).unwrap() = store(DISARMED, 0);
app.update();
assert_eq!(
mainhand(&app, player),
Some(attach_id::HIP_MAIN),
"a stowed weapon is left exactly where it is"
);
*app.world_mut().get_mut::<ObjectStore>(player).unwrap() = store(DISARMED, 1);
app.update();
assert_eq!(
mainhand(&app, player),
Some(attach_id::HIP_MAIN),
"and stays there — a disarmed hand cannot draw"
);
drop(rx);
// (3) Already disarmed when we first see the unit — the edge happened elsewhere, so
// nothing was ever attached to that hand and the attach sites all decline.
let (app, player, rx) = spawn(DISARMED, 1);
assert_eq!(
mainhand(&app, player),
None,
"a unit that streams in disarmed shows no mainhand weapon"
);
drop(rx);
}
/// The COMBAT reading is separate from the display one, and only the display one is frozen:
/// the hands still report what is worn (`GetWeapon(slot, 1)` — what the paperdoll shows),
/// while `armed_main` reports what the unit can fight with.
#[test]
fn the_hands_keep_the_worn_reading_through_a_disarm() {
use crate::creature_anim::Wielded;
const UNIT_FLAGS: u16 = 46;
const VISIBLE_ITEM_MAINHAND_ENTRY: u16 = 258 + 2 + 12 * 15;
const DISARMED: u32 = 0x0020_0000;
let mut app = App::new();
app.add_plugins((MinimalPlugins, bevy::asset::AssetPlugin::default()));
let mut items = Items::default();
items.insert_template(
500,
Some(ItemInfo {
class: 2,
subclass: 7,
display_info_id: 950,
inventory_type: 21,
sheath: 3,
..crate::items::test_template("Worn Shortsword")
}),
);
let (tx, rx) = crossbeam_channel::unbounded::<ClientCommand>();
app.insert_resource(items);
app.insert_resource(NetCommands(tx));
app.insert_resource(ItemDisplays::icons_for_tests(
benilla_formats::ItemDisplayCatalog::from_displays(std::collections::HashMap::new()),
));
let player = app
.world_mut()
.spawn((
NetEntity {
kind: EntityKind::Player,
display_id: Some(49),
scale: 1.0,
},
ObjectStore(ObjectFields::from_pairs(&[
(BYTES_0, 1 | 1 << 8),
(UNIT_FLAGS, DISARMED),
(VISIBLE_ITEM_MAINHAND_ENTRY, 500),
])),
))
.id();
app.add_systems(Update, resolve_equipment);
app.update();
drop(rx);
let w = *app.world().get::<Wielded>(player).expect("hands resolved");
assert!(w.disarmed);
assert_eq!(w.main, Some((2, 7)), "GetWeapon(0, 1) still sees the sword");
assert_eq!(w.armed_main(), None, "GetWeapon(0, 0) is NULL");
assert_eq!(
w.disarmed_hand(),
Some(0),
"the main hand is the one hidden"
);
}
}
+7 -1
View File
@@ -166,7 +166,13 @@ pub(super) fn update(
// melee → ranged → stowed, gated on what is actually worn — never a
// two-state flip. `None` = the ref makes no call at all (nothing equipped).
let w = wielded.copied().unwrap_or_default();
let worn = (w.main.is_some() || w.off.is_some(), w.ranged.is_some());
// `0x5eb5f0`/`0x600`/`0x610` are `GetWeapon(slot, 0)` (1863): a disarmed hand
// is not "worn" to the cycle, so the press walks past melee — and with nothing
// else equipped makes no call at all, exactly like the ref's unarmed branch.
let worn = (
w.armed_main().is_some() || w.armed_off().is_some(),
w.ranged.is_some(),
);
let next =
crate::creature_anim::toggle_sheath_next(drv.sheath_state().unwrap_or(0), worn);
if let Some(state) = next {
+127 -42
View File
@@ -12,9 +12,15 @@
//!
//! The render law is byte-verified (wow-re object-layer `questgiver-marker.md`):
//! - **Attach** (`0x6074c0`): the marker is a CHILD of the unit's own body M2 at attachment slot
//! **18** (0x12) — slot 29 is preferred only when a mount model exists (benilla has no mounts
//! yet; add that branch with the mount arc). No slot ⇒ the marker is created but never parented
//! — invisible; we render nothing.
//! **18** (0x12), or **29** (`PlayerNameMounted`) when a mount model exists and the body authors
//! it — [`overhead_slot`], one definition shared with the overhead name/plate/FCT readers. No
//! slot ⇒ the marker is created but never parented — invisible; we render nothing.
//!
//! **That pick is LIVE.** `0x6074c0` is a call site of `0x5ffa50`, the
//! `UNIT_FIELD_MOUNTDISPLAYID` field-watch handler, so the reference re-runs the whole attach —
//! slot included — every time a unit mounts or dismounts. Our seat is *parented* rather than
//! read per frame, so re-picking means rebuilding the instance: [`sync_markers`] compares the
//! slot the seat was built at against the live pick on every pass (decision 1871).
//! - **Scale** (`0x607570`): `1 / |attach-bone basis|`, computed once at attach and baked into the
//! marker's base matrix (`marker+0xbc`). Not distance-based, not unit scale, no clamp or floor.
//!
@@ -61,8 +67,9 @@
//! reference eye, decision record with this change).
//!
//! benilla's translation (m2bones-probed: every marker M2 is ONE bone with a 3-key translation
//! bob): a **seat** entity parented under the unit's slot-18 joint (the held-items rail) carries
//! the one-time `1/L` counter-scale; the `!` models (plain bone) animate through the doodad rail
//! bob): a **seat** entity parented under the unit's overhead joint (18 or 29 — the held-items
//! rail) carries the one-time `1/L` counter-scale; the `!` models (plain bone) animate through
//! the doodad rail
//! ([`benilla_world::doodad_anim::spawn_anim_host`] — skinned twin + the one-time sequence-0 arm); the `?`
//! models (cylindrical-billboard bone) render as [`BillboardCard`]s under the identity root —
//! cards write ABSOLUTE world transforms, so they can't sit under the moving seat; instead
@@ -96,7 +103,7 @@ use benilla_world::billboard::BillboardCard;
use benilla_world::mesh_tag::spawn_tag;
use bevy::mesh::MeshTag;
use crate::entities::{ATTACH_OVERHEAD, ATTACH_OVERHEAD_MOUNTED};
use crate::entities::overhead_slot;
/// The marker anim ids the client arms (`0x6076c0`): the low bob, and the raised bob played while
/// the unit shows an overhead name (`unit+0xc7c` live — [`Nameplates::shows`] here).
@@ -145,11 +152,15 @@ struct MarkerInst {
struct QuestMarkerRoot {
npc: u64,
handle: Handle<M2Model>,
/// The seat under the unit's slot-18 joint, once built. Lives OUTSIDE this root's hierarchy —
/// The seat under the unit's overhead joint, once built. Lives OUTSIDE this root's hierarchy —
/// [`sync_markers`] despawns it explicitly (guarded: a despawned unit already cascaded it).
seat: Option<Entity>,
/// The unit's body model resolved but has no slot-18 attachment: the client's marker is
/// created but never parented — invisible. Latched so we stop retrying.
/// The overhead slot ([`overhead_slot`]) the live [`Self::seat`] was parented at — `None`
/// until it is built. A parent link bakes the slot in, so this is what [`sync_markers`]
/// watches against the live pick to catch a mount or dismount moving it (decision 1871).
slot: Option<u16>,
/// The unit's body model resolved but authors NEITHER overhead attachment: the client's
/// marker is created but never parented — invisible. Latched so we stop retrying.
no_anchor: bool,
/// The armed pose: `Some(true)` = the raised (anim 190) bob, `Some(false)` = the low (anim 0)
/// bob, `None` = not yet posed (fresh build) — [`pose_markers`] arms on the first pass and
@@ -157,8 +168,8 @@ struct QuestMarkerRoot {
raised: Option<bool>,
}
/// The attach seat: a child of the unit's slot-18 joint at the attachment offset. Carries the
/// marker joints + plain submeshes as children; [`bake_seat_scale`] bakes the one-time `1/L`
/// The attach seat: a child of the unit's overhead joint (18/29) at the attachment offset.
/// Carries the marker joints + plain submeshes as children; [`bake_seat_scale`] bakes the `1/L`
/// counter-scale into its local transform (the client's `0x607570` base matrix).
#[derive(Component)]
struct MarkerSeat {
@@ -199,25 +210,43 @@ fn sync_markers(
assets: Option<Res<WorldAssets>>,
roots: Query<&QuestMarkerRoot>,
seats: Query<(), With<MarkerSeat>>,
// The live overhead-slot pick's two inputs, read per pass off the unit (a handful of markers
// exist at once, so this is a few map lookups a frame).
anchors: Query<&BoneAttach>,
mounts: Query<(), With<crate::entities::mount::MountChild>>,
mut live: Local<HashMap<u64, MarkerInst>>,
) {
if assets.is_none() {
return; // no client data — nothing could build anyway
}
// A dead seat — the unit's visual rebuilt out from under it (a mount transition despawns
// the joint tree the seat lived under; decision 0441) — despawns the whole instance: the
// next pass rebuilds it under the fresh joints, re-picking the overhead slot (18 ↔ 29) for
// the new configuration. A partial rebuild would duplicate the root's billboard cards.
let orphaned: Vec<u64> = live
.iter()
.filter(|(_, inst)| {
roots
.get(inst.root)
.is_ok_and(|m| m.seat.is_some_and(|s| !seats.contains(s)))
})
.map(|(&npc, _)| npc)
.collect();
for npc in orphaned {
// Rebuild the whole instance whenever the built seat has stopped describing the unit — all
// or nothing, because a partial rebuild would duplicate the root's billboard cards. The next
// pass then re-attaches under the joints and the slot that are true now. Two triggers:
//
// - **the seat entity is gone** — the unit's visual was rebuilt out from under it, taking the
// joint tree the seat lived under with it (a redress, a display change);
// - **the overhead slot has moved** (18 ↔ 29) — the unit mounted or dismounted since the
// build. Nothing else would notice, and this is the trigger benilla was missing (1871): a
// mount transition deliberately leaves the rider's visual intact — `reseat_mounts`
// RE-PARENTS the rig's joint anchors onto the seat frame instead of despawning them — so
// the marker's seat survives the transition, riding the right bone at the WRONG slot,
// permanently. The reference re-runs the whole attach here: `0x6074c0` is a call site of
// `0x5ffa50`, the `UNIT_FIELD_MOUNTDISPLAYID` watch handler.
let rebuild: Vec<u64> =
live.iter()
.filter(|(&npc, inst)| {
let Ok(marker) = roots.get(inst.root) else {
return false;
};
let want = index.0.get(&npc).and_then(|&unit| {
overhead_slot(anchors.get(unit).ok()?, mounts.contains(unit))
});
let alive = marker.seat.is_some_and(|seat| seats.contains(seat));
seat_is_stale(marker, alive, want)
})
.map(|(&npc, _)| npc)
.collect();
for npc in rebuild {
if let Some(old) = live.remove(&npc) {
despawn_marker(&mut commands, &roots, old.root);
}
@@ -254,6 +283,7 @@ fn sync_markers(
npc,
handle: asset_server.load::<M2Model>(m2_url(path)),
seat: None,
slot: None,
no_anchor: false,
raised: None,
},
@@ -276,6 +306,22 @@ fn sync_markers(
}
}
/// [`sync_markers`]' rebuild test for one live instance, split out because each of its three legs
/// has a trap in it:
///
/// - **not built yet** (`seat: None`) is never stale — [`build_markers`] owns that instance, and
/// calling it stale would despawn a root that is still waiting on its M2.
/// - **`seat_alive == false`** is stale: the unit's visual was rebuilt out from under the seat,
/// taking the joint tree it lived under with it.
/// - **`want` differs from the slot the seat was built at** is stale: the unit mounted or
/// dismounted. `want` is [`overhead_slot`]'s live pick, and `None` means *unreadable this pass*
/// (the unit is still streaming, or gone) — which must NOT count as a move, or a marker would
/// rebuild its whole instance every frame its unit's attachment table is out of reach. A unit
/// that really is gone is pruned below on its own terms.
fn seat_is_stale(marker: &QuestMarkerRoot, seat_alive: bool, want: Option<u16>) -> bool {
marker.seat.is_some() && (!seat_alive || (want.is_some() && want != marker.slot))
}
/// Build a marker once its M2 AND its unit's joint set ([`BoneAttach`]) have both landed: the seat
/// under the slot-18 joint, the anim host + skinned plain submeshes on it (the `!` bob), and the
/// billboard cards under the identity root with their bob loop armed (the `?`). Retries silently
@@ -318,24 +364,20 @@ fn build_markers(
else {
continue;
};
// Slot 29 (PlayerNameMounted) is preferred while a mount model is attached (wow-re
// `questgiver-marker.md`; decision 0441 — `sync_markers` re-seats on the transition),
// else 18. Absent ⇒ never parented ⇒ invisible, like the client.
let slot = if mounts.contains(unit) && anchor.points.contains_key(&ATTACH_OVERHEAD_MOUNTED)
{
ATTACH_OVERHEAD_MOUNTED
} else {
ATTACH_OVERHEAD
};
let Some((joint, offset)) = anchor.points.get(&slot).and_then(|&(bone, offset)| {
poses
.get_mut(unit)
.ok()
.and_then(|mut p| p.anchor_for(&mut commands, unit, bone))
.map(|joint| (joint, offset))
}) else {
// The overhead slot as it stands THIS pass — 29 while a mount model is attached and the
// body authors it, else 18 (`0x6074c0`'s own pick). It is recorded on the root below so
// `sync_markers` can rebuild the instance when a mount or dismount moves it. Neither slot
// ⇒ never parented ⇒ invisible, like the client.
let Some((slot, joint, offset)) =
overhead_slot(anchor, mounts.contains(unit)).and_then(|slot| {
let &(bone, offset) = anchor.points.get(&slot)?;
let mut pose = poses.get_mut(unit).ok()?;
let joint = pose.anchor_for(&mut commands, unit, bone)?;
Some((slot, joint, offset))
})
else {
debug!(
"quest_markers: {:#x} has no slot-{slot} attachment — marker never parents (invisible, the client's own behavior)",
"quest_markers: {:#x} has no overhead attachment (18/29) — marker never parents (invisible, the client's own behavior)",
marker.npc
);
marker.no_anchor = true;
@@ -461,6 +503,7 @@ fn build_markers(
h.finish(&mut commands);
}
marker.seat = Some(seat);
marker.slot = Some(slot);
}
}
@@ -629,6 +672,48 @@ impl Plugin for QuestMarkersPlugin {
mod tests {
use super::*;
fn root(seat: Option<Entity>, slot: Option<u16>) -> QuestMarkerRoot {
QuestMarkerRoot {
npc: 0xdead,
handle: Handle::default(),
seat,
slot,
no_anchor: false,
raised: None,
}
}
/// The rebuild test the mounted marker turns on (1871). A seat bakes its slot into a parent
/// link, and a mount transition leaves the rider's visual — and therefore the seat — intact,
/// so this comparison is the ONLY thing that can notice 18 ↔ 29 moving.
#[test]
fn a_seat_goes_stale_when_its_slot_moves_but_never_on_a_reading_it_could_not_take() {
let built_at_18 = root(
Some(Entity::from_raw_u32(1).expect("a valid entity id")),
Some(18),
);
assert!(
seat_is_stale(&built_at_18, true, Some(29)),
"the unit mounted — the seat hangs at 18 and nothing else would notice"
);
assert!(!seat_is_stale(&built_at_18, true, Some(18)), "settled");
assert!(
!seat_is_stale(&built_at_18, true, None),
"the attachment table was unreadable this pass — not a move; churning on it would \
rebuild the instance every frame a unit is out of reach"
);
assert!(
seat_is_stale(&built_at_18, false, Some(18)),
"the seat entity is gone — the visual was rebuilt out from under it"
);
// Not built yet: `build_markers` owns the instance, and a rebuild would despawn a root
// still waiting on its M2. True even when the seat is reported dead — it has none.
let unbuilt = root(None, None);
assert!(!seat_is_stale(&unbuilt, false, Some(29)));
assert!(!seat_is_stale(&unbuilt, true, Some(29)));
}
/// The seat's counter-scale is the client's `0x607570`: `1/L` from the attach joint's world
/// basis, baked ONCE at attach — a later joint rescale does not re-bake (the one-time latch),
/// and an exactly-1.0 basis skips the write (the client's own identity guard).
+22 -4
View File
@@ -257,7 +257,15 @@ fn swing_weapon(
offhand: bool,
materials: Option<&benilla_formats::MaterialCatalog>,
) -> (u32, bool) {
let hand = wielded.and_then(|w| if offhand { w.off } else { w.main });
// `0x625400`/`0x625460` pass `visFlag = 0`, so a disarmed hand's weapon is not here to be
// heard either — the punch a disarmed unit throws must not clang like a sword (1863).
let hand = wielded.and_then(|w| {
if offhand {
w.armed_off()
} else {
w.armed_main()
}
});
match hand {
// class 2 = weapon; anything else in hand (held misc) swings as unarmed.
Some((2, subclass)) => {
@@ -328,7 +336,15 @@ fn swing_weight(
offhand: bool,
sub_classes: &benilla_formats::ItemSubClassCatalog,
) -> Option<u32> {
match wielded.and_then(|w| if offhand { w.off } else { w.main }) {
// `0x623870`, `visFlag = 0` again: the disarmed hand whooshes with the small unarmed
// samples, by this function's own empty-hand leg (1863).
match wielded.and_then(|w| {
if offhand {
w.armed_off()
} else {
w.armed_main()
}
}) {
None => Some(0),
Some((class, subclass)) if u32::from(class) == ITEM_CLASS_WEAPON => {
sub_classes.weapon_swing_size(ITEM_CLASS_WEAPON, u32::from(subclass))
@@ -362,12 +378,14 @@ fn defended(victim_state: u32) -> bool {
/// (`0x623690 test eax,eax; je`), which is what an unarmed parry sounds like.
fn defending_item(wielded: Option<&Wielded>, block: bool) -> Option<(u8, u8)> {
let w = wielded?;
// Both probes are `0x625400(sel)` with `visFlag = 0` (1863): a disarmed parry finds no
// mainhand weapon and falls through, and an unarmed parry rings nothing at all.
if !block {
if let Some((2, _)) = w.main {
if let Some((2, _)) = w.armed_main() {
return Some((2, w.materials[0]));
}
}
let (class, _) = w.off?;
let (class, _) = w.armed_off()?;
matches!(class, 2 | 4).then_some((class, w.materials[1]))
}
+59 -11
View File
@@ -16,9 +16,27 @@
//!
//! 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".
//! same dword at `0x623749`, as the `$FSD` handler does at `0x62341d`. `None` (the client's `−1`)
//! is silence here too, never "ask the other leg".
//!
//! ## Indoors is silent, and that is the data
//!
//! The two consumers share the terrain id and then **diverge on it**, which is the single most
//! surprising thing about this system. Both tables are keyed on `TerrainType.SoundID`, and
//! `TerrainType 10 "None"` — the id a WMO surface takes when its `MOMT+0x20` says nothing — has
//! `SoundID = 0`. `FootstepTerrainLookup` carries a row at terrain sound **0** for 17 footstep
//! classes; `DeathThudLookups` carries **none at all**. So the same floor that creaks underfoot
//! swallows the body that lands on it.
//!
//! That is not a rounding error in the content: `benilla-extract thudcensus` measures **10 075 of
//! the 10 299 shipped MOMT materials (97.8 %) at `TerrainType 10`**, and **694 of the 815 WMO
//! roots have no surface that can thud at all** — every inn and tavern in the game among them.
//! What *does* thud indoors is the blacksmiths, barns, barracks, chapels, Stormwind, Ironforge and
//! most dungeon sets, whose floors carry a real Stone/Metallic/Wood material.
//!
//! So "a big corpse hit the ground silently *indoors*" is the **reference's own behaviour**, not a
//! gap — and the retest for this system has to be run outdoors, or in one of the 121 buildings the
//! census names.
//!
//! **Three gates, and no others.** The handler's early-returns in order (`0x6236e0`):
//!
@@ -42,10 +60,14 @@
//! 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.
//! The play is `0x458870` — **bus 0, uncapped** (`0x458880 xor ecx,ecx`; cap `0x7FFFFFFF`), 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. The `1.0` it passes is a **multiplier** on the
//! kit's authored `SoundEntries.Volume`, not the final gain — the shipped rows carry 0.4 for
//! Small/Medium, 1.0 for Large/Giant/Colossal, 0.3/0.6 in water — and the `-1` beside it is a
//! **file-variation index** (`variant != -1`), not a channel. Every `DeathThud*` row sets flag
//! `0x400`, so the kit player's own ±15 % pitch draw applies (`0x458da0`, [`super::math`]).
use bevy::prelude::*;
@@ -242,18 +264,44 @@ mod tests {
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.
/// **Indoors is silent, and the footstep on the same floor is not** — the asymmetry in the
/// module docs, pinned as an executable claim because it is the one a future reader will
/// disbelieve and "fix".
///
/// `TerrainType 10 "None"` is 97.8 % of shipped WMO material, and it resolves to terrain sound
/// `0`. `FootstepTerrainLookup` has a row there; `DeathThudLookups` has none. A building whose
/// floor carries a real material (a blacksmith's stone, a barn's wood) thuds normally — that
/// is the control, and it must not change.
#[test]
fn a_body_on_floorboards_thuds_on_wood() {
fn an_unmaterialed_floor_footsteps_but_never_thuds() {
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");
// The unmaterialed floor: a footstep, on every body, at any size.
assert_eq!(
steps.resolve_terrain(7, 10).map(|(dry, _)| dry),
Some(560),
r#"a character still steps on TerrainType "None""#
);
for size in 0..=4 {
assert_eq!(
pick_kit(&thuds, &steps, size, 10, None),
None,
"…and no size of body thuds on it"
);
}
// The control — a floor with a real material still thuds, at both ends of the size axis.
assert_eq!(
pick_kit(&thuds, &steps, 3, 4, None),
Some(926),
"DeathThudGiantWood"
"Giant on wood"
);
assert_eq!(
pick_kit(&thuds, &steps, 0, 2, None),
Some(910),
"Small on stone"
);
}
}
+87 -1
View File
@@ -33,6 +33,7 @@ use crate::target::{can_attack, ring_reaction, Factions};
use super::Spells;
/// Leg 8's caster unit-flag test — the shared bit, declared once ([`crate::player`]).
use crate::creature_anim::UNIT_FLAG_DISARMED;
use crate::player::UNIT_FLAG_IN_COMBAT;
/// The implicit-target enums leg 10b forks on (`0x6e3f8a`/`0x6e3fa2`): 6 = single enemy →
@@ -50,6 +51,26 @@ pub(crate) struct UsableCtx<'a> {
pub(crate) cooldowns: &'a Cooldowns,
}
/// Equipment slots 15/16 — `EQUIPMENT_SLOT_MAINHAND` / `_OFFHAND` (vmangos `EquipmentSlots`), the
/// two the disarm ladder can hide.
const EQUIPMENT_SLOT_MAINHAND: u8 = 15;
const EQUIPMENT_SLOT_OFFHAND: u8 = 16;
/// One equipment slot's item class, through the same guid → entry → template walk
/// [`equipped_item_fits`] uses. `None` for an empty slot or a template still in flight — and an
/// unresolved template therefore never triggers the disarm skip, the same benefit-of-the-doubt
/// this file gives everywhere else.
fn equipped_class(
store: &ObjectStore,
items: &mut Items,
commands: &NetCommands,
slot: u8,
) -> Option<u8> {
let guid = store.0.player_inv_slot(slot).filter(|&g| g != 0)?;
let entry = items.object(guid).and_then(|o| o.object_entry())?;
Some(items.template(entry, guid, commands)?.class as u8)
}
/// Leg 4's own test (`0x6e40e0`), shared with the spell tooltip's requirement line: does some
/// WORN item match `EquippedItemClass` + `EquippedItemSubClassMask`? `true` when the spell asks
/// for nothing (`class < 0`). An equipped item whose template hasn't streamed yet counts as a
@@ -64,7 +85,20 @@ pub(crate) fn equipped_item_fits(
return true;
}
let class = d.equipped_item_class as u32;
(0..19).any(|slot| {
// The disarm ladder (decision 1863). The reference's own equipped-item test `0x5ea5d0` walks
// its three `GetWeapon` slots with `visFlag = 0`, so the hand `UNIT_FLAG_DISARMED` hides does
// not count toward the requirement — a disarmed warrior's Heroic Strike greys out and its
// tooltip requirement line turns red. Exactly one hand is hidden, main first, so a disarmed
// dual-wielder's off-hand weapon still satisfies it.
let hidden = (store.0.unit_flags() & UNIT_FLAG_DISARMED != 0)
.then(|| {
let main = equipped_class(store, items, commands, EQUIPMENT_SLOT_MAINHAND);
let off = equipped_class(store, items, commands, EQUIPMENT_SLOT_OFFHAND);
crate::creature_anim::disarmed_hand(main, off)
})
.flatten()
.map(|hand| EQUIPMENT_SLOT_MAINHAND + hand as u8);
(0..19).filter(|slot| Some(*slot) != hidden).any(|slot| {
let Some(guid) = store.0.player_inv_slot(slot).filter(|&g| g != 0) else {
return false;
};
@@ -262,6 +296,58 @@ mod tests {
ObjectStore(ObjectFields::from_pairs(&base))
}
/// **The disarm ladder reaches the action bar** (decision 1863). The reference's own
/// equipped-item test `0x5ea5d0` walks its three `GetWeapon` slots with `visFlag = 0`, so the
/// hand `UNIT_FLAG_DISARMED` hides stops satisfying a weapon requirement — a disarmed
/// warrior's Heroic Strike greys out and its tooltip requirement line turns red. Because the
/// ladder hides exactly ONE weapon, main hand first, a dual-wielder is still armed enough.
#[test]
fn a_disarmed_hand_does_not_satisfy_the_equipped_item_requirement() {
use crate::items::TestDeps;
/// `PLAYER_FIELD_INV_SLOT_HEAD + 2×slot` for equipment slots 15 and 16.
const INV_MAINHAND: u16 = 486 + 2 * 15;
const INV_OFFHAND: u16 = 486 + 2 * 16;
const DISARMED: u32 = 0x0020_0000;
// "Requires a melee weapon" — class 2, any subclass.
let needs_a_weapon = SpellDisplay {
equipped_item_class: 2,
equipped_item_subclass_mask: 0,
..Default::default()
};
let fits = |disarmed: bool, hands: &[(u16, u64)]| {
let mut deps = TestDeps::new();
let mut pairs = vec![(46u16, (1 << 3) | if disarmed { DISARMED } else { 0 })];
for (i, (field, guid)) in hands.iter().enumerate() {
pairs.push((*field, *guid as u32));
let entry = 500 + i as u32;
deps.items
.insert_object(*guid, ObjectFields::from_pairs(&[(3, entry)]));
deps.items.insert_template(
entry,
Some(benilla_protocol::messages::ItemInfo {
class: 2,
subclass: 7,
..crate::items::test_template("Sword")
}),
);
}
let store = ObjectStore(ObjectFields::from_pairs(&pairs));
equipped_item_fits(&needs_a_weapon, &store, &mut deps.items, &deps.commands)
};
// CONTROL — armed, the sword satisfies it.
assert!(fits(false, &[(INV_MAINHAND, 0x2a)]));
// Disarmed with only a main hand: the one weapon is hidden, nothing satisfies it.
assert!(!fits(true, &[(INV_MAINHAND, 0x2a)]));
// Disarmed dual-wielder: the main hand's claim cancels the off-hand gate, so the off-hand
// weapon is still there and the ability stays usable.
assert!(fits(true, &[(INV_MAINHAND, 0x2a), (INV_OFFHAND, 0x2b)]));
// Disarmed with an off hand only: that is the hand the ladder hides.
assert!(!fits(true, &[(INV_OFFHAND, 0x2b)]));
}
fn ctx<'a>(
store: &'a ObjectStore,
cooldowns: &'a Cooldowns,
+145 -14
View File
@@ -10,6 +10,7 @@
use benilla_formats::SpellDisplay;
use crate::creature_anim::UNIT_FLAG_DISARMED;
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::net::{NetCommands, ObjectStore};
@@ -30,27 +31,45 @@ const ITEM_SUBCLASS_THROWN: u32 = 16;
/// weapon to borrow from, instead of spell 6603's `Temp` placeholder (decision 0231).
const SPELL_RESET_ICON: &str = "Interface\\Buttons\\Spell-Reset";
/// The equipped main-hand weapon's inventory icon (slot 15 → the item's `ItemDisplayInfo` icon,
/// the chain the bags/paper doll use). `None` when unarmed or the item hasn't streamed yet.
fn main_hand_weapon_icon(
/// `ItemClass` 2 — **WEAPON**: what the disarmed guard tests on the hand it just fetched
/// (`0x4e68df`'s `GetWeapon(0, 1)` result byte `== 2`). Disarm takes weapons, so a main hand
/// holding anything else keeps showing that item's own icon.
const ITEM_CLASS_WEAPON: u32 = 2;
/// The equipped main-hand item's `(item class, its inventory icon)` — slot 15 → the item's
/// `ItemDisplayInfo` icon, the chain the bags/paper doll use. `None` for an empty hand or an item
/// that hasn't streamed yet; the icon half alone is `None` when the display row or its texture
/// hasn't. Both halves come off ONE walk because the disarmed guard needs the class of the very
/// item whose icon would otherwise be shown.
fn main_hand_item(
store: &ObjectStore,
items: &mut Items,
icons: Option<&ItemDisplays>,
commands: &NetCommands,
) -> Option<String> {
) -> Option<(u32, Option<String>)> {
let guid = store.0.player_inv_slot(EQUIPMENT_SLOT_MAINHAND)?;
let entry = items.object(guid)?.object_entry()?;
let display = items.template(entry, guid, commands)?.display_info_id;
icons?.catalog.get(display)?.icon.clone()
let template = items.template(entry, guid, commands)?;
let (class, display) = (template.class, template.display_info_id);
let icon = icons
.and_then(|i| i.catalog.get(display))
.and_then(|d| d.icon.clone());
Some((class, icon))
}
/// The character's melee auto-attack icon (decision 0231; the client's melee helper `0x4e6870`):
/// the **current shapeshift form's own attack face** when its `SpellShapeshiftForm` row carries
/// one (the `+0x34` AttackIconID read, `0x4e68af`–`0x4e68da` — a cat's paw, a bear's swipe; wow-re
/// `action-spell-icon-apis.md` §3.3, closing decision 0231's deferred form case), else the
/// equipped main-hand weapon's icon, else [`SPELL_RESET_ICON`]. Character-level — independent of
/// WHICH auto-attack spell (they all show this), so the spellbook can pre-resolve it once for its
/// whole page.
/// The character's melee auto-attack icon (decision 0231; the client's melee helper `0x4e6870`).
/// The helper's four steps, in order (wow-re `attack-icon-substitution.md` §7):
///
/// 1. the **current shapeshift form's own attack face** when its `SpellShapeshiftForm` row carries
/// one (the `+0x34` AttackIconID read, `0x4e68af`–`0x4e68da` — a cat's paw, a bear's swipe;
/// wow-re `action-spell-icon-apis.md` §3.3, closing decision 0231's deferred form case);
/// 2. the **disarmed guard** (`0x4e68df`) → [`SPELL_RESET_ICON`], weapon equipped or not
/// (decision 1863, closing 0231's other deferred case);
/// 3. the equipped main-hand weapon's icon;
/// 4. no main-hand item → [`SPELL_RESET_ICON`].
///
/// Character-level — independent of WHICH auto-attack spell (they all show this), so the
/// spellbook can pre-resolve it once for its whole page.
pub(crate) fn melee_auto_attack_icon(
store: &ObjectStore,
forms: &std::collections::HashMap<u32, benilla_formats::ShapeshiftForm>,
@@ -67,7 +86,20 @@ pub(crate) fn melee_auto_attack_icon(
return icon;
}
}
main_hand_weapon_icon(store, items, icons, commands)
let main = main_hand_item(store, items, icons, commands);
// Precedence step 2 — the **disarmed guard** (`0x4e68df`: `test dword ptr [ecx+0xa0],
// 0x200000`, then `GetWeapon(0, 1)` and a `== 2` on the returned class byte): while the
// character is disarmed, a weapon in the main hand shows `Spell-Reset` exactly as an empty
// hand does — the weapon is equipped and on screen, but the button reads unarmed, because
// the swing it fires is (decision 1863, closing 0231's deferred case).
if store.0.unit_flags() & UNIT_FLAG_DISARMED != 0
&& main
.as_ref()
.is_some_and(|&(class, _)| class == ITEM_CLASS_WEAPON)
{
return SPELL_RESET_ICON.to_string();
}
main.and_then(|(_, icon)| icon)
.unwrap_or_else(|| SPELL_RESET_ICON.to_string())
}
@@ -124,3 +156,102 @@ pub(super) fn auto_attack_icon(
}
None
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use benilla_formats::{ItemDisplay, ItemDisplayCatalog, ShapeshiftForm};
use benilla_protocol::messages::{ItemInfo, ObjectFields};
use super::*;
use crate::items::TestDeps;
/// `PLAYER_FIELD_INV_SLOT_HEAD + 2×15` (the main hand's private item guid), `UNIT_FIELD_FLAGS`
/// and `UNIT_FIELD_BYTES_1` (the form byte lives in its third byte) — raw wire indices, the
/// constants being crate-private to benilla-protocol.
const INV_SLOT_MAINHAND: u16 = 486 + 2 * 15;
const UNIT_FLAGS: u16 = 46;
const UNIT_BYTES_1: u16 = 138;
/// `UNIT_FLAG_DISARMED`.
const DISARMED: u32 = 0x0020_0000;
const SWORD_ICON: &str = "Interface\\Icons\\INV_Sword_04";
const BEAR_ICON: &str = "Interface\\Icons\\Ability_Racial_BearForm";
/// One `melee_auto_attack_icon` resolve. `hand` is the item CLASS held in the main hand
/// (`None` = empty), `flags` the descriptor's `UNIT_FIELD_FLAGS`, `form` the shapeshift byte.
/// The item is always the same sword display, so any change in the answer is the law moving,
/// not the fixture.
fn icon(flags: u32, hand: Option<u32>, form: u8) -> String {
let mut deps = TestDeps::new();
let mut pairs = vec![(UNIT_FLAGS, flags), (UNIT_BYTES_1, u32::from(form) << 16)];
if let Some(class) = hand {
pairs.push((INV_SLOT_MAINHAND, 0x2a));
deps.items
.insert_object(0x2a, ObjectFields::from_pairs(&[(3, 500)]));
deps.items.insert_template(
500,
Some(ItemInfo {
class,
subclass: 7,
display_info_id: 950,
..crate::items::test_template("Worn Shortsword")
}),
);
}
let store = ObjectStore(ObjectFields::from_pairs(&pairs));
let icons =
ItemDisplays::icons_for_tests(ItemDisplayCatalog::from_displays(HashMap::from([(
950u32,
ItemDisplay {
icon: Some(SWORD_ICON.into()),
..Default::default()
},
)])));
// Form 1 carries an attack icon (a bear's swipe); every other form id has none.
let forms = HashMap::from([(
1u32,
ShapeshiftForm {
attack_icon: Some(BEAR_ICON.into()),
..Default::default()
},
)]);
melee_auto_attack_icon(
&store,
&forms,
&mut deps.items,
Some(&icons),
&deps.commands,
)
}
/// **The disarmed guard on the Attack button** (`0x4e68df`, decision 1863 closing 0231's
/// deferred case): a disarmed character's auto-attack shows `Spell-Reset` with the weapon
/// still equipped — the same face an empty hand shows, because the swing it fires is the
/// same bare-handed one.
#[test]
fn a_disarmed_character_shows_spell_reset_though_armed() {
// CONTROL — armed, and the button wears the sword.
assert_eq!(icon(0, Some(2), 0), SWORD_ICON);
// The guard.
assert_eq!(icon(DISARMED, Some(2), 0), SPELL_RESET_ICON);
// Its `== 2` half (the recursed `GetWeapon(0, 1)` class test): Disarm takes weapons, so a
// main hand holding anything else keeps showing that item.
assert_eq!(icon(DISARMED, Some(4), 0), SWORD_ICON);
// The step-4 fallback is the same string, and is NOT what the guard is being read from:
// an empty hand shows `Spell-Reset` whether the flag is up or down.
assert_eq!(icon(0, None, 0), SPELL_RESET_ICON);
assert_eq!(icon(DISARMED, None, 0), SPELL_RESET_ICON);
}
/// The precedence the helper reads in (§7 of the wow-re note): the **form** override is step
/// 1 and the disarmed guard step 2, so a disarmed bear still swipes with its own paw.
#[test]
fn the_form_icon_outranks_the_disarmed_guard() {
assert_eq!(icon(DISARMED, Some(2), 1), BEAR_ICON);
assert_eq!(icon(0, Some(2), 1), BEAR_ICON, "and outranks the weapon");
// A form with no attack icon of its own falls through to the rest of the ladder.
assert_eq!(icon(DISARMED, Some(2), 2), SPELL_RESET_ICON);
assert_eq!(icon(0, Some(2), 2), SWORD_ICON);
}
}
+37 -6
View File
@@ -7,7 +7,7 @@
//! Unlike the questgiver panels (a transient NPC-session window), the quest log is **durable
//! player state**: [`feed_quest_log`] reads the self player's `PLAYER_QUEST_LOG` descriptor slots
//! every frame (the wire pin's field 198 + 3·slot) rather than reacting to one wire event, and
//! resolves EVERY occupied slot's title/level/objectives from the `SMSG_QUEST_QUERY_RESPONSE`
//! resolves EVERY occupied slot's title/level/tag/objectives from the `SMSG_QUEST_QUERY_RESPONSE`
//! template cache ([`QuestLog`] — the exact ask-once twin of [`crate::items::Items`]'s template
//! cache): [`build_objectives`] turns the template's fixed objective quads + the slot's counters
//! (creature/GO objectives) or the live bag count (item objectives; the wire pin: item-objective
@@ -138,7 +138,8 @@ impl Plugin for UiQuestLogPlugin {
app.init_resource::<QuestLog>()
.add_systems(
Startup,
load_quest_header_names.after(benilla_assets::AssetSet::Open),
(load_quest_header_names, load_quest_tag_names)
.after(benilla_assets::AssetSet::Open),
)
.add_systems(
Update,
@@ -423,6 +424,7 @@ fn feed_quest_log(
icons: Option<Res<ItemDisplays>>,
commands: Res<NetCommands>,
header_names: Option<Res<QuestHeaderNamesRes>>,
tag_names: Option<Res<QuestTagNamesRes>>,
states: Res<crate::world_state::WorldStates>,
mut last: Local<crate::ui_script::VmMemo<QuestLogState>>,
mut prior_quest_ids: Local<crate::ui_script::VmMemo<Option<HashSet<u32>>>>,
@@ -501,7 +503,7 @@ fn feed_quest_log(
quest_id: 0,
title: name.clone(),
level: 0,
tag: None,
tag: None, // a header names a zone/sort, never a quest — it can carry no tag
is_header: true,
collapsed,
complete: 0,
@@ -516,12 +518,20 @@ fn feed_quest_log(
if collapsed {
continue; // folded: the quest stays in the log, just not in the visible list
}
let (title, level, pushable, objectives) = match quest_log
let (title, level, tag, pushable, objectives) = match quest_log
.template(r.quest_id, &commands)
{
Some(t) => (
t.title.clone(),
t.level,
// The `(Elite)`/`(Dungeon)`/`(Raid)`/`(PvP)` suffix: the template's `Type` is
// a `QuestInfo.dbc` row id ([`benilla_formats::QuestTagNames`]), and `Type`
// 0 — the untagged majority — names no row. Absent table (no client data) =
// no tag, never an empty string: the row Lua branches on presence.
tag_names
.as_ref()
.and_then(|tags| tags.0.resolve(t.quest_type))
.map(str::to_string),
// `QUEST_FLAGS_SHARABLE` (vmangos `QuestDef.h:153`) — the whole of the
// shareable test, and it is the CLIENT's alone: vmangos's
// `HandlePushQuestToParty` never checks the bit, it only re-tests it on the
@@ -535,7 +545,7 @@ fn feed_quest_log(
// QUEST_LOG_UPDATE refresh on landing fills the row in. `pushable` false with it:
// the reference reads the same cache, so its button is dark until the answer lands
// too.
None => ("...".to_string(), 0, false, Vec::new()),
None => ("...".to_string(), 0, None, false, Vec::new()),
};
let complete = if r.log_slot.state & quest_slot_state::COMPLETE != 0 {
1
@@ -548,7 +558,7 @@ fn feed_quest_log(
quest_id: r.quest_id,
title,
level,
tag: None,
tag,
is_header: false,
collapsed: false,
complete,
@@ -767,6 +777,27 @@ fn load_quest_header_names(
}
}
/// The quest `Type → tag name` lookup ([`benilla_formats::QuestTagNames`] — `QuestInfo.dbc`),
/// loaded once at startup. Absent when the client data didn't load; the feed then pushes no tag,
/// which is what an untagged quest pushes anyway.
#[derive(Resource)]
pub(crate) struct QuestTagNamesRes(pub benilla_formats::QuestTagNames);
/// Startup: read the `QuestInfo.dbc` tag names through the patch chain (the header-names load's
/// exact shape).
fn load_quest_tag_names(mut commands: Commands, assets: Option<Res<benilla_assets::WorldAssets>>) {
use benilla_assets::LockRecover;
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_quest_tag_names(&mut chain)
};
match loaded {
Ok(names) => commands.insert_resource(QuestTagNamesRes(names)),
Err(e) => warn!("ui_quest_log: quest tag names failed to load: {e:#}"),
}
}
/// Drain the header fold intents (`CollapseQuestHeader`/`ExpandQuestHeader`): map each 1-based
/// entry index through this frame's `header_keys` to the header TITLE and toggle it in the
/// app-owned collapse set; index 0 = every header (the ref's collapse-all button). The next
@@ -965,3 +965,66 @@ fn the_combat_log_window_runs_its_own_bottom_button_blink() {
"window 2's bottom-button flash blinks while it is scrolled up"
);
}
/// **The chat menu runs on the REFERENCE's UIMenu kit** — `Interface\FrameXML\UIMenu.xml` and its
/// own `UIMenu.lua`, not a transcription of them.
///
/// Ours kept the reference's ten names in `ChatFrame.xml`, which loads at manifest 939 against the
/// chain's 194, so our copies overwrote the chain's from the day that entry landed — the reverse of
/// 1855's direction, and nothing drove this menu in a test, so nothing said so. Decision 1869.
#[test]
fn the_chat_menu_builds_its_rows_on_the_references_kit() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"GameTooltip.xml",
r"Interface\FrameXML\UIMenu.xml",
"ChatFrame.xml",
] {
load_xml(&s, f);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
let _ = s.errors();
// `ChatMenu_OnLoad` calls `UIMenu_Initialize()` bare, relying on `this` — the reference's own
// idiom — then `UIMenu_AddButton` per row. If the kit were missing, the rows would not exist.
s.run("ChatMenu:Show()").unwrap();
assert!(s.errors().is_empty(), "opening it raises: {:?}", s.errors());
assert!(
s.eval::<i64>("return ChatMenu.numButtons or 0").unwrap() >= 7,
"the reference's `UIMenu_AddButton` counted the rows this window adds"
);
assert_eq!(
s.eval::<String>("return ChatMenuButton1:GetText()")
.unwrap(),
"Say",
// The reference labels a row with `button:SetText(text)` — the Button's own text, not a
// named FontString, so there is no `…Button1Text` to read.
"row 1 is the Say row, built by the chain's kit"
);
// A row click runs the owner's func and closes the menu — `UIMenuButton_OnClick`'s contract.
s.run("ChatMenuButton1:Click()").unwrap();
assert!(
s.errors().is_empty(),
"a row click raises: {:?}",
s.errors()
);
assert!(
!s.eval::<bool>("return ChatMenu:IsShown() and true or false")
.unwrap(),
"the reference's row click hides the menu"
);
assert!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
.unwrap()
.starts_with("/s"),
"and it seeded the edit box with the row's slash"
);
}
@@ -624,6 +624,84 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
/// The empty-log v1 simplification (this file's XML header comment): zero entries hides every row,
/// disables Abandon, and shows the centered empty-state message instead of `EmptyQuestLogFrame`.
/// The quest TAG on the list row (`GetQuestLogTitle`'s third return): it paints on its own
/// right-flush `$parentTag` string, wrapped in the ref's parentheses, and a finished quest's
/// Failed/Complete word OVERWRITES it — the ref's own precedence (`QuestLogFrame.lua:187-195`),
/// which is why one row never shows both. Headers carry no tag. The title keeps the bare quest
/// name: the state word is NOT appended to it any more.
#[test]
fn the_row_tag_is_its_own_right_flush_string_and_the_state_word_wins() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
// The state word comes from the reference's OWN globals (`COMPLETE`/`FAILED`) as of this row's
// ref-verbatim tag handling — the app loads GlobalStrings.lua as `benilla.toc`'s second entry,
// so this harness must too or the override silently reads nil.
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "Interface\\FrameXML\\MerchantFrame.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
let mut state = eight_entries();
// Row 1: a plain elite quest. Row 2: an elite quest that is COMPLETE — the state word wins.
// Row 3: no tag at all, in progress. Row 4: a header, which can carry neither.
state.entries[0].tag = Some("Elite".into());
state.entries[1].tag = Some("Raid".into());
state.entries[1].complete = 1;
state.entries[2].complete = -1;
state.entries[3].is_header = true;
state.entries[3].title = "Elwynn Forest".into();
state.entries[3].tag = None;
s.set_quest_log(state);
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let tag = |s: &mut UiScript, i: u32| {
s.eval::<String>(&format!("return QuestLogTitle{i}Tag:GetText()"))
.unwrap()
};
assert_eq!(
tag(&mut s, 1),
"(Elite)",
"the engine's tag, in parentheses"
);
assert_eq!(
tag(&mut s, 2),
"(Complete)",
"COMPLETE overwrites the quest's own tag — ref l.190-192"
);
assert_eq!(tag(&mut s, 3), "(Failed)", "FAILED, same override");
assert_eq!(tag(&mut s, 4), "", "a header carries no tag");
// The title is the bare (indented) name — the state word lives on the tag string now, so
// appending it here too would double it.
let title1 = s
.eval::<String>("return QuestLogTitle1Text:GetText()")
.unwrap();
assert_eq!(title1, " Quest 1");
let title2 = s
.eval::<String>("return QuestLogTitle2Text:GetText()")
.unwrap();
assert_eq!(title2, " Quest 2", "no \"(Complete)\" on the title");
// Right-flush at the row's right edge less 2 — the ref's own anchor (its
// QuestLogFrame.xml:10-21).
let row_right = s.eval::<f32>("return QuestLogTitle1:GetRight()").unwrap();
let tag_right = s
.eval::<f32>("return QuestLogTitle1Tag:GetRight()")
.unwrap();
assert!(
(row_right - tag_right - 2.0).abs() < 0.5,
"tag right-flush at row right -2 (row {row_right}, tag {tag_right})"
);
}
#[test]
fn empty_quest_log_hides_rows_and_disables_abandon() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1948,7 +1948,11 @@ mod tests {
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") {
// `<Include>` brings a sibling document; `<Script file=>` brings the code, and
// that is where nearly every `RegisterEvent` lives — a reader that follows only
// the first sees a window's frames without its handlers.
let kind = tag.trim_start();
if !kind.starts_with("Include") && !kind.starts_with("Script") {
continue;
}
if let Some(file) = tag_attr(tag, "file") {
@@ -2037,4 +2041,156 @@ mod tests {
stale.join("\n ")
);
}
/// **A stock file listening for an event nothing produces is silent on both sides.**
///
/// This is 1819's shape: `ui_unit.rs` fired the Classic Era power pair while the reference's
/// frames registered the 1.12 per-resource names, so no mana bar could live-update — and
/// nothing anywhere said so, because an event name is a plain string at both ends. 1818 is the
/// same seam one API over. The *arity* half of that class already has an instrument (the shape
/// gate, 1842/1843/1845); this is the event half, which had none.
///
/// A CENSUS with a declared set, not a hard zero: most of these are features we have not built,
/// and listing them is the point. What must not happen is a NEW one appearing — that means a
/// window migration just brought a listener nothing feeds, which is exactly how 1819 arrived.
///
/// **Constructed names have to be declared**, because a literal scan cannot see them:
/// `ui_unit.rs` builds the power events with `format!("UNIT_{}", power_token(...))`, and a gate
/// blind to that reports nine false positives — which is what its first run did.
#[test]
fn every_event_a_chain_file_registers_has_a_producer() {
let _data = benilla_formats::wow_data_or_skip!();
// Names benilla builds at runtime rather than writing as literals. Each is a family, with
// the site that constructs it — an entry here is a promise that something fires it.
const CONSTRUCTED: &[&str] = &[
// `ui_unit.rs`: `format!("UNIT_{}", power_token(ty))` and its `UNIT_MAX…` twin, over
// `power_token`'s five resources (`unit/mod.rs`). Decision 1819.
"UNIT_MANA",
"UNIT_RAGE",
"UNIT_FOCUS",
"UNIT_ENERGY",
"UNIT_HAPPINESS",
"UNIT_MAXMANA",
"UNIT_MAXRAGE",
"UNIT_MAXFOCUS",
"UNIT_MAXENERGY",
"UNIT_MAXHAPPINESS",
];
// Registered by a chain file we load, produced by nothing. Each is a feature we have not
// built; none is 1819-shaped, because no PAIR is split (a half-fired pair is the tell).
const UNPRODUCED: &[(&str, &str)] = &[
("BAG_OPEN", "ContainerFrame.lua"),
("CHAT_MSG_RAID_WARNING", "RaidWarning.lua"),
(
"CVAR_UPDATE",
"TextStatusBar.lua — a CVar change does not repaint the bar text",
),
("DISPLAY_SIZE_CHANGED", "the four paperdoll files"),
("ITEM_TEXT_TRANSLATION", "ItemTextFrame.lua"),
(
"PARTY_MEMBER_DISABLE",
"PartyMemberFrame.lua — the offline grey-out",
),
("PARTY_MEMBER_ENABLE", "PartyMemberFrame.lua"),
("PET_UI_CLOSE", "PetPaperDollFrame.lua"),
("PET_UI_UPDATE", "PetPaperDollFrame.lua"),
("PLAYER_DAMAGE_DONE_MODS", "PaperDollFrame.lua"),
(
"PLAYER_FLAGS_CHANGED",
"TargetFrame.lua — the AFK/DND badge",
),
(
"PLAYER_REGEN_ENABLED",
"PlayerFrame.lua — and _DISABLED is unfired too, so the pair is whole",
),
("PLAYTIME_CHANGED", "PlayerFrame.lua"),
("SHOW_COMPARE_TOOLTIP", "PaperDollFrame.lua"),
("SYSMSG", "UIErrorsFrame.lua"),
("UNIT_DEFENSE", "PetPaperDollFrame.lua"),
(
"UNIT_MODEL_CHANGED",
"four files — the paperdoll model refresh",
),
("UNIT_PORTRAIT_UPDATE", "three files — the portrait refresh"),
];
let mut fired: std::collections::HashSet<String> =
CONSTRUCTED.iter().map(|s| (*s).to_string()).collect();
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("workspace root")
.join("crates");
let mut stack = vec![root];
while let Some(dir) = stack.pop() {
for e in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "rs") {
let text = std::fs::read_to_string(&p).unwrap_or_default();
let mut from = 0;
while let Some(i) = text[from..].find("fire_event(") {
let at = from + i + "fire_event(".len();
from = at;
let rest = text[at..].trim_start();
if let Some(body) = rest.strip_prefix('"') {
if let Some(end) = body.find('"') {
fired.insert(body[..end].to_string());
}
}
}
}
}
}
let toc = &super::super::addons::Addon::builtin().toc.files;
let mut dead: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
for entry in toc.iter().filter(|f| super::is_chain_entry(f)) {
for text in entry_sources(entry) {
let mut from = 0;
while let Some(i) = text[from..].find("RegisterEvent(") {
let at = from + i + "RegisterEvent(".len();
from = at;
let rest = text[at..].trim_start();
if let Some(body) = rest.strip_prefix('"') {
if let Some(end) = body.find('"') {
let ev = &body[..end];
if !fired.contains(ev) {
dead.entry(ev.to_string()).or_insert_with(|| entry.clone());
}
}
}
}
}
}
let expected: std::collections::HashSet<&str> =
UNPRODUCED.iter().map(|(e, _)| *e).collect();
let surprises: Vec<String> = dead
.iter()
.filter(|(e, _)| !expected.contains(e.as_str()))
.map(|(e, f)| format!("{e} (registered in {f})"))
.collect();
assert!(
surprises.is_empty(),
"a chain file listens for an event NOTHING fires, and it is not one of the known gaps \
— this is 1819 arriving: a window migration brought a listener with no producer:\n {}",
surprises.join("\n ")
);
let fixed: Vec<&str> = UNPRODUCED
.iter()
.map(|(e, _)| *e)
.filter(|e| !dead.contains_key(*e))
.collect();
assert!(
fixed.is_empty(),
"these now HAVE a producer — take them out of UNPRODUCED so the list keeps meaning \
what it says:\n {fixed:?}"
);
}
}
@@ -72,7 +72,7 @@ pub(crate) fn m2_names(chain: &mut Chain, prefix: Option<&str>) -> Result<Vec<St
/// spell it inline had drifted into spelling it *differently* (one guarded a stem shorter than
/// four characters, the other did not, so a hypothetical `123.wmo` was a root to one and a group
/// to the other).
fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result<Vec<String>> {
pub(crate) fn wmo_roots(chain: &mut Chain, prefix: Option<&str>) -> Result<Vec<String>> {
let pfx = prefix.map(|p| p.to_ascii_lowercase().replace('/', "\\"));
Ok(chain
.list()
@@ -60,6 +60,7 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> {
}
let axis: Vec<u32> = thuds.terrain_sounds().collect();
let classes: BTreeSet<u32> = thuds.size_classes();
let classes_all = classes.clone();
println!(
"DeathThudLookups.dbc — {} rows over {} size class(es) × {} TerrainTypeSounds id(s)\n",
@@ -202,5 +203,91 @@ pub fn thudcensus(chain: &mut Chain) -> Result<()> {
dangling.len(),
off_axis.len()
);
// ── 3 · where a corpse is SILENT ─────────────────────────────────────────────────────────
// The asymmetry the table half implies and only this sweep sizes. Both lookups are keyed on
// `TerrainType.SoundID`, and `TerrainType 10 "None"` — the unauthored default a WMO surface
// takes when its `MOMT+0x20` says nothing — has `SoundID = 0`. `FootstepTerrainLookup` has a
// **row at terrain sound 0** for 17 footstep classes; `DeathThudLookups` has **none at all**.
// So the same floor that creaks underfoot swallows the body that lands on it, and that is
// authored data in both directions, not a gap on either side.
//
// What this section measures is how much of the shipped building stock that covers, because
// "a corpse landed silently indoors" is a *correct* observation that looks exactly like a bug.
let ftl_at_zero: Vec<u32> = (0..256)
.filter(|c| steps.resolve_terrain(*c, 10).is_some())
.collect();
let dtl_at_zero = classes_all
.iter()
.filter(|c| thuds.resolve(**c, 0).is_some())
.count();
println!(
"\nSilence indoors — TerrainType 10 \"None\" (SoundID 0): {} footstep class(es) have a \
lookup row, {dtl_at_zero} size class(es) have a thud row",
ftl_at_zero.len()
);
let mut wmo_roots = 0u32;
let mut materials = 0u32;
let mut by_terrain: BTreeMap<u32, u32> = BTreeMap::new();
let mut silent_roots = 0u32;
let mut thudding: Vec<String> = Vec::new();
for path in crate::scan::wmo_roots(chain, None)? {
let Ok(bytes) = chain.read_file(&path) else {
continue;
};
let Ok(root) = benilla_formats::parse_wmo_root(&bytes) else {
continue;
};
wmo_roots += 1;
let mut any_thud = false;
for ground_type in root.material_ground_types() {
materials += 1;
*by_terrain.entry(ground_type).or_default() += 1;
// A material thuds iff its terrain id reaches a nonzero TerrainTypeSounds class that
// the thud table has a row for. Size class is irrelevant to *whether* — every class
// 0..=4 is populated on the same terrain axis.
if steps
.sound_class_of(ground_type)
.is_some_and(|ts| thuds.resolve(0, ts).is_some())
{
any_thud = true;
}
}
if any_thud {
thudding.push(path);
} else {
silent_roots += 1;
}
}
println!(
"\n{wmo_roots} WMO root(s), {materials} MOMT material(s) — the `MOMT+0x20` TerrainType each \
surface carries\n"
);
for (terrain, n) in &by_terrain {
let sound = steps.sound_class_of(*terrain);
let verdict = match sound {
Some(0) | None => "SILENT — no thud row".to_string(),
Some(ts) => match thuds.resolve(0, ts) {
Some(_) => format!("thuds (terrain sound {ts})"),
None => format!("SILENT — no thud row (terrain sound {ts})"),
},
};
println!(
" TerrainType {:>3} {:>6} material(s) {:>5.1}% {verdict}",
terrain,
n,
100.0 * f64::from(*n) / f64::from(materials.max(1)),
);
}
println!(
"\n{silent_roots} of {wmo_roots} WMO roots have NO surface that can thud; {} do",
thudding.len()
);
// All of them, not a head: the question this list answers is "does THIS building thud", and a
// truncated list cannot answer it. 121 lines is a census, not a flood.
for p in &thudding {
println!(" thuds somewhere: {p}");
}
Ok(())
}
+41
View File
@@ -83,3 +83,44 @@ pub(crate) fn load_spell_icon_map(chain: &mut Chain) -> Result<HashMap<u32, Stri
}
Ok(icons)
}
/// A minimal `ID(0) … Name(name_col) …` schema of `cols` u32-wide columns — the shape every
/// "id → localized name" DBC has, since a string column is itself one u32 offset into the string
/// block. Enough for the id→name reads; the loc block's other 7 slots and its flag mask stay
/// anonymous `cN` columns.
pub(crate) fn id_name_schema(what: &str, name_col: usize, cols: usize) -> Schema {
let mut s = Schema::new(what);
for i in 0..cols {
if i == name_col {
s.add_field(SchemaField::new("Name", FieldType::String));
} else {
s.add_field(SchemaField::new(format!("c{i}"), FieldType::UInt32));
}
}
s
}
/// Read an `ID → name` DBC through the patch chain into a map, skipping rows whose name is empty.
/// Shared by every such table ([`crate::quest_headers`]'s AreaTable/QuestSort,
/// [`crate::quest_info`]'s QuestInfo) — the layout differences are the two column numbers.
pub(crate) fn load_id_name_table(
chain: &mut Chain,
file: &str,
name_col: usize,
cols: usize,
what: &str,
) -> Result<HashMap<u32, String>> {
let bytes = chain
.read_file(file)
.with_context(|| format!("reading {file}"))?;
let rs = parse(&bytes, id_name_schema(what, name_col, cols), what)?;
let mut out = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
// `str_at` already drops an empty string — a row whose name slot points at the string
// block's leading NUL is a hole, not a name.
if let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, name_col)) {
out.insert(id, name);
}
}
Ok(out)
}
+2
View File
@@ -143,6 +143,8 @@ mod anim_data;
pub use anim_data::{load_anim_data_catalog, AnimDataCatalog, AnimEntry};
mod quest_headers;
pub use quest_headers::{load_quest_header_names, QuestHeaderNames};
mod quest_info;
pub use quest_info::{load_quest_tag_names, QuestTagNames};
mod area_trigger;
pub use area_trigger::{load_area_trigger_catalog, AreaTriggerCatalog, AreaTriggerRow};
mod area_sound;
+4 -36
View File
@@ -10,11 +10,10 @@
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use anyhow::Result;
use crate::chain::Chain;
use crate::dbc::{parse, str_at, u32_at};
use crate::dbc::load_id_name_table;
/// The resolved `ZoneOrSort → name` lookup ([`load_quest_header_names`]).
#[derive(Debug, Default)]
@@ -41,41 +40,10 @@ impl QuestHeaderNames {
}
}
/// A minimal `ID(0) … name(name_col) …` schema of `cols` u32-wide columns (strings are one u32
/// ref) — enough for the two id→name reads this module does.
fn id_name_schema(what: &str, name_col: usize, cols: usize) -> Schema {
let mut s = Schema::new(what);
for i in 0..cols {
if i == name_col {
s.add_field(SchemaField::new("Name", FieldType::String));
} else {
s.add_field(SchemaField::new(format!("c{i}"), FieldType::UInt32));
}
}
s
}
/// Load both name tables through the patch chain.
pub fn load_quest_header_names(chain: &mut Chain) -> Result<QuestHeaderNames> {
let mut read =
|file: &str, name_col: usize, cols: usize, what: &str| -> Result<HashMap<u32, String>> {
let bytes = chain
.read_file(file)
.with_context(|| format!("reading {file}"))?;
let rs = parse(&bytes, id_name_schema(what, name_col, cols), what)?;
let mut out = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let (Some(id), Some(name)) = (u32_at(r, 0), str_at(&rs, r, name_col)) else {
continue;
};
if !name.is_empty() {
out.insert(id, name);
}
}
Ok(out)
};
Ok(QuestHeaderNames {
zones: read("DBFilesClient\\AreaTable.dbc", 11, 25, "AreaTable")?,
sorts: read("DBFilesClient\\QuestSort.dbc", 1, 10, "QuestSort")?,
zones: load_id_name_table(chain, "DBFilesClient\\AreaTable.dbc", 11, 25, "AreaTable")?,
sorts: load_id_name_table(chain, "DBFilesClient\\QuestSort.dbc", 1, 10, "QuestSort")?,
})
}
+84
View File
@@ -0,0 +1,84 @@
//! Quest-log **tag names**: the quest template's `Type` → the parenthesised suffix a quest-log row
//! carries after its title — `(Elite)`, `(Dungeon)`, `(Raid)`, `(PvP)`.
//!
//! `QuestInfo.dbc` on 5875 (read here through the client's own patch chain — it ships in
//! `patch-2.MPQ`) is **7 records × 10 u32 fields, record size 40**: `ID(0)`, `Name(1)`, the 7
//! remaining loc-block slots, and the loc flag mask at col 9 — the same `ID … Name … loc` shape
//! `QuestSort.dbc` has, so it reads through the same [`crate::dbc::load_id_name_table`].
//!
//! **The whole table**, in file order — these seven ids are the entire tag vocabulary a 1.12
//! client can render, and they are SPARSE, so the lookup is by id and never by row index:
//!
//! | ID | Name |
//! |----|------|
//! | 1 | `Elite` |
//! | 21 | `Life` |
//! | 41 | `PvP` |
//! | 62 | `Raid` |
//! | 81 | `Dungeon` |
//! | 82 | `World Event` |
//! | 83 | `Legendary` |
//!
//! Note `1 = "Elite"`, not the later expansions' `"Group"` — a 1.12 group quest reads `(Elite)`.
//!
//! The key is [`benilla_protocol::messages::QuestTemplate::quest_type`], the 5th `u32` of
//! `SMSG_QUEST_QUERY_RESPONSE` (vmangos writes `pQuest->GetType()` there, and its
//! `quest_template.Type` carries exactly these sparse ids). A quest with `Type == 0` — the great
//! majority — names no row and takes no tag.
//!
//! All of that is **§5-verified against the binary** (wow-re `ui/scratch/questlog-title-tag.md`):
//! `0x4df930` reads the cached template's `+0x10`, bounds-checks it against the table's maxId
//! (`0xc0d9d0`) and indexes `0xc0d9cc` with no arithmetic on the id; type `0` passes the bounds
//! and dies on the zero-filled slot, which reaches Lua as `nil` because `lua_pushstring` tail-jumps
//! to `lua_pushnil` on NULL. **Read this path through the patch chain and nowhere else:** the base
//! `dbc.MPQ` copy has only four rows (PvP, Life, Elite, Raid; maxId 62), so a truncated chain
//! silently loses Dungeon, World Event and Legendary while looking right on everything else — the
//! fixture test's row-count assert is what stands between us and that.
//!
//! **The `Type` field has a second reader in the client, and it is not a tag:** the
//! `PLAYER_QUEST_LOG` field watcher (`0x5dde6b`/`0x5ddf02`) tests it against `1` and fires
//! TriggerTutorial 40 when an *Elite* quest is accepted. Nothing else renders this table
//! (`0xc0d9cc` has one reader image-wide), so the tag belongs to the quest-log row and to nothing
//! else — but `+0x10` is not "the tag id".
use std::collections::HashMap;
use anyhow::Result;
use crate::chain::Chain;
use crate::dbc::load_id_name_table;
/// The resolved `Type → tag name` lookup ([`load_quest_tag_names`]).
#[derive(Debug, Default)]
pub struct QuestTagNames(HashMap<u32, String>);
impl QuestTagNames {
/// The tag for a quest template's `Type`, or `None` for a type the table doesn't name —
/// which includes `0`, the untagged majority. The caller renders nothing for `None`; it must
/// not substitute an empty string, because the reference's row Lua branches on the tag's
/// presence, not on its length.
pub fn resolve(&self, quest_type: u32) -> Option<&str> {
self.0.get(&quest_type).map(String::as_str)
}
/// How many tags the table names — the load's own sanity check (5875 ships 7).
pub fn len(&self) -> usize {
self.0.len()
}
/// Whether the table named nothing at all.
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
/// Load the tag names through the patch chain.
pub fn load_quest_tag_names(chain: &mut Chain) -> Result<QuestTagNames> {
Ok(QuestTagNames(load_id_name_table(
chain,
"DBFilesClient\\QuestInfo.dbc",
1,
10,
"QuestInfo",
)?))
}
@@ -0,0 +1,42 @@
//! Asset-gated fixture: `QuestInfo.dbc` against the real 5875 data — pins the whole 7-row tag
//! vocabulary and the `ID(0)/Name(1)` columns of its 10-column loc-block layout, so a schema drift
//! or column slip fails loudly instead of quietly blanking every quest-log tag. Skips (passes)
//! without `<repo>/WoW/Data`.
use benilla_formats::{load_quest_tag_names, open_chain};
#[test]
fn quest_tag_names_are_the_whole_5875_vocabulary() {
let data = benilla_formats::wow_data_or_skip!();
let mut chain = open_chain(&data).expect("open vanilla patch chain");
let tags = load_quest_tag_names(&mut chain).expect("load quest tag names");
// The complete table — 5875 ships exactly these seven, and the ids are sparse (so the lookup
// must be by id, never by row index).
let want = [
(1, "Elite"),
(21, "Life"),
(41, "PvP"),
(62, "Raid"),
(81, "Dungeon"),
(82, "World Event"),
(83, "Legendary"),
];
// The row count is the load-bearing assert, not a tidiness one. The client resolves this
// path to **patch-2.MPQ**; the base `dbc.MPQ` copy has only FOUR rows (PvP, Life, Elite,
// Raid, unordered, maxId 62), so a chain that stopped short of the patch would answer `None`
// for exactly Dungeon / World Event / Legendary and look perfectly correct on everything
// else (wow-re `ui/scratch/questlog-title-tag.md`).
assert_eq!(tags.len(), want.len(), "QuestInfo.dbc row count");
for (id, name) in want {
assert_eq!(tags.resolve(id), Some(name), "QuestInfo id {id}");
}
// 1.12 says "Elite" where later expansions say "Group" — the string a group quest's row shows.
assert_eq!(tags.resolve(1), Some("Elite"));
// Type 0 is the untagged majority, and it must resolve to nothing rather than a row.
assert_eq!(tags.resolve(0), None);
// An id past the table (a later expansion's Escort/Heroic/Dungeon-tier ids) names nothing.
assert_eq!(tags.resolve(84), None);
}
+101 -7
View File
@@ -39,6 +39,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::number_arg;
use super::quest::QuestItemView;
use super::Model;
@@ -55,8 +56,15 @@ pub struct QuestLogEntryView {
pub title: String,
/// The quest's display level (`GetQuestLogTitle` return 2; the ref colors the row by it).
pub level: u32,
/// The quest tag suffix (elite/dungeon/…) — `None` for a plain quest. v1 pushes `None`
/// (the 1.12 wire's giver panels carry no tag; a template-derived tag is a later dressing).
/// The quest tag suffix — the bare word, no parentheses: `Elite`, `Dungeon`, `Raid`, `PvP`,
/// `Life`, `World Event`, `Legendary`. `None` for a plain quest, which is most of them, and
/// always `None` on a header row. The app resolves it from the cached
/// `SMSG_QUEST_QUERY_RESPONSE` template's `Type` through `QuestInfo.dbc`
/// ([`benilla_formats::QuestTagNames`]); `Type` 0 names no row and takes no tag.
///
/// `None`, never `Some("")` — the reference's row Lua branches on the tag's PRESENCE
/// (`if ( questTag )`, ref `QuestLogFrame.lua:194`) to decide whether to shrink the title and
/// reseat the watch check, so an empty string would take the wrong branch.
pub tag: Option<String>,
/// A zone header row (the app synthesizes these from each quest's ZoneOrSort).
pub is_header: bool,
@@ -286,19 +294,40 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetQuestLogTitle(i) → title, level, tag, isHeader, isCollapsed, isComplete
// (the load-bearing 6-tuple — ref QuestLogFrame.lua:144/:272/:321/:571). Out of range → nil.
// isCollapsed is always false (no headers in v1); isComplete is 1 / -1 / nil.
// (the load-bearing 6-tuple — ref QuestLogFrame.lua:144/:272/:321/:571).
// `tag` is the bare word (`Elite`, `Raid`, …) or nil — the ref's Lua wraps it in the
// parentheses itself (`"("..questTag..")"`, ref l.195). isComplete is 1 / -1 / nil.
//
// **The arity is always SIX, and the two failure shapes are different** (wow-re
// `ui/scratch/questlog-title-tag.md`, §5-verified at `0x4df930`): a MISSING or non-number
// argument raises `Usage:` (shape A — [`number_arg`], truncating toward zero like the
// reference's `_ftol`), while an out-of-range NUMBER is not an error at all — it returns
// `nil, 0, nil, nil, nil, nil` off `mov eax,6` at all three exits. Return 2 is the NUMBER
// `0` there, never nil, and it is `0` for a header row too. We used to answer a single
// `Nil` for both, which reads the same through the reference's own
// `local t, l, … = GetQuestLogTitle(i)` (Lua pads with nil) but hands an addon doing
// arithmetic on the level a nil where the client gives it a zero.
g.set(
"GetQuestLogTitle",
lua.create_function(|lua, i: usize| {
lua.create_function(|lua, i: Value| {
let n = number_arg(lua, i, "Usage: GetQuestLogTitle(index)")?;
let entry = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
i.checked_sub(1)
usize::try_from(n)
.ok()
.and_then(|n| n.checked_sub(1))
.and_then(|n| model.quest_log.entries.get(n))
.cloned()
};
let Some(e) = entry else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
return Ok(MultiValue::from_vec(vec![
Value::Nil,
Value::Integer(0),
Value::Nil,
Value::Nil,
Value::Nil,
Value::Nil,
]));
};
let tag = match &e.tag {
Some(t) => Value::String(lua.create_string(t)?),
@@ -874,6 +903,71 @@ mod tests {
.unwrap());
}
/// The §5-verified arity contract (wow-re `ui/scratch/questlog-title-tag.md`, `0x4df930`):
/// six values ALWAYS, an out-of-range number is not an error, and return 2 is the number `0`
/// — while a missing or non-number argument raises `Usage:`.
#[test]
fn out_of_range_is_six_values_with_a_zero_level_and_a_bad_arg_raises() {
let mut s = UiScript::new().unwrap();
// Empty log: the first return is still nil (so the ref's `if ( questLogTitleText )`
// reads the same), but the tuple is full and the level is a NUMBER.
assert_eq!(
s.eval::<i64>("return select('#', GetQuestLogTitle(1))")
.unwrap(),
6
);
assert!(s
.eval::<bool>("local t, l = GetQuestLogTitle(1) return t == nil and l == 0")
.unwrap());
s.set_quest_log(two_quests());
// Index 0 and a negative index are out of range, not errors — same six values.
for i in ["0", "-1", "99"] {
assert!(
s.eval::<bool>(&format!(
"local t, l = GetQuestLogTitle({i}) return t == nil and l == 0"
))
.unwrap(),
"index {i} is out of range, not an error"
);
}
// `_ftol` truncates toward zero, so 1.9 addresses entry 1.
assert!(s
.eval::<bool>("return GetQuestLogTitle(1.9) == 'A Threat Within'")
.unwrap());
// A missing / non-number argument is the other shape.
for bad in ["", "nil", "{}", "print"] {
let e = format!(
"{:?}",
s.eval::<mlua::Value>(&format!("return GetQuestLogTitle({bad})"))
.unwrap_err()
);
assert!(
e.contains("Usage: GetQuestLogTitle(index)"),
"arg `{bad}` must raise Usage:, got {e}"
);
}
}
#[test]
fn tag_is_the_bare_word_and_nil_when_absent() {
// The third return is the BARE word — the ref's own Lua adds the parentheses
// (`"("..questTag..")"`, ref QuestLogFrame.lua:195), so pushing "(Elite)" here would
// paint "((Elite))". An untagged quest pushes nil, not "", because the ref branches on
// presence (`if ( questTag )`, ref l.194).
let mut s = UiScript::new().unwrap();
let mut state = two_quests();
state.entries[1].tag = Some("Elite".into());
s.set_quest_log(state);
assert!(s
.eval::<bool>("return select(3, GetQuestLogTitle(1)) == nil")
.unwrap());
assert!(s
.eval::<bool>("return select(3, GetQuestLogTitle(2)) == 'Elite'")
.unwrap());
}
#[test]
fn selection_is_synchronous() {
let mut s = UiScript::new().unwrap();