the spellbook grows its pet tab, and the pet bar gets its keys

- the spellbook's pet tab lists what the pet knows, with autocast toggled right on the page
- pet ability cooldowns sweep on the pet bar, and the bar binds to 1.12's Secondary Action Button keys
- the options window scrolls its pages in a real trough with seated arrows, nothing overflows the frame anymore
- Esc un-queues a queued strike, and a hostile unit's frame stops showing buffs the real client hides

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-07 16:01:27 +07:00
co-authored by Claude Fable 5
parent b02f93e121
commit 1f06d032dd
61 changed files with 5299 additions and 1332 deletions
File diff suppressed because it is too large Load Diff
@@ -44,6 +44,15 @@
them off the same cursor transition that drives ACTIONBAR_SHOWGRID/HIDEGRID but on a
disjoint predicate, so a held spell lights the action bar's empty slots and a held pet
action lights this bar's — never both, because neither payload can land on the other bar.
- NO HotKey corner, and none is missing (1052). The ref's button inherits
ActionButtonTemplate's $parentHotKey and repaints it on UPDATE_BINDINGS — but
PetActionButton_SetHotkeys (l.311-319) writes the label only when
`bindingSuffix == this:GetID()`, a STRING against a NUMBER, which Lua never equates. So
the real 1.12 pet bar renders an empty hotkey on every slot, always, and the reporter's
own reference shot for B218 shows exactly that: CTRL-1..CTRL-5 bound in the window, no
glyph on any pet button. An empty corner is the faithful render; a filled one would be
the divergence. (Our own action bar's corners are real — ActionBar.xml — because the
ref's ActionButton_UpdateHotkeys has no such bug.)
- The autocast sparkle is TEXTURES, not the ref's <Model>. benilla has no Model widget
(the Cooldown and the bag ItemAnim took the same posture): model the mechanism, let our
own renderer draw it. The numbers below are the shipped model's own — see BENILLA_PET_
@@ -228,6 +237,31 @@
end
end
-- The KEYBIND pair (decision 1052) — ref PetActionButtonDown/Up l.218-231, which is where
-- the BONUSACTIONBUTTONn bindings land: their bodies call BonusActionButtonDown/Up, and
-- those are one-liners onto these (BonusActionBarFrame.lua:106-112). The ref's own
-- runOnUp shape: DOWN only shows the pushed art, UP fires.
--
-- Note what the key path does NOT do — it is a bare CastPetAction, with none of the click
-- path's forks: no shift pick-up, no right-button autocast, and no IsPetAttackActive
-- call-off. That is the reference's split, not a simplification of ours: the fork lives in
-- PetActionButton_OnClick, which a key press never reaches. So CTRL-1 on a pet already
-- attacking re-issues Attack where a left click would call it off.
function BenillaPetActionButtonDown(id)
local button = getglobal("BenillaPetActionButton" .. id)
if ( button:GetButtonState() == "NORMAL" ) then
button:SetButtonState("PUSHED")
end
end
function BenillaPetActionButtonUp(id)
local button = getglobal("BenillaPetActionButton" .. id)
if ( button:GetButtonState() == "PUSHED" ) then
button:SetButtonState("NORMAL")
CastPetAction(id)
end
end
-- Both drag ends are the SAME verb (ref l.269-283, byte-verified at `0x4be180`): with an
-- empty cursor it picks the slot up, with a pet payload already held it drops onto this
-- slot. There is no separate "place" binding on this bar, which is also why a spell or an
@@ -1,7 +1,10 @@
<Ui>
<!--
benilla's reusable scroll kits — TWO of them, sharing one scrollbar and one pair of arrow
helpers. The **faux-scroll** kit (this header) re-binds a fixed set of rows to a moving data
benilla's reusable scroll kits — TWO of them, sharing one scrollbar, one pair of arrow
helpers and one optional TROUGH (`BenillaScrollTroughTemplate`, at the foot: the recessed
channel a bar rides in — 1.12 authors that art per window, so an owner declares one beside
its bar and the kit shows and hides the two together).
The **faux-scroll** kit (this header) re-binds a fixed set of rows to a moving data
offset; the **real-scroll** kit (second half of the script block) pans and CLIPS a
variable-height child through the engine's `<ScrollFrame>` (decision 0112) — the one a gossip
menu of wrapped options needs, since its content height isn't known until it is laid out.
@@ -95,6 +98,60 @@
scrollBar:SetValue(offset * scrollBar:GetValueStep())
end
-- Seat a trough ON ITS BAR. THE SOCKETS, and why this is not a free choice: the channel art
-- is not a plain tube — each end carries a 16-tall SOCKET that the arrow button drops into,
-- bounded by the frame's outer cap and an inner crossbar. Measured texel-exact off
-- UI-Character-ScrollBar (64x256):
-- top piece (left column) : rows 0-3 CAP · rows 4-19 SOCKET · rows 20-21 crossbar
-- bottom piece (right column): rows 85-86 crossbar · rows 87-102 SOCKET · rows 104-106 CAP
-- Both pieces draw 1:1 anchored to their own end of the trough, and the arrow buttons sit 16
-- outside the slider's ends — so a socket lands on its arrow only when the trough OVERHANGS
-- the bar by 21 above and 20 below (4 rows of cap at the top against 3+1 at the bottom; that
-- 1 is the whole asymmetry). Those are the ref's own numbers, reached from the other side:
-- ReputationFrame hangs its trough at the scroll frame's TOPRIGHT (-2,+5) / BOTTOMRIGHT
-- (-2,-4) against a bar seated (6,-16)/(6,+16) — 21, 20, and the 8-left hang
-- (ref-ReputationFrame.xml l.573-599 against ref-UIPanelTemplates.xml l.166-181). Miss it by
-- even 4 and the arrows ride OUT of their sockets onto the caps with a sliver of bare socket
-- showing beside them, which is exactly what B224 reported.
--
-- Seated HERE, off the bar, rather than restated in each owner's XML: it is one law, and an
-- owner made to re-derive it in offsets is an owner that can get it wrong — which is how
-- B224 happened. So a trough carries NO anchors of its own; declare it before the bar (so it
-- draws beneath) and name it `<barname>Trough`, and that is the whole contract. Lazy,
-- because the bar is declared after the trough and does not exist at the trough's OnLoad.
function BenillaScrollTrough_Seat(trough, scrollBar)
if trough.seated then return end
trough.seated = true
trough:ClearAllPoints()
trough:SetPoint("TOPLEFT", scrollBar, "TOPLEFT", -8, 21)
trough:SetPoint("BOTTOMRIGHT", scrollBar, "BOTTOMRIGHT", 7, -20)
end
-- Show/hide a bar TOGETHER WITH ITS TROUGH — the recessed channel it rides in, if the
-- owner authored one beside it (a `<barname>Trough` frame on BenillaScrollTroughTemplate
-- below). Every path that shows or hides a bar goes through here, so a window that fits
-- shows neither and one that overflows shows both — and so a trough is seated on its bar
-- before it can ever be visible, whichever way the first call goes.
--
-- Why the trough is OPT-IN rather than part of the bar template: 1.12 authors trough art
-- PER WINDOW, never in the shared scroll kit — `UIPanelScrollBarTemplate` carries only the
-- two arrow buttons and the knob, and the ref's FrameXML then hangs loose trough textures
-- beside 29 individual bars, in two different skins (UI-Character-ScrollBar for the
-- dialog/paperdoll family, UI-ClassTrainer-ScrollBar for the trainer family). The era's own
-- MinimalScrollBar went the other way and made its Track part of the bar; this pair keeps
-- the era's COUPLING (track lives and dies with the bar) on 1.12's per-window SKIN.
function BenillaScrollBar_SetShown(scrollBar, shown)
local trough = getglobal(scrollBar:GetName() .. "Trough")
if trough then BenillaScrollTrough_Seat(trough, scrollBar) end
if shown then
scrollBar:Show()
if trough then trough:Show() end
else
scrollBar:Hide()
if trough then trough:Hide() end
end
end
-- Reconcile the bar to a freshly-sized list (ref FauxScrollFrame_Update): clamp the offset into
-- range, set the pixel range + step, seat the thumb at the current offset, and show the bar only
-- when the list overflows the visible rows. Called by the owner's list-repaint each pass.
@@ -110,11 +167,7 @@
scrollBar:SetValue(frame.offset * itemHeight)
BenillaScrollBar_UpdateArrows(scrollBar)
if numItems > numToDisplay then
scrollBar:Show()
else
scrollBar:Hide()
end
BenillaScrollBar_SetShown(scrollBar, numItems > numToDisplay)
end
-- A wheel spin over the list → one row (WoW convention: +1 up, −1 down). Called from the
@@ -160,7 +213,7 @@
bar:SetMinMaxValues(0, range)
bar:SetValue(sf:GetVerticalScroll())
BenillaScrollBar_UpdateArrows(bar)
if range > 0 then bar:Show() else bar:Hide() end
BenillaScrollBar_SetShown(bar, range > 0)
end
-- Pan by `delta` px; the engine clamps to [0, range].
@@ -251,6 +304,84 @@
</ThumbTexture>
</Slider>
<!--
The scroll bar's TROUGH — the recessed channel the bar rides in: the "background and
border" a bare arrows+knob bar is missing. An owner declares one of these BESIDE its bar,
named `<barname>Trough` and WITH NO ANCHORS — it seats itself on the bar the first time
BenillaScrollBar_SetShown touches it (BenillaScrollTrough_Seat above, where the socket law
and the ref citations live). Declare it BEFORE the bar so it draws beneath.
THE ART, and why it is sliced this way. `Interface\PaperDollInfoFrame\UI-Character-ScrollBar`
is a 64x256 sheet holding TWO 31-wide channel pieces, measured texel-exact from the file —
rails at x 2-5 and 25-30 with a 19-wide interior between them, and each piece bearing a
16-tall SOCKET for its arrow button:
· LEFT column (u 0 .. 31/64): rows 0-3 the solid TOP cap, 4-19 the up-arrow socket,
20-21 the crossbar that closes it, then plain channel from row 22 to the bottom edge.
· RIGHT column (u 33/64 .. 1): plain channel, then rows 85-86 the crossbar, 87-102 the
down-arrow socket, 104-106 the solid BOTTOM cap (nothing below 107).
The ref composes a trough from exactly two quads — the whole left column plus the right
column's bottom 108 rows — which reaches 362 units and no further, and only works because
its scroll frames are short enough for the two to OVERLAP. Ours must span a 563-tall page,
so the same art is cut as a THREE-SLICE instead: the two socket ends at their natural
heights and a plain-channel slice stretched between them. The middle is constant down its
whole length, so stretching it is exact — there is no seam to find.
The black `Fill` is the ref's own trough backing, not an invention: ItemTextFrame draws a
0.8-alpha black quad behind its channel art for exactly this (ref-ItemTextFrame.xml's
`Middle`). It sits in BACKGROUND and the channel art in BORDER — two layers, because within
ONE layer the client re-sorts overlapping quads by texture handle and their order is not
ours to depend on (order.rs). The bar's own furniture stays clear above both: button state
textures are ARTWORK, the knob is OVERLAY.
-->
<Frame name="BenillaScrollTroughTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="31"/></Size>
<Layers>
<Layer level="BACKGROUND">
<!-- The channel interior: x 6..24 of the 31-wide piece, i.e. inset 6 from each edge. -->
<Texture name="$parentFill">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Color r="0" g="0" b="0" a="0.8"/>
</Texture>
</Layer>
<Layer level="BORDER">
<!-- Cap + up-arrow socket + crossbar: left column, rows 0..22 (v 0 .. 23/256). -->
<Texture name="$parentTop" file="Interface\PaperDollInfoFrame\UI-Character-ScrollBar">
<Size><AbsDimension y="23"/></Size>
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="TOPRIGHT"/>
</Anchors>
<TexCoords left="0" right="0.484375" top="0" bottom="0.08984375"/>
</Texture>
<!-- Crossbar + down-arrow socket + cap: right column, rows 82..107
(v 82/256 .. 108/256) — the mirror of the top's 23, cut to put row 107 on the
trough's bottom edge exactly as ReputationFrame's own 108/0.421875 does, which
is what lands the socket on the arrow (BenillaScrollTrough_Seat's law). -->
<Texture name="$parentBottom" file="Interface\PaperDollInfoFrame\UI-Character-ScrollBar">
<Size><AbsDimension y="26"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<TexCoords left="0.515625" right="1.0" top="0.3203125" bottom="0.421875"/>
</Texture>
<!-- The stretched run between them: left column rows 30..70, plain rails only.
Declared LAST because it anchors to the two above by name, and an anchor whose
target is declared later falls back to the parent (ItemTextFrame's own note). -->
<Texture name="$parentMiddle" file="Interface\PaperDollInfoFrame\UI-Character-ScrollBar">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentTop" relativePoint="BOTTOMLEFT"/>
<Anchor point="BOTTOMRIGHT" relativeTo="$parentBottom" relativePoint="TOPRIGHT"/>
</Anchors>
<TexCoords left="0" right="0.484375" top="0.1171875" bottom="0.2734375"/>
</Texture>
</Layer>
</Layers>
</Frame>
<!--
The faux-scroll container: a plain `<Frame>` carrying the offset state + a right-hand scroll bar
(inset 16px top/bottom so the arrow buttons sit inside the track span — the ref
+264 -54
View File
@@ -31,23 +31,18 @@
`MAX_SKILLLINE_TABS`/`SPELLBOOK_PAGENUMBERS` stay here — genuine per-window UI state, the
ref's own home for them.
THE PET BOOK IS LIVE (decision 1032) — the second tab, its 12-slot page, the autocast
overlay + sparkle, the right-click autocast toggle, and the cast/pickup pair, all over
`crate::ui_pet_book`'s feed of `SMSG_PET_SPELLS`' own spell list. Two reference QUIRKS are
reproduced on purpose here and each carries a do-not-fix note at its site (the standing
rule from decision 1030): the pet page **ignores its page number**, and its page arrows
write the *spell* book's page counter. See `BenillaSpellBook_GetSpellID` and the two
page-turn handlers.
Named deferrals/OMITs (each re-cited at its own site below):
- The PET spellbook: no pets stream yet (decision 0216's own scope note). The ref's
Spell/Pet toggle tab row (`SpellBookFrameTabButton1/2/3`, `HasPetSpells()`-gated) is
dropped OUTRIGHT — this window only ever shows the one book that exists, the 0138
merchant-tab precedent ("tabs arrive with their pages"). `LEARNED_SPELL_IN_TAB`'s tab
flash (`SpellBookTabFlashFrame`, `CheckButtonGlow`) is dropped with it — no such event
exists yet (no "just learned a spell" signal split out of the general `SPELLS_CHANGED`
diff).
- Pet autocast (`GetSpellAutocast`/`ToggleSpellAutocast`/`UI-AutoCastableOverlay`/the
`AutoCast` model): pet-only, no pet book. Right-click pet-autocast toggle
(`SpellButton_OnClick`'s `arg1 ~= "LeftButton"` branch) is out of scope this slice
per the same brief — every click routes straight to cast/pickup. (Cooldowns —
originally deferred here alongside autocast for want of a feed — are LIVE:
`GetSpellCooldown` + the native Cooldown child + `SPELL_UPDATE_COOLDOWN`, ref
`SpellButton_UpdateButton` l.359-365; the app feed stamps each slot's triple from
the one cooldown store, `benilla::ui_spellbook`. CharacterFrame's item cooldowns
remain that file's own named gap.)
- `LEARNED_SPELL_IN_TAB`'s tab flash (`SpellBookTabFlashFrame`, `CheckButtonGlow`): no
such event exists yet (no "just learned a spell" signal split out of the general
`SPELLS_CHANGED` diff). The ref returns early on it for the pet book anyway.
- The CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE legs of the checked ring: the ring
itself is LIVE (`BenillaSpellButton_UpdateSelection`/`IsCurrentCast` over the
app-resolved slot verdict — the shapeshift arm makes Ghost Wolf's/a druid form's
@@ -57,12 +52,11 @@
- The MacroFrame branch (`SpellButton_OnClick`'s shift-click "already in a macro editor"
fork): no macro window exists — shift-click always falls to the ref's own "else"
(`PickupSpell`), exactly what the ref itself does whenever no MacroFrame is visible.
- `GameTooltip:SetSpell(id, bookType)` (the full spell-effect/cost/range tooltip): no
such content channel exists (the item-stats precedent's own gap — no spell-stat feed).
`BenillaSpellButton_OnEnter` shows a plain name + rank two-liner instead
(`GetSpellName`'s own two return values) — the `this.updateTooltip`/`OnUpdate`
re-poll loop is dropped with it (nothing about a static name/rank line changes
per-frame the way a live cooldown would).
- The `this.updateTooltip`/`OnUpdate` re-poll loop around the hover: `GameTooltip:SetSpell`
itself is LIVE (the engine channel, decision 0276's line law; it reads whichever book
`bookType` names — 1050), but its per-frame re-poll is dropped, since nothing in the
rendered lines changes per-frame the way a live cooldown would. The ref's own
`if ( GameTooltip:SetSpell(…) )` return value exists only to arm that loop.
- `maxLines="3"` on `$parentSpellName` (word-wrap line cap): no such FontString
attribute is modeled (grepped `benilla-ui/src/loader/widgets.rs`) — the name just
wraps unbounded inside its box width, a harmless cosmetic gap on the rare long name.
@@ -84,6 +78,11 @@
-- `X = X or "..."` idiom matches CastingBar.xml/CharacterFrame.xml's own convention) ────
SPELLBOOK = SPELLBOOK or "Spellbook" -- :3820
PAGE_NUMBER = PAGE_NUMBER or "Page %d" -- :2981
-- The pet tab's label AND the window title while the pet book is up. Keyed, not chosen:
-- HasPetSpells()'s second return is ChrClasses.dbc field 4 ("PET"/"DEMON") and the ref
-- concatenates it (`getglobal("PET_TYPE_"..token)`, ref SpellBookFrame.lua:173).
PET_TYPE_PET = PET_TYPE_PET or "Pet" -- :3058
PET_TYPE_DEMON = PET_TYPE_DEMON or "Demon" -- :3057
-- ── This window's own constants (ref SpellBookFrame.lua:1-7, Benilla-prefixed where the
-- ref's own name would collide with the engine's BOOKTYPE_SPELL/BOOKTYPE_PET globals —
@@ -94,15 +93,17 @@
-- ── The window: toggle (ref ToggleSpellBook, l.9-50) ─────────────────────────────────────
-- Kept BARE (not Benilla-prefixed) — the addon-facing entry point, matching this
-- codebase's own ToggleCharacter/ToggleQuestLog precedent. The ref's `HasPetSpells()`
-- short-circuit (blocking a toggle INTO an empty pet book) is dropped outright — this
-- file's header comment, the pet book is never fed so `bookType` is always
-- BOOKTYPE_SPELL in practice. The ref's own DUPLICATED prev/next enable/disable (once
-- inside the `currentPage > maxPages` clamp branch, once again unconditionally right
-- after — both produce the identical end state) collapses to the ONE
-- BenillaSpellBook_UpdatePageArrows() call already shared by every other page-turn site
-- below — a redundant-code cleanup, not a behavior change.
-- codebase's own ToggleCharacter/ToggleQuestLog precedent. The ref's own DUPLICATED
-- prev/next enable/disable (once inside the `currentPage > maxPages` clamp branch, once
-- again unconditionally right after — both produce the identical end state) collapses to
-- the ONE BenillaSpellBook_UpdatePageArrows() call already shared by every other
-- page-turn site below — a redundant-code cleanup, not a behavior change.
function ToggleSpellBook(bookType)
-- ref l.11-14: asking for a pet book you haven't got does NOTHING — not even close a
-- spellbook that is already open. The TOGGLEPETBOOK key binding is the live caller.
if bookType == BOOKTYPE_PET and not HasPetSpells() then
return
end
local isVisible = BenillaSpellBookFrame:IsVisible()
HideUIPanel(BenillaSpellBookFrame)
if (not isVisible or (BenillaSpellBookFrame.bookType ~= bookType)) then
@@ -132,6 +133,7 @@
for i = 1, MAX_SKILLLINE_TABS do
SPELLBOOK_PAGENUMBERS[i] = 1
end
SPELLBOOK_PAGENUMBERS["pet"] = 1 -- ref l.66; read by GetCurrentPage, written by nothing
BenillaSpellBookSkillLineTab_OnClick(1)
end
@@ -152,18 +154,20 @@
BenillaSpellBookFrame_Update(1)
end
-- ref SpellBookFrame_Update (l.107-164): the Spell/Pet toggle-tab hide loop and the
-- HasPetSpells()/petTitle branch are DROPPED outright (header comment — no pet book, no
-- toggle row exists at all this slice).
-- ref SpellBookFrame_Update (l.107-164), whole. Three things happen here and they are
-- easy to conflate: the skill-line strip is gated on the book TYPE (the pet book has no
-- tabs at all — GetSpellTabInfo never sees it), the Spell/Pet toggle row is gated on
-- HasPetSpells(), and the title + open sound fork on which book is up.
function BenillaSpellBookFrame_Update(showing)
if showing then
BenillaSpellBookSkillLineTab_OnClick(BenillaSpellBookFrame.selectedSkillLine)
BenillaUpdateSpells()
end
local isPet = BenillaSpellBookFrame.bookType == BOOKTYPE_PET
local numSkillLineTabs = GetNumSpellTabs()
for i = 1, MAX_SKILLLINE_TABS do
local skillLineTab = getglobal("BenillaSpellBookSkillLineTab" .. i)
if i <= numSkillLineTabs then
if i <= numSkillLineTabs and not isPet then
local name, texture = GetSpellTabInfo(i)
skillLineTab:SetNormalTexture(texture)
skillLineTab.tooltip = name
@@ -173,9 +177,74 @@
skillLineTab:Hide()
end
end
BenillaSpellBookTitleText:SetText(SPELLBOOK)
if showing then
PlaySound("igSpellBookOpen")
-- ref l.108-111 + l.141-151: hide the row first, then raise exactly the tabs that
-- exist. Tab 3 never appears in 1.12 (no third book) and is hidden by the template's
-- own `hidden="true"`; it is kept so the anchor chain reads like the ref's.
BenillaSpellBookFrameTabButton1:Hide()
BenillaSpellBookFrameTabButton2:Hide()
local hasPetSpells, petToken = HasPetSpells()
BenillaSpellBookFrame.petTitle = nil
if hasPetSpells then
BenillaSpellBookFrame_SetTabType(BenillaSpellBookFrameTabButton1, BOOKTYPE_SPELL)
BenillaSpellBookFrame_SetTabType(BenillaSpellBookFrameTabButton2, BOOKTYPE_PET, petToken)
elseif isPet then
-- The pet died/was dismissed while its book was open: the ref closes the window
-- and reverts, rather than showing an empty pet page (l.147-151).
HideUIPanel(BenillaSpellBookFrame)
BenillaSpellBookFrame.bookType = BOOKTYPE_SPELL
isPet = false
end
if not isPet then
BenillaSpellBookTitleText:SetText(SPELLBOOK)
if showing then PlaySound("igSpellBookOpen") end
else
BenillaSpellBookTitleText:SetText(BenillaSpellBookFrame.petTitle)
-- ref l.161: the pet book opens on the ABILITY kit, not the spellbook one. Both
-- ship in the real SoundEntries.dbc.
if showing then PlaySound("igAbilityOpen") end
end
end
-- ref SpellBookFrame_SetTabType (l.166-183). `binding` is what the tab's tooltip appends
-- the bound key to (MicroButtonTooltipText); `petTitle` is set here as a side effect,
-- exactly as the ref does, because this is the only place the token is in hand.
function BenillaSpellBookFrame_SetTabType(tabButton, bookType, token)
if bookType == BOOKTYPE_SPELL then
tabButton.bookType = BOOKTYPE_SPELL
tabButton:SetText(SPELLBOOK)
tabButton.binding = "TOGGLESPELLBOOK"
else
tabButton.bookType = BOOKTYPE_PET
local label = getglobal("PET_TYPE_" .. (token or "PET")) or PET_TYPE_PET
tabButton:SetText(label)
tabButton.binding = "TOGGLEPETBOOK"
BenillaSpellBookFrame.petTitle = label
end
-- The tab for the book you are ON is disabled, which is also what draws it as the
-- selected (Tab1-Selected) plate — the ref uses <DisabledTexture> for the "current"
-- look rather than a checked state.
if BenillaSpellBookFrame.bookType == bookType then
tabButton:Disable()
else
tabButton:Enable()
end
tabButton:Show()
end
-- The shared sparkle clock — PetActionBar.xml's own pattern, one OnUpdate for the twelve
-- buttons rather than twelve: the emitters are one model and every autocasting button
-- plays the same loop in step, which is what the ref's per-button Models do too. Only runs
-- while the window is shown, so it costs nothing the rest of the time.
function BenillaSpellBookFrame_OnUpdate(frame, elapsed)
if not BenillaPetAutocast_Update then return end
frame.clock = (frame.clock or 0) + elapsed
if frame.clock > BENILLA_PET_AUTOCAST_PERIOD then
frame.clock = frame.clock - BENILLA_PET_AUTOCAST_PERIOD
end
for i = 1, SPELLS_PER_PAGE do
BenillaPetAutocast_Update(getglobal("BenillaSpellButton" .. i), frame.clock)
end
end
@@ -184,7 +253,11 @@
-- on). UpdateMicroButtons is LIVE as of the micro-menu slice (MicroMenu.xml).
function BenillaSpellBookFrame_OnHide()
if UpdateMicroButtons then UpdateMicroButtons() end
PlaySound("igSpellBookClose")
if BenillaSpellBookFrame.bookType == BOOKTYPE_PET then
PlaySound("igAbilityClose") -- ref l.191
else
PlaySound("igSpellBookClose")
end
end
-- UpdateSpells(): the ref's OWN global (`SpellBookFrame.lua:26/116/411/426/450` — never
@@ -198,16 +271,25 @@
end
-- ── The spell button (ref SpellButton_*, l.206-404) ──────────────────────────────────────
-- CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE/PET_BAR_UPDATE are OMITTED (header comment:
-- no craft/trade-skill window arm, no pet bar); SPELLS_CHANGED and SPELL_UPDATE_COOLDOWN
-- drive the full refresh (the ref's own shared branch, l.221) and
-- CURRENT_SPELL_CAST_CHANGED the checked ring (ref SpellButton_OnEvent's own split).
-- CRAFT_SHOW/_CLOSE/TRADE_SKILL_SHOW/_CLOSE are OMITTED (header comment: no craft or
-- trade-skill window arm); SPELLS_CHANGED and SPELL_UPDATE_COOLDOWN drive the full refresh
-- (the ref's own shared branch, l.221) and CURRENT_SPELL_CAST_CHANGED the checked ring.
-- PET_BAR_UPDATE is LIVE (ref l.214 + l.227-231) and it is the pet page's whole repaint
-- wire: the pet book's cooldowns, autocast bits and active ring all move without the BOOK
-- changing, so SPELLS_CHANGED alone would leave a stale page.
function BenillaSpellButton_OnLoad(button)
button:RegisterEvent("SPELLS_CHANGED")
button:RegisterEvent("CURRENT_SPELL_CAST_CHANGED")
button:RegisterEvent("SPELL_UPDATE_COOLDOWN")
button:RegisterEvent("PET_BAR_UPDATE")
button:RegisterForDrag("LeftButton")
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
button.autocastable = getglobal(button:GetName() .. "AutoCastable")
-- The ref's `$parentAutoCast` is a <Model> playing UI-AutoCastButton.mdx; we have no
-- Model widget, so the pet bar's byte-derived sparkle emitter stands in for it
-- (decision 0982 — PetActionBar.xml owns the numbers and both surfaces share them).
-- Guarded because several engine test harnesses load this file without the pet bar.
if BenillaPetAutocast_Create then BenillaPetAutocast_Create(button) end
BenillaSpellButton_UpdateButton(button)
end
@@ -216,6 +298,10 @@
BenillaSpellButton_UpdateButton(button)
elseif event == "CURRENT_SPELL_CAST_CHANGED" then
BenillaSpellButton_UpdateSelection(button)
elseif event == "PET_BAR_UPDATE" then
if BenillaSpellBookFrame.bookType == BOOKTYPE_PET then
BenillaSpellButton_UpdateButton(button)
end
end
end
@@ -250,11 +336,17 @@
-- `id > MAX_SPELLS` guard is dropped too: GetSpellName/CastSpell/PickupSpell already
-- answer nil/no-op for any id past the book's real length, so the extra guard checks
-- nothing a MAX_SPELLS constant would add here.
function BenillaSpellButton_OnClick(button, drag)
function BenillaSpellButton_OnClick(button, drag, mouseButton)
local id = BenillaSpellBook_GetSpellID(button:GetID())
button:SetChecked("false")
if drag then
PickupSpell(id, BenillaSpellBookFrame.bookType)
elseif not IsShiftKeyDown() and mouseButton and mouseButton ~= "LeftButton"
and BenillaSpellBookFrame.bookType == BOOKTYPE_PET then
-- ref l.284-285: on the PET page a non-left click flips autocast instead of
-- casting. Shift still outranks it — the ref tests shift first, so a shift
-- right-click on a pet spell picks it up.
ToggleSpellAutocast(id, BenillaSpellBookFrame.bookType)
elseif IsShiftKeyDown() then
-- ref SpellButton_OnClick l.271-283 (LIVE since decision 0983): with the macro
-- editor open a shift-click APPENDS `/cast <name>[(<rank>)]` to the body instead of
@@ -294,6 +386,7 @@
if not BenillaSpellBookFrame.selectedSkillLine then
BenillaSpellBookFrame.selectedSkillLine = 1
end
local isPet = BenillaSpellBookFrame.bookType == BOOKTYPE_PET
local temp, tabTexture, offset, numSpells = GetSpellTabInfo(BenillaSpellBookFrame.selectedSkillLine)
offset = offset or 0
numSpells = numSpells or 0
@@ -304,12 +397,19 @@
local spellString = getglobal(name .. "SpellName")
local subSpellString = getglobal(name .. "SubSpellName")
local cooldown = getglobal(name .. "Cooldown")
if id > (offset + numSpells) then
local autocastable = getglobal(name .. "AutoCastable")
-- ref l.328: the out-of-tab DISABLE is `(bookType ~= BOOKTYPE_PET) and ...` — the pet
-- page has no tab to be out of, so a pet button past the end of the book falls to the
-- no-texture branch below and stays ENABLED. Faithful, and harmless: an enabled button
-- with no spell behind it clicks into GetSpellName's nil and does nothing.
if (not isPet) and id > (offset + numSpells) then
button:Disable()
iconTexture:Hide()
spellString:Hide()
subSpellString:Hide()
cooldown:Hide()
autocastable:Hide()
button.autocasting = nil
button:SetChecked(0)
getglobal(name .. "NormalTexture"):SetVertexColor(1.0, 1.0, 1.0)
return
@@ -324,6 +424,8 @@
spellString:Hide()
subSpellString:Hide()
cooldown:Hide()
autocastable:Hide()
button.autocasting = nil
highlightTexture:SetTexture("Interface\\Buttons\\ButtonHilight-Square")
button:SetChecked(0)
normalTexture:SetVertexColor(1.0, 1.0, 1.0)
@@ -339,6 +441,12 @@
else
iconTexture:SetVertexColor(0.4, 0.4, 0.4)
end
-- ref l.367-377: the autocast pair. `GetSpellAutocast` answers (nil, nil) for the
-- player book by the engine's own short-circuit, so this needs no isPet guard — the
-- overlay and the sparkle simply never come on outside the pet page.
local autoCastAllowed, autoCastEnabled = GetSpellAutocast(id, BenillaSpellBookFrame.bookType)
if autoCastAllowed then autocastable:Show() else autocastable:Hide() end
button.autocasting = autoCastEnabled
local spellName, subSpellName = GetSpellName(id, BenillaSpellBookFrame.bookType)
local isPassive = IsSpellPassive(id, BenillaSpellBookFrame.bookType)
if isPassive then
@@ -365,13 +473,24 @@
end
-- ── Page turn (ref PrevPageButton_OnClick/NextPageButton_OnClick, l.406-434) ─────────────
-- Always the spell-book page-turn kit (no pet book to branch on, header comment).
--
-- DO NOT "FIX" THE KEY (decision 1032, the standing rule from 1030). The reference writes
-- `SPELLBOOK_PAGENUMBERS[selectedSkillLine]` on BOTH books, while `SpellBook_GetCurrentPage`
-- reads `SPELLBOOK_PAGENUMBERS["pet"]` for the pet one — a key nothing writes. So on the
-- pet page the arrows turn nothing, the page text says a number the page does not honour,
-- and the *spell* book's page counter moves behind your back. All three are the reference's
-- and all three are reproduced; `BenillaSpellBook_GetSpellID`'s note is the other half.
function BenillaSpellBookPrevPageButton_OnClick()
local pageNum = BenillaSpellBook_GetCurrentPage() - 1
SPELLBOOK_PAGENUMBERS[BenillaSpellBookFrame.selectedSkillLine] = pageNum
BenillaSpellBook_UpdatePageArrows()
BenillaSpellBookPageText:SetText(format(PAGE_NUMBER, pageNum))
BenillaUpdateSpells()
-- ref l.414-417: the pet page re-asserts its own title, because the spell book's page
-- text handler is shared and the title is not.
if BenillaSpellBookFrame.bookType == BOOKTYPE_PET then
BenillaSpellBookTitleText:SetText(BenillaSpellBookFrame.petTitle)
end
PlaySound("igAbiliityPageTurn")
end
@@ -381,6 +500,9 @@
BenillaSpellBook_UpdatePageArrows()
BenillaSpellBookPageText:SetText(format(PAGE_NUMBER, pageNum))
BenillaUpdateSpells()
if BenillaSpellBookFrame.bookType == BOOKTYPE_PET then
BenillaSpellBookTitleText:SetText(BenillaSpellBookFrame.petTitle)
end
PlaySound("igAbiliityPageTurn")
end
@@ -403,11 +525,18 @@
end
end
-- ref SpellBook_GetSpellID (l.459-465): the pet-book `return id` branch is DROPPED
-- outright (header comment — bookType is always BOOKTYPE_SPELL, dead code for this
-- window). This is the book-id the engine's own byte-verified seam expects verbatim
-- (`benilla-ui/src/script/spellbook.rs`'s own module doc).
-- ref SpellBook_GetSpellID (l.459-465). This is the book id the engine's own byte-verified
-- seam expects verbatim (`benilla-ui/src/script/spellbook.rs`'s module doc).
--
-- DO NOT "FIX" THE PET ARM (decision 1032, the standing rule from 1030). The reference's
-- pet branch is a bare `return id` — no tab offset, and **no page term** — so the pet page
-- always shows book entries 1..12 no matter what page number the window is displaying. A
-- well-trained hunter pet can know more than twelve spells, so this is visible, and it is
-- still the reference's behaviour. Its sibling quirk is in the two page-turn handlers.
function BenillaSpellBook_GetSpellID(id)
if BenillaSpellBookFrame.bookType == BOOKTYPE_PET then
return id
end
return id + (BenillaSpellBookFrame.selectedSkillLineOffset or 0)
+ (SPELLS_PER_PAGE * ((SPELLBOOK_PAGENUMBERS[BenillaSpellBookFrame.selectedSkillLine] or 1) - 1))
end
@@ -427,11 +556,21 @@
end
end
-- ref SpellBook_GetCurrentPage (l.481-493): the pet-book branch is DROPPED outright
-- (header comment). `maxPages` is clamped to at least 1 (a defensive addition this file's
-- own — an empty/not-yet-fed book must still show ONE (empty) page rather than disabling
-- both page buttons into a currentPage=1 > maxPages=0 dead state).
-- ref SpellBook_GetCurrentPage (l.481-493), both branches. `maxPages` is clamped to at
-- least 1 (a defensive addition this file's own — an empty/not-yet-fed book must still
-- show ONE (empty) page rather than disabling both page buttons into a currentPage=1 >
-- maxPages=0 dead state).
--
-- The pet branch reads `SPELLBOOK_PAGENUMBERS["pet"]`, which is the counter NOTHING ever
-- writes — see the page-turn handlers. So it is always 1, and with it `maxPages > 1` the
-- Next arrow stays enabled forever on a big pet book. Reference behaviour; see the
-- do-not-fix note on `BenillaSpellBook_GetSpellID`.
function BenillaSpellBook_GetCurrentPage()
local numPetSpells = HasPetSpells()
if numPetSpells and BenillaSpellBookFrame.bookType == BOOKTYPE_PET then
return SPELLBOOK_PAGENUMBERS["pet"] or 1,
max(ceil(numPetSpells / SPELLS_PER_PAGE), 1)
end
local currentPage = SPELLBOOK_PAGENUMBERS[BenillaSpellBookFrame.selectedSkillLine] or 1
local temp, texture, offset, numSpells = GetSpellTabInfo(BenillaSpellBookFrame.selectedSkillLine)
local maxPages = max(ceil((numSpells or 0) / SPELLS_PER_PAGE), 1)
@@ -494,6 +633,16 @@
</Anchors>
</Texture>
</Layer>
<Layer level="OVERLAY">
<!-- ref SpellButtonTemplate's $parentAutoCastable: the static ring that says a pet
spell CAN autocast, 58x58 over a 37x37 button. Its animated sibling (the
$parentAutoCast <Model>) has no widget here — PetActionBar.xml's byte-derived
sparkle emitter stands in, created in OnLoad. -->
<Texture name="$parentAutoCastable" file="Interface\Buttons\UI-AutoCastableOverlay" hidden="true">
<Size><AbsDimension x="58" y="58"/></Size>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="BORDER">
<Texture name="$parentIconTexture" setAllPoints="true" hidden="true"/>
<!-- maxLines="3" unmodeled (this file's header comment) — wraps unbounded instead. -->
@@ -524,7 +673,7 @@
<Scripts>
<OnLoad>BenillaSpellButton_OnLoad(self)</OnLoad>
<OnEvent>BenillaSpellButton_OnEvent(self)</OnEvent>
<OnClick>BenillaSpellButton_OnClick(self)</OnClick>
<OnClick>BenillaSpellButton_OnClick(self, nil, arg1)</OnClick>
<OnShow>BenillaSpellButton_UpdateButton(self)</OnShow>
<!-- The reference routes both drag entries to the plain click path with drag=1
(SpellButtonTemplate l.169-174) — the bag/doll/action slots' own precedent. -->
@@ -542,6 +691,41 @@
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
</CheckButton>
<!--
The Spell/Pet toggle tab (ref SpellBookFrameTabButtonTemplate, l.40-71): a 128x64 plate
that hangs off the window's bottom-left, drawn UNSELECTED normally and swapping to the
selected plate through its <DisabledTexture> — the ref uses Disable() for "this is the
book you are on", not a checked state, which is why this is a plain Button and not a
CheckButton. Hidden by default; SpellBookFrame_Update raises exactly the ones that exist.
-->
<Button name="BenillaSpellBookFrameTabButtonTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="128" y="64"/></Size>
<HitRectInsets><AbsInset left="15" right="14" top="13" bottom="15"/></HitRectInsets>
<Scripts>
<OnClick>ToggleSpellBook(self.bookType)</OnClick>
<OnEnter>
GameTooltip:SetOwner(self, "ANCHOR_RIGHT")
-- ref l.53: the label plus its bound key. MicroButtonTooltipText is MicroMenu.xml's
-- (loaded before this file); nil-guarded like every other cross-file call here.
if MicroButtonTooltipText then
GameTooltip:SetText(MicroButtonTooltipText(self:GetText(), self.binding), 1.0, 1.0, 1.0)
else
GameTooltip:SetText(self:GetText(), 1.0, 1.0, 1.0)
end
GameTooltip:Show()
</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<ButtonText>
<Anchors><Anchor point="CENTER"><Offset><AbsDimension x="0" y="3"/></Offset></Anchor></Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormalSmall"/>
<HighlightFont inherits="GameFontHighlightSmall"/>
<DisabledFont inherits="GameFontHighlightSmall"/>
<NormalTexture file="Interface\SpellBook\UI-SpellBook-Tab-Unselected"/>
<HighlightTexture file="Interface\SpellBook\UI-SpellbookPanel-Tab-Highlight" alphaMode="ADD"/>
</Button>
<Frame name="BenillaSpellBookFrame" hidden="true">
<!-- The real 384x512 window (ref l.202-205). No <Anchors> — ShowUIPanel/SetLeftFrame
SetPoints this to the left slot on open (UiPanels.xml's own convention). -->
@@ -606,6 +790,31 @@
</Scripts>
</Button>
<!-- ref l.288-317. Tab 3 exists in the ref's own XML and is never raised in 1.12
(there is no third book); kept so the anchor chain reads like the ref's. -->
<Button name="BenillaSpellBookFrameTabButton1" inherits="BenillaSpellBookFrameTabButtonTemplate">
<Anchors>
<Anchor point="CENTER" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="79" y="61"/></Offset></Anchor>
</Anchors>
<DisabledTexture file="Interface\SpellBook\UI-SpellBook-Tab1-Selected"/>
</Button>
<Button name="BenillaSpellBookFrameTabButton2" inherits="BenillaSpellBookFrameTabButtonTemplate">
<Anchors>
<Anchor point="LEFT" relativeTo="BenillaSpellBookFrameTabButton1" relativePoint="RIGHT">
<Offset><AbsDimension x="-20" y="0"/></Offset>
</Anchor>
</Anchors>
<DisabledTexture file="Interface\SpellBook\UI-SpellBook-Tab1-Selected"/>
</Button>
<Button name="BenillaSpellBookFrameTabButton3" inherits="BenillaSpellBookFrameTabButtonTemplate">
<Anchors>
<Anchor point="LEFT" relativeTo="BenillaSpellBookFrameTabButton2" relativePoint="RIGHT">
<Offset><AbsDimension x="-20" y="0"/></Offset>
</Anchor>
</Anchors>
<DisabledTexture file="Interface\SpellBook\UI-SpellBook-Tab3-Selected"/>
</Button>
<Button name="BenillaSpellBookPrevPageButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
@@ -719,6 +928,7 @@
<OnEvent>BenillaSpellBookFrame_OnEvent(self)</OnEvent>
<OnShow>BenillaSpellBookFrame_OnShow()</OnShow>
<OnHide>BenillaSpellBookFrame_OnHide()</OnHide>
<OnUpdate>BenillaSpellBookFrame_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Ui>
+19 -20
View File
@@ -91,6 +91,20 @@ pub(crate) fn normalize_path(path: &str) -> String {
path.replace('/', "\\").to_ascii_lowercase()
}
/// The archive key a UI **sprite** reference resolves to: [`normalize_path`] plus the FrameXML
/// extensionless→`.blp` rule (`Interface\Buttons\UI-Quickslot2` → `interface\buttons\ui-quickslot2.blp`;
/// a path that already carries an extension passes through). One function because every sprite
/// entry point needs it — the five decoders below and, deliberately, the shipped-XML sweep that
/// asserts each `file=` in our own UI actually resolves (`ui_script::shipped_xml_tests`): a sweep
/// re-implementing this rule could agree with itself while disagreeing with the renderer.
pub(crate) fn sprite_key(path: &str) -> String {
let mut key = normalize_path(path);
if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) {
key.push_str(".blp");
}
key
}
/// Mutate an asset only when `differs` says its current state doesn't already match.
///
/// `Assets::get_mut` alone marks the asset Modified — a uniform re-upload and (on the Metal
@@ -181,10 +195,7 @@ impl WorldAssets {
// FrameXML/DBC texture references are canonically EXTENSIONLESS
// (`Interface\Buttons\UI-Quickslot2`) — the real client appends `.blp` at resolve. Paths
// that already carry an extension (`textures\moon.blp`) pass through unchanged.
let mut key = normalize_path(path);
if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) {
key.push_str(".blp");
}
let key = sprite_key(path);
// Cached (hits AND misses): the UI extractor asks per quad per frame — uncached, every
// frame re-decoded every BLP into a fresh `Image` (unbounded asset growth) and the fresh
// handles defeated the quad-list dirty check.
@@ -203,10 +214,7 @@ impl WorldAssets {
/// which resizes the 128×32 frame to the plate's exact size). Same extensionless→`.blp` resolve
/// as [`Self::sprite_texture`]; not cached (a one-shot decode at load, not a per-frame ask).
pub(crate) fn decode_rgba(&mut self, path: &str) -> Option<(u32, u32, Vec<u8>)> {
let mut key = normalize_path(path);
if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) {
key.push_str(".blp");
}
let key = sprite_key(path);
read_texture_rgba(&mut self.chain.lock_recover(), &key).ok()
}
@@ -220,10 +228,7 @@ impl WorldAssets {
path: &str,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
let mut key = normalize_path(path);
if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) {
key.push_str(".blp");
}
let key = sprite_key(path);
if let Some(cached) = self.tiled_sprites.get(&key) {
return cached.clone();
}
@@ -243,10 +248,7 @@ impl WorldAssets {
path: &str,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
let mut key = normalize_path(path);
if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) {
key.push_str(".blp");
}
let key = sprite_key(path);
if let Some(cached) = self.portraits.get(&key) {
return cached.clone();
}
@@ -265,10 +267,7 @@ impl WorldAssets {
path: &str,
images: &mut Assets<Image>,
) -> Option<Handle<Image>> {
let mut key = normalize_path(path);
if !key.rsplit('\\').next().is_some_and(|f| f.contains('.')) {
key.push_str(".blp");
}
let key = sprite_key(path);
if let Some(cached) = self.masks.get(&key) {
return cached.clone();
}
+34
View File
@@ -671,6 +671,40 @@ mod tests {
assert!(!state(&app).fired(cmd::TOGGLE_SHEATH));
}
/// **The pet lane routes on the CTRL digits, and the number row is untouched** (B218,
/// decision 1052). The two share their base keys, so the only thing keeping them apart is
/// the exact-modifier law — worth pinning on the pair that actually collides rather than
/// trusting the law in the abstract. CTRL-0 is slot **10**, the 1.12 cache's own wrap.
#[test]
fn the_pet_lane_dispatches_on_the_ctrl_digits() {
let by_name =
|n: &str| Cmd(SPECS.iter().position(|s| s.name == n).expect("registered") as u16);
let mut app = harness();
press_key(&mut app, KeyCode::ControlLeft);
press_key(&mut app, KeyCode::Digit1);
app.update();
assert!(state(&app).pressed(by_name("BONUSACTIONBUTTON1")));
assert!(
!state(&app).pressed(by_name("ACTIONBUTTON1")),
"the modifier decides: CTRL-1 is not the number row's"
);
// The runOnUp half: the latch drops on the BASE key's release (which is what runs
// BenillaPetActionButtonUp → CastPetAction in the app's VM), Ctrl still held.
release_key(&mut app, KeyCode::Digit1);
app.update();
assert!(!state(&app).pressed(by_name("BONUSACTIONBUTTON1")));
// CTRL-0 → slot 10.
press_key(&mut app, KeyCode::Digit0);
app.update();
assert!(state(&app).pressed(by_name("BONUSACTIONBUTTON10")));
// Bare 1 is still the action bar's, with no pet command in sight.
let mut app = harness();
press_key(&mut app, KeyCode::Digit1);
app.update();
assert!(state(&app).pressed(by_name("ACTIONBUTTON1")));
assert!(!state(&app).pressed(by_name("BONUSACTIONBUTTON1")));
}
#[test]
fn the_typing_gate_blocks_new_input_and_clears_held_latches_once() {
let mut app = harness();
+134 -9
View File
@@ -85,15 +85,15 @@ pub(crate) mod cmd {
pub(crate) const OPEN_CHAT: Cmd = Cmd(12);
pub(crate) const OPEN_CHAT_SLASH: Cmd = Cmd(13);
pub(crate) const REPLY: Cmd = Cmd(17);
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = Cmd(40);
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = Cmd(41);
pub(crate) const NAMEPLATES: Cmd = Cmd(52);
pub(crate) const FRIEND_NAMEPLATES: Cmd = Cmd(53);
pub(crate) const ALL_NAMEPLATES: Cmd = Cmd(54);
pub(crate) const ATTACK_TARGET: Cmd = Cmd(55);
pub(crate) const TOGGLE_UI: Cmd = Cmd(76);
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(77);
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(78);
pub(crate) const TARGET_NEAREST_ENEMY: Cmd = Cmd(50);
pub(crate) const TARGET_PREVIOUS_ENEMY: Cmd = Cmd(51);
pub(crate) const NAMEPLATES: Cmd = Cmd(62);
pub(crate) const FRIEND_NAMEPLATES: Cmd = Cmd(63);
pub(crate) const ALL_NAMEPLATES: Cmd = Cmd(64);
pub(crate) const ATTACK_TARGET: Cmd = Cmd(65);
pub(crate) const TOGGLE_UI: Cmd = Cmd(86);
pub(crate) const CAMERA_ZOOM_IN: Cmd = Cmd(87);
pub(crate) const CAMERA_ZOOM_OUT: Cmd = Cmd(88);
}
/// The registry, 1.12 `Bindings.xml` order. Sub-tables (action buttons, shapeshift, raid
@@ -334,6 +334,114 @@ pub(crate) static SPECS: &[Spec] = &[
Some("CTRL-F10"),
None
),
// The PET bar's row — 1.12 calls it BONUSACTIONBUTTON and files it under this same ACTIONBAR
// header (Bindings.xml:321-390 carries no `header` attribute, so it inherits l.121's), which
// is why "Secondary Action Button 1-10" reads under Action Bar in the reference's own window.
// The ref's body is BonusActionButtonDown/Up(id), and those two are one-liners onto
// PetActionButtonDown/Up (BonusActionBarFrame.lua:106-112) — the PET bar's buttons, not the
// bonus bar's. benilla has no bonus bar at all, so the pet bar is the whole lane, exactly as
// the reference wires it. Defaults are byte-real and unanimous: CTRL-1..CTRL-0 in all three
// of the install's `bindings-cache.wtf` files (unlike TOGGLEUI's rebind, 0870).
spec!(
"BONUSACTIONBUTTON1",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(1)",
"BenillaPetActionButtonUp(1)"
),
Some("CTRL-1"),
None
),
spec!(
"BONUSACTIONBUTTON2",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(2)",
"BenillaPetActionButtonUp(2)"
),
Some("CTRL-2"),
None
),
spec!(
"BONUSACTIONBUTTON3",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(3)",
"BenillaPetActionButtonUp(3)"
),
Some("CTRL-3"),
None
),
spec!(
"BONUSACTIONBUTTON4",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(4)",
"BenillaPetActionButtonUp(4)"
),
Some("CTRL-4"),
None
),
spec!(
"BONUSACTIONBUTTON5",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(5)",
"BenillaPetActionButtonUp(5)"
),
Some("CTRL-5"),
None
),
spec!(
"BONUSACTIONBUTTON6",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(6)",
"BenillaPetActionButtonUp(6)"
),
Some("CTRL-6"),
None
),
spec!(
"BONUSACTIONBUTTON7",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(7)",
"BenillaPetActionButtonUp(7)"
),
Some("CTRL-7"),
None
),
spec!(
"BONUSACTIONBUTTON8",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(8)",
"BenillaPetActionButtonUp(8)"
),
Some("CTRL-8"),
None
),
spec!(
"BONUSACTIONBUTTON9",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(9)",
"BenillaPetActionButtonUp(9)"
),
Some("CTRL-9"),
None
),
spec!(
"BONUSACTIONBUTTON10",
ACTIONBAR,
Kind::EdgeUpDown(
"BenillaPetActionButtonDown(10)",
"BenillaPetActionButtonUp(10)"
),
Some("CTRL-0"),
None
),
// ── Targeting (BINDING_HEADER_TARGETING) ────────────────────────────────────────────
spec!(
"TARGETNEARESTENEMY",
@@ -988,6 +1096,23 @@ mod tests {
}
}
/// No two commands ship the SAME default chord. One key, one command is the binding table's
/// own law (the steal pass in [`super::store::resolve`] enforces it at load), so a duplicate
/// here would ship a table that eats itself: registration order would decide the winner and
/// the loser would come up silently unbound. Cheap tripwire for the one mistake a new block
/// of rows can make — 1052's CTRL-1..CTRL-0 went in with nothing to catch a collision.
#[test]
fn no_two_commands_ship_the_same_default_chord() {
let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new();
for s in SPECS {
for d in [s.d1, s.d2].into_iter().flatten() {
if let Some(prev) = seen.insert(d, s.name) {
panic!("default '{d}' is on both {prev} and {}", s.name);
}
}
}
}
/// Names are unique (the table is keyed by them everywhere: files, Lua, the window).
#[test]
fn names_are_unique() {
+5
View File
@@ -175,8 +175,13 @@ fn probe_key_by_name(name: &str) -> Option<KeyCode> {
// Added because the harness could not reach the leg that broke decision 0793: the director's
// first real run was a boosted free-fly, whose camera crosses the art radius in ~5 s, and
// reproducing it needed a held `Ctrl` + `W` behind an `F`.
//
// The toggle is the dev chord + `F` since decision 1043 (it was a bare `F`), so a probe that
// wants free-fly holds `Ctrl` and `Shift` across the `F` tap — hence `Shift` here. `Ctrl`
// stays held afterwards for the ×5 boost, which is unchanged.
"F" => KeyCode::KeyF,
"Ctrl" => KeyCode::ControlLeft,
"Shift" => KeyCode::ShiftLeft,
_ => return None,
})
}
+130
View File
@@ -274,6 +274,136 @@ pub(crate) fn stop_attack_local(
}
}
/// The one local **StartAttack** — benilla's `0x5ecb70`, [`stop_attack_local`]'s twin (wow-re §5
/// `melee-autorepeat-exclusion.md` §5c–§5f). Two halves, and keeping them apart is the whole point:
///
/// - **the send** (`0x5eccfd`) is gated by the already-attacking test at `0x5eccda` — skipped when
/// the attack lock is held, *unless* a stop is in flight (`[+0xc54]`), which is why
/// stop → select → re-swing re-points at a new target instead of going quiet;
/// - **the tail** (`0x5ecd78`–`0x5ecd95`) — melee sheath SNAP then the local auto-repeat cancel
/// `0x6ea080` — runs either way, because the skip's `jmp` lands *inside* it. That is what makes
/// "start swinging" and "keep auto-shooting" mutually exclusive.
///
/// `stop_in_flight` is a **parameter, not stored state**: the ref's `[+0xc54]` is set by
/// [`stop_attack_local`] and consumed here, and the only caller that spans both is the target
/// switch, which does them back to back in one call chain. Decision 1036 removed the persistent
/// latch for exactly this reason — outside that chain it has no reader.
///
/// The caller owes the reference's own two entry conditions, which differ per site and are not
/// re-derived here: a cast press must not call this at all while engaged (`TryCast`'s `6e51cb`/
/// `6e51d2` gate), and the Attack button forks to [`stop_attack_local`] instead (`0x6131a0` @
/// `0x6131d9`). `0x5ecb70`'s own validator legs — the target's alive-or-feign + `CanAttack
/// 0x606980` walk, and the `[0xb4b3e4]` world gate — stay with the callers that already compute
/// them (`target::scan`'s `new_attackable`, the drain's `attack_actor_refusal`).
#[allow(clippy::too_many_arguments)] // the tail writes four sinks; the alternative is a bundle
pub(crate) fn start_attack_local(
entity: Entity,
target: u64,
engaged: bool,
stop_in_flight: bool,
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
sheath: &mut MessageWriter<SheathRequest>,
commands: &mut Commands,
net: &crate::net::NetCommands,
) {
if !engaged || stop_in_flight {
let _ = net
.0
.send(crate::net::ClientCommand::AttackSwing { guid: target });
}
// The tail. The sheath setter is idempotent — the client's own "no-op if already melee" — so
// re-running it on a re-point costs nothing and matches `0x5ecd80`'s unconditional call.
sheath.write(SheathRequest {
entity,
state: 1,
ceremony: false,
});
cancel_auto_repeat_local(Some(entity), auto_repeat, commands, net);
}
/// The **Attack toggle** — benilla's `0x6131a0`, the third of the trio and the one the base Attack
/// pseudo-spell runs (`TryCast`'s effect-`0x4e` short-circuit at `0x6e4c7a` dispatches straight to
/// it, ahead of every cast gate). It forks on the attack lock and nothing else: already attacking →
/// `0x6131d9 call 0x5ecac0` [`stop_attack_local`], otherwise `0x6131ee call 0x5ecb70`
/// [`start_attack_local`] (wow-re `melee-autorepeat-exclusion.md` §5f).
///
/// The fork is the whole content, and it is worth a name because both halves used to be wrong here:
/// the button never toggled melee OFF, and it cancelled a running auto-repeat on *every* press —
/// but only the start arm reaches `0x5ecd8c`, so in the reference toggling melee off leaves Auto
/// Shot running.
///
/// `[0xb4b3e4]` — the reference's second condition on the stop arm, a world/session global also
/// tested at `0x5ecbc0` — is unmodelled; nothing in benilla can be false there while a press is
/// being drained.
#[allow(clippy::too_many_arguments)] // the seams' write set, minus the bundle a caller can't hold
pub(crate) fn toggle_attack_local(
entity: Entity,
target: u64,
engaged: bool,
queued_melee: &mut crate::ui_cast::QueuedMeleeSpell,
auto_repeat: &mut crate::ui_action::AutoRepeatActive,
sheath: &mut MessageWriter<SheathRequest>,
commands: &mut Commands,
net: &crate::net::NetCommands,
) {
if engaged {
stop_attack_local(engaged, queued_melee, net);
} else {
start_attack_local(
entity,
target,
engaged,
false,
auto_repeat,
sheath,
commands,
net,
);
}
}
/// **The local attack seams' whole write set, as ONE [`SystemParam`]** — so a caller threading
/// [`stop_attack_local`]/[`start_attack_local`] through a call chain takes one param instead of
/// six, and so a new sink in either seam lands in every caller at once.
///
/// The targeting side needs exactly this: `target::scan`'s `commit` runs the reference's
/// stop → select → re-swing law (`SetSelection 0x493540`'s own `0x493a08 call 0x5ecac0` and
/// `0x4938c8 call 0x5ecb70`), so every selection writer that can fire it has to carry both seams'
/// inputs. [`crate::ui_action::cast_send::CastLadder`] already carries them field by field for the
/// cast path and calls the free functions directly.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct AttackSeam<'w, 's> {
pub(crate) net: Res<'w, crate::net::NetCommands>,
pub(crate) queued_melee: ResMut<'w, crate::ui_cast::QueuedMeleeSpell>,
pub(crate) auto_repeat: ResMut<'w, crate::ui_action::AutoRepeatActive>,
pub(crate) sheath: MessageWriter<'w, SheathRequest>,
pub(crate) ecs: Commands<'w, 's>,
/// Our own entity — the sheath snap's and the auto-repeat cancel's subject.
pub(crate) me: Query<'w, 's, Entity, With<crate::net::SelfPlayer>>,
}
impl AttackSeam<'_, '_> {
/// `0x5ecac0` — see [`stop_attack_local`].
pub(crate) fn stop(&mut self, engaged: bool) {
stop_attack_local(engaged, &mut self.queued_melee, &self.net);
}
/// `0x5ecb70` — see [`start_attack_local`]. A no-op before our own entity streams in.
pub(crate) fn start(&mut self, target: u64, engaged: bool, stop_in_flight: bool) {
let Ok(e) = self.me.single() else { return };
start_attack_local(
e,
target,
engaged,
stop_in_flight,
&mut self.auto_repeat,
&mut self.sheath,
&mut self.ecs,
&self.net,
);
}
}
/// A unit is mid-cast (`SMSG_SPELL_START` .. `GO`/`FAILED_OTHER`, decision 0099 phase 1) — the
/// wire-truth state seam. Inserted on `SpellStart` only when it carries a nonzero cast time (an
/// instant cast's `SpellGo` follows with nothing to interrupt, so it never gets one); removed on
@@ -100,16 +100,58 @@ fn catalog() -> AnimData {
]))
}
/// What a bare `app.update()` advances the harness clock by — small and nonzero, which is the
/// regime every assertion in this file was written against (a real frame used to land here by
/// accident). Zero is NOT equivalent: a frame with no delta never ticks a transition at all, so
/// clips the tests expect to find mid-fade are never started.
const FRAME_STEP: std::time::Duration = std::time::Duration::from_millis(1);
/// The delta [`step_clock`] will use for the next frame, when a test asked for more than
/// [`FRAME_STEP`]. It has to travel as a resource rather than a plain pre-`update()` call on
/// `Time`, because `Time::advance_by` *sets* the frame's delta rather than accumulating it — so a
/// value written before `update()` is simply overwritten by the in-frame step.
#[derive(Resource, Default)]
struct NextStep(Option<std::time::Duration>);
/// The harness's whole clock, in place of `TimePlugin`'s real one (see [`app`]): one frame of
/// [`FRAME_STEP`], or of whatever [`advance`] asked for.
fn step_clock(mut time: ResMut<Time>, mut next: ResMut<NextStep>) {
time.advance_by(next.0.take().unwrap_or(FRAME_STEP));
}
/// Run one frame whose delta is exactly `ms` — the only way time moves further than
/// [`FRAME_STEP`] (see [`app`]). Replaces `thread::sleep` + `update()`: the same intent, but the
/// delta is the number written here rather than however long the machine happened to take.
fn advance(app: &mut App, ms: u64) {
app.world_mut().resource_mut::<NextStep>().0 = Some(std::time::Duration::from_millis(ms));
app.update();
}
fn app() -> App {
let mut app = App::new();
// Asset + animation plugins so tests with REAL clip assets (the watchdog test) get Bevy's
// `advance_animations` ticking completions; units without a graph handle are skipped by it,
// so the asset-less tenants are unaffected.
//
// **`TimePlugin` is deliberately disabled, and the clock is ours** ([`step_clock`]). With it,
// `Time`'s delta is the REAL gap between two `app.update()` calls, and this file is full of
// assertions that a fade or clip is still mid-flight. A stalled frame — parallel `cargo test`
// on a loaded machine, a debugger, a cold page — then runs the whole fade out in one delta and
// the clip is gone from the player, which is a coin flip rather than a test: it red-lit main's
// gates on 2026-08-06 in `the_swim_relatch_holds_the_kick_but_a_ground_cut_freezes_it`, and
// injecting a 400 ms stall before that test's ground cut reproduces the failure exactly. The
// tests that DO need time to pass used to buy it with `thread::sleep`, which is the same coin
// flip pointed the other way; they now say how far the clock moves, through [`advance`].
//
// The step is small-but-nonzero rather than zero on purpose — see [`FRAME_STEP`].
app.add_plugins((
MinimalPlugins,
MinimalPlugins.build().disable::<bevy::time::TimePlugin>(),
AssetPlugin::default(),
bevy::animation::AnimationPlugin,
));
app.init_resource::<Time>();
app.init_resource::<NextStep>();
app.add_systems(bevy::app::First, step_clock);
app.add_message::<SwingMessage>()
.add_message::<crate::creature_anim::SwingImpact>()
.add_message::<crate::creature_anim::DefenseAnim>()
@@ -1232,8 +1274,7 @@ fn a_looping_arm_advances_through_its_variations_at_window_end() {
let mut seen = std::collections::HashSet::new();
for _ in 0..60 {
std::thread::sleep(std::time::Duration::from_millis(25));
app.update();
advance(&mut app, 25);
let tr = app
.world()
.entity(unit)
@@ -1684,8 +1725,7 @@ fn a_finished_masked_cast_fades_out_instead_of_snapping() {
// Step in small frames until the clip completes (a big frame would run the whole 150 ms fade
// out in one go — right behaviour, useless assertion).
for _ in 0..60 {
std::thread::sleep(std::time::Duration::from_millis(20));
app.update();
advance(&mut app, 20);
if app
.world()
.entity(unit)
@@ -1719,8 +1759,7 @@ fn a_finished_masked_cast_fades_out_instead_of_snapping() {
assert!(active.weight() > 0.0, "still blended in as λ decays");
}
// Past the 150 ms window: the slot self-releases, exactly like the kernel's `+0xd0 = -1`.
std::thread::sleep(std::time::Duration::from_millis(200));
app.update();
advance(&mut app, 200);
let e = app.world().entity(unit);
assert!(
e.get::<AnimDriver>().unwrap().overlay_fade.is_none(),
@@ -2079,8 +2118,7 @@ fn a_melee_to_ranged_toggle_stows_both_hands_before_it_reaches_for_the_bow() {
let mut seen = vec![[1u8, 1]];
let mut settled = false;
for _ in 0..60 {
std::thread::sleep(std::time::Duration::from_millis(20));
app.update();
advance(&mut app, 20);
match visual(&app) {
Some(v) if seen.last() != Some(&v) => seen.push(v),
None => {
+16 -14
View File
@@ -2,7 +2,7 @@
//!
//! The panel is a rounded, translucent **window floated off the top-right corner** (a gap from the
//! edges, like the perf pill) — it overlays the 3D view, which renders full-screen underneath and is
//! never letterboxed. Fixed width (no resize handle). Press the backtick key (`` ` ``) to show/hide it.
//! never letterboxed. Fixed width (no resize handle). [`DEV_CHORD`]+`D` shows/hides it (1043, 1048).
//! Its look (translucent dark backing + crisp text) is shared with the perf pill and the inspector card
//! via [`OVERLAY_FILL`] / [`overlay_text`], so the dev overlays read as one family.
//!
@@ -234,9 +234,10 @@ impl Plugin for DebugPanelPlugin {
.add_systems(
Update,
(
// After the UI keyboard feed so a backtick typed into a focused EditBox never
// toggles the panel.
toggle_panel.after(crate::ui_script::UiInput),
// No ordering against the UI keyboard feed any more: the toggle is a dev chord
// (1043), which no focused EditBox can consume and which needs no capture gate —
// the same reason `perf`'s and `sound`'s toggles never needed one.
toggle_panel,
apply_model_visibility
.after(crate::wmo_portal::WmoPvsSet)
.in_set(ModelVisSet),
@@ -267,14 +268,14 @@ fn spawn_egui_camera(mut commands: Commands) {
));
}
fn toggle_panel(
keys: Res<ButtonInput<KeyCode>>,
ui_capture: Res<crate::ui_script::UiKeyboardCapture>,
mut debug: ResMut<DebugState>,
) {
// Don't toggle while the chat input is open or a focused EditBox owns the keyboard — a backtick
// keypress there belongs in the text.
if !ui_capture.0 && keys.just_pressed(KeyCode::Backquote) {
fn toggle_panel(keys: Res<ButtonInput<KeyCode>>, mut debug: ResMut<DebugState>) {
// The dev chord + `D` (decision 1048). It was a *bare* backtick until 1043 — backtick reads as
// "not a game key", but it is one ([`crate::bindings::chord`] gives it the token `` ` ``, so the
// reference's binding UI can bind it like any other), which made a bare toggle here exactly the
// squat 0585 moved the perf HUD off `P` for. 1043 put it on the chord; `` ` `` is a bad key to
// hold a chord on, so it became a letter like the rest of the fleet. The chat-bar/EditBox gate
// the bare key needed is gone either way: a chord can't be mistaken for typed text.
if dev_chord(&keys, KeyCode::KeyD) {
debug.open = !debug.open;
}
}
@@ -284,7 +285,8 @@ fn toggle_panel(
/// what [`dev_chord`] actually listens for.
pub(crate) const DEV_CHORD: &str = "Ctrl+Shift";
/// Did the **dev-overlay chord** — [`DEV_CHORD`]+*key* — just fire? (decisions 0585, 0867, 0870.)
/// Did the **dev-overlay chord** — [`DEV_CHORD`]+*key* — just fire? (decisions 0585, 0867, 0870,
/// 1043 — which moved the last two dev keys onto it, so the whole fleet is here now.)
///
/// The dev instruments used to sit on bare letters, which is a namespace we don't own: every letter is
/// a *game* binding in the reference client, so `P` both opened the spellbook and toggled the perf HUD
@@ -674,7 +676,7 @@ fn debug_panel_ui(
ui.separator();
ui.label(
egui::RichText::new(format!(
"` panel · {DEV_CHORD}: P perf · I inspect · M mute"
"{DEV_CHORD}: D panel · P perf · I inspect · M mute · F free-fly"
))
.small()
.color(OVERLAY_TEXT_DIM),
+1 -1
View File
@@ -11,7 +11,7 @@ use bevy::prelude::*;
/// (`open: false`), each section its own `Default` (so this derives cleanly).
#[derive(Resource, Default)]
pub struct DebugState {
/// Panel visible? Hidden by default; toggled with the backtick key.
/// Panel visible? Hidden by default; toggled with the dev chord + `D` (decisions 1043, 1048).
pub open: bool,
pub models: ModelDebug,
pub lighting: LightingDebug,
+1 -1
View File
@@ -15,7 +15,7 @@
//! misses).
//! - the **inspector surface** ([`inspect_ui`]) — a standalone, key-toggleable overlay (**the dev
//! chord + `I`**): a weak "armed" pill plus a small identity card that follows the cursor over any
//! picked object. It's its own surface, *not* a section of the backtick debug panel, so
//! picked object. It's its own surface, *not* a section of the dev-chord debug panel, so
//! identifying a thing costs one chord and no panel.
//! - the **cast journal** ([`journal`]) — the *temporal* half of the same instrument: a spell is an
//! event, gone before a cursor could reach it, so every cast edge is recorded as it flows past and
+8 -3
View File
@@ -16,9 +16,11 @@
//! sits at the login screen, which is what the real one does. The scene harness (`$WOW_CAPTURE`)
//! boots straight in-world and is unaffected.
//!
//! Controls: WASD walks the avatar (Ctrl sprints); right-drag turns it, left-drag orbits the camera
//! (both hide/freeze the cursor while held), scroll wheel zooms; `F` toggles free-fly (then WASD flies
//! the camera with Space up / C down, Ctrl boost).
//! Controls: WASD walks the avatar; right-drag turns it, left-drag orbits the camera (both
//! hide/freeze the cursor while held), scroll wheel zooms. Those are all **bindings** now (0997) —
//! rebindable, and nothing in the client squats on a bare key beside them (1043). The dev chord
//! (`Ctrl`+`Shift`) + `F` toggles free-fly, then WASD flies the camera with Space up / C down and
//! `Ctrl` boosts — a boost that exists only inside free-fly, itself behind the chord.
mod area;
mod area_trigger;
@@ -130,6 +132,7 @@ mod ui_net;
mod ui_party;
mod ui_pass;
mod ui_pet;
mod ui_pet_book;
mod ui_pet_stats;
mod ui_quest;
mod ui_quest_log;
@@ -216,6 +219,7 @@ use ui_net::UiNetPlugin;
use ui_party::UiPartyPlugin;
use ui_pass::PlayerUiPlugin;
use ui_pet::UiPetPlugin;
use ui_pet_book::UiPetBookPlugin;
use ui_pet_stats::UiPetStatsPlugin;
use ui_quest::UiQuestPlugin;
use ui_quest_log::UiQuestLogPlugin;
@@ -715,6 +719,7 @@ pub fn run(build: BuildId) -> AppExit {
// so this renders the ten packed words the last `SMSG_PET_SPELLS` delivered and sends
// intents back. After UiActionPlugin (shares `Spells` and the cooldown triple's clock).
.add_plugins(UiPetPlugin)
.add_plugins(UiPetBookPlugin)
// The pet's paper-doll stat block (happiness/loyalty/XP/training points). Its own plugin
// because it runs off descriptor fields and two DBC tables rather than off `SMSG_PET_SPELLS`.
.add_plugins(UiPetStatsPlugin)
+13
View File
@@ -704,6 +704,19 @@ pub(crate) enum ClientCommand {
/// player twin: no answer packet — the removal arrives as a `UNIT_FIELD_AURA` delta on the
/// pet, which is also what puts the slot's icon back.
PetCancelAura { pet_guid: u64, spell_id: u32 },
/// Flip one pet **spellbook** entry's autocast (`CMSG_PET_SPELL_AUTOCAST` 0x2F3, decision
/// 1032) — `ToggleSpellAutocast`'s send, and **not** the pet bar's autocast verb.
///
/// The bar's right click is [`Self::PetSetAction`]: it rewrites a *slot's word*, so its body
/// names a position. This one names a **spell id**, because the book has no positions —
/// `0x4b4240` looks the id up in `0xb6f098` and hands `0x4bccb0` the id, never an index. Both
/// end up flipping the same bit on the same word; only one of them can say which word by slot.
/// No reply packet either way.
PetSpellAutocast {
pet_guid: u64,
spell_id: u32,
enabled: bool,
},
/// Start melee auto-attack on `guid` (`CMSG_ATTACKSWING`); echoed as `SMSG_ATTACKSTART`.
AttackSwing { guid: u64 },
/// Stop melee auto-attack (`CMSG_ATTACKSTOP`); echoed as `SMSG_ATTACKSTOP`, whose receive path
+1
View File
@@ -1038,6 +1038,7 @@ pub(super) fn apply_net_updates(
ui_actions.11.as_deref(),
&mut items,
&net_commands,
&mut pet_bar,
),
play_seq.next(),
),
+21
View File
@@ -83,6 +83,27 @@ pub(super) fn pet_spells(spells: PetSpells, catalog: Option<&Spells>, bar: &mut
.collect::<Vec<_>>()
.join(" ")
);
// The spell LIST, spelled out the same way and for the same reason (decision 1032). It is a
// different question from the bar's — "why is the pet book shorter than the packet said" — and
// the answer is always one of three things this line names: the id resolves to no `Spell.dbc`
// record, it carries `DO_NOT_DISPLAY` (the book's whole add-gate, `0x4b2f90`), or it is in.
// vmangos sends the pet's runtime passives here alongside its real spells, so the gap between
// the two counts is usually large and entirely correct.
debug!(
"net: pet spellbook — {}",
spells
.spells
.iter()
.map(|e| {
match catalog.and_then(|c| c.catalog.get(e.action())) {
Some(d) if d.in_pet_book() => format!("{} ({})", d.name, e.action()),
Some(d) => format!("{} ({}) DO_NOT_DISPLAY", d.name, e.action()),
None => format!("{} NOT IN CATALOG", e.action()),
}
})
.collect::<Vec<_>>()
.join(" · ")
);
let now = Instant::now();
bar.cooldowns = crate::cooldowns::Cooldowns::default();
for cd in &spells.cooldowns {
+254 -6
View File
@@ -309,12 +309,15 @@ pub(super) fn spell_go(
queued_melee: &mut QueuedMeleeSpell,
text: &mut MessageWriter<crate::combat_text::CombatTextSpawn>,
go_lid: &mut MessageWriter<crate::go_anim::GoLidOpen>,
// The cooldown store + what its start laws read (grouped: one arm-body concern).
// The cooldown store + what its start laws read (grouped: one arm-body concern). The last
// member is the PET's store — the reference inserts into two banks from this one handler
// ([`pet_go_cooldown`]).
cooldown_ctx: (
&mut Cooldowns,
Option<&Spells>,
&mut crate::items::Items,
&crate::net::NetCommands,
&mut crate::ui_pet::PetBar,
),
seq: u64,
) {
@@ -331,6 +334,9 @@ pub(super) fn spell_go(
if let Some(go_guid) = go_target {
go_lid.write(crate::go_anim::GoLidOpen { go_guid, spell_id });
}
let (cooldowns, spells, items, net_commands, pet_bar) = cooldown_ctx;
let now = Instant::now();
let display = spells.and_then(|s| s.catalog.get(spell_id));
// Our own launch completes the cast bar (a shown bar fills green and fades; auto-repeat
// shots — GO with no bar showing — no-op in the reference Lua) and opens the in-flight guard
// (spell-id-keyed, so a triggered proc's GO mid-cast doesn't unblock the running cast early).
@@ -366,9 +372,6 @@ pub(super) fn spell_go(
// normal source — this insert is how a Charge sweep appears on vmangos, which sends no
// cooldown packet for a plain cast. A pre-launch failure never reaches here, so nothing
// needs reverting.
let (cooldowns, spells, items, net_commands) = cooldown_ctx;
let now = Instant::now();
let display = spells.and_then(|s| s.catalog.get(spell_id));
// The ranged-shot pad (the category scaler `0x6e2b60`, byte-verified — wow-re
// `ranged-cooldown-sweep.md`, decision 0378): a ranged-slot cast folds our live
// `UNIT_FIELD_RANGEDATTACKTIME` (haste-scaled, server-written) into the category
@@ -415,6 +418,28 @@ pub(super) fn spell_go(
}
}
}
// **The PET leg of the same insert** (decision 1031) — a second, independent `if` in the very
// same handler, not an else of the one above.
if let Some(d) = display.filter(|_| pet_go_cooldown(caster, self_guid, index, stores)) {
// No ranged pad here: `0x6e2b60` is called on the self leg only (`0x6e845d`), and there is
// nothing on a pet to read a `UNIT_FIELD_RANGEDATTACKTIME` from that the client uses.
pet_bar.cooldowns.start_spell(spell_id, d, 0, now);
// `0x6e85fc` + `0x6e8601`: SPELL_UPDATE_COOLDOWN and PET_BAR_UPDATE_COOLDOWN. benilla
// collapses both into the pet bar's one repaint event, which the feed fires off its own
// diff — and the diff moves, because the slot's cooldown triple just changed. The signal
// bump is belt-and-braces for the case where the same spell re-arms to an identical
// triple (a zero-length re-cast), which the diff would otherwise swallow.
pet_bar.bar_signals = pet_bar.bar_signals.wrapping_add(1);
if crate::dbg_trace::enabled() {
crate::dbg_trace::line(
"cd",
&format!(
"arm spell={spell_id} rec={}ms cat={}:{}ms (GO pet-insert)",
d.recovery_ms, d.category, d.category_recovery_ms
),
);
}
}
// The miss list's floating words (0137 phase 2, the `0x6e7a70` handler): one outcome word
// over each missed target — except REFLECT, which re-anchors to the caster (`0x6e7e51`).
// Gate A applies to whichever unit the word lands over. Source-classified first (the color
@@ -602,6 +627,53 @@ pub(super) fn cancel_auto_repeat(
crate::creature_anim::cancel_auto_repeat_local(self_e, auto_repeat, commands, net);
}
/// **Does this `SMSG_SPELL_GO` arm the PET's cooldown bank?** (decision 1031; `0x6e857a`-`0x6e85ad`.)
///
/// The reference's GO handler makes two independent cooldown inserts, into two banks
/// (`0x6e2ea0`'s `bankHead = 0xcecaec + 24*bank`): the self leg above writes bank 0 at
/// `0x6e8493 mov ecx, 0xcecaec`, and this one writes **bank 1** at `0x6e85f2 mov ecx, 0xcecb04` —
/// the same bank `SMSG_PET_SPELLS`' cooldown tail seeds (`0x4bdaa8 push 1`) and the same bank
/// `GetPetActionCooldown` and `GetSpellCooldown(id, "pet")` read. One packet can arm both.
///
/// The gate is **ownership of the caster**, read off the caster's own descriptor and nothing else
/// — not the pet-bar guid, not `UNIT_FIELD_PETNUMBER`:
///
/// ```text
/// 0x6e858b edi = fields[0x14] ; edx = fields[0x10] ; CHARMEDBY as a 64-bit pair
/// 0x6e8594 or edx, edi ; jne -> edi = &fields[0x10]
/// 0x6e859a else edi = &fields[0x18] ; SUMMONEDBY
/// 0x6e859d call 0x468550 ; the active player's guid
/// 0x6e85a2 if (*edi, *(edi+4)) != that guid -> skip
/// ```
///
/// i.e. `charmedBy` when set, else **`summonedBy`** — `OwnerFallback::SummonedBy`, the pair the
/// `PET_ATTACK_*` callback uses, and **not** `0x5ee5a0`'s `createdBy` fallback. Picking the wrong
/// one would arm the bank for totems and miss real pets: exactly the silent failure that enum
/// exists to prevent.
///
/// **Why this leg matters at all on vmangos**: the server sends *no* cooldown packet for a pet's
/// own cast. `Creature::AddCooldown` (`Objects/Creature.cpp:3259-3282`) stores the cooldown and
/// returns; its one `SendSpellCooldown` call sits in the `else` branch, reached only by a
/// **charmed non-pet** casting an instant under mind control. So without this insert a hunter's
/// Growl or a warlock imp's Firebolt shows no sweep at all until the next `SMSG_PET_SPELLS`
/// reseeds the bank — and that is a summon, a swap or a learned spell, never a cast.
fn pet_go_cooldown(
caster: u64,
self_guid: &SelfGuid,
index: &GuidIndex,
stores: &Query<&mut ObjectStore>,
) -> bool {
let Some(me) = self_guid.0 else {
return false;
};
index
.0
.get(&caster)
.and_then(|&e| stores.get(e).ok())
.and_then(|s| s.0.unit_owner(benilla_protocol::OwnerFallback::SummonedBy))
== Some(me)
}
/// `SMSG_SPELL_COOLDOWN` (`0x6e9460`) — server-pushed cooldowns (school lockouts, and the pet's
/// own list). **Which unit's store** it lands in is the caller's decision
/// ([`super::addressed_store`]): the four cooldown packets all carry a caster guid, and resolving
@@ -821,6 +893,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
// The self player mid-cast on Fireball (133): the bar is up, `Casting{133}` marks it.
@@ -861,6 +934,7 @@ mod tests {
mut text: MessageWriter<CombatTextSpawn>,
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>| {
let net_commands = crate::net::NetCommands(tx.clone());
spell_go(
@@ -886,7 +960,13 @@ mod tests {
&mut queued_melee,
&mut text,
&mut go_lid,
(&mut cooldowns, None, &mut items, &net_commands),
(
&mut cooldowns,
None,
&mut items,
&net_commands,
&mut pet_bar,
),
1,
);
},
@@ -912,6 +992,166 @@ mod tests {
assert!(matches!(feed[0], CastBarEdge::Stop), "…with a STOP");
}
/// **The GO handler's PET leg** (decision 1031): a spell going off on a unit WE own arms the
/// pet's own cooldown bank, and nothing else does — vmangos sends no cooldown packet for a
/// pet's cast, so without this the pet bar's sweep never runs in play.
///
/// Three things are pinned, and each is a way the leg could be subtly wrong:
/// - the insert lands in the **pet's** store, not the player's (two banks, `0xcecaec` /
/// `0xcecb04`);
/// - the owner read falls back to **SUMMONEDBY**, not CREATEDBY (`0x6e859a`) — a totem, which
/// carries only CREATEDBY, must not arm it;
/// - a stranger's cast arms neither.
#[test]
fn a_pets_own_go_arms_the_pet_bank_and_only_the_pet_bank() {
use crate::combat_text::CombatTextSpawn;
use crate::creature_anim::Casting;
use crate::go_anim::GoLidOpen;
use crate::net::{Guid, SelfPlayer};
use bevy::ecs::system::RunSystemOnce;
/// `UNIT_FIELD_CHARMEDBY` / `SUMMONEDBY` / `CREATEDBY` — 64-bit, so each takes two dwords.
const CHARMEDBY: u16 = 10;
const SUMMONEDBY: u16 = 12;
const CREATEDBY: u16 = 14;
const GROWL: u32 = 2649;
let growl = || benilla_formats::SpellDisplay {
name: "Growl".into(),
recovery_ms: 5000,
..Default::default()
};
let make_spells = || crate::ui_action::Spells {
catalog: benilla_formats::SpellCatalog::from_displays(
[(GROWL, growl())].into_iter().collect(),
),
forms: Default::default(),
ranges: Default::default(),
cast_times: Default::default(),
durations: Default::default(),
radii: Default::default(),
};
// A world with us (guid 10), our pet (20, SUMMONEDBY us), a totem (30, CREATEDBY us only)
// and a stranger's pet (40, SUMMONEDBY somebody else).
let owned = |field: u16, owner: u64| {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(field, owner as u32),
(field + 1, (owner >> 32) as u32),
]))
};
let fire = |caster: u64, store: ObjectStore| {
let mut app = App::new();
app.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
.world_mut()
.spawn((Guid(10), SelfPlayer, ObjectStore::default()))
.id();
let caster_e = app.world_mut().spawn((Guid(caster), store)).id();
{
let mut index = app.world_mut().resource_mut::<GuidIndex>();
index.0.insert(10, self_e);
index.0.insert(caster, caster_e);
}
app.world_mut().resource_mut::<SelfGuid>().0 = Some(10);
let (tx, _rx) = crossbeam_channel::unbounded();
let spells = make_spells();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
casting: Query<&Casting>,
mut cast_events: MessageWriter<CastEvent>,
mut go_targets: MessageWriter<SpellGoTargets>,
self_guid: Res<SelfGuid>,
stores: Query<&mut ObjectStore>,
mut cast_bar: ResMut<CastBarFeed>,
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>,
mut text: MessageWriter<CombatTextSpawn>,
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>| {
let net_commands = crate::net::NetCommands(tx.clone());
spell_go(
caster,
GROWL,
0,
vec![],
vec![],
None,
None,
None,
None,
Some(caster),
&mut commands,
&index,
&casting,
&mut cast_events,
&mut go_targets,
&self_guid,
&stores,
&mut cast_bar,
&mut pending,
&mut queued_melee,
&mut text,
&mut go_lid,
(
&mut cooldowns,
Some(&spells),
&mut items,
&net_commands,
&mut pet_bar,
),
1,
);
},
)
.unwrap();
let now = Instant::now();
let armed = |c: &Cooldowns| c.info(GROWL, 0, Some(&growl()), now).remaining_ms > 0;
let world = app.world();
(
armed(world.resource::<Cooldowns>()),
armed(&world.resource::<crate::ui_pet::PetBar>().cooldowns),
world.resource::<crate::ui_pet::PetBar>().bar_signals,
)
};
// Our pet: the PET bank only, and a forced repaint with it.
let (player, pet, signals) = fire(20, owned(SUMMONEDBY, 10));
assert!(pet, "our pet's GO arms the pet bank");
assert!(!player, "…and never the player's");
assert_eq!(signals, 1, "PET_BAR_UPDATE_COOLDOWN's repaint");
// A charm reads CHARMEDBY first — the same leg, the other field.
let (_, charmed, _) = fire(20, owned(CHARMEDBY, 10));
assert!(charmed, "a charmed unit's GO arms it too");
// A TOTEM carries CREATEDBY and no SUMMONEDBY. `0x5ee5a0` would accept it; this leg's own
// fallback (`0x6e859a`) does not, and reading the wrong one is invisible until a shaman
// drops a totem and the pet bar sweeps.
let (_, totem, _) = fire(30, owned(CREATEDBY, 10));
assert!(!totem, "CREATEDBY alone is not this leg's owner test");
// Somebody else's pet: neither bank.
let (p2, pet2, _) = fire(40, owned(SUMMONEDBY, 99));
assert!(!p2 && !pet2, "a stranger's pet arms nothing");
}
/// **Both** producers of the caster's chain-hop array (decision 0955): `SMSG_SPELL_GO`'s own
/// hit list — the leg that makes a non-channelled chain spell draw at all — and the 816 packet.
/// Each drops the caster's own guid and each clears before it fills; unstreamed targets fall
@@ -936,6 +1176,7 @@ mod tests {
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
// A caster and two streamed victims; guid 40 is never streamed to us.
@@ -977,6 +1218,7 @@ mod tests {
mut text: MessageWriter<CombatTextSpawn>,
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>| {
let net_commands = crate::net::NetCommands(tx.clone());
spell_go(
@@ -1002,7 +1244,13 @@ mod tests {
&mut queued_melee,
&mut text,
&mut go_lid,
(&mut cooldowns, None, &mut items, &net_commands),
(
&mut cooldowns,
None,
&mut items,
&net_commands,
&mut pet_bar,
),
1,
);
},
+5
View File
@@ -709,6 +709,11 @@ fn writer_loop(
ClientCommand::PetCancelAura { pet_guid, spell_id } => {
w.pet_cancel_aura(pet_guid, spell_id)
}
ClientCommand::PetSpellAutocast {
pet_guid,
spell_id,
enabled,
} => w.pet_spell_autocast(pet_guid, spell_id, enabled),
ClientCommand::AttackSwing { guid } => w.attack_swing(guid),
ClientCommand::AttackStop => w.attack_stop(),
ClientCommand::SetSheathed { state } => w.set_sheathed(state),
+21 -34
View File
@@ -12,7 +12,8 @@
//! distance (clamped to the vanilla `cameraDistanceMax` range; the camera *glides* to the new
//! distance). A left-drag orbit offset *persists* — the vanilla `cameraSmoothStyle` auto-follow that
//! swung the camera back behind the character while moving is deliberately removed (director's call).
//! `F` toggles free-fly; the **dev chord + `G`** lands the avatar where the camera is ([`land`]).
//! The **dev chord + `F`** toggles free-fly (1043); the **dev chord + `G`** lands the avatar where
//! the camera is ([`land`]).
//!
//! Movement is a thin kinematic capsule controller over avian's `MoveAndSlide` (decision 0009).
@@ -260,7 +261,8 @@ fn mirror_self_collision_height(
/// Camera + avatar controller. Free-flies until the server reports our position; then takes
/// third-person control (WASD walks the avatar; right-drag turns it, left-drag orbits the camera,
/// wheel zooms) and streams our movement to the server as the confirmed mover. `F` toggles free-fly.
/// wheel zooms) and streams our movement to the server as the confirmed mover. The dev chord + `F`
/// toggles free-fly (decision 1043).
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn control(
time: Res<Time>,
@@ -307,8 +309,8 @@ fn control(
Res<crate::ui_script::UiKeyboardCapture>,
Res<crate::ui_script::PlayerUiClickConsumed>,
// The binding dispatch (decision 0997): every rebindable input below reads command
// state from here — raw `keys` remain only for the dev instruments (F free-fly, the
// Ctrl boost) and the look-session mouse.
// state from here — raw `keys` remain only for the dev chord's free-fly toggle
// (decision 1043) and the look-session mouse.
Res<crate::bindings::BindingsState>,
),
mut commands: Commands,
@@ -396,27 +398,14 @@ fn control(
let binds = &speed_capsule.7;
let dt = time.delta_secs();
// While a focused UI EditBox (the chat input, a mail field) owns the keyboard, keyboard reads see
// "no keys held" — so WASD/F/Ctrl/Z don't also drive the avatar while typing (a `.tele` command).
// Mouse still works. The gate is `UiKeyboardCapture`, which the focused chat EditBox drives.
// "no keys held" — so the avatar isn't also driven while typing (a `.tele` command). Mouse still
// works. The gate is `UiKeyboardCapture`, which the focused chat EditBox drives; the free-fly
// chord below is deliberately outside it, like every dev chord ([`debug_panel::dev_chord`]).
let typing = ui_capture.0;
let keys_pressed = |k: KeyCode| !typing && keys.pressed(k);
let keys_just_pressed = |k: KeyCode| !typing && keys.just_pressed(k);
// The rebindable inputs all read `binds` (decision 0997): the dispatch already enforced the
// typing gate and 0585's exact-modifier law when it latched, so this module carries neither
// anymore. What stays on raw keys: the F free-fly toggle and the Ctrl boost (dev
// instruments, not 1.12 bindings — F still wants the bare-binding rule locally so CTRL-F
// and friends stay distinct) and the mouse look session.
let modified = keys.any_pressed([
KeyCode::ShiftLeft,
KeyCode::ShiftRight,
KeyCode::ControlLeft,
KeyCode::ControlRight,
KeyCode::AltLeft,
KeyCode::AltRight,
KeyCode::SuperLeft,
KeyCode::SuperRight,
]);
let bare_binding = |k: KeyCode| keys_just_pressed(k) && !modified;
// anymore. Nothing here reads a bare key any more (decision 1043) — the free-fly toggle is on
// the dev chord, and the Ctrl run boost is gone.
// Both mouse buttons held together = vanilla's "both-button run": the avatar runs forward while
// the character steers with the mouse (turns like a right-drag), regardless of which button went
@@ -513,7 +502,11 @@ fn control(
- binds.amount(crate::bindings::cmd::CAMERA_ZOOM_OUT);
apply_zoom_scroll(zoom, dt, &mut rig);
if bare_binding(KeyCode::KeyF) {
// Free-fly is a dev instrument, so it sits on the dev chord, not a bare `F` (decision 1043).
// A bare `F` is a key the reference lets a player bind — our own store test binds it to JUMP —
// and a dev doesn't get to squat on the game's namespace (0585, the same rule that moved the
// perf HUD off bare `P`). Ungated on `typing` like every chord: it can't be mistaken for text.
if crate::debug_panel::dev_chord(&keys, KeyCode::KeyF) {
player.detached = !player.detached;
}
@@ -889,11 +882,6 @@ fn control(
}
}
}
let boost = if keys_pressed(KeyCode::ControlLeft) {
2.5
} else {
1.0
};
// Backpedaling is slower: the backward move-flag selects the backward speed, dominating
// strafe (binary-VERIFIED — see RUN_BACK_RATIO). Net-backward = the S key held without a
// forward override (W or both-button run). The backward arm is a **min**, not a plain
@@ -912,12 +900,11 @@ fn control(
Some(s) if !move_speed.env_override => (s.run, s.run_back),
_ => (move_speed.value, move_speed.value * RUN_BACK_RATIO),
};
let speed = boost
* if net_backward {
run_back_speed.min(run_speed)
} else {
run_speed
};
let speed = if net_backward {
run_back_speed.min(run_speed)
} else {
run_speed
};
let mut want_jump = binds.fired(crate::bindings::cmd::JUMP) && !player.modes.rooted;
// Swim vs walk: the water over our feet decides. Hysteresis-latched (`update_swimming`,
+4 -1
View File
@@ -85,7 +85,10 @@ pub(crate) fn land_here(
return;
}
if !player.detached {
info!("land: not free-flying — press F, fly somewhere, then land");
info!(
"land: not free-flying — press {chord}+F, fly somewhere, then land",
chord = crate::debug_panel::DEV_CHORD
);
return;
}
let Ok(cam) = camera.single() else {
+2 -1
View File
@@ -359,7 +359,8 @@ pub(crate) fn incapacitated_flags(flags: u32, rooted: bool, stunned: bool) -> u3
}
/// Our controllable avatar. Until `active`, the camera free-flies; once the server reports our
/// position we take control (third-person) and drive movement. Toggle free-fly with `F`.
/// position we take control (third-person) and drive movement. Toggle free-fly with the dev
/// chord + `F` (decision 1043).
/// `active`/`pos`/`detached` are `pub(crate)` so terrain streaming can center the loaded block on the
/// avatar in third-person and on the free-flying camera while detached.
#[derive(Resource, Default)]
+3 -2
View File
@@ -246,9 +246,10 @@ pub(super) fn apply_server_moves(
// cross-map worldport despawns and re-streams this entity while `player.active` stays
// true — per-entity state attached only on this one-shot edge would be lost on transfer.
info!(
"took control of player @ {:?} facing {:.3} ('F' toggles free-fly)",
"took control of player @ {:?} facing {:.3} ({} + F toggles free-fly)",
player.pos,
yaw.rem_euclid(std::f32::consts::TAU)
yaw.rem_euclid(std::f32::consts::TAU),
crate::debug_panel::DEV_CHORD
);
}
}
+89 -2
View File
@@ -53,8 +53,9 @@ pub(crate) enum WorldStage {
Present,
}
/// Installs the [`WorldStage`] ordering. Add before the subsystem plugins (order doesn't matter — set
/// configuration is resolved at schedule build).
/// Installs the [`WorldStage`] ordering + the boot-order correction below. Add before the subsystem
/// plugins (order doesn't matter — set configuration is resolved at schedule build), but *after*
/// `DefaultPlugins`, which is where `StatesPlugin` seeds the order this fixes.
pub(crate) struct SchedulePlugin;
impl Plugin for SchedulePlugin {
@@ -69,5 +70,91 @@ impl Plugin for SchedulePlugin {
)
.chain(),
);
move_initial_state_transition_after_startup(app);
}
}
/// **The initial state's `OnEnter` runs AFTER the app is built, not before it.**
///
/// `bevy_state` seeds the startup order with (`bevy_state-0.18.1/src/app.rs:308-309`):
///
/// ```ignore
/// schedule.insert_after(PreUpdate, StateTransition);
/// schedule.insert_startup_before(PreStartup, StateTransition); // ← this one
/// ```
///
/// so the one-shot transition that enters the *initial* state runs before `PreStartup` — ahead of
/// every `Startup` system, i.e. ahead of the patch chain ([`crate::assets::AssetSet::Open`]), the
/// Lua VM, the mixer and every catalog. An `OnEnter` for the state the app boots into therefore
/// sees an empty world, which is not what "on entering this state" means anywhere else.
///
/// Three subsystems each hit this independently and each wrote the same local workaround — spawn
/// from a per-frame `Update` poll instead of the state edge:
/// [`crate::login::screen::materialize_screen`], [`crate::char_select::screen::enter_select`] and
/// `sound::glue::start_glue_music`. Their comments name it "the boot-order trap". It is one cause
/// with three patches, which is the shape §3 says to go after rather than add a fourth to.
///
/// The fix is to move that single startup entry to the end: the initial transition now runs after
/// `PostStartup`, so `OnEnter(<initial>)` observes a fully built app exactly like every later
/// transition does. Safe here because **no `Startup` system in the workspace writes `NextState`**
/// (every writer is an `Update` system), so nothing can be waiting on the transition to be applied
/// mid-startup; and `insert_state`/`init_state` insert the `State<S>` resource directly at plugin
/// build, so a `Startup` system reading `Res<State<..>>` is unaffected either way.
///
/// This does not retire the three workarounds — two of them also re-spawn when *async* client art
/// lands, which is a different clock. It removes the boot-order reason to write a fourth.
fn move_initial_state_transition_after_startup(app: &mut App) {
use bevy::app::MainScheduleOrder;
use bevy::state::state::StateTransition;
let mut order = app.world_mut().resource_mut::<MainScheduleOrder>();
let before = order.startup_labels.len();
order.startup_labels.retain(|l| !(**l).eq(&StateTransition));
// Only re-seat what was actually there — if bevy_state ever stops seeding it, do not invent it.
if order.startup_labels.len() < before {
order.insert_startup_after(PostStartup, StateTransition);
}
}
#[cfg(test)]
mod tests {
use bevy::prelude::*;
use bevy::state::app::StatesPlugin;
use super::SchedulePlugin;
use crate::char_select::ClientState;
#[derive(Resource)]
struct BuiltAtStartup;
/// What [`super::move_initial_state_transition_after_startup`] buys: an `OnEnter` for the state
/// the app BOOTS INTO sees the resources `Startup` built. Without the re-seat this is false —
/// the initial transition runs ahead of `PreStartup` — and that is the boot-order trap three
/// subsystems each worked around by hand.
#[test]
fn the_initial_states_on_enter_sees_what_startup_built() {
#[derive(Resource, Default)]
struct Saw(Option<bool>);
let mut app = App::new();
app.add_plugins((MinimalPlugins, StatesPlugin))
.add_plugins(SchedulePlugin)
.insert_state(ClientState::InWorld)
.init_resource::<Saw>()
.add_systems(Startup, |mut c: Commands| c.insert_resource(BuiltAtStartup))
.add_systems(
OnEnter(ClientState::InWorld),
|built: Option<Res<BuiltAtStartup>>, mut saw: ResMut<Saw>| {
saw.0 = Some(built.is_some());
},
);
app.update();
assert_eq!(
app.world().resource::<Saw>().0,
Some(true),
"the initial state's OnEnter ran before Startup — the boot-order trap is back"
);
}
}
+4 -4
View File
@@ -71,7 +71,7 @@ use bevy::prelude::*;
use benilla_protocol::guid;
use crate::names::NameCache;
use crate::net::{Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::net::{Guid, GuidIndex, ObjectStore, SelfPlayer};
use super::{scan, Selection};
@@ -567,7 +567,7 @@ pub(super) fn follow_requests(
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
pub(crate) struct SelectCommit<'w, 's> {
selection: ResMut<'w, Selection>,
net: Res<'w, NetCommands>,
seam: crate::creature_anim::AttackSeam<'w, 's>,
me: Query<
'w,
's,
@@ -588,7 +588,7 @@ impl SelectCommit<'_, '_> {
let me = self.me.single().ok();
// The new-target classification `scan::commit` expects from its callers (it does not
// re-derive one) — the same `can_attack` the cursor and the TAB scan pass.
let attackable = scan::can_attack(
let attackable = super::relations::can_attack(
self.stores.get(entity).ok(),
self.factions.as_deref(),
&self.reputations,
@@ -596,7 +596,7 @@ impl SelectCommit<'_, '_> {
);
scan::commit(
&mut self.selection,
&self.net,
&mut self.seam,
entity,
guid,
me.is_some_and(|(_, _, engaged)| engaged),
+34 -39
View File
@@ -56,7 +56,7 @@ pub(super) fn select_on_click(
hovered: Res<Hovered>,
cursor: Res<WorldCursor>,
mut selection: ResMut<Selection>,
net: Res<NetCommands>,
mut seam: crate::creature_anim::AttackSeam,
self_q: Query<(&Guid, Has<Engaged>), With<SelfPlayer>>,
payload_held: Res<crate::ui_script::CursorPayloadHeld>,
occlusion: Res<PickOcclusion>,
@@ -95,7 +95,7 @@ pub(super) fn select_on_click(
// neutral, hover-refreshed every frame) IS Attack `0x5ecb70`'s new-target validation.
scan::commit(
&mut selection,
&net,
&mut seam,
entity,
guid,
engaged,
@@ -113,7 +113,7 @@ pub(super) fn select_on_click(
// at 0 = an empty-world click keeps the target (1.12's own inverted checkbox).
_ => {
if click_cfg.deselect_on_click && (!payload_held.0 || occlusion.distance.is_finite()) {
clear(&mut selection, &net, engaged);
clear(&mut selection, &mut seam, engaged);
}
}
}
@@ -149,9 +149,8 @@ pub(super) fn act_on_right_click(
hovered_object: Res<HoveredObject>,
cursor: Res<WorldCursor>,
mut selection: ResMut<Selection>,
net: Res<NetCommands>,
mut seam: crate::creature_anim::AttackSeam,
self_player: Query<(Entity, &Guid, Has<Engaged>), With<SelfPlayer>>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
mut emote: MessageWriter<crate::creature_anim::EmoteAnim>,
mut play_seq: ResMut<crate::creature_anim::PlaySeq>,
// The GameObject lock-routing inputs (decisions 0239 / 0545 / 0752) as one [`GoLockInputs`]
@@ -227,13 +226,13 @@ pub(super) fn act_on_right_click(
&player_actions.spells,
go,
me_store,
&net,
&seam.net,
) {
GoAction::Use if cursor.unable => {}
GoAction::OpenLock(_) | GoAction::OpenByKey { .. } if cursor.unable => {}
GoAction::Use => {
debug!("right-click gameobject use: {guid:#x}");
let _ = net.0.send(ClientCommand::GameObjUse { guid });
let _ = seam.net.0.send(ClientCommand::GameObjUse { guid });
}
GoAction::OpenLock(spell_id) => {
// The opener cast funnels through the ref's TryCast like any other cast
@@ -254,7 +253,7 @@ pub(super) fn act_on_right_click(
return;
}
debug!("right-click gameobject open-lock: cast {spell_id} at {guid:#x}");
let _ = net.0.send(ClientCommand::CastSpellGameObject {
let _ = seam.net.0.send(ClientCommand::CastSpellGameObject {
spell_id,
go_guid: guid,
});
@@ -277,7 +276,7 @@ pub(super) fn act_on_right_click(
// *spell* as well as its block ordinal, and the binder has no GameObject
// arm (`CastCommit::Item::on_object` is the seam that awaits it). Stated
// gap, decision 0914.
let _ = net.0.send(ClientCommand::UseItem {
let _ = seam.net.0.send(ClientCommand::UseItem {
bag_index,
slot,
spell_index,
@@ -315,7 +314,7 @@ pub(super) fn act_on_right_click(
// it never swings at them.
let outcome = scan::commit(
&mut selection,
&net,
&mut seam,
entity,
guid,
me.is_some_and(|(_, _, e)| e),
@@ -335,23 +334,14 @@ pub(super) fn act_on_right_click(
// refused — selection stands, no swing
} else {
debug!("right-click attack: {guid:#x}");
// Auto-draw through the anim layer's ONE setter (decision 0080) — a SNAP, no
// ceremony, no sound: the attack path passes `(newState=1, bInstant=1,
// bFireEvent=1)` at `0x5ecd80` (wow-re `sheath-policy.md`). The setter's
// idempotency is the client's own "no-op if already melee".
if let Some((e, _, _)) = me {
sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 1,
ceremony: false,
});
}
// The commit's engaged-switch law may already have re-pointed the swing at this
// guid (the ref's `0x5ecb70` skips a second send while the attack lock is set) —
// only the fresh attack-start still owes its ATTACKSWING.
if !outcome.swung {
let _ = net.0.send(ClientCommand::AttackSwing { guid });
}
// The right-click's own StartAttack, through the one seam (auto-draw + the swing +
// the auto-repeat cancel — `0x5ecb70`'s whole body). The commit above may already
// have re-pointed the swing at this guid, in which case the ref's `0x5eccda` is the
// thing that suppresses a second send, so pass it the real lock state and let the
// seam decide: `swung` means the re-swing already went out and the lock is ours, so
// no stop is in flight any more.
let engaged = me.is_some_and(|(_, _, e)| e);
seam.start(guid, engaged || outcome.swung, false);
}
} else if loot {
// A dead unit carrying UNIT_DYNFLAG_LOOTABLE (the Pickup loot cursor, decision 0084): open
@@ -360,7 +350,7 @@ pub(super) fn act_on_right_click(
// No EmoteTalk: looting is not an NPC interaction, the corpse plays no talk.
if !cursor.unable {
debug!("right-click loot: {guid:#x}");
let _ = net.0.send(ClientCommand::Loot { guid });
let _ = seam.net.0.send(ClientCommand::Loot { guid });
// The kneel is client-predicted AT THE SEND: the real client's `CMSG_LOOT` sender
// (`0x5df253`) sets the loot-target latch `[player+0x1d28]` and plays Loot 50 before
// any server response (decision 0515). Arm the latch the anim driver's loot leg
@@ -377,7 +367,7 @@ pub(super) fn act_on_right_click(
if !cursor.unable {
if let Some(spell_id) = learned.skinning {
debug!("right-click skin: {guid:#x} (spell {spell_id})");
let _ = net.0.send(ClientCommand::CastSpell {
let _ = seam.net.0.send(ClientCommand::CastSpell {
spell_id,
target: Some(guid),
});
@@ -395,7 +385,7 @@ pub(super) fn act_on_right_click(
.unwrap_or(0);
if let Some(cmd) = interact_command(cursor.kind, guid, npc_flags) {
debug!("right-click interact: {guid:#x} ({:?})", cursor.kind);
let _ = net.0.send(cmd);
let _ = seam.net.0.send(cmd);
// Play EmoteTalk on our avatar — the reconcile stows a drawn weapon, persistently
// (decisions 0080/0081; no sheath wiring here).
if let Some((e, _, _)) = me {
@@ -674,14 +664,14 @@ fn interact_command(
pub(super) fn clear_target_requests(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
mut selection: ResMut<Selection>,
net: Res<NetCommands>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
) {
let Some(mut script) = script else {
return;
};
if script.take_target_clear() {
clear(&mut selection, &net, !engaged.is_empty());
clear(&mut selection, &mut seam, !engaged.is_empty());
}
}
@@ -698,7 +688,7 @@ pub(super) fn clear_target_requests(
pub(super) fn target_unit_requests(
script: Option<NonSendMut<UiScript>>,
mut selection: ResMut<Selection>,
net: Res<NetCommands>,
mut seam: crate::creature_anim::AttackSeam,
self_q: Query<(Entity, &Guid, Has<Engaged>), With<SelfPlayer>>,
group: Res<crate::ui_party::GroupState>,
index: Res<crate::net::GuidIndex>,
@@ -739,7 +729,7 @@ pub(super) fn target_unit_requests(
// switch fired from here can only ever STOP the swing — never re-point it.
scan::commit(
&mut selection,
&net,
&mut seam,
entity,
guid,
engaged,
@@ -755,13 +745,18 @@ pub(super) fn target_unit_requests(
/// one is running (`engaged`, our server-echoed [`Engaged`]): `CMSG_ATTACKSTOP` — the ref stops
/// swinging and drops the attack stance on Esc/click-off/target-death alike (the stance itself falls
/// when the `SMSG_ATTACKSTOP` echo removes [`Engaged`]). Weapons *stay drawn* — combat never stows.
pub(super) fn clear(selection: &mut Selection, net: &NetCommands, engaged: bool) {
pub(super) fn clear(
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
engaged: bool,
) {
if selection.target.take().is_some() {
selection.guid = None;
let _ = net.0.send(ClientCommand::SetSelection { guid: 0 });
if engaged {
let _ = net.0.send(ClientCommand::AttackStop);
}
let _ = seam.net.0.send(ClientCommand::SetSelection { guid: 0 });
// `SetSelection 0x493540`'s own `0x493a08 call 0x5ecac0` — the real StopAttack, so
// losing the target also un-queues a pending on-next-swing strike. It was a bare
// `CMSG_ATTACKSTOP` before the seam existed.
seam.stop(engaged);
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ use bevy::prelude::*;
use crate::creature_anim::Engaged;
use crate::net::{ObjectStore, Reputations, SelfPlayer};
use super::scan::can_attack;
use super::relations::can_attack;
use super::{Factions, Selection};
/// This frame's flash verdict + the global pulse clock. Recomputed every frame by
+3 -1
View File
@@ -51,6 +51,7 @@ mod flash;
mod highlight;
mod hover;
pub(crate) mod lock;
mod relations;
mod reticle;
mod ring;
mod scan;
@@ -65,7 +66,8 @@ pub(crate) use flash::CombatFlash;
// (`scan`) answers it with the same core TAB uses. `attack_order_target` + `EnemyScan` are that
// same core called *synchronously*, by the pet bar's ATTACK arm: the pet's order has to leave in
// the frame it was pressed carrying the acquired guid, so it cannot go round through a request.
pub(crate) use scan::{attack_order_target, can_attack, AttackNearestRequest, EnemyScan};
pub(crate) use relations::{can_assist, can_attack};
pub(crate) use scan::{attack_order_target, AttackNearestRequest, EnemyScan};
// The chat layer's by-name selection asks (`/target`, `/assist` — decision 0886), answered by the
// shared resolver the reference parameterises per caller.
pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
+101
View File
@@ -0,0 +1,101 @@
//! **Who may I attack, who may I help** — the two unit-relationship predicates the reference
//! shares across systems, kept together because they are one concern and neither belongs to the
//! system that happens to call them first.
//!
//! Both are byte-derived and both key off [`ring_reaction`], on opposite sides of the same bar:
//! `CanAttack 0x606980` wants reaction ≤ 3 (not friendly), `CanAssist 0x6066f0` wants ≥ 4
//! (friendly) plus two flag clauses. They lived in `scan.rs` while `can_attack` had exactly one
//! caller; `can_assist` (the `UnitBuff` gate, decision 1035) made that a second concern in a file
//! about TAB targeting.
use benilla_protocol::messages::OwnerFallback;
use crate::net::{ObjectStore, Reputations};
use super::{ring_reaction, Factions};
/// The five `UNIT_FIELD_FLAGS` disqualifiers `CanAttack 0x606980` tests (bit positions
/// byte-verified; names vmangos-corroborated): NON_ATTACKABLE(1), NOT_ATTACKABLE_1(7),
/// NON_ATTACKABLE_2(16), TAXI_FLIGHT(20), NOT_SELECTABLE(25).
pub(super) const FLAG_DISQUALIFIERS: u32 = (1 << 1) | (1 << 7) | (1 << 16) | (1 << 20) | (1 << 25);
/// `CanAttack 0x606980` — the shared attackability predicate (the combat flash's gate and the
/// scan's filter 3): the flag disqualifiers clear AND an attackable reaction. A unit without a
/// store passes the flag leg (nothing known to disqualify). The duel/PVP legs are deferred with
/// duels/PvP.
///
/// The reaction leg is **≤ neutral (rank ≤ 3)** — attackable means *not friendly*. First
/// director-observed (0170), then **byte-confirmed** by the `0x606980` re-pin: the function has
/// three reaction legs selected by `UNIT_FLAG_PVP_ATTACKABLE` (bit 3) on both parties, and the
/// player-vs-creature case is Leg B — `UnitReaction(player→target) < 4` (`cmp eax,4; setl`),
/// single direction, friendly-only blocked. The old "≤ 1 hostile" gloss was Leg A (both parties
/// UN-flagged: NPC-vs-NPC, bidirectional), never the player's check. Leg C (both flagged: the
/// PvP/duel/group machinery) is deferred with PvP.
pub(crate) fn can_attack(
store: Option<&ObjectStore>,
factions: Option<&Factions>,
reputations: &Reputations,
self_store: Option<&ObjectStore>,
) -> bool {
if store.is_some_and(|s| s.0.unit_flags() & FLAG_DISQUALIFIERS != 0) {
return false;
}
ring_reaction(factions, reputations, store, self_store) <= 3
}
/// `UNIT_FLAG_NOT_SELECTABLE` (bit 25) — `CanAssist`'s own first disqualifier, and one of
/// [`FLAG_DISQUALIFIERS`]' five (vmangos `UnitDefines.h`).
const UNIT_FLAG_NOT_SELECTABLE: u32 = 1 << 25;
/// `UNIT_FLAG_PVP` (bit 12) — what `IsPvP 0x605ff0` tests, after resolving the unit's owner
/// (vmangos `UnitDefines.h:UNIT_FLAG_PVP = 0x1000`).
const UNIT_FLAG_PVP: u32 = 0x1000;
/// `UNIT_FLAG_PVP_ATTACKABLE` (bit 3) — behaviourally "player-controlled", the same bit
/// [`ring_reaction`]'s duel leg selects on.
const UNIT_FLAG_PLAYER_CONTROLLED: u32 = 0x8;
/// `CanAssist 0x6066f0` — "may I help this unit?", the predicate `UnitBuff`'s unit-level gate runs
/// (see [`crate::ui_aura::buffs_visible_on`]). Byte-derived in wow-re's
/// `ui/scratch/aura-display-pipeline.md` §9b; named by its own Lua registrar pair
/// (`.data 0x8504c8 = {"UnitCanAssist", 0x516bb0}`), not by resemblance.
///
/// Three clauses: `UNIT_FLAG_NOT_SELECTABLE` clear, `UnitReaction ≥ 4`, and — for a unit that is
/// **not** player-controlled — `IsPvP 0x605ff0` on the unit (owner-chased). The reaction bar is the
/// **internal** scale, not the Lua one: `UnitReaction 0x5167e0` does `inc eax` at `0x51683e` before
/// pushing, so Lua's FRIENDLY(5) is internal 4. [`ring_reaction`] already returns the raw internal
/// rank, so the comparison is against 4 directly — **neutral fails**.
///
/// The player-controlled arm (`0x60673e`–`0x60679f`) keys on an `[obj+0xe68]` record whose fields
/// wow-re did **not** name, so it is deliberately NOT modelled: a player-controlled unit takes the
/// permissive answer here. That is conservative in the only direction that matters (it shows what
/// we already showed) and it keeps the un-derived arm out of the one place it could silently blank
/// a player's or a pet's buffs.
pub(crate) fn can_assist(
store: Option<&ObjectStore>,
factions: Option<&Factions>,
reputations: &Reputations,
self_store: Option<&ObjectStore>,
owner_store: impl FnOnce(u64) -> Option<ObjectStore>,
) -> bool {
let Some(store) = store else {
return false;
};
let flags = store.0.unit_flags();
if flags & UNIT_FLAG_NOT_SELECTABLE != 0 {
return false;
}
if ring_reaction(factions, reputations, Some(store), self_store) < 4 {
return false;
}
if flags & UNIT_FLAG_PLAYER_CONTROLLED != 0 {
// The un-derived arm — see the doc comment.
return true;
}
// `IsPvP 0x605ff0`: the flag on the unit's OWNER when it has one (charmedBy, else createdBy —
// the `0x5ee5a0` fallback pair), otherwise on the unit itself.
let owned = store
.0
.unit_owner(OwnerFallback::CreatedBy)
.and_then(owner_store);
let pvp_flags = owned.as_ref().map_or(flags, |o| o.0.unit_flags());
pvp_flags & UNIT_FLAG_PVP != 0
}
+4 -4
View File
@@ -46,7 +46,7 @@ use bevy::prelude::*;
use crate::assets::{LockRecover, WorldAssets};
use crate::collision::GroundDecalSurface;
use crate::decal::{project_decal, DecalFrame};
use crate::net::{NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer};
use crate::net::{NetEntity, ObjectStore, Reputations, SelfPlayer};
use crate::particles::buffer::{EffectBlend, EffectDrawSpec, EffectFog, EffectQuads, EffectVertex};
use super::click::clear;
@@ -276,7 +276,7 @@ pub(super) fn update_ring(
// `.respawn`ed creature reuses its spawn guid, so change-armed state goes stale and misses the
// second kill (the bug this replaces).
mut last_vitals: Local<Option<(u64, bool)>>,
net_commands: Res<NetCommands>,
mut seam: crate::creature_anim::AttackSeam,
// Net entities are roots, so their `Transform` is already world-space + current this frame
// (net motion ran in `WorldStage::Net`), avoiding the 1-frame lag a `GlobalTransform` read
// would add. Tupled into one param (the 16-param ceiling): `.0` the target's own components;
@@ -375,7 +375,7 @@ pub(super) fn update_ring(
.is_some_and(|g| *last_vitals == Some((g, false)));
*last_vitals = selection.guid.map(|g| (g, is_dead));
if died {
clear(&mut selection, &net_commands, !engaged.is_empty());
clear(&mut selection, &mut seam, !engaged.is_empty());
*last_vitals = None;
state.shown = false;
state.verts.clear();
@@ -416,7 +416,7 @@ pub(super) fn update_ring(
// selection-death-clear RE; this is also what drops a selected corpse at respawn, when
// the server destroys it ahead of the fresh create).
Err(_) => {
clear(&mut selection, &net_commands, !engaged.is_empty());
clear(&mut selection, &mut seam, !engaged.is_empty());
*last_vitals = None;
*logged_guid = None;
true
+115 -132
View File
@@ -53,41 +53,11 @@ use bevy::prelude::*;
use crate::assets::{LockRecover, WorldAssets};
use crate::creature_anim::{Engaged, SwingMessage};
use crate::names::NameCache;
use crate::net::{
ClientCommand, Guid, NetCommands, NetEntity, ObjectStore, Reputations, SelfPlayer,
};
use crate::net::{ClientCommand, Guid, NetEntity, ObjectStore, Reputations, SelfPlayer};
use super::relations::can_attack;
use super::{ring_reaction, Factions, Selection};
/// The five `UNIT_FIELD_FLAGS` disqualifiers `CanAttack 0x606980` tests (bit positions
/// byte-verified; names vmangos-corroborated): NON_ATTACKABLE(1), NOT_ATTACKABLE_1(7),
/// NON_ATTACKABLE_2(16), TAXI_FLIGHT(20), NOT_SELECTABLE(25).
pub(super) const FLAG_DISQUALIFIERS: u32 = (1 << 1) | (1 << 7) | (1 << 16) | (1 << 20) | (1 << 25);
/// `CanAttack 0x606980` — the shared attackability predicate (the combat flash's gate and the
/// scan's filter 3): the flag disqualifiers clear AND an attackable reaction. A unit without a
/// store passes the flag leg (nothing known to disqualify). The duel/PVP legs are deferred with
/// duels/PvP.
///
/// The reaction leg is **≤ neutral (rank ≤ 3)** — attackable means *not friendly*. First
/// director-observed (0170), then **byte-confirmed** by the `0x606980` re-pin: the function has
/// three reaction legs selected by `UNIT_FLAG_PVP_ATTACKABLE` (bit 3) on both parties, and the
/// player-vs-creature case is Leg B — `UnitReaction(player→target) < 4` (`cmp eax,4; setl`),
/// single direction, friendly-only blocked. The old "≤ 1 hostile" gloss was Leg A (both parties
/// UN-flagged: NPC-vs-NPC, bidirectional), never the player's check. Leg C (both flagged: the
/// PvP/duel/group machinery) is deferred with PvP.
pub(crate) fn can_attack(
store: Option<&ObjectStore>,
factions: Option<&Factions>,
reputations: &Reputations,
self_store: Option<&ObjectStore>,
) -> bool {
if store.is_some_and(|s| s.0.unit_flags() & FLAG_DISQUALIFIERS != 0) {
return false;
}
ring_reaction(factions, reputations, store, self_store) <= 3
}
// == The Classic-priority dials ==
// Each names the Classic Era cvar it stands in for. The VALUES are tuned, not pinned — the
// binary's defaults are packer-encrypted; when the director's in-game `GetCVarInfo` dump lands,
@@ -498,7 +468,7 @@ pub(super) struct CommitOutcome {
/// the swing and does NOT re-point (the tail's self exception).
pub(super) fn commit(
selection: &mut Selection,
net: &NetCommands,
seam: &mut crate::creature_anim::AttackSeam,
entity: Entity,
guid: u64,
engaged: bool,
@@ -513,12 +483,19 @@ pub(super) fn commit(
selection.guid = Some(guid);
let stop_and_repoint = engaged && had_old;
if stop_and_repoint {
let _ = net.0.send(ClientCommand::AttackStop);
// `0x493a08 call 0x5ecac0` — the real StopAttack, so the switch also cancels a queued
// on-next-swing strike (the seam's `0x6e6f30` tail). Before this routed through the seam
// it was a bare `CMSG_ATTACKSTOP` and a queued Heroic Strike survived the switch.
seam.stop(engaged);
}
let _ = net.0.send(ClientCommand::SetSelection { guid });
let _ = seam.net.0.send(ClientCommand::SetSelection { guid });
let swung = stop_and_repoint && new_attackable && self_guid != Some(guid);
if swung {
let _ = net.0.send(ClientCommand::AttackSwing { guid });
// `0x4938c8 call 0x5ecb70` with a stop in flight (`[+0xc54]`, set by the call above), so
// the swing goes out despite the still-set lock. Its tail cancels a running auto-repeat —
// wow-re `melee-autorepeat-exclusion.md` §6 REFUTES `nocked-ammo-cancel.md`'s
// direct-callers-only census, which had this chain never reaching `0x6ea080`.
seam.start(guid, engaged, true);
}
CommitOutcome {
changed: true,
@@ -537,7 +514,7 @@ pub(super) fn tab_target(
scan: EnemyScan,
mut history: ResMut<TabHistory>,
mut selection: ResMut<Selection>,
net: Res<NetCommands>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
) {
let reverse = binds.fired(crate::bindings::cmd::TARGET_PREVIOUS_ENEMY);
@@ -605,7 +582,7 @@ pub(super) fn tab_target(
}
let out = commit(
&mut selection,
&net,
&mut seam,
entity,
guid,
!engaged.is_empty(),
@@ -650,7 +627,7 @@ pub(super) fn tab_target(
fn acquire_nearest_enemy(
scan: &EnemyScan,
selection: &mut Selection,
net: &NetCommands,
seam: &mut crate::creature_anim::AttackSeam,
errors: &mut crate::ui_action::UiErrorKeys,
) -> Option<(Entity, u64)> {
let cands = scan.build();
@@ -666,7 +643,7 @@ fn acquire_nearest_enemy(
// `engaged = false`: the switch law only fires when we were already swinging at the OLD
// selection, and every path that reaches here had no selection or a non-hostile one — neither
// is a thing you can be engaged with.
commit(selection, net, c.entity, c.guid, false, None, false);
commit(selection, seam, c.entity, c.guid, false, None, false);
Some((c.entity, c.guid))
}
@@ -703,14 +680,14 @@ fn acquire_nearest_enemy(
pub(crate) fn attack_order_target(
scan: &EnemyScan,
selection: &mut Selection,
net: &NetCommands,
seam: &mut crate::creature_anim::AttackSeam,
errors: &mut crate::ui_action::UiErrorKeys,
) -> Option<u64> {
// `0x61306b` + `0x6130a3`: the selection is the candidate, and it survives only if the actor is
// hostile to it. Otherwise `0x6130b5` acquires.
let guid = match keeps_held_target(selection.guid, |g| scan.reaction_hostile(g)) {
Some(kept) => kept,
None => acquire_nearest_enemy(scan, selection, net, errors)?.1,
None => acquire_nearest_enemy(scan, selection, seam, errors)?.1,
};
// `0x613167`'s final gate on whatever we ended up with. `0x61312e`'s legs are the ACTOR's and
// are Phase A's, already run by the caller; these two are the target's.
@@ -777,11 +754,9 @@ pub(super) fn acquire_and_attack(
mut requests: MessageReader<AttackNearestRequest>,
scan: EnemyScan,
mut selection: ResMut<Selection>,
net: Res<NetCommands>,
self_player: Query<Entity, With<SelfPlayer>>,
mut seam: crate::creature_anim::AttackSeam,
self_store: Query<&crate::net::ObjectStore, With<SelfPlayer>>,
mut ui_error_keys: ResMut<crate::ui_action::UiErrorKeys>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>,
) {
if requests.read().last().is_none() {
return;
@@ -809,20 +784,16 @@ pub(super) fn acquire_and_attack(
// `0x6130b5` and `0x6130d9`, shared with the pet bar's ATTACK arm — see
// [`acquire_nearest_enemy`]. The selection is empty (checked above), which is exactly the
// `candidate == 0` leg of [`attack_order_target`]; the follow-through below is what differs.
let Some((_, guid)) = acquire_nearest_enemy(&scan, &mut selection, &net, &mut ui_error_keys)
let Some((_, guid)) =
acquire_nearest_enemy(&scan, &mut selection, &mut seam, &mut ui_error_keys)
else {
return;
};
debug!("attack acquire: best candidate {guid:#x} → select + swing");
// The attack start (`0x6131a0` continuation): auto-draw (snap, decision 0080) + the swing.
if let Ok(e) = self_player.single() {
sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 1,
ceremony: false,
});
}
let _ = net.0.send(ClientCommand::AttackSwing { guid });
// `0x6131a0`'s continuation — StartAttack through the one seam, so the acquire path gets the
// auto-draw AND the auto-repeat cancel its hand-rolled copy used to miss. The selection was
// empty on entry, so we cannot have been engaged: no stop is in flight.
seam.start(guid, false, false);
}
/// Auto-acquire the attacker (behavior 2): the ATTACKERSTATEUPDATE victim handler's
@@ -833,7 +804,7 @@ pub(super) fn auto_acquire_attacker(
self_player: Query<Entity, With<SelfPlayer>>,
guids: Query<&Guid>,
mut selection: ResMut<Selection>,
net: Res<NetCommands>,
mut seam: crate::creature_anim::AttackSeam,
) {
for s in swings.read() {
if selection.target.is_some() {
@@ -853,126 +824,138 @@ pub(super) fn auto_acquire_attacker(
guid.0
);
// Selection is empty here (checked above): no engaged-switch law, selection only.
commit(&mut selection, &net, s.attacker, guid.0, false, None, false);
commit(
&mut selection,
&mut seam,
s.attacker,
guid.0,
false,
None,
false,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::net::NetCommands;
/// The `SetSelection` wire law, byte-read from `0x493540` (the director's "TAB while
/// auto-attacking kills the attack" bug): an engaged SWITCH is **stop → select → re-swing**
/// — the attack follows the new target — while a plain select sends only the selection, a
/// self-target stops without re-pointing, and an unattackable new target (Attack `0x5ecb70`'s
/// validation) switches and stops but never swings.
///
/// Driven through a World because `commit` now runs the two real seams
/// ([`crate::creature_anim::AttackSeam`]) rather than emitting bare packets — which is what
/// the second half of this test pins: the switch's stop un-queues an on-next-swing strike
/// (`0x493a08 call 0x5ecac0` → `0x6e6f30`) and its re-swing cancels a running auto-repeat
/// (`0x4938c8 call 0x5ecb70` → `0x5ecd8c`). Both were missing while this path hand-rolled
/// its own packets.
#[test]
fn commit_follows_the_stop_select_reswing_law() {
use bevy::ecs::system::RunSystemOnce;
let (tx, rx) = crossbeam_channel::unbounded();
let net = NetCommands(tx);
let mut selection = Selection::default();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<Selection>();
world.spawn(SelfPlayer);
let drain = |rx: &crossbeam_channel::Receiver<ClientCommand>| {
rx.try_iter()
.map(|c| match c {
ClientCommand::SetSelection { .. } => "select",
ClientCommand::AttackStop => "stop",
ClientCommand::AttackSwing { .. } => "swing",
ClientCommand::CancelCast { .. } => "cancel-cast",
ClientCommand::CancelAutoRepeat => "cancel-repeat",
_ => "other",
})
.collect::<Vec<_>>()
};
// One `commit` through a one-shot system, returning its outcome.
fn go(
world: &mut World,
guid: u64,
engaged: bool,
self_guid: Option<u64>,
attackable: bool,
) -> CommitOutcome {
world
.run_system_once(
move |mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam| {
commit(
&mut selection,
&mut seam,
Entity::PLACEHOLDER,
guid,
engaged,
self_guid,
attackable,
)
},
)
.expect("commit runs as a one-shot system")
}
// Not engaged: first select and a switch are selection-only; a same-guid re-commit dedups.
assert!(
commit(
&mut selection,
&net,
Entity::PLACEHOLDER,
0xA,
false,
Some(1),
true
)
.changed
);
assert!(
commit(
&mut selection,
&net,
Entity::PLACEHOLDER,
0xB,
false,
Some(1),
true
)
.changed
);
assert!(
!commit(
&mut selection,
&net,
Entity::PLACEHOLDER,
0xB,
false,
Some(1),
true
)
.changed
);
assert!(go(&mut world, 0xA, false, Some(1), true).changed);
assert!(go(&mut world, 0xB, false, Some(1), true).changed);
assert!(!go(&mut world, 0xB, false, Some(1), true).changed);
assert_eq!(drain(&rx), ["select", "select"]);
// Engaged switch onto an attackable unit: the byte order stop → select → swing.
let out = commit(
&mut selection,
&net,
Entity::PLACEHOLDER,
0xC,
true,
Some(1),
true,
);
let out = go(&mut world, 0xC, true, Some(1), true);
assert!(out.changed && out.swung);
assert_eq!(drain(&rx), ["stop", "select", "swing"]);
// Engaged switch onto MYSELF (TargetUnit("player") mid-combat): stop, no re-point.
let out = commit(
&mut selection,
&net,
Entity::PLACEHOLDER,
0x1,
true,
Some(1),
true,
);
let out = go(&mut world, 0x1, true, Some(1), true);
assert!(out.changed && !out.swung);
assert_eq!(drain(&rx), ["stop", "select"]);
// Engaged switch onto an unattackable unit (vendor/corpse): stop, no swing at it.
let out = commit(
&mut selection,
&net,
Entity::PLACEHOLDER,
0xD,
true,
Some(1),
false,
);
let out = go(&mut world, 0xD, true, Some(1), false);
assert!(out.changed && !out.swung);
assert_eq!(drain(&rx), ["stop", "select"]);
// Engaged FIRST select (no old target): the [ebp-1] latch is off — selection only.
let mut fresh = Selection::default();
let out = commit(
&mut fresh,
&net,
Entity::PLACEHOLDER,
0xE,
true,
Some(1),
true,
);
*world.resource_mut::<Selection>() = Selection::default();
let out = go(&mut world, 0xE, true, Some(1), true);
assert!(out.changed && !out.swung);
assert_eq!(drain(&rx), ["select"]);
// **The two edges the seam brought.** A queued strike and a running auto-repeat, then an
// engaged switch onto an attackable unit: the stop un-queues the strike, the re-swing
// kills the repeat.
world
.resource_mut::<crate::ui_cast::QueuedMeleeSpell>()
.arm(78);
world.resource_mut::<crate::ui_action::AutoRepeatActive>().0 = Some(75);
let out = go(&mut world, 0xF, true, Some(1), true);
assert!(out.changed && out.swung);
assert_eq!(
drain(&rx),
["stop", "cancel-cast", "select", "swing", "cancel-repeat"]
);
assert_eq!(
world
.resource::<crate::ui_cast::QueuedMeleeSpell>()
.current(),
None,
"the switch un-queued Heroic Strike"
);
assert_eq!(
world.resource::<crate::ui_action::AutoRepeatActive>().0,
None,
"and the re-swing killed Auto Shot"
);
}
fn cand(guid: u64, score: f32, on_screen: bool) -> Candidate {
+15 -16
View File
@@ -612,21 +612,18 @@ fn send_spell_cast(
// above means an auto-repeat can't be running while we swing in the first place.
if !engaged {
debug!("ui_action: cast {spell_id} initiates auto-attack at {guid:#x}");
sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 1,
ceremony: false,
});
// `0x5ecb70`'s tail `0x5ecd78`–`0x5ecd95`: the melee sheath snap
// (`0x611cf0(1,1,1)`) then the local auto-repeat cancel (`0x6ea080` at
// `0x5ecd8c`) — you can't start swinging and keep auto-shooting.
crate::creature_anim::cancel_auto_repeat_local(
Some(e),
// No stop is ever in flight on this route: the gate above IS the ref's
// `6e51d2`, so we only get here with the lock clear.
crate::creature_anim::start_attack_local(
e,
guid,
engaged,
false,
auto_repeat,
sheath,
ecs,
commands,
);
let _ = commands.0.send(ClientCommand::AttackSwing { guid });
}
}
}
@@ -881,13 +878,15 @@ mod tests {
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CastSpell { spell_id, .. }) if spell_id == RAPTOR_STRIKE)
);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CancelAutoRepeat)),
"the attack-start tail's `0x6ea080`"
);
// The reference's order, which the hand-rolled copy this seam replaced had backwards:
// the swing send is `0x5eccfd`, the auto-repeat cancel is `0x5ecd8c` in the tail below it.
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::AttackSwing { guid }) if guid == MOB),
"and then the swing itself"
"the swing goes out first"
);
assert!(
matches!(rx.try_recv(), Ok(ClientCommand::CancelAutoRepeat)),
"then the attack-start tail's `0x6ea080`"
);
assert_eq!(
world.resource::<AutoRepeatActive>().0,
+39 -31
View File
@@ -108,22 +108,25 @@ pub(super) fn attack_target_binding(
}
match targeting.selection.guid {
Some(guid) => {
debug!("bindings: ATTACKTARGET swing at {guid:#x}");
if let Ok((e, _)) = ladder.self_player.single() {
ladder.sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 1,
ceremony: false,
});
}
let self_e = ladder.self_player.single().ok().map(|(e, _)| e);
crate::creature_anim::cancel_auto_repeat_local(
self_e,
let Ok((e, engaged)) = ladder.self_player.single() else {
return;
};
debug!(
"bindings: ATTACKTARGET {} at {guid:#x}",
if engaged { "toggled off" } else { "swing" }
);
// The same `0x6131a0` this binding's doc says it shares with the action button — so
// it takes the same seam, toggle and all, instead of a second copy that drifts.
crate::creature_anim::toggle_attack_local(
e,
guid,
engaged,
&mut ladder.queued_melee,
&mut ladder.auto_repeat,
&mut ladder.sheath,
&mut ladder.ecs,
&ladder.commands,
);
let _ = ladder.commands.0.send(ClientCommand::AttackSwing { guid });
}
None => {
debug!("bindings: ATTACKTARGET with no target — acquiring nearest");
@@ -168,29 +171,34 @@ pub(super) fn drain_action_uses(
}
match selection.guid {
Some(guid) => {
debug!("ui_action: attack swing at {guid:#x}");
// Auto-draw: initiating melee requests melee sheath state through the
// anim layer's ONE setter (decision 0080) — a SNAP, no ceremony, no
// sound: the attack path passes `(newState=1, bInstant=1, bFireEvent=1)`
// at `0x5ecd80` (wow-re `sheath-policy.md`). The setter's idempotency
// is the client's own "no-op if already melee".
if let Ok((e, _)) = ladder.self_player.single() {
ladder.sheath.write(crate::creature_anim::SheathRequest {
entity: e,
state: 1,
ceremony: false,
});
}
// Melee attack-start cancels a running auto-repeat UNCONDITIONALLY —
// the client's `0x5ecd8c` (wow-re `nocked-ammo-cancel.md` §Q-B-5).
let self_e = ladder.self_player.single().ok().map(|(e, _)| e);
crate::creature_anim::cancel_auto_repeat_local(
self_e,
// **The Attack button is a TOGGLE** — `0x6131a0`, which the base Attack
// pseudo-spell reaches through `TryCast`'s effect-0x4e short-circuit
// (`0x6e4c7a`), forks on `0x60ecb0`: already attacking →
// `0x6131d9 call 0x5ecac0` **StopAttack**, else `0x6131ee call 0x5ecb70`
// **StartAttack** (wow-re `melee-autorepeat-exclusion.md` §5f).
//
// Both halves were wrong here. There was no toggle-off at all, and the
// press cancelled a running auto-repeat *unconditionally* — but only the
// START arm reaches `0x5ecd8c`, so in the reference toggling melee OFF
// leaves Auto Shot running. The seams carry the sheath snap and the cancel
// now, so neither is spelled out twice.
let Ok((e, engaged)) = ladder.self_player.single() else {
continue;
};
debug!(
"ui_action: attack {} at {guid:#x}",
if engaged { "toggled off" } else { "swing" }
);
crate::creature_anim::toggle_attack_local(
e,
guid,
engaged,
&mut ladder.queued_melee,
&mut ladder.auto_repeat,
&mut ladder.sheath,
&mut ladder.ecs,
&ladder.commands,
);
let _ = ladder.commands.0.send(ClientCommand::AttackSwing { guid });
}
// No target: the client's attack resolver runs the nearest-enemy core and
// swings at the winner (`0x612df0` @ `6130b5`) — `target::scan` answers.
+209 -6
View File
@@ -49,8 +49,10 @@ use benilla_protocol::messages::{UnitAuraSlot, AURA_FLAG_CANCELABLE, UNIT_AURA_P
use benilla_ui::script::{AuraState, ScriptValue, TrackingState, UiScript};
use crate::char_select::ClientState;
use crate::net::{ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::target::Selection;
use crate::net::{
ClientCommand, Guid, GuidIndex, NetCommands, ObjectStore, Reputations, SelfPlayer,
};
use crate::target::{can_assist, Factions, Selection};
use crate::ui_action::Spells;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
@@ -298,10 +300,21 @@ fn projection_of(list: &[AuraState]) -> Vec<AuraProjection> {
/// only thing any of them can show is what `UNIT_FIELD_AURA` holds. Filtering here rather than in
/// the Lua binding keeps `UnitBuff(token, i)` returning the i-th *shown* aura, matching the
/// reference's own indices.
fn other_unit_auras(store: &ObjectStore, catalog: Option<&SpellCatalog>) -> Vec<AuraState> {
///
/// `buffs_visible` is the **unit-level** gate (decision 1035): `UnitBuff 0x519500` decides, once
/// per unit and *before* it walks a single slot, whether this unit's positive half is enumerable at
/// all — see [`buffs_visible_on`]. False drops the helpful half entirely, which is exactly what the
/// reference does (a single nil for every index), so `UnitBuff(token, i)` goes nil while
/// `UnitDebuff(token, i)` is untouched: `UnitDebuff 0x5198f0` has no such gate.
fn other_unit_auras(
store: &ObjectStore,
catalog: Option<&SpellCatalog>,
buffs_visible: bool,
) -> Vec<AuraState> {
store
.0
.unit_auras()
.filter(|a| buffs_visible || a.slot >= UNIT_AURA_POSITIVE_SLOTS)
.filter(|a| shown_in_aura_ui(catalog, a.spell_id))
.map(|a| {
let display = catalog.and_then(|cat| cat.get(a.spell_id));
@@ -325,6 +338,50 @@ fn other_unit_auras(store: &ObjectStore, catalog: Option<&SpellCatalog>) -> Vec<
.collect()
}
/// **`UnitBuff 0x519500`'s unit-level gate** — may this unit's *buffs* be enumerated at all?
/// (Decision 1035; byte-derived in wow-re `ui/scratch/aura-display-pipeline.md` §9b.)
///
/// This sits **before** the per-slot walk and outranks it: when it fails, `UnitBuff` pushes a
/// single nil for **every** index without examining one slot, so the unit shows *no buffs*
/// whatever its aura array holds. `UnitDebuff 0x5198f0` has no counterpart — debuffs are always
/// enumerable, which is why a hostile mob shows its debuffs and nothing else.
///
/// ```text
/// (unit.UNIT_FIELD_FLAGS & UNIT_FLAG_AURAS_VISIBLE) // 0x08000000
/// || (CanAssist(activePlayer, unit) && activePlayer.UNIT_FIELD_CHARMEDBY == 0)
/// ```
///
/// The first clause is **Detect Magic** (spell 2855 → `SPELL_AURA_AURAS_VISIBLE`, vmangos
/// `UnitDefines.h:518` *"Detect Magic. Added by SPELL_AURA_AURAS_VISIBLE"*). That spell exists
/// precisely *because* the second clause denies a hostile unit's buffs — which is the independent
/// gameplay corroboration that this gate is real and not an artifact of one screenshot.
///
/// **It is also why GM mode changes the answer**, and why the same mob read two ways for a while:
/// vmangos ORs the bit into the outgoing `UNIT_FIELD_FLAGS` *for GameMasters only* (`Object.cpp`
/// 765-766, *"Gamemasters should be always able to select units and view auras"*). So the server
/// already tailors this per viewer and we need know nothing about GM state — reading the flag off
/// the descriptor as received is both simpler and exactly right in both regimes.
fn buffs_visible_on(
store: &ObjectStore,
self_store: Option<&ObjectStore>,
factions: Option<&Factions>,
reputations: &Reputations,
owner_store: impl FnOnce(u64) -> Option<ObjectStore>,
) -> bool {
if store.0.unit_flags() & UNIT_FLAG_AURAS_VISIBLE != 0 {
return true;
}
// `activePlayer.UNIT_FIELD_CHARMEDBY == 0` — while something else is driving you, you assist
// nobody (`0x5195f1`-`0x5195f7`).
if self_store.is_some_and(|s| s.0.unit_charmed_by().is_some()) {
return false;
}
can_assist(Some(store), factions, reputations, self_store, owner_store)
}
/// `UNIT_FLAG_AURAS_VISIBLE` — see [`buffs_visible_on`].
const UNIT_FLAG_AURAS_VISIBLE: u32 = 0x0800_0000;
/// The reference's aura display filter (decisions 0268 + 0417): an aura is shown iff its spell is
/// *not* flagged never-display (`SPELL_ATTR_DO_NOT_DISPLAY` / `SPELL_ATTR_EX_NO_AURA_ICON`, via
/// `SpellDisplay::hidden_from_aura_bar`) **and** is not a tracking spell
@@ -335,7 +392,16 @@ fn other_unit_auras(store: &ObjectStore, catalog: Option<&SpellCatalog>) -> Vec<
/// display — the player's own bar and any other unit's rows alike (the director watched the
/// reference hide a target's Battle Stance; 0417 corrects 0268's player-only scope note and
/// wow-re §9). A spell the catalog can't resolve stays visible — fail-open, like every other
/// catalog miss in the feed (and like the reference's own no-SpellRec path, which inserts).
/// catalog miss in the feed.
///
/// **That fail-open is ours, not the reference's, on this path** — the parenthetical that used to
/// justify it here ("like the reference's own no-SpellRec path, which inserts") was true of the
/// *player cache* (§3) and is FALSE of the non-player walk: `UnitBuff`/`UnitDebuff` **skip** a slot
/// whose id has no `Spell.dbc` row (`id > [0xc0d78c]` or a null row; wow-re §9c, decision 1035).
/// Kept as fail-open deliberately: every id on the wire is a real spell, so the two behaviours are
/// indistinguishable in practice, and failing *closed* would let a catalog load hiccup silently
/// blank every aura on every frame. A knowing divergence, recorded — not a comment asserting
/// something untrue.
fn shown_in_aura_ui(catalog: Option<&SpellCatalog>, spell_id: u32) -> bool {
catalog
.and_then(|c| c.get(spell_id))
@@ -374,6 +440,11 @@ fn feed_auras(
// The pet half (decision 0990): the bar owns the pet's identity, the index resolves it.
pet: Res<crate::ui_pet::PetBar>,
index: Res<GuidIndex>,
// The `UnitBuff` unit-level gate's inputs ([`buffs_visible_on`]) — the same reaction resolve
// the selection ring and the name plates run, so one law answers "is this unit friendly to me"
// everywhere.
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
// Read-only over the stamps: the feed joins them, the net apply path writes them, and only the
// session end drops them (decision 0900). Nothing the feed sees may invalidate one.
durations: Res<AuraDurations>,
@@ -530,7 +601,25 @@ fn feed_auras(
if guid == self_guid.0 {
return Some(list.clone());
}
Some(other_unit_auras(stores.get(e).ok()?, catalog))
// NOT named `store` — that is the SELF player's, bound above and needed here as the
// gate's second argument.
let target_store = stores.get(e).ok()?;
// The gate is resolved per unit, once, exactly where the reference resolves it —
// before any slot is read (decision 1035).
let buffs = buffs_visible_on(
target_store,
Some(store),
factions.as_deref(),
&reputations,
|owner| {
index
.0
.get(&owner)
.and_then(|&e| stores.get(e).ok())
.cloned()
},
);
Some(other_unit_auras(target_store, catalog, buffs))
});
// The pet's list — the pet frame's four debuff buttons (decision 0990). Same other-unit law as
@@ -543,7 +632,13 @@ fn feed_auras(
.then(|| index.0.get(&pet_guid))
.flatten()
.and_then(|&e| stores.get(e).ok())
.map(|store| other_unit_auras(store, catalog));
// **The pet keeps the ungated read, deliberately** (decision 1035). A pet is
// PLAYER_CONTROLLED, which sends `CanAssist` down an arm (`0x60673e`-`0x60679f`, keyed on
// an `[obj+0xe68]` record) wow-re did NOT name — so the gate is not known here, and
// guessing it is the one mistake that could blank a working pet frame. `true` is the
// status quo; the pet frame draws only debuffs today anyway (0990), so nothing observable
// rides on it until that arm is derived.
.map(|store| other_unit_auras(store, catalog, true));
let target_cur = selection
.guid
@@ -685,6 +780,10 @@ mod tests {
// the `"pet"` token stays cleared and the feed's pet leg is inert.
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<GuidIndex>()
// The `UnitBuff` gate's reaction inputs (decision 1035). `Factions` is an
// `Option<Res<..>>` (it needs the DBC catalog), but `Reputations` is not — the feed
// reads it unconditionally, so a bare app must carry it or the system fails validation.
.init_resource::<Reputations>()
.insert_resource(NetCommands(tx))
.add_plugins(UiAuraPlugin);
app
@@ -971,4 +1070,108 @@ mod tests {
"expiry rebased onto the script clock, got {expiry}"
);
}
// == Decision 1035 — `UnitBuff 0x519500`'s unit-level gate ==
//
// The B216 case: a stealthed Ridge Stalker carries TWO auras that both resolve to
// `Ability_Stealth` — 5916 "Shadowstalker Stealth" in a POSITIVE slot (creature_addon) and
// 22766 "Sneak" in a NEGATIVE slot (EventAI; vmangos files it harmful because its
// MOD_DECREASE_SPEED effect is negative). We drew both. The reference draws only the debuff,
// because `UnitBuff` never enumerates a hostile creature's positive half at all.
/// `UNIT_FIELD_FLAGS` index — the fixture writes it directly (`from_pairs` is the public
/// fixture constructor; live code only ever decodes the wire).
const F_FLAGS: u16 = 46;
/// `UNIT_FIELD_CHARMEDBY`'s low dword — a guid pair, so the high half is the next index.
const F_CHARMEDBY: u16 = 10;
/// The B216 mob's two slots: 5916 helpful, 22766 harmful.
fn ridge_stalker_slots(flags: u32) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(F_FLAGS, flags),
(47, 5916), // UNIT_FIELD_AURA slot 0 — the positive half
(47 + 32, 22766), // slot 32 — the negative half
(95, 0x0000_0008), // AURAFLAGS slot 0 nibble 0x8 (eff0)
(95 + 4, 0x0000_0008), // slot 32 nibble 0x8
]))
}
/// **The gate's consequence, both ways.** With buffs denied, `other_unit_auras` returns the
/// harmful half ONLY — which is what makes `UnitBuff("target", i)` nil for every i while
/// `UnitDebuff` is untouched, exactly as the reference behaves (a single nil for every buff
/// index, no slot examined). With buffs allowed, both come back.
#[test]
fn the_unit_gate_drops_the_whole_helpful_half_and_never_the_harmful_one() {
let store = ridge_stalker_slots(0);
let denied = other_unit_auras(&store, None, false);
assert_eq!(
denied.iter().map(|a| a.spell_id).collect::<Vec<_>>(),
[22766],
"a hostile creature shows its debuffs and NOT its buffs — the B216 divergence"
);
assert!(denied.iter().all(|a| !a.helpful));
let allowed = other_unit_auras(&store, None, true);
assert_eq!(
allowed.iter().map(|a| a.spell_id).collect::<Vec<_>>(),
[5916, 22766],
"with the gate open both halves enumerate, ascending slot"
);
}
/// **Detect Magic / GM mode short-circuits the gate.** `UNIT_FLAG_AURAS_VISIBLE` is clause
/// one, tested before any reaction work — and it is why the same mob reads two ways: vmangos
/// ORs the bit in for GameMasters only (`Object.cpp:765-766`), so a GM sees the buffs and an
/// ordinary player does not. Nothing here consults GM state; the flag arrives on the wire.
#[test]
fn detect_magic_or_gm_mode_opens_the_gate_on_the_flag_alone() {
let reputations = Reputations::default();
// No factions catalog → `ring_reaction` falls through to NEUTRAL (3), which FAILS the
// `>= 4` bar. So the flag is the only thing that can open the gate in this fixture.
let hostile = ridge_stalker_slots(0);
assert!(
!buffs_visible_on(&hostile, None, None, &reputations, |_| None),
"neutral reaction, not player-controlled, no PvP flag → no buffs"
);
let detected = ridge_stalker_slots(UNIT_FLAG_AURAS_VISIBLE);
assert!(
buffs_visible_on(&detected, None, None, &reputations, |_| None),
"UNIT_FLAG_AURAS_VISIBLE alone opens it, whatever the reaction says"
);
}
/// **`NOT_SELECTABLE` and the charm clause.** Both are outside the reaction ladder: the first
/// is `CanAssist`'s own first test, the second is `UnitBuff`'s `activePlayer.CHARMEDBY == 0`
/// (`0x5195f1`-`0x5195f7`) — while something else drives you, you assist nobody.
#[test]
fn not_selectable_and_being_charmed_each_close_the_gate_on_their_own() {
let reputations = Reputations::default();
// AURAS_VISIBLE would open it — but it is clause one and NOT_SELECTABLE lives inside
// CanAssist, so the flag still wins. Pin that ordering, it is the reference's.
let both = ridge_stalker_slots(UNIT_FLAG_AURAS_VISIBLE | (1 << 25));
assert!(
buffs_visible_on(&both, None, None, &reputations, |_| None),
"AURAS_VISIBLE is tested FIRST and short-circuits CanAssist entirely"
);
// Player-controlled + friendly would otherwise pass the permissive arm; being charmed
// ourselves closes it before CanAssist runs at all.
let friendly_pc = ridge_stalker_slots(0x8);
let charmed_self = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[
(F_CHARMEDBY, 0x1234),
(F_CHARMEDBY + 1, 0),
]));
assert!(
!buffs_visible_on(
&friendly_pc,
Some(&charmed_self),
None,
&reputations,
|_| None
),
"activePlayer.CHARMEDBY != 0 → no buffs on anyone"
);
}
}
+263 -38
View File
@@ -188,15 +188,13 @@ impl PendingCast {
/// **an auto-repeat press** (`0x6e5976`), target change/death, mount, interact — never movement.
/// That chain is [`crate::creature_anim::stop_attack_local`], and the auto-repeat arm inside
/// [`crate::ui_action::cast_send`]'s commit runs it whole: starting Auto Shot un-queues the
/// strike, which is what darkens its checked ring. **The two target-side StopAttack senders do
/// not route through it yet** — `target::scan`'s `commit` (the engaged-switch law) and
/// `target::click`'s `clear` still emit a bare `CMSG_ATTACKSTOP`, so a target switch or a
/// click-off leaves the queue standing where the reference cancels it. The same two sites owe a
/// second edge the 2026-08-06 §5 pinned: a mid-melee target switch reaches `Attack 0x5ecb70`
/// (`SetSelection 0x493540` @ `0x4938c8`, gated on `0x60ecb0` already true) and therefore runs
/// its `0x5ecd8c` **auto-repeat cancel** — refuting `nocked-ammo-cancel.md`'s direct-callers-only
/// census. Threading the seam through those eight callers is the named follow-up; the wire still
/// resolves the queue either way.
/// strike, which is what darkens its checked ring. Every attack edge reaches that seam since
/// 1044 — the Attack toggle, a target switch, a click-off, the ring's death teardown.
///
/// **Escape is the other route, and it is not that chain at all** (1049). In the reference a
/// queued strike simply *is* the inflight spell, so `Script::SpellStopCasting 0x6e6e80`'s plain
/// `IsCasting 0x6e3d30` branch cancels it like any cast — no melee-specific code anywhere in it.
/// [`Inflight`] is where our two slots are re-joined for that reader.
#[derive(Resource, Default)]
pub(crate) struct QueuedMeleeSpell(Option<u32>);
@@ -221,6 +219,57 @@ impl QueuedMeleeSpell {
}
}
/// What the reference's single inflight spell id (`0xceca88`) holds — as our **two** slots see it.
///
/// The client has one slot, with one save beneath it for the nesting. An ordinary cast and a
/// queued on-next-swing strike both live in `0xceca88`, which is exactly why `IsCasting 0x6e3d30`
/// — a bare `ceca88 != 0` — answers true for either, and why `AbortCast 0x6e4940` cancels
/// whichever is there without ever asking which kind it was. [`PendingCast`] and
/// [`QueuedMeleeSpell`] split that one slot in two (its doc says why), which leaves every reader
/// of *"what is in flight"* to re-join them — and the one reader that forgot was the Esc ladder,
/// which is the bug decision 1049 fixes. So the join gets a name, once, here.
///
/// **The order is the reference's nesting, not a preference.** An ordinary cast started over a
/// queued strike is allowed through `TryCast`'s already-casting refusal precisely because the
/// inflight rec has `Attributes & 0x404` (`0x6e4d97`/`0x6e4dbe`), and it *pushes*
/// (`PushPopNestedCast 0x6e4ad0` @ `0x6e5026`): the cast becomes `0xceca88`, the strike the save
/// in `0xcecaa8`. `AbortCast` cancels the cast and pops the strike back up — so the cast dies on
/// the first Esc and the strike on the second. Reading `PendingCast` first reproduces that
/// without our needing the push/pop pair.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Inflight {
/// An ordinary cast — ours to send-guard, to paint a bar for, and to reap.
Cast(u32),
/// A queued on-next-swing strike waiting for the swing. No bar ever opened for it and it
/// holds no [`Casting`], so cancelling it is the wire message and the slot, nothing else.
Strike(u32),
}
impl Inflight {
/// The spell id in the slot — what a cancel names on the wire, either way.
pub(crate) fn spell_id(self) -> u32 {
match self {
Self::Cast(id) | Self::Strike(id) => id,
}
}
}
/// Resolve the reference's inflight slot from our two — see [`Inflight`] for the order and the
/// why. `started` is the self [`Casting`] spell id, which covers a cast that never armed the send
/// guard (an item use) exactly as the ref's one id covers it.
pub(crate) fn inflight(
pending: &PendingCast,
queued_melee: &QueuedMeleeSpell,
started: Option<u32>,
now: Instant,
) -> Option<Inflight> {
pending
.current(now)
.or(started)
.map(Inflight::Cast)
.or_else(|| queued_melee.current().map(Inflight::Strike))
}
/// Slack past the server-named channel end, so a lost/late `MSG_CHANNEL_UPDATE(0)` can't pin the
/// checked ring on forever — the natural end clears through the deadline instead.
const CHANNEL_SLACK: Duration = Duration::from_secs(2);
@@ -335,6 +384,7 @@ fn local_self_cancel(
script: Option<NonSendMut<UiScript>>,
mut moved: ResMut<LocalMoveStart>,
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>,
channel: Res<ActiveChannel>,
mut auto_repeat: ResMut<crate::ui_action::AutoRepeatActive>,
mut feed: ResMut<CastBarFeed>,
@@ -378,12 +428,16 @@ fn local_self_cancel(
.and_then(|s| s.catalog.get(spell_id))
.is_none_or(pick)
};
// The cast half. The inflight id is the ref's `0xceca88` union: our own send guard
// (pre-START, armed by the spell-cast send path only) OR the started self cast (`Casting`,
// set at `SMSG_SPELL_START` for ANY cast — an item use's cast, which never arms the guard,
// is still move/Esc-cancelable this way, exactly as the ref's one inflight id covers it).
// The cast half — the ref's ONE inflight slot ([`Inflight`]), which is an ordinary cast or a
// queued on-next-swing strike. `AbortCast` doesn't distinguish, so neither does the send or
// the movement gate; only the local teardown differs, because only a cast ever had a bar.
let started = self_e.and_then(|e| casting.get(e).ok()).map(|c| c.spell_id);
if let Some(spell_id) = pending.current(now).or(started) {
if let Some(slot) = inflight(&pending, &queued_melee, started, now) {
let spell_id = slot.spell_id();
// Esc bypasses the flags gate; movement consults the slot's own `InterruptFlags`, which
// is what spares a queued strike (Heroic Strike 78, Cleave 845, Raptor Strike 2973 all
// ship `InterruptFlags = 0x0` — verified against Spell.dbc, vs Fireball's `0xf`). So
// charging in with a strike queued keeps it queued, and only Esc takes it.
if esc
|| flags_open(
|d| d.interrupt_flags & SPELL_INTERRUPT_MOVEMENT != 0,
@@ -392,29 +446,46 @@ fn local_self_cancel(
{
if *crate::net::CAST_TRACE {
info!(
"cast-trace: LOCAL self-cancel — spell {spell_id} ({}), CMSG_CANCEL_CAST",
"cast-trace: LOCAL self-cancel — {slot:?} ({}), CMSG_CANCEL_CAST",
if esc { "esc" } else { "moved" }
);
}
let _ = net.0.send(ClientCommand::CancelCast { spell_id });
pending.clear_if(spell_id);
// The ref's two-step at RTT→0: STOP arms the bar's flash overlay, the echo's
// INTERRUPTED repaints it red and starts the hold — so after the hold the flash
// BURSTS white over the red bar, then the fade runs. One edge alone misses the
// burst (which the enemy-interrupt path — no prior STOP — correctly lacks).
feed.0.push(CastBarEdge::Stop);
feed.0.push(CastBarEdge::Interrupted);
// The reap — `spell_failed_other`'s self arm, run early so the echo finds it done.
if let Some(e) = self_e {
if casting.get(e).is_ok_and(|c| c.spell_id == spell_id) {
ecs.entity(e).remove::<Casting>();
match slot {
Inflight::Cast(_) => {
pending.clear_if(spell_id);
// The ref's two-step at RTT→0: STOP arms the bar's flash overlay, the echo's
// INTERRUPTED repaints it red and starts the hold — so after the hold the
// flash BURSTS white over the red bar, then the fade runs. One edge alone
// misses the burst (which the enemy-interrupt path — no prior STOP —
// correctly lacks).
feed.0.push(CastBarEdge::Stop);
feed.0.push(CastBarEdge::Interrupted);
// The reap — `spell_failed_other`'s self arm, run early so the echo finds it
// done.
if let Some(e) = self_e {
if casting.get(e).is_ok_and(|c| c.spell_id == spell_id) {
ecs.entity(e).remove::<Casting>();
}
cast_events.write(CastEvent {
entity: e,
spell_id,
kind: CastEventKind::Fail,
seq: play_seq.next(),
});
}
}
Inflight::Strike(_) => {
// Clearing the slot IS the un-toggle: the checked ring reads it
// (`ui_action::state`), so the button darkens this frame instead of waiting
// for vmangos's FAILED_OTHER + CAST_RESULT echo (which then finds the slot
// already open and is idempotent). `AbortCast` fires its `0x152`
// SPELLCAST_STOP here too — inert on a bar that never opened
// (`CastingBar.xml` guards every arm on `IsShown`), so it is fired for
// fidelity, not effect. No red INTERRUPTED: there is nothing to repaint.
queued_melee.clear_if(spell_id);
feed.0.push(CastBarEdge::Stop);
}
cast_events.write(CastEvent {
entity: e,
spell_id,
kind: CastEventKind::Fail,
seq: play_seq.next(),
});
}
}
}
@@ -443,16 +514,22 @@ fn local_self_cancel(
/// argless STOP / FAILED / INTERRUPTED / CHANNEL_STOP. A channel update of `0` fires
/// CHANNEL_STOP — the server ends both the natural finish and the interrupt that way.
///
/// Also pushes the frame's stoppable mirror (running auto-repeat OR cast in flight — NOT a
/// channel, which `SpellStopCasting()` answers nil for: wow-re `esc-stopcasting.md`) into the
/// Also pushes the frame's stoppable mirror (running auto-repeat OR the [`Inflight`] slot — NOT
/// a channel, which `SpellStopCasting()` answers nil for: wow-re `esc-stopcasting.md`) into the
/// VM **after** [`local_self_cancel`] resolved, so the ESC chain's `SpellStopCasting()` reads
/// post-cancel truth when `UiInput` runs later this frame.
///
/// The mirror is what decides whether the press is EATEN, and that is half of decision 1049: a
/// queued strike makes `IsCasting` true in the reference, so `SpellStopCasting()` returns `1` and
/// `ToggleGameMenu`'s ladder never reaches `ClearTarget()` (`UIParent.lua` l.1489 vs l.1492).
/// Reading only the ordinary-cast half here is what dropped the target on the first Esc.
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
fn feed_cast_bar(
script: Option<NonSendMut<UiScript>>,
mut feed: ResMut<CastBarFeed>,
spells: Option<Res<Spells>>,
pending: Res<PendingCast>,
queued_melee: Res<QueuedMeleeSpell>,
auto_repeat: Res<crate::ui_action::AutoRepeatActive>,
self_guid: Res<SelfGuid>,
index: Res<GuidIndex>,
@@ -462,14 +539,18 @@ fn feed_cast_bar(
return;
};
let now = Instant::now();
// The same pending ∪ started union as the cancel's (an item use's cast must answer
// `SpellStopCasting()` truthily too), plus the auto-repeat key (`0xceac30`) it reads first.
// The same slot the cancel resolves, so the mirror and the drain can never disagree about
// whether a press had something to spend itself on — plus the auto-repeat key (`0xceac30`)
// the ref reads first.
let started = self_guid
.0
.as_ref()
.and_then(|g| index.0.get(g))
.is_some_and(|&e| casting.get(e).is_ok());
script.set_casting(auto_repeat.0.is_some() || pending.current(now).is_some() || started);
.and_then(|&e| casting.get(e).ok())
.map(|c| c.spell_id);
script.set_casting(
auto_repeat.0.is_some() || inflight(&pending, &queued_melee, started, now).is_some(),
);
let name = |id: u32| -> String {
spells
.as_ref()
@@ -660,6 +741,7 @@ mod tests {
use std::collections::HashMap;
const ARCANE_MISSILES: u32 = 5143;
const HEROIC_STRIKE: u32 = 78;
fn harness(
displays: HashMap<u32, SpellDisplay>,
@@ -669,6 +751,7 @@ mod tests {
app.add_message::<CastEvent>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<ActiveChannel>()
.init_resource::<crate::ui_action::AutoRepeatActive>()
.init_resource::<LocalMoveStart>()
@@ -967,6 +1050,148 @@ mod tests {
);
}
/// **The director's report, decision 1049: Esc with Heroic Strike queued un-toggles the
/// STRIKE and keeps the target.** In the reference a queued on-next-swing spell simply
/// *is* the inflight spell (`0xceca88`), so `SpellStopCasting 0x6e6e80` takes its plain
/// `IsCasting 0x6e3d30` branch — `AbortCast` → `CMSG_CANCEL_CAST` — and returns `1`,
/// which EATS the press so `ToggleGameMenu`'s ladder never reaches `ClearTarget()`. We
/// split that one slot in two and the Esc reader saw only the cast half, so the binding
/// answered nil: the strike stayed lit *and* the target dropped, both from one cause.
#[test]
fn esc_unqueues_a_strike_and_eats_the_press() {
let (mut app, rx) = harness(HashMap::from([(
HEROIC_STRIKE,
SpellDisplay {
attributes: 0x5_0014, // the shipped row: on-next-swing (`& 0x404`)
interrupt_flags: 0, // …and no movement bit, also shipped
..Default::default()
},
)]));
app.insert_non_send_resource(UiScript::new().unwrap());
app.world_mut()
.resource_mut::<QueuedMeleeSpell>()
.arm(HEROIC_STRIKE);
app.world_mut().run_system_once(feed_cast_bar).unwrap();
assert_eq!(
app.world()
.non_send_resource::<UiScript>()
.eval::<Option<i64>>("return SpellStopCasting()")
.unwrap(),
Some(1),
"the queued strike makes IsCasting true, so the binding EATS the press — which \
is what spares the target three rungs further down the ladder"
);
app.world_mut().run_system_once(local_self_cancel).unwrap();
assert!(
matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CancelCast {
spell_id: HEROIC_STRIKE
})
),
"AbortCast names the queued strike on the wire, exactly as it would a cast"
);
assert_eq!(
app.world().resource::<QueuedMeleeSpell>().current(),
None,
"the slot opens locally — the checked ring darkens this frame, not one RTT later"
);
assert!(
matches!(
app.world().resource::<CastBarFeed>().0[..],
[CastBarEdge::Stop]
),
"the ref's `0x152` STOP and nothing else: no red INTERRUPTED for a spell that \
never opened a bar"
);
}
/// …but MOVEMENT does not take it. The movement path consults the inflight spell's own
/// `InterruptFlags`, and Heroic Strike 78 / Cleave 845 / Raptor Strike 2973 all ship
/// `InterruptFlags = 0x0` (read off the shipped `Spell.dbc`; Fireball is `0xf`). So
/// charging in with a strike queued keeps it queued — Esc is the only local taker.
#[test]
fn a_move_edge_leaves_a_queued_strike_queued() {
let (mut app, rx) = harness(HashMap::from([(
HEROIC_STRIKE,
SpellDisplay {
interrupt_flags: 0,
..Default::default()
},
)]));
app.world_mut()
.resource_mut::<QueuedMeleeSpell>()
.arm(HEROIC_STRIKE);
run(&mut app, true);
assert!(rx.try_recv().is_err(), "running at the mob cancels nothing");
assert_eq!(
app.world().resource::<QueuedMeleeSpell>().current(),
Some(HEROIC_STRIKE)
);
}
/// The nesting order (`Inflight`'s doc): an ordinary cast started over a queued strike
/// pushes it down (`PushPopNestedCast 0x6e4ad0`), so `0xceca88` holds the CAST. One Esc
/// takes the cast; `AbortCast`'s pop restores the strike, and the next Esc takes that.
/// One press, one thing — the ladder's law all the way down.
#[test]
fn esc_takes_the_cast_first_and_the_strike_on_the_next_press() {
let (mut app, rx) = harness(HashMap::new());
app.insert_non_send_resource(UiScript::new().unwrap());
app.world_mut()
.resource_mut::<QueuedMeleeSpell>()
.arm(HEROIC_STRIKE);
app.world_mut()
.resource_mut::<PendingCast>()
.arm(FIREBALL, Instant::now());
mark_casting(&mut app, FIREBALL);
app.world_mut().run_system_once(feed_cast_bar).unwrap();
assert_eq!(
app.world()
.non_send_resource::<UiScript>()
.eval::<Option<i64>>("return SpellStopCasting()")
.unwrap(),
Some(1)
);
app.world_mut().run_system_once(local_self_cancel).unwrap();
assert!(
matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CancelCast { spell_id: FIREBALL })
),
"the nested cast is what sits in the inflight slot — it dies first"
);
assert!(rx.try_recv().is_err());
assert_eq!(
app.world().resource::<QueuedMeleeSpell>().current(),
Some(HEROIC_STRIKE),
"the strike is the SAVE underneath, untouched by the first press"
);
// Press 2 — the pop put the strike back in the slot.
app.world_mut().run_system_once(feed_cast_bar).unwrap();
assert_eq!(
app.world()
.non_send_resource::<UiScript>()
.eval::<Option<i64>>("return SpellStopCasting()")
.unwrap(),
Some(1),
"still stoppable — so this press is eaten too, and the target still survives"
);
app.world_mut().run_system_once(local_self_cancel).unwrap();
assert!(matches!(
rx.try_recv(),
Ok(crate::net::ClientCommand::CancelCast {
spell_id: HEROIC_STRIKE
})
));
assert_eq!(app.world().resource::<QueuedMeleeSpell>().current(), None);
}
/// The channel half is the SEND and nothing else — the verified asymmetry (0445): the
/// ref's `0x6e9b70` ships CMSG_CANCEL_CHANNELLING but fires no FrameScript event and
/// clears no state; the channel bar and the mirror both close on the server's
+2 -1
View File
@@ -681,6 +681,7 @@ fn drain_pet_actions(
spells: Option<Res<Spells>>,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
scan: crate::target::EnemyScan,
mut seam: crate::creature_anim::AttackSeam,
) {
let Some(mut script) = script else {
return;
@@ -747,7 +748,7 @@ fn drain_pet_actions(
|| match crate::target::attack_order_target(
&scan,
&mut selection,
&commands,
&mut seam,
&mut ui_errors,
) {
Some(guid) => {
+434
View File
@@ -0,0 +1,434 @@
//! The **pet spellbook** — the spellbook window's second tab (decision 1032).
//!
//! Its source has been on the wire and parsed since 0988 and nothing read it:
//! [`benilla_protocol::messages::PetSpells::spells`], the packed-word list `SMSG_PET_SPELLS`
//! carries after the ten bar slots. This module is the join that turns it into a book.
//!
//! **It is not the player book with a flag on it**, and the three differences are the reason this
//! is its own module rather than an arm of [`crate::ui_spellbook`]:
//!
//! - **A different add-gate.** `0x4b2f90` keeps a spell out only on `Attributes & 0x80`
//! (`DO_NOT_DISPLAY`) — `SpellDisplay::in_pet_book`. The player book's `IS_TRADESKILL` and
//! `castUI` legs are simply absent.
//! - **No tabs.** `GetNumSpellTabs`/`GetSpellTabInfo` never see this book;
//! `SpellBook_GetSpellID`'s pet arm is a bare `return id`, so a button's own 1..12 id *is* the
//! book id.
//! - **Two extra per-slot answers the player book has no analogue for** — the autocast pair, read
//! off the pet's **raw** words (bits 31/30) rather than the filtered book, and the packed word
//! itself, which is what `PickupSpell(id, "pet")` puts on the cursor.
//!
//! The **order** is shared, though: `0x4b2fd0(ecx = 0, edx = 1)` sorts the pet array with
//! `0x4b30c0`, the player book's own comparator (name, then the parsed rank number), so
//! [`crate::ui_spellbook::spell_sort_key`] serves both.
//!
//! ## Where each answer comes from
//!
//! | Lua | source |
//! |---|---|
//! | `GetSpellName` / `GetSpellTexture` / `IsSpellPassive` | `Spell.dbc` via [`Spells`] |
//! | `GetSpellCooldown(id, "pet")` | **bank 1** — [`PetBar::cooldowns`], the pet's own store. The reference reaches it with the same `0x6e2ea0(edx = isPet)` `GetPetActionCooldown` uses (`0x4b40dd`), so a spell on the bar and the same spell in the book read one timer, and they do here too. |
//! | `GetSpellAutocast` | the raw word's bits 31/30 |
//! | `IsCurrentCast` | the pet's own auras — `0x4b36f0`'s pet arm scans `[pet.fields + 0xa4]`, which is exactly the predicate the bar's [`crate::ui_pet`] already applies per slot |
//! | `HasPetSpells`'s token | `ChrClasses.dbc` field 4 (`benilla_formats::PetNameTokens`) |
//!
//! ## The two verbs
//!
//! Both are pet verbs, and neither is the player book's:
//!
//! - **`CastSpell(id, "pet")` → `CMSG_PET_ACTION`** with a **synthesized** type-1 word,
//! `0x0100_0000 | (spellId & 0xFFFF)` (`0x4b350a`-`0x4b3516`), aimed at the passed target or,
//! absent one, the current selection (`0x4b34af`). So the book can cast a pet spell that is not
//! on the bar at all — the word is built for the send, never looked up.
//! - **`ToggleSpellAutocast(id, "pet")` → `CMSG_PET_SPELL_AUTOCAST 0x2F3`**, naming the **spell**.
//! Its sender `0x4bccb0` does three things before the packet leaves, and all three are
//! reproduced in [`drain_pet_book`]: it flips bit 30 in the raw word in place, it **mirrors that
//! bit onto every bar slot carrying the same action** (`0x4bcd20`-`0x4bcd5a`, compared under
//! `& 0x3FFFFFFF`), and it fires `PET_BAR_UPDATE`. That mirror is why toggling autocast in the
//! book lights the sparkle on the bar button in the same frame.
use std::time::Instant;
use bevy::prelude::*;
use benilla_formats::{PetNameTokens, SpellDisplay};
use benilla_protocol::messages::PetActionEntry;
use benilla_ui::script::{PetBookState, SpellSlotView, UiScript};
use crate::assets::{AssetSet, LockRecover, WorldAssets};
use crate::net::{ClientCommand, GuidIndex, NetCommands, ObjectStore, SelfPlayer};
use crate::target::Selection;
use crate::ui_action::Spells;
use crate::ui_pet::PetBar;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
/// `ChrClasses.dbc`'s pet-name-token column — absent when the client data failed to load, in which
/// case [`benilla_ui`]'s own `"PET"` fallback stands (the reference's `0x4b44a6` arm).
#[derive(Resource)]
pub(crate) struct PetNameTokenTable(pub(crate) PetNameTokens);
pub(crate) struct UiPetBookPlugin;
impl Plugin for UiPetBookPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, load_pet_name_tokens.after(AssetSet::Open))
.add_systems(
Update,
(
// Rides the unit feed beside the player book's, and before the input pass so a
// tab flipped this frame already reads a populated book.
feed_pet_book
.in_set(UnitFeed)
.before(crate::ui_action::CooldownEvents)
.before(UiInput),
// After the input pass, and — like `ui_pet`'s own drain — writing back into
// `PetBar`, whose next feed carries the mirrored autocast bit.
drain_pet_book.after(UiInput),
),
);
}
}
fn load_pet_name_tokens(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_pet_name_tokens(&mut chain)
};
match loaded {
Ok(tokens) => commands.insert_resource(PetNameTokenTable(tokens)),
Err(e) => warn!(
"ui_pet_book: ChrClasses.dbc failed to load — every pet book tab reads the client's \
own \"PET\" fallback, so a warlock's says Pet rather than Demon: {e:#}"
),
}
}
/// What the feed last pushed, so `SPELLS_CHANGED` fires on a real change rather than every frame
/// (the [`crate::ui_spellbook`] memory pattern).
#[derive(Default)]
struct FeedMemory {
pushed: PetBookState,
}
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
fn feed_pet_book(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
spells: Option<Res<Spells>>,
tokens: Option<Res<PetNameTokenTable>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
clock: Res<crate::ui_script::UiClock>,
mut memory: Local<FeedMemory>,
) {
let Some(mut script) = script else {
return;
};
// Nothing to resolve a name/icon/passive from yet — try again once Spell.dbc lands. Unlike the
// bar (which can still draw its command tokens), a book with no catalog has no slots at all.
let Some(spells) = spells.as_deref() else {
return;
};
let now = Instant::now();
let (anchor, ui_now) = (clock.anchor, clock.ui_now);
let pet_store = index
.0
.get(&bar.spells.pet_guid)
.and_then(|&e| stores.get(e).ok());
let mut fresh = PetBookState {
// The token is the PLAYER's class, not the pet's — `0x4b4463` reads `player.fields + 0x79`.
// A warlock reading "Demon" while a hunter reads "Pet" is the whole of it.
token: self_q.single().ok().and_then(|s| {
let class = u32::from(s.0.unit_class()?);
tokens.as_deref().map(|t| t.0.token(class).to_string())
}),
slots: Vec::new(),
};
if bar.has_bar() {
for &entry in &bar.spells.spells {
let spell_id = entry.action();
let Some(d) = spells.catalog.get(spell_id) else {
// `0x4b2f90`'s first gate: no `Spell.dbc` record, no book slot.
continue;
};
if !d.in_pet_book() {
continue;
}
fresh
.slots
.push(slot_view(entry, d, &bar, pet_store, now, anchor, ui_now));
}
// `0x4b2fd0`'s re-sort, with the player book's own comparator.
fresh
.slots
.sort_by_key(|s| crate::ui_spellbook::spell_sort_key_of(&s.name, s.rank.as_deref()));
}
if fresh != memory.pushed {
debug!("ui_pet_book: fed {} pet spell(s)", fresh.slots.len());
let changed = book_changed(&fresh, &memory.pushed);
script.set_pet_book(fresh.clone());
memory.pushed = fresh;
// The reference fires ONE `SPELLS_CHANGED` for both books off the shared re-sort
// (`0x4b2fd0` tail-jumps `SignalEvent(0x104)`), which is also what re-reads the spell
// buttons. The cooldown/autocast half rides `PET_BAR_UPDATE` instead — `SpellButton_OnLoad`
// registers it precisely so the pet page can repaint without a book change
// (`SpellBookFrame.lua:214`, `l.227-231`) — and `ui_pet`'s own feed already fires that.
if changed {
script.fire_event("SPELLS_CHANGED", vec![]);
}
}
}
/// Did the book itself move, as opposed to a per-slot cooldown/autocast/ring bit? Only the former
/// is `SPELLS_CHANGED`; the latter reaches the buttons through `PET_BAR_UPDATE`.
fn book_changed(fresh: &PetBookState, old: &PetBookState) -> bool {
fresh.token != old.token
|| fresh.slots.len() != old.slots.len()
|| fresh.slots.iter().zip(&old.slots).any(|(a, b)| {
(a.spell_id, &a.name, &a.rank, &a.texture, a.passive)
!= (b.spell_id, &b.name, &b.rank, &b.texture, b.passive)
})
}
/// One book slot, fully resolved.
#[allow(clippy::too_many_arguments)]
fn slot_view(
entry: PetActionEntry,
d: &SpellDisplay,
bar: &PetBar,
pet_store: Option<&ObjectStore>,
now: Instant,
anchor: Instant,
ui_now: f64,
) -> SpellSlotView {
let spell_id = entry.action();
SpellSlotView {
spell_id,
name: d.name.clone(),
rank: d.rank.clone(),
texture: d.icon.clone(),
passive: d.passive,
// `IsCurrentCast`'s pet arm (`0x4b36f0`, and the delegate `0x4b3600` it shares a shape
// with): the ring lights while the spell's aura is on the PET. Same predicate the bar's
// ActiveIcon swap uses, so a lit book slot and a lit bar slot always agree.
current: pet_store
.is_some_and(|s| crate::ui_action::toggle::active_action_toggle(spell_id, d, s)),
// **Bank 1** — the pet's own store, the same one `GetPetActionCooldown` reads.
cooldown: bar
.cooldowns
.info(spell_id, 0, Some(d), now)
.ui_triple(anchor, ui_now),
// The autocast pair off the RAW word. `autocast_allowed` is additionally gated on the
// spell resolving in `Spell.dbc` (`0x4bdd65`'s own second test) — which it has, or we
// would not be here.
autocast: Some((entry.autocast_allowed(), entry.autocast_on())),
packed: entry.packed,
}
}
/// Drain the pet book's two intents.
fn drain_pet_book(
script: Option<NonSendMut<UiScript>>,
mut bar: ResMut<PetBar>,
selection: Res<Selection>,
commands: Res<NetCommands>,
spells: Option<Res<Spells>>,
pet: crate::ui_pet::PetUnit,
) {
let Some(mut script) = script else {
return;
};
let casts = script.take_pet_spell_casts();
let autocasts = script.take_pet_spell_autocasts();
if casts.is_empty() && autocasts.is_empty() {
return;
}
let pet_guid = bar.spells.pet_guid;
if pet_guid == 0 {
debug!("ui_pet_book: dropping queued pet book intents — the bar is gone");
return;
}
let target_guid = selection.guid.unwrap_or(0);
let pet_store = pet.store(pet_guid);
for spell_id in casts {
// **The press-again-to-cancel arm comes first** (`0x4b33af`-`0x4b3461`), exactly as it does
// on the bar: a click on a pet spell whose aura is already running on the pet sends
// `CMSG_PET_CANCEL_AURA` (0x26B) **and returns** — the ordinary order never leaves. Same
// predicate, same order of tests, one implementation
// (`crate::ui_action::toggle::active_action_toggle`).
let display = spells.as_ref().and_then(|s| s.catalog.get(spell_id));
if let (Some(d), Some(store)) = (display, pet_store) {
if crate::ui_action::toggle::active_action_toggle(spell_id, d, store) {
debug!("ui_pet_book: cast {spell_id} cancels the pet's own aura");
let _ = commands
.0
.send(ClientCommand::PetCancelAura { pet_guid, spell_id });
continue;
}
}
// The synthesized word, `0x4b350a`-`0x4b3516`: `(spellId & 0xFFFF) | 0x01000000`. Type 1
// is the client's spell branch and the autocast bits are left clear — this word describes
// the ORDER, not the slot, so nothing is read back out of it.
let packed = 0x0100_0000 | (spell_id & 0xFFFF);
debug!("ui_pet_book: cast {spell_id} (target {target_guid:#x})");
let _ = commands.0.send(ClientCommand::PetAction {
pet_guid,
packed,
target_guid,
});
}
for spell_id in autocasts {
let Some(on) = flip_autocast(&mut bar.spells, spell_id) else {
continue;
};
// `0x4bcdc6`'s `SignalEvent(0x161)` — the bar repainted before the packet left, which is
// the same "write locally, never wait for a reply" law the rest of this bar runs on
// (`ui_pet`'s module doc). Bumping the signal count is how that repaint reaches the feed.
bar.bar_signals = bar.bar_signals.wrapping_add(1);
debug!("ui_pet_book: autocast {spell_id} -> {on}");
let _ = commands.0.send(ClientCommand::PetSpellAutocast {
pet_guid,
spell_id,
enabled: on,
});
}
}
/// `0x4bccb0`'s local half, in its own order: find the spell's **raw** word, flip bit 30 in place,
/// and mirror the new bit onto every **bar** slot carrying the same action. Returns the new state,
/// or `None` when the reference would have aborted before sending anything.
///
/// The mirror is the load-bearing half and the easy one to miss (`0x4bcd20`-`0x4bcd5a`): the loop
/// walks all ten bar words, compares `(bar[i] ^ word) & 0x3FFFFFFF` — type and action, autocast
/// bits excluded — and copies bit 30 across on a match. Without it, flipping autocast in the book
/// leaves the bar button's sparkle stale until the next `SMSG_PET_SPELLS`.
///
/// The gate is `0x4bccf5`: **the word must be autocast-ALLOWED** (bit 31). A passive's is not, and
/// neither is a command token's — so neither can be toggled from anywhere.
fn flip_autocast(
spells: &mut benilla_protocol::messages::PetSpells,
spell_id: u32,
) -> Option<bool> {
let word = spells
.spells
.iter_mut()
.find(|w| w.action() == spell_id && w.autocast_allowed())?;
let on = !word.autocast_on();
*word = word.with_autocast(on);
let action = word.packed & 0x3FFF_FFFF;
for slot in &mut spells.bar {
if slot.packed & 0x3FFF_FFFF == action {
*slot = slot.with_autocast(on);
}
}
Some(on)
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_protocol::messages::{
PetSpells, PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE,
};
fn word(kind: u8, spell_id: u32) -> PetActionEntry {
PetActionEntry::from((u32::from(kind) << 24) | spell_id)
}
/// **The pet book's add-gate is not the player book's.** `0x4b2f90` tests exactly one bit —
/// `Attributes & 0x80` (DO_NOT_DISPLAY) — where the player book also refuses `IS_TRADESKILL`
/// (`0x20`) and `castUI != 0`. A spell with either of those two SHOWS in a pet book.
#[test]
fn the_pet_books_gate_is_do_not_display_alone() {
let shown = |attributes: u32, cast_ui: u32| SpellDisplay {
attributes,
cast_ui,
..Default::default()
};
assert!(shown(0, 0).in_pet_book());
assert!(!shown(0x80, 0).in_pet_book(), "DO_NOT_DISPLAY hides it");
// The two the PLAYER book additionally refuses — kept here, and this is the whole point.
assert!(
shown(0x20, 0).in_pet_book(),
"IS_TRADESKILL is not a pet-book gate"
);
assert!(!shown(0x20, 0).in_spellbook());
assert!(shown(0, 1).in_pet_book(), "castUI is not a pet-book gate");
assert!(!shown(0, 1).in_spellbook());
// A passive is shown in both — being passive was never a book gate.
assert!(shown(0x40, 0).in_pet_book() && shown(0x40, 0).in_spellbook());
}
/// The autocast toggle's local half (`0x4bccb0`): the flip lands on the raw **book** word AND
/// on every **bar** slot carrying the same action, compared under `& 0x3FFFFFFF`. Missing the
/// mirror leaves the bar button's sparkle stale until the next `SMSG_PET_SPELLS`.
#[test]
fn the_book_toggle_mirrors_onto_the_bar() {
const CLAW: u32 = 16827;
let mut spells = PetSpells {
pet_guid: 0x2A,
spells: vec![
word(PET_ACT_DISABLED, CLAW), // autocastable, currently OFF
word(PET_ACT_PASSIVE, 3025), // a passive: not autocastable
],
..Default::default()
};
// Claw sits in bar slot 4; slot 5 holds a DIFFERENT spell with the same low bits pattern
// is impossible, so use a plain second spell to prove the compare is by action.
spells.bar[4] = word(PET_ACT_DISABLED, CLAW);
spells.bar[5] = word(PET_ACT_DISABLED, 2649);
assert_eq!(flip_autocast(&mut spells, CLAW), Some(true));
assert!(spells.spells[0].autocast_on(), "the book word flipped");
assert!(spells.bar[4].autocast_on(), "…and so did the bar slot");
assert!(
!spells.bar[5].autocast_on(),
"a different action is untouched"
);
// Flipping back does the same in reverse.
assert_eq!(flip_autocast(&mut spells, CLAW), Some(false));
assert!(!spells.spells[0].autocast_on());
assert!(!spells.bar[4].autocast_on());
// `0x4bccf5`: a word that is not autocast-ALLOWED aborts before anything is written.
assert_eq!(
flip_autocast(&mut spells, 3025),
None,
"a passive cannot be toggled"
);
assert_eq!(
flip_autocast(&mut spells, 99),
None,
"nor can a spell the pet lacks"
);
}
/// A word already ON flips OFF and an `ACT_ENABLED` word starts ON — the two server states our
/// decode collapses onto one type (`0988`), which is exactly why the flip reads bit 30 and not
/// the type byte.
#[test]
fn the_flip_reads_bit_thirty_not_the_type_byte() {
const GROWL: u32 = 2649;
let mut spells = PetSpells {
spells: vec![word(PET_ACT_ENABLED, GROWL)],
..Default::default()
};
assert!(
spells.spells[0].autocast_on(),
"ACT_ENABLED means bit 30 is set"
);
assert_eq!(flip_autocast(&mut spells, GROWL), Some(false));
assert!(!spells.spells[0].autocast_on());
assert!(
spells.spells[0].autocast_allowed(),
"…and it stays ALLOWED — only bit 30 moves"
);
}
}
@@ -266,6 +266,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
passive: false,
current: false,
cooldown: None,
..Default::default()
}],
});
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
@@ -486,6 +486,23 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
.eval::<bool>(&format!("return {SF}ScrollBar:IsVisible()"))
.unwrap());
s.resolve();
// The page area's OWN scroll (B217) stays out of it: this page fills the body exactly, so the
// outer range is 0 and its bar never joins this one in the gutter the two share.
for _ in 0..4 {
s.tick(0.016);
s.resolve();
}
assert_eq!(
s.eval::<f32>("return OptionsFrameContainerScroll:GetVerticalScrollRange()")
.unwrap(),
0.0,
"the page scroll has nothing to scroll here — the list's own bar owns the gutter"
);
assert!(
!s.eval::<bool>("return OptionsFrameContainerScrollBar:IsVisible()")
.unwrap(),
"…so only one bar is on screen"
);
let body_right = s
.eval::<f64>("return OptionsFrameContainerBodyKeybindings:GetRight()")
.unwrap();
@@ -504,6 +521,49 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
bar_right <= body_right - 8.0,
"bar inset from the body edge ({bar_right} vs body {body_right})"
);
// …wearing its TROUGH: the recessed channel (the shared kit's, shown and hidden with the bar),
// which seats ITSELF on the bar — 31 wide against the bar's 16, hung 8 units left, and
// overhanging it 21/20 so each arrow drops into the 16-tall socket the art carries (B224; the
// law and its ref citation are in BenillaScrollTrough_Seat). Asserted off the ARROWS here,
// which is the half the eye judges: 5 units of cap above the up arrow, 4 below the down arrow.
const TROUGH: &str = "OptionsFrameContainerBodyKeybindingsScrollFrameScrollBarTrough";
let bar = format!("{SF}ScrollBar");
let g = |f: &str, m: &str| s.eval::<f64>(&format!("return {f}:{m}()")).unwrap();
assert!(
s.eval::<bool>(&format!("return {TROUGH}:IsVisible()"))
.unwrap(),
"the trough shows with the bar"
);
assert!((g(TROUGH, "GetWidth") - 31.0).abs() < 0.01);
assert!((bar_left - g(TROUGH, "GetLeft") - 8.0).abs() < 0.01);
assert!((g(TROUGH, "GetTop") - g(&bar, "GetTop") - 21.0).abs() < 0.01);
assert!((g(&bar, "GetBottom") - g(TROUGH, "GetBottom") - 20.0).abs() < 0.01);
assert!(
(g(TROUGH, "GetTop") - g(&format!("{bar}ScrollUpButton"), "GetTop") - 5.0).abs() < 0.01
);
assert!(
(g(&format!("{bar}ScrollDownButton"), "GetBottom") - g(TROUGH, "GetBottom") - 4.0).abs()
< 0.01
);
// A list that FITS shows neither.
let set_all = |s: &UiScript, open: bool| {
s.run(&format!(
"for i = 1, table.getn(KeyBindingsPage.sections) do KeyBindings_ExpandSection(i, {}) end
KeyBindingsPage_Update()",
if open { "true" } else { "nil" }
))
.unwrap();
};
set_all(&s, false);
for f in [format!("{SF}ScrollBar"), TROUGH.to_string()] {
assert!(
!s.eval::<bool>(&format!("return {f}:IsVisible()")).unwrap(),
"{f} goes away when the list fits"
);
}
set_all(&s, true);
s.resolve();
// A spin over a row NAME — the dead zone when the handler lived on the sibling.
let quads = s.extract();
let (wx, wy) = quads
@@ -563,3 +623,68 @@ fn the_wheel_bubbles_from_the_rows_and_the_bar_rides_the_gutter() {
s.run("KeyBindings_SetSelected(nil)").unwrap();
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// **The pet lane reaches the page** (B218 / decision 1052). 1008 registered the two multibars
/// and stopped; 1.12's `BONUSACTIONBUTTON1-10` — "Secondary Action Button" in its own window —
/// never joined, so the pet bar was the one visible bar with no way to bind it.
///
/// Pinned on both sides of the seam, because the report is about the *page*: the registry carries
/// the ten rows under the ACTION BAR header with their byte-real `CTRL-1..CTRL-0` defaults (1.12
/// files them under l.121's header, having none of their own), and the page's own search — the
/// path a player actually finds a binding by — paints the row live with that key on its capsule.
#[test]
fn the_pet_lane_is_registered_under_the_action_bar_header() {
let mut s = harness();
on_page(&mut s);
// Registry side: the whole lane, in Bindings.xml order right after the shapeshift block,
// sharing the action bar's category token.
let category = |s: &mut UiScript, name: &str| {
s.eval::<String>(&format!(
r#"for i = 1, GetNumBindings() do
local n, c = GetBinding(i)
if n == "{name}" then return c end
end"#
))
.unwrap()
};
for i in 1..=10 {
let name = format!("BONUSACTIONBUTTON{i}");
assert_eq!(
category(&mut s, &name),
"BINDING_HEADER_ACTIONBAR",
"{name} files under the action bar, as 1.12 does"
);
let key = s
.eval::<String>(&format!(r#"return GetBindingKey("{name}")"#))
.unwrap();
assert_eq!(key, format!("CTRL-{}", i % 10), "{name}'s 1.12 default");
}
// …and it is a runOnUp pair like the action bar's own rows, which is what makes the wheel
// refusal apply to it (SetBinding answers nil rather than binding MOUSEWHEELUP).
assert!(
s.eval::<Option<u32>>(r#"return SetBinding("MOUSEWHEELUP", "BONUSACTIONBUTTON1")"#)
.unwrap()
.is_none(),
"press+release commands refuse the wheel"
);
// Page side: search finds it and paints a live row with the capsule filled.
s.run(r#"OptionsFrameSearchBox:SetText("bonusactionbutton1")"#)
.unwrap();
assert!(s
.eval::<bool>("return OptionsFrameContainerBodySearchHeadKeybindings:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Description:GetText()")
.unwrap(),
"BONUSACTIONBUTTON1",
"the raw-token fallback — the app's GlobalStrings read 'Secondary Action Button 1'"
);
assert_eq!(
s.eval::<String>("return OptionsFrameContainerBodyKeybindSearch1Key1ButtonText:GetText()")
.unwrap(),
"CTRL-1"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -0,0 +1,369 @@
//! The shipped FrameXML manifest and the three ways it is loaded.
//!
//! Split out of `ui_script/mod.rs` as its own concern: a 250-entry ordered data table plus the
//! loader that walks it. The interesting thing here is the **seam at index 0** — see
//! [`load_default_ui`].
use bevy::prelude::*;
use benilla_ui::script::UiScript;
/// The shipped FrameXML manifest, in load order. Index 0 (`Fonts.xml`) is the **font-object
/// registry** and is deliberately separable from the rest: it materializes zero frames, and the
/// glyph atlas bake ([`crate::ui_text`]) reads its objects to build the bake plan. Splitting the
/// manifest here is what lets the in-game UI load at world entry while the glue screens — which
/// share our one atlas — still get every (face, size, outline) combination they need (1051).
const UI_MANIFEST: &[&str] = &[
// The named virtual Font objects (decision 0084) — must load first so any FontString's
// `inherits="GameFontNormal"` resolves against a registered object.
"Fonts.xml",
// The UIPanel slot manager (decision 0084 §2): script-only, no frames of its own — must load
// before any panel frame (Gossip/Merchant today) so ShowUIPanel/HideUIPanel already exist
// when those frames' OnLoad/OnEvent reference them.
"UiPanels.xml",
// The managed bottom-stack positions (decision 0272): script-only —
// UIPARENT_MANAGED_FRAME_POSITIONS + UIParent_ManageFramePositions, the ref
// UIParent.lua mechanism that re-anchors the cast bar / chat over the bottom bars.
// Loaded early so the stance bar's OnShow/OnHide can call it; applied once by the
// post-load bootstrap below.
"UIParent.xml",
// The tooltip: a hover window the bag/merchant/loot/unit frames drive. Loaded before them so
// `GameTooltip` + its verbs exist when their OnEnter handlers fire (runtime, so order
// is not strictly required — but it keeps the dependency legible).
"GameTooltip.xml",
// The dropdown kit (0203 phase 2's closing sub-slice, widened for 0434 phase 5):
// UIDropDownMenuTemplate + DropDownList1/2 + the UIDropDownMenu_* API — before its
// first customers (the unit popups below, the world map's pickers, the trainer filter).
"UIDropDownMenu.xml",
// The unit right-click popups (0434 phase 5): UnitPopupMenus/Buttons + UnitPopup_ShowMenu
// — after the dropdown kit it drives, before the unit/party frames whose DropDown
// children's OnLoad initialize into it.
"UnitPopup.xml",
// The chat-link click router (SetItemRef): item links → ItemRefTooltip:SetHyperlink; a
// player name → left-click whisper / right-click FRIEND dropdown. After GameTooltip.xml
// (GameTooltip_OnLoad) AND the dropdown kit + UnitPopup — its FriendsDropDown inherits
// UIDropDownMenuTemplate and opens a UnitPopup FRIEND menu.
"ItemRef.xml",
"UnitFrames.xml",
// The combo-point dots (decision 0869) — anchored to BenillaTargetFrame, so after
// UnitFrames.xml; its fade chain needs UiPanels.xml's UIFrameFade above.
"ComboFrame.xml",
// The center-screen scrolling combat text (decision 0578) — the Blizzard_CombatText
// transcription; consumes COMBAT_TEXT_UPDATE + the regen/health/power events, needs
// only Fonts.xml (CombatTextFont) + UIParent before it.
"CombatText.xml",
// The party member frames + the PARTY_INVITE popup + its event driver (decision 0434
// phase 2): a StaticPopup registry entry, so after UiPanels.xml.
"PartyFrame.xml",
// The death arc's dialogs + event driver (decision 0308): registry entries on the shared
// StaticPopup engine, so after UiPanels.xml.
"DeathFrame.xml",
// The two duel dialogs + their event driver (decision 0633): registry entries on the same
// StaticPopup engine, and DUEL_OUTOFBOUNDS leans on its per-tick countdown branch.
"DuelFrame.xml",
// The two enchant-apply confirms + their event driver (decision 0928): the same StaticPopup
// registry again. Not CraftFrame's — the gate that raises them is the generic item-target
// bind, reached by a poison or a sharpening stone with no profession window open.
"EnchantConfirm.xml",
// The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
// consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
"Cooldown.xml",
"ActionBar.xml",
// The two always-on bottom multibars (actions 61-72 / 49-60): pure XML/Lua over
// ActionBar.xml's shared button template + handler set — loads right after it
// (inherits BenillaActionButtonTemplate, anchors to BenillaActionButton1, and its
// buttons join the BENILLA_ACTION_BUTTONS roster ActionBar.xml declares).
"MultiBars.xml",
// The stance/shapeshift bar: hidden until `crate::ui_shapeshift`'s feed pushes a
// non-empty form list (a formless class never shows it). After Cooldown.xml
// (CooldownFrame_SetTimer) + ActionBar.xml (BENILLA_FALLBACK_ICON, the BenillaActionBar
// anchor target).
"StanceBar.xml",
// The pet action bar (decision 0982): hidden until `crate::ui_pet`'s feed reports a bar,
// i.e. until the server sends `SMSG_PET_SPELLS` for a live pet or charm — so a class that
// never has one never sees it. Right after the stance bar, whose row it shares (either
// one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
// (CooldownFrame_SetTimer), ActionBar.xml (the `BenillaActionBar` anchor target) and
// GameTooltip.xml (its hover).
"PetActionBar.xml",
// The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
// loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
// into BenillaActionBarArtFrame by name and seats itself with that file's
// BenillaActionBarArt_SeatAbove. It must come BEFORE the panels it toggles, because each of
// their OnShow/OnHide calls the `UpdateMicroButtons` this file defines (the panel frames
// themselves are read back through getglobal, so the reverse dependency is call-time).
"MicroMenu.xml",
// The HUD minimap cluster (decision 0203 phase 1): the chrome around the engine-rendered
// <Minimap> widget hole (`crate::minimap` fills it). After GameTooltip (its zone-text
// hover uses the shared tooltip).
"MinimapCluster.xml",
// The zone-entry splash (decision 0287): ZoneTextFrame/SubZoneTextFrame + the inlined
// FadingFrame kit, driven by the ZONE_CHANGED family `crate::area` fires. Needs only
// Fonts (ZoneTextFont/SubZoneTextFont) + UIParent; kept by the minimap cluster — the
// other consumer of the zone-text host globals.
"ZoneText.xml",
// The faux-scroll kit (decisions 0247/0250/0251): BenillaScrollBarTemplate (the draggable
// Slider scroll bar) + BenillaFauxScrollFrameTemplate + the BenillaFauxScrollFrame_* API —
// before its first customer (the trainer list) below.
"ScrollTemplates.xml",
// The fullscreen world map (decision 0203 phase 2), over the worldmap host API
// (`crate::ui_world_map` feeds it); the 'M' binding below calls its ToggleWorldMap().
"WorldMapFrame.xml",
// The cast bar (decision 0137 phase 1) — the extracted 1.12 CastingBarFrame, driven by
// the SPELLCAST_* events `crate::ui_cast` fires. Only needs Fonts before it.
"CastingBar.xml",
// The mirror timers (decision 0874): the breath/fatigue bars at top-center, driven by the
// MIRROR_TIMER_* events `crate::ui_mirror` fires. Needs only Fonts (GameFontHighlight)
// before it; kept beside the cast bar, whose border art it shares.
"MirrorTimer.xml",
// The player buff/debuff bar (decisions 0255/0257 — the player aura arc): the HUD row of
// aura icons under the minimap, driven by the UnitAura feed (`crate::ui_aura`). Needs Fonts
// (its FontStrings) and ActionBar's `BENILLA_FALLBACK_ICON` global (both already loaded).
"BuffFrame.xml",
// The durability alert (the "armor guy"): under the minimap beside BuffFrame, driven by
// UPDATE_INVENTORY_ALERTS off the same inventory push the character window reads. Needs
// MinimapCluster (its anchor) — loaded above.
"DurabilityFrame.xml",
"ErrorsFrame.xml",
"BagFrame.xml",
// The stack-split spinner (decision 0216 §6/slice 2): loaded beside BagFrame, whose slot
// click handler (`BenillaBagSlot_OnClick`) calls `OpenStackSplitFrame`. Order between the
// two doesn't matter for correctness (Lua globals resolve at call time, and neither file's
// XML `inherits=` reaches into the other's templates) — kept adjacent for legibility.
"StackSplit.xml",
// The character window (decision 0208 phase 1a): the C-key paper doll. Loaded after BagFrame
// (needs nothing from it, but keeps player-state windows grouped) and before the NPC session
// windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
// already loaded above.
"CharacterFrame.xml",
// The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate
// top-level file (SkillFrame.xml's own header comment — this engine has no cross-file
// `parent=` attachment, so it's positioned by name via `<Anchors relativeTo=
// "BenillaCharacterFrame">` instead). Loaded immediately after CharacterFrame.xml so it
// paints ON TOP of it (later file = higher z) and so `BenillaCharacterFrame` already exists
// for that anchor to resolve against.
"SkillFrame.xml",
// The inspect window (decision 0631): another player's paper doll. Beside the character
// window it mirrors — it needs the same three (Fonts/UiPanels/GameTooltip) plus
// `CharacterFrameTabButtonTemplate` from UiPanels.xml, and nothing from CharacterFrame.xml
// itself (its slot template, handlers, and booth slot are all its own — the two windows
// share only the reference's art).
"InspectFrame.xml",
// The spellbook window (decision 0216 §8, slice 5): the P-key window, the spell SOURCE
// for the cursor-payload arc. Loaded right after CharacterFrame (the same "player-state
// windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the
// 'P' binding below calls its bare ToggleSpellBook(BOOKTYPE_SPELL).
"SpellBookFrame.xml",
// The talent window (decision 0304 §4): the N-key window, the class talent grid over the
// `benilla-ui::script::talent` engine seam. Loaded right after SpellBookFrame (the same
// "player-state windows grouped" posture; needs Fonts/UiPanels/GameTooltip plus
// ScrollTemplates.xml — its real ScrollFrame's scrollbar reuses BenillaScrollBarTemplate —
// all already loaded above). The 'N' binding (ui_script/input.rs) calls its bare
// ToggleTalentFrame().
"TalentFrame.xml",
// The social window (decision 0668): the O-key friends/ignore/who window over the social
// API (`benilla-ui/src/script/social.rs`) + `crate::ui_social`'s feed/drain. Needs
// UiPanels (the panel manager, `CharacterFrameTabButtonTemplate`, and the StaticPopup
// engine its two name-entry dialogs register into), ScrollTemplates (three faux lists),
// GameTooltip (the newbie tips) and UIDropDownMenu (the who list's variable column) —
// all loaded above.
"FriendsFrame.xml",
"GossipFrame.xml",
"MerchantFrame.xml",
// The mail window (decision 0544 P1/P2): the mailbox inbox, open-letter, and send tab over
// the Era mail API (`benilla-ui/src/script/mail.rs`) + `crate::ui_mail`'s feed/drain. Loaded
// right after MerchantFrame because it REUSES that file's global BenillaMoney_Set/_Clear
// coin helpers (postage/enclosed/COD displays) — they must be defined first.
"MailFrame.xml",
// The player-to-player trade window (decision 0592 P1): the two-sided offer over the trade
// API (`benilla-ui/src/script/trade.rs`) + `crate::ui_trade`'s feed/drain. Loaded after
// MerchantFrame because it REUSES that file's global BenillaMoney_Set coin helpers (the two
// gold displays) — they must be defined first.
"TradeFrame.xml",
// The item-text reader: letters (mail-made permanent copies) and, later, books/plaques —
// the ItemTextGet* seam (`benilla-ui/src/script/item_text.rs`) over `crate::ui_item_text`'s
// feed/drain. Loaded right after MailFrame (the same arc; needs only Fonts/UiPanels, both
// loaded above).
"ItemTextFrame.xml",
// The trainer window (decision 0237 phase 3): the read-only class/profession trainer list.
// Loaded right after MerchantFrame per the director's brief (kept adjacent even though this
// v1 uses plain-text costs, not MerchantFrame's coin helpers) and before the other NPC
// session windows below.
"TrainerFrame.xml",
// The bank window (decision 0604 phase 4): the vault, purchase ladder, and the 6 bank-bag
// popouts, over the C_Container feed already carrying container -1/5..10 (ui_items.rs) +
// the bank Lua surface (benilla-ui/src/script/bank.rs) + crate::ui_bank's feed/drain.
// Loaded AFTER MerchantFrame.xml (reuses its BenillaMoney_* coin helpers for the purchase
// row + purse) and AFTER BagFrame.xml, already above (its BenillaBagSlotTemplate/
// BenillaBagWindowTemplate + the BenillaBagSlot_*/BenillaBagFrame_* function family are
// reused verbatim — the 24 generic slots and the 6 bank-bag popouts are, respectively, a
// container-slot grid and ordinary container windows, exactly like the bag arc's own).
// Kept in the "NPC session windows grouped" cluster beside Trainer/Taxi.
"BankFrame.xml",
// The taxi-map window (decision 0484 phase 2): the flight-master session, driven by the
// taxi engine seam (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain.
// Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
// needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney), both already loaded above.
"TaxiFrame.xml",
// The crafting window (decision 0437 phase 2): NOT an NPC session (opens off your own cast of
// an effect-47 opener spell, TradeSkillFrame.xml's own header comment) — loaded right after
// TrainerFrame per the same "player-state/profession window" grouping and because it depends
// on nothing TrainerFrame doesn't already need (Fonts/UiPanels/GameTooltip/ScrollTemplates,
// all loaded above).
"TradeSkillFrame.xml",
// The Enchanting window (decision 0437 phase 3): the exact twin of TradeSkillFrame above —
// same "opens off your own cast, not an NPC session" shape (CraftFrame.xml's own header
// comment) — loaded right after it so `BuildColoredListString` (a guarded GLOBAL
// TradeSkillFrame.xml defines) already exists when CraftFrame.xml reuses it without
// redefining (CraftFrame.xml's own header comment, deviation 2).
"CraftFrame.xml",
"LootFrame.xml",
// The group-loot roll popups (decision 0591): the Need/Greed/Pass dialogs, a sibling of
// LootFrame.xml (loaded right after it) over the `benilla-ui::script::loot_roll` seam
// (`crate::ui_loot_roll`'s feed/drain). NOT a UIPanel — four `frameStrata="DIALOG"` toplevel
// popups with their own hardcoded anchors — so it needs only Fonts (GameFontNormalSmall) +
// GameTooltip (the item/PASS/NEED/GREED hovers), both already loaded above.
"GroupLootFrame.xml",
// The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after
// MerchantFrame (the BenillaMoney coin helpers live there) and GossipFrame (shared parchment
// art conventions); its title/panel-button labels use the auto-centred ButtonText slot.
"QuestFrame.xml",
// The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an
// NPC session — loaded after MerchantFrame (the BenillaMoney coin helpers it reuses) and
// alongside QuestFrame (same arc); the 'L' binding below calls its ToggleQuestLog().
"QuestLogFrame.xml",
"ChatFrame.xml",
// The macro window (decision 0983): the editor + its name/icon popup, driven over the
// engine's OWN macro table (`benilla_ui::script::macros` — 1.12 macros have no server
// side, so this is the one window whose model is not an app feed). Needs UiPanels (the
// panel manager + `TabButtonTemplate`), ScrollTemplates (BOTH kits — the real one scrolls
// the body box, the faux one the icon grid), MicroMenu (`UpdateMicroButtons` on
// show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
// `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
"MacroFrame.xml",
// The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
// the templates + script of the Options window's Keybindings category, over the
// engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
// GetBinding/SetBinding Lua API). Needs UiPanels (popup engine) and GameTooltip (the
// character-specific checkbox's hover); MUST load before OptionsFrame.xml, whose
// Keybindings body inherits these templates and calls this script at OnLoad.
"KeyBindingsPage.xml",
// The options window (the 0985 provenance split: 0950's era structure, 0978's
// 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
// cuts — bare fully-live sliders, no corner X; 0992's dropdown rows + the Nameplates
// page): the era-shaped OptionsFrame the game menu's Options button opens. Needs
// Fonts' objects, UiPanels' panel manager, and UIDropDownMenu's capsule template
// (all far above); sits right before GameMenuFrame so the menu — which reaches it by
// name at click time — stays the last, outermost layer.
"OptionsFrame.xml",
// The game menu (decision 0674): the frame ESC opens, plus the CAMP/QUIT dialogs and their
// event driver. LAST on purpose — it is the outermost layer of the shell, it depends on
// everything it touches being loaded (UiPanels' popup engine + panel manager, MicroMenu's
// UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing depends on it: the ESC
// chain and the micro button both reach it by name at call time.
"GameMenuFrame.xml",
];
/// Load a slice of [`UI_MANIFEST`] into the VM, in the order given. Returns per-file errors.
///
/// `bootstrap_positions` runs decision 0272's load-time `UIParent_ManageFramePositions()` pass —
/// only meaningful once the frames that table names exist, so the font-registry-only load
/// ([`load_font_registry`]) skips it. It is defined in `UIParent.xml`, which is in the deferred
/// half; calling it after `Fonts.xml` alone is a nil-global error, not a no-op.
fn load_ui_files(script: &UiScript, files: &[&str], bootstrap_positions: bool) -> Vec<String> {
let mut failures = Vec::new();
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
// Provider for FrameXML/Lua references: try the path as given and by basename (Blizzard-style
// backslash paths, dir-relative), resolved against our own assets/ui dir.
let provider = |req: &str| -> Option<String> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read_to_string(dir.join(&norm))
.or_else(|_| std::fs::read_to_string(dir.join(base)))
.ok()
};
for file in files {
let entry = dir.join(file);
let text = match std::fs::read_to_string(&entry) {
Ok(t) => t,
Err(e) => {
error!("ui_script: reading {}: {e}", entry.display());
continue;
}
};
let doc = match benilla_ui::framexml::parse(&text) {
Ok(d) => d,
Err(e) => {
error!("ui_script: parsing {file}: {e}");
continue;
}
};
let report = benilla_ui::loader::load(script, &doc, &provider);
for w in &report.warnings {
warn!("ui_script({file}): {w}");
}
for e in &report.errors {
error!("ui_script({file}): {e}");
failures.push(format!("{file}: {e}"));
}
info!(
"ui_script: {file} loaded ({} frames materialized)",
report.frames
);
}
// The managed positions' startup pass (decision 0272): the ref applies
// UIPARENT_MANAGED_FRAME_POSITIONS once at load, then re-fires from the bottom bars'
// OnShow/OnHide. Every frame the table names now exists, so this is that load-time
// application; the stance bar's show/hide handles the rest at runtime.
if bootstrap_positions {
if let Err(e) = script.run("UIParent_ManageFramePositions()") {
error!("ui_script: managed-positions bootstrap: {e}");
failures.push(format!("managed-positions bootstrap: {e}"));
}
}
failures
}
/// Load benilla's own default UI (`assets/ui/*.xml` — the unit frames and the action bar) through
/// the engine-free loader, resolving any `<Include>`/`<Script file=>` references against the
/// crate's `assets/ui` dir. This is our content (MIT/Apache), committed and read from source —
/// `CARGO_MANIFEST_DIR` (compile-time) points at this crate. Textures (`Interface\…`) still resolve at
/// render through the MPQ `sprite_texture` path; the loader only needs the XML/Lua text.
///
/// Returns every loader error, tagged `"<file>: <error>"` — the app ignores the value (each is
/// already logged as it happens) and [`shipped_xml_tests`] asserts it empty. The manifest is an
/// inline array no other test walks, so before that assertion a broken entry — a bad file name, a
/// frame that collides with a later window's, a template referenced before its definer — reached a
/// real run with nothing but a log line. Capture runs cannot cover it either: they skip this
/// function entirely unless `WOW_CAPTURE_UI=1`.
///
/// **Split across the boot boundary (1051).** `Fonts.xml` — index 0, zero frames materialized —
/// is the font-object registry the glyph atlas bakes its plan from, and our native glue screens
/// share that one atlas, so it must exist before the login screen. Everything after it is in-game
/// UI and loads at world entry ([`load_ingame_ui`]). This whole-manifest entry point stays for the
/// tests, which assert over the complete shipped set — production now loads in two phases, so this
/// has no non-test caller.
#[cfg(test)]
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
load_ui_files(script, UI_MANIFEST, true)
}
/// The font-object registry alone (`Fonts.xml`), loaded at `Startup` — see [`load_default_ui`].
///
/// Verified lossless for the atlas: the full manifest and this file alone both yield the **same 19
/// distinct `(font, height, outline)` combinations**. The three font objects defined outside it
/// (`GameFontNormalMed1` 13, `OptionsFontHighlightMedium` 14, `OptionsFontHighlightHuge` 20) are
/// un-outlined and their heights are already declared here, so they add nothing to the bake plan.
pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
load_ui_files(script, &UI_MANIFEST[..1], false)
}
/// The in-game UI — everything after the font registry — loaded on entering the world.
///
/// The reference does the same at `CGGameUI::Initialize 0x48fbf0`, reached only from world entry
/// (`0x401570` ← `0x46c236`); its glue screens run GlueXML with their own `GlueFonts.xml` registry,
/// which is why the reference has no equivalent of our shared-atlas coupling (wow-5875-re, 1051).
pub(crate) fn load_ingame_ui(script: &UiScript) -> Vec<String> {
load_ui_files(script, &UI_MANIFEST[1..], true)
}
+56 -319
View File
@@ -29,6 +29,12 @@ use crate::ui_unit::UnitFeed;
mod extract;
mod input;
mod manifest;
// The manifest's loaders read as `ui_script::…` at every call site, including the tests' `super::`.
#[cfg(test)]
pub(crate) use manifest::load_default_ui;
pub(crate) use manifest::{load_font_registry, load_ingame_ui};
/// Is the pointer over *any* UI this frame — the egui dev overlay OR a mouse-enabled player-UI
/// frame? The single source of truth for "the mouse is talking to the UI, not the world",
@@ -176,10 +182,17 @@ impl Plugin for UiScriptPlugin {
.init_resource::<PlayerUiClickConsumed>()
.init_resource::<CursorPayloadHeld>()
.init_resource::<UiClock>()
.init_resource::<IngameUiLoaded>()
// After `AssetSet::Open` so the patch chain exists at boot: the VM's first load is
// the real `GlobalStrings.lua` (the reference's own FrameXML order), which the
// cast-fail display (0427) resolves its messages from.
.add_systems(Startup, setup_script.after(crate::assets::AssetSet::Open))
// The in-game UI materializes on entering the world, not at boot (1051) — the
// reference's own seam. Only the font registry loads at `Startup`.
.add_systems(
OnEnter(crate::char_select::ClientState::InWorld),
load_ingame_ui_on_world_entry,
)
// `drive_script` resolves layout; `feed_ui_input` hit-tests against those rects, so they
// chain (also required because both take the single `NonSend` VM). The pair runs before
// `WorldStage::Input` so the hover result reaches the pointer arbiter in time. The input
@@ -251,17 +264,55 @@ fn setup_script(world: &mut World) {
};
load_global_strings(world, &script);
load_emote_tokens(world, &script);
// The unit frames are the default player UI — always loaded in a normal run. Captures stay
// pristine (world-render baselines) unless WOW_CAPTURE_UI=1 opts the UI in.
let capturing = world.contains_resource::<crate::capture::CaptureMode>();
if !capturing || std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1") {
// ONLY the font-object registry at boot (1051). The glyph atlas bakes once, on the first
// `Update`, from `script.font_objects()` — and our native glue screens share that one atlas, so
// the registry has to exist before the login screen or in-game text loses its outlined variants
// and its registry-declared sizes for the whole session. The other 55 files are in-game UI and
// load at world entry ([`load_ingame_ui`]); the reference splits at exactly this seam, with
// GlueXML carrying its own `GlueFonts.xml`.
if ui_wanted(world) {
// Errors are already logged per file as they happen; the returned list is the test-side
// assertion (`shipped_xml_tests`), not a second reporting channel.
let _ = load_default_ui(&script);
let _ = load_font_registry(&script);
}
world.insert_non_send_resource(script);
}
/// Does this run want the player UI at all? Captures stay pristine — their baselines regression-test
/// the WORLD render — unless `WOW_CAPTURE_UI=1` opts the UI in.
fn ui_wanted(world: &World) -> bool {
!world.contains_resource::<crate::capture::CaptureMode>()
|| std::env::var("WOW_CAPTURE_UI").as_deref() == Ok("1")
}
/// Has the in-game UI been materialized this process? The load is **once per process**, not once
/// per world entry: nothing tears the frame tree down on `OnExit(InWorld)` yet, so a re-entry after
/// `/logout` must not stack a second copy of every window.
///
/// The reference *does* tear down and rebuild (`ClientDestroyGame 0x401ee0` ↔ `0x401570`, and its
/// `ReloadUI` is verified end to end doing exactly that). Matching it is the follow-on; this latch
/// is what makes the move safe without it, and is the thing to delete when the teardown lands.
#[derive(Resource, Default)]
pub(crate) struct IngameUiLoaded(pub(crate) bool);
/// `OnEnter(InWorld)`: materialize the in-game UI, once.
///
/// Safe on the state edge only because 1038 moved the initial transition after `PostStartup` — a
/// capture boots straight into `InWorld`, so before that this would have run ahead of
/// [`crate::assets::AssetSet::Open`] and loaded against no patch chain.
fn load_ingame_ui_on_world_entry(world: &mut World) {
if world.resource::<IngameUiLoaded>().0 || !ui_wanted(world) {
return;
}
let Some(script) = world.remove_non_send_resource::<UiScript>() else {
warn!("ui_script: entering the world with no VM — the in-game UI will not load");
return;
};
let _ = load_ingame_ui(&script);
world.insert_non_send_resource(script);
world.resource_mut::<IngameUiLoaded>().0 = true;
}
/// Execute the real `Interface\FrameXML\GlobalStrings.lua` off the patch chain into the VM —
/// the reference boots FrameXML with exactly this file FIRST, and it is the source of every
/// localized string global the UI reads (the cast-fail display's whole message set, 0427).
@@ -366,320 +417,6 @@ pub(crate) fn is_emote_token_line(line: &str) -> bool {
.all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_')
}
/// Load benilla's own default UI (`assets/ui/*.xml` — the unit frames and the action bar) through
/// the engine-free loader, resolving any `<Include>`/`<Script file=>` references against the
/// crate's `assets/ui` dir. This is our content (MIT/Apache), committed and read from source —
/// `CARGO_MANIFEST_DIR` (compile-time) points at this crate. Textures (`Interface\…`) still resolve at
/// render through the MPQ `sprite_texture` path; the loader only needs the XML/Lua text.
///
/// Returns every loader error, tagged `"<file>: <error>"` — the app ignores the value (each is
/// already logged as it happens) and [`shipped_xml_tests`] asserts it empty. The manifest is an
/// inline array no other test walks, so before that assertion a broken entry — a bad file name, a
/// frame that collides with a later window's, a template referenced before its definer — reached a
/// real run with nothing but a log line. Capture runs cannot cover it either: they skip this
/// function entirely unless `WOW_CAPTURE_UI=1`.
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
let mut failures = Vec::new();
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
// Provider for FrameXML/Lua references: try the path as given and by basename (Blizzard-style
// backslash paths, dir-relative), resolved against our own assets/ui dir.
let provider = |req: &str| -> Option<String> {
let norm = req.replace('\\', "/");
let base = norm.rsplit('/').next().unwrap_or(&norm);
std::fs::read_to_string(dir.join(&norm))
.or_else(|_| std::fs::read_to_string(dir.join(base)))
.ok()
};
for file in [
// The named virtual Font objects (decision 0084) — must load first so any FontString's
// `inherits="GameFontNormal"` resolves against a registered object.
"Fonts.xml",
// The UIPanel slot manager (decision 0084 §2): script-only, no frames of its own — must load
// before any panel frame (Gossip/Merchant today) so ShowUIPanel/HideUIPanel already exist
// when those frames' OnLoad/OnEvent reference them.
"UiPanels.xml",
// The managed bottom-stack positions (decision 0272): script-only —
// UIPARENT_MANAGED_FRAME_POSITIONS + UIParent_ManageFramePositions, the ref
// UIParent.lua mechanism that re-anchors the cast bar / chat over the bottom bars.
// Loaded early so the stance bar's OnShow/OnHide can call it; applied once by the
// post-load bootstrap below.
"UIParent.xml",
// The tooltip: a hover window the bag/merchant/loot/unit frames drive. Loaded before them so
// `GameTooltip` + its verbs exist when their OnEnter handlers fire (runtime, so order
// is not strictly required — but it keeps the dependency legible).
"GameTooltip.xml",
// The dropdown kit (0203 phase 2's closing sub-slice, widened for 0434 phase 5):
// UIDropDownMenuTemplate + DropDownList1/2 + the UIDropDownMenu_* API — before its
// first customers (the unit popups below, the world map's pickers, the trainer filter).
"UIDropDownMenu.xml",
// The unit right-click popups (0434 phase 5): UnitPopupMenus/Buttons + UnitPopup_ShowMenu
// — after the dropdown kit it drives, before the unit/party frames whose DropDown
// children's OnLoad initialize into it.
"UnitPopup.xml",
// The chat-link click router (SetItemRef): item links → ItemRefTooltip:SetHyperlink; a
// player name → left-click whisper / right-click FRIEND dropdown. After GameTooltip.xml
// (GameTooltip_OnLoad) AND the dropdown kit + UnitPopup — its FriendsDropDown inherits
// UIDropDownMenuTemplate and opens a UnitPopup FRIEND menu.
"ItemRef.xml",
"UnitFrames.xml",
// The combo-point dots (decision 0869) — anchored to BenillaTargetFrame, so after
// UnitFrames.xml; its fade chain needs UiPanels.xml's UIFrameFade above.
"ComboFrame.xml",
// The center-screen scrolling combat text (decision 0578) — the Blizzard_CombatText
// transcription; consumes COMBAT_TEXT_UPDATE + the regen/health/power events, needs
// only Fonts.xml (CombatTextFont) + UIParent before it.
"CombatText.xml",
// The party member frames + the PARTY_INVITE popup + its event driver (decision 0434
// phase 2): a StaticPopup registry entry, so after UiPanels.xml.
"PartyFrame.xml",
// The death arc's dialogs + event driver (decision 0308): registry entries on the shared
// StaticPopup engine, so after UiPanels.xml.
"DeathFrame.xml",
// The two duel dialogs + their event driver (decision 0633): registry entries on the same
// StaticPopup engine, and DUEL_OUTOFBOUNDS leans on its per-tick countdown branch.
"DuelFrame.xml",
// The two enchant-apply confirms + their event driver (decision 0928): the same StaticPopup
// registry again. Not CraftFrame's — the gate that raises them is the generic item-target
// bind, reached by a poison or a sharpening stone with no profession window open.
"EnchantConfirm.xml",
// The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
// consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
"Cooldown.xml",
"ActionBar.xml",
// The two always-on bottom multibars (actions 61-72 / 49-60): pure XML/Lua over
// ActionBar.xml's shared button template + handler set — loads right after it
// (inherits BenillaActionButtonTemplate, anchors to BenillaActionButton1, and its
// buttons join the BENILLA_ACTION_BUTTONS roster ActionBar.xml declares).
"MultiBars.xml",
// The stance/shapeshift bar: hidden until `crate::ui_shapeshift`'s feed pushes a
// non-empty form list (a formless class never shows it). After Cooldown.xml
// (CooldownFrame_SetTimer) + ActionBar.xml (BENILLA_FALLBACK_ICON, the BenillaActionBar
// anchor target).
"StanceBar.xml",
// The pet action bar (decision 0982): hidden until `crate::ui_pet`'s feed reports a bar,
// i.e. until the server sends `SMSG_PET_SPELLS` for a live pet or charm — so a class that
// never has one never sees it. Right after the stance bar, whose row it shares (either
// one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
// (CooldownFrame_SetTimer), ActionBar.xml (the `BenillaActionBar` anchor target) and
// GameTooltip.xml (its hover).
"PetActionBar.xml",
// The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
// loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
// into BenillaActionBarArtFrame by name and seats itself with that file's
// BenillaActionBarArt_SeatAbove. It must come BEFORE the panels it toggles, because each of
// their OnShow/OnHide calls the `UpdateMicroButtons` this file defines (the panel frames
// themselves are read back through getglobal, so the reverse dependency is call-time).
"MicroMenu.xml",
// The HUD minimap cluster (decision 0203 phase 1): the chrome around the engine-rendered
// <Minimap> widget hole (`crate::minimap` fills it). After GameTooltip (its zone-text
// hover uses the shared tooltip).
"MinimapCluster.xml",
// The zone-entry splash (decision 0287): ZoneTextFrame/SubZoneTextFrame + the inlined
// FadingFrame kit, driven by the ZONE_CHANGED family `crate::area` fires. Needs only
// Fonts (ZoneTextFont/SubZoneTextFont) + UIParent; kept by the minimap cluster — the
// other consumer of the zone-text host globals.
"ZoneText.xml",
// The faux-scroll kit (decisions 0247/0250/0251): BenillaScrollBarTemplate (the draggable
// Slider scroll bar) + BenillaFauxScrollFrameTemplate + the BenillaFauxScrollFrame_* API —
// before its first customer (the trainer list) below.
"ScrollTemplates.xml",
// The fullscreen world map (decision 0203 phase 2), over the worldmap host API
// (`crate::ui_world_map` feeds it); the 'M' binding below calls its ToggleWorldMap().
"WorldMapFrame.xml",
// The cast bar (decision 0137 phase 1) — the extracted 1.12 CastingBarFrame, driven by
// the SPELLCAST_* events `crate::ui_cast` fires. Only needs Fonts before it.
"CastingBar.xml",
// The mirror timers (decision 0874): the breath/fatigue bars at top-center, driven by the
// MIRROR_TIMER_* events `crate::ui_mirror` fires. Needs only Fonts (GameFontHighlight)
// before it; kept beside the cast bar, whose border art it shares.
"MirrorTimer.xml",
// The player buff/debuff bar (decisions 0255/0257 — the player aura arc): the HUD row of
// aura icons under the minimap, driven by the UnitAura feed (`crate::ui_aura`). Needs Fonts
// (its FontStrings) and ActionBar's `BENILLA_FALLBACK_ICON` global (both already loaded).
"BuffFrame.xml",
// The durability alert (the "armor guy"): under the minimap beside BuffFrame, driven by
// UPDATE_INVENTORY_ALERTS off the same inventory push the character window reads. Needs
// MinimapCluster (its anchor) — loaded above.
"DurabilityFrame.xml",
"ErrorsFrame.xml",
"BagFrame.xml",
// The stack-split spinner (decision 0216 §6/slice 2): loaded beside BagFrame, whose slot
// click handler (`BenillaBagSlot_OnClick`) calls `OpenStackSplitFrame`. Order between the
// two doesn't matter for correctness (Lua globals resolve at call time, and neither file's
// XML `inherits=` reaches into the other's templates) — kept adjacent for legibility.
"StackSplit.xml",
// The character window (decision 0208 phase 1a): the C-key paper doll. Loaded after BagFrame
// (needs nothing from it, but keeps player-state windows grouped) and before the NPC session
// windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
// already loaded above.
"CharacterFrame.xml",
// The Skills tab (decision 0437 phase 4): a CharacterFrame child page, but a separate
// top-level file (SkillFrame.xml's own header comment — this engine has no cross-file
// `parent=` attachment, so it's positioned by name via `<Anchors relativeTo=
// "BenillaCharacterFrame">` instead). Loaded immediately after CharacterFrame.xml so it
// paints ON TOP of it (later file = higher z) and so `BenillaCharacterFrame` already exists
// for that anchor to resolve against.
"SkillFrame.xml",
// The inspect window (decision 0631): another player's paper doll. Beside the character
// window it mirrors — it needs the same three (Fonts/UiPanels/GameTooltip) plus
// `CharacterFrameTabButtonTemplate` from UiPanels.xml, and nothing from CharacterFrame.xml
// itself (its slot template, handlers, and booth slot are all its own — the two windows
// share only the reference's art).
"InspectFrame.xml",
// The spellbook window (decision 0216 §8, slice 5): the P-key window, the spell SOURCE
// for the cursor-payload arc. Loaded right after CharacterFrame (the same "player-state
// windows grouped" posture, same Fonts/UiPanels/GameTooltip-only dependency set); the
// 'P' binding below calls its bare ToggleSpellBook(BOOKTYPE_SPELL).
"SpellBookFrame.xml",
// The talent window (decision 0304 §4): the N-key window, the class talent grid over the
// `benilla-ui::script::talent` engine seam. Loaded right after SpellBookFrame (the same
// "player-state windows grouped" posture; needs Fonts/UiPanels/GameTooltip plus
// ScrollTemplates.xml — its real ScrollFrame's scrollbar reuses BenillaScrollBarTemplate —
// all already loaded above). The 'N' binding (ui_script/input.rs) calls its bare
// ToggleTalentFrame().
"TalentFrame.xml",
// The social window (decision 0668): the O-key friends/ignore/who window over the social
// API (`benilla-ui/src/script/social.rs`) + `crate::ui_social`'s feed/drain. Needs
// UiPanels (the panel manager, `CharacterFrameTabButtonTemplate`, and the StaticPopup
// engine its two name-entry dialogs register into), ScrollTemplates (three faux lists),
// GameTooltip (the newbie tips) and UIDropDownMenu (the who list's variable column) —
// all loaded above.
"FriendsFrame.xml",
"GossipFrame.xml",
"MerchantFrame.xml",
// The mail window (decision 0544 P1/P2): the mailbox inbox, open-letter, and send tab over
// the Era mail API (`benilla-ui/src/script/mail.rs`) + `crate::ui_mail`'s feed/drain. Loaded
// right after MerchantFrame because it REUSES that file's global BenillaMoney_Set/_Clear
// coin helpers (postage/enclosed/COD displays) — they must be defined first.
"MailFrame.xml",
// The player-to-player trade window (decision 0592 P1): the two-sided offer over the trade
// API (`benilla-ui/src/script/trade.rs`) + `crate::ui_trade`'s feed/drain. Loaded after
// MerchantFrame because it REUSES that file's global BenillaMoney_Set coin helpers (the two
// gold displays) — they must be defined first.
"TradeFrame.xml",
// The item-text reader: letters (mail-made permanent copies) and, later, books/plaques —
// the ItemTextGet* seam (`benilla-ui/src/script/item_text.rs`) over `crate::ui_item_text`'s
// feed/drain. Loaded right after MailFrame (the same arc; needs only Fonts/UiPanels, both
// loaded above).
"ItemTextFrame.xml",
// The trainer window (decision 0237 phase 3): the read-only class/profession trainer list.
// Loaded right after MerchantFrame per the director's brief (kept adjacent even though this
// v1 uses plain-text costs, not MerchantFrame's coin helpers) and before the other NPC
// session windows below.
"TrainerFrame.xml",
// The bank window (decision 0604 phase 4): the vault, purchase ladder, and the 6 bank-bag
// popouts, over the C_Container feed already carrying container -1/5..10 (ui_items.rs) +
// the bank Lua surface (benilla-ui/src/script/bank.rs) + crate::ui_bank's feed/drain.
// Loaded AFTER MerchantFrame.xml (reuses its BenillaMoney_* coin helpers for the purchase
// row + purse) and AFTER BagFrame.xml, already above (its BenillaBagSlotTemplate/
// BenillaBagWindowTemplate + the BenillaBagSlot_*/BenillaBagFrame_* function family are
// reused verbatim — the 24 generic slots and the 6 bank-bag popouts are, respectively, a
// container-slot grid and ordinary container windows, exactly like the bag arc's own).
// Kept in the "NPC session windows grouped" cluster beside Trainer/Taxi.
"BankFrame.xml",
// The taxi-map window (decision 0484 phase 2): the flight-master session, driven by the
// taxi engine seam (`benilla-ui/src/script/taxi.rs`) over `crate::ui_taxi`'s feed/drain.
// Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
// needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney), both already loaded above.
"TaxiFrame.xml",
// The crafting window (decision 0437 phase 2): NOT an NPC session (opens off your own cast of
// an effect-47 opener spell, TradeSkillFrame.xml's own header comment) — loaded right after
// TrainerFrame per the same "player-state/profession window" grouping and because it depends
// on nothing TrainerFrame doesn't already need (Fonts/UiPanels/GameTooltip/ScrollTemplates,
// all loaded above).
"TradeSkillFrame.xml",
// The Enchanting window (decision 0437 phase 3): the exact twin of TradeSkillFrame above —
// same "opens off your own cast, not an NPC session" shape (CraftFrame.xml's own header
// comment) — loaded right after it so `BuildColoredListString` (a guarded GLOBAL
// TradeSkillFrame.xml defines) already exists when CraftFrame.xml reuses it without
// redefining (CraftFrame.xml's own header comment, deviation 2).
"CraftFrame.xml",
"LootFrame.xml",
// The group-loot roll popups (decision 0591): the Need/Greed/Pass dialogs, a sibling of
// LootFrame.xml (loaded right after it) over the `benilla-ui::script::loot_roll` seam
// (`crate::ui_loot_roll`'s feed/drain). NOT a UIPanel — four `frameStrata="DIALOG"` toplevel
// popups with their own hardcoded anchors — so it needs only Fonts (GameFontNormalSmall) +
// GameTooltip (the item/PASS/NEED/GREED hovers), both already loaded above.
"GroupLootFrame.xml",
// The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after
// MerchantFrame (the BenillaMoney coin helpers live there) and GossipFrame (shared parchment
// art conventions); its title/panel-button labels use the auto-centred ButtonText slot.
"QuestFrame.xml",
// The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an
// NPC session — loaded after MerchantFrame (the BenillaMoney coin helpers it reuses) and
// alongside QuestFrame (same arc); the 'L' binding below calls its ToggleQuestLog().
"QuestLogFrame.xml",
"ChatFrame.xml",
// The macro window (decision 0983): the editor + its name/icon popup, driven over the
// engine's OWN macro table (`benilla_ui::script::macros` — 1.12 macros have no server
// side, so this is the one window whose model is not an app feed). Needs UiPanels (the
// panel manager + `TabButtonTemplate`), ScrollTemplates (BOTH kits — the real one scrolls
// the body box, the faux one the icon grid), MicroMenu (`UpdateMicroButtons` on
// show/hide), and it must come AFTER SpellBookFrame, whose shift-click reaches
// `BenillaMacroFrame_AddMacroLine`. Before GameMenuFrame, whose Macros button opens it.
"MacroFrame.xml",
// The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
// the templates + script of the Options window's Keybindings category, over the
// engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
// GetBinding/SetBinding Lua API). Needs UiPanels (popup engine) and GameTooltip (the
// character-specific checkbox's hover); MUST load before OptionsFrame.xml, whose
// Keybindings body inherits these templates and calls this script at OnLoad.
"KeyBindingsPage.xml",
// The options window (the 0985 provenance split: 0950's era structure, 0978's
// 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
// cuts — bare fully-live sliders, no corner X; 0992's dropdown rows + the Nameplates
// page): the era-shaped OptionsFrame the game menu's Options button opens. Needs
// Fonts' objects, UiPanels' panel manager, and UIDropDownMenu's capsule template
// (all far above); sits right before GameMenuFrame so the menu — which reaches it by
// name at click time — stays the last, outermost layer.
"OptionsFrame.xml",
// The game menu (decision 0674): the frame ESC opens, plus the CAMP/QUIT dialogs and their
// event driver. LAST on purpose — it is the outermost layer of the shell, it depends on
// everything it touches being loaded (UiPanels' popup engine + panel manager, MicroMenu's
// UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing depends on it: the ESC
// chain and the micro button both reach it by name at call time.
"GameMenuFrame.xml",
] {
let entry = dir.join(file);
let text = match std::fs::read_to_string(&entry) {
Ok(t) => t,
Err(e) => {
error!("ui_script: reading {}: {e}", entry.display());
continue;
}
};
let doc = match benilla_ui::framexml::parse(&text) {
Ok(d) => d,
Err(e) => {
error!("ui_script: parsing {file}: {e}");
continue;
}
};
let report = benilla_ui::loader::load(script, &doc, &provider);
for w in &report.warnings {
warn!("ui_script({file}): {w}");
}
for e in &report.errors {
error!("ui_script({file}): {e}");
failures.push(format!("{file}: {e}"));
}
info!(
"ui_script: {file} loaded ({} frames materialized)",
report.frames
);
}
// The managed positions' startup pass (decision 0272): the ref applies
// UIPARENT_MANAGED_FRAME_POSITIONS once at load, then re-fires from the bottom bars'
// OnShow/OnHide. Every frame the table names now exists, so this is that load-time
// application; the stance bar's show/hide handles the rest at runtime.
if let Err(e) = script.run("UIParent_ManageFramePositions()") {
error!("ui_script: managed-positions bootstrap: {e}");
failures.push(format!("managed-positions bootstrap: {e}"));
}
failures
}
/// Feed synthetic `"player"`/`"target"` snapshots each frame (overriding the real feed, which finds
/// no avatar in a server-less capture) and fire the initial events once — proving the full chain
/// end-to-end on a screenshot: snapshot → `Unit*` bindings → Lua `OnEvent` → bars + text, for both
@@ -1159,3 +1159,240 @@ fn defaults_resets_the_controls_page_to_registered_defaults() {
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Drive the window a few frames so the frame-late fits (the scroll body, the tab widths)
/// converge: rects resolve after Lua runs, so OptionsScroll_Fit answers one frame behind.
fn settle(s: &mut UiScript) {
for _ in 0..4 {
s.resolve();
s.tick(0.016);
}
s.resolve();
}
/// B217: a search that matches across categories chains five group heads and every matched row
/// into one column — ~150 units past the page area, which before the page scrolled drew straight
/// out through the dialog border onto the world. Now the body is the scroll child: it grows to the
/// content, the bar and its trough appear, and everything past the fold is CLIPPED to the page
/// rect until scrolled to.
#[test]
fn a_broad_search_scrolls_the_page_instead_of_overflowing_it() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
settle(&mut s);
// Control first: a settled page fits, so there is no scroll and no bar at all — and the body
// sits exactly on the page rect, the seat every page's XML anchors were authored against.
assert_eq!(
s.eval::<f32>("return OptionsFrameContainerScroll:GetVerticalScrollRange()")
.unwrap(),
0.0,
"the Controls page fits its area"
);
for f in [
"OptionsFrameContainerScrollBar",
"OptionsFrameContainerScrollBarTrough",
] {
assert!(
!s.eval::<bool>(&format!("return {f}:IsVisible()")).unwrap(),
"{f} stays away while the page fits"
);
}
let page_h: f32 = s
.eval("return OptionsFrameContainerScroll:GetHeight()")
.unwrap();
assert!(
(s.eval::<f32>("return OptionsFrameContainerBody:GetHeight()")
.unwrap()
- page_h)
.abs()
< 0.5,
"the body is the page rect when nothing overflows"
);
// Now the broad search: every category surfaces, and the column outruns the page.
s.run("OptionsFrameSearchBox:SetText(\"e\")").unwrap();
settle(&mut s);
let range: f32 = s
.eval("return OptionsFrameContainerScroll:GetVerticalScrollRange()")
.unwrap();
assert!(
range > 100.0,
"the results outrun the page, so there is something to scroll (range {range})"
);
for f in [
"OptionsFrameContainerScrollBar",
"OptionsFrameContainerScrollBarTrough",
] {
assert!(
s.eval::<bool>(&format!("return {f}:IsVisible()")).unwrap(),
"{f} appears with the overflow"
);
}
// THE SYMPTOM: nothing under the page draws past its bottom edge any more. Every quad the
// page's own content emits carries the page rect as its clip (the engine's ScrollFrame
// mechanism), so the deepest DRAWN pixel is the page's own bottom — while the content's own
// rects still reach far below it, which is exactly the spill that used to be on screen.
let quads = s.extract();
let clip = quads
.iter()
.find_map(|q| q.clip)
.expect("the page clips its content");
assert!(
quads.iter().all(|q| q.clip.is_none_or(|c| c == clip)),
"one clip in play: the page area"
);
let deepest_rect = quads
.iter()
.filter(|q| q.clip.is_some())
.filter_map(|q| q.rect)
.map(|r| r.bottom)
.fold(f32::INFINITY, f32::min);
assert!(
deepest_rect < clip.bottom - 100.0,
"there is a real fold to make: the results reach {deepest_rect} against a page bottom of {}",
clip.bottom
);
let deepest_drawn = quads
.iter()
.filter_map(|q| Some((q.rect?, q.clip?)))
.map(|(r, c)| r.bottom.max(c.bottom))
.fold(f32::INFINITY, f32::min);
assert!(
deepest_drawn >= clip.bottom - 0.01,
"…and it is folded AT the page bottom, not spilling ({deepest_drawn} vs {})",
clip.bottom
);
// The last result is reachable: scrolling to the end brings it inside the page rect. (Widget
// coordinates here, not the extract's screen px — the window carries ERA_WINDOW_SCALE.)
let sf_bottom: f32 = s
.eval("return OptionsFrameContainerScroll:GetBottom()")
.unwrap();
let tail = |s: &UiScript| -> f32 { s.eval("return OptionsScroll_ContentBottom()").unwrap() };
assert!(
tail(&s) < sf_bottom,
"the tail starts below the fold ({} vs {sf_bottom})",
tail(&s)
);
s.run(&format!("OptionsFrameContainerScrollBar:SetValue({range})"))
.unwrap();
settle(&mut s);
assert!(
tail(&s) >= sf_bottom - 0.5,
"scrolling to the end brings the tail into the page ({} vs {sf_bottom})",
tail(&s)
);
// Clearing the search puts the page — and the bar with its trough — back.
s.run("OptionsFrameSearchBox:SetText(\"\")").unwrap();
settle(&mut s);
assert_eq!(
s.eval::<f32>("return OptionsFrameContainerScroll:GetVerticalScrollRange()")
.unwrap(),
0.0
);
for f in [
"OptionsFrameContainerScrollBar",
"OptionsFrameContainerScrollBarTrough",
] {
assert!(
!s.eval::<bool>(&format!("return {f}:IsVisible()")).unwrap(),
"{f} goes away with the results"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The bar's TROUGH — the recessed channel it rides in, the "background and border" a bare
/// arrows-and-knob bar was missing. It is the shared kit's template on 1.12's own
/// UI-Character-ScrollBar channel art over the ref's black backing, and it seats ITSELF on its bar
/// at the ref's hang: 31 wide against the bar's 16, 8 units left, and — the part B224 caught —
/// overhanging the bar by 21 above and 20 below, which is what drops each arrow button into the
/// 16-tall SOCKET the art carries for it. (The Keybindings page's own bar wears the same one —
/// keybindings_tests, where the harness has a real binding registry to overflow the list with.)
#[test]
fn the_page_scroll_bar_wears_the_trough_with_its_arrows_in_the_sockets() {
let mut s = harness_on(audio_harness());
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameSearchBox:SetText(\"e\")").unwrap();
settle(&mut s);
let bar = "OptionsFrameContainerScrollBar";
let trough = "OptionsFrameContainerScrollBarTrough";
let g = |f: &str, m: &str| s.eval::<f32>(&format!("return {f}:{m}()")).unwrap();
assert!(
(g(trough, "GetWidth") - 31.0).abs() < 0.01,
"the trough is the 31-wide channel"
);
assert!(
(g(bar, "GetWidth") - 16.0).abs() < 0.01,
"against a 16-wide bar"
);
assert!(
(g(bar, "GetLeft") - g(trough, "GetLeft") - 8.0).abs() < 0.01,
"the trough hangs the ref's 8 units left of the bar, so the bar rides it centred"
);
// THE SOCKET LAW (BenillaScrollTrough_Seat): the trough overhangs the bar by 21/20 — not the
// arrow-to-arrow 16/16 that B224 reported, which sat both arrows 4 units OUT of their sockets,
// riding the caps with bare socket showing beside them. Ref: ReputationFrame's trough at the
// scroll frame's +5/-4 against a UIPanelScrollBarTemplate bar at -16/+16.
assert!(
(g(trough, "GetTop") - g(bar, "GetTop") - 21.0).abs() < 0.01,
"trough top 21 above the bar"
);
assert!(
(g(bar, "GetBottom") - g(trough, "GetBottom") - 20.0).abs() < 0.01,
"trough bottom 20 below the bar"
);
// Read the same law off the ARROWS, which is what the eye actually judges: 5 units of top cap
// above the up arrow, 4 of bottom cap below the down arrow, and the 16-tall sockets between.
let up = format!("{bar}ScrollUpButton");
let down = format!("{bar}ScrollDownButton");
assert!((g(&up, "GetHeight") - 16.0).abs() < 0.01, "16-tall arrows");
assert!((g(&down, "GetHeight") - 16.0).abs() < 0.01);
assert!(
(g(trough, "GetTop") - g(&up, "GetTop") - 5.0).abs() < 0.01,
"the cap shows 5 above the up arrow"
);
assert!(
(g(&down, "GetBottom") - g(trough, "GetBottom") - 4.0).abs() < 0.01,
"and 4 below the down arrow"
);
// …and on THIS page the whole channel lands on the page rect: the scroll frame is the page
// area, so the bar takes the 21/20 inset rather than the trough poking out through the header
// divider above and the container's bottom below.
let sf = "OptionsFrameContainerScroll";
assert!((g(trough, "GetTop") - g(sf, "GetTop")).abs() < 0.01);
assert!((g(trough, "GetBottom") - g(sf, "GetBottom")).abs() < 0.01);
// The art: three channel slices from the one 1.12 file — top cap, stretched run, bottom cap —
// each spanning the channel's full width, stacked with no gap and no overlap.
let mut slices: Vec<_> = s
.extract()
.into_iter()
.filter_map(|q| match &q.content {
QuadContent::Texture { path: Some(p), .. } if p.contains("UI-Character-ScrollBar") => {
q.rect
}
_ => None,
})
.collect();
assert_eq!(slices.len(), 3, "top cap, stretched run, bottom cap");
slices.sort_by(|a, b| b.top.total_cmp(&a.top));
assert!(
slices
.windows(2)
.all(|w| (w[0].bottom - w[1].top).abs() < 0.01),
"the three stack flush into one channel: {slices:?}"
);
let width = slices[0].right - slices[0].left;
assert!(
slices
.iter()
.all(|r| (r.right - r.left - width).abs() < 0.01),
"…all at the channel's own width: {slices:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -510,3 +510,71 @@ fn shift_clicking_a_pet_button_picks_it_up_rather_than_casting() {
vec![vec![(3, CLAW_WORD & 0xFFFF_0000)]]
);
}
/// **The keybind pair** (decision 1052) — what a bound `BONUSACTIONBUTTONn` runs. The ref's
/// binding body calls `BonusActionButtonDown/Up`, one-liners onto `PetActionButtonDown/Up`
/// (l.218-231), so the whole lane is these two functions: press shows the pushed art, release
/// fires the slot.
///
/// The load-bearing half is what the key path does NOT do: it is a bare `CastPetAction`, with none
/// of `PetActionButton_OnClick`'s forks. Pinned below on the Attack token with the pet ALREADY
/// attacking — a left click there calls the attack off (the test above), while the key re-issues
/// it. That asymmetry is the reference's, and the only thing that could hide a regression into it
/// is a test that never presses a key on an active slot.
#[test]
fn the_keybind_pair_pushes_and_casts_without_the_clicks_forks() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_pet_bar(&s);
declare_token_strings(&s);
s.set_pet_actions(true, true, true, hunter_slots());
s.fire_event("PET_BAR_UPDATE", vec![]);
s.resolve();
let state = |s: &UiScript| {
s.eval::<String>("return BenillaPetActionButton1:GetButtonState()")
.unwrap()
};
// Down shows the pushed art and fires nothing (the ref's runOnUp shape).
s.run("BenillaPetActionButtonDown(1)").unwrap();
assert_eq!(state(&s), "PUSHED");
assert!(
s.take_pet_actions().is_empty(),
"the press is visual only — the ref fires on the release"
);
// Up releases the art and casts — slot 1 is Attack and the pet is already attacking, so a
// LEFT CLICK here would call it off instead. The key has no such fork.
s.run("BenillaPetActionButtonUp(1)").unwrap();
assert_eq!(state(&s), "NORMAL");
assert_eq!(s.take_pet_actions(), vec![1]);
assert_eq!(
s.take_pet_stop_attacks(),
0,
"IsPetAttackActive lives in OnClick, which a key press never reaches"
);
// The state guard is the whole re-entrancy story: an up with nothing pushed does nothing
// (a focus-stolen release, a stuck-latch sweep), and a second down does not re-fire.
s.run("BenillaPetActionButtonUp(1)").unwrap();
assert!(
s.take_pet_actions().is_empty(),
"an unmatched release fires nothing"
);
s.run("BenillaPetActionButtonDown(1) BenillaPetActionButtonDown(1)")
.unwrap();
assert_eq!(state(&s), "PUSHED");
s.run("BenillaPetActionButtonUp(1)").unwrap();
assert_eq!(s.take_pet_actions(), vec![1], "one press, one cast");
// An EMPTY slot is inert through the same path (CastPetAction's own guard): slot 2 of the
// hunter bar carries no name, and its button is hidden.
s.run("BenillaPetActionButtonDown(2) BenillaPetActionButtonUp(2)")
.unwrap();
assert!(
s.take_pet_actions().is_empty(),
"an unnamed slot queues nothing"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -85,6 +85,93 @@ fn loading_the_shipped_ui_queues_no_sounds() {
);
}
/// **Every texture path the shipped UI names RESOLVES in the real archives** — the tripwire for a
/// mis-typed `file=`, which the renderer reports by drawing a plain WHITE QUAD and nothing else.
///
/// A sprite that fails to resolve is `None` all the way to `ui_pass`, where a texture-less quad
/// samples the shared 1×1 white image (that fallback is what makes flat-shaded quads batch, so it
/// cannot itself be made loud). The bug it hid (1046): six `file=` attributes in
/// `SpellBookFrame.xml` were written with **doubled** separators — `Interface\\SpellBook\\…`, the
/// Lua escaping, in an XML attribute where a backslash is already literal. `normalize_path` only
/// folds case and slashes, so the doubled key missed the archive hash and the pet book shipped with
/// a white slab over its autocast ring and another under its tab row. Every gate was green.
///
/// The shape half runs everywhere; the resolve half needs client data and skips without it (a
/// hand-kept list of real file names would rot into agreeing with itself — the `text=` sweep's
/// argument below). Resolution goes through the renderer's own `sprite_key`, not a copy of it.
#[test]
fn every_shipped_texture_path_resolves_in_the_client_archives() {
use benilla_ui::framexml::{Element, TopLevel};
// `file=` on a texture element is an ARCHIVE path; `<Script file=>`/`<Include file=>` name our
// own source files and are top-level items, not elements, so walking elements can't see them.
fn walk(el: &Element, file: &str, out: &mut Vec<(String, String, String)>) {
for (key, value) in el.attrs() {
let archive_path = ["file", "bgfile", "edgefile"]
.contains(&key.to_ascii_lowercase().as_str())
&& !value.is_empty();
if archive_path {
out.push((file.to_string(), el.tag.clone(), value.clone()));
}
}
for child in &el.children {
walk(child, file, out);
}
}
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
let mut refs = Vec::new();
for entry in std::fs::read_dir(&dir).expect("assets/ui").flatten() {
let path = entry.path();
if path.extension().is_none_or(|e| e != "xml") {
continue;
}
let text = std::fs::read_to_string(&path).expect("read");
let file = path.file_name().unwrap_or_default().to_string_lossy();
let doc = benilla_ui::framexml::parse(&text).expect("parses");
for item in &doc.items {
match item {
TopLevel::Font(el) | TopLevel::Template(el) | TopLevel::Instance(el) => {
walk(el, &file, &mut refs);
}
TopLevel::Include(_) | TopLevel::Script(_) => {}
}
}
}
// Never let the sweep pass by matching nothing — the shipped UI names hundreds of textures.
assert!(refs.len() >= 200, "only {} texture paths swept", refs.len());
// The shape half: a doubled separator is the Lua escaping written into XML, and it resolves to
// nothing. Checked without the client so a data-less machine still catches this exact class.
for (file, tag, path) in &refs {
assert!(
!path.contains("\\\\"),
"{file}: <{tag} file=\"{path}\"> has DOUBLED separators — XML attributes are not Lua \
strings, so the backslashes stay doubled, the archive lookup misses, and the widget \
draws as a white quad"
);
}
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!(
"skipping the resolve half: no client data at {}",
data.display()
);
return;
}
let chain = benilla_formats::open_chain(&data).expect("open chain");
let missing: Vec<String> = refs
.iter()
.filter(|(_, _, path)| !chain.contains(&crate::assets::sprite_key(path)))
.map(|(file, tag, path)| format!("{file}: <{tag} file=\"{path}\">"))
.collect();
assert!(
missing.is_empty(),
"texture paths that resolve to nothing (each draws as a white quad): {missing:#?}"
);
}
/// **Every `text=` in the shipped UI is answerable against the REAL `GlobalStrings.lua`** — the
/// tripwire for the defect that put "CREATE_MACROS" across the macro window's title bar (0991).
///
@@ -248,3 +335,75 @@ fn every_texture_frame_outranks_its_status_bars() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The boot phase is inert.** `Fonts.xml` — the only file loaded at `Startup` since 1051 — is a
/// pure registry: it materializes no frames, so nothing of the in-game UI can draw, tick or ring
/// before a character is in the world. (1033's tack was a load-time handler firing on the login
/// screen; this is the structural half of that fix.)
#[test]
fn the_boot_phase_materializes_no_frames() {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/Fonts.xml");
let text = std::fs::read_to_string(&dir).expect("Fonts.xml");
let doc = benilla_ui::framexml::parse(&text).expect("parses");
let provider = |_: &str| -> Option<String> { None };
let report = benilla_ui::loader::load(&s, &doc, &provider);
assert_eq!(
report.frames, 0,
"the boot-phase load materialized {} frame(s) — the login screen is meant to carry none",
report.frames
);
assert!(report.errors.is_empty(), "{:?}", report.errors);
}
/// **The font registry alone covers the WHOLE glyph-atlas bake plan** — the property that makes
/// 1051's split safe, asserted rather than reasoned.
///
/// Our native glue screens share the one atlas, and that atlas bakes **once**, on the first
/// `Update`, from `script.font_objects()` (`ui_text/atlas.rs` — the face size-list extension *and*
/// the outlined-cell census). So the registry must be complete at boot even though the other 55
/// manifest files now load at world entry. It is: three font objects live outside `Fonts.xml`
/// (`GameFontNormalMed1` 13, `OptionsFontHighlightMedium` 14, `OptionsFontHighlightHuge` 20), all
/// un-outlined, all at heights `Fonts.xml` already declares.
///
/// If this fails, someone added a font object to a non-`Fonts.xml` file with a new height or an
/// outline. In-game text would silently lose that variant for the whole session — the bake has
/// already happened by the time the file loads. Either move it into `Fonts.xml` or make the atlas
/// rebakeable.
#[test]
fn the_font_registry_alone_covers_the_whole_bake_plan() {
let plan = |whole: bool| -> std::collections::BTreeSet<(String, String, String)> {
let mut s = benilla_ui::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
if whole {
let _ = super::load_default_ui(&s);
} else {
let _ = super::load_font_registry(&s);
}
s.font_objects()
.iter()
.map(|f| {
(
f.font.clone().unwrap_or_default().to_ascii_lowercase(),
format!("{:?}", f.height),
format!("{:?}", f.outline),
)
})
.collect()
};
let whole = plan(true);
let registry_only = plan(false);
let missing: Vec<_> = whole.difference(&registry_only).collect();
assert!(
missing.is_empty(),
"these (font, height, outline) combinations exist in the full manifest but NOT in the \
boot-time font registry, so the atlas would never bake them: {missing:#?}"
);
// Never let this pass by finding nothing on both sides.
assert!(
registry_only.len() >= 19,
"only {} combinations swept — the registry sweep broke",
registry_only.len()
);
}
@@ -52,6 +52,7 @@ fn book() -> SpellBookState {
passive: false,
current: false,
cooldown: None,
..Default::default()
},
SpellSlotView {
spell_id: 2136,
@@ -61,6 +62,7 @@ fn book() -> SpellBookState {
passive: false,
current: false,
cooldown: None,
..Default::default()
},
SpellSlotView {
spell_id: 168,
@@ -70,14 +72,33 @@ fn book() -> SpellBookState {
passive: false,
current: false,
cooldown: None,
..Default::default()
},
],
}
}
/// The centre of a laid-out frame, for a real mouse click through the hit-test — the file's own
/// idiom, lifted out of `shipped_spellbook_drives_end_to_end` so the pet test shares it.
fn center(s: &UiScript, name: &str) -> (f32, f32) {
let l: f32 = s.eval(&format!("return {name}:GetLeft()")).unwrap();
let r: f32 = s.eval(&format!("return {name}:GetRight()")).unwrap();
let t: f32 = s.eval(&format!("return {name}:GetTop()")).unwrap();
let b: f32 = s.eval(&format!("return {name}:GetBottom()")).unwrap();
((l + r) * 0.5, (t + b) * 0.5)
}
/// One full press/release of `button` on the named frame.
fn click(s: &mut UiScript, name: &str, button: &str) {
s.resolve();
let (x, y) = center(s, name);
s.mouse_button(x, y, button, true);
s.mouse_button(x, y, button, false);
}
/// The loader itself: every file the window depends on parses and materializes with no errors —
/// the window + close + prev/next page buttons + 12 spell buttons (each with a Cooldown child)
/// + 8 skill-line tabs = 36 frames.
/// + 8 skill-line tabs + the 3 Spell/Pet toggle tabs.
#[test]
fn shipped_spellbook_loads_clean() {
let s = UiScript::new().unwrap();
@@ -96,9 +117,9 @@ fn shipped_spellbook_loads_clean() {
report.errors
);
assert_eq!(
report.frames, 36,
report.frames, 39,
"window + close + prev/next + 12 spell buttons (each with a Cooldown child) + 8 \
skill-line tabs"
skill-line tabs + the 3 Spell/Pet toggle tabs (decision 1032)"
);
}
@@ -157,13 +178,6 @@ fn shipped_spellbook_drives_end_to_end() {
"book id 7 is past the 2-spell Fire tab — disabled"
);
let center = |s: &UiScript, name: &str| -> (f32, f32) {
let l: f32 = s.eval(&format!("return {name}:GetLeft()")).unwrap();
let r: f32 = s.eval(&format!("return {name}:GetRight()")).unwrap();
let t: f32 = s.eval(&format!("return {name}:GetTop()")).unwrap();
let b: f32 = s.eval(&format!("return {name}:GetBottom()")).unwrap();
((l + r) * 0.5, (t + b) * 0.5)
};
s.resolve();
let (x1, y1) = center(&s, "BenillaSpellButton1");
@@ -347,3 +361,202 @@ fn set_checked_uses_blizzard_bool_coercion() {
assert_eq!(got, want, "SetChecked({arg})");
}
}
/// A hunter's pet book: Growl (autocast ON, on cooldown), Claw (autocast OFF), Avoidance (a
/// passive — not autocastable). Three spells so the tab row raises and the page has content.
fn pet_book() -> benilla_ui::script::PetBookState {
benilla_ui::script::PetBookState {
token: Some("PET".into()),
slots: vec![
SpellSlotView {
spell_id: 2649,
name: "Growl".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Ability_Physical_Taunt".into()),
cooldown: Some((9400, 5000, true)),
autocast: Some((true, true)),
packed: 0xC100_0000 | 2649,
..Default::default()
},
SpellSlotView {
spell_id: 16827,
name: "Claw".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
autocast: Some((true, false)),
packed: 0x8100_0000 | 16827,
..Default::default()
},
SpellSlotView {
spell_id: 3025,
name: "Avoidance".into(),
texture: Some("Interface\\Icons\\Spell_Nature_SpiritArmor".into()),
passive: true,
autocast: Some((false, false)),
packed: 0x0100_0000 | 3025,
..Default::default()
},
],
}
}
/// **The pet tab, end to end through the shipped XML** (decision 1032): the toggle row appears
/// only once there are pet spells, clicking the pet tab switches the book, the page renders the
/// pet's own spells with their autocast overlay, the skill-line strip goes away, the title becomes
/// the class token's label, and a right-click flips autocast instead of casting.
#[test]
fn the_pet_tab_switches_books_and_renders_the_pets_spells() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "SpellBookFrame.xml");
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.set_spellbook(book());
// ── No pet: no toggle row, and the pet book cannot be opened at all (ref l.11-14) ──────────
s.run("ToggleSpellBook(BOOKTYPE_SPELL)").unwrap();
assert!(s.errors().is_empty(), "open errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaSpellBookFrameTabButton1:IsVisible()")
.unwrap(),
"with no pet spells the ref hides the whole toggle row"
);
s.run("ToggleSpellBook(BOOKTYPE_PET)").unwrap();
assert!(
s.eval::<bool>("return BenillaSpellBookFrame.bookType == BOOKTYPE_SPELL")
.unwrap(),
"asking for a pet book you have not got does nothing — not even close the window"
);
assert!(s
.eval::<bool>("return BenillaSpellBookFrame:IsVisible()")
.unwrap());
// ── A pet arrives: the row appears on the next repaint ────────────────────────────────────
s.set_pet_book(pet_book());
s.fire_event("SPELLS_CHANGED", vec![]);
assert!(s.errors().is_empty(), "repaint errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaSpellBookFrameTabButton1:IsVisible()")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaSpellBookFrameTabButton2:GetText()")
.unwrap(),
"Pet",
"the label is PET_TYPE_<token>, not the token"
);
// ── Click the pet tab ─────────────────────────────────────────────────────────────────────
click(&mut s, "BenillaSpellBookFrameTabButton2", "LeftButton");
assert!(s.errors().is_empty(), "tab errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return BenillaSpellBookFrame.bookType == BOOKTYPE_PET")
.unwrap());
assert_eq!(
s.eval::<String>("return BenillaSpellBookTitleText:GetText()")
.unwrap(),
"Pet",
"the window title becomes the pet's, not SPELLBOOK"
);
assert!(
!s.eval::<bool>("return BenillaSpellBookSkillLineTab1:IsVisible()")
.unwrap(),
"the pet book has no skill lines — the whole strip hides (ref l.124)"
);
// The page: book ids are the button ids themselves on this book, so button 1 is Growl and
// button 3 (id="2") is Claw — the same column-major id map the spell book uses.
assert_eq!(
s.eval::<String>("return BenillaSpellButton1SpellName:GetText()")
.unwrap(),
"Growl"
);
assert_eq!(
s.eval::<String>("return BenillaSpellButton3SpellName:GetText()")
.unwrap(),
"Claw"
);
// The autocast overlay follows GetSpellAutocast's FIRST return (can it), not the second.
assert!(s
.eval::<bool>("return BenillaSpellButton1AutoCastable:IsVisible()")
.unwrap());
assert!(
!s.eval::<bool>("return BenillaSpellButton5AutoCastable:IsVisible()")
.unwrap(),
"a passive is not autocastable"
);
// …and the sparkle follows the SECOND (is it on).
assert!(s
.eval::<bool>("return BenillaSpellButton1.autocasting == 1")
.unwrap());
assert!(s
.eval::<bool>("return BenillaSpellButton3.autocasting == nil")
.unwrap());
// ── The clicks ────────────────────────────────────────────────────────────────────────────
// Left: a pet cast, on the pet queue and NOT the player's.
click(&mut s, "BenillaSpellButton1", "LeftButton");
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert_eq!(s.take_pet_spell_casts(), vec![2649]);
assert!(s.take_spell_casts().is_empty());
assert!(s.take_pet_spell_autocasts().is_empty());
// Right: autocast, never a cast (ref l.284-285).
click(&mut s, "BenillaSpellButton3", "RightButton");
assert!(
s.errors().is_empty(),
"right-click errors: {:?}",
s.errors()
);
assert_eq!(s.take_pet_spell_autocasts(), vec![16827]);
assert!(
s.take_pet_spell_casts().is_empty(),
"a right-click on the pet page must not also cast"
);
// ── The two reference QUIRKS, asserted on purpose (decisions 1030/1032) ───────────────────
// The pet page ignores its page number: `SpellBook_GetSpellID`'s pet arm is a bare `return id`.
assert_eq!(
s.eval::<i64>("return BenillaSpellBook_GetSpellID(1)")
.unwrap(),
1
);
s.run(r#"SPELLBOOK_PAGENUMBERS["pet"] = 2"#).unwrap();
assert_eq!(
s.eval::<i64>("return BenillaSpellBook_GetSpellID(1)")
.unwrap(),
1,
"DO NOT FIX: the ref's pet arm has no page term (decision 1032)"
);
// …and the Next arrow writes the SPELL book's counter, leaving the pet page where it was.
s.run(r#"SPELLBOOK_PAGENUMBERS["pet"] = 1"#).unwrap();
click(&mut s, "BenillaSpellBookNextPageButton", "LeftButton");
assert!(s.errors().is_empty(), "page errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>(r#"return SPELLBOOK_PAGENUMBERS["pet"]"#)
.unwrap(),
1,
"DO NOT FIX: the ref writes SPELLBOOK_PAGENUMBERS[selectedSkillLine] on both books"
);
assert_eq!(
s.eval::<String>("return BenillaSpellButton1SpellName:GetText()")
.unwrap(),
"Growl",
"so the page does not turn"
);
// ── The pet leaves: the window closes and reverts (ref l.147-151) ─────────────────────────
s.set_pet_book(benilla_ui::script::PetBookState::default());
s.fire_event("SPELLS_CHANGED", vec![]);
assert!(s.errors().is_empty(), "teardown errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return BenillaSpellBookFrame:IsVisible()")
.unwrap(),
"a pet book with no pet closes rather than showing an empty page"
);
assert!(s
.eval::<bool>("return BenillaSpellBookFrame.bookType == BOOKTYPE_SPELL")
.unwrap());
}
+17 -3
View File
@@ -310,6 +310,10 @@ fn build_book(
// after the build (they need the live form/cooldown state, not the catalog).
current: false,
cooldown: None,
// Player-book slots: `GetSpellAutocast` answers (nil, nil) for this book by the
// reference's own short-circuit, and there is no packed word behind a known spell.
autocast: None,
packed: 0,
});
}
tabs.push(SpellTabView {
@@ -331,9 +335,19 @@ fn build_book(
/// modeled — module doc.)
fn spell_sort_key(catalog: &SpellCatalog, spell_id: u32) -> (String, u32, String) {
let d = catalog.get(spell_id);
let name = d.map(|d| d.name.clone()).unwrap_or_default();
let rank_str = d.and_then(|d| d.rank.clone()).unwrap_or_default();
(name, leading_number(&rank_str), rank_str)
spell_sort_key_of(
d.map(|d| d.name.as_str()).unwrap_or_default(),
d.and_then(|d| d.rank.as_deref()),
)
}
/// [`spell_sort_key`] over an already-resolved `(name, rank)` — the **pet** book's entry point
/// (`crate::ui_pet_book`). The reference sorts both arrays with the same comparator `0x4b30c0`
/// (`0x4b2fd0` calls it for `0xb700f0` and `0xb6f098` alike), so the two books share this and not
/// a copy of it.
pub(crate) fn spell_sort_key_of(name: &str, rank: Option<&str>) -> (String, u32, String) {
let rank_str = rank.unwrap_or_default().to_string();
(name.to_string(), leading_number(&rank_str), rank_str)
}
/// The first run of ASCII digits in `s` as a number, or `0` when there is none — the client's own
@@ -0,0 +1,26 @@
//! Throwaway: print the interrupt-flag columns for named spell ids, so a claim about what
//! cancels a spell locally can be checked against the real shipped Spell.dbc.
fn main() -> anyhow::Result<()> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let catalog = benilla_formats::load_spell_catalog(&mut chain)?;
for arg in std::env::args().skip(1) {
let id: u32 = arg.parse()?;
let Some(d) = catalog.get(id) else {
println!("{id}: no row");
continue;
};
println!(
"{id} {:?}\n InterruptFlags={:#x} (movement bit 0x1 = {})\n AuraInterruptFlags={:#x} ChannelInterruptFlags={:#x}\n Attributes={:#x} AttributesEx={:#x} AttributesEx2={:#x}",
d.name,
d.interrupt_flags,
d.interrupt_flags & 0x1 != 0,
d.aura_interrupt_flags,
d.channel_interrupt_flags,
d.attributes,
d.attributes_ex,
d.attributes_ex2,
);
}
Ok(())
}
@@ -0,0 +1,27 @@
//! Throwaway: print the cast-arm targeting facts for named spell ids — `Targets`,
//! `EffectImplicitTargetA[0]`, the three effects, and the item-target gate columns — so a claim
//! about which targeting seam a spell arms can be checked against the real shipped Spell.dbc
//! instead of reasoned about.
fn main() -> anyhow::Result<()> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
let mut chain = benilla_formats::open_chain(&data)?;
let catalog = benilla_formats::load_spell_catalog(&mut chain)?;
for arg in std::env::args().skip(1) {
let id: u32 = arg.parse()?;
let Some(d) = catalog.get(id) else {
println!("{id}: no row");
continue;
};
println!(
"{id} {:?}\n Targets={:#06x} implicitA1={} effect_1={}\n equippedClass={} subclassMask={:#x} invTypeMask={:#x}",
d.name,
d.targets,
d.implicit_target_a1,
d.effect_1,
d.equipped_item_class,
d.equipped_item_subclass_mask,
d.equipped_item_inventory_type_mask,
);
}
Ok(())
}
+2
View File
@@ -58,6 +58,8 @@ pub use lock::{
};
mod lock_type;
pub use lock_type::{load_lock_type_catalog, LockTypeCatalog};
mod pet_name_token;
pub use pet_name_token::{load_pet_name_tokens, PetNameTokens, PET_NAME_TOKEN_FALLBACK};
mod pet_stats;
pub use pet_stats::{
load_pet_loyalty_names, load_pet_personalities, PetHappiness, PetLoyaltyNames,
@@ -0,0 +1,125 @@
//! `ChrClasses.dbc`'s **pet name token** — `HasPetSpells()`'s second return, and the only thing
//! that decides whether the spellbook's second tab reads "Pet" or "Demon" (decision 1032).
//!
//! `HasPetSpells 0x4b4410` pushes it verbatim off a class-indexed record table:
//!
//! ```text
//! 0x4b445d eax = [player + 0x110] ; the descriptor fields
//! 0x4b4463 eax = byte [fields + 0x79] ; UNIT_FIELD_BYTES_0 byte 1 = the CLASS
//! 0x4b446b if (class < 0 || class > [0xc0def8]) -> the out-of-range arm
//! 0x4b4473 ecx = [0xc0def4] ; the class record table
//! 0x4b4479 eax = ecx[class] ; 1-BASED — index 0 is never a class
//! 0x4b447c edx = [rec + 0x10] ; <- the token string
//! 0x4b44a6 (player did not resolve) edx = "PET" ; the literal 0x846a40
//! ```
//!
//! **`rec + 0x10` is field 4, and the file says which field that is**: dumped from the real 5875
//! `ChrClasses.dbc` (9 rows, 17 columns, 0x44-byte records), field 4 is `"PET"` in every row except
//! Warlock (id 9), which is `"DEMON"`. Field 5 is the class name (`Warrior`/`Paladin`/…), so a
//! one-column slip would put "Warlock" on the tab — plausible enough to ship, which is why
//! [`tests`] anchors both columns.
//!
//! FrameXML then does `getglobal("PET_TYPE_"..token)` (`SpellBookFrame.lua:173`) against
//! `PET_TYPE_PET = "Pet"` / `PET_TYPE_DEMON = "Demon"` (`GlobalStrings.lua:3057-3058`) — so the
//! token is a **key**, never display text, and it is right that it is not localized here.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{parse, str_at, u32_at};
const CHR_CLASSES: &str = "DBFilesClient\\ChrClasses.dbc";
/// `ChrClasses.dbc`'s column count in 5875 (the header's `field_count`; `benilla-dbc` enforces it).
const CHR_CLASSES_FIELDS: usize = 17;
/// The pet-name-token column — byte `0x10` in the client's own record read, i.e. field 4.
const PET_NAME_TOKEN_FIELD: usize = 0x10 / 4;
/// The literal the client pushes when the player object does not resolve (`0x846a40`). Also the
/// value nine of the ten class rows carry, which is why an unloaded table degrades invisibly.
pub const PET_NAME_TOKEN_FALLBACK: &str = "PET";
/// `ChrClasses.dbc` → class id ⇒ pet name token.
#[derive(Debug, Default, Clone)]
pub struct PetNameTokens(HashMap<u32, String>);
impl PetNameTokens {
/// The token for a class id, or the client's own [`PET_NAME_TOKEN_FALLBACK`] for a class with
/// no row. Mirrors `0x4b44a6`: the reference never answers nil here, only ever a string.
pub fn token(&self, class: u32) -> &str {
self.0
.get(&class)
.map_or(PET_NAME_TOKEN_FALLBACK, String::as_str)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
fn schema() -> Schema {
let mut s = Schema::new("ChrClasses");
for i in 0..CHR_CLASSES_FIELDS {
// Only the token column is read; everything else stays an opaque dword. The class NAME
// block (field 5 up) is deliberately not decoded — nothing here displays it.
let ty = if i == PET_NAME_TOKEN_FIELD {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// Load `ChrClasses.dbc`'s pet-name-token column from the patch chain.
pub fn load_pet_name_tokens(chain: &mut Chain) -> Result<PetNameTokens> {
let bytes = chain
.read_file(CHR_CLASSES)
.with_context(|| format!("reading {CHR_CLASSES}"))?;
let rs = parse(&bytes, schema(), "ChrClasses.dbc")?;
let mut by_id = HashMap::new();
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(token) = str_at(&rs, r, PET_NAME_TOKEN_FIELD) {
by_id.insert(id, token);
}
}
Ok(PetNameTokens(by_id))
}
#[cfg(test)]
mod tests {
use super::*;
fn chain() -> Option<crate::chain::Chain> {
let data = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../WoW/Data");
if !data.is_dir() {
eprintln!("skipping: vanilla client not present at {}", data.display());
return None;
}
Some(crate::open_chain(&data).expect("open chain"))
}
/// The real 5875 table, byte-anchored on the one column that matters. The neighbouring field
/// holds the class NAME, so a one-column slip reads "Warlock"/"Hunter" — which would still
/// resolve a `PET_TYPE_*` global lookup to nil and blank the tab, i.e. fail silently.
#[test]
fn warlocks_pet_is_a_demon_and_everyone_elses_is_a_pet() {
let Some(mut chain) = chain() else { return };
let t = load_pet_name_tokens(&mut chain).expect("load ChrClasses.dbc");
assert!(!t.is_empty());
assert_eq!(t.token(9), "DEMON", "Warlock");
for (class, who) in [(1, "Warrior"), (3, "Hunter"), (11, "Druid")] {
assert_eq!(t.token(class), "PET", "{who}");
}
// 6 and 10 have no row in 1.12 (Death Knight / Monk arrive later); the client's own
// out-of-range arm answers the literal, and so does this.
assert_eq!(t.token(6), PET_NAME_TOKEN_FALLBACK);
assert_eq!(t.token(0), PET_NAME_TOKEN_FALLBACK);
}
}
@@ -414,6 +414,24 @@ impl SpellDisplay {
self.attributes & (ATTR_DO_NOT_DISPLAY | ATTR_IS_TRADESKILL) == 0 && self.cast_ui == 0
}
/// Whether this spell appears in the **pet's** book — a *different* add-gate from
/// [`Self::in_spellbook`]'s, and deliberately narrower (decision 1032). `0x4b2f90`, the pet
/// book's whole append routine, tests the record's existence and then exactly one bit:
///
/// ```text
/// 0x4b2fa8 mov dl, byte ptr [rec + 0x18] ; Attributes, byte 0
/// 0x4b2fab test dl, dl
/// 0x4b2fad js <drop> ; the SIGN of the byte = 0x80 = DO_NOT_DISPLAY
/// 0x4b2fb4 [0xb6f098 + 4*count++] = spellId
/// ```
///
/// No `IS_TRADESKILL` leg and no `castUI` leg — the player book's other two gates simply are
/// not there. Reusing `in_spellbook` here would be a guess that happens to agree on every
/// vmangos pet list today and would diverge the moment one didn't.
pub fn in_pet_book(&self) -> bool {
self.attributes & ATTR_DO_NOT_DISPLAY == 0
}
/// The **melee auto-attack** — `Effect[0] == SPELL_EFFECT_ATTACK (78)`, the client's own
/// effect-type trigger for the equipped-weapon icon substitution (decision 0231; wow-re
/// `attack-icon-substitution.md`, resolvers `0x4b3f8a`/`0x4e59de`). In 1.12 the only spell
+4 -4
View File
@@ -130,10 +130,10 @@ pub use opcode_names::opcode_name;
pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket};
pub use parse::parse_server;
pub use pet::{
pet_action, pet_cancel_aura, pet_set_action, pet_stop_attack, PetActionEntry, PetMode,
PetSpellCooldown, PetSpells, PET_ACTION_SLOTS, PET_ACT_COMMAND, PET_ACT_DISABLED,
PET_ACT_ENABLED, PET_ACT_PASSIVE, PET_ACT_REACTION, PET_AUTOCAST_ALLOWED, PET_AUTOCAST_ON,
PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW, PET_COMMAND_STAY,
pet_action, pet_cancel_aura, pet_set_action, pet_spell_autocast, pet_stop_attack,
PetActionEntry, PetMode, PetSpellCooldown, PetSpells, PET_ACTION_SLOTS, PET_ACT_COMMAND,
PET_ACT_DISABLED, PET_ACT_ENABLED, PET_ACT_PASSIVE, PET_ACT_REACTION, PET_AUTOCAST_ALLOWED,
PET_AUTOCAST_ON, PET_COMMAND_ATTACK, PET_COMMAND_DISMISS, PET_COMMAND_FOLLOW, PET_COMMAND_STAY,
PET_COOLDOWN_PERMANENT, PET_REACT_AGGRESSIVE, PET_REACT_DEFENSIVE, PET_REACT_PASSIVE,
PET_STATE_BAR_DISABLED, PET_TYPE_SPELL_FIRST, PET_TYPE_SPELL_LAST, PET_UNUSABLE_UNIT_FLAGS,
};
@@ -436,6 +436,28 @@ pub fn pet_cancel_aura(pet_guid: u64, spell_id: u32) -> Vec<u8> {
body
}
/// Body of `CMSG_PET_SPELL_AUTOCAST` (opcode 755): `u64 petGuid`, `u32 spellId`, `u8 state` —
/// **thirteen** bytes, and the trailing field is a byte (decision 1032).
///
/// Both ends agree independently. The client builds it at `0x4bcd5f`–`0x4bcdb2`: `push 0x2f3`, the
/// guid pair through the u64 writer `0x418370`, the spell id through the u32 writer `0x418190`,
/// then `(word >> 30) & 0xFFFFFF01` through **`0x418070`**, the *byte* writer — which is also why
/// that mask ends in `01`. vmangos reads `guid, spellId, state` in that order with `state` a
/// `uint8` (`PetSpellAutocast::ReadFromWorldPacket`, `Server/Packets/Pet.cpp:44-49`).
///
/// **This is the pet SPELLBOOK's autocast verb, not the pet bar's.** The bar's right click sends
/// [`pet_set_action`], whose body names a slot *position*; the book has no positions, so this one
/// names the spell. The server's handler is correspondingly different — it re-derives which slots
/// to update itself (`HandlePetSpellAutocastOpcode`, `PetHandler.cpp:451-478`), gating on
/// `pet->HasSpell(spellId) && Spells::IsAutocastable(spellId)`. No reply packet.
pub fn pet_spell_autocast(pet_guid: u64, spell_id: u32, enabled: bool) -> Vec<u8> {
let mut body = Vec::with_capacity(13);
body.extend_from_slice(&pet_guid.to_le_bytes());
body.extend_from_slice(&spell_id.to_le_bytes());
body.push(u8::from(enabled));
body
}
/// Body of `CMSG_PET_SET_ACTION` (opcode 372): `u64 petGuid` then one or two
/// `(u32 position, u32 packedData)` pairs. The server distinguishes the forms **by body size
/// alone** — 24 bytes ⇒ two entries, anything else ⇒ one (`PetSetAction::ReadFromWorldPacket`,
@@ -70,4 +70,24 @@ impl WorldWriter {
&messages::pet_set_action(pet_guid, entries),
)
}
/// Flip one pet **spellbook** entry's autocast (`CMSG_PET_SPELL_AUTOCAST`, layout in
/// [`messages::pet_spell_autocast`]) — `ToggleSpellAutocast`'s send (decision 1032).
///
/// The fifth verb, and the one easy to mistake for the fourth: the pet BAR's autocast right
/// click is [`WorldWriter::pet_set_action`] with one entry (a slot and its word), while this
/// names a **spell id**, because the book it comes from has no slots. Same bit, same
/// server-side effect; different opcode, different body, different handler. Like the rest of
/// this file: no reply.
pub fn pet_spell_autocast(
&mut self,
pet_guid: u64,
spell_id: u32,
enabled: bool,
) -> Result<()> {
self.send(
opcode::CMSG_PET_SPELL_AUTOCAST,
&messages::pet_spell_autocast(pet_guid, spell_id, enabled),
)
}
}
+3 -1
View File
@@ -139,7 +139,9 @@ pub use shapeshift::ShapeshiftFormView;
pub use skills::{SkillEntry, SkillsState};
pub use social::{FriendInfo, SocialRequest, SocialState, WhoInfo};
pub use sound::SoundRequest;
pub use spellbook::{resolve_spell_by_name, SpellBookState, SpellSlotView, SpellTabView};
pub use spellbook::{
resolve_spell_by_name, PetBookState, SpellBookState, SpellSlotView, SpellTabView,
};
pub use talent::{TalentPrereqView, TalentTabView, TalentUiState, TalentView};
pub use taxi::{TaxiNodeType, TaxiUiNode, TaxiUiState};
pub use tooltip_spell::SpellTooltipView;
+14
View File
@@ -241,6 +241,11 @@ pub(crate) struct Model {
/// `GetSpellTabInfo`/`GetSpellName`/… bindings read ([`spellbook`]). Durable player state like
/// `actions` above, never `Option` — "no known spells yet" is simply empty vectors.
pub(crate) spellbook: spellbook::SpellBookState,
/// The **pet's** book (decision 1032) — a second flat slot list, no tabs, with its own
/// add-gate and its own class token ([`spellbook::PetBookState`]). Held apart from
/// [`Self::spellbook`] because the reference holds two arrays too (`0xb700f0` / `0xb6f098`)
/// and every `bookType`-taking binding is a fork between them, never a filter over one.
pub(crate) pet_book: spellbook::PetBookState,
/// The player's **macros** (decision 0983) — the one game-state table this crate owns
/// outright, because 1.12 macros have no server side at all ([`macros`]'s module docs). The
/// app seeds it from `benilla/macros/…` and reads it back to persist.
@@ -258,6 +263,12 @@ pub(crate) struct Model {
/// Spell ids `CastSpell` queued since the app's last [`super::UiScript::take_spell_casts`]
/// drain.
pub(crate) spell_casts: Vec<u32>,
/// Pet spell ids `CastSpell(id, "pet")` queued — a separate list because the wire verb is a
/// separate opcode (`CMSG_PET_ACTION` with a synthesized type-1 word, `0x4b34ce`).
pub(crate) pet_spell_casts: Vec<u32>,
/// Pet spell ids `ToggleSpellAutocast` queued — `CMSG_PET_SPELL_AUTOCAST 0x2F3`, which names a
/// spell rather than the pet bar's slot ([`Self::pet_autocast_toggles`] is the bar's).
pub(crate) pet_spell_autocasts: Vec<u32>,
/// Whether the app's own cast lifecycle holds something `SpellStopCasting()` can stop — a
/// running auto-repeat or an in-flight cast; a channel is NOT stoppable there (wow-re
/// `esc-stopcasting.md`; pushed each frame by the app's cast feed,
@@ -790,11 +801,14 @@ impl Model {
action_sets: Vec::new(),
ui_errors: Vec::new(),
spellbook: spellbook::SpellBookState::default(),
pet_book: spellbook::PetBookState::default(),
macros: macros::MacroState::default(),
macros_dirty: false,
macros_generation: 0,
macro_icons: Vec::new(),
spell_casts: Vec::new(),
pet_spell_casts: Vec::new(),
pet_spell_autocasts: Vec::new(),
casting: false,
spell_stop: false,
spell_targeting: false,
+20 -3
View File
@@ -48,15 +48,32 @@ fn with_scroll<T>(
/// `GetVerticalScrollRange()`'s live computation: `max(0, child_height − frame_height)` from the
/// **resolved** rects — `0.0` when either is unresolved or there is no child. Never cached: the
/// scroll offset always clamps against the current layout, not a stale snapshot.
///
/// In **LOCAL units**, each height divided by its own frame's effective scale — i.e. exactly
/// `child:GetHeight() - self:GetHeight()`, the widget contract this method states. The resolved
/// rects are screen px, and the scroll offset is NOT: `SetVerticalScroll` becomes the scroll
/// child's anchor y-offset, which the solver multiplies by that child's scale (`resolve`'s
/// override; `anchor_resolve_y`). Reporting a screen-px range against a local-unit offset makes
/// every SCALED ScrollFrame under-scroll by exactly its scale — the bar's max is the range, so it
/// stops short of the end and the tail of the content is unreachable. Invisible at scale 1.0,
/// which is every 1.12-native window; the era-scaled options window (ERA_WINDOW_SCALE 0.78) is the
/// first real-scroll customer that isn't, and it lost 22% of its travel.
fn scroll_range(model: &Model, h: FrameHandle) -> f32 {
let child = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Scroll(s)) => s.child,
_ => None,
};
let Some(child) = child else { return 0.0 };
let frame_h = model.resolved.get(&h).map(|r| r.height());
let child_h = model.resolved.get(&child).map(|r| r.height());
match (frame_h, child_h) {
let local_height = |h: FrameHandle| {
let scale = model
.arena
.frame(h)
.map(|f| f.effective_scale)
.filter(|s| s.abs() >= 1e-6)
.unwrap_or(1.0);
model.resolved.get(&h).map(|r| r.height() / scale)
};
match (local_height(h), local_height(child)) {
(Some(fh), Some(ch)) => (ch - fh).max(0.0),
_ => 0.0,
}
+497 -42
View File
@@ -25,14 +25,22 @@
//! running sum of every earlier tab's `num_spells` (tab 1's is `0`, so its first spell's book id
//! is `1`, matching the ref's own "first tab's first spell is id 1").
//!
//! ## Pet book (named deferral)
//! ## The pet book (decision 1032 — live; 0216 §8's deferral is retired)
//!
//! `BOOKTYPE_PET` is anticipated (decision 0216 §8: "pet book deferred, no pets streamed yet")
//! but answers empty everywhere: `GetNumSpellTabs`/`GetSpellTabInfo` don't even take a `bookType`
//! (matching the ref's own signature — they always read whatever `SpellBookFrame.bookType`
//! selected, and this engine only ever HOLDS the spell book, so there is no separate pet-book
//! state to switch between), and every `bookType`-taking binding treats `"pet"` as an instant
//! empty/no-op via [`slot_index`]'s own gate.
//! `BOOKTYPE_PET` selects a **second slot list** ([`PetBookState`]), fed from `SMSG_PET_SPELLS`'
//! own spell tail. Every `bookType`-taking binding is a two-way fork ([`book_slot`]) exactly as the
//! reference's are — `isPet ? [0xb6f098 + 4*i] : [0xb700f0 + 4*i]`, written out once per binding —
//! and the three pet-only bindings (`HasPetSpells`, `GetSpellAutocast`, `ToggleSpellAutocast`) live
//! here with them.
//!
//! Two asymmetries are the API and not tidiable away:
//!
//! - `GetNumSpellTabs`/`GetSpellTabInfo` take **no** `bookType` and only ever answer the player's
//! skill lines. That is the reference's own signature: the pet book has no tabs, and
//! `SpellBookFrame_Update` hides the whole skill-line strip while it is up.
//! - `PickupSpell` and `CastSpell` produce a **different kind of thing** on the pet side — a pet
//! action word on the cursor (`0x494e20`, cursor modes 1-7) and a `CMSG_PET_ACTION` on the wire
//! (`0x4b34ce`) — rather than a spell payload and a player cast. See each binding.
//!
//! `BOOKTYPE_SPELL`/`BOOKTYPE_PET` are installed as plain Lua globals here rather than left to the
//! transcribed XML's own `<Script>` block (the ref's actual home for them, `SpellBookFrame.lua:
@@ -48,6 +56,11 @@ use super::Model;
const BOOKTYPE_SPELL: &str = "spell";
const BOOKTYPE_PET: &str = "pet";
/// `HasPetSpells`' second return when the app has not resolved a token — the reference's own
/// literal at `0x846a40`, pushed by `0x4b44a6` whenever the player object fails to resolve. Never
/// nil: FrameXML concatenates it (`"PET_TYPE_"..token`), which would error on one.
const PET_TOKEN_FALLBACK: &str = "PET";
/// One skill-line tab (`GetSpellTabInfo`'s own Era tuple shape). `offset` is the tab's 0-based
/// START index into [`SpellBookState::slots`] (module docs' book-id seam) — pushed by the app,
/// trusted here (the engine holds no spell knowledge to derive it from itself).
@@ -87,6 +100,39 @@ pub struct SpellSlotView {
/// `(start, duration, enable)`. `None` = cold. Frame-stable per arm (the absolute start), so
/// a running cooldown never churns the book diff.
pub cooldown: Option<(i64, u32, bool)>,
/// `GetSpellAutocast`'s `(allowed, enabled)` pair — **pet-book only**, and `None` is not
/// "neither": it is the reference's own player-book answer, `(nil, nil)`, because `0x4b4180`
/// short-circuits on the book flag (`0x4b41cb`/`0x4b41d6`) before it looks a spell up at all.
/// Read off the pet's **raw** word (`0x4bd160` → bits 31/30), never off the filtered book.
pub autocast: Option<(bool, bool)>,
/// The pet slot's packed word **verbatim** — what `PickupSpell(id, "pet")` puts on the cursor.
/// `0x4b3260`'s pet arm hands `0x494e20` a *pointer* to this very word, so the payload is the
/// server's own dword, type byte and autocast bits included, not a synthesized one. `0` for a
/// player-book slot, which has no word.
pub packed: u32,
}
/// The **pet's** book — the reference's second flat array (`0xb6f098`, count `0xb71174`), which is
/// a genuinely different object from the player's rather than a variant of it:
///
/// - **no tabs.** `GetNumSpellTabs`/`GetSpellTabInfo` take no `bookType` and only ever answer the
/// player's skill lines; `SpellBookFrame_Update` hides every skill-line tab while the pet book is
/// up (`SpellBookFrame.lua:124`), and `SpellBook_GetSpellID` returns the button's own 1..12 id
/// with no tab offset at all (`l.460-462`).
/// - **a different add-gate.** `0x4b2f90` admits a spell iff it resolves in `Spell.dbc` **and**
/// `Attributes & 0x80` (DO_NOT_DISPLAY) is clear — `0x4b2fa8 mov dl,[rec+0x18]; test dl,dl; js`.
/// That is *one* of the three tests the player book's own gate makes: no `IS_TRADESKILL` leg and
/// no `castUI == 0` leg. Reusing the player book's gate here would be the same class of mistake
/// as reusing its tab routing.
/// - **the same order.** `0x4b2fd0(ecx = 0, edx = 1)` sorts it with `0x4b30c0`, the player book's
/// own comparator (name, then parsed rank), and tail-jumps `SPELLS_CHANGED`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct PetBookState {
/// `HasPetSpells`'s second return: `ChrClasses.dbc` field 4 for the player's class — `"PET"`
/// or `"DEMON"` (`benilla_formats::PetNameTokens`). A **key**, not display text: FrameXML does
/// `getglobal("PET_TYPE_"..token)`. `None` only while there is no book at all.
pub token: Option<String>,
pub slots: Vec<SpellSlotView>,
}
/// The player's known-spell book: tabs (skill lines) + the flat slot list every tab indexes into
@@ -106,6 +152,30 @@ impl super::UiScript {
self.model_mut().spellbook = state;
}
/// Push the pet's book ([`PetBookState`]), replacing whatever was there. A bare setter for the
/// same reason as its sibling: the reference fires `SPELLS_CHANGED` for **both** books off the
/// one re-sort (`0x4b2fd0` → `SignalEvent(0x104)`), and whose diff moved is the app's to know.
pub fn set_pet_book(&mut self, state: PetBookState) {
self.model_mut().pet_book = state;
}
/// Drain the pet spell ids `CastSpell(id, "pet")` queued. Separate from
/// [`Self::take_spell_casts`] because the wire verb is different in kind: the dispatcher's pet
/// arm sends **`CMSG_PET_ACTION`** with a synthesized type-1 word (`0x4b34ce`), not a player
/// cast, so folding them into one queue would lose which end of the leash the cast came from.
pub fn take_pet_spell_casts(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().pet_spell_casts)
}
/// Drain the pet spell ids `ToggleSpellAutocast` queued — the pet **book**'s autocast verb,
/// which is a different opcode from the pet **bar**'s ([`super::pet::UiScript::…`]'s
/// `take_pet_autocast_toggles` → `CMSG_PET_SET_ACTION`): this one is
/// `CMSG_PET_SPELL_AUTOCAST 0x2F3` and names a spell id rather than a bar slot
/// (`0x4b4291` → `0x4bccb0`).
pub fn take_pet_spell_autocasts(&mut self) -> Vec<u32> {
std::mem::take(&mut self.model_mut().pet_spell_autocasts)
}
/// Read the book back — for the ONE app-side consumer that must resolve a spell name by the
/// same law `CastSpellByName` does: a macro's bound spell (`benilla::ui_macro`, decision
/// 0983). Going through the pushed book rather than re-deriving from the catalog is what
@@ -150,16 +220,42 @@ impl super::UiScript {
}
}
/// The book-id → 0-based [`SpellBookState::slots`] index seam (module docs): `None` for a
/// `bookType` other than `"spell"` (the pet deferral) or an id of `0` (the ref's ids start at 1,
/// so `id - 1` would otherwise underflow).
fn slot_index(id: u32, book_type: &str) -> Option<usize> {
if book_type != BOOKTYPE_SPELL {
return None;
}
/// Which book a `bookType` argument names — the reference's own one-line test, and it is a
/// **case-insensitive compare against `"pet"` alone** (`0x4b3f27` → `SStrCmpI(arg2, "pet")`), so
/// every other string, `"spell"` included, is the player's book. Reproduced rather than tightened:
/// the shared parser also *requires* a string second argument (`0x4b3ee8 lua_isstring(2)`), which
/// is why these bindings take `String` and not `Option<String>`.
fn is_pet_book(book_type: &str) -> bool {
book_type.eq_ignore_ascii_case(BOOKTYPE_PET)
}
/// The book-id → 0-based slot-list index seam (module docs). `None` for an id of `0` (the ref's
/// ids start at 1, so `id - 1` would otherwise underflow) — the reference's own `arg1 - 1` with its
/// `[0, 0x400)` bound, which our slot lists enforce by being shorter than that anyway.
fn slot_index(id: u32) -> Option<usize> {
usize::try_from(id.checked_sub(1)?).ok()
}
/// The one lookup every `bookType`-taking binding shares: pick the book, then the slot. This is
/// literally the reference's shape — `isPet ? [0xb6f098 + 4*i] : [0xb700f0 + 4*i]`, one fork
/// repeated verbatim inside each binding (`0x4b3f5d`, `0x4b40e6`, `0x4b3735`, `0x4b3339`, …).
///
/// Shared with the tooltip channel (`super::tooltip_spell`'s `SetSpell`), which is a `GameTooltip`
/// method rather than a global but repeats the identical fork at `0x532e1c`/`0x532e2a` — and which
/// read the player's book for a pet hover until 1050, because it took the argument and dropped it.
pub(super) fn book_slot<'a>(
model: &'a Model,
id: u32,
book_type: &str,
) -> Option<&'a SpellSlotView> {
let slots = if is_pet_book(book_type) {
&model.pet_book.slots
} else {
&model.spellbook.slots
};
slots.get(slot_index(id)?)
}
/// Resolve a spell **by name** against the player's book — the law behind `CastSpellByName` and,
/// through it, `/cast` and every macro's `/cast` line (decision 0983).
///
@@ -246,17 +342,40 @@ fn pickup_spell(model: &mut Model, id: u32, book_type: &str) -> bool {
if model.cursor.is_some() {
return false;
}
let Some(slot) = slot_index(id, book_type).and_then(|i| model.spellbook.slots.get(i)) else {
let Some(slot) = book_slot(model, id, book_type) else {
return false;
};
let payload = CursorSpell {
book_slot: id,
book_type: book_type.to_string(),
spell_id: slot.spell_id,
texture: slot.texture.clone(),
passive: slot.passive,
// **The pet book's payload is a different KIND**, not a Spell payload with a flag on it: the
// pet arm of `0x4b3260` calls `0x494e20` with a pointer to the pet's raw word (cursor modes
// 1-7), while the player arm calls `0x494d20` with a spell id (mode 9). That is why a pet
// spell can be dropped onto the pet bar and a player spell cannot — the bar's drop accepts
// exactly one payload kind, and the book is the *second* place that kind is produced.
let payload = if is_pet_book(book_type) {
// `0x494e20`'s own jump table refuses type 0 and type >= 8, so a word that could not sit
// on the bar cannot ride the cursor either (`cursor::pet::payload_word`, same rule).
let packed = slot.packed;
if !(1..=7).contains(&((packed >> 24) & 0x3F)) {
return false;
}
CursorPayload::PetAction(super::cursor::CursorPetAction {
// No source slot: this word came out of the BOOK, not off the bar, so there is
// nothing to blank behind it and nothing to swap back to. The reference is the same
// shape — its cursor holds a pointer into the raw spell array, not into the bar.
src_slot: 0,
packed,
passive: slot.passive,
texture: slot.texture.clone(),
})
} else {
CursorPayload::Spell(CursorSpell {
book_slot: id,
book_type: book_type.to_string(),
spell_id: slot.spell_id,
texture: slot.texture.clone(),
passive: slot.passive,
})
};
model.cursor = Some(CursorPayload::Spell(payload));
model.cursor = Some(payload);
queue_cursor_update(model);
true
}
@@ -268,6 +387,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set("BOOKTYPE_SPELL", BOOKTYPE_SPELL)?;
g.set("BOOKTYPE_PET", BOOKTYPE_PET)?;
/// The 1/nil boolean every Era binding in this file answers with.
fn flag(b: bool) -> Value {
if b {
Value::Integer(1)
} else {
Value::Nil
}
}
g.set(
"GetNumSpellTabs",
lua.create_function(|lua, ()| {
@@ -304,8 +432,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetSpellName",
lua.create_function(|lua, (id, book_type): (u32, String)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(slot) = slot_index(id, &book_type).and_then(|i| model.spellbook.slots.get(i))
else {
let Some(slot) = book_slot(&model, id, &book_type) else {
return Ok(MultiValue::from_vec(vec![Value::Nil]));
};
let rank = match &slot.rank {
@@ -323,9 +450,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"GetSpellTexture",
lua.create_function(|lua, (id, book_type): (u32, String)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let tex = slot_index(id, &book_type)
.and_then(|i| model.spellbook.slots.get(i))
.and_then(|s| s.texture.clone());
let tex = book_slot(&model, id, &book_type).and_then(|s| s.texture.clone());
match tex {
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
None => Ok(Value::Nil),
@@ -341,9 +466,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"IsCurrentCast",
lua.create_function(|lua, (id, book_type): (u32, String)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let current = slot_index(id, &book_type)
.and_then(|i| model.spellbook.slots.get(i))
.is_some_and(|s| s.current);
let current = book_slot(&model, id, &book_type).is_some_and(|s| s.current);
// The ref's binding convention: 1 or nil, never false.
match current {
true => Ok(Value::Integer(1)),
@@ -362,9 +485,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, (id, book_type): (u32, String)| {
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
let model = lua.app_data_ref::<Model>().expect("model app_data");
let cooldown = slot_index(id, &book_type)
.and_then(|i| model.spellbook.slots.get(i))
.and_then(|s| s.cooldown);
let cooldown = book_slot(&model, id, &book_type).and_then(|s| s.cooldown);
Ok(match cooldown {
Some((start_ms, duration_ms, enabled)) => {
let (start, duration) =
@@ -384,31 +505,106 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"IsSpellPassive",
lua.create_function(|lua, (id, book_type): (u32, String)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(slot_index(id, &book_type)
.and_then(|i| model.spellbook.slots.get(i))
.is_some_and(|s| s.passive))
Ok(book_slot(&model, id, &book_type).is_some_and(|s| s.passive))
})?,
)?;
// CastSpell(id, bookType) — the plain click (ref SpellButton_OnClick's `else` branch): queues
// the resolved spell id UNLESS the slot is passive (module doc: a passive is permanent player
// state, never something the player casts) or bookType/id resolve to nothing.
//
// **The pet arm is a different verb, not a flag.** `0x4b3300`'s tail forks on the book byte
// one instruction before the send (`0x4b34c8 cmp ecx, 0; je` → the player's own cast
// `0x6e5a90`), and the pet side builds `CMSG_PET_ACTION 0x175` by hand:
// `{ u64 [0xb714a0], u32 (spellId & 0xFFFF) | 0x01000000, u64 target }` (`0x4b34ce`-`0x4b3524`)
// — a **synthesized type-1 word**, which is why the pet book can cast a spell that is not on
// the bar at all. The target is the passed one, falling back to the current selection
// (`0x4b34af`-`0x4b34bb`), exactly as `CastPetAction` does; the app supplies it at the drain.
g.set(
"CastSpell",
lua.create_function(|lua, (id, book_type): (u32, String)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(slot) =
slot_index(id, &book_type).and_then(|i| model.spellbook.slots.get(i))
{
if let Some(slot) = book_slot(&model, id, &book_type) {
if !slot.passive {
let spell_id = slot.spell_id;
model.spell_casts.push(spell_id);
if is_pet_book(&book_type) {
model.pet_spell_casts.push(spell_id);
} else {
model.spell_casts.push(spell_id);
}
}
}
Ok(())
})?,
)?;
// HasPetSpells() → numPetSpells, petToken — no arguments, and **always exactly two returns**
// (`0x4b4410`, `EAX = 2` on every path). Zero spells answers `(nil, nil)` (`0x4b4420`), which
// is the gate `ToggleSpellBook` and `SpellBookFrame_Update` both read: no pet book, no tab row.
//
// Return 1 is the **count as a number**, not a boolean — `SpellBook_GetCurrentPage` divides by
// it (`ceil(numPetSpells/12)`), so answering 1/nil would silently pin the pet book to one page.
g.set(
"HasPetSpells",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let n = model.pet_book.slots.len();
if n == 0 {
return Ok(MultiValue::from_vec(vec![Value::Nil, Value::Nil]));
}
let token = match &model.pet_book.token {
Some(t) => Value::String(lua.create_string(t)?),
// The reference's own unresolved-player arm pushes the literal "PET"
// (`0x4b44a6`), never nil — a nil here would make `"PET_TYPE_"..token` error.
None => Value::String(lua.create_string(PET_TOKEN_FALLBACK)?),
};
Ok(MultiValue::from_vec(vec![Value::Integer(n as i64), token]))
})?,
)?;
// GetSpellAutocast(id, bookType) → autoCastAllowed, autoCastEnabled (1/nil each) — the
// AutoCastable overlay and the sparkle model on a pet book button.
//
// **Pet-only, and it fails to (nil, nil) rather than (nil) for the player book**: `0x4b4180`
// tests the book flag twice (`0x4b41cb`, `0x4b41d6`) and falls into the same two nil pushes the
// no-record path uses, so the arity is 2 on every path including a bad index.
g.set(
"GetSpellAutocast",
lua.create_function(move |lua, (id, book_type): (u32, String)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let (allowed, enabled) = book_slot(&model, id, &book_type)
.filter(|_| is_pet_book(&book_type))
.and_then(|s| s.autocast)
.unwrap_or((false, false));
Ok((flag(allowed), flag(enabled)))
})?,
)?;
// ToggleSpellAutocast(id, bookType) — the pet book's right click (ref `SpellButton_OnClick`'s
// `arg1 ~= "LeftButton"` fork). **A different binding and a different opcode from the pet
// BAR's `TogglePetAutocast`**: `0x4b4240` indexes the pet spellbook and calls `0x4bccb0`, which
// sends `CMSG_PET_SPELL_AUTOCAST 0x2F3` naming a **spell id**, where the bar's verb sends
// `CMSG_PET_SET_ACTION` naming a slot. Confusing them is a wire bug that looks like a UI one.
//
// The gate here is the same one `0x4bccb0` applies before it sends: the word must be
// autocast-ALLOWED (`0x4bccf5 test cl,1`). Everything else the sender does — flipping bit 30
// in place and mirroring it onto every bar slot carrying the same action — is state the app
// owns, so it happens at the drain.
g.set(
"ToggleSpellAutocast",
lua.create_function(|lua, (id, book_type): (u32, String)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let spell_id = book_slot(&model, id, &book_type)
.filter(|_| is_pet_book(&book_type))
.filter(|s| s.autocast.is_some_and(|(allowed, _)| allowed))
.map(|s| s.spell_id);
if let Some(spell_id) = spell_id {
model.pet_spell_autocasts.push(spell_id);
}
Ok(())
})?,
)?;
// CastSpellByName(name [, onSelf]) — the reference binding `0x4b4ab0`, whose only two callers
// share the `0x4b3300` dispatcher with `CastSpell` above (wow-re ledger), so this queues onto
// the same `spell_casts` list and the app's one cast tail handles both. `SlashCmdList["CAST"]`
@@ -501,7 +697,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::{SpellBookState, SpellSlotView, SpellTabView};
use super::{PetBookState, SpellBookState, SpellSlotView, SpellTabView};
use crate::script::cursor::{CursorAction, CursorPayload};
use crate::script::UiScript;
@@ -532,6 +728,7 @@ mod tests {
passive: false,
current: false,
cooldown: None,
..Default::default()
},
SpellSlotView {
spell_id: 2136,
@@ -541,6 +738,7 @@ mod tests {
passive: true, // artificial: exercises the refusal gate
current: false,
cooldown: None,
..Default::default()
},
SpellSlotView {
spell_id: 168,
@@ -550,6 +748,7 @@ mod tests {
passive: false,
current: false,
cooldown: None,
..Default::default()
},
],
}
@@ -779,8 +978,11 @@ mod tests {
.unwrap());
}
/// **With no pet book fed, every pet arm answers nothing** — the old deferral's behaviour,
/// which is also the reference's whenever `[0xb71174] == 0`. Kept as its own case so the
/// pet-book tests below can never pass by the player book leaking into them.
#[test]
fn pet_book_answers_empty_everywhere() {
fn an_absent_pet_book_answers_empty_everywhere() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
@@ -793,13 +995,266 @@ mod tests {
assert!(!s
.eval::<bool>(r#"return IsSpellPassive(1, BOOKTYPE_PET)"#)
.unwrap());
assert!(s
.eval::<bool>(r#"local n, t = HasPetSpells() return n == nil and t == nil"#)
.unwrap());
s.run(r#"CastSpell(1, BOOKTYPE_PET)"#).unwrap();
assert!(s.take_spell_casts().is_empty(), "pet cast is a no-op");
assert!(s.take_pet_spell_casts().is_empty());
assert!(!s
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_PET)"#)
.unwrap());
assert!(s.cursor_payload().is_none(), "pet pickup is a no-op");
}
/// A hunter's pet book: Growl (autocastable, ON, on cooldown), Claw (autocastable, OFF) and
/// Avoidance (a passive — no autocast, `ACT_PASSIVE 0x01`).
fn pet_book() -> PetBookState {
PetBookState {
token: Some("PET".into()),
slots: vec![
SpellSlotView {
spell_id: 2649,
name: "Growl".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Ability_Physical_Taunt".into()),
cooldown: Some((9400, 5000, true)),
autocast: Some((true, true)),
packed: 0xC100_0000 | 2649,
..Default::default()
},
SpellSlotView {
spell_id: 16827,
name: "Claw".into(),
rank: Some("Rank 1".into()),
texture: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
autocast: Some((true, false)),
packed: 0x8100_0000 | 16827,
..Default::default()
},
SpellSlotView {
spell_id: 3025,
name: "Avoidance".into(),
texture: Some("Interface\\Icons\\Spell_Nature_SpiritArmor".into()),
passive: true,
autocast: Some((false, false)),
packed: 0x0100_0000 | 3025,
..Default::default()
},
],
}
}
/// `HasPetSpells` is **the count and a token**, always two returns, and the count is a NUMBER
/// — `SpellBook_GetCurrentPage` divides by it, so a 1/nil boolean would pin the page count.
#[test]
fn has_pet_spells_answers_a_count_and_a_class_token() {
let mut s = UiScript::new().unwrap();
s.set_pet_book(pet_book());
assert!(s
.eval::<bool>(r#"local n, t = HasPetSpells() return n == 3 and t == "PET""#)
.unwrap());
// A warlock's book carries the other token, which is the whole of what makes the tab read
// "Demon" — FrameXML does `getglobal("PET_TYPE_"..token)`.
let mut demon = pet_book();
demon.token = Some("DEMON".into());
s.set_pet_book(demon);
assert_eq!(
s.eval::<String>("local _, t = HasPetSpells() return t")
.unwrap(),
"DEMON"
);
// No token resolved (no ChrClasses.dbc) still answers a STRING, never nil — a nil would
// make the reference's own concatenation error.
let mut untokened = pet_book();
untokened.token = None;
s.set_pet_book(untokened);
assert_eq!(
s.eval::<String>("local _, t = HasPetSpells() return t")
.unwrap(),
"PET"
);
}
/// The two books are separate lists reached by the SAME id — the reference's `isPet ? petArray
/// : playerArray` fork. Book id 1 means Fireball in one and Growl in the other.
#[test]
fn one_id_reads_two_different_books() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
s.set_pet_book(pet_book());
assert_eq!(
s.eval::<String>(r#"return GetSpellName(1, BOOKTYPE_SPELL)"#)
.unwrap(),
"Fireball"
);
assert_eq!(
s.eval::<String>(r#"return GetSpellName(1, BOOKTYPE_PET)"#)
.unwrap(),
"Growl"
);
// The book type is a case-insensitive compare against "pet" ALONE (`0x4b3f27`), so every
// other string is the player's book — including a typo'd one.
assert_eq!(
s.eval::<String>(r#"return GetSpellName(1, "PeT")"#)
.unwrap(),
"Growl"
);
assert_eq!(
s.eval::<String>(r#"return GetSpellName(1, "spel")"#)
.unwrap(),
"Fireball"
);
// Past the pet book's end: one nil, the out-of-range shape.
assert!(s
.eval::<bool>(r#"return GetSpellName(4, BOOKTYPE_PET) == nil"#)
.unwrap());
}
/// `GetSpellAutocast` is **pet-only and always two returns**: the player book short-circuits
/// to `(nil, nil)` before it looks anything up (`0x4b41cb`/`0x4b41d6`), and so does an
/// out-of-range pet index.
#[test]
fn autocast_is_a_pet_only_pair() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
s.set_pet_book(pet_book());
assert!(s
.eval::<bool>(
r#"local a, e = GetSpellAutocast(1, BOOKTYPE_PET) return a == 1 and e == 1"#
)
.unwrap());
assert!(s
.eval::<bool>(
r#"local a, e = GetSpellAutocast(2, BOOKTYPE_PET) return a == 1 and e == nil"#
)
.unwrap());
assert!(
s.eval::<bool>(
r#"local a, e = GetSpellAutocast(3, BOOKTYPE_PET) return a == nil and e == nil"#
)
.unwrap(),
"a passive is not autocastable"
);
assert!(
s.eval::<bool>(
r#"local a, e = GetSpellAutocast(1, BOOKTYPE_SPELL) return a == nil and e == nil"#
)
.unwrap(),
"the PLAYER book never answers a pair"
);
assert!(
s.eval::<bool>(
r#"local a, e = GetSpellAutocast(9, BOOKTYPE_PET) return a == nil and e == nil"#
)
.unwrap(),
"still two returns out of range"
);
}
/// `ToggleSpellAutocast` queues only what `0x4bccb0` would actually send: a pet-book slot whose
/// word is autocast-ALLOWED. A passive, a player-book id and an out-of-range id all queue
/// nothing — and none of them may leak into the player's cast queue.
#[test]
fn only_an_autocastable_pet_slot_queues_a_toggle() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
s.set_pet_book(pet_book());
s.run(
r#"ToggleSpellAutocast(1, BOOKTYPE_PET)
ToggleSpellAutocast(3, BOOKTYPE_PET)
ToggleSpellAutocast(9, BOOKTYPE_PET)
ToggleSpellAutocast(1, BOOKTYPE_SPELL)"#,
)
.unwrap();
assert_eq!(s.take_pet_spell_autocasts(), vec![2649]);
assert!(s.take_pet_spell_autocasts().is_empty(), "drain empties");
assert!(s.take_spell_casts().is_empty());
}
/// A pet cast is a **different queue** from a player cast, because it is a different opcode at
/// the far end (`CMSG_PET_ACTION`, not a player cast). A passive still refuses on both.
#[test]
fn a_pet_cast_queues_apart_from_a_player_cast() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
s.set_pet_book(pet_book());
s.run(
r#"CastSpell(1, BOOKTYPE_PET)
CastSpell(3, BOOKTYPE_PET)
CastSpell(1, BOOKTYPE_SPELL)"#,
)
.unwrap();
assert_eq!(s.take_pet_spell_casts(), vec![2649], "the passive refused");
assert_eq!(s.take_spell_casts(), vec![133]);
assert!(s.take_pet_spell_casts().is_empty(), "drain empties");
}
/// A pet-book pickup puts a **pet action word** on the cursor, not a spell payload — which is
/// exactly what makes it droppable on the pet bar (`cursor::pet`'s payload). The word is the
/// server's own, autocast bits and all.
#[test]
fn a_pet_book_pickup_carries_the_packed_word() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
s.set_pet_book(pet_book());
assert!(s
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_PET)"#)
.unwrap());
let Some(CursorPayload::PetAction(p)) = s.cursor_payload() else {
panic!(
"expected a pet action payload, got {:?}",
s.cursor_payload()
);
};
assert_eq!(p.packed, 0xC100_0000 | 2649);
assert_eq!(p.src_slot, 0, "it came out of the book, not off the bar");
// The player book still produces a SPELL payload — the two are not interchangeable.
s.run("ClearCursor()").unwrap();
assert!(s
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#)
.unwrap());
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
}
/// `GetSpellCooldown(id, "pet")` reads the PET's slot — the reference reaches bank 1 with the
/// same `0x6e2ea0(edx = isPet)` `GetPetActionCooldown` uses, so a spell on the bar and the same
/// spell in the book must never disagree. The elapsed-goes-cold rule is the player book's.
#[test]
fn the_pet_books_cooldown_is_the_pets_own() {
let mut s = UiScript::new().unwrap();
s.tick(10.0); // GetTime == 10
s.set_spellbook(book());
s.set_pet_book(pet_book());
let (start, duration, enable) = s
.eval::<(f64, f64, i32)>(r#"return GetSpellCooldown(1, BOOKTYPE_PET)"#)
.unwrap();
assert!((start - 9.4).abs() < 1e-9, "start {start}");
assert!((duration - 5.0).abs() < 1e-9);
assert_eq!(enable, 1);
// The player book's slot 1 has none — proof the fork reached the right list.
assert_eq!(
s.eval::<(f64, f64, i32)>(r#"return GetSpellCooldown(1, BOOKTYPE_SPELL)"#)
.unwrap(),
(0.0, 0.0, 1)
);
s.tick(5.0); // now == 15 > 9.4 + 5.0
assert_eq!(
s.eval::<(f64, f64, i32)>(r#"return GetSpellCooldown(1, BOOKTYPE_PET)"#)
.unwrap(),
(0.0, 0.0, 1)
);
}
}
@@ -184,6 +184,56 @@ fn vertical_scroll_range_is_live_and_zero_when_unresolved_or_childless_or_shorte
);
}
/// The range is in LOCAL units, not screen px — `child:GetHeight() - self:GetHeight()`, the
/// contract the method states. It has to be: `SetVerticalScroll` becomes the child's anchor
/// y-offset, which the solver multiplies by the child's scale, so a screen-px range against a
/// local-unit offset makes a SCALED frame stop short of its own end and strand the tail of its
/// content. (Found on the era-scaled options window's page scroll, which lost 22% of its travel.)
#[test]
fn vertical_scroll_range_is_local_units_on_a_scaled_frame() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
local frame = CreateFrame("ScrollFrame", "SF")
frame:SetPoint("TOPLEFT", 0, -100)
frame:SetSize(300, 200)
frame:SetScale(0.5)
local child = CreateFrame("Frame", "Child", frame)
child:SetSize(300, 600)
frame:SetScrollChild(child)
"#,
)
.unwrap();
s.resolve();
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScrollRange()").unwrap(),
400.0,
"600 - 200 in the frame's own units, NOT (600 - 200) * 0.5"
);
assert_eq!(
s.eval::<f32>("return Child:GetHeight() - SF:GetHeight()")
.unwrap(),
400.0,
"…which is exactly what the widget contract says it is"
);
// And the clamp agrees, so scrolling to the range really reaches the end: the child's bottom
// lands on the frame's.
s.run("SF:SetVerticalScroll(400)").unwrap();
s.resolve();
assert_eq!(
s.eval::<f32>("return SF:GetVerticalScroll()").unwrap(),
400.0,
"the full range is reachable, not clamped away"
);
assert_eq!(
s.eval::<f32>("return Child:GetBottom() - SF:GetBottom()")
.unwrap(),
0.0,
"at the end, the child's bottom sits on the frame's"
);
}
#[test]
fn vertical_scroll_fires_clamped_and_update_rect_fires_range_changed() {
let mut s = script();
@@ -63,6 +63,68 @@ fn spellbook_hover_renders_the_verified_shape() {
assert!(s.take_errors().is_empty());
}
/// **`bookType` decides which book the hover reads** — the fork `SetSpell 0x532d10` makes at
/// `0x532e1c`/`0x532e2a` (`[4*i + 0xb6f098]` for the pet, `[4*i + 0xb700f0]` otherwise).
///
/// The defect (1050): the binding took the argument and dropped it, so a pet-book hover indexed the
/// PLAYER's slot list — the imp's first spell showed the player's first spell, "Attack" and its
/// crit line, on the director's screen. Both books are populated here with a *different* spell at
/// the same slot id, which is the only arrangement that can tell the two apart.
#[test]
fn a_pet_book_hover_reads_the_pets_book_not_the_players() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.set_spell_tooltip(133, fireball());
s.set_spell_tooltip(
3110,
SpellTooltipView {
name: "Firebolt".into(),
description: "Hurls a fiery ball.".into(),
..Default::default()
},
);
s.set_spellbook(SpellBookState {
tabs: Vec::new(),
slots: vec![SpellSlotView {
spell_id: 133,
name: "Fireball".into(),
..Default::default()
}],
});
s.set_pet_book(PetBookState {
token: Some("DEMON".into()),
slots: vec![SpellSlotView {
spell_id: 3110,
name: "Firebolt".into(),
..Default::default()
}],
});
s.run(
r#"
local a = CreateFrame("Button", "SB1"); a:SetPoint("CENTER", 0, 0); a:SetSize(10, 10)
local tt = CreateFrame("GameTooltip", "TT")
tt:SetOwner(a, "ANCHOR_RIGHT")
tt:SetSpell(1, "pet")
assert(TTTextLeft1:GetText() == "Firebolt", "pet slot 1, got " .. TTTextLeft1:GetText())
-- The same slot id in the other book is the OTHER spell.
tt:SetSpell(1, "spell")
assert(TTTextLeft1:GetText() == "Fireball", "player slot 1, got " .. TTTextLeft1:GetText())
-- The compare is case-insensitive against "pet" ALONE (0x532e13's SStrCmpI), so every
-- other string is the player's book — including a mis-cased one being the pet's.
tt:SetSpell(1, "PeT")
assert(TTTextLeft1:GetText() == "Firebolt", "case-insensitive pet")
tt:SetSpell(1, "book")
assert(TTTextLeft1:GetText() == "Fireball", "any other string is the player's book")
-- A non-string third argument is not "the player's book": the reference bails outright
-- (0x532dc0's isstring test), leaving whatever was shown.
tt:SetSpell(1, 7)
assert(TTTextLeft1:GetText() == "Fireball", "a non-string arg shows nothing new")
"#,
)
.unwrap();
assert!(s.take_errors().is_empty());
}
/// The tracking icon's hover (SetTrackingSpell): a GOLD name over the white (aura-variant)
/// description — the shape the director's reference A/B pinned (2026-07-20), distinct from
/// SetPlayerBuff's white name. No cost/casttime lines, no duration-remaining line.
+21 -5
View File
@@ -306,15 +306,31 @@ fn set_spell_by_id(
/// Register the spell/aura content channels into the GameTooltip kind method table.
pub(super) fn install_methods(lua: &Lua, m: &Table) -> mlua::Result<()> {
// GameTooltip:SetSpell(bookId, bookType) — the spellbook hover: the 1-based flat book slot
// resolves through the spellbook state to a spell id (the era signature; bookType is always
// the player book here — pets are a later arc).
// GameTooltip:SetSpell(bookId, bookType) — the spellbook hover: the 1-based book slot resolves
// through the named book's state to a spell id.
//
// **`bookType` decides which book**, exactly like every `bookType`-taking global
// (`super::spellbook::book_slot`). Byte-verified in `SetSpell 0x532d10`, which is its own
// implementation of the same fork rather than a caller of the shared parser: arg2 → number,
// `- 1`, bounded `[0, 0x400)` (`0x532dd4`-`0x532df4`); arg3 → string, compared against the
// literal `"pet"` at `0x846960` (`0x532e13`); match takes `[4*i + 0xb6f098]` — the PET book —
// and sets `isPet = 1` (`0x532e1c`), everything else takes `[4*i + 0xb700f0]` (`0x532e2a`).
// That `isPet` then rides into `0x6e2ea0` as the cooldown BANK (`0x532e50`), the same bank
// split 1031 built.
//
// Before the fork, a pet-book hover indexed the PLAYER's slot list, so hovering the imp's first
// spell showed the player's first spell — "Attack", crit line and all (decision 1050).
m.set(
"SetSpell",
lua.create_function(|lua, (this, book_id, _book_type): (Table, usize, Value)| {
lua.create_function(|lua, (this, book_id, book_type): (Table, u32, Value)| {
// The reference requires a STRING third argument and bails otherwise (`0x532dc0`'s
// `lua_isstring(3)` → `je` out); a non-string is therefore not "the player's book".
let Some(book_type) = book_type.as_string().and_then(|s| s.to_str().ok()) else {
return Ok(());
};
let (spell_id, name) = {
let model = lua.app_data_mut::<Model>().expect("model app_data");
match model.spellbook.slots.get(book_id.saturating_sub(1)) {
match super::spellbook::book_slot(&model, book_id, &book_type) {
Some(s) => (s.spell_id, Some(s.name.clone())),
None => return Ok(()),
}