the action bars' empty slots go ghost, and a rooted creature stays put

- picking a spell up off the bar no longer blacks out every empty slot: `UI-Quickslot` is a FILLED 60%-black plate, not a hollow ring (decoded off the shipped BLPs — Quickslot2's interior is RGBA 0,0,0,0, Quickslot's 0,0,0,153), and the reference dims it to half alpha in `ActionButton_ShowGrid`, so an empty well now ghosts at 30% black where it read as a hole; the wow-re cross-check then found the reference never UN-dims it (a 3-argument `SetVertexColor` preserves the current alpha at `0x79abd0`, and `SetNormalTexture` reuses the region), so in the real client the first drag leaves every button's border at half alpha for the rest of the session — benilla ends the dim on purpose and records it as the divergence it is (1782)
- the bag bar is the reference's own `MainMenuBarBagButtons.xml` (window 3 of the stock-first migration), and the swap found a real bug rather than a move: the reference's `ItemAnim_OnEvent` matches a push against the button's inventory-slot id (20..23) and benilla emitted its own container numbering (1..4), so all four bag-push cards would have silently never played against the stock body — `push_container` now emits the reference's selector (`bag + 1`), which is what an addon expects anyway; beside it, a readiness probe that asks the LOADER, not a grep, which of the 85 stock windows load off the player's chain today (58 do; the 27 that don't name their own blocker)
- a rooted, hovering, water-walking or walking creature reached benilla as an unhandled opcode: the twelve `SMSG_SPLINE_MOVE_*` (the observer twin of the ack'd force family — bare packed guid, no ack, any unit) now decode into one MOVEMENTFLAGS word per unit, which benilla kept for our mover and for a relayed player and not at all for a creature, the one body this family is ever addressed to; ROOT wipes the direction bits so the dead-reckon stops walking a rooted body on for its next heartbeat (`0x7c7340`), HOVER clears a yard off the floor without climbing a body already inside that yard (`0x636e81`), WATERWALK elects the liquid surface against the terrain, SAFE_FALL's terminal is 7.0 not 60.148 (`0x7c5d23`), and SET_RUN_MODE's bool is inverted against the bit, folded at the parse; the inspect card names a unit's granted modes in words (`granted rooted+hover`) so a creature resting a yard off the floor reads as intended rather than broken

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 16:57:55 +07:00
co-authored by Claude Fable 5.1
parent 631b4a99c1
commit 6da7ed3fca
43 changed files with 2048 additions and 1266 deletions
+105 -8
View File
@@ -56,8 +56,14 @@
1218/1219 fixed for MultiBarLeft.
Unlike the ref (which HIDES an empty, non-grid slot entirely), our MAIN bar always shows
all 12 wells; the grid only swaps an EMPTY slot's ring art between the occupied and empty
Quickslot textures. The ref's extra SetVertexColor(1,1,1,0.5) dim on a grid ring is NOT
ours — that look is unchanged since 0216 and is the director's to move.
Quickslot textures. The ref's SetVertexColor(1,1,1,0.5) dim on a grid ring IS ours as of
1781 — this note said for three hundred decisions that the dim was "the director's to
move", and they moved it: an empty well under a held spell read as a black hole, because
UI-Quickslot is a FILLED plate (see BenillaActionButton_SetRing) and we were painting it
opaque. The dim now rides the ring's own setter, so art and tint cannot drift apart.
What we do NOT take is where the ref's dim ENDS: nothing in the real client ever clears
it, so one pickup fades every border for the session (byte-pinned; the setter's comment
has the addresses). Ours lets go with the payload — divergence, chosen, decision 1782.
- Exhaustion tick + rest fill, XP text (hover/cvar-gated), MaxLevelBar: OUT. The bar's
right-hand cluster is otherwise complete — the backpack toggle + four bag slots live in
BagFrame.xml and the eight micro buttons in MicroMenu.xml (both anchored into
@@ -291,8 +297,12 @@
end
-- Losing the last key with the keyring open would strand an orphan window; the ref
-- can't reach this state (its latch is one-way), so this is ours to decide. The window
-- is an ordinary ContainerFrame since 1751, so this is the reference's own CloseBag.
if not show then CloseBag(KEYRING_CONTAINER) end
-- is an ordinary ContainerFrame since 1751, so this is the reference's own CloseBag —
-- nil-guarded like every other reach out of this function, because the trigger above
-- lives on THIS file's art frame now (1751 window 3) and a dozen harnesses load the
-- bar with no bag stack behind it. With no ContainerFrame.lua there is no keyring
-- window to strand either, so skipping is the whole correct answer.
if not show and CloseBag then CloseBag(KEYRING_CONTAINER) end
end
-- ── The bonus-bar slide (decision 1524; ref BonusActionBarFrame.lua:1-98) ──────────────
@@ -888,12 +898,64 @@
BenillaActionButton_Update(button)
end
-- ── The ring: art and tint set TOGETHER ────────────────────────────────────────────────
-- One well, one answer to "what does it look like". Setting the two apart is precisely how
-- the reference's own pair got out of step: ActionButton_ShowGrid (ActionButton.lua:247)
-- dims the ring and ActionButton_Update, the only thing that ever repaints it, never
-- restores the dim.
--
-- `ghost` is that dim, the ref's own SetVertexColor(1.0, 1.0, 1.0, 0.5), and it is not
-- cosmetic trim. UI-Quickslot and UI-Quickslot2 are not two rings: Quickslot2 is a hollow
-- border (interior alpha 0), Quickslot is a FILLED plate — solid black at alpha 0.6 across
-- its whole middle (measured off the shipped BLPs). So the grid swap does not outline an
-- empty well, it fills it, and painting that plate opaque drops a 60%-black square into
-- every empty slot on every bar the moment a payload is picked up. The ref's half-alpha
-- takes it to 30%, where it reads as a drop-target hint rather than a hole. Ours skipped
-- the dim from 0216 to 1781 and the bars went dark under a held spell.
--
-- **Only the EMPTY well ghosts, and that is a DIVERGENCE — a chosen one (1782).** The
-- reference fans SHOWGRID to every button and dims each one, occupied included, and then
-- nothing ever clears it: `SetNormalTexture(path)` REUSES the region and touches only the
-- texture handle, and `UpdateUsable`'s three-argument SetVertexColor is
-- `SetVertexColor(r, g, b, currentAlpha)` — the binding asks a second time whether the
-- alpha argument is present and, when it is not, copies the alpha it just read back over
-- the 1.0 default. Both byte-verified: wow-re `system/ui/scratch/
-- button-state-texture-path-setter.md` §2/§7 (`0x778f10`, `0x770200`, `0x79adfe`).
--
-- So in stock 1.12.1 the FIRST spell anyone picks up leaves every action button's border at
-- vertex alpha 0x80 for the rest of the session, occupied slots included. That is a stuck
-- state, not a look: it is keyed to nothing the player can see, it never comes back, and
-- reproducing it would fade the whole bar permanently on the first drag. We take the look
-- the dim was written for — the wells the grid opens read as drop targets — and let go of
-- it with the payload. 1782 is why, and what we would need to see to revisit it.
function BenillaActionButton_SetRing(button, texture, ghost)
button:SetNormalTexture(texture)
-- The derived name, exactly as UpdateUsable reaches the same region (and as the ref
-- reaches it); nil only for an anonymous foreign button, which has no wells of ours.
local name = button:GetName()
local normal = name and getglobal(name .. "NormalTexture")
if not normal then return end
if ghost then
normal:SetVertexColor(1.0, 1.0, 1.0, 0.5)
else
-- The fourth argument is written OUT, never left to the default. Our binding
-- currently reads an absent alpha as 1.0 and the reference reads it as the alpha
-- already there (above); the day that divergence is closed, a 3-argument call here
-- would preserve the 0.5 it is supposed to be clearing and the ghost would become
-- exactly the stuck state this function exists to avoid — silently, with no test
-- touching this file able to see it. Saying 1.0 out loud is correct under either.
normal:SetVertexColor(1.0, 1.0, 1.0, 1.0)
end
end
function BenillaActionButton_Update(button)
local action = BenillaActionButton_Action(button)
if HasAction(action) then
button.icon:SetTexture(GetActionTexture(action) or BENILLA_FALLBACK_ICON)
button.icon:Show()
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot2")
-- Full alpha here only to clear whatever the ring last wore; the real occupied
-- tint is UpdateUsable's, below.
BenillaActionButton_SetRing(button, "Interface\\Buttons\\UI-Quickslot2", false)
button:Show()
button.rangeTimer = -1
BenillaActionButton_UpdateCount(button)
@@ -925,17 +987,23 @@
if not grid then
button:Hide()
else
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot")
BenillaActionButton_SetRing(button, "Interface\\Buttons\\UI-Quickslot", true)
button:Show()
end
else
-- While the grid is up, an empty slot shows the ref's own "no action" ring
-- (Interface\Buttons\UI-Quickslot) so it reads as a drop target; otherwise the
-- plain occupied-style ring, unchanged from before this slice (SCOPE note).
--
-- Either way the tint is WRITTEN, not left: this arm used to touch the ring's
-- art and never its colour, so a slot emptied while out of power kept
-- UpdateUsable's blue-grey 0.5/0.5/1.0 on a well with nothing in it — 1108's
-- "a tint outlives the art" one region over. A ring that is only ever painted
-- as a function of the button's state cannot carry the last one's history.
if grid then
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot")
BenillaActionButton_SetRing(button, "Interface\\Buttons\\UI-Quickslot", true)
else
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot2")
BenillaActionButton_SetRing(button, "Interface\\Buttons\\UI-Quickslot2", false)
end
end
end
@@ -2036,6 +2104,35 @@
<Scripts><OnClick>ActionBar_PageDown() PlaySound("UChatScrollButton")</OnClick></Scripts>
</Button>
</Frames>
<!-- ── The keyring's SHOW trigger (ref MainMenuBar.xml l.322-341) ──────────────
The art frame watches the bags and re-derives the keyring half of the bar; the
reference puts these three registrations and this exact call on THIS frame, and
that is why they are here rather than on KeyRingButton.
They used to live on KeyRingButton's own OnEvent, in our BagFrame.xml, because
that file was ours to write scripts into. 1751 window 3 made the bag bar the
reference's own MainMenuBarBagButtons.xml, whose KeyRingButton has no OnEvent —
so the trigger had to come back to where the reference keeps it, which is here.
The ref's body latches SHOW_KEYRING on forever the first time HasKey() is true
(a saved variable) and pulses the button for 60s. Ours does neither, and 0765
is the record: the latch is re-derived from HasKey() every BAG_UPDATE in BOTH
directions, and the pulse is out for 0304's reason. So there is no
SHOW_KEYRING and no SetButtonPulse here — MainMenuBar_UpdateKeyRing (top of
this file) carries the whole decision, and this is only its trigger.
NOT called from OnLoad, and the reference does not call it there either: this
file is manifest entry 25 and the body reaches `CloseBag` (the sourced
ContainerFrame.lua, entry 46) and `KeyRingButton` (entry 48), neither of which
exists yet. PLAYER_ENTERING_WORLD is what paints the first state, there and
here. -->
<Scripts>
<OnLoad>
this:RegisterEvent("PLAYER_ENTERING_WORLD")
this:RegisterEvent("BAG_UPDATE")
</OnLoad>
<OnEvent>MainMenuBar_UpdateKeyRing()</OnEvent>
</Scripts>
</Frame>
<!-- The performance ("ping") meter (ref-MainMenuBar.xml l.344-407). 16x64 pinned to the
-994
View File
@@ -1,994 +0,0 @@
<Ui>
<!--
**benilla's bag BAR** — the five buttons on the right of the main action bar, and nothing
else. The bag WINDOWS this file used to hold are gone: they are the reference's own
`ContainerFrame1..12`, executed off the player's installed patch chain (decision 1751,
`Interface\FrameXML\ContainerFrame.xml` in benilla.toc, three lines above this one).
So what remains is this client's stand-in for the reference's `MainMenuBarBagButtons.xml` /
`.lua` — the backpack button, `CharacterBag0Slot`..`CharacterBag3Slot`, `KeyRingButton`, and
the item-push card that flies into whichever of them took an item. It keeps the reference's
names because those are what addons reach for, and it will be replaced by the reference's
own file the day `PutItemInBag` / `PickupBagFromSlot` are bound (see the note at
`BagSlotButton_OnClick`, which is the migration ticket).
THE VERBS ARE THE REFERENCE'S NOW, and every button below calls them through the global:
`ToggleBackpack`, `ToggleBag`, `OpenAllBags`, `ToggleKeyRing`, `PutKeyInKeyRing`,
`IsBagOpen` — all from the sourced `ContainerFrame.lua`. That is not incidental: those
names are precisely what a bag addon replaces, so a button that called a private body
instead would make the addon silently inert (the lesson this file's own history carries —
four of the five buttons did exactly that once).
WHAT LIGHTS A BUTTON. The reference's `ContainerFrame_OnShow`/`_OnHide` write
`MainMenuBarBackpackButton:SetChecked` / `CharacterBag<N>Slot:SetChecked` directly, so with
stock windows the flag is already correct by the time a click handler's tail runs; each
tail below re-derives it with `IsBagOpen`, which is the reference's own scan under the name
it publishes for it. `KeyRingButton` is the same at `KEYRING_CONTAINER`.
KEYRING. `KEYRING_CONTAINER = -2` lives here because it lives in
`MainMenuBarBagButtons.lua:1` in the reference — and the sourced `ContainerFrame.lua` reads
it, so this file is load-bearing for a file above it in the manifest. Decision 0765's
keyring-as-container stands: the app feeds it like any other bag, so the reference's own
window, verbs and sounds take it unchanged. The one place the reference detours out of the
container API for a keyring slot is its tooltip, and that detour is adapted — with its
reasons — in `ContainerFrameAdapters.xml`.
ITEM PUSH (decision 0887): `BENILLA_ITEM_PUSH_*` + `BenillaItemPushAnim_*` are ours, and
deliberately not the reference's `ItemAnimTemplate` — that is a `<Model>` playing an .mdx
sequence, and this engine renders no FrameXML models (see `Cooldown.xml`'s own note). The
card below is the same animation sampled from the model's tracks and drawn as a quad.
The counts are NumberFontNormal and the labels GameFontHighlight — both from the shipped
Fonts.xml, so they render in the real fonts.
-->
<Script><![CDATA[
-- The keyring's container id (ref MainMenuBarBagButtons.lua:1) — decision 0765. It is an
-- ordinary container in every respect the seam cares about: the app feeds it like any other
-- bag (its slots are the player descriptor's own 81.., no container object), and every
-- container verb, the cursor, the pending-op lock and the wire drains take it unchanged.
-- What makes it special is only what the reference makes special: the window's art, its
-- open/close sound, and the fact that its capacity is not a wire field.
KEYRING_CONTAINER = -2
-- The backpack button (MainMenuBarBackpackButton): if any bag window is open, close them
-- all; otherwise open every existing bag (ref ToggleBackpack — "if numOpen > 0 then
-- CloseAllBags() else OpenAllBags()").
--
-- ONE STATED DIVERGENCE from ref BackpackButton_OnClick (MainMenuBarBagButtons.lua
-- l.55-69). The ref's tail re-derives the checked state from a scan of its NATIVE
-- ContainerFrames — and OnShow/OnHide dispatch is SYNCHRONOUS in the reference (wow-re
-- `onshow-onhide-dispatch-order.md`: Show binder 0x775750 → transition 0x76ae10 → notify
-- 0x76b260 → lua_pcall, every hop a direct call), so when an addon owns the bags its
-- OnShow's `SetChecked(1)` (Bagnon.lua l.33) lands FIRST and the ref tail stomps it: on
-- the real client the backpack button ends UNLIT after clicking it with Bagnon, and
-- lights only via the key binding (whose body is the bare `ToggleBackpack()`, no tail).
-- That click-path outcome contradicts the addon's evident intent and the director's call,
-- so ours swaps the scan for the auto-toggle UNDO below: checked belongs to the WINDOWS'
-- OnShow/OnHide writes — ours (BenillaBagFrame_OnShow/_OnHide) and any addon's alike —
-- and this handler only repairs what the CheckButton widget flipped before it ran. Every
-- stock outcome is identical to the ref's (the transition writes land inside
-- ToggleBackpack and are the last word); the addon case ends lit instead of stomped.
-- The widget-flip UNDO itself lives in the button's XML <OnClick> wrapper — the one seat
-- where the CheckButton auto-toggle has just happened — so every DIRECT caller of this
-- function (the tests, a macro) gets pure toggle semantics with no checked bookkeeping
-- to corrupt.
function BenillaBagToggle_OnClick()
-- Through the GLOBAL, so an addon that replaced `ToggleBackpack` gets the click.
ToggleBackpack()
end
-- ── The bag bar (decision 0216 slice 2) — four 30×30 buttons left of MainMenuBarBackpackButton,
-- mirroring the reference main-bar bag buttons (CharacterBag0..3Slot). No exact reference
-- XML was available to transcribe (not in the session's extracted set) — size (30×30) and
-- placement (left of the backpack button) are per the brief; inter-button spacing is an
-- authored assumption (2px), FLAGGED for the director's look. Each button shows the
-- equipped bag's own icon (GetInventoryItemTexture, inv slots 20..23 — Bag0Slot..Bag3Slot,
-- PaperDollItemFrame.dbc-verified, decision 0216 slice 2's char-feed extension) or the
-- empty-slot art when no bag is equipped there. Click toggles that bag's window — NAMED
-- DEFERRAL: no drag-to-equip a bag onto the bar yet (that's the paper-doll drag arc, slice
-- 3's scope, not this one).
-- The bag bar (and the backpack toggle) are RELOCATED onto the action bar — anchored into
-- MainMenuBarArtFrame's bottom-right — which means they need seating one frame level
-- above the bar art or it paints over their icons. That lever lives with the frame it's
-- about: BenillaActionBarArt_SeatAbove (ActionBar.xml, loaded before this file), which
-- carries the full explanation; the micro-button row is its second caller. Called
-- nil-GUARDED here: the shipped load order always has the bar first, but ~25 bag/bank/
-- escape/merchant test harnesses load THIS file alone — and with no bar loaded there is by
-- definition no bar art to seat above, so the guard's no-op is the right answer, not a
-- swallowed failure.
-- Bag0Slot..Bag3Slot = live inv ids 20..23 (char_stats.rs SLOT_INFO, DBC-verified); OUR bag
-- ids 1..4 map the same way (bagId N ⇒ inv slot 19+N), the id PickupInventoryItem/
-- GetInventoryItemTexture speak.
function BenillaBagBarSlot_InvSlot(button) return 19 + button.bagId end
function BenillaBagBarSlot_OnLoad(button, bagId)
button.bagId = bagId
-- The button's ID is the REFERENCE's: its 1-based inventory slot, 20..23 (the real
-- client gets there through PaperDollItemSlotButton_OnLoad, which resolves
-- "CharacterBag0Slot" → "Bag0Slot" → GetInventorySlotInfo → 20). Nothing of ours reads
-- it — every body here goes through `button.bagId` — but every ADDON does:
-- `Bagnon.lua:104` computes its highlight target as `this:GetID() - 19`, and against
-- an unset id that is -19, so Bagnon highlighted the wrong bag (silently, with no
-- raise) on every bag-slot hover. `isBag` is the same story from the same template
-- (ref BagSlotButtonTemplate OnLoad l.33), read by KeyRingItemButton_OnClick.
button:SetID(19 + bagId)
button.isBag = 1
button.icon = getglobal(button:GetName() .. "IconTexture")
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(button) end
-- Both buttons (ref BagSlotButtonTemplate inherits PaperDollItemSlotButtonTemplate, and
-- its OnLoad calls PaperDollItemSlotButton_OnLoad — PaperDollFrame.lua:86 — so a bag-bar
-- slot registers the pair like every doll slot; MainMenuBarBagButtons.xml:78 does the
-- same for the backpack). BagSlotButton_OnClick reads no button: right-click opens the
-- bag, exactly as left does.
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
-- Drag-to-equip (decision 0208 phase 1b's deferred bag-icon arm, now landed): the bar
-- slot is a paper-doll equipment slot for bags. LEFT drag picks the equipped bag up;
-- a drag/receive drops a carried bag in to equip it (PickupInventoryItem — the exact
-- doll-slot transition, the app maps id 20..23 onto wire bag slots 19..22).
button:RegisterForDrag("LeftButton")
button:RegisterEvent("UNIT_INVENTORY_CHANGED")
button:RegisterEvent("BAG_UPDATE")
button:RegisterEvent("ITEM_LOCK_CHANGED")
BenillaBagBarSlot_Update(button)
end
-- Placing/equipping a carried bag (or picking the equipped one up) rides PickupInventoryItem,
-- exactly like a paper-doll slot; the empty-cursor plain click just toggles the bag's window.
function BenillaBagBarSlot_Place(button)
PickupInventoryItem(BenillaBagBarSlot_InvSlot(button))
BenillaBagBarSlot_Update(button)
end
-- ref BagSlotButton_OnClick (MainMenuBarBagButtons.lua l.3-21): the cursor arm first (the
-- ref's PutItemInBag; ours is the equip/swap PickupInventoryItem arm, decision 0208 1b),
-- otherwise the toggle goes THROUGH THE GLOBAL `ToggleBag` — the exact name
-- Bagnon_Core/Overrides.lua replaces — with the ref's own button sound on that arm only.
-- Toggling the window directly here (what this did before) made every bag addon's override
-- silently inert for the four slot buttons while the backpack button honoured it: the
-- 517-line rule above ("the reference's names are the real entry points") applied to only
-- one of the five buttons, and the director met the other four.
function BenillaBagBarSlot_OnClick(button)
if CursorHasItem() then
BenillaBagBarSlot_Place(button) -- drop/equip the carried bag onto this slot
else
ToggleBag(button.bagId)
PlaySound("BAGMENUBUTTONPRESS")
end
-- The click's own CheckButton auto-toggle is provisional — re-derive from the NATIVE
-- window on every branch, the ref's exact tail (BagSlotButton_OnClick l.12-20 scans
-- ContainerFrame1..N: SetChecked(isVisible) whether the click toggled, placed a bag,
-- or did nothing). `IsBagOpen` IS that scan, under the name the reference publishes
-- for it. Reading the WINDOW rather than what ToggleBag did is load-bearing both ways:
-- stock, the flag is already set when the tail runs (ContainerFrame_OnShow wrote it);
-- with an addon holding the bags, no ContainerFrame is shown and the slot button stays
-- unlit — the reference's own behaviour, its scan being equally blind to addon frames.
button:SetChecked(IsBagOpen(button.bagId) and 1 or nil)
end
function BenillaBagBarSlot_OnEvent(button)
if event == "UNIT_INVENTORY_CHANGED" and arg1 ~= "player" then return end
-- ITEM_LOCK_CHANGED names its slot in arg1/arg2; the bar's slots are the EQUIPMENT_BAG
-- space, so a bag-window lock event (a real bag id in arg1) never concerns the icon —
-- repainting anyway is idempotent (the lock read below is slot-specific). BAG_UPDATE
-- fires per-bag-CONTENTS change, not per-icon change (the icon is the bag ITEM's own
-- texture) — repainting on it too covers the freshly-equipped-bag-template-in-flight frame.
BenillaBagBarSlot_Update(button)
end
-- ── The bar buttons' hovers ───────────────────────────────────────────────────────────
-- ref GlobalStrings.lua l.132 / l.1458 (enUS), fallback form: the real localized
-- GlobalStrings.lua runs before this file at runtime (ui_script/mod.rs), so these only
-- fill in for the harnesses that load no patch chain.
BACKPACK_TOOLTIP = BACKPACK_TOOLTIP or "Backpack"
EQUIP_CONTAINER = EQUIP_CONTAINER or "Equip Container"
-- ref GlobalStrings.lua l.2472 / l.2810 — the keyring's window title + button tooltip, and
-- the full-keyring refusal.
KEYRING = KEYRING or "Keyring"
NO_EMPTY_KEYRING_SLOTS_ERROR = NO_EMPTY_KEYRING_SLOTS_ERROR or "Your keyring is full."
-- **A REFERENCE TYPO, repaired here rather than lived with** (decision 1751: a divergence
-- becomes faithful or becomes a record, never silent — this is the record).
--
-- `PutKeyInKeyRing`'s full-ring arm reads `NO_EMPTY_KEYRING_SLOTS`
-- (ContainerFrame.lua l.753), and 1.12 ships **only** `NO_EMPTY_KEYRING_SLOTS_ERROR`
-- (GlobalStrings.lua l.2810 — checked, the shorter name appears nowhere in the file). So
-- in the real client the text is nil, `UIErrorsFrame:AddMessage` refuses it, and dropping
-- a key onto a full keyring reports nothing at all.
--
-- benilla ran its own `PutKeyInKeyRing` before 1751 and simply used the right name; now
-- the reference's own file runs, so the choice is explicit. **Supplying the name the
-- reference meant is the call**: the mechanism is not in dispute (its own line says which
-- string it wants), nothing of Blizzard's is edited, and the alternative is a player
-- gesture that silently does nothing while an error scrolls past. If fidelity to the bug
-- is ever wanted instead, deleting this line is the whole change.
NO_EMPTY_KEYRING_SLOTS = NO_EMPTY_KEYRING_SLOTS or NO_EMPTY_KEYRING_SLOTS_ERROR
-- Both are PLAIN tooltips — the reference calls GameTooltip:SetText here, not
-- GameTooltip_AddNewbieTip, so unlike the micro buttons next door (0661) there is no second
-- explanation line to be missing. They also anchor beside the button (ANCHOR_LEFT, so the
-- plate opens toward screen center off a bottom-right button), NOT at the default corner.
--
-- ref MainMenuBarBackpackButton OnEnter (ref-MainMenuBarBagButtons.xml l.91-99). The ref
-- appends the live binding key with GameTooltip:AppendText; ours concatenates the same
-- suffix from a LIVE GetBindingKey read since 0997, rendered through the Key Bindings
-- window's display law when it is loaded. Unbound: the bare label, the ref's own read.
-- The command is TOGGLEBACKPACK — the ref's own read here, and since 1494 ours too: it is
-- the key that runs what a plain click on this button runs.
function BenillaBagToggle_OnEnter(button)
if not GameTooltip then return end -- engine tests load file subsets
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
local text = BACKPACK_TOOLTIP
local key = GetBindingKey("TOGGLEBACKPACK")
if key then
if KeyBindings_KeyText then key = KeyBindings_KeyText(key) end
text = text .. " " .. NORMAL_FONT_COLOR_CODE .. "(" .. key .. ")" .. FONT_COLOR_CODE_CLOSE
end
GameTooltip:SetText(text, 1.0, 1.0, 1.0)
end
-- ref BagSlotButton_OnEnter (ref-MainMenuBarBagButtons.lua l.86-96): the equipped bag's own
-- item tooltip, or "Equip Container" for an empty slot. The ref's "(F8)".."(F11)" suffix
-- needed GetBindingKey AND the TOGGLEBAG1..4 bindings; those bind as of 1494, so the ONE
-- thing still missing is `GameTooltip:AppendText` — our tooltip has SetText/AddLine and no
-- append verb, and the ref's suffix lands on a plate SetInventoryItem already wrote. That
-- verb's byte semantics (which line does it extend?) are unverified, so the suffix waits
-- rather than guessing; this is where it goes, over `GetBindingKey("TOGGLEBAG"..(5 -
-- button.bagId))` — the ref's index is REVERSED against the bar, so the slot nearest the
-- backpack (bagId 1) is TOGGLEBAG4/F11 and the farthest (bagId 4) is TOGGLEBAG1/F8.
function BenillaBagBarSlot_OnEnter(button)
if not GameTooltip then return end
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
if not GameTooltip:SetInventoryItem("player", BenillaBagBarSlot_InvSlot(button)) then
GameTooltip:SetText(EQUIP_CONTAINER, 1.0, 1.0, 1.0)
end
end
-- `BagSlotButton_OnEnter()` — THE REFERENCE'S NAME for the hover above, and the entry point
-- the four `CharacterBag<N>Slot` buttons actually call (their XML below). Published for the
-- same standing reason `ToggleBackpack` is: an addon replaces the client's global and
-- expects the client to call the replacement.
--
-- `Bagnon.lua:67` is the exact shape, and it is a hover-frequency call, not a rare one:
-- bBagSlotButton_OnEnter = BagSlotButton_OnEnter -- capture ours
-- BagSlotButton_OnEnter = BagnonBlizBag_OnEnter -- replace the global
-- Bagnon's replacement highlights the matching slots in its own window and then calls the
-- capture. With the global absent that capture was nil, and — because nothing here ever
-- looked the global up — the replacement was never reached to raise on it: Bagnon's
-- highlight was simply, silently inert. Corpus demand is exactly one addon (this one) at
-- two sites; its siblings `BagSlotButton_OnClick`/`_OnDrag`/`_OnShiftClick`/
-- `BackpackButton_OnClick` score ZERO and are deliberately not published (1210).
--
-- The reference's body takes no argument and reads `this`; so does this one.
function BagSlotButton_OnEnter()
BenillaBagBarSlot_OnEnter(this)
end
-- ref BagSlotButton_OnClick / _OnDrag / _OnShiftClick (MainMenuBarBagButtons.lua l.3-53),
-- published beside _OnEnter above for the same reason it was: they are the names
-- BagSlotButtonTemplate binds, and an addon that builds its bag bar from that template
-- reaches them and nothing else. pfUI is the measured caller (modules/bags.lua l.537), and
-- it CHAINS them — `GetScript("OnEnter")` then calling what it got — so a missing one is
-- not a dead button, it is a nil call inside the addon's own wrapper.
--
-- **Our four bar slots do not come through here** and are untouched: they carry an explicit
-- bagId from BenillaBagBarSlot_OnLoad and wire the Benilla bodies directly. These three
-- exist so a TEMPLATE-built button reaches the same bodies — one implementation, two entry
-- points, which is the whole point of publishing the reference's name over ours.
--
-- NOT the reference's bodies, and ONE of the two reasons for that expired with decision
-- 1751's bag swap. The reference's versions do two things: scan
-- ContainerFrame1..NUM_CONTAINER_FRAMES to re-derive the checked state — which now works
-- here, and the tails below do exactly it through `IsBagOpen` — and reach for
-- `PutItemInBag` / `PickupBagFromSlot`, which this client still does not bind (our
-- equip/swap arm is the paper doll's `PickupInventoryItem`, 0208 1b). That second half is
-- what still keeps `Interface\FrameXML\MainMenuBarBagButtons.xml` out of the manifest:
-- sourcing it today hands every caller a body that raises on a verb we have not built.
-- Those two verbs are the bag BAR's migration ticket, and this file is what it replaces.
function BagSlotButton_OnClick()
BenillaBagBarSlot_OnClick(this)
end
-- The drag arm: ref picks the equipped bag up with PickupBagFromSlot; ours is the same
-- PickupInventoryItem transition the bar slots already use, so an empty cursor picks the
-- bag up and a held bag places it.
function BagSlotButton_OnDrag()
BenillaBagBarSlot_Place(this)
end
-- Shift-click opens every bag if this slot HOLDS one (ref l.38-41 verbatim), then re-reads
-- the checked state off our own window the way BenillaBagBarSlot_OnClick's tail does.
function BagSlotButton_OnShiftClick()
if GetInventoryItemTexture("player", this:GetID()) then
OpenAllBags()
end
this:SetChecked(this.bagId and IsBagOpen(this.bagId) and 1 or nil)
end
-- ref OnLeave for both (l.100-102 / the template's own): a plain Hide, no fade — the item
-- half of the split BenillaUnitFrame_OnLeave's comment describes.
function BenillaBagBarButton_OnLeave()
if GameTooltip then GameTooltip:Hide() end
end
-- ref KeyRingButton's OnLoad/OnClick/OnReceiveDrag (MainMenuBarBagButtons.xml l.142-199).
-- The button is authored HIDDEN and only ever revealed by MainMenuBar_UpdateKeyRing
-- (ActionBar.xml), which is where the whole show/hide law and its divergence note live.
function BenillaKeyRingButton_OnLoad(button)
button:SetID(KEYRING_CONTAINER)
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
button:RegisterForDrag("LeftButton")
-- The ref watches PLAYER_ENTERING_WORLD / VARIABLES_LOADED / BAG_UPDATE on the bar's art
-- frame; with no saved variable there is nothing for VARIABLES_LOADED to restore, and
-- BAG_UPDATE is what actually carries "a key just arrived".
button:RegisterEvent("PLAYER_ENTERING_WORLD")
button:RegisterEvent("BAG_UPDATE")
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(button) end
BenillaKeyRingButton_OnEvent()
end
function BenillaKeyRingButton_OnEvent()
if MainMenuBar_UpdateKeyRing then MainMenuBar_UpdateKeyRing() end
end
function BenillaKeyRingButton_OnClick(button)
-- Holding something ⇒ file it; empty-handed ⇒ open/close the keyring (ref l.166-173).
if CursorHasItem() then
PutKeyInKeyRing()
else
ToggleKeyRing()
end
-- Re-derive the lit ring from the window, the same provisional-toggle tail every other
-- bag-bar button uses. The keyring's window is an ordinary ContainerFrame now, so this
-- is the same `IsBagOpen` scan as the four bag slots', at KEYRING_CONTAINER.
button:SetChecked(IsBagOpen(KEYRING_CONTAINER) and 1 or nil)
end
-- ref l.180-184: the plain KEYRING title plus the ref's own empty AddLine (which reserves
-- the second row the binding key would print into — we ship no TOGGLEKEYRING binding, the
-- same gap the bag slots' "(F1)".."(F4)" suffixes have).
function BenillaKeyRingButton_OnEnter(button)
if not GameTooltip then return end
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
GameTooltip:SetText(KEYRING, 1.0, 1.0, 1.0)
end
function BenillaBagBarSlot_Update(button)
local invSlot = BenillaBagBarSlot_InvSlot(button)
local texture = GetInventoryItemTexture("player", invSlot)
button.icon:SetTexture(texture or "Interface\\PaperDoll\\UI-PaperDoll-Slot-Bag")
-- Dim while the bag is up on the cursor or a pending equip is in flight (the doll's own
-- drag feedback, IsInventoryItemLocked — the bag-slot twin of BenillaBagSlot_Update).
if IsInventoryItemLocked(invSlot) then
button.icon:SetVertexColor(0.4, 0.4, 0.4)
elseif button:IsEnabled() ~= 0 then
button.icon:SetVertexColor(1, 1, 1)
else
-- Disabled by Disable_BagButtons below: keep the grey. The reference needs no such
-- arm — its desaturation is a shader flag on the texture, untouched by an Update
-- that only re-sets the image — but ours IS a vertex colour (UiPanels.xml
-- SetDesaturation), so a bag update behind the open game menu would otherwise
-- repaint the icon bright under a button that stayed dead.
button.icon:SetVertexColor(0.5, 0.5, 0.5)
end
end
-- ── The item-push drop animation (decision 0887) ─────────────────────────────────────────
-- An item lands in a bag: its icon pops in above the bag bar, hangs there, then falls into
-- the button that took it and shrinks away. ref ItemAnim (MainMenuBarBagButtons.xml l.4-16 +
-- .lua l.68-80, .lua l.92-94): each bag button owns a hidden child that, on ITEM_PUSH
-- naming its own container, swaps in the pushed item's icon, rewinds to sequence time 0 and
-- shows itself; OnAnimFinished hides it again. A second push on the same button RESTARTS it
-- (SetSequenceTime(0,0)); pushes into different bags run independently. That is exactly the
-- machine below, with the reference's ITEM_PUSH `arg1` translated from its inventory-slot
-- vocabulary (20..23) into ours (bagId 1..4) app-side — ui_loot.rs `push_container` carries
-- the byte-verified selector.
--
-- The reference's is a <Model> playing Interface\ItemAnimations\ForcedBackpackItem.mdx — a
-- single 4-vertex quad on one animated bone, i.e. a 2D icon card with keyframed scale, alpha
-- and offset. benilla has no Model widget (decision 0137's Cooldown took the same posture:
-- model the mechanism, let our own renderer draw it), so it is a Texture driven by the
-- model's OWN tracks, read out of the shipped .m2 with `benilla-extract m2anim`/`m2bones`:
--
-- sequence 0, CLAMP (plays once), duration 1.000 s
-- texture-weight (alpha) 0.000→0.0 0.133→1.0 0.267→1.0 1.000→0.0 linear
-- bone 0 scale 0.000→1.0 0.133→1.2 0.267→1.0 1.000→0.014 linear
-- bone 0 translation 0.000→(0,0) 0.500→(0,0) 1.000→(+0.010,-0.040)
-- the quad itself: 0.03 x 0.03 model units, blend Alpha, unlit, no depth write
--
-- So: fade in over the first 133 ms while swelling to 1.2x; settle back to full size by
-- 267 ms; from there dwindle and fade continuously for the remaining 733 ms — while the
-- POSITION holds until the half-second and only then falls. The three tracks deliberately
-- do not share a schedule: the card is already down to ~0.69 size and ~0.68 alpha when it
-- starts moving, so the drop reads as the icon being drawn into the bag rather than as a
-- solid tile sliding. Those numbers are the file's, not ours.
--
-- THE MODEL-UNIT → PIXEL SCALE is no longer ours to calibrate: the <Model> widget's
-- projection is RE-recorded now (wow-re `system/ui/scratch/modelframe-animation-clock.md`,
-- dispatched over the pet bar's B228 and folded back with it). The ortho leg gives
--
-- px per model unit = (5/3) * modelScale * layoutScale * screenHeightPx
--
-- which in FrameXML units is **1280 * modelScale * layoutScale**, aspect- and
-- resolution-independent, with the model ORIGIN at the frame rect's bottom-left. The ref's
-- `ItemAnimTemplate` (MainMenuBarBagButtons.xml:4) carries **no `scale` attribute**, so
-- modelScale is 1 and the constant is a flat 1280. Decision 0887's named gap is closed.
--
-- This REPLACES a back-fit, and the back-fit is worth naming because it looked like
-- evidence. The old 1200 came from assuming CooldownFrameTemplate's 0.04-unit sweep
-- *exactly* covers a 36 px action button at its scale="0.75" (⇒ K = 1200). The assumption
-- is the error: at the real projection that sweep is 0.04 · 1280 · 0.75 = **38.4 px**, so
-- it overhangs its button by 6.7% rather than fitting it — exactly as the autocast shine's
-- 30.72 px square overhangs its 30 px button. Solving against a wrong premise produced a
-- number that then "cross-checked" against a second model derived the same wrong way, and
-- both files cited the agreement as the reason to trust it.
--
-- Everything below is the model's own ratios times this constant, so the correction lands
-- in one place and the animation stays in proportion.
BENILLA_ITEM_PUSH_PX_PER_UNIT = 1280
BENILLA_ITEM_PUSH_DURATION = 1.0
-- ── The widget rect, and why the card is not placed against the button ─────────────────
-- A `<Model>` with no `<Size>` and no `setAllPoints` takes its rect from the **M2 header
-- bounding box** (`MD20+0xB4`, max−min per axis), consumed RAW as layout units — unlike
-- CSimpleTexture's texel fallback, there is no AbsDimension conversion. So the ref's
-- ItemAnim widget is **42.41 x 124.72**, nothing like the 36-square everyone assumes from
-- looking at the XML, and **model-space (0,0,0) lands on that rect's BOTTOM-LEFT corner**.
-- (wow-re `system/ui/scratch/modelframe-implicit-size-law.md`, §5-cross-checked: vtable
-- 0x81c5d8 +0x1c/+0x20 -> 0x76d080/0x76d0d0, bbox source 0x713640 at `713661 add esi,0xb4`.
-- The one worker who read `MD20+0xD0` — the VertexBox — got an all-zero box and concluded
-- the widget was degenerate; that wrong premise is exactly what the pair round exists to
-- catch, and it is why this looked unresolvable in 0887.)
--
-- That rect is the whole reason this animation reads the way it does: it stands 124.72 tall
-- off the button's own bottom edge, so the card's travel band reaches **54 px above the
-- button's top**. Decision 0887 could not know that and placed the card against the
-- button's CENTER instead, which is what put it in the wrong place in x and gave it no
-- horizontal drift at all.
BENILLA_ITEM_PUSH_FRAME_W = 42.41
BENILLA_ITEM_PUSH_FRAME_H = 124.72
-- Where that rect hangs off the button: the ref anchors the widget's BOTTOMRIGHT to the
-- button's, 10 in from the right for the five bag buttons and flush (0) for the keyring —
-- MainMenuBarBagButtons.xml l.19-27 (BagSlotButtonTemplate) and l.152-160 (KeyRingButton).
-- Per-button, so it is an OnLoad argument, not a constant.
BENILLA_ITEM_PUSH_ANCHOR_X = -10
-- ── The model's own numbers, unrounded ────────────────────────────────────────────────
-- Every one of these was rounded in 0887 and every rounding moved the card. The quad span
-- read as 0.03 (it is 0.0288 — a 4% size error), and the translation as (0.010, -0.040)
-- (which alone accounts for half a pixel of the landing point).
--
-- The bone transform is `v' = pivot + T(t) + S(t) * (v - pivot)` — three wow-re workers
-- derived that independently in three matrix conventions and agreed. The pivot
-- (M2CompBone+0x60) is NOT the quad's centre: it sits 0.23 px right and 0.07 px above it,
-- so the card shrinks *almost* in place and drifts a fifth of a pixel doing it. Keeping
-- the pivot as its own term rather than folding it into the centre is what makes this
-- reproduce the reference exactly at all three sampled times instead of nearly.
-- Bone 0's ROTATION track is empty (nKeys = 0), so there is no roll to model.
BENILLA_ITEM_PUSH_PIVOT_X = 0.017580900341272354
BENILLA_ITEM_PUSH_PIVOT_Y = 0.053958501666784286
BENILLA_ITEM_PUSH_QUAD_X = 0.017400000942870975
BENILLA_ITEM_PUSH_QUAD_Y = 0.053900001570582390
BENILLA_ITEM_PUSH_QUAD_SPAN = 0.028800001833587885
-- {time, value} keyframes, linear between (M2 track interpolation 1) — the file's own keys,
-- at full precision. The three tracks keep DIFFERENT schedules and that is the whole feel:
-- the card fades up while swelling to 1.2x over 133 ms, settles back to full size at
-- 267 ms, then dwindles and fades *continuously* for the remaining 733 ms — while its
-- position holds until the half second and only then falls. So it is already at ~0.69 size
-- and ~0.68 alpha when it starts to move, and it is invisible by the time it lands.
BENILLA_ITEM_PUSH_ALPHA = { {0.000, 0.0}, {0.133, 1.0}, {0.267, 1.0}, {1.000, 0.0} }
BENILLA_ITEM_PUSH_SCALE = { {0.000, 1.0}, {0.133, 1.2000000476837158}, {0.267, 1.0}, {1.000, 0.01441549975425005} }
-- Translation is per-AXIS: the card falls 51.4 px and drifts 13.3 px RIGHT, so one shared
-- 0..1 "drop" curve (0887's shape) cannot express it — the old code had no horizontal term
-- at all and moved the card the wrong way in x when it inferred one.
BENILLA_ITEM_PUSH_TRANS_X = { {0.000, 0.0}, {0.500, 0.0}, {1.000, 0.010394600220024586} }
BENILLA_ITEM_PUSH_TRANS_Y = { {0.000, 0.0}, {0.500, 0.0}, {1.000, -0.040167398750782013} }
-- Sample a {time, value} keyframe list at `t`, linear between keys, clamped at both ends
-- (the M2 sampler's own shape for a CLAMP sequence).
function BenillaItemPushAnim_Sample(track, t)
local last = table.getn(track)
if t <= track[1][1] then return track[1][2] end
for i = 2, last do
local key = track[i]
if t <= key[1] then
local prev = track[i - 1]
local span = key[1] - prev[1]
if span <= 0 then return key[2] end
return prev[2] + (key[2] - prev[2]) * (t - prev[1]) / span
end
end
return track[last][2]
end
-- Place/size/fade the card for the current clock, from the model's own transform.
--
-- The card's centre in MODEL space is `pivot + T(t) + S(t)*(quadCentre - pivot)`; times the
-- projection constant that is its offset in layout units from the model origin, which is
-- the widget rect's bottom-left corner. The rect's own BOTTOMRIGHT hangs off the button's,
-- so the card is placed against the BUTTON'S BOTTOMRIGHT — never its centre — and the
-- frame width is what converts between the two.
--
-- On screen: a button-sized card appears with its bottom edge ~13.6 px ABOVE the button's
-- top, pops to 1.2x and back while fading in, hangs there through the first half second,
-- then falls down-and-right INTO the button, shrinking to a half-pixel point at its centre
-- as it fades out. Reproduces the reference at t = 0, 0.133 and 1.0 to the hundredth of a
-- pixel (`modelframe-implicit-size-law.md` §3's table).
--
-- NOT modelled, deliberately, and both are named in the record: the reference's widget
-- CLIPS the card to its own rect, which shaves 1.94 px off the right edge at the 1.2x peak
-- **at 4:3 only** (16:10 and 16:9 clear it); and the widget clears the depth buffer inside
-- that 124.72-tall rect every paint. benilla draws this as a Texture, not a Model, so it
-- inherits neither — reproducing an aspect-conditional sliver would be hard-coding a
-- rendering artefact rather than modelling the mechanism that causes it.
function BenillaItemPushAnim_Paint(anim)
local t = anim.elapsed
local k = BENILLA_ITEM_PUSH_PX_PER_UNIT
local scale = BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_SCALE, t)
local size = BENILLA_ITEM_PUSH_QUAD_SPAN * scale * k
local cx = (BENILLA_ITEM_PUSH_PIVOT_X
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_X, t)
+ scale * (BENILLA_ITEM_PUSH_QUAD_X - BENILLA_ITEM_PUSH_PIVOT_X)) * k
local cy = (BENILLA_ITEM_PUSH_PIVOT_Y
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_Y, t)
+ scale * (BENILLA_ITEM_PUSH_QUAD_Y - BENILLA_ITEM_PUSH_PIVOT_Y)) * k
anim:SetWidth(size)
anim:SetHeight(size)
anim:SetPoint("CENTER", anim:GetParent(), "BOTTOMRIGHT",
anim.anchorX - BENILLA_ITEM_PUSH_FRAME_W + cx, cy)
anim:SetAlpha(BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_ALPHA, t))
end
-- ref ItemAnim_OnLoad (l.68-70): the card listens for ITEM_PUSH and nothing else. It is
-- authored hidden, so it costs no OnUpdate until a push shows it (events reach hidden
-- frames; OnUpdate does not — the reference's own split).
function BenillaItemPushAnim_OnLoad(anim, containerId, anchorX)
anim.containerId = containerId
-- The widget's own offset from the button's BOTTOMRIGHT: -10 for the five bag buttons,
-- flush 0 for the keyring (the ref's two different anchors — see the constant block).
anim.anchorX = anchorX or BENILLA_ITEM_PUSH_ANCHOR_X
anim.icon = getglobal(anim:GetName() .. "Icon")
anim.elapsed = 0
anim:RegisterEvent("ITEM_PUSH")
end
-- ref ItemAnim_OnEvent (l.72-80): `if this:GetParent():GetID() == arg1`, then
-- ReplaceIconTexture(arg2) / SetSequence(0) / SetSequenceTime(0,0) / Show. A push into a bag
-- with no button (a bank bag) matches nothing and animates nowhere, there and here.
function BenillaItemPushAnim_OnEvent(anim)
if arg1 ~= anim.containerId then return end
anim.icon:SetTexture(arg2)
anim.elapsed = 0
-- Seat the card above its button rather than at load: the bag bar's own level is set in
-- the BUTTON's OnLoad (BenillaActionBarArt_SeatAbove), which the loader may run after
-- this child exists. Re-deriving it per push is one call and can never read it stale.
anim:SetFrameLevel(anim:GetParent():GetFrameLevel() + 1)
BenillaItemPushAnim_Paint(anim)
anim:Show()
end
-- The CLAMP sequence's end is the ref's OnAnimFinished → this:Hide() (l.92-94).
function BenillaItemPushAnim_OnUpdate(anim, elapsed)
anim.elapsed = anim.elapsed + (elapsed or 0)
if anim.elapsed >= BENILLA_ITEM_PUSH_DURATION then
anim:Hide()
return
end
BenillaItemPushAnim_Paint(anim)
end
-- The bag BAR's five buttons (the backpack toggle + the four equipped-bag slots) — the row
-- Disable_/Enable_BagButtons greys as a unit. Distinct from UiPanels.xml's BAG_WINDOWS,
-- which is the five bag *windows* those buttons open.
BENILLA_BAG_BUTTONS = { "MainMenuBarBackpackButton", "CharacterBag0Slot", "CharacterBag1Slot",
"CharacterBag2Slot", "CharacterBag3Slot" }
-- Disable_BagButtons / Enable_BagButtons — ref MainMenuBarBagButtons.lua l.102-128, called
-- by GameMenuFrame's OnShow/OnHide (decision 0674) and by nothing else: while the game menu
-- holds the screen, the whole bag row greys out. That is the *visible* half of a rule the
-- panel manager already enforces (ShowUIPanel's native-center gate refuses every window
-- while the menu is up — UiPanels.xml `CanOpenPanels`); without it the bags would look
-- clickable and simply do nothing.
--
-- Mapping onto benilla's row: the ref's MainMenuBarBackpackButton is MainMenuBarBackpackButton and
-- CharacterBag0..3Slot are CharacterBag0Slot..4 — and in BOTH cases the greying goes onto
-- the button's `$parentIcon` LAYER texture, never onto a state texture. That is the
-- reference's own structure and it is load-bearing here: a disabled button draws its
-- DisabledTexture, and with none set it draws no state texture at all
-- (`ButtonState::region_visible`), so greying a NormalTexture would grey something already
-- gone. The four slots' UI-Quickslot2 rings ARE state textures and do vanish while
-- disabled unless the slot states one explicitly — so what you should see is the
-- ring stay put and the bag icon grey — which is why each slot repeats its ring as
-- DisabledTexture (see the slot instances below).
function Disable_BagButtons()
for _, button in ipairs(BENILLA_BAG_BUTTONS) do
local b = getglobal(button)
b:Disable()
SetDesaturation(b.icon, 1)
end
end
function Enable_BagButtons()
for _, button in ipairs(BENILLA_BAG_BUTTONS) do
local b = getglobal(button)
b:Enable()
SetDesaturation(b.icon, nil)
end
-- The four slots' own paint decides their restored colour: an item locked on the cursor
-- wants its 0.4 grey back, which the un-desaturate above does not restore (it clears
-- the greyscale flag, not the tint).
for i = 1, 4 do
BenillaBagBarSlot_Update(getglobal("CharacterBag" .. (i - 1) .. "Slot"))
end
end
]]></Script>
<!--
The backpack toggle, RELOCATED onto the main action bar's right side so the bar reads whole
(the real client's MainMenuBarBackpackButton, ref-MainMenuBarBagButtons.xml l.54-64): a 37x37
button parented/anchored to the bar art frame at BOTTOMRIGHT (-6,2). Its behavior (toggle the
backpack window) is unchanged; only its seat moved from the free-floating screen corner onto
the bar. (MainMenuBarArtFrame is defined in ActionBar.xml, which the loader loads before
this file; the anchor resolves by name.)
-->
<CheckButton name="MainMenuBarBackpackButton" id="0" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativeTo="MainMenuBarArtFrame" relativePoint="BOTTOMRIGHT">
<Offset><AbsDimension x="-6" y="2"/></Offset>
</Anchor>
</Anchors>
<!-- The backpack art is a LAYER texture, not the NormalTexture — the reference's own
structure (MainMenuBarBackpackButton inherits ItemButtonTemplate and sets
`MainMenuBarBackpackButtonIconTexture` in its OnLoad; the state textures are the
template's ring). It matters because `Disable_BagButtons` (decision 0674) disables this
button while the game menu is up, and a *state* texture with no DisabledTexture beside
it is not drawn in the disabled state (`ButtonState::region_visible` — the byte rule at
`SetState 0x779790`), so the backpack icon vanished outright instead of greying
(director-reported). As a layer it is untouched by button state, exactly like the four
bag-bar slots' own `$parentIcon`, and the greying is the vertex tint on it. -->
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIconTexture" file="Interface\Buttons\Button-Backpack-Up"/>
</Layer>
</Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button, container id 0 = the backpack. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 0)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<!-- The lit ring while the backpack window is open (ref MainMenuBarBackpackButton
l.104) — a CheckButton like the ref's; OnShow/OnHide of the window drive it. -->
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<!-- Seat the backpack icon above the bar art too (same reason as the bag-bar slots —
BenillaActionBarArt_SeatAbove's comment). -->
<OnLoad>
self.icon = getglobal("MainMenuBarBackpackButtonIconTexture")
if BenillaActionBarArt_SeatAbove then BenillaActionBarArt_SeatAbove(self) end
</OnLoad>
<!-- The undo repairs the CheckButton widget's own pre-handler flip; from there the
checked state belongs to the windows' OnShow/OnHide writes (ours and any
addon's) — the stated divergence at BenillaBagToggle_OnClick's comment.
The shift fork is the ref's own (MainMenuBarBagButtons.xml l.81-87): the button
carries the SAME pair of knobs the B / SHIFT-B keys do (1494), so a shift-click
opens all bags and a plain one toggles the backpack. -->
<OnClick>
self:SetChecked(not self:GetChecked())
if IsShiftKeyDown() then
OpenAllBags()
else
BenillaBagToggle_OnClick()
end
</OnClick>
<OnEnter>BenillaBagToggle_OnEnter(self)</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
</Scripts>
</CheckButton>
<!--
The bag bar (decision 0216 slice 2, BenillaBagBarSlot_* above): four 36×36 buttons to the
LEFT of MainMenuBarBackpackButton, mirroring the reference CharacterBag0..3Slot row. Bar slot N (1..4)
sits at chain position N counting away from the backpack button — CharacterBag0Slot (bagId
1, Bag0Slot) is CLOSEST to the backpack, CharacterBag3Slot (bagId 4, Bag3Slot) FARTHEST —
the real bar's own left-to-right order. Sized to MATCH the action buttons (36×36 icon in a
66×66 UI-Quickslot2 ring, 6px chain gap = the 42px stride), so the bag icons fill their slots
exactly like the spell icons instead of floating undersized (the earlier 30×30 guess).
-->
<CheckButton name="CharacterBag0Slot" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="MainMenuBarBackpackButton" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIconTexture"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 1. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 1)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</NormalTexture>
<!-- The same ring again as the DISABLED art, so the slot keeps its frame while
Disable_BagButtons has the row greyed under the game menu (decision 0674). A button
with no DisabledTexture draws NO state texture when disabled
(`ButtonState::region_visible`) — the ring would blink out of the bar entirely. The
reference needs no such line because its widget keeps the previously shown texture up
when the new state's slot is empty (`SetState 0x779790`'s gated hide); ours states it
instead of relying on that. -->
<DisabledTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</DisabledTexture>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<!-- The lit ring while this bag's window is open (ref BagSlotButtonTemplate l.52). -->
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnLoad>BenillaBagBarSlot_OnLoad(self, 1)</OnLoad>
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
<OnEnter>BagSlotButton_OnEnter()</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
bag places), mirroring the paper-doll/bag-slot drag routing. -->
<OnDragStart>BenillaBagBarSlot_Place(self)</OnDragStart>
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<CheckButton name="CharacterBag1Slot" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="CharacterBag0Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIconTexture"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 2. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 2)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</NormalTexture>
<!-- The same ring again as the DISABLED art, so the slot keeps its frame while
Disable_BagButtons has the row greyed under the game menu (decision 0674). A button
with no DisabledTexture draws NO state texture when disabled
(`ButtonState::region_visible`) — the ring would blink out of the bar entirely. The
reference needs no such line because its widget keeps the previously shown texture up
when the new state's slot is empty (`SetState 0x779790`'s gated hide); ours states it
instead of relying on that. -->
<DisabledTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</DisabledTexture>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnLoad>BenillaBagBarSlot_OnLoad(self, 2)</OnLoad>
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
<OnEnter>BagSlotButton_OnEnter()</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
bag places), mirroring the paper-doll/bag-slot drag routing. -->
<OnDragStart>BenillaBagBarSlot_Place(self)</OnDragStart>
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<CheckButton name="CharacterBag2Slot" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="CharacterBag1Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIconTexture"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 3. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 3)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</NormalTexture>
<!-- The same ring again as the DISABLED art, so the slot keeps its frame while
Disable_BagButtons has the row greyed under the game menu (decision 0674). A button
with no DisabledTexture draws NO state texture when disabled
(`ButtonState::region_visible`) — the ring would blink out of the bar entirely. The
reference needs no such line because its widget keeps the previously shown texture up
when the new state's slot is empty (`SetState 0x779790`'s gated hide); ours states it
instead of relying on that. -->
<DisabledTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</DisabledTexture>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnLoad>BenillaBagBarSlot_OnLoad(self, 3)</OnLoad>
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
<OnEnter>BagSlotButton_OnEnter()</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
bag places), mirroring the paper-doll/bag-slot drag routing. -->
<OnDragStart>BenillaBagBarSlot_Place(self)</OnDragStart>
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<CheckButton name="CharacterBag3Slot" parent="MainMenuBarArtFrame">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="CharacterBag2Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<Layers><Layer level="BACKGROUND"><Texture name="$parentIconTexture"/></Layer></Layers>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id 4. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, 4)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</NormalTexture>
<!-- The same ring again as the DISABLED art, so the slot keeps its frame while
Disable_BagButtons has the row greyed under the game menu (decision 0674). A button
with no DisabledTexture draws NO state texture when disabled
(`ButtonState::region_visible`) — the ring would blink out of the bar entirely. The
reference needs no such line because its widget keeps the previously shown texture up
when the new state's slot is empty (`SetState 0x779790`'s gated hide); ours states it
instead of relying on that. -->
<DisabledTexture file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="66" y="66"/></Size>
<Anchors><Anchor point="CENTER" relativePoint="CENTER"/></Anchors>
</DisabledTexture>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnLoad>BenillaBagBarSlot_OnLoad(self, 4)</OnLoad>
<OnClick>BenillaBagBarSlot_OnClick(self)</OnClick>
<OnEvent>BenillaBagBarSlot_OnEvent(self)</OnEvent>
<OnEnter>BagSlotButton_OnEnter()</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- The drag trio: OnDragStart picks the equipped bag up, OnReceiveDrag drops a carried
bag in — both the same PickupInventoryItem transition (empty cursor picks up, a held
bag places), mirroring the paper-doll/bag-slot drag routing. -->
<OnDragStart>BenillaBagBarSlot_Place(self)</OnDragStart>
<OnReceiveDrag>BenillaBagBarSlot_Place(self)</OnReceiveDrag>
</Scripts>
</CheckButton>
<!--
The keyring button (decision 0765) — ref KeyRingButton, MainMenuBarBagButtons.xml l.142-199.
Sits at the FAR left of the bag row, past CharacterBag3Slot (the ref's CharacterBag3Slot),
and is authored HIDDEN: nothing reveals it but MainMenuBar_UpdateKeyRing, and only
while HasKey() — the point being that a character with no key has no keyring at all, and the
bar's plate has no socket for one.
18x39 and the TexCoords are the ref's exactly, and they are not arbitrary: the three state
atlases are 32x64 holding one 18x39 button, so 18/32 = 0.5625 and 39/64 = 0.609375 crop it
1:1. The chain gap is OUR row's -6 rather than the ref's -5, so the button seats evenly
against the 36px slots this bar scaled its own row to.
Unlike the five bag buttons it is NOT in BENILLA_BAG_BUTTONS: its art is a state texture, not
an $parentIcon layer, so Disable_BagButtons' SetDesaturation(b.icon) has nothing to grey —
and the ref leaves it out of its own Disable_BagButtons for the same reason.
-->
<CheckButton name="KeyRingButton" parent="MainMenuBarArtFrame" hidden="true">
<Size><AbsDimension x="18" y="39"/></Size>
<Anchors>
<Anchor point="RIGHT" relativeTo="CharacterBag3Slot" relativePoint="LEFT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor>
</Anchors>
<!-- The item-push drop card (decision 0887, BenillaItemPushAnim_* above) — the ref's
$parentItemAnim, one per bag button; container id -2 (KEYRING_CONTAINER). The ref gives
the keyring one too (MainMenuBarBagButtons.xml l.152-160), and the byte selector really
does reach it: a push landing in player-array slots 81..112 resolves to -2, not 0. -->
<Frames>
<Frame name="$parentItemAnim" hidden="true">
<Size><AbsDimension x="36" y="36"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="OVERLAY">
<Texture name="$parentIcon" setAllPoints="true"/>
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, -2, 0)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
</Frame>
</Frames>
<NormalTexture file="Interface\Buttons\UI-Button-KeyRing">
<TexCoords left="0" right="0.5625" top="0" bottom="0.609375"/>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Button-KeyRing-Down">
<TexCoords left="0" right="0.5625" top="0" bottom="0.609375"/>
</PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-Button-KeyRing-Highlight" alphaMode="ADD">
<TexCoords left="0" right="0.5625" top="0" bottom="0.609375"/>
</HighlightTexture>
<!-- The lit ring while the keyring window is open — the bag row's own convention
(the ref drives the same state through UpdateMicroButtons' SetButtonState). -->
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnLoad>BenillaKeyRingButton_OnLoad(self)</OnLoad>
<OnEvent>BenillaKeyRingButton_OnEvent()</OnEvent>
<OnClick>BenillaKeyRingButton_OnClick(self)</OnClick>
<OnEnter>BenillaKeyRingButton_OnEnter(self)</OnEnter>
<OnLeave>BenillaBagBarButton_OnLeave()</OnLeave>
<!-- A key dragged onto the button files itself, the ref's own OnReceiveDrag (l.174-178). -->
<OnReceiveDrag>PutKeyInKeyRing()</OnReceiveDrag>
</Scripts>
</CheckButton>
</Ui>
@@ -21,7 +21,7 @@
Bongos_ActionBar, the CT_BarMod family) hook that global and call through; they now reach the
real thing.
── The one adapter, and why it is not a stub ────────────────────────────────────────────────
── The adapters, and why neither is a stub ──────────────────────────────────────────────────
`ContainerFrameItemButton_OnEnter` opens with a keyring fork (ref l.615-620):
if this:GetParent():GetID() == KEYRING_CONTAINER then
@@ -118,5 +118,277 @@
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
-- ══ THE ITEM-PUSH DROP CARD, over the reference's own bag bar (decision 0887) ═════════
--
-- The bar is `Interface\FrameXML\MainMenuBarBagButtons.xml` now (1751 window 3), and its
-- `ItemAnimTemplate` is a `<Model>` playing `Interface\ItemAnimations\ForcedBackpackItem.mdx`.
-- **This engine's `<Model>` holds the scene the Lua API reads and writes but draws no
-- pixels** (`script/modelframe.rs` — the pane is the app renderer's job and no renderer
-- claims a FrameXML model), so those six frames load, register, and animate nothing.
--
-- That is a genuine engine difference, which is the one thing 1751 §2 keeps an adapter
-- for. So the reference's file is sourced unedited — its Models exist and are harmless —
-- and the card the player actually sees is built HERE, as a sibling Frame + Texture on the
-- same six buttons, driven by the model's OWN keyframes read out of the shipped .m2. The
-- bodies below moved verbatim from the deleted `BagFrame.xml`; every number in them is
-- byte-derived and the comments carry their provenance.
--
-- ONE THING CHANGED IN THE MOVE, and it is the reason the swap needed thought at all.
-- `ItemAnim_OnEvent` matches on `this:GetParent():GetID()`, which for a bag button is its
-- INVENTORY-slot id (20..23, via `GetInventorySlotInfo`). benilla used to translate
-- `ITEM_PUSH`'s `arg1` into its own container vocabulary (1..4) because the old bar was
-- keyed that way, and against the reference's body that comparison is false for every bag
-- — four cards that would simply never have played, with nothing raising. The fix was not
-- to adapt the reference: `ui_loot.rs`'s `push_container` stopped translating and now emits
-- `0x491bb5`'s selector verbatim, which is what addons expect too. So the id a card matches
-- on is `button:GetID()`, exactly as the reference's own body reads it.
-- ── The item-push drop animation (decision 0887) ─────────────────────────────────────────
-- An item lands in a bag: its icon pops in above the bag bar, hangs there, then falls into
-- the button that took it and shrinks away. ref ItemAnim (MainMenuBarBagButtons.xml l.4-16 +
-- .lua l.68-80, .lua l.92-94): each bag button owns a hidden child that, on ITEM_PUSH
-- naming its own container, swaps in the pushed item's icon, rewinds to sequence time 0 and
-- shows itself; OnAnimFinished hides it again. A second push on the same button RESTARTS it
-- (SetSequenceTime(0,0)); pushes into different bags run independently. That is exactly the
-- machine below, with the reference's ITEM_PUSH `arg1` translated from its inventory-slot
-- vocabulary (20..23) into ours (bagId 1..4) app-side — ui_loot.rs `push_container` carries
-- the byte-verified selector.
--
-- The reference's is a <Model> playing Interface\ItemAnimations\ForcedBackpackItem.mdx — a
-- single 4-vertex quad on one animated bone, i.e. a 2D icon card with keyframed scale, alpha
-- and offset. benilla has no Model widget (decision 0137's Cooldown took the same posture:
-- model the mechanism, let our own renderer draw it), so it is a Texture driven by the
-- model's OWN tracks, read out of the shipped .m2 with `benilla-extract m2anim`/`m2bones`:
--
-- sequence 0, CLAMP (plays once), duration 1.000 s
-- texture-weight (alpha) 0.000→0.0 0.133→1.0 0.267→1.0 1.000→0.0 linear
-- bone 0 scale 0.000→1.0 0.133→1.2 0.267→1.0 1.000→0.014 linear
-- bone 0 translation 0.000→(0,0) 0.500→(0,0) 1.000→(+0.010,-0.040)
-- the quad itself: 0.03 x 0.03 model units, blend Alpha, unlit, no depth write
--
-- So: fade in over the first 133 ms while swelling to 1.2x; settle back to full size by
-- 267 ms; from there dwindle and fade continuously for the remaining 733 ms — while the
-- POSITION holds until the half-second and only then falls. The three tracks deliberately
-- do not share a schedule: the card is already down to ~0.69 size and ~0.68 alpha when it
-- starts moving, so the drop reads as the icon being drawn into the bag rather than as a
-- solid tile sliding. Those numbers are the file's, not ours.
--
-- THE MODEL-UNIT → PIXEL SCALE is no longer ours to calibrate: the <Model> widget's
-- projection is RE-recorded now (wow-re `system/ui/scratch/modelframe-animation-clock.md`,
-- dispatched over the pet bar's B228 and folded back with it). The ortho leg gives
--
-- px per model unit = (5/3) * modelScale * layoutScale * screenHeightPx
--
-- which in FrameXML units is **1280 * modelScale * layoutScale**, aspect- and
-- resolution-independent, with the model ORIGIN at the frame rect's bottom-left. The ref's
-- `ItemAnimTemplate` (MainMenuBarBagButtons.xml:4) carries **no `scale` attribute**, so
-- modelScale is 1 and the constant is a flat 1280. Decision 0887's named gap is closed.
--
-- This REPLACES a back-fit, and the back-fit is worth naming because it looked like
-- evidence. The old 1200 came from assuming CooldownFrameTemplate's 0.04-unit sweep
-- *exactly* covers a 36 px action button at its scale="0.75" (⇒ K = 1200). The assumption
-- is the error: at the real projection that sweep is 0.04 · 1280 · 0.75 = **38.4 px**, so
-- it overhangs its button by 6.7% rather than fitting it — exactly as the autocast shine's
-- 30.72 px square overhangs its 30 px button. Solving against a wrong premise produced a
-- number that then "cross-checked" against a second model derived the same wrong way, and
-- both files cited the agreement as the reason to trust it.
--
-- Everything below is the model's own ratios times this constant, so the correction lands
-- in one place and the animation stays in proportion.
BENILLA_ITEM_PUSH_PX_PER_UNIT = 1280
BENILLA_ITEM_PUSH_DURATION = 1.0
-- ── The widget rect, and why the card is not placed against the button ─────────────────
-- A `<Model>` with no `<Size>` and no `setAllPoints` takes its rect from the **M2 header
-- bounding box** (`MD20+0xB4`, max−min per axis), consumed RAW as layout units — unlike
-- CSimpleTexture's texel fallback, there is no AbsDimension conversion. So the ref's
-- ItemAnim widget is **42.41 x 124.72**, nothing like the 36-square everyone assumes from
-- looking at the XML, and **model-space (0,0,0) lands on that rect's BOTTOM-LEFT corner**.
-- (wow-re `system/ui/scratch/modelframe-implicit-size-law.md`, §5-cross-checked: vtable
-- 0x81c5d8 +0x1c/+0x20 -> 0x76d080/0x76d0d0, bbox source 0x713640 at `713661 add esi,0xb4`.
-- The one worker who read `MD20+0xD0` — the VertexBox — got an all-zero box and concluded
-- the widget was degenerate; that wrong premise is exactly what the pair round exists to
-- catch, and it is why this looked unresolvable in 0887.)
--
-- That rect is the whole reason this animation reads the way it does: it stands 124.72 tall
-- off the button's own bottom edge, so the card's travel band reaches **54 px above the
-- button's top**. Decision 0887 could not know that and placed the card against the
-- button's CENTER instead, which is what put it in the wrong place in x and gave it no
-- horizontal drift at all.
BENILLA_ITEM_PUSH_FRAME_W = 42.41
BENILLA_ITEM_PUSH_FRAME_H = 124.72
-- Where that rect hangs off the button: the ref anchors the widget's BOTTOMRIGHT to the
-- button's, 10 in from the right for the five bag buttons and flush (0) for the keyring —
-- MainMenuBarBagButtons.xml l.19-27 (BagSlotButtonTemplate) and l.152-160 (KeyRingButton).
-- Per-button, so it is an OnLoad argument, not a constant.
BENILLA_ITEM_PUSH_ANCHOR_X = -10
-- ── The model's own numbers, unrounded ────────────────────────────────────────────────
-- Every one of these was rounded in 0887 and every rounding moved the card. The quad span
-- read as 0.03 (it is 0.0288 — a 4% size error), and the translation as (0.010, -0.040)
-- (which alone accounts for half a pixel of the landing point).
--
-- The bone transform is `v' = pivot + T(t) + S(t) * (v - pivot)` — three wow-re workers
-- derived that independently in three matrix conventions and agreed. The pivot
-- (M2CompBone+0x60) is NOT the quad's centre: it sits 0.23 px right and 0.07 px above it,
-- so the card shrinks *almost* in place and drifts a fifth of a pixel doing it. Keeping
-- the pivot as its own term rather than folding it into the centre is what makes this
-- reproduce the reference exactly at all three sampled times instead of nearly.
-- Bone 0's ROTATION track is empty (nKeys = 0), so there is no roll to model.
BENILLA_ITEM_PUSH_PIVOT_X = 0.017580900341272354
BENILLA_ITEM_PUSH_PIVOT_Y = 0.053958501666784286
BENILLA_ITEM_PUSH_QUAD_X = 0.017400000942870975
BENILLA_ITEM_PUSH_QUAD_Y = 0.053900001570582390
BENILLA_ITEM_PUSH_QUAD_SPAN = 0.028800001833587885
-- {time, value} keyframes, linear between (M2 track interpolation 1) — the file's own keys,
-- at full precision. The three tracks keep DIFFERENT schedules and that is the whole feel:
-- the card fades up while swelling to 1.2x over 133 ms, settles back to full size at
-- 267 ms, then dwindles and fades *continuously* for the remaining 733 ms — while its
-- position holds until the half second and only then falls. So it is already at ~0.69 size
-- and ~0.68 alpha when it starts to move, and it is invisible by the time it lands.
BENILLA_ITEM_PUSH_ALPHA = { {0.000, 0.0}, {0.133, 1.0}, {0.267, 1.0}, {1.000, 0.0} }
BENILLA_ITEM_PUSH_SCALE = { {0.000, 1.0}, {0.133, 1.2000000476837158}, {0.267, 1.0}, {1.000, 0.01441549975425005} }
-- Translation is per-AXIS: the card falls 51.4 px and drifts 13.3 px RIGHT, so one shared
-- 0..1 "drop" curve (0887's shape) cannot express it — the old code had no horizontal term
-- at all and moved the card the wrong way in x when it inferred one.
BENILLA_ITEM_PUSH_TRANS_X = { {0.000, 0.0}, {0.500, 0.0}, {1.000, 0.010394600220024586} }
BENILLA_ITEM_PUSH_TRANS_Y = { {0.000, 0.0}, {0.500, 0.0}, {1.000, -0.040167398750782013} }
-- Sample a {time, value} keyframe list at `t`, linear between keys, clamped at both ends
-- (the M2 sampler's own shape for a CLAMP sequence).
function BenillaItemPushAnim_Sample(track, t)
local last = table.getn(track)
if t <= track[1][1] then return track[1][2] end
for i = 2, last do
local key = track[i]
if t <= key[1] then
local prev = track[i - 1]
local span = key[1] - prev[1]
if span <= 0 then return key[2] end
return prev[2] + (key[2] - prev[2]) * (t - prev[1]) / span
end
end
return track[last][2]
end
-- Place/size/fade the card for the current clock, from the model's own transform.
--
-- The card's centre in MODEL space is `pivot + T(t) + S(t)*(quadCentre - pivot)`; times the
-- projection constant that is its offset in layout units from the model origin, which is
-- the widget rect's bottom-left corner. The rect's own BOTTOMRIGHT hangs off the button's,
-- so the card is placed against the BUTTON'S BOTTOMRIGHT — never its centre — and the
-- frame width is what converts between the two.
--
-- On screen: a button-sized card appears with its bottom edge ~13.6 px ABOVE the button's
-- top, pops to 1.2x and back while fading in, hangs there through the first half second,
-- then falls down-and-right INTO the button, shrinking to a half-pixel point at its centre
-- as it fades out. Reproduces the reference at t = 0, 0.133 and 1.0 to the hundredth of a
-- pixel (`modelframe-implicit-size-law.md` §3's table).
--
-- NOT modelled, deliberately, and both are named in the record: the reference's widget
-- CLIPS the card to its own rect, which shaves 1.94 px off the right edge at the 1.2x peak
-- **at 4:3 only** (16:10 and 16:9 clear it); and the widget clears the depth buffer inside
-- that 124.72-tall rect every paint. benilla draws this as a Texture, not a Model, so it
-- inherits neither — reproducing an aspect-conditional sliver would be hard-coding a
-- rendering artefact rather than modelling the mechanism that causes it.
function BenillaItemPushAnim_Paint(anim)
local t = anim.elapsed
local k = BENILLA_ITEM_PUSH_PX_PER_UNIT
local scale = BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_SCALE, t)
local size = BENILLA_ITEM_PUSH_QUAD_SPAN * scale * k
local cx = (BENILLA_ITEM_PUSH_PIVOT_X
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_X, t)
+ scale * (BENILLA_ITEM_PUSH_QUAD_X - BENILLA_ITEM_PUSH_PIVOT_X)) * k
local cy = (BENILLA_ITEM_PUSH_PIVOT_Y
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_Y, t)
+ scale * (BENILLA_ITEM_PUSH_QUAD_Y - BENILLA_ITEM_PUSH_PIVOT_Y)) * k
anim:SetWidth(size)
anim:SetHeight(size)
anim:SetPoint("CENTER", anim:GetParent(), "BOTTOMRIGHT",
anim.anchorX - BENILLA_ITEM_PUSH_FRAME_W + cx, cy)
anim:SetAlpha(BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_ALPHA, t))
end
-- ref ItemAnim_OnLoad (l.68-70): the card listens for ITEM_PUSH and nothing else. It is
-- authored hidden, so it costs no OnUpdate until a push shows it (events reach hidden
-- frames; OnUpdate does not — the reference's own split).
function BenillaItemPushAnim_OnLoad(anim, containerId, anchorX)
anim.containerId = containerId
-- The widget's own offset from the button's BOTTOMRIGHT: -10 for the five bag buttons,
-- flush 0 for the keyring (the ref's two different anchors — see the constant block).
anim.anchorX = anchorX or BENILLA_ITEM_PUSH_ANCHOR_X
anim.icon = anim.icon or getglobal(anim:GetName() .. "Icon")
anim.elapsed = 0
anim:RegisterEvent("ITEM_PUSH")
end
-- ref ItemAnim_OnEvent (l.72-80): `if this:GetParent():GetID() == arg1`, then
-- ReplaceIconTexture(arg2) / SetSequence(0) / SetSequenceTime(0,0) / Show. A push into a bag
-- with no button (a bank bag) matches nothing and animates nowhere, there and here.
function BenillaItemPushAnim_OnEvent(anim)
if arg1 ~= anim.containerId then return end
anim.icon:SetTexture(arg2)
anim.elapsed = 0
-- Seat the card above its button rather than at load: the bag bar's own level is set in
-- the BUTTON's OnLoad (BenillaActionBarArt_SeatAbove), which the loader may run after
-- this child exists. Re-deriving it per push is one call and can never read it stale.
anim:SetFrameLevel(anim:GetParent():GetFrameLevel() + 1)
BenillaItemPushAnim_Paint(anim)
anim:Show()
end
-- The CLAMP sequence's end is the ref's OnAnimFinished → this:Hide() (l.92-94).
function BenillaItemPushAnim_OnUpdate(anim, elapsed)
anim.elapsed = anim.elapsed + (elapsed or 0)
if anim.elapsed >= BENILLA_ITEM_PUSH_DURATION then
anim:Hide()
return
end
BenillaItemPushAnim_Paint(anim)
end
-- The six buttons and the offset each one's card hangs at, read off the reference's own
-- anchors: BOTTOMRIGHT -10 for the five bag buttons (`BagSlotButtonTemplate` l.19-27 and
-- `MainMenuBarBackpackButton` l.69-77), flush 0 for the keyring (l.152-160).
BENILLA_ITEM_PUSH_BUTTONS = {
{ "MainMenuBarBackpackButton", -10 },
{ "CharacterBag0Slot", -10 },
{ "CharacterBag1Slot", -10 },
{ "CharacterBag2Slot", -10 },
{ "CharacterBag3Slot", -10 },
{ "KeyRingButton", 0 },
}
-- Build one button's card. Authored hidden, so it costs no OnUpdate until a push shows it
-- — the reference's own split (events reach hidden frames, OnUpdate does not).
--
-- Named `$parentBenillaItemPush` rather than `$parentItemAnim`: the reference's Model
-- already owns that name on every one of these buttons, and an addon that reaches for it
-- must find the reference's frame, not ours.
function BenillaItemPushAnim_Attach(button, anchorX)
local anim = CreateFrame("Frame", button:GetName() .. "BenillaItemPush", button)
anim:Hide()
anim:SetWidth(36)
anim:SetHeight(36)
anim:SetPoint("CENTER", button, "CENTER", 0, 0)
anim.icon = anim:CreateTexture(anim:GetName() .. "Icon", "OVERLAY")
anim.icon:SetAllPoints(anim)
BenillaItemPushAnim_OnLoad(anim, button:GetID(), anchorX)
anim:SetScript("OnEvent", function() BenillaItemPushAnim_OnEvent(this) end)
anim:SetScript("OnUpdate", function() BenillaItemPushAnim_OnUpdate(this, arg1) end)
return anim
end
-- At load, because the bar is entry 48 and this file is entry 50 — every button exists and
-- has run its own OnLoad, so `GetID()` is already the reference's. A button that is absent
-- (a harness that loaded no bar) is skipped rather than raising: the card is a flourish,
-- and a missing bar is not this file's error to report.
for _, entry in ipairs(BENILLA_ITEM_PUSH_BUTTONS) do
local button = getglobal(entry[1])
if button then
BenillaItemPushAnim_Attach(button, entry[2])
end
end
]]></Script>
</Ui>
@@ -67,36 +67,10 @@
</Scripts>
</Button>
<!-- Ref MainMenuBarBagButtons.xml l.17-53. A CheckButton over a Button template — the checked
ring is the "this bag's window is open" light, which is why the type changes at this rung.
`$parentItemAnim` is the ref's item-push card; ours is BenillaItemPushAnim_* (0887), and the
template cannot carry it because the reference's ItemAnimTemplate binds a per-instance
container id our four hand-wired slots pass explicitly. A template-built button therefore
gets no push animation, which is a missing flourish and not a missing behaviour. -->
<CheckButton name="BagSlotButtonTemplate" inherits="PaperDollItemSlotButtonTemplate" virtual="true">
<Scripts>
<OnLoad>
PaperDollItemSlotButton_OnLoad();
this:RegisterEvent("BAG_UPDATE");
this.isBag = 1;
-- OURS, and the one deviation in this file's scripts. The reference's three bag
-- handlers each recompute a `translatedID` from these same two ids at every call
-- site; our bodies read `button.bagId`, the field BenillaBagBarSlot_OnLoad sets for
-- the four hand-wired slots. Resolving it once here is what lets a template-built
-- button and a hand-wired one share one implementation instead of two.
this.bagId = this:GetID() - CharacterBag0Slot:GetID() + 1;
</OnLoad>
<OnClick>
if ( IsShiftKeyDown() ) then
BagSlotButton_OnShiftClick();
else
BagSlotButton_OnClick();
end
</OnClick>
<OnDragStart>BagSlotButton_OnDrag();</OnDragStart>
<OnReceiveDrag>BagSlotButton_OnClick();</OnReceiveDrag>
<OnEnter>BagSlotButton_OnEnter();</OnEnter>
</Scripts>
<CheckedTexture alphaMode="ADD" file="Interface\Buttons\CheckButtonHilight"/>
</CheckButton>
<!-- `BagSlotButtonTemplate` was declared here, transcribed from MainMenuBarBagButtons.xml
because that file could not be sourced. It IS sourced now — benilla.toc entry 48, one line
below this file — so the reference's own declaration is the live one and the copy is gone
(decision 1751, window 3). pfUI and anything else that inherits the template now inherits
the reference's, including the `$parentItemAnim` Model our copy structurally could not
carry. -->
</Ui>
+37 -13
View File
@@ -297,13 +297,43 @@ ErrorsFrame.xml
# (Cooldown.xml), `UIPanelCloseButton` (UIPanelTemplates.xml) — plus `parent="UIParent"`. It
# cannot load "first" the way a Lua-only sourcing had to.
Interface\FrameXML\ContainerFrame.xml
# The bag BAR: MainMenuBarBackpackButton / CharacterBag0..3Slot / KeyRingButton and the item-push
# card — this client's stand-in for the reference's own MainMenuBarBagButtons.xml, which cannot be
# sourced until `PutItemInBag`/`PickupBagFromSlot` are bound (the file says so at the site). After
# ContainerFrame.xml for legibility, not necessity: every verb it calls resolves at click time. It
# also owns `KEYRING_CONTAINER`, which is where the reference puts it and which the file above
# reads — again a global resolved at call time, so the order does not bind.
BagFrame.xml
# The reference's item-SLOT button base, `PaperDollItemSlotButtonTemplate` — published for addons
# (pfUI builds its bag bar from what inherits it) and, since window 3, a LOAD-TIME dependency of the
# entry directly below: stock `MainMenuBarBagButtons.xml` inherits it on all five bag buttons, and
# an `inherits=` is resolved at load. That requirement is what moved this file up from below
# CharacterFrame.xml, where it sat only because the chain crossed the old BagFrame -> CharacterFrame
# the wrong way. It needs ItemButtonTemplate.xml above it for the base and Cooldown.xml for the
# $parentCooldown the doll template carries — both far above.
#
# `BagSlotButtonTemplate` used to be declared here too, transcribed from MainMenuBarBagButtons.xml
# because that file could not be sourced. It is sourced now, one entry down, so the reference's own
# declaration is the live one and the copy is deleted — the whole point of the migration.
ItemSlotButtonTemplates.xml
# ══ THE BAG BAR — the reference's own, off the player's chain (decision 1751, window 3) ══
# `MainMenuBarBackpackButton`, `CharacterBag0Slot`..`CharacterBag3Slot`, `KeyRingButton`,
# `BagSlotButtonTemplate` and the `BagSlotButton_*`/`BackpackButton_*`/`Disable_BagButtons` family
# — the real 1.12 file, with `MainMenuBarBagButtons.lua` arriving through its own `<Script file=>`.
# It also owns `KEYRING_CONTAINER = -2`, which is where the reference puts it (l.1).
#
# Our `BagFrame.xml` stood here and is deleted. Its own header named the condition it was waiting
# on — "replaced by the reference's own file the day `PutItemInBag` / `PickupBagFromSlot` are
# bound" — and both are bindings now (`script/cursor/bag_verbs.rs`), so the ref's own click arms
# work: its `PutItemInBag(id)` is the equip/swap our `BenillaBagBarSlot_Place` open-coded.
#
# THE POSITION IS THE REQUIREMENT: `inherits="PaperDollItemSlotButtonTemplate"` on the four bag
# slots and `inherits="ItemButtonTemplate"` on the backpack are resolved at LOAD, and so is
# `parent="MainMenuBarArtFrame"` — so this sits below ItemSlotButtonTemplates.xml (above) and
# ActionBar.xml (which declares the art frame). Being BELOW the sourced ContainerFrame.xml is
# legibility, not necessity, in either direction: every verb each file calls of the other resolves
# at call time, `KEYRING_CONTAINER` included (no top-level read of it exists in ContainerFrame.lua).
#
# TWO THINGS THE SWAP DID NOT INHERIT, and both live in ContainerFrameAdapters.xml below rather
# than being edited into anything of Blizzard's: the item-push card (0887 — the reference's is a
# `<Model>` playing an .mdx and this engine renders no FrameXML models, so ours samples that
# model's own tracks and draws a quad), and the GlobalStrings fallbacks the install-less harnesses
# need. The keyring's SHOW trigger moved the other way — into ActionBar.xml, our MainMenuBar.xml,
# which is exactly where the reference keeps it (MainMenuBar.xml l.323-341).
Interface\FrameXML\MainMenuBarBagButtons.xml
# The stack-split spinner (decision 0216 §6/slice 2): loaded beside the bag windows, whose slot
# click handler (the reference's `ContainerFrameItemButton_OnClick`) calls `OpenStackSplitFrame`.
# Order between the two doesn't matter for correctness (Lua globals resolve at call time, and
@@ -318,12 +348,6 @@ ContainerFrameAdapters.xml
# windows (Gossip/Merchant/Quest*) — it depends only on Fonts/UiPanels/GameTooltip, all
# already loaded above.
CharacterFrame.xml
# The reference's item-SLOT button templates (PaperDollItemSlotButtonTemplate <-
# BagSlotButtonTemplate), published for addons rather than used by our own windows. Here
# rather than beside their subjects because the chain crosses BagFrame.xml (241) ->
# CharacterFrame.xml (256) the wrong way; needs ItemButtonTemplate.xml (45) above it for the
# base and Cooldown.xml (124) for the $parentCooldown the doll template carries.
ItemSlotButtonTemplates.xml
# The Pet tab (decision 1057): the same separate-top-level-file shape as SkillFrame below,
# and ahead of it because it is the earlier tab. It leans on CharacterFrame.xml for real —
# the `(unit, prefix)` stat setters, the `BenillaStatRow`/`BenillaMagicResistanceFrame`
+22 -5
View File
@@ -294,6 +294,10 @@ pub(super) fn drive_animations(
// ogre at 2.2× cycles its legs 2.2× slower than a same-speed human. Read-only: nothing
// in this system moves a unit.
&Transform,
// The server-granted movement modes (decision 1780) — root, water-walk, feather-fall,
// hover, walk-mode, swim. `unify` folds them into the flags word the selector reads, so
// a creature the server has rooted or put in walk mode is animated as one.
Option<&crate::net::UnitMoveModes>,
),
)>,
// A mount child's movement view is its HOST's (decision 0441): the same
@@ -307,6 +311,8 @@ pub(super) fn drive_animations(
Option<&UnitSpeeds>,
Option<&FacingStep>,
Has<crate::net::CreatureSwimming>,
// …and the host's granted modes, for the same reason `unify` reads them on the rider.
Option<&crate::net::UnitMoveModes>,
// …and the rider's own `OBJECT_FIELD_SCALE_X`: a mount child's transform carries only its
// `CreatureDisplayInfo` column, and the mount renders at the PRODUCT of the two (the
// byte-verified mount composition, wow-re `0x613ef0`). The rate divisor wants that product
@@ -443,7 +449,16 @@ pub(super) fn drive_animations(
wielded,
cast_hold,
facing_step,
(engaged, auto_repeat, is_self, mount_body, creature_swimming, nock_latched, transform),
(
engaged,
auto_repeat,
is_self,
mount_body,
creature_swimming,
nock_latched,
transform,
move_modes,
),
) in &mut units
{
// A mount child drives from its HOST's movement view (decision 0441) — same inputs the
@@ -464,14 +479,15 @@ pub(super) fn drive_animations(
speeds,
facing_step,
creature_swimming,
move_modes,
host_scale,
ridden_by_self,
) = match mount_body {
Some(mb) => match mount_hosts.get(mb.host) {
Ok((s, r, m, sp, f, sw, t, host_self)) => {
(s, r, m, sp, f, sw, t.scale.x, host_self)
Ok((s, r, m, sp, f, sw, md, t, host_self)) => {
(s, r, m, sp, f, sw, md, t.scale.x, host_self)
}
Err(_) => (None, None, None, None, None, false, 1.0, false),
Err(_) => (None, None, None, None, None, false, None, 1.0, false),
},
None => (
spline,
@@ -480,6 +496,7 @@ pub(super) fn drive_animations(
speeds,
facing_step,
creature_swimming,
move_modes,
1.0,
false,
),
@@ -518,7 +535,7 @@ pub(super) fn drive_animations(
let mount_edge = std::mem::replace(&mut drv.mount_display, mount_display) != mount_display;
// First time we drive this unit (nothing chosen yet) — used to settle a corpse to its end pose.
let first = drv.gait.is_none() && drv.mode == Mode::Gait;
let mut mv = unify(movement, remote, spline, creature_swimming);
let mut mv = unify(movement, remote, spline, creature_swimming, move_modes);
// Stand state (decision 0080c): a unit without a controller-fed [`MovementState`] (remote
// players, creatures) poses from its `UNIT_FIELD_BYTES_1` stand-state byte — a seated
// remote player renders for free. Our own avatar's rides its `MovementState` (the
+12 -2
View File
@@ -1150,11 +1150,20 @@ pub(super) fn scaled_rate(clip: &AnimClip, speed: f32, model_scale: f32) -> Opti
/// the flag into the spline/stationary views, so a pathing water creature plays Swim 42 and an idle
/// one treads water (SwimIdle 41) instead of running/standing on the lakebed. Ignored for the
/// self/remote legs — their flag arrives properly.
///
/// `modes` is the unit's **server-granted** movement modes ([`crate::net::UnitMoveModes`], decision
/// 1780) — the `SMSG_SPLINE_MOVE_*` family. They are OR'd into the flags word on the remote and
/// creature legs, because in the reference they are bits of the *same* `CMovement+0x40` word the
/// selector already tests; a creature the server has put in walk mode or rooted has those bits set
/// exactly like a player who arrived carrying them in a pose. The self leg deliberately skips them:
/// our own mover's modes are the ack'd family's, held on [`crate::player::state::MoveModes`] and
/// already folded into the controller's own `MovementState`.
pub(super) fn unify(
movement: Option<&MovementState>,
remote: Option<&RemoteMotion>,
spline: Option<&Spline>,
creature_swimming: bool,
modes: Option<&crate::net::UnitMoveModes>,
) -> MovementState {
// The flying-spline fact is read through to the live `Spline` on EVERY leg — the client's
// selector reads the active CMovement's spline flags at select time (RF-0057 `0x5fd19c`),
@@ -1163,10 +1172,11 @@ pub(super) fn unify(
if let Some(m) = movement {
return MovementState { flying, ..*m };
}
let granted = modes.map_or(0, |m| m.0);
if let Some(r) = remote {
return MovementState {
speed: r.speed,
flags: r.flags,
flags: r.flags | granted,
vertical_speed: r.vertical_velocity,
flying,
..default()
@@ -1180,7 +1190,7 @@ pub(super) fn unify(
let s = spline.map(|s| s.speed()).filter(|s| *s > MOVING_EPSILON);
MovementState {
speed: s.unwrap_or(0.0),
flags: swim | if s.is_some() { move_flags::FORWARD } else { 0 },
flags: granted | swim | if s.is_some() { move_flags::FORWARD } else { 0 },
flying,
..default()
}
@@ -927,14 +927,78 @@ fn unify_stamps_flying_from_the_live_spline_on_every_leg() {
// Self leg: the controller's stored component says nothing about the ride — the live
// Spline does (the client reads the active CMovement's spline flags at select time).
let m = moving_forward(32.0);
assert!(unify(Some(&m), None, Some(&spline(false)), false).flying);
assert!(!unify(Some(&m), None, Some(&spline(true)), false).flying);
assert!(!unify(Some(&m), None, None, false).flying);
assert!(unify(Some(&m), None, Some(&spline(false)), false, None).flying);
assert!(!unify(Some(&m), None, Some(&spline(true)), false, None).flying);
assert!(!unify(Some(&m), None, None, false, None).flying);
// Spline leg (a remote taxi after the stale-RemoteMotion drop): flying + FORWARD + speed.
let v = unify(None, None, Some(&spline(false)), false);
let v = unify(None, None, Some(&spline(false)), false, None);
assert!(v.flying && v.flags & move_flags::FORWARD != 0 && v.speed > 0.0);
}
/// **The server-granted modes reach the selector's flags word** (decision 1780). They are bits of
/// the same `CMovement+0x40` word the selector already tests, so a creature the server has put in
/// walk mode or rooted must read as one — the reference re-runs this very selector on the spot when
/// the grant lands (`0x6014ec push edi; call 0x60e480`, then `push -1; call 0x5fd9e0`), which is
/// what makes this opcode family a *visual* one rather than bookkeeping.
///
/// The self leg is deliberately exempt: our own mover's modes are the ack'd family's, already
/// folded into the `MovementState` the controller writes. Reading them from a second place would be
/// a second source of truth for one bit.
#[test]
fn the_granted_modes_fold_into_the_flags_word_on_every_leg_but_our_own() {
use crate::net::UnitMoveModes;
use std::time::{Duration, Instant};
let rooted = UnitMoveModes(move_flags::ROOT);
let walking = UnitMoveModes(move_flags::WALK_MODE);
// Creature leg — a standing NPC the server has rooted.
let v = unify(None, None, None, false, Some(&rooted));
assert_eq!(v.flags & move_flags::ROOT, move_flags::ROOT);
// Creature leg on a path: the granted bit rides alongside the spline's own FORWARD.
let spline = crate::net::Spline {
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
start: Instant::now(),
duration: Duration::from_secs(10),
id: 1,
grounded: true,
run_mode: true,
};
let v = unify(None, None, Some(&spline), false, Some(&walking));
assert_eq!(
v.flags,
move_flags::WALK_MODE | move_flags::FORWARD,
"the walk grant does not displace the spline's own travel bit"
);
// Remote leg — OR'd onto the pose's flags, never replacing them.
let rm = crate::net::RemoteMotion {
wow_pos: [0.0; 3],
pending: std::collections::VecDeque::new(),
orientation: 0.0,
flags: move_flags::FORWARD,
pitch: 0.0,
speed: 7.0,
vertical_velocity: 0.0,
jump_xy_vel: [0.0; 2],
fall_start_z: None,
relay: Default::default(),
last_apply_ms: 0.0,
last_apply_pos: [0.0; 3],
};
let v = unify(None, Some(&rm), None, false, Some(&walking));
assert_eq!(v.flags, move_flags::FORWARD | move_flags::WALK_MODE);
// Self leg — untouched.
let m = moving_forward(32.0);
assert_eq!(
unify(Some(&m), None, None, false, Some(&rooted)).flags,
m.flags,
"our own mover's modes are the ack'd family's; this word is the controller's"
);
}
/// The prowl creep outranks the WHOLE speed tail (RF-0057 `0x5fd1d3` precedes `0x5fd202`): a
/// stealthed unit plays 119 at a dead crawl and at sprint speed alike — never Walk, Run or Sprint.
#[test]
+46 -11
View File
@@ -39,6 +39,28 @@ fn kind_color(kind: ModelKind) -> egui::Color32 {
}
}
/// The granted movement modes as the card names them (decision 1780) — `None` when the unit has
/// none, which is nearly all of them, so the common line is unchanged. Named rather than derived
/// from the raw word on the card: `0x40001000` says nothing to the eye, and the whole reason this
/// is on the card is that the modes explain a body that looks wrong and is not.
fn granted_modes(modes: Option<&crate::net::UnitMoveModes>) -> Option<String> {
use crate::creature_anim::move_flags as f;
let w = modes?.0;
let named = [
(f::ROOT, "rooted"),
(f::HOVER, "hover"),
(f::WATER_WALKING, "waterwalk"),
(f::SAFE_FALL, "feather"),
(f::WALK_MODE, "walk-mode"),
(f::SWIMMING, "swim"),
]
.into_iter()
.filter(|(bit, _)| w & bit != 0)
.map(|(_, name)| name)
.collect::<Vec<_>>();
(!named.is_empty()).then(|| format!("granted {}", named.join("+")))
}
/// The inspector's GameObject collision readout (decision 0763): does a hull exist, is it disabled
/// right now, and what stored state does the passability gate see. Named so the bundled `stores`
/// param stays readable.
@@ -56,9 +78,16 @@ type GoCollisionReadout = (
/// fast?* The AQ drakes are the case that earned it — a Brood of Nozdormu 190 yd overhead beating
/// its wings at a flat 1× while its path crawls at walk pace reads as frozen, and nothing on the
/// card said the path was the slow half.
///
/// …and, since 1780, the third producer: the modes the server **granted** this unit
/// ([`crate::net::UnitMoveModes`]). They belong on the same line because they answer the same
/// report from the other side — a body a yard off the floor, standing on water, or refusing every
/// path the server sends it looks broken and is not, and the mode word is the only place that
/// difference is written down. A unit with none reads nothing extra, which is nearly all of them.
type MotionReadout = (
Option<&'static crate::net::Spline>,
Option<&'static crate::net::RemoteMotion>,
Option<&'static crate::net::UnitMoveModes>,
);
/// The inspector's entity LIGHT readout (decision 0776): the lane this object's parts render
@@ -444,18 +473,24 @@ pub(super) fn inspect_ui(
let motion_line = net_entity
.filter(|_| is_unit)
.and_then(|p| motion.get(p).ok())
.map(|(spline, remote)| match (spline, remote) {
(Some(sp), _) => format!(
"motion {:.2} yd/s · {} path {:.0}% of {:.0}s",
sp.speed(),
if sp.grounded { "ground" } else { "flying" },
100.0 * sp.elapsed_frac(),
sp.duration.as_secs_f32(),
),
(None, Some(r)) if r.speed > 0.0 => {
format!("motion {:.2} yd/s · dead-reckoned", r.speed)
.map(|(spline, remote, modes)| {
let moving = match (spline, remote) {
(Some(sp), _) => format!(
"motion {:.2} yd/s · {} path {:.0}% of {:.0}s",
sp.speed(),
if sp.grounded { "ground" } else { "flying" },
100.0 * sp.elapsed_frac(),
sp.duration.as_secs_f32(),
),
(None, Some(r)) if r.speed > 0.0 => {
format!("motion {:.2} yd/s · dead-reckoned", r.speed)
}
_ => "motion still".to_string(),
};
match granted_modes(modes) {
Some(m) => format!("{moving} · {m}"),
None => moving,
}
_ => "motion still".to_string(),
});
// An `AnimationData` id as the card names it — shared by the creature and GameObject anim
// lines below, which read the same id space.
+1 -1
View File
@@ -44,7 +44,7 @@ use motion::{
// `creature_anim`, and the cross-seam test that pins the pair runs both systems in one app.
pub(crate) use motion::drive_display_facing;
pub(crate) use motion::{
jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline, SplineStopped,
jump_seed, CreatureSwimming, FacingStep, RemoteMotion, Spline, SplineStopped, UnitMoveModes,
};
// `GroundClamped`'s only consumer outside `net::motion::spline` is the ground-census probe, and a
// probe is an instrument — a build with the instruments compiled out contains nothing that names
+23 -3
View File
@@ -12,7 +12,7 @@ use super::{
AiReactionMessage, CharActionResultMessage, CharListMessage, EmoteMessage, EnteredWorldMessage,
Guid, GuidIndex, LoggedOutMessage, NetCommands, NetEvents, NetStatus, ObjectStore,
PendingTransfer, RemoteMotion, Reputations, SelfGuid, SelfPlayer, ServerSoundMessage,
ServerTime, ServerWallClock, TeleportMessage, WorldportMessage,
ServerTime, ServerWallClock, TeleportMessage, UnitMoveModes, WorldportMessage,
};
use benilla_world::weather::WeatherMessage;
@@ -106,7 +106,11 @@ pub(super) fn apply_net_updates(
mut reputations: ResMut<Reputations>,
mut transforms: Query<&mut Transform>,
mut stores: Query<&mut ObjectStore>,
mut remote_motion: Query<&mut RemoteMotion>,
// One tuple param (the 16-SystemParam ceiling this signature already lives against): the two
// per-unit motion states the drain writes. [`RemoteMotion`] is a relayed player's dead-reckon;
// [`UnitMoveModes`] is any unit's server-granted movement modes (decision 1780). Different
// components, one concern — what the wire says about how a body we don't control is moving.
mut motion: (Query<&mut RemoteMotion>, Query<&mut UnitMoveModes>),
// One tuple param (the 16-SystemParam ceiling): the session-lifecycle one-shot writers — the
// player's teleport/worldport snaps + the glue-screen edges (decision 0193).
session_msgs: (
@@ -487,6 +491,9 @@ pub(super) fn apply_net_updates(
// This also removes a latent clobber: a plain per-delta `insert` on a not-yet-spawned entity would
// overwrite an earlier partial rather than merge it (decision 0061).
let mut pending: HashMap<u64, ObjectFields> = HashMap::new();
// The drain's staged [`UnitMoveModes`] grants — see [`objects::StagedModes`]. A grant and the
// `SMSG_MONSTER_MOVE` it refuses can land in the same drain, and the refusal has to see it.
let mut staged_modes = objects::StagedModes::new();
// The same deferral trap for movers' speeds, and the one B213 fell into: a create's
// `UnitSpeeds` insert is a Command, so a `SMSG_FORCE_*_SPEED_CHANGE` arriving later in this
// same drain could not land on top of it. Both stage here in packet order (decision 1478).
@@ -672,7 +679,7 @@ pub(super) fn apply_net_updates(
&mut commands,
&index,
&self_guid,
&mut remote_motion,
&mut motion.0,
&mut transforms,
&mut audio.13,
&mut self_moves,
@@ -723,6 +730,7 @@ pub(super) fn apply_net_updates(
duration_ms,
flying,
run_mode,
objects::modes_of(guid, &index, &motion.1, &staged_modes).rooted(),
&mut commands,
&index,
&mut transforms,
@@ -1360,6 +1368,18 @@ pub(super) fn apply_net_updates(
&mut death_net,
&mut move_modes,
),
// ── The observer movement-mode family (decision 1780) — the same modes, on a body
// somebody else is driving. No ack, so this arm ends the packet.
SessionEvent::SplineMoveMode { guid, mode, apply } => objects::spline_move_mode(
guid,
mode,
apply,
&mut commands,
&index,
&mut motion.1,
&mut motion.0,
&mut staged_modes,
),
SessionEvent::KnockBack {
guid,
counter,
+355 -10
View File
@@ -7,7 +7,9 @@ use std::collections::hash_map::Entry;
use std::collections::HashMap;
use benilla_assets::coords::bevy_to_wow;
use benilla_protocol::{guid, EntityKind, MonsterMoveFacing, MoveSpeeds, ObjectFields, SpeedKind};
use benilla_protocol::{
guid, EntityKind, MonsterMoveFacing, MoveSpeeds, ObjectFields, SpeedKind, SplineMode,
};
use bevy::prelude::*;
use crate::go_templates::GameObjectTemplates;
@@ -18,11 +20,11 @@ use benilla_world::vis_chain::VisChainOnly;
use super::super::motion::{
create_spline, gameobject_rotation, monster_move_spline, pose_transform, resolve_facing,
trace_create_spline, trace_move_snap, wire_yaw, write_pose, SplineStopped,
trace_create_spline, trace_move_snap, wire_yaw, write_pose, SplineStopped, ROOT_APPLY_WIPE,
};
use super::super::{
Guid, GuidIndex, NetCommands, NetEntity, ObjectStore, RemoteMotion, SelfGuid,
SpeedChangeMessage, Spline, UnitSpeeds,
SpeedChangeMessage, Spline, UnitMoveModes, UnitSpeeds,
};
/// A GameObject plays its one-shot **Custom** animation (`SMSG_GAMEOBJECT_CUSTOM_ANIM`, decision
@@ -482,6 +484,7 @@ pub(super) fn monster_move(
duration_ms: u32,
flying: bool,
run_mode: bool,
rooted: bool,
commands: &mut Commands,
index: &GuidIndex,
transforms: &mut Query<&mut Transform>,
@@ -526,12 +529,7 @@ pub(super) fn monster_move(
}
}
}
// The spline is authoritative now, not the relay stream — the mirror of `unit_move`'s
// "drop a stale spline". A splined PLAYER (charge, taxi) otherwise keeps a stale
// `RemoteMotion` whose old flags outrank the spline in the anim selector's `unify`
// precedence (Stand frozen mid-flight); the relay re-seeds it on its next packet.
//
// A spline move also **un-nocks and drops the weapon-visual hold**, unconditionally:
// A spline move **un-nocks and drops the weapon-visual hold**, unconditionally:
// `0x6018f0` (this packet's handler, via `0x603f00` registered on opcodes 0xDD/0x2AE) has
// exactly one `ret`, and its shared tail runs `0x6020e8 call 0x60d040` (clear `0x400`) →
// `0x6020ef call 0x60f530` (un-nock) → `RecomputeBaseAnim(-1)` on every path through it
@@ -539,10 +537,37 @@ pub(super) fn monster_move(
// archer NPC yanked along a path, or a player charged/knocked back, loses the arrow —
// the ranged sheath is untouched, exactly like the locomotion un-nock.
commands.entity(e).remove::<(
RemoteMotion,
crate::creature_anim::NockLatch,
crate::creature_anim::RangedHold,
)>();
// **A rooted unit cannot be splined** (decision 1780). `0x6187a0` — the *server
// position/spline apply*, and the sole path from this packet's parse chain into
// `CMovement`'s spline installer `0x7c6a50` — opens with `0x6187c2 test ah,0x10` and
// returns on ROOT (wow-re `moveflag-family.md` §5.3, and §5.4's verdict in as many words:
// *"cannot translate, cannot jump, cannot be splined"*).
//
// **Scoped to the path deliberately.** What is byte-verified is that the *position/spline*
// apply is refused; whether the rest of `0x6018f0` still runs for a rooted unit is not
// recorded, and the one part of it we do know — the un-nock tail — is verified to run on
// **every** path through the handler (`shooter-stop-law.md` §J1.2/§J1.3), so it stays above
// this gate. The facing snap is left running for the same reason: refusing more than the
// note establishes would be inventing a behaviour, and refusing the path is what keeps the
// body still.
//
// On vmangos this is a race rather than the common case — `Unit::SetRooted(true)` calls
// `StopMoving()` before the root goes out, so an already-walking creature is normally
// stopped by its own stop-spline. The gate is what keeps a path the server had already
// queued from moving a body it has since pinned.
if rooted {
return;
}
// The spline is authoritative now, not the relay stream — the mirror of `unit_move`'s
// "drop a stale spline". A splined PLAYER (charge, taxi) otherwise keeps a stale
// `RemoteMotion` whose old flags outrank the spline in the anim selector's `unify`
// precedence (Stand frozen mid-flight); the relay re-seeds it on its next packet. Below the
// root gate because it is the *install's* own consequence: with no path taken there is no
// new authority to hand the pose to.
commands.entity(e).remove::<RemoteMotion>();
match monster_move_spline(path, spline_id, stop, duration_ms, flying, run_mode) {
// A moving path: sample_splines drives the transform along every waypoint.
Some(spline) => {
@@ -560,6 +585,86 @@ pub(super) fn monster_move(
}
}
/// **Per-drain staging for [`UnitMoveModes`]** — the same shape (and the same reason) as
/// [`merge_fields`]' `pending`: a component this drain inserted through `Commands` is not queryable
/// until the sync point, so a grant and the packet it changes the meaning of can arrive in one drain
/// and the second must see the first. Seeded lazily from the live component; never read after the
/// drain.
pub(super) type StagedModes = HashMap<u64, UnitMoveModes>;
/// This unit's granted modes as of *now within the drain* — the staged word if it was granted this
/// drain, else the live component, else none. The reference's word lives as long as the `CGUnit`, so
/// "no component" and "all bits clear" are the same answer and both are [`UnitMoveModes::default`].
pub(super) fn modes_of(
guid: u64,
index: &GuidIndex,
modes: &Query<&mut UnitMoveModes>,
staged: &StagedModes,
) -> UnitMoveModes {
staged
.get(&guid)
.copied()
.or_else(|| index.0.get(&guid).and_then(|&e| modes.get(e).ok().copied()))
.unwrap_or_default()
}
/// **A movement mode granted on a unit we do not control** — the `SMSG_SPLINE_MOVE_*` family
/// (decision 1780). No ack, and `guid` is whatever unit the server named: normally a creature, which
/// is exactly the body the ack'd `SMSG_FORCE_*` family structurally cannot address.
///
/// An unresolvable guid is **dropped**, faithfully: the reference's handler `0x603c80` resolves with
/// `ClntObjMgrObjectPtr(TYPEMASK_UNIT)` and returns without applying anything when it misses.
///
/// Our own guid is not special-cased. vmangos cannot send it for our mover (`Unit::SetRooted` and
/// its three siblings take the broadcast leg only when the unit is **not** moved by a player), and
/// the reference applies to whatever it finds — so we do too. It is inert on the avatar either way:
/// the animation selector's `unify` gives the controller's own `MovementState` precedence, and our
/// mover's modes are the handshake family's ([`crate::player::state::MoveModes`]).
#[allow(clippy::too_many_arguments)] // the wire fields + the apply context, one per concern
pub(super) fn spline_move_mode(
guid: u64,
mode: SplineMode,
apply: bool,
commands: &mut Commands,
index: &GuidIndex,
modes: &mut Query<&mut UnitMoveModes>,
remote: &mut Query<&mut RemoteMotion>,
staged: &mut StagedModes,
) {
let Some(&e) = index.0.get(&guid) else {
return; // no such unit — the reference drops it too
};
let mut word = modes_of(guid, index, modes, staged);
word.set(mode, apply);
staged.insert(guid, word);
if let Ok(mut live) = modes.get_mut(e) {
*live = word;
} else {
commands.entity(e).insert(word);
}
// **`SetRoot 0x7c7340`'s one-shot wipe**, on the one benilla body that has direction bits to
// wipe — a relayed player's. The reference clears `0xffe07f00`'s complement from the flags word
// at apply and calls `0x7c6290` StopFalling with it; with the direction bits gone the unit fails
// the integration gate (`move_flags::INTEGRATED`) and is not stepped at all, which is *how* a
// root stops a body rather than leaving it coasting on its last reported heading until the next
// packet. Without this the dead-reckon keeps walking a rooted player forward for the ~500 ms to
// its next heartbeat.
//
// A creature needs none of it: it has no direction bits (its motion is its [`Spline`]), and what
// stops *it* is `monster_move`'s refusal above.
if mode == SplineMode::Root && apply {
if let Ok(mut rm) = remote.get_mut(e) {
use crate::creature_anim::move_flags;
rm.flags &= ROOT_APPLY_WIPE & !(move_flags::FALLING | move_flags::FALLING_FAR);
rm.speed = 0.0;
rm.vertical_velocity = 0.0;
rm.jump_xy_vel = [0.0; 2];
rm.fall_start_z = None;
}
}
}
/// The ask-once GameObject template (`SMSG_GAMEOBJECT_QUERY_RESPONSE`, decision 0239): cache it and
/// resolve the lockId from the type-specific `data[]` slot — the interact routing reads it to choose
/// use-vs-cast; the hover tooltip reads the name (decision 0276's GO law).
@@ -740,6 +845,246 @@ mod tests {
const ME: u64 = 0x0000_0000_0000_002A;
/// The observer movement-mode family's own harness (decision 1780): one indexed unit, and a
/// `World` small enough that the only thing that can move it is the code under test.
mod spline_modes {
use super::*;
use benilla_protocol::SplineMode;
use bevy::ecs::system::RunSystemOnce;
const MOB: u64 = 0x0000_0000_0000_00F0;
/// A world holding one indexed unit, optionally already dead-reckoning as a relayed player.
fn world_with_unit(remote: Option<RemoteMotion>) -> (World, Entity) {
let mut w = World::new();
let mut e = w.spawn_empty();
if let Some(rm) = remote {
e.insert(rm);
}
let entity = e.id();
let mut index = GuidIndex::default();
index.0.insert(MOB, entity);
w.insert_resource(index);
(w, entity)
}
fn remote_walking_forward() -> RemoteMotion {
RemoteMotion {
wow_pos: [0.0; 3],
pending: std::collections::VecDeque::new(),
orientation: 0.0,
flags: crate::creature_anim::move_flags::FORWARD,
pitch: 0.0,
speed: 7.0,
vertical_velocity: 0.0,
jump_xy_vel: [0.0; 2],
fall_start_z: None,
relay: Default::default(),
last_apply_ms: 0.0,
last_apply_pos: [0.0; 3],
}
}
/// Run one grant through the real arm body, then flush the `Commands` it queued.
fn grant(w: &mut World, mode: SplineMode, apply: bool) {
w.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
mut modes: Query<&mut UnitMoveModes>,
mut remote: Query<&mut RemoteMotion>| {
let mut staged = StagedModes::new();
spline_move_mode(
MOB,
mode,
apply,
&mut commands,
&index,
&mut modes,
&mut remote,
&mut staged,
);
},
)
.unwrap();
}
/// **The grant lands, and the revoke takes it back off** — the whole family in one shape,
/// because every one of the twelve opcodes is this same set-or-clear of one bit.
#[test]
fn a_grant_sets_the_bit_and_its_pair_clears_it() {
use crate::creature_anim::move_flags;
let (mut w, e) = world_with_unit(None);
grant(&mut w, SplineMode::Hover, true);
grant(&mut w, SplineMode::WaterWalk, true);
let m = *w
.entity(e)
.get::<UnitMoveModes>()
.expect("first grant inserts");
assert_eq!(
m.0,
move_flags::HOVER | move_flags::WATER_WALKING,
"two grants accumulate in one word — the reference has one CMovement+0x40"
);
grant(&mut w, SplineMode::Hover, false);
let m = *w.entity(e).get::<UnitMoveModes>().unwrap();
assert_eq!(
m.0,
move_flags::WATER_WALKING,
"UNSET_HOVER takes back hover and nothing else"
);
}
/// **`SET_RUN_MODE` clears the walk bit.** The pair is inverted against its opcode names
/// (`0x617e80` → `SetRunMode 0x7c71c0`, whose argument is *run*), folded at the parse — so
/// by the time it reaches here `apply` is the bit's direction and this is a plain round
/// trip. Pinned anyway: the inversion is the family's one trap, and a regression that
/// flipped it would put every running creature into a walk with nothing else visibly wrong.
#[test]
fn the_walk_bit_round_trips_with_the_run_opcodes_sense() {
use crate::creature_anim::move_flags;
let (mut w, e) = world_with_unit(None);
grant(&mut w, SplineMode::WalkMode, true); // SMSG_SPLINE_MOVE_SET_WALK_MODE
assert_eq!(
w.entity(e).get::<UnitMoveModes>().unwrap().0,
move_flags::WALK_MODE
);
grant(&mut w, SplineMode::WalkMode, false); // SMSG_SPLINE_MOVE_SET_RUN_MODE
assert_eq!(w.entity(e).get::<UnitMoveModes>().unwrap().0, 0);
}
/// **A root stops a watched body dead, and that is the direction bits' doing.** `SetRoot
/// 0x7c7340` sets the bit and then wipes `0xffe07f00`'s complement — the four direction
/// bits among it — in one shot; with those gone the unit fails the client's integration
/// gate (`0x616e20 test dword [esi+0x40],0x20ff`) and is not stepped at all. Without the
/// wipe our dead-reckon walks a rooted player forward for the ~500 ms to its next
/// heartbeat, which is the reported "rooted and still sliding".
#[test]
fn a_root_wipes_the_direction_bits_the_dead_reckon_runs_on() {
use crate::creature_anim::move_flags;
let (mut w, e) = world_with_unit(Some(remote_walking_forward()));
grant(&mut w, SplineMode::Root, true);
let rm = w.entity(e).get::<RemoteMotion>().unwrap();
assert_eq!(
rm.flags & move_flags::INTEGRATED,
0,
"nothing the integration gate tests may survive the root — this is what stops it"
);
assert_eq!(rm.speed, 0.0, "and it is not still travelling at run speed");
assert!(w.entity(e).get::<UnitMoveModes>().unwrap().rooted());
}
/// The unroot is **not** the wipe run backwards: `ClearRoot 0x7c7370` only clears the bit.
/// A unit is put back in motion by the server's next pose or path, never by us inventing
/// direction bits for it.
#[test]
fn an_unroot_clears_the_bit_and_invents_no_movement() {
let (mut w, e) = world_with_unit(Some(remote_walking_forward()));
grant(&mut w, SplineMode::Root, true);
grant(&mut w, SplineMode::Root, false);
assert!(!w.entity(e).get::<UnitMoveModes>().unwrap().rooted());
assert_eq!(
w.entity(e).get::<RemoteMotion>().unwrap().flags,
0,
"still no direction bits — the wipe is one-shot and the unroot does not undo it"
);
}
/// **An unknown guid is dropped, not defaulted.** The reference's handler `0x603c80`
/// resolves with `ClntObjMgrObjectPtr(TYPEMASK_UNIT)` and returns having applied nothing
/// when it misses. Ours must not spawn or index anything to hold the grant: a mode
/// broadcast can outrun its unit's create block, and the create carries the modes itself.
#[test]
fn a_grant_for_a_unit_we_do_not_have_is_dropped() {
let (mut w, e) = world_with_unit(None);
w.run_system_once(
|mut commands: Commands,
index: Res<GuidIndex>,
mut modes: Query<&mut UnitMoveModes>,
mut remote: Query<&mut RemoteMotion>| {
let mut staged = StagedModes::new();
spline_move_mode(
0xDEAD_BEEF,
SplineMode::Root,
true,
&mut commands,
&index,
&mut modes,
&mut remote,
&mut staged,
);
assert!(
staged.is_empty(),
"nothing staged for a unit we do not have"
);
},
)
.unwrap();
assert!(w.entity(e).get::<UnitMoveModes>().is_none());
}
/// **A rooted unit cannot be splined** — `0x6187a0`, the server position/spline apply,
/// opens `0x6187c2 test ah,0x10` and returns. So a path the server queued before it pinned
/// the body does not move it.
///
/// The gate is scoped to the *path*, not to the packet: what wow-re establishes is that
/// the position/spline apply is refused, and the one other thing the handler is known to do
/// (the un-nock tail) is verified to run on every path through it. The assertion below is
/// therefore about the `Spline` and nothing else.
#[test]
fn a_rooted_unit_refuses_the_path_the_server_queued() {
let mut w = World::new();
let e = w
.spawn((
Transform::default(),
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
))
.id();
let mut index = GuidIndex::default();
index.0.insert(MOB, e);
w.insert_resource(index);
let path = vec![[0.0, 0.0, 0.0], [30.0, 0.0, 0.0]];
for (rooted, expect_spline) in [(true, false), (false, true)] {
let path = path.clone();
w.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
mut transforms: Query<&mut Transform>| {
monster_move(
MOB,
[0.0, 0.0, 0.0],
7,
path.clone(),
MonsterMoveFacing::None,
false,
3000,
false,
true,
rooted,
&mut commands,
&index,
&mut transforms,
);
},
)
.unwrap();
assert_eq!(
w.entity(e).get::<Spline>().is_some(),
expect_spline,
"rooted={rooted}: a pinned body takes no path, an unpinned one does"
);
}
}
}
/// A dismounted human's set — 1.12.1's base run 7.0 (the `--speed` probe's own golden).
fn on_foot() -> MoveSpeeds {
MoveSpeeds {
+3
View File
@@ -23,6 +23,7 @@ use benilla_assets::coords::{wow_rotation_to_bevy, wow_to_bevy};
use bevy::prelude::*;
mod facing;
mod modes;
mod relay;
mod remote;
mod spline;
@@ -33,6 +34,8 @@ pub(crate) use facing::drive_display_facing;
pub(in crate::net) use facing::resolve_facing;
pub(in crate::net) use facing::DisplayFacing;
pub(crate) use facing::FacingStep;
pub(crate) use modes::UnitMoveModes;
pub(in crate::net) use modes::ROOT_APPLY_WIPE;
pub(crate) use relay::{PendingMove, RelayMove};
pub(crate) use remote::jump_seed;
pub(crate) use remote::RemoteMotion;
@@ -0,0 +1,80 @@
//! The movement modes the server grants a unit we do **not** control — the `SMSG_SPLINE_MOVE_*`
//! family's state (decision 1780).
use benilla_protocol::SplineMode;
use bevy::prelude::*;
use crate::creature_anim::move_flags;
/// **A streamed unit's server-granted movement modes**, as `MOVEMENTFLAGS` bits — root, water-walk,
/// feather-fall, hover, walk-mode and swim.
///
/// This is [`crate::player::state::MoveModes`]' observer twin: the same bits, in the same word, for
/// a body somebody else is driving. It exists because those bits had **nowhere to live** for a unit
/// that is not our mover — a remote player's arrive inside [`super::RemoteMotion::flags`] with each
/// relayed pose, and a creature, which never sends a pose at all, had no flags word whatsoever. So
/// every mode a creature was granted was simply dropped: `crates/benilla-app/src/net/motion/
/// remote.rs` said as much in as many words ("a remote's own granted modes are not modelled yet").
///
/// **One word per unit is the reference's own shape**, not a convenience: `CMovement` is embedded at
/// `CGUnit+0x9a8` and its `+0x40` MOVEMENTFLAGS dword is *the* per-unit movement state, written by
/// the relay merge, the create block, the spline installer and this opcode family alike (wow-re
/// `collision/scratch/moveflag-family.md` §5.2). We keep the granted-mode half separate from
/// [`super::RemoteMotion::flags`] for one reason: the relay merge re-authors that word wholesale
/// from every pose, and a creature has no poses to be re-authored from.
///
/// Absent component ⇒ no modes granted, which is the overwhelming majority of units; it is inserted
/// on the first `SMSG_SPLINE_MOVE_*` that names the unit and lives as long as the entity, exactly as
/// the reference's word lives as long as the `CGUnit` (`walk-mode-law.md` §1: `CMovement` is
/// constructed once per unit and nothing else clears it).
#[derive(Component, Default, Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) struct UnitMoveModes(pub(crate) u32);
impl UnitMoveModes {
/// Grant or revoke one mode. `apply` is always the direction of the bit — the run/walk pair's
/// inversion against its opcode names is folded at the parse ([`SplineMode::WalkMode`]).
pub(crate) fn set(&mut self, mode: SplineMode, apply: bool) {
if apply {
self.0 |= mode.flag();
} else {
self.0 &= !mode.flag();
}
}
/// Rooted (`MOVEFLAG_ROOT`) — this unit **cannot be splined**: the reference's server-position
/// apply `0x6187a0` refuses outright while the bit is set (`0x6187c2 test ah,0x10`; wow-re
/// `moveflag-family.md` §5.3/§5.4, *"cannot translate, cannot jump, cannot be splined"*).
pub(crate) fn rooted(self) -> bool {
self.0 & move_flags::ROOT != 0
}
/// Hovering (`MOVEFLAG_HOVER`) — the body rests [`crate::player::HOVER_HEIGHT`] above the floor.
pub(crate) fn hovering(self) -> bool {
self.0 & move_flags::HOVER != 0
}
/// Water-walking (`MOVEFLAG_WATERWALKING`) — the liquid surface counts as walkable ground.
pub(crate) fn water_walking(self) -> bool {
self.0 & move_flags::WATER_WALKING != 0
}
}
// **`MOVEFLAG_SAFE_FALL` deliberately has no accessor here.** The bit is carried in the word (and
// folded into the animation selector's view like every other), but nothing reads it *from this
// component*, for a structural reason: benilla integrates a fall for exactly two bodies — our own
// mover, whose feather fall is [`crate::player::state::MoveModes::feather_fall`], and a relayed
// player, whose `MOVEFLAG_SAFE_FALL` rides its own poses inside the server-authored merge mask and
// is read straight off [`super::RemoteMotion::flags`] there. A creature, the one body this
// component is really for, never falls: it rides its [`super::Spline`] and its Z is the ground
// clamp's. An accessor with no caller would be a claim that the mode is wired when it is not.
/// **What `SetRoot 0x7c7340` wipes from the flags word at apply**, as a mask to AND with: the four
/// direction bits, the two keyboard turn bits and the two pitch bits (`0xff`), the `0x8000` latch,
/// and the five deferred-input latches (`0x1f0000`) — `and dword ptr [esi+0x40], 0xffe07f00`,
/// read byte-for-byte in wow-re `moveflag-family.md` §1.
///
/// It is a **one-shot wipe at apply**, not a standing gate, and it is the whole reason a rooted
/// mover stops rather than coasting: with the direction bits gone the unit fails the client's
/// integration gate (`move_flags::INTEGRATED`, `0x616e20 test dword [esi+0x40],0x20ff`) and is not
/// stepped at all. `ROOT` itself is preserved by the mask.
pub(crate) const ROOT_APPLY_WIPE: u32 = 0xffe0_7f00;
+88 -17
View File
@@ -345,7 +345,8 @@ pub(in crate::net) fn apply_move(
.remove::<crate::transport::TransportRider>();
}
}
let (vertical_velocity, jump_xy_vel) = jump_seed(ev.jump, ev.fall_time);
let (vertical_velocity, jump_xy_vel) =
jump_seed(ev.jump, ev.fall_time, ev.flags & move_flags::SAFE_FALL != 0);
let now_falling = ev.flags & FALLING != 0;
// The remote landing predictor (decision 0415): on the FALLING → grounded edge the fall
// height gates the grunt + dust puff, exactly as the self controller does for us.
@@ -436,12 +437,17 @@ pub(in crate::net) fn drain_pending_moves(
/// ([`drain_pending_moves`]); a dead-reckon that advanced on a different (virtual, clamped) clock
/// than the fire-times it converges toward would never land on them.
#[allow(clippy::type_complexity)]
#[allow(clippy::too_many_arguments)] // a Bevy system signature: one param per resource/query
pub(in crate::net) fn extrapolate_remote_units(
time: Res<Time<Real>>,
mut commands: Commands,
// Avian's kinematic move-and-slide + the player-body capsule — the *same* pair the local
// controller sweeps (decision 0626): one controller, every mover.
world: benilla_world::collision::WorldCollision,
// The liquid query, for a water-walking mover's surface (decision 1780). Liquid is asked on our
// side rather than swept, so — exactly as the local controller does — the plane has to be handed
// to the step ([`crate::player::mover::Support::water`]).
points: benilla_world::world_point::WorldPoint,
capsule: Res<crate::player::PlayerCapsule>,
// The runaway watch's per-mover throttle: the last whole silent second reported, so a stuck
// mover writes one line a second rather than one a frame. Trace-only, hence a `Local` and not
@@ -458,6 +464,13 @@ pub(in crate::net) fn extrapolate_remote_units(
Option<&mut crate::creature_anim::BodyTwist>,
Has<super::FacingStep>,
Has<crate::transport::TransportRider>,
// The server-granted modes this unit was handed by the `SMSG_SPLINE_MOVE_*` family
// (decision 1780). Normally empty on a relayed **player** — vmangos only broadcasts
// that family for a unit it is driving itself — which is exactly why it is OR'd with
// the pose's own flags below rather than replacing them: for a player the modes ride
// the pose (they are inside the server-authored merge mask), for a creature they ride
// this component, and the union is the reference's one `CMovement+0x40` word.
Option<&crate::net::UnitMoveModes>,
),
(Without<Spline>, Without<ActiveMover>),
>,
@@ -465,7 +478,7 @@ pub(in crate::net) fn extrapolate_remote_units(
use crate::creature_anim::{ease_strafe_yaw, strafe_body_offset, wrap_pi};
let dt = time.delta_secs();
let now_ms = time.elapsed_secs_f64() * 1000.0;
for (e, mut t, mut rm, speeds, twist, latched, riding) in &mut q {
for (e, mut t, mut rm, speeds, twist, latched, riding, granted) in &mut q {
// The runaway watch (trace-only): a mover still carrying a direction flag, with nothing
// queued behind it and nothing applied for seconds, is running on our extrapolation alone.
if benilla_assets::trace::enabled() {
@@ -534,6 +547,12 @@ pub(in crate::net) fn extrapolate_remote_units(
// this resolve for a flag-still mover whose answer was proven identical (1473 §3), and the
// gate skips it strictly earlier and for a stated reason — taking the last un-dated
// collision cache (1384's part 3, never fixed on this lane) with it.
//
// **This mover's whole `MOVEMENTFLAGS` word, as the reference holds it** (decision 1780):
// the pose's own flags plus whatever the `SMSG_SPLINE_MOVE_*` family granted. Read by the
// ground step's `Support` below — not by the integration gate on the next line, which is
// the reference's own `0x20ff` over the *pose's* direction bits and none of the modes.
let modes = rm.flags | granted.map_or(0, |m| m.0);
let integrating = rm.flags & move_flags::INTEGRATED != 0 || idle_gate_disabled();
if integrating && !afloat && !riding && !flat_extrapolation() {
let half_h = Vec3::Y * (crate::player::CAPSULE_HEIGHT * 0.5);
@@ -549,24 +568,53 @@ pub(in crate::net) fn extrapolate_remote_units(
let resolved_center = if airborne {
crate::player::mover::airborne_step(&world, &capsule.0, from, vel, time.delta())
} else {
// **The observed mover's own granted modes, honoured** (decision 1780 — 0866 built
// the family for our own mover and left this site reading `Support::default()`,
// which drew a hovering watched player a yard low and sagged a water-walker
// through the surface between packets). `modes` is the union of the pose's flags
// and the `SMSG_SPLINE_MOVE_*` component, which is the reference's single
// `CMovement+0x40` word; see the query docs for why a union is exact here.
//
// Both halves are the local controller's, unchanged: [`Support::offset`] is
// HOVER's yard (`0x636e81`) and [`Support::water`] is the liquid plane
// `MOVEFLAG_WATERWALKING` ORs into the reference's trace mask (`0x63162e`), which
// ours has to be handed because liquid is queried rather than swept.
//
// `swimming` is read off the same word — the water plane is **not** floor while
// swimming (`moveflag-family.md` §2.2: the arm is taken only when the swim bit is
// clear), which is what keeps a water-walker who is already in deep water from
// being popped onto the surface. The pitch gate the local mover applies is the
// *aim* pitch of a diving player and has no meaning for an observed one, so it is
// deliberately not reproduced: `water_floor`'s three gates are asked here as the
// two that apply.
//
// Still **no carried steep-support bit** (1129): it is per-frame state the
// dead-reckon does not keep between packets, so a watched player descending a
// steep face gets the ordinary cone reach, and the next packet's wire Z corrects
// whatever that misses.
let water = (modes & move_flags::WATER_WALKING != 0
&& modes & move_flags::SWIMMING == 0)
.then(|| {
points
.liquid_at(benilla_world::world_point::Subject::Unit(e), pos)
.map(|hit| wow_to_bevy(pos).y + (hit.surface_z - pos[2]))
})
.flatten();
let g = crate::player::mover::grounded_step(
&world,
&capsule.0,
from,
vel,
time.delta(),
// Default support: **no hover offset and no water plane**, because a remote's
// own granted modes are not modelled yet — decision 0866 builds the family for
// our own mover. A hovering *observed* player draws a yard low until the
// relayed `MSG_MOVE_HOVER` is read into per-unit mode state, and an observed
// water-walker's dead-reckon steps down through the surface between packets
// (decision 1623's sag, unfought here because nothing climbs it back) until the
// next wire Z corrects it; the same shape as every other unmodelled remote
// mode. And **no carried steep-support bit** (1129):
// it is per-frame state the dead-reckon does not keep between packets, so a
// watched player descending a steep face gets the ordinary cone reach, and the
// next packet's wire Z corrects whatever that misses.
crate::player::mover::Support::default(),
crate::player::mover::Support {
offset: if modes & move_flags::HOVER != 0 {
crate::player::HOVER_HEIGHT
} else {
0.0
},
water,
steep: false,
},
);
g.center
};
@@ -688,11 +736,20 @@ pub(in crate::net) fn extrapolate_remote_units(
/// and the current up-speed is `-zspeed - g·t`. Mirrors vmangos `Unit.cpp` `ExtrapolateMovement`
/// (`z = start.z + jumpInitialSpeed·t - ½g·t²`, `jumpInitialSpeed = -zspeed`) under the same `gravity`
/// (decision 0053; sign corrected by the sniff — decision 0054).
pub(crate) fn jump_seed(jump: Option<JumpInfo>, fall_time: u32) -> (f32, [f32; 2]) {
///
/// `feather` picks the clamp the recovered speed lands on — the reference's `0x7c5d20` chooses
/// `[0x87d898]` = 7.0 over `[0x87d894]` = 60.148 on `MOVEFLAG_SAFE_FALL`, and it is the *mover's*
/// bit, so a watched Slow Fall recovers to 7 yd/s exactly as ours does (decisions 0866, 1780).
pub(crate) fn jump_seed(jump: Option<JumpInfo>, fall_time: u32, feather: bool) -> (f32, [f32; 2]) {
let terminal = if feather {
crate::player::FEATHER_TERMINAL_VELOCITY
} else {
TERMINAL_VELOCITY
};
match jump {
Some(j) => {
let t = fall_time as f32 / 1000.0;
let vertical = (-j.zspeed - GRAVITY * t).max(-TERMINAL_VELOCITY);
let vertical = (-j.zspeed - GRAVITY * t).max(-terminal);
(
vertical,
[j.cos_angle * j.xy_speed, j.sin_angle * j.xy_speed],
@@ -772,8 +829,18 @@ impl RemoteMotion {
// watched player's jump overshot by `½·g·dt²` every frame and floated; the local mover
// had the mirror-image bug in the other direction. The mean velocity is the exact
// displacement rate under constant acceleration, so neither drifts now.
//
// **Feather fall is the mover's own bit** (decision 1780): `MOVEFLAG_SAFE_FALL` is
// inside the reference's server-authored merge mask, so a watched player under Slow
// Fall or Levitate arrives carrying it in every relayed pose — this word IS the word
// `0x7c5d23` tests, and picking the clamp off it is the whole of the mechanism.
let terminal = if self.flags & move_flags::SAFE_FALL != 0 {
crate::player::FEATHER_TERMINAL_VELOCITY
} else {
TERMINAL_VELOCITY
};
let (vertical, mean_vy) =
crate::player::mover::fall_step(self.vertical_velocity, dt, TERMINAL_VELOCITY);
crate::player::mover::fall_step(self.vertical_velocity, dt, terminal);
pos[2] += mean_vy * dt;
let speed = self.jump_xy_vel[0].hypot(self.jump_xy_vel[1]);
return (pos, self.orientation, vertical, speed);
@@ -937,6 +1004,10 @@ mod under_floor {
PhysicsPlugins::new(bevy::app::PostUpdate),
));
app.init_asset::<Mesh>().init_resource::<ColliderEpoch>();
// The liquid/room facade the clamp and the dead-reckon both ask for a water-walker's
// surface (decision 1780). Seeded empty: this harness is about geometry, and an empty
// world answers "no liquid here", which is the case every test below is written for.
benilla_world::world_point::init_world_point_resources(app.world_mut());
app.insert_resource(PlayerCapsule(Collider::capsule(
CAPSULE_RADIUS,
CAPSULE_HEIGHT - 2.0 * CAPSULE_RADIUS,
+120 -3
View File
@@ -533,6 +533,9 @@ const GROUND_CLAMP_DOWN: f32 = 4.0;
#[allow(clippy::type_complexity)] // one Bevy system's full input set
pub(in crate::net) fn ground_clamp_creatures(
world: benilla_world::collision::WorldCollision,
// The liquid query — a water-walking creature's floor is the surface, not the lakebed
// (decision 1780).
points: benilla_world::world_point::WorldPoint,
epoch: Res<benilla_world::collision::ColliderEpoch>,
mut commands: Commands,
mut q: Query<(
@@ -542,6 +545,10 @@ pub(in crate::net) fn ground_clamp_creatures(
&mut Transform,
Option<&mut GroundClamped>,
Has<CreatureSwimming>,
// The server-granted movement modes (decision 1780). Two of them move this answer: HOVER
// rests the body a yard clear of the floor it found, and WATERWALKING makes the liquid
// surface one of the floors it can find.
Option<&crate::net::UnitMoveModes>,
)>,
) {
let cost = clamp_cost_enabled();
@@ -554,7 +561,7 @@ pub(in crate::net) fn ground_clamp_creatures(
// `armed` means the collider set is churning and the gate is holding nothing.
let (mut reseat, mut armed) = (0u32, 0u32);
for (entity, net, spline, mut t, mut clamped, swimming) in &mut q {
for (entity, net, spline, mut t, mut clamped, swimming, modes) in &mut q {
visited += 1;
if net.kind != EntityKind::Unit {
skipped += 1;
@@ -569,6 +576,11 @@ pub(in crate::net) fn ground_clamp_creatures(
continue; // in-liquid: the wire Z is the creature's swim depth
}
let xz = [t.translation.x, t.translation.z];
// The granted-mode word is a *fourth input to the ray's answer*, so it joins the cache
// gate below: a creature that is handed HOVER while standing perfectly still has not moved
// and the world has not changed, and without this its cached ground answer would outlive
// the grant (decision 1780).
let granted = modes.copied().unwrap_or_default();
// **Re-seat on any write that wasn't ours.** The Y standing here differs from the one this
// unit's last clamp left behind exactly when somebody who owns this unit's position moved
// it — the wire, its spline, a transport deck — and *that* Y is the authority the probe
@@ -591,7 +603,7 @@ pub(in crate::net) fn ground_clamp_creatures(
let same_question = if legacy {
c.y_written == t.translation.y
} else {
c.seat_y == seat_y && c.epoch == epoch.get()
c.seat_y == seat_y && c.epoch == epoch.get() && c.modes == granted
};
if c.hit && c.xz == xz && same_question {
held += 1;
@@ -614,13 +626,41 @@ pub(in crate::net) fn ground_clamp_creatures(
let hit = world.ray_body(origin, Dir3::NEG_Y, reach);
// The down-ray's hit point Y = feet on the surface; no surface in reach = the seat, i.e.
// where the server put it.
let y = match &hit {
let mut y = match &hit {
Some(h) => {
hit_n += 1;
origin.y - h.distance
}
None => seat_y,
};
// **Water walking** (decision 1780): `MOVEFLAG_WATERWALKING` ORs the two ADT liquid layers
// into the reference's walk trace mask (`0x63162e or edi,0x30000`), so the surface simply
// *is* floor and the one trace elects between it and the terrain. Ours queries liquid
// separately, so the election is written out: the higher of the two wins, which is what a
// single trace against both would have returned. Reached only by a creature the swim mark
// has already let through — the reference takes this arm only when the swim bit is clear
// (wow-re `moveflag-family.md` §2.2), and a swimming creature `continue`d above.
if granted.water_walking() {
let wow = bevy_to_wow(t.translation);
let who = benilla_world::world_point::Subject::Unit(entity);
if let Some(l) = points.liquid_at(who, wow) {
y = y.max(t.translation.y + (l.surface_z - wow[2]));
}
}
// **Hover** (decision 1780): the body rests [`crate::player::HOVER_HEIGHT`] above the floor
// — and, faithfully, **does not descend at all when the floor is under a yard away**. The
// reference's finalize is `z_final = z_before_snap − max(L − 1.0, 0)` (`0x636e81`-`0x636ea9`,
// wow-re `moveflag-family.md` §4.1), with `L` the achieved down-probe distance; here `L` is
// `seat_y − y`, so the same expression is `(y + 1.0).min(seat_y)`.
//
// The reference's second, rate-limited pass (`0x636fa1`, climbing back toward the clearance
// at 7 yd/s) is deliberately NOT reproduced: this clamp is a pure function of (server pose,
// colliders) by construction, which is what makes its cache gate sound (decision 1384), and
// a per-frame ramp is state. A creature's seat is the server's, so the static answer is the
// one it converges to anyway.
if granted.hovering() {
y = (y + crate::player::HOVER_HEIGHT).min(seat_y);
}
// Exact bit equality is deliberate, not a sloppy float compare: the question is "would the
// write change anything" — Bevy's change detection fires on the DerefMut regardless of
// value, so writing an equal Y every frame marked every standing creature's transform
@@ -636,6 +676,7 @@ pub(in crate::net) fn ground_clamp_creatures(
y_written: t.translation.y,
hit: hit.is_some(),
epoch: epoch.get(),
modes: granted,
};
match clamped.as_deref_mut() {
Some(c) => *c = state,
@@ -805,6 +846,10 @@ pub(crate) struct GroundClamped {
/// The collider-set stamp the answer was computed against — a cached answer outlives neither
/// the unit's own pose nor the world it described.
epoch: u64,
/// The granted-mode word the answer was computed under (decision 1780). HOVER and WATERWALKING
/// both change what the same ray, from the same seat, in the same world, resolves to — so they
/// are as much an input to the cached answer as the other three.
modes: crate::net::UnitMoveModes,
}
/// The reference's `0x6030c0` decision for one creature, as a pure function of everything it
@@ -951,6 +996,10 @@ mod under_floor {
PhysicsPlugins::new(bevy::app::PostUpdate),
));
app.init_asset::<Mesh>().init_resource::<ColliderEpoch>();
// The liquid/room facade the clamp and the dead-reckon both ask for a water-walker's
// surface (decision 1780). Seeded empty: this harness is about geometry, and an empty
// world answers "no liquid here", which is the case every test below is written for.
benilla_world::world_point::init_world_point_resources(app.world_mut());
// `update()` never runs plugin `finish()`, where avian seats its diagnostics resources —
// and the second `update()` below (the one that lands the late floor) does step physics.
app.finish();
@@ -1015,6 +1064,74 @@ mod under_floor {
);
}
/// **A hovering creature rests exactly a yard over the floor it found** — and, faithfully,
/// **does not descend at all when that floor is under a yard away** (decision 1780). The
/// reference finalises `z = z_before_snap − max(L − 1.0, 0)` (`0x636e81`-`0x636ea9`), so the
/// grant is not "+1 yd" but "stop a yard short of the snap, and never move *up*".
///
/// The second half is the one worth pinning: written as a plain `floor + 1.0` the same grant
/// would *raise* a creature the server had already placed inside that yard, which is a body
/// popping upward on an aura that is supposed to leave it where it is.
#[test]
fn a_hovering_creature_floats_a_yard_and_never_climbs() {
let (mut app, npc) = half_arrived_world();
clamp(&mut app);
assert_eq!(y_of(&app, npc), TERRAIN_Y, "the plain snap, for reference");
// The grant arrives on a body that has not moved: the cached answer must not outlive it.
app.world_mut()
.entity_mut(npc)
.insert(crate::net::UnitMoveModes(
crate::creature_anim::move_flags::HOVER,
));
clamp(&mut app);
assert_eq!(
y_of(&app, npc),
TERRAIN_Y + crate::player::HOVER_HEIGHT,
"a yard clear of the floor — and the grant alone re-armed the cast"
);
// Now the near case: seat the body less than a yard over its floor. `min(seat)` holds it
// where the server put it rather than lifting it to the clearance.
let near = TERRAIN_Y + 0.25;
app.world_mut()
.entity_mut(npc)
.get_mut::<Transform>()
.unwrap()
.translation
.y = near;
clamp(&mut app);
assert_eq!(
y_of(&app, npc),
near,
"inside the clearance the reference subtracts nothing — it must not climb"
);
}
/// The water-walker's floor election, and the one that proves it is an *election*: the surface
/// wins over a lakebed below it, and loses to ground above it. The reference gets this for
/// free — `MOVEFLAG_WATERWALKING` ORs the liquid layers into the walk trace's class mask
/// (`0x63162e`), so one trace returns whichever is nearer the feet. Ours queries liquid
/// separately, so the max() is that election written out (decision 1780).
///
/// With no liquid in this harness the mode must be inert — which is the assertion that would
/// catch a `max` against a garbage surface (a `f32::MIN` sentinel, an unwrapped `None`).
#[test]
fn water_walking_with_no_liquid_changes_nothing() {
let (mut app, npc) = half_arrived_world();
app.world_mut()
.entity_mut(npc)
.insert(crate::net::UnitMoveModes(
crate::creature_anim::move_flags::WATER_WALKING,
));
clamp(&mut app);
assert_eq!(
y_of(&app, npc),
TERRAIN_Y,
"no liquid over this NPC: the terrain is still the only floor"
);
}
#[test]
fn a_settled_unit_is_not_re_cast_while_the_world_holds_still() {
// 1357's saving, still intact: the gate holds an answer whose three inputs are unchanged.
+7 -4
View File
@@ -271,7 +271,7 @@ fn jump_seed_derives_velocity_and_clamps() {
sin_angle: 0.0,
xy_speed: 7.0,
};
let (vz, xy) = jump_seed(Some(j), 0);
let (vz, xy) = jump_seed(Some(j), 0, false);
assert!(
(vz - 7.955_547).abs() < 1e-3,
"take-off up-speed = -zspeed (positive, rising): {vz}"
@@ -281,19 +281,19 @@ fn jump_seed_derives_velocity_and_clamps() {
"horizontal +X: {xy:?}"
);
// Mid-fall (1s in): up-speed = -zspeed − g·t (now negative, descending).
let (vz1, _) = jump_seed(Some(j), 1000);
let (vz1, _) = jump_seed(Some(j), 1000, false);
assert!(
(vz1 - (7.955_547 - GRAVITY)).abs() < 1e-3,
"vertical decays by gravity: {vz1}"
);
// A long fall is clamped to terminal velocity.
let (vzt, _) = jump_seed(Some(j), 10_000);
let (vzt, _) = jump_seed(Some(j), 10_000, false);
assert!(
(vzt + TERMINAL_VELOCITY).abs() < 1e-3,
"clamped to −terminal: {vzt}"
);
// A non-jumping packet → grounded: no vertical, no horizontal freeze.
assert_eq!(jump_seed(None, 0), (0.0, [0.0, 0.0]));
assert_eq!(jump_seed(None, 0, false), (0.0, [0.0, 0.0]));
}
#[test]
@@ -971,6 +971,9 @@ fn a_flag_still_remote_is_left_where_the_wire_put_it() {
));
app.init_asset::<Mesh>()
.init_resource::<benilla_world::collision::ColliderEpoch>();
// The liquid/room facade the dead-reckon asks for a water-walker's surface (decision 1780),
// seeded empty — no mover here has the mode, and an empty world answers "no liquid".
benilla_world::world_point::init_world_point_resources(app.world_mut());
app.finish();
app.cleanup();
app.insert_resource(crate::player::PlayerCapsule(Collider::capsule(
+1 -1
View File
@@ -622,7 +622,7 @@ fn apply_self_move(
// running the landing reaction when it just cleared.
match (was_falling, now_falling) {
(_, true) => {
let (vel_y, xy) = crate::net::jump_seed(m.jump, m.fall_time);
let (vel_y, xy) = crate::net::jump_seed(m.jump, m.fall_time, player.modes.feather_fall);
let t = m.fall_time as f32 / 1000.0;
player.airborne_since = Some(time.elapsed_secs() - t);
player.jump_zspeed = m.jump.map_or(0.0, |j| -j.zspeed);
+22 -17
View File
@@ -22,9 +22,7 @@
//! → coin ? [`ClientCommand::LootMoney`] : [`ClientCommand::AutostoreLootItem`] (the clicked 1-based
//! row mapped to the item's **wire** loot slot); `CloseLoot` → [`ClientCommand::LootRelease`].
use benilla_protocol::messages::{
slot_type, ItemPushResult, LootItem, BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST,
};
use benilla_protocol::messages::{slot_type, ItemPushResult, LootItem, BAG_PLAYER_INVENTORY};
use bevy::prelude::*;
use benilla_ui::script::{LootRow, LootState as LootSnapshot, ScriptValue, UiScript};
@@ -107,8 +105,9 @@ struct PendingReceive {
tries: u16,
}
/// The wire push's `(bag, slot)` → the live-API **container id** the bag bar speaks (`0` backpack,
/// `1..=4` an equipped bag, [`KEYRING_CONTAINER`] the keyring) — `ITEM_PUSH`'s `arg1`.
/// The wire push's `(bag, slot)` → `ITEM_PUSH`'s **`arg1`**, in the reference's own vocabulary:
/// `0` the backpack, `20..=23` an equipped bag (the INVENTORY-slot id its bag buttons carry),
/// [`KEYRING_CONTAINER`] the keyring.
///
/// **Byte-VERIFIED** at `CGGameUI::OnItemPush` `0x491bb5`-`0x491bd6`, which is the whole selector:
///
@@ -125,15 +124,21 @@ struct PendingReceive {
/// 491bd4 xor eax,eax ; ⇒ 0 (the backpack)
/// ```
///
/// The one translation: the reference emits `bag + 1`, which is the **inventory-slot** id its bag
/// buttons carry (`CharacterBag0Slot` = 20 … `Bag3Slot` = 23, `GetInventorySlotInfo`); benilla's bag
/// bar is keyed by container id like every other bag surface here (`BAG_UPDATE(bagID)`,
/// `BenillaBagBarSlot_InvSlot` = `19 + bagId`), so the same wire slots 19..22 become `1..=4`. Both
/// vocabularies agree on `0` and `-2`, and both leave a non-equipped-bag container (a bank bag,
/// wire 63..68) landing on an id no bag-bar button carries — no animation, exactly as there.
/// **No translation any more, and that is the point.** This used to emit benilla's own container
/// vocabulary (`1..=4` for the four bags), because benilla's bag BAR was ours and was keyed that
/// way. 1751 window 3 made the bar the reference's own `MainMenuBarBagButtons.xml`, whose
/// `ItemAnim_OnEvent` reads `this:GetParent():GetID()` — 20..23, from `GetInventorySlotInfo` — and
/// compares it to `arg1`. Against the translated value that comparison is false for every bag, so
/// the four bag cards would simply never have played, silently. The fix is not to adapt the
/// reference's body: it is to stop translating, which is also what every addon reading `ITEM_PUSH`
/// expects, so the function is now `0x491bb5`'s selector verbatim.
///
/// A non-equipped-bag container (a bank bag, wire 63..68) still lands on an id no bag-bar button
/// carries — no animation, exactly as there.
fn push_container(bag: u8, slot: u32) -> i64 {
if bag != BAG_PLAYER_INVENTORY {
return i64::from(bag) - i64::from(SLOT_BAG_FIRST) + 1;
// `491bbb lea eax,[edi+1]` — the wire bag byte plus one, nothing else.
return i64::from(bag) + 1;
}
if PUSH_KEYRING_SLOTS.contains(&slot) {
return KEYRING_CONTAINER;
@@ -2130,10 +2135,10 @@ mod tests {
/// value that decides which bag button the drop animation plays on.
#[test]
fn push_container_maps_the_wire_destination_onto_a_bag_bar_button() {
// `bag != 255` — an equipped bag. Wire 19..22 are the four bag inventory slots; the
// reference emits 20..23 (its buttons' inventory-slot ids), ours the container ids 1..=4.
assert_eq!(push_container(19, 0), 1);
assert_eq!(push_container(22, 5), 4);
// `bag != 255` — an equipped bag. Wire 19..22 are the four bag inventory slots, and the
// emitted value is the reference's own `bag + 1`: 20..23, the ids its buttons carry.
assert_eq!(push_container(19, 0), 20);
assert_eq!(push_container(22, 5), 23);
// `bag == 255` with a keyring slot (the client's own 0x51..=0x70 window) — the keyring.
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 81), KEYRING_CONTAINER);
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 96), KEYRING_CONTAINER);
@@ -2147,7 +2152,7 @@ mod tests {
assert_eq!(push_container(BAG_PLAYER_INVENTORY, u32::MAX), 0);
// A bank bag (wire 63..68) lands on an id no bag-bar button carries: no animation, which is
// exactly what the reference's `bag + 1` does with the same push.
assert!(!(0..=4).contains(&push_container(63, 0)));
assert!(!(20..=23).contains(&push_container(63, 0)));
}
#[test]
@@ -765,7 +765,7 @@ fn shipped_bag_frame_drives_end_to_end() {
// [`BAG_UI`] is `benilla.toc`'s own order for everything a bag window needs. Three files join
// it at the positions the manifest gives them:
// * ActionBar.xml straight after Cooldown.xml — the bag bar is anchored INTO
// MainMenuBarArtFrame, so the bar must exist before BagFrame.xml loads or the toggle's
// MainMenuBarArtFrame, so the bar must exist before the bag bar loads or the toggle's
// cross-file `relativeTo` silently falls back to the screen root (which would land in the
// right place here anyway, by the 1024-wide coincidence: screen BOTTOMRIGHT == the
// full-width bar's art-frame BOTTOMRIGHT — so the failure would be invisible);
@@ -780,7 +780,7 @@ fn shipped_bag_frame_drives_end_to_end() {
if *file == "Cooldown.xml" {
load_ui(&s, "ActionBar.xml");
}
if *file == "BagFrame.xml" {
if *file == "Interface\\FrameXML\\MainMenuBarBagButtons.xml" {
bar_frames = frames;
}
}
@@ -788,12 +788,13 @@ fn shipped_bag_frame_drives_end_to_end() {
load_ui(&s, "MerchantFrame.xml");
assert_eq!(
bar_frames, 12,
bar_frames, 16,
"the bag bar is six CheckButtons — MainMenuBarBackpackButton, CharacterBag0..3Slot, \
KeyRingButton — each carrying one $parentItemAnim push card (decision 0887): 6 + 6. \
Every window this file used to declare (the backpack, four equipped bags, the keyring) \
is the reference's ContainerFrame now, and its slot buttons, close buttons and money \
rows went with it (decision 1751)"
KeyRingButton — each carrying one $parentItemAnim Model (6 + 6), plus a $parentCooldown \
Model on each of the four slots that inherit PaperDollItemSlotButtonTemplate (+4). The \
deleted BagFrame.xml built 12: it mirrored the six buttons and their push cards but had \
no cooldown on a bag-bar slot at all, which is the reference's own and is what the swap \
to Interface\\FrameXML\\MainMenuBarBagButtons.xml brought with it (1751 window 3)"
);
// The app's feed: a backpack with Tough Jerky ×5 in slot 1.
+102 -63
View File
@@ -95,7 +95,7 @@ fn backpack_toggle_plays_open_and_close_kits() {
assert!(!open(&s, 0));
// Toggle open → OnShow → igBackPackOpen.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(open(&s, 0));
assert_eq!(
s.take_sounds(),
@@ -104,7 +104,7 @@ fn backpack_toggle_plays_open_and_close_kits() {
);
// Toggle closed → OnHide → igBackPackClose.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(!open(&s, 0));
assert_eq!(
s.take_sounds(),
@@ -155,7 +155,7 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() {
);
// Toggle open: the BACKPACK, and only the backpack — bag 2 is equipped and stays shut.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(open(&s, 0), "backpack opens");
assert!(
@@ -171,7 +171,7 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() {
// takes the lot down.
s.run("ToggleBag(2)").unwrap();
assert!(open(&s, 2));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(
!open(&s, 0) && !open(&s, 2),
@@ -181,7 +181,7 @@ fn b_opens_the_backpack_alone_and_closes_every_bag() {
// Bag 0 shut, bag 2 open: the ref's condition reads bag 0, so this is the OPEN arm — the
// backpack joins the bag already up instead of closing it.
s.run("ToggleBag(2)").unwrap();
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(
open(&s, 0) && open(&s, 2),
@@ -441,7 +441,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
s.resolve();
@@ -517,7 +517,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.resolve();
let btn = bag_slot_button(&s, 0, 1);
@@ -756,7 +756,7 @@ fn readable_letter_hover_shows_the_inspect_magnifier() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
s.resolve();
let b1 = bag_slot_button(&s, 0, 1);
@@ -824,7 +824,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
s.resolve();
@@ -910,8 +910,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
assert!(!open(&s, 1), "hidden by default");
// CharacterBag0Slot == bag id 1 (BenillaBagBarSlot_OnLoad(self, 1) in BagFrame.xml).
s.run("BenillaBagBarSlot_OnClick(CharacterBag0Slot)")
.unwrap();
s.run("CharacterBag0Slot:Click()").unwrap();
let _ = s.take_sounds();
assert!(open(&s, 1), "the bag-bar click opened bag 1's window");
let w = window(&s, 1);
@@ -1040,7 +1039,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
s.resolve();
@@ -1391,10 +1390,19 @@ fn bag_slot_cooldown_sweeps_through_the_xml() {
}
/// The bar's five bag buttons had NO hover at all — the director's "the bags are missing their
/// simple tooltips". These are the ref's plain `SetText` plates (ref-MainMenuBarBagButtons.xml
/// l.91-99 for the backpack, ref-MainMenuBarBagButtons.lua l.86-96 for the four slots), NOT the
/// two-line newbie kind the micro buttons next to them use — so what's pinned here is the label,
/// the empty-slot fallback, and that they seat BESIDE the button rather than at the screen corner.
/// simple tooltips". These are the ref's plain `SetText` plates (MainMenuBarBagButtons.xml l.91-99
/// for the backpack, MainMenuBarBagButtons.lua l.86-96 for the four slots), NOT the two-line
/// newbie kind the micro buttons next to them use — so what's pinned here is the label, the
/// empty-slot fallback, and that they seat BESIDE the button rather than at the screen corner.
///
/// Those two bodies are the reference's OWN now (1751 window 3), which changes one thing this test
/// records: **the binding-key suffix appears on the four slots too.** The reference appends
/// `GetBindingKey("TOGGLEBAG"..(4 - (this:GetID() - CharacterBag0Slot:GetID())))` — its numbering
/// runs the bar right-to-left, so the slot next to the backpack is `TOGGLEBAG4`, our F11
/// (`bindings/commands.rs`, quoting ref Bindings.xml l.564-575). Our own handler never did this,
/// so the suffix is new here and is the reference's behaviour, not a regression. The backpack's
/// key is the reference's `TOGGLEBACKPACK` rather than the `OPENALLBAGS` ours read; both default
/// to B, so the string is unchanged and only the reason is.
#[test]
fn the_bar_bag_buttons_name_themselves_on_hover() {
let _data = benilla_formats::wow_data_or_skip!();
@@ -1410,14 +1418,13 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
s.resolve();
// The backpack: its label plus OPENALLBAGS's live key (default B; 0997's divergence note).
s.run("BenillaBagToggle_OnEnter(MainMenuBarBackpackButton)")
.unwrap();
hover(&mut s, "MainMenuBarBackpackButton");
let line = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
assert!(
line.starts_with("Backpack") && line.contains("(B)"),
"the backpack names itself and its key: {line:?}"
"the backpack names itself and its TOGGLEBACKPACK key: {line:?}"
);
// Beside the button, not the default corner — the ref's ANCHOR_LEFT.
assert!(
@@ -1431,8 +1438,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
);
// An empty bag slot falls back to the ref's EQUIP_CONTAINER rather than showing nothing.
s.run("BenillaBagBarSlot_OnEnter(CharacterBag0Slot)")
.unwrap();
hover(&mut s, "CharacterBag0Slot");
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -1461,16 +1467,20 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
enchants: Vec::new(),
});
s.set_inventory_slots(inv);
s.run("BenillaBagBarSlot_OnEnter(CharacterBag0Slot)")
hover(&mut s, "CharacterBag0Slot");
let line = s
.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
"Small Brown Pouch",
"an equipped slot shows that bag, not the empty-slot fallback"
assert!(
line.starts_with("Small Brown Pouch"),
"an equipped slot shows that bag, not the empty-slot fallback: {line:?}"
);
assert!(
line.contains("(F11)"),
"…and the reference appends this slot's own TOGGLEBAG4 key: {line:?}"
);
s.run("BenillaBagBarButton_OnLeave()").unwrap();
unhover(&mut s);
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
@@ -1651,8 +1661,7 @@ fn the_keyring_button_opens_a_keyring_window() {
let _ = s.take_sounds();
assert!(!open(&s, -2));
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
s.run("KeyRingButton:Click()").unwrap();
assert!(open(&s, -2), "the button opens the keyring");
assert_eq!(
s.take_sounds(),
@@ -1687,8 +1696,7 @@ fn the_keyring_button_opens_a_keyring_window() {
7146
);
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
s.run("KeyRingButton:Click()").unwrap();
assert!(!open(&s, -2));
assert_eq!(
s.take_sounds(),
@@ -1730,8 +1738,7 @@ fn a_key_dropped_on_the_button_files_itself() {
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
s.run("KeyRingButton:Click()").unwrap();
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
@@ -1762,28 +1769,42 @@ fn a_key_dropped_on_the_button_files_itself() {
}
s.set_container(-2, Some(full));
s.run("PickupContainerItem(0, 3)").unwrap();
// The refusal is now the REFERENCE's own `PutKeyInKeyRing`, reporting through the name its
// own line asks for — which 1.12 never defines. `BagFrame.xml` supplies it from the string the
// reference meant, and that repair is what this half of the test guards (its comment there
// carries the reasoning).
s.run("BenillaKeyRingButton_OnClick(KeyRingButton)")
.unwrap();
// The refusal is the REFERENCE's own `PutKeyInKeyRing`, reporting through the name its own
// line asks for — `NO_EMPTY_KEYRING_SLOTS`, which 1.12 never defines. So the drop is refused
// and **nothing is printed**: a real 1.12 bug, and faithful here on purpose.
//
// This client un-quirked it once, by assigning the string the reference plainly meant. Window
// 1 of 1751 retired that (ContainerFrameAdapters.xml's header records it), but the assignment
// itself outlived the decision in `BagFrame.xml` for a window and a half. Window 3 deleted that
// file, so the stated posture and the behaviour agree again. Restoring the repair is one line
// in the adapters if it is ever wanted; it is not silent either way.
s.run("KeyRingButton:Click()").unwrap();
assert!(
s.take_container_moves().is_empty(),
"a full keyring queues no move"
);
// ...and says so, on the errors frame — read where a player reads it, off the drawn quads.
// (`UIErrorsFrame` is a real `<MessageFrame>`; its lines are the widget's, not FontStrings.)
s.resolve();
assert!(
s.extract().iter().any(|q| matches!(
!s.extract().iter().any(|q| matches!(
&q.content,
QuadContent::Text { text: Some(t), .. } if t == "Your keyring is full."
QuadContent::Text { text: Some(t), .. } if t.contains("keyring is full")
)),
"the refusal posts to UIErrorsFrame — the reference's own dangling string name prints \
nothing"
"the reference's own dangling string name prints nothing — un-quirking it is a decision, \
not a default"
);
// The nil ALSO raises, out of `MessageFrame:AddMessage`. That is deliberately not asserted as
// correct: our binding takes its text as an mlua `String`, which raises on nil by default —
// not from a carved gate (`messageframe/plain.rs` reads `0x795590`'s rgb/alpha parse off the
// bytes and says nothing about the text argument). Whether 1.12 raises here, no-ops, or prints
// a blank line is out with wow-re. So this pins the keyring's own behaviour — the refusal, and
// the silence — and lets exactly that one error through by name, which still fails the test on
// any OTHER error the click produces.
let errs = s.errors();
assert!(
errs.iter()
.all(|e| e.contains("AddMessage") && e.contains("PutKeyInKeyRing")),
"the only error may be the reference's own nil message: {errs:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The item-push drop animation (decision 0887): `ITEM_PUSH(container, icon)` runs the pushed item's
@@ -1806,13 +1827,13 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
s.resolve();
let shown = |s: &UiScript, name: &str| {
s.eval::<bool>(&format!("return {name}ItemAnim:IsShown()"))
s.eval::<bool>(&format!("return {name}BenillaItemPush:IsShown()"))
.unwrap()
};
// The card's centre relative to its button's centre, in screen px (y-up): (dx, dy).
let offset = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}ItemAnim, {name} \
"local a, b = {name}BenillaItemPush, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
local bx, by = b:GetLeft() + b:GetWidth() / 2, b:GetBottom() + b:GetHeight() / 2 \
return ax - bx, ay - by"
@@ -1820,11 +1841,11 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
.unwrap()
};
let size = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}ItemAnim:GetWidth()"))
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetWidth()"))
.unwrap()
};
let alpha = |s: &UiScript, name: &str| {
s.eval::<f32>(&format!("return {name}ItemAnim:GetAlpha()"))
s.eval::<f32>(&format!("return {name}BenillaItemPush:GetAlpha()"))
.unwrap()
};
// The card's centre relative to its button's BOTTOMRIGHT corner — the reference's OWN frame of
@@ -1834,7 +1855,7 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
// instead is what let 0887's placement look plausible.
let corner = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}ItemAnim, {name} \
"local a, b = {name}BenillaItemPush, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
return ax - b:GetRight(), ay - b:GetBottom()"
))
@@ -1850,11 +1871,14 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
assert!(!shown(&s, b), "{b}'s card starts hidden");
}
// A push into equipped bag 2.
// A push into equipped bag 2. `arg1` is the reference's own vocabulary since 1751 window 3 —
// the button's INVENTORY-slot id, which is what `ItemAnim_OnEvent` compares against — so bag 2
// is `CharacterBag1Slot`'s 21, not the container id 2 this used to send (`ui_loot.rs`
// `push_container`, the `0x491bb5` selector).
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(2),
benilla_ui::script::ScriptValue::Int(21),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
@@ -1988,11 +2012,16 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
(cx + 16.87).abs() < 0.2 && (cy - 18.68).abs() < 0.2,
"arrives at the ref's landing point: got ({cx}, {cy})"
);
// Which is also, within a pixel, the button's own centre — the reference's card really does
// drop INTO the bag button, and that is the one thing 0887 got right for the wrong reason.
// Which is also, within a couple of pixels, the button's own centre — the reference's card
// really does drop INTO the bag button, and that is the one thing 0887 got right for the wrong
// reason. The corner-relative assertion above is the exact one; this is the sanity check, and
// its tolerance is a button-size term, not slack: the landing point is fixed relative to the
// button's BOTTOMRIGHT, so the offset from its CENTRE moves by half of any size change. The
// bar's buttons are 37x37 now — `ItemButtonTemplate`'s own size, inherited through the
// reference's `PaperDollItemSlotButtonTemplate` — where the deleted BagFrame.xml authored 36.
let (dx, dy) = offset(&s, "CharacterBag1Slot");
assert!(
dx.abs() < 1.5 && dy.abs() < 1.5,
dx.abs() < 2.0 && dy.abs() < 2.0,
"lands on the button it went into: got ({dx}, {dy})"
);
assert!(
@@ -2064,7 +2093,7 @@ fn an_addon_that_hooks_toggle_backpack_receives_the_click() {
.unwrap();
// The button's own OnClick path — what the B binding and a plain backpack-button click run.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return HOOK_RAN").unwrap(),
@@ -2123,12 +2152,22 @@ fn the_bag_slots_carry_the_references_names_and_icon_names() {
);
}
// 0-based: CharacterBag0Slot is bag 1. Pinned because the off-by-one is the whole trap, and
// two name-building call sites had to learn it.
// 0-based: CharacterBag0Slot is bag 1, and the off-by-one is the whole trap. Asked the way the
// reference asks it, which is the only way there is now: the bar is stock
// `MainMenuBarBagButtons.xml` since 1751's third window, its buttons carry no `bagId` field,
// and every one of its handlers recomputes `this:GetID() - CharacterBag0Slot:GetID() + 1` at
// the call site. The ids themselves come from `GetInventorySlotInfo("Bag0Slot")` = 20 through
// the sourced `PaperDollItemSlotButton_OnLoad`, which is what makes the arithmetic work.
assert_eq!(
s.eval::<i64>("return CharacterBag0Slot.bagId").unwrap(),
1,
"CharacterBag0Slot IS bag 1 — the reference names them 0-based"
s.eval::<i64>("return CharacterBag0Slot:GetID()").unwrap(),
20,
"CharacterBag0Slot's id is its INVENTORY slot — 8 corpus addons compute `GetID() - 19`"
);
assert_eq!(
s.eval::<i64>("return CharacterBag3Slot:GetID() - CharacterBag0Slot:GetID() + 1")
.unwrap(),
4,
"the reference's own translation: the fourth button is bag 4"
);
// Bagnon's exact reach.
@@ -455,11 +455,11 @@ fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference
s.tick(0.133); // the opaque peak — the instant the card is most visible
s.resolve();
assert!(
shown(&s, "MainMenuBarBackpackButtonItemAnim"),
shown(&s, "MainMenuBarBackpackButtonBenillaItemPush"),
"the card plays"
);
let card = rect(&s, "MainMenuBarBackpackButtonItemAnim");
let card = rect(&s, "MainMenuBarBackpackButtonBenillaItemPush");
let bar = rect(&s, "MultiBarBottomRight");
assert!(
(card.1 - 48.9).abs() < 0.5 && (card.3 - 93.1).abs() < 0.5,
@@ -49,7 +49,6 @@ fn load_room(s: &UiScript) {
"UIParent.xml",
"GameTooltip.xml",
"Cooldown.xml",
"BagFrame.xml",
] {
load_xml(s, file);
}
@@ -107,7 +106,7 @@ fn backpack_with_jerky(s: &mut UiScript) -> String {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
s.resolve();
bag_slot_button(s, 0, 1)
@@ -75,7 +75,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
s.set_container(0, Some(one_item_backpack()));
// Open the bag and the loot window; drain the open kits (not under test here).
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.set_loot(Some(LootState {
fishing: false,
master_candidates: Vec::new(),
@@ -147,7 +147,7 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(
s.focus_editbox("ChatFrameEditBox"),
"the chat edit box focuses"
@@ -264,7 +264,7 @@ fn escape_closes_an_open_stack_split_frame() {
slots,
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(bag_open(&s, 0), "the backpack window is up");
// Open the spinner with a real SHIFT + left-click on the button holding slot 1 — asked of the
@@ -311,7 +311,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
load_xml(&s, "MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(bag_open(&s, 0));
s.set_unit(
@@ -405,7 +405,7 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
load_xml(&s, "MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
assert!(bag_open(&s, 0));
@@ -347,37 +347,17 @@ const KNOWN: &[Known] = &[
},
// ── id ─────────────────────────────────────────────────────────────────────────────────────
//
// The reference side of this comparison is XML only: the sweep cannot see a `SetID` the
// reference makes from Lua at `OnLoad`, so a frame the reference numbers *there* reads as 0
// here. Every entry below is that case, and each ends at the same runtime number ours declares
// — which is why they are accepted rather than fixed. (The blind spot only ever under-reports:
// it can hide a divergence, never invent one, so nothing it misses is a false failure.)
Known {
frame: "CharacterBag0Slot",
flag: Flag::Id,
why: "the bag slots are numbered from Lua in the reference (ContainerFrame.lua's bag-slot \
OnLoad); ours declares the same inventory slot in XML",
},
Known {
frame: "CharacterBag1Slot",
flag: Flag::Id,
why: "as CharacterBag0Slot",
},
Known {
frame: "CharacterBag2Slot",
flag: Flag::Id,
why: "as CharacterBag0Slot",
},
Known {
frame: "CharacterBag3Slot",
flag: Flag::Id,
why: "as CharacterBag0Slot",
},
Known {
frame: "KeyRingButton",
flag: Flag::Id,
why: "-2 is KEYRING_CONTAINER, which the reference also arrives at — from Lua, not XML",
},
// Empty, and it is worth saying why rather than leaving a bare header. The reference side of
// this comparison is XML only: the sweep cannot see a `SetID` the reference makes from Lua at
// `OnLoad`, so a frame the reference numbers *there* used to read as 0 here while ours
// declared the number in XML. The five entries that lived here were all one case — the bag
// bar's `CharacterBag0..3Slot` and `KeyRingButton`.
//
// 1751's third window deleted them by making the difference not exist: the bar IS
// `Interface\FrameXML\MainMenuBarBagButtons.xml` now, so those ids come from
// `PaperDollItemSlotButton_OnLoad`'s `GetInventorySlotInfo` and from `this:SetID(
// KEYRING_CONTAINER)` — the reference's own Lua, on both sides. This gate reported them the
// frame it was built to report (1751 §5: the drift instruments retire with the copies).
// ── parent ─────────────────────────────────────────────────────────────────────────────────
//
// Three, and each is a seat inside the SAME tree the reference seats it in — which is the
@@ -204,7 +204,7 @@ fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_
s.set_container(0, Some(backpack()));
// A press with a window open is eaten by CloseAllWindows — the menu stays down.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
let _ = s.take_sounds();
s.run("ToggleGameMenu()").unwrap();
assert!(!bag_open(&s, 0), "the press closed the bag");
@@ -239,7 +239,7 @@ fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
let mut s = bag_harness_with(&[], &["MerchantFrame.xml"]);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(bag_open(&s, 0));
s.run("ToggleGameMenu(1)").unwrap();
@@ -272,7 +272,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.set_loot(Some(LootState {
fishing: false,
master_candidates: Vec::new(),
@@ -441,7 +441,7 @@ fn vendor_leaves_an_already_open_backpack_alone() {
equip_bag(&mut s, 0, "Backpack", 16);
// Open the bag first (the 'B' toggle), then the vendor over it.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
assert!(bag_open(&s, 0), "fixture: the player's own bag is up first");
let _ = s.take_sounds();
s.set_merchant(Some(MerchantState::default()));
@@ -5,7 +5,7 @@
//! Since decision 1500 all four bars are player options that ship OFF, so most of what is here
//! raises the bar it is about first — through the same two globals the Options rows write.
use benilla_ui::script::{ActionSlot, QuadContent, SpellTooltipView, UiScript};
use benilla_ui::script::{ActionSlot, QuadContent, ScriptValue, SpellTooltipView, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
@@ -802,6 +802,131 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A held payload GHOSTS the wells it opens — the reference's `SetVertexColor(1, 1, 1, 0.5)` on
/// a shown grid ring (`ActionButton.lua:247`), which ours skipped from 0216 to 1781.**
///
/// `UI-Quickslot` is not a second ring. Measured off the shipped BLPs: `UI-Quickslot2`'s interior
/// is fully transparent (alpha 0) — a hollow border — while `UI-Quickslot`'s is solid black at
/// alpha 0.6 across its whole middle. So the grid swap does not *outline* an empty well, it
/// *fills* it, and at full alpha picking up a spell dropped a 60%-black square into every empty
/// slot on every raised bar at once. That is the director's report. Half alpha takes the well to
/// 30% black, where it reads as a drop target instead of a hole.
///
/// The occupied rings stay opaque, and letting go of the payload restores the wells — both
/// DIVERGENCES, chosen in 1782. In stock 1.12.1 the dim is fanned to every button and then never
/// cleared by anything: `SetNormalTexture(path)` reuses the region and writes only its texture
/// handle, and a 3-argument `SetVertexColor` is `SetVertexColor(r, g, b, currentAlpha)` — so the
/// first spell anyone picks up leaves every border at alpha 0x80 for the session (wow-re
/// `button-state-texture-path-setter.md` §2/§7, byte-verified). That is a stuck state keyed to
/// nothing the player can see. We take the look the dim was written for and let go of it with the
/// payload, which is what the last two assertions here pin.
#[test]
fn a_held_payload_ghosts_the_empty_wells_it_opens() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
kind: 0x00,
action: 100,
count: 0,
consumable: false,
}),
);
// The raise comes AFTER the seed: `UIParent.xml`'s PLAYER_ENTERING_WORLD arm reads the
// server's bar-toggle byte once and would put an early raise straight back down.
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
show_bars(&s, &[1]);
s.resolve();
/// The drawn alpha of every ring quad wearing `path` — `None` colour is untinted white.
fn ring_alphas(s: &UiScript, path: &str) -> Vec<f32> {
s.extract()
.iter()
.filter_map(|q| match &q.content {
QuadContent::Texture {
path: Some(p),
color,
..
} if p == path => Some(color.map_or(1.0, |c| c[3])),
_ => None,
})
.collect()
}
let quickslot = "Interface\\Buttons\\UI-Quickslot";
let quickslot2 = "Interface\\Buttons\\UI-Quickslot2";
// Nothing in hand: the main bar's 12 always-visible wells wear the hollow ring, all opaque.
assert_eq!(ring_alphas(&s, quickslot), Vec::<f32>::new());
let resting = ring_alphas(&s, quickslot2);
assert_eq!(
resting.len(),
12,
"the 12 main wells; the multibar's are hidden"
);
assert!(
resting.iter().all(|a| *a == 1.0),
"a resting ring is opaque: {resting:?}"
);
// Payload up. Every well the grid opened is the FILLED plate at HALF alpha.
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
s.resolve();
let ghosts = ring_alphas(&s, quickslot);
assert_eq!(
ghosts.len(),
23,
"11 empty main wells swap art + MultiBarBottomLeft's 12 appear"
);
assert!(
ghosts.iter().all(|a| *a == 0.5),
"every grid ring is the ref's half-alpha ghost, not an opaque plate: {ghosts:?}"
);
// …and the one OCCUPIED button is untouched by it.
let occupied = ring_alphas(&s, quickslot2);
assert_eq!(occupied, vec![1.0], "the occupied ring does not ghost");
// A well that was ghosted and is then FILLED comes back opaque. This is the assertion that
// outlives the engine's own `SetVertexColor` divergence (1782): the day an absent alpha stops
// meaning 1.0 and starts meaning "whatever is already there", a `SetRing` that let the fourth
// argument default would carry the 0.5 straight into the occupied ring, and only this catches
// it — the HIDEGRID check below would not, because that path re-derives an EMPTY well.
s.set_action(
2,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_Rogue_Ambush".into()),
kind: 0x00,
action: 101,
count: 0,
consumable: false,
}),
);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Number(2.0)]);
s.resolve();
assert_eq!(
ring_alphas(&s, quickslot2),
vec![1.0, 1.0],
"a ghosted well that gets an action is opaque again, payload still held"
);
// Letting go restores both halves — art and tint, which is why they share one setter.
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
s.resolve();
assert_eq!(ring_alphas(&s, quickslot), Vec::<f32>::new());
let after = ring_alphas(&s, quickslot2);
assert_eq!(after.len(), 12);
// (10 empty main wells back to the hollow ring + the 2 occupied ones.)
assert!(
after.iter().all(|a| *a == 1.0),
"the dim does not outlive the payload (the ref's own stuck-dim bug): {after:?}"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The byte the row sends is the four globals, and the seed brings that byte back.**
///
/// The whole round trip, over the REAL bindings (`benilla_ui::script::action_bar_toggles`): the
@@ -733,7 +733,6 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
@@ -200,6 +200,137 @@ mod tests {
assert!(s.errors().is_empty(), "{:#?}", s.errors());
}
/// **The migration readiness probe** — which stock FrameXML file could be swapped in *today*,
/// asked of the running loader rather than guessed from a source scan.
///
/// ```text
/// cargo test -p benilla-app --lib chain_readiness_report -- --ignored --nocapture
/// ```
///
/// 1751 is a long migration — 88 manifest entries, three of them chain entries at the time this
/// was written — and the expensive question at every step is *which window is ready*. Picking by
/// eye means reading a stock file, listing the globals it calls, and grepping each one; that is
/// slow, and it is wrong in both directions. It over-reports (a name that exists in a comment
/// greps as present — `framexml-file-demand.py` states that crudeness about itself) and it
/// under-reports the things a grep cannot see at all: an XML element type the loader does not
/// build, a script handler nothing dispatches, an attribute silently dropped (1739 measured 151
/// of those), a template inherited before its definer.
///
/// So the probe does not analyse. It **loads the file** — the whole shipped manifest first, into
/// a fresh VM, exactly as a real run does, and then the candidate off the chain on top — and
/// reports what the loader and the VM actually said. That is ground truth: the same machinery
/// that would run it for real, answering the same question, with no model of the engine in
/// between that could be out of date.
///
/// **What a clean line does and does not mean.** It means the file *loads* — every element
/// built, every template resolved, every load-time body ran without raising. It does not mean
/// the window *works*: a verb that only a click reaches is not exercised by loading, and neither
/// is anything behind an event. Clean is "start here", not "done"; the window's own test module
/// and the director's eye are what finish it (§7).
///
/// **Loading on top of the manifest, not instead of it**, because that is the position a
/// migrated file occupies — every template it inherits is declared by an earlier entry, and
/// asking whether `ContainerFrame.xml` loads *alone* only measures that it has predecessors.
/// Names it collides with are simply overwritten in the probe VM, which is thrown away; the
/// probe is asking about load failures, not about who wins a collision (the manifest's order is
/// what settles those, and it is settled per-window when the swap is made).
///
/// Ignored because it stands up ~90 fresh VMs and each one loads the entire interface; it is an
/// instrument you run when choosing the next window, not a gate.
#[test]
#[ignore = "instrument: run by hand when choosing the next window to migrate"]
fn chain_readiness_report() {
let _data = benilla_formats::wow_data_or_skip!();
// The reference's OWN order, off the chain — never a hand-kept list here. A file's position
// in it is also the answer to "where does its manifest line go", so the report prints it.
let toc = String::from_utf8_lossy(
&super::read("Interface\\FrameXML\\FrameXML.toc").expect("the reference's own toc"),
)
.into_owned();
let stock: Vec<String> = toc
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty() && !l.starts_with('#') && l.ends_with(".xml"))
.map(str::to_string)
.collect();
let migrated: Vec<String> = super::super::addons::Addon::builtin()
.toc
.files
.iter()
.filter(|f| super::is_chain_entry(f))
.map(|f| {
f.rsplit(['\\', '/'])
.next()
.unwrap_or(f.as_str())
.to_string()
})
.collect();
println!(
"\n=== 1751 migration readiness — {} stock windows ===",
stock.len()
);
println!(
"{:>3} {:<32} what stops it (empty = loads clean)",
"pos", "file"
);
let mut clean = Vec::new();
for (i, name) in stock.iter().enumerate() {
let pos = i + 1;
if migrated.iter().any(|m| m == name) {
println!("{pos:>3} {name:<32} — already migrated");
continue;
}
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
let base = super::super::manifest::load_default_ui(&s);
assert!(base.is_empty(), "the shipped manifest itself: {base:#?}");
s.resolve();
let before = s.errors().len();
let path = format!("Interface\\FrameXML\\{name}");
let addon = super::addon(vec![path.clone()]);
let mut said = addon.load_files(&s, std::slice::from_ref(&path));
s.resolve();
said.extend(s.errors().into_iter().skip(before));
if said.is_empty() {
clean.push((pos, name.clone()));
println!("{pos:>3} {name:<32} CLEAN");
} else {
// One line per distinct complaint, deduped and truncated: the same missing verb
// reported by twelve frames is one fact, and the tail of a Lua traceback is noise.
let mut seen: Vec<String> = Vec::new();
for e in said {
let one = e.lines().next().unwrap_or("").trim().to_string();
let one = if one.len() > 140 {
format!("{}…", &one[..140])
} else {
one
};
if !one.is_empty() && !seen.contains(&one) {
seen.push(one);
}
}
println!("{pos:>3} {name:<32} {} issue(s)", seen.len());
for one in seen.iter().take(6) {
println!(" · {one}");
}
if seen.len() > 6 {
println!(" · … and {} more", seen.len() - 6);
}
}
}
println!("\n=== loads clean today: {} ===", clean.len());
for (pos, name) in &clean {
println!(" {pos:>3} {name}");
}
}
/// A path is a chain entry; a bare name is ours. The one-line rule the manifest rests on.
#[test]
fn a_separator_is_what_makes_an_entry_the_players_own_file() {
+31 -7
View File
@@ -65,7 +65,25 @@ fn read(req: &str) -> Option<Vec<u8>> {
/// own files around it (a merchant, the bank, the action bar art), and the ORDER is the thing
/// being reused — it is `benilla.toc`'s, trimmed to what the bags actually reach for.
pub(super) const BAG_UI: &[&str] = &[
// The reference's own localized strings — `BACKPACK_TOOLTIP`, `EQUIP_CONTAINER`, `KEYRING`,
// the `*_FONT_COLOR_CODE` pair. The app loads this at VM setup, ahead of the manifest
// (`ui_script/mod.rs`, `setup_script`); a test VM has to say so itself. Not optional since
// 1751's third window: stock `MainMenuBarBagButtons.lua`'s hovers pass these straight into
// `GameTooltip:SetText`, and `SetText(nil)` raises rather than showing an empty plate. Our
// deleted `BagFrame.xml` carried `X = X or "…"` fallbacks for exactly this gap; the real file
// is the better answer, and these tests already gate on the install.
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml",
// The reference's own `PaperDollItemSlotButton_*` family, sourced at manifest entry 2. Not
// optional since 1751's third window: stock `BagSlotButtonTemplate`'s OnLoad *is*
// `PaperDollItemSlotButton_OnLoad()`, which is what gives each bag button its inventory-slot
// id (20..23 via `GetInventorySlotInfo`), its six event registrations and its first paint.
"Interface\\FrameXML\\PaperDollFrame.lua",
// `TEXT()` — the reference's own identity-function wrapper, which stock
// `MainMenuBarBackpackButton`'s OnEnter calls (`GameTooltip:SetText(TEXT(BACKPACK_TOOLTIP)…)`)
// and `BagSlotButton_OnEnter` calls for `EQUIP_CONTAINER`. Manifest entry 3, and not optional
// for the bag bar since 1751's third window made that bar the reference's own.
"BasicControls.xml",
// `UIParent` itself: the twelve `ContainerFrame`s declare `parent="UIParent"`, and
// `updateContainerFrameAnchors` anchors each open bag to `frame:GetParent()` while
// `OpenAllBags` opens with `if not UIParent:IsVisible() then return end`. Without it the
@@ -76,16 +94,21 @@ pub(super) const BAG_UI: &[&str] = &[
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
// The bag BAR hangs off `MainMenuBarArtFrame` and seats itself one frame level above the bar
// art (`BenillaActionBarArt_SeatAbove`), both at LOAD — so without this the five buttons fall
// back to UIParent and sit at a level no production run ever puts them at.
// The bag BAR declares `parent="MainMenuBarArtFrame"`, resolved at LOAD — so without this the
// six buttons fall back to UIParent and sit at a level no production run ever puts them at.
// It also carries `MainMenuBar_UpdateKeyRing`, which is what puts the keyring on the bar.
"ActionBar.xml",
// `UpdateMicroButtons` — the KEYRING's own OnShow/OnHide calls it (ContainerFrame.lua l.117,
// l.137), because in the reference the keyring's existence moves the micro-button row.
"MicroMenu.xml",
"UIPanelTemplates.xml",
"Interface\\FrameXML\\ContainerFrame.xml",
"BagFrame.xml",
// `PaperDollItemSlotButtonTemplate`, which every bag button inherits — resolved at load, so it
// has to precede the bar exactly as it does in the manifest.
"ItemSlotButtonTemplates.xml",
// The bag BAR itself, the reference's own since 1751's third window: MainMenuBarBackpackButton,
// CharacterBag0..3Slot, KeyRingButton, `BagSlotButtonTemplate`, and `KEYRING_CONTAINER`.
"Interface\\FrameXML\\MainMenuBarBagButtons.xml",
// `StackSplitFrame` is not optional either: the reference's own
// `ContainerFrameItemButton_OnClick` calls `StackSplitFrame:Hide()` on EVERY plain click
// (ContainerFrame.lua l.581) before the pickup, and opens it on the shift fork.
@@ -94,9 +117,10 @@ pub(super) const BAG_UI: &[&str] = &[
// `if ( ChatFrameEditBox:IsShown() )` (ContainerFrame.lua l.569) to decide between posting the
// item's link and splitting the stack, so a VM without it raises before either.
"ChatFrame.xml",
// Our adapters over the reference's container files — one wrapper for the keyring tooltip, and
// the three bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`). It has to
// be AFTER `ContainerFrame.xml` for either job, which is why it is here and not up with
// Our adapters over the reference's container files — the keyring tooltip wrapper, the three
// bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`), and the item-push
// card the reference draws with a `<Model>` this engine does not render (0887). It has to be
// AFTER `ContainerFrame.xml` and after the bar, which is why it is here and not up with
// UiPanels.xml.
"ContainerFrameAdapters.xml",
// `updateContainerFrameAnchors` measures every open bag against `BankFrame:GetRight()`
@@ -34,7 +34,6 @@ fn harness() -> UiScript {
"ItemRef.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"BagFrame.xml",
"CharacterFrame.xml",
] {
load_xml(&s, f);
@@ -159,7 +158,7 @@ fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
// Open the bag and hover the helm slot. The button is ASKED for by its own `GetID()` — the
// reference numbers a window's buttons backwards and recycles the windows (1751), so neither
// the frame name nor the button index is a property to assume.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
let btn = bag_slot_button(&s, 0, 1);
hover(&mut s, &btn);
@@ -308,7 +307,7 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
// Arm a compare first through a BAG hover (the stale-arm hazard the doll hover must clear) —
// the reference's own `ContainerFrameItemButton_OnEnter` since 1751, reached by moving the
// mouse onto the button rather than by calling the handler.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.run("MainMenuBarBackpackButton:Click()").unwrap();
s.take_sounds();
let btn = bag_slot_button(&s, 0, 1);
hover(&mut s, &btn);
+15
View File
@@ -1119,6 +1119,21 @@ pub enum SessionEvent {
mode: crate::messages::MoveMode,
apply: bool,
},
/// **Some unit's movement mode changed** — the `SMSG_SPLINE_MOVE_*` twelve (decision 1780),
/// the observer half of [`Self::MoveMode`]'s family. Nothing to ack; `guid` is any unit, and on
/// vmangos it is one the server is driving itself (a creature), which is precisely the case the
/// ack'd family structurally cannot reach. `apply` is always the direction of the mode's
/// `MOVEMENTFLAGS` bit ([`crate::messages::SplineMode::flag`]).
///
/// The app folds it into that unit's granted-mode word, which the animation selector, the
/// creature ground clamp and the remote dead-reckon all read — the reference does the same and
/// then re-runs the unit's gait selector on the spot (`0x6014ec`), which is what makes the
/// family *visual* rather than bookkeeping.
SplineMoveMode {
guid: u64,
mode: crate::messages::SplineMode,
apply: bool,
},
/// **A knockback aimed at our own mover** (`SMSG_MOVE_KNOCK_BACK`, decision 1702) — the server
/// hands the controlling client a ballistic launch and lets it fly the arc itself; nothing about
/// it is a spline or a teleport. `launch` is the packet's quad as the jump tail it becomes:
@@ -501,6 +501,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
mode,
apply,
}],
ServerPacket::SplineMoveMode { guid, mode, apply } => {
vec![SessionEvent::SplineMoveMode { guid, mode, apply }]
}
ServerPacket::KnockBack {
guid,
counter,
+1 -1
View File
@@ -19,7 +19,7 @@ pub use events::{
};
pub use messages::{
CharCreateReq, CharEnumItem, Character, CorpseLook, CreateSpline, ItemInfo, JumpInfo,
MonsterMoveFacing, MoveMode, ObjectFields, OwnerFallback, ServerPacket, SpeedKind,
MonsterMoveFacing, MoveMode, ObjectFields, OwnerFallback, ServerPacket, SpeedKind, SplineMode,
TransportPose, CHARACTER_FLAG_GHOST, CHARACTER_FLAG_HIDE_CLOAK, CHARACTER_FLAG_HIDE_HELM,
CHARACTER_FLAG_RENAME,
};
+1 -1
View File
@@ -168,7 +168,7 @@ pub use mirror_timer::{
read_pause_mirror_timer, read_start_mirror_timer, read_stop_mirror_timer, MirrorTimerKind,
MirrorTimerStart,
};
pub use movement::{JumpInfo, MoveMode, MovementInfo, SpeedKind, TransportPose};
pub use movement::{JumpInfo, MoveMode, MovementInfo, SpeedKind, SplineMode, TransportPose};
pub use opcode_names::opcode_name;
pub use packet::{CreatureQueryInfo, MonsterMoveFacing, ServerPacket};
pub use page_text::page_text_query;
@@ -168,6 +168,57 @@ impl MoveMode {
}
}
/// **A movement mode granted on a unit we do not control** — the `SMSG_SPLINE_MOVE_*` twelve
/// (decision 1780), the observer half of [`MoveMode`]'s family.
///
/// Same bits, and for the four they share the same reference setters — but it is a *different
/// message*, and the three differences are what the type exists to keep straight:
///
/// 1. **No counter and no ack.** The controller's family is a handshake (`packed guid + u32
/// counter`, ack or the server never applies it); this one is a bare `packed guid` broadcast.
/// The client's handler `0x603c80` replies to nothing.
/// 2. **Any unit.** `0x603c80` resolves the guid with `TYPEMASK_UNIT` and applies to whatever it
/// finds — normally a creature, since vmangos only takes this leg for a unit *not* being moved
/// by a player (`Unit::SetRooted`/`SetWaterWalking`/`SetFeatherFall`/`SetHover`'s `else` arm,
/// and `SendToggleRunWalkToAll` unconditionally).
/// 3. **Two extra modes.** [`WalkMode`](Self::WalkMode) and [`Swimming`](Self::Swimming) have no
/// ack'd counterpart at all.
///
/// [`Swimming`](Self::Swimming) is modelled but vmangos never sends it — nothing in the tree
/// constructs `SMSG_SPLINE_MOVE_START_SWIM`/`_STOP_SWIM`, and `Opcodes.cpp:888` marks both
/// `SendByServer`-only. The client handles them (`0x61a130`/`0x61a160`), so we decode them; on this
/// server they are dead wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SplineMode {
Root,
WaterWalk,
FeatherFall,
Hover,
/// `MOVEFLAG_WALK_MODE` (`0x100`) — the gait selector. **`apply` here is the flag's direction,
/// not the opcode's noun**: `SMSG_SPLINE_MOVE_SET_WALK_MODE` is `apply: true` and
/// `..._SET_RUN_MODE` is `apply: false`, because the reference's `0x617e80` passes the opcode's
/// bool to `SetRunMode 0x7c71c0`, whose argument is *run*. The inversion is folded at the parse
/// so every variant of this enum means the same thing by "apply".
WalkMode,
/// `MOVEFLAG_SWIMMING` (`0x20_0000`) — `SMSG_SPLINE_MOVE_START_SWIM`/`_STOP_SWIM`. Never sent by
/// vmangos (see the type docs).
Swimming,
}
impl SplineMode {
/// This mode's `MOVEMENTFLAGS` bit — the same word [`MoveMode::flag`] writes into.
pub fn flag(self) -> u32 {
match self {
SplineMode::Root => 0x0000_1000,
SplineMode::WaterWalk => 0x1000_0000,
SplineMode::FeatherFall => 0x2000_0000,
SplineMode::Hover => 0x4000_0000,
SplineMode::WalkMode => 0x0000_0100,
SplineMode::Swimming => 0x0020_0000,
}
}
}
/// One jump's ballistic launch parameters — the conditional `MovementInfo` tail present iff the
/// `MOVEFLAG_JUMPING` (0x2000) flag is set. VERIFIED byte-for-byte against vmangos `MovementInfo::Read`
/// (build 1.12.1): wire order is `zspeed, cosAngle, sinAngle, xyspeed` (note cos *before* sin). The
@@ -616,6 +616,57 @@ pub const SMSG_SPLINE_SET_SWIM_SPEED: u16 = 0x0300; // 768
pub const SMSG_SPLINE_SET_WALK_SPEED: u16 = 0x0301; // 769
pub const SMSG_SPLINE_SET_SWIM_BACK_SPEED: u16 = 0x0302; // 770
pub const SMSG_SPLINE_SET_TURN_RATE: u16 = 0x0303; // 771
// **The OBSERVER movement-mode family** (decision 1780) — the spline-move twelve. Where the
// `SMSG_FORCE_*`/`SMSG_MOVE_*` family above hands the *controlling client* a mode to ack, this one
// tells everyone else that some unit's mode changed, and it is a different wire in three ways:
// the body is a **bare packed guid** (no counter, no `MovementInfo`), there is **no ack**, and the
// target is **any unit**, not our mover.
//
// VERIFIED both ends. Server: vmangos `MovementPacketSender::SendMovementFlagChangeToAll` /
// `SendToggleRunWalkToAll` (`MovementPacketSender.cpp:399-462`) write `data << unit->GetPackGUID()`
// and stop — the `+ 4` / `9` in the `WorldPacket` ctor is a capacity hint, not content. Client:
// all twelve register the single handler `0x603c80`, which reads one packed guid (`0x642ed0`),
// resolves it with `ClntObjMgrObjectPtr(TYPEMASK_UNIT)` and calls the dispatcher `0x601420`
// (`lea eax,[edi-0x304]; cmp eax,0x16; ja` — so `0x304..=0x31A`, and `SMSG_SPLINE_MOVE_ROOT`'s
// out-of-band `0x31A` is deliberately the range's top). An unresolvable guid is dropped silently.
//
// The dispatcher's twelve arms, in registration order (`0x603775`-`0x603830`), are a 1:1 map onto
// the twelve opcodes; six of the arms are independently VERIFIED in wow-re's collision ledger:
//
// | opcode | arm | effect on `CMovement+0x40` |
// |---|---|---|
// | `0x31A` ROOT | `0x619a10` | `SetRoot 0x7c7340` — sets `0x1000`, and **wipes `0xffe07f00`'s complement once at apply** |
// | `0x304` UNROOT | `0x619a40` | `ClearRoot 0x7c7370(1)` — clears `0x1000` |
// | `0x305`/`0x306` | `0x61a4e0` | `SetFeatherFall 0x7c72e0` — `0x20000000` |
// | `0x307`/`0x308` | `0x61a620` | `SetHover 0x7c7310` — `0x40000000` |
// | `0x309`/`0x30A` | `0x61a3d0` | `SetWaterWalk 0x7c7280` — `0x10000000` |
// | `0x30B`/`0x30C` | `0x61a130`/`0x61a160` | `SetSwim 0x7c6e50`/`0x7c6e80` — `0x200000` |
// | `0x30D`/`0x30E` | `0x617e80` | `SetRunMode 0x7c71c0` — `0x100`, **inverted** (see below) |
//
// After any arm the dispatcher re-runs the unit's animation selector (`0x6014ec push edi; call
// 0x60e480` then `push -1; call 0x5fd9e0`), which is why this family is *visual*: the mode change
// re-picks the gait on the spot rather than waiting for the next pose.
//
// (wow-re `collision/scratch/moveflag-family.md` §§2-5, `collision/scratch/walk-mode-law.md` §5,
// `collision/scratch/remote-swim-decision.md` §1.4, `collision/ledger.tsv` rows for each arm.)
pub const SMSG_SPLINE_MOVE_UNROOT: u16 = 0x0304; // 772
pub const SMSG_SPLINE_MOVE_FEATHER_FALL: u16 = 0x0305; // 773
pub const SMSG_SPLINE_MOVE_NORMAL_FALL: u16 = 0x0306; // 774
pub const SMSG_SPLINE_MOVE_SET_HOVER: u16 = 0x0307; // 775
pub const SMSG_SPLINE_MOVE_UNSET_HOVER: u16 = 0x0308; // 776
pub const SMSG_SPLINE_MOVE_WATER_WALK: u16 = 0x0309; // 777
pub const SMSG_SPLINE_MOVE_LAND_WALK: u16 = 0x030A; // 778
pub const SMSG_SPLINE_MOVE_START_SWIM: u16 = 0x030B; // 779
pub const SMSG_SPLINE_MOVE_STOP_SWIM: u16 = 0x030C; // 780
// **The run/walk pair is inverted against the flag bit, and that is the reference's own sign.**
// `0x617e80` passes the opcode's bool straight to `CMovement::SetRunMode 0x7c71c0`, whose argument
// is *run* — so `SET_RUN_MODE` CLEARS `MOVEFLAG_WALK_MODE` (`0x100`) and `SET_WALK_MODE` sets it.
// Modelled as one [`super::movement::SplineMode::WalkMode`] whose `apply` is the flag's direction,
// so every arm of the family means the same thing by "apply": set this bit.
pub const SMSG_SPLINE_MOVE_SET_RUN_MODE: u16 = 0x030D; // 781
pub const SMSG_SPLINE_MOVE_SET_WALK_MODE: u16 = 0x030E; // 782
pub const SMSG_SPLINE_MOVE_ROOT: u16 = 0x031A; // 794
pub const MSG_MOVE_SET_RUN_SPEED: u16 = 0x00CD; // 205
pub const MSG_MOVE_SET_RUN_BACK_SPEED: u16 = 0x00CF; // 207
pub const MSG_MOVE_SET_WALK_SPEED: u16 = 0x00D1; // 209
+28 -3
View File
@@ -18,9 +18,9 @@ use super::{
QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestPushResult,
QuestRequestItems, QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets,
SpellCooldown, SpellDamageLog, SpellDispelLog, SpellEnergizeLog, SpellGo, SpellHealLog,
SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog, SpellStart, StabledPet,
TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem,
WhoResults, XpGain,
SpellInstaKillLog, SpellLogExecute, SpellLogMiss, SpellOutcomeLog, SpellStart, SplineMode,
StabledPet, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose,
VendorItem, WhoResults, XpGain,
};
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
@@ -1018,6 +1018,16 @@ pub enum ServerPacket {
mode: MoveMode,
apply: bool,
},
/// **Some unit's movement mode changed** — the `SMSG_SPLINE_MOVE_*` twelve (decision 1780), the
/// observer half of [`Self::MoveMode`]'s family. A bare packed guid: no counter, **no ack**, and
/// `guid` is any unit — normally a creature, which is exactly the case the ack'd family cannot
/// reach. `apply` is always the direction of [`SplineMode::flag`]'s bit (the run/walk pair's
/// inversion is folded at the parse).
SplineMoveMode {
guid: u64,
mode: SplineMode,
apply: bool,
},
/// **The server aimed a knockback at our mover** (`SMSG_MOVE_KNOCK_BACK`, decision 1702) —
/// a ballistic launch it hands the controlling client to fly. `launch` is the packet's four
/// floats read into the launch quad they *are*: `cos_angle`/`sin_angle` the world-XY direction,
@@ -1639,6 +1649,21 @@ impl ServerPacket {
(MoveMode::Hover, true) => "SMSG_MOVE_SET_HOVER".into(),
(MoveMode::Hover, false) => "SMSG_MOVE_UNSET_HOVER".into(),
},
ServerPacket::SplineMoveMode { mode, apply, .. } => match (mode, apply) {
(SplineMode::Root, true) => "SMSG_SPLINE_MOVE_ROOT".into(),
(SplineMode::Root, false) => "SMSG_SPLINE_MOVE_UNROOT".into(),
(SplineMode::WaterWalk, true) => "SMSG_SPLINE_MOVE_WATER_WALK".into(),
(SplineMode::WaterWalk, false) => "SMSG_SPLINE_MOVE_LAND_WALK".into(),
(SplineMode::FeatherFall, true) => "SMSG_SPLINE_MOVE_FEATHER_FALL".into(),
(SplineMode::FeatherFall, false) => "SMSG_SPLINE_MOVE_NORMAL_FALL".into(),
(SplineMode::Hover, true) => "SMSG_SPLINE_MOVE_SET_HOVER".into(),
(SplineMode::Hover, false) => "SMSG_SPLINE_MOVE_UNSET_HOVER".into(),
(SplineMode::Swimming, true) => "SMSG_SPLINE_MOVE_START_SWIM".into(),
(SplineMode::Swimming, false) => "SMSG_SPLINE_MOVE_STOP_SWIM".into(),
// Inverted on purpose — see [`SplineMode::WalkMode`].
(SplineMode::WalkMode, true) => "SMSG_SPLINE_MOVE_SET_WALK_MODE".into(),
(SplineMode::WalkMode, false) => "SMSG_SPLINE_MOVE_SET_RUN_MODE".into(),
},
ServerPacket::KnockBack { .. } => "SMSG_MOVE_KNOCK_BACK".into(),
ServerPacket::LogoutComplete => "SMSG_LOGOUT_COMPLETE".into(),
ServerPacket::LogoutResponse { .. } => "SMSG_LOGOUT_RESPONSE".into(),
@@ -16,6 +16,7 @@ use super::{
mirror_timer, monster_move, movement, opcode, page_text, pet, petition, progression, pvp,
quest, social, spellbook, spells, stable, summon, taxi, trade, trainer, update_object, vendor,
world_state, Character, CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind,
SplineMode,
};
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
@@ -891,6 +892,45 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
apply,
}
}
// **The observer movement-mode family** (decision 1780) — the spline-move twelve. Body is a
// BARE packed guid: no counter, no `MovementInfo`, and nothing to ack (VERIFIED vmangos
// `MovementPacketSender::SendMovementFlagChangeToAll`/`SendToggleRunWalkToAll`, and the
// reference's single handler `0x603c80`, which reads one packed guid and dispatches).
// `guid` is any unit, not our mover — the constants block in `opcode` carries the arm map.
opcode::SMSG_SPLINE_MOVE_ROOT
| opcode::SMSG_SPLINE_MOVE_UNROOT
| opcode::SMSG_SPLINE_MOVE_WATER_WALK
| opcode::SMSG_SPLINE_MOVE_LAND_WALK
| opcode::SMSG_SPLINE_MOVE_FEATHER_FALL
| opcode::SMSG_SPLINE_MOVE_NORMAL_FALL
| opcode::SMSG_SPLINE_MOVE_SET_HOVER
| opcode::SMSG_SPLINE_MOVE_UNSET_HOVER
| opcode::SMSG_SPLINE_MOVE_START_SWIM
| opcode::SMSG_SPLINE_MOVE_STOP_SWIM
| opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE
| opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE => {
let (mode, apply) = match opcode {
opcode::SMSG_SPLINE_MOVE_ROOT => (SplineMode::Root, true),
opcode::SMSG_SPLINE_MOVE_UNROOT => (SplineMode::Root, false),
opcode::SMSG_SPLINE_MOVE_WATER_WALK => (SplineMode::WaterWalk, true),
opcode::SMSG_SPLINE_MOVE_LAND_WALK => (SplineMode::WaterWalk, false),
opcode::SMSG_SPLINE_MOVE_FEATHER_FALL => (SplineMode::FeatherFall, true),
opcode::SMSG_SPLINE_MOVE_NORMAL_FALL => (SplineMode::FeatherFall, false),
opcode::SMSG_SPLINE_MOVE_SET_HOVER => (SplineMode::Hover, true),
opcode::SMSG_SPLINE_MOVE_UNSET_HOVER => (SplineMode::Hover, false),
opcode::SMSG_SPLINE_MOVE_START_SWIM => (SplineMode::Swimming, true),
opcode::SMSG_SPLINE_MOVE_STOP_SWIM => (SplineMode::Swimming, false),
// The one inverted pair: RUN_MODE *clears* `MOVEFLAG_WALK_MODE`, because the
// reference's `0x617e80` feeds the opcode's bool to `SetRunMode 0x7c71c0`.
opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE => (SplineMode::WalkMode, true),
_ => (SplineMode::WalkMode, false),
};
ServerPacket::SplineMoveMode {
guid: read_packed_guid(&mut r)?,
mode,
apply,
}
}
// **The knockback the server aims at our mover** (decision 1702): `packed guid + u32
// counter`, then the launch quad `vcos, vsin, speedXY, speedZ` (VERIFIED vmangos
// `MovementPacketSender.cpp:261-277`, the `> CLIENT_BUILD_1_9_4` branch). The quad reads
+124
View File
@@ -595,3 +595,127 @@ fn a_misframed_batch_errors_rather_than_dropping_moves() {
let undersized = hx("030000007801637cc7000001e200f0");
assert!(messages::parse_server(messages::opcode::SMSG_COMPRESSED_MOVES, &undersized).is_err());
}
/// **The observer movement-mode family, byte-exact** (decision 1780) — the `SMSG_SPLINE_MOVE_*`
/// twelve. Three things are asserted because all three are how this family differs from the ack'd
/// `SMSG_FORCE_*` one it is otherwise a twin of:
///
/// 1. **The body is a bare packed guid** — parsing a *four-byte-shorter* body than the FORCE family's
/// must succeed, so a trailing counter is not being read. Reading one would desync every
/// subsequent packet in the same TCP read.
/// 2. **All twelve opcodes are handled**, including `SMSG_SPLINE_MOVE_ROOT`'s out-of-band `0x31A`
/// (the reference's dispatcher range is `0x304..=0x31A`, root deliberately at the top).
/// 3. **The run/walk pair is inverted** — `SET_RUN_MODE` *clears* `MOVEFLAG_WALK_MODE`, because the
/// reference's `0x617e80` hands the opcode's bool to `SetRunMode 0x7c71c0`, whose argument is
/// *run*. Getting this backwards would walk every running creature in view.
#[test]
fn spline_move_mode_family_parses_golden() {
use benilla_protocol::messages::SplineMode;
// Packed guid 0xAA (mask 0x01, one byte) and NOTHING else — four bytes shorter than the
// FORCE family's `[packed guid][u32 counter]`.
let body = hx("01aa");
assert_eq!(body.len(), 2, "the whole body is one packed guid");
let expected = [
(
messages::opcode::SMSG_SPLINE_MOVE_ROOT,
SplineMode::Root,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_UNROOT,
SplineMode::Root,
false,
),
(
messages::opcode::SMSG_SPLINE_MOVE_WATER_WALK,
SplineMode::WaterWalk,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_LAND_WALK,
SplineMode::WaterWalk,
false,
),
(
messages::opcode::SMSG_SPLINE_MOVE_FEATHER_FALL,
SplineMode::FeatherFall,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_NORMAL_FALL,
SplineMode::FeatherFall,
false,
),
(
messages::opcode::SMSG_SPLINE_MOVE_SET_HOVER,
SplineMode::Hover,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_UNSET_HOVER,
SplineMode::Hover,
false,
),
(
messages::opcode::SMSG_SPLINE_MOVE_START_SWIM,
SplineMode::Swimming,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_STOP_SWIM,
SplineMode::Swimming,
false,
),
// The inversion: the WALK opcode sets the bit, the RUN opcode clears it.
(
messages::opcode::SMSG_SPLINE_MOVE_SET_WALK_MODE,
SplineMode::WalkMode,
true,
),
(
messages::opcode::SMSG_SPLINE_MOVE_SET_RUN_MODE,
SplineMode::WalkMode,
false,
),
];
for (op, mode, apply) in expected {
let packet = messages::parse_server(op, &body)
.unwrap_or_else(|e| panic!("{op:#06x} must parse from a bare packed guid: {e}"));
match &packet {
ServerPacket::SplineMoveMode {
guid: 0xAA,
mode: m,
apply: a,
} => assert_eq!(
(*m, *a),
(mode, apply),
"{} decoded to the wrong mode/direction",
packet.name()
),
p => panic!("expected SplineMoveMode for {op:#06x}, got {}", p.name()),
}
match decode(packet).as_slice() {
[SessionEvent::SplineMoveMode {
guid: 0xAA,
mode: m,
apply: a,
}] => assert_eq!((*m, *a), (mode, apply)),
other => panic!("expected one SplineMoveMode event for {op:#06x}, got {other:?}"),
}
}
// The flag bits are the same word the ack'd family writes — the two families are one state.
assert_eq!(SplineMode::Root.flag(), messages::MoveMode::Root.flag());
assert_eq!(
SplineMode::WaterWalk.flag(),
messages::MoveMode::WaterWalk.flag()
);
assert_eq!(
SplineMode::FeatherFall.flag(),
messages::MoveMode::FeatherFall.flag()
);
assert_eq!(SplineMode::Hover.flag(), messages::MoveMode::Hover.flag());
}
@@ -38,6 +38,22 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
})?,
)?;
// **KNOWN DIVERGENCE, byte-pinned and deliberately not closed here (decision 1782).** An
// ABSENT alpha argument is 1.0 below; in the reference it is *the alpha already on the region*.
// `0x79abd0` reads the current colour back (`0x79ac81` -> `0x77f8c0`), packs the caller's rgb
// against an `a` default of 1.0 (`0x79ad43`), and then asks a SECOND time whether argument 5
// was present — when it was not, it copies byte 3 of the read-back colour over the packed
// alpha (`0x79adfe`/`0x79ae0a`) before the store. So `SetVertexColor(r, g, b)` on 1.12.1 is
// `SetVertexColor(r, g, b, currentAlpha)`, and a three-argument call CANNOT restore opacity.
// wow-re `system/ui/scratch/button-state-texture-path-setter.md` §7.
//
// Why it still says 1.0: the fix is one line, but its blast radius is every three-argument
// call in FrameXML and in the addon corpus, and it only bites where a region already carries
// alpha < 1 — where it would surface as art going permanently translucent, which is a LOOK and
// therefore the director's call, not a quiet correction to fold into an unrelated slice. The
// known live case is the action bar's grid ring, and `ActionBar.xml`'s
// BenillaActionButton_SetRing is already written to be correct under BOTH readings (it passes
// the fourth argument explicitly), so closing this cannot silently change that file.
m.set(
"SetVertexColor",
lua.create_function(
+26 -9
View File
@@ -79,6 +79,31 @@ pub struct WorldPoint<'w, 's> {
wmo_areas: Option<Res<'w, crate::wmo_portal::WmoAreas>>,
}
/// **Seed a headless `World` with everything [`WorldPoint`] needs to be constructible.**
///
/// [`WorldPoint`] is a facade over ten resources that the world plugins insert during startup, and a
/// `RunSystemOnce` harness runs none of them — so a test that touches it fails not with an assertion
/// but with `SystemParamValidationError { message: "Resource does not exist" }`, naming whichever
/// field happens to be first. That error is the same whatever the test was about, which makes it a
/// tax on every harness that ever reaches the world facade rather than a signal about any of them.
///
/// One call here is the whole of it, and it stays correct by construction: a resource added to
/// [`WorldPoint`] is added here in the same edit, instead of being discovered by three unrelated
/// test modules failing at once. The defaults are the empty world — no rooms claimed, no liquid, no
/// area — which is what a geometry harness wants: it supplies the geometry it is about and nothing
/// else answers.
pub fn init_world_point_resources(world: &mut bevy::prelude::World) {
world.init_resource::<Underwater>();
world.init_resource::<PlayerWmoRoom>();
world.init_resource::<CameraInteriorClaim>();
world.init_resource::<bevy::prelude::Assets<benilla_assets::WmoModel>>();
world.init_resource::<bevy::prelude::Assets<benilla_assets::AdtTile>>();
world.init_resource::<crate::terrain_stream::TerrainStreamer>();
world.init_resource::<CurrentArea>();
world.init_resource::<CurrentWmoInterior>();
world.init_resource::<CurrentAreaInterior>();
}
/// The nearest wet point of one liquid sound class — see [`WorldPoint::nearest_liquid_per_class`].
pub struct NearestLiquid {
/// Squared distance from the query point, in WoW yards.
@@ -291,15 +316,7 @@ mod tests {
#[test]
fn a_unit_subject_resolves_to_that_units_own_room() {
let mut world = World::new();
world.init_resource::<Underwater>();
world.init_resource::<PlayerWmoRoom>();
world.init_resource::<CameraInteriorClaim>();
world.init_resource::<Assets<benilla_assets::WmoModel>>();
world.init_resource::<Assets<benilla_assets::AdtTile>>();
world.init_resource::<crate::terrain_stream::TerrainStreamer>();
world.init_resource::<CurrentArea>();
world.init_resource::<CurrentWmoInterior>();
world.init_resource::<CurrentAreaInterior>();
super::init_world_point_resources(&mut world);
let placement = world.spawn_empty().id();
let indoors = world