the action bars are the reference's own, and a gift can be wrapped

- the action-bar cluster is the reference's own: `MainMenuBar.xml`, `ActionBarFrame.xml`, `MultiActionBars.xml` and `BonusActionBarFrame.xml` run off the player's own chain, and our `ActionBar.xml` (2047 lines), `MultiBars.xml` and `StanceBar.xml` are deleted — three, since the stock bonus-bar file declares the shapeshift bar too; one engine verb, `ChangeActionBarPage`, fires `ACTIONBAR_PAGE_CHANGED` and nothing else; one load blocker: stock `MultiActionBars.lua` writes the options window's table from its OnLoad, the reference's own load-order hack, so our options window moves to the reference's toc slot; and ten things the reference does that ours did not: an empty button is HIDDEN while showgrid is 0, the bonus overlay sits one pixel right of the main buttons, every slide takes one frame more than its time, and `ActionButton_ShowGrid` ghosts EVERY ring, occupied included (1938)
- a gift can be wrapped: right-clicking wrapping paper fell through to plain use and sent `CMSG_USE_ITEM`, so nothing happened — you could receive a gift and never give one; the wrap is a displayed-cursor MODE plus a stashed slot, never a payload, so `CursorHasItem()` stays false and every stock gate on it stays shut: right-click arms it, a left-click on a bag slot sends `CMSG_WRAP_ITEM`, and the client applies no eligibility test — the server's `ERR_CANT_WRAP_*` reasons are the error line (1934); a duration-limited item counts down: `SMSG_ITEM_TIME_UPDATE` parks a deadline and the tooltip's `ITEM_DURATION` line runs the enchant countdown's bucket ladder, so "Duration: 1 days" is what the reference renders; an update for an item we do not hold is DROPPED, no queue (1933); and a renamed crowd-control refusal names its mechanic — "Can't do that while stunned" — off `SpellMechanic.dbc` (1943)
- a creature paths on a boat: `SMSG_MONSTER_MOVE_TRANSPORT` carries the transport's guid and deck-local coordinates, and is the attach/detach edge; a deck sample lands on the rider or nowhere, never as a world write a few yards from the map origin; and "there is no terrain under a boat" turned out wrong: the ground probe admits GameObject meshes — a boat's `.wmo` is a GameObject, so the probe hits the deck (1936); `MSG_MOVE_TIME_SKIPPED` is read, not dropped — the reference adds the lag to the observed unit's last-wire stamp, so dropping it scheduled their next packet `lag` ms late, a hitch for every player who stalled zoning in near us; and our own settle hold reports once (1935); and the tradeskill tool order was backwards — focus FIRST, so every two-tool recipe had read "Blacksmith Hammer, Anvil" for the reference's "Anvil, Blacksmith Hammer"; the focus's `hasTool` is 1.0 in the reference too (1942)
- the crowd's second sitting, instruments first: the probe line carries the tail — cpu p95/p99 and the six worst frames — because a mean under budget hides the one frame in twenty over it (raid legs railed at 16.67 with p95 at 18–20 ms) and whether that frame was work or a wait is the first question about a stutter, and the visible meshes are counted by lane; then two cuts — a terrain tile furnishes as 4×4 cells of 4×4 chunks, one mesh, one entity, one draw per 133-yd cell instead of per 33-yd chunk (a city horizon is over a thousand chunks a frame, and a cell changes nothing a pixel can see; Stormwind 10.55 → 10.32 `cpu_ms`), and an authored point light is our own `WorldPointLight`, not Bevy's `PointLight`, which was only a data carrier while Bevy ran its whole light lane over every lamp, ~2 % of the alone frame; the sitting's record is 1945, and the code says so (1945)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-04 20:54:27 +07:00
co-authored by Claude Fable 5.1
parent ad7edb3198
commit 4c61e6b7c6
107 changed files with 3407 additions and 3490 deletions
File diff suppressed because it is too large Load Diff
@@ -85,6 +85,13 @@
</Button>
<Script><![CDATA[
-- Seat a micro button above the bar's art plate (was ActionBar.xml's; moved here with 1938 —
-- this file is its one caller, and it goes when this window migrates).
function BenillaActionBarArt_SeatAbove(frame)
local art = getglobal("MainMenuBarArtFrame")
if art then frame:SetFrameLevel(art:GetFrameLevel() + 1) end
end
-- ref GlobalStrings.lua — the eight labels (enUS), in the `X = X or` FALLBACK form: the
-- app runs the real localized GlobalStrings.lua off the patch chain BEFORE this file
-- (ui_script/mod.rs load_global_strings), so the installed locale wins and these copies
-532
View File
@@ -1,532 +0,0 @@
<Ui>
<!--
**The four extra action bars — MultiBarBottomLeft/BottomRight and the vertical
MultiBarRight/MultiBarLeft — are PLAYER OPTIONS, and all four ship OFF (decision 1500,
which reverses 0270 and the half of 1219 that made "hidden" unconditional).**
0270 made the two bottom bars unconditionally on and the toggles unshippable; 1219 added
the two vertical ones as unconditionally hidden frames for the addons that index them. Both
were the director's call at the time, and 1500 is the director's call now: the four bars
become rows on the Options window's Action Bars page, and a fresh character sees none of
them.
THE STORE IS THE SERVER'S, not a CVar and not a saved variable — verified against the real
1.12.1 binary by a wow-re §5 cross-check (three independent derivations, arbitrated;
wow-5875-re commit 417c2d31, node system/ui/scratch/action-bar-toggles.md):
- The four bits live in **PLAYER_FIELD_BYTES byte 2**, written with
**CMSG_SET_ACTIONBAR_TOGGLES (opcode 703)**. Per-character and server-persisted, which is
what makes "off by default" fall out for free: a fresh character's byte is 0.
- `SetActionBarToggles` takes exactly **FOUR** arguments (`cmp esi,4` at 0x4e770e) and packs
them as arg1→0x01, arg2→0x02, arg3→0x04, arg4→0x08. **A FIFTH argument is silently
DROPPED by the binding** — it never reaches the byte or the wire — even though the
reference's own options panel passes ALWAYS_SHOW_MULTIBARS as one. That is why the grid
option below is a saved variable and the four bars are not: the split is a verified
consequence of the binding's arity, not an authoring choice.
- `GetActionBarToggles` returns exactly FOUR values, each the Lua number `1` or `nil` —
never booleans, and four nils when not in world.
- The client NEVER writes that field locally and NOTHING fires on a change to it. So the
LIVE state is the four Lua globals below; `GetActionBarToggles()` is read ONCE, on
PLAYER_ENTERING_WORLD (UIParent.xml), purely as their seed. Nothing re-reads it.
The bar↔bit mapping is a FrameXML-layer rule — the binary is bar-agnostic: 0x01 →
MultiBarBottomLeft, 0x02 → MultiBarBottomRight, 0x04 → MultiBarRight, 0x08 → MultiBarLeft,
and **MultiBarLeft shows only when bar 3 is ALSO on** (confirmed by reading the install's
own MultiActionBars.lua l.73, `if ( SHOW_MULTI_ACTIONBAR_3 and SHOW_MULTI_ACTIONBAR_4 )`).
That conjunction is not arbitrary: MultiBarLeft's TOPRIGHT hangs off MultiBarRight's
TOPLEFT, so on its own it would be a column of buttons anchored to a bar that is not there.
Geometry + slot ranges QUOTED from the real 1.12 FrameXML (extracted from patch.MPQ):
- ref-MultiActionBars.xml l.36-39/496-513: each bar a 500x38 frame; BottomLeft's
BOTTOMLEFT anchors to ActionButton1's TOPLEFT +(0,17); BottomRight's LEFT anchors to
BottomLeft's RIGHT +(10,0); 12 buttons chained BOTTOMLEFT then LEFT-of-prev-RIGHT +6.
- ref-ActionButton.lua l.6-9/446-467 (ActionButton_GetPagedID): BottomLeft = page 6 ⇒
actions 61..72; BottomRight = page 5 ⇒ actions 49..60. The bases below feed
BenillaActionButton_Action (ActionBar.xml), which carries the ref's parent-name fork
as per-button data.
- Buttons reuse BenillaActionButtonTemplate + the whole ActionBar.xml handler set: same
click/drag/keyless-use, same feedback layer (state/usable/cooldown/flash), fanned from
MainMenuBar's bar-level events via the BENILLA_ACTION_BUTTONS roster each button
joins at OnLoad.
Ref divergences (deliberate, mirroring ActionBar.xml's SCOPE notes):
- MULTIACTIONBAR1/2BUTTON1-12 are BINDABLE since 1008 (the ref's own commands, registered
in crate::bindings::commands with the ref's runOnUp pair below); they ship UNBOUND like
the ref (no MULTIACTIONBAR* line in any of the install's bindings-cache.wtf files), so
out of the box the hotkey corner still carries the ref's range dot — a bound slot wears
its abbreviated label exactly like the main bar (ActionBar.xml's PaintHotkey law).
- Empty wells hide (the ref's own default) and appear as drop-target rings only while the
grid is asked for — a held cursor payload, or ALWAYS_SHOW_MULTIBARS below — unlike the
main bar's always-visible wells (its art has sockets; floating rings over bare world
would not read as a bar).
- MULTIACTIONBAR3/4BUTTON (the vertical bars' commands) still stay OUT of
`bindings::commands`. 1008's honest tree held while those bars could never be shown, and
it holds differently now that they can: the keybind screen lists what the player can
reach, and a bar behind an option is reachable. Registering them is a separate slice with
its own default-bindings question; until then painting a hotkey from an unregistered
command is a no-op (GetBindingKey answers nil) and the label is simply blank.
-->
<Script><![CDATA[
-- ref ActionButton.lua:6-7 — BOTTOMLEFT_ACTIONBAR_PAGE = 6, BOTTOMRIGHT_ACTIONBAR_PAGE = 5;
-- base = (page - 1) * 12.
BENILLA_MULTIBAR_BOTTOMLEFT_BASE = 60
BENILLA_MULTIBAR_BOTTOMRIGHT_BASE = 48
-- ref ActionButton.lua:8-9 — LEFT_ACTIONBAR_PAGE = 4, RIGHT_ACTIONBAR_PAGE = 3.
BENILLA_MULTIBAR_LEFT_BASE = 36
BENILLA_MULTIBAR_RIGHT_BASE = 24
-- ref MultiActionBars.lua:1 — every extra bar is twelve wells wide.
NUM_MULTIBAR_BUTTONS = 12
-- ── The four toggles: LIVE state, seeded once from the server byte ──────────────────────
-- These names are the REFERENCE's and they are a published contract, not a private cache —
-- the addon corpus reads and writes them directly. Bartender2.lua:45-48 sets all four to 1
-- at load (it takes the bars over), and CT_BarMod reads each one by name in three separate
-- places to decide whether a button of ours is its business (CT_BarMod.lua:232-235,
-- 1573-1576, 1624-1627). So the names are fixed and the VALUES have to be what the corpus
-- expects: 1 or nil, exactly what GetActionBarToggles returns, never true/false.
--
-- nil here = OFF, which is also the whole of "off by default": a fresh character's server
-- byte is 0, and the seed on PLAYER_ENTERING_WORLD (UIParent.xml) writes these four back
-- to nil. Declared explicitly anyway rather than left absent — an undeclared global reads
-- nil too, but then nothing in this file says the state exists.
SHOW_MULTI_ACTIONBAR_1 = nil
SHOW_MULTI_ACTIONBAR_2 = nil
SHOW_MULTI_ACTIONBAR_3 = nil
SHOW_MULTI_ACTIONBAR_4 = nil
-- The FIFTH switch, and the one with a different store — see the header: the engine
-- binding drops a fifth argument, so this one has no server side and is an ordinary
-- saved-variable global, exactly as the reference makes it (UIOptionsFrame.lua l.106-107
-- assigns "0" and RegisterForSave's it). It holds the four bars' EMPTY wells open so the
-- player can see the shape of a bar they have not filled yet. String "1"/"0", because that
-- is what the options row's uvar store writes; zBar reads it as either
-- (zActionButton.lua:61 tests both spellings) and so does the reader below.
--
-- This assignment is also the row's registered DEFAULT: OptionsGlobalRow_OnLoad captures
-- the file-scope value at load, so this line is the single source of it.
ALWAYS_SHOW_MULTIBARS = "0"
-- The four bars in toggle (and bit) order, each paired with the main-bar page it takes OUT
-- of the paging cycle while it is up — a page a multibar already displays must not also be
-- reachable on the main bar, or paging up shows you the twelve actions already on screen
-- beside it. ActionBar.xml declares VIEWABLE_ACTION_BAR_PAGES with all six viewable (all
-- four bars start hidden) and this file does the arithmetic from there.
BENILLA_MULTIBARS = {
{frame = "MultiBarBottomLeft", page = BOTTOMLEFT_ACTIONBAR_PAGE},
{frame = "MultiBarBottomRight", page = BOTTOMRIGHT_ACTIONBAR_PAGE},
{frame = "MultiBarRight", page = RIGHT_ACTIONBAR_PAGE},
{frame = "MultiBarLeft", page = LEFT_ACTIONBAR_PAGE},
}
-- One bar: on screen or not, and the main-bar page it owns settled to match. Split out so
-- MultiActionBar_Update below reads as the four-line list it actually is — which is where
-- the one thing a reader must not miss lives (bar 4's compound condition).
function BenillaMultiBar_Settle(bar, up)
local frame = getglobal(bar.frame)
if up then
frame:Show()
VIEWABLE_ACTION_BAR_PAGES[bar.page] = nil
else
frame:Hide()
VIEWABLE_ACTION_BAR_PAGES[bar.page] = 1
end
end
-- Re-derive all four bars from the four globals. Every writer of that state calls this —
-- the options rows, the PLAYER_ENTERING_WORLD seed, and any addon that sets a global and
-- asks for the bars back (the reference's own contract, and Bartender2 relies on it).
--
-- **The manage pass runs from HERE, not from per-bar OnShow/OnHide**, and that is a choice
-- worth naming because both work. A bar coming up or going down moves the cast bar, the
-- chat frames, the stance/pet row and the bag stack's corner
-- (UIPARENT_MANAGED_FRAME_POSITIONS), and the pass reads the bars' own :IsShown() rather
-- than these globals — so it must run AFTER the last Show/Hide, not four times in the
-- middle of them. One call at the end of the one function that moves them is that; four
-- OnShow/OnHide hooks would fire the pass up to four times per click, and three of those
-- times against a half-applied state. The reference reaches the same place from the other
-- side: its options panel calls MultiActionBar_Update() and UIParent_ManageFramePositions()
-- back to back at every one of its five click sites (UIOptionsFrame.xml l.665-666 et al).
function MultiActionBar_Update()
local bars = BENILLA_MULTIBARS
BenillaMultiBar_Settle(bars[1], SHOW_MULTI_ACTIONBAR_1)
BenillaMultiBar_Settle(bars[2], SHOW_MULTI_ACTIONBAR_2)
BenillaMultiBar_Settle(bars[3], SHOW_MULTI_ACTIONBAR_3)
-- Bar 4 is the one bar with a COMPOUND condition (ref MultiActionBars.lua l.73) — see
-- the header for why MultiBarLeft cannot stand without MultiBarRight under it.
BenillaMultiBar_Settle(bars[4], SHOW_MULTI_ACTIONBAR_3 and SHOW_MULTI_ACTIONBAR_4)
UIParent_ManageFramePositions()
end
-- ── The empty-well grid over the four bars ──────────────────────────────────────────────
-- The three verbs are the reference's own published names and signatures, and the corpus
-- is why they are worth shipping as such: CT_BarMod REPLACES MultiActionBar_UpdateGrid
-- wholesale (CT_BarMod.lua:1649) and zBar WRAPS ShowAllGrids/HideAllGrids around its own
-- bars (zActionButton.lua:83-94), both by name and both expecting this shape. The
-- per-button half lives in ActionBar.xml (ActionButton_ShowGrid/_HideGrid over
-- `button.showgrid`), which is where the reference keeps it too.
function MultiActionBar_UpdateGrid(barName, show)
for i = 1, NUM_MULTIBAR_BUTTONS do
local button = getglobal(barName .. "Button" .. i)
if show then
ActionButton_ShowGrid(button)
else
ActionButton_HideGrid(button)
end
end
end
function MultiActionBar_ShowAllGrids()
for _, bar in ipairs(BENILLA_MULTIBARS) do
MultiActionBar_UpdateGrid(bar.frame, 1)
end
end
function MultiActionBar_HideAllGrids()
for _, bar in ipairs(BENILLA_MULTIBARS) do
MultiActionBar_UpdateGrid(bar.frame)
end
end
-- The option's apply: re-derive the grid from ALWAYS_SHOW_MULTIBARS. Run on every write of
-- that global (the row's applyFunc) and once at startup over the restored saved value —
-- the reference does the same restore by hand in its VARIABLES_LOADED ladder
-- (UIOptionsFrame.lua l.217-220); ours is OptionsFrame_ApplySavedSettings' walk.
--
-- Both spellings tested because the global is published and the two stores disagree: our
-- options row writes the STRING "1", while an addon setting it writes the number (zBar
-- tests both for exactly this reason, zActionButton.lua:61).
--
-- The `held` latch is ours and it is what makes this option a WELL-BEHAVED holder of the
-- count: one asker may hold at most one. Without it, re-applying an "off" that was already
-- off decrements every button again — so applying defaults while a payload is in hand would
-- close the wells that payload is holding open. The reference has exactly that flaw; the
-- latch also means the startup apply of the default "0" is a no-op instead of a decrement.
BENILLA_MULTIBAR_GRID_HELD = nil
function MultiActionBar_UpdateGridVisibility()
local want
if ALWAYS_SHOW_MULTIBARS == "1" or ALWAYS_SHOW_MULTIBARS == 1 then want = 1 end
if want == BENILLA_MULTIBAR_GRID_HELD then return end
BENILLA_MULTIBAR_GRID_HELD = want
if want then
MultiActionBar_ShowAllGrids()
return
end
MultiActionBar_HideAllGrids()
end
-- ── The write half of a bar toggle (the Options rows' setter) ───────────────────────────
-- The reference has no counterpart: its panel edits SHOW_MULTI_ACTIONBAR_n inline in each
-- checkbox's OnClick and batches ONE SetActionBarToggles into UIOptionsFrame_Save. Our
-- window commits on the click (0961's grammar, no Save button), so the whole edit — the
-- global, the bars, the packet — is this one function and the rows pass it a bar index.
--
-- `value` is the row's "1"/"0" STRING (OptionsRow_Set hands an API row its own string), and
-- the number/boolean spellings are tolerated because this is reachable from addon code too.
-- What is stored is 1 or nil and nothing else: that is what GetActionBarToggles returns and
-- what the corpus expects to read back (see the globals' own comment above).
--
-- **FOUR arguments.** Not five. The reference passes ALWAYS_SHOW_MULTIBARS as a fifth and
-- the binding drops it on the floor (`cmp esi,4` at 0x4e770e) — see the header. The whole
-- byte is re-sent every time because the packet carries the byte, not a delta.
function BenillaMultiBar_SetShown(bar, value)
local on
if value and value ~= "0" and value ~= 0 then on = 1 end
setglobal("SHOW_MULTI_ACTIONBAR_" .. bar, on)
MultiActionBar_Update()
SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2,
SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)
end
-- The MULTIACTIONBAR keybind pair — ref MultiActionBars.lua:24-45 MultiActionButtonDown/Up,
-- the same NORMAL→PUSHED debounce dance as the main bar's ActionButtonDown/Up
-- (ActionBar.xml — the ref's MacroFrame_SaveMacro hook is not modeled there either).
-- Driven by the binding dispatch (crate::bindings, Kind::EdgeUpDown — decision 1008).
--
-- `bar` IS THE WHOLE FRAME-NAME PREFIX, not a suffix (decision 1199). The reference resolves
-- `getglobal(bar.."Button"..id)` and its own Bindings.xml passes "MultiBarBottomLeft"; ours
-- used to pass "BottomLeft" and splice "MultiBar" on in here. Same arity, different MEANING
-- — the silent kind of wrong shape: zBar calls `MultiActionButtonDown("zBar"..barid, id)`
-- and under the old splice built the nonexistent frame name "MultiBarzBar1Button1". The
-- caller now names the bar in full, exactly as the reference's binding does, so a third-
-- party bar can address these at all. `onSelf` rides to UseAction like ActionButtonUp's.
function MultiActionButtonDown(bar, id)
local button = getglobal(bar .. "Button" .. id)
if ( button:GetButtonState() == "NORMAL" ) then
button:SetButtonState("PUSHED")
end
end
function MultiActionButtonUp(bar, id, onSelf)
local button = getglobal(bar .. "Button" .. id)
if ( button:GetButtonState() == "PUSHED" ) then
button:SetButtonState("NORMAL")
UseAction(BenillaActionButton_Action(button), 0, onSelf)
-- Ref MultiActionButtonUp:39-43: the checked ring reflects the use immediately.
BenillaActionButton_UpdateState(button)
end
end
]]></Script>
<!-- frameStrata HIGH on both bottom bars is the ref's own (`MultiActionBars.xml` l.36 and l.151,
HorizontalMultiBar1/2). They share the bottom band with the bag windows, which are MEDIUM on
both sides by design — and both sides carry deep nested button trees, so co-tenanting one
stratum is exactly the level-outranks-insertion collision decision 0597 fixed for the party
frame. HIGH is what keeps the bars above an open bag.
`hidden="true"` on ALL FOUR bars since 1500 — the reference's own posture for every one of
them (its HorizontalMultiBar1/2 templates carry it too, l.36/l.151), and the shipped state
a character with a zero toggle byte lands in. MultiActionBar_Update is the only thing that
opens one. -->
<Frame name="MultiBarBottomLeft" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="500" y="38"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ActionButton1" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="17"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarBottomLeftButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomLeftButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomLeftButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMLEFT_BASE, "MULTIACTIONBAR1BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
<!-- HIGH and hidden, same as BottomLeft above (ref `MultiActionBars.xml` l.151). -->
<Frame name="MultiBarBottomRight" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="500" y="38"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="MultiBarBottomLeft" relativePoint="RIGHT"><Offset><AbsDimension x="10" y="0"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarBottomRightButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton1" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton2" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton3" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton4" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton5" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton6" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton7" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton8" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton9" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton10" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarBottomRightButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="LEFT" relativeTo="MultiBarBottomRightButton11" relativePoint="RIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_BOTTOMRIGHT_BASE, "MULTIACTIONBAR2BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
<!-- **The two VERTICAL bars — now the same kind of thing as the two above them.**
They were scoped out first (a bar nobody asked for should not exist), then 1219 built them
as real frames that shipped unconditionally hidden — because the reference does not choose
between "shown" and "absent": `MultiActionBars.xml` l.515/523 instantiates BOTH as real
`parent="UIParent"` frames whose templates (VerticalMultiBar3/4, l.266/381) carry
`hidden="true"`, and they stay hidden until SHOW_MULTI_ACTIONBAR_3/4 says otherwise.
What 1219 bought, and still buys, is that Atlas, Bartender2 and FuBartender2 stopped DYING
at session start on `MultiBarLeft` — Atlas at `Atlas.lua:387`
(`MultiBarLeft:SetFrameStrata("MEDIUM")`), Bartender2 at `Bartender2.lua:74`
(`MultiBarLeft:ClearAllPoints()`). Neither shows the bar; both only need it to be there.
Five more addons index `MultiBarRight` (decision 1218's corrected ranking).
What 1500 changed is only the last sentence of that story: "hidden, and nothing can show
it" has become "hidden, until the player ticks its row" — SHOW_MULTI_ACTIONBAR_3/4 and the
Options page's two Right ActionBar rows. `MultiBarLeft` still needs `MultiBarRight` under
it (the header's conjunction), which is why its row is a dependent child of bar 3's.
Pages from ref-ActionButton.lua l.8-9: LEFT_ACTIONBAR_PAGE = 4 (actions 37..48),
RIGHT_ACTIONBAR_PAGE = 3 (actions 25..36). Geometry from ref-MultiActionBars.xml: a 38x500
frame, button 1 at TOPRIGHT, each next TOP-to-BOTTOM of the previous at -6. -->
<Frame name="MultiBarRight" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="38" y="500"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-7" y="98"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarRightButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton2" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton3" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton4" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton5" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton6" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton7" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton8" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton9" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton10" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarRightButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarRightButton11" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_RIGHT_BASE, "MULTIACTIONBAR3BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
<Frame name="MultiBarLeft" toplevel="true" parent="UIParent" frameStrata="HIGH" hidden="true">
<Size><AbsDimension x="38" y="500"/></Size>
<Anchors>
<Anchor point="TOPRIGHT" relativeTo="MultiBarRight" relativePoint="TOPLEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor>
</Anchors>
<Frames>
<CheckButton name="MultiBarLeftButton1" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 1, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton2" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 2, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton3" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton2" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 3, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton4" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton3" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 4, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton5" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton4" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 5, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton6" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton5" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 6, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton7" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton6" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 7, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton8" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton7" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 8, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton9" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton8" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 9, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton10" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton9" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 10, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton11" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton10" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 11, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
<CheckButton name="MultiBarLeftButton12" inherits="BenillaActionButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="MultiBarLeftButton11" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-6"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaActionButton_OnLoad(self, 12, BENILLA_MULTIBAR_LEFT_BASE, "MULTIACTIONBAR4BUTTON")</OnLoad></Scripts>
</CheckButton>
</Frames>
</Frame>
</Ui>
+198 -34
View File
@@ -190,6 +190,111 @@
</Font>
<Script><![CDATA[
-- ── The reference's own option table, verbatim (stock UIOptionsFrame.lua l.1-84; 1938) ──
-- Stock MultiActionBars.lua:10 writes five rows of this table from MultiActionBarFrame_OnLoad
-- ("Hack to get around load order dependencies": the reference's toc loads UIOptionsFrame.xml
-- at l.21, before MultiActionBars.xml at l.39, so the MultiBar*_IsVisible funcs named at
-- l.55-59 are still nil when the table is declared, exactly as they are here). Our rows
-- below keep their own store (OptionsRow_Get's three kinds) and do not read this table; it
-- is published because the reference's bar file indexes it at LOAD, and this file is the
-- options window — the table's home — now seated at the reference's position in the
-- manifest. It goes with the window when UIOptionsFrame.xml migrates.
-- The two Action Bars uvars the reference's own options window inits in UIOptionsFrame_Init
-- (stock UIOptionsFrame.lua l.102-107), at file scope here because the rows below capture the
-- value at their OnLoad as the registered default. LOCK_ACTIONBAR is read by stock
-- ActionBarFrame.xml's drag guards and PetActionBarFrame.lua:270/278 (ours PetActionBar.xml);
-- ALWAYS_SHOW_MULTIBARS by stock MultiActionBar_UpdateGridVisibility. Both lived in the bar
-- files until 1938 took those stock.
LOCK_ACTIONBAR = "0"
RegisterForSave("LOCK_ACTIONBAR")
ALWAYS_SHOW_MULTIBARS = "0"
RegisterForSave("ALWAYS_SHOW_MULTIBARS")
UIOptionsFrameCheckButtons = { };
------------------- Basic Options -------------------
-- Controls
UIOptionsFrameCheckButtons["INVERT_MOUSE"] = { index = 1, cvar = "mouseInvertPitch" };
-- General
UIOptionsFrameCheckButtons["CLICK_TO_MOVE"] = { index = 6, cvar = "autointeract" };
UIOptionsFrameCheckButtons["GAMEFIELD_DESELECT_TEXT"] = { index = 45, cvar = "deselectOnClick"};
UIOptionsFrameCheckButtons["ASSIST_ATTACK"] = { index = 3, cvar = "assistAttack" };
UIOptionsFrameCheckButtons["CLEAR_AFK"] = { index = 16, cvar = "autoClearAFK" };
UIOptionsFrameCheckButtons["BLOCK_TRADES"] = { index = 14, cvar = "BlockTrades" };
UIOptionsFrameCheckButtons["AUTO_SELF_CAST_TEXT"] = { index = 61, cvar = "autoSelfCast"};
-- Display
UIOptionsFrameCheckButtons["USE_UBERTOOLTIPS"] = { index = 12, cvar = "UberTooltips" };
UIOptionsFrameCheckButtons["STATUS_BAR_TEXT"] = { index = 2, cvar = "statusBarText" };
UIOptionsFrameCheckButtons["PROFANITY_FILTER"] = { index = 5, cvar = "profanityFilter" };
UIOptionsFrameCheckButtons["SHOW_CLOAK"] = { index = 31, func = ShowingCloak, setFunc = ShowCloak};
UIOptionsFrameCheckButtons["SHOW_HELM"] = { index = 20, func = ShowingHelm , setFunc = ShowHelm };
UIOptionsFrameCheckButtons["SHOW_QUEST_FADING_TEXT"] = { index = 42, uvar = "QUEST_FADING_DISABLE", default = "0"};
UIOptionsFrameCheckButtons["SHOW_BUFF_DURATION_TEXT"] = { index = 39, uvar = "SHOW_BUFF_DURATIONS", default = "0"};
UIOptionsFrameCheckButtons["SHOW_PLAYER_NAMES"] = { index = 21, cvar = "UnitNamePlayer"};
UIOptionsFrameCheckButtons["SHOW_GUILD_NAMES"] = { index = 22, cvar = "UnitNamePlayerGuild"};
UIOptionsFrameCheckButtons["SHOW_PLAYER_TITLES"] = { index = 23, cvar = "UnitNamePlayerPVPTitle"};
UIOptionsFrameCheckButtons["SHOW_NPC_NAMES"] = { index = 30, cvar = "UnitNameNPC"};
UIOptionsFrameCheckButtons["SHOW_OWN_NAME"] = { index = 67, cvar = "UnitNameOwn"};
UIOptionsFrameCheckButtons["SHOW_PARTY_BACKGROUND_TEXT"] = { index = 43, uvar = "SHOW_PARTY_BACKGROUND"};
UIOptionsFrameCheckButtons["HIDE_OUTDOOR_WORLD_STATE_TEXT"] = { index = 62, uvar = "HIDE_OUTDOOR_WORLD_STATE", default = "0"};
UIOptionsFrameCheckButtons["AUTO_QUEST_WATCH_TEXT"] = { index = 66, uvar = "AUTO_QUEST_WATCH", default = "1"};
-- Camera Controls
UIOptionsFrameCheckButtons["FOLLOW_TERRAIN"] = { index = 24, cvar = "cameraTerrainTilt"};
UIOptionsFrameCheckButtons["HEAD_BOB"] = { index = 26, cvar = "cameraBobbing"};
UIOptionsFrameCheckButtons["WATER_COLLISION"] = { index = 27, cvar = "cameraWaterCollision"};
UIOptionsFrameCheckButtons["SMART_PIVOT"] = { index = 15, cvar = "cameraPivot" };
-- Help
UIOptionsFrameCheckButtons["SHOW_TUTORIALS"] = { index = 28};
UIOptionsFrameCheckButtons["SHOW_NEWBIE_TIPS_TEXT"] = { index = 29, uvar = "SHOW_NEWBIE_TIPS"};
UIOptionsFrameCheckButtons["SHOW_TIPOFTHEDAY_TEXT"] = { index = 44, cvar = "showGameTips"};
------------------- Advanced Options -------------------
-- Chat
UIOptionsFrameCheckButtons["SIMPLE_CHAT_TEXT"] = { index = 7, uvar = "SIMPLE_CHAT" , default = "1"};
UIOptionsFrameCheckButtons["CHAT_LOCKED_TEXT"] = { index = 8, uvar = "CHAT_LOCKED" , default = "1"};
UIOptionsFrameCheckButtons["GUILDMEMBER_ALERT"] = { index = 13, cvar = "guildMemberNotify" };
UIOptionsFrameCheckButtons["REMOVE_CHAT_DELAY_TEXT"] = { index = 18, uvar = "REMOVE_CHAT_DELAY", default = "0"};
UIOptionsFrameCheckButtons["CHAT_BUBBLES_TEXT"] = { index = 37, cvar = "ChatBubbles"};
UIOptionsFrameCheckButtons["PARTY_CHAT_BUBBLES_TEXT"] = { index = 38, cvar = "ChatBubblesParty"};
UIOptionsFrameCheckButtons["SHOW_LOOT_SPAM"] = { index = 46, cvar = "showLootSpam"};
UIOptionsFrameCheckButtons["AUTO_JOIN_GUILD_CHANNEL"] = { index = 51};
UIOptionsFrameCheckButtons["DISABLE_SPAM_FILTER"] = { index = 68, cvar = "spamFilter"};
-- Action Bars
UIOptionsFrameCheckButtons["LOCK_ACTIONBAR_TEXT"] = { index = 32, uvar = "LOCK_ACTIONBAR", default = "0"};
UIOptionsFrameCheckButtons["SHOW_MULTIBAR1_TEXT"] = { index = 33, func = MultiBar1_IsVisible, setFunc = Multibar_EmptyFunc};
UIOptionsFrameCheckButtons["SHOW_MULTIBAR2_TEXT"] = { index = 34, func = MultiBar2_IsVisible, setFunc = Multibar_EmptyFunc};
UIOptionsFrameCheckButtons["SHOW_MULTIBAR3_TEXT"] = { index = 35, func = MultiBar3_IsVisible, setFunc = Multibar_EmptyFunc};
UIOptionsFrameCheckButtons["SHOW_MULTIBAR4_TEXT"] = { index = 36, func = MultiBar4_IsVisible, setFunc = Multibar_EmptyFunc};
UIOptionsFrameCheckButtons["ALWAYS_SHOW_MULTIBARS_TEXT"] = { index = 40, func = MultibarGrid_IsVisible, setFunc = Multibar_EmptyFunc};
-- Raid & Party Options
UIOptionsFrameCheckButtons["HIDE_PARTY_INTERFACE_TEXT"] = { index = 47, uvar = "HIDE_PARTY_INTERFACE" , default = "0"};
UIOptionsFrameCheckButtons["SHOW_DISPELLABLE_DEBUFFS_TEXT"] = { index = 48, uvar = "SHOW_DISPELLABLE_DEBUFFS" , default = "1"};
UIOptionsFrameCheckButtons["SHOW_CASTABLE_BUFFS_TEXT"] = { index = 49, uvar = "SHOW_CASTABLE_BUFFS" , default = "1"};
UIOptionsFrameCheckButtons["SHOW_TARGET_OF_TARGET_TEXT"] = { index = 50, uvar = "SHOW_TARGET_OF_TARGET" , default = "0"};
UIOptionsFrameCheckButtons["SHOW_PARTY_PETS_TEXT"] = { index = 41, uvar = "SHOW_PARTY_PETS", default = "1"};
-- Combat Text
UIOptionsFrameCheckButtons["SHOW_COMBAT_TEXT_TEXT"] = { index = 52, uvar = "SHOW_COMBAT_TEXT" , default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_LOW_HEALTH_MANA_TEXT"] = { index = 53, uvar = "COMBAT_TEXT_SHOW_LOW_HEALTH_MANA" , default = "1"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_AURAS_TEXT"] = { index = 54, uvar = "COMBAT_TEXT_SHOW_AURAS" , default = "1"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_AURA_FADE_TEXT"] = { index = 55, uvar = "COMBAT_TEXT_SHOW_AURA_FADE" , default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_COMBAT_STATE_TEXT"] = { index = 56, uvar = "COMBAT_TEXT_SHOW_COMBAT_STATE" , default = "1"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_DODGE_PARRY_MISS_TEXT"] = { index = 57, uvar = "COMBAT_TEXT_SHOW_DODGE_PARRY_MISS", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_RESISTANCES_TEXT"] = { index = 58, uvar = "COMBAT_TEXT_SHOW_RESISTANCES", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_REPUTATION_TEXT"] = { index = 60, uvar = "COMBAT_TEXT_SHOW_REPUTATION", default = "0"};
UIOptionsFrameCheckButtons["SHOW_DAMAGE_TEXT"] = { index = 19, cvar = "CombatDamage"};
UIOptionsFrameCheckButtons["LOG_PERIODIC_EFFECTS"] = { index = 11, cvar = "CombatLogPeriodicSpells" };
UIOptionsFrameCheckButtons["SHOW_PET_MELEE_DAMAGE"] = { index = 9, cvar = "PetMeleeDamage" };
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_REACTIVES_TEXT"] = { index = 63, uvar = "COMBAT_TEXT_SHOW_REACTIVES", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_FRIENDLY_NAMES_TEXT"] = { index = 64, uvar = "COMBAT_TEXT_SHOW_FRIENDLY_NAMES", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_COMBO_POINTS_TEXT"] = { index = 65, uvar = "COMBAT_TEXT_SHOW_COMBO_POINTS", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_MANA_TEXT"] = { index = 59, uvar = "COMBAT_TEXT_SHOW_MANA", default = "0"};
UIOptionsFrameCheckButtons["COMBAT_TEXT_SHOW_HONOR_GAINED_TEXT"] = { index = 69, uvar = "COMBAT_TEXT_SHOW_HONOR_GAINED", default = "1"};
-- ── SHOW_BUFF_DURATIONS: the buff bar's timer switch (decision 1139) ──────────────────
--
-- The reference declares this in `UIOptionsFrame.lua` (l.21/104), whose `default` is
@@ -787,8 +892,8 @@
-- four bar rows are `func` rows over the server's own PLAYER_FIELD_BYTES byte 2 (no
-- local store exists to save), while Always Show ActionBars is a `uvar` row — the
-- engine binding takes exactly four arguments and silently drops the fifth the
-- reference passes it, so that switch has no server side at all. MultiBars.xml's
-- header carries the byte-level evidence.
-- reference passes it, so that switch has no server side at all. Decision 1500 and
-- wow-re action-bar-toggles.md carry the byte-level evidence.
ActionBars = {
"RowMultiBar1", "RowMultiBar2", "RowMultiBar3", "RowMultiBar4",
"RowAlwaysShowMultibars", "RowLockActionBar",
@@ -864,7 +969,14 @@
function OptionsRow_Set(row, value)
if row.uvar then
setglobal(row.uvar, value)
if row.applyFunc then row.applyFunc() end
-- `applyFunc` is a global's NAME: the rows are read at this file's load, which since
-- 1938 sits at the reference's slot — before every file an apply lives in — so the
-- function is looked up when the row is applied, not when it is declared. A name
-- with no global behind it (a page harness without that definer) applies nothing,
-- which is what a nil capture always did.
local apply = row.applyFunc
if type(apply) == "string" then apply = getglobal(apply) end
if apply then apply() end
return
end
-- An API row hands the setter the row's own "1"/"0" STRING, which is what 1.12 does
@@ -925,14 +1037,27 @@
-- no particular order (they are independent by construction — each one re-derives one
-- feature's state from one global). A row whose defining file was not loaded has a nil
-- applyFunc and is skipped, which is what keeps a partial test harness honest.
-- The multibar grid switch's load arm — stock UIOptionsFrame.lua l.218-220 verbatim.
function OptionsFrame_MultibarGrid_Load()
if ( ALWAYS_SHOW_MULTIBARS == "1" or ALWAYS_SHOW_MULTIBARS == 1 ) then
MultiActionBar_ShowAllGrids()
end
end
function OptionsFrame_ApplySavedSettings()
local ran = {}
for key, rows in pairs(OPTIONS_PAGE_ROWS) do
for _, rkey in ipairs(rows) do
local row = getglobal("OptionsFrameContainerBody" .. key .. rkey)
local apply = row and row.applyFunc
if apply and not ran[apply] then
ran[apply] = 1
-- `applyFunc` is a NAME (see OptionsPage_Apply): resolved here, when the
-- definer has long since loaded; a name with nothing behind it is skipped. A
-- row whose LOAD-time side effect differs from its click's names it as
-- `loadFunc` (the multibar grid switch, below).
local name = row and (row.loadFunc or row.applyFunc)
local apply = name
if type(name) == "string" then apply = getglobal(name) end
if apply and not ran[name] then
ran[name] = 1
apply()
end
end
@@ -1299,8 +1424,8 @@
if key == "ActionBars" then
-- 1.12's own rule for this pair, verbatim in effect (UIOptionsFrame.lua l.722-726):
-- Show Right ActionBar 2 is dead while Show Right ActionBar is unticked, because
-- MultiBarLeft does not appear without MultiBarRight under it (MultiBars.xml's
-- conjunction). It is the only cross-row dependency on this page — the two bottom
-- MultiBarLeft does not appear without MultiBarRight under it (stock
-- MultiActionBar_Update's conjunction). It is the only cross-row dependency on this page — the two bottom
-- bars, the grid switch and the lock are all independent.
OptionsRow_SetEnabled(OptionsFrameContainerBodyActionBarsRowMultiBar4,
SHOW_MULTI_ACTIONBAR_3)
@@ -2639,7 +2764,7 @@
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "SHOW_COMBAT_TEXT",
"Enable Floating Combat Text", "OPTION_TOOLTIP_SHOW_COMBAT_TEXT")
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<!-- The scroll mode. 1.12's own dropdown, values and all
@@ -2659,7 +2784,7 @@
{ text = "Arc", value = "3" },
})
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowLowHealthMana" inherits="OptionsCheckboxRowTemplate">
@@ -2672,7 +2797,7 @@
"Show Low Mana and Health",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_LOW_HEALTH_MANA", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAuras" inherits="OptionsCheckboxRowTemplate">
@@ -2684,7 +2809,7 @@
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_AURAS",
"Show Auras", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_AURAS", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAuraFade" inherits="OptionsCheckboxRowTemplate">
@@ -2696,7 +2821,7 @@
OptionsGlobalRow_OnLoad(self, "COMBAT_TEXT_SHOW_AURA_FADE",
"Show Fading Auras", "OPTION_TOOLTIP_COMBAT_TEXT_SHOW_AURA_FADE", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowCombatState" inherits="OptionsCheckboxRowTemplate">
@@ -2709,7 +2834,7 @@
"Show Combat State",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_COMBAT_STATE", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowDodgeParryMiss" inherits="OptionsCheckboxRowTemplate">
@@ -2722,7 +2847,7 @@
"Show Dodge/Parries/Misses",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_DODGE_PARRY_MISS", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowResistances" inherits="OptionsCheckboxRowTemplate">
@@ -2735,7 +2860,7 @@
"Show Damage Reduction",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_RESISTANCES", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowReputation" inherits="OptionsCheckboxRowTemplate">
@@ -2748,7 +2873,7 @@
"Show Reputation",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_REPUTATION", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowReactives" inherits="OptionsCheckboxRowTemplate">
@@ -2761,7 +2886,7 @@
"Show Reactive Spells and Abilities",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_REACTIVES", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowFriendlyNames" inherits="OptionsCheckboxRowTemplate">
@@ -2774,7 +2899,7 @@
"Show Friendly Healer Names",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_FRIENDLY_NAMES", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<!-- Combo Points carries the reference's SECOND gate: greyed
@@ -2790,7 +2915,7 @@
"Show Combo Points",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_COMBO_POINTS", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowEnergyGains" inherits="OptionsCheckboxRowTemplate">
@@ -2803,7 +2928,7 @@
"Show Energy Gains",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_MANA", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowHonorGained" inherits="OptionsCheckboxRowTemplate">
@@ -2816,7 +2941,7 @@
"Show Honor Gained",
"OPTION_TOOLTIP_COMBAT_TEXT_SHOW_HONOR_GAINED", 1)
self.parentRow = "RowCombatText"
self.applyFunc = CombatText_UpdateDisplayedMessages
self.applyFunc = "CombatText_UpdateDisplayedMessages" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
</Frames>
@@ -2841,11 +2966,11 @@
four arguments and silently DROPS the fifth that the reference
passes it (`cmp esi,4` at 0x4e770e), so that switch has no
server store to write and the reference RegisterForSave's it
locally too (UIOptionsFrame.lua l.106-107). MultiBars.xml's
header carries the whole byte-level finding.
locally too (UIOptionsFrame.lua l.106-107). Decision 1500 and
wow-re action-bar-toggles.md carry the whole byte-level finding.
The getters and setters are written here as closures rather
than captured from MultiBars.xml by name, deliberately: a row's
than captured from the bar file by name, deliberately: a row's
OnLoad captures a function VALUE, so a named import would make
this page depend on load ORDER — and a harness (or a manifest
reordering) that loaded the window without the bars would give
@@ -2874,7 +2999,15 @@
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self,
function() return SHOW_MULTI_ACTIONBAR_1 end,
function(value) BenillaMultiBar_SetShown(1, value) end,
function(value)
local on
if value and value ~= "0" and value ~= 0 then on = 1 end
SHOW_MULTI_ACTIONBAR_1 = on
MultiActionBar_Update()
SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2,
SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)
UIParent_ManageFramePositions()
end,
"0", "Show Bottom Left ActionBar", "OPTION_TOOLTIP_SHOW_MULTIBAR1")
</OnLoad></Scripts>
</Frame>
@@ -2886,7 +3019,15 @@
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self,
function() return SHOW_MULTI_ACTIONBAR_2 end,
function(value) BenillaMultiBar_SetShown(2, value) end,
function(value)
local on
if value and value ~= "0" and value ~= 0 then on = 1 end
SHOW_MULTI_ACTIONBAR_2 = on
MultiActionBar_Update()
SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2,
SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)
UIParent_ManageFramePositions()
end,
"0", "Show Bottom Right ActionBar", "OPTION_TOOLTIP_SHOW_MULTIBAR2")
</OnLoad></Scripts>
</Frame>
@@ -2898,7 +3039,15 @@
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self,
function() return SHOW_MULTI_ACTIONBAR_3 end,
function(value) BenillaMultiBar_SetShown(3, value) end,
function(value)
local on
if value and value ~= "0" and value ~= 0 then on = 1 end
SHOW_MULTI_ACTIONBAR_3 = on
MultiActionBar_Update()
SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2,
SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)
UIParent_ManageFramePositions()
end,
"0", "Show Right ActionBar", "OPTION_TOOLTIP_SHOW_MULTIBAR3")
</OnLoad></Scripts>
</Frame>
@@ -2919,7 +3068,15 @@
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self,
function() return SHOW_MULTI_ACTIONBAR_4 end,
function(value) BenillaMultiBar_SetShown(4, value) end,
function(value)
local on
if value and value ~= "0" and value ~= 0 then on = 1 end
SHOW_MULTI_ACTIONBAR_4 = on
MultiActionBar_Update()
SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2,
SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)
UIParent_ManageFramePositions()
end,
"0", "Show Right ActionBar 2", "OPTION_TOOLTIP_SHOW_MULTIBAR4", 1)
</OnLoad></Scripts>
</Frame>
@@ -2931,7 +3088,14 @@
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "ALWAYS_SHOW_MULTIBARS",
"Always Show ActionBars", "OPTION_TOOLTIP_ALWAYS_SHOW_MULTIBARS")
self.applyFunc = MultiActionBar_UpdateGridVisibility
-- Two arms, as the reference has them: a CLICK runs
-- MultiActionBar_UpdateGridVisibility (UIOptionsFrame.xml:683);
-- the LOAD arm only ever SHOWS (UIOptionsFrame.lua:218-220),
-- because every hide is a counted decrement on each well
-- (ActionButton_HideGrid) and a load-time hide against wells
-- nobody opened would push the count negative.
self.applyFunc = "MultiActionBar_UpdateGridVisibility"
self.loadFunc = "OptionsFrame_MultibarGrid_Load"
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowLockActionBar" inherits="OptionsCheckboxRowTemplate">
@@ -3013,7 +3177,7 @@
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "SHOW_TARGET_OF_TARGET",
"Show Target of Target", "OPTION_TOOLTIP_SHOW_TARGET_OF_TARGET")
self.applyFunc = TargetofTarget_Update
self.applyFunc = "TargetofTarget_Update" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<!-- The label is ours: the reference declares a Label
@@ -3039,7 +3203,7 @@
{ text = "Party or Raid", value = "4", tip = "OPTION_TOOLTIP_TARGETOFTARGET4" },
{ text = "Always", value = "5", tip = "OPTION_TOOLTIP_TARGETOFTARGET5" },
})
self.applyFunc = TargetofTarget_Update
self.applyFunc = "TargetofTarget_Update" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<!-- The two equipment-display toggles (decision 1472,
@@ -3094,7 +3258,7 @@
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "SHOW_BUFF_DURATIONS",
"Buff Durations", "OPTION_TOOLTIP_SHOW_BUFF_DURATION")
self.applyFunc = BuffButtons_UpdatePositions
self.applyFunc = "BuffButtons_UpdatePositions" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAutoQuestWatch" inherits="OptionsCheckboxRowTemplate">
@@ -3180,7 +3344,7 @@
OptionsGlobalRow_OnLoad(self, "REMOVE_CHAT_DELAY",
"Remove Chat Hover Delay",
"OPTION_TOOLTIP_REMOVE_CHAT_DELAY")
self.applyFunc = ChatFrame_ApplyMouseOverDelay
self.applyFunc = "ChatFrame_ApplyMouseOverDelay" -- by NAME, resolved when applied: this file loads before its definer now (1938)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowChatBubbles" inherits="OptionsCheckboxRowTemplate">
@@ -36,7 +36,8 @@
- ONE refresh event. The app diffs the whole pushed bar and fires PET_BAR_UPDATE on any
change, so the ref's PET_BAR_UPDATE / PET_BAR_UPDATE_COOLDOWN / UNIT_PET / the
UNIT_FLAGS+UNIT_AURA pair it filters for arg1=="pet" collapse into one — the same
collapse StanceBar.xml makes, for the same reason.
collapse our stance bar made, for the same reason (the stock shapeshift bar,
BonusActionBarFrame.xml since 1938, registers the reference's six).
- The drag is ONE verb. PickupPetAction serves shift-click, OnDragStart and OnReceiveDrag
alike (decision 1010) — that is the reference's own shape, not a collapse of ours: the
binding itself forks on whether the cursor is already carrying a pet payload, so there is
-374
View File
@@ -1,374 +0,0 @@
<Ui>
<!--
The stance/shapeshift bar (vanilla ShapeshiftBarFrame, ref-BonusActionBarFrame.xml/.lua —
extracted from patch.MPQ). Warrior stances, druid forms, rogue Stealth: one CheckButton per
known form, checked = the ACTIVE form (the descriptor's UNIT_FIELD_BYTES_1 byte-2 form,
wow-re bonusbar-shapeshift.md), grey icon when not castable, cooldown swipe, click casts
the form (`crate::ui_shapeshift` drains CastShapeshiftForm onto the wire).
Geometry QUOTED from the ref's ShapeshiftBar_UpdatePosition (BonusActionBarFrame.lua
l.229-251), which forks on whether MultiBarBottomLeft is up: buttons 30x30, button1 at frame
BOTTOMLEFT +(11,3), chained +7; NormalTexture UI-Quickslot2 at the RAISED mode's 50x50 (ref
l.236-237), CENTER (0,-1) like the action template. Max 10 slots (NUM_SHAPESHIFT_SLOTS).
THE SEAT IS NOT THIS FILE'S — the XML anchor below (MainMenuBar TOPLEFT +(30,45)) is only
the value the manage pass computes with the bottom-left bar up, written statically so the
frame is somewhere sane before the first pass runs. `ShapeshiftBarFrame` is a managed row
(UIParent.xml: baseY 0, bottomLeft 45, xOffset 30), so the pass re-derives +45 raised and
+0 unraised — the ref's own two positions — and re-seats the frame on every bar change. That
used to be a distinction without a difference (0270's bottom bars were always on, so raised
was the only state); decision 1500 made all four extra bars player options, and the unraised
state is now reachable. It is covered, and `multibar_stance_tests` pins both seats.
The ART half of that same fork IS this file's, and it is `ShapeshiftBar_UpdatePosition` in
the script below: the ref's unraised branch also shows the ShapeshiftBarEnds/Middle shelf
strips and grows each button's ring from 50x50 to 64x64. That branch was dead code while the
bottom bars were always on, and 1500 made it the DEFAULT — every form class meets the
unraised bar on a fresh character — so a shelf-less bar would be the shipped look rather than
an edge case. It rides `UIParent_RegisterManagedPositionListener` (decision 1499's
after-every-recompute hook, the same one the bag stack uses) so the art settles when the pass
that decides raised-ness has finished, and it needs no edit to UIParent.xml.
Ref divergences (deliberate):
- ONE refresh event: the app diffs the whole pushed form list (identity, active, castable,
cooldown) and fires UPDATE_SHAPESHIFT_FORMS on any change — so the ref's separate
Update/UpdateState split over six events (SPELL_UPDATE_COOLDOWN/_USABLE,
PLAYER_AURAS_CHANGED, UPDATE_INVENTORY_ALERTS "Wha??", ACTIONBAR_PAGE_CHANGED) collapses
into Update on UPDATE_SHAPESHIFT_FORMS + PLAYER_ENTERING_WORLD.
- No lastSelected bookkeeping (ref ShapeshiftBar_ChangeForm) — checked state is always
re-derived from the pushed `isActive`, never locally latched: the server's form byte is
the truth and the app re-pushes on its every change. (Verified dead in the ref itself:
`lastSelected` is written twice and read nowhere in the shipped FrameXML.)
-->
<Script><![CDATA[
NUM_SHAPESHIFT_SLOTS = 10
-- RIGHT-CLICK IS DEAD HERE, and deliberately (decision 1030 reverting 1027). The ref's
-- ShapeshiftButtonTemplate registers NOTHING: it inherits ActionButtonTemplate and then
-- overrides <OnLoad> with a body that only scales the cooldown, so the
-- `RegisterForClicks("LeftButtonUp","RightButtonUp")` that ActionButton_OnLoad gives every
-- other action-style button (ref ActionButton.lua:109) is lost and the default
-- {LeftButtonUp} stands. A right-press therefore neither flashes the pushed art
-- (0x77924b gates that on the same mask) nor casts. It reads like a Blizzard oversight
-- and it is the only bar that behaves this way — reproduced anyway, because the director
-- checked it against the reference and called faithful.
function BenillaShapeshiftButton_OnLoad(button, index)
button.index = index
button.icon = getglobal(button:GetName() .. "Icon")
button.cooldown = getglobal(button:GetName() .. "Cooldown")
end
-- The CheckButton widget toggles itself BEFORE OnClick fires (the widget contract); the
-- ref immediately reverts that toggle (ShapeshiftButtonTemplate OnClick, ref-
-- BonusActionBarFrame.xml l.31-38), so the checked ring never follows the click — only
-- the isActive repaint. That is what keeps a warrior's ACTIVE stance lit through the
-- click's silent wire no-op (stances never cancel — the 0x4b4963 guard), and a druid
-- form/Stealth lit until the server actually confirms the shift-out. The revert is
-- unconditional, which is why no click here can leave a lit form dark — unlike the pet
-- bar's flat SetChecked(0), which needs a repaint to be correct.
function BenillaShapeshiftButton_OnClick(button)
if button:GetChecked() then
button:SetChecked(nil)
else
button:SetChecked(1)
end
CastShapeshiftForm(button.index)
end
-- Hover: the form's spell tooltip through the engine channel (ref ShapeshiftBar's
-- GameTooltip:SetShapeshift; decision 0274 P2). Anchor: the ref's ShapeshiftButton
-- OnEnter (BonusActionBarFrame.xml l.40-45) takes the default corner when UberTooltips
-- is "1" — its byte-verified stock default — and sits beside the button otherwise. Like
-- the action bar's, that fork was collapsed to the "1" leg until B230 registered the CVar
-- (cvars.rs carries the byte-read); the stock look is unchanged, the off leg is reachable.
function BenillaShapeshiftButton_OnEnter(button)
if not GameTooltip then return end -- engine tests load file subsets
if GetCVar("UberTooltips") == "0" then
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
else
GameTooltip_SetDefaultAnchor(GameTooltip, button)
end
GameTooltip:SetShapeshift(button.index)
end
function BenillaShapeshiftButton_OnLeave(button)
GameTooltip:Hide()
end
-- Ref ShapeshiftBar_Update + ShapeshiftBar_UpdateState fused (SCOPE note above): size the
-- bar to the form count (hide the whole frame at 0 — a formless class never shows it),
-- then paint each button — icon, checked (active), the 1.0/0.4 castable tint (ref l.210-214),
-- cooldown swipe (CooldownFrame_SetTimer, Cooldown.xml's shared transcription).
function ShapeshiftBar_Update()
local numForms = GetNumShapeshiftForms()
if numForms > 0 then
-- ── The shelf's LENGTH is the form count (ref l.161-175) ─────────────────────
-- The three strips are DECLARED as the >2-form chain (Left → Middle → Right), so
-- the one- and two-form shelves have to re-point the end cap. Hiding the middle
-- strip does NOT close its gap — a hidden region still resolves its rect, here
-- and in the reference — so without this the 42x50 right cap sits 38px past the
-- last button as a stray empty recess: a rogue's Stealth with a second, always-
-- empty slot beside it. One form pulls the cap back INSIDE the left cap (+12, a
-- 54px shelf around the single button); two butt the caps together; past two the
-- middle strip carries one slot per extra form and the cap chains off it.
if numForms == 1 then
ShapeshiftBarRight:SetPoint("LEFT", ShapeshiftBarLeft, "LEFT", 12, 0)
elseif numForms == 2 then
ShapeshiftBarRight:SetPoint("LEFT", ShapeshiftBarLeft, "RIGHT", 0, 0)
else
ShapeshiftBarMiddle:SetPoint("LEFT", ShapeshiftBarLeft, "RIGHT", 0, 0)
ShapeshiftBarMiddle:SetWidth(38 * (numForms - 2))
ShapeshiftBarMiddle:SetTexCoord(0, numForms - 2, 0, 1)
ShapeshiftBarRight:SetPoint("LEFT", ShapeshiftBarMiddle, "RIGHT", 0, 0)
end
ShapeshiftBarFrame:Show()
else
ShapeshiftBarFrame:Hide()
end
for i = 1, NUM_SHAPESHIFT_SLOTS do
local button = getglobal("ShapeshiftButton" .. i)
if i <= numForms then
local texture, name, isActive, isCastable = GetShapeshiftFormInfo(i)
button.icon:SetTexture(texture or BENILLA_FALLBACK_ICON)
local start, duration, enable = GetShapeshiftFormCooldown(i)
CooldownFrame_SetTimer(button.cooldown, start, duration, enable)
if isActive then
button:SetChecked(1)
else
button:SetChecked(0)
end
if isCastable then
button.icon:SetVertexColor(1.0, 1.0, 1.0)
else
button.icon:SetVertexColor(0.4, 0.4, 0.4)
end
button:Show()
else
button:Hide()
end
end
-- The art depends on the form COUNT too (the middle strip's `> 2`), so it is re-derived
-- here as well as on every managed-position pass. Pure derivation, so running it twice
-- costs nothing and missing it costs a strip.
ShapeshiftBar_UpdatePosition()
end
-- ── The raised/unraised ART fork (ref ShapeshiftBar_UpdatePosition, l.229-251) ──────────
-- The reference's function does TWO things and we only owe it one. Its two SetPoints —
-- MainMenuBar TOPLEFT +(30,45) with the bottom-left multibar up, +(30,0) without — are
-- already `UIParent_ManageFramePositions`' job here: `ShapeshiftBarFrame` is a managed row
-- (baseY 0, bottomLeft 45, xOffset 30), which IS those two positions, and a second
-- SetPoint from this file would be a competing answer to a question already answered.
-- What the pass does NOT carry is the art half, and that is this:
--
-- raised — the three shelf strips hidden, each button's ring 50x50. The shelf art IS
-- the bar's border and only reads as one while the bar sits on the main bar;
-- a row up it would draw across whatever is underneath (the same rule the
-- pass applies to the pet bar's sliding strips).
-- unraised — the two END-CAPS shown, the MIDDLE strip shown only past two forms, each
-- button's ring 64x64.
--
-- The `> 2` on the middle strip is the ref's own and easy to miss: a two-form class (a
-- priest with Shadowform, a warrior before Berserker) gets the end caps and no middle.
--
-- **This became load-bearing with 1500.** While the bottom bars were always on (0270) the
-- raised branch was the only reachable state and this whole fork was dead code; now every
-- form class sees the UNRAISED bar on a fresh character, so the shelf-less version would be
-- the shipped default look rather than an edge case.
--
-- It keeps the reference's NAME because the corpus replaces that name wholesale:
-- CT_BottomBar.lua:712/741 saves and overwrites `ShapeshiftBar_UpdatePosition` to take the
-- bar over. The listener below therefore calls it through the GLOBAL, so an addon that
-- replaces it really does take the art with it.
function ShapeshiftBar_UpdatePosition()
local raised = MultiBarBottomLeft and MultiBarBottomLeft:IsShown()
local ring = 64
if raised then
ring = 50
ShapeshiftBarLeft:Hide()
ShapeshiftBarMiddle:Hide()
ShapeshiftBarRight:Hide()
else
ShapeshiftBarLeft:Show()
ShapeshiftBarRight:Show()
if GetNumShapeshiftForms() > 2 then
ShapeshiftBarMiddle:Show()
else
ShapeshiftBarMiddle:Hide()
end
end
-- The ref sizes only the buttons a form occupies; ours sizes all ten. Same pixels (a
-- hidden button draws nothing) and one less thing to get wrong — a button that appears
-- later is already the right size, without this needing to run again for it.
for i = 1, NUM_SHAPESHIFT_SLOTS do
local ringTexture = getglobal("ShapeshiftButton" .. i .. "NormalTexture")
ringTexture:SetWidth(ring)
ringTexture:SetHeight(ring)
end
end
function ShapeshiftBar_OnLoad()
local frame = this
frame:RegisterEvent("PLAYER_ENTERING_WORLD")
frame:RegisterEvent("UPDATE_SHAPESHIFT_FORMS")
ShapeshiftBar_Update()
end
function ShapeshiftBar_OnEvent()
local frame = this
ShapeshiftBar_Update()
end
-- Re-derive the art after every managed-position recompute (decision 1499's hook — the same
-- after-the-pass seam the bag stack registers on). The pass is what decides whether this bar
-- is raised, so the art fork has to run when the pass has finished and not before. Called
-- through the global name so an addon replacement is honoured (see the function's comment).
-- Guarded for the harnesses that load this file without UIParent.xml, the way BagFrame.xml
-- guards its own registration.
if UIParent_RegisterManagedPositionListener then
UIParent_RegisterManagedPositionListener(function() ShapeshiftBar_UpdatePosition() end)
end
]]></Script>
<!--
One stance button (ref ShapeshiftButtonTemplate: 30x30, inherits ActionButtonTemplate's
art set): icon fills (BACKGROUND); Cooldown child (the ref SetScale(.83)s the 36px
template's — ours is sized 30x30 directly); the raised-mode 50x50 UI-Quickslot2 ring
CENTER (0,-1); the action template's pushed/highlight/checked trio.
-->
<CheckButton name="BenillaShapeshiftButtonTemplate" virtual="true">
<Size><AbsDimension x="30" y="30"/></Size>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentIcon"/>
</Layer>
</Layers>
<Frames>
<Cooldown name="$parentCooldown" hidden="true">
<Size><AbsDimension x="30" y="30"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</Cooldown>
</Frames>
<NormalTexture name="$parentNormalTexture" file="Interface\Buttons\UI-Quickslot2">
<Size><AbsDimension x="50" y="50"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
</Anchors>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Quickslot-Depress"/>
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
<Scripts>
<OnClick>BenillaShapeshiftButton_OnClick(self)</OnClick>
<OnEnter>BenillaShapeshiftButton_OnEnter(self)</OnEnter>
<OnLeave>BenillaShapeshiftButton_OnLeave(self)</OnLeave>
</Scripts>
</CheckButton>
<Frame name="ShapeshiftBarFrame" enableMouse="true" parent="MainMenuBar" hidden="true">
<Size><AbsDimension x="29" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="MainMenuBar" relativePoint="TOPLEFT"><Offset><AbsDimension x="30" y="45"/></Offset></Anchor>
</Anchors>
<!-- The shelf art (ref BonusActionBarFrame.xml l.220-249), declared HIDDEN.
The header above says this art "is not modeled at all" because raised mode hides it and
we are permanently raised. That reasoning was right about the PIXELS and wrong about
the NAMES: 1219/1223 settled that the reference does not choose between shown and
absent, and "we don't show it" is not a reason for it not to exist. Four frames landed
on that principle; these are the first REGIONS to, which is why the sweep that closed
the class missed them — it ran over the frames/tables ranking, and a Texture is
neither.
Two corpus addons index them, and both do pure LAYOUT — they do not use the art, they
move it, exactly the shape 1223 found for BonusActionBarFrame:
zBarEx/zMainBar.lua:288-290 SetParent(MainMenuBar) on all three
CT_BottomBar.lua:681-692 ClearAllPoints + SetPoint off-screen
Absent, `ShapeshiftBarLeft:ClearAllPoints()` is `attempt to index global` and takes the
addon's whole handler with it.
Geometry, anchor chain and TexCoords quoted whole: Left and Right are two crops of ONE
`ShapeshiftBarEnds` file (0..0.453125 and 0.453125..0.875), Middle its own, and Right
hangs off Middle's RIGHT — the >2-form chain, which is the DECLARED state only. The
cap is re-pointed per form count by `ShapeshiftBar_Update` above (the ref's l.161-175):
hiding the middle strip leaves its 38px rect behind, so the declared chain alone
strands the cap past the last button.
`hidden="true"` is the RAISED state, and it is only the pre-pass value now:
`ShapeshiftBar_UpdatePosition` (above) shows and hides all three from the bottom-left
multibar's state on every managed-position pass. Declared hidden rather than shown so a
harness that never runs the pass draws nothing new. -->
<Layers>
<Layer level="OVERLAY">
<Texture name="ShapeshiftBarLeft" file="Interface\ShapeshiftBar\ShapeshiftBarEnds" hidden="true">
<Size><AbsDimension x="45" y="50"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
<TexCoords left="0" right="0.453125" top="0" bottom="1"/>
</Texture>
<Texture name="ShapeshiftBarMiddle" file="Interface\ShapeshiftBar\ShapeshiftBarMiddle" hidden="true">
<Size><AbsDimension x="38" y="50"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftBarLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="1" top="0" bottom="1"/>
</Texture>
<Texture name="ShapeshiftBarRight" file="Interface\ShapeshiftBar\ShapeshiftBarEnds" hidden="true">
<Size><AbsDimension x="42" y="50"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftBarMiddle" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.453125" right="0.875" top="0" bottom="1"/>
</Texture>
</Layer>
</Layers>
<Frames>
<CheckButton name="ShapeshiftButton1" inherits="BenillaShapeshiftButtonTemplate" id="1">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="11" y="3"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 1)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton2" inherits="BenillaShapeshiftButtonTemplate" id="2">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton1" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 2)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton3" inherits="BenillaShapeshiftButtonTemplate" id="3">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton2" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 3)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton4" inherits="BenillaShapeshiftButtonTemplate" id="4">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton3" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 4)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton5" inherits="BenillaShapeshiftButtonTemplate" id="5">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton4" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 5)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton6" inherits="BenillaShapeshiftButtonTemplate" id="6">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton5" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 6)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton7" inherits="BenillaShapeshiftButtonTemplate" id="7">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton6" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 7)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton8" inherits="BenillaShapeshiftButtonTemplate" id="8">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton7" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 8)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton9" inherits="BenillaShapeshiftButtonTemplate" id="9">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton8" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 9)</OnLoad></Scripts>
</CheckButton>
<CheckButton name="ShapeshiftButton10" inherits="BenillaShapeshiftButtonTemplate" id="10">
<Anchors><Anchor point="LEFT" relativeTo="ShapeshiftButton9" relativePoint="RIGHT"><Offset><AbsDimension x="7" y="0"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaShapeshiftButton_OnLoad(self, 10)</OnLoad></Scripts>
</CheckButton>
</Frames>
<Scripts>
<OnLoad>ShapeshiftBar_OnLoad()</OnLoad>
<OnEvent>ShapeshiftBar_OnEvent()</OnEvent>
<!-- The stance bar is the pet/stance term of the managed bottom-stack positions
(UIParent.xml, decision 0272): its show/hide re-fires the pass, exactly the
ref's BonusActionBarFrame.xml:350-355. -->
<OnShow>UIParent_ManageFramePositions()</OnShow>
<OnHide>UIParent_ManageFramePositions()</OnHide>
</Scripts>
</Frame>
</Ui>
+31 -3
View File
@@ -74,8 +74,8 @@
XML anchor: every layout pass re-anchors it to a base offset plus per-bar deltas -
either bottom multibar, the pet/stance bar, the reputation watch bar - fired from those
bars' OnShow/OnHide (ref BonusActionBarFrame.xml:350-355 et al.) and applied once at
startup. The loader's post-load call (ui_script/mod.rs) is our startup seam; the stance
bar's OnShow/OnHide (StanceBar.xml) is the one live re-fire today.
startup. The loader's post-load call (ui_script/mod.rs) is our startup seam; the pet
bar's OnShow/OnHide and the options rows' setters (1938) are the live re-fires today.
Departures, each named:
- Lua 5.1 dialect (the 1.12 client ran 5.0): pairs() iteration, table.insert for
@@ -207,7 +207,7 @@
-- `CMSG_SET_ACTIONBAR_TOGGLES` and waits for the field to come back (wow-re
-- `system/ui/scratch/action-bar-toggles.md` §5: the setter's every store is into its own
-- stack frame, and nothing in the binary fires on a change to that byte). So the live UI
-- state is the four Lua globals `MultiBars.xml` owns, and `GetActionBarToggles()` is read
-- state is the four Lua globals stock `MultiActionBars.lua` reads, and `GetActionBarToggles()` is read
-- exactly ONCE, here, to seed them. Reading it again later would fight the optimistic
-- value the options rows just set and lose a round trip's worth of clicks.
--
@@ -810,6 +810,12 @@
-- while the stance bar is up starts 20px past the last stance button. That case is
-- rare but real — a possessed or charmed unit gives a pet bar to a class that also
-- has forms.
-- Update shapeshift bar appearance — ref UIParent.lua l.1705-1732, the reference's own
-- arm of this pass: with the bottom-left bar up the stance bar loses its shelf (end
-- caps + middle strip) and its rings shrink to 50; otherwise the shelf shows (the
-- middle only past two forms) and the rings are 64. Stock BonusActionBarFrame.lua
-- carries a `ShapeshiftBar_UpdatePosition` with the same body that nothing calls; the
-- pass IS the caller. (Our StanceBar.xml did this through a listener until 1938.)
local bottomLeftUp = MultiBarBottomLeft and MultiBarBottomLeft:IsShown()
if ( SlidingActionBarTexture0 and SlidingActionBarTexture1 ) then
if ( bottomLeftUp ) then
@@ -820,6 +826,28 @@
SlidingActionBarTexture1:Show()
end
end
if ( ShapeshiftBarFrame ) then
local numForms = GetNumShapeshiftForms and GetNumShapeshiftForms() or 0
if ( bottomLeftUp ) then
ShapeshiftBarLeft:Hide()
ShapeshiftBarRight:Hide()
ShapeshiftBarMiddle:Hide()
for i=1, numForms do
getglobal("ShapeshiftButton"..i.."NormalTexture"):SetWidth(50)
getglobal("ShapeshiftButton"..i.."NormalTexture"):SetHeight(50)
end
else
if ( numForms > 2 ) then
ShapeshiftBarMiddle:Show()
end
ShapeshiftBarLeft:Show()
ShapeshiftBarRight:Show()
for i=1, numForms do
getglobal("ShapeshiftButton"..i.."NormalTexture"):SetWidth(64)
getglobal("ShapeshiftButton"..i.."NormalTexture"):SetHeight(64)
end
end
end
if ( PetActionBarFrame ) then
PETACTIONBAR_XPOS = 36
local forms = GetNumShapeshiftForms and GetNumShapeshiftForms() or 0
@@ -24,6 +24,11 @@
its comment names the reference row it stands in for.
-->
<Script><![CDATA[
-- The placeholder icon for an item/spell whose texture has not arrived yet (Craft, Quest,
-- QuestLog, Trainer and TradeSkill read it). Was ActionBar.xml's; that file went stock with
-- 1938 and this kit is the constant's home now — a benilla name, so nothing stock reads it.
BENILLA_FALLBACK_ICON = "Interface\\Icons\\INV_Misc_QuestionMark"
-- UIParent.lua l.14-42: UIPanelWindows["FrameName"] = { area, pushable, whileDead }. Only
-- the rows benilla ships today; the ref rows with no benilla window yet (Tabard,
-- Battlefield, WorldStateScore, Minigame) join as their windows land (audited
+82 -75
View File
@@ -340,54 +340,104 @@ ConfirmSummon.xml
# asks. The only entry in the registry that raises a money frame, so it needs MoneyFrame.xml (far
# above, ahead of UiPanels.xml) as well as the StaticPopup engine itself.
TalentWipeConfirm.xml
# ── MEASUREMENT (1938): the options window at the reference's slot (FrameXML.toc l.21, before
# MainMenuBar l.34) with the four rows it inherits from. ──
# The faux-scroll kit (decisions 0247/0250/0251): BenillaScrollBarTemplate (the draggable
# Slider scroll bar) + FauxScrollFrameTemplate + the reference's own FauxScrollFrame_*
# API (Update/GetOffset/SetOffset/OnVerticalScroll + ScrollFrame_OnLoad) —
# before its first customer (the trainer list) below.
ScrollTemplates.xml
# ══ THE PANEL TEMPLATE KIT — the reference's own, off the player's chain (1751, window 31) ══
# TWO entries, because the reference splits this kit the way it splits every other: the `.lua`
# carries the verbs and the `.xml` the templates. Listing the `.xml` alone brings none of the verbs
# (`every_chain_xml_brings_its_own_lua` is a gate because that already bit twice).
#
# ONE swap, THREE of our files, which is why it had to move as a unit and why 1837 measured it
# before anyone executed it:
# * `UIPanelTemplates.xml` — DELETED. Our 18 templates were a strict SUBSET of the reference's,
# zero benilla-only additions, so the template half is a pure gain.
# * the scroll half of `ScrollTemplates.xml` — `FauxScrollFrameTemplate`, `FauxScrollFrame_*`,
# `ScrollFrame_*` — now the chain's. What stays there is `BenillaScrollBarTemplate` and
# `BenillaScrollTroughTemplate`, which are ours and which six windows still inherit.
# * the `PanelTemplates_*` family out of `UiPanels.xml`.
#
# It could not be split: `ScrollTemplates.xml` sits ABOVE this entry, so the chain's
# `FauxScrollFrameTemplate` lands after ours and wins (registration is last-in). There is no
# ordering that takes the templates without also taking Faux.
#
# GAINED: `TabButtonTemplate`, and the whole `ScrollingEdit_*` family, which we had NOWHERE — that
# is HelpFrame's named blocker, answered as a side effect. And it unblocks MacroFrame (1835).
Interface\FrameXML\UIPanelTemplates.lua
Interface\FrameXML\UIPanelTemplates.xml
# The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
# the templates + script of the Options window's Keybindings category, over the
# engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
# GetBinding/SetBinding Lua API). Needs UiPanels (popup engine) and GameTooltip (the
# character-specific checkbox's hover); MUST load before OptionsFrame.xml, whose
# Keybindings body inherits these templates and calls this script at OnLoad.
KeyBindingsPage.xml
# The options window (the 0985 provenance split: 0950's era structure, 0978's
# 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
# cuts — bare fully-live sliders, no corner X; 0992's dropdown rows + the Nameplates
# page): the era-shaped OptionsFrame the game menu's Options button opens. Needs
# Fonts' objects, UiPanels' panel manager, and UIDropDownMenu's capsule template
# (all far above); sits right before GameMenuFrame so the menu — which reaches it by
# name at click time — stays the last, outermost layer.
OptionsFrame.xml
# ── The action-bar cluster, stock (1938): MainMenuBar.xml before Cooldown.xml as the reference's
# toc has it (l.34 → l.36); ActionBarFrame.xml + MultiActionBars.xml right after ActionButtonTemplate.xml
# (l.37-39); BonusActionBarFrame.xml — which also declares the shapeshift bar — after PetActionBar.xml
# (l.73-74). Replaces ActionBar.xml, MultiBars.xml and StanceBar.xml.
Interface\FrameXML\MainMenuBar.xml
# The shared CooldownFrame_SetTimer (the ref's Cooldown.lua file split) — before its
# consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
# consumers (the action, multibar, bonus and shapeshift buttons; the bag slots).
Cooldown.xml
# ══ THE ACTION-BUTTON REGIONS — the reference's own, off the player's chain (1751) ══
# `ActionButtonTemplate`: the REGIONS-ONLY half of the reference's two-level split — icon, count,
# hotkey, name, border, flash, cooldown and normal texture. It declares NO scripts and calls
# nothing, so it needs no `.lua` beside it. The handler half is `ActionBarButtonTemplate`, which
# ActionBar.xml still declares and which inherits this; those handlers stay ours until
# `ActionButton.lua`'s 21 verbs migrate (we have 10 of them, under the reference's own names).
# ActionBarFrame.xml below declares over `ActionButton.lua` (stock since 1938).
#
# Ours was a transcription of this exact file — the eight `$parent*` child names match to the
# letter — so the swap is a deletion.
#
# **It must come BEFORE ActionBar.xml.** A template is registered by name and the later line
# wins, so seated below it our transcription would take the name straight back.
# **It must come BEFORE ActionBarFrame.xml**, whose template inherits it by name at load.
Interface\FrameXML\ActionButtonTemplate.xml
ActionBar.xml
Interface\FrameXML\ActionBarFrame.xml
# The four OPTIONAL extra bars (bottom left/right 61-72 / 49-60, vertical right/left
# 25-36 / 37-48), all shipping hidden until the player ticks their row (decision 1500):
# pure XML/Lua over ActionBar.xml's shared button template + handler set. Loads right
# after it, and the order is a REQUIREMENT, not tidiness: it inherits
# BenillaActionButtonTemplate, anchors to ActionButton1, its buttons join the
# BENILLA_ACTION_BUTTONS roster ActionBar.xml declares, its grid verbs call that file's
# ActionButton_ShowGrid/_HideGrid, and its BENILLA_MULTIBARS table reads that file's
# *_ACTIONBAR_PAGE constants at FILE SCOPE. UIParent.xml (far above) owns the manage pass
# MultiActionBar_Update ends with, and the PLAYER_ENTERING_WORLD seed that fills the four
# SHOW_MULTI_ACTIONBAR_* globals from the server's byte.
MultiBars.xml
# The stance/shapeshift bar: hidden until `crate::ui_shapeshift`'s feed pushes a
# non-empty form list (a formless class never shows it). After Cooldown.xml
# (CooldownFrame_SetTimer) + ActionBar.xml (BENILLA_FALLBACK_ICON, the MainMenuBar
# anchor target).
StanceBar.xml
# 25-36 / 37-48), all shipping hidden until the player ticks their row (decision 1500) — the
# reference's own file, right after ActionBarFrame.xml as its toc has it (l.38-39): it inherits
# ActionBarButtonTemplate, anchors to ActionButton1, and calls ActionButton_ShowGrid/_HideGrid.
# Its OnLoad writes five rows of `UIOptionsFrameCheckButtons`, which is why OptionsFrame.xml sits
# ABOVE this cluster (1938). UIParent.xml (far above) carries the PLAYER_ENTERING_WORLD seed
# that fills the four SHOW_MULTI_ACTIONBAR_* globals from the server's byte; the manage pass runs
# from the options rows' setters, as the stock options window runs it on hide.
Interface\FrameXML\MultiActionBars.xml
# The pet action bar (decision 0982): hidden until `crate::ui_pet`'s feed reports a bar,
# i.e. until the server sends `SMSG_PET_SPELLS` for a live pet or charm — so a class that
# never has one never sees it. Right after the stance bar, whose row it shares (either
# one showing raises UIParent.xml's managed "pet" delta), and after Cooldown.xml
# (CooldownFrame_SetTimer), ActionBar.xml (the `MainMenuBar` anchor target),
# (CooldownFrame_SetTimer), MainMenuBar.xml (the `MainMenuBar` anchor target),
# GameTooltip.xml (its hover), Fonts.xml (NORMAL_FONT_COLOR_CODE, which wraps the binding
# a token slot's tooltip appends) and UIParent.xml (GetBindingText, which renders it).
# It is also the pet ARC's file, so decision 1066's three
# right-click-menu dialogs (ABANDON_PET / RENAME_PET / PETRENAMECONFIRM) register here —
# which adds UiPanels.xml to its dependency set, already far above.
PetActionBar.xml
# The bonus (stance-page) bar AND the shapeshift bar — one stock file declares both, and the
# reference's toc seats it right after PetActionBarFrame (l.73-74): the bonus buttons'
# Up/Down call PetActionButtonUp/Down, which PetActionBar.xml above defines.
Interface\FrameXML\BonusActionBarFrame.xml
# The micro-button row in the bar's right-hand recess (the ref's own file split, its TOC
# loads MainMenuBarMicroButtons right after MainMenuBar). After ActionBar.xml: it anchors
# into MainMenuBarArtFrame by name and seats itself with that file's
# BenillaActionBarArt_SeatAbove. It must come BEFORE the panels it toggles, because each of
# loads MainMenuBarMicroButtons right after MainMenuBar). After MainMenuBar.xml: it anchors
# into MainMenuBarArtFrame by name and seats itself above the art plate with its own
# BenillaActionBarArt_SeatAbove (moved here from ActionBar.xml with 1938). It must come BEFORE the panels it toggles, because each of
# their OnShow/OnHide calls the `UpdateMicroButtons` this file defines (the panel frames
# themselves are read back through getglobal, so the reverse dependency is call-time).
MicroMenu.xml
@@ -440,33 +490,6 @@ Interface\FrameXML\FadingFrame.xml
# strings, which came from ours as `X = X or "…"` fallbacks and now come from the player's own
# GlobalStrings.lua like every other string in this interface.
Interface\FrameXML\ZoneText.xml
# The faux-scroll kit (decisions 0247/0250/0251): BenillaScrollBarTemplate (the draggable
# Slider scroll bar) + FauxScrollFrameTemplate + the reference's own FauxScrollFrame_*
# API (Update/GetOffset/SetOffset/OnVerticalScroll + ScrollFrame_OnLoad) —
# before its first customer (the trainer list) below.
ScrollTemplates.xml
# ══ THE PANEL TEMPLATE KIT — the reference's own, off the player's chain (1751, window 31) ══
# TWO entries, because the reference splits this kit the way it splits every other: the `.lua`
# carries the verbs and the `.xml` the templates. Listing the `.xml` alone brings none of the verbs
# (`every_chain_xml_brings_its_own_lua` is a gate because that already bit twice).
#
# ONE swap, THREE of our files, which is why it had to move as a unit and why 1837 measured it
# before anyone executed it:
# * `UIPanelTemplates.xml` — DELETED. Our 18 templates were a strict SUBSET of the reference's,
# zero benilla-only additions, so the template half is a pure gain.
# * the scroll half of `ScrollTemplates.xml` — `FauxScrollFrameTemplate`, `FauxScrollFrame_*`,
# `ScrollFrame_*` — now the chain's. What stays there is `BenillaScrollBarTemplate` and
# `BenillaScrollTroughTemplate`, which are ours and which six windows still inherit.
# * the `PanelTemplates_*` family out of `UiPanels.xml`.
#
# It could not be split: `ScrollTemplates.xml` sits ABOVE this entry, so the chain's
# `FauxScrollFrameTemplate` lands after ours and wins (registration is last-in). There is no
# ordering that takes the templates without also taking Faux.
#
# GAINED: `TabButtonTemplate`, and the whole `ScrollingEdit_*` family, which we had NOWHERE — that
# is HelpFrame's named blocker, answered as a side effect. And it unblocks MacroFrame (1835).
Interface\FrameXML\UIPanelTemplates.lua
Interface\FrameXML\UIPanelTemplates.xml
# ══ THE OPTIONS WIDGET KIT — the reference's own, off the player's chain (1751, window 29) ══
# The four templates addons name for an options panel: `OptionFrameBoxTemplate`,
# `OptionsButtonTemplate`, `OptionsCheckButtonTemplate`, `OptionsSliderTemplate`. Self-contained —
@@ -498,7 +521,7 @@ OptionsFrameTemplates.xml
#
# THE SEAT IS A REQUIREMENT, not tidiness: the Okay/Cancel buttons
# `inherits="GameMenuButtonTemplate"`, resolved at LOAD, and that template lives in
# UIPanelTemplates.xml six lines up (it moved there from GameMenuFrame.xml — the reference's own
# UIPanelTemplates.xml far above (it moved there from GameMenuFrame.xml — the reference's own
# file for it — precisely because GameMenuFrame.xml is deliberately LAST, which is also why this
# entry does not follow the reference's own position below it). Everything else it needs is far
# above: Fonts' GameFontNormal trio, and UiPanels' ShowUIPanel/HideUIPanel plus its UISpecialFrames
@@ -641,7 +664,7 @@ Interface\FrameXML\ContainerFrame.xml
# `UIPanelButtonTemplate`, and `PanelTemplates_SetNumTabs`, which `CharacterFrame_OnLoad` calls at
# LOAD), TextStatusBar.xml (`SetTextStatusBarTextPrefix`, called at load too, plus the
# `TextStatusBar` the pet page's XP bar inherits), UnitFrames.xml (`PlayerFrameHealthBar` /
# `PlayerFrameManaBar`, named in that same OnLoad), ActionBar.xml (`MainMenuExpBar`, likewise),
# `PlayerFrameManaBar`, named in that same OnLoad), MainMenuBar.xml (`MainMenuExpBar`, likewise),
# MicroMenu.xml (`MicroButtonTooltipText`, the five tab hovers) and UIParent.xml
# (`Model_OnLoad`/`_RotateLeft`/`_RotateRight`/`_OnUpdate` — the model pane's turntable, the
# reference's own code living in our UIParent.lua counterpart).
@@ -669,7 +692,7 @@ Interface\FrameXML\PetPaperDollFrame.xml
# 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 the character-window block (above, which is
# where stock `PaperDollFrame.xml` declares that template) and below ActionBar.xml (which declares
# where stock `PaperDollFrame.xml` declares that template) and below MainMenuBar.xml (which declares
# the art frame). It sat below a transcribed copy of the template in `ItemSlotButtonTemplates.xml`
# until the character window migrated and made that copy redundant (decision 1810). Being BELOW the sourced ContainerFrame.xml is
# legibility, not necessity, in either direction: every verb each file calls of the other resolves
@@ -679,8 +702,8 @@ Interface\FrameXML\PetPaperDollFrame.xml
# 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).
# need. The keyring's SHOW trigger moved the other way — into the main bar's file, which is exactly
# where the reference keeps it (MainMenuBar.xml l.323-341; stock since 1938).
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`.
@@ -700,8 +723,7 @@ ContainerFrameAdapters.xml
# template's first benilla consumer), OptionsFrameTemplates.xml (`OptionsCheckButtonTemplate`, two
# of the three detail boxes), GameTooltip.xml (the column newbie tips) and Fonts.xml. One
# dependency runs the other way and is worth naming: the WATCH BAR half of the reference's
# ReputationFrame lives in ActionBar.xml (entry 94) beside the MainMenuBar frames it drives — that
# file's own comment carries the justification — so `FACTION_BAR_COLORS` and
# ReputationFrame is declared in this same stock file (since 1875's co-edit, below) — so `FACTION_BAR_COLORS` and
# `ReputationWatchBar_Update` are already defined when this page paints a row or ticks the
# "Show as Experience Bar" box.
# ══ THE REPUTATION PANE — the reference's own, off the player's chain (1751, scoped by 1844) ══
@@ -712,7 +734,7 @@ ContainerFrameAdapters.xml
#
# THE CO-EDIT was compulsory. The reference declares the reputation WATCH BAR in this same file;
# ours lived in `ActionBar.xml` behind a note defending the placement. Frame globals publish
# NON-overwritingly and ActionBar.xml is manifest line 340 against this file's — so ours would have
# NON-overwritingly and ActionBar.xml was manifest line 340 against this file's — so ours would have
# kept the name and the chain's bar would have been built and unreachable, which is exactly what
# `GroupLootFrame` did for months (1838). The bar, its update, its two text verbs,
# `MAX_PLAYER_LEVEL` and `FACTION_BAR_COLORS` all went home with it (1875).
@@ -1053,21 +1075,6 @@ Interface\FrameXML\HelpFrame.xml
# nothing else, and nothing needs it — the engine reaches it purely by firing CINEMATIC_START.
# Seated late because it is an outermost, screen-owning layer, like the two windows below it.
Interface\FrameXML\CinematicFrame.xml
# The Keybindings page MODULE (decision 1008, superseding 0997's standalone window):
# the templates + script of the Options window's Keybindings category, over the
# engine's binding table (benilla_ui::script::keybind + crate::bindings — the 1.12
# GetBinding/SetBinding Lua API). Needs UiPanels (popup engine) and GameTooltip (the
# character-specific checkbox's hover); MUST load before OptionsFrame.xml, whose
# Keybindings body inherits these templates and calls this script at OnLoad.
KeyBindingsPage.xml
# The options window (the 0985 provenance split: 0950's era structure, 0978's
# 1.12-native art, 0981/0984's 1.14 chrome + working era search, 0989's directed
# cuts — bare fully-live sliders, no corner X; 0992's dropdown rows + the Nameplates
# page): the era-shaped OptionsFrame the game menu's Options button opens. Needs
# Fonts' objects, UiPanels' panel manager, and UIDropDownMenu's capsule template
# (all far above); sits right before GameMenuFrame so the menu — which reaches it by
# name at click time — stays the last, outermost layer.
OptionsFrame.xml
# The game menu (decision 0674): the frame ESC opens. LAST on purpose — it is the outermost
# layer of the shell, it depends on everything it touches being loaded (UiPanels' popup engine
# + panel manager, MicroMenu's UpdateMicroButtons, BagFrame's Disable_BagButtons), and nothing
+9 -8
View File
@@ -307,10 +307,10 @@ const TABLE: &[Spec] = &[
None
),
// The stance/shapeshift row (ref ShapeshiftBar_ChangeForm(n)) — ours clicks the bar's own
// buttons, which carry the full form-switch law (StanceBar.xml).
// buttons, which carry the full form-switch law (stock BonusActionBarFrame.xml, 1938).
// ── The self-cast dozen (1.12 `Bindings.xml`:257-293) ───────────────────────────────
// The same two halves as ACTIONBUTTON, with `ActionButtonUp`'s second argument set: the
// reference's own `onSelf`, which `ActionBar.xml` has always forwarded to `UseAction`'s third
// reference's own `onSelf`, which stock `ActionButtonUp` forwards to `UseAction`'s third
// and the host used to drop (1745). `ALT-1`…`ALT-=` are byte-real from DefaultBindings.wtf,
// and they sit one modifier off the plain bar exactly as the reference lays them out.
spec!(
@@ -567,8 +567,9 @@ const TABLE: &[Spec] = &[
),
// ── The action-bar PAGES (1.12 `Bindings.xml`:395-431) ──────────────────────────────
// The bar is six pages of twelve (action slots 1..72) and it has been since 1500 shipped the
// multibars; `ChangeActionBarPage` and the `ActionBar_Page{Up,Down}` wrap are ActionBar.xml's
// own, quoted 1:1 from the reference. `SHIFT-1..6` and the SHIFT-arrow / SHIFT-wheel steps are
// multibars; `ChangeActionBarPage` is the engine's (it fires ACTIONBAR_PAGE_CHANGED and nothing
// else — 1938) and the `ActionBar_Page{Up,Down}` wrap is stock ActionButton.lua's. `SHIFT-1..6`
// and the SHIFT-arrow / SHIFT-wheel steps are
// byte-real from `WTF\\DefaultBindings.wtf`.
spec!(
"ACTIONPAGE1",
@@ -645,7 +646,7 @@ const TABLE: &[Spec] = &[
Some("SHIFT-MOUSEWHEELDOWN")
),
// The action-bar lock (decision 1136), the ref's own binding body verbatim (Bindings.xml:433-
// 439) — it flips the `LOCK_ACTIONBAR` uvar `ActionBar.xml` declares, the same global the
// 439) — it flips the `LOCK_ACTIONBAR` uvar OptionsFrame.xml declares (1938), the same global the
// Options window's Action Bars row writes. It sits here because the reference files it under
// this header (l.433 carries no `header=`, so it inherits l.121's ACTIONBAR), and it ships
// **unbound**: no `TOGGLEACTIONBARLOCK` line in any of the install's three
@@ -1148,10 +1149,10 @@ const TABLE: &[Spec] = &[
None
),
// ── MultiActionBar (BINDING_HEADER_MULTIACTIONBAR) ──────────────────────────────────
// The two bottom bars' buttons (MultiBars.xml renders exactly these; 1.12's right bars
// The two bottom bars' buttons (stock MultiActionBars.xml renders exactly these; 1.12's right bars
// and their MULTIACTIONBAR3/4 commands stay out — honest tree). Ref bodies are the
// MultiActionButtonDown/Up runOnUp pair (Bindings.xml:799-966), transcribed in
// MultiBars.xml; shipped UNBOUND like the ref (no MULTIACTIONBAR* line in any of the
// MultiActionButtonDown/Up runOnUp pair (Bindings.xml:799-966), defined in stock
// MultiActionBars.lua; shipped UNBOUND like the ref (no MULTIACTIONBAR* line in any of the
// install's bindings-cache.wtf files). 1.12 files bar 2 under a BLANK spacer-header;
// both bars sit under the one MULTIACTIONBAR header here (1008, recorded).
spec!(
+1
View File
@@ -840,6 +840,7 @@ mod tests {
..default()
};
let path = |grounded: bool| Spline {
deck: None,
points: vec![[0.0; 3], [1.0, 0.0, 0.0]],
start: std::time::Instant::now(),
duration: std::time::Duration::from_secs(1),
@@ -1453,6 +1453,7 @@ fn seed_bag_window(
benilla_ui::script::ContainerSlot {
petition: None,
durability: None,
duration_ms: None,
bar_placeable: true,
texture: icon(disp),
count,
@@ -1549,6 +1550,7 @@ fn seed_equipped_bags(
|disp: u32, count: u32, name: &str, quality: u32| benilla_ui::script::ContainerSlot {
petition: None,
durability: None,
duration_ms: None,
bar_placeable: true,
texture: icon(disp),
count,
@@ -50,6 +50,8 @@ impl Plugin for LiveFpsPlugin {
run: std::env::var("WOW_LIVE_FPS_MOVE").as_deref() == Ok("1"),
phase: LiveFpsPhase::Waiting,
samples: Vec::new(),
cpu_samples: Vec::new(),
cpu_prev: None,
cpu_at_start: None,
sys_at_start: None,
occluded_now: false,
@@ -80,6 +82,11 @@ struct LiveFps {
run: bool,
phase: LiveFpsPhase,
samples: Vec<f32>,
/// Process CPU per frame, ms, index-parallel with `samples` — the TAIL half of `cpu_ms`:
/// a mean under budget hides the one frame in twenty that is over it, and whether that
/// frame was CPU work or a wait is the first question about a stutter.
cpu_samples: Vec<f32>,
cpu_prev: Option<f64>,
/// Process CPU seconds at the first sampled frame ([`crate::perf::process_cpu_secs`]) — the
/// baseline for the window's `cpu_ms`/`cpu_pct`.
cpu_at_start: Option<f64>,
@@ -122,6 +129,21 @@ type ScreenParams<'w, 's> = (
Query<'w, 's, &'static Camera>,
// The UI's live texture-identity runs (`ui_batches=`): one `Mesh2d` draw each in the 2D pass.
Query<'w, 's, (), With<crate::ui_pass::UiQuadBatch>>,
// Every view-visible `Mesh3d` by lane (`vis_terrain/model/liquid/other=`): the 3D passes'
// draw population. `drawn=` counts model submeshes only; the terrain is one entity per
// ADT chunk and a city horizon is over a thousand of them — a per-draw cost is not
// attributable until this split is known.
Query<
'w,
's,
(
&'static bevy::camera::visibility::ViewVisibility,
Has<MeshMaterial3d<benilla_assets::materials::TerrainMaterial>>,
Has<benilla_world::model_render::ModelPart>,
Has<MeshMaterial3d<benilla_assets::materials::LiquidMaterial>>,
),
With<Mesh3d>,
>,
);
#[derive(SystemParam)]
@@ -165,13 +187,14 @@ fn drive_live_fps(
// the leg line carries gpu percentiles beside the cpu ones (absent when the meter is off).
mut screen: ScreenParams,
) {
let (monitors, gpu, gpu_samples, wgpu_census, cameras, ui_batches) = (
let (monitors, gpu, gpu_samples, wgpu_census, cameras, ui_batches, vis_meshes) = (
&screen.0,
&screen.1,
&mut screen.2,
&screen.3,
&screen.4,
&screen.5,
&screen.6,
);
// Drain every frame so the state is current whichever phase we're in — the window can be
// occluded before sampling ever starts (a detached launch spawns behind whatever is open).
@@ -246,6 +269,13 @@ fn drive_live_fps(
}
let ms = time.delta_secs() * 1000.0;
probe.samples.push(ms);
let cpu_now = crate::perf::process_cpu_secs();
let cpu_ms = match (probe.cpu_prev, cpu_now) {
(Some(a), Some(b)) => ((b - a) * 1000.0) as f32,
_ => 0.0,
};
probe.cpu_prev = cpu_now;
probe.cpu_samples.push(cpu_ms);
if let Some(gpu) = gpu {
let ns = gpu.0.load(std::sync::atomic::Ordering::Relaxed);
if ns > 0 {
@@ -344,9 +374,34 @@ fn drive_live_fps(
let cpu = match (probe.cpu_at_start, crate::perf::process_cpu_secs()) {
(Some(t0), Some(t1)) => {
let per_frame_ms = (t1 - t0) * 1000.0 / v.len() as f64;
// The tail: CPU percentiles over the same frames, how many frames ran over
// the 60 Hz budget, and the worst six as `index:frame_ms/cpu_ms` — a frame
// whose cpu is near its length was WORK; one far under it was a WAIT (GPU,
// present, a lock).
let mut c: Vec<f32> = probe.cpu_samples.clone();
c.sort_by(f32::total_cmp);
let cat = |q: f32| c[(((c.len() - 1) as f32) * q).round() as usize];
let over = probe.samples.iter().filter(|&&f| f > 16.9).count();
let mut worst: Vec<usize> = (0..probe.samples.len()).collect();
worst.sort_by(|&a, &b| probe.samples[b].total_cmp(&probe.samples[a]));
let worst = worst
.iter()
.take(6)
.map(|&i| {
format!(
"{i}:{:.1}/{:.1}",
probe.samples[i],
probe.cpu_samples.get(i).copied().unwrap_or(0.0)
)
})
.collect::<Vec<_>>()
.join(",");
format!(
" cpu_ms={per_frame_ms:.2} cpu_pct={:.0}",
per_frame_ms / mean as f64 * 100.0
" cpu_ms={per_frame_ms:.2} cpu_pct={:.0} cpu_p95={:.2} cpu_p99={:.2} \
frames_over={over} worst=[{worst}]",
per_frame_ms / mean as f64 * 100.0,
cat(0.95),
cat(0.99),
)
}
_ => String::new(),
@@ -431,7 +486,7 @@ fn drive_live_fps(
})
.unwrap_or_default();
let residency_line = format!(
" mats={} mats_parked={} meshes={} images={} uv={} tint={} views={} ui_batches={}",
" mats={} mats_parked={} meshes={} images={} uv={} tint={} views={} ui_batches={}{vis}",
seen.mats,
seen.mats_parked,
seen.meshes,
@@ -440,6 +495,28 @@ fn drive_live_fps(
seen.tint_anims,
cameras.iter().filter(|c| c.is_active).count(),
ui_batches.iter().count(),
vis = {
let mut n = [0usize; 4];
for (vv, terrain, model, liquid) in vis_meshes.iter() {
if !vv.get() {
continue;
}
let k = if terrain {
0
} else if model {
1
} else if liquid {
2
} else {
3
};
n[k] += 1;
}
format!(
" vis_terrain={} vis_model={} vis_liquid={} vis_other={}",
n[0], n[1], n[2], n[3]
)
},
);
println!(
"FPS_PROBE scenario=live frames={} mean_ms={mean:.2} p50_ms={:.2} p95_ms={:.2} p99_ms={:.2} max_ms={:.2} fps={:.1} emitters={} active={} particles={} submeshes={} drawn={} streamed={} parked={} entities={}{rigs}{residency_line} px={}x{}{cpu}{sys}{present}{display}{gpu_line} occluded_frames={}{at_pin}{cam_pose}{gate}{sky}{ribbons}{culled}",
@@ -917,6 +917,7 @@ fn a_flying_spline_is_fly_before_backward_and_speed() {
fn unify_stamps_flying_from_the_live_spline_on_every_leg() {
use std::time::{Duration, Instant};
let spline = |grounded| crate::net::Spline {
deck: None,
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
start: Instant::now(),
duration: Duration::from_secs(10),
@@ -958,6 +959,7 @@ fn the_granted_modes_fold_into_the_flags_word_on_every_leg_but_our_own() {
// Creature leg on a path: the granted bit rides alongside the spline's own FORWARD.
let spline = crate::net::Spline {
deck: None,
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
start: Instant::now(),
duration: Duration::from_secs(10),
+11 -1
View File
@@ -227,7 +227,17 @@ fn drive_displayed_cursor(
kind: CursorKind::Repair,
unable: false,
}
} else if targeting.active() {
} else if targeting.active()
|| script
.as_ref()
.is_some_and(|s| s.gift_wrap_armed().is_some())
{
// An armed **gift wrap** parks the base at Cast(2) for the same reason a targeting spell
// does, and by the same act: `0x5edea0`'s three calls are `LockItem`, **`SetCursorBaseMode(2)`**
// and `CursorSetMode(2)` (decision 1934). Both cells, deliberately — the displayed one so the
// cursor changes at the click, the BASE one so it survives the pointer crossing the world,
// where the classifier would otherwise write its own verdict over it. Setting only the
// displayed mode would lose the wrap cursor the moment the mouse left the bag.
WorldCursor {
kind: CursorKind::Cast,
unable: false,
+1 -1
View File
@@ -525,7 +525,7 @@ pub(crate) const REGISTERED: &[Registered] = &[
// Enhanced Tooltips (B230): 1.12's `UberTooltips`, the *Enhanced Tooltips* checkbox
// (`UIOptionsFrame.lua:15`, `USE_UBERTOOLTIPS`). **No host knob** — its consumers are Lua, and
// there are three: PetActionBar.xml forks the whole tooltip on it (a token's own text with the
// binding appended, vs the engine's pet-spell channel), ActionBar.xml and StanceBar.xml fork
// binding appended, vs the engine's pet-spell channel), the stock action and shapeshift buttons fork
// their anchor. Registered "1" — byte-read, not behaviour-derived: WoW.exe `0x48fdd9`, default
// string `0x82e748`, with the sibling rows `BlockTrades`→"0" and `UnitNameRenderMode`→"2"
// confirming the layout. Those three Lua sites each carried the reference's fork in prose and
@@ -122,7 +122,12 @@ mod tests {
let mut spawned: Vec<(Entity, Vec3, Entity)> = app
.world_mut()
.query::<(Entity, &PointLight, &Transform, &ChildOf)>()
.query::<(
Entity,
&benilla_world::lighting::WorldPointLight,
&Transform,
&ChildOf,
)>()
.iter(app.world())
.map(|(e, _, t, c)| (e, t.translation, c.parent()))
.collect();
@@ -145,12 +150,11 @@ mod tests {
let pl = app
.world()
.entity(spawned[0].0)
.get::<PointLight>()
.get::<benilla_world::lighting::WorldPointLight>()
.unwrap();
let lin = pl.color.to_linear();
let recovered = pl.intensity / (4.0 * std::f32::consts::PI);
assert!(
(lin.red * recovered - 1.4).abs() < 1e-3,
(pl.color[0] * recovered - 1.4).abs() < 1e-3,
"colour × intensity survives the packing"
);
}
+137 -17
View File
@@ -320,6 +320,13 @@ pub(crate) struct Items {
/// duration field, which the reference's tooltip never reads. Session state, so it clears with
/// the objects (decision 0920).
enchant_deadlines: HashMap<(u64, u32), std::time::Instant>,
/// The **item-lifetime deadlines**, keyed by item guid — a duration-limited instance's
/// remaining life (a conjured stone, a holiday gift, a timed quest item). Its only feed is
/// `SMSG_ITEM_TIME_UPDATE`; the instance's own `ITEM_FIELD_DURATION` carries the same number
/// and is sent to the owner, but vmangos's writer says in as many words that the field is not
/// what the client displays from (`Item::SendTimeUpdate`, `Objects/Item.cpp:1094`). Same shape
/// as [`Self::enchant_deadlines`] one field up, for the same reason (decision 1933).
duration_deadlines: HashMap<u64, std::time::Instant>,
}
impl Items {
@@ -353,6 +360,7 @@ impl Items {
self.object_epoch = self.object_epoch.wrapping_add(1);
}
self.enchant_deadlines.retain(|&(g, _), _| g != guid);
self.duration_deadlines.remove(&guid);
}
/// The instance-side broadcast counter — see the [`Self::object_epoch`] field.
@@ -404,23 +412,27 @@ impl Items {
}
/// The counter a gated feed watches instead of holding its gate open per-frame: moves exactly
/// when some [`Self::enchant_remaining_display_ms`] can move. Each live deadline contributes
/// `floor(seconds left) + 1` — the `+1` makes the final `Some(0) → None` collapse (the tooltip
/// reverting to the plain enchant name) its own step, one frame after the deadline elapses.
/// when some displayable countdown can — [`Self::enchant_remaining_display_ms`] or
/// [`Self::duration_remaining_display_ms`], the two per-second timers a bag/equipment
/// snapshot renders. Each live deadline contributes `floor(seconds left) + 1` — the `+1`
/// makes the final `Some(0) → None` collapse (the tooltip reverting to the plain enchant
/// name, the duration line vanishing) its own step, one frame after the deadline elapses.
/// Landings and removals move [`Self::object_epoch`] on their own. Empty — the overwhelmingly
/// common case — costs an empty iteration.
pub(crate) fn enchant_display_epoch(&self) -> u64 {
/// common case — costs two empty iterations.
pub(crate) fn countdown_display_epoch(&self) -> u64 {
let now = std::time::Instant::now();
let step = |at: &std::time::Instant| {
let left = at.saturating_duration_since(now);
if left.is_zero() {
0
} else {
left.as_secs() + 1
}
};
self.enchant_deadlines
.values()
.map(|&at| {
let left = at.saturating_duration_since(now);
if left.is_zero() {
0
} else {
left.as_secs() + 1
}
})
.chain(self.duration_deadlines.values())
.map(step)
.sum()
}
@@ -442,6 +454,56 @@ impl Items {
)
}
/// `SMSG_ITEM_TIME_UPDATE` landed: park the absolute deadline for that item instance's
/// remaining lifetime. Same store shape as the enchant path one field up
/// ([`Self::set_enchant_deadline`]) — byte-confirmed as such, not assumed (decision 1933's
/// fold-back).
///
/// Three rules, each the reference's:
///
/// - **A non-POSITIVE duration clears.** `0x5d9c0c` tests the wire value **signed** (`jle`),
/// so `0` and anything with the top bit set both zero the cell rather than parking a
/// 68-year deadline. An expired countdown is an absent line, not a line reading `0`.
/// - **An item we do not hold is DROPPED — no queue, no retry.** `0x1EA`'s arm resolves the
/// guid and returns on a miss. This is the load-bearing asymmetry with its sibling: the
/// `0x1EB` enchant arm *does* enqueue an unresolved update and replays it when the item
/// appears (`0x5ebd40` → `0x5ebde0`); this one does not. Following the sibling would also
/// grow this map without bound for items that never arrive.
/// - The cell dies with the object, so a relog or a zone destroy loses it; the server re-sends
/// for everything carried on world enter (`Player::SendItemDurations`).
pub(crate) fn set_item_duration(&mut self, guid: u64, seconds: u32) {
if !self.objects.contains_key(&guid) {
debug!("item duration: {guid:#x} names an item we do not hold — dropped");
return;
}
if seconds == 0 || seconds as i32 <= 0 {
if self.duration_deadlines.remove(&guid).is_some() {
self.object_epoch = self.object_epoch.wrapping_add(1);
}
return;
}
let at = std::time::Instant::now() + std::time::Duration::from_secs(u64::from(seconds));
self.duration_deadlines.insert(guid, at);
self.object_epoch = self.object_epoch.wrapping_add(1);
debug!("item duration: item {guid:#x} → {seconds}s");
}
/// Milliseconds left on an item instance's lifetime, or `None` when it carries no timer —
/// including an elapsed one. Recomputed on read from the parked deadline, never ticked (the
/// enchant path's `0x5d9d00` shape).
pub(crate) fn duration_remaining_ms(&self, guid: u64) -> Option<u64> {
let at = self.duration_deadlines.get(&guid)?;
let left = at.saturating_duration_since(std::time::Instant::now());
(!left.is_zero()).then_some(left.as_millis() as u64)
}
/// [`Self::duration_remaining_ms`] at **display granularity** — floored to the whole second,
/// for the same reason [`Self::enchant_remaining_display_ms`] is: a raw per-ms read makes
/// every snapshot differ every frame and fires the inventory feeds at frame rate.
pub(crate) fn duration_remaining_display_ms(&self, guid: u64) -> Option<u64> {
self.duration_remaining_ms(guid).map(|ms| ms - ms % 1000)
}
/// A tracked item object's merged descriptor fields.
pub(crate) fn object(&self, guid: u64) -> Option<&ObjectFields> {
self.objects.get(&guid)
@@ -787,12 +849,12 @@ mod tests {
/// to the whole second, and clearing the deadline zeroes the epoch (the `Some(0) → None`
/// collapse is the term's own last step).
#[test]
fn the_enchant_display_epoch_steps_by_displayable_seconds() {
fn the_countdown_display_epoch_steps_by_displayable_seconds() {
let mut items = Items::default();
assert_eq!(items.enchant_display_epoch(), 0, "no deadlines, no epoch");
assert_eq!(items.countdown_display_epoch(), 0, "no deadlines, no epoch");
items.set_enchant_deadline(0x42, 1, 90);
let epoch = items.enchant_display_epoch();
let epoch = items.countdown_display_epoch();
assert!(
(90..=91).contains(&epoch),
"a 90 s deadline contributes floor(secs)+1, got {epoch}"
@@ -803,13 +865,71 @@ mod tests {
items.set_enchant_deadline(0x42, 1, 0);
assert_eq!(
items.enchant_display_epoch(),
items.countdown_display_epoch(),
0,
"cleared deadline, term gone"
);
assert_eq!(items.enchant_remaining_display_ms(0x42, 1), None);
}
/// The item-lifetime store (decision 1933) — the enchant timer's shape, one key narrower:
/// `seconds == 0` stores ABSENCE, the read is second-floored, the deadline joins the shared
/// display epoch, and destroying the item takes its timer with it.
#[test]
fn an_item_duration_parks_a_deadline_and_dies_with_the_item() {
let mut items = Items::default();
assert_eq!(
items.duration_remaining_ms(0x42),
None,
"no timer by default"
);
items.insert_object(0x42, ObjectFields::default());
items.set_item_duration(0x42, 1800);
let shown = items.duration_remaining_display_ms(0x42).expect("parked");
assert_eq!(shown % 1000, 0, "the display read is second-floored");
assert!(shown <= 1_800_000);
let epoch = items.countdown_display_epoch();
assert!(
(1800..=1801).contains(&epoch),
"the lifetime deadline joins the same per-second epoch as the enchant one, got {epoch}"
);
// `0` is the wire's "no timer", never a zero-second timer — the same store the enchant
// path takes for the same reason (an expired countdown is an absent line).
items.set_item_duration(0x42, 0);
assert_eq!(items.duration_remaining_ms(0x42), None);
assert_eq!(items.countdown_display_epoch(), 0);
// A destroyed item takes its timer with it: the guid can be reissued by the server, and a
// stale deadline would print a countdown on whatever lands there next.
items.insert_object(0x99, ObjectFields::default());
items.set_item_duration(0x99, 600);
items.remove_object(0x99);
assert_eq!(items.duration_remaining_ms(0x99), None);
// **An item we do not hold is dropped outright** — no queue, no retry, and nothing
// parked. `0x1EA`'s arm resolves the guid and returns on a miss; its enchant sibling
// `0x1EB` is the one that enqueues and replays, and copying that here would grow the map
// without bound for guids that never arrive.
items.set_item_duration(0xdead, 600);
assert_eq!(items.duration_remaining_ms(0xdead), None);
assert_eq!(items.countdown_display_epoch(), 0);
// **A non-POSITIVE duration clears** — the reference's test is SIGNED (`0x5d9c0c jle`),
// so a wire value with the top bit set zeroes the cell instead of parking a 68-year
// deadline.
items.insert_object(0x7, ObjectFields::default());
items.set_item_duration(0x7, 600);
assert!(items.duration_remaining_ms(0x7).is_some());
items.set_item_duration(0x7, 0x8000_0000);
assert_eq!(
items.duration_remaining_ms(0x7),
None,
"a negative-as-signed duration clears, it does not park a far future"
);
}
/// The push half of the tooltip store: a landed template is marked fresh exactly once (the
/// drain empties), and a cached negative never is (there's nothing to push).
#[test]
+1 -1
View File
@@ -711,7 +711,7 @@ pub fn run(build: BuildId) -> AppExit {
// in shape — a guid-carrying question over an already-closed gossip menu.
.add_plugins(UiTalentWipePlugin)
// The stance/shapeshift bar feed (wow-re shapeshift-bar-api.md): builds the form list from
// PlayerActions.spells per the byte-verified admission/order, drives StanceBar.xml through
// PlayerActions.spells per the byte-verified admission/order, drives the stock shapeshift bar through
// the engine's shapeshift seam, and drains its clicks (cancel-if-active else cast). After
// UiActionPlugin (shares `Spells`, the `usable` walk, and the cast tail).
.add_plugins(UiShapeshiftPlugin)
+24
View File
@@ -770,6 +770,15 @@ pub(crate) enum ClientCommand {
jump: Option<JumpInfo>,
transport: Option<TransportPose>,
},
/// Report that our mover's simulation advanced through `lag_ms` without integrating
/// (`CMSG_MOVE_TIME_SKIPPED`) — decision 1935. `guid` is the mover's own, the reference's
/// `0x600be0` gate being that it equals the active-mover globals. The server folds the number
/// into its copy of that mover's movement clock (`stime`/`ctime`), and — while it thinks we
/// have just boarded a transport — answers by re-sending the transport's create update.
MoveTimeSkipped {
guid: u64,
lag_ms: u32,
},
/// Claim `guid` as our mover (`CMSG_SET_ACTIVE_MOVER`). Login sends it for our own body; a
/// possession handoff re-sends it for the unit we were handed, because the server drops every
/// `MSG_MOVE_*` for a mover it has not confirmed.
@@ -881,6 +890,21 @@ pub(crate) enum ClientCommand {
bag_index: u8,
slot: u8,
},
/// Wrap the item at `(item_bag, item_slot)` in the paper at `(gift_bag, gift_slot)`
/// (`CMSG_WRAP_ITEM`, the same bag addressing) — the completion of the local wrap cursor a
/// `ITEM_FLAG_WRAPPER` item's right-click armed. The **paper leads**, as the wire does.
///
/// The client applies no eligibility filter of its own (decision 1934): an equipped item, a
/// bag, a soulbound or stackable or unique item, or an already-wrapped one all send and are
/// refused server-side with one of the six `ERR_CANT_WRAP_*` reasons, arriving here as an
/// `InventoryFailure` on the UI error line. Success is silent — field updates on the target
/// and one paper destroyed.
WrapItem {
gift_bag: u8,
gift_slot: u8,
item_bag: u8,
item_slot: u8,
},
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, same bag addressing) — the drain's fork for an
/// *equippable* click, mirroring the real client's equip-vs-use decision. Refusals come back
/// as `InventoryFailure` events onto the UI error line.
+29 -4
View File
@@ -106,11 +106,17 @@ pub(super) fn apply_net_updates(
mut reputations: ResMut<Reputations>,
mut transforms: Query<&mut Transform>,
mut stores: Query<&mut ObjectStore>,
// One tuple param (the 16-SystemParam ceiling this signature already lives against): the two
// One tuple param (the 16-SystemParam ceiling this signature already lives against): the three
// 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>),
// [`UnitMoveModes`] is any unit's server-granted movement modes (decision 1780);
// [`crate::transport::TransportRider`] is the deck a unit is standing on and its pose there
// (decision 1936). 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>,
Query<&mut crate::transport::TransportRider>,
),
// 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: (
@@ -651,6 +657,16 @@ pub(super) fn apply_net_updates(
&index,
&mut transforms,
),
// **An observed mover skipped time** (decision 1935). No pose moved — only that
// unit's clock ran on — so this touches its relay chain and nothing else, which is
// the whole of what the reference's handler does (`0x603b40` → `0x61ab90`:
// `[CMovement+0xac] += lag`). A guid we do not hold is dropped, faithfully: the
// reference resolves under `TYPEMASK_UNIT` and returns on a miss.
SessionEvent::MoveTimeSkipped { guid, lag_ms } => {
if let Some(mut m) = index.0.get(&guid).and_then(|&e| motion.0.get_mut(e).ok()) {
m.relay.skip_time(lag_ms);
}
}
SessionEvent::UnitMove {
guid,
position,
@@ -717,6 +733,7 @@ pub(super) fn apply_net_updates(
}
SessionEvent::MonsterMove {
guid,
transport,
start,
spline_id,
path,
@@ -727,6 +744,7 @@ pub(super) fn apply_net_updates(
run_mode,
} => objects::monster_move(
guid,
transport,
start,
spline_id,
path,
@@ -739,6 +757,7 @@ pub(super) fn apply_net_updates(
&mut commands,
&index,
&mut transforms,
&mut motion.2,
),
SessionEvent::Teleport {
guid,
@@ -1668,6 +1687,12 @@ pub(super) fn apply_net_updates(
item_guid,
spell_id,
} => item_cooldown(item_guid, spell_id, &items, &mut ui_actions.10),
// The item-lifetime countdown's ONLY feed (decision 1933): park the deadline on the
// item store, exactly as the enchant timer below does — vmangos's own writer says the
// `ITEM_FIELD_DURATION` field the client also holds is not what it displays from.
SessionEvent::ItemTime { item_guid, seconds } => {
items.set_item_duration(item_guid, seconds)
}
// The temporary-enchant countdown's ONLY feed (decision 0920): park the deadline on the
// item store, which every tooltip surface reads back through `enchant_remaining_ms`.
SessionEvent::ItemEnchantTime {
+205 -6
View File
@@ -488,11 +488,20 @@ pub(super) fn objects_removed(guids: Vec<u64>, commands: &mut Commands, index: &
}
}
/// A creature path packet (`SMSG_MONSTER_MOVE`): apply the dictated facing snap, then attach or
/// clear the travel spline.
/// A creature path packet (`SMSG_MONSTER_MOVE`, or its deck twin `SMSG_MONSTER_MOVE_TRANSPORT`):
/// settle the unit's transport membership, apply the dictated facing snap, then attach or clear
/// the travel spline.
///
/// **`transport` changes the frame of everything else in the packet** (decision 1936). When it is
/// `Some`, `start`, every `path` point and an `Angle` facing are offsets in that transport's frame,
/// the unit becomes (or stays) a [`TransportRider`] on it, and the spline is sampled into the
/// rider's local pose for `transport::compose_riders` to carry out to the world. When it is `None`
/// on a unit we had riding, the unit has *left* the deck — vmangos drops it from the transport on
/// exactly this edge (`MoveSplineInit::Launch`, `spline/MoveSplineInit.cpp:156-159`).
#[allow(clippy::too_many_arguments)]
pub(super) fn monster_move(
guid: u64,
transport: Option<u64>,
start: [f32; 3],
spline_id: u32,
path: Vec<[f32; 3]>,
@@ -505,6 +514,7 @@ pub(super) fn monster_move(
commands: &mut Commands,
index: &GuidIndex,
transforms: &mut Query<&mut Transform>,
riders: &mut Query<&mut crate::transport::TransportRider>,
) {
if let Some(&e) = index.0.get(&guid) {
// The DESYNC readout (decision 0708): how far this packet is about to teleport the unit — the
@@ -512,11 +522,78 @@ pub(super) fn monster_move(
// correctly-followed creature reads ~0; a frozen one reads the whole walk it slept through.
trace_move_snap(
guid,
transforms.get(e).ok().map(|t| bevy_to_wow(t.translation)),
// Both sides of the readout have to be in ONE frame. A deck packet's `start` is a
// transport-local offset, so it is compared against the rider's own local pose, not
// against the composed world position (which would read as the whole boat's travel).
match transport {
Some(_) => riders.get(e).ok().map(|r| r.local_pos),
None => transforms.get(e).ok().map(|t| bevy_to_wow(t.translation)),
},
start,
stop,
duration_ms,
);
// **Transport membership, settled before anything reads a pose.** A packet naming a
// transport attaches the unit to it (and re-seeds its deck-local pose from the packet's
// own `start`, so the stop form places a body too); a packet naming none detaches a unit
// we had riding. The facing that goes with it is deck-local for an `Angle` — vmangos runs
// `CalculatePassengerOffset(…, &args.facing.angle)` on it (`MoveSplineInit.cpp:88`) — but
// a `Spot`/`Target` facing is **not** converted server-side, so those resolve to a world
// bearing and are rebased here by the deck's own yaw.
//
// **Above the root gate deliberately.** The gate below refuses the *path*, which is what
// wow-re establishes; membership is not a path. A rooted body on a deck that was left
// unattached would be abandoned in the sea as the boat sails out from under it, which is
// a worse failure than carrying a pinned body along with the deck it is standing on.
let deck_yaw = |t: u64| {
index
.0
.get(&t)
.and_then(|&te| transforms.get(te).ok())
.map(|tf| tf.rotation.to_euler(EulerRot::YXZ).0)
};
if let Some(t) = transport {
let local_facing = match facing {
MonsterMoveFacing::None => None,
MonsterMoveFacing::Angle(a) => Some(a),
other => {
let target_pos = |g: u64| {
index
.0
.get(&g)
.and_then(|&te| transforms.get(te).ok())
.map(|tf| bevy_to_wow(tf.translation))
};
// The unit's own WORLD position anchors the bearing (the wire's `start` is
// deck-local and would put the unit near the map origin).
let world_pos = transforms.get(e).ok().map(|tf| bevy_to_wow(tf.translation));
world_pos
.and_then(|p| resolve_facing(other, p, target_pos))
.zip(deck_yaw(t))
.map(|(world, yaw)| world - yaw)
}
};
match riders.get_mut(e) {
Ok(mut rider) => {
rider.transport_guid = t;
rider.local_pos = start;
if let Some(o) = local_facing {
rider.local_orientation = o;
}
}
Err(_) => {
commands.entity(e).insert(crate::transport::TransportRider {
transport_guid: t,
local_pos: start,
local_orientation: local_facing.unwrap_or_default(),
});
}
}
} else if riders.get(e).is_ok() {
commands
.entity(e)
.remove::<crate::transport::TransportRider>();
}
// Apply the dictated final facing (moveType 2/3/4) as a **snap** — faithful to the
// client, which stores it straight into the unit's **raw** movement facing (`0x7c6f30`).
// This is the *packet*-driven re-face — a scripted/emote/aggro `SetFacingTo` the
@@ -529,7 +606,9 @@ pub(super) fn monster_move(
// frame (faithful — the client's spline-follow snaps the mesh yaw to the path tangent;
// wow-re body-facing §4). The receipt snap thus only sticks for a path-less move (a
// `Stop`/in-place re-face); a moving unit ends on its last tangent.
if !matches!(facing, MonsterMoveFacing::None) {
// The world-space facing snap — for a deck packet the rider's `local_orientation` above is
// the one that counts, and `compose_riders` writes the rotation from it.
if transport.is_none() && !matches!(facing, MonsterMoveFacing::None) {
let target_pos = |g: u64| {
index
.0
@@ -585,7 +664,15 @@ pub(super) fn monster_move(
// 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) {
match monster_move_spline(
path,
spline_id,
stop,
duration_ms,
flying,
run_mode,
transport,
) {
// A moving path: sample_splines drives the transform along every waypoint.
Some(spline) => {
commands.entity(e).insert(spline).remove::<SplineStopped>();
@@ -1074,9 +1161,11 @@ mod tests {
w.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
mut transforms: Query<&mut Transform>| {
mut transforms: Query<&mut Transform>,
mut riders: Query<&mut crate::transport::TransportRider>| {
monster_move(
MOB,
None,
[0.0, 0.0, 0.0],
7,
path.clone(),
@@ -1089,6 +1178,7 @@ mod tests {
&mut commands,
&index,
&mut transforms,
&mut riders,
);
},
)
@@ -1100,6 +1190,115 @@ mod tests {
);
}
}
/// **`SMSG_MONSTER_MOVE_TRANSPORT` attaches, and its plain twin detaches** (decision
/// 1936). A packet naming a transport makes the unit a [`TransportRider`] on it, seeds the
/// rider pose from the packet's own deck-local `start`, and hands the spline the deck so
/// the sampler writes the local pose instead of the world transform. A packet naming none
/// takes the unit off the deck, which is the edge vmangos itself drops the passenger on
/// (`MoveSplineInit::Launch`, `spline/MoveSplineInit.cpp:156-159`).
#[test]
fn a_transport_path_attaches_the_rider_and_a_plain_one_lets_it_go() {
const BOAT: u64 = 0x2000_0000_0000_0007;
let mut w = World::new();
let e = w
.spawn((
Transform::default(),
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
))
.id();
let boat = w.spawn(Transform::default()).id();
let mut index = GuidIndex::default();
index.0.insert(MOB, e);
index.0.insert(BOAT, boat);
w.insert_resource(index);
let deck_path = vec![[1.5, -2.5, 0.75], [4.0, -2.5, 0.75]];
w.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
mut transforms: Query<&mut Transform>,
mut riders: Query<&mut crate::transport::TransportRider>| {
monster_move(
MOB,
Some(BOAT),
[1.5, -2.5, 0.75],
7,
deck_path.clone(),
MonsterMoveFacing::Angle(1.25),
false,
3000,
false,
true,
false,
&mut commands,
&index,
&mut transforms,
&mut riders,
);
},
)
.unwrap();
let rider = w
.entity(e)
.get::<crate::transport::TransportRider>()
.expect("a transport path attaches its rider");
assert_eq!(rider.transport_guid, BOAT);
assert_eq!(rider.local_pos, [1.5, -2.5, 0.75], "seeded deck-local");
assert!(
(rider.local_orientation - 1.25).abs() < 1e-6,
"Angle is deck-local"
);
assert_eq!(
w.entity(e).get::<Spline>().expect("a path").deck,
Some(BOAT),
"the spline rides the deck frame, not the world"
);
// The rider's world transform is `compose_riders`' to write, not this apply's — the
// deck-local start must never have been mistaken for a world position.
assert_eq!(
w.entity(e).get::<Transform>().unwrap().translation,
Vec3::ZERO
);
let world_path = vec![[100.0, 200.0, 30.0], [110.0, 200.0, 30.0]];
w.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
mut transforms: Query<&mut Transform>,
mut riders: Query<&mut crate::transport::TransportRider>| {
monster_move(
MOB,
None,
[100.0, 200.0, 30.0],
8,
world_path.clone(),
MonsterMoveFacing::None,
false,
3000,
false,
true,
false,
&mut commands,
&index,
&mut transforms,
&mut riders,
);
},
)
.unwrap();
assert!(
w.entity(e)
.get::<crate::transport::TransportRider>()
.is_none(),
"a plain path takes the unit off the deck"
);
assert_eq!(w.entity(e).get::<Spline>().expect("a path").deck, None);
}
}
/// A dismounted human's set — 1.12.1's base run 7.0 (the `--speed` probe's own golden).
+9
View File
@@ -832,6 +832,9 @@ fn writer_loop(
orientation,
spline_id,
} => w.move_spline_done(flags, pos, orientation, spline_id),
ClientCommand::MoveTimeSkipped { guid, lag_ms } => {
w.move_time_skipped(guid, lag_ms)
}
ClientCommand::ForceSpeedAck {
kind,
guid,
@@ -919,6 +922,12 @@ fn writer_loop(
target,
} => w.use_item(bag_index, slot, spell_index, target),
ClientCommand::OpenItem { bag_index, slot } => w.open_item(bag_index, slot),
ClientCommand::WrapItem {
gift_bag,
gift_slot,
item_bag,
item_slot,
} => w.wrap_item(gift_bag, gift_slot, item_bag, item_slot),
ClientCommand::AutoEquipItem { bag_index, slot } => {
w.auto_equip_item(bag_index, slot)
}
@@ -156,6 +156,25 @@ impl RelayChain {
fire_ms
}
/// **The sender skipped time** (`MSG_MOVE_TIME_SKIPPED`, decision 1935): advance this chain's
/// copy of the mover's wire clock by `lag_ms`, without scheduling anything. The reference does
/// exactly this and nothing else — `0x603b40` resolves the unit and `0x61ab90` runs
/// `[CMovement+0xac] += lag`, which is this field.
///
/// It has to be applied even though no pose moved, because [`Self::schedule`] pays the whole
/// chain off `last_wire_ms`: leave it short and the mover's next real packet reads as a `step`
/// `lag` ms larger than it was, buys that much extra `wire_delta`, and fires `lag` late — a
/// hitch on one unit that looks like packet loss and isn't. Wrapping, for
/// [`Self::schedule`]'s reason: the stamp is a `u32` ms clock that wraps every 49.7 days.
///
/// A chain that has never seen a packet is left alone: there is no reference stamp to advance
/// yet, and the first real packet seeds both cells from itself.
pub(crate) fn skip_time(&mut self, lag_ms: u32) {
if self.seeded {
self.last_wire_ms = self.last_wire_ms.wrapping_add(lag_ms);
}
}
/// Record this packet's lateness and return the window's worst (`0x618b50`): store
/// `base + lateness` at the cursor, advance it mod 32, and return the max over all 32 slots (the
/// entry just written included — the reference seeds its scan with it and walks the whole ring).
+58 -1
View File
@@ -42,6 +42,17 @@ pub(crate) struct Spline {
/// gait is derived from [`Spline::speed`], the path's arc length over its duration, so its
/// walk bit would select nothing.
pub(crate) run_mode: bool,
/// **A deck path** (`SMSG_MONSTER_MOVE_TRANSPORT`): the transport guid whose frame every
/// point in `points` is expressed in. `None` — the overwhelmingly common case — is the plain
/// `SMSG_MONSTER_MOVE`, whose points are absolute world coordinates.
///
/// A deck path is sampled into the rider's [`crate::transport::TransportRider`] local pose
/// rather than straight into the [`Transform`]; `compose_riders` then carries it through the
/// transport's live matrix, exactly as it already does for a rider whose pose came off a
/// `MSG_MOVE_*` relay. It is also exempt from [`ground_clamp_creatures`]: the re-ground is a
/// world-space terrain probe, there is no terrain under a deck, and the wire Z is already the
/// deck-local height (decision 1936).
pub(crate) deck: Option<u64>,
}
/// **A spline the server ended by decree**, carrying the id the acknowledgement owes (decision
@@ -234,6 +245,7 @@ fn catmull_rom(pts: &[[f32; 3]], i: usize, u: f32) -> ([f32; 3], [f32; 3]) {
/// takes the Catmull-Rom family instead (the taxi/flight look — see [`Spline::sample`]'s INTERIM note).
/// Returns `None` — "stationary, clear any path" — for a `Stop`, a zero duration, or a path with fewer
/// than two points (nothing to travel along).
#[allow(clippy::too_many_arguments)] // one argument per decoded wire field
pub(in crate::net) fn monster_move_spline(
path: Vec<[f32; 3]>,
spline_id: u32,
@@ -241,6 +253,7 @@ pub(in crate::net) fn monster_move_spline(
duration_ms: u32,
flying: bool,
run_mode: bool,
deck: Option<u64>,
) -> Option<Spline> {
if stop || duration_ms == 0 || path.len() < 2 {
return None;
@@ -250,8 +263,13 @@ pub(in crate::net) fn monster_move_spline(
start: Instant::now(),
duration: Duration::from_millis(u64::from(duration_ms)),
id: spline_id,
// A deck path's Z is the deck-local height and nothing re-derives it, so `grounded` — the
// terrain-clamp opt-in — is meaningless there; `deck` is what the clamp actually skips on.
// The flag still rides along because the SAMPLER reads it too: `grounded` selects the
// linear follow, `!grounded` the Catmull-Rom flight family (decision 1936).
grounded: !flying,
run_mode,
deck,
})
}
@@ -286,6 +304,10 @@ pub(in crate::net) fn create_spline(spline: CreateSpline) -> Option<Spline> {
id: spline.id,
grounded: !spline.flying,
run_mode: spline.run_mode,
// The create block's spline tail carries no transport of its own — a unit created on a
// deck takes its `TransportRider` from the same block's movement tail, and its ride is in
// world coordinates like any other create spline.
deck: None,
})
}
@@ -391,6 +413,12 @@ pub(in crate::net) fn sample_splines(
// The rider's server identity, for the `spl` trace alone — `Option` because the sampler
// must never gate on it (a spline entity with no guid still has to be flown).
Option<&super::super::Guid>,
// A **deck path**'s destination: the sample is the rider's pose in the transport's frame,
// and `transport::compose_riders` — which runs after this stage — carries it out to the
// world. `Option` because only a `SMSG_MONSTER_MOVE_TRANSPORT` rider has one; a deck
// spline whose component is missing falls back to the world write below rather than
// freezing (decision 1936).
Option<&mut crate::transport::TransportRider>,
)>,
mut trace_next: Local<f32>,
time: Res<Time>,
@@ -404,8 +432,30 @@ pub(in crate::net) fn sample_splines(
if tick {
*trace_next = time.elapsed_secs() + SPL_TRACE_SECS;
}
for (entity, spline, mut t, swimming, guid) in &mut q {
for (entity, spline, mut t, swimming, guid, rider) in &mut q {
let (wow_pos, facing, pitch) = spline.sample(now);
// **The deck fork.** A transport path's points are offsets in the named transport's frame,
// so the sample IS the rider's local pose; writing it to the `Transform` would place the
// unit a few yards from the world origin. `compose_riders` composes it through the deck's
// live matrix later this frame — the same route an observed rider's relayed pose takes.
if let Some(deck) = spline.deck {
// A deck sample lands on the rider or NOWHERE. Falling through to the world write
// below would put the body a few yards from the map origin, because that is what a
// transport-local offset reads as in world coordinates — so a rider component that is
// missing (the insert is deferred by one sync point) or that names a different
// transport freezes the unit for that frame instead. Freezing is recoverable; a body
// teleported to the middle of the map is what a bug report looks like.
if let Some(mut rider) = rider.filter(|r| r.transport_guid == deck) {
rider.local_pos = wow_pos;
if let Some(f) = facing {
rider.local_orientation = f;
}
}
if now.saturating_duration_since(spline.start) >= spline.duration {
commands.entity(entity).remove::<Spline>();
}
continue;
}
// What the transform held *coming in* — i.e. whatever the previous frame left there after
// every other writer had its turn. Traced beside the fresh sample as `was=`: a rider whose
// `was` is not last frame's sample is being stomped by another system, which no amount of
@@ -687,6 +737,10 @@ pub(in crate::net) fn ground_clamp_creatures(
skipped += 1;
continue; // a flying path is authoritative on Z
}
if spline.is_some_and(|s| s.deck.is_some()) {
skipped += 1;
continue; // a deck path: no terrain under a boat, and the wire Z is deck-local (1936)
}
if swimming {
skipped += 1;
continue; // in-liquid: the wire Z is the creature's swim depth
@@ -1330,6 +1384,7 @@ mod tests {
/// A spline `frac` of the way through its ride: 10 s duration, started `frac·10 s` ago.
fn spline_at(points: Vec<[f32; 3]>, grounded: bool, frac: f32) -> (Spline, Instant) {
let spline = Spline {
deck: None,
points,
start: Instant::now() - Duration::from_secs_f32(10.0 * frac),
duration: Duration::from_secs(10),
@@ -1697,6 +1752,7 @@ mod server_moved_players {
/// sends for a bot and for a charged/knocked-back/feared player alike.
fn walking(app: &mut App, e: Entity) {
app.world_mut().entity_mut(e).insert(Spline {
deck: None,
points: vec![RIM_A, RIM_B],
start: Instant::now(),
duration: Duration::from_secs(4),
@@ -1781,6 +1837,7 @@ mod server_moved_players {
fn a_flying_path_is_left_alone() {
let (mut app, body) = hollow(EntityKind::Player);
app.world_mut().entity_mut(body).insert(Spline {
deck: None,
points: vec![RIM_A, RIM_B],
start: Instant::now(),
duration: Duration::from_secs(4),
+57 -4
View File
@@ -426,6 +426,7 @@ fn spline_interpolates_constant_speed_and_faces_travel() {
// Two legs: 10 yd east (+X), then 10 yd north-ish (+Y), over 4s total (constant speed → 2s/leg).
let start = Instant::now();
let s = Spline {
deck: None,
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0], [10.0, 10.0, 0.0]],
start,
duration: Duration::from_secs(4),
@@ -466,6 +467,7 @@ fn spline_travel_pitch_is_the_segment_climb_angle() {
// observed-mover pitch rule `asin(dir.z)` the swimming-creature body pitch renders.
let start = Instant::now();
let s = Spline {
deck: None,
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 10.0]],
start,
duration: Duration::from_secs(4),
@@ -491,7 +493,7 @@ fn monster_move_carries_every_waypoint() {
[10.0, 10.0, 0.0],
[10.0, 10.0, 5.0],
];
let s = monster_move_spline(path.clone(), 42, false, 2000, false, true)
let s = monster_move_spline(path.clone(), 42, false, 2000, false, true, None)
.expect("a moving monster-move yields a spline");
assert_eq!(
s.points, path,
@@ -508,6 +510,54 @@ fn monster_move_carries_every_waypoint() {
);
}
/// **`MSG_MOVE_TIME_SKIPPED` advances the chain's wire clock and nothing else** (decision 1935).
/// The whole of the reference's handler is `[CMovement+0xac] += lag` (`0x603b40` → `0x61ab90`),
/// and `+0xac` is this chain's `last_wire_ms`. The property that matters is downstream: after the
/// skip, the mover's next packet — whose stamp is `lag` further on than it would otherwise have
/// been — must schedule as though nothing unusual happened. Drop the skip and that same packet
/// buys `lag` ms of extra `wire_delta` and fires that much late.
#[test]
fn a_reported_skip_keeps_the_relay_chain_level_with_the_sender() {
let step = |chain: &mut RelayChain, wire_ms: u32, now_ms: f64| {
chain.schedule(wire_ms, now_ms, 0, true)
};
// Two chains fed identically, except that one is told about the sender's 300 ms skip.
let (mut told, mut untold) = (RelayChain::default(), RelayChain::default());
step(&mut told, 10_000, 0.0);
step(&mut untold, 10_000, 0.0);
told.skip_time(300);
// The sender's next packet: 100 ms of real play later, but its stamp has ALSO carried the
// 300 ms it skipped — so the wire step is 400 while only 100 ms of our clock passed.
let a = step(&mut told, 10_400, 100.0);
let b = step(&mut untold, 10_400, 100.0);
assert!(
a < b,
"the informed chain schedules earlier: it already knew 300 of those 400 ms were skipped, \
not travelled (informed {a}, uninformed {b})"
);
assert!(
(b - a - 300.0).abs() < 1e-6,
"and the difference is exactly the skip it was told about: {} vs 300",
b - a
);
}
/// A skip for a mover whose chain has never seen a packet is inert: there is no reference stamp
/// to advance, and the first real packet seeds both cells off itself.
#[test]
fn a_skip_before_the_first_packet_changes_nothing() {
let mut seeded = RelayChain::default();
let mut cold = RelayChain::default();
cold.skip_time(5_000);
assert_eq!(
seeded.schedule(10_000, 0.0, 0, true),
cold.schedule(10_000, 0.0, 0, true),
"an unseeded chain has no clock to move"
);
}
#[test]
fn monster_move_flying_spline_is_not_grounded() {
// A FLYING path keeps the server's Z — the ground-clamp must leave it alone.
@@ -518,6 +568,7 @@ fn monster_move_flying_spline_is_not_grounded() {
2000,
true,
true,
None,
)
.expect("a flying monster-move still yields a spline");
assert!(
@@ -535,7 +586,8 @@ fn monster_move_stop_clears_the_spline() {
true,
2000,
false,
true
true,
None
)
.is_none(),
"a Stop move snaps and clears, never builds a path"
@@ -551,7 +603,8 @@ fn monster_move_zero_duration_clears_the_spline() {
false,
0,
false,
true
true,
None
)
.is_none(),
"a zero-duration move would divide by ~0 when sampled; treat as stationary"
@@ -561,7 +614,7 @@ fn monster_move_zero_duration_clears_the_spline() {
#[test]
fn monster_move_without_a_travelable_path_clears_the_spline() {
assert!(
monster_move_spline(vec![[1.0, 2.0, 3.0]], 0, false, 2000, false, true).is_none(),
monster_move_spline(vec![[1.0, 2.0, 3.0]], 0, false, 2000, false, true, None).is_none(),
"a single point is nowhere to travel — no spline"
);
}
@@ -1063,6 +1063,15 @@ pub(super) fn control(
orientation: (player.face_yaw - r.boat_yaw).rem_euclid(std::f32::consts::TAU),
}
});
// The skipped-time clock (decision 1935): a held frame is a frame of movement simulation
// we advanced through without integrating, and the mover we name is the one we are
// actually driving — a possessed unit while we hold its reins. Read out before the call
// takes `player` mutably.
let skip = movement_net::SkipClock {
dt,
held: player.settling,
mover: player.foreign_mover.or(self_guid),
};
movement_net::stream_self_movement(
&net.0 .0,
&mut player,
@@ -1090,6 +1099,7 @@ pub(super) fn control(
// byte is 0 → `0x615c80 je 0x616539`: no apply, and no ack).
knockback.filter(|_| knocked),
wire_transport,
skip,
);
} else {
// Free fly (pre-connect or detached): aim from the look angles, move the camera directly
@@ -216,6 +216,20 @@ pub(super) fn swim(feet_y: f32, surface_y: f32, swimming: bool, h: f32) {
);
}
/// One `sett` line per `CMSG_MOVE_TIME_SKIPPED` we send (decision 1935) — how many milliseconds
/// of movement simulation the hold ran through without integrating, and for which mover. It rides
/// the settle's own tag because it reports the settle's own cost: a `skipped` far larger than the
/// `sett` line beside it means the hold outlived the stream it was waiting for.
pub(super) fn skipped_time(guid: u64, lag_ms: u32) {
if !trace::enabled() {
return;
}
trace::line(
"sett",
&format!("skipped {lag_ms:5} ms for mover {guid:#x}"),
);
}
/// How the post-teleport settle hold **ended** — the whole diagnosis of a fall-through report.
///
/// `resident` means the destination's world arrived (scene spawned + collider queue quiet —
+165 -1
View File
@@ -89,6 +89,19 @@ const IN_MOTION: u32 = move_flags::ANY_MOVE
| move_flags::SWIMMING
| move_flags::ON_TRANSPORT;
/// What this frame's **skipped-time report** ([`stream_skipped_time`]) needs: how long the frame
/// was, whether the mover integrated it, and which mover to name.
pub(super) struct SkipClock {
/// This frame's length in seconds — the budget the mover either integrated or did not.
pub(super) dt: f32,
/// **The mover held instead of stepping this frame** ([`super::Player::settling`]): the world
/// under us has not streamed in, so nothing integrated `dt`.
pub(super) held: bool,
/// The mover's own guid — a possessed unit's while we hold its reins, else our own body's.
/// `None` before the server has named one, which is also before anything could be skipped.
pub(super) mover: Option<u64>,
}
/// This frame's **arc edges** — the airborne lifecycle as the send law reads it. One struct rather
/// than four adjacent bools in the argument list, where a miscount is silent and the symptom is a
/// wrong opcode on the wire.
@@ -145,6 +158,7 @@ pub(super) fn stream_self_movement(
speed_acks: &[crate::net::SpeedChangeMessage],
knock_ack: Option<super::state::PendingKnockback>,
transport: Option<TransportPose>,
skip: SkipClock,
) {
let ArcEdges {
jumped,
@@ -153,6 +167,7 @@ pub(super) fn stream_self_movement(
landed,
fall_time,
} = arc;
stream_skipped_time(sender, player, skip);
let wow_pos = bevy_to_wow(player.pos);
// Normalize the facing into [0, 2π) before it goes on the wire. `face_yaw` is an unbounded
// accumulator (mouse-look and A/D turning keep growing it), but the real client always sends a
@@ -478,6 +493,65 @@ pub(super) fn stream_self_movement(
/// Park our mover on the wire: flush a single `MSG_MOVE_STOP` (flags cleared) so the server — and the
/// observers extrapolating from it — drop whatever locomotion flags we last reported, then zero our
/// bookkeeping. Called when the controller stops driving the avatar with stale flags still live on the
/// **`CMSG_MOVE_TIME_SKIPPED` — the milliseconds the mover advanced through without integrating**
/// (decision 1935). Accumulate while the mover holds; report the total once, on the release edge.
///
/// **The law is "time the movement simulation advanced through without integrating"** — not "a
/// long frame", which is what this packet is usually described as. wow-re's carve
/// (`collision/scratch/move-time-skipped-law.md`, §5-verified, landed 2026-09-03) found **four**
/// emission sites feeding one builder `0x600be0`, and the long-frame one (`0x616642 cmp esi,0xfa`
/// → send `dt − 250`) is only one of them and not the common one. The two that dominate real
/// traffic are the **no-geometry** pair: the resolve entry's swept query returning nothing
/// (`0x634154`) and the fall integrator's TOI kernel finding no contact (`0x635742`), each
/// reporting the substep budget they could not spend. Across 79 sniffs of the reference,
/// 1277 of 1278 packets carry `lag ≤ 250` — the clamped substep budget those two draw from —
/// and an exact-build 36-minute session sent **three**, at world entry and after a teleport.
///
/// **benilla's no-geometry state is the settle hold**, and that is what this reports: the frames
/// after a login, teleport or worldport where the destination's terrain and building colliders
/// have not streamed in, so the mover freezes rather than falling through a city that isn't there
/// (decision 0737). Same cause, same quantity, same moments the reference's own captures show it.
///
/// **The coalescing is ours, deliberately.** The reference sends once per firing, which for it is
/// once per skipped substep — and its no-geometry window is a substep or two wide, because it
/// loads the world before it places you. Ours is a live stream that can hold for seconds, so
/// per-frame parity would put hundreds of packets on the wire where the reference puts one. The
/// *quantity* is what the wire means and the quantity is preserved whole; the packet count is
/// kept at the reference's. What is NOT copied is the send's `[mgr+0x130] += lag` side effect
/// (the 500 ms heartbeat deadline pushed out by the skipped time): that deadline is only ever read
/// while moving, and a held body streams zeroed flags with its mover parked, so there is no
/// heartbeat inside a settle for it to delay. Applying a multi-second delay to the first heartbeat
/// *after* one would be our invention, not the reference's.
///
/// The other two sites are deliberately unbuilt. The long-frame one has no meaning here: benilla
/// has no 250 ms substep clamp — it integrates the whole frame — so a long frame is time we
/// *spent*, not time we skipped, and reporting it would be a lie about our own simulation. The
/// `[CMovement+0x40] & 0x8000000` free-advance bit's own setter is not modelled at all. wow-re's
/// own verdict on the build order says as much: a client implementing cases 1–3 matches every
/// packet in the shipped captures.
fn stream_skipped_time(sender: &Sender<ClientCommand>, player: &mut Player, skip: SkipClock) {
if skip.held {
player.skipped_ms += skip.dt * 1000.0;
return;
}
// The release edge: report the whole hold at once, then disarm. Sub-millisecond residue is
// dropped rather than carried — the wire field is whole milliseconds and a `0` lag is a
// packet that says nothing.
let lag_ms = player.skipped_ms as u32;
player.skipped_ms = 0.0;
if lag_ms == 0 {
return;
}
let Some(guid) = skip.mover else {
return;
};
// The reference's `0x600be0` refuses unless the named mover IS the active mover
// (`0x600bec`/`0x600bfb` against `[0xc4da98]`/`[0xc4da9c]`); ours is the same statement, made
// by naming the mover the controller is driving and nothing else.
super::move_trace::skipped_time(guid, lag_ms);
let _ = sender.send(ClientCommand::MoveTimeSkipped { guid, lag_ms });
}
/// wire — entering free-fly (`F`), where [`stream_self_movement`] no longer runs each frame, so nothing
/// else would ever clear them and observers would extrapolate a phantom walk/spin until we re-attach.
/// **Idempotent**: a no-op once we've already reported stopped, so it's safe to call every free-fly
@@ -509,10 +583,68 @@ pub(super) fn park_mover(sender: &Sender<ClientCommand>, player: &mut Player) {
#[cfg(test)]
mod tests {
/// A frame that integrated everything it was given — the skipped-time report's no-op input,
/// which is every frame in these tests but the one that exercises it.
fn no_skip() -> SkipClock {
SkipClock {
dt: 0.0,
held: false,
mover: None,
}
}
use super::*;
use std::f32::consts::TAU;
/// **The gait toggle announces itself standing perfectly still** — which is the whole reason
/// **The skipped-time report** (decision 1935): held frames accumulate, the release edge sends
/// the total ONCE, and nothing is sent for a frame that actually integrated. The three
/// properties together are the whole contract — a per-frame send would be hundreds of packets
/// per settle, a lost accumulator would tell the server nothing, and a send with no hold
/// behind it would be a lie about our own simulation.
#[test]
fn a_settle_hold_reports_its_skipped_milliseconds_once_on_release() {
const MOVER: u64 = 0x0000_0000_0000_002a;
let (tx, rx) = crossbeam_channel::unbounded();
let mut player = Player::default();
let held = |dt: f32| SkipClock {
dt,
held: true,
mover: Some(MOVER),
};
let released = || SkipClock {
dt: 0.016,
held: false,
mover: Some(MOVER),
};
// Three held frames: 0.5 s of simulation advanced through, nothing integrated, and
// NOTHING on the wire yet — the report is one packet, not one per frame.
for _ in 0..3 {
stream_skipped_time(&tx, &mut player, held(0.1));
}
stream_skipped_time(&tx, &mut player, held(0.2));
assert!(
rx.try_recv().is_err(),
"a hold in progress sends nothing — the reference's own captures show one packet per \
skip, not one per frame"
);
// The release: one packet, carrying the whole hold.
stream_skipped_time(&tx, &mut player, released());
match rx.try_recv().expect("the release edge reports") {
ClientCommand::MoveTimeSkipped { guid, lag_ms } => {
assert_eq!(guid, MOVER, "the packet names the mover we were driving");
assert_eq!(lag_ms, 500, "0.1+0.1+0.1+0.2 s of un-integrated simulation");
}
other => panic!("expected MoveTimeSkipped, got {other:?}"),
}
// …and the accumulator disarms: a second released frame reports nothing.
stream_skipped_time(&tx, &mut player, released());
assert!(
rx.try_recv().is_err(),
"the report is an edge; a running mover skips nothing and says nothing"
);
}
/// the reference gives `ToggleRun` its own move-event enqueue instead of letting the
/// move-state broadcaster find the flag change: that broadcaster gates every send on the
/// locomotion nibble (`0x61a99d test al,0xf`), so a toggle with no direction bit set would
@@ -541,6 +673,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move { kind, flags, .. } =
rx.try_recv().expect("a standing walk toggle still sends")
@@ -552,7 +685,18 @@ mod tests {
assert_eq!(flags & move_flags::WALK_MODE, move_flags::WALK_MODE);
// …and leaving it is the OTHER opcode, not a repeat of the same one.
stream_self_movement(&tx, &mut player, 0, 0.0, arc(), 0.0, &[], None, None);
stream_self_movement(
&tx,
&mut player,
0,
0.0,
arc(),
0.0,
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move { kind, flags, .. } =
rx.try_recv().expect("the run half sends too")
else {
@@ -573,6 +717,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
while let Ok(ClientCommand::Move { kind, .. }) = rx.try_recv() {
assert_eq!(kind, MoveKind::SetWalkMode, "the re-entry edge only");
@@ -587,6 +732,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
assert!(
rx.try_recv().is_err(),
@@ -620,6 +766,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let kinds: Vec<_> = rx
.try_iter()
@@ -661,6 +808,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move { orientation, .. } = rx
@@ -706,6 +854,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move {
@@ -749,6 +898,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
assert!(rx.try_recv().is_err(), "a mid-air release sends nothing");
assert_eq!(
@@ -773,6 +923,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move { kind, flags, .. } = rx.try_recv().expect("the landing packet")
else {
@@ -817,6 +968,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
};
@@ -882,6 +1034,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move {
kind, flags, jump, ..
@@ -922,6 +1075,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
assert!(
rx.try_recv().is_err(),
@@ -956,6 +1110,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move { kind, flags, .. } =
rx.try_recv().expect("a mid-air turn broadcasts")
@@ -985,6 +1140,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let kinds: Vec<_> = rx
.try_iter()
@@ -1029,6 +1185,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move { kind, flags, .. } = rx
.try_recv()
@@ -1064,6 +1221,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
assert!(rx.try_recv().is_err(), "an unchanged facing is silent");
}
@@ -1097,6 +1255,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
assert!(
rx.try_recv().is_err(),
@@ -1121,6 +1280,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move { kind, .. } = rx.try_recv().expect("the STOP_TURN") else {
panic!("expected a Move command");
@@ -1159,6 +1319,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let kinds: Vec<_> = rx
.try_iter()
@@ -1189,6 +1350,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
}
@@ -1327,6 +1489,7 @@ mod tests {
launch,
}),
None,
no_skip(),
);
let ClientCommand::KnockBackAck {
@@ -1388,6 +1551,7 @@ mod tests {
&[],
None,
None,
no_skip(),
);
let ClientCommand::Move { kind, jump, .. } =
rx.try_recv().expect("a jump take-off is announced")
@@ -193,6 +193,14 @@ pub(super) fn drive_self_ride(
let y = ride_z(
&player,
&points,
// **A deck path re-derives its Z exactly like any other** (decision 1936's
// correction). The tempting exemption — "there is no terrain under a boat" — is
// refuted: the transport guid appears in neither `0x616cb0`'s predicate nor
// `0x634040`'s dispatch, so a grounded deck spline takes the same fork, and the
// probe does not miss because it runs at the **composed world position** against
// a class mask that admits GameObject meshes. This system is in `WorldStage::Input`
// and `transport::compose_riders` in the stage before it, so the translation read
// here is already composed — nothing else is needed for the deck case.
spline.grounded,
transform.translation,
floor,
@@ -352,6 +360,7 @@ mod tests {
.spawn((
Transform::default(),
Spline {
deck: None,
points: vec![[0.0, 0.0, 0.0], [10.0, 0.0, 0.0]],
start: Instant::now(),
duration: Duration::from_secs(600), // far from ending during the test
+6
View File
@@ -853,6 +853,12 @@ pub(crate) struct Player {
pub(crate) owes_worldport_ack: bool,
/// `Time::elapsed_secs` when we last sent a heartbeat.
pub(super) last_heartbeat: f32,
/// **Milliseconds of movement simulation this client advanced through without integrating** —
/// the quantity `CMSG_MOVE_TIME_SKIPPED` reports (decision 1935). Accumulated while
/// [`Self::settling`] holds the mover (the world under us has not streamed in, so no step
/// runs), drained and sent by [`super::movement_net::stream_self_movement`] on the release
/// edge. Fractional because it accumulates a frame `dt` at a time; the wire takes whole ms.
pub(super) skipped_ms: f32,
/// `Time::elapsed_secs` when the current airborne phase (jump or step-off) began, else `None` on the
/// ground. Drives the wire `fall_time` (ms airborne) and detects the take-off / landing transitions
/// that emit `MSG_MOVE_JUMP` / `MSG_MOVE_FALL_LAND` (decision 0053).
+73
View File
@@ -94,6 +94,7 @@ impl Plugin for TransportPlugin {
tick_transports,
republish_moved_collider_aabbs::<Collider>,
compose_riders,
ground_deck_riders,
)
.chain()
.after(benilla_world::schedule::WorldStage::Net)
@@ -538,6 +539,78 @@ fn compose_riders(
}
}
/// **Re-ground a deck-splined rider onto the deck it is walking on** (decision 1936's correction).
///
/// The tempting reading — "a transport spline's Z is authoritative, because there is no terrain
/// under a boat" — is what benilla shipped for a few hours and what wow-re's carve refuted. The
/// transport guid appears in **neither** `0x616cb0`'s predicate **nor** `0x634040`'s dispatch: a
/// grounded `SMSG_MONSTER_MOVE_TRANSPORT` spline takes the *same* fork as a plain one,
/// `0x616d03` zeroes its Z-delta and the WALK resolver `0x6367b0` re-derives Z off the world trace
/// (`0x636e52`: `pos.z -= hitDistance`). The reason that does not drop the unit into the sea is
/// that the query runs at the **composed world position**, and the class mask `0x6315f0` yields
/// `0x100111` whose `0x100000` bit unconditionally admits GameObject meshes — a boat's `.wmo`
/// being in the global map-object list precisely because it is a type-11/15 GameObject.
///
/// **So the deck's grounding is the ordinary grounding, moved one step later.** It cannot live in
/// [`crate::net::ground_clamp_creatures`] with every other unit's, because that system runs inside
/// `WorldStage::Net` — *before* [`compose_riders`] — where a deck rider's `Transform` is still
/// last frame's composed pose and its fresh position exists only as a transport-local offset. Here
/// the deck is at its this-frame pose, its collider AABB has just been republished
/// ([`republish_moved_collider_aabbs`], decision 1663 — without which the probe would be pruned
/// against the deck's previous-frame box) and the rider's world position is on the transform. One
/// probe, at the reference's own moment.
///
/// The corrected Y is written to the `Transform` and not back into
/// [`TransportRider::local_pos`], deliberately: [`compose_riders`] recomputes the world pose from
/// the local one every frame, so a write-back would be re-derived away next frame anyway, and the
/// correction is idempotent as it stands. [`crate::net::RemoteMotion`]'s canonical pose moves with
/// it, as it does in `compose_riders`.
///
/// **A named gap.** The reference has a *third* regime this does not model: `0x616af0`'s anti-warp
/// guard snaps `pos := base + sampledOffset` verbatim — wire Z included — and bypasses the
/// re-ground entirely whenever the horizontal step exceeds 3 yd (`[0x80c5bc]` = 9.0, squared) or
/// 60 yd/s. For a creature freshly placed on a deck that is the common *first* substep, so our
/// first frame re-grounds where the reference would take the wire Z. It settles to the same place
/// on the next frame either way.
#[allow(clippy::type_complexity)] // one Bevy system's full input set
fn ground_deck_riders(
world: benilla_world::collision::WorldCollision,
mut riders: Query<
(
&mut Transform,
Option<&mut crate::net::RemoteMotion>,
&crate::net::Spline,
),
(With<TransportRider>, Without<Transport>),
>,
) {
/// The probe's reach above and below the rider's seat — the creature clamp's own numbers
/// (`GROUND_CLAMP_UP`/`_DOWN`), for the same reason: enough to clear a slightly-high server Z
/// and to follow a step down, not enough to grab a deck one storey up.
const UP: f32 = 2.5;
const DOWN: f32 = 4.0;
for (mut transform, motion, spline) in &mut riders {
// Only a GROUNDED deck path. A flying one owns its altitude (the `0x200` bit is the same
// one that decides the point encoding and the resolver arm), and a world-frame spline is
// `ground_clamp_creatures`' business, not ours.
if spline.deck.is_none() || !spline.grounded {
continue;
}
let origin = transform.translation + Vec3::Y * UP;
let Some(hit) = world.ray_body(origin, Dir3::NEG_Y, UP + DOWN) else {
continue; // nothing under the rider — leave the composed pose alone
};
let y = origin.y - hit.distance;
if y == transform.translation.y {
continue;
}
transform.translation.y = y;
if let Some(mut rm) = motion {
rm.wow_pos = benilla_assets::coords::bevy_to_wow(transform.translation);
}
}
}
/// Publish the **ride frame** onto every model standing on a transport — the reference's
/// `SetMoveBase` install (`0x617170`/`0x618970` → `0x718910` → `[CM2Model+0x17c]`), which is the
/// frame a rider's world-space effects are *stored* in ([`benilla_world::ride_frame`]).
+36 -4
View File
@@ -22,12 +22,14 @@
//! NO_POWER).
//!
//! **The argument arms, and what is still approximate.** Filled: `0x5e` REQUIRES_SPELL_FOCUS and
//! `0x5d` REQUIRES_AREA here (decision 1313 — `0x6e1f62`/`0x6e1fad`), and `0x78` TOTEMS / `0x5c`
//! `0x5d` REQUIRES_AREA here (decision 1313 — `0x6e1f62`/`0x6e1fad`), plus `0x8d`
//! PREVENTED_BY_MECHANIC (decision 1948 — `0x6e2190`, and the one arm whose word is produced
//! locally rather than read off the wire), and `0x78` TOTEMS / `0x5c`
//! REAGENTS / `0x19`–`0x1b` EQUIPPED_ITEM_CLASS\* in the drain, which owns them because their
//! fills need the item caches and the query-then-redisplay cache-miss behavior ("Requires Mining
//! Pick", decisions 0545 + 0552). Still stripped rather than filled — each needs a DBC we do not
//! load yet: `0x56` ONLY_SHAPESHIFT (form-name list), `0x8d` PREVENTED_BY_MECHANIC
//! (`SpellMechanic.dbc`), `0x90` MIN_SKILL (`SkillLine.dbc`), `0x31` NEED_EXOTIC_AMMO and `0x84`
//! load yet: `0x56` ONLY_SHAPESHIFT (form-name list), `0x90` MIN_SKILL (`SkillLine.dbc`),
//! `0x31` NEED_EXOTIC_AMMO and `0x84`
//! PROSPECT_NEED_MORE. Stripping is a **deliberate divergence**, now byte-confirmed as one: on a
//! bad id or an absent word the reference jumps to the default arm with the pointer still on the
//! *unfilled* template, so it displays a literal `Requires %s` (wow-re §WIRE-ARGS C3 — the fill
@@ -36,7 +38,7 @@
//! `0x0a`'s item-spell leg (`ERR_INVALID_ITEM_TARGET`) is unmodeled — the drain does not know
//! item-ness.
use benilla_formats::{AreaTableCatalog, SpellDisplay, SpellFocusCatalog};
use benilla_formats::{AreaTableCatalog, SpellDisplay, SpellFocusCatalog, SpellMechanicCatalog};
/// Wire reason → its `SPELL_FAILED_*` GlobalStrings key (the `0x6e23e0` table, byte-exact).
pub(super) const CAST_FAIL_KEYS: [&str; 146] = [
@@ -220,6 +222,8 @@ pub(super) struct FailArgs<'a> {
pub(super) focus: Option<&'a SpellFocusCatalog>,
/// `AreaTable.dbc` (`0xc0e048`) — the `0x5d` arm's `AreaName`.
pub(super) areas: Option<&'a AreaTableCatalog>,
/// `SpellMechanic.dbc` (`0xc0d7c4`) — the `0x8d` arm's mechanic name (decision 1948).
pub(super) mechanics: Option<&'a SpellMechanicCatalog>,
}
impl FailArgs<'_> {
@@ -247,6 +251,13 @@ impl FailArgs<'_> {
/// in `Spell.dbc` holds it, so an absent word means an unfilled message.
fn fill(&self, reason: u8, spell: Option<&SpellDisplay>) -> Option<String> {
match reason {
// **`0x8d` PREVENTED_BY_MECHANIC** (`0x6e2190`) → `SpellMechanic.dbc` (`0xc0d7c4`),
// so "Can't do that while %s" reads "Can't do that while stunned". Unlike its two
// neighbours the word is not the wire's: this refusal is raised locally and the id
// comes from the crowd-control ladder's own exemption scan (decisions 1941/1948).
// A `0` or unknown id leaves the template to the strip fallback, which is also what
// the arm does when the scan named no mechanic at all.
0x8D => self.mechanics?.name(self.arg?).map(str::to_string),
0x5D => self.areas?.name(self.arg?).map(str::to_string),
0x5E => {
let id = self
@@ -634,11 +645,32 @@ mod tests {
let focus =
benilla_formats::load_spell_focus_catalog(&mut chain).expect("SpellFocusObject");
let areas = benilla_formats::load_area_table_catalog(&mut chain).expect("AreaTable");
let mechanics =
benilla_formats::load_spell_mechanic_catalog(&mut chain).expect("SpellMechanic");
let args = |arg: u32| FailArgs {
arg: Some(arg),
focus: Some(&focus),
areas: Some(&areas),
mechanics: Some(&mechanics),
};
// **0x8d PREVENTED_BY_MECHANIC** (decision 1948) — the crowd-control ladder's renamed
// refusal, and the one argument arm whose word is produced locally rather than read off
// the wire. The names are lower-case adjectives in the shipped data, which is what makes
// them read as the tail of the sentence.
assert_eq!(
cast_fail_text(0x8D, None, args(12), &g).unwrap().text,
"Can't do that while stunned"
);
assert_eq!(
cast_fail_text(0x8D, None, args(5), &g).unwrap().text,
"Can't do that while fleeing"
);
// An unknown or absent mechanic strips to the bare stem rather than showing a raw `%s`.
assert!(!cast_fail_text(0x8D, None, args(999), &g)
.unwrap()
.text
.contains('%'));
// 0x5e REQUIRES_SPELL_FOCUS: the Crown of the Earth phials' own refusal. Focus 12 is the
// Starbreeze Village moonwell — using the Jade Phial at any *other* pool is the report.
assert_eq!(
@@ -523,8 +523,15 @@ fn send_spell_cast(
.unwrap_or_default()
},
) {
debug!("ui_action: cast {spell_id} refused locally — crowd control ({reason:#x})");
cast_errors.push_local(spell_id, reason);
let (reason, mechanic) = reason;
debug!(
"ui_action: cast {spell_id} refused locally — crowd control ({reason:#x}, \
mechanic {mechanic:?})"
);
match mechanic {
Some(m) => cast_errors.push_local_arg(spell_id, reason, m),
None => cast_errors.push_local(spell_id, reason),
}
return;
}
// The client-side mounted gate (decision 0481; wow-re `mounted-action-gate.md` §5:
+19 -3
View File
@@ -42,10 +42,16 @@ pub(crate) struct CastErrors(pub Vec<CastFail>);
/// word that fills the message's `%s` (the arm table at `0x6e1d8e` — a `SpellFocusObject.dbc` id
/// for 0x5e, an `AreaTable.dbc` id for 0x5d).
///
/// `arg` is `None` for every **client-local** refusal, and that is faithful rather than a gap: the
/// local refusals the reference raises itself go through `DisplayError` with no argText, and the
/// two client-generated argument messages it *does* build (the lock toasts, MIN_SKILL) are
/// `arg` is `None` for **almost** every client-local refusal, and that is faithful rather than a
/// gap: the local refusals the reference raises itself go through `DisplayError` with no argText,
/// and the two client-generated argument messages it *does* build (the lock toasts, MIN_SKILL) are
/// [`UiError`]'s tenants, not this queue's.
///
/// The **one** exception is the crowd-control ladder's `0x8d` (decision 1948). That refusal is
/// local — `0x6094f0` bails before any packet — yet it carries an argument all the same, because
/// its own exemption scan produced one: the blocking aura's `SpellMechanic.dbc` id. It is the
/// only place a locally-generated word reaches this queue, which is why `push_local` still cannot
/// set one and [`CastErrors::push_local_arg`] exists beside it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct CastFail {
pub spell_id: u32,
@@ -69,6 +75,16 @@ impl CastErrors {
pub(crate) fn push_local(&mut self, spell_id: u32, reason: u8) {
self.0.push(CastFail::local(spell_id, reason));
}
/// Queue a client-local refusal that carries its own argument word — see [`CastFail::arg`]
/// for why there is exactly one such tenant.
pub(crate) fn push_local_arg(&mut self, spell_id: u32, reason: u8, arg: u32) {
self.0.push(CastFail {
spell_id,
reason,
arg: Some(arg),
});
}
}
/// (Dis)mount refusals queued for the UI error line, as the wire pair `(mount, code)` from
+27 -9
View File
@@ -75,6 +75,31 @@ pub(super) struct FeedMemory {
macro_generation: u64,
}
/// The DBC name tables the cast-fail **argument arms** read (`FailArgs`), as one parameter — three
/// `Option<Res<…>>` that are one concept and were pushing this system past Bevy's parameter arity
/// (decision 1948). Each is `Option` for the same reason `FailArgs`' fields are: a client with no
/// game data has none, and the arm then declines and the template strips.
#[derive(bevy::ecs::system::SystemParam)]
pub(super) struct FailNameTables<'w> {
/// `SpellFocusObject.dbc` — the crafting book's catalog, for `0x5e`.
focus: Option<Res<'w, crate::ui_tradeskill::SpellFocus>>,
/// `AreaTable.dbc` — the map arc's, for `0x5d`.
areas: Option<Res<'w, crate::area::AreaTableRes>>,
/// `SpellMechanic.dbc` — for `0x8d`, the one arm whose word is produced locally.
mechanics: Option<Res<'w, super::SpellMechanics>>,
}
impl FailNameTables<'_> {
fn args(&self) -> cast_fail::FailArgs<'_> {
cast_fail::FailArgs {
arg: None,
focus: self.focus.as_deref().map(|f| &f.catalog),
areas: self.areas.as_deref().map(|a| &a.0),
mechanics: self.mechanics.as_deref().map(|m| &m.catalog),
}
}
}
#[allow(clippy::too_many_arguments)] // a Bevy system's full input set
pub(super) fn feed_actions(
script: Option<NonSendMut<UiScript>>,
@@ -88,10 +113,7 @@ pub(super) fn feed_actions(
mut items: ResMut<Items>,
icons: Option<Res<ItemDisplays>>,
sub_classes: Option<Res<crate::ui_items::ItemSubClasses>>,
// The two DBC name tables the cast-fail argument arms read (`FailArgs`): the crafting book's
// SpellFocusObject catalog and the map arc's AreaTable one, both already loaded.
spell_focus: Option<Res<crate::ui_tradeskill::SpellFocus>>,
areas: Option<Res<crate::area::AreaTableRes>>,
name_tables: FailNameTables,
commands: Res<NetCommands>,
mut memory: Local<crate::ui_script::VmMemo<FeedMemory>>,
// Where a displayed message lands: the chat window (the combat log's own record of a failed
@@ -122,11 +144,7 @@ pub(super) fn feed_actions(
// things — "Not enough mana." on the screen, "You fail to cast Frostbolt: Not enough mana."
// in the log.
let mut fail_lines: Vec<crate::ui_chat::combat::PendingCombat> = Vec::new();
let fail_args = cast_fail::FailArgs {
arg: None,
focus: spell_focus.as_deref().map(|f| &f.catalog),
areas: areas.as_deref().map(|a| &a.0),
};
let fail_args = name_tables.args();
let texts: Vec<cast_fail::CastFailLine> = cast_errors
.0
.drain(..)
+29
View File
@@ -291,6 +291,34 @@ fn track_learned_abilities(
}
}
/// `SpellMechanic.dbc` — the vocabulary that fills `SPELL_FAILED_PREVENTED_BY_MECHANIC`'s `%s`
/// ([`benilla_formats::SpellMechanicCatalog`], decision 1948). Its one reader is the cast-failure
/// resolver's `0x8d` arm.
#[derive(Resource)]
pub(crate) struct SpellMechanics {
pub(crate) catalog: benilla_formats::SpellMechanicCatalog,
}
fn load_spell_mechanics(mut commands: Commands, assets: Option<Res<benilla_assets::WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
let mut chain = benilla_assets::LockRecover::lock_recover(&*assets.chain);
benilla_formats::load_spell_mechanic_catalog(&mut chain)
};
match loaded {
Ok(catalog) => {
debug!("ui_action: {} spell-mechanic name(s)", catalog.len());
commands.insert_resource(SpellMechanics { catalog });
}
// Absent data is the strip fallback, not a failure: the refusal still appears, it just
// shows its bare stem instead of naming what is holding you.
Err(e) => warn!(
"ui_action: SpellMechanic.dbc failed to load — the crowd-control refusal drops the \
mechanic name: {e:#}"
),
}
}
pub(crate) struct UiActionPlugin;
impl Plugin for UiActionPlugin {
@@ -308,6 +336,7 @@ impl Plugin for UiActionPlugin {
.init_resource::<targeting::SpellTargeting>()
.init_resource::<targeting::EnchantConfirmItem>()
.add_systems(Startup, load_spells.after(AssetSet::Open))
.add_systems(Startup, load_spell_mechanics.after(AssetSet::Open))
.add_systems(
Update,
(
+113 -19
View File
@@ -140,7 +140,7 @@ pub(crate) fn cast_cc_refusal(
charmed_by_other: bool,
spell: Option<&SpellDisplay>,
exempt: &mut impl FnMut(&[u32]) -> benilla_formats::CcExemption,
) -> Option<u8> {
) -> Option<(u8, Option<u32>)> {
use crate::player::UNIT_FLAG_STUNNED;
/// `UNIT_FIELD_FLAGS` bits 13/17/22/23 (vmangos `UnitDefines.h`).
const UNIT_FLAG_SILENCED: u32 = 0x0000_2000;
@@ -158,15 +158,17 @@ pub(crate) fn cast_cc_refusal(
// immunity to whatever is blocking — the arm is SKIPPED and the cast proceeds. Otherwise the
// refusal is `0x8d` "Can't do that while %s" when the scan named a mechanic, and the arm's own
// reason when it did not (decision 1946).
let mut arm = |aura_types: &[u32], own_reason: u8| -> Option<u8> {
let mut arm = |aura_types: &[u32], own_reason: u8| -> Option<(u8, Option<u32>)> {
let scan = exempt(aura_types);
if scan.exempt {
return None;
}
Some(if scan.mechanic != 0 {
REASON_PREVENTED_BY_MECHANIC
// The mechanic rides along as the message's `%s` — the one client-LOCAL refusal that
// carries an argument word (decision 1948).
(REASON_PREVENTED_BY_MECHANIC, Some(scan.mechanic))
} else {
own_reason
(own_reason, None)
})
};
@@ -1209,9 +1211,14 @@ mod cc_refusal_tests {
|_: &[u32]| benilla_formats::CcExemption::default()
}
/// The reason alone, dropping the mechanic — most of these tests are about which arm fires.
fn reason_of(v: Option<(u8, Option<u32>)>) -> Option<u8> {
v.map(|(r, _)| r)
}
/// A live caster, nobody else at the reins, no auras.
fn cc(flags: u32, d: &SpellDisplay) -> Option<u8> {
cast_cc_refusal(flags, Some(100), false, Some(d), &mut no_auras())
cast_cc_refusal(flags, Some(100), false, Some(d), &mut no_auras()).map(|(r, _)| r)
}
/// **The arms are not symmetric** (decision 1904, widened by 1925): stun, fear, charm and
@@ -1234,7 +1241,13 @@ mod cc_refusal_tests {
}
// …and charm, which is not a flag at all but "somebody else holds the reins".
assert_eq!(
cast_cc_refusal(0, Some(100), true, Some(&neither), &mut no_auras()),
reason_of(cast_cc_refusal(
0,
Some(100),
true,
Some(&neither),
&mut no_auras()
)),
Some(0x14)
);
@@ -1250,11 +1263,23 @@ mod cc_refusal_tests {
// Nothing up, nothing refused; and no record claims no prevention.
assert_eq!(cc(0, &cast), None);
assert_eq!(
cast_cc_refusal(SILENCED, Some(100), false, None, &mut no_auras()),
reason_of(cast_cc_refusal(
SILENCED,
Some(100),
false,
None,
&mut no_auras()
)),
None
);
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, None, &mut no_auras()),
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
None,
&mut no_auras()
)),
Some(0x64),
"the stun needs no record"
);
@@ -1268,13 +1293,13 @@ mod cc_refusal_tests {
let cast = spell(1);
// Charm is arm 1: it beats a simultaneous stun.
assert_eq!(
cast_cc_refusal(
reason_of(cast_cc_refusal(
STUNNED | SILENCED,
Some(100),
true,
Some(&cast),
&mut no_auras()
),
)),
Some(0x14)
);
// Stun is arm 2: it beats silence, pacify, fear and confuse.
@@ -1298,25 +1323,43 @@ mod cc_refusal_tests {
// No aura of the arm's type: the arm's OWN reason, as everywhere else.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 0)),
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
Some(&cast),
&mut scan(false, 0)
)),
Some(0x64)
);
// A blocking aura the cast does NOT counter: the refusal survives but is renamed to
// `0x8d` "Can't do that while %s", the mechanic naming the aura.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 12)),
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
Some(&cast),
&mut scan(false, 12)
)),
Some(0x8d)
);
// The cast grants immunity: the arm is SKIPPED and the cast goes out. This is the whole
// point — Ice Block cast while stunned.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(true, 0)),
reason_of(cast_cc_refusal(
STUNNED,
Some(100),
false,
Some(&cast),
&mut scan(true, 0)
)),
None
);
// …and skipping one arm does not skip the ladder: a silenced-and-stunned caster whose
// spell counters only the stun still refuses on silence below it.
assert_eq!(
cast_cc_refusal(
reason_of(cast_cc_refusal(
STUNNED | SILENCED,
Some(100),
false,
@@ -1328,32 +1371,83 @@ mod cc_refusal_tests {
mechanic: 0,
}
}
),
)),
Some(0x60)
);
}
/// **The mechanic rides out with the refusal** (decision 1948) — the half that turns
/// "Can't do that while %s" into a sentence. It is the ONE client-local refusal that carries
/// an argument word, and it is `None` for every arm that fell to its own reason.
#[test]
fn the_renamed_refusal_carries_the_mechanic_and_the_others_carry_nothing() {
let cast = spell(1);
let scan = |exempt: bool, mechanic: u32| {
move |_: &[u32]| benilla_formats::CcExemption { exempt, mechanic }
};
// Rejected by a blocking aura: reason `0x8d` AND the mechanic that names it.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 12)),
Some((0x8d, Some(12)))
);
// No aura: the arm's own reason, and NO argument — the message has no `%s` to fill.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(false, 0)),
Some((0x64, None))
);
// Exempt: nothing at all.
assert_eq!(
cast_cc_refusal(STUNNED, Some(100), false, Some(&cast), &mut scan(true, 0)),
None
);
}
/// **A dead caster skips all six** (`0x60980d`'s `jle` on `UNIT_FIELD_HEALTH`) — a corpse is
/// refused by an earlier rung, and reporting a stun over it would be the wrong line.
#[test]
fn a_dead_caster_takes_no_crowd_control_refusal() {
let cast = spell(1);
assert_eq!(
cast_cc_refusal(STUNNED, Some(0), false, Some(&cast), &mut no_auras()),
reason_of(cast_cc_refusal(
STUNNED,
Some(0),
false,
Some(&cast),
&mut no_auras()
)),
None
);
assert_eq!(
cast_cc_refusal(0, Some(0), true, Some(&cast), &mut no_auras()),
reason_of(cast_cc_refusal(
0,
Some(0),
true,
Some(&cast),
&mut no_auras()
)),
None
);
// Alive again, and the arm is back.
assert_eq!(
cast_cc_refusal(STUNNED, Some(1), false, Some(&cast), &mut no_auras()),
reason_of(cast_cc_refusal(
STUNNED,
Some(1),
false,
Some(&cast),
&mut no_auras()
)),
Some(0x64)
);
// No health field at all is not death — the descriptor simply has not landed.
assert_eq!(
cast_cc_refusal(STUNNED, None, false, Some(&cast), &mut no_auras()),
reason_of(cast_cc_refusal(
STUNNED,
None,
false,
Some(&cast),
&mut no_auras()
)),
Some(0x64)
);
}
+10 -3
View File
@@ -95,7 +95,7 @@ struct CharFeedMemo {
items_objects: gate::Watch,
items_templates: gate::Watch,
names_generation: gate::Watch,
/// `Items::enchant_display_epoch` — one step per displayable countdown change (the slot
/// `Items::countdown_display_epoch` — one step per displayable countdown change (the slot
/// views read second-floored countdowns), including the last elapse's collapsing push.
enchant_deadlines: gate::Watch,
/// `PendingItemOps::epoch` — one step per change to the in-flight lock set. Watched BESIDE
@@ -604,6 +604,8 @@ fn slot_view(
// feed's twin.
let enchant_ms: [Option<u64>; 7] =
std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32));
// The item's own expiry countdown — the bag feed's twin (decision 1933).
let duration_ms = items.duration_remaining_display_ms(guid);
let obj = items.object(guid)?;
let entry = obj.object_entry()?;
let count = obj.item_stack_count().unwrap_or(1).max(1);
@@ -710,6 +712,7 @@ fn slot_view(
bar_placeable,
creator,
enchants,
duration_ms,
})
}
@@ -805,8 +808,10 @@ fn inventory_slots(
bar_placeable: false,
creator: None,
// Ammo carries no enchant slots to read: the wire never streams an ammo instance
// here, only the template id off the player descriptor.
// here, only the template id off the player descriptor — and for the same reason it
// carries no expiry countdown either (there is no instance guid to key one on).
enchants: Vec::new(),
duration_ms: None,
});
}
// Equipment 1..=19, then Bag0Slot..Bag3Slot (ids 20..23, PaperDollItemFrame.dbc-verified) —
@@ -1061,7 +1066,9 @@ pub(crate) fn feed_char(
// second per ticking enchant, plus one final step at the elapse. Holding the gate open on
// `live_enchant_deadlines() > 0` (the first 1439 shape) rebuilt both snapshots and fired
// `UNIT_INVENTORY_CHANGED` at frame rate for the whole life of a poison.
let deadlines_moved = memo.enchant_deadlines.moved(items.enchant_display_epoch());
let deadlines_moved = memo
.enchant_deadlines
.moved(items.countdown_display_epoch());
let self_changed = !changed_self.is_empty();
// `is_added` for the icon catalog: the feeds read only its load-once icon column;
// its model-cache half churns every frame (the containers gate's own note).
+11 -6
View File
@@ -216,13 +216,12 @@ fn feed_craft(
if reagents.is_empty() {
num_available = 0;
}
// **Focus first, then the totems** — `0x4ff980`'s own push order, and
// `GetCraftSpellFocus 0x4f78b0` returns the very same pair list despite its name
// (wow-re `tradeskill-tools-and-spell-focus.md`). The focus's flag is the literal
// `1.0` with no predicate: the reference never reddens it. See
// [`crate::ui_tradeskill`]'s twin, where the law is written out.
let mut tools = Vec::new();
for &t in d.totems.iter().filter(|&&t| t != 0) {
let have = count_of(&store.0, &items, t, InventoryScope::CARRIED) > 0;
if let Some(info) = items.template(t, 0, &commands) {
tools.push((info.name.clone(), have));
}
}
if d.requires_spell_focus != 0 {
if let Some(n) = focus
.as_deref()
@@ -231,6 +230,12 @@ fn feed_craft(
tools.push((n.to_string(), true));
}
}
for &t in d.totems.iter().filter(|&&t| t != 0) {
let have = count_of(&store.0, &items, t, InventoryScope::CARRIED) > 0;
if let Some(info) = items.template(t, 0, &commands) {
tools.push((info.name.clone(), have));
}
}
let needs_item_target = matches!(
d.effects[0],
SPELL_EFFECT_ENCHANT_ITEM | SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY
+96
View File
@@ -301,6 +301,17 @@ pub(super) fn drain_container_uses(
}
}
for (bag, slot) in script.take_container_uses() {
// **A right-click cancels an armed gift wrap, whatever it then does** (decision 1934):
// only a LEFT-click on a container slot spends the paper, and the reference's use path
// clears the cursor on its way. Above every affordance below, because a sell or a
// deposit is still a right-click. Re-arming is not a special case: a right-click on a
// second piece of paper cancels the first here and arms itself in the dispatcher.
if let Some(w) = script.cancel_gift_wrap() {
debug!(
"ui_items: right-click cancels the armed gift wrap on bag {} slot {}",
w.bag, w.slot
);
}
// Lua (bagID, 1-based slot) → the wire's player-array addressing.
let slot0 = u8::try_from(slot.saturating_sub(1)).ok();
// Sell affordance (decision 0081 v1): while a merchant is open, a bag-slot click sells the
@@ -380,6 +391,7 @@ pub(super) fn drain_container_uses(
spell_index: t.use_spell_index().unwrap_or(0),
use_spell: t.use_spell.map(|u| u.spell_id),
unwraps_gift: t.unwraps_gift(inst_flags),
begins_gift_wrap: t.begins_gift_wrap(inst_flags),
opens_loot: t.opens_loot(),
page_text: t.page_text,
is_charter: t.flags & benilla_protocol::messages::ITEM_FLAG_CHARTER != 0,
@@ -435,6 +447,21 @@ pub(super) fn drain_container_uses(
});
continue;
}
// …and arm #2's OTHER side: a piece of wrapping paper (`0x5d8d9d`'s clear branch →
// `0x5edea0`). **Purely local — nothing is sent.** The paper's slot locks, the displayed
// cursor becomes mode 2, and the next LEFT-click on a container slot is what sends
// `CMSG_WRAP_ITEM` (`ui_items::feed`'s wrap drain, through the engine's
// `pickup_container_item`). Decision 1934.
if let Some(c) = clicked.filter(|c| c.begins_gift_wrap) {
debug!(
"ui_items: arm gift wrap with {:#x} (lua bag {bag} slot {slot})",
c.guid
);
// `arm_gift_wrap` queues the slot's own `ITEM_LOCK_CHANGED` — the paper dims from
// the lock the arm takes, exactly as a held item's source slot does.
script.arm_gift_wrap(bag, slot);
continue;
}
// #3 — the quest-starter (`0x5d8dd2`, decision 0664): the item's own guid is the
// questgiver. Placed at the reference's position rather than at the tail, so a starter
// that is *also* readable or lootable offers its quest instead of reading/opening. Routed
@@ -602,6 +629,9 @@ struct Clicked {
use_spell: Option<u32>,
/// `ItemInfo::unwraps_gift` for this instance — dispatcher arm #2.
unwraps_gift: bool,
/// `ItemInfo::begins_gift_wrap` — arm #2's OTHER side: a piece of wrapping paper. Arms the
/// local wrap cursor and sends nothing (decision 1934).
begins_gift_wrap: bool,
/// `ItemInfo::opens_loot` for this template — dispatcher arm #8.
opens_loot: bool,
/// The template's `PageText` — dispatcher arm #5's book gate (decision 1105); `0` = not a
@@ -645,6 +675,31 @@ pub(super) fn drain_container_moves(
return;
};
let store = self_q.iter().next();
// **The completed gift wraps** (decision 1934): a left-click that spent an armed paper. Both
// pairs go out in the wire's own order — the paper first — and the whole eligibility question
// is the server's: it answers an ineligible target with one of the six `ERR_CANT_WRAP_*`
// reasons through the ordinary `SMSG_INVENTORY_CHANGE_FAILURE` line.
for (gift_bag, gift_slot, item_bag, item_slot) in script.take_container_wraps() {
let (Some((gift_bag_index, gift_wire_slot)), Some((item_bag_index, item_wire_slot))) =
(wire_pos(gift_bag, gift_slot), wire_pos(item_bag, item_slot))
else {
debug!(
"ui_items: WrapItem({gift_bag}/{gift_slot} → {item_bag}/{item_slot}) out of \
range — ignored"
);
continue;
};
debug!(
"ui_items: wrap {item_bag}/{item_slot} with the paper at {gift_bag}/{gift_slot} \
(wire {gift_bag_index}/{gift_wire_slot} → {item_bag_index}/{item_wire_slot})"
);
let _ = commands.0.send(ClientCommand::WrapItem {
gift_bag: gift_bag_index,
gift_slot: gift_wire_slot,
item_bag: item_bag_index,
item_slot: item_wire_slot,
});
}
for mv in script.take_container_moves() {
send_container_move(
&mut script,
@@ -932,6 +987,46 @@ mod tests {
"the slot greys at the click"
);
}
/// **Right-clicking wrapping paper arms the wrap and sends NOTHING** (decision 1934) — the
/// same dispatcher arm the wrapped-gift unwrap takes, on its other side. This is the bug the
/// carve found: benilla fell through to `CMSG_USE_ITEM`, which casts a spell the paper does
/// not have.
#[test]
fn a_wrapper_right_click_arms_the_cursor_and_ships_no_packet() {
let (mut app, rx) = open_the_clam();
while rx.try_recv().is_ok() {} // drain the clam's own send
// Re-dress backpack slot 1 as a piece of wrapping paper: WRAPPER on the template, and no
// WRAPPED bit on the instance (that combination is the begin-wrap arm; with the bit set it
// would be a present, and would send `CMSG_OPEN_ITEM`).
let mut items = app.world_mut().resource_mut::<Items>();
items.insert_template(
CLAM_ENTRY,
Some(ItemInfo {
flags: benilla_protocol::messages::ITEM_FLAG_WRAPPER,
..crate::items::test_template("Red Ribboned Wrapping Paper")
}),
);
{
let script = app.world().non_send_resource::<UiScript>();
script.run("UseContainerItem(0, 1)").unwrap();
}
app.world_mut()
.run_system_once(drain_container_uses)
.unwrap();
assert!(
rx.try_recv().is_err(),
"the begin-wrap arm is purely local — nothing goes on the wire until a container \
click spends it"
);
let script = app.world().non_send_resource::<UiScript>();
assert_eq!(
script.gift_wrap_armed(),
Some(benilla_ui::script::PendingWrap { bag: 0, slot: 1 }),
"…and the paper is armed"
);
}
}
/// **The bind confirmations' answers** (decision 1750) — `EquipPendingItem`/`CancelPendingEquip`
@@ -1091,6 +1186,7 @@ mod bind_confirm_tests {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
+7 -2
View File
@@ -41,7 +41,7 @@ pub(crate) struct FeedMemory {
/// what a charter slot's tooltip lines are built from. Lazy, so its arrival moves nothing else
/// here (the same reason `names_generation` exists one line up).
petition_records: gate::Watch,
/// `Items::enchant_display_epoch` — one step per displayable countdown change (the slot
/// `Items::countdown_display_epoch` — one step per displayable countdown change (the slot
/// views read second-floored countdowns), including the last elapse's collapsing push.
enchant_deadlines: gate::Watch,
/// The item guid **of the bag itself** in each of the ten bag slots — container ids 1..=10
@@ -565,6 +565,9 @@ fn resolve_slot(
// (both live on `Items`): one `Option<ms>` per enchant slot.
let enchant_ms: [Option<u64>; 7] =
std::array::from_fn(|s| items.enchant_remaining_display_ms(guid, s as u32));
// The item's own expiry countdown, read off the same deadline store and floored the same way
// — `SMSG_ITEM_TIME_UPDATE`'s only surface (decision 1933).
let duration_ms = items.duration_remaining_display_ms(guid);
let (entry, count, durability, readable, creator, flags, already_bound, roll, enchant_lines) =
match items.object(guid) {
Some(fields) => (
@@ -631,6 +634,7 @@ fn resolve_slot(
flags,
already_bound,
enchants: enchant_lines,
duration_ms,
..Default::default()
});
};
@@ -678,6 +682,7 @@ fn resolve_slot(
flags,
already_bound,
enchants: enchant_lines,
duration_ms,
equip_slots: find_equip_slot(t.inventory_type, has_relic_slot),
bar_placeable: t.placeable_on_action_bar(),
cooldown: t.use_spell.and_then(|u| {
@@ -830,7 +835,7 @@ pub(crate) fn feed_containers(
// that rebuilt every bag snapshot for the whole life of a ticking enchant.
let deadlines_moved = memory
.enchant_deadlines
.moved(items.enchant_display_epoch());
.moved(items.countdown_display_epoch());
// Bound as `let`s (not a bare OR-chain) so the gate trace below can name each input.
let sweep = cooldowns.sweep_pending(clock.anchor);
let self_changed = !self_q.1.is_empty();
+1 -1
View File
@@ -1,7 +1,7 @@
//! The connection-telemetry feed — the app side of `benilla_ui::script::net_stats`' seam.
//!
//! One number: the latency `GetNetStats()` reports, which is the main bar's performance ("ping")
//! meter's whole input (`ActionBar.xml`'s `MainMenuBarPerformanceBarFrame` polls it every 10 s and
//! meter's whole input (stock `MainMenuBar.xml`'s `MainMenuBarPerformanceBarFrame` polls it every 10 s and
//! colors the bar green/yellow/red). The measurement itself belongs to the **net read thread** —
//! every `SMSG_PONG` is timed where it lands and filed in the connection clock's RTT history at the
//! reference's own depth — and this feed only carries its average across the engine boundary (decision
@@ -18,11 +18,25 @@ fn shipped_action_bar_drives_end_to_end() {
// `load_ui` returns the same `report.frames` the disk reader asserted on, so this
// count is the one that always stood here — moved, not re-derived.
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
let frames = super::test_ui::load_ui(&s, "ActionBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, "UIParent.xml");
super::test_ui::load_ui(&s, "MoneyFrame.xml");
super::test_ui::load_ui(&s, "GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
let frames = super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml")
+ super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml")
+ super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
assert_eq!(
frames, 59,
"bar + XP StatusBar (+ its numerals overlay) + exhaustion tick + max-level rail + art frame + 12 buttons (each with a Cooldown child) + 2 page buttons + the performance meter and its hover button, \
+ BonusActionBarFrame and its 12 buttons with their Cooldown children (25 — hidden, as the reference's is; decision 1223) — and NO ReputationWatchBar: its three frames went home to the reference's own ReputationFrame.xml with 1875"
frames, 80,
"what the three stock files declare (1938): MainMenuBar.xml's 8 — the bar, the XP StatusBar, \
the overlay frame, the max-level rail, the art frame, the performance bar and its button, \
the exhaustion tick; ActionBarFrame.xml's 14 — 12 ActionButtons and the 2 page arrows; \
BonusActionBarFrame.xml's 24 — the bonus frame with its 12 buttons and the shapeshift frame \
with its 10; plus one $parentCooldown per action, bonus and shapeshift button (12 + 12 + 10). \
Ours built 59 for the same seats: no shapeshift bar in the file (StanceBar.xml's), no \
overlay frame, no performance-bar button"
);
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
@@ -60,13 +74,20 @@ fn shipped_action_bar_drives_end_to_end() {
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The app fires this on the offset's edge (ui_action/feed.rs); the stock bonus frame shows on
// it and slides up over the main bar for BONUSACTIONBAR_SLIDETIME (0.15 s) — one OnUpdate
// paints the start, the next lands it.
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
s.tick(10.0);
s.tick(0.2);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Button 1 paints action 73's icon seated in the art frame's first well: the 1024-wide bar
// centers at BOTTOM of the 1024-wide screen ⇒ bar/art-frame left edge = (1024-1024)/2 = 0.
// Button1 anchors the art frame's BOTTOMLEFT +(8,4), 36×36 ⇒ x[8,44] y[4,40]; the icon fills
// the button (owner-sized) ⇒ the same rect. The chain stride is 36 + 6 = 42, so button i's left
// edge is 8 + (i-1)*42.
// The stance page is painted by BonusActionButton1/2, not by the main bar (1897/1938). The
// 1024-wide bar centers at BOTTOM of the 1024-wide screen ⇒ bar left edge = 0; the bonus
// frame lands at the bar's BOTTOMLEFT + (BONUSACTIONBAR_XPOS 4, BONUSACTIONBAR_YPOS 43), is
// 43 high, and its button 1 sits at its own BOTTOMLEFT + (5, 4), 36×36 ⇒ x[9,45] y[4,40] —
// one pixel right of the main bar's (8, 4). The reference's own geometry, off
// BonusActionBarFrame.xml:54-96; the chain stride is 36 + 6 = 42.
s.resolve();
let quads = s.extract();
let icon = |path: &str| {
@@ -75,11 +96,12 @@ fn shipped_action_bar_drives_end_to_end() {
.find(|q| matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p == path))
.and_then(|q| q.rect)
};
let r = icon("Interface\\Icons\\Ability_SteelMelee").expect("button 1 icon");
assert_eq!((r.left, r.bottom, r.right, r.top), (8.0, 4.0, 44.0, 40.0));
let r2 = icon("Interface\\Icons\\Ability_Rogue_Ambush").expect("button 2 icon");
assert_eq!(r2.left, 8.0 + 42.0); // button 2 left = 50
// Twelve quickslot rings (every button draws its NormalTexture), two icons only.
let r = icon("Interface\\Icons\\Ability_SteelMelee").expect("bonus button 1 icon");
assert_eq!((r.left, r.bottom, r.right, r.top), (9.0, 4.0, 45.0, 40.0));
let r2 = icon("Interface\\Icons\\Ability_Rogue_Ambush").expect("bonus button 2 icon");
assert_eq!(r2.left, 9.0 + 42.0); // bonus button 2 left = 51
// Only an OCCUPIED button is shown — an empty one hides while showgrid == 0
// (ActionButton.lua:69-70) — so two rings, two icons.
let rings = quads
.iter()
.filter(|q| {
@@ -87,7 +109,10 @@ fn shipped_action_bar_drives_end_to_end() {
if p.contains("UI-Quickslot2"))
})
.count();
assert_eq!(rings, 12);
assert_eq!(
rings, 2,
"the two occupied bonus buttons; every empty main and bonus slot is hidden"
);
let icons = quads
.iter()
.filter(|q| {
@@ -97,8 +122,8 @@ fn shipped_action_bar_drives_end_to_end() {
.count();
assert_eq!(icons, 2, "empty slots draw no icon quad");
// A physical click on button 1 queues UseAction(73) — the stance page's id, not 1. Button 1's
// center is (8+18, 4+18) = (26, 22).
// A physical click at (26, 22) lands on BonusActionButton1 (9..45 × 4..40, two frame levels
// above the hidden main slot) and queues UseAction(73) — the stance page's id, not 1.
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
assert_eq!(action_ids(&mut s), vec![73]);
@@ -124,9 +149,10 @@ fn shipped_action_bar_drives_end_to_end() {
s.run("ActionButtonDown(2)").unwrap();
assert_eq!(depressed(&s), 1, "key DOWN shows the pushed texture");
assert_eq!(
s.eval::<String>("return ActionButton2:GetButtonState()")
s.eval::<String>("return BonusActionButton2:GetButtonState()")
.unwrap(),
"PUSHED"
"PUSHED",
"with the overlay up a key drives the BONUS button (ActionButton.lua:15-22)"
);
s.run("ActionButtonUp(2)").unwrap();
assert_eq!(action_ids(&mut s), vec![74], "key '2' fires action 74");
@@ -135,6 +161,10 @@ fn shipped_action_bar_drives_end_to_end() {
// Stance drops (offset 0): the bar re-pages to actions 1..12 — all empty here, icons clear.
s.set_bonus_bar_offset(0);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
// The overlay descends carrying the old form's page (`lastBonusBar`) and hides at the end
// of its slide — one OnUpdate more than BONUSACTIONBAR_SLIDETIME (paint, then advance).
s.tick(0.2);
s.tick(0.01);
s.resolve();
let icons_after = s
.extract()
@@ -151,7 +181,16 @@ fn shipped_action_bar_drives_end_to_end() {
fn load_action_bar(s: &UiScript) {
super::test_ui::load_ui(s, "Cooldown.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(s, "ActionBar.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(s, "UIParent.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(s, "MoneyFrame.xml");
super::test_ui::load_ui(s, "GameTooltip.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
@@ -164,6 +203,14 @@ fn load_action_bar(s: &UiScript) {
super::test_ui::load_ui(s, r"Interface\FrameXML\UIPanelTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\OptionsFrameTemplates.xml");
super::test_ui::load_ui(s, r"Interface\FrameXML\ReputationFrame.xml");
// The options window: `LOCK_ACTIONBAR` and `ALWAYS_SHOW_MULTIBARS` are declared there, as the
// reference declares them in UIOptionsFrame_Init (1938) — and the manifest loads it before
// the bars.
super::test_ui::load_ui(s, "UiPanels.xml");
super::test_ui::load_ui(s, "Interface\\FrameXML\\UIDropDownMenu.xml");
super::test_ui::load_ui(s, "ScrollTemplates.xml");
super::test_ui::load_ui(s, "KeyBindingsPage.xml");
super::test_ui::load_ui(s, "OptionsFrame.xml");
}
/// The state/feedback layer (decision 0137 phase 4) through the REAL shipped XML: a pushed
@@ -454,7 +501,7 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
s.resolve();
s.run(r#"LOCK_ACTIONBAR = "1""#).unwrap();
s.run("BenillaActionButton_OnDragStart(ActionButton1)")
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnDragStart\")()")
.unwrap();
assert!(
s.cursor_payload().is_none(),
@@ -480,7 +527,7 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
);
// The receiving end is guarded too: the held action cannot be dropped back by a drag…
s.run("BenillaActionButton_OnReceiveDrag(ActionButton1)")
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnReceiveDrag\")()")
.unwrap();
assert!(
s.cursor_payload().is_some(),
@@ -488,7 +535,7 @@ fn the_action_bar_lock_stops_the_drag_and_leaves_shift_click_alone() {
);
// …and unlocking makes both ends live again.
s.run(r#"LOCK_ACTIONBAR = "0""#).unwrap();
s.run("BenillaActionButton_OnReceiveDrag(ActionButton1)")
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnReceiveDrag\")()")
.unwrap();
assert!(s.cursor_payload().is_none(), "unlocked, the drop lands");
assert_eq!(
@@ -779,7 +826,16 @@ fn shipped_bag_frame_drives_end_to_end() {
let frames = load_ui(&s, file);
if *file == "Cooldown.xml" {
load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_ui(&s, "ActionBar.xml");
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
load_ui(&s, "UIParent.xml");
load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_ui(&s, "MoneyFrame.xml");
load_ui(&s, "GameTooltip.xml");
load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
}
if *file == "Interface\\FrameXML\\MainMenuBarBagButtons.xml" {
bar_frames = frames;
@@ -803,6 +859,7 @@ fn shipped_bag_frame_drives_end_to_end() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -1112,7 +1169,20 @@ fn the_bonus_action_bar_exists_hidden_and_takes_layout_calls() {
.unwrap();
}
// A hidden bar changes nothing about the visible one.
// A hidden bar changes nothing about the visible one. (An EMPTY main-bar button is itself
// hidden under the reference — `ActionButton_Update` hides a slot with no action while
// `showgrid == 0`, ActionButton.lua:69-70 — so slot 1 is occupied first.)
s.set_action(
1,
Some(ActionSlot {
texture: Some("Interface\\Icons\\Ability_SteelMelee".into()),
kind: 0x00,
action: 100,
count: 0,
consumable: false,
}),
);
s.fire_event("ACTIONBAR_SLOT_CHANGED", vec![ScriptValue::Int(1)]);
assert!(
s.eval::<bool>("return ActionButton1:IsShown()").unwrap(),
"the main bar is untouched"
@@ -1193,7 +1263,7 @@ fn both_reference_action_button_templates_are_inheritable() {
// zBar's exact shape: inherit the bar template, supply your own OnLoad.
let doc = benilla_ui::framexml::parse(
r#"<Ui>
<CheckButton name="ZLikeButton" inherits="ActionBarButtonTemplate" id="1">
<CheckButton name="ZLikeButton" inherits="ActionBarButtonTemplate" parent="UIParent" id="1">
<Anchors><Anchor point="CENTER"/></Anchors>
</CheckButton>
<CheckButton name="BareLikeButton" inherits="ActionButtonTemplate" id="1">
@@ -1279,7 +1349,14 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
"UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -1288,7 +1365,15 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"MultiBars.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
] {
// `test_ui::load_ui`, not a disk read: this list names chain entries now (the reputation
// pane and the templates it inherits through), and `assets/ui` cannot answer for those.
@@ -1378,14 +1463,31 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
reference's own asymmetry with page-down, which rescans instead"
);
// A bonus page outranks the paged one entirely: with an offset up, the page is ignored.
// The bonus branch is GUARDED by the page (ActionButton.lua:447: `button.isBonus and
// CURRENT_ACTIONBAR_PAGE == 1`) — and a MAIN-bar button has no isBonus at all, so it never
// takes that branch: the bonus offset is BonusActionBarFrame's own twelve buttons' business
// (1897, adopted with 1938). Main-bar slot 1 on page 3 is action 25 whatever the offset.
s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap();
s.set_bonus_bar_offset(1);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
25,
"a main-bar button follows its page, never the bonus offset (ActionButton.lua:447-456)"
);
s.run("CURRENT_ACTIONBAR_PAGE = 1").unwrap();
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
73,
"bonus offset 1 is action 73, whatever page the main bar is on"
"the bonus frame's own button, on page 1 with offset 1, is 72 + 1"
);
s.run("CURRENT_ACTIONBAR_PAGE = 3").unwrap();
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(BonusActionButton1)")
.unwrap(),
25,
"and off page 1 even the bonus button follows the page: the conjunct 1897 found missing"
);
s.set_bonus_bar_offset(0);
assert_eq!(
@@ -1410,7 +1512,16 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "ActionBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, "UIParent.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(&s, "MoneyFrame.xml");
super::test_ui::load_ui(&s, "GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
@@ -1474,12 +1585,34 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
.into_iter()
.map(|u| (u.action, u.on_self))
.collect::<Vec<_>>(),
vec![(73, true)],
"ActionButtonUp's onSelf reaches UseAction's third argument"
vec![(73, false)],
"the BONUS branch of stock ActionButtonUp passes a literal 0 for onSelf (ActionButton.lua:38) \
— in a stance, ALT-1 drives the bonus page without self-cast; the reference's own rule, \
which 1745's expectation of (73, true) had smoothed over"
);
s.run("BonusActionBarFrame:Hide() ActionButtonDown(1) ActionButtonUp(1, 1)")
.unwrap();
assert_eq!(
s.take_action_uses()
.into_iter()
.map(|u| (u.action, u.on_self))
.collect::<Vec<_>>(),
vec![(1, true)],
"on the main-bar branch onSelf reaches UseAction's third argument (ActionButton.lua:51)"
);
s.run("BonusActionBarFrame:Show()").unwrap();
// Half the slide: the replica is half-risen (top = 0.5 * 43 over the bar's bottom edge at
// y=0), the paint suppression still holds, still silent.
// The slide, frame by frame. Stock `BonusActionBar_OnUpdate` paints the position the timer
// has REACHED and then advances it (BonusActionBarFrame.lua:37-49), so the first OnUpdate
// after a Show paints the start (top = 0 over the bar's bottom edge at y=0), the second the
// half-risen replica (0.5 * 43), and the one that finds the timer past BONUSACTIONBAR_SLIDETIME
// lands it. Silent until then.
s.tick(0.075);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(top.abs() < 0.01, "first frame top = {top}, want 0");
assert!(s.take_sounds().is_empty());
s.tick(0.075);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
@@ -1495,8 +1628,10 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
);
assert!(s.take_sounds().is_empty());
// The landing edge: snap to 43, THE sound, and the main bar adopts the bonus page.
s.tick(0.08);
// The landing edge: snap to 43 and THE sound. The main bar does NOT adopt the bonus page:
// a main-bar button has no `isBonus`, so `ActionButton_GetPagedID` keeps it on its page and the
// overlay IS the stance page (ActionButton.lua:447-456; 1897, adopted with 1938).
s.tick(0.01);
assert_eq!(
s.take_sounds(),
vec![benilla_ui::script::SoundRequest::KitName(
@@ -1519,8 +1654,8 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
73,
"the main bar adopts the bonus page at landing"
1,
"the main bar keeps its page under the landed overlay"
);
s.tick(0.5);
assert!(s.take_sounds().is_empty(), "a landed bar never re-sounds");
@@ -1540,7 +1675,8 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
97
1,
"the main bar stays on its page through a form swap"
);
s.tick(0.2);
assert!(
@@ -1571,7 +1707,11 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
// stand-in while the frame is still shown.
s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap();
assert_eq!(action_ids(&mut s), vec![97]);
// Paint-then-advance again: the frame that finds the timer past the slide time is the one
// that hides (BonusActionBarFrame.lua:51-66), so the descent takes one OnUpdate more than
// the time itself.
s.tick(0.2);
s.tick(0.01);
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap(),
@@ -1593,7 +1733,16 @@ fn bonus_bar_turnaround_continues_from_position() {
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "ActionBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, "UIParent.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(&s, "MoneyFrame.xml");
super::test_ui::load_ui(&s, "GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
@@ -1620,15 +1769,28 @@ fn bonus_bar_turnaround_continues_from_position() {
"hide"
);
// A third of the way back down from the turnaround point: 43 * (0.5 - 0.03/0.15).
// `HideBonusActionBar` keeps a running timer (it resets it only when `completed`,
// BonusActionBarFrame.lua:86-88), so the descent starts where the rise had got to: the rise's
// one OnUpdate advanced the timer to 0.075 (half), and the hide arm paints (1 − 0.075/0.15) · 43
// = 21.5 on its first frame, then 12.9 on the next — paint, then advance.
s.tick(0.03);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 21.5).abs() < 0.6,
"turnaround descends from 21.5, top = {top}"
);
s.tick(0.03);
let top = s
.eval::<f64>("return BonusActionBarFrame:GetTop()")
.unwrap();
assert!(
(top - 12.9).abs() < 0.6,
"turnaround descends from 21.5, top = {top}"
"next frame top = {top}, want ~12.9"
);
s.tick(0.2);
s.tick(0.01);
assert!(!s
.eval::<bool>("return BonusActionBarFrame:IsShown()")
.unwrap());
@@ -1652,7 +1814,16 @@ fn the_page_arrows_do_not_steal_each_other_s_clicks() {
s.set_screen_size(1024.0, 768.0);
super::test_ui::load_ui(&s, "Cooldown.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
super::test_ui::load_ui(&s, "ActionBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\TextStatusBar.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\Fonts.xml");
super::test_ui::load_ui(&s, "UIParent.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\GlobalStrings.lua");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\MainMenuBar.xml");
super::test_ui::load_ui(&s, "MoneyFrame.xml");
super::test_ui::load_ui(&s, "GameTooltip.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
super::test_ui::load_ui(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// `ExhaustionTick_Update` indexes `ReputationWatchBar` UNGUARDED — the reference's own code,
// safe there because `ReputationFrame.xml` is always loaded and always declares the bar. Since
+14 -4
View File
@@ -415,6 +415,7 @@ fn bag_tooltip_hangs_left_when_the_slot_sits_in_the_right_half() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -491,6 +492,7 @@ fn hovered_bag_tooltip_fills_itself_when_the_stats_land() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -594,6 +596,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -615,6 +618,7 @@ fn vendor_bag_hover_shows_sell_price_and_arms_the_pouch_cursor() {
slots.insert(
2,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -798,6 +802,7 @@ fn drag_across_two_slots_queues_the_same_move_a_click_pickup_would() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -881,6 +886,7 @@ fn a_second_bag_window_feeds_and_paints_via_the_bag_bar() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -1013,6 +1019,7 @@ fn open_backpack_with_a_five_stack(s: &mut UiScript) -> (f32, f32) {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -1454,6 +1461,7 @@ fn the_bar_bag_buttons_name_themselves_on_hover() {
// SetInventoryItem arm. Bar slot 1 is inventory slot 20 (Bag0Slot).
let mut inv: benilla_ui::script::InventorySlots = Default::default();
inv[20] = Some(benilla_ui::script::InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -1635,13 +1643,15 @@ fn the_first_key_puts_the_keyring_on_the_bar() {
bar_right - perf_right
);
// And it reverts: destroying the last key takes the keyring back off the bar (our divergence
// from the ref's one-way saved-variable latch — see MainMenuBar_UpdateKeyRing).
// And it does NOT revert: the reference's latch is one-way — `MainMenuBar_UpdateKeyRing`
// only ever Shows, and SHOW_KEYRING is a saved variable (stock MainMenuBar.lua:174-183,
// MainMenuBar.xml:323-336). Our file used to take the button away with the last key; that
// divergence went with the file (1938).
s.set_has_key(false);
s.fire_event("BAG_UPDATE", vec![benilla_ui::script::ScriptValue::Int(-2)]);
assert!(
!s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"losing the last key takes the button away again"
s.eval::<bool>("return KeyRingButton:IsShown()").unwrap(),
"losing the last key leaves the button on the bar — the reference's one-way latch"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+13 -9
View File
@@ -40,15 +40,19 @@ fn harness() -> UiScript {
load_xml(&s, "UIParent.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON (the unknown-icon fallback)
// The timer switch, PLANTED ON — not the shipped value. 1.12 declares it in
// UIOptionsFrame.lua (default "0"); we have no counterpart to that file, so it lives with the
// row that drives it (OptionsFrame.xml), where it shipped "1" from 0255/1139 until 1804 put
// it back on the reference's "0". These tests are about the timer text and the geometry it
// buys, so the harness turns it on the way the Interface page's row does. Order matters: the
// reference's `BuffFrame_OnLoad` calls `BuffButtons_UpdatePositions`, which seats the debuff
// row 20px differently depending on this value, so setting it afterwards leaves the bar laid
// out for the wrong one.
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); // BENILLA_FALLBACK_ICON (the unknown-icon fallback)
// The timer switch, PLANTED ON — not the shipped value. 1.12 declares it in
// UIOptionsFrame.lua (default "0"); we have no counterpart to that file, so it lives with the
// row that drives it (OptionsFrame.xml), where it shipped "1" from 0255/1139 until 1804 put
// it back on the reference's "0". These tests are about the timer text and the geometry it
// buys, so the harness turns it on the way the Interface page's row does. Order matters: the
// reference's `BuffFrame_OnLoad` calls `BuffButtons_UpdatePositions`, which seats the debuff
// row 20px differently depending on this value, so setting it afterwards leaves the bar laid
// out for the wrong one.
s.run("SHOW_BUFF_DURATIONS = \"1\"").unwrap();
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
// …and APPLIED, the way the app applies it (`manifest::apply_buff_durations`).
@@ -412,6 +412,14 @@ fn managed_positions_track_the_bottom_bar_stack() {
assert_eq!(bottom(&s, "ChatFrame1"), 102.0, "85 + bottomLeft 17");
// The stance bar shows (the warrior at login): the pet term, plus chat's both-flags extra.
// The pass's shapeshift-appearance arm (the reference's own, UIParent.lua:1705-1732 — in ours
// since 1938) touches the bar's three shelf textures by name, unguarded as the reference has
// it; a stand-in frame needs stand-in textures.
s.run(
"local t = { Show = function() end, Hide = function() end } \
ShapeshiftBarLeft, ShapeshiftBarMiddle, ShapeshiftBarRight = t, t, t",
)
.unwrap();
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
@@ -68,6 +68,7 @@ fn combat_stats() -> UnitCombatStats {
fn inventory_with_head_item() -> InventorySlots {
let mut slots: InventorySlots = Default::default();
slots[1] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -94,6 +95,7 @@ fn backpack_with_fitting_helm() -> benilla_ui::script::ContainerState {
slots.insert(
1,
benilla_ui::script::ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -315,6 +315,7 @@ fn chat_click_dismisses_a_stuck_spell_but_not_an_item() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -22,6 +22,7 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -24,6 +24,7 @@ fn one_item_backpack() -> ContainerState {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -244,6 +245,7 @@ fn escape_closes_an_open_stack_split_frame() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -23,7 +23,10 @@ fn exp_bar_harness() -> UiScript {
"Interface\\FrameXML\\TextStatusBar.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -63,7 +66,8 @@ fn the_xp_bar_takes_the_mouse_and_explains_itself() {
"the XP strip must be mouse-enabled or the hover never fires"
);
s.run("BenillaExpBar_OnEnter(MainMenuExpBar)").unwrap();
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
.unwrap(),
@@ -81,7 +85,8 @@ fn the_xp_bar_takes_the_mouse_and_explains_itself() {
"the default-corner anchor"
);
s.run("BenillaExpBar_OnLeave()").unwrap();
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnLeave\")()")
.unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
@@ -301,7 +306,8 @@ fn the_xp_bar_numerals_show_on_hover() {
s.set_player_xp(1234, 5678);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.run("BenillaExpBar_OnEnter(MainMenuExpBar)").unwrap();
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<String>("return MainMenuBarExpText:GetText()")
.unwrap(),
@@ -315,7 +321,7 @@ fn the_xp_bar_numerals_show_on_hover() {
// `this.isZero` on one line where every other line reads `bar` (TextStatusBar.lua:95 — the
// reference's own slip, benign there because `this` is always SOME frame during dispatch, and
// a nil index only from bare Lua). Called from the bar's own `<OnLeave>` in play.
s.run("this = MainMenuExpBar BenillaExpBar_OnLeave(MainMenuExpBar) this = nil")
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnLeave\")() this = nil")
.unwrap();
assert!(
!s.eval::<bool>("return MainMenuBarExpText:IsShown()")
@@ -338,7 +344,8 @@ fn the_rest_state_line_joins_the_held_open_tooltip() {
// 1.12's default posture (0661): detailed newbie tips ON.
s.run("SHOW_NEWBIE_TIPS = \"1\"").unwrap();
s.run("BenillaExpBar_OnEnter(MainMenuExpBar)").unwrap();
s.run("this = MainMenuExpBar MainMenuExpBar:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<f64>("return ExhaustionTick.timer").unwrap(),
1.0,
@@ -1509,7 +1509,7 @@ fn convert_entry(
// A slice that runs PAST the texture is the reference's tiling idiom, not a crop:
// `SetTexCoord(0, n, 0, 1)` on an n-slots-wide strip repeats the art n times — the
// stance shelf's middle carries one slot per extra form exactly that way
// (`StanceBar.xml`, `ShapeshiftBar_Update`). Clamp-sampled it smears the last column
// (stock `ShapeshiftBar_Update`). Clamp-sampled it smears the last column
// across the extra width instead. Clamp/repeat bake into the `Image`, so this picks
// the tiled GPU image + cache entry, as the `Backdrop` arm's `tile` does below.
let tiled = uv
@@ -667,7 +667,16 @@ fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
&[
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
"UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
],
&["Interface\\FrameXML\\MerchantFrame.xml"],
);
@@ -758,6 +767,7 @@ fn backpack() -> ContainerState {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -49,7 +49,7 @@ fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
/// `UIParent.xml` comes with it because the `START_LOOT_ROLL` router lives there now — the
/// reference's own slot for it — where our file used to carry a dedicated hidden driver frame.
fn load_group_loot(s: &UiScript) {
load_xml(s, "UIParent.xml");
// `UIParent.xml` is loaded by `setup()` above — the stock main bar needs it first (1938).
// `LootFrame.xml` brings the whole loot window, and its `GroupLootDropDown` calls
// `UIDropDownMenu_Initialize` from its own OnLoad — the dropdown kit is on the chain too.
load_xml(s, r"Interface\FrameXML\UIDropDownMenu.xml");
@@ -81,8 +81,14 @@ fn setup() -> UiScript {
load_xml(&s, "GameTooltip.xml"); // PASS/NEED/GREED + item hovers
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON (the in-flight icon fallback) —
// buff_tests.rs's own load-order precedent for this same global.
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); // BENILLA_FALLBACK_ICON (the in-flight icon fallback) —
// buff_tests.rs's own load-order precedent for this same global.
s
}
@@ -569,6 +575,14 @@ fn managed_positions_engage_for_the_bare_frame_name() {
assert_eq!(bottom(&s), 102.0, "60 + bottomEither 42 — the row engaged");
// The stance bar shows on top of them.
// The pass's shapeshift-appearance arm (the reference's own, UIParent.lua:1705-1732 — in ours
// since 1938) touches the bar's three shelf textures by name, unguarded as the reference has
// it; a stand-in frame needs stand-in textures.
s.run(
"local t = { Show = function() end, Hide = function() end } \
ShapeshiftBarLeft, ShapeshiftBarMiddle, ShapeshiftBarRight = t, t, t",
)
.unwrap();
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
@@ -36,7 +36,13 @@ fn setup() -> UiScript {
// happens. Ours never called it. MicroMenu needs the bar's button kit under it.
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
// `TicketStatusFrame_OnEvent` re-anchors TemporaryEnchantFrame before it arms the repoll
// timer, so without BuffFrame the handler raises and `refreshTime` is never set. This is
@@ -25,7 +25,15 @@ fn harness_with(extra: &[&str]) -> UiScript {
"Interface\\FrameXML\\Fonts.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
]
.into_iter()
+2 -2
View File
@@ -651,7 +651,7 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
),
(80, "Interface\\Icons\\INV_Misc_Food_16", 0x80, 117, 5),
(84, "Interface\\Icons\\Spell_Holy_SealOfMight", 0x00, 103, 0),
// The always-on multibars (MultiBars.xml): BottomLeft = actions 61..72, BottomRight
// The always-on multibars (stock MultiActionBars.xml): BottomLeft = actions 61..72, BottomRight
// = 49..60. A few occupied wells on each so the capture shows both rows seated
// (empty multibar wells hide — the ref's own default — so without these the rows
// would be invisible).
@@ -696,7 +696,7 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
}),
);
}
// The stance bar (StanceBar.xml): the synthetic warrior's three stances, battle active —
// The stance bar (stock BonusActionBarFrame.xml): the synthetic warrior's three stances, battle active —
// matches the bonus offset 1 above (battle stance page). Defensive shows the not-castable
// grey; berserker a running cooldown swipe.
script.set_shapeshift_forms(vec![
@@ -24,7 +24,12 @@ fn load_action_bar(s: &UiScript) {
"GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -33,6 +38,14 @@ fn load_action_bar(s: &UiScript) {
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
// The stock multibar file's OnLoad writes `UIOptionsFrameCheckButtons`, which our options
// window publishes — the reference's own load order (UIOptionsFrame.xml l.21 before
// MultiActionBars.xml l.39), and the manifest's since 1938.
"Interface\\FrameXML\\UIDropDownMenu.xml",
"UiPanels.xml", // StaticPopupDialogs, which the options window's pages register into
"ScrollTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
] {
load_xml(s, file);
}
@@ -47,7 +60,9 @@ fn show_bars(s: &UiScript, bars: &[u32]) {
if bars.contains(&bar) { "1" } else { "nil" }
));
}
lua.push_str("MultiActionBar_Update()");
// …then the manage pass, which the options window runs after a toggle (stock
// UIOptionsFrame.xml:1192, and our rows the same); stock MultiActionBar_Update does not.
lua.push_str("MultiActionBar_Update() UIParent_ManageFramePositions()");
s.run(&lua).unwrap();
}
@@ -65,21 +80,11 @@ fn shipped_multibars_drive_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/MultiBars.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
let frames = super::test_ui::load_ui(&s, "Interface\\FrameXML\\MultiActionBars.xml");
assert_eq!(
report.frames, 100,
"4 bar frames + 48 buttons, each with a Cooldown child — the two VERTICAL bars joined \
(hidden, as the reference's VerticalMultiBar3/4 are), which doubled this count"
frames, 100,
"what stock MultiActionBars.xml declares (1938): the four bar frames and their 48 buttons, \
each with a $parentCooldown — the same 100 ours built for the same seats"
);
// Occupy main slot 1, BottomLeft slot 1 (action 61), BottomRight slot 1 (action 49).
@@ -152,8 +157,9 @@ fn shipped_multibars_drive_end_to_end() {
};
assert_eq!(
rings(&quads, "Interface\\Buttons\\UI-Quickslot2"),
14,
"12 main wells + 2 occupied multibar buttons; 22 empty multibar wells hidden"
3,
"the occupied main slot + the 2 occupied multibar buttons; every empty well is hidden, \
the main bar's included (ActionButton.lua:69-70)"
);
// A click on BottomLeft button 1 (center (26,75)) queues the FIXED id 61 — and stays 61 when
@@ -183,8 +189,10 @@ fn shipped_multibars_drive_end_to_end() {
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
// The offset edges above showed the bonus overlay (1524) and its descent takes 0.15s of
// frame time this event-only test never used to pass; without it the 12 overlay wells are
// still on screen and correctly answer the SHOWGRID below as 12 extra drop targets.
// still on screen and correctly answer the SHOWGRID below as 12 extra drop targets. The
// stock slide paints, THEN advances, so the frame that hides it is one OnUpdate past the time.
s.tick(0.2);
s.tick(0.01);
// While a payload is held (SHOWGRID), the hidden empty wells appear as drop-target rings
// (UI-Quickslot, the "no action" ring): 11 empty main wells swap texture + 22 multibar wells
@@ -199,10 +207,19 @@ fn shipped_multibars_drive_end_to_end() {
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
s.resolve();
assert_eq!(rings(&s.extract(), "Interface\\Buttons\\UI-Quickslot"), 0);
assert_eq!(rings(&s.extract(), "Interface\\Buttons\\UI-Quickslot2"), 14);
assert_eq!(
rings(&s.extract(), "Interface\\Buttons\\UI-Quickslot2"),
3,
"letting go hides every empty well again, the main bar's included; the three occupied \
buttons keep their rings"
);
// The unbound multibar hotkey corner carries the ref's RANGE_INDICATOR dot: out of range paints
// the red dot, back in range clears it (the main bar's labels tint instead).
// the red dot, back in range clears it (the main bar's labels tint instead). Two stock
// mechanisms, in order: `ActionButton_UpdateHotkeys` decides whether the corner's TEXT is the
// dot at all — only while `IsActionInRange` answers non-nil, i.e. with a target — and it runs
// on PLAYER_TARGET_CHANGED (ActionButton.lua:121-145, 333-334); every `ActionButton_OnUpdate`
// then shows or hides that text by range (l.389-395) and tints it red past the 0.2 s recheck.
use benilla_ui::script::ActionState;
s.set_action_state(
61,
@@ -213,6 +230,7 @@ fn shipped_multibars_drive_end_to_end() {
..Default::default()
}),
);
s.fire_event("PLAYER_TARGET_CHANGED", vec![]);
s.tick(0.5); // past the 0.2 s range recheck
s.resolve();
let dot_shown = |quads: &[benilla_ui::script::ExtractedQuad]| {
@@ -255,22 +273,27 @@ fn shipped_stance_bar_drives_end_to_end() {
// bottom multibar is UP to raise the flag. Since 1500 that is a player option rather than a
// given, so this test raises it; `the_stance_bar_sits_where_the_pass_puts_it` below owns the
// other state.
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
show_bars(&s, &[1]);
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/StanceBar.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
// The shapeshift bar is declared by stock BonusActionBarFrame.xml (ShapeshiftBarFrame and
// ShapeshiftButton1..10 beside the bonus bar), which load_action_bar loaded above (1938).
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
assert_eq!(
report.frames, 21,
"the bar frame + 10 buttons, each with a Cooldown child"
s.eval::<bool>("return ShapeshiftBarFrame ~= nil and ShapeshiftButton10 ~= nil")
.unwrap(),
"the stance bar's frames come with the stock bonus-bar file"
);
// No forms pushed (a mage): the frame is hidden.
@@ -440,7 +463,12 @@ fn multibar_hover_renders_the_buttons_own_action() {
"GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -449,7 +477,15 @@ fn multibar_hover_renders_the_buttons_own_action() {
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"MultiBars.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
] {
load_xml(&s, file);
}
@@ -483,7 +519,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
let hover_name = |s: &UiScript, button: &str| -> Option<String> {
s.run(&format!(
"GameTooltip:Hide() BenillaActionButton_OnEnter({button})"
"GameTooltip:Hide() this = {button} ActionButton_SetTooltip()"
))
.unwrap();
s.eval::<Option<String>>(
@@ -537,7 +573,20 @@ fn multibar_hover_renders_the_buttons_own_action() {
fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
let s = UiScript::new().unwrap();
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
for bar in ["MultiBarRight", "MultiBarLeft"] {
assert!(
@@ -556,13 +605,13 @@ fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
// ref ActionButton.lua:8-9 — RIGHT_ACTIONBAR_PAGE = 3 (actions 25..36), LEFT = 4 (37..48).
for (bar, first, last) in [("MultiBarRight", 25, 36), ("MultiBarLeft", 37, 48)] {
assert_eq!(
s.eval::<i64>(&format!("return {bar}Button1.base + {bar}Button1.index"))
s.eval::<i64>(&format!("return ActionButton_GetPagedID({bar}Button1)"))
.unwrap(),
first,
"{bar}'s first slot"
);
assert_eq!(
s.eval::<i64>(&format!("return {bar}Button12.base + {bar}Button12.index"))
s.eval::<i64>(&format!("return ActionButton_GetPagedID({bar}Button12)"))
.unwrap(),
last,
"{bar}'s last slot"
@@ -586,7 +635,20 @@ fn every_extra_bar_stays_down_until_its_own_toggle_is_set() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
const BARS: [&str; 4] = [
"MultiBarBottomLeft",
@@ -660,7 +722,20 @@ fn raising_a_bottom_bar_moves_the_managed_bottom_stack() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Interface\\FrameXML\\CastingBarFrame.xml");
// The pass writes the y of a frame row into its anchor; read it back off the anchor rather
@@ -711,7 +786,20 @@ fn viewable_action_bar_pages_follow_the_bar_toggles() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
let viewable = |s: &UiScript| {
s.eval::<String>(
@@ -764,7 +852,20 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
show_bars(&s, &[1]);
let well = |s: &UiScript| {
@@ -798,12 +899,36 @@ fn the_grid_option_holds_the_extra_bars_empty_wells_open() {
// Re-applying an "off" that is already off must not owe anything: with a payload in hand the
// wells stay open through it (the reference's own idempotence bug, closed by the latch).
// The reference's hide is a COUNTED decrement on every well (`ActionButton_HideGrid`), never
// a no-op: with the option off, one more apply closes a payload-held well. That is why our
// row runs `MultiActionBar_UpdateGridVisibility` only on a CLICK (stock UIOptionsFrame.xml:683)
// and its load arm only ever SHOWS (UIOptionsFrame.lua:218-220) — a load-time hide would push
// counts nobody raised below zero. (Ours used to guard the no-op; the guard went with the
// file, 1938.)
// The load arm, both ways: off touches no count, on opens the wells.
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" OptionsFrame_ApplySavedSettings()")
.unwrap();
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
s.run("MultiActionBar_UpdateGridVisibility()").unwrap();
s.run("MultiActionBar_UpdateGridVisibility()").unwrap();
assert!(well(&s), "a no-op apply may not decrement");
assert!(well(&s));
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
assert!(!well(&s), "a load with the option off left the count alone");
s.run("ALWAYS_SHOW_MULTIBARS = \"1\" OptionsFrame_ApplySavedSettings()")
.unwrap();
assert!(well(&s), "a load with the option on opens the wells");
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" MultiActionBar_UpdateGridVisibility()")
.unwrap();
assert!(!well(&s));
// …and the click arm against a held payload: one apply closes the well the payload opened.
// (The payload's own HIDEGRID that follows takes the count to −1 in the reference too — its
// quirk, not modelled here.)
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
assert!(well(&s));
s.run("MultiActionBar_UpdateGridVisibility()").unwrap();
assert!(
!well(&s),
"the option's hide counts against the payload's open"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -830,7 +955,20 @@ 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");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
s.set_action(
1,
Some(ActionSlot {
@@ -869,8 +1007,9 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
let resting = ring_alphas(&s, quickslot2);
assert_eq!(
resting.len(),
12,
"the 12 main wells; the multibar's are hidden"
1,
"only the occupied main slot draws a ring: every empty well, main bar included, is \
hidden while nothing is held (ActionButton.lua:69-70)"
);
assert!(
resting.iter().all(|a| *a == 1.0),
@@ -891,9 +1030,14 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
"every grid ring is the ref's half-alpha ghost, not an opaque plate: {ghosts:?}"
);
// …and the one OCCUPIED button is untouched by it.
// …and the one OCCUPIED button ghosts with it: `ActionButton_ShowGrid` sets the half alpha
// (ActionButton.lua:246) before anything asks whether the slot holds an action, and
// `HideGrid` touches no colour at all. What restores an occupied ring is its next
// `ActionButton_UpdateUsable` (l.271-283), whose `SetVertexColor(1, 1, 1)` carries the default
// alpha — from Update, and on SPELL_UPDATE_USABLE. Ours skipped occupied rings; the stock
// file does not (1938).
let occupied = ring_alphas(&s, quickslot2);
assert_eq!(occupied, vec![1.0], "the occupied ring does not ghost");
assert_eq!(occupied, vec![0.5], "the occupied ring ghosts too");
// 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
@@ -914,21 +1058,28 @@ fn a_held_payload_ghosts_the_empty_wells_it_opens() {
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"
vec![0.5, 1.0],
"the well that got an action is opaque again (its Update ran UpdateUsable); the other \
occupied ring keeps its ghost, payload still held"
);
// Letting go restores both halves — art and tint, which is why they share one setter.
// Letting go takes the empty wells away; the colour it leaves alone.
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_eq!(
after,
vec![0.5, 1.0],
"HideGrid touches no colour: a ring the grid ghosted stays dim past the payload — the \
reference's own rule (ours used to clear it here)"
);
// What happens to that ring NEXT is decision 1782's open question, deliberately not asserted:
// the reference's `SetVertexColor(1, 1, 1)` in `ActionButton_UpdateUsable` keeps the alpha
// already on the region (byte-pinned, wow-re button-state-texture-path-setter.md §7 — the
// "stuck dim" a 1.12 player sees after a drag), while this engine's three-argument call still
// resets it to 1.0 until the director calls 1782. A test that pinned either outcome would be
// enshrining a divergence as fidelity.
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -947,25 +1098,42 @@ fn a_bar_toggle_sends_the_byte_its_globals_pack_to() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
let _ = s.take_action_bar_toggle_sends();
// The row's own setter, with the row's own "1"/"0" strings.
s.run("BenillaMultiBar_SetShown(1, \"1\")").unwrap();
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x01]);
s.run("BenillaMultiBar_SetShown(3, \"1\")").unwrap();
s.run("SHOW_MULTI_ACTIONBAR_3 = 1 MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x05],
"the WHOLE byte re-sent, not a delta"
);
s.run("BenillaMultiBar_SetShown(4, \"1\")").unwrap();
s.run("SHOW_MULTI_ACTIONBAR_4 = 1 MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x0d]);
s.run("BenillaMultiBar_SetShown(1, \"0\")").unwrap();
s.run("SHOW_MULTI_ACTIONBAR_1 = nil MultiActionBar_Update() SetActionBarToggles(SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3, SHOW_MULTI_ACTIONBAR_4)")
.unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x0c],
"the row hands the setter a STRING, and \"0\" is off"
"the row turns its \"0\" into nil before the send, and nil is off"
);
// What is stored is 1 or nil and nothing else — GetActionBarToggles' own shape, and what the
@@ -1017,7 +1185,20 @@ fn the_shipped_setter_passes_exactly_four_arguments() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
// `r##`: the Lua contains `select("#", ...)`, and `"#` would close a single-hash raw string.
s.run(
@@ -1029,7 +1210,11 @@ fn the_shipped_setter_passes_exactly_four_arguments() {
"##,
)
.unwrap();
s.run("BenillaMultiBar_SetShown(2, \"1\")").unwrap();
// The shipped setter is the Action Bars row's own closure (OptionsFrame.xml), which is
// stock's three lines — assign the global, MultiActionBar_Update(), SetActionBarToggles(…) —
// plus the manage pass.
s.run("OptionsFrameContainerBodyActionBarsRowMultiBar2Check:Click()")
.unwrap();
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_TOGGLE_ARGC").unwrap(),
4,
@@ -1052,8 +1237,22 @@ fn the_stance_bar_sits_where_the_pass_puts_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "StanceBar.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
s.set_shapeshift_forms(vec![ShapeshiftFormView {
spell_id: 2457,
texture: Some("Interface\\Icons\\Stance_A".into()),
@@ -1110,8 +1309,22 @@ fn the_stance_shelf_follows_the_bottom_left_bar() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "StanceBar.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
let form = |id: u32| ShapeshiftFormView {
spell_id: id,
@@ -1175,8 +1388,17 @@ fn the_stance_shelf_follows_the_bottom_left_bar() {
// Even at three forms, raised keeps the middle strip down — the fork is on the BAR, not the
// form count; the count only decides the middle strip within the unraised branch.
// A third form learned while the bar is up: the reference's `ShapeshiftBar_Update` shows the
// middle strip for three forms regardless of the bar (BonusActionBarFrame.lua:198-206), and
// nothing runs the pass on that event — the strip draws across the row until something
// moves. The pass, not Update, owns the raised look (UIParent.lua:1705-1720).
s.set_shapeshift_forms(vec![form(2457), form(71), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
assert!(
shown(&s, "ShapeshiftBarMiddle"),
"Update shows the strip; only the pass takes it down"
);
s.run("UIParent_ManageFramePositions()").unwrap();
assert!(!shown(&s, "ShapeshiftBarMiddle"));
assert_eq!(ring(&s), 50.0);
@@ -1208,8 +1430,22 @@ fn the_stance_shelf_is_as_long_as_the_form_count() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "StanceBar.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
show_bars(&s, &[]); // unraised: the shelf art is the state under test
let form = |id: u32| ShapeshiftFormView {
@@ -1311,7 +1547,20 @@ fn an_extra_bars_empty_well_keeps_its_bound_hotkey_label() {
// the player binds it, as the reporter had).
s.register_bindings(&crate::bindings::registry_commands());
load_action_bar(&s);
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
show_bars(&s, &[1]);
s.set_action(
61,
@@ -750,7 +750,10 @@ fn interface_harness() -> UiScript {
"Interface\\FrameXML\\ColorPickerFrame.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -811,7 +814,14 @@ fn actionbars_harness() -> UiScript {
"UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -820,7 +830,15 @@ fn actionbars_harness() -> UiScript {
r"Interface\FrameXML\UIPanelTemplates.xml",
r"Interface\FrameXML\OptionsFrameTemplates.xml",
r"Interface\FrameXML\ReputationFrame.xml",
"MultiBars.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
],
);
harness_on(s)
@@ -2695,7 +2713,7 @@ fn the_action_bars_page_locks_the_real_bar() {
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
s.resolve();
s.run("BenillaActionButton_OnDragStart(ActionButton1)")
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnDragStart\")()")
.unwrap();
assert!(
s.cursor_payload().is_none(),
@@ -2705,7 +2723,7 @@ fn the_action_bars_page_locks_the_real_bar() {
// Defaults walks it back to ActionBar.xml's own assignment, and the bar drags again.
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
assert_eq!(s.eval::<String>("return LOCK_ACTIONBAR").unwrap(), "0");
s.run("BenillaActionButton_OnDragStart(ActionButton1)")
s.run("this = ActionButton1 ActionButton1:GetScript(\"OnDragStart\")()")
.unwrap();
assert!(s.cursor_payload().is_some(), "unlocked again");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -2846,9 +2864,9 @@ fn the_target_of_target_rows_gate_each_other_and_write_their_globals() {
assert!(
s.eval::<bool>(
"return OptionsFrameContainerBodyInterfaceRowTargetOfTarget.applyFunc \
== TargetofTarget_Update \
== \"TargetofTarget_Update\" \
and OptionsFrameContainerBodyInterfaceRowTargetOfTargetMode.applyFunc \
== TargetofTarget_Update"
== \"TargetofTarget_Update\""
)
.unwrap(),
"both rows re-decide the frame when they are written"
@@ -17,7 +17,15 @@ fn harness(extra: &[&str]) -> UiScript {
load_xml(&s, "Interface\\FrameXML\\Fonts.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "ActionBar.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
for f in extra {
load_xml(&s, f);
}
@@ -230,7 +238,7 @@ fn hovering_the_meter_shows_the_live_latency() {
"the button over the meter takes the mouse"
);
s.run("BenillaPerformanceBar_OnEnter(MainMenuBarPerformanceBarFrameButton)")
s.run("this = MainMenuBarPerformanceBarFrameButton MainMenuBarPerformanceBarFrameButton:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<String>("return GameTooltipTextLeft1:GetText()")
@@ -277,7 +285,7 @@ fn hovering_the_meter_shows_the_live_latency() {
"the refresh rebuilds BOTH lines — a held-open plate never decays to one"
);
s.run("BenillaPerformanceBar_OnLeave()").unwrap();
s.run("this = MainMenuBarPerformanceBarFrameButton MainMenuBarPerformanceBarFrameButton:GetScript(\"OnLeave\")()").unwrap();
assert!(
!s.eval::<bool>("return GameTooltip:IsVisible()").unwrap(),
"leaving hides the plate"
@@ -19,7 +19,13 @@ fn load_pet_bar(s: &UiScript) {
"UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -370,7 +376,13 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
"UIParent.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -383,7 +395,14 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
] {
load_xml(&s, file);
}
load_xml(&s, "MultiBars.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.lua");
load_xml(&s, r"Interface\FrameXML\UIPanelTemplates.xml");
load_xml(&s, "KeyBindingsPage.xml");
load_xml(&s, "OptionsFrame.xml");
load_xml(&s, "Interface\\FrameXML\\MultiActionBars.xml");
if with_multibar {
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 MultiActionBar_Update()")
.unwrap();
@@ -665,7 +684,12 @@ fn load_pet_bar_with_tooltip(s: &UiScript) {
"GameTooltip.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -1075,8 +1075,7 @@ mod tests {
/// do not ship under its own name usually has a counterpart of ours under a different one, and
/// both would declare the same frames.
///
/// That half doubles as the map nothing else holds: `ActionBarFrame.xml` is our
/// `ActionBar.xml`, `FloatingChatFrame.xml` is our `ChatFrame.xml`,
/// That half doubles as the map nothing else holds: `FloatingChatFrame.xml` is our `ChatFrame.xml`,
/// `MainMenuBarMicroButtons.xml` is our `MicroMenu.xml`, `StaticPopup.xml` is our
/// `UiPanels.xml`. (The row this map used to lead with — `PlayerFrame.xml`/`TargetFrame.xml`/
/// `PetFrame.xml` all being our one `UnitFrames.xml` — is retired: those four are the
@@ -2037,11 +2036,6 @@ mod tests {
flow works without it",
),
("ITEM_TEXT_TRANSLATION", "ItemTextFrame.lua"),
(
"PARTY_MEMBER_DISABLE",
"PartyMemberFrame.lua — the offline grey-out",
),
("PARTY_MEMBER_ENABLE", "PartyMemberFrame.lua"),
("PET_UI_CLOSE", "PetPaperDollFrame.lua"),
("PET_UI_UPDATE", "PetPaperDollFrame.lua"),
("PLAYER_DAMAGE_DONE_MODS", "PaperDollFrame.lua"),
@@ -2049,10 +2043,6 @@ mod tests {
"PLAYER_FLAGS_CHANGED",
"TargetFrame.lua — the AFK/DND badge",
),
(
"PLAYER_REGEN_ENABLED",
"PlayerFrame.lua — and _DISABLED is unfired too, so the pair is whole",
),
("PLAYTIME_CHANGED", "PlayerFrame.lua"),
("SHOW_COMPARE_TOOLTIP", "PaperDollFrame.lua"),
("SYSMSG", "UIErrorsFrame.lua"),
@@ -2080,47 +2070,59 @@ mod tests {
} else if p.extension().is_some_and(|x| x == "rs") {
let text = std::fs::read_to_string(&p).unwrap_or_default();
// Split so this file cannot match its own walker (see the sibling gate).
const CALL: &str = concat!("fire_event", "(");
let mut from = 0;
// Two producer shapes: `UiScript::fire_event(` and its `&Lua` twin
// `tick::fire_event_into(` (1924), which an engine verb fires from inside a
// binding — `UpdateSpells` → SPELLS_CHANGED, `ChangeActionBarPage` →
// ACTIONBAR_PAGE_CHANGED (1938).
const CALLS: [&str; 2] =
[concat!("fire_event", "("), concat!("fire_event_into", "(")];
let mut fires_indirectly = false;
while let Some(i) = text[from..].find(CALL) {
let at = from + i + CALL.len();
from = at;
let rest = text[at..].trim_start();
if !rest.starts_with('"') {
// `fire_event(event, args)` — the name reached the call through a
// variable, so adjacency cannot see it. `ui_cast.rs` does exactly
// this: a `match edge` yields `("SPELLCAST_INTERRUPTED", vec![])` and
// one call site fires whatever it produced. Treated below.
fires_indirectly = true;
}
if let Some(body) = rest.strip_prefix('"') {
if let Some(end) = body.find('"') {
fired.insert(body[..end].to_string());
for call in CALLS {
let mut from = 0;
while let Some(i) = text[from..].find(call) {
let at = from + i + call.len();
from = at;
let rest = text[at..].trim_start();
let rest = rest.strip_prefix("lua,").map_or(rest, str::trim_start);
if !rest.starts_with('"') {
fires_indirectly = true;
}
if let Some(body) = rest.strip_prefix('"') {
if let Some(end) = body.find('"') {
fired.insert(body[..end].to_string());
}
}
}
}
if fires_indirectly {
// The name reached `fire_event` through a variable. Rather than guess, we
// match the SHAPE that produced it: `("EVENT_NAME", vec!` — a tuple whose
// second element is the argument vector, which is how `ui_cast.rs`'s
// `match edge` hands a name and its args to one shared call site.
//
// The first attempt at this counted EVERY SCREAMING_CASE literal in such a
// file, and that swept up eighteen names off the UNPRODUCED list — a gate
// made vacuous by its own conservatism, which is the failure mode this
// file has now hit twice. The shape is the discipline: it admits the
// literals that are actually being fired and nothing else.
// An indirect fire names its event either as `"NAME", vec![…]` or as one
// arm of an `if … { "A" } else { "B" }` chosen before the call — a literal
// standing alone on its line (`ui_action/state.rs`'s START/STOP_AUTOREPEAT).
const SHAPE: &str = "\", vec!";
let caps = |lit: &str| {
!lit.is_empty()
&& lit.chars().all(|c| {
c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit()
})
};
for (i, _) in text.match_indices(SHAPE) {
let before = &text[..i];
let Some(q) = before.rfind('"') else { continue };
let lit = &before[q + 1..];
if !lit.is_empty()
&& lit.chars().all(|c| {
c.is_ascii_uppercase() || c == '_' || c.is_ascii_digit()
})
{
if caps(lit) {
fired.insert(lit.to_string());
}
}
let lines: Vec<&str> = text.lines().map(str::trim).collect();
for (i, t) in lines.iter().enumerate() {
let Some(lit) = t.strip_prefix('"').and_then(|x| x.strip_suffix('"'))
else {
continue;
};
let arm = i > 0
&& lines[i - 1].ends_with('{')
&& lines.get(i + 1).is_some_and(|n| n.starts_with('}'));
if arm && caps(lit) {
fired.insert(lit.to_string());
}
}
@@ -662,7 +662,7 @@ fn a_hover_sweep_across_owners_costs_no_graph_derivation() {
///
/// The two guards above drive `SetOwner(button, "ANCHOR_RIGHT")` — the bag slot's idiom. The bars
/// do not use it. With `UberTooltips` at its shipped default of `"1"`, every action button routes
/// through `GameTooltip_SetDefaultAnchor` (`ActionBar.xml:510`, and the stance/pet/bonus bars the
/// through `GameTooltip_SetDefaultAnchor` (stock `ActionButton_SetTooltip`, and the stance/pet/bonus bars the
/// same), which is `SetOwner(owner, "ANCHOR_NONE")` followed by an explicit `SetPoint` — a
/// completely different arm of the same verb, and the one that DROPS the tooltip's anchors.
///
@@ -1280,7 +1280,7 @@ fn a_cinematic_leaves_nothing_of_the_interface_on_screen() {
// The sweep is only worth anything if there was something to hide in the first place.
let before = names.iter().filter(|n| visible(&s, n)).count();
assert!(
before > 50,
before > 20, // 50 until 1938 took the three bar files (and their visible frames) stock
"only {before} frames visible before the cinematic — the sweep found no interface to \
hide, so it would pass no matter what the cascade did"
);
@@ -1350,7 +1350,7 @@ fn every_declared_parent_really_attaches() {
let declared = shipped_frame_parents();
assert!(
declared.len() > 60,
declared.len() > 30, // 67 before 1938 took the three bar files (25 of them) stock
"only {} parent declarations found — the scan broke",
declared.len()
);
@@ -127,7 +127,13 @@ fn shipped_spellbook_drives_end_to_end() {
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "ActionBar.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -203,6 +209,14 @@ fn shipped_spellbook_drives_end_to_end() {
// (`cursor::bar::place_action`) a bar-to-bar drag uses: a plain click on an action button
// routes through UseAction's checkCursor=1 fork to a place. Packs kind 0x00 (SPELL, decision
// 0216 §1) with the spell id — `action_sets` is the app's own CMSG_SET_ACTION_BUTTON queue.
// An EMPTY main-bar slot is hidden under the reference (ActionButton.lua:69-70) until a held
// payload opens the grid: the engine derives ACTIONBAR_SHOWGRID from the cursor's edge and
// fires it on the next tick, as the app ticks between any two mouse events.
s.tick(0.016);
assert!(
s.eval::<bool>("return ActionButton1:IsVisible()").unwrap(),
"the held spell opened the empty well"
);
let (ax, ay) = center(&s, "ActionButton1");
s.mouse_button(ax, ay, "LeftButton", true);
s.mouse_button(ax, ay, "LeftButton", false);
@@ -684,7 +698,12 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() {
r"Interface\FrameXML\ClassTrainerFrameTemplates.xml",
"MicroMenu.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
// The reference declares the reputation WATCH BAR in `ReputationFrame.xml`, and
// `ExhaustionTick_Update` reads `ReputationWatchBar:IsShown()` twice — the reference's own
// coupling of MainMenuBar to that pane. So an action-bar harness loads it, and with it the
@@ -25,10 +25,15 @@ fn harness() -> UiScript {
load_xml(&s, "Interface\\FrameXML\\UIDropDownMenu.xml"); // the unit popups' kit (TargetFrameDropDown's template)
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "ActionBar.xml"); // BENILLA_FALLBACK_ICON
// Before UnitPopup: that file reads ITEM_QUALITY_COLORS at FILE SCOPE, and its
// declarer is UIParent (ref UIParent.lua:65) since 1888.
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "UIParent.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml"); // BENILLA_FALLBACK_ICON
// Before UnitPopup: that file reads ITEM_QUALITY_COLORS at FILE SCOPE, and its
// declarer is UIParent (ref UIParent.lua:65) since 1888.
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
+9 -3
View File
@@ -202,7 +202,7 @@ pub(super) const LOOT_UI: &[&str] = &[
/// so `UnitFrame.xml` and `CombatFeedback.xml` come first — the reference's own toc order.
/// (This entry used to be our one `UnitFrames.xml`, and this note used to say "when they are
/// migrated, this becomes the stock pair". They are; it did.)
/// * **`ActionBar.xml`** for `MainMenuExpBar` — the third bar of that prefix call — and for
/// * **`Interface\FrameXML\MainMenuBar.xml`** for `MainMenuExpBar` — the third bar of that prefix call — and for
/// `ShowWatchedReputationBarText` / `HideWatchedReputationBarText`, which the window's
/// show/hide pair calls.
/// * **`UIPanelTemplates.xml`** for `PanelTemplates_SetNumTabs`/`_SetTab`, the last two lines of
@@ -259,7 +259,9 @@ pub(super) const CHARACTER_UI: &[&str] = &[
"Interface\\FrameXML\\PartyFrameTemplates.xml",
"Interface\\FrameXML\\PetFrame.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"MicroMenu.xml",
// The two page files these three need before the window can be OPENED, which is not the same
// as before it can load: `CHARACTERFRAME_SUBFRAMES` lists all five pages by name and
@@ -315,7 +317,11 @@ pub(super) const BAG_UI: &[&str] = &[
// 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.
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.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",
@@ -231,10 +231,20 @@ fn action_button_hover_takes_the_default_corner() {
let mut s = harness(&[
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
"UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
]);
s.register_cvars(crate::cvars::registered_pairs());
s.run("BenillaActionButton_OnEnter(ActionButton3)").unwrap();
s.run("this = ActionButton3 ActionButton_SetTooltip()")
.unwrap();
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
let ok: bool = s
.eval(
@@ -264,8 +274,25 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
let mut s = harness(&[
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"MultiBars.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
"UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"UiPanels.xml",
"Interface\\FrameXML\\UIDropDownMenu.xml",
"ScrollTemplates.xml",
r"Interface\FrameXML\UIPanelTemplates.lua",
r"Interface\FrameXML\UIPanelTemplates.xml",
"KeyBindingsPage.xml",
"OptionsFrame.xml",
"Interface\\FrameXML\\MultiActionBars.xml",
]);
s.register_cvars(crate::cvars::registered_pairs());
s.set_cvar_engine("UberTooltips", "0");
@@ -278,7 +305,8 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
.unwrap()
};
s.run("BenillaActionButton_OnEnter(ActionButton3)").unwrap();
s.run("this = ActionButton3 ActionButton_SetTooltip()")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip.default == nil").unwrap(),
"off: the main bar's plate is owner-anchored, not the default corner"
@@ -297,7 +325,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
// All three members of the ref's LEFT set, by frame — membership is not gated on visibility,
// and the two vertical bars are hidden until their option is ticked.
for bar in ["MultiBarBottomRight", "MultiBarRight", "MultiBarLeft"] {
s.run(&format!("BenillaActionButton_OnEnter({bar}Button1)"))
s.run(&format!("this = {bar}Button1 ActionButton_SetTooltip()"))
.unwrap();
assert_eq!(
seat(&s),
@@ -308,7 +336,7 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
// And the CVar back on restores the corner — the fork is a fork, not a one-way door.
s.set_cvar_engine("UberTooltips", "1");
s.run("BenillaActionButton_OnEnter(MultiBarBottomRightButton1)")
s.run("this = MultiBarBottomRightButton1 ActionButton_SetTooltip()")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip.default ~= nil").unwrap(),
@@ -326,8 +354,18 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
let mut s = harness(&[
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"StanceBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
"UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
]);
s.register_cvars(crate::cvars::registered_pairs());
s.set_shapeshift_forms(vec![benilla_ui::script::ShapeshiftFormView {
@@ -341,7 +379,7 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
s.resolve();
s.run("BenillaShapeshiftButton_OnEnter(ShapeshiftButton1)")
s.run("this = ShapeshiftButton1 ShapeshiftButton1:GetScript(\"OnEnter\")()")
.unwrap();
assert!(
s.eval::<bool>("return GameTooltip.default ~= nil").unwrap(),
@@ -349,7 +387,7 @@ fn ubertooltips_off_seats_stance_plates_beside_the_button() {
);
s.set_cvar_engine("UberTooltips", "0");
s.run("BenillaShapeshiftButton_OnEnter(ShapeshiftButton1)")
s.run("this = ShapeshiftButton1 ShapeshiftButton1:GetScript(\"OnEnter\")()")
.unwrap();
assert_eq!(
s.eval::<String>(
@@ -371,7 +409,16 @@ fn buff_hover_hangs_below_left_of_the_button() {
let mut s = harness(&[
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\Fonts.xml",
"UIParent.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"MoneyFrame.xml",
"GameTooltip.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"Interface\\FrameXML\\TextStatusBar.lua",
"Interface\\FrameXML\\TextStatusBar.xml",
"Interface\\FrameXML\\BuffFrame.xml",
@@ -102,6 +102,7 @@ fn harness_with_bags() -> UiScript {
fn seed_items(s: &mut UiScript) {
let mut inv: InventorySlots = Default::default();
inv[1] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -148,6 +149,7 @@ fn seed_items(s: &mut UiScript) {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -308,6 +310,7 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
// Break the equipped helm: instance pair (0, 40); the template stays authored-full.
let mut inv: InventorySlots = Default::default();
inv[1] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: Some((0, 40)),
@@ -68,7 +68,12 @@ fn load_tot() -> UiScript {
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "Interface\\FrameXML\\ActionButtonTemplate.xml");
load_xml(&s, "ActionBar.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\GlobalStrings.lua");
load_xml(&s, "Interface\\FrameXML\\MainMenuBar.xml");
load_xml(&s, "Interface\\FrameXML\\ActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\BonusActionBarFrame.xml");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.lua");
load_xml(&s, "Interface\\FrameXML\\TextStatusBar.xml");
load_xml(&s, "Interface\\FrameXML\\BuffFrame.xml");
@@ -483,7 +483,10 @@ fn load_pet_menu_frames(s: &UiScript) {
"Interface\\FrameXML\\PetFrame.xml",
"Cooldown.xml",
"Interface\\FrameXML\\ActionButtonTemplate.xml",
"ActionBar.xml",
"Interface\\FrameXML\\GlobalStrings.lua",
"Interface\\FrameXML\\MainMenuBar.xml",
"Interface\\FrameXML\\ActionBarFrame.xml",
"Interface\\FrameXML\\BonusActionBarFrame.xml",
"PetActionBar.xml",
] {
load_xml(s, file);
+2 -1
View File
@@ -31,7 +31,8 @@
//! The refresh model is one diff-and-fire: the feed rebuilds the pushed list each frame
//! (identity, active, castable, cooldown) and fires `UPDATE_SHAPESHIFT_FORMS` on any change — the
//! real client's learn/unlearn edge (`0x5e9c20`/`0x5e9fe0`) plus its separate per-state repaint events
//! collapse into one, which `StanceBar.xml` documents as its deliberate divergence. Cooldown
//! collapse into one (our `StanceBar.xml` documented that as its deliberate divergence; since 1938 the
//! stock shapeshift bar registers the reference's six events, and this one event drives them all). Cooldown
//! remainders re-push only on a [`Cooldowns::generation`] change (`state.rs`'s own churn gate) —
//! the engine extrapolates the sweep from the absolute start between pushes.
+27 -9
View File
@@ -19,8 +19,11 @@
//! Craft-vs-TradeSkill routing are all byte-confirmed. **The header law landed too:** rows group
//! by the created item's `(ItemClass, ItemSubClass)`, named from `ItemSubClass.dbc`, two-level
//! sort (0446; the filter family on top of it, 0452) — this feed resolves each recipe's `group`
//! ([`resolve_recipe`]), so the book is no longer flat. Remaining gap: the spell-focus tool never
//! rendered red (no client-side proximity model — the server refuses the cast).
//! ([`resolve_recipe`]), so the book is no longer flat. The spell-focus tool never renders red, and
//! that is **faithful, not a gap** (wow-re `tradeskill-tools-and-spell-focus.md`, §5-carved): the
//! reference's own `hasTool` for a focus is the literal `1.0` with no predicate anywhere, and the
//! client holds neither a focus id nor a radius to test proximity with. The server refusing the
//! cast is the whole of the feedback, there as here.
use std::time::Instant;
@@ -310,20 +313,35 @@ fn resolve_recipe(
.flatten()
.map_or((None, 0, 0), |(g, it, il)| (Some(g), it, il));
// Tools: the two totem items (present-not-consumed — a Blacksmith Hammer) + the spell focus
// (Anvil/Forge/Cooking Fire; never red — module doc INTERIM).
// Tools — **the spell FOCUS first, then `Totem[0]`, then `Totem[1]`** (wow-re
// `tradeskill/scratch/tradeskill-tools-and-spell-focus.md`, §5-carved: `0x4ff980`'s own push
// order). We had the totems leading, which reverses the Requirements line for every recipe
// that needs both — a Blacksmithing anvil recipe reads "Blacksmith Hammer, Anvil" instead of
// "Anvil, Blacksmith Hammer".
//
// **The focus is unconditionally satisfied, and that is VERIFIED rather than a gap.** Its
// `hasTool` is the instruction immediate `push 0x3ff00000 / push 0` — the literal `1.0`, with
// no predicate at all: an exhaustive absence (17 calls, 7 absolute operands, **zero FPU**;
// `SpellRec+0x3c` has exactly two readers image-wide; the GameObject descriptor block carries
// no focus id or radius, so the client could not test proximity even if it wanted to). The
// reference NEVER reddens an Anvil line. `BuildColoredListString` is still byte-faithful and
// still reds an unmet TOTEM — that half is live.
//
// A tool whose template has not landed contributes **no pair at all** (the reference drops it
// and fires `CMSG_ITEM_QUERY_SINGLE`, so the arity grows between calls) — which is what the
// `if let Some(info)` below already does, for the same reason.
let mut tools = Vec::new();
if d.requires_spell_focus != 0 {
if let Some(name) = focus.and_then(|f| f.catalog.name(d.requires_spell_focus)) {
tools.push((name.to_string(), true));
}
}
for &t in d.totems.iter().filter(|&&t| t != 0) {
let have = count_of(&store.0, items, t, InventoryScope::CARRIED) > 0;
if let Some(info) = items.template(t, 0, commands) {
tools.push((info.name.clone(), have));
}
}
if d.requires_spell_focus != 0 {
if let Some(name) = focus.and_then(|f| f.catalog.name(d.requires_spell_focus)) {
tools.push((name.to_string(), true));
}
}
let cd = cooldowns.info(spell_id, 0, Some(d), now);
Some(TradeSkillRecipe {
+47 -23
View File
@@ -110,38 +110,62 @@ impl Default for ChunkShading {
///
/// Returns `None` for a chunk the hole mask emptied (it draws nothing: no mesh, no entity).
pub fn chunk_to_mesh(chunk: &ChunkMesh, shading: &ChunkShading) -> Option<Mesh> {
if chunk.indices.len() < 3 {
chunks_to_mesh(&[(chunk, shading)])
}
/// [`chunk_to_mesh`] over several chunks of ONE tile as one mesh — the terrain CELL (decision
/// 1944): every per-chunk fact the shader reads (the layer indices, the alpha/shadow slot) is
/// already baked per vertex, and a tile's chunks share its material, so concatenating them
/// changes nothing a pixel can see and turns sixteen draws into one. Positions are absolute,
/// so no per-chunk transform is folded. A hole-emptied chunk contributes nothing; a cell of
/// nothing but holes is `None`.
pub fn chunks_to_mesh(parts: &[(&ChunkMesh, &ChunkShading)]) -> Option<Mesh> {
let live: Vec<&(&ChunkMesh, &ChunkShading)> =
parts.iter().filter(|(c, _)| c.indices.len() >= 3).collect();
if live.is_empty() {
return None;
}
let positions: Vec<[f32; 3]> = chunk
.positions
.iter()
.map(|p| wow_to_bevy(*p).to_array())
.collect();
// Prefer authored MCNR normals (continuous across MCNK borders — no seams); else flat.
let normals: Vec<[f32; 3]> = if chunk.normals.len() == chunk.positions.len() {
chunk
.normals
let total: usize = live.iter().map(|(c, _)| c.positions.len()).sum();
let mut positions: Vec<[f32; 3]> = Vec::with_capacity(total);
let mut normals: Vec<[f32; 3]> = Vec::with_capacity(total);
let mut uvs: Vec<[f32; 2]> = Vec::with_capacity(total);
let mut uv1: Vec<[f32; 2]> = Vec::with_capacity(total);
let mut colors: Vec<[f32; 4]> = Vec::with_capacity(total);
let mut indices: Vec<u32> = Vec::with_capacity(live.iter().map(|(c, _)| c.indices.len()).sum());
for (chunk, shading) in live {
let base = u32::try_from(positions.len()).expect("a cell's vertex count fits u32");
let chunk_positions: Vec<[f32; 3]> = chunk
.positions
.iter()
.map(|n| wow_to_bevy(*n).to_array())
.collect()
} else {
computed_normals(&positions, &chunk.indices)
};
let li = shading.layers;
let col = [li[0] as f32, li[1] as f32, li[2] as f32, li[3] as f32];
let uv1 = [shading.alpha as f32, shading.shadow];
let n = positions.len();
.map(|p| wow_to_bevy(*p).to_array())
.collect();
if chunk.normals.len() == chunk.positions.len() {
normals.extend(chunk.normals.iter().map(|n| wow_to_bevy(*n).to_array()));
} else {
normals.extend(computed_normals(&chunk_positions, &chunk.indices));
}
let li = shading.layers;
let col = [li[0] as f32, li[1] as f32, li[2] as f32, li[3] as f32];
let n = chunk_positions.len();
uv1.extend(std::iter::repeat_n(
[shading.alpha as f32, shading.shadow],
n,
));
colors.extend(std::iter::repeat_n(col, n));
uvs.extend_from_slice(&chunk.uvs);
indices.extend(chunk.indices.iter().map(|i| i + base));
positions.extend(chunk_positions);
}
let mut mesh = Mesh::new(
PrimitiveTopology::TriangleList,
RenderAssetUsages::RENDER_WORLD,
);
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, chunk.uvs.clone());
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, vec![uv1; n]);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, vec![col; n]);
mesh.insert_indices(Indices::U32(chunk.indices.clone()));
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_1, uv1);
mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, colors);
mesh.insert_indices(Indices::U32(indices));
Some(mesh)
}
+1 -1
View File
@@ -41,7 +41,7 @@ pub use model::{
mod adt;
mod terrain;
mod wdt;
pub use adt::{chunk_to_mesh, AdtLoader, AdtTile, ChunkShading};
pub use adt::{chunk_to_mesh, chunks_to_mesh, AdtLoader, AdtTile, ChunkShading};
pub use wdt::{WdtIndex, WdtIndexLoader};
mod blp;
pub use blp::{BlpImageLoader, BlpLoaderSettings, BlpVariant};
+2
View File
@@ -207,7 +207,9 @@ pub use skill_lines::{
load_skill_line_catalog, SkillLineCatalog, SkillLineInfo, SkillRaceClass, SlaInfo,
};
mod spell_focus;
mod spell_mechanic;
pub use spell_focus::{load_spell_focus_catalog, SpellFocusCatalog};
pub use spell_mechanic::{load_spell_mechanic_catalog, SpellMechanicCatalog};
mod talents;
pub use talents::{load_talent_catalog, Talent, TalentCatalog, TalentTabInfo, MAX_TALENT_RANK};
mod spell_visual;
@@ -0,0 +1,107 @@
//! SpellMechanic.dbc — the vocabulary that fills `SPELL_FAILED_PREVENTED_BY_MECHANIC`'s `%s`,
//! turning "Can't do that while %s" into "Can't do that while stunned" (decision 1948).
//!
//! It is the crowd-control ladder's other half: the exemption scan (decision 1941) reports the
//! blocking aura's mechanic as an **id**, and this names it. Without the table the arm's refusal
//! displayed its template with the specifier unfilled, which is the loose end 1941 recorded.
//!
//! The reference reads the same store (`0xc0d7c4`) from `0x6e2190`, the `0x8d` argument arm
//! (wow-re `cast-fail-strings.md` line 120).
//!
//! Layout byte-checked on the raw 5875 file (a struct-unpack dump: **27 records × 10 fields,
//! record size 40**, string block 246) — the same shape as `SpellFocusObject.dbc`: `ID@0` · the
//! 8-locale `Name` block (enUS first ⇒ **Name = column 1**) · its flags word (9). Anchor rows:
//! 5 "fleeing" · 7 "rooted" · 12 "stunned" · 17 "polymorphed".
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::dbc::{parse, str_at, u32_at};
use crate::Chain;
const SPELL_MECHANIC: &str = "DBFilesClient\\SpellMechanic.dbc";
const SPELL_MECHANIC_FIELDS: usize = 10;
const COL_NAME_ENUS: usize = 1;
/// `SpellMechanic.Id → Name` — the "Can't do that while stunned" vocabulary.
///
/// The names are **lower-case and adjectival** in the shipped data ("stunned", "asleep",
/// "polymorphed"), which is what makes them read as the tail of that sentence rather than as a
/// title. Nothing here capitalises them; the reference does not either.
pub struct SpellMechanicCatalog {
names: HashMap<u32, String>,
}
impl SpellMechanicCatalog {
/// The display name for a mechanic id, or `None` for 0/unknown — which is exactly the case
/// the refusal treats as "no mechanic named", falling back to the arm's own reason.
pub fn name(&self, mechanic_id: u32) -> Option<&str> {
self.names.get(&mechanic_id).map(String::as_str)
}
pub fn len(&self) -> usize {
self.names.len()
}
pub fn is_empty(&self) -> bool {
self.names.is_empty()
}
}
fn schema() -> Schema {
let mut s = Schema::new("SpellMechanic");
for i in 0..SPELL_MECHANIC_FIELDS {
let ty = if i == COL_NAME_ENUS {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// Load SpellMechanic.dbc from the patch chain into a [`SpellMechanicCatalog`].
pub fn load_spell_mechanic_catalog(chain: &mut Chain) -> Result<SpellMechanicCatalog> {
let bytes = chain
.read_file(SPELL_MECHANIC)
.with_context(|| format!("reading {SPELL_MECHANIC}"))?;
let rs = parse(&bytes, schema(), "SpellMechanic.dbc")?;
let mut names = HashMap::new();
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(name) = str_at(&rs, r, COL_NAME_ENUS) {
names.insert(id, name);
}
}
Ok(SpellMechanicCatalog { names })
}
#[cfg(test)]
mod tests {
use super::*;
/// The rows the crowd-control ladder actually names, on the real build-5875 file. A column
/// slip lands on another locale or the flags word and fails loudly. Skips without client data.
///
/// The four ids here are the ones decision 1941's arms can report, cross-checked against the
/// `Spell.dbc` mechanic columns pinned in `spell_catalog`: Fear's `Mechanic` is 5, Frost Nova's
/// `EffectMechanic[1]` is 7, Polymorph's is 17.
#[test]
fn real_spell_mechanic_names_the_crowd_control_rows() {
let data = crate::wow_data_or_skip!();
let mut chain = crate::open_chain(&data).expect("open chain");
let cat = load_spell_mechanic_catalog(&mut chain).expect("load SpellMechanic.dbc");
assert_eq!(cat.name(5), Some("fleeing"));
assert_eq!(cat.name(7), Some("rooted"));
assert_eq!(cat.name(11), Some("ensnared"));
assert_eq!(cat.name(12), Some("stunned"));
assert_eq!(cat.name(17), Some("polymorphed"));
assert_eq!(cat.name(0), None, "0 = no mechanic, and no line to fill");
assert_eq!(cat.name(999), None);
assert_eq!(cat.len(), 27, "the 5875 file's full row count");
}
}
+22
View File
@@ -346,8 +346,21 @@ pub enum SessionEvent {
/// `[start, …waypoints…, endpoint]` — at constant (arc-length) speed over `duration_ms`. Every
/// waypoint is carried, so a curved patrol reads as its real path, not a straight `start → endpoint`
/// shortcut. A `Stop` / zero-duration / <2-point move clears the path (`path` empty).
/// `MSG_MOVE_TIME_SKIPPED` — an observed mover reported that its own client skipped `lag_ms`
/// of movement simulation. **Not a pose**: nothing about where the unit is has changed, only
/// how far its clock has run. The app advances that mover's relay-chain wire stamp by it, the
/// reference's `[CMovement+0xac] += lag` (`0x603b40` → `0x601560` → `0x61ab90`). Ignoring it
/// leaves our copy of the chain permanently short, so the mover's next real packet reads as a
/// step `lag` too large and is scheduled that much late. Decision 1935.
MoveTimeSkipped { guid: u64, lag_ms: u32 },
MonsterMove {
guid: u64,
/// `SMSG_MONSTER_MOVE_TRANSPORT` only: the transport whose frame `start` and every `path`
/// point are expressed in — deck-local offsets, composed through the transport's live pose
/// rather than read as world coordinates. `None` = the ordinary absolute path. A packet
/// naming a transport is also the *attach* signal: the unit rides that deck from here
/// (decision 1936).
transport: Option<u64>,
start: [f32; 3],
/// The server's per-move spline counter — echoed in `CMSG_MOVE_SPLINE_DONE` when this spline
/// drives our own player (Charge/knockback/taxi); ignored for a creature's walk.
@@ -361,6 +374,8 @@ pub enum SessionEvent {
duration_ms: u32,
/// `true` ⇒ a 3-D flight path (keep the spline's Z); `false` ⇒ a ground walk whose Z the app
/// re-derives from the terrain under the unit (see the renderer's creature ground-clamp).
/// The re-derive is world-space and so does not apply to a `transport` path: there is no
/// terrain under a deck, and the wire Z is already the deck-local height (decision 1936).
flying: bool,
/// `SPLINEFLAG_RUNMODE` — the path is travelled at run speed. Its **absence** forces
/// `MOVEFLAG_WALK_MODE` on for the unit the spline moves (decision 1758).
@@ -777,6 +792,13 @@ pub enum SessionEvent {
},
/// Put an item instance on the client's fixed 30 s use cooldown (`SMSG_ITEM_COOLDOWN`).
ItemCooldown { item_guid: u64, spell_id: u32 },
/// `SMSG_ITEM_TIME_UPDATE` — the seconds left on one duration-limited item instance (a
/// conjured stone, a holiday gift, a timed quest item). The item's own `ITEM_FIELD_DURATION`
/// carries the same number and is sent to the owner, but vmangos's writer says outright that
/// the field is not what the client displays from (`Item::SendTimeUpdate`,
/// `Objects/Item.cpp:1094`) — same shape as the enchant countdown (decision 0920).
/// `seconds == 0` = expired / no timer. Decision 1933.
ItemTime { item_guid: u64, seconds: u32 },
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` — the seconds left on one item's TEMPORARY enchant, in the
/// named enchant slot. The **only** feed for the tooltip's countdown: the item's own
/// `ITEM_FIELD_ENCHANTMENT` duration field is never read for it (wow-re
@@ -257,6 +257,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
item_guid,
spell_id,
}],
ServerPacket::ItemTime { item_guid, seconds } => {
vec![SessionEvent::ItemTime { item_guid, seconds }]
}
ServerPacket::ItemEnchantTime {
item_guid,
slot,
@@ -640,8 +643,12 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
ServerPacket::TriggerCinematic { cinematic_id } => {
vec![SessionEvent::CinematicTriggered { cinematic_id }]
}
ServerPacket::MoveTimeSkipped { guid, lag_ms } => {
vec![SessionEvent::MoveTimeSkipped { guid, lag_ms }]
}
ServerPacket::MonsterMove {
guid,
transport,
start,
spline_id,
path,
@@ -652,6 +659,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
run_mode,
} => vec![SessionEvent::MonsterMove {
guid,
transport,
start: v3(start),
spline_id,
path: path.into_iter().map(v3).collect(),
@@ -283,6 +283,19 @@ pub fn movement(info: &MovementInfo) -> Vec<u8> {
body
}
/// Body of `CMSG_MOVE_TIME_SKIPPED` (client): the mover's **full 8-byte** guid then the skipped
/// milliseconds (vmangos `MoveTimeSkipped::ReadFromWorldPacket`, `Server/Packets/Movement.cpp:10-14`
/// — `recv_data >> guid` is `ObjectGuid`'s plain `read<uint64>`, `ObjectGuid.cpp:180`, not the
/// packed reader `PackedGuidReader` takes). Note the asymmetry with the server's own relay of the
/// same fact, `MSG_MOVE_TIME_SKIPPED`, which writes `GetPackGUID()` (`MovementHandler.cpp:1014`):
/// inbound is full, outbound is packed.
pub fn move_time_skipped(guid: u64, lag_ms: u32) -> Vec<u8> {
let mut body = Vec::with_capacity(12);
body.extend_from_slice(&guid.to_le_bytes());
body.extend_from_slice(&lag_ms.to_le_bytes());
body
}
/// Body of `CMSG_MOVE_SPLINE_DONE` (client): the `MovementInfo` at the ride's endpoint, the
/// `spline_id` being acknowledged (matched against the server's newest spline for our mover), and a
/// trailing float the server reads-and-discards (`MoveSplineDone::ReadFromWorldPacket` does an
+67 -2
View File
@@ -247,12 +247,25 @@ impl ItemInfo {
///
/// It is a separate predicate from [`Self::opens_loot`] because it sits at a different point
/// in the dispatcher's order — *before* the quest-starter and readable arms, where the loot
/// arm sits after them. A wrapper template whose instance is no longer wrapped takes the
/// begin-wrap cursor path instead (local, no packet; not built yet).
/// arm sits after them. A wrapper template whose instance is *not* wrapped takes the
/// begin-wrap cursor path instead ([`Self::begins_gift_wrap`]) — local, no packet.
pub fn unwraps_gift(&self, instance_flags: u32) -> bool {
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED != 0
}
/// Does a right-click on this instance **arm the gift-wrap cursor**? The *other* side of the
/// same fork [`Self::unwraps_gift`] tests (`0x5d8d00` arm 2): template [`ITEM_FLAG_WRAPPER`]
/// with the instance's [`ITEM_DYNFLAG_WRAPPED`] **clear** — a piece of wrapping paper rather
/// than a wrapped present.
///
/// **Nothing is sent on this click.** `0x5d8dba` calls `0x5edea0`, whose only three acts are
/// `LockItem 0x4953e0` on the paper, `SetCursorBaseMode(2)` and `CursorSetMode(2)` — a purely
/// local arm (wow-re `object-layer/scratch/gift-wrap-law.md`, §5-carved). What completes it is
/// the next **left**-click on a container slot, which sends [`wrap_item`]. Decision 1934.
pub fn begins_gift_wrap(&self, instance_flags: u32) -> bool {
self.flags & ITEM_FLAG_WRAPPER != 0 && instance_flags & ITEM_DYNFLAG_WRAPPED == 0
}
/// Does a right-click on this template send `CMSG_OPEN_ITEM` to **loot it open**? The
/// reference's arm #8 (`0x5d8f7c: test al,4` → the `0x5edc80` emitter): a **bare** template
/// [`ITEM_FLAG_LOOTABLE`] test.
@@ -663,6 +676,21 @@ pub fn open_item(bag_index: u8, slot: u8) -> Vec<u8> {
vec![bag_index, slot]
}
/// Body of `CMSG_WRAP_ITEM` (VERIFIED vmangos `WrapItem::ReadFromWorldPacket`,
/// `Server/Packets/Item.cpp:121-127` + `.h:191-201`; opcode 467 `Opcodes_1_12_1.h:468`):
/// `giftBag`, `giftSlot`, `itemBag`, `itemSlot` — all `uint8`, the **paper's** position first and
/// the target's second, the same bag addressing as [`use_item`].
///
/// The server refuses every ineligible target with an `EQUIP_ERR_*` of its own
/// (`HandleWrapItemOpcode`: equipped, a bag, soulbound, stackable, unique, already wrapped, or
/// mid-cast) — so a refusal reaches the player as an `SMSG_INVENTORY_CHANGE_FAILURE` line, not as
/// a silently eaten click. On success the *target* item keeps its guid and takes the paper's
/// `WrappedGift` entry, gains `ITEM_FIELD_GIFTCREATOR` and `ITEM_DYNFLAG_WRAPPED`, and one paper
/// is destroyed — all as ordinary field updates, no answering packet of its own.
pub fn wrap_item(gift_bag: u8, gift_slot: u8, item_bag: u8, item_slot: u8) -> Vec<u8> {
vec![gift_bag, gift_slot, item_bag, item_slot]
}
/// Body of `CMSG_AUTOEQUIP_ITEM` (VERIFIED vmangos `AutoEquipItem::ReadFromWorldPacket`,
/// `Server/Packets/Item.cpp:17-21` + `.h:31-39`; opcode 266 `Opcodes_1_12_1.h:269`): source
/// `srcbag`/`srcslot` (both `uint8`), the same bag addressing as [`use_item`]. The real client
@@ -760,6 +788,21 @@ pub(super) fn read_inventory_change_failure(
Ok((reason, required_level, item_guid, bag_slot))
}
/// Read `SMSG_ITEM_TIME_UPDATE` (VERIFIED vmangos `Item::SendTimeUpdate`,
/// `Objects/Item.cpp:1096-1106`): the item guid, then the remaining **seconds**
/// (`ITEM_FIELD_DURATION`, which vmangos counts down in seconds — `Item::UpdateDuration`,
/// `Item.cpp:243-257`). No trailing player guid on this one, unlike its enchant sibling: the
/// packet is `(8 + 4)` bytes and vmangos sizes it exactly so.
///
/// Sent on world enter for every carried duration item (`Player::SendItemDurations`,
/// `Player.cpp:12007`) and again whenever one is created or its duration changes
/// (`Player.cpp:19782-19785`).
pub(super) fn read_item_time(r: &mut &[u8]) -> io::Result<(u64, u32)> {
let item_guid = read_u64_le(r)?;
let seconds = read_u32_le(r)?;
Ok((item_guid, seconds))
}
/// Read `SMSG_ITEM_ENCHANT_TIME_UPDATE` (VERIFIED vmangos
/// `WorldPackets::Item::ItemEnchantTimeUpdate::AppendBodyTo`, `Server/Packets/Item.cpp:161-169`):
/// item guid, enchant **slot**, remaining **seconds**, then the owning player's guid (present from
@@ -792,6 +835,12 @@ mod tests {
assert_eq!(auto_equip_item(255, 30), vec![255, 30]);
}
#[test]
fn wrap_item_body_paper_first() {
// 467: giftbag, giftslot, itembag, itemslot (Item.cpp:121-127) — the PAPER's pair leads.
assert_eq!(wrap_item(255, 23, 255, 24), vec![255, 23, 255, 24]);
}
#[test]
fn auto_store_bag_item_body() {
// 267: srcbag, srcslot, dstbag (Item.cpp:23-28).
@@ -878,6 +927,22 @@ mod tests {
assert_eq!(r.len(), 8);
}
/// `SMSG_ITEM_TIME_UPDATE`'s body: guid then seconds, and **nothing after** — the enchant
/// twin's trailing player guid is not on this packet (vmangos sizes it `8 + 4`). Pinned
/// because the two share a handler and it would be easy to copy the wrong tail.
#[test]
fn item_time_reads_guid_then_seconds() {
let mut buf = Vec::new();
buf.extend_from_slice(&0x4000_0000_0000_00f7u64.to_le_bytes()); // itemGuid
buf.extend_from_slice(&1800u32.to_le_bytes()); // seconds
let mut r = &buf[..];
assert_eq!(
read_item_time(&mut r).unwrap(),
(0x4000_0000_0000_00f7, 1800)
);
assert!(r.is_empty(), "the body is exactly 12 bytes");
}
#[test]
fn inventory_failure_nonlevel_branch_has_no_u32() {
// Any failed reason != 1 skips requiredLevel: item1Guid u64, item2Guid u64, bagSlot u8.
+5 -5
View File
@@ -100,8 +100,8 @@ pub use client::{
channel_set_owner, channel_unban, channel_unmoderator, channel_unmute, char_create,
creature_query, force_speed_ack, full_guid, join_channel, knock_back_ack, leave_channel,
messagechat, messagechat_channel, messagechat_kind, messagechat_whisper, move_flag_ack,
move_spline_done, movement, pet_name_query, ping, played_time, query_time, random_roll,
teleport_ack, text_emote,
move_spline_done, move_time_skipped, movement, pet_name_query, ping, played_time, query_time,
random_roll, teleport_ack, text_emote,
};
pub use combat_log::{
DamageShield, DispelFailed, EnchantmentLog, EnvironmentalDamageLog, ExecuteLog, PartyKillLog,
@@ -150,9 +150,9 @@ pub use instance::{
};
pub use items::{
auto_equip_item, auto_store_bag_item, destroy_item, item_query, open_item, set_ammo,
split_item, swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry,
ItemUseSpell, UseItemTarget, BAG_PLAYER_INVENTORY, ITEM_DYNFLAG_UNLOCKED, ITEM_DYNFLAG_WRAPPED,
ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER, SLOT_BAG_FIRST, SLOT_PACK_FIRST,
split_item, swap_inv_item, swap_item, use_item, wrap_item, ItemDamage, ItemInfo,
ItemSpellEntry, ItemUseSpell, UseItemTarget, BAG_PLAYER_INVENTORY, ITEM_DYNFLAG_UNLOCKED,
ITEM_DYNFLAG_WRAPPED, ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER, SLOT_BAG_FIRST, SLOT_PACK_FIRST,
};
pub use loot::{
autostore_loot_item, loot, loot_error, loot_master_give, loot_money, loot_release, loot_roll,
@@ -1,10 +1,19 @@
//! `SMSG_MONSTER_MOVE` — the server-dictated creature movement path.
//! `SMSG_MONSTER_MOVE` — the server-dictated creature movement path — and its transport twin.
//!
//! One packet: a mover, its `start`, a spline id, a `moveType`-switched final facing, then (unless it is
//! a `Stop`) the spline block — a flag word, a duration, and the waypoints. Decodes into the full
//! travel-order polyline [`ServerPacket::MonsterMove::path`] the app rides at constant (arc-length) speed
//! (decision 0097). The wire packs the waypoints two ways, keyed by the `Flying`/`Mask_CatmullRom` flag —
//! see [`read_monster_move_spline`].
//!
//! **`SMSG_MONSTER_MOVE_TRANSPORT` is the same packet with one field inserted**: a second packed guid,
//! the transport's, immediately after the mover's — and every coordinate in the body is then an
//! **offset in that transport's frame**, not world space (decision 1936). vmangos writes exactly that
//! (`Movement::MoveSplineInit::Launch`, `spline/MoveSplineInit.cpp:138-170`: the same builder, the
//! opcode swapped and the transport packed guid appended, after `CalculatePassengerOffset` has moved
//! the start into deck space), and it computes such a path against the transport's **model** navmesh
//! rather than the map's (`PathFinder::calculate`, `Maps/PathFinder.cpp:76-84`), so the waypoints are
//! deck-local too. One reader serves both, keyed by [`read_monster_move`]'s `on_transport`.
use std::io;
@@ -39,8 +48,12 @@ const SPLINE_FLAG_RUNMODE: u32 = 0x100;
/// reading a tail anyway over-ran the body, the `0x00dd: failed to fill whole buffer` skip that dropped
/// every creature stop). Otherwise the spline block yields the full polyline `[start, …waypoints…,
/// endpoint]`.
pub(super) fn read_monster_move(r: &mut &[u8]) -> io::Result<ServerPacket> {
pub(super) fn read_monster_move(r: &mut &[u8], on_transport: bool) -> io::Result<ServerPacket> {
let guid = read_packed_guid(r)?;
// `SMSG_MONSTER_MOVE_TRANSPORT` only: the deck the whole body is expressed on, packed like the
// mover's guid and read immediately after it. Everything below is unchanged — the two opcodes
// share the builder server-side and differ by this one field.
let transport = on_transport.then(|| read_packed_guid(r)).transpose()?;
let start = Vector3d::read(r)?;
// The server's per-move spline counter. Discarded for a creature (nothing acks its walk), but
// the client echoes it back in `CMSG_MOVE_SPLINE_DONE` when a spline drives its OWN player
@@ -62,6 +75,7 @@ pub(super) fn read_monster_move(r: &mut &[u8]) -> io::Result<ServerPacket> {
Ok(if move_type == MONSTER_MOVE_STOP {
ServerPacket::MonsterMove {
guid,
transport,
start,
spline_id,
path: Vec::new(),
@@ -93,6 +107,7 @@ pub(super) fn read_monster_move(r: &mut &[u8]) -> io::Result<ServerPacket> {
};
ServerPacket::MonsterMove {
guid,
transport,
start,
spline_id,
path,
@@ -204,6 +219,52 @@ mod tests {
}
}
/// `SMSG_MONSTER_MOVE_TRANSPORT` is `SMSG_MONSTER_MOVE` with the transport's packed guid
/// inserted between the mover's guid and the start position — everything after it parses
/// identically, and every coordinate is then a deck-local offset. Reading the *plain* opcode's
/// layout for it would consume the transport guid as the start's X and Y and land the creature
/// somewhere near the map origin; reading the transport layout for a plain packet is the same
/// mistake in reverse. Both directions are pinned here (decision 1936).
#[test]
fn monster_move_transport_reads_the_deck_guid_first() {
let mut body = Vec::new();
write_packed_guid(0x55, &mut body).unwrap(); // the mover
write_packed_guid(0x2000_0000_0000_0007, &mut body).unwrap(); // the transport
for f in [1.5f32, -2.5, 0.75] {
body.extend_from_slice(&f.to_le_bytes()); // deck-local start
}
body.extend_from_slice(&7u32.to_le_bytes()); // splineId
body.push(0); // moveType: a plain move
body.extend_from_slice(&0u32.to_le_bytes()); // spline flags: ground, walk
body.extend_from_slice(&2_000u32.to_le_bytes()); // duration
append_linear_path(&mut body, &[[1.5, -2.5, 0.75], [4.0, -2.5, 0.75]]);
match parse_server(opcode::SMSG_MONSTER_MOVE_TRANSPORT, &body).expect("parses") {
ServerPacket::MonsterMove {
guid,
transport,
start,
path,
..
} => {
assert_eq!(guid, 0x55);
assert_eq!(transport, Some(0x2000_0000_0000_0007));
assert_eq!((start.x, start.y, start.z), (1.5, -2.5, 0.75));
assert_eq!(path.len(), 2, "start + endpoint, both deck-local");
assert_eq!((path[1].x, path[1].y, path[1].z), (4.0, -2.5, 0.75));
}
_ => panic!("expected MonsterMove"),
}
// The plain opcode over the SAME bytes must NOT come back with a transport — and it
// mis-reads the head, which is exactly why the two layouts cannot share one arm.
match parse_server(opcode::SMSG_MONSTER_MOVE, &body) {
Ok(ServerPacket::MonsterMove { transport, .. }) => assert_eq!(transport, None),
Ok(_) => panic!("expected MonsterMove"),
Err(_) => {} // an under-run is equally fine: the point is that it is not the same read
}
}
#[test]
fn monster_move_stop_has_no_tail() {
// A STOP (moveType 1) is head-only: no flags/duration/points. Parsing the tail anyway
@@ -18,6 +18,20 @@ pub(super) const MOVEMENT_FLAG_SWIMMING: u32 = 0x20_0000;
pub(super) const MOVEMENT_FLAG_SPLINE_ENABLED: u32 = 0x40_0000;
pub(super) const MOVEMENT_FLAG_SPLINE_ELEVATION: u32 = 0x400_0000;
/// Read `MSG_MOVE_TIME_SKIPPED` — one observed mover's **packed** guid and the milliseconds its
/// own client skipped (VERIFIED: the reference's handler `0x603b40` reads a packed guid through
/// `0x642ed0`, resolves under `TYPEMASK_UNIT`, then reads a plain `u32`; vmangos relays exactly
/// that shape, `MovementHandler.cpp:1011-1017`).
///
/// Note the **asymmetry with the client's own send**, which is the same fact in the other
/// direction and writes a *plain* 8-byte guid (see [`super::client::move_time_skipped`]).
/// Inbound packed, outbound plain — that is the reference's own encoding, not a slip.
pub(super) fn read_move_time_skipped(r: &mut &[u8]) -> io::Result<(u64, u32)> {
let guid = crate::wire::read_packed_guid(r)?;
let lag_ms = read_u32_le(r)?;
Ok((guid, lag_ms))
}
/// Read a wire `MovementInfo` — the body shared by every `MSG_MOVE_*` (and the teleport ack). Surfaces
/// `flags`/`position`/`orientation`/`timestamp`/`fall_time`, the **transport pose** ([`TransportPose`],
/// present iff `MOVEFLAG_ON_TRANSPORT` — this is how a boarded rider's `MSG_MOVE_*` heartbeat carries
@@ -60,6 +60,13 @@ pub const SMSG_TRIGGER_CINEMATIC: u16 = 0x00FA;
pub const CMSG_NEXT_CINEMATIC_CAMERA: u16 = 0x00FB;
pub const CMSG_COMPLETE_CINEMATIC: u16 = 0x00FC;
pub const SMSG_MONSTER_MOVE: u16 = 0x00DD;
/// `SMSG_MONSTER_MOVE_TRANSPORT` (VERIFIED vmangos `Opcodes_1_12_1.h`: 686) — the transport twin of
/// [`SMSG_MONSTER_MOVE`]: the identical body with the **transport's packed guid inserted after the
/// mover's**, and every coordinate in it a deck-local offset instead of a world position. Sent for a
/// unit pathing *on* a transport — a pet following its owner aboard a boat, anything a player pulls
/// onto a deck (`Movement::MoveSplineInit::Launch`, `spline/MoveSplineInit.cpp:146-154`). Body in
/// [`super::monster_move::read_monster_move`]; decision 1936.
pub const SMSG_MONSTER_MOVE_TRANSPORT: u16 = 0x02AE; // 686
pub const SMSG_INITIALIZE_FACTIONS: u16 = 0x0122;
/// A faction became visible in the reputation pane (VERIFIED vmangos `Opcodes_1_12_1.h`: 291,
/// sender `ReputationMgr::SendVisible`) — body one `u32` reputation-list slot. The server sets the
@@ -198,6 +205,30 @@ pub const SMSG_PET_CAST_FAILED: u16 = 0x0138; // 312
/// and a trailing float the server `read_skip`s (VERIFIED vmangos `Opcodes_1_12_1.h`: 713;
/// `MoveSplineDone::ReadFromWorldPacket`).
pub const CMSG_MOVE_SPLINE_DONE: u16 = 0x02C9; // 713
/// The client's report that its movement clock **skipped** — a stall, a long frame, a window that
/// went to the background — carrying the mover and how many milliseconds went missing (VERIFIED
/// vmangos `Opcodes_1_12_1.h`: 718). Body in [`super::client::move_time_skipped`].
///
/// vmangos adds the reported `lag` to the mover's `stime`/`ctime` so its own movement clock stays
/// aligned with ours — **and hangs a 1.12-specific transport fix off it**: a player whose last
/// movement packet was the *first* one carrying `MOVEFLAG_ONTRANSPORT` is flagged `JustBoarded`,
/// and the next `CMSG_MOVE_TIME_SKIPPED` makes the server re-send that transport's out-of-range
/// update followed by a fresh create (`HandleMoveTimeSkippedOpcode`,
/// `Handlers/MovementHandler.cpp:989-1019`, comment *"fix an 1.12 client problem with
/// transports"*). A client that never sends this never gets that refresh. Decision 1935.
pub const CMSG_MOVE_TIME_SKIPPED: u16 = 0x02CE; // 718
/// The **broadcast twin** of [`CMSG_MOVE_TIME_SKIPPED`] (VERIFIED vmangos `Opcodes_1_12_1.h`: 793):
/// the server relays one mover's skip to everyone watching it, so their copies of that unit's
/// movement clock move with it (`MovementHandler.cpp:1011-1017`, packed guid + the same `u32`).
///
/// **Not ignorable.** The reference registers a handler for it (`0x6035e0` → `0x603b40`) that reads
/// a packed guid, resolves under `TYPEMASK_UNIT`, and adds the lag to `[CMovement+0xac]` — the
/// observed unit's last-seen **wire** movement timestamp, the monotonic maximum the relay chain
/// hangs off (`0x601560` → `0x61ab90`). That cell is benilla-app's relay-chain `last_wire_ms`
/// (`net::motion::relay::RelayChain`, decision 0615, which models `+0xa8`/`+0xac` by name). Drop the packet and the chain's copy stays `lag` ms short of the sender's
/// forever: the mover's next real packet then reads as a step `lag` too large and is scheduled
/// that much late — a hitch on that unit, not a lost packet. Decision 1935.
pub const MSG_MOVE_TIME_SKIPPED: u16 = 0x0319; // 793
pub const SMSG_ATTACKSTART: u16 = 0x0143; // 323
pub const SMSG_ATTACKSTOP: u16 = 0x0144; // 324
pub const SMSG_ATTACKERSTATEUPDATE: u16 = 0x014A; // 330
@@ -228,6 +259,21 @@ pub const SMSG_COOLDOWN_EVENT: u16 = 0x0135; // 309
pub const SMSG_CLEAR_COOLDOWN: u16 = 0x01DE; // 478
pub const SMSG_COOLDOWN_CHEAT: u16 = 0x01E1; // 481
/// `SMSG_ITEM_TIME_UPDATE` (VERIFIED vmangos `Opcodes_1_12_1.h`: 490) — the
/// remaining lifetime, in **seconds**, of one duration-limited item instance (a conjured stone, a
/// holiday gift, a timed quest item). Shares its handler with
/// [`SMSG_ITEM_ENCHANT_TIME_UPDATE`]: both read an 8-byte item guid first and then fork on the
/// opcode (`0x5e4f69 sub eax,0x1ea ; je …` / `0x5e4f74 dec eax` — wow-re
/// `ui/scratch/weapon-enchant-info.md` §7).
///
/// The item's own `ITEM_FIELD_DURATION` (wire field 15) carries the same number and is sent to the
/// owner, but vmangos's writer says in as many words that it is not what the client displays —
/// *"Though the client has the information in the item's data field, we have to send
/// SMSG_ITEM_TIME_UPDATE to display the remaining time"* (`Item::SendTimeUpdate`,
/// `Objects/Item.cpp:1094`). Same shape as the enchant countdown (decision 0920): a client-local
/// deadline whose only feed is the packet. Body in [`super::items::read_item_time`]; decision 1933.
pub const SMSG_ITEM_TIME_UPDATE: u16 = 0x01EA; // 490
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` (VERIFIED vmangos `Opcodes_1_12_1.h`: 491) — the **only** source
/// of a temporary enchant's remaining time. The item's `ITEM_FIELD_ENCHANTMENT` duration field is
/// never read for it: the reference's tooltip reads a client-side per-slot deadline array
@@ -365,6 +411,17 @@ pub const CMSG_USE_ITEM: u16 = 0x00AB; // 171
/// guid** (`HandleOpenItemOpcode` → `SendLoot(item, LOOT_CORPSE)`), so the loot window opens over
/// a thing in your bag rather than a corpse in the world.
pub const CMSG_OPEN_ITEM: u16 = 0x00AC; // 172
/// Wrap an item in a piece of gift wrapping (VERIFIED vmangos `Opcodes_1_12_1.h`: 467). Body in
/// [`super::items::wrap_item`]: `giftBag`, `giftSlot`, `itemBag`, `itemSlot` — the **paper first**,
/// then the thing being wrapped.
///
/// The other half of the gift arc: [`CMSG_OPEN_ITEM`] above *unwraps* one, and this is how a gift
/// gets made. Right-clicking a `ITEM_FLAG_WRAPPER` template whose instance is **not** already
/// wrapped sends nothing at all — it arms a purely local wrap cursor (`0x5edea0`: the item lock
/// `0x4953e0` @`0x5edeb5` + `CursorSetMode(2)`, disarmed by `0x5eded0` from inside `ClearCursor`;
/// wow-re `ui/scratch/right-click-open.md`, use-dispatcher arm 2, VERIFIED) — and *this* opcode
/// goes out when the armed cursor is then clicked on a target. Decision 1934.
pub const CMSG_WRAP_ITEM: u16 = 0x01D3; // 467
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 177. Body in [`super::gameobj_use`] — a full guid, the
/// GameObject to use (decision 0236). Not interchangeable with `CMSG_LOOT`: the server rejects a
/// GameObject guid on `CMSG_LOOT`, so a chest opens its loot through this opcode.
+26 -1
View File
@@ -135,8 +135,19 @@ pub enum ServerPacket {
TriggerCinematic {
cinematic_id: u32,
},
/// `MSG_MOVE_TIME_SKIPPED` — an observed mover's own client skipped `lag_ms` of movement
/// simulation, so every watcher's copy of its wire clock must move with it (layout in
/// [`super::movement::read_move_time_skipped`]). Decision 1935.
MoveTimeSkipped {
guid: u64,
lag_ms: u32,
},
MonsterMove {
guid: u64,
/// `SMSG_MONSTER_MOVE_TRANSPORT` only: the transport whose frame `start` and every `path`
/// point are expressed in (deck-local offsets, not world coordinates). `None` = the plain
/// `SMSG_MONSTER_MOVE`, whose coordinates are absolute. Decision 1936.
transport: Option<u64>,
start: Vector3d,
/// The server's per-move spline counter — echoed back in `CMSG_MOVE_SPLINE_DONE` when the
/// spline drives our own player (Charge/knockback/taxi); ignored for a creature's walk.
@@ -645,6 +656,12 @@ pub enum ServerPacket {
item_guid: u64,
spell_id: u32,
},
/// `SMSG_ITEM_TIME_UPDATE` — how long one duration-limited item instance has left, in
/// **seconds** (layout in [`super::items::read_item_time`]). Decision 1933.
ItemTime {
item_guid: u64,
seconds: u32,
},
/// `SMSG_ITEM_ENCHANT_TIME_UPDATE` — how long one item's TEMPORARY enchant has left (layout in
/// [`super::items::read_item_enchant_time`]). The tooltip's countdown has no other source
/// (decision 0920).
@@ -1496,7 +1513,14 @@ impl ServerPacket {
ServerPacket::CompressedMoves { .. } => "SMSG_COMPRESSED_MOVES".into(),
ServerPacket::DestroyObject { .. } => "SMSG_DESTROY_OBJECT".into(),
ServerPacket::TriggerCinematic { .. } => "SMSG_TRIGGER_CINEMATIC".into(),
ServerPacket::MonsterMove { .. } => "SMSG_MONSTER_MOVE".into(),
ServerPacket::MoveTimeSkipped { .. } => "MSG_MOVE_TIME_SKIPPED".into(),
ServerPacket::MonsterMove { transport, .. } => {
if transport.is_some() {
"SMSG_MONSTER_MOVE_TRANSPORT".into()
} else {
"SMSG_MONSTER_MOVE".into()
}
}
ServerPacket::PlayerMove { opcode, .. } => format!("MSG_MOVE relay ({opcode:#06x})"),
ServerPacket::Teleport { .. } => "MSG_MOVE_TELEPORT_ACK".into(),
ServerPacket::NewWorld { .. } => "SMSG_NEW_WORLD".into(),
@@ -1572,6 +1596,7 @@ impl ServerPacket {
ServerPacket::CancelAutoRepeat => "SMSG_CANCEL_AUTO_REPEAT".into(),
ServerPacket::SpellCooldownList { .. } => "SMSG_SPELL_COOLDOWN".into(),
ServerPacket::ItemCooldown { .. } => "SMSG_ITEM_COOLDOWN".into(),
ServerPacket::ItemTime { .. } => "SMSG_ITEM_TIME_UPDATE".into(),
ServerPacket::ItemEnchantTime { .. } => "SMSG_ITEM_ENCHANT_TIME_UPDATE".into(),
ServerPacket::CooldownEvent { .. } => "SMSG_COOLDOWN_EVENT".into(),
ServerPacket::ClearCooldown { .. } => "SMSG_CLEAR_COOLDOWN".into(),
+10 -1
View File
@@ -244,7 +244,12 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
opcode::SMSG_COMPRESSED_MOVES => ServerPacket::CompressedMoves {
packets: read_compressed_moves(&mut r)?,
},
opcode::SMSG_MONSTER_MOVE => monster_move::read_monster_move(&mut r)?,
opcode::MSG_MOVE_TIME_SKIPPED => {
let (guid, lag_ms) = movement::read_move_time_skipped(&mut r)?;
ServerPacket::MoveTimeSkipped { guid, lag_ms }
}
opcode::SMSG_MONSTER_MOVE => monster_move::read_monster_move(&mut r, false)?,
opcode::SMSG_MONSTER_MOVE_TRANSPORT => monster_move::read_monster_move(&mut r, true)?,
opcode::MSG_MOVE_TELEPORT_ACK => {
let guid = read_packed_guid(&mut r)?;
let counter = read_u32_le(&mut r)?;
@@ -575,6 +580,10 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
spell_id,
}
}
opcode::SMSG_ITEM_TIME_UPDATE => {
let (item_guid, seconds) = items::read_item_time(&mut r)?;
ServerPacket::ItemTime { item_guid, seconds }
}
opcode::SMSG_ITEM_ENCHANT_TIME_UPDATE => {
let (item_guid, slot, seconds) = items::read_item_enchant_time(&mut r)?;
ServerPacket::ItemEnchantTime {
@@ -55,6 +55,23 @@ impl WorldWriter {
)
}
/// Wrap an item in gift paper (`CMSG_WRAP_ITEM`, layout in [`messages::wrap_item`]) — the
/// completion of the local wrap cursor a `ITEM_FLAG_WRAPPER` item's right-click arms. The
/// paper's `(bag, slot)` leads, the target's follows. Success is silent (field updates on the
/// target, one paper destroyed); every refusal comes back as `SMSG_INVENTORY_CHANGE_FAILURE`.
pub fn wrap_item(
&mut self,
gift_bag: u8,
gift_slot: u8,
item_bag: u8,
item_slot: u8,
) -> Result<()> {
self.send(
opcode::CMSG_WRAP_ITEM,
&messages::wrap_item(gift_bag, gift_slot, item_bag, item_slot),
)
}
/// Equip a bag item (`CMSG_AUTOEQUIP_ITEM`, layout in [`messages::auto_equip_item`]) — the
/// server picks the destination slot. Success arrives as inventory-slot values deltas (and the
/// visible-item change everyone renders); refusal as `SMSG_INVENTORY_CHANGE_FAILURE`.
@@ -92,6 +92,18 @@ impl WorldWriter {
self.send(opcode::CMSG_MOVE_NOT_ACTIVE_MOVER, &body)
}
/// Report that our movement clock skipped `lag_ms` (`CMSG_MOVE_TIME_SKIPPED`, layout in
/// [`messages::move_time_skipped`]) for the named mover. The server folds the number into its
/// own copy of our movement clock — and, when we have just boarded a transport, answers by
/// re-sending that transport's create update (the 1.12 boarding fix; see
/// [`opcode::CMSG_MOVE_TIME_SKIPPED`]).
pub fn move_time_skipped(&mut self, guid: u64, lag_ms: u32) -> Result<()> {
self.send(
opcode::CMSG_MOVE_TIME_SKIPPED,
&messages::move_time_skipped(guid, lag_ms),
)
}
/// Acknowledge that a server-authored spline (Charge/knockback/taxi — an `SMSG_MONSTER_MOVE`
/// addressed to our own guid) finished: `CMSG_MOVE_SPLINE_DONE` with a `MovementInfo` at the
/// ride's endpoint and the `spline_id` we were driven by. The server sets `SplineDonePending` for
@@ -229,18 +229,20 @@ fn set_faction_standing_parses_and_decodes() {
/// opcode numbers.
#[test]
fn unknown_opcode_decodes_to_packet_dropped() {
// 0x0319 = MSG_MOVE_TIME_SKIPPED — assigned in 1.12.1 but deliberately unparsed by benilla.
let packet = messages::parse_server(0x0319, &hx("0102030405")).unwrap();
assert!(matches!(packet, ServerPacket::Other { opcode: 0x0319 }));
// 0x0324 = SMSG_PET_ACTION_SOUND — assigned in 1.12.1 but deliberately unparsed by benilla.
// (The sentinel used to be 0x0319 `MSG_MOVE_TIME_SKIPPED`, which decision 1935 gave a parse
// arm: an observed mover's skipped time has to reach its relay chain or that unit hitches.)
let packet = messages::parse_server(0x0324, &hx("0102030405")).unwrap();
assert!(matches!(packet, ServerPacket::Other { opcode: 0x0324 }));
match decode(packet).as_slice() {
[SessionEvent::PacketDropped {
opcode: 0x0319,
opcode: 0x0324,
unparseable: false,
}] => {}
other => panic!("expected one PacketDropped event, got {other:?}"),
}
// The generated name table: a known opcode resolves, an unassigned number doesn't.
assert_eq!(messages::opcode_name(0x0319), Some("MSG_MOVE_TIME_SKIPPED"));
assert_eq!(messages::opcode_name(0x0324), Some("SMSG_PET_ACTION_SOUND"));
assert_eq!(
messages::opcode_name(messages::opcode::SMSG_UPDATE_OBJECT),
Some("SMSG_UPDATE_OBJECT")
+34 -1
View File
@@ -273,7 +273,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// UseAction(action [, checkCursor [, onSelf]]). **`onSelf` is the self-cast modifier** — the
// third argument `ActionBar.xml`'s `ActionButtonUp(id, onSelf)` has always forwarded and this
// third argument stock `ActionButtonUp(id, onSelf)` forwards on its main-bar branch and this
// host used to drop on the floor, which is why 1.12's twelve `SELFACTIONBUTTON` bindings
// (`ALT-1`…`ALT-=`) had no home. It rides out on [`ActionUse::on_self`] and the app's cast
// resolver reads it. `checkCursor` TRUTHY (numeric nonzero — the
@@ -327,6 +327,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// `ChangeActionBarPage()` — four instructions in the reference (`0x4e7650`: `mov ecx,0xd4;
// call FrameScript_SignalEvent; xor eax,eax; ret` — wow-re `action-bar-toggles.md` §6, `ui.md`):
// it fires `ACTIONBAR_PAGE_CHANGED` and does NOTHING else. The page is FrameXML state
// (`CURRENT_ACTIONBAR_PAGE`, which `ActionBar_PageUp/Down` and the SHIFT-n bindings write before
// calling this), the buttons repaint from `ActionButton_OnEvent`, and the up-arrow's OnEvent
// restamps the numeral. Synchronous, like the reference's `SignalEvent` (1938; the pattern is
// `UpdateSpells` → `SPELLS_CHANGED`, 1924). Zero return values on every path.
g.set(
"ChangeActionBarPage",
lua.create_function(|lua, ()| {
super::tick::fire_event_into(lua, "ACTIONBAR_PAGE_CHANGED", Vec::new());
Ok(())
})?,
)?;
// ── The dynamic-state read API (decision 0137 phase 4): the reference ActionButton.lua's
// whole input surface, answered from the app-pushed per-action state. Return conventions are
// the 1.12 API's own (1/nil booleans; IsActionInRange's nil/0/1 tri-state) — the transcribed
@@ -465,6 +480,24 @@ mod tests {
);
}
#[test]
fn change_action_bar_page_fires_the_event_and_nothing_else() {
let s = UiScript::new().unwrap();
s.run(
"CURRENT_ACTIONBAR_PAGE = 3 \
local f = CreateFrame('Frame') f:RegisterEvent('ACTIONBAR_PAGE_CHANGED') \
f:SetScript('OnEvent', function() SEEN = event .. ':' .. CURRENT_ACTIONBAR_PAGE end) \
N = table.getn({ChangeActionBarPage()})",
)
.unwrap();
assert_eq!(
s.eval::<String>("return SEEN").unwrap(),
"ACTIONBAR_PAGE_CHANGED:3",
"the event fires synchronously, and the page is whatever FrameXML wrote"
);
assert_eq!(s.eval::<i64>("return N").unwrap(), 0, "zero return values");
}
#[test]
fn action_snapshot_reads_and_use_queues() {
let mut s = UiScript::new().unwrap();
@@ -287,6 +287,11 @@ pub struct InvSlotView {
/// and the reference renders all 7 from it — but a 1.12 server fills only PERM and TEMP, so in
/// practice that is what an inspect hover shows.
pub enchants: Vec<super::EnchantView>,
/// **The instance's remaining LIFETIME in milliseconds** — the doll twin of
/// [`super::container::ContainerSlot::duration_ms`]. `None` = no timer. In practice an
/// equipped duration item is rare (the holiday masks are the shipped case), but the line law
/// is one law and the doll hover runs the same builder. Decision 1933.
pub duration_ms: Option<u64>,
}
/// The inventory-slot snapshot: index 0 = ammo, 1..=19 the equipment slots, 20..=23 the four
+180 -2
View File
@@ -105,6 +105,13 @@ pub struct ContainerSlot {
/// hands over the row's name plus the three facts the line law needs to place and paint it.
/// Empty = unenchanted, or the enchant DBC never loaded. Decisions 0915/0920.
pub enchants: Vec<super::EnchantView>,
/// **The instance's remaining LIFETIME in milliseconds** — a duration-limited item counting
/// down to its own destruction (a conjured stone, a holiday gift, a timed quest item). Its
/// only feed is `SMSG_ITEM_TIME_UPDATE`, parked as an absolute deadline and recomputed on
/// read ([`crate::items::Items::duration_remaining_display_ms`] in benilla-app), floored to
/// the whole second so the snapshot moves once a second rather than every frame.
/// `None` = no timer, which is nearly every item. Decision 1933.
pub duration_ms: Option<u64>,
/// The petition this item names, when it is a signable charter — **line 3 of the tooltip's
/// emission law**, between the NAME and the green `ITEM_SIGNABLE` line.
///
@@ -263,6 +270,47 @@ impl super::UiScript {
std::mem::take(&mut self.model_mut().container_destroys)
}
/// **Arm the gift-wrap cursor** on the paper at `(bag, slot)` — the app's call when a
/// right-click takes the reference's begin-wrap arm (`ItemInfo::begins_gift_wrap`).
///
/// Three acts, exactly the three `0x5edea0` performs (wow-re
/// `object-layer/scratch/gift-wrap-law.md`): the paper's slot reads **locked**, the displayed
/// cursor becomes **mode 2** (`Interface\Cursor\Cast.blp` — the same art the cast cursor
/// uses, table `0x853b88` slot 2), and **nothing goes on the wire**. What completes it is the
/// next left-click on a container slot. Decision 1934.
pub fn arm_gift_wrap(&mut self, bag: i64, slot: u32) {
let mut model = self.model_mut();
model.pending_wrap = Some(PendingWrap { bag, slot });
model.ui_cursor = Some(UiCursorMode::Cast);
model.ui_cursor_dirty = true;
cursor::queue_lock_changed(&mut model, bag, slot);
}
/// **Cancel an armed gift wrap and nothing else** — disarm, unlock the paper, reset the
/// cursor. Deliberately narrower than `ClearCursor`, which also drops any held payload:
/// what wow-re records is that a right-click "uses the item and cancels the wrap", not by
/// which of the two routes, so this touches only the half the finding actually names.
pub fn cancel_gift_wrap(&mut self) -> Option<PendingWrap> {
let mut model = self.model_mut();
let wrap = model.pending_wrap.take()?;
cursor::queue_lock_changed(&mut model, wrap.bag, wrap.slot);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
Some(wrap)
}
/// Is a gift wrap armed, and on which slot? Read by the app to decide whether a right-click
/// is cancelling one.
pub fn gift_wrap_armed(&self) -> Option<PendingWrap> {
self.model_ref().pending_wrap
}
/// Drain the `(giftBag, giftSlot, itemBag, itemSlot)` quads a completed wrap queued — the app
/// resolves each pair to the wire's bag/slot addressing and sends `CMSG_WRAP_ITEM`.
pub fn take_container_wraps(&mut self) -> Vec<(i64, u32, i64, u32)> {
std::mem::take(&mut self.model_mut().container_wraps)
}
/// The mode the last FrameXML cursor call armed — `None` after a `ResetCursor`. This is the
/// *value* of the most recent write; whether a write happened at all is
/// [`Self::take_cursor_write`], and that is what the app acts on (decision 1061).
@@ -285,6 +333,19 @@ impl super::UiScript {
}
}
/// **The armed gift wrap** — which piece of wrapping paper the next container click will spend.
///
/// The reference holds this as globals beside the displayed-cursor mode rather than as a cursor
/// *payload*; the distinction is load-bearing and is why this is its own type instead of a
/// [`cursor::CursorPayload`] variant (see [`super::Model::pending_wrap`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct PendingWrap {
/// The paper's container id, in the same bag space every cursor verb speaks.
pub bag: i64,
/// The paper's 1-based slot within that container.
pub slot: u32,
}
/// A **displayed-cursor override** the FrameXML cursor family arms (wow-re cursor-system.md §7): the
/// single "displayed mode" (`0xbe2c2c`) the real client swaps to while a UI element wants a non-base
/// cursor, restored to the base mode by `ResetCursor`. The app maps each to the matching
@@ -330,6 +391,49 @@ pub enum UiCursorMode {
///
/// Returns whether the caller should repaint (the source-slot lock or the held payload changed).
fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool {
// **The gift-wrap completion, ahead of the whole gesture** (decision 1934). `0x4f9b30` is the
// *only* entry to the wrap sender `0x5edfc0` image-wide — which is why the paper doll cannot
// finish a wrap and an equipped item is therefore unwrappable — and it takes this branch
// before it looks at the cursor at all.
if let Some(wrap) = model.pending_wrap {
// An empty or unresolved slot **bails and leaves the wrap armed** — the reference's own
// edge. Nothing is spent and the cursor keeps its mode, so the next click can still land
// on a real item.
if !model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.is_some_and(|s| s.item_id != 0)
{
return false;
}
// **The paper has to still be there.** `0x5edfef` bails when the stashed gift GUID no
// longer resolves, and — the edge worth transcribing — it then sends *and clears*
// nothing: the wrap stays armed and the paper's slot stays locked. So this returns
// without touching either.
if !model
.containers
.get(&wrap.bag)
.and_then(|c| c.slots.get(&wrap.slot))
.is_some_and(|s| s.item_id != 0)
{
return false;
}
// No eligibility test of any kind. The wrap arm reads each item's `ITEM_FIELD_CONTAINED`
// and nothing else — not flags, not stack count, not bind state — and the client ships six
// `ERR_CANT_WRAP_*` strings for the server to answer with (`SMSG_INVENTORY_CHANGE_FAILURE`
// reasons 43-48). Same law as the openable click (decision 0896): gating locally would eat
// the click in silence and cost the player their error line.
model.pending_wrap = None;
model.container_wraps.push((wrap.bag, wrap.slot, bag, slot));
// The paper unlocks and the cursor resets **immediately**, not on the server's answer:
// `0x5edfc0` calls `0x5eded0` at `0x5ee0aa`, right after the send. The target is never
// locked at all.
cursor::queue_lock_changed(model, wrap.bag, wrap.slot);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
return true;
}
match model.cursor.take() {
None => {
let picked = model
@@ -653,7 +757,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let held = matches!(
&model.cursor,
Some(CursorPayload::Item(c)) if c.bag == bag && c.slot == slot
);
) ||
// An armed gift wrap locks its paper for as long as it stands (`0x4953e0` at
// `0x5edeb5`) — the same dimming a held item's source slot shows, and released by
// the send or by any cancel rather than by the server (decision 1934).
matches!(
&model.pending_wrap,
Some(w) if w.bag == bag && w.slot == slot
);
(
model
.containers
@@ -858,7 +969,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::{ContainerMove, ContainerSlot, ContainerState};
use super::{ContainerMove, ContainerSlot, ContainerState, PendingWrap, UiCursorMode};
use crate::script::UiScript;
fn backpack() -> ContainerState {
@@ -866,6 +977,7 @@ mod tests {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -903,6 +1015,71 @@ mod tests {
}
}
/// **The gift-wrap state machine** (decision 1934), all four of its edges. Armed by the app's
/// right-click on a wrapper, the arm locks the paper and paints cursor mode 2 and sends
/// nothing; the next LEFT-click on a container slot spends it; an empty slot bails and leaves
/// it armed; and any `ClearCursor` cancels it.
#[test]
fn an_armed_gift_wrap_spends_on_the_next_container_click() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
// The arm: nothing on the wire, the paper reads locked, the cursor is mode 2, and the
// held-item predicate stays FALSE — this is a cursor mode, not a payload.
s.arm_gift_wrap(0, 1);
assert_eq!(s.gift_wrap_armed(), Some(PendingWrap { bag: 0, slot: 1 }));
assert!(s.take_container_wraps().is_empty(), "arming sends nothing");
assert_eq!(s.take_cursor_write(), Some(Some(UiCursorMode::Cast)));
assert!(
s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap(),
"the paper dims while the wrap stands"
);
assert!(
!s.eval::<bool>("return CursorHasItem()").unwrap(),
"the wrap writes no payload, so every stock CursorHasItem() gate stays shut"
);
// A click on an EMPTY slot bails and leaves the wrap armed — the reference's own edge.
s.eval::<()>("PickupContainerItem(0, 9)").unwrap();
assert_eq!(s.gift_wrap_armed(), Some(PendingWrap { bag: 0, slot: 1 }));
assert!(s.take_container_wraps().is_empty());
// A click on a real item spends it: the paper's pair leads, as the wire does.
s.eval::<()>("PickupContainerItem(0, 4)").unwrap();
assert_eq!(s.take_container_wraps(), vec![(0, 1, 0, 4)]);
assert_eq!(s.gift_wrap_armed(), None);
assert_eq!(
s.take_cursor_write(),
Some(None),
"the cursor resets on the send, not on the server's answer"
);
assert!(
s.cursor_payload().is_none(),
"the completing click is spent on the wrap, not on picking the target up"
);
}
/// Any `ClearCursor` cancels an armed wrap — the reference tests it FIRST inside
/// `ClearCursor 0x495190` and does so ungated by either parameter, so all 70 of its call
/// sites cancel (decision 1934).
#[test]
fn clear_cursor_cancels_an_armed_gift_wrap() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.arm_gift_wrap(0, 1);
let _ = s.take_cursor_write();
s.eval::<()>("ClearCursor()").unwrap();
assert_eq!(s.gift_wrap_armed(), None);
assert_eq!(s.take_cursor_write(), Some(None), "and the mode goes back");
assert!(
!s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap(),
"the paper unlocks with the cancel — the lock never waited on a server"
);
// …and the next container click is an ordinary pickup again.
s.eval::<()>("PickupContainerItem(0, 1)").unwrap();
assert!(s.take_container_wraps().is_empty());
assert!(s.cursor_payload().is_some(), "a plain pickup, as before");
}
#[test]
fn container_snapshot_reads() {
let mut s = UiScript::new().unwrap();
@@ -1032,6 +1209,7 @@ mod tests {
state.slots.insert(
5,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
+11
View File
@@ -321,6 +321,16 @@ pub(super) fn item_link_name(link: Option<&str>) -> String {
/// item payload also un-locks its source slot; an already-empty cursor is not a transition (no
/// event).
pub(crate) fn clear_cursor(model: &mut Model) {
// **An armed gift wrap dies with any cancel** (decision 1934), and this is the FIRST thing
// the reference's `ClearCursor 0x495190` does — `0x5edf10` is its opening act and it is
// **ungated by either parameter**, including `ClearCursor(0)`, the flavour that deliberately
// keeps an ordinary held item's lock. So all 70 of its call sites cancel a wrap. The paper
// unlocks and the cursor mode goes back to the base.
if let Some(wrap) = model.pending_wrap.take() {
queue_lock_changed(model, wrap.bag, wrap.slot);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
}
match model.cursor.take() {
Some(CursorPayload::Item(item)) => {
queue_cursor_update(model);
@@ -741,6 +751,7 @@ mod tests {
slots.insert(
1,
crate::script::container::ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -286,6 +286,7 @@ mod tests {
fn doll_slots() -> crate::script::InventorySlots {
let mut slots: crate::script::InventorySlots = Default::default();
slots[1] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -303,6 +304,7 @@ mod tests {
enchants: Vec::new(),
});
slots[11] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -320,6 +322,7 @@ mod tests {
enchants: Vec::new(),
});
slots[19] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -344,6 +347,7 @@ mod tests {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
@@ -487,6 +491,7 @@ mod tests {
});
// An equipped bag in Bag0Slot (id 20): its equip_slots is the four bag slots.
slots[20] = Some(InvSlotView {
duration_ms: None,
already_bound: false,
bar_placeable: true,
durability: None,
@@ -526,6 +531,7 @@ mod tests {
slots.insert(
3,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
+2 -2
View File
@@ -167,8 +167,8 @@ pub use char_stats::{
pub use chat_send::ChatSend;
pub use chat_window::ChatWindowLook;
pub use container::{
BagAutoStore, ContainerMove, ContainerSlot, ContainerState, EnchantView, PetitionSlotView,
RandomPropertyView, UiCursorMode,
BagAutoStore, ContainerMove, ContainerSlot, ContainerState, EnchantView, PendingWrap,
PetitionSlotView, RandomPropertyView, UiCursorMode,
};
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
pub use cursor::{
+14
View File
@@ -862,6 +862,18 @@ pub(crate) struct Model {
/// stack) — the popup-confirmed destroy (decision 0216 §3), drained by the app into
/// `CMSG_DESTROYITEM`.
pub(crate) container_destroys: Vec<(i64, u32, u32)>,
/// **The armed gift wrap** — the `(bag, slot)` of a piece of wrapping paper whose right-click
/// took the reference's begin-wrap arm (`0x5edea0`: lock the paper, cursor mode 2, no packet).
/// `None` = nothing armed, which is nearly always. Decision 1934.
///
/// Deliberately **not** a [`cursor::CursorPayload`]: the reference writes no payload global at
/// all here, only the displayed-cursor mode, so `CursorHasItem()` stays nil and none of the
/// thirteen stock `CursorHasItem()` gates open. Making it a payload would silently open every
/// one of them — the same trap decision 1677's mode-5 note names for the vendor cursor.
pub(crate) pending_wrap: Option<container::PendingWrap>,
/// `(giftBag, giftSlot, itemBag, itemSlot)` quads a completed wrap queued, drained by the app
/// into `CMSG_WRAP_ITEM`. The paper's pair leads, as the wire does.
pub(crate) container_wraps: Vec<(i64, u32, i64, u32)>,
/// The Lua-set **displayed-cursor override** (`ShowContainerSellCursor`/`ShowMerchantSellCursor`/
/// `ShowBuybackSellCursor`/`ShowInspectCursor` set it, `ResetCursor` clears it; [`container`] +
/// [`merchant`]) — the single "displayed mode" the real client keeps at `0xbe2c2c`, restored to
@@ -1704,6 +1716,8 @@ impl Model {
item_picks: Vec::new(),
enchant_confirms: Vec::new(),
container_destroys: Vec::new(),
pending_wrap: None,
container_wraps: Vec::new(),
ui_cursor: None,
ui_cursor_dirty: false,
container_autoequips: Vec::new(),
+3 -3
View File
@@ -51,9 +51,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// 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.
// known live case is the action bar's grid ring: stock `ActionButton_ShowGrid` passes the
// fourth argument explicitly (`SetVertexColor(1.0, 1.0, 1.0, 0.5)`), so closing this cannot
// silently change that file.
m.set(
"SetVertexColor",
lua.create_function(
@@ -64,6 +64,7 @@ fn shift_compare_fires_seats_and_renders_the_compare_shape() {
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,

Some files were not shown because too many files have changed in this diff Show More