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