four extra action bars as options, and the bottom band gets one owner

- the four extra action bars become per-character options, off by default, on a new Action Bars options page — the toggles ride the server's own byte (`CMSG_SET_ACTIONBAR_TOGGLES` into `PLAYER_FIELD_BYTES`), so a fresh character's zero is the default for free; turning the bottom-left bar off gives the stance bar its previously unbuilt unraised look, end-caps and 64×64 rings
- bag windows stop drawing straight through a raised bottom bar: the bottom band has ONE owner — the manage pass — the bag stack joins it through a listener seam, and a guard test walks all eight raisable-bar combinations across 24 bar/tenant pairs and refuses overlap
- the item-push card takes the reference's real geometry, closing the gap 0887 measured
- resizable addon windows stop at their bounds instead of pinning and walking across the screen: four per-frame f32s with 0.0 the disable sentinel, the clamp on the interactive drag only with the residual rebated out of the drag delta, `<ResizeBounds>` in XML honoured — and `SetFontString`'s three raises land exactly as the bytes have them

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-21 18:12:19 +07:00
co-authored by Claude Fable 5
parent 6b05373630
commit 348f55e37e
42 changed files with 3620 additions and 336 deletions
+112 -39
View File
@@ -39,11 +39,23 @@
a mount, which has no stack; the engine-side law is ItemInfo::is_consumable, byte-cited
from 0x4e5250).
- Empty-slot grid (ref ActionButton.lua:241-259's ShowGrid/HideGrid, a per-button COUNTER):
simplified to one bar-level BOOLEAN — the cursor is a single global payload, so no two
"shows" can ever nest, and a counter would just be a boolean with extra steps. Unlike the
ref (which HIDES an empty, non-grid slot entirely), our bar always shows all 12 wells; the
grid only swaps an EMPTY slot's ring art between the occupied and empty Quickslot textures
— the default (nothing held) look is unchanged from before this slice, no capture regression.
the counter is BACK, and the argument that retired it is exactly why. 0216 §7 collapsed it
to one global boolean because "the cursor is a single global payload, so no two shows can
ever nest" — true while the payload was the only thing that could ask for a grid. Decision
1500 gives it a second asker (ALWAYS_SHOW_MULTIBARS, which holds the four extra bars'
empty wells open with no payload in hand), and with two independent askers the boolean is
wrong by its own reasoning: dropping a payload would close wells the option is still
asking for. So `button.showgrid` COUNTS, exactly as the reference's does, and
ActionButton_ShowGrid/_HideGrid below are its two verbs.
The count — rather than a flag — is also what the addon corpus already assumes: zBar
REPLACES both verbs and writes `button.showgrid = 1/0`, CT_BarMod writes 0/1/2 and
increments it on OUR ActionButton1-12, and Bartender2 HOOKS ActionButton_ShowGrid at
load — which died on a nil global here until now, the same session-start crash class
1218/1219 fixed for MultiBarLeft.
Unlike the ref (which HIDES an empty, non-grid slot entirely), our MAIN bar always shows
all 12 wells; the grid only swaps an EMPTY slot's ring art between the occupied and empty
Quickslot textures. The ref's extra SetVertexColor(1,1,1,0.5) dim on a grid ring is NOT
ours — that look is unchanged since 0216 and is the director's to move.
- Exhaustion tick + rest fill, XP text (hover/cvar-gated), MaxLevelBar: OUT. The bar's
right-hand cluster is otherwise complete — the backpack toggle + four bag slots live in
BagFrame.xml and the eight micro buttons in MicroMenu.xml (both anchored into
@@ -125,10 +137,6 @@
end
end
-- Whether a placeable cursor payload is held (decision 0216 §7's ACTIONBAR_SHOWGRID/
-- _HIDEGRID pair, simplified to a boolean — see the SCOPE note above).
BENILLA_ACTIONBAR_GRID_SHOWN = false
-- Every live action button — the main bar's 12 plus the multibars' (MultiBars.xml joins
-- at OnLoad). The fan-out roster for the bar-level events below: the ref registers the
-- feedback events per-button; our bar-level fan needs the full set in one place.
@@ -146,23 +154,17 @@
CURRENT_ACTIONBAR_PAGE = 1
-- "Table of actionbar pages and whether they're viewable or not" (the reference's own
-- comment). The page walk below honours it rather than counting, which is what makes the
-- next two lines work at all.
-- whole thing work: a page an extra bar already displays must not ALSO be reachable on the
-- main bar, or paging up shows you the twelve actions already on screen beside it.
--
-- **Pages 5 and 6 are deliberately NOT viewable, and that is the reference's own rule
-- applied to our configuration rather than a deviation from it.**
-- `MultiActionBar_Update` (ref MultiActionBars.lua l.49-60) blanks
-- `VIEWABLE_ACTION_BAR_PAGES[BOTTOMLEFT_ACTIONBAR_PAGE]` whenever MultiBarBottomLeft is
-- SHOWN — 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 below. benilla's two bottom
-- bars are always-on with no `SHOW_MULTI_ACTIONBAR_*` state (0270, the director's call),
-- and they own pages 6 and 5 (MultiBars.xml: BottomLeft = actions 61..72, BottomRight =
-- 49..60), so the two rows are blanked at declaration instead of by a toggle.
--
-- MultiBarLeft/Right (pages 4 and 3) stay viewable because ours ship HIDDEN (1219), which
-- is the same state the reference is in with SHOW_MULTI_ACTIONBAR_3/4 unset.
VIEWABLE_ACTION_BAR_PAGES = {1, 1, 1, 1}
VIEWABLE_ACTION_BAR_PAGES[BOTTOMRIGHT_ACTIONBAR_PAGE] = nil
VIEWABLE_ACTION_BAR_PAGES[BOTTOMLEFT_ACTIONBAR_PAGE] = nil
-- **This is the shipped START state, not a constant: it FOLLOWS the four bar toggles.**
-- All six pages are viewable here because all four extra bars now ship hidden (decision
-- 1500 — they are player options, off by default), and `MultiActionBar_Update`
-- (MultiBars.xml) does the arithmetic from then on: a bar that comes up takes its page out
-- of the cycle, a bar that goes down puts it back. Blanking two rows at declaration was
-- only ever right because visibility was static (0270's always-on bottom pair), and the
-- moment the player can move it a declaration cannot be the answer.
VIEWABLE_ACTION_BAR_PAGES = {1, 1, 1, 1, 1, 1}
-- `ChangeActionBarPage()` takes NO arguments — every reference caller sets
-- CURRENT_ACTIONBAR_PAGE first and then calls this to apply it (l.79, l.100), and
@@ -339,6 +341,9 @@
button.cooldown = getglobal(n .. "Cooldown")
button.flashing = button.flashing or 0
button.flashtime = button.flashtime or 0
-- The grid count, same posture: a foreign button reaching ActionButton_HideGrid with
-- no count would raise on the arithmetic, which is 1199's class of silent-wrong-shape.
button.showgrid = button.showgrid or 0
end
function BenillaActionButton_Action(button)
@@ -369,14 +374,21 @@
-- it; 0954 ended that and cvars.rs now registers the key (default "1", byte-read — the
-- evidence lives on that row), so the fork is live and the stock look is unchanged.
--
-- The ref's LEFT set is {MultiBarBottomRight, MultiBarRight, MultiBarLeft}. Only the first
-- exists here — the two VERTICAL bars are deliberately not shipped (see the header of
-- MultiBars.xml) — so it is the whole test, and the day they land they join it.
-- The ref's LEFT set is {MultiBarBottomRight, MultiBarRight, MultiBarLeft} — the three bars
-- that sit at (or against) the right edge, so their plates have to open toward the centre.
-- All three exist here now: the two VERTICAL bars landed with 1219 and became player
-- options with 1500, and this comment said "only the first exists" for as long as that was
-- true. Membership is by FRAME, not by visibility — a hidden bar is never hovered anyway.
function BenillaActionButton_OnEnter(button)
BenillaActionButton_Adopt(button)
if not GameTooltip then return end -- engine tests load file subsets
if GetCVar("UberTooltips") == "0" then
if button:GetParent() == MultiBarBottomRight then
-- `parent and` first: an orphan button's nil parent would otherwise match a nil
-- MultiBarRight in a harness that loaded no multibars, and anchor the wrong way.
local parent = button:GetParent()
local edgeBar = parent and (parent == MultiBarBottomRight
or parent == MultiBarRight or parent == MultiBarLeft)
if edgeBar then
GameTooltip:SetOwner(button, "ANCHOR_LEFT")
else
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
@@ -400,6 +412,8 @@
button.flashing = 0
button.flashtime = 0
button.rangeTimer = nil
-- ref ActionButton_OnLoad l.104: the empty-well grid count starts at zero.
button.showgrid = 0
-- Every bar button carries its binding COMMAND (0997 main bar, 1008 multibars):
-- the caller names the command family (MultiBars.xml passes MULTIACTIONBAR1/2BUTTON
-- — the ref's own bar↔page mapping quoted there); nothing passed = the main bar's
@@ -673,10 +687,16 @@
local button = this
if not button then return end
BenillaActionButton_Adopt(button)
-- Through the two verbs, ref-verbatim in shape (ActionButton_OnEvent l.317-324) and
-- no longer the 1/0 assignment this arm shipped with: `showgrid` is a COUNT now, and a
-- foreign button handed a bare 0 while the option still wanted its wells open would
-- close them. Each verb repaints, so these arms return rather than fall through.
if event == "ACTIONBAR_SHOWGRID" then
button.showgrid = 1
ActionButton_ShowGrid(button)
return
elseif event == "ACTIONBAR_HIDEGRID" then
button.showgrid = 0
ActionButton_HideGrid(button)
return
elseif event == "UPDATE_BINDINGS" then
BenillaActionButton_PaintHotkey(button)
end
@@ -704,6 +724,46 @@
BenillaActionButton_UpdateUsable(this)
end
-- ── The empty-slot grid (ref ActionButton.lua l.243-260) ────────────────────────────────
-- `button.showgrid` is a COUNT of how many things currently want this button's empty well
-- visible, and it is the reference's own field under its own name — see the SCOPE note for
-- why a count and not a flag, and for the three corpus addons that already write it.
--
-- Today there are exactly two askers and they are independent: the cursor's placeable
-- payload (the ACTIONBAR_SHOWGRID/_HIDEGRID pair, fanned over the whole roster in
-- BenillaActionBar_OnEvent) and ALWAYS_SHOW_MULTIBARS (MultiBars.xml's
-- MultiActionBar_UpdateGridVisibility, over the four extra bars only). Either alone holds
-- the wells open; the count is what lets one let go without closing what the other holds.
--
-- Divergence from the ref, deliberate: ours re-derive the whole button through
-- BenillaActionButton_Update rather than doing their own Show/Hide + vertex tint. There is
-- one paint path in this file and the grid is one of its inputs, so a verb that painted
-- directly would be a second, slightly different answer to "what does an empty well look
-- like" — which is how the two got out of step in the ref (its ShowGrid dims the ring, its
-- Update never restores the dim).
function BenillaActionButton_GridShown(button)
return button.showgrid ~= nil and button.showgrid > 0
end
function ActionButton_ShowGrid(button)
button = button or this
BenillaActionButton_Adopt(button)
button.showgrid = (button.showgrid or 0) + 1
BenillaActionButton_Update(button)
end
function ActionButton_HideGrid(button)
button = button or this
BenillaActionButton_Adopt(button)
-- Clamped at zero. The ref subtracts unconditionally and can go negative — a button
-- that joined the roster mid-payload takes one HIDEGRID it never took a SHOWGRID for,
-- and from then on its wells need TWO shows to open. Ours simply cannot owe.
local count = (button.showgrid or 0) - 1
if count < 0 then count = 0 end
button.showgrid = count
BenillaActionButton_Update(button)
end
function BenillaActionButton_Update(button)
local action = BenillaActionButton_Action(button)
if HasAction(action) then
@@ -730,24 +790,25 @@
button.hotkey:SetVertexColor(0.6, 0.6, 0.6)
button:SetChecked(0)
BenillaActionButton_StopFlash(button)
local grid = BenillaActionButton_GridShown(button)
if button.base then
-- An EMPTY multibar well is the ref's own default: hidden entirely, appearing
-- as a drop-target ring only while a payload is held (ActionButton_Update's
-- showgrid==0 Hide; MultiActionBar_ShowAllGrids). Only the MAIN bar keeps our
-- always-visible wells divergence (the SCOPE note) — its art has sockets;
-- floating rings over bare world would not read as a bar.
-- as a drop-target ring only while the grid is asked for (a held payload, or
-- ALWAYS_SHOW_MULTIBARS). Only the MAIN bar keeps our always-visible wells
-- divergence (the SCOPE note) — its art has sockets; floating rings over bare
-- world would not read as a bar.
button.hotkey:SetText("")
if BENILLA_ACTIONBAR_GRID_SHOWN then
if grid then
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot")
button:Show()
else
button:Hide()
end
else
-- While a payload is held, an empty slot shows the ref's own "no action" ring
-- While the grid is up, an empty slot shows the ref's own "no action" ring
-- (Interface\Buttons\UI-Quickslot) so it reads as a drop target; otherwise the
-- plain occupied-style ring, unchanged from before this slice (SCOPE note).
if BENILLA_ACTIONBAR_GRID_SHOWN then
if grid then
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot")
else
button:SetNormalTexture("Interface\\Buttons\\UI-Quickslot2")
@@ -863,8 +924,20 @@
end)
return
end
-- The payload edge, routed through the two PUBLISHED verbs rather than a private flag
-- — which is what makes Bartender2's `self:Hook("ActionButton_ShowGrid")` see a cursor
-- pick-up at all. The engine fires this pair strictly on the cursor's None↔Some edges
-- (`script/model.rs`), so the count it drives is balanced by construction.
if event == "ACTIONBAR_SHOWGRID" or event == "ACTIONBAR_HIDEGRID" then
BENILLA_ACTIONBAR_GRID_SHOWN = (event == "ACTIONBAR_SHOWGRID")
local show = (event == "ACTIONBAR_SHOWGRID")
for i = 1, table.getn(BENILLA_ACTION_BUTTONS) do
if show then
ActionButton_ShowGrid(BENILLA_ACTION_BUTTONS[i])
else
ActionButton_HideGrid(BENILLA_ACTION_BUTTONS[i])
end
end
return -- each verb already repainted its own button
end
for i = 1, table.getn(BENILLA_ACTION_BUTTONS) do
BenillaActionButton_Update(BENILLA_ACTION_BUTTONS[i])
+130 -31
View File
@@ -728,15 +728,25 @@
-- ── The open-bag stack + layout law (ref ContainerFrame1.bags +
-- updateContainerFrameAnchors, ContainerFrame.lua:472-552, transcribed shape) ─────────────
-- Open windows stack bottom-right UPWARD in show order: the first at the backpack's own
-- anchor (BOTTOMRIGHT, x=0/y=70 — CONTAINER_OFFSET_X/Y), each next one anchored above the
-- previous (TOPRIGHT → BOTTOMRIGHT, CONTAINER_SPACING=0 gap); when a stack would run off the
-- top of the screen, the NEXT window starts a fresh column one CONTAINER_WIDTH further left.
-- Open windows stack bottom-right UPWARD in show order: the first at the bottom-right
-- CLEARANCE CORNER, each next one anchored above the previous (TOPRIGHT → BOTTOMRIGHT,
-- CONTAINER_SPACING=0 gap); when a stack would run off the top of the screen, the NEXT
-- window starts a fresh column one CONTAINER_WIDTH further left.
-- Out of scope (nothing here ever needs it): the ref's UI-scale/BankFrame-collision shrink
-- branch (l.505-539) — benilla has no uiScale CVar and no bank window yet.
--
-- The corner is `CONTAINER_OFFSET_X`/`CONTAINER_OFFSET_Y` — the two GLOBALS
-- `UIParent_ManageFramePositions()` recomputes every time the bottom of the screen fills up
-- or empties (UIParent.xml's var rows). Reading them live is the whole point and it is
-- load-bearing, not tidiness: this file used to keep its own `local
-- BENILLA_CONTAINER_OFFSET_X/Y = 0/70` shadow pair, which meant the stack sat at the
-- no-bars-raised corner FOREVER — with a bottom multibar up, the lowest bag window drew
-- straight through the bar's buttons (director-caught, decision 1499). A shadow copy of a
-- managed value cannot be kept in sync by anyone; the fix is to have no copy.
-- `BenillaBagStack_Relayout` is registered as a managed-position LISTENER at the end of this
-- file, so a bar raised while bags are open re-seats them on the same pass that moves the
-- cast bar and the chat frames.
BENILLA_BAG_STACK = {}
local BENILLA_CONTAINER_OFFSET_X = 0
local BENILLA_CONTAINER_OFFSET_Y = 70
local BENILLA_CONTAINER_WIDTH = 192
local BENILLA_CONTAINER_SPACING = 0
local BENILLA_VISIBLE_CONTAINER_SPACING = 3
@@ -757,19 +767,25 @@
end
function BenillaBagStack_Relayout()
-- Read the clearance corner FRESH on every pass — never cached in a local that outlives
-- the call (see the law above). The `or` fallbacks cover the per-window test harnesses
-- that load this file without UIParent.xml: with no manage pass in the VM the globals
-- are nil, and the no-bars-raised corner is the right answer there by construction.
local offsetX = CONTAINER_OFFSET_X or 0
local offsetY = CONTAINER_OFFSET_Y or 70
local screenHeight = GetScreenHeight()
local freeHeight = screenHeight - BENILLA_CONTAINER_OFFSET_Y
local freeHeight = screenHeight - offsetY
local column = 0
for i = 1, table.getn(BENILLA_BAG_STACK) do
local frame = BENILLA_BAG_STACK[i]
if i == 1 then
frame:SetPoint("BOTTOMRIGHT", -BENILLA_CONTAINER_OFFSET_X, BENILLA_CONTAINER_OFFSET_Y)
frame:SetPoint("BOTTOMRIGHT", -offsetX, offsetY)
elseif freeHeight < frame:GetHeight() then
column = column + 1
freeHeight = screenHeight - BENILLA_CONTAINER_OFFSET_Y
freeHeight = screenHeight - offsetY
frame:SetPoint("BOTTOMRIGHT",
-(column * BENILLA_CONTAINER_WIDTH) - BENILLA_CONTAINER_OFFSET_X,
BENILLA_CONTAINER_OFFSET_Y)
-(column * BENILLA_CONTAINER_WIDTH) - offsetX,
offsetY)
else
frame:SetPoint("BOTTOMRIGHT", BENILLA_BAG_STACK[i - 1], "TOPRIGHT", 0, BENILLA_CONTAINER_SPACING)
end
@@ -777,6 +793,17 @@
end
end
-- The stack is a CONSUMER of two managed vars, so it joins the pass's listener list: the
-- pass writes CONTAINER_OFFSET_X/Y and then calls everything registered here, which is how
-- an open bag stack gets out of the way of a bar raised underneath it. (The reference does
-- the same thing by hard-calling `updateContainerFrameAnchors()` from the end of its own
-- pass — UIParent.lua:1774; ours is that call generalized so the next var consumer does not
-- have to edit UIParent.xml to be noticed. Decision 1499.) Existence-guarded for the
-- harnesses that load this file alone.
if UIParent_RegisterManagedPositionListener then
UIParent_RegisterManagedPositionListener(BenillaBagStack_Relayout)
end
-- ── The bag bar (decision 0216 slice 2) — four 30×30 buttons left of MainMenuBarBackpackButton,
-- mirroring the reference main-bar bag buttons (CharacterBag0..3Slot). No exact reference
-- XML was available to transcribe (not in the session's extracted set) — size (30×30) and
@@ -1113,16 +1140,63 @@
-- in one place and the animation stays in proportion.
BENILLA_ITEM_PUSH_PX_PER_UNIT = 1280
BENILLA_ITEM_PUSH_DURATION = 1.0
BENILLA_ITEM_PUSH_SIZE = 0.03 * BENILLA_ITEM_PUSH_PX_PER_UNIT -- 38.4
BENILLA_ITEM_PUSH_FALL = 0.04 * BENILLA_ITEM_PUSH_PX_PER_UNIT -- 51.2
BENILLA_ITEM_PUSH_DRIFT = 0.01 * BENILLA_ITEM_PUSH_PX_PER_UNIT -- 12.8
-- {time, value} keyframes, linear between (M2 track interpolation 1) — the file's own keys.
-- ── The widget rect, and why the card is not placed against the button ─────────────────
-- A `<Model>` with no `<Size>` and no `setAllPoints` takes its rect from the **M2 header
-- bounding box** (`MD20+0xB4`, max−min per axis), consumed RAW as layout units — unlike
-- CSimpleTexture's texel fallback, there is no AbsDimension conversion. So the ref's
-- ItemAnim widget is **42.41 x 124.72**, nothing like the 36-square everyone assumes from
-- looking at the XML, and **model-space (0,0,0) lands on that rect's BOTTOM-LEFT corner**.
-- (wow-re `system/ui/scratch/modelframe-implicit-size-law.md`, §5-cross-checked: vtable
-- 0x81c5d8 +0x1c/+0x20 -> 0x76d080/0x76d0d0, bbox source 0x713640 at `713661 add esi,0xb4`.
-- The one worker who read `MD20+0xD0` — the VertexBox — got an all-zero box and concluded
-- the widget was degenerate; that wrong premise is exactly what the pair round exists to
-- catch, and it is why this looked unresolvable in 0887.)
--
-- That rect is the whole reason this animation reads the way it does: it stands 124.72 tall
-- off the button's own bottom edge, so the card's travel band reaches **54 px above the
-- button's top**. Decision 0887 could not know that and placed the card against the
-- button's CENTER instead, which is what put it in the wrong place in x and gave it no
-- horizontal drift at all.
BENILLA_ITEM_PUSH_FRAME_W = 42.41
BENILLA_ITEM_PUSH_FRAME_H = 124.72
-- Where that rect hangs off the button: the ref anchors the widget's BOTTOMRIGHT to the
-- button's, 10 in from the right for the five bag buttons and flush (0) for the keyring —
-- MainMenuBarBagButtons.xml l.19-27 (BagSlotButtonTemplate) and l.152-160 (KeyRingButton).
-- Per-button, so it is an OnLoad argument, not a constant.
BENILLA_ITEM_PUSH_ANCHOR_X = -10
-- ── The model's own numbers, unrounded ────────────────────────────────────────────────
-- Every one of these was rounded in 0887 and every rounding moved the card. The quad span
-- read as 0.03 (it is 0.0288 — a 4% size error), and the translation as (0.010, -0.040)
-- (which alone accounts for half a pixel of the landing point).
--
-- The bone transform is `v' = pivot + T(t) + S(t) * (v - pivot)` — three wow-re workers
-- derived that independently in three matrix conventions and agreed. The pivot
-- (M2CompBone+0x60) is NOT the quad's centre: it sits 0.23 px right and 0.07 px above it,
-- so the card shrinks *almost* in place and drifts a fifth of a pixel doing it. Keeping
-- the pivot as its own term rather than folding it into the centre is what makes this
-- reproduce the reference exactly at all three sampled times instead of nearly.
-- Bone 0's ROTATION track is empty (nKeys = 0), so there is no roll to model.
BENILLA_ITEM_PUSH_PIVOT_X = 0.017580900341272354
BENILLA_ITEM_PUSH_PIVOT_Y = 0.053958501666784286
BENILLA_ITEM_PUSH_QUAD_X = 0.017400000942870975
BENILLA_ITEM_PUSH_QUAD_Y = 0.053900001570582390
BENILLA_ITEM_PUSH_QUAD_SPAN = 0.028800001833587885
-- {time, value} keyframes, linear between (M2 track interpolation 1) — the file's own keys,
-- at full precision. The three tracks keep DIFFERENT schedules and that is the whole feel:
-- the card fades up while swelling to 1.2x over 133 ms, settles back to full size at
-- 267 ms, then dwindles and fades *continuously* for the remaining 733 ms — while its
-- position holds until the half second and only then falls. So it is already at ~0.69 size
-- and ~0.68 alpha when it starts to move, and it is invisible by the time it lands.
BENILLA_ITEM_PUSH_ALPHA = { {0.000, 0.0}, {0.133, 1.0}, {0.267, 1.0}, {1.000, 0.0} }
BENILLA_ITEM_PUSH_SCALE = { {0.000, 1.0}, {0.133, 1.2}, {0.267, 1.0}, {1.000, 0.014} }
-- The translation track as a 0..1 fraction of the fall (both components move together, so
-- one curve drives both axes — the file keys (0,0) at 0.000 and 0.500, the full offset at
-- 1.000, i.e. it hangs for the first half second and then drops).
BENILLA_ITEM_PUSH_DROP = { {0.000, 0.0}, {0.500, 0.0}, {1.000, 1.0} }
BENILLA_ITEM_PUSH_SCALE = { {0.000, 1.0}, {0.133, 1.2000000476837158}, {0.267, 1.0}, {1.000, 0.01441549975425005} }
-- Translation is per-AXIS: the card falls 51.4 px and drifts 13.3 px RIGHT, so one shared
-- 0..1 "drop" curve (0887's shape) cannot express it — the old code had no horizontal term
-- at all and moved the card the wrong way in x when it inferred one.
BENILLA_ITEM_PUSH_TRANS_X = { {0.000, 0.0}, {0.500, 0.0}, {1.000, 0.010394600220024586} }
BENILLA_ITEM_PUSH_TRANS_Y = { {0.000, 0.0}, {0.500, 0.0}, {1.000, -0.040167398750782013} }
-- Sample a {time, value} keyframe list at `t`, linear between keys, clamped at both ends
-- (the M2 sampler's own shape for a CLAMP sequence).
@@ -1141,27 +1215,52 @@
return track[last][2]
end
-- Place/size/fade the card for the current clock. The card LANDS centred on its button and
-- STARTS a fall's height above it (and a drift's width to the left), so the icon reads as
-- floating clear above the bar and then dropping into the bag — the model's own relative
-- motion, anchored to the button rather than to the ref's unresolvable model origin.
-- Place/size/fade the card for the current clock, from the model's own transform.
--
-- The card's centre in MODEL space is `pivot + T(t) + S(t)*(quadCentre - pivot)`; times the
-- projection constant that is its offset in layout units from the model origin, which is
-- the widget rect's bottom-left corner. The rect's own BOTTOMRIGHT hangs off the button's,
-- so the card is placed against the BUTTON'S BOTTOMRIGHT — never its centre — and the
-- frame width is what converts between the two.
--
-- On screen: a button-sized card appears with its bottom edge ~13.6 px ABOVE the button's
-- top, pops to 1.2x and back while fading in, hangs there through the first half second,
-- then falls down-and-right INTO the button, shrinking to a half-pixel point at its centre
-- as it fades out. Reproduces the reference at t = 0, 0.133 and 1.0 to the hundredth of a
-- pixel (`modelframe-implicit-size-law.md` §3's table).
--
-- NOT modelled, deliberately, and both are named in the record: the reference's widget
-- CLIPS the card to its own rect, which shaves 1.94 px off the right edge at the 1.2x peak
-- **at 4:3 only** (16:10 and 16:9 clear it); and the widget clears the depth buffer inside
-- that 124.72-tall rect every paint. benilla draws this as a Texture, not a Model, so it
-- inherits neither — reproducing an aspect-conditional sliver would be hard-coding a
-- rendering artefact rather than modelling the mechanism that causes it.
function BenillaItemPushAnim_Paint(anim)
local t = anim.elapsed
local drop = BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_DROP, t)
local size = BENILLA_ITEM_PUSH_SIZE * BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_SCALE, t)
local k = BENILLA_ITEM_PUSH_PX_PER_UNIT
local scale = BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_SCALE, t)
local size = BENILLA_ITEM_PUSH_QUAD_SPAN * scale * k
local cx = (BENILLA_ITEM_PUSH_PIVOT_X
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_X, t)
+ scale * (BENILLA_ITEM_PUSH_QUAD_X - BENILLA_ITEM_PUSH_PIVOT_X)) * k
local cy = (BENILLA_ITEM_PUSH_PIVOT_Y
+ BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_TRANS_Y, t)
+ scale * (BENILLA_ITEM_PUSH_QUAD_Y - BENILLA_ITEM_PUSH_PIVOT_Y)) * k
anim:SetWidth(size)
anim:SetHeight(size)
anim:SetPoint("CENTER", anim:GetParent(), "CENTER",
BENILLA_ITEM_PUSH_DRIFT * (drop - 1),
BENILLA_ITEM_PUSH_FALL * (1 - drop))
anim:SetPoint("CENTER", anim:GetParent(), "BOTTOMRIGHT",
anim.anchorX - BENILLA_ITEM_PUSH_FRAME_W + cx, cy)
anim:SetAlpha(BenillaItemPushAnim_Sample(BENILLA_ITEM_PUSH_ALPHA, t))
end
-- ref ItemAnim_OnLoad (l.68-70): the card listens for ITEM_PUSH and nothing else. It is
-- authored hidden, so it costs no OnUpdate until a push shows it (events reach hidden
-- frames; OnUpdate does not — the reference's own split).
function BenillaItemPushAnim_OnLoad(anim, containerId)
function BenillaItemPushAnim_OnLoad(anim, containerId, anchorX)
anim.containerId = containerId
-- The widget's own offset from the button's BOTTOMRIGHT: -10 for the five bag buttons,
-- flush 0 for the keyring (the ref's two different anchors — see the constant block).
anim.anchorX = anchorX or BENILLA_ITEM_PUSH_ANCHOR_X
anim.icon = getglobal(anim:GetName() .. "Icon")
anim.elapsed = 0
anim:RegisterEvent("ITEM_PUSH")
@@ -2088,7 +2187,7 @@
</Layer>
</Layers>
<Scripts>
<OnLoad>BenillaItemPushAnim_OnLoad(self, -2)</OnLoad>
<OnLoad>BenillaItemPushAnim_OnLoad(self, -2, 0)</OnLoad>
<OnEvent>BenillaItemPushAnim_OnEvent(self)</OnEvent>
<OnUpdate>BenillaItemPushAnim_OnUpdate(self, arg1)</OnUpdate>
</Scripts>
+10 -3
View File
@@ -31,8 +31,6 @@
-- GameTooltip_SetDefaultAnchor's position math (ref-GameTooltip.lua l.6,73-77): the
-- bottom-right screen corner, -CONTAINER_OFFSET_X - 13 in x, CONTAINER_OFFSET_Y in y.
DEFAULT_TOOLTIP_POSITION = -13
CONTAINER_OFFSET_X = CONTAINER_OFFSET_X or 0
CONTAINER_OFFSET_Y = CONTAINER_OFFSET_Y or 70
-- The shopping-compare pair count (ref GameTooltip.lua l.7; PaperDollFrame's listener
-- bounds arg2 by it).
NUM_SHOPPING_TOOLTIPS = 2
@@ -86,8 +84,17 @@
-- owner anchor (tooltip.rs SetOwner), so no ClearAllPoints - the ref has none either.
function GameTooltip_SetDefaultAnchor(tooltip, parent)
tooltip:SetOwner(parent, "ANCHOR_NONE")
-- Read the two managed globals FRESH, and fall back AT USE rather than assigning a
-- copy: this file used to open with `CONTAINER_OFFSET_X = CONTAINER_OFFSET_X or 0`,
-- which is a second written statement of numbers UIParent.xml owns. Harmless in the
-- shipped load order (UIParent.xml is manifest 42, this file later) but it is the same
-- shape as the shadow pair that froze the bag stack at the no-bars corner, so the law
-- of 1499 forbids it outright: nothing outside UIParent.xml assigns a managed offset.
-- The `or` defaults below cover the tooltip-only harnesses that load no UIParent.
local offsetX = CONTAINER_OFFSET_X or 0
local offsetY = CONTAINER_OFFSET_Y or 70
tooltip:SetPoint("BOTTOMRIGHT", "UIParent", "BOTTOMRIGHT",
-CONTAINER_OFFSET_X + DEFAULT_TOOLTIP_POSITION, CONTAINER_OFFSET_Y)
-offsetX + DEFAULT_TOOLTIP_POSITION, offsetY)
tooltip.default = 1
end
+232 -35
View File
@@ -1,9 +1,39 @@
<Ui>
<!--
The two bottom multibars (vanilla MultiBarBottomLeft / MultiBarBottomRight), ALWAYS ON —
the director's call: no interface-options toggles, no SHOW_MULTI_ACTIONBAR_* state, no
CMSG_SET_ACTION_BAR_TOGGLES roaming (the server-side PLAYER_FIELD_BYTES byte the real
options screen persists is ignored entirely).
**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
@@ -24,11 +54,16 @@
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 a
cursor payload is held — unlike the main bar's always-visible wells (its art has
sockets; floating rings over bare world would not read as a bar).
- The vertical MultiBarRight/MultiBarLeft (pages 3/4, actions 25..48) stay OUT — only the
two bottom bars were asked for; the base-as-data mechanism extends to them for free.
- 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;
@@ -39,6 +74,170 @@
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).
@@ -73,8 +272,13 @@
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. -->
<Frame name="MultiBarBottomLeft" frameStrata="HIGH">
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" 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>
@@ -131,8 +335,8 @@
</Frames>
</Frame>
<!-- HIGH, same as BottomLeft above (ref `MultiActionBars.xml` l.151). -->
<Frame name="MultiBarBottomRight" frameStrata="HIGH">
<!-- HIGH and hidden, same as BottomLeft above (ref `MultiActionBars.xml` l.151). -->
<Frame name="MultiBarBottomRight" 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>
@@ -189,31 +393,24 @@
</Frames>
</Frame>
<!-- **The two VERTICAL bars, built but never shown.**
<!-- **The two VERTICAL bars — now the same kind of thing as the two above them.**
They were scoped out (see the header's ref-divergence note) on the reasonable reading that
a bar nobody asked for should not exist. Measuring the corpus corrected the reading: 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"`. On the real client they exist from load and stay hidden
until SHOW_MULTI_ACTIONBAR_3/4. So declaring them costs nothing on screen and is what the
reference actually does.
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 it buys: Atlas, Bartender2 and FuBartender2 all DIE 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 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).
Still no SHOW_MULTI_ACTIONBAR_* state and still no options toggle — 0270's call stands, and
these two are simply hidden rather than optional. An addon that calls `MultiBarLeft:Show()`
gets a working bar on actions 37..48, which is exactly what the reference gives it.
The buttons carry the reference's own MULTIACTIONBAR3/4BUTTON command names, and those
commands stay OUT of `bindings::commands` — 1008's "honest tree" holds: the keybind screen
lists what the player can actually reach, and a bar we never show is not that. Painting a
hotkey from an unregistered command is a no-op (GetBindingKey answers nil), so the label is
simply blank until someone registers them.
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
+139 -13
View File
@@ -635,11 +635,27 @@
-- its Advanced Options chat chain runs CheckButton37 → 46 (Loot Spam, no knob of ours)
-- → 38, so ours are adjacent with that gap named rather than silently closed.
Social = { "RowChatBubbles", "RowPartyChatBubbles" },
-- 1.12's Advanced Options "Action Bars" group is six rows; five of them are the
-- multibar show/hide toggles, which read `func = MultiBarN_IsVisible` — not a store at
-- all, and our two bottom multibars are always on (MultiBars.xml). So the honest page
-- is the sixth (decision 1136).
ActionBars = { "RowLockActionBar" },
-- 1.12's Advanced Options "Action Bars" group, whole (decision 1500 completing 1136).
-- Its six rows, the four bar toggles then the grid switch then the lock. That is the
-- DIRECTOR's order, not the reference's: 1.12's box is two columns and leads with the
-- lock (CheckButton32 at its TOPLEFT, then 33/40/34 down the left, 35/36 down the
-- right), which does not survive being flattened into one column — the five switches
-- that decide what is on screen belong above the one that decides whether it can be
-- dragged. 1136 could ship only the lock, because the other five had
-- nothing behind them: the two bottom bars were unconditionally on and the two vertical
-- ones unconditionally hidden (0270/1219). 1500 made all four bars options, which is
-- what gave the five rows a store to read.
--
-- Two DIFFERENT stores on one page, and the split is verified rather than chosen: the
-- 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.
ActionBars = {
"RowMultiBar1", "RowMultiBar2", "RowMultiBar3", "RowMultiBar4",
"RowAlwaysShowMultibars", "RowLockActionBar",
},
-- The floating-combat-text family (decision 1134), in the reference's own on-screen
-- order — its AdvancedOptionsCombatText box's anchor chain, master → scroll mode →
-- the twelve, NOT its check-button index order (which jumps 58→60→63…59→69).
@@ -1079,6 +1095,16 @@
end
return
end
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
-- bars, the grid switch and the lock are all independent.
OptionsRow_SetEnabled(OptionsFrameContainerBodyActionBarsRowMultiBar4,
SHOW_MULTI_ACTIONBAR_3)
return
end
if key ~= "Audio" then return end
local on = GetCVar("MasterSoundEffects") ~= "0"
local box = OptionsFrameContainerBodyAudioRowEnableAmbienceCheck
@@ -2409,24 +2435,124 @@
</Frame>
</Frames>
</Frame>
<!-- The ACTION BARS page (decision 1136). One row, because the
reference's group is the lock plus five multibar show/hide
toggles whose store is a `func`, not a uvar or a CVar — and our
two bottom multibars are unconditionally on. The lock itself
had to be BUILT before it could have a row: ActionBar.xml and
PetActionBar.xml now guard their drag ends on it, the way the
reference does. -->
<!-- The ACTION BARS page (1136's lock row; the five switches above
it are 1500). 1.12's Advanced Options group, whole.
The four bar rows are API (`func`) rows — the third store, the
one Show Cloak / Show Helm opened in 1472 — because there is
nothing local to save: the preference is four bits of this
CHARACTER's server-side PLAYER_FIELD_BYTES byte 2, written with
CMSG_SET_ACTIONBAR_TOGGLES. The getter reads the live Lua
global (the client never re-reads the field and nothing fires
on a change to it, so the global IS the state between logins);
the setter writes the global, re-derives the bars, and re-sends
the whole byte. Default OFF for all four — a fresh character's
byte is 0.
Always Show ActionBars is a SAVED-VARIABLE row instead, and the
split is a verified consequence of the engine binding rather
than an authoring choice: `SetActionBarToggles` takes exactly
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.
The getters and setters are written here as closures rather
than captured from MultiBars.xml 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
every row a nil store instead of an unticked box. A closure
resolves its callee when the player clicks.
Labels and OPTION_TOOLTIP_* keys are 1.12's GlobalStrings
verbatim (SHOW_MULTIBAR1..4_TEXT l.3444-3447,
ALWAYS_SHOW_MULTIBARS_TEXT l.51) — including the leading
"Show ", which is part of the reference's own display text.
The lock itself had to be BUILT before it could have a row:
ActionBar.xml and PetActionBar.xml guard their drag ends on it,
the way the reference does. -->
<Frame name="$parentActionBars" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
</Anchors>
<Frames>
<Frame name="$parentRowLockActionBar" inherits="OptionsCheckboxRowTemplate">
<Frame name="$parentRowMultiBar1" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT"><Offset><AbsDimension x="10" y="-12"/></Offset></Anchor>
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-20" y="-12"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self,
function() return SHOW_MULTI_ACTIONBAR_1 end,
function(value) BenillaMultiBar_SetShown(1, value) end,
"0", "Show Bottom Left ActionBar", "OPTION_TOOLTIP_SHOW_MULTIBAR1")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowMultiBar2" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMultiBar1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowMultiBar1" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self,
function() return SHOW_MULTI_ACTIONBAR_2 end,
function(value) BenillaMultiBar_SetShown(2, value) end,
"0", "Show Bottom Right ActionBar", "OPTION_TOOLTIP_SHOW_MULTIBAR2")
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowMultiBar3" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMultiBar2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowMultiBar2" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self,
function() return SHOW_MULTI_ACTIONBAR_3 end,
function(value) BenillaMultiBar_SetShown(3, value) end,
"0", "Show Right ActionBar", "OPTION_TOOLTIP_SHOW_MULTIBAR3")
</OnLoad></Scripts>
</Frame>
<!-- Bar 4 is a CHILD row: MultiBarLeft only shows when bar 3
is also on (MultiBars.xml's conjunction — its column
hangs off MultiBarRight's edge), so the switch is
meaningless on its own. `child` carries the era's
indent-and-small-font subordination; the GREYING is
OptionsPage_UpdateDependencies' ActionBars arm, which
is the reference's own rule for this exact pair
(UIOptionsFrame.lua l.722-726 disables CheckButton36
while 35 is unchecked). -->
<Frame name="$parentRowMultiBar4" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMultiBar3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowMultiBar3" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsFuncRow_OnLoad(self,
function() return SHOW_MULTI_ACTIONBAR_4 end,
function(value) BenillaMultiBar_SetShown(4, value) end,
"0", "Show Right ActionBar 2", "OPTION_TOOLTIP_SHOW_MULTIBAR4", 1)
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowAlwaysShowMultibars" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowMultiBar4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowMultiBar4" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "ALWAYS_SHOW_MULTIBARS",
"Always Show ActionBars", "OPTION_TOOLTIP_ALWAYS_SHOW_MULTIBARS")
self.applyFunc = MultiActionBar_UpdateGridVisibility
</OnLoad></Scripts>
</Frame>
<Frame name="$parentRowLockActionBar" inherits="OptionsCheckboxRowTemplate">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="$parentRowAlwaysShowMultibars" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
<Anchor point="TOPRIGHT" relativeTo="$parentRowAlwaysShowMultibars" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="-9"/></Offset></Anchor>
</Anchors>
<Scripts><OnLoad>
OptionsGlobalRow_OnLoad(self, "LOCK_ACTIONBAR",
"Lock ActionBars", "OPTION_TOOLTIP_LOCK_ACTIONBAR")
+99 -9
View File
@@ -6,12 +6,28 @@
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, permanently in its RAISED mode (ShapeshiftBar_UpdatePosition's
MultiBarBottomLeft.isShowing branch — our bottom multibars are ALWAYS ON, MultiBars.xml):
frame BOTTOMLEFT at MainMenuBar TOPLEFT +(30,45); the ShapeshiftBarEnds/Middle shelf art
HIDDEN (raised mode hides it, so it is not modeled at all); 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).
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,
@@ -112,6 +128,64 @@
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()
@@ -125,6 +199,16 @@
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>
<!--
@@ -186,9 +270,15 @@
Absent, `ShapeshiftBarLeft:ClearAllPoints()` is `attempt to index global` and takes the
addon's whole handler with it.
Geometry and TexCoords quoted: Left and Right are two crops of ONE `ShapeshiftBarEnds`
file (0..0.453125 and 0.453125..0.875), Middle its own. hidden="true" is the state
raised mode leaves them in, so this still draws nothing. -->
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 exactly as the ref chains them — which is why hiding the
middle strip leaves its gap rather than closing it, the reference's own result.
`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">
+80 -3
View File
@@ -8,14 +8,23 @@
load-time anchor - the "tooltip on my character" bug). String anchors ("UIParent" in
SetPoint) had silently fallen back to the screen root, which has the same rect, so only
the *global* was ever missing. No enableMouse: the hit test gates on it (pointer.rs), so
the full-screen plate swallows nothing. The ref's OnLoad/OnEvent (UIParent_OnLoad's
global event routing) is not ours to ship - no consumer yet.
the full-screen plate swallows nothing.
The ref's UIParent_OnLoad registers a long list of globally-routed events and its OnEvent
is a chain of `if event == ...` arms; ours ships exactly the ONE arm that has a consumer
(the extra bars' login seed, below) and grows an arm at a time as consumers appear. A
transcription of the whole routing table would be a list of no-ops that reads as coverage.
Known seam, stated: the real client PARENTS every FrameXML frame under UIParent (scale/
visibility cascade); benilla's top-level frames still attach to the screen root. Same
rect, so anchors agree - re-parenting is a separate arc if a cascade consumer appears.
-->
<Frame name="UIParent" setAllPoints="true" frameStrata="MEDIUM"/>
<Frame name="UIParent" setAllPoints="true" frameStrata="MEDIUM">
<Scripts>
<OnLoad>this:RegisterEvent("PLAYER_ENTERING_WORLD")</OnLoad>
<OnEvent>UIParent_OnEvent(event)</OnEvent>
</Scripts>
</Frame>
<!--
WorldFrame — the reference's full-screen, bottom-of-the-strata frame that the 3D world
@@ -167,6 +176,31 @@
end
end
-- ── The login seed for the extra action bars ──────────────────────────────────────────
--
-- The four extra bars' visibility is stored SERVER-SIDE, in `PLAYER_FIELD_BYTES` byte 2,
-- and the client never writes that byte itself — `SetActionBarToggles` only posts
-- `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
-- 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.
--
-- This is the reference's own seam, arm for arm (its UIParent_OnEvent does the same two
-- statements on the same event). `MultiActionBar_Update` is existence-guarded because the
-- ~25 per-window harnesses that load this file load no bars; the binding itself is engine
-- level and always installed. Decision 1500.
function UIParent_OnEvent(event)
if ( event == "PLAYER_ENTERING_WORLD" ) then
SHOW_MULTI_ACTIONBAR_1, SHOW_MULTI_ACTIONBAR_2, SHOW_MULTI_ACTIONBAR_3,
SHOW_MULTI_ACTIONBAR_4 = GetActionBarToggles()
if ( MultiActionBar_Update ) then
MultiActionBar_Update()
end
end
end
-- ── Everything that has to move when the bottom of the screen fills up ──────────────────
--
-- One row per thing whose seat depends on which bars are raised. A row reads:
@@ -204,6 +238,29 @@
PETACTIONBAR_YPOS = {baseY = 97, bottomLeft = 43, reputation = 9, maxLevel = -5, isVar = "yAxis"},
}
-- ── Consumers of the VAR rows ──────────────────────────────────────────────────────────
-- A row with `isVar` leaves a NUMBER in a global and anchors nothing, so whoever seats
-- itself from that number has to be told when the number changes — otherwise it keeps the
-- seat it computed the first time and the bars grow underneath it. That is not a
-- hypothetical: the open-bag stack read a file-local copy of CONTAINER_OFFSET_X/Y and sat
-- at the no-bars corner forever, so a bottom multibar drew straight through the lowest bag
-- window (director-caught, decision 1499).
--
-- The reference closes the same loop by hard-calling `updateContainerFrameAnchors()` from
-- the end of its own pass (UIParent.lua:1774). This is that call generalized: a consumer
-- registers itself and the pass calls it after every recompute, so the NEXT var consumer
-- does not have to edit this file to be noticed. Order of registration is the order of
-- call; listeners are independent (nothing here reads what another writes).
--
-- The law, stated once so it can be pointed at: **nothing outside this file may keep its
-- own copy of a managed offset.** Read the global fresh at use, and if you cache a SEAT
-- derived from it, register here. `bottom_hud_tests.rs` enforces both halves.
UIPARENT_MANAGED_POSITION_LISTENERS = {}
function UIParent_RegisterManagedPositionListener(fn)
table.insert(UIPARENT_MANAGED_POSITION_LISTENERS, fn)
end
function UIParent_ManageFramePositions()
-- Frames that affect offsets in y axis
local yOffsetFrames = {}
@@ -280,6 +337,18 @@
elseif ( row.isVar ) then
_G[index] = yOffset
else
-- ClearAllPoints first: the row states the frame's WHOLE seat, so the pass
-- must own it outright. `SetPoint` only replaces the same-NAMED point, so
-- any other anchor the frame was authored with survives and fights this
-- one — ChatFrame2 is declared in XML with both a TOPLEFT and a
-- BOTTOMRIGHT onto ChatFrame1, and the row's BOTTOMRIGHT replaced only
-- the second, leaving the pane stretched from ChatFrame1's top-left
-- corner to the screen's bottom-right. Masked today only because
-- ChatFrame2 ships hidden, which is the worst kind of latent (decision
-- 1499). Every row here supplies a single complete anchor, so this is a
-- no-op for the other eight and the reference's plain SetPoint is
-- behaviour-identical on its own frames.
frame:ClearAllPoints()
frame:SetPoint(row.point or "BOTTOM", row.anchorTo or "UIParent",
row.rpoint or "BOTTOM", xOffset, yOffset)
end
@@ -371,6 +440,14 @@
if ( QuestWatchFrame ) then
QuestWatchFrame:SetPoint("TOPRIGHT", "MinimapCluster", "BOTTOMRIGHT", -CONTAINER_OFFSET_X, anchorY)
end
-- Last: everything that seats itself from a var row this pass just rewrote. After the
-- frames, so a listener may measure one (the bag stack reads GetScreenHeight and its
-- own windows' heights, not another managed frame — but the ordering costs nothing and
-- removes a class of question).
for _, fn in ipairs(UIPARENT_MANAGED_POSITION_LISTENERS) do
fn()
end
end
-- The published binding-label API — benilla's implementation of the behaviour ref
+10 -4
View File
@@ -96,10 +96,16 @@ BinderConfirm.xml
# consumers (ActionBar's buttons, the multibars, the stance bar, BagFrame's slots).
Cooldown.xml
ActionBar.xml
# The two always-on bottom multibars (actions 61-72 / 49-60): pure XML/Lua over
# ActionBar.xml's shared button template + handler set — loads right after it
# (inherits BenillaActionButtonTemplate, anchors to ActionButton1, and its
# buttons join the BENILLA_ACTION_BUTTONS roster ActionBar.xml declares).
# 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
+15
View File
@@ -930,6 +930,21 @@ pub(crate) enum ClientCommand {
/// engine's own `kind<<24 | action` word. Sent by the action drain on every queued
/// `PickupAction`/`PlaceAction` mutation — client-authoritative, no answer packet.
SetActionButton { button: u8, packed: u32 },
/// Post the four extra bars' visibility byte (`CMSG_SET_ACTIONBAR_TOGGLES`, `PLAYER_FIELD_BYTES`
/// byte 2 — wow-re `system/ui/scratch/action-bar-toggles.md`). Sent by the toggle drain, one
/// per `SetActionBarToggles` call: the binding gates nothing, so two calls in a frame are two
/// packets.
///
/// **Nothing answers it, and the sender is not allowed to answer itself.** Unlike
/// [`Self::SetActionButton`], whose state is genuinely ours, this byte is *server-owned*: the
/// real client has no instruction that writes the cell (the one `+0x102a` access image-wide is
/// a read), so the value only becomes true when the server's `SMSG_UPDATE_OBJECT` echoes it
/// into the descriptor — and no field-change callback fires when it does. The owner therefore
/// sees its own change come back the long way round, and the UI's optimism lives in Lua.
///
/// A send while disconnected is a **silent no-op**, matching the reference's three unreported
/// drops (`0x5ab637`, `0x5379ab`, `0x5379b6`) behind a binding that returns zero Lua values.
SetActionBarToggles { toggles: u8 },
/// Press one pet bar slot (`CMSG_PET_ACTION`, decisions 0982/0988). `packed` is the slot's OWN
/// word as the server last sent it — command, reaction and spell all ride this one command,
/// because the server dispatches on the type byte inside the word. `target_guid` is the
+3
View File
@@ -717,6 +717,9 @@ fn writer_loop(
ClientCommand::SetActionButton { button, packed } => {
w.set_action_button(button, packed)
}
ClientCommand::SetActionBarToggles { toggles } => {
w.set_actionbar_toggles(toggles)
}
ClientCommand::PetAction {
pet_guid,
packed,
@@ -1181,11 +1181,20 @@ fn both_reference_action_button_templates_are_inheritable() {
/// paging must be the `else` arm and not an addition. And **page-up wraps to the literal page 1**
/// while page-down rescans for the last viewable page: that asymmetry is the reference's, and it
/// is observable the moment a page is blanked from `VIEWABLE_ACTION_BAR_PAGES`.
///
/// Since 1500 the blanking is driven here the way the client drives it — by raising the two bottom
/// multibars — rather than read off a declaration. All six pages are viewable at rest now, because
/// every extra bar ships off and nothing has claimed a page yet.
#[test]
fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in ["Cooldown.xml", "ActionBar.xml"] {
for file in [
"UIParent.xml",
"Cooldown.xml",
"ActionBar.xml",
"MultiBars.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
@@ -1204,6 +1213,20 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
1,
"page 1 button 1 is action 1"
);
assert!(
s.eval::<bool>(
"for i = 1, NUM_ACTIONBAR_PAGES do \
if not VIEWABLE_ACTION_BAR_PAGES[i] then return false end \
end return true"
)
.unwrap(),
"all six pages are viewable at rest — every extra bar ships off (1500)"
);
// Raise the two bottom bars the way the client does, which is what takes pages 6 and 5 out of
// the cycle from here on.
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 MultiActionBar_Update()")
.unwrap();
// Page up walks to 2, so button 1 shows action 13.
s.run("ActionBar_PageUp()").unwrap();
@@ -1227,16 +1250,16 @@ fn the_main_bar_pages_and_a_bonus_page_still_outranks_it() {
s.eval::<i64>("return CURRENT_ACTIONBAR_PAGE").unwrap(),
4,
"page-down off the bottom rescans for the last VIEWABLE page — 4, not 6, because the \
always-on bottom multibars own pages 5 and 6"
two raised bottom multibars own pages 5 and 6"
);
assert_eq!(
s.eval::<i64>("return ActionButton_GetPagedID(ActionButton1)")
.unwrap(),
37
);
// The pages the bottom bars already display are unreachable from the main bar, which is what
// MultiActionBar_Update does on the reference the moment those bars are shown. Without this,
// paging up lands on a duplicate of the twelve actions already on screen below.
// The pages the bottom bars already display are unreachable from the main bar — which is
// exactly what MultiActionBar_Update did above. Without it, paging up lands on a duplicate of
// the twelve actions already on screen below.
assert!(s
.eval::<bool>(
"return VIEWABLE_ACTION_BAR_PAGES[5] == nil and VIEWABLE_ACTION_BAR_PAGES[6] == nil"
+79 -31
View File
@@ -1865,6 +1865,19 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
s.eval::<f32>(&format!("return {name}ItemAnim:GetAlpha()"))
.unwrap()
};
// The card's centre relative to its button's BOTTOMRIGHT corner — the reference's OWN frame of
// reference for this widget (it anchors the `<Model>`'s BOTTOMRIGHT to the button's), and the
// only one the numbers are stable in: the card's placement depends on the anchor offset and
// the M2 bounding box, NOT on how big the button is. Measuring against the button's centre
// instead is what let 0887's placement look plausible.
let corner = |s: &UiScript, name: &str| -> (f32, f32) {
s.eval::<(f32, f32)>(&format!(
"local a, b = {name}ItemAnim, {name} \
local ax, ay = a:GetLeft() + a:GetWidth() / 2, a:GetBottom() + a:GetHeight() / 2 \
return ax - b:GetRight(), ay - b:GetBottom()"
))
.unwrap()
};
// Nothing is animating until a push arrives.
for b in [
@@ -1907,24 +1920,34 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
"the card wears the pushed item's icon (ITEM_PUSH's arg2)"
);
// t=0: invisible, full size, held a whole fall above the button (and a drift to its left) —
// the .m2's alpha key (0.000, 0.0), scale key (0.000, 1.0), translation (0.000, (0,0)).
// Every figure below is the REFERENCE's, from wow-re's §5-cross-checked
// `modelframe-implicit-size-law.md` §3 — not a ratio we chose. The three it publishes
// (t = 0, 0.133, 1.0) are asserted to a tenth of a pixel; the rest assert the SHAPE of the
// motion, which is where 0887's authored placement actually went wrong.
//
// The pixel figures below are the model's own ratios at the <Model> widget's REAL projection
// (1280 px per model unit at this template's modelScale of 1 — BagFrame.xml's header carries
// the derivation and why the 1200 that produced 36/48/12 was a back-fit). Same animation,
// 6.7% larger: a 38.4 px card, a 51.2 px fall, a 12.8 px drift.
let (dx, dy) = offset(&s, "CharacterBag1Slot");
// What changed from 0887, and why the old numbers looked right: it measured against the
// button's CENTRE and drove both axes off one shared 0..1 "drop" curve, which happens to land
// within ~1 px of the truth in y for a 36 px button. In x it was wrong in DIRECTION — the card
// drifts RIGHT as it falls, and the old code had it starting left and ending centred.
// t=0: invisible, full size, parked high above the button. The .m2's alpha key (0.000, 0.0),
// scale key (0.000, 1.0), translation (0.000, (0,0)).
let (cx, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(dy - 51.2).abs() < 0.5 && (dx + 12.8).abs() < 0.5,
"starts one fall (51.2px) above and one drift (12.8px) left: got ({dx}, {dy})"
(cx + 30.14).abs() < 0.1 && (cy - 68.99).abs() < 0.1,
"starts at the ref's (-30.14, 68.99) from the button's bottom-right: got ({cx}, {cy})"
);
assert!(
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
"the quad is 0.0288 model units, not the 0.03 that 0887 rounded it to: got {}",
size(&s, "CharacterBag1Slot")
);
assert!(
alpha(&s, "CharacterBag1Slot") < 0.01,
"fades in from nothing"
);
// t=0.133: fully faded in, at the 1.2x swell — the pop the director sees above the bar.
// t=0.133: fully faded in, at the 1.2x swell.
s.tick(0.133);
s.resolve();
assert!(
@@ -1932,33 +1955,46 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
"opaque by the alpha track's second key"
);
assert!(
(size(&s, "CharacterBag1Slot") - 38.4 * 1.2).abs() < 0.5,
"swollen to 1.2x the 38.4px card at the scale track's peak"
(size(&s, "CharacterBag1Slot") - 44.24).abs() < 0.1,
"swollen to the ref's 44.24 px peak: got {}",
size(&s, "CharacterBag1Slot")
);
// The pop moves the centre by a twentieth of a pixel, and that tiny drift is the tell that the
// bone's PIVOT is not the quad's centre — scaling up pushes the centre away from the pivot,
// scaling down pulls it in, one mechanism at both ends. Folding the pivot into the centre (the
// obvious simplification) loses this and misses the landing point by a fifth of a pixel.
let (px, py) = corner(&s, "CharacterBag1Slot");
assert!(
(px + 30.18).abs() < 0.02 && (py - 68.98).abs() < 0.02,
"the 1.2x pop drifts the centre off the pivot by the ref's (-0.046, -0.015): got ({px}, {py})"
);
// t=0.267: back to the icon's own size, still opaque, still parked.
s.tick(0.134);
s.resolve();
assert!(
(size(&s, "CharacterBag1Slot") - 38.4).abs() < 0.5,
"settled back to the 38.4px card at the scale track's third key"
(size(&s, "CharacterBag1Slot") - 36.86).abs() < 0.1,
"settled back at the scale track's third key"
);
let (_, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cy - 68.99).abs() < 0.1,
"has not started falling: got {cy}"
);
let (_, dy) = offset(&s, "CharacterBag1Slot");
assert!((dy - 51.2).abs() < 0.5, "has not started falling: got {dy}");
// t=0.5: STILL PARKED — the translation track's second key is (0,0), so the whole first half
// second is a hang above the bar. The other two tracks are already running down, though: the
// three curves keep different schedules, which is the thing a "hold everything then drop"
// reading would get wrong (scale/alpha ≈ 0.686/0.682 here, the 0.267→1.000 ramps at t=0.5).
// second is a hang. The other two tracks are already running down, though: the three curves
// keep different schedules, which is the thing a "hold everything then drop" reading would get
// wrong (scale/alpha are 0.687/0.682 here, on the 0.267→1.000 ramps).
s.tick(0.233);
s.resolve();
let (_, dy) = offset(&s, "CharacterBag1Slot");
let (_, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(dy - 51.2).abs() < 0.5,
"has not moved yet at the half second: got {dy}"
(cy - 69.0).abs() < 0.1,
"has not moved yet at the half second: got {cy}"
);
assert!(
(size(&s, "CharacterBag1Slot") - 38.4 * 0.686).abs() < 0.5,
(size(&s, "CharacterBag1Slot") - 25.31).abs() < 0.2,
"but is already dwindling: got {}",
size(&s, "CharacterBag1Slot")
);
@@ -1968,26 +2004,38 @@ fn an_item_push_drops_its_icon_into_the_bag_that_took_it() {
alpha(&s, "CharacterBag1Slot")
);
// t=0.75: halfway down.
// t=0.75: halfway through the fall — and moving RIGHT as it drops, which is the axis 0887 had
// backwards. The translation is per-axis (+0.0104, -0.0402 model units), not one shared curve.
s.tick(0.25);
s.resolve();
let (_, dy) = offset(&s, "CharacterBag1Slot");
let (cx, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(dy - 25.6).abs() < 0.5,
"half the fall travelled by t=0.75: got {dy}"
(cx + 23.34).abs() < 0.2,
"drifted RIGHT by half the 13.3 px total: got {cx}"
);
assert!(
(cy - 43.33).abs() < 0.2,
"half of the 51.4 px fall travelled by t=0.75: got {cy}"
);
// Just short of the end: landed on the button, shrunk to nothing, faded out.
// Just short of the end: down on the button, shrunk to nothing, faded out.
s.tick(0.24);
s.resolve();
let (cx, cy) = corner(&s, "CharacterBag1Slot");
assert!(
(cx + 16.87).abs() < 0.2 && (cy - 18.68).abs() < 0.2,
"arrives at the ref's landing point: got ({cx}, {cy})"
);
// Which is also, within a pixel, the button's own centre — the reference's card really does
// drop INTO the bag button, and that is the one thing 0887 got right for the wrong reason.
let (dx, dy) = offset(&s, "CharacterBag1Slot");
assert!(
dx.abs() < 1.0 && dy.abs() < 1.5,
"lands centred on the button it went into: got ({dx}, {dy})"
dx.abs() < 1.5 && dy.abs() < 1.5,
"lands on the button it went into: got ({dx}, {dy})"
);
assert!(
size(&s, "CharacterBag1Slot") < 2.0,
"collapsed to the scale track's 0.014"
"collapsed to the scale track's 0.0144"
);
assert!(alpha(&s, "CharacterBag1Slot") < 0.05, "faded out");
@@ -0,0 +1,464 @@
//! **The bottom-of-screen clearance law, enforced** (decision 1499).
//!
//! The bottom band of the screen is shared: the main bar sits on it, the extra action bars, the
//! stance/pet bar and the reputation watch bar stack above it, and the bag windows, the cast bar,
//! the chat panes and the default tooltip anchor all have to keep clear of whatever is standing.
//! `UIParent_ManageFramePositions()` (UIParent.xml) is the ONE thing that decides that — it seats
//! the managed frames and writes the managed globals — and every one of the defects below was a
//! frame that quietly opted out of it.
//!
//! Three guards, each pinning a different way to opt out:
//!
//! 1. [`no_shipped_file_declares_its_own_copy_of_a_managed_offset`] — the *shadow copy*.
//! `BagFrame.xml` kept `local BENILLA_CONTAINER_OFFSET_X/Y = 0/70`. Locals, so the pass's
//! writes could never reach them; the open-bag stack sat at the no-bars corner forever and a
//! raised bottom multibar drew straight through the lowest bag window (director-caught — the
//! screenshot that opened 1499). `GameTooltip.xml` carried the softer `X = X or 0` form of the
//! same thing. A value with two written statements has no owner.
//!
//! 2. [`every_bottom_anchored_top_level_frame_is_accounted_for`] — the *new frame nobody wired
//! up*. This is the guard that has to hold for features that do not exist yet: a top-level
//! frame anchored to the screen's bottom edge is, by construction, in the contested band, so it
//! must be a managed row, a listener consumer, or an exemption someone wrote a reason for.
//!
//! 3. [`no_bottom_band_frame_overlaps_a_raised_bar`] — the *symptom itself*, over the real shipped
//! XML: for every combination of raised bars, no bottom-band frame's rect may intersect a
//! visible bar's. This is the one that would have failed on the director's screenshot.
use benilla_ui::script::UiScript;
/// The globals `UIPARENT_MANAGED_FRAME_POSITIONS`'s `isVar` rows own. Only `UIParent.xml` may
/// write these; everyone else reads them fresh at use (falling back inline, never by assigning).
const MANAGED_GLOBALS: &[&str] = &[
"CONTAINER_OFFSET_X",
"CONTAINER_OFFSET_Y",
"PETACTIONBAR_XPOS",
"PETACTIONBAR_YPOS",
"BATTLEFIELD_TAB_OFFSET_Y",
];
fn ui_dir() -> std::path::PathBuf {
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui")
}
/// Blank out `<!-- … -->` regions, keeping every newline so line numbers still point at the file.
/// The scan below reads Lua, and an XML comment is prose: `BagFrame.xml`'s own explanation of the
/// backpack anchor says "CONTAINER_OFFSET_X=0 / CONTAINER_OFFSET_Y=70" in English, which is a
/// description of the law, not a violation of it.
fn without_xml_comments(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find("<!--") {
out.push_str(&rest[..start]);
let after = &rest[start..];
let end = after.find("-->").map(|e| e + 3).unwrap_or(after.len());
for ch in after[..end].chars() {
if ch == '\n' {
out.push('\n');
}
}
rest = &after[end..];
}
out.push_str(rest);
out
}
fn shipped_xml() -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = std::fs::read_dir(ui_dir())
.expect("assets/ui")
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "xml"))
.map(|p| {
(
p.file_name().unwrap().to_string_lossy().into_owned(),
std::fs::read_to_string(&p).expect("read"),
)
})
.collect();
out.sort();
assert!(out.len() >= 40, "only {} xml files swept", out.len());
out
}
/// **Nothing outside `UIParent.xml` may ASSIGN a managed offset** — not as a local, not as a
/// global, not as an `or`-guarded default.
///
/// Reading one is fine and expected (`-CONTAINER_OFFSET_X` in the right stack, `CONTAINER_OFFSET_Y`
/// in the bag stack and the tooltip anchor). Falling back at the point of use is fine
/// (`local offsetY = CONTAINER_OFFSET_Y or 70`) — that is a read with a default, and it is what the
/// per-window harnesses that ship no `UIParent.xml` need. What is forbidden is *storing* the value
/// anywhere the pass cannot reach, because the pass recomputes these on every bar change and a
/// stored copy is stale from the moment the next bar goes up.
///
/// The rule is spelled as "an assignment statement whose left-hand side names a managed global,
/// or a name containing one" — the second half is what catches `BENILLA_CONTAINER_OFFSET_Y`, which
/// was not the global's name at all and is exactly why nothing noticed it for so long.
#[test]
fn no_shipped_file_declares_its_own_copy_of_a_managed_offset() {
let mut offences = Vec::new();
for (name, text) in shipped_xml() {
if name == "UIParent.xml" {
continue; // the owner: its var rows are where these numbers are defined
}
for (n, line) in without_xml_comments(&text).lines().enumerate() {
let code = line.split("--").next().unwrap_or("");
let Some((lhs, _)) = code.split_once('=') else {
continue;
};
// `==`, `<=`, `>=`, `~=` are comparisons, not assignments.
if code[lhs.len() + 1..].starts_with('=')
|| lhs.ends_with(['<', '>', '~', '=', '!'])
|| lhs.contains("--")
{
continue;
}
let lhs = lhs.trim().trim_start_matches("local ").trim();
if MANAGED_GLOBALS.iter().any(|g| lhs.contains(g)) {
offences.push(format!("{name}:{}: {}", n + 1, line.trim()));
}
}
}
assert!(
offences.is_empty(),
"a managed offset is owned by UIParent.xml's manage pass and may only be READ elsewhere \
(fall back inline at the point of use — `local y = CONTAINER_OFFSET_Y or 70` — never by \
assigning a copy, which goes stale the moment a bar is raised; decision 1499):\n{}",
offences.join("\n")
);
}
/// Frames whose seat the manage pass owns — the `UIPARENT_MANAGED_FRAME_POSITIONS` keys that name
/// a frame, plus the two the pass's custom tail seats by hand. Kept here as literal text so this
/// test and `UIParent.xml` cannot drift apart silently: the next guard asserts every one of these
/// really appears in the shipped pass.
const MANAGED_FRAMES: &[&str] = &[
"MultiBarBottomLeft",
"GroupLootFrame1",
"TutorialFrameParent",
"FramerateLabel",
"CastingBarFrame",
"ChatFrame1",
"ChatFrame2",
"ShapeshiftBarFrame",
"PetActionBarFrame",
"QuestTimerFrame",
"DurabilityFrame",
"QuestWatchFrame",
];
/// Top-level frames that ARE anchored to the screen's bottom edge and are deliberately not managed.
/// Every entry carries the reason, because "it was already like that" is how the bag stack got its
/// shadow copy. Adding a frame here is a decision; adding one silently is what this test forbids.
const BOTTOM_EXEMPT: &[(&str, &str)] = &[
(
"MainMenuBar",
"the base itself — the bar everything else measures its clearance FROM, so it has no \
clearance of its own to compute",
),
(
"WorldFrame",
"a full-screen named handle, not a HUD frame (UIParent.xml) — setAllPoints, renders \
nothing",
),
("UIParent", "the full-screen root itself"),
(
"ZoneTextFrame",
"a mid-screen splash at BOTTOM +512 — nowhere near the contested band",
),
("SubZoneTextFrame", "as ZoneTextFrame"),
(
"MultiBarRight",
"a BAR, not a tenant of the band — it is one of the things the others clear. Its own seat \
is the reference's (MultiActionBars.xml: BOTTOMRIGHT -7,+98, with MultiBarLeft riding its \
TOPLEFT), and it sets the rightLeft/rightRight flags CONTAINER_OFFSET_X reads",
),
(
"ItemRefTooltip",
"the reference's own answer, matched exactly (ItemRef.xml l.4): frameStrata HIGH + \
toplevel + movable at BOTTOM +80. A linked-item window lands ABOVE the HIGH bars rather \
than clearing them (decision 1318), and the player drags it where they want",
),
(
"ChatFrame3",
"undocked chat panes are user-placed, and the reference manages only ChatFrame1/2 in \
UIPARENT_MANAGED_FRAME_POSITIONS. All five ship hidden at a placeholder BOTTOMLEFT",
),
("ChatFrame4", "as ChatFrame3"),
("ChatFrame5", "as ChatFrame3"),
("ChatFrame6", "as ChatFrame3"),
("ChatFrame7", "as ChatFrame3"),
];
/// **A top-level frame anchored to the screen's bottom edge is either managed, or exempt with a
/// stated reason.** There is no third option, and this is the guard that covers features nobody
/// has written yet.
///
/// The band at the bottom of the screen is contested by construction: the main bar owns it, and
/// every optional bar stacks upward into it. So the moment a new window anchors `BOTTOM`,
/// `BOTTOMLEFT` or `BOTTOMRIGHT` to the screen root, it has taken on a clearance problem — and the
/// only correct answers are to join `UIPARENT_MANAGED_FRAME_POSITIONS`, to seat itself from a
/// managed global and register a listener (the bag stack's answer), or to say out loud why it does
/// not need to. Failing here is not a bug report; it is a prompt to pick one.
#[test]
fn every_bottom_anchored_top_level_frame_is_accounted_for() {
let pass = std::fs::read_to_string(ui_dir().join("UIParent.xml")).expect("UIParent.xml");
for f in MANAGED_FRAMES {
assert!(
pass.contains(f),
"{f} is listed here as managed but does not appear in UIParent.xml's pass — this \
list has drifted from the file it mirrors"
);
}
let mut unaccounted = Vec::new();
for (file, text) in shipped_xml() {
let doc = benilla_ui::framexml::parse(&text).unwrap_or_else(|e| panic!("{file}: {e}"));
for (name, anchors) in bottom_anchored_top_level(&doc) {
let known = MANAGED_FRAMES.contains(&name.as_str())
|| BOTTOM_EXEMPT.iter().any(|(n, _)| *n == name)
|| registers_a_listener(&text, &name);
if !known {
unaccounted.push(format!("{file}: {name} ({anchors})"));
}
}
}
assert!(
unaccounted.is_empty(),
"these top-level frames anchor to the screen's bottom edge but nothing decides their \
clearance over the action bars. Give each one a row in \
UIPARENT_MANAGED_FRAME_POSITIONS (UIParent.xml), or seat it from a managed global and \
register UIParent_RegisterManagedPositionListener, or add it to BOTTOM_EXEMPT here with \
the reason it needs neither (decision 1499):\n{}",
unaccounted.join("\n")
);
}
/// A file that registers a managed-position listener is seating something itself; the frames it
/// declares are covered by that registration. Coarse on purpose — the listener seats a whole
/// family (the bag stack is five windows), and naming each one here would be the same drift trap
/// `MANAGED_FRAMES` guards against.
fn registers_a_listener(text: &str, _frame: &str) -> bool {
text.contains("UIParent_RegisterManagedPositionListener")
}
/// Every top-level INSTANCE (a `<Frame>`/`<Button>`/… that is not `virtual` and carries no
/// `parent` attribute) whose own `<Anchors>` name a BOTTOM point against the screen root — an
/// anchor with no `relativeTo`, or one naming `UIParent`/`WorldFrame`, all three of which resolve
/// to the same full-screen rect.
///
/// Only the frame's OWN anchors, not its children's: a child that anchors BOTTOM to its parent is
/// stating a position inside a window, which is nobody's clearance problem.
fn bottom_anchored_top_level(doc: &benilla_ui::framexml::ParsedDocument) -> Vec<(String, String)> {
let mut out = Vec::new();
for item in &doc.items {
let benilla_ui::framexml::TopLevel::Instance(el) = item else {
continue;
};
if el.attr("parent").is_some() {
continue;
}
let Some(name) = el.name() else { continue };
if name.contains("$parent") {
continue;
}
let hits: Vec<String> = el
.children
.iter()
.filter(|c| c.tag.eq_ignore_ascii_case("Anchors"))
.flat_map(|anchors| anchors.children.iter())
.filter(|a| a.tag.eq_ignore_ascii_case("Anchor"))
.filter(|a| {
a.attr("point")
.is_some_and(|p| p.to_ascii_uppercase().starts_with("BOTTOM"))
&& a.attr("relativeTo")
.is_none_or(|r| r == "UIParent" || r == "WorldFrame")
})
.filter_map(|a| a.attr("point").map(|p| p.to_string()))
.collect();
if !hits.is_empty() {
out.push((name.to_string(), hits.join("/")));
}
}
out
}
/// Every bar a player can raise into the band the tenants below have to share.
///
/// The two VERTICAL bars are in here deliberately, even though they are the only pair that moves
/// the tenants sideways rather than upward: `CONTAINER_OFFSET_X`'s `rightLeft = 90` /
/// `rightRight = 45` arithmetic has never once run in a shipped session, because until the
/// visibility options (1500) both bars were unconditionally hidden and the flags could not be set.
/// Half of the managed table was dead arithmetic nothing exercised. It runs here.
const RAISABLE_BARS: &[&str] = &[
"MultiBarBottomLeft",
"MultiBarBottomRight",
"ShapeshiftBarFrame",
"MultiBarRight",
"MultiBarLeft",
];
/// **The symptom, pinned: with any combination of bars raised, nothing in the bottom band overlaps
/// a raised bar.**
///
/// This is the assertion that would have failed on the screenshot that opened 1499 — an open bag
/// window drawn straight through a bottom multibar's buttons. It runs against the REAL shipped
/// XML, drives the bars' visibility directly (so it keeps holding however the toggles are
/// eventually wired), and re-runs the pass between combinations exactly as a live bar change does.
#[test]
fn no_bottom_band_frame_overlaps_a_raised_bar() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
// Open every bag window. The stack only exists once something is in it, and an EMPTY stack is
// exactly the state that hid this defect from every earlier test — `bag_tests` never loads
// `MultiBars.xml` and the multibar tests never open a bag, so no suite had both on screen at
// once. Shown through the real OnShow, so the stack layout runs the way a B keypress runs it.
s.run(
"for _, f in ipairs({BenillaBagFrame, BenillaBagFrame1, BenillaBagFrame2, \
BenillaBagFrame3, BenillaBagFrame4}) do if f then f:Show() end end",
)
.unwrap();
// Everything that sits in the band and must give way. Each is checked only while shown, so a
// frame that never appears contributes nothing — which is why the pair counter below matters.
const TENANTS: &[&str] = &[
"BenillaBagFrame",
"BenillaBagFrame1",
"BenillaBagFrame2",
"BenillaBagFrame3",
"BenillaBagFrame4",
"CastingBarFrame",
"ChatFrame1",
];
let mut pairs = 0usize;
for mask in 0..(1u32 << RAISABLE_BARS.len()) {
let mut raised = Vec::new();
for (i, bar) in RAISABLE_BARS.iter().enumerate() {
let on = mask & (1 << i) != 0;
s.run(&format!(
"if {bar} then {bar}:{}() end",
if on { "Show" } else { "Hide" }
))
.unwrap();
if on {
raised.push(*bar);
}
}
s.run("UIParent_ManageFramePositions()").unwrap();
s.resolve();
for bar in &raised {
if !shown(&s, bar) {
continue;
}
let bar_rect = rect(&s, bar);
for victim in TENANTS {
if !shown(&s, victim) {
continue;
}
let v = rect(&s, victim);
pairs += 1;
assert!(
!overlaps(bar_rect, v),
"with {raised:?} raised, {victim} {v:?} overlaps {bar} {bar_rect:?} — \
something in the bottom band is not clearing the bars. Its seat must come \
from UIParent_ManageFramePositions, read fresh (decision 1499)."
);
}
}
}
// Never let this pass by testing nothing: a renamed bag frame or a bar that fails to show
// would otherwise turn the whole guard green while checking zero pairs. With three raisable
// bars over seven tenants the real number is in the dozens.
assert!(
pairs >= 24,
"only {pairs} bar/tenant pairs were actually compared — the sweep is not exercising the \
band it claims to guard"
);
}
fn shown(s: &UiScript, name: &str) -> bool {
s.eval::<bool>(&format!(
"return {name} and {name}:IsShown() and true or false"
))
.unwrap_or(false)
}
/// `(left, bottom, right, top)` in screen pixels.
fn rect(s: &UiScript, name: &str) -> (f32, f32, f32, f32) {
s.eval::<(f32, f32, f32, f32)>(&format!(
"return {name}:GetLeft(), {name}:GetBottom(), {name}:GetRight(), {name}:GetTop()"
))
.unwrap_or_else(|e| panic!("rect of {name}: {e}"))
}
/// Touching edges do not overlap — the stack seats windows flush against each other by design.
fn overlaps(a: (f32, f32, f32, f32), b: (f32, f32, f32, f32)) -> bool {
a.0 < b.2 && b.0 < a.2 && a.1 < b.3 && b.1 < a.3
}
/// **The item-push card's travel band overlaps a raised bottom bar — and so does the reference's.**
///
/// The director reported the looted-item card as "hidden behind" the extra action bar in the same
/// breath as the bag overlap, so this measures it instead of reasoning about it. The answer is not
/// the one the bag half had:
///
/// ```text
/// card at its opaque peak x 1253.7..1298.0 y 48.9..93.1
/// MultiBarBottomRight x 806.0..1306.0 y 57.0..95.0
/// ```
///
/// The card is MEDIUM (a child of a MEDIUM bag button); the bars are `frameStrata="HIGH"`
/// (`MultiActionBars.xml` l.36/151/266/381). **The reference has exactly this arrangement** — its
/// bag buttons are `parent="MainMenuBarArtFrame"` under a `MainMenuBar` with no strata at all, and
/// its `MultiBarBottomRight` runs from `MultiBarBottomLeft`'s right edge +10 for 500 px, which puts
/// its last buttons directly over the bag bar at every resolution. So a push into a bag draws
/// behind that bar's twelfth button whenever that slot is filled, in the real 1.12.1 client too.
/// It goes unnoticed because a multibar's EMPTY wells are hidden, so most players have nothing
/// there; the director's screenshot has a chicken in exactly that slot.
///
/// This is therefore a **finding test, not a guard**: it records what the reference's own geometry
/// produces so that changing either side has to come and edit these numbers on purpose. If we ever
/// diverge deliberately — raising the card's strata, or lifting its band clear of the bars — this
/// is the test that says so out loud rather than the change slipping through green.
#[test]
fn the_item_push_card_shares_the_band_with_a_raised_bar_exactly_as_the_reference_does() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1600.0, 900.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.run("MultiBarBottomLeft:Show() MultiBarBottomRight:Show() UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
s.fire_event(
"ITEM_PUSH",
vec![
benilla_ui::script::ScriptValue::Int(0),
benilla_ui::script::ScriptValue::Str("Interface\\Icons\\INV_Misc_Bag_08".into()),
],
);
s.tick(0.133); // the opaque peak — the instant the card is most visible
s.resolve();
assert!(
shown(&s, "MainMenuBarBackpackButtonItemAnim"),
"the card plays"
);
let card = rect(&s, "MainMenuBarBackpackButtonItemAnim");
let bar = rect(&s, "MultiBarBottomRight");
assert!(
(card.1 - 48.9).abs() < 0.5 && (card.3 - 93.1).abs() < 0.5,
"the card's band is the reference's 48.9..93.1 above the screen floor: got {card:?}"
);
assert!(
overlaps(bar, card),
"the reference's own geometry puts the card inside the bar's band ({bar:?} vs {card:?}) — \
if this stops being true, benilla has diverged from it and the divergence needs a record"
);
}
@@ -415,14 +415,14 @@ fn managed_positions_track_the_bottom_bar_stack() {
// The bar stubs carry a no-op SetPoint: `MultiBarBottomLeft` and `ShapeshiftBarFrame` are
// themselves rows in UIPARENT_MANAGED_FRAME_POSITIONS, so since those frames wear their
// reference names the pass positions them as well as reading their visibility.
s.run("MultiBarBottomLeft = { IsShown = function() return true end, SetPoint = function() end }; MultiBarBottomRight = MultiBarBottomLeft; UIParent_ManageFramePositions()")
s.run("MultiBarBottomLeft = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; MultiBarBottomRight = MultiBarBottomLeft; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s, "CastingBarFrame"), 100.0, "60 + bottomEither 40");
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.
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s, "CastingBarFrame"), 140.0, "60 + 40 + pet 40");
@@ -433,7 +433,7 @@ fn managed_positions_track_the_bottom_bar_stack() {
);
// It hides again (a druid leaving forms is the live case): everything settles back.
s.run("ShapeshiftBarFrame = { IsShown = function() return false end, SetPoint = function() end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame = { IsShown = function() return false end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s, "CastingBarFrame"), 100.0);
@@ -304,7 +304,18 @@ pub(super) fn drive_script(
};
{
let _span = bevy::log::info_span!("ui_script: tick").entered();
script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
let resized = script.set_screen_size(w / s, if h > 0.0 { h / s } else { 768.0 });
// A resize re-runs the bottom-stack manage pass (decision 1499). Anchors follow the new
// screen rect by themselves; what does not is a seat somebody COMPUTED from the old
// height — the open-bag stack starts a fresh column when the current one would run off
// the top, and that decision is made from `GetScreenHeight()` at layout time. Without
// this, dragging the window smaller leaves the bag columns wrapped for the old height
// until the next bag opens. Existence-guarded: the pass is defined by `UIParent.xml`,
// which is in-game UI, and this system also runs on the glue screens.
if resized {
let _ = script
.run("if UIParent_ManageFramePositions then UIParent_ManageFramePositions() end");
}
script.tick(time.delta_secs());
}
// The frame's clock pair ([`super::UiClock`]): the VM value the tick just produced, anchored
@@ -500,19 +500,19 @@ fn managed_positions_engage_for_the_bare_frame_name() {
// The bar stubs carry a no-op SetPoint: `MultiBarBottomLeft` and `ShapeshiftBarFrame` are
// themselves rows in UIPARENT_MANAGED_FRAME_POSITIONS, so since those frames wear their
// reference names the pass positions them as well as reading their visibility.
s.run("MultiBarBottomLeft = { IsShown = function() return true end, SetPoint = function() end }; MultiBarBottomRight = MultiBarBottomLeft; UIParent_ManageFramePositions()")
s.run("MultiBarBottomLeft = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; MultiBarBottomRight = MultiBarBottomLeft; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 102.0, "60 + bottomEither 42 — the row engaged");
// The stance bar shows on top of them.
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame = { IsShown = function() return true end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 144.0, "60 + 42 + pet 42");
// And it settles back when the stance bar hides.
s.run("ShapeshiftBarFrame = { IsShown = function() return false end, SetPoint = function() end }; UIParent_ManageFramePositions()")
s.run("ShapeshiftBarFrame = { IsShown = function() return false end, SetPoint = function() end, ClearAllPoints = function() end }; UIParent_ManageFramePositions()")
.unwrap();
s.resolve();
assert_eq!(bottom(&s), 102.0, "back to the multibar-only stack");
+14
View File
@@ -615,6 +615,17 @@ fn demo_unit_feed(script: Option<NonSendMut<UiScript>>, mut fired: Local<VmMemo<
// Set before PLAYER_ENTERING_WORLD so the bar's first Update reads it.
script.set_player_xp(4200, 6000);
script.fire_event("PLAYER_ENTERING_WORLD", vec![]);
// The two bottom multibars are player options and ship OFF since 1500, and a capture runs
// with no server behind it — so the login seed reads a zero toggle byte and neither bar
// comes up. Raise them the way the Options rows do, or the 61../49.. wells seeded above
// draw nowhere and `ui-actionbar` loses two rows it exists to show. This is the DEMO's
// choice about what to photograph, not a default: `MultiActionBar_Update` is the same
// function the row calls, so nothing here is a private door into the bars. Guarded because
// this feed also runs with `WOW_CAPTURE_UI` unset, where no interface has been loaded.
let _ = script.run(
"SHOW_MULTI_ACTIONBAR_1 = 1 SHOW_MULTI_ACTIONBAR_2 = 1 \
if MultiActionBar_Update then MultiActionBar_Update() end",
);
script.fire_event("PLAYER_XP_UPDATE", vec![]);
for token in ["player", "target"] {
script.fire_event("UNIT_HEALTH", vec![ScriptValue::Str(token.into())]);
@@ -850,6 +861,9 @@ mod errors_tests;
#[cfg(test)]
mod shipped_xml_tests;
#[cfg(test)]
mod bottom_hud_tests;
#[cfg(test)]
mod bagnon_render_tests;
@@ -1,6 +1,9 @@
//! The always-on multibars (`MultiBars.xml`) + the stance bar (`StanceBar.xml`) driven end to
//! The four optional extra bars (`MultiBars.xml`) + the stance bar (`StanceBar.xml`) driven end to
//! end through the REAL shipped XML — split out of `action_bar_tests.rs` (one file per bar
//! family; self-contained loader per the `bag_tests` precedent).
//!
//! Since decision 1500 all four bars are player options that ship OFF, so most of what is here
//! raises the bar it is about first — through the same two globals the Options rows write.
use benilla_ui::script::{ActionSlot, QuadContent, SpellTooltipView, UiScript};
@@ -40,12 +43,28 @@ fn load_action_bar(s: &UiScript) {
}
}
/// The two always-on bottom multibars (MultiBars.xml) through the REAL shipped XML: the fixed
/// Raise exactly the bars named, through the globals the Options rows write.
fn show_bars(s: &UiScript, bars: &[u32]) {
let mut lua = String::new();
for bar in 1..=4u32 {
lua.push_str(&format!(
"SHOW_MULTI_ACTIONBAR_{bar} = {} ",
if bars.contains(&bar) { "1" } else { "nil" }
));
}
lua.push_str("MultiActionBar_Update()");
s.run(&lua).unwrap();
}
/// The two bottom multibars (MultiBars.xml) through the REAL shipped XML, RAISED: the fixed
/// page bases (BottomLeft = actions 61..72, BottomRight = 49..60 — ref ActionButton_GetPagedID's
/// parent-name fork), the vanilla anchor chain (BottomLeft's BOTTOMLEFT on ActionButton1's
/// TOPLEFT +17, BottomRight 10 to its right), empty wells HIDDEN except while a payload is held
/// (the ref's own multibar default, unlike the main bar's always-visible wells), a click queuing
/// the multibar id, and the bonus-bar page flip leaving multibar ids untouched.
///
/// The raise is the first thing this does since 1500 — the bars ship off, and everything below is
/// about what a bar looks like once the player has asked for it.
#[test]
fn shipped_multibars_drive_end_to_end() {
let mut s = UiScript::new().unwrap();
@@ -100,6 +119,7 @@ fn shipped_multibars_drive_end_to_end() {
}),
);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
show_bars(&s, &[1, 2]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve();
let quads = s.extract();
@@ -223,10 +243,12 @@ fn shipped_stance_bar_drives_end_to_end() {
load_action_bar(&s);
// MultiBars.xml too, and it is load-bearing for the geometry below: the stance bar's OnShow
// re-fires UIParent_ManageFramePositions, whose `ShapeshiftBarFrame` row computes the y as
// baseY 0 + bottomLeft 45 — the same +45 the XML anchor carries statically, but only while
// the always-on bottom multibar (0270) exists to raise the flag. Without this file the pass
// sees no bottom bar, drops the 45 and the bar sits on MainMenuBar's top edge.
// baseY 0 + bottomLeft 45 — the same +45 the XML anchor carries statically, but only while a
// 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");
show_bars(&s, &[1]);
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/StanceBar.xml"),
)
@@ -438,6 +460,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
);
}
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
show_bars(&s, &[1, 2]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let hover_name = |s: &UiScript, button: &str| -> Option<String> {
@@ -483,7 +506,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The two vertical multibars exist and stay hidden** — the reference's own posture, and the
/// **The two vertical multibars exist and start hidden** — the reference's own posture, and the
/// reason three corpus addons stopped dying at session start.
///
/// `MultiActionBars.xml` l.515/523 instantiates `MultiBarRight`/`MultiBarLeft` as real
@@ -491,6 +514,7 @@ fn multibar_hover_renders_the_buttons_own_action() {
/// `hidden="true"`. Atlas (`Atlas.lua:387`, `MultiBarLeft:SetFrameStrata`) and Bartender2
/// (`Bartender2.lua:74`, `MultiBarLeft:ClearAllPoints`) only need them to BE there — neither shows
/// one. So all three claims below are the fix: present, hidden, and on the reference's pages.
/// Since 1500 "hidden" is where they START rather than where they stay — see the toggle tests.
#[test]
fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
let s = UiScript::new().unwrap();
@@ -527,3 +551,500 @@ fn the_vertical_multibars_exist_hidden_on_the_reference_pages() {
);
}
}
/// **Every extra bar is down until its own toggle says otherwise, and `MultiBarLeft` needs two.**
///
/// The four bits of `PLAYER_FIELD_BYTES` byte 2 map to bars 1-4 at the FrameXML layer (the binary
/// is bar-agnostic — wow-5875-re `system/ui/scratch/action-bar-toggles.md`), and a fresh
/// character's byte is 0, which is the whole of "off by default": nothing here fakes a default,
/// the bars simply have nothing telling them to show.
///
/// The conjunction on bar 4 is the reference's own (`MultiActionBars.lua` l.73,
/// `if ( SHOW_MULTI_ACTIONBAR_3 and SHOW_MULTI_ACTIONBAR_4 )`) and it is not cosmetic:
/// `MultiBarLeft` anchors its TOPRIGHT to `MultiBarRight`'s TOPLEFT, so alone it would be a column
/// hanging off a bar that is not on screen.
#[test]
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");
const BARS: [&str; 4] = [
"MultiBarBottomLeft",
"MultiBarBottomRight",
"MultiBarRight",
"MultiBarLeft",
];
let shown =
|s: &UiScript, bar: &str| s.eval::<bool>(&format!("return {bar}:IsShown()")).unwrap();
// The zero byte, applied through the same path a login takes.
s.run("MultiActionBar_Update()").unwrap();
for bar in BARS {
assert!(!shown(&s, bar), "{bar} must be down at a zero toggle byte");
}
// Each of the first three raises ITS bar and only its bar.
for (flag, want) in [(1u32, 0usize), (2, 1), (3, 2)] {
show_bars(&s, &[flag]);
for (i, bar) in BARS.iter().enumerate() {
assert_eq!(
shown(&s, bar),
i == want,
"bar {flag} alone: {bar} shown {}, expected {}",
shown(&s, bar),
i == want
);
}
}
// Bar 4 alone does nothing at all — not even to itself.
show_bars(&s, &[4]);
for bar in BARS {
assert!(
!shown(&s, bar),
"{bar}: SHOW_MULTI_ACTIONBAR_4 without 3 raises nothing"
);
}
// 3 and 4 together bring up both vertical bars.
show_bars(&s, &[3, 4]);
assert!(shown(&s, "MultiBarRight"));
assert!(shown(&s, "MultiBarLeft"), "MultiBarLeft rides on bar 3");
assert!(!shown(&s, "MultiBarBottomLeft"));
assert!(!shown(&s, "MultiBarBottomRight"));
// All four on, then all four off again — the toggle is a toggle, not a one-way door.
show_bars(&s, &[1, 2, 3, 4]);
for bar in BARS {
assert!(shown(&s, bar), "{bar} up with the full byte");
}
show_bars(&s, &[]);
for bar in BARS {
assert!(!shown(&s, bar), "{bar} down again");
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Raising a bottom bar moves everything that shares the bottom band with it.**
///
/// `MultiActionBar_Update` ends by running `UIParent_ManageFramePositions`, and that is the whole
/// reason a bar toggle is safe to expose: the cast bar, the chat frames and the bag stack's corner
/// all seat themselves off that pass. Pinned on the two the pass expresses differently — a VAR row
/// (`CONTAINER_OFFSET_Y`, the number the bag stack reads: 70 with the band clear, +27 with either
/// bottom bar up) and a FRAME row (`CastingBarFrame`, baseY 60 +40 on `bottomEither`).
///
/// The regression it locks is the pass never firing at all, which would leave every one of those
/// frames drawing straight through a bar the player just asked for (decision 1499's screenshot).
#[test]
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, "CastingBar.xml");
// The pass writes the y of a frame row into its anchor; read it back off the anchor rather
// than off a resolved rect, so a hidden cast bar answers the same as a visible one.
let cast_y = |s: &UiScript| {
s.eval::<f64>("local _, _, _, _, y = CastingBarFrame:GetPoint() return y")
.unwrap()
};
let offset_y = |s: &UiScript| s.eval::<f64>("return CONTAINER_OFFSET_Y").unwrap();
show_bars(&s, &[]);
assert_eq!(offset_y(&s), 70.0, "band clear: the row's base");
let low = cast_y(&s);
assert_eq!(low, 60.0, "CastingBarFrame's baseY");
show_bars(&s, &[1]);
assert_eq!(
offset_y(&s),
97.0,
"bottom-left up: 70 + the row's bottomEither 27"
);
assert_eq!(cast_y(&s), 100.0, "the cast bar rises with it (60 + 40)");
// The RIGHT bottom bar raises the same flag — either one, not both.
show_bars(&s, &[2]);
assert_eq!(offset_y(&s), 97.0, "bottomEither is either");
assert_eq!(cast_y(&s), 100.0);
// Both up is still one step for these two rows (`bottomEither` is paid once); the bag corner's
// own `bottomRight` delta is 0, which is why this number does not move again.
show_bars(&s, &[1, 2]);
assert_eq!(offset_y(&s), 97.0);
// And down again — the pass is re-derived, never accumulated.
show_bars(&s, &[]);
assert_eq!(offset_y(&s), 70.0);
assert_eq!(cast_y(&s), low);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **A raised bar takes its page out of the main bar's cycle, and a lowered one gives it back.**
///
/// `ActionBar.xml` now declares all six pages viewable, because all four extra bars ship off; the
/// arithmetic lives in `MultiActionBar_Update`. Before 1500 two rows were blanked at DECLARATION,
/// which was only ever right because visibility was static.
#[test]
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");
let viewable = |s: &UiScript| {
s.eval::<String>(
"local out = {} \
for i = 1, NUM_ACTIONBAR_PAGES do \
if VIEWABLE_ACTION_BAR_PAGES[i] then table.insert(out, i) end \
end \
return table.concat(out, \",\")",
)
.unwrap()
};
show_bars(&s, &[]);
assert_eq!(viewable(&s), "1,2,3,4,5,6", "no bar up, no page claimed");
// ref ActionButton.lua:6-9 — BottomLeft 6, BottomRight 5, Right 3, Left 4.
show_bars(&s, &[1]);
assert_eq!(viewable(&s), "1,2,3,4,5", "BottomLeft owns page 6");
show_bars(&s, &[2]);
assert_eq!(viewable(&s), "1,2,3,4,6", "BottomRight owns page 5");
show_bars(&s, &[3]);
assert_eq!(viewable(&s), "1,2,4,5,6", "MultiBarRight owns page 3");
show_bars(&s, &[3, 4]);
assert_eq!(
viewable(&s),
"1,2,5,6",
"…and MultiBarLeft page 4 beside it"
);
// Bar 4 without bar 3 shows nothing, so it claims nothing either — the page arithmetic reads
// the same conjunction the Show/Hide does, not the raw flag.
show_bars(&s, &[4]);
assert_eq!(viewable(&s), "1,2,3,4,5,6");
show_bars(&s, &[1, 2, 3, 4]);
assert_eq!(viewable(&s), "1,2", "all four up: only the main pages left");
show_bars(&s, &[]);
assert_eq!(viewable(&s), "1,2,3,4,5,6", "and every page comes back");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Always Show ActionBars holds every extra bar's empty wells open — and lets go without closing
/// what a held payload is still holding.**
///
/// That last clause is why `button.showgrid` is a COUNT rather than a flag (`ActionBar.xml`'s SCOPE
/// note): the option and the cursor payload are two independent askers, and 0216's "no two shows
/// can ever nest" stopped being true the moment this switch existed.
#[test]
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");
show_bars(&s, &[1]);
let well = |s: &UiScript| {
s.eval::<bool>("return MultiBarBottomLeftButton5:IsShown()")
.unwrap()
};
assert!(!well(&s), "an empty multibar well is hidden by default");
s.run("ALWAYS_SHOW_MULTIBARS = \"1\" MultiActionBar_UpdateGridVisibility()")
.unwrap();
assert!(well(&s), "the option opens it with nothing in hand");
// Every one of the four bars, not just the raised one — the reference's ShowAllGrids names all
// four, and a bar the player turns on later must already be holding its wells open.
for bar in ["MultiBarBottomRight", "MultiBarRight", "MultiBarLeft"] {
assert!(
s.eval::<bool>(&format!("return {bar}Button5:IsShown()"))
.unwrap(),
"{bar}'s wells are held open too"
);
}
// A payload arrives on top of the option, then the option lets go: the well stays open because
// the payload is still asking for it. A boolean would have closed it here.
s.fire_event("ACTIONBAR_SHOWGRID", vec![]);
assert!(well(&s));
s.run("ALWAYS_SHOW_MULTIBARS = \"0\" MultiActionBar_UpdateGridVisibility()")
.unwrap();
assert!(well(&s), "the payload still holds the well open");
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
assert!(!well(&s), "and it closes when the last asker lets go");
// 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).
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");
s.fire_event("ACTIONBAR_HIDEGRID", vec![]);
assert!(!well(&s));
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The byte the row sends is the four globals, and the seed brings that byte back.**
///
/// The whole round trip, over the REAL bindings (`benilla_ui::script::action_bar_toggles`): the
/// row's setter writes a global, re-derives the bars and posts `CMSG_SET_ACTIONBAR_TOGGLES` with
/// the WHOLE byte — bits `0x01/0x02/0x04/0x08`, verified against the 1.12.1 binary (wow-5875-re
/// `417c2d31`). Coming back, the server's descriptor push is the only thing that moves the getter,
/// and `UIParent.xml`'s `PLAYER_ENTERING_WORLD` arm reads it exactly once as the seed.
///
/// Every `Set` is one packet, deliberately (the binding gates nothing), which is why the drain is a
/// list and each step below checks the packet it just caused.
#[test]
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");
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();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x01]);
s.run("BenillaMultiBar_SetShown(3, \"1\")").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();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x0d]);
s.run("BenillaMultiBar_SetShown(1, \"0\")").unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x0c],
"the row hands the setter a STRING, and \"0\" is off"
);
// What is stored is 1 or nil and nothing else — GetActionBarToggles' own shape, and what the
// corpus reads back (Bartender2 writes 1s; CT_BarMod tests `not SHOW_MULTI_ACTIONBAR_n`).
assert_eq!(
s.eval::<String>(
"return type(SHOW_MULTI_ACTIONBAR_3) .. \"/\" .. type(SHOW_MULTI_ACTIONBAR_1)"
)
.unwrap(),
"number/nil"
);
// The way back in. Wipe the live state, push the byte the server would hold, and run the seed
// `UIParent.xml`'s PLAYER_ENTERING_WORLD arm runs — nothing else re-reads that field, so this
// one read is the whole restore.
show_bars(&s, &[]);
let _ = s.take_action_bar_toggle_sends();
s.set_action_bar_toggles(0x0c);
s.fire_event("PLAYER_ENTERING_WORLD", vec![]);
assert!(
s.eval::<bool>("return MultiBarRight:IsShown() and MultiBarLeft:IsShown()")
.unwrap(),
"the seed brings back exactly the byte that was sent"
);
assert!(!s
.eval::<bool>("return MultiBarBottomLeft:IsShown()")
.unwrap());
assert!(
s.take_action_bar_toggle_sends().is_empty(),
"the seed READS — a login must not post the byte back at the server"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The shipped setter passes exactly FOUR arguments** — the one claim the real binding cannot be
/// asked, which is why this is the one place that shadows it.
///
/// `SetActionBarToggles` loops `i = 0..3` (`0x4e770e cmp esi,4`) and never fetches a fifth, so a
/// call with five is *behaviourally* identical to one with four — the difference is invisible from
/// outside and only a spy on the Lua side can see it. The reference's own panel does pass five
/// (`UIOptionsFrame_Save` l.363, with `ALWAYS_SHOW_MULTIBARS` on the end) and that fifth is the
/// reason its grid option looks server-backed and is not. Ours passes four and this holds it there,
/// so the day someone "restores fidelity" by appending the fifth, they are told what it costs.
///
/// A later reader: the shadowing here is deliberate and is NOT a leftover shim. Everything else in
/// this file drives the real binding.
#[test]
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");
// `r##`: the Lua contains `select("#", ...)`, and `"#` would close a single-hash raw string.
s.run(
r##"
BENILLA_TEST_TOGGLE_ARGC = nil
function SetActionBarToggles(...)
BENILLA_TEST_TOGGLE_ARGC = select("#", ...)
end
"##,
)
.unwrap();
s.run("BenillaMultiBar_SetShown(2, \"1\")").unwrap();
assert_eq!(
s.eval::<i64>("return BENILLA_TEST_TOGGLE_ARGC").unwrap(),
4,
"four — never the reference's five, which the binding drops on the floor"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The stance bar's seat is the manage pass's, in BOTH bottom-bar states.**
///
/// `StanceBar.xml`'s XML anchor carries the RAISED value (`MainMenuBar` TOPLEFT +(30,45)) so the
/// frame is somewhere sane before the first pass; the truth is the `ShapeshiftBarFrame` row
/// (baseY 0, bottomLeft 45). That distinction did not matter while the bottom bars were always on
/// — the two agreed by construction — and 1500 makes the unraised state reachable, so it is pinned
/// here rather than assumed.
#[test]
fn the_stance_bar_sits_where_the_pass_puts_it() {
use benilla_ui::script::ShapeshiftFormView;
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");
s.set_shapeshift_forms(vec![ShapeshiftFormView {
spell_id: 2457,
texture: Some("Interface\\Icons\\Stance_A".into()),
name: "Battle Stance".into(),
active: true,
castable: true,
cooldown: None,
}]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
let seat = |s: &UiScript| {
s.eval::<f64>("local _, _, _, _, y = ShapeshiftBarFrame:GetPoint() return y")
.unwrap()
};
show_bars(&s, &[]);
assert_eq!(
seat(&s),
0.0,
"no bottom bar: the row's baseY, on the main bar"
);
show_bars(&s, &[1]);
assert_eq!(seat(&s), 45.0, "bottom-left up: +45, the ref's raised seat");
show_bars(&s, &[2]);
assert_eq!(
seat(&s),
0.0,
"the RIGHT bottom bar is not under it — the row's flag is bottomLeft, not bottomEither"
);
show_bars(&s, &[]);
assert_eq!(seat(&s), 0.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **The stance bar's SHELF ART follows the same pass as its seat** (ref
/// `ShapeshiftBar_UpdatePosition`, BonusActionBarFrame.lua l.229-251).
///
/// Sitting on the main bar, the shelf IS the bar's border: the two end caps show, the middle strip
/// shows only past two forms, and each button's ring grows to 64. Raised a row over the bottom-left
/// multibar the strips would draw across whatever is underneath, so all three hide and the rings
/// drop to 50.
///
/// This was dead code until 1500. With the bottom bars always on (0270) only the raised branch was
/// reachable; now the unraised bar is what every form class sees on a fresh character, which makes
/// the missing border the DEFAULT look rather than an edge case.
///
/// The last block is the point of the whole test: one `MultiActionBar_Update()` flips the seat AND
/// the art, which is what proves the art rides `UIParent_RegisterManagedPositionListener` rather
/// than having been set once at load.
#[test]
fn the_stance_shelf_follows_the_bottom_left_bar() {
use benilla_ui::script::ShapeshiftFormView;
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");
let form = |id: u32| ShapeshiftFormView {
spell_id: id,
texture: Some(format!("Interface\\Icons\\Stance_{id}")),
name: format!("Form {id}"),
active: false,
castable: true,
cooldown: None,
};
let shown = |s: &UiScript, region: &str| {
s.eval::<bool>(&format!("return {region}:IsShown()"))
.unwrap()
};
let ring = |s: &UiScript| {
s.eval::<f64>("return ShapeshiftButton1NormalTexture:GetWidth()")
.unwrap()
};
// A three-form warrior, no bottom bar: the whole shelf, and the big ring.
s.set_shapeshift_forms(vec![form(2457), form(71), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
show_bars(&s, &[]);
assert!(shown(&s, "ShapeshiftBarLeft"), "the left end cap");
assert!(shown(&s, "ShapeshiftBarRight"), "the right end cap");
assert!(
shown(&s, "ShapeshiftBarMiddle"),
"3 forms: the middle strip"
);
assert_eq!(ring(&s), 64.0, "unraised rings are the ref's 64");
// Two forms drop the middle strip and nothing else — the ref's own `> 2`.
s.set_shapeshift_forms(vec![form(2457), form(71)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
assert!(shown(&s, "ShapeshiftBarLeft"));
assert!(shown(&s, "ShapeshiftBarRight"));
assert!(
!shown(&s, "ShapeshiftBarMiddle"),
"exactly 2 forms: end caps only"
);
assert_eq!(ring(&s), 64.0);
// Raising the bottom-left bar flips the SEAT and the ART in the one pass — the listener seam.
show_bars(&s, &[1]);
assert_eq!(
s.eval::<f64>("local _, _, _, _, y = ShapeshiftBarFrame:GetPoint() return y")
.unwrap(),
45.0,
"the seat rose"
);
for region in [
"ShapeshiftBarLeft",
"ShapeshiftBarMiddle",
"ShapeshiftBarRight",
] {
assert!(
!shown(&s, region),
"{region} must not draw across the row below"
);
}
assert_eq!(ring(&s), 50.0, "raised rings are the ref's 50");
// 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.
s.set_shapeshift_forms(vec![form(2457), form(71), form(2458)]);
s.fire_event("UPDATE_SHAPESHIFT_FORMS", vec![]);
assert!(!shown(&s, "ShapeshiftBarMiddle"));
assert_eq!(ring(&s), 50.0);
// …and back down again, in one call.
show_bars(&s, &[]);
assert!(shown(&s, "ShapeshiftBarMiddle"));
assert_eq!(ring(&s), 64.0);
assert_eq!(
s.eval::<f64>("local _, _, _, _, y = ShapeshiftBarFrame:GetPoint() return y")
.unwrap(),
0.0
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+181 -15
View File
@@ -677,13 +677,24 @@ fn interface_harness() -> UiScript {
harness_on(s)
}
/// The Action Bars page's harness (decision 1136): `ActionBar.xml` declares `LOCK_ACTIONBAR`, and
/// it needs `UIParent.xml` (the managed bottom stack it seats into) and `Cooldown.xml`
/// (`CooldownFrame_SetTimer`, every button's child) ahead of it — the manifest's own order.
/// The Action Bars page's harness (1136's lock row, 1500's five switches): `ActionBar.xml` declares
/// `LOCK_ACTIONBAR` and `MultiBars.xml` the four `SHOW_MULTI_ACTIONBAR_*` globals plus
/// `ALWAYS_SHOW_MULTIBARS` (whose file-scope "0" IS the row's registered default), and both need
/// `UIParent.xml` (the managed bottom stack the bars move) and `Cooldown.xml`
/// (`CooldownFrame_SetTimer`, every button's child) ahead of them — the manifest's own order.
fn actionbars_harness() -> UiScript {
let mut s = audio_harness();
s.set_screen_size(1024.0, 768.0);
load_definers(&s, &["UIParent.xml", "Cooldown.xml", "ActionBar.xml"]);
load_definers(
&s,
&[
"Fonts.xml",
"UIParent.xml",
"Cooldown.xml",
"ActionBar.xml",
"MultiBars.xml",
],
);
harness_on(s)
}
@@ -1772,12 +1783,13 @@ fn every_row_tooltip_key_resolves_in_the_real_global_strings() {
// 21 CVar rows (Social's two bubble switches are 1139's; Status Bar Text, Mouse Sensitivity
// and Max Camera Distance 1140's; Graphics' Vertical Sync is 1394's; Camera Following Style
// 1493's) + the Combat page's 14 saved-variable rows (1134) + the Interface page's 4 (3 from
// 1136, Buff Durations 1139) and the Action Bars page's 1 (1136) + the Interface page's 2 API
// rows (Show Cloak / Show Helm, 1472) — which is the point of counting here rather than per
// page: the third store's rows are held to the same "the key is 1.12's own and it resolves"
// 1136, Buff Durations 1139) and the Action Bars page's 2 (the lock 1136, Always Show
// ActionBars 1500) + 6 API rows (the Interface page's Show Cloak / Show Helm, 1472; the Action
// Bars page's four multibar switches, 1500) — which is the point of counting here rather than
// per page: the third store's rows are held to the same "the key is 1.12's own and it resolves"
// bar as the other two. Camera Following Style is counted on the key it wears at rest (Smart's
// OPTION_TOOLTIP_CAMERA1); the other two ride the same census as the selection moves.
assert_eq!(checked, 42, "every row but one carries a live 1.12 key");
assert_eq!(checked, 47, "every row but one carries a live 1.12 key");
assert_eq!(
untipped,
vec!["ControlsRowAutoLoot".to_string()],
@@ -1869,9 +1881,9 @@ fn every_flavor_of_row_raises_its_plate_from_the_page_it_lives_on() {
// 21 of the 22 CVar rows (Social's two are 1139's; Status Bar Text, Mouse Sensitivity and
// Max Camera Distance 1140's; Vertical Sync 1394's; Camera Following Style 1493's), plus the
// Combat page's 14 saved-variable rows (1134), the Interface page's 4 (1136, + Buff Durations
// 1139), Action Bars' 1 (1136) and the Interface page's 2 API rows (Show Cloak / Show Helm,
// 1472).
assert_eq!(raised, 42, "every row but Auto Loot has a 1.12 description");
// 1139), Action Bars' 2 (the lock 1136, Always Show ActionBars 1500) and 6 API rows (Show
// Cloak / Show Helm, 1472; the four multibar switches, 1500).
assert_eq!(raised, 47, "every row but Auto Loot has a 1.12 description");
}
/// The **Combat page** (decision 1134) — the first rows in this window whose store is a
@@ -2109,10 +2121,10 @@ fn what_the_combat_page_writes_survives_a_restart() {
);
}
/// The **Action Bars page** (decision 1136) — one row, over the one global on that group whose
/// store is a uvar at all (the five multibar toggles read a `func`, and our two bottom multibars
/// are unconditionally on). It is also the page whose setting had to be BUILT first: 1134 §3 listed
/// `LOCK_ACTIONBAR` as "not defined, and no guard exists".
/// The **Action Bars page**'s lock row (decision 1136) — the one global on that group whose store
/// is a uvar. It is also the page whose setting had to be BUILT first: 1134 §3 listed
/// `LOCK_ACTIONBAR` as "not defined, and no guard exists". The five switches above it are 1500's
/// and have their own test below.
///
/// The end-to-end teeth are the last block: the row's write reaches the shipped bar's own drag
/// guard in the same VM. `action_bar_tests`/`pet_bar_tests` own the guard's full behaviour
@@ -2926,3 +2938,157 @@ fn the_camera_following_style_dropdown_carries_the_engine_enum_and_plate() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The **Action Bars page's five switches** (decision 1500) — the four bar toggles and the grid
/// option, on ONE page over TWO different stores, which is the whole point of the test.
///
/// The four bar rows are API (`func`) rows: there is nothing local to save, because the preference
/// is four bits of this character's server-side `PLAYER_FIELD_BYTES` byte 2. A click writes the
/// live Lua global, re-derives the bars and re-sends the whole byte — with **four** arguments, the
/// binding's verified arity. Always Show ActionBars is a saved-variable row instead, and NOT
/// because someone preferred it: the same binding silently drops the fifth argument the reference
/// passes it, so that switch has no server store to write.
///
/// The end-to-end teeth are the real bars moving in the same VM — the row's write reaches
/// `MultiActionBar_Update`, which reaches `UIParent_ManageFramePositions`.
#[test]
fn the_action_bars_page_toggles_the_real_bars() {
let mut s = actionbars_harness();
s.run("ShowUIPanel(OptionsFrame)").unwrap();
s.run("OptionsFrameCategoryListRowActionBars:Click()")
.unwrap();
let box_of = |row: &str| format!("OptionsFrameContainerBodyActionBars{row}Check");
let checked = |s: &UiScript, row: &str| {
s.eval::<bool>(&format!(
"return {}:GetChecked() and true or false",
box_of(row)
))
.unwrap()
};
let shown =
|s: &UiScript, bar: &str| s.eval::<bool>(&format!("return {bar}:IsShown()")).unwrap();
// Read: every switch ships OFF, and so does every bar.
for row in [
"RowMultiBar1",
"RowMultiBar2",
"RowMultiBar3",
"RowMultiBar4",
"RowAlwaysShowMultibars",
] {
assert!(!checked(&s, row), "{row} ships unticked");
}
for bar in [
"MultiBarBottomLeft",
"MultiBarBottomRight",
"MultiBarRight",
"MultiBarLeft",
] {
assert!(!shown(&s, bar), "{bar} ships down");
}
// Bar 4's row is DEAD while bar 3's is unticked — MultiBarLeft cannot stand without
// MultiBarRight (the reference's own rule for this pair, UIOptionsFrame.lua l.722-726).
assert!(
!s.eval::<bool>(&format!("return {}:IsEnabled()", box_of("RowMultiBar4")))
.unwrap(),
"Show Right ActionBar 2 is disabled until Show Right ActionBar is on"
);
// The write: a click raises the bar, writes the global as the number 1, and sends the byte.
let _ = s.take_cvar_changes();
s.run(&format!("{}:Click()", box_of("RowMultiBar1")))
.unwrap();
assert!(shown(&s, "MultiBarBottomLeft"), "the row reached the bar");
assert_eq!(s.eval::<i64>("return SHOW_MULTI_ACTIONBAR_1").unwrap(), 1);
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x01],
"one CMSG_SET_ACTIONBAR_TOGGLES carrying the whole byte"
);
assert!(
s.take_cvar_changes().is_empty(),
"an API row must not touch the CVar table"
);
// …and the managed bottom stack moved with it (CONTAINER_OFFSET_Y's bottomEither 27).
assert_eq!(s.eval::<f64>("return CONTAINER_OFFSET_Y").unwrap(), 97.0);
// Ticking bar 3 wakes bar 4's row; ticking bar 4 then brings MultiBarLeft up beside it.
s.run(&format!("{}:Click()", box_of("RowMultiBar3")))
.unwrap();
assert!(
s.eval::<bool>(&format!("return {}:IsEnabled()", box_of("RowMultiBar4")))
.unwrap(),
"bar 3 on wakes bar 4's row"
);
assert!(shown(&s, "MultiBarRight"));
assert!(!shown(&s, "MultiBarLeft"), "bar 4 is still off");
s.run(&format!("{}:Click()", box_of("RowMultiBar4")))
.unwrap();
assert!(shown(&s, "MultiBarLeft"));
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x05, 0x0d],
"one packet per click — bars 1+3, then 1+3+4"
);
// Untick bar 3 and MultiBarLeft goes with it, even though bar 4's own flag is still set — the
// conjunction is in MultiActionBar_Update, not in the row.
s.run(&format!("{}:Click()", box_of("RowMultiBar3")))
.unwrap();
assert!(!shown(&s, "MultiBarRight"));
assert!(!shown(&s, "MultiBarLeft"), "MultiBarLeft rides on bar 3");
assert_eq!(s.eval::<i64>("return SHOW_MULTI_ACTIONBAR_4").unwrap(), 1);
assert!(
!s.eval::<bool>(&format!("return {}:IsEnabled()", box_of("RowMultiBar4")))
.unwrap(),
"…and its row goes back to sleep"
);
// The grid switch is the OTHER store: a saved-variable global, no packet, and an applyFunc
// that opens every extra bar's empty wells.
let _ = s.take_action_bar_toggle_sends();
s.run(&format!("{}:Click()", box_of("RowAlwaysShowMultibars")))
.unwrap();
assert_eq!(
s.eval::<String>("return ALWAYS_SHOW_MULTIBARS").unwrap(),
"1",
"a uvar row stores the panel's string"
);
assert!(
s.take_action_bar_toggle_sends().is_empty(),
"and sends NOTHING — the binding has no room for a fifth argument"
);
assert!(
s.eval::<bool>("return MultiBarBottomLeftButton5:IsShown()")
.unwrap(),
"the applyFunc opened the empty wells"
);
// Defaults walks the whole page back: every bar down, the grid off, the lock unlocked.
s.run("OptionsFrameContainerDefaults:Click()").unwrap();
for bar in [
"MultiBarBottomLeft",
"MultiBarBottomRight",
"MultiBarRight",
"MultiBarLeft",
] {
assert!(!shown(&s, bar), "{bar} back down");
}
assert_eq!(
*s.take_action_bar_toggle_sends()
.last()
.expect("Defaults writes every bar row"),
0,
"the last packet Defaults sends is the empty byte"
);
assert_eq!(
s.eval::<String>("return ALWAYS_SHOW_MULTIBARS").unwrap(),
"0",
"MultiBars.xml's own file-scope assignment IS the registered default"
);
assert_eq!(s.eval::<f64>("return CONTAINER_OFFSET_Y").unwrap(), 70.0);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -355,9 +355,10 @@ fn a_disabled_bar_greys_rather_than_hides() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The pet bar as it lands with the bottom-left multibar loaded and shown — which is how benilla
/// actually runs (0270: the bottom bars are always on). `shelf` counts the two `UI-PetBar` strips;
/// `attack_top` is the top edge of Attack's icon, the bar's own row in one number.
/// The pet bar with and without the bottom-left multibar RAISED. Since 1500 that bar is a player
/// option shipping off, so the flag now raises it rather than merely loading the file — both states
/// are ones a real session lands in. `shelf` counts the two `UI-PetBar` strips; `attack_top` is the
/// top edge of Attack's icon, the bar's own row in one number.
fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -369,8 +370,10 @@ fn pet_bar_row(with_multibar: bool) -> (usize, f32) {
] {
load_xml(&s, file);
}
load_xml(&s, "MultiBars.xml");
if with_multibar {
load_xml(&s, "MultiBars.xml");
s.run("SHOW_MULTI_ACTIONBAR_1 = 1 MultiActionBar_Update()")
.unwrap();
}
load_xml(&s, "PetActionBar.xml");
declare_token_strings(&s);
@@ -219,8 +219,10 @@ fn action_button_hover_takes_the_default_corner() {
/// The other leg of that same branch, live since B230 registered the CVar: with `UberTooltips`
/// off, an action button's plate leaves the screen corner and seats BESIDE the button — LEFT for
/// the bars the reference lists (`MultiBarBottomRight` is the only one benilla ships; the two
/// vertical bars are not), RIGHT for everything else, including the main bar.
/// the three bars the reference lists (`MultiBarBottomRight`, `MultiBarRight`, `MultiBarLeft`: the
/// ones at or against the right edge, whose plates have to open toward the centre), RIGHT for
/// everything else, including the main bar. All three exist here since 1219/1500; the set used to
/// be the first alone.
///
/// The two anchors are read off the resolved SetPoint pair, which is what `SetOwner` actually
/// writes: ANCHOR_RIGHT = the plate's BOTTOMLEFT on the button's TOPRIGHT, ANCHOR_LEFT its mirror
@@ -255,14 +257,17 @@ fn ubertooltips_off_seats_action_bar_plates_beside_the_button() {
"ANCHOR_RIGHT — the main bar is not in the ref's LEFT set"
);
// MultiBarBottomRight IS in that set — the one shipped member of it.
s.run("BenillaActionButton_OnEnter(MultiBarBottomRightButton1)")
.unwrap();
assert_eq!(
seat(&s),
"BOTTOMRIGHT/MultiBarBottomRightButton1/TOPLEFT",
"ANCHOR_LEFT — MultiBarBottomRight opens toward screen centre"
);
// 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)"))
.unwrap();
assert_eq!(
seat(&s),
format!("BOTTOMRIGHT/{bar}Button1/TOPLEFT"),
"ANCHOR_LEFT — {bar} opens toward screen centre"
);
}
// And the CVar back on restores the corner — the fork is a fork, not a one-way door.
s.set_cvar_engine("UberTooltips", "1");
+59
View File
@@ -147,6 +147,11 @@ struct UnitFeedMemo {
/// belief optimistically and the server's answer is a round trip away, so a per-frame push
/// would snap the value back to the stale descriptor in between.
worn_hidden: Option<(bool, bool)>,
/// The self player's last-pushed `PLAYER_FIELD_BYTES` byte 2 — the four extra bars' visibility
/// (wow-re `action-bar-toggles.md`). A player-global like the combo pair, pushed on the edge.
/// `None` until first seen; there is no event to fire on it, because the real client registers
/// no field-change callback anywhere near this offset.
action_bar_toggles: Option<u8>,
}
/// Adds the per-frame unit feed. The `Unit*` bindings themselves live in `benilla-ui`; this only
@@ -184,6 +189,7 @@ impl Plugin for UiUnitPlugin {
)
.add_systems(Update, drain_pvp_toggles.after(UiInput))
.add_systems(Update, drain_worn_display_toggles.after(UiInput))
.add_systems(Update, drain_action_bar_toggles.after(UiInput))
.add_systems(Update, feed_default_language.in_set(UnitFeed))
// `load_exhaustion_rows` pushes into the VM, so it runs per VM in `Update` (1290);
// `load_default_languages` only builds a Bevy resource and stays a one-shot.
@@ -647,6 +653,26 @@ fn drain_worn_display_toggles(script: Option<NonSendMut<UiScript>>, commands: Re
}
}
/// Drain the `SetActionBarToggles` posts into `CMSG_SET_ACTIONBAR_TOGGLES` (wow-re
/// `system/ui/scratch/action-bar-toggles.md` §3) — the Options window's four extra-bar rows, and
/// the only callers there are.
///
/// **Every queued call becomes a packet**, with no did-it-change gate and no coalescing: the real
/// binding has neither (unlike `ShowHelm`/`ShowCloak`, which send only on a difference), so two
/// calls in a frame are two sends. The byte is absolute rather than a flip, so a duplicate is
/// harmless — but dropping one would be an optimisation the reference does not make, and the
/// server is the only store this preference has.
fn drain_action_bar_toggles(script: Option<NonSendMut<UiScript>>, commands: Res<NetCommands>) {
let Some(mut script) = script else {
return;
};
for toggles in script.take_action_bar_toggle_sends() {
let _ = commands
.0
.send(crate::net::ClientCommand::SetActionBarToggles { toggles });
}
}
/// `PLAYER_FLAGS_PVP_DESIRED` — the PvP *preference* bit (vmangos `PlayerDefines.h`), which is what
/// `CMSG_TOGGLE_PVP` flips. Not to be confused with `UNIT_FIELD_FLAGS`' `PVP` bit `0x1000`, the flag
/// the icon draws: the preference clears instantly, the flag lingers for the server's timer.
@@ -1051,6 +1077,35 @@ fn feed_units(
}
}
// The action-bar toggle feed: `PLAYER_FIELD_BYTES` byte 2 — which of the four extra bars the
// player has switched on (wow-re `system/ui/scratch/action-bar-toggles.md`). PRIVATE, like the
// combo byte one address down (`+0x1029` vs `+0x102a`), and pushed on the EDGE.
//
// **This push is the ONLY thing that moves the VM's copy**, and that is the mechanism rather
// than our simplification: no instruction in the real client writes this cell (§4.1 — the one
// `+0x102a` access image-wide is `GetActionBarToggles`' read), so `SetActionBarToggles` posts
// the byte and leaves the descriptor alone until the server's UPDATE_OBJECT echoes it. Nothing
// is notified when it lands either (§4.2: all 49 field-change registrations at `0x468070` were
// enumerated; none sits at an offset ≥ `0x1000`), so there is **no event to fire here** — the
// reference reads the binding exactly once, in `UIParent.lua`'s `PLAYER_ENTERING_WORLD`
// handler, and keeps `SHOW_MULTI_ACTIONBAR_1..4` as its optimistic copy in between.
//
// Which is why this sits ABOVE the fire, on 1087's precedent for the XP/rest pushes: the
// handler that reads `GetActionBarToggles()` runs synchronously inside `fire_event`, so a push
// below it would hand the first-paint the previous frame's value — four nils.
//
// `unwrap_or(0)` is faithful, not a shrug: with no local player the reference's chain fails
// soft and the getter returns four `nil`s, which is exactly what a zero byte returns — "not in
// world" and "byte == 0" share the branch and are indistinguishable to Lua (§5).
if let Some((store, _)) = self_q.iter().next() {
let toggles = store.0.player_action_bar_toggles().unwrap_or(0);
if memo.action_bar_toggles != Some(toggles) {
gate.audit("feed_units", "the action-bar toggle byte");
memo.action_bar_toggles = Some(toggles);
script.set_action_bar_toggles(toggles);
}
}
// Initial pull: fire PLAYER_ENTERING_WORLD once PER WORLD ENTRY so frames do their first
// paint on their own — gated on our avatar's descriptor EXISTING. 1087 stated the real
// client's guarantee (the player object lands before this event) and moved the XP/rest
@@ -1088,6 +1143,10 @@ fn feed_units(
// carrying the last body's bits would silently skip a new body that happens to disagree
// with the VM's fresh "both shown" default (decision 1472).
memo.worn_hidden = None;
// Same reason as the worn-display pair: the push is an EDGE, so a memo carrying the last
// body's byte would skip a new character whose own toggles happen to match it — and this
// one has no optimistic default to fall back on, only four nils.
memo.action_bar_toggles = None;
}
for (token, snap) in [("player", &player), ("target", &target)] {
@@ -5,6 +5,10 @@
//! `u32`s and hands them back at login, and the client sends one `CMSG_SET_ACTION_BUTTON` per local
//! slot mutation. There is no server-side edit in normal play, so `SMSG_ACTION_BUTTONS` is a
//! login-only packet in practice — which is why the whole family is three items.
//!
//! The **visibility** of the four extra bars is the opposite arrangement and shares this file only
//! because it shares the subject: it is one server-owned byte the client can post but never write
//! locally. See [`set_actionbar_toggles`].
use std::io::{self};
@@ -63,3 +67,30 @@ pub fn set_action_button(button: u8, packed: u32) -> Vec<u8> {
body.extend_from_slice(&packed.to_le_bytes());
body
}
/// Body of `CMSG_SET_ACTIONBAR_TOGGLES` (opcode [`super::opcode::CMSG_SET_ACTIONBAR_TOGGLES`]):
/// **one `u8`**, and nothing else — VERIFIED at the bytes, wow-re
/// `system/ui/scratch/action-bar-toggles.md` §3. `SetActionBarToggles 0x4e76e0` builds a stack
/// `CDataStore`, appends the opcode with `PutUInt32 0x418190` and the byte with `PutUInt8
/// 0x418070`, and sends; those two appends are the only ones between construction and send, and
/// `NetClient::Send 0x5379b3` computes the payload length as `size − read` = **5** — the
/// independent confirmation that 4 + 1 is the whole frame.
///
/// **The byte is server-owned; this is a post, not a write.** No instruction in the real client
/// stores to `PLAYER_FIELD_BYTES` byte 2 (§4.1: displacement `0x102a` occurs exactly once
/// image-wide and it is the *read* inside `GetActionBarToggles`) — the cell only ever moves through
/// the generic `SMSG_UPDATE_OBJECT` value-apply. So the sender's own field copy still holds the old
/// value until the server echoes, and nothing is notified when it does (§4.2: all 49 field-change
/// registrations at `0x468070` were enumerated and none sits at an offset ≥ `0x1000`). Read it back
/// with [`super::ObjectFields::player_action_bar_toggles`].
///
/// **Only the low nibble is reachable.** The real binding accumulates from zero and ORs at most
/// bits 0..3 (§2), so a value it produces is always `0x00..=0x0f` and a `Set` *destroys* whatever
/// the server held in the high nibble. This encoder does not mask: what the caller packed is what
/// goes on the wire, and the four-bit law belongs where the packing happens (the Lua binding).
///
/// The bit→bar meaning is **not** the binary's — it is bar-agnostic (§7), and naming the bars here
/// would put a FrameXML convention in the protocol layer.
pub fn set_actionbar_toggles(toggles: u8) -> Vec<u8> {
vec![toggles]
}
+2 -1
View File
@@ -59,7 +59,8 @@ mod update_object;
mod vendor;
mod world_state;
pub use action_bar::{
set_action_button, ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL,
set_action_button, set_actionbar_toggles, ActionButton, ACTION_KIND_ITEM, ACTION_KIND_MACRO,
ACTION_KIND_SPELL,
};
pub use addons::{SecureAddon, STANDARD_MODULUS_CRC, STOCK_SECURE_ADDONS};
pub use area_trigger::area_trigger;
@@ -133,6 +133,16 @@ pub const CMSG_SET_AMMO: u16 = 0x0268; // 616
/// VERIFIED vmangos `Opcodes_1_12_1.h`: 296 — `MiscHandler.cpp:885`'s `HandleSetActionButtonOpcode`
/// (decision 0216 §7/0218 §4). Body in [`super::action_bar::set_action_button`].
pub const CMSG_SET_ACTION_BUTTON: u16 = 0x0128; // 296
/// The four extra action bars' visibility byte (`PLAYER_FIELD_BYTES` byte 2) — VERIFIED at the
/// bytes, wow-re `system/ui/scratch/action-bar-toggles.md` §3: `0x4e771d push 0x2bf` is the ONE
/// site image-wide that emits this opcode, and the frame it builds is `u32 opcode` + a single `u8`
/// and nothing else (`0x418190` PutUInt32 then `0x418070` PutUInt8; `0x5379b3` computes the
/// payload as size − read = **5**). Body in [`super::action_bar::set_actionbar_toggles`].
///
/// Corroborated by vmangos `Opcodes_1_12_1.h`: 703 → `HandleSetActionBarTogglesOpcode`
/// (`Packets/Misc.cpp:150-153` reads one `uint8`; `Handlers/MiscHandler.cpp:923-932` stores it with
/// `SetByteValue(PLAYER_FIELD_BYTES, 2, …)`).
pub const CMSG_SET_ACTIONBAR_TOGGLES: u16 = 0x02BF; // 703
pub const SMSG_ACTION_BUTTONS: u16 = 0x0129; // 297
pub const SMSG_INITIAL_SPELLS: u16 = 0x012A; // 298
@@ -526,6 +526,36 @@ impl ObjectFields {
pub fn player_combo_target(&self) -> u64 {
self.get_guid(FIELD_PLAYER_FIELD_COMBO_TARGET).unwrap_or(0)
}
/// `PLAYER_FIELD_BYTES` byte 2 — the **action-bar toggle** byte: which of the four extra bars
/// the player has switched on (vmangos `Player.h:360-363`
/// `PLAYER_FIELD_BYTES_OFFSET_ACTION_BARS = 2`, written by `HandleSetActionBarTogglesOpcode`'s
/// `SetByteValue(PLAYER_FIELD_BYTES, 2, packet.actionBar)`). PRIVATE — the field is only
/// *allocated* on our own player (self 1282 dwords vs 486 for any other player), which is why
/// the reference binding resolves the local GUID itself instead of taking a unit token.
///
/// Byte-VERIFIED (wow-re `system/ui/scratch/action-bar-toggles.md` §4): `GetActionBarToggles
/// 0x4e7660` reads it at `[[player+0xe68]+0x102a]`, and `0x2f0 + 0x1028 = 0x1318 = 1222·4`
/// closes the arithmetic against index 1222 — the same player-block base that puts
/// [`Self::player_combo_points`] at `+0x1029` and [`Self::player_honor_rank`] at `+0x102b`.
/// (vmangos's inline comment on the field reads `// 0x4C0`, six low; its own arithmetic
/// `UNIT_END + 0x40A` and the binary both say `0x4C6`. The comment is stale — do not copy it.)
///
/// **This is the ONLY source of the value: the client never writes the byte.** Displacement
/// `0x102a` occurs exactly once image-wide and it is that read; `SetActionBarToggles` stores
/// nothing outside its own stack frame and only posts `CMSG_SET_ACTIONBAR_TOGGLES`
/// ([`crate::messages::set_actionbar_toggles`]), so the cell moves solely through the generic
/// `SMSG_UPDATE_OBJECT` value-apply — and nothing is notified when it does (§4.2). A purely
/// field-driven UI therefore lags a round trip; the reference keeps its optimistic copy in Lua
/// and re-reads this exactly once, at `PLAYER_ENTERING_WORLD`.
///
/// Only bits `0x01`..`0x08` are meaningful to the client — the binding tests four and can set
/// four (§2/§5); the high nibble is never read and is destroyed by the next `Set`. Bit→bar is a
/// FrameXML convention, not the descriptor's, so it is not named here. `None` before the field
/// streams.
pub fn player_action_bar_toggles(&self) -> Option<u8> {
self.get_u32(FIELD_PLAYER_FIELD_BYTES)
.map(|b| ((b >> 16) & 0xff) as u8)
}
/// `PLAYER_FIELD_BYTES` byte 3 — the player's **highest lifetime honor rank** (vmangos
/// `PLAYER_FIELD_BYTES_OFFSET_HIGHEST_HONOR_RANK`, written by `HonorMgr`): what the client's
/// item-usable gate compares an item's `RequiredHonorRank` against (`0x5ea930` reads the
@@ -249,17 +249,26 @@ fn player_ammo_id_reads_the_field() {
}
#[test]
fn player_field_bytes_splits_into_combo_points_and_honor_rank() {
fn player_field_bytes_splits_into_combo_points_toggles_and_honor_rank() {
// flags=0x01 / combo=0x02 / actionBars=0x03 / highestHonorRank=5 packed little-endian: the
// dword's two live readers take byte 1 and byte 3 without bleeding into each other.
// dword's three live readers take bytes 1, 2 and 3 without bleeding into each other. The
// offsets are the binary's own — the reads sit at `[[player+0xe68]+0x1029/0x102a/0x102b]`
// (wow-re `action-bar-toggles.md` §4), i.e. bytes 1/2/3 of field 1222.
let f = ObjectFields::from_pairs(&[(1222, 0x05_03_02_01)]);
assert_eq!(f.player_honor_rank(), Some(5));
assert_eq!(f.player_action_bar_toggles(), Some(3));
assert_eq!(f.player_combo_points(), Some(2));
// A full five points with everything else clear.
let capped = ObjectFields::from_pairs(&[(1222, 0x00_00_05_00)]);
assert_eq!(capped.player_combo_points(), Some(5));
assert_eq!(capped.player_action_bar_toggles(), Some(0));
assert_eq!(capped.player_honor_rank(), Some(0));
// The server may legally hold a high nibble here (it stores the whole byte); the accessor is
// the raw byte and does not mask — the four-bit law is the Lua binding's, not the field's.
let high = ObjectFields::from_pairs(&[(1222, 0x00_f5_00_00)]);
assert_eq!(high.player_action_bar_toggles(), Some(0xf5));
assert_eq!(ObjectFields::default().player_honor_rank(), None);
assert_eq!(ObjectFields::default().player_action_bar_toggles(), None);
assert_eq!(ObjectFields::default().player_combo_points(), None);
}
@@ -5,6 +5,9 @@
//! 0218 §4): the server stores the slots and hands them back at login and never edits them in
//! normal play. So every pickup, place and hop the player makes is exactly one of these — a
//! drag-swap is two sends, never atomic.
//!
//! The second verb is the mirror image of the first: [`WorldWriter::set_actionbar_toggles`] posts a
//! byte the client is **not** allowed to write locally.
use anyhow::Result;
@@ -25,4 +28,26 @@ impl WorldWriter {
&messages::set_action_button(button, packed),
)
}
/// Post the four extra bars' visibility byte (`CMSG_SET_ACTIONBAR_TOGGLES`, layout in
/// [`messages::set_actionbar_toggles`]) — `PLAYER_FIELD_BYTES` byte 2, VERIFIED wow-re
/// `system/ui/scratch/action-bar-toggles.md`.
///
/// **The opposite arrangement to [`Self::set_action_button`]**, and the difference is the whole
/// point. The bar's *contents* are client-authoritative; the bar's *visibility* is
/// server-owned: the real client never stores this byte itself (§4.1 — the one `+0x102a`
/// instruction image-wide is a read), so the value only becomes true when the server's
/// `SMSG_UPDATE_OBJECT` echoes it back into the descriptor. There is no answer packet and no
/// field-change notification (§4.2), which is why the reference UI keeps its own optimistic
/// copy in Lua and re-reads the field exactly once, at `PLAYER_ENTERING_WORLD`.
///
/// Sending while disconnected is a **silent no-op**, not an error, matching the reference: the
/// binding gates nothing and drops the packet at `0x5ab637` (no connection) or `0x5379ab`
/// (connection state ≠ 6), returning zero Lua values either way, so nothing upstream can tell.
pub fn set_actionbar_toggles(&mut self, toggles: u8) -> Result<()> {
self.send(
opcode::CMSG_SET_ACTIONBAR_TOGGLES,
&messages::set_actionbar_toggles(toggles),
)
}
}
+25
View File
@@ -120,6 +120,31 @@ fn spell_and_action_wire() {
"packed 0 clears the slot"
);
// CMSG_SET_ACTIONBAR_TOGGLES opcode (703 / 0x02BF) + body: ONE u8 and nothing else — VERIFIED
// at the bytes, wow-re `system/ui/scratch/action-bar-toggles.md` §3 (`0x4e771d push 0x2bf`,
// the one emitter image-wide; PutUInt32 opcode + PutUInt8 byte; NetClient::Send computes the
// payload as 5 = 4 + 1). vmangos corroborates: `Misc.cpp:150-153` reads a single uint8.
assert_eq!(
messages::opcode::CMSG_SET_ACTIONBAR_TOGGLES,
0x02BF,
"CMSG_SET_ACTIONBAR_TOGGLES opcode"
);
assert_eq!(
messages::set_actionbar_toggles(0x0b),
hx("0b"),
"bars 1, 2 and 4 shown (bits 0x01|0x02|0x08)"
);
assert_eq!(
messages::set_actionbar_toggles(0),
hx("00"),
"every extra bar hidden — the default, and a real value rather than an empty body"
);
assert_eq!(
messages::set_actionbar_toggles(0x0f),
hx("0f"),
"all four — the largest value the reference binding can ever accumulate (§2)"
);
// CMSG_SETSHEATHED opcode (480 / 0x01E0 — vmangos Opcodes_1_12_1.h) + body: one u32 state.
assert_eq!(
messages::opcode::CMSG_SETSHEATHED,
+25
View File
@@ -59,6 +59,31 @@ impl Loader<'_> {
dbg,
);
}
// `<ResizeBounds><minResize><AbsDimension x= y=/></minResize><maxResize>…` → the resize
// quad (rf24 `0x769820`'s child-loop arm at `0x769baa`; wow-re
// `system/ui/scratch/resize-bounds-and-button-fontstring.md` §4). Three clauses from that
// carve rather than from the shape of the element:
//
// · **Both pairs are written unconditionally once `<ResizeBounds>` matches**, each side
// defaulting to `0` before its lookup — so a block carrying only `<minResize>` RESETS
// the max pair to unbounded. That is why this writes both calls, never just the one it
// found.
// · `x` is the WIDTH and `y` the HEIGHT, through the same logical→internal transform
// `<Size>` uses — the same `abs_dim` reader, and the same inline-or-`AbsDimension` pair
// of forms.
// · `<Size>` and `<Anchors>` are consumed by the base-class `CLayoutFrame::LoadXML`
// *before* the child loop is entered at all, so they always precede this structurally
// whatever the document order — and neither path clamps, so an authored `<Size>`
// outside the authored bounds survives load unchanged.
//
// The shipped 1.12.1 FrameXML uses it exactly once (`FloatingChatFrame.xml:223`,
// min 296×75 / max 608×400) and never calls the Lua setters at all.
if let Some(rb) = children_named(el, "ResizeBounds").next() {
for (tag, verb) in [("minResize", "SetMinResize"), ("maxResize", "SetMaxResize")] {
let (x, y) = children_named(rb, tag).next().map_or((None, None), abs_dim);
self.call(wrapper, verb, (x.unwrap_or(0.0), y.unwrap_or(0.0)), dbg);
}
}
// `movable`/`resizable` → SetMovable/SetResizable — the same flag word the methods write
// (`0x76a3c0` with mask 0x100 / 0x200; wow-re `rf24-framexml-loader.md` records the loader
// calling that very setter). Both were in the gap list below until the movable family
@@ -0,0 +1,319 @@
//! `GetActionBarToggles` / `SetActionBarToggles` — the four extra action bars' visibility, as one
//! server-owned nibble. Byte-VERIFIED against the 1.12.1 binary: wow-re
//! `system/ui/scratch/action-bar-toggles.md` (a §5 trio cross-check, `417c2d31`), whose section
//! numbers the comments below cite.
//!
//! ## The shape, and why it is not the shape it looks like
//!
//! Four booleans in, four booleans out — but **the getter and the setter do not talk to each
//! other**. `SetActionBarToggles 0x4e76e0` packs its four arguments into a stack byte, posts
//! `CMSG_SET_ACTIONBAR_TOGGLES` and returns zero values; every store in its body is into its own
//! `ebp` frame (§4.1). `GetActionBarToggles 0x4e7660` reads the live descriptor at
//! `[[player+0xe68]+0x102a]` — `PLAYER_FIELD_BYTES` byte 2 — on every call, with no cache (§5). The
//! only writer of that cell in the whole image is the generic `SMSG_UPDATE_OBJECT` value-apply
//! (`apply_update_fields 0x466590`), and **nothing is notified when it lands**: all 49 field-change
//! registrations at `0x468070` were enumerated and none sits at an offset ≥ `0x1000` (§4.2).
//!
//! So `SetActionBarToggles(1)` followed immediately by `GetActionBarToggles()` returns the **old**
//! value, for a whole round trip. That is not a bug to paper over — it is the mechanism, and it is
//! why [`super::UiScript::set_action_bar_toggles`] (the app's descriptor push) is the *only* thing
//! that moves our copy. Contrast [`super::worn_display`], whose setter updates its belief
//! optimistically because its wire verb is a blind flip that would otherwise mis-compute.
//!
//! The reference UI does not feel the lag because it never asks: it keeps `SHOW_MULTI_ACTIONBAR_1..4`
//! as Lua globals, updates them on the checkbox click, and reads the binding exactly once — in
//! `UIParent.lua`'s `PLAYER_ENTERING_WORLD` handler (§7). Our Lua layer copies that split.
//!
//! ## Four bits, and only four
//!
//! The setter's loop runs `i = 0..3` (`0x4e770e cmp esi,4`) and ORs `1 << i` for argument `i + 1`;
//! the accumulator is written by exactly two instructions image-wide — the `mov BYTE [ebp-0x4],0`
//! that zeroes it and the loop's `or` (§2). Two consequences we reproduce exactly:
//!
//! - A **fifth argument is silently dropped**. The shipped `UIOptionsFrame_Save` passes
//! `ALWAYS_SHOW_MULTIBARS` as a fifth; the binding never fetches it, so it never reaches the byte
//! or the wire (and, consistently, it is the one option of the five that FrameXML saves locally).
//! - A `Set` **destroys the high nibble**. It starts from zero, so whatever `0x10..0x80` the server
//! happened to hold is overwritten with 0 by the next post. The getter never tests those bits
//! either (§5), so the client's view of this field is genuinely 4-bit.
//!
//! The bit→bar meaning is a FrameXML convention, not the binary's (§7) — the engine stores four
//! unnamed bits and the names live in the UI layer.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::bool_or_default;
use super::Model;
/// How many toggle bits the bindings read and write — `0x4e770e cmp esi,4` in the setter,
/// `0x4e76cd mov eax,4` in the getter. Not a UI choice: four is what the binary does, in both
/// directions, and bits above it are unreachable from Lua.
const ACTION_BAR_TOGGLE_BITS: u32 = 4;
impl super::UiScript {
/// Push the server's `PLAYER_FIELD_BYTES` byte 2 — the app calls this on the descriptor edge,
/// and it is the **only** way this value moves (see the module doc). Mirrors
/// [`Self::set_combo_points`], the other PRIVATE byte out of the same dword.
pub fn set_action_bar_toggles(&mut self, toggles: u8) {
self.model_mut().action_bar_toggles = Some(toggles);
}
/// What the VM last had pushed into it, or `None` if nothing ever has — the app's own read,
/// and the tests'. `None` and `Some(0)` are indistinguishable to Lua by design: with no local
/// player the reference's chain fails soft and the getter returns four `nil`s, which is exactly
/// what a zero byte returns (§5).
pub fn action_bar_toggles(&self) -> Option<u8> {
self.model_ref().action_bar_toggles
}
/// Drain the `CMSG_SET_ACTIONBAR_TOGGLES` payloads queued since the last call — one per
/// `SetActionBarToggles` call, in order.
///
/// A list rather than a "latest wins" slot because the binding gates **nothing**: it has no
/// did-it-change test (unlike `ShowHelm`/`ShowCloak`) and no connection check of its own, so
/// two calls in a frame are two packets. Dropping one here would be an optimisation the real
/// client does not make.
pub fn take_action_bar_toggle_sends(&mut self) -> Vec<u8> {
std::mem::take(&mut self.model_mut().action_bar_toggle_sends)
}
}
/// Register the two action-bar-toggle globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// GetActionBarToggles() -> four values, each the NUMBER 1 or nil (`0x6f3810` pushes tag 3 with
// the double 1.0; `0x6f37f0` pushes tag 0). Never booleans — callers write `if toggle then`,
// and the WoW `1`-or-`nil` idiom is what the reference's own `SHOW_MULTI_ACTIONBAR_n` globals
// then hold. Return N tests bit `1 << (N-1)`, the exact inverse of the setter's map.
g.set(
"GetActionBarToggles",
lua.create_function(|lua, ()| {
let byte = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.action_bar_toggles.unwrap_or(0)
};
let bit = |i: u32| {
if byte & (1 << i) != 0 {
Value::Number(1.0)
} else {
Value::Nil
}
};
Ok((bit(0), bit(1), bit(2), bit(3)))
})?,
)?;
// SetActionBarToggles(a, b, c, d) -> nothing. Four argument slots, no more: a fifth is never
// fetched (§2), which matters because the shipped Options panel passes one.
//
// The arguments are read through `0x6f1c10`, NOT Lua truthiness ([`bool_or_default`]) — the
// panel that calls this hands option bindings the strings "0"/"1", and `"0"` is truthy in Lua.
// The default at this call site is FALSE (`0x4e76f4 push 0x0`), so an omitted argument is off.
//
// Nothing local is written: the byte we hold is the server's, and only the server's push moves
// it (module doc). A `Set` that never reaches the server therefore leaves the UI showing the
// truth rather than a lie.
g.set(
"SetActionBarToggles",
lua.create_function(|lua, args: MultiValue| {
let args: Vec<Value> = args.into_iter().collect();
let mut packed = 0u8;
for i in 0..ACTION_BAR_TOGGLE_BITS {
if bool_or_default(args.get(i as usize), false) {
packed |= 1 << i;
}
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.action_bar_toggle_sends.push(packed);
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The argument→bit map, read straight off `0x4e7703 shl al,cl` with `cl = argIndex − 1`.
#[test]
fn the_four_arguments_pack_into_bits_0_through_3() {
let mut s = UiScript::new().unwrap();
for (call, want) in [
("SetActionBarToggles(1)", 0x01),
("SetActionBarToggles(nil, 1)", 0x02),
("SetActionBarToggles(nil, nil, 1)", 0x04),
("SetActionBarToggles(nil, nil, nil, 1)", 0x08),
("SetActionBarToggles(1, 1, 1, 1)", 0x0f),
("SetActionBarToggles()", 0x00),
] {
s.run(call).unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![want],
"{call} packs {want:#04x}"
);
}
}
/// The coercion is `0x6f1c10`, not `if arg then` — and the case that proves it is the one the
/// reference's own Options panel produces. `"0"` is truthy in Lua 5.0/5.1; here it is OFF.
#[test]
fn the_arguments_are_coerced_the_binarys_way_not_luas() {
let mut s = UiScript::new().unwrap();
s.run(r#"SetActionBarToggles("1", "0", "1", "0")"#).unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x05],
r#"the strings the panel passes: "0" is FALSE, where Lua truthiness says true"#
);
// The number arm TRUNCATES toward zero (`0x40a2b0`, RC = chop) — it does not round.
s.run("SetActionBarToggles(0.5, -0.9, 1.5, 0)").unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x04],
"fractions inside (-1, 1) truncate to 0 and read false; 1.5 truncates to 1"
);
// The keyword and letter arms.
s.run(r#"SetActionBarToggles("on", "off", "enabled", "disabled")"#)
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x05]);
s.run(r#"SetActionBarToggles("true", "false", "yes", "no")"#)
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x05]);
// Booleans, the spelling our own Lua uses.
s.run("SetActionBarToggles(true, false, true, false)")
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x05]);
// The default arm (`push 0x0` at this call site) — a table, and an unrecognised string.
s.run(r#"SetActionBarToggles({}, "maybe", 1, 1)"#).unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x0c],
"both take the default, which is FALSE here"
);
}
/// The shipped `UIOptionsFrame_Save` calls this with **five** arguments
/// (`…, ALWAYS_SHOW_MULTIBARS`). The binding's loop stops at four and never fetches the fifth,
/// so it reaches neither the byte nor the wire — the reason that option is the only one of the
/// five FrameXML saves locally.
#[test]
fn a_fifth_argument_is_accepted_and_dropped() {
let mut s = UiScript::new().unwrap();
s.run("SetActionBarToggles(nil, nil, nil, nil, 1)").unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x00]);
s.run("SetActionBarToggles(1, 1, 1, 1, 1, 1, 1)").unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x0f],
"still four bits — the accumulator can only ever hold 0x00..0x0f"
);
}
/// A `Set` starts from **zero** and ORs at most four bits, so any high nibble the server held
/// is destroyed by the next post. Faithful, not tidy: it is what two accumulator writes
/// image-wide amount to.
#[test]
fn a_set_destroys_whatever_the_server_held_above_the_low_nibble() {
let mut s = UiScript::new().unwrap();
s.set_action_bar_toggles(0xf5);
s.run("SetActionBarToggles(1, 0, 0, 0)").unwrap();
assert_eq!(
s.take_action_bar_toggle_sends(),
vec![0x01],
"0xf5's high nibble is gone — the accumulator never saw it"
);
}
/// The getter reads the **app-pushed descriptor byte**, tests only bits 0..3, and returns the
/// NUMBER 1 or nil — never a boolean, never `0`.
#[test]
fn the_getter_returns_four_ones_or_nils_from_the_pushed_byte() {
let mut s = UiScript::new().unwrap();
// Nothing pushed: four nils, and no error. Indistinguishable from a zero byte, exactly as
// "no local player" is in the reference (§5).
assert!(s
.eval::<bool>(
"local a,b,c,d = GetActionBarToggles() \
return a == nil and b == nil and c == nil and d == nil"
)
.unwrap());
assert!(s
.eval::<bool>("return select('#', GetActionBarToggles()) == 4")
.unwrap());
s.set_action_bar_toggles(0x0a);
assert!(s
.eval::<bool>(
"local a,b,c,d = GetActionBarToggles() \
return a == nil and b == 1 and c == nil and d == 1"
)
.unwrap());
assert!(
s.eval::<bool>("local a,b = GetActionBarToggles() return type(b) == 'number'")
.unwrap(),
"the NUMBER 1 (0x6f3810 pushes tag 3 / double 1.0), never the boolean true"
);
// Bits 0x10..0x80 are loaded but never tested — a byte of 0xf0 reads as all four off.
s.set_action_bar_toggles(0xf0);
assert!(s
.eval::<bool>(
"local a,b,c,d = GetActionBarToggles() \
return a == nil and b == nil and c == nil and d == nil"
)
.unwrap());
}
/// Round trip: what the setter packs is what the getter reads back — but only once the value
/// has travelled through the app's push, because the setter deliberately does not touch the
/// local copy. The stale read in the middle is the mechanism, not an oversight (§4.1).
#[test]
fn the_setter_does_not_touch_the_local_copy_so_the_read_lags_a_round_trip() {
let mut s = UiScript::new().unwrap();
s.set_action_bar_toggles(0x00);
s.run("SetActionBarToggles(1, 1, 0, 0)").unwrap();
assert_eq!(
s.action_bar_toggles(),
Some(0x00),
"still the server's value — the setter wrote nothing local"
);
assert!(s
.eval::<bool>("local a = GetActionBarToggles() return a == nil")
.unwrap());
let sent = s.take_action_bar_toggle_sends();
assert_eq!(sent, vec![0x03]);
// The server echoes it back through UPDATE_OBJECT; now the getter agrees.
s.set_action_bar_toggles(sent[0]);
assert!(s
.eval::<bool>(
"local a,b,c,d = GetActionBarToggles() \
return a == 1 and b == 1 and c == nil and d == nil"
)
.unwrap());
}
/// No did-it-change gate and no connection test: two calls in a frame are two packets, in
/// order. The binding returns zero Lua values either way, so nothing upstream can tell.
#[test]
fn every_call_queues_a_packet_and_returns_nothing() {
let mut s = UiScript::new().unwrap();
s.run("SetActionBarToggles(1) SetActionBarToggles(1) SetActionBarToggles(nil)")
.unwrap();
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x01, 0x01, 0x00]);
assert!(s
.eval::<bool>("return select('#', SetActionBarToggles(1)) == 0")
.unwrap());
assert_eq!(s.take_action_bar_toggle_sends(), vec![0x01]);
}
}
+141
View File
@@ -76,6 +76,80 @@ pub(crate) fn string_arg(lua: &Lua, v: Value, usage: &'static str) -> mlua::Resu
}
}
/// `GetBoolOrDefault` (`0x6f1c10`) — the reference's **boolean argument** coercion, which is *not*
/// Lua truthiness and gets three arms backwards from the obvious reading. Byte-VERIFIED: wow-re
/// `system/ui/scratch/action-bar-toggles.md` §2.1 re-derives the whole jump table at `0x6f1ce8`
/// (`ui.md`, `tooltip-content-law.md` and `object-layer/scratch/helm-cloak-hide.md` cite the same
/// helper).
///
/// `v` is `None` for an **absent** argument (`LUA_TNONE` = −1, unsigned-compares above the table's
/// bound and takes the default arm). mlua collapses "missing" into `Value::Nil` for a plain
/// `Value` parameter, and the two are *different* here, so a binding whose default is `true` must
/// take its argument as a `MultiValue` to tell them apart.
///
/// | Lua type | result |
/// |---|---|
/// | absent (`None`) | `default` |
/// | `nil` | **false** |
/// | boolean | itself |
/// | lightuserdata | `default` |
/// | number | truncate toward zero (`0x40a2b0`), then `!= 0` |
/// | string | the table below |
/// | table / function / userdata / thread | `default` |
///
/// The **string** arm is the one that bites. It does not parse the number; it dispatches on the
/// **first byte** through the 0x4a-entry remap table at `0x6f1d08` (indexed `byte − 0x30`,
/// *signed*, so anything outside `'0'..='y'` — an empty string's terminator included — falls to the
/// keyword arm):
///
/// - first byte `'0' 'F' 'N' 'f' 'n'` → **false**
/// - first byte `'1'..='9' 'T' 'Y' 't' 'y'` → **true**
/// - anything else → whole-string, case-insensitive (`SStrCmpI 0x64a4c0`): `"off"`/`"disabled"` →
/// false, `"on"`/`"enabled"` → true, and **anything unrecognised takes the `default`**
///
/// So `"0"` is FALSE where Lua truthiness says true (in Lua every string is truthy), which is
/// load-bearing rather than pedantic: 1.12's own Interface panel hands its option bindings the
/// *strings* `"0"`/`"1"`, and a re-implementation wired to `lua_toboolean` inverts every
/// string-valued option in the game. Equally, `"0.5"` and `"-1"` are decided by their first byte
/// alone — false and `default` — never by their numeric value.
pub(crate) fn bool_or_default(v: Option<&Value>, default: bool) -> bool {
let Some(v) = v else {
return default; // LUA_TNONE
};
match v {
Value::Nil => false,
Value::Boolean(b) => *b,
Value::LightUserData(_) => default,
// `lua_tonumber` then `0x40a2b0`, whose RC = chop makes it a C cast; the caller tests the
// low dword only, which is why the double round-trips through `i64 as i32` (see
// [`number_arg`]).
Value::Integer(i) => (*i as i32) != 0,
Value::Number(n) => (*n as i64 as i32) != 0,
Value::String(s) => {
let bytes = s.as_bytes();
match bytes.first() {
Some(b'0' | b'F' | b'N' | b'f' | b'n') => false,
Some(b'1'..=b'9' | b'T' | b'Y' | b't' | b'y') => true,
// The keyword arm — and the fall-through for every byte the remap table cannot
// index, which is why an empty string lands here too.
_ => {
if bytes.eq_ignore_ascii_case(b"off") || bytes.eq_ignore_ascii_case(b"disabled")
{
false
} else if bytes.eq_ignore_ascii_case(b"on")
|| bytes.eq_ignore_ascii_case(b"enabled")
{
true
} else {
default
}
}
}
}
_ => default,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -84,6 +158,73 @@ mod tests {
Lua::new()
}
/// Every arm of `0x6f1c10`, including the three a plausible implementation gets backwards.
#[test]
fn bool_or_default_is_not_lua_truthiness() {
let lua = lua();
let st = |t: &str| Value::String(lua.create_string(t).unwrap());
// Absent takes the DEFAULT; an explicit nil is FALSE. The two are different arms.
assert!(bool_or_default(None, true));
assert!(!bool_or_default(None, false));
assert!(!bool_or_default(Some(&Value::Nil), true));
// Booleans pass through.
assert!(bool_or_default(Some(&Value::Boolean(true)), false));
assert!(!bool_or_default(Some(&Value::Boolean(false)), true));
// Numbers TRUNCATE toward zero — they do not round. `0.5` is off, not on.
assert!(!bool_or_default(Some(&Value::Number(0.5)), true));
assert!(!bool_or_default(Some(&Value::Number(-0.5)), true));
assert!(!bool_or_default(Some(&Value::Number(-0.999)), true));
assert!(bool_or_default(Some(&Value::Number(1.5)), false));
assert!(bool_or_default(Some(&Value::Number(-1.5)), false));
assert!(!bool_or_default(Some(&Value::Integer(0)), true));
assert!(bool_or_default(Some(&Value::Integer(7)), false));
// THE headline: `"0"` is FALSE, where Lua truthiness would say true.
assert!(!bool_or_default(Some(&st("0")), true));
assert!(bool_or_default(Some(&st("1")), false));
// First byte only — `"0.5"` is false because it starts `'0'`, not because it truncates.
assert!(!bool_or_default(Some(&st("0.5")), true));
assert!(bool_or_default(Some(&st("9lives")), false));
// The letter arms.
for t in ["false", "F", "no", "NIL", "nope"] {
assert!(
!bool_or_default(Some(&st(t)), true),
"{t} starts F/N → false"
);
}
for t in ["true", "T", "yes", "Yup"] {
assert!(
bool_or_default(Some(&st(t)), false),
"{t} starts T/Y → true"
);
}
// The keyword arm, case-insensitive whole-string.
assert!(!bool_or_default(Some(&st("off")), true));
assert!(!bool_or_default(Some(&st("OFF")), true));
assert!(!bool_or_default(Some(&st("Disabled")), true));
assert!(bool_or_default(Some(&st("on")), false));
assert!(bool_or_default(Some(&st("ENABLED")), false));
// Unrecognised strings — and every byte the remap table cannot index — take the DEFAULT.
for t in ["", "-1", "?", "maybe", "@"] {
assert!(bool_or_default(Some(&st(t)), true), "{t:?} → default true");
assert!(
!bool_or_default(Some(&st(t)), false),
"{t:?} → default false"
);
}
// Table/function take the default arm, like an absent argument.
let tbl = Value::Table(lua.create_table().unwrap());
assert!(bool_or_default(Some(&tbl), true));
assert!(!bool_or_default(Some(&tbl), false));
}
#[test]
fn number_arg_truncates_toward_zero_not_floor() {
let lua = lua();
+114 -18
View File
@@ -296,33 +296,129 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, this: Table| get_slot_texture(lua, &this, Slot::Text))?,
)?;
// SetFontString(fontString) — ADOPT a caller-made FontString as this Button's label
// (`0x780a60`; the pointer it writes is the Button's `+0x338`, the field
// `GetFontString`/`SetText`/`GetTextWidth` all read — wow-re `widget-api-batch-benilla.md` Q8).
// SetFontString(fontString) — ADOPT a caller-made FontString as this Button's label.
//
// The idiom it exists for: build the label yourself so you can style and place it, then hand it
// to the button so `SetText` and the per-state font machinery drive it.
// Byte-carved end to end by wow-re (decision 1505,
// `system/ui/scratch/resize-bounds-and-button-fontstring.md`
// §5): the binding `0x780a60` is gates + a delegate to `CSimpleButton::SetFontString
// 0x778d20`, which is the SAME function `SetText`'s lazy creation path funnels through — so
// adopting and creating share their whole tail, and only the allocation differs.
//
// local fs = btn:CreateFontString(nil, "OVERLAY", "GameFontNormal")
// btn:SetFontString(fs)
// The idiom it exists for: build the label yourself so you can style and place it, then hand
// it to the button so `SetText` and the per-state font machinery drive it. Quiver's
// `Component/Select.wow.lua` builds every dropdown option row that way, and with the method
// nil the row died on its first line — the second of B267's three walls.
//
// Quiver's `Component/Select.wow.lua` builds every dropdown option row that way, and with the
// method nil the row died on its first line — the second of B267's three walls.
// **It raises, in three distinct ways, and never silently no-ops on a bad argument** — the
// first shape I built here was a lenient no-op and the bytes say otherwise:
//
// Adoption only: no region is created, and the argument keeps its own parent and its own
// anchors (callers anchor it after the call as often as before). A non-region argument leaves
// the button unchanged rather than raising — the reference marshals it and writes a pointer,
// and what it does with a bad one is not byte-read.
// · a **missing** argument, `nil`, a number, a string or a boolean → `lua_type(L,2) != TABLE`
// → `Usage: %s:SetFontString(fontstring)` (`0x87a100`). Missing and nil take the identical
// leg (`index2adr` answers NULL → type −1), so `SetFontString(nil)` **cannot clear the
// label from Lua**; the C++ clear exists but no binding reaches it.
// · a **table that is not a widget** → `%s:SetFontString(): Couldn't find 'this' in
// fontstring` (`0x87a160`).
// · a **Frame or a Texture** — anything that is not a FontString → `%s:SetFontString():
// Wrong object type, expected fontstring` (`0x87a124`), gated by an `IsA` against the
// FontString token, which a Texture fails.
//
// `%s` is the BUTTON's name (or `<unnamed>`), never the argument's. Zero return values.
//
// What `0x778d20` then does, all four clauses VERIFIED and all four here:
//
// · **`new == old` is a total no-op** — the first compare, before anything is touched.
// · **the previous label is DESTROYED**, not orphaned (`old->vtable[0](1)`, the scalar
// deleting destructor, returning the storage to the FontString pool). So a button whose
// `SetText` already lazily made one does not leak a stray string behind the new label.
// · **the string is RE-PARENTED to the button and its draw layer forced to ARTWORK**
// (`0x77fd10(parent, 2, 1)` — layer id 2 in the client's own `.rdata 0x811a80` name table).
// Always, whatever it was parented to or drawn in before.
// · **it is anchored only if it has NO anchors of its own** (a scan of all nine
// `anchorPoints` slots): LEFT/RIGHT/CENTER by the justify bits, to the matching point on
// the button. That is exactly [`super::region::implicit_creation_anchor`]'s FontString arm,
// which already transcribes the same `& 7` → LEFT(1)/RIGHT(4)/else-CENTER chain, so it is
// reused rather than re-written. One stated difference: the reference reads the *button's*
// Normal `CSimpleFont` justify word and ours reads the string's own — they agree in the
// ordinary case, because our extract resolves the button's per-state font onto the label
// every frame anyway.
//
// The fifth clause — apply the button's per-state font immediately — needs no code: our
// extract re-points the label at the current state's font object every frame
// ([`ButtonState::normal_font`]), so binding the label IS applying it.
m.set(
"SetFontString",
lua.create_function(|lua, (this, fs): (Table, Value)| {
let Value::Table(fs) = fs else { return Ok(()) };
let Ok(rh) = super::region::region_handle_of(lua, &fs) else {
return Ok(());
lua.create_function(|lua, args: MultiValue| {
let mut it = args.into_iter();
let this = match it.next() {
Some(Value::Table(t)) => t,
_ => return Err(mlua::Error::runtime("expected a button")),
};
let who = {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.arena
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string())
};
let Some(Value::Table(fs)) = it.next() else {
return Err(mlua::Error::runtime(format!(
"Usage: {who}:SetFontString(fontstring)"
)));
};
// The two remaining legs are DIFFERENT questions and the reference asks them in this
// order: first "is this a framescript object at all" (`0x780b27`), then "is it a
// FontString" (`0x780b90`'s `IsA` against the FontString token). A Frame or a Texture
// passes the first and fails the second — so resolving straight to a region handle
// would collapse both onto the first message and mislabel every widget argument.
let rh = {
let not_an_object = || {
mlua::Error::runtime(format!(
"{who}:SetFontString(): Couldn't find 'this' in fontstring"
))
};
let id = super::object::decode_id(&fs).map_err(|_| not_an_object())?;
let model = lua.app_data_ref::<Model>().expect("model app_data");
let known_widget =
model.id_to_frame.contains_key(&id) || model.id_to_region.contains_key(&id);
if !known_widget {
return Err(not_an_object());
}
match model.id_to_region.get(&id).copied() {
Some(rh)
if model.arena.region(rh).map(|r| r.kind)
== Some(RegionKind::FontString) =>
{
rh
}
_ => {
return Err(mlua::Error::runtime(format!(
"{who}:SetFontString(): Wrong object type, expected fontstring"
)))
}
}
};
let old = with_button(lua, &this, |bs| bs.text)?;
if old == Some(rh) {
return Ok(());
}
let owner = frame_handle_of(lua, &this)?;
with_button(lua, &this, |bs| bs.text = Some(rh))?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The label is a measured region, and its new role feeds the button's text extents.
if let Some(dead) = old {
// DESTROYED, not orphaned — the shared region-lifetime law, whose doc carries the
// reason (`0x778d3c`'s scalar deleting destructor).
super::region::free_region(&mut model, dead);
}
model.arena.set_region_owner(rh, Some(owner));
if let Some(r) = model.arena.region_mut(rh) {
r.draw_layer = DrawLayer::Artwork;
}
super::region::implicit_creation_anchor(&mut model, rh);
// A new label, a new owner and a possible new anchor — the layout's read set moved,
// and the string's extents are what the button's own `GetTextWidth` reports.
model.touch_layout();
model.touch_measure(rh);
Ok(())
})?,
+17 -4
View File
@@ -37,6 +37,7 @@
//! (an opaque identity in a lightuserdata) — only the *encoding* differs.
mod action;
mod action_bar_toggles;
mod addon_message;
mod aura;
mod backdrop;
@@ -535,6 +536,7 @@ impl UiScript {
sound::install(&lua)?;
pointer::install(&lua)?;
action::install(&lua)?;
action_bar_toggles::install(&lua)?;
container::install(&lua)?;
cursor::install(&lua)?;
spellbook::install(&lua)?;
@@ -674,13 +676,24 @@ impl UiScript {
/// it; changing it invalidates the next `resolve`. The app calls this every frame with an
/// almost-always-identical size — compared before writing so the per-frame idiom doesn't
/// dirty the layout gate's tier 1 (`Model::touch_layout`).
pub fn set_screen_size(&mut self, width: f32, height: f32) {
///
/// **Returns whether the size actually changed.** Anchors follow the new rect on their own, so
/// most of the UI needs nothing; what does not follow is anything that *computed* a seat from
/// the old height and stored it — the open-bag stack wraps into a fresh column from
/// `GetScreenHeight()`, and that answer is stale the moment the window is resized. The 1.12
/// client never had to care (its resolution changed through a restart, and its own
/// `DISPLAY_SIZE_CHANGED` listeners are three model panes, none of them the manage pass), but
/// benilla runs in a freely resizable window, so the caller re-runs
/// `UIParent_ManageFramePositions()` on a true return. Decision 1499.
pub fn set_screen_size(&mut self, width: f32, height: f32) -> bool {
let new = Rect::new(0.0, 0.0, height, width);
let mut model = self.model_mut();
if model.screen != new {
model.screen = new;
model.touch_layout();
if model.screen == new {
return false;
}
model.screen = new;
model.touch_layout();
true
}
/// Replace the Era atlas table (decision 0950) — pushed once at boot, before the XML loads.
+15
View File
@@ -467,6 +467,19 @@ pub(crate) struct Model {
/// the two slots are different packets.
pub(crate) worn_display_toggles: Vec<super::worn_display::WornDisplay>,
/// The server's `PLAYER_FIELD_BYTES` **byte 2** — which of the four extra action bars are on
/// ([`action_bar_toggles`], wow-re `system/ui/scratch/action-bar-toggles.md`). `None` until the
/// app pushes it ([`super::UiScript::set_action_bar_toggles`]), which is the ONLY thing that
/// moves it: the real client never writes this cell, so `SetActionBarToggles` deliberately
/// leaves it alone and the value lags the setter by a round trip. That is the opposite
/// arrangement to [`Self::helm_shown`] next door, and the difference is the wire verb's — a
/// blind flip has to be predicted, an absolute post does not.
pub(crate) action_bar_toggles: Option<u8>,
/// `CMSG_SET_ACTIONBAR_TOGGLES` payloads queued since the app's last
/// [`super::UiScript::take_action_bar_toggle_sends`] drain — one per `SetActionBarToggles`
/// call. A list, because the binding gates nothing: two calls in a frame are two packets.
pub(crate) action_bar_toggle_sends: Vec<u8>,
/// Sounds queued by the Lua `PlaySound`/`PlaySoundFile` bindings since the app's last
/// [`UiScript::take_sounds`] drain — the outbound Lua→app intent seam ([`sound`]).
pub(crate) sound_queue: Vec<SoundRequest>,
@@ -1301,6 +1314,8 @@ impl Model {
helm_shown: true,
cloak_shown: true,
worn_display_toggles: Vec::new(),
action_bar_toggles: None,
action_bar_toggle_sends: Vec::new(),
sound_queue: Vec::new(),
cvars: HashMap::new(),
cvars_saved_base: HashMap::new(),
+124 -62
View File
@@ -158,20 +158,24 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
// ── The resize-bounds quad — `Set/GetMinResize`, `Set/GetMaxResize` ────────────────────────
//
// Frame method table `0x878ec0`: `GetMinResize 0x775f20` · `SetMinResize 0x776020` ·
// `GetMaxResize 0x7761a0` · `SetMaxResize 0x7762a0`. The setters' `Usage:` strings are in the
// image verbatim — `0x8797b8 "Usage: %s:SetMinResize(minWidth, minHeight)"` and
// `0x8797e4 "Usage: %s:SetMaxResize(maxWidth, maxHeight)"` — so a bad argument RAISES, and
// that is what is reproduced here.
// `GetMaxResize 0x7761a0` · `SetMaxResize 0x7762a0`. Byte-carved end to end by wow-re
// (`system/ui/scratch/resize-bounds-and-button-fontstring.md` §2), and every clause below is
// from that read rather than from the shape of the name:
//
// **Who asks.** They are the second and third lines of every resizable-window kit, right after
// `SetResizable(true)`; Quiver's `SideEffectMakeMoveable` calls `SetMinResize` on every module
// frame it builds, and with the method nil its `OnEnable` died before the addon's
// `VARIABLES_LOADED` handler could publish anything (bug B267's third wall).
//
// **Two halves are out with wow-re and fold into ONE place** ([`clamp_resize`]): whether the
// client's "unbounded" is a `0` sentinel the getters hand back (ours answers `nil`), and
// whether the clamp binds only the drag pump (what we do) or every size write. Neither changes
// the surface below.
// · **The argument gate is `lua_isnumber`, not a strict number** (`0x6f34d0` on BOTH slots 2
// and 3), so a numeric STRING passes and is coerced — while a missing argument, an explicit
// `nil`, a boolean, a table or a non-numeric string fails. Either slot failing raises
// `Usage: %s:SetMinResize(minWidth, minHeight)` (`0x8797b8`) / the `MaxResize` twin
// (`0x8797e4`), with `%s` the frame's name or `<unnamed>`. Arguments past slot 3 are
// ignored; there is no upper arity check.
// · **Stored RAW, and nothing else happens.** After the store-backs each setter is
// `pop/pop/xor eax,eax/pop/…/ret` — no layout invalidate, no dirty mark, no `min > max`
// reconciliation, and the frame's current size is never read. Setting a bound a frame
// already violates does nothing at all until the next drag tick.
// · **Zero return values from the setters; TWO from the getters, always** — `(width, height)`
// as plain numbers, `0, 0` on a frame nobody bounded. Never `nil`: `0.0` is the client's
// unbounded sentinel, so the "unset" answer and the "explicitly unbounded" answer are the
// same answer, by construction.
for (name, upper) in [("MinResize", false), ("MaxResize", true)] {
m.set(
format!("Set{name}"),
@@ -182,14 +186,12 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
_ => return Err(mlua::Error::runtime("expected a frame")),
};
let h = frame_handle_of(lua, &this)?;
// The pair is required. `lua_isnumber` accepts a numeric STRING too, which is why
// this reads the values through the same coercion the rest of the surface uses
// rather than demanding `Value::Number`.
let pair = (it.next(), it.next());
// `lua_isnumber`'s own set: a number, or a string 5.0's `luaV_tonumber` converts.
let num = |v: &Option<Value>| match v {
Some(Value::Number(n)) => Some(*n as f32),
Some(Value::Integer(i)) => Some(*i as f32),
Some(Value::String(s)) => s.to_str().ok()?.parse::<f32>().ok(),
Some(Value::String(s)) => s.to_str().ok()?.trim().parse::<f32>().ok(),
_ => None,
};
let (Some(w), Some(hgt)) = (num(&pair.0), num(&pair.1)) else {
@@ -199,15 +201,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
.frame(h)
.and_then(|f| f.name.clone())
.unwrap_or_else(|| "<unnamed>".to_string());
let verb = if upper {
"SetMaxResize"
let (verb, a, b) = if upper {
("SetMaxResize", "maxWidth", "maxHeight")
} else {
"SetMinResize"
};
let (a, b) = if upper {
("maxWidth", "maxHeight")
} else {
("minWidth", "minHeight")
("SetMinResize", "minWidth", "minHeight")
};
return Err(mlua::Error::runtime(format!(
"Usage: {who}:{verb}({a}, {b})"
@@ -216,9 +213,9 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let mut model = lua.app_data_mut::<Model>().expect("model");
if let Some(f) = model.arena.frame_mut(h) {
if upper {
f.max_resize = Some((w, hgt));
f.max_resize = (w, hgt);
} else {
f.min_resize = Some((w, hgt));
f.min_resize = (w, hgt);
}
}
Ok(())
@@ -229,15 +226,14 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
lua.create_function(move |lua, this: Table| {
let h = frame_handle_of(lua, &this)?;
let model = lua.app_data_ref::<Model>().expect("model");
let bound =
model
.arena
.frame(h)
.and_then(|f| if upper { f.max_resize } else { f.min_resize });
Ok(match bound {
Some((w, hgt)) => (Value::Number(f64::from(w)), Value::Number(f64::from(hgt))),
None => (Value::Nil, Value::Nil),
})
let (w, hgt) = model.arena.frame(h).map_or((0.0, 0.0), |f| {
if upper {
f.max_resize
} else {
f.min_resize
}
});
Ok((f64::from(w), f64::from(hgt)))
})?,
)?;
}
@@ -316,16 +312,35 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
let p = point.unwrap_or_default().to_ascii_uppercase();
let (left, right) = (p.contains("LEFT"), p.contains("RIGHT"));
let (top, bottom) = (p.contains("TOP"), p.contains("BOTTOM"));
// A grip naming no edge would resize nothing; the reference has no such call and we
// refuse rather than invent one.
if !(left || right || top || bottom) {
return Ok(());
}
// The raise and the userPlaced bit are the ENTRY's, not the grip's: `0x7652b0` runs
// for every `StartSizing` whatever it was handed, and only the pump's per-grip switch
// is selective. So both happen before the grip is looked at.
super::toplevel::raise(&mut model, h);
if let Some(f) = model.arena.frame_mut(h) {
f.user_placed = true;
}
let sample = model.cursor_pos;
// **`StartSizing("CENTER")` is a MOVE, not a resize** — and it is a real case, not a
// caller error. The pump's switch is on the anchor point-id, and case 4 (CENTER) is the
// one arm that never touches width or height: it shifts the frame's anchor offsets and
// returns before the clamp is ever reached, so a CENTER grip is unbounded by
// construction (wow-re `resize-bounds-and-button-fontstring.md` §3.6). This file used
// to say *"a grip naming no edge would resize nothing; the reference has no such call
// and we refuse rather than invent one"* — the bytes have since answered that, and the
// answer is the translate below, which is exactly what [`advance_move`] already does.
if p == "CENTER" {
model.moving = Some(FrameMove {
frame: h,
sample,
// `StartSizing` enters at `0x7652b0(frame, 3, …)` — drag mode 3, the one the
// mouse-up handler does NOT auto-cancel — whatever grip it was handed. So a
// CENTER grip outlives the button release, like `StartMoving`'s.
auto_stop: false,
});
return Ok(());
}
// Any other name resolves to no point-id at all, so the pump falls to its default tail
// and the drag is inert — started (raised, userPlaced) but moving nothing.
model.sizing = Some(FrameSizing {
frame: h,
left,
@@ -468,29 +483,42 @@ pub(crate) fn start_title_move(model: &mut Model, h: FrameHandle) -> bool {
true
}
/// Apply a frame's `SetMinResize`/`SetMaxResize` bounds to a proposed size.
/// Apply a frame's `SetMinResize`/`SetMaxResize` bounds to a proposed size, per axis.
///
/// **The one place the two open halves of the resize-bounds RE fold into.** Today: an unset bound
/// is unbounded, the floor with no `SetMinResize` is the `1.0` this function inherited from the
/// drag pump it was extracted from, and the *only* caller is [`advance_size`] — so a programmatic
/// `SetWidth` past a bound is currently not clamped. wow-re is deriving whether the client clamps
/// there too, and what its no-bound floor really is; both answers land here and nowhere else.
/// The reference's predicate, transcribed (decision 1505; wow-re
/// `system/ui/scratch/resize-bounds-and-button-fontstring.md` §3.5 — decoded from `geo_768710`'s
/// **emitted** `jcc`, not from the FPU status mask, and deliberately not from the dead `geo_768550`
/// copy whose operand order differs):
///
/// A `min` above a `max` resolves in `min`'s favour (the clamp order below), which is the only
/// self-consistent reading of a contradiction and is also out with wow-re.
/// ```text
/// if (min != 0.0) and (v < min): v = min
/// if (max != 0.0) and (v > max): v = max
/// ```
///
/// Three things a plausible implementation gets wrong, all VERIFIED:
///
/// · **There is NO floor when no bound is set.** benilla had a hardcoded `1.0` here (inherited
/// from the drag pump before the bounds existed) and the client has nothing of the kind — a
/// drag can carry an unbounded frame to zero and through it into negative extents.
/// · **Only an exactly-`0.0` bound is the sentinel**, so a *negative* bound clamps normally.
/// · **min first, then max — so when `min > max`, MAX wins.** Nothing in the client warns,
/// rejects or reconciles the pair.
///
/// The returned residual per axis is what the caller rebates out of the drag delta ([`advance_size`]).
fn clamp_resize(model: &Model, h: FrameHandle, w: f32, hgt: f32) -> (f32, f32) {
let (min, max) = model
.arena
.frame(h)
.map_or((None, None), |f| (f.min_resize, f.max_resize));
let mut w = w;
let mut hgt = hgt;
if let Some((mw, mh)) = max {
w = w.min(mw);
hgt = hgt.min(mh);
}
let (fw, fh) = min.unwrap_or((1.0, 1.0));
(w.max(fw), hgt.max(fh))
.map_or(((0.0, 0.0), (0.0, 0.0)), |f| (f.min_resize, f.max_resize));
let axis = |v: f32, lo: f32, hi: f32| {
let v = if lo != 0.0 && v < lo { lo } else { v };
if hi != 0.0 && v > hi {
hi
} else {
v
}
};
(axis(w, min.0, max.0), axis(hgt, min.1, max.1))
}
/// Pump an in-flight `StartSizing`: move the gripped edges to the cursor, leave the others.
@@ -536,8 +564,35 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
return;
};
// The frame's own `SetMinResize`/`SetMaxResize` bounds — read before the mutable borrow, and
// the only place they bind today (see `clamp_resize`).
// the only path in the client that consults them at all (`clamp_resize`, and wow-re's §3.1
// reachability census behind it).
let (new_w, new_h) = clamp_resize(model, sz.frame, w0 + dw, h0 + dh);
// **The rebate.** A saturated clamp is not a bare `max`/`min` in the reference: the residual
// it swallowed is subtracted back out of the drag delta before the delta reaches the anchors
// (`0x768770`/`0x7687a2` — `dx := dx ∓ residual·scale`). Without it the size pins at the bound
// while the anchor keeps sliding, so a window held past its minimum stops shrinking and starts
// *walking* across the screen. With it the moving edge lands exactly on the bound and stays.
//
// Both are already in LOCAL units here (`dx`/`dy` were divided by the effective scale above,
// and the anchor offsets they feed are pre-scale), so the rebate is a plain subtraction — the
// reference's `·layoutScale` is its own conversion back out of cursor units, not part of the
// rule. The sign follows the grip: a growing edge (`dw = +dx`) adds the residual, a shrinking
// one (`dw = -dx`) subtracts it, so the delta always agrees with the size the clamp allowed.
// An axis nobody gripped is left alone rather than rebated against a bound the frame was
// already outside of — that frame stays put until a drag touches its axis, which is §3.8.
let rebate = |d: f32, want: f32, got: f32, grew: bool, gripped: bool| {
if !gripped {
return d;
}
let residual = got - want;
if grew {
d + residual
} else {
d - residual
}
};
let dx = rebate(dx, w0 + dw, new_w, sz.right, sz.left || sz.right);
let dy = rebate(dy, h0 + dh, new_h, sz.top, sz.top || sz.bottom);
let Some(input) = model.layout_inputs.get_mut(&sz.frame) else {
return;
};
@@ -546,6 +601,13 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
let mut moved = input.width.to_bits() != w0.to_bits() || input.height.to_bits() != h0.to_bits();
// The planted edge: a frame anchored by its LEFT that is gripped on the LEFT has to move too,
// or the resize would push the right edge instead. Only the gripped axis shifts.
//
// **Which edges travel is OURS, and the byte carve does not settle it** — stated rather than
// quietly aligned. The reference collapses the frame to a single planted TOPLEFT anchor at
// `StartSizing` (`0x768430`), so *its* travelling edges are necessarily LEFT and TOP; we keep
// the authored anchor set intact and shift every anchor's offsets, which is a different model
// with a different answer. The rebate above is the part the carve does settle, and it applies
// either way.
if !input.anchors.is_empty() && (sz.left || sz.bottom) {
for a in &mut input.anchors {
if sz.left {
@@ -559,9 +621,9 @@ pub(crate) fn advance_size(model: &mut Model, pos: (f32, f32)) {
}
// The zero-delta return above is on the RAW cursor delta, which is not the same question: a
// single-axis grip dragged purely across its axis gives `dw == dh == 0`, and so does a grip
// held past `.max(1.0)` saturation. Both used to bump the epoch and then hash all ~10k
// anchored regions to conclude nothing had moved — the castbar's bug class in miniature, for
// every frame of such a drag (decision 1385).
// held against a bound. Both used to bump the epoch and then hash all ~10k anchored regions to
// conclude nothing had moved — the castbar's bug class in miniature, for every frame of such a
// drag (decision 1385).
if moved {
// Offsets only — a resize grip never repoints an anchor, so the frame names itself and the
// cached graph survives the drag (decision 1388). A drag is per-frame by nature: this is
+29
View File
@@ -528,6 +528,35 @@ pub(super) fn region_type_name(model: &Model, rh: RegionHandle) -> &'static str
}
}
/// Free one region and **every trace of its identity** — the arena slot, its paint, its resolved
/// rect, its name registration, and the stable id both directions.
///
/// Anything that had fetched its wrapper now resolves to a stale-handle error, which is the honest
/// answer for an object the widget destroyed. That matters because the client really does destroy:
/// `CSimpleButton::SetFontString 0x778d20` runs the *scalar deleting* destructor on the label it
/// replaces (`old->vtable[0](1)`, returning the storage to the FontString pool), so a caller
/// holding the old string is holding a freed object — orphaning it instead would leave a live,
/// parentless label that still answers every getter, which is a different and quieter bug.
///
/// One law, two callers ([`super::simplehtml`]'s block free and the button's label swap); it lives
/// here because the region side owns region lifetime, and because two hand-written copies of a
/// five-map teardown is how one of them ends up forgetting a map.
pub(crate) fn free_region(model: &mut Model, rh: RegionHandle) {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
// A NAMED region also holds a slot in the name registry — anonymous ones (every SimpleHTML
// block, most labels) do not, which is why the first caller of this law never needed it.
model.region_names.retain(|_, v| *v != id);
}
model.arena.destroy_region(rh);
// A death is the archetypal STRUCTURAL change — it takes a node out of the layout roster and
// its reverse edges with it, which is exactly what a per-node ledger cannot describe
// (decision 1388).
model.touch_layout();
}
pub(super) fn region_owner_id(model: &mut Model, rh: RegionHandle) -> u32 {
match model.arena.region(rh).map(|r| r.owner) {
Some(owner) => model.frame_id(owner),
+5 -17
View File
@@ -404,24 +404,12 @@ fn anchor_for(prev_block: Option<u32>, frame_id: u32, point: Point, next_y: f32)
}
}
/// Free one block region and every trace of its identity — the arena slot, the paint, the resolved
/// rect, and the stable id both directions. A block is anonymous, so nothing in `region_names`
/// points at it; anything that had fetched its wrapper now resolves to a stale-handle error, which
/// is the honest answer for an object the widget destroyed.
/// Free one block region — [`crate::script::region::free_region`], which is where this function's
/// body went when the button's label swap needed the same five-map teardown. Kept as a name
/// because `set_text`'s call sites read better with it, and because the doc that explains *why* a
/// destroyed region must answer stale rather than linger lives on the shared law now.
fn free_block(model: &mut Model, rh: RegionHandle) {
model.region_data.remove(&rh);
model.region_resolved.remove(&rh);
if let Some(id) = model.region_to_id.remove(&rh) {
model.id_to_region.remove(&id);
}
model.arena.destroy_region(rh);
// A death is the archetypal STRUCTURAL change — it takes a node out of the layout roster and
// its reverse edges with it, which is exactly what a per-node ledger cannot describe
// (decision 1388). `set_text`, this function's only caller, already ends in the same
// conservative touch, so this is redundant today; it is here because the redundancy is the
// point. A destruction site that does not invalidate the graph it destroys part of is a
// stale rect waiting for a second caller.
model.touch_layout();
crate::script::region::free_region(model, rh);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -547,3 +547,131 @@ fn a_button_reports_its_labels_extent() {
"GetTextWidth is Button's, not Region's (wow-re Q8's own split)"
);
}
/// **`Button:SetFontString` adopts aggressively, and it RAISES** — wow-re
/// `system/ui/scratch/resize-bounds-and-button-fontstring.md` §5, byte-carved.
///
/// The first cut of this binding was a lenient no-op on a bad argument and a bare pointer swap on
/// a good one. Every line below is a clause of that carve that the plausible reading got wrong.
#[test]
fn set_font_string_adopts_the_label_and_raises_on_anything_else() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
b = CreateFrame("Button", "Btn")
b:SetWidth(120) b:SetHeight(24)
b:SetPoint("CENTER", nil, "CENTER", 0, 0)
fs = b:CreateFontString("MyLabel", "BACKGROUND")
"#,
)
.unwrap();
// Adoption binds the string as the label the whole rest of the surface reads.
s.run("b:SetFontString(fs)").unwrap();
assert!(s.eval::<bool>("return b:GetFontString() == fs").unwrap());
s.run("b:SetText(\"Okay\")").unwrap();
assert_eq!(s.eval::<String>("return fs:GetText()").unwrap(), "Okay");
assert_eq!(s.eval::<String>("return b:GetText()").unwrap(), "Okay");
// Re-parented — always, whatever it was parented to before.
assert!(s.eval::<bool>("return fs:GetParent() == b").unwrap());
// The draw layer is forced to ARTWORK on the same call (`0x77fd10(parent, 2, 1)`), and that
// half is NOT asserted here for a stated reason: `GetDrawLayer` is one of the client's nine
// per-leaf verbs we deliberately do not install (`script::REGION_LEAF_SHARED`'s own note —
// "absent is absent"), so there is no Lua way to read it back yet. The binding does it; when
// that verb lands, this is where it gets pinned.
// Adopting the SAME string again is a total no-op — the first compare in the client, and the
// one path that must not run the destroy below.
s.run("b:SetFontString(fs)").unwrap();
assert!(s.eval::<bool>("return b:GetFontString() == fs").unwrap());
assert_eq!(s.eval::<String>("return fs:GetText()").unwrap(), "Okay");
// A SECOND adoption DESTROYS the first label rather than orphaning it.
s.run("fs2 = b:CreateFontString(\"MyLabel2\", \"OVERLAY\") b:SetFontString(fs2)")
.unwrap();
assert!(s.eval::<bool>("return b:GetFontString() == fs2").unwrap());
let err = s.run("fs:GetText()").unwrap_err().to_string();
assert!(
err.contains("stale") || err.contains("invalid"),
"the replaced label must be destroyed, not left live: {err}"
);
// Three distinct raises, all naming the BUTTON — never a silent no-op, and `nil` cannot clear
// the label from Lua at all.
for (call, want) in [
("b:SetFontString()", "Usage: Btn:SetFontString(fontstring)"),
(
"b:SetFontString(nil)",
"Usage: Btn:SetFontString(fontstring)",
),
("b:SetFontString(7)", "Usage: Btn:SetFontString(fontstring)"),
(
"b:SetFontString(\"x\")",
"Usage: Btn:SetFontString(fontstring)",
),
(
"b:SetFontString({})",
"Btn:SetFontString(): Couldn't find 'this' in fontstring",
),
(
"b:SetFontString(b)",
"Btn:SetFontString(): Wrong object type, expected fontstring",
),
] {
let err = s.run(call).unwrap_err().to_string();
assert!(err.contains(want), "`{call}` → {err}");
}
// A TEXTURE is the near-miss the type gate exists for: it is a Region, and it still fails.
s.run("tex = b:CreateTexture(nil, \"ARTWORK\")").unwrap();
let err = s.run("b:SetFontString(tex)").unwrap_err().to_string();
assert!(
err.contains("Wrong object type, expected fontstring"),
"{err}"
);
// …and none of that changed the label.
assert!(s.eval::<bool>("return b:GetFontString() == fs2").unwrap());
}
/// Adoption anchors the string **only when it has none of its own** — and leaves an already
/// anchored one exactly where the caller put it.
#[test]
fn set_font_string_anchors_only_an_unanchored_label() {
let mut s = crate::script::UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
b = CreateFrame("Button", "Btn")
b:SetWidth(120) b:SetHeight(24)
b:SetPoint("CENTER", nil, "CENTER", 0, 0)
bare = b:CreateFontString(nil, "OVERLAY")
bare:ClearAllPoints()
placed = b:CreateFontString(nil, "OVERLAY")
placed:ClearAllPoints()
placed:SetPoint("TOPLEFT", b, "TOPLEFT", 7, -3)
"#,
)
.unwrap();
assert_eq!(s.eval::<i64>("return bare:GetNumPoints()").unwrap(), 0);
s.run("b:SetFontString(placed)").unwrap();
let (p, _rel, rp, x, y): (String, mlua::Value, String, f64, f64) =
s.eval("return placed:GetPoint(1)").unwrap();
assert_eq!(
(p.as_str(), rp.as_str(), x, y),
("TOPLEFT", "TOPLEFT", 7.0, -3.0),
"an already-anchored label keeps its own anchors"
);
assert_eq!(s.eval::<i64>("return placed:GetNumPoints()").unwrap(), 1);
// The unanchored one gets the justify-derived point, to the matching point on the button.
s.run("b:SetFontString(bare)").unwrap();
assert_eq!(s.eval::<i64>("return bare:GetNumPoints()").unwrap(), 1);
let (p, _rel, rp, x, y): (String, mlua::Value, String, f64, f64) =
s.eval("return bare:GetPoint(1)").unwrap();
assert_eq!(
(p.as_str(), rp.as_str(), x, y),
("CENTER", "CENTER", 0.0, 0.0)
);
}
@@ -27,6 +27,7 @@ mod object_model;
mod reference_surface;
mod region_map;
mod regions;
mod resize_bounds;
mod scrollframe;
mod setparent;
mod simplehtml;
@@ -0,0 +1,316 @@
//! **The resize-bounds quad**, pinned to wow-re's byte carve
//! (`system/ui/scratch/resize-bounds-and-button-fontstring.md`, §5 trio, 2026-08-21).
//!
//! Every assertion here is one the first cut of this code got *wrong* by writing the plausible
//! thing instead of the read thing — which is why they are pinned rather than trusted:
//!
//! | the plausible thing | the byte thing |
//! |---|---|
//! | an unset bound is "no bound" and reads back `nil` | all four fields are `0.0`, the getters push two numbers |
//! | a floor of `1.0` keeps a drag sane | there is **no floor** — a drag goes through zero into negatives |
//! | a bound is a bound | only an exactly-`0.0` bound is the disable sentinel; a **negative** one clamps |
//! | `min > max` is a caller error to reconcile | nothing reconciles it; **max wins** |
//! | setting a bound fixes a frame already outside it | nothing happens until the next drag tick |
//! | `StartSizing("CENTER")` grips no edge, so it is a no-op | it is the **move** arm, and it is never bounded |
use crate::script::UiScript;
/// A resizable 200×100 frame planted at its BOTTOMLEFT, the shape the sibling sizing test uses.
fn sizer() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
s.run(
r#"
f = CreateFrame("Frame", "Sizer", UIParent)
f:SetResizable(true)
f:SetWidth(200) f:SetHeight(100)
f:SetPoint("BOTTOMLEFT", UIParent, "BOTTOMLEFT", 100, 50)
"#,
)
.unwrap();
s.resolve();
s
}
/// The getters answer **two numbers**, `0, 0` on a frame nobody bounded — never `nil`.
#[test]
fn a_virgin_frames_bounds_read_back_as_zero_not_nil() {
let s = sizer();
for verb in ["GetMinResize", "GetMaxResize"] {
let (w, h): (f64, f64) = s.eval(&format!("return Sizer:{verb}()")).unwrap();
assert_eq!((w, h), (0.0, 0.0), "{verb} on an unbounded frame");
assert_eq!(
s.eval::<i64>(&format!(
"local a,b = Sizer:{verb}() return (a and 1 or 0)+(b and 1 or 0)"
))
.unwrap(),
2,
"{verb} must push two values, not nils"
);
}
s.run("Sizer:SetMinResize(120, 60) Sizer:SetMaxResize(400, 300)")
.unwrap();
assert_eq!(
s.eval::<(f64, f64)>("return Sizer:GetMinResize()").unwrap(),
(120.0, 60.0)
);
assert_eq!(
s.eval::<(f64, f64)>("return Sizer:GetMaxResize()").unwrap(),
(400.0, 300.0)
);
// Setting one pair rewrites the other verbatim — the reference reads all four and writes all
// four, so the untouched pair round-trips rather than being cleared.
s.run("Sizer:SetMinResize(10, 10)").unwrap();
assert_eq!(
s.eval::<(f64, f64)>("return Sizer:GetMaxResize()").unwrap(),
(400.0, 300.0)
);
}
/// The argument gate is `lua_isnumber` — a numeric **string** passes; everything else raises the
/// reference's own `Usage:` text, naming the frame.
#[test]
fn the_setters_gate_is_lua_isnumber_and_the_usage_text_is_the_references() {
let s = sizer();
s.run("Sizer:SetMinResize(\"120\", \"60\")").unwrap();
assert_eq!(
s.eval::<(f64, f64)>("return Sizer:GetMinResize()").unwrap(),
(120.0, 60.0),
"a numeric string is coerced, as 5.0's lua_isnumber does"
);
// Arguments past the third are ignored — no upper arity check.
s.run("Sizer:SetMaxResize(400, 300, 999, \"x\")").unwrap();
assert_eq!(
s.eval::<(f64, f64)>("return Sizer:GetMaxResize()").unwrap(),
(400.0, 300.0)
);
for bad in [
"Sizer:SetMinResize()",
"Sizer:SetMinResize(120)",
"Sizer:SetMinResize(120, nil)",
"Sizer:SetMinResize(120, true)",
"Sizer:SetMinResize(120, \"wide\")",
"Sizer:SetMinResize({}, {})",
] {
let err = s.run(bad).unwrap_err().to_string();
assert!(
err.contains("Usage: Sizer:SetMinResize(minWidth, minHeight)"),
"`{bad}` must raise the reference's Usage text, got: {err}"
);
}
let err = s.run("Sizer:SetMaxResize(1)").unwrap_err().to_string();
assert!(err.contains("Usage: Sizer:SetMaxResize(maxWidth, maxHeight)"));
}
/// **A drag clamps against the bounds, and the anchor stops exactly on them.**
///
/// The second half is the rebate: without it the size pins at the bound while the delta keeps
/// feeding the anchor, so a window held past its minimum stops shrinking and starts *walking*.
#[test]
fn a_drag_clamps_and_the_planted_edge_stops_on_the_bound() {
let mut s = sizer();
s.run("Sizer:SetMinResize(150, 60) Sizer:SetMaxResize(260, 300)")
.unwrap();
// RIGHT grip, dragged far right: width saturates at the max, the left edge never moves.
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
s.mouse_move(900.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 260.0);
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 100.0);
s.run("Sizer:StopMovingOrSizing()").unwrap();
// LEFT grip, dragged far right: width saturates at the MIN — and the left edge lands exactly
// on it (100 + 260 − 150 = 210), not wherever the raw cursor delta would have carried it.
let right_before = s.eval::<f32>("return Sizer:GetRight()").unwrap();
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"LEFT\")").unwrap();
s.mouse_move(900.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 150.0);
assert_eq!(
s.eval::<f32>("return Sizer:GetLeft()").unwrap(),
right_before - 150.0,
"the rebate: a saturated grip stops ON the bound instead of walking past it"
);
assert_eq!(
s.eval::<f32>("return Sizer:GetRight()").unwrap(),
right_before,
"the ungripped edge must not move"
);
// Dragging further while saturated moves nothing at all.
s.mouse_move(1500.0, 100.0);
s.resolve();
assert_eq!(
s.eval::<f32>("return Sizer:GetLeft()").unwrap(),
right_before - 150.0
);
s.run("Sizer:StopMovingOrSizing()").unwrap();
}
/// **`0.0` is the disable sentinel, per field** — and a NEGATIVE bound is live.
#[test]
fn zero_disables_and_a_negative_bound_still_clamps() {
let mut s = sizer();
// Height bounded, width explicitly unbounded on both ends.
s.run("Sizer:SetMinResize(0, 80) Sizer:SetMaxResize(0, 90)")
.unwrap();
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"BOTTOMRIGHT\")").unwrap();
s.mouse_move(900.0, 100.0);
s.resolve();
assert_eq!(
s.eval::<f32>("return Sizer:GetWidth()").unwrap(),
800.0,
"a 0 width bound must not clamp — it is the disable sentinel, not a limit"
);
assert_eq!(s.eval::<f32>("return Sizer:GetHeight()").unwrap(), 90.0);
s.run("Sizer:StopMovingOrSizing()").unwrap();
// A negative maximum is NOT a sentinel: it clamps, and the width really does go negative.
s.run("Sizer:SetMaxResize(-50, 0) Sizer:SetMinResize(0, 0)")
.unwrap();
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
s.mouse_move(310.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), -50.0);
}
/// With no bound at all there is **no floor** — the client has no `1.0` and neither do we.
#[test]
fn an_unbounded_drag_goes_through_zero() {
let mut s = sizer();
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
s.mouse_move(50.0, 100.0);
s.resolve();
assert_eq!(
s.eval::<f32>("return Sizer:GetWidth()").unwrap(),
-50.0,
"no SetMinResize means no floor, not a floor of 1"
);
}
/// **`min > max` ends at `max`** — min is applied first, then max, and nothing reconciles them.
#[test]
fn a_min_above_the_max_resolves_to_the_max() {
let mut s = sizer();
s.run("Sizer:SetMinResize(400, 10) Sizer:SetMaxResize(200, 500)")
.unwrap();
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
s.mouse_move(305.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 200.0);
}
/// **Setting a bound a frame already violates does nothing** until the next drag tick — the setter
/// does not touch the size, and no other write path consults the bounds.
#[test]
fn a_bound_set_late_does_not_resize_the_frame() {
let mut s = sizer();
s.run("Sizer:SetMinResize(400, 400)").unwrap();
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 200.0);
assert_eq!(s.eval::<f32>("return Sizer:GetHeight()").unwrap(), 100.0);
// A programmatic SetWidth past the bound is not clamped either — VERIFIED: no layout vtable
// in the client overrides the plain, non-clamping SetWidth/SetHeight.
s.run("Sizer:SetWidth(50)").unwrap();
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 50.0);
// The first drag tick is what snaps it into range.
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"RIGHT\")").unwrap();
s.mouse_move(301.0, 100.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetWidth()").unwrap(), 400.0);
}
/// **`StartSizing("CENTER")` translates the frame and is never bounded** — the pump's case 4.
#[test]
fn a_center_grip_moves_the_frame_and_ignores_the_bounds() {
let mut s = sizer();
s.run("Sizer:SetMinResize(200, 100) Sizer:SetMaxResize(200, 100)")
.unwrap();
s.mouse_move(300.0, 100.0);
s.run("Sizer:StartSizing(\"CENTER\")").unwrap();
s.mouse_move(340.0, 130.0);
s.resolve();
assert_eq!(
(
s.eval::<f32>("return Sizer:GetWidth()").unwrap(),
s.eval::<f32>("return Sizer:GetHeight()").unwrap()
),
(200.0, 100.0),
"a CENTER grip resizes nothing, so the bounds are never consulted"
);
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 140.0);
s.run("Sizer:StopMovingOrSizing()").unwrap();
s.mouse_move(500.0, 300.0);
s.resolve();
assert_eq!(s.eval::<f32>("return Sizer:GetLeft()").unwrap(), 140.0);
}
/// `<ResizeBounds>` in XML reaches the same four fields — and a block naming only `<minResize>`
/// **resets the max pair to unbounded**, because the client writes all four unconditionally.
#[test]
fn the_xml_element_writes_both_pairs() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let doc = benilla_ui_doc(
r#"
<Frame name="Chatty" resizable="true">
<Size><AbsDimension x="400" y="200"/></Size>
<ResizeBounds>
<minResize><AbsDimension x="296" y="75"/></minResize>
<maxResize><AbsDimension x="608" y="400"/></maxResize>
</ResizeBounds>
</Frame>
"#,
);
load(&s, &doc);
assert_eq!(
s.eval::<(f64, f64)>("return Chatty:GetMinResize()")
.unwrap(),
(296.0, 75.0)
);
assert_eq!(
s.eval::<(f64, f64)>("return Chatty:GetMaxResize()")
.unwrap(),
(608.0, 400.0)
);
// An authored `<Size>` outside the authored bounds survives load unchanged — neither path
// clamps, whatever the document order.
assert_eq!(s.eval::<f32>("return Chatty:GetWidth()").unwrap(), 400.0);
let partial = benilla_ui_doc(
r#"
<Frame name="Half" resizable="true">
<ResizeBounds><minResize><AbsDimension x="10" y="20"/></minResize></ResizeBounds>
</Frame>
"#,
);
load(&s, &partial);
assert_eq!(
s.eval::<(f64, f64)>("return Half:GetMinResize()").unwrap(),
(10.0, 20.0)
);
assert_eq!(
s.eval::<(f64, f64)>("return Half:GetMaxResize()").unwrap(),
(0.0, 0.0),
"an absent <maxResize> writes 0 — the block is unconditional on both pairs"
);
}
/// Wrap a fragment in the `<Ui>` root the parser wants.
fn benilla_ui_doc(body: &str) -> String {
format!("<Ui>{body}</Ui>")
}
fn load(s: &UiScript, src: &str) {
let doc = crate::framexml::parse(src).expect("parse");
let report = crate::loader::load_in(s, &doc, "test.xml", &|_| None);
assert!(report.errors.is_empty(), "{:#?}", report.errors);
}
+30 -17
View File
@@ -283,21 +283,33 @@ pub struct Frame {
/// `StartSizing` half of the same family. Default false. benilla stores and reports the flag;
/// no resize *drag* is built (`StopMovingOrSizing` already covers the stop side of both).
pub resizable: bool,
/// `SetMinResize` / `SetMaxResize` — the interactive-resize bounds (`0x776020` / `0x7762a0`,
/// with `GetMinResize 0x775f20` / `GetMaxResize 0x7761a0`; all four on the Frame method table
/// `0x878ec0`). `None` = that bound was never set, i.e. unbounded on both axes.
/// `SetMinResize` / `SetMaxResize` — the interactive-resize bounds as `(width, height)`
/// (`0x776020` / `0x7762a0`, with `GetMinResize 0x775f20` / `GetMaxResize 0x7761a0`; all four
/// on the Frame method table `0x878ec0`, stored in the client at `CLayoutFrame+0x5c..+0x68`
/// as a `CRect`-shaped `{minY, minX, maxY, maxX}`).
///
/// A window kit's opening lines are `SetResizable(true)` + these two, and without them a
/// `StartSizing` drag has nothing to stop it — Quiver's `SideEffectMakeMoveable` calls
/// `SetMinResize` on every module frame it builds and died on the nil method (part of B267).
/// **`0.0` is the client's own "unbounded" sentinel, on each field independently** (1505) — not a
/// flag, not a negative, not a `None`: the `CLayoutFrame` ctor `0x767680` zeroes all four, the
/// getters hand back two plain numbers (`0, 0` on a frame nobody bounded, never `nil`), and
/// the clamp's first test on every axis is `bound == 0.0 → skip`. A **negative** bound is
/// therefore live and clamps normally. Byte-verified: wow-re
/// `system/ui/scratch/resize-bounds-and-button-fontstring.md` §1–2.
///
/// **The sentinel and the enforcement points are out with wow-re** (`0` may be the client's
/// "unbounded", and whether the clamp binds only the drag pump or every size write is the
/// load-bearing half). Until that lands, `None` is unbounded and only the drag pump clamps —
/// `script::object::movable::clamp_resize` is the single place either answer folds into.
pub min_resize: Option<(f32, f32)>,
/// The upper twin of [`Frame::min_resize`] — see its doc.
pub max_resize: Option<(f32, f32)>,
/// The values are lengths in the same space as the explicit width/height, so they compare
/// directly against [`crate::layout::LayoutInput`]'s — the binding runs the byte-identical
/// logical→internal transform `SetWidth`'s does.
///
/// A window kit's opening lines are `SetResizable(true)` + these two; Quiver's
/// `SideEffectMakeMoveable` calls `SetMinResize` on every module frame it builds and died on
/// the nil method (part of B267).
///
/// **Only the interactive drag reads them** — VERIFIED, not an omission: the setters store raw
/// and do not even mark the layout dirty, `CLayoutFrame::SetWidth`/`SetHeight` never consult
/// them, and no layout-resolve path does either. A frame sized 100 wide with
/// `SetMinResize(400, 400)` stays 100 wide until the first drag tick snaps it into range.
pub min_resize: (f32, f32),
/// The upper twin of [`Frame::min_resize`] — see its doc, including the `0.0` sentinel.
pub max_resize: (f32, f32),
/// `SetUserPlaced` — the client's "the user placed this frame; persist its position across
/// sessions" bit. Default false. Stored and readable (`IsUserPlaced`); **nothing consumes it
/// yet** — persisting a frame's position belongs with the layout cache, not with the drag that
@@ -593,10 +605,11 @@ impl WidgetArena {
// it, at the level it was created with.
movable: false,
resizable: false,
// No frame is born with resize bounds: both are opt-in, and a frame nobody bounded
// resizes freely (see the field docs — the client's own sentinel is out with wow-re).
min_resize: None,
max_resize: None,
// All four zeroed, which IS the client's unbounded state — `CLayoutFrame`'s ctor
// `0x767680` does exactly this (`xor esi,esi` into `+0x5c..+0x68`), and `0.0` is the
// sentinel the clamp tests rather than a separate flag.
min_resize: (0.0, 0.0),
max_resize: (0.0, 0.0),
user_placed: false,
toplevel: false,
scale,