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