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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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]))
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user