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