the bag windows are the real 1.12 files, and /walk actually walks

- the bag windows are the reference's own `ContainerFrame.xml` running off the player's patch chain — decision 1751 makes the stock 1.12 UI off the install the end state and `assets/ui` scaffolding that retires file-by-file (86 files against the 99-file stock corpus, 72% of the 1128-function global surface already built) — via one ordered manifest, `benilla.toc`, where a bare name is a file we ship and an `Interface\FrameXML\` path is the player's own file; the windows on screen are `ContainerFrame1..12`, the bank's six copy-pasted popouts fold into them at ids 5..10, net −750 lines, and the swap forced real engine verbs into being rather than stubs (`SetBagPortaitTexture` with its id-0 no-texture arm, `GameTooltip:AppendText`, `SetButtonPulse`, and a `<Model>` whose file is the cooldown indicator materializing as the native sweep, which three corpus addons inherit)
- the 1.12 walk toggle lands: `TOGGLERUN` latches one bit with `ToggleRun`'s own silent refusal chain (dead, rooted, on a live server spline), and the walk animation was never missing at all — the flag reaches it through exactly one channel, the speed (`0x5fd100` picks Walk over Run on `speed > 2 × walkSpeed`); pinning it caught our remote extrapolator with the two min rungs backwards — `min(walk, run)` runs BEFORE the backward min, so a walking backpedaller moves at 2.5 yd/s where we would have played them the Run clip at 4.5 — and one shared `current_speed` now keeps the local controller and the extrapolator from ever disagreeing about it
- the instance and raid lockout messages exist — six packets that were dropped as unknown opcodes now land, four of them as the CHAT_MSG_SYSTEM lines the client composes itself, which is why no Lua handler ever surfaced them — plus `IsInInstance`, `ResetInstances`, and the unit popup's "Reset all instances" row behind the reference's own predicate; where the binary contradicts vmangos: the ownership flag means "any instance bind" but vmangos only ever names raid binds, so the row was dead for every character without a raid lockout — term 1 is now also satisfied by our own eyes, walking out of a party dungeon the server itself bound us to
- opening the world map showed 3D world at the sheet's margins: `BlackoutWorld` was parented to the very `UIParent` a fullscreen panel hides, and three more frames had crossed the same boundary — the shared dropdown lists (so the map's continent and zone pickers answered a click with nothing at all), the master-looter menu, and the screenshot confirmation, which could never appear during a cinematic; `frame_flag_gate` now checks the parent seat against the reference both ways a frame acquires one (the attribute, template-inherited included, and `<Frames>` nesting), and its first harvest took 30 divergences down to three accepted ones

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:17:25 +07:00
co-authored by Claude Fable 5
parent 23b1a3e68b
commit 03c70a7035
73 changed files with 5561 additions and 3278 deletions
+8 -10
View File
@@ -290,9 +290,9 @@
if show then button:Show() else button:Hide() end
end
-- Losing the last key with the keyring open would strand an orphan window; the ref
-- can't reach this state (its latch is one-way), so this is ours to decide.
local frame = getglobal("BenillaKeyRingFrame")
if frame and not show and frame:IsShown() then frame:Hide() end
-- can't reach this state (its latch is one-way), so this is ours to decide. The window
-- is an ordinary ContainerFrame since 1751, so this is the reference's own CloseBag.
if not show then CloseBag(KEYRING_CONTAINER) end
end
-- ── The bonus-bar slide (decision 1524; ref BonusActionBarFrame.lua:1-98) ──────────────
@@ -1262,12 +1262,11 @@
-- `< MAX_PLAYER_LEVEL`, unwatched on `== MAX_PLAYER_LEVEL`, which is the reference's own
-- asymmetry and only differs above the cap, where nothing can go.
--
-- ONE GUARD ADDED, at the tail: `updateContainerFrameAnchors` is defined by
-- ContainerFrameAdapters.xml (manifest 206, below this file), and a harness that shows this
-- bar without it would take a nil call on a line that only ever runs on a visibility flip.
-- The `if <global> then` shape is this codebase's own subset-load idiom (CharacterFrame.xml's
-- `if UpdateMicroButtons then`). `UIParent_ManageFramePositions` needs no such guard:
-- UIParent.xml is manifest 42, above every consumer.
-- The tail used to call `updateContainerFrameAnchors()` itself, guarded, because that
-- global was a benilla no-op standing in for a body that had no windows to anchor.
-- Decision 1751 gave it the reference's real body and put the call where the reference
-- puts it — the end of `UIParent_ManageFramePositions` (UIParent.lua:1774) — so this
-- caller gets it for free, along with every other one.
function ReputationWatchBar_Update(newLevel)
newLevel = newLevel or UnitLevel("player")
local name, reaction, rankMin, rankMax, standing = GetWatchedFactionInfo()
@@ -1308,7 +1307,6 @@
end
if wasShown ~= ReputationWatchBar:IsShown() then
UIParent_ManageFramePositions()
if updateContainerFrameAnchors then updateContainerFrameAnchors() end
end
end
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,26 @@
<!--
OUR adapters over the reference's own `Interface\FrameXML\ContainerFrame.lua`.
OUR adapters over the reference's own `Interface\FrameXML\ContainerFrame.xml`/`.lua`.
**This client does not transcribe that file — it EXECUTES it, off the player's own patch
chain**, before any of our `assets/ui` files (`ui_script::reference_ui`, whose header carries
the rule and the licensing reason: Blizzard's Lua is never committed here). What is in THIS
file is only benilla's, and only where a reference body reaches for something this client
genuinely does not have.
**This client does not transcribe those files — it EXECUTES them, off the player's own patch
chain** (decision 1751; `ui_script::reference_ui` carries the rule, and `benilla.toc` carries
the position). The bag windows on screen ARE `ContainerFrame1..12`. What is in THIS file is
only benilla's, and only where a reference body reaches for something this client genuinely
does not have.
The rule, restated in one line so it can be applied without re-reading the module: the sourced
file's globals stand unmodified; ours (loading after) win where both define a name; and where a
file's globals stand unmodified; ours, loading after, win where both define a name; and where a
reference body needs a benilla answer, it is WRAPPED here — never silently stubbed.
TWO THINGS THIS FILE USED TO DO AND NO LONGER DOES, both retired by 1751's swap:
· It declared `ContainerFrameItemButtonTemplate`, transcribed from ContainerFrame.xml, because
the Lua could be sourced and the XML could not. The XML is sourced now, so the reference's
own declaration is the live one and the copy is deleted — the whole point of the migration.
· It defined `updateContainerFrameAnchors()` as a deliberate no-op, because the reference's
body walks twelve `ContainerFrame` windows this client did not build. It builds them, so the
reference's own body is correct here and the stub is deleted. Five corpus addons (Bongos,
Bongos_ActionBar, the CT_BarMod family) hook that global and call through; they now reach the
real thing.
── The one adapter, and why it is not a stub ────────────────────────────────────────────────
`ContainerFrameItemButton_OnEnter` opens with a keyring fork (ref l.615-620):
@@ -23,17 +33,18 @@
That fork exists because the REAL client's keyring items are inventory slots, not container
slots, so it has to detour out of the container API to describe one. benilla's keyring is an
ordinary container (id -2, decision 0765): its slots come through `GetContainerItemInfo` and
`GameTooltip:SetBagItem` like every other bag's. So the detour is not merely unavailable here
(`KeyRingButtonIDToInvSlotID` is not a binding here; `CursorUpdate` now is, transcribed from
UIParent.lua into UiPanels.xml, so only half the detour is missing) — it is *wrong* here,
and supplying the two verbs would buy an empty tooltip, because our keyring items were never
fed into the inventory-slot space it reads.
`GameTooltip:SetBagItem` like every other bag's — which is exactly why the reference's keyring
WINDOW, its verbs and its sounds all take our keyring unchanged, and why this one line is the
only thing in that file the swap did not simply inherit. The detour is not merely unavailable
here (`KeyRingButtonIDToInvSlotID` is not a binding here) — it is *wrong* here, and supplying
the verb would buy an empty tooltip, because our keyring items were never fed into the
inventory-slot space it reads.
So the wrapper below answers the keyring case the way benilla answers every other bag, and
hands EVERY other slot straight to the reference's own body. The path the player is actually
on — hovering an ordinary bag slot — runs the real client's code, unedited.
── Named, not hidden: what still does not work through the sourced file ─────────────────────
── Named, not hidden: what still does not work through the sourced files ────────────────────
· `MerchantItemButton_OnClick`. The reference's `ContainerFrameItemButton_OnClick` closes its
shift-right-click-at-a-vendor arm with a `SplitStack` closure that calls that global, and
benilla's merchant window publishes `BenillaMerchantItem_OnClick` instead. Confirming a stack
@@ -43,6 +54,11 @@
· `REPAIR_COST` is read by the reference's repair-mode arm. It is a `GlobalStrings.lua` key and
this client sources that file too, so it resolves at runtime — and the arm is unreachable
today regardless, since `GameTooltip:SetBagItem`'s repairCost is an INTERIM 0.
· `NO_EMPTY_KEYRING_SLOTS` is read by the reference's `PutKeyInKeyRing` when the ring is full,
and no such GlobalString exists — 1.12 ships only `NO_EMPTY_KEYRING_SLOTS_ERROR`. That is a
REFERENCE BUG, and this client used to un-quirk it. It does not any more: the reference's own
file runs, so a full keyring prints the reference's own empty error line. Faithful, and
deliberately so (1751: a divergence becomes faithful or becomes a record, never silent).
-->
<Ui xmlns="http://www.blizzard.com/wow/ui/">
<Script><![CDATA[
@@ -80,97 +96,27 @@
refItemButtonOnEnter(button)
end
-- ── `updateContainerFrameAnchors` — a no-op here, and that IS the benilla answer ────────
-- The reference's body (l.480-489) opens `while ContainerFrame1.bags[index] do` and walks
-- the twelve `ContainerFrame1..12` windows it owns, positioning each into columns. benilla
-- does not build those windows at all — our bags are `BagFrame.xml`'s own, and they
-- position themselves. `ui_script::manifest` already records the same difference one
-- function over: *"ToggleBackpack walks twelve ContainerFrames we do not build; ours walks
-- our windows."*
-- ══ Re-publishing the three bag verbs 0561 shadows, and why it is HERE ═════════════
--
-- So this is not a stub standing in for something missing: there is nothing here for it to
-- anchor, and the reference body raises `attempt to index global 'ContainerFrame1'` the
-- moment anything calls it. FIVE corpus addons did — Bongos, Bongos_ActionBar and the
-- CT_BarMod family all HOOK it (`BLIZZARD_Original_updateContainerFrameAnchors` in the
-- traceback) and then call through, so the raise landed inside a Blizzard file with our
-- bag layout nowhere in the stack.
-- `OpenBackpack`, `CloseBackpack` and `CloseAllBags` are all defined by the sourced
-- `ContainerFrame.lua` (l.187-217, l.702-707) — and benilla answers all three differently,
-- for one reason: **0561, the director's call that a window opening your bags opens EVERY
-- equipped bag**, not just the backpack. The vendor, bank, mail and trade windows call
-- `OpenBackpack` from their OnShow so your bags come up with them and you can sell or
-- deposit from one.
--
-- Deliberately silent rather than warned: an addon calling this on every bag update would
-- turn a warn-once into a warn-always, and unlike the cases in the section above nothing is
-- LOST by doing nothing — the windows it would have moved are already where our own layout
-- put them. The header's "never silently stubbed" rule is about absent capability; this is
-- present capability with a different owner.
function updateContainerFrameAnchors()
end
-- The bodies live in `UiPanels.xml`, because every window that calls them is loaded by
-- harnesses that have no bag stack at all, and a nil `OpenBackpack` in a merchant test is
-- a nil for no reason. But UiPanels is manifest entry 7 and the reference's container file
-- is entry 46, so **its publications lose the collision** — the reference's bodies land on
-- top and the vendor silently stops opening your bags. That is not hypothetical: it is
-- what shipped for the length of one session before a test caught it.
--
-- So the bodies stay where their callers can reach them, under `Benilla*` names that
-- nothing overwrites, and this file — entry 49, after the reference's — puts them back on
-- the reference's names. Three lines, and they are the whole of the fix.
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
]]></Script>
<!-- ── The reference's item-button TEMPLATE, which the sourced file cannot bring ────────────
`ui_script::reference_ui` sources `ContainerFrame.lua`, so every handler named below is
the reference's own live code, not a transcription. It cannot source `ContainerFrame.xml`
— that file also declares the twelve `ContainerFrame1..12` windows this client does not
build — so the template that BINDS those handlers to a shape was the one missing piece.
Declaring it here is the smallest thing that closes the gap, and it goes in THIS file
because this is where reference container behaviour that needs a benilla answer lives.
Ref ContainerFrame.xml l.4-60, verbatim but for the one deviation named below. Loads after
ItemButtonTemplate.xml (its base, toc l.45) and after Cooldown.xml (CooldownFrameTemplate,
toc l.124) — both are why this cannot simply sit next to the base.
pfUI is the measured caller: `CreateFrame("Button", name, pfUI.bags[bag], tpl)`
(modules/bags.lua l.381) died on "Couldn't find inherited node" and took its whole bag
module with it. It then reads `$parentCooldown`, `$parentCount`, `$parentIconTexture` and
`$parentNormalTexture` straight off what it just built, and installs NO scripts of its own
on the slot — so the handlers below are not decoration, they are what makes a pfUI bag
respond to a click at all.
DEVIATION, named: the reference's OnEnter/OnDragStart/OnReceiveDrag/OnClick each open with
a KeyRing fork keyed on `KEYRING_CONTAINER`. Those bodies are the SOURCED ones and they
keep their fork; what this file's header explains at length is that our keyring is an
ordinary container (0765), so the fork is wrong here rather than merely unavailable, and
the adapter above is what answers it. Nothing new is introduced by this template. -->
<Button name="ContainerFrameItemButtonTemplate" inherits="ItemButtonTemplate" hidden="true" virtual="true">
<Anchors>
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
<Frames>
<Model name="$parentCooldown" inherits="CooldownFrameTemplate"/>
</Frames>
<Scripts>
<OnLoad>ContainerFrameItemButton_OnLoad();</OnLoad>
<OnClick>
if ( this:GetParent():GetID() == KEYRING_CONTAINER ) then
KeyRingItemButton_OnClick(arg1);
return;
end
ContainerFrameItemButton_OnClick(arg1);
</OnClick>
<OnEnter>ContainerFrameItemButton_OnEnter();</OnEnter>
<OnLeave>
this.updateTooltip = nil;
GameTooltip:Hide();
ResetCursor();
</OnLeave>
<OnHide>
if ( this.hasStackSplit and (this.hasStackSplit == 1) ) then
StackSplitFrame:Hide();
end
</OnHide>
<OnDragStart>
if ( this:GetParent():GetID() == KEYRING_CONTAINER ) then
KeyRingItemButton_OnClick("LeftButton", 1);
return;
end
ContainerFrameItemButton_OnClick("LeftButton", 1);
</OnDragStart>
<OnReceiveDrag>
if ( this:GetParent():GetID() == KEYRING_CONTAINER ) then
KeyRingItemButton_OnClick("LeftButton", 1);
return;
end
ContainerFrameItemButton_OnClick("LeftButton", 1);
</OnReceiveDrag>
<OnUpdate>ContainerFrameItemButton_OnUpdate(arg1);</OnUpdate>
</Scripts>
</Button>
</Ui>
+21 -16
View File
@@ -19,18 +19,22 @@
--
-- THE ONE FORK, and why it is two faithful branches rather than a guard. The reference's
-- body arms a Model (`this.start = start … this:SetSequence(0)`); ours arms our native
-- <Cooldown> widget, which is where 0263 put that machine. Both kinds really do reach this
-- function: our own windows pass a <Cooldown>, and an addon inheriting the template below
-- passes a <Model>, because an XML instance is created from ITS OWN tag and every real
-- caller writes the reference's `<Model … inherits="CooldownFrameTemplate"/>`.
-- <Cooldown> widget, which is where 0263 put that machine.
--
-- Calling SetCooldown unconditionally is what shipped, and it raised on exactly that case:
-- a Bagnon slot holding an item with a live cooldown died on
-- `attempt to call method 'SetCooldown' (a nil value)` — and, because !OmniCC wraps this
-- very function, it took OmniCC's countdown down with it. A Model has no sweep for this
-- engine to arm (`SetSequence`/`AdvanceTime` are not bindings here), so that branch shows
-- and hides the frame and nothing more: the addon's own cooldown child stays inert, which
-- is a missing visual, not a broken window.
-- **Since decision 1751 the fork is almost never taken, and that is the fix.** The loader
-- now materializes a `<Model>` whose file IS the cooldown indicator as a real `<Cooldown>`
-- (`loader::frame_kind_of`) — the file is the thing's identity, and this engine draws that
-- sweep natively. So `<Model … inherits="CooldownFrameTemplate"/>`, which is what every
-- real caller writes (the reference's own ContainerFrameItemButtonTemplate, its action
-- buttons, three corpus addons), arrives here as a Cooldown and sweeps.
--
-- The `if this.SetCooldown` guard stays for the case that remains: a Model built with
-- some OTHER file, or a plain frame handed in by an addon. Calling SetCooldown
-- unconditionally is what shipped, and it raised on exactly that: a Bagnon slot holding an
-- item with a live cooldown died on `attempt to call method 'SetCooldown' (a nil value)`
-- — and, because !OmniCC wraps this very function, it took OmniCC's countdown down with
-- it. That branch shows and hides the frame and nothing more, which is a missing visual,
-- not a broken window.
function CooldownFrame_SetTimer(this, start, duration, enable)
if ( start > 0 and duration > 0 and enable > 0 ) then
if ( this.SetCooldown ) then
@@ -44,11 +48,12 @@
]]></Script>
<!--
ref Cooldown.xml l.4-14. The reference's own attributes are kept verbatim
(its .mdx file, its 0.75 scale, setAllPoints, hidden) even where this engine has no use for
the model file: the template's JOB here is to exist under the reference's name so an
addon's `inherits=` resolves and its cooldown child inherits the geometry the addon is
counting on.
ref Cooldown.xml l.4-14. The reference's own attributes are kept verbatim (its .mdx file,
its 0.75 scale, setAllPoints, hidden) — and the `file=` is no longer decoration: since
decision 1751 it is what the loader reads to know that an inheriting `<Model>` is really
this engine's `<Cooldown>` widget (`loader::frame_kind_of`). The template's other job is
unchanged: to exist under the reference's name so an addon's `inherits=` resolves and its
cooldown child inherits the geometry the addon is counting on.
Its two `<Scripts>` — OnUpdateModel / OnAnimFinished — are deliberately NOT transcribed.
Both are real 1.12 script slots that this engine does not fire (see
+5 -1
View File
@@ -711,8 +711,12 @@
GroupLootFrame1..4: LootFrame_OnEvent hands ToggleDropDownMenu this frame by literal global
lookup, and a 1.12 addon that wants to extend the master-loot menu expects to find it here.
Hidden and unanchored: ToggleDropDownMenu positions it at the clicked loot row.
No `parent=`, which is the reference's own declaration (l.233, a top-level sibling of
LootFrame) and the dropdown family's rule since 1757: a menu host under `UIParent` goes
invisible with it under an `area = "full"` panel, and a hidden host seats a hidden list.
-->
<Frame name="GroupLootDropDown" parent="UIParent" inherits="UIDropDownMenuTemplate" id="1" hidden="true">
<Frame name="GroupLootDropDown" inherits="UIDropDownMenuTemplate" id="1" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
</Anchors>
+13 -5
View File
@@ -475,7 +475,7 @@
-- Arm the write-on (ref l.538-551): objectives/rewards invisible and Accept dead
-- until the description has finished writing itself onto the parchment.
getglobal("QuestDetailTextAlphaFrame"):SetAlpha(0)
getglobal("TextAlphaDependentFrame"):SetAlpha(0)
getglobal("QuestFrameAcceptButton"):Disable()
local panel = getglobal("QuestFrameDetailPanel")
panel.fading = 1
@@ -503,10 +503,10 @@
if not getglobal("QuestDescription"):SetAlphaGradient(panel.fadingProgress, QUEST_DESCRIPTION_GRADIENT_LENGTH) then
panel.fading = nil
if QUEST_FADING_DISABLE == "0" then
UIFrameFadeIn(getglobal("QuestDetailTextAlphaFrame"), QUESTINFO_FADE_IN)
UIFrameFadeIn(getglobal("TextAlphaDependentFrame"), QUESTINFO_FADE_IN)
else
-- instant: the block snaps on, no fade (ref l.561-564)
getglobal("QuestDetailTextAlphaFrame"):SetAlpha(1)
getglobal("TextAlphaDependentFrame"):SetAlpha(1)
end
getglobal("QuestFrameAcceptButton"):Enable()
end
@@ -1093,8 +1093,16 @@
QuestFrame.xml l.553): 1.12 SetAlpha overwrite-cascades into
the child item buttons/coins (wow-re propagation.md,
CORRECTED), so the whole block hides while the text writes
and fades in as one. -->
<Frame name="QuestDetailTextAlphaFrame">
and fades in as one.
**The reference's own bare name.** This was spelled
`QuestDetailTextAlphaFrame` until decision 1757, with the
citation above already naming what it should have been —
the slip 1189 describes and `BlackoutWorld` paid for: a
published global spelled to our taste is a global the
reference has and we do not, and the reference's own
QuestFrame.lua indexes this one by hand (l.543/562/564). -->
<Frame name="TextAlphaDependentFrame">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestDescription" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor></Anchors>
<Layers>
+31 -1
View File
@@ -42,7 +42,10 @@
`PromoteToAssistant`, `DemoteAssistant`, `DoReadyCheck`, `ConfirmReadyCheck`,
`RequestRaidInfo`, `GetNumSavedInstances`, `GetSavedInstanceInfo`,
`SetRaidRosterSelection`/`GetRaidRosterSelection` — all in `benilla-ui/src/script/party.rs`
beside `GetRaidRosterInfo`, which already existed. The events are `RAID_ROSTER_UPDATE`,
beside `GetRaidRosterInfo`, which already existed. Decision 1748 added the lockout family's
own three (`IsInInstance`, `CanShowResetInstances`, `ResetInstances`) in
`benilla-ui/src/script/instance.rs`; the CONFIRM_RESET_INSTANCES dialog at the foot of this
file is their one caller, raised by UnitPopup's SELF menu. The events are `RAID_ROSTER_UPDATE`,
`READY_CHECK` and `UPDATE_INSTANCE_INFO`, fired by `ui_party::feed`; the `raid1`..`raid40`
unit tokens the rows target and hover are fed there too.
-->
@@ -69,6 +72,10 @@
GROUP = GROUP or "Group" -- l.2029
EMPTY = EMPTY or "Empty" -- l.1433
UNKNOWN = UNKNOWN or "Unknown" -- l.4443
CONFIRM_RESET_INSTANCES = CONFIRM_RESET_INSTANCES
or "Do you really want to reset all of your instances?" -- l.851
YES = YES or "Yes" -- l.5463
NO = NO or "No" -- l.2794
-- ── The counts (ref RaidFrame.lua l.2-5) ────────────────────────────────────────────────
-- Published globals, not local constants: `MAX_RAID_MEMBERS` alone is named by a large
@@ -615,6 +622,29 @@
StaticPopup_Hide("ADD_RAIDMEMBER")
end,
}
-- ── "Reset all instances" (ref StaticPopup.lua l.23-33; decision 1748) ──────────────────
-- Raised by UnitPopup's SELF menu, and registered HERE because this file is the instance
-- house — the same rule that keeps the pet dialogs in PetActionBar.xml. `ResetInstances()`
-- is the only call in the client that sends `CMSG_RESET_INSTANCES`; everything else in the
-- lockout family is the server talking.
--
-- The answer is per instance rather than one verdict, and it arrives as CHAT_MSG_SYSTEM
-- lines the engine composes: an `SMSG_INSTANCE_RESET` per dungeon that reset, and — on
-- the group path only — an `SMSG_INSTANCE_RESET_FAILED` per dungeon that would not.
-- Nothing back at all is the ordinary answer for a character with nothing resettable. So
-- there is nothing to show here and no result to wait for.
StaticPopupDialogs["CONFIRM_RESET_INSTANCES"] = {
text = CONFIRM_RESET_INSTANCES,
button1 = YES,
button2 = NO,
timeout = 0,
whileDead = 1,
hideOnEscape = 1,
OnAccept = function()
ResetInstances()
end,
}
]]></Script>
<!-- ── One member row (ref Blizzard_RaidUI.xml l.4-116) ───────────────────────────────────
@@ -25,11 +25,22 @@
elapsed its OnUpdate is handed — we accumulate the handed value instead, which is one clock
read fewer per frame and one field fewer on the frame.
SEATING. `parent="UIParent" setAllPoints="true"` with a CENTER-anchored string, which is
AutoFollowStatus's seat in ZoneText.xml and for the same reason: the reference parents this
to WorldFrame, benilla's WorldFrame is a stub (UIParent.xml) rather than the 3D layer, and
both are full-screen in the reference so their CENTERs coincide. TOOLTIP strata is the
reference's own — the confirmation has to be readable over whatever panel is open.
SEATING. `parent="WorldFrame" setAllPoints="true"` with a CENTER-anchored string — the
reference's own nesting (WorldFrame.xml l.42, inside WorldFrame's `<Frames>`), and it is
load-bearing rather than decorative. That file's header states the law in one line:
"Children of the world frame are visible even when the UI is turned off." benilla's
WorldFrame is a stub (UIParent.xml) rather than the 3D layer, but it is a stub with the
property that matters here — top-level, so nothing hides it.
This read `parent="UIParent"` until 1757, on a note that argued the two seats are the same
because both frames are full-screen and their CENTERs coincide. That was true of the
GEOMETRY and blind to the rest: decision 1734 restored `SetFullScreenFrame`'s
`UIParent:Hide()`, so under a fullscreen panel the UIParent seat is a hidden one — and the
case that exposes it is this frame's own, because `CinematicFrame` answers the SCREENSHOT
key by hand (`RunBinding`, 1701) precisely so a fly-by can be captured, and the
confirmation for that capture could never appear. TOOLTIP strata is the reference's own —
the confirmation has to be readable over whatever panel is open, and an explicit strata is
not dragged down by a BACKGROUND-strata parent.
-->
<Script>
<![CDATA[
@@ -81,7 +92,7 @@
]]>
</Script>
<Frame name="ScreenshotStatus" parent="UIParent" setAllPoints="true" frameStrata="TOOLTIP" hidden="true">
<Frame name="ScreenshotStatus" parent="WorldFrame" setAllPoints="true" frameStrata="TOOLTIP" hidden="true">
<Layers>
<Layer level="ARTWORK">
<FontString name="ScreenshotStatusText" inherits="SystemFont">
@@ -1119,8 +1119,15 @@
it is ONE library file replicated, and only that one of its two sites is unguarded (the
other is `info.textHeight or UIDROPDOWNMENU_DEFAULT_TEXT_HEIGHT or 10`). Writing a
literal here would have been a silent fidelity divergence, not a fix. -->
<!-- `toplevel` is authored on the ref's INSTANCES, not its template (decision 1318). -->
<Button name="DropDownList1" inherits="UIDropDownListTemplate" id="1" toplevel="true" parent="UIParent">
<!-- `toplevel` is authored on the ref's INSTANCES, not its template (decision 1318).
**Neither list carries a `parent=`, and neither does the reference's** (UIDropDownMenu.xml
l.5/l.20 — `toplevel="true" frameStrata="FULLSCREEN_DIALOG"` and nothing else). That is
not incidental. A dropdown has to open over whatever opened it, an `area = "full"` panel
included — and a `full` panel is shown through `SetFullScreenFrame`, which hides
`UIParent`. A list under `UIParent` opens into a hidden tree, so the world map's own
continent and zone pickers answered a click with nothing at all (decision 1757). -->
<Button name="DropDownList1" inherits="UIDropDownListTemplate" id="1" toplevel="true">
<Scripts>
<OnLoad>
local fontName, fontHeight, fontFlags = getglobal("DropDownList1Button1NormalText"):GetFont();
@@ -1132,7 +1139,7 @@
</OnHide>
</Scripts>
</Button>
<Button name="DropDownList2" inherits="UIDropDownListTemplate" id="2" toplevel="true" parent="UIParent">
<Button name="DropDownList2" inherits="UIDropDownListTemplate" id="2" toplevel="true">
<Scripts>
<OnHide>
CloseDropDownMenus(3);
@@ -496,6 +496,15 @@
for _, fn in ipairs(UIPARENT_MANAGED_POSITION_LISTENERS) do
fn()
end
-- …and the reference's own consumer, at the reference's own position
-- (UIParent.lua:1774). The open-bag stack re-anchors from here because the bags are
-- the reference's `ContainerFrame1..12` now (decision 1751) and this is the line that
-- used to be generalized into the listener table above — 1499's regression, closed the
-- way the real client closes it. Guarded because a machine with no client data does
-- not get `ContainerFrame.lua` at all, and then there is no bag stack to anchor.
if updateContainerFrameAnchors then
updateContainerFrameAnchors()
end
end
-- The published binding-label API — benilla's implementation of the behaviour ref
+204 -71
View File
@@ -378,6 +378,39 @@
return 1
end
-- IsOptionFrameOpen() — UIParent.lua l.996-1002, and the reference's own bag guard: it is
-- the first line of `ToggleBag`, `ToggleBackpack` and `ToggleKeyRing`, so with an options
-- window up a bag keybind does nothing at all. Distinct from CanOpenPanels above, which
-- asks about the panel MANAGER's centre slot; this asks a fixed list of five frames by
-- name, none of which is a managed panel.
--
-- The reference checks GameMenuFrame, OptionsFrame, UIOptionsFrame, SoundOptionsFrame and
-- KeyBindingFrame, and EVERY member is read through a nil guard here. That is the
-- reference's own idiom, not a softening of it — it already writes
-- `(KeyBindingFrame and KeyBindingFrame:IsVisible())` because that frame is created on
-- demand — extended to the whole list, because "is an options screen up" has a correct
-- answer, *no*, whenever the screen does not exist.
--
-- Two of the five genuinely do not exist in this client: benilla folds the reference's
-- three separate options windows into one `OptionsFrame` (its Video/Sound/Interface pages
-- are tabs of it) and its key-bindings page is a page of that same window
-- (KeyBindingsPage.xml), so `OptionsFrame` + `GameMenuFrame` cover every screen the other
-- three name. Those two the manifest does guarantee — but a PARTIAL interface is a real
-- surface (a focused test VM, and the reference's own `ADDON_LOADED` ordering), and this
-- runs on the first line of every `ToggleBag`/`ToggleBackpack`/`ToggleKeyRing`, so it is
-- the last place that should be the thing to raise.
function IsOptionFrameOpen()
if (GameMenuFrame and GameMenuFrame:IsVisible())
or (OptionsFrame and OptionsFrame:IsVisible())
or (UIOptionsFrame and UIOptionsFrame:IsVisible())
or (SoundOptionsFrame and SoundOptionsFrame:IsVisible())
or (KeyBindingFrame and KeyBindingFrame:IsVisible()) then
return 1
else
return nil
end
end
-- Everything that can REFUSE an open, asked in the reference's own order (UIParent.lua
-- l.663-689). Answers false when the frame must not open. Not a pure predicate, and that
-- is the point: `force` is precisely the right to throw the incumbent out instead of being
@@ -606,36 +639,25 @@
return found
end
-- CloseAllWindows(ignoreCenter) — UIParent.lua l.970-982. The real fn first loops
-- ContainerFrame1..NUM_CONTAINER_FRAMES to hide any open bags, then calls CloseWindows() for
-- the panel slots, returning whether anything closed. benilla ships a single backpack window
-- (BenillaBagFrame, decision 0068 T2), so that loop collapses to hiding it. The bag is NOT a
-- UIPanel (no slot registration) — it must be closed here explicitly, exactly as the real
-- client closes its ContainerFrames outside the slot system. Naming BenillaBagFrame here
-- mirrors UIParent.lua naming ContainerFrame in the very same function.
-- The five bag windows (the real fn's ContainerFrame1..NUM_CONTAINER_FRAMES): the backpack
-- plus the four equipped-bag windows. Chunk-LOCAL rather than BagFrame.xml's own
-- BENILLA_BAG_WINDOWS global, so every user below stays nil-safe on a surface without the
-- bag file (a headless panel test) — each still resolves per window through `getglobal`.
-- (It was three identical inline copies until the game menu needed a fourth.)
local BAG_WINDOWS = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2",
"BenillaBagFrame3", "BenillaBagFrame4" }
-- ESC closes the KEYRING too, but nothing else here does. The ref's split, exactly: its
-- CloseAllWindows loops every ContainerFrame and hides whatever is shown (the keyring
-- included), while CloseAllBags/OpenBackpack/CloseBackpack are id-driven over the real bags
-- and never see container -2 (decision 0765). So the keyring is appended for the ESC path
-- alone — a vendor opening your bags must not open your keyring with them.
local ESC_CLOSES = { "BenillaBagFrame", "BenillaBagFrame1", "BenillaBagFrame2",
"BenillaBagFrame3", "BenillaBagFrame4", "BenillaKeyRingFrame" }
-- CloseAllWindows(ignoreCenter) — UIParent.lua l.970-982, and VERBATIM now: the real fn
-- loops ContainerFrame1..NUM_CONTAINER_FRAMES hiding any open bag, then calls
-- CloseWindows() for the panel slots and returns whether anything closed. Until decision
-- 1751's bag swap this file had to spell that loop out over five benilla-named windows plus
-- the keyring; those windows ARE ContainerFrames now, and the reference's own loop covers
-- every one of them — the keyring included, which is why the bespoke ESC_CLOSES list (bags
-- + keyring, distinct from the bag-only list the vendor path used) is gone rather than
-- retargeted. A bag window is NOT a UIPanel (no slot registration), so it must be closed
-- here explicitly, exactly as the real client closes its ContainerFrames outside the slot
-- system.
-- `NUM_CONTAINER_FRAMES or 0` here and `NUM_BAG_FRAMES or 0` below are not defensive
-- padding: both constants come from the sourced `ContainerFrame.lua`, and a machine with no
-- client data does not get that file (`reference_ui`'s stated degradation, warned once at
-- boot). On that surface there are no bag windows AT ALL, so "close every open bag" really
-- is a no-op — the guard says exactly that, rather than hiding an absence.
function CloseAllWindows(ignoreCenter)
local bagsVisible = nil
-- Hide every open bag window. Now that 'B' opens all bags at once (BagFrame.xml
-- BenillaBagToggle_OnClick), ESC must close all of them, not just the backpack. The bag
-- windows are NOT UIPanels (no slot registration) — they must be closed here explicitly,
-- exactly as the real client closes its ContainerFrames outside the slot system.
for _, name in ipairs(ESC_CLOSES) do
local bag = getglobal(name)
for i = 1, (NUM_CONTAINER_FRAMES or 0) do
local bag = getglobal("ContainerFrame" .. i)
if bag and bag:IsShown() then
bag:Hide()
bagsVisible = 1
@@ -645,62 +667,76 @@
return bagsVisible or windowsVisible
end
-- CloseAllBags() — the ref ContainerFrame.lua l.702-707 (`CloseBackpack()` + `CloseBag(1..N)`),
-- called by ShowUIPanel's native-center branch above. Distinct from CloseBackpack below on
-- purpose: this is the UNCONDITIONAL close (the ref's CloseBag ignores the was-open memory),
-- because a center frame takes the whole screen — nothing is being "restored afterwards"
-- the way a vendor window restores the bags it opened.
function CloseAllBags()
for _, name in ipairs(BAG_WINDOWS) do
local bag = getglobal(name)
if bag and bag:IsShown() then
bag:Hide()
end
-- ══ The three bag verbs benilla answers differently (decisions 0561 + 1751) ═════════
--
-- `CloseAllBags`, `OpenBackpack` and `CloseBackpack` all exist in the sourced
-- `ContainerFrame.lua` too, and benilla's differ for one reason, not three: **0561, the
-- director's call that a window which opens your bags opens EVERY equipped bag**, not just
-- the backpack. The vendor/bank/mail/trade windows call `OpenBackpack` from their OnShow so
-- your bags come up with them and you can sell or deposit from one; the reference's own
-- body touches bag 0 alone. `CloseBackpack` is its inverse and has to match it, and
-- `CloseAllBags` is the same family's unconditional form.
--
-- **The `Benilla*` names, and the publish under them, are load ORDER** (1751). Order
-- decides every collision and the manifest is the order: this file is entry 7 and the
-- reference's container file is entry 46, so a plain `function OpenBackpack()` here is
-- overwritten before the player ever sees a vendor. The bodies stay here because every
-- window that calls them is loaded by harnesses with no bag stack at all, and a nil
-- `OpenBackpack` in a merchant test is a nil for no reason; `ContainerFrameAdapters.xml`
-- (entry 49, after the reference's) is what puts these three back on the reference's names.
--
-- What CHANGED with the swap is only the plumbing: these used to walk five benilla windows
-- by name and stash `wasOpen` on each frame. The windows are the reference's recycled
-- `ContainerFrame1..12` now — frame N is a different bag every time it opens — so the
-- was-open memory is keyed by BAG ID, and every open and close goes through the
-- reference's own `OpenBag`/`CloseBag`/`IsBagOpen` rather than a raw Show/Hide. That buys
-- the reference's own guards for free (OpenBag's CanOpenPanels + NotWhileDeadError,
-- ToggleBag's `size > 0`) and means an addon replacing those verbs is honoured here too.
BENILLA_BAG_WAS_OPEN = {}
-- The UNCONDITIONAL close, called by ShowUIPanel's native-center branch above: a centre
-- frame takes the whole screen, so nothing is being "restored afterwards" the way a vendor
-- window restores the bags it opened, and the was-open memory is deliberately ignored.
-- Range 0..NUM_CONTAINER_FRAMES rather than the old five names — the reference's own
-- CloseAllBags covers the bank bags too (l.702-707), and now so does this.
function BenillaCloseAllBags()
for bagId = 0, (NUM_CONTAINER_FRAMES or -1) do
CloseBag(bagId)
end
end
-- OpenBackpack / CloseBackpack — the real ContainerFrame.lua globals (l.187-217) the
-- vendor/bank/mail/trade windows call from their OnShow/OnHide so your bags open with them
-- (playing igBackPackOpen/Close alongside the window's own panel sound — the two "go
-- together"), and you can sell/deposit from the open bag. Homed here in benilla's panel-glue
-- file (rather than the bag's own, as in the real client) so every window that opens can call
-- them regardless of FrameXML load order — the windows are looked up by name. The real fn
-- tracks `backpackWasOpen` so a bag you already had open is LEFT open on the window's close;
-- benilla stores that on each frame (`wasOpen`). benilla DIVERGES from the ref here on
-- purpose: the real OpenBackpack touches only the backpack, but ours opens EVERY equipped
-- bag with the window (director's call — decision 0561; its stated reason was matching the
-- 'B' all-bags toggle, which 1494 split apart — the call itself stands on its own),
-- so the names keep the ref's caller contract while the body loops the five windows. Every
-- step is guarded, so a surface without the bag file — a headless panel test — no-ops
-- rather than erroring.
function OpenBackpack()
for bagId = 0, 4 do
-- The backpack (bag 0) always exists; an equipped-bag window only when a bag is in
-- the slot (the same existence test as BagFrame.xml's OpenAllBags).
local exists = bagId == 0
or (GetContainerNumSlots and GetContainerNumSlots(bagId) > 0)
local bag = exists and getglobal(BAG_WINDOWS[bagId + 1])
if bag then
if bag:IsShown() then
bag.wasOpen = 1 -- already open: leave it, and don't close it on window hide
-- OpenBackpack — 0561's all-bags form. A bag already open is REMEMBERED and left alone, so
-- the paired CloseBackpack does not close a window the player opened themselves; that
-- memory is the reference's own `backpackWasOpen`, one entry per bag instead of one flag.
function BenillaOpenBackpack()
for bagId = 0, (NUM_BAG_FRAMES or -1) do
-- The backpack always exists; an equipped-bag window only when a bag is in the
-- slot — `OpenBag` makes the same test itself, but the memory has to agree with it.
if bagId == 0 or GetContainerNumSlots(bagId) > 0 then
if IsBagOpen(bagId) then
BENILLA_BAG_WAS_OPEN[bagId] = 1
else
bag.wasOpen = nil
BenillaBagWindow_Repaint(bag)
bag:Show() -- OnShow → igBackPackOpen
BENILLA_BAG_WAS_OPEN[bagId] = nil
OpenBag(bagId)
end
end
end
end
function CloseBackpack()
for _, name in ipairs(BAG_WINDOWS) do
local bag = getglobal(name)
if bag and bag:IsShown() and not bag.wasOpen then
bag:Hide() -- OnHide → igBackPackClose
function BenillaCloseBackpack()
for bagId = 0, (NUM_BAG_FRAMES or -1) do
if not BENILLA_BAG_WAS_OPEN[bagId] then
CloseBag(bagId)
end
end
end
-- The publish, for every surface that never loads the reference's container file — a
-- harness, and a machine with no client data. Where it IS loaded, these three lose to it
-- and `ContainerFrameAdapters.xml` wins them back.
OpenBackpack = BenillaOpenBackpack
CloseBackpack = BenillaCloseBackpack
CloseAllBags = BenillaCloseAllBags
-- ToggleGameMenu(clicked) — UIParent.lua l.1465-1497, the ESCAPE binding's action
-- (TOGGLEGAMEMENU), run by the host (crates/benilla ui_script/input.rs) when no EditBox
-- consumed the key, and by the Main Menu micro button with `clicked = 1`. It was
@@ -1753,6 +1789,92 @@
return nil
end
-- tDeleteItem(table, item) — ref UIParent.lua l.1376-1390, the reference's own array
-- delete: it removes EVERY occurrence, closing the hole each time, and it is published
-- surface (the pulse kit below is one caller; addons written against 1.12 are the rest).
function tDeleteItem(t, item)
local index = 1
while t[index] do
if item == t[index] then
table.remove(t, index)
else
index = index + 1
end
end
end
-- ── The button-pulse kit — ref UIParent.lua l.9 + l.1334-1373, transcribed ──────────────
-- A button flashes its highlight on and off for a while: the reference uses it for the
-- keyring button when the player picks up their first key (MainMenuBar.xml l.335), for the
-- micro buttons on a new talent point (MainMenuBarMicroButtons.lua l.124) and for the
-- tutorial frame's advance button (TutorialFrame.lua l.72).
--
-- **Why it is here now.** Decision 0304 scoped pulses out while nothing called them.
-- benilla now runs the reference's own `ContainerFrame.lua` off the player's chain (1751),
-- and `ToggleKeyRing` closes with `SetButtonPulse(KeyRingButton, 0, 1)` — a *stop*, the
-- duration-0 form — on every keyring open. A nil global there is a raise on a window the
-- player is opening, and the honest fix is the reference's mechanism, not a guard at the
-- call site: it is thirty lines and it makes the other three call sites work the day they
-- arrive.
--
-- Verbatim but for the tick source: the reference hangs ButtonPulse_OnUpdate on UIParent's
-- OnUpdate (UIParent.xml l.16-22), which walks an empty list every frame forever. Ours is
-- BenillaPulseDriver below — the same shape the fade kit already uses (and the same reason:
-- the engine dispatches OnUpdate to visible frames only, so a driver that hides itself on
-- an empty list costs nothing while idle, and SetButtonPulse, the single inserter, shows it
-- again).
PULSEBUTTONS = {}
function SetButtonPulse(button, duration, pulseRate)
-- The reference really does write pulseDuration twice, the first time from pulseRate —
-- its own comment on the next line ("pulseRate is actually seconds per pulse state")
-- is why: pulseDuration counts down one HALF-cycle and is reloaded from pulseRate.
button.pulseDuration = pulseRate
button.pulseTimeLeft = duration
button.pulseRate = pulseRate
button.pulseOn = 0
tinsert(PULSEBUTTONS, button)
BenillaPulseDriver:Show()
end
function ButtonPulse_OnUpdate(elapsed)
local index = 1
while PULSEBUTTONS[index] do
local button = PULSEBUTTONS[index]
if button.pulseTimeLeft > 0 then
if button.pulseDuration < 0 then
if button.pulseOn == 1 then
button:UnlockHighlight()
button.pulseOn = 0
else
button:LockHighlight()
button.pulseOn = 1
end
button.pulseDuration = button.pulseRate
end
button.pulseDuration = button.pulseDuration - elapsed
button.pulseTimeLeft = button.pulseTimeLeft - elapsed
index = index + 1
else
-- The duration-0 case, which is how `ToggleKeyRing` says "stop": the button is
-- unhighlighted and dropped on the very first tick after the call.
button:UnlockHighlight()
button.pulseOn = 0
tDeleteItem(PULSEBUTTONS, button)
-- No index bump — tDeleteItem closed the hole, so the next entry is here now.
-- (The reference walks with `for index, button in PULSEBUTTONS` and deletes
-- mid-walk, which skips the entry after each removal; this is the same walk
-- FADEFRAMES already spells out longhand for the same reason.)
end
end
end
function ButtonPulse_StopPulse(button)
button:UnlockHighlight()
button.pulseOn = 0
tDeleteItem(PULSEBUTTONS, button)
end
-- ── The tab kit (ref UIPanelTemplates.lua l.1-29/90-133, transcribed VERBATIM shape) ──
-- One selected tab per group: the frame carries numTabs/selectedTab, tabs are named
-- <frame>Tab1..N (CharacterFrameTabButtonTemplate instances — the template lives in this
@@ -1979,6 +2101,17 @@
<Scripts><OnUpdate>if not FADEFRAMES[1] then this:Hide() return end UIFrameFadeUpdate(arg1)</OnUpdate></Scripts>
</Frame>
<!-- The pulse kit's tick source, the fade driver's twin — same reason, same shape: the ref
hangs ButtonPulse_OnUpdate on UIParent's OnUpdate (UIParent.xml l.16-22) and walks an
empty list every frame; this runs only while PULSEBUTTONS has entries, and SetButtonPulse
is the single inserter that shows it again. Hidden at load: nothing pulses until something
asks. -->
<Frame name="BenillaPulseDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts><OnUpdate>if not PULSEBUTTONS[1] then this:Hide() return end ButtonPulse_OnUpdate(arg1)</OnUpdate></Scripts>
</Frame>
<!--
The window tab button (ref CharacterFrameTabButtonTemplate — Interface\FrameXML\
CharacterFrameTemplates.xml in patch.MPQ, NOT the base FrameXML.toc set; extracted via
+20 -3
View File
@@ -25,9 +25,11 @@
reference. `/pvp` is the toggle the client has.
- DEFERRED ENTRIES: rows whose backing system doesn't exist yet are HIDDEN outright (the
BENILLA_UNITPOPUP_DEFERRED set below) rather than shown-dead: TARGET (the friends
list), RESET_INSTANCES (instances). TRADE came out
list). TRADE came out
with decision 0592 P1, DUEL with 0633, INSPECT with 0631, GUILD_PROMOTE/GUILD_LEAVE with
the guild arc (1257 — their hide predicates and click rows are below), FOLLOW with 0890's
the guild arc (1257 — their hide predicates and click rows are below), RESET_INSTANCES with
the instance-lockout arc (1748 — its predicate is the reference's own
`CanShowResetInstances()`, whose four terms are now real), FOLLOW with 0890's
follow-up — and
that last entry's stated reason ("its CMSG family is its own slice") was simply WRONG: 0890
verified auto-follow sends nothing at all, it is pure client-side movement, so the row was
@@ -228,7 +230,6 @@
-- The DEFERRED set (header): entries hidden until their backing arc lands.
BENILLA_UNITPOPUP_DEFERRED = {
TARGET = 1,
RESET_INSTANCES = 1,
};
-- ── Shared helpers ─────────────────────────────────────────────────────────────────
@@ -347,6 +348,15 @@
HIDE_ROW["LOOT_METHOD"] = hideOutsideAParty;
HIDE_ROW["LOOT_THRESHOLD"] = hideOutsideAParty;
-- "Reset all instances", ref UnitPopup.lua l.377-380 (decision 1748) — TWO conditions,
-- and the second is not `hideUnlessLeading`: a SOLO player may reset (there is no party
-- to hold anyone inside), a party LEADER may, and a party member may not. The engine half
-- is the interesting one: `CanShowResetInstances()` is true only while we hold a
-- permanent bind, are not standing in a 5-man, and left one within the last 25 hours.
HIDE_ROW["RESET_INSTANCES"] = function(menu, party)
return not CanShowResetInstances() or (party.inParty == 1 and party.isLeader == 0);
end
local function hideUnlessLeading(menu, party)
return party.inParty == 0 or party.isLeader == 0;
end
@@ -713,6 +723,13 @@
LeaveParty();
end
-- Resetting goes behind a confirm (ref UnitPopup_OnClick l.620-621): the send is
-- irreversible and takes every dungeon we own at once. The dialog is registered with the
-- rest of the instance arc, in RaidFrame.xml.
CLICK_ROW["RESET_INSTANCES"] = function()
StaticPopup_Show("CONFIRM_RESET_INSTANCES");
end
-- Picking a loot method sets it and writes the choice back onto the parent row — list 1,
-- button 2, the fixed place LOOT_METHOD sits in the SELF menu. Master loot is the one
-- method that also names who holds it.
+12 -2
View File
@@ -474,8 +474,18 @@
**The kind still differs and is not papered over**: `GetObjectType()` answers "Frame"
here and "Texture" in the reference. Every corpus caller only Shows/Hides it, which both
kinds answer identically; an addon that asked its type would see the deviation, and that
is the honest cost of the structural choice above. -->
<Frame name="BlackoutWorld" frameStrata="FULLSCREEN" hidden="true" enableMouse="true" parent="UIParent">
is the honest cost of the structural choice above.
**And it carries NO `parent=`, for the reason WorldMapFrame does not** (see that frame's
note below). The reference's blackout is a region *inside* WorldMapFrame, so it belongs to
the very panel `SetFullScreenFrame` shows and cannot be reached by the `UIParent:Hide()`
that showing it performs. Ours is a separate frame — the structural deviation above — and
while that frame hung off `UIParent` it inherited the opposite property: opening the map
hid the blackout, and the 3D world came back through the margins beside the 4:3 sheet
(director-reported, decision 1757). Top-level restores the reference's own relationship;
the TOPLEFT/BOTTOMRIGHT pair then pins to the screen root, which is the same rect
`parent="UIParent"` was resolving to. -->
<Frame name="BlackoutWorld" frameStrata="FULLSCREEN" hidden="true" enableMouse="true">
<Anchors>
<Anchor point="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT"/>
+66 -8
View File
@@ -13,6 +13,25 @@
# The FIRST entry is the font-object registry, and the loader splits there: it alone loads at
# startup, ahead of the glue screens that share our one glyph atlas, and everything after it
# materializes on entering the world (decision 1051).
#
# ── TWO STORES, ONE LIST (decision 1751) ──────────────────────────────────────────────────────
# A **bare filename** is a file we ship, from `assets/ui` (compiled into the binary, 1175).
# A **path** — `Interface\FrameXML\ContainerFrame.xml` — is the reference's OWN file, executed off
# the player's installed patch chain. Nothing of Blizzard's is copied here; the player runs the
# file they already own, and it cannot drift from itself.
#
# That is the end state for this interface: the stock 1.12 FrameXML, off the player's chain, and
# `assets/ui` is scaffolding that retires window by window. Migrating one is one line here changing
# from ours to theirs, plus the deletion of our copy — which is why the two stores share ONE
# ordered list instead of the reference getting a private "load everything first" pass. It could
# not have that pass: stock `ContainerFrame.xml` inherits `ItemButtonTemplate`,
# `CooldownFrameTemplate`, `SmallMoneyFrameTemplate` and `UIPanelCloseButton`, so it has to sit
# after the files that declare them, exactly like any other entry.
#
# Order still decides every collision, in both directions. A reference file sourced ABOVE ours has
# its colliding globals overwritten by ours (that is why `PaperDollFrame.lua` is where it is); a
# reference file sourced AT the position of the window it replaces owns its names outright, which
# is what migrating a window means. `ui_script::reference_ui` is the rule; this list is the order.
## Interface: 11200
## Title: benilla
@@ -20,6 +39,26 @@
# The named virtual Font objects (decision 0084) — must load first so any FontString's
# `inherits="GameFontNormal"` resolves against a registered object.
Fonts.xml
# ── A reference file sourced ABOVE ours, so OUR bodies win every collision ─────────────────────
# This one is not a migrated window: it is here for one family of frame-agnostic globals that
# addons call by name, while the window that file also drives is still ours. So it loads first and
# our later files overwrite the names we own. (A migrated window is the other case, and sits at its
# window's own position — `Interface\FrameXML\ContainerFrame.xml` below is the first of them.)
#
# PaperDollFrame.lua earns its place for ONE family: `PaperDollItemSlotButton_OnLoad`/`_OnEvent`/
# `_OnClick`/`_Update`/`_UpdateLock`/`_OnEnter`/`_OnUpdate` — frame-agnostic in the same sense,
# acting on `this`/`this:GetID()` through live APIs and never on a named reference window.
# `PaperDollItemSlotButtonTemplate` and `BagSlotButtonTemplate` are declared over them
# (ItemSlotButtonTemplates.xml), and pfUI builds its bag bar from the latter.
#
# Its top level is four constants (NUM_RESISTANCE_TYPES, NUM_STATS, NUM_SHOPPING_TOOLTIPS,
# ATTACK_POWER_MAGIC_NUMBER) — nothing touches a frame at load, so sourcing it cannot raise. 18 of
# its 29 functions collide with ours (the PaperDollFrame_Set* stat family, the two damage-frame
# hovers, PaperDollFormatStat, PaperDollStatTooltip) and this position settles all of them the
# documented way: CharacterFrame.xml loads far below and its bodies win. Not one of the seven
# names we are here for is in that overlap, and our own doll slots keep calling
# `BenillaPaperDollSlot_*`, untouched.
Interface\FrameXML\PaperDollFrame.lua
# The reference's own second file, in its own position: TEXT/message/_ERRORMESSAGE, the
# ScriptErrors dialog, and the DialogBoxFrame + DialogButton*Texture kit it is built from.
# Straight after Fonts.xml because those are the only things it needs (GameFontRedLarge,
@@ -244,16 +283,35 @@ BuffFrame.xml
# MinimapCluster (its anchor) — loaded above.
DurabilityFrame.xml
ErrorsFrame.xml
# ══ THE BAG WINDOWS — the reference's own, off the player's chain (decision 1751, window 1) ══
# `ContainerFrame1..12`, `ContainerFrameTemplate`, `ContainerFrameItemButtonTemplate` and the whole
# `ToggleBag`/`ToggleBackpack`/`OpenAllBags`/`IsBagOpen`/`ToggleKeyRing` verb family — the real
# 1.12 file, running as the file, with `ContainerFrame.lua` arriving through its own
# `<Script file=>`. Our `BagFrame.xml` used to build five windows here; it is the bag BAR only now,
# and the bank's six popouts (BankFrame.xml) became ContainerFrames at ids 5..10, which is what the
# reference does.
#
# THE POSITION IS THE REQUIREMENT, and it is why 1751 moved sourcing into this manifest at all:
# this file's `inherits=` are resolved at LOAD and reach four templates above it —
# `ItemButtonTemplate`, `SmallMoneyFrameTemplate` (MoneyFrame.xml), `CooldownFrameTemplate`
# (Cooldown.xml), `UIPanelCloseButton` (UIPanelTemplates.xml) — plus `parent="UIParent"`. It
# cannot load "first" the way a Lua-only sourcing had to.
Interface\FrameXML\ContainerFrame.xml
# The bag BAR: MainMenuBarBackpackButton / CharacterBag0..3Slot / KeyRingButton and the item-push
# card — this client's stand-in for the reference's own MainMenuBarBagButtons.xml, which cannot be
# sourced until `PutItemInBag`/`PickupBagFromSlot` are bound (the file says so at the site). After
# ContainerFrame.xml for legibility, not necessity: every verb it calls resolves at click time. It
# also owns `KEYRING_CONTAINER`, which is where the reference puts it and which the file above
# reads — again a global resolved at call time, so the order does not bind.
BagFrame.xml
# The stack-split spinner (decision 0216 §6/slice 2): loaded beside BagFrame, whose slot
# click handler (`BenillaBagSlot_OnClick`) calls `OpenStackSplitFrame`. Order between the
# two doesn't matter for correctness (Lua globals resolve at call time, and neither file's
# XML `inherits=` reaches into the other's templates) — kept adjacent for legibility.
# The stack-split spinner (decision 0216 §6/slice 2): loaded beside the bag windows, whose slot
# click handler (the reference's `ContainerFrameItemButton_OnClick`) calls `OpenStackSplitFrame`.
# Order between the two doesn't matter for correctness (Lua globals resolve at call time, and
# neither file's XML `inherits=` reaches into the other's templates) — adjacent for legibility.
StackSplit.xml
# Our ADAPTERS over the reference's own `Interface\\FrameXML\\ContainerFrame.lua`, which this
# client EXECUTES off the patch chain rather than transcribing (`ui_script::reference_ui`) — see
# that file's header for the rule. Must load AFTER the sourced reference file (every builtin does)
# and after BagFrame/StackSplit, whose globals it leans on.
# Our ADAPTERS over the reference's own container files — one wrapper, for the keyring tooltip
# detour benilla answers differently (0765). Must load AFTER them, since it captures at load the
# body it wraps; its own header says what it stopped needing to do when the XML became sourceable.
ContainerFrameAdapters.xml
# The character window (decision 0208 phase 1a): the C-key paper doll. Loaded after BagFrame
# (needs nothing from it, but keeps player-state windows grouped) and before the NPC session
@@ -4486,12 +4486,50 @@ mod dependency_tests {
/// Without this, a probe arm that is simply broken would charge its own failure to all 218
/// addons — the mis-attribution 1209 exists about, arriving through the instrument instead of
/// through a claim.
///
/// **"Silent" has to mean "ran and said nothing", never "had nothing to run"** — the same
/// distinction the hover-arm test above is built around ("found nothing" vs "ran nothing"),
/// and decision 1751 put this test one step from the wrong side of it: `ToggleBackpack`, the
/// probe's first and most-hooked entry point, is defined in the reference's own
/// `ContainerFrame.lua` now, which `load_default_ui` reads off the PLAYER'S INSTALL. On a
/// machine without one the global is simply absent, `drive_ui_probe`'s `try` guard skips it,
/// and an empty error list would mean the probe drove nothing at all. So the entry points are
/// asserted to EXIST before the silence is asserted, and the test gates on client data like
/// every other reader of the chain.
///
/// What is deliberately NOT asserted here is `load_default_ui`'s own failure list. It is not
/// empty on a seated session today — `TargetFrame`'s OnLoad reaches
/// `BenillaTargetAuras_Update` (UnitFrames.xml:956), which indexes `TargetofTargetFrame`
/// ~1150 lines before that frame is declared (UnitFrames.xml:2103), so a seated TARGET raises
/// there at load. That is a real bug in that file and a separate one from anything this test
/// measures; pinning it here would make the probe's gate hostage to it. Recorded rather than
/// worked around.
#[test]
fn the_ui_probe_is_silent_on_a_clean_vm() {
let _data = benilla_formats::wow_data_or_skip!();
let mut script = UiScript::new().unwrap();
script.set_screen_size(1024.0, 768.0);
seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
// The probe's entry points are here to be driven. Each is `try`-guarded inside
// `drive_ui_probe`, so a missing one is silent — which would make the assertion below
// pass by having done nothing.
for entry in [
"ToggleBackpack",
"UnitFrame_OnEnter",
"UnitFrame_OnLeave",
"ActionButton_Update",
"GameTooltip_SetDefaultAnchor",
] {
assert_eq!(
script
.eval::<String>(&format!("return type({entry})"))
.unwrap(),
"function",
"{entry} is not in the VM, so the probe would skip it and this test would pass \
without driving anything"
);
}
let errs = drive_ui_probe(&mut script);
assert!(
+29 -29
View File
@@ -1543,32 +1543,33 @@ mod tests {
/// globals directly, so they could not see that BOTH chords were registered on one command;
/// the registry tests read the table, so they could not see what the bodies do. Only pressing
/// the key joins the two.
///
/// **The windows on the far end are the reference's own now** (1751). `BenillaBagFrame`/
/// `BenillaBagFrame2` were five static frames our `BagFrame.xml` declared, so this test could
/// name them and load six files by hand; the live windows are `ContainerFrame1..12`, generated
/// on demand off the player's chain and RECYCLED across containers, so the question is
/// `IsBagOpen(id)` and never a frame name. Two consequences, both deliberate:
/// * the interface is loaded with the app's own [`crate::ui_script::load_default_ui`] rather
/// than a hand-listed six, because the chain half of the manifest is what makes the bag
/// windows exist at all and a private six-file reader cannot express it — which also makes
/// this test the load order the player gets;
/// * it needs client data, like every test that touches a chain entry.
#[test]
fn b_opens_the_backpack_and_shift_b_opens_every_bag() {
let _data = benilla_formats::wow_data_or_skip!();
let mut script = UiScript::new().expect("VM");
script.register_bindings(&registry_commands());
script.set_screen_size(1024.0, 768.0);
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"MerchantFrame.xml",
"Cooldown.xml",
"BagFrame.xml",
] {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.expect("shipped asset");
let doc = benilla_ui::framexml::parse(&text).expect("parse");
let report = benilla_ui::loader::load(&script, &doc, &|_| None);
assert!(report.errors.is_empty(), "{file}: {:?}", report.errors);
}
let failures = crate::ui_script::load_default_ui(&script);
assert!(
failures.is_empty(),
"default UI failed to load: {failures:?}"
);
script.set_money(0);
// The backpack, plus one equipped bag in slot 2 — so "all bags" is two windows and
// "the backpack alone" is visibly different from it.
// "the backpack alone" is visibly different from it. Fed BEFORE any key: the reference
// builds a window only for a container that exists (`OpenBag`'s `size > 0` gate), where
// our own `BenillaBagFrame*` were static frames that showed regardless.
script.set_container(
0,
Some(benilla_ui::script::ContainerState {
@@ -1586,10 +1587,12 @@ mod tests {
}),
);
let mut app = vm_harness(script);
let shown = |app: &App, name: &str| {
// `IsBagOpen(id)` — the reference's own scan over `ContainerFrame1..12`, and the only
// honest way to ask: which window a bag lands in depends on what else is open.
let open = |app: &App, id: i64| {
app.world()
.non_send_resource::<UiScript>()
.eval::<bool>(&format!("return {name}:IsShown()"))
.eval::<bool>(&format!("return IsBagOpen({id}) ~= nil"))
.expect("eval")
};
@@ -1598,9 +1601,9 @@ mod tests {
app.update();
release_key(&mut app, KeyCode::KeyB);
app.update();
assert!(shown(&app, "BenillaBagFrame"), "B opens the backpack");
assert!(open(&app, 0), "B opens the backpack");
assert!(
!shown(&app, "BenillaBagFrame2"),
!open(&app, 2),
"B does NOT open the equipped bag — this is the bug 1494 fixes"
);
@@ -1609,7 +1612,7 @@ mod tests {
app.update();
release_key(&mut app, KeyCode::KeyB);
app.update();
assert!(!shown(&app, "BenillaBagFrame"), "B again shuts it");
assert!(!open(&app, 0), "B again shuts it");
// SHIFT-B — OPENALLBAGS' shipped default, a DIFFERENT command with a different body.
press_key(&mut app, KeyCode::ShiftLeft);
@@ -1617,10 +1620,7 @@ mod tests {
app.update();
release_key(&mut app, KeyCode::KeyB);
app.update();
assert!(
shown(&app, "BenillaBagFrame") && shown(&app, "BenillaBagFrame2"),
"SHIFT-B opens every bag"
);
assert!(open(&app, 0) && open(&app, 2), "SHIFT-B opens every bag");
// …and closes them again, the count now reading all-open.
press_key(&mut app, KeyCode::KeyB);
@@ -1629,7 +1629,7 @@ mod tests {
release_key(&mut app, KeyCode::ShiftLeft);
app.update();
assert!(
!shown(&app, "BenillaBagFrame") && !shown(&app, "BenillaBagFrame2"),
!open(&app, 0) && !open(&app, 2),
"SHIFT-B again shuts them all"
);
}
+142 -41
View File
@@ -128,6 +128,7 @@ pub(crate) mod cmd {
pub(crate) const SIT_OR_STAND: Cmd = by_name("SITORSTAND");
pub(crate) const TOGGLE_SHEATH: Cmd = by_name("TOGGLESHEATH");
pub(crate) const TOGGLE_AUTORUN: Cmd = by_name("TOGGLEAUTORUN");
pub(crate) const TOGGLE_RUN: Cmd = by_name("TOGGLERUN");
pub(crate) const OPEN_CHAT: Cmd = by_name("OPENCHAT");
pub(crate) const OPEN_CHAT_SLASH: Cmd = by_name("OPENCHATSLASH");
pub(crate) const REPLY: Cmd = by_name("REPLY");
@@ -175,6 +176,13 @@ const TABLE: &[Spec] = &[
Some("NUMLOCK"),
Some("BUTTON4")
),
spec!(
"TOGGLERUN",
MOVEMENT,
Kind::Host,
Some("NUMPADDIVIDE"),
None
),
spec!(
"FOLLOWTARGET",
MOVEMENT,
@@ -1767,11 +1775,6 @@ pub(crate) static ABSENT: &[Absent] = &[
["PitchDownStart", "PitchDownStop"],
"no keyboard pitch — see PITCHUP"
),
absent!(
"TOGGLERUN",
["ToggleRun"],
"no walk gait: neither MSG_MOVE_SET_WALK_MODE/SET_RUN_MODE nor a walk animation exists"
),
// ── Chat ────────────────────────────────────────────────────────────────────────────
absent!(
"COMBATLOGPAGEUP",
@@ -2160,9 +2163,20 @@ mod tests {
}
/// **The scanner's own falsifier.** [`the_absent_commands_are_still_absent`] is only a gate
/// while `lua_globals_defined` really sees both kinds of definition; a scanner that quietly
/// while `lua_globals_defined` really sees every kind of definition; a scanner that quietly
/// returned nothing would pass every row forever, which is the exact failure it exists to
/// end. So: one global of each kind that is certainly defined, and one that certainly is not.
///
/// **There are THREE kinds since decision 1751, and this test found the third the hard way.**
/// `ToggleKeyRing` sat in the FrameXML row below and started failing the day the bag windows
/// migrated: it is defined in `ContainerFrame.lua`, which this client no longer ships — it
/// EXECUTES it off the player's own patch chain. The honest repair was not to swap the name
/// for one still in `assets/ui`; it was that the scan had gone blind to a whole store, and
/// blind to it in exactly the direction that matters (1751 §4 makes "ship the stock files off
/// the chain" the default for a NEW window, so the global that retires an ABSENT row —
/// `ToggleBattlefieldMinimap`, say — will appear in neither Rust nor `assets/ui`). So
/// `lua_globals_defined` reads the chain half of the manifest too, and `ToggleKeyRing` stays
/// here as the witness for it.
#[test]
fn the_global_scanner_sees_both_kinds_of_definition() {
let defined = lua_globals_defined();
@@ -2172,12 +2186,26 @@ mod tests {
"the Rust scan missed the host registration `{host}`"
);
}
for framexml in ["ToggleKeyRing", "ToggleCharacter", "ChangeActionBarPage"] {
// Each is defined ONLY in `assets/ui` — the reference's own homes for them
// (CharacterFrame.lua, ActionBarFrame.lua, UIParent.lua) are not among the files
// `benilla.toc` sources, so the chain scan below cannot cover for a broken one.
for shipped in ["ToggleCharacter", "ChangeActionBarPage", "ShowUIPanel"] {
assert!(
defined.contains(framexml),
"the FrameXML scan missed `{framexml}`"
defined.contains(shipped),
"the assets/ui scan missed `{shipped}`"
);
}
// The chain half needs a client, like every other reader of install content — and the
// two rows above still run without one, so the test keeps its teeth either way.
if benilla_formats::wow_data().is_some() {
for sourced in ["ToggleKeyRing", "ToggleBag", "OpenAllBags"] {
assert!(
defined.contains(sourced),
"the chain scan missed `{sourced}` — it is defined in the reference's own \
ContainerFrame.lua, which benilla.toc sources off the player's install"
);
}
}
assert!(
!defined.contains("BenillaNoSuchGlobalExists"),
"the scanner claims to define a name nobody wrote"
@@ -2197,13 +2225,25 @@ mod tests {
Some(benilla_ui::bindings_xml::parse(&xml).expect("Blizzard's own file parses"))
}
/// Every Lua global this client defines, read out of its own sources: the host registrations
/// (`g.set("Name", …)` anywhere in the workspace) and the FrameXML port's own function
/// definitions (`function Name(` / `Name = function` inside the `<Script>` blocks).
/// Every Lua global this client defines, read out of its own sources — **all three stores**:
/// the host registrations (`g.set("Name", …)` anywhere in the workspace), our own FrameXML
/// port's function definitions (`function Name(` / `Name = function` inside the `<Script>`
/// blocks of `assets/ui`), and the reference's own FrameXML that decision 1751 executes off
/// the player's installed patch chain.
///
/// The third store is not decoration. Until the bag windows migrated, "the FrameXML this
/// client runs" and "the files in `assets/ui`" were the same set; they are not any more, and
/// 1751 §4 makes the divergence grow — a NEW window ships as the stock files off the chain
/// plus the engine verbs they call, so the global that would retire an [`ABSENT`] row can land
/// without a line changing in either half this scan used to read. `benilla.toc` is the one
/// ordered list of both stores (a manifest entry carrying a path is the reference's file), so
/// it is what the chain half walks. No install ⇒ no chain half, the standard degradation.
///
/// Deliberately syntactic and deliberately generous — a false "defined" fails the gate loudly
/// (it asks for a row that turns out not to work), while a false "missing" would let a landed
/// mechanism stay silent, which is the failure this whole gate exists to end.
/// mechanism stay silent, which is the failure this whole gate exists to end. A reference
/// file's `local function name()` is therefore counted as well; that is the generous direction
/// on purpose.
fn lua_globals_defined() -> std::collections::HashSet<String> {
fn walk(dir: &std::path::Path, ext: &str, out: &mut Vec<std::path::PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
@@ -2222,6 +2262,59 @@ mod tests {
}
}
fn push(names: &mut std::collections::HashSet<String>, s: &str) {
if s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
{
names.insert(s.to_string());
}
}
/// One Lua-carrying text's own definitions — a `<Script>` block of ours or a reference
/// `.lua` off the chain, the same two shapes either way.
fn scan_lua(text: &str, names: &mut std::collections::HashSet<String>) {
let mut rest = text;
while let Some(i) = rest.find("function ") {
rest = &rest[i + "function ".len()..];
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
if rest[end..].trim_start().starts_with('(') {
push(names, &rest[..end]);
}
}
for line in text.lines() {
let line = line.trim_start();
if let Some((lhs, rhs)) = line.split_once('=') {
let lhs = lhs.trim();
if rhs.trim_start().starts_with("function")
&& lhs.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
{
push(names, lhs);
}
}
}
}
/// The `.lua` files an XML document pulls in through `<Script file="…"/>`. Only a script
/// reference ends in `.lua` — a `<Texture file=>` names art — so the extension is the
/// whole test, and the paths come back relative to the document's own directory, which is
/// how the loader resolves them (1186).
fn script_files(text: &str) -> Vec<String> {
let mut out = Vec::new();
let mut rest = text;
while let Some(i) = rest.find("file=\"") {
rest = &rest[i + "file=\"".len()..];
let Some(end) = rest.find('"') else { break };
if rest[..end].to_ascii_lowercase().ends_with(".lua") {
out.push(rest[..end].to_string());
}
rest = &rest[end..];
}
out
}
// `crates/benilla-app/` → the workspace root.
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
@@ -2230,14 +2323,6 @@ mod tests {
.to_path_buf();
let mut names = std::collections::HashSet::new();
let mut push = |s: &str| {
if s.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
{
names.insert(s.to_string());
}
};
let mut rust = Vec::new();
walk(&root.join("crates"), "rs", &mut rust);
@@ -2253,37 +2338,53 @@ mod tests {
let head = rest.trim_start();
if let Some(body) = head.strip_prefix('"') {
if let Some(end) = body.find('"') {
push(&body[..end]);
push(&mut names, &body[..end]);
}
}
}
}
let ui = root.join("crates/benilla-app/assets/ui");
let mut xml = Vec::new();
walk(&root.join("crates/benilla-app/assets/ui"), "xml", &mut xml);
walk(&ui, "xml", &mut xml);
for path in xml {
let Ok(text) = std::fs::read_to_string(&path) else {
continue;
};
let mut rest = text.as_str();
while let Some(i) = rest.find("function ") {
rest = &rest[i + "function ".len()..];
let end = rest
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.unwrap_or(rest.len());
if rest[end..].trim_start().starts_with('(') {
push(&rest[..end]);
}
scan_lua(&text, &mut names);
}
// The chain half of the manifest — the reference's own files, read the way the client
// reads them.
let Some(data) = benilla_formats::wow_data() else {
return names;
};
let Ok(mut chain) = benilla_formats::open_chain(&data) else {
return names;
};
let manifest =
std::fs::read_to_string(ui.join("benilla.toc")).expect("the manifest is committed");
for entry in manifest
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
// The manifest's own rule for which store an entry names
// (`ui_script::reference_ui::is_chain_entry`): our tree is flat, so a separator is a
// path and a path is the reference's. The bare names were walked above.
.filter(|l| l.contains('\\') || l.contains('/'))
{
let Ok(bytes) = chain.read_file(entry) else {
continue;
};
let text = String::from_utf8_lossy(&bytes).into_owned();
scan_lua(&text, &mut names);
if !entry.to_ascii_lowercase().ends_with(".xml") {
continue;
}
for line in text.lines() {
let line = line.trim_start();
if let Some((lhs, rhs)) = line.split_once('=') {
let lhs = lhs.trim();
if rhs.trim_start().starts_with("function")
&& lhs.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
{
push(lhs);
}
let dir = entry.rfind('\\').map_or("", |i| &entry[..=i]);
for script in script_files(&text) {
if let Ok(b) = chain.read_file(&format!("{dir}{script}")) {
scan_lua(&String::from_utf8_lossy(&b), &mut names);
}
}
}
@@ -50,6 +50,56 @@ fn walk_run_boundary_is_twice_walk_speed() {
assert_eq!(gait_candidates(&moving_forward(5.1), 2.5, None, None)[0], 5);
}
/// **The walk gait, composed end to end** (decision 1752) — the one assertion that says the
/// feature actually works rather than that each half does.
///
/// The selector never reads `MOVEFLAG_WALK_MODE`; it reads the *speed*. So the walk gait is real
/// only if the speed cascade and the gait cascade agree, and they agree only because the reference
/// chose `2 × walkSpeed` as the boundary and `min(walk, run)` as the walk speed — at any vanilla
/// speed set the walk lands strictly under its own boundary. Feed one into the other, with no
/// hand-picked number in between, and see which clip comes out.
#[test]
fn the_walk_bit_reaches_the_clip_through_the_speed_and_nothing_else() {
use crate::creature_anim::move_flags as f;
let speeds = benilla_protocol::MoveSpeeds {
walk: 2.5,
run: 7.0,
run_back: 4.5,
swim: 4.722,
swim_back: 2.5,
turn_rate: std::f32::consts::PI,
};
let gait = |flags: u32| {
let mv = MovementState {
speed: crate::net::current_speed(&speeds, flags),
flags,
..Default::default()
};
gait_candidates(&mv, speeds.walk, None, None)[0]
};
assert_eq!(gait(f::FORWARD), 5, "no walk bit: Run");
assert_eq!(gait(f::FORWARD | f::WALK_MODE), 4, "walk bit: Walk");
// Backwards is WalkBackwards(13) either way — the *speed* is what the walk bit changes there,
// and the clip's rate scaling is what makes it read as a walk rather than a fast reverse.
assert_eq!(gait(f::BACKWARD), 13);
assert_eq!(gait(f::BACKWARD | f::WALK_MODE), 13);
// …and a mount at 11.2 yd/s is not dragged under the boundary by anything above.
let mounted = benilla_protocol::MoveSpeeds {
run: 11.2,
..speeds
};
let mv = MovementState {
speed: crate::net::current_speed(&mounted, f::FORWARD),
flags: f::FORWARD,
..Default::default()
};
assert_eq!(
gait_candidates(&mv, mounted.walk, None, None)[0],
143,
"Sprint above 11"
);
}
#[test]
fn boundary_scales_with_the_units_own_walk_speed() {
assert_eq!(gait_candidates(&moving_forward(7.0), 4.0, None, None)[0], 4);
+7
View File
@@ -126,6 +126,7 @@ mod ui_guild;
mod ui_hide;
mod ui_honor;
mod ui_inspect;
mod ui_instance;
mod ui_item_text;
mod ui_items;
mod ui_layout;
@@ -206,6 +207,7 @@ use ui_follow::UiFollowPlugin;
use ui_gm_ticket::UiGmTicketPlugin;
use ui_gossip::UiGossipPlugin;
use ui_guild::UiGuildPlugin;
use ui_instance::UiInstancePlugin;
use ui_item_text::UiItemTextPlugin;
use ui_items::UiItemsPlugin;
use ui_layout::UiLayoutPlugin;
@@ -630,6 +632,11 @@ pub fn run(build: BuildId) -> AppExit {
// Auto-follow's UI seam: the popup's Follow row + `FollowUnit`/`FollowByName` inbound, and
// the AUTOFOLLOW_BEGIN/END pair that drives the centre-screen status line outbound.
.add_plugins(UiFollowPlugin)
// Instance/raid lockouts (decision 1748): the four CHAT_MSG_SYSTEM lines the client composes
// itself out of GlobalStrings, the last-dungeon/ownership bookkeeping behind
// `CanShowResetInstances()`, and the SELF menu's one send. Beside the binder family for the
// same feed/drain shape; it needs the map catalog, which is up long before Update runs.
.add_plugins(UiInstancePlugin)
// Leaving (decision 0674): the game menu's Logout/Exit Game — the request, the server's
// 20-second answer narrated as the CAMP/QUIT countdown, and the process exit.
.add_plugins(UiLogoutPlugin)
+157
View File
@@ -213,6 +213,51 @@ pub(crate) struct NetEntity {
#[derive(Component, Clone, Copy)]
pub(crate) struct UnitSpeeds(pub(crate) MoveSpeeds);
/// **`CMovement::GetCurrentSpeed 0x7c4c90`** — the one place a mover's move-flags become a
/// yards/second, for our own body and for every remote one. It is a *cascade*, and its ORDER is
/// the whole of it (VERIFIED bytes, wow-re `collision/scratch/airborne-steerability.md` §2.4 +
/// `object-layer/scratch/swim-mechanism.md` TU-H):
///
/// 1. `0x7c4c99 test dl,0xf` — **no direction bit at all → 0.0** (`[0x7ffd74]`), never a stale
/// speed. A standing mover is standing however its speed fields read.
/// 2. `0x7c4cd9 test edx,0x200000` — **SWIMMING first**, and its arm returns from inside: there
/// is no "swim-walk", so the walk bit below cannot reach a swimmer. Backward → the `min`.
/// 3. `0x7c4d11 test dh,0x1` — **WALK mode (`0x100`) next**, and this is the ordering that is easy
/// to get backwards: the walk arm `0x7c4d4d fld [ecx+0x88]; fld [ecx+0x8c]; fcompp` is taken
/// **before** the run arm's backward `min`, so a **walking backpedal is walk speed, not
/// run-back speed**. The arm is `min(MOVE_WALK, MOVE_RUN)` — not a plain select — so a
/// walk-speed buff above run speed is clamped exactly as the reference clamps it.
/// 4. `0x7c4d1d` — the run arm: backward → `min(MOVE_RUN_BACK, MOVE_RUN)`, else `MOVE_RUN`.
///
/// Both backward arms are a **min**, not a select: x87 `fcompp` + `test ah,0x41` + `jp` returns
/// the smaller (ties/NaN → the forward field). At vanilla values the min is always the back field,
/// so the clamp is only visible under a server that force-sets a back speed above its forward one.
///
/// A **zero** `walk` is the ctor state, not a rate (`0x7c48e8`–`0x7c4906` zeroes all six speed
/// fields and only the create block or a `SMSG_FORCE_WALK_SPEED_CHANGE` fills them), so a mover
/// whose speeds have not landed falls back to the run arm rather than freezing solid — the same
/// treatment `turn_rate` already gets in [`crate::player`]'s controller.
pub(crate) fn current_speed(s: &MoveSpeeds, flags: u32) -> f32 {
use crate::creature_anim::move_flags as f;
if flags & f::ANY_MOVE == 0 {
return 0.0;
}
if flags & f::SWIMMING != 0 {
return if flags & f::BACKWARD != 0 {
s.swim_back.min(s.swim)
} else {
s.swim
};
}
if flags & f::WALK_MODE != 0 && s.walk > 0.0 {
return s.walk.min(s.run);
}
if flags & f::BACKWARD != 0 {
return s.run_back.min(s.run);
}
s.run
}
/// A streamed object's descriptor field set (`UpdateFields`) — the ECS's mirror of the object's
/// server-side values array (decision 0061). Seeded from the create mask and [`ObjectFields::merge`]d
/// with each `Values` delta, so it accumulates the object's full descriptor state over its life.
@@ -654,6 +699,13 @@ pub(crate) enum MoveKind {
FallLand,
StartSwim,
StopSwim,
/// The walk/run gait flipped — the `TOGGLERUN` keybind's own packet. The reference sends it
/// from the toggle handler itself (`ToggleRun 0x513d50` → `0x60e080` → `0x617de0`, which
/// picks move-event kind `0xf`/`0xe` off the CURRENT walk bit and enqueues it), not from the
/// move-state broadcaster — so it goes out **standing still**, where the broadcaster's
/// locomotion-nibble gate (`0x61a99d test al,0xf`) would have swallowed it.
SetWalkMode,
SetRunMode,
SetFacing,
Heartbeat,
}
@@ -676,6 +728,8 @@ impl MoveKind {
MoveKind::FallLand => op::MSG_MOVE_FALL_LAND,
MoveKind::StartSwim => op::MSG_MOVE_START_SWIM,
MoveKind::StopSwim => op::MSG_MOVE_STOP_SWIM,
MoveKind::SetWalkMode => op::MSG_MOVE_SET_WALK_MODE,
MoveKind::SetRunMode => op::MSG_MOVE_SET_RUN_MODE,
MoveKind::SetFacing => op::MSG_MOVE_SET_FACING,
MoveKind::Heartbeat => op::MSG_MOVE_HEARTBEAT,
}
@@ -1876,6 +1930,10 @@ pub(crate) enum ClientCommand {
},
/// Ask for our saved raid lockouts (`CMSG_REQUEST_RAID_INFO`) — the Raid Info panel.
RequestRaidInfo,
/// Reset every dungeon we own (`CMSG_RESET_INSTANCES`, empty body) — the SELF menu's "Reset
/// all instances" (decision 1748). Answered per instance: an `SMSG_INSTANCE_RESET` for each
/// map that reset, an `SMSG_INSTANCE_RESET_FAILED` for each that would not.
ResetInstances,
// ── The duel family (decision 0633; writer bodies in benilla-protocol
// `world/writer/duel.rs`). Challenging is a `CastSpell` of the duel spell, not a verb here.
/// Accept a duel challenge (`CMSG_DUEL_ACCEPTED`) — the popup's Accept, and the challenger's
@@ -2424,6 +2482,105 @@ pub(crate) struct AiReactionMessage {
mod tests {
use super::*;
/// The vanilla player set, and the one every number below is read against:
/// walk 2.5 · run 7.0 · runBack 4.5 · swim 4.722 · swimBack 2.5 (vmangos `baseMoveSpeed`).
fn vanilla() -> MoveSpeeds {
MoveSpeeds {
walk: 2.5,
run: 7.0,
run_back: 4.5,
swim: 4.722,
swim_back: 2.5,
turn_rate: std::f32::consts::PI,
}
}
/// **The cascade's ORDER is the whole feature, and one rung of it is counter-intuitive.**
/// `0x7c4c90` takes the walk arm at `0x7c4d11` *before* the run arm's backward min at
/// `0x7c4d1d`, so a walking backpedal is walk speed — not run-back. Our remote extrapolator
/// tested the backpedal first and got 4.5 here for five hundred commits (decision 1752); this
/// is the assertion that pins the fix, in the one place both callers now go through.
#[test]
fn the_walk_arm_outranks_the_backward_min() {
use crate::creature_anim::move_flags as f;
let s = vanilla();
assert_eq!(current_speed(&s, f::FORWARD), 7.0, "plain run");
assert_eq!(current_speed(&s, f::BACKWARD), 4.5, "the backward min");
assert_eq!(current_speed(&s, f::FORWARD | f::WALK_MODE), 2.5, "walk");
assert_eq!(
current_speed(&s, f::BACKWARD | f::WALK_MODE),
2.5,
"walking backwards is a WALK, not a run-back: the walk arm is taken first"
);
// …and a strafing walker likewise — the walk arm is ahead of everything on land.
assert_eq!(current_speed(&s, f::STRAFE_LEFT | f::WALK_MODE), 2.5);
}
/// SWIMMING pre-empts the walk bit entirely (`0x7c4cd9` precedes `0x7c4d11`): there is no
/// "swim-walk" in this client, so a walker who wades in strokes at full swim speed.
#[test]
fn swimming_is_tested_first_so_there_is_no_swim_walk() {
use crate::creature_anim::move_flags as f;
let s = vanilla();
assert_eq!(
current_speed(&s, f::FORWARD | f::SWIMMING | f::WALK_MODE),
s.swim
);
assert_eq!(
current_speed(&s, f::BACKWARD | f::SWIMMING | f::WALK_MODE),
2.5,
"the swim arm's own backward min — reached, and unaffected by the walk bit"
);
}
/// Both backward arms are a **min**, not a select (x87 `fcompp` + `test ah,0x41` + `jp`), and
/// so is the walk arm. Only visible under a server that force-sets a slower field above its
/// faster one — which is exactly why it is a test and not a comment.
#[test]
fn every_arm_that_looks_like_a_select_is_really_a_min() {
use crate::creature_anim::move_flags as f;
let s = MoveSpeeds {
walk: 12.0, // a walk-speed buff above run
run: 7.0,
run_back: 9.0, // a run-back above run
swim: 4.0,
swim_back: 9.0, // a swim-back above swim
turn_rate: std::f32::consts::PI,
};
assert_eq!(current_speed(&s, f::FORWARD | f::WALK_MODE), 7.0);
assert_eq!(current_speed(&s, f::BACKWARD), 7.0);
assert_eq!(current_speed(&s, f::FORWARD | f::SWIMMING), 4.0);
assert_eq!(current_speed(&s, f::BACKWARD | f::SWIMMING), 4.0);
}
/// The two guards at the ends of the cascade. No direction bit → 0.0 (`0x7c4c99 test dl,0xf`),
/// never a stale speed: a mover standing still is standing however its fields read, and the
/// turn/pitch/mode bits are not translation. And a **zero walk speed is the ctor state, not a
/// rate** — a mover whose speed block has not landed falls back to the run arm rather than
/// freezing solid the moment somebody toggles walk (the same treatment `turn_rate` gets).
#[test]
fn no_direction_bit_is_zero_and_an_unfilled_walk_speed_is_not_a_freeze() {
use crate::creature_anim::move_flags as f;
let s = vanilla();
assert_eq!(current_speed(&s, 0), 0.0);
assert_eq!(
current_speed(&s, f::WALK_MODE),
0.0,
"a mode is not a motion"
);
assert_eq!(current_speed(&s, f::TURN_LEFT | f::ROOT | f::SWIMMING), 0.0);
let unfilled = MoveSpeeds {
run: 7.0,
..Default::default()
};
assert_eq!(
current_speed(&unfilled, f::FORWARD | f::WALK_MODE),
7.0,
"walk 0.0 is the ctor, not a rate — fall back rather than freeze"
);
}
/// **The addon lane's four wire bytes** (decision 1235) — the client's own whitelist at
/// `0x49fa3f`-`0x49fa4e`, and the last link in the chain between an addon's Lua call and the
/// bytes `benilla-protocol`'s `addon_message_bodies_golden` pins. Wrong here and the payload
+28
View File
@@ -270,6 +270,11 @@ pub(super) fn apply_net_updates(
// summoner's guid, zone and expiry here and `crate::ui_summon` turns it into the
// CONFIRM_SUMMON dialog, whose Accept is the only packet in the flow.
ResMut<crate::ui_summon::SummonState>,
// The instance-lockout bookkeeping (decision 1748) — four of its six packets
// queue a GlobalStrings-templated chat line here for `crate::ui_instance` to
// resolve against the VM, and two write the latch behind
// `CanShowResetInstances()`.
ResMut<crate::ui_instance::InstanceState>,
),
),
),
@@ -455,6 +460,7 @@ pub(super) fn apply_net_updates(
mut registrar,
mut petition,
mut summon,
mut instances,
),
),
) = caches;
@@ -1134,6 +1140,28 @@ pub(super) fn apply_net_updates(
self_guid.0,
social.is_ignored(challenger),
),
// ── The instance/raid lockout family (decision 1748): four lines the client
// composes itself out of GlobalStrings, and the two-packet latch behind the SELF
// menu's reset row. The lines are QUEUED — resolving them needs the VM, which this
// drain has no access to (decision 0669's split) ──
SessionEvent::RaidInstanceMessage { message } => {
crate::ui_instance::apply::raid_instance_message(&mut instances, message);
}
SessionEvent::InstanceSaveCreated { flag } => {
crate::ui_instance::apply::instance_save_created(&mut instances, flag);
}
SessionEvent::InstanceReset { map } => {
crate::ui_instance::apply::instance_reset(&mut instances, map);
}
SessionEvent::InstanceResetFailed { failure } => {
crate::ui_instance::apply::instance_reset_failed(&mut instances, failure);
}
SessionEvent::UpdateLastInstance { map } => {
crate::ui_instance::apply::update_last_instance(&mut instances, map);
}
SessionEvent::UpdateInstanceOwnership { owns } => {
crate::ui_instance::apply::update_instance_ownership(&mut instances, owns);
}
SessionEvent::DuelOutOfBounds => crate::ui_duel::apply::bounds(&mut duel, true),
SessionEvent::DuelInBounds => crate::ui_duel::apply::bounds(&mut duel, false),
SessionEvent::DuelComplete { started } => {
+1
View File
@@ -1094,6 +1094,7 @@ fn writer_loop(
ClientCommand::ReadyCheckStart => w.ready_check_start(),
ClientCommand::ReadyCheckAnswer { ready } => w.ready_check_answer(ready),
ClientCommand::RequestRaidInfo => w.request_raid_info(),
ClientCommand::ResetInstances => w.reset_instances(),
ClientCommand::DuelAccepted { arbiter } => w.duel_accepted(arbiter),
ClientCommand::DuelCancelled { arbiter } => w.duel_cancelled(arbiter),
ClientCommand::TogglePvp => w.toggle_pvp(),
+11 -20
View File
@@ -824,26 +824,17 @@ impl RemoteMotion {
let dz = fwd_amt * vp;
let len = (dx * dx + dy * dy + dz * dz).sqrt();
// The speed the move-flags imply — the ref's `GetCurrentSpeed 0x7c4c90` (the swim §5's
// TU-H): swimming → swim, backward bit → `min(swimBack, swim)`; on land a net-backward
// move (S with no forward override) → `min(runBack, run)`; a /walk-toggled mover → walk;
// otherwise run. The min is the byte law — a plain back-speed select whenever it's the
// slower (always, at vanilla values).
let backpedal =
self.flags & move_flags::BACKWARD != 0 && self.flags & move_flags::FORWARD == 0;
let base = if swimming {
if backpedal {
speeds.swim_back.min(speeds.swim)
} else {
speeds.swim
}
} else if backpedal {
speeds.run_back.min(speeds.run)
} else if self.flags & move_flags::WALK_MODE != 0 {
speeds.walk
} else {
speeds.run
};
// The speed this mover's flags imply — the reference's `GetCurrentSpeed 0x7c4c90`, stated
// once in [`crate::net::current_speed`] so this side and the local controller cannot drift
// apart about a cascade whose whole content is its ORDER. Everything it reads is the
// sender's own word, relayed verbatim: the direction bits, the swim bit and the walk bit.
//
// **The walk arm outranks the backward min, and this block used to have it backwards**
// (decision 1752): it tested the backpedal first, so a walking backpedaller extrapolated
// at `min(runBack, run)` = 4.5 yd/s. The bytes take the walk arm at `0x7c4d11` *before*
// the run arm's `0x7c4d1d`, so the answer is `min(walk, run)` = 2.5 — a walking backpedal
// is exactly as slow as a walk, and the observed body now plays Walk instead of Run.
let base = crate::net::current_speed(&speeds, self.flags);
let mut pos = self.wow_pos;
let speed = if len > 1.0e-4 {
let step = base * dt / len; // normalize the 3D direction, then advance by base·dt
@@ -166,6 +166,42 @@ fn remote_motion_backpedal_uses_run_back_speed() {
assert_eq!(speed, 4.5);
}
/// **A walking remote is a walking remote in every direction** (decision 1752). This block used to
/// test the backpedal ahead of the walk bit, so an observed player who toggled walk and pressed S
/// extrapolated at run-back speed (4.5) — fast enough to clear the `> 2× walkSpeed` boundary and
/// play the *run* clip while its owner walked. The bytes take the walk arm (`0x7c4d11` →
/// `0x7c4d4d`) before the run arm's backward min (`0x7c4d1d`), so both directions are
/// `min(walk, run)` = 2.5.
#[test]
fn a_walking_remote_backpedals_at_walk_speed_not_run_back() {
let s = speeds();
let (pos, _o, _vz, speed) =
motion(move_flags::FORWARD | move_flags::WALK_MODE, 0.0).advance(s, 1.0);
assert!(
(pos[0] - 2.5).abs() < 1e-3,
"a walk advances +X by 2.5: {pos:?}"
);
assert_eq!(speed, 2.5);
let (pos, _o, _vz, speed) =
motion(move_flags::BACKWARD | move_flags::WALK_MODE, 0.0).advance(s, 1.0);
assert!(
(pos[0] + 2.5).abs() < 1e-3,
"…and a WALKING backpedal is still 2.5, not run_back's 4.5: {pos:?}"
);
assert_eq!(speed, 2.5);
// The control: without the walk bit the same press is the run-back min, unchanged.
let (_pos, _o, _vz, speed) = motion(move_flags::BACKWARD, 0.0).advance(s, 1.0);
assert_eq!(speed, 4.5);
// …and swimming still pre-empts the walk bit entirely — there is no swim-walk.
let (_pos, _o, _vz, speed) = motion(
move_flags::FORWARD | move_flags::SWIMMING | move_flags::WALK_MODE,
0.0,
)
.advance(s, 1.0);
assert_eq!(speed, 4.0, "a walker who wades strokes at full swim speed");
}
#[test]
fn remote_motion_swim_backpedal_takes_min_of_the_swim_pair() {
// The byte law (`0x7c4c90`'s backward arms, swim-feel §5 TU-H): backward speed is
+4 -2
View File
@@ -92,6 +92,8 @@ mod swim;
/// What the camera orbits, when that is not our own body — the far-sight anchor (B151, and Mind
/// Control's camera half in B211, which rides the same field).
mod view_subject;
/// The walk/run gait toggle (`TOGGLERUN`) — one latched bit and the reference's refusal chain.
mod walk;
mod wire_in;
// `apply_self_model_fade` is `pub(crate)`-visible: it is the LAST writer of a self body part's
@@ -115,8 +117,8 @@ use state::{
MoveSpeed, PlayerRide, AIR_NUDGE_SPEED, FALL_FAR_DROP, FALL_FAR_TIME, FOOT_CONE_HEIGHT,
GROUND_COS, GROUND_PROBE, JUMP_SPEED, LAND_PROBE, MOUSELOOK_PITCH_CLAMP, RUN_BACK_RATIO,
SKIN_WIDTH, STATIONARY_CHASE_RATE, STEP_SLOPE_RATIO, STEP_SNAP_SLACK, STEP_UP_ADVANCE,
STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WATER_WALK_PITCH_FLOOR, WEDGE_MIN_FALL,
WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
STEP_UP_HEIGHT, TURN_RATE, TURN_RATE_MOVING, WALK_RATIO, WATER_WALK_PITCH_FLOOR,
WEDGE_MIN_FALL, WEDGE_STALL_RATIO, WEDGE_STILL_FRAMES,
};
// `SETTLE_TIMEOUT` is `pub(crate)`: the settle release lives in the terrain streamer (decision
// 0737 — residency releases the hold, not ground contact), which owns the deadline push while the
+42 -18
View File
@@ -501,6 +501,11 @@ pub(super) fn control(
// `turn_delta` the same way) or a real mouse TURN of the body. One of the movement inputs
// that stands a seated avatar back up, and the camera's rigid carry below.
let turned = turn_delta != 0.0 || mouse_turned;
// The gait toggle (`TOGGLERUN`) — the walk/run latch, run here so the speed select
// below reads the bit this frame's press left, which is the reference's own order
// (`ToggleRun` is an input-phase command; the mover reads `CMovement+0x40` after it).
// [`super::walk`] owns the latch and the refusal chain.
walk::update(&mut player, &body, binds);
// Posture ([`posture`]) — the stand state (`X` and the `/sit` family) and the sheath
// toggle (`Z`), which interlock: the stow rider fires off the stand state this commits,
// and the toggle's guard chain refuses on it. Returns the **committed** state, which the
@@ -517,26 +522,45 @@ pub(super) fn control(
);
// Backpedaling is slower: the backward move-flag selects the backward speed, dominating
// strafe (binary-VERIFIED — see RUN_BACK_RATIO). Net-backward = the S key held without a
// forward override (W or both-button run). The backward arm is a **min**, not a plain
// select — `0x7c4d1d` computes `min(runBack, run)` (the swim §5's TU-H; observably the
// plain runBack whenever it's the slower, i.e. always at vanilla values, but a server
// that force-sets runBack above run is clamped like the ref). The resulting (slower)
// speed also feeds jump takeoff, so a backward jump lands shorter for free.
// forward override (W or both-button run). The resulting (slower) speed also feeds jump
// takeoff, so a backward jump lands shorter for free.
let net_backward = fwd_axis < 0;
// Run/runback are server-authoritative (`UnitSpeeds`: seeded by our create's LIVING block,
// updated live by SMSG_FORCE_*_SPEED_CHANGE — so `.modify speed`, mounts and slows actually
// move us at the server's number). `$WOW_MOVE_SPEED` stays the absolute dev override
// (backpedal keeps the vanilla 4.5/7.0 ratio under it); pre-create frames fall back the
// same way.
let (run_speed, run_back_speed) = match mover_speeds {
Some(s) if !move_speed.env_override => (s.run, s.run_back),
_ => (move_speed.value, move_speed.value * RUN_BACK_RATIO),
};
let speed = if net_backward {
run_back_speed.min(run_speed)
} else {
run_speed
// The mover's speed SET this frame: server-authoritative (`UnitSpeeds` — seeded by our
// create's LIVING block, moved live by SMSG_FORCE_*_SPEED_CHANGE, so `.modify speed`,
// mounts and slows actually move us at the server's number), or the `$WOW_MOVE_SPEED` dev
// override's synthetic set, which keeps the vanilla 2.5/4.5/7.0 ratios so that walking and
// backpedaling stay themselves under it. Pre-create frames take the same fallback.
let speeds = match mover_speeds {
Some(s) if !move_speed.env_override => s,
_ => benilla_protocol::MoveSpeeds {
walk: move_speed.value * WALK_RATIO,
run: move_speed.value,
run_back: move_speed.value * RUN_BACK_RATIO,
..Default::default()
},
};
// …turned into a yards/second by the ONE statement of the reference's
// `GetCurrentSpeed 0x7c4c90` ([`crate::net::current_speed`]), the same call the remote
// extrapolator makes — so our own body and every body we watch agree about the cascade,
// including the part that is easy to get backwards: **the walk arm is taken before the
// backward min**, so walking backwards is walk speed (2.5), not run-back (4.5).
//
// The flag word handed over is this frame's *gait intent*, not the wire word — that one is
// built later, after the mover has run ([`super::flags::this_frame`]). Only the three bits
// the ground cascade reads are needed and all three are known here; swimming never reaches
// this arm ([`super::swim`] owns its own leg of the same getter).
let speed = crate::net::current_speed(
&speeds,
if net_backward {
move_flags::BACKWARD
} else {
move_flags::FORWARD
} | if player.walking {
move_flags::WALK_MODE
} else {
0
},
);
// Root is refused HERE (the reference refuses it twice upstream of the handler: the Lua
// gate's `test ch,0x12` and the replay allow-list `0x615c71`/`0x618030`, where command
// id 7 is blocked). HOVER's refusal is NOT here — it belongs to the movement handler
+95
View File
@@ -165,6 +165,18 @@ pub(super) fn this_frame(
move_flags_now = 0;
}
}
// **The walk gait rides every packet** — `MOVEFLAG_WALK_MODE` `0x100`, latched by the
// `TOGGLERUN` keybind ([`super::walk`], decision 1752). Deliberately OUTSIDE the branches
// above, and after the settle's `move_flags_now = 0`: this is a MODE, not a motion. The
// settle's wipe exists so a frozen avatar reports no locomotion, and clearing the walk bit
// with it would tell the server we went back to running — a spurious SET_RUN_MODE, then a
// SET_WALK_MODE the frame the settle releases, on every teleport. Nothing below strips it
// either: `incapacitated_flags` touches only the direction and turn bits, which is the
// reference's own answer too — its input allow-list (`0x615c71` → the table at `0x618054`)
// blocks the translation commands while rooted and **explicitly permits run/walk**.
if player.walking {
move_flags_now |= move_flags::WALK_MODE;
}
// **A knockback arc streams FORWARD for its whole length** (decision 1740). The reference's
// apply plants it as part of the launch (`0x6179c0`'s `0x617a18 or edx,0x8001` — set bit 0,
// clear bit 1) and nothing clears it until the arc ends, and the send mask
@@ -225,6 +237,89 @@ mod tests {
}
}
/// **Walk mode is a MODE, so it survives everything that clears locomotion** (decision 1752).
/// Three states that each wipe or narrow the word, and the bit must be in both the wire and
/// the pose word out of every one of them:
///
/// * the post-teleport/login **settle** (`held`), whose `move_flags_now = 0` exists so a
/// frozen avatar reports no walking — clearing the gait with it would tell the server we
/// went back to running, then re-announce a frame later: a SET_RUN_MODE/SET_WALK_MODE pair
/// on every single teleport;
/// * **rooted**, which drops the direction bits — and the reference agrees the gait stays: its
/// input allow-list (`0x615c71` → `0x618054`) blocks the translation commands while rooted
/// and **explicitly permits** run/walk;
/// * **stunned**, which drops the turn bits.
#[test]
fn the_walk_gait_outlives_the_settle_the_root_and_the_stun() {
let case = |held: bool, rooted: bool, stunned: bool| {
let mut player = Player {
walking: true,
..Default::default()
};
player.modes.rooted = rooted;
let mut axes = still();
axes.fwd = 1;
axes.translating = true;
this_frame(
&mut player,
&axes,
None,
false,
false,
held,
false,
stunned,
1.0,
0.0,
)
};
for (name, held, rooted, stunned) in [
("moving", false, false, false),
("settling", true, false, false),
("rooted", false, true, false),
("stunned", false, false, true),
] {
let f = case(held, rooted, stunned);
assert_eq!(
f.wire & move_flags::WALK_MODE,
move_flags::WALK_MODE,
"{name}: the gait rides the wire ({:#x})",
f.wire
);
assert_eq!(
f.pose & move_flags::WALK_MODE,
move_flags::WALK_MODE,
"{name}: …and the animation sees it too ({:#x})",
f.pose
);
}
// The settle really is wiping the locomotion — the control that says the assertion above
// is about a mode surviving a wipe, not about there being no wipe.
assert_eq!(
case(true, false, false).wire & move_flags::ANY_MOVE,
0,
"the settle still clears the direction bits"
);
// …and a runner never carries the bit.
let mut runner = Player::default();
let mut axes = still();
axes.fwd = 1;
axes.translating = true;
let f = this_frame(
&mut runner,
&axes,
None,
false,
false,
false,
false,
false,
1.0,
0.0,
);
assert_eq!(f.wire & move_flags::WALK_MODE, 0);
}
/// **A knockback arc streams FORWARD for its whole length** (decision 1740). The reference's
/// apply plants the bit as part of the launch (`0x6179c0`'s `0x617a18 or edx,0x8001` — set bit
/// 0, clear bit 1) and only the arc's end clears it; the send mask
@@ -18,6 +18,13 @@
//! a granted sit and a refused one look identical on screen, and the granted-underwater bug and a
//! gate that refuses everything are the same picture. Covers the `X` key and the `/sit` family
//! alike, because both land on the one commit it is emitted from.
//! - **`gait`** — one line per **walk/run toggle** decision ([`gait`]): the `TOGGLERUN` press and
//! what the reference's guard chain did with it. Same argument as `sit`, half applied: the
//! *granted* half is observable twice over (the body's gait changes and `snd` logs the
//! `SET_WALK_MODE`/`SET_RUN_MODE` that went out), but a **refused** press is silent by design —
//! indistinguishable from a key that isn't bound, or from a binding whose dispatch never fired.
//! The chain also has an open byte (`0x1200`'s second bit, decision 1752 §6), so a live run is
//! how the §5's answer gets checked against a real refusal instead of re-reasoned.
//! - **`knb`** — one line per **knockback** ([`knockback`]): the launch quad the server aimed and
//! whether the mover flew it. The one mover edge with no observable of its own — its ack is not a
//! `MSG_MOVE_*` so `snd` misses it, its refusal is silent by design, and a wrong ack's whole
@@ -108,6 +115,28 @@ pub(super) fn posture(what: &str, state: u8, from: u8, flags: u32) {
);
}
/// One `gait` line per **walk/run toggle** decision, granted or refused (decision 1752).
///
/// `what` is `commit` or `REFUSED`; `to` is the gait the press asked for, and the three flags are
/// the reference's own guard chain at `0x513d8e`–`0x513dcd` spelled out one by one — because
/// "why did nothing happen?" is exactly the question this tag exists to stop anyone answering from
/// memory, and three silent gates share the same silence.
pub(super) fn gait(what: &str, to: bool, dead: bool, rooted: bool, on_spline: bool) {
if !trace::enabled() {
return;
}
trace::line(
"gait",
&format!(
"{what} to={} dead={} rooted={} spline={}",
if to { "walk" } else { "run" },
u8::from(dead),
u8::from(rooted),
u8::from(on_spline),
),
);
}
/// One `knb` line per **knockback**, whether we flew it or refused it (decision 1702).
///
/// The tag exists because a knockback is the one mover edge with **no observable of its own on
@@ -342,6 +342,21 @@ pub(super) fn stream_self_movement(
} else if removed & move_flags::SWIMMING != 0 {
send_move!(MoveKind::StopSwim);
}
// The gait toggle: `MSG_MOVE_SET_WALK_MODE` (0xc3) entering walk, `MSG_MOVE_SET_RUN_MODE`
// (0xc2) leaving it — the same "a dedicated opcode the frame the bit flips" shape as the swim
// pair above, and for the same reason. It sits OUTSIDE the `if !falling || air_nudged` axis
// block on purpose: a mode flip is not an axis transition, so the airborne silence (which
// exists because mid-air direction presses are deferred into an inert latch) does not apply —
// the reference's `ToggleRun` refuses nothing about being airborne and enqueues its event
// regardless. And it must fire while standing perfectly still, which is the whole reason the
// reference gives the toggle its own enqueue rather than letting the move-state broadcaster
// find it: that broadcaster gates on the locomotion nibble (`0x61a99d test al,0xf`) and would
// drop it. This differ has no such gate (decision 1752).
if added & move_flags::WALK_MODE != 0 {
send_move!(MoveKind::SetWalkMode);
} else if removed & move_flags::WALK_MODE != 0 {
send_move!(MoveKind::SetRunMode);
}
// Forward/back axis — silent while airborne (the flag state rides the next packet instead),
// except on the nudge frame. The **stop** arms stay silent while falling no matter what: a
// mid-air release is the deferred-latch case, always, and the nudge frame is a press by
@@ -497,6 +512,128 @@ mod tests {
use super::*;
use std::f32::consts::TAU;
/// **The gait toggle announces itself standing perfectly still** — which is the whole reason
/// the reference gives `ToggleRun` its own move-event enqueue instead of letting the
/// move-state broadcaster find the flag change: that broadcaster gates every send on the
/// locomotion nibble (`0x61a99d test al,0xf`), so a toggle with no direction bit set would
/// never leave the client through it. Ours rides the flag differ, which has no such gate.
/// Observers derive a walker's speed from the relayed bit, so a swallowed packet is a
/// remote body that keeps running at 7 yd/s while its owner walks (decision 1752).
#[test]
fn the_walk_toggle_sends_its_own_opcode_with_no_movement_at_all() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut player = Player::default();
let arc = || ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 0,
};
// Entering walk: MSG_MOVE_SET_WALK_MODE (0xc3), and it carries the bit it announces.
stream_self_movement(
&tx,
&mut player,
move_flags::WALK_MODE,
0.0,
arc(),
0.0,
&[],
None,
None,
);
let ClientCommand::Move { kind, flags, .. } =
rx.try_recv().expect("a standing walk toggle still sends")
else {
panic!("expected a Move command");
};
assert_eq!(kind, MoveKind::SetWalkMode);
assert_eq!(kind.opcode(), 0x00C3);
assert_eq!(flags & move_flags::WALK_MODE, move_flags::WALK_MODE);
// …and leaving it is the OTHER opcode, not a repeat of the same one.
stream_self_movement(&tx, &mut player, 0, 0.0, arc(), 0.0, &[], None, None);
let ClientCommand::Move { kind, flags, .. } =
rx.try_recv().expect("the run half sends too")
else {
panic!("expected a Move command");
};
assert_eq!(kind, MoveKind::SetRunMode);
assert_eq!(kind.opcode(), 0x00C2);
assert_eq!(flags & move_flags::WALK_MODE, 0);
// A steady walk announces nothing further — it is an edge, not a per-frame report.
stream_self_movement(
&tx,
&mut player,
move_flags::WALK_MODE,
0.0,
arc(),
0.0,
&[],
None,
None,
);
while let Ok(ClientCommand::Move { kind, .. }) = rx.try_recv() {
assert_eq!(kind, MoveKind::SetWalkMode, "the re-entry edge only");
}
stream_self_movement(
&tx,
&mut player,
move_flags::WALK_MODE,
0.1,
arc(),
0.1,
&[],
None,
None,
);
assert!(
rx.try_recv().is_err(),
"a walk already announced is silent until it flips back"
);
}
/// The toggle is not an axis transition, so the airborne silence must not swallow it: a
/// mid-air press flips the bit and the packet goes out (the reference's `ToggleRun` refuses
/// nothing about being airborne, and the fall's own heartbeats would carry the bit anyway).
#[test]
fn a_walk_toggle_taken_mid_air_is_not_swallowed_by_the_airborne_silence() {
let (tx, rx) = crossbeam_channel::unbounded();
let mut player = Player {
move_flags: move_flags::FORWARD | move_flags::FALLING,
..Default::default()
};
stream_self_movement(
&tx,
&mut player,
move_flags::FORWARD | move_flags::FALLING | move_flags::WALK_MODE,
0.0,
ArcEdges {
jumped: false,
wire_launch: false,
air_nudged: false,
landed: false,
fall_time: 400,
},
0.0,
&[],
None,
None,
);
let kinds: Vec<_> = rx
.try_iter()
.map(|c| match c {
ClientCommand::Move { kind, .. } => kind,
_ => panic!("expected Move commands"),
})
.collect();
assert!(
kinds.contains(&MoveKind::SetWalkMode),
"the walk edge rides out mid-arc: {kinds:?}"
);
}
#[test]
fn wire_orientation_is_normalized_into_0_2pi() {
// `face_yaw` is an unbounded accumulator, but vmangos's `VerifyMovementInfo` rejects any
+25
View File
@@ -19,6 +19,14 @@ use bevy::prelude::*;
/// ~36% shorter — no separate constant.
pub(super) const RUN_BACK_RATIO: f32 = 4.5 / 7.0;
/// Walk speed as a fraction of run: vanilla `MOVE_WALK` 2.5 / `MOVE_RUN` 7.0 — the sibling of
/// [`RUN_BACK_RATIO`], and used the same way and only for the same reason: `$WOW_MOVE_SPEED`
/// replaces the server's speed set outright, and the walk gait has to stay a walk under it
/// rather than becoming a second run. The live number is always the server's (`MoveSpeeds::walk`,
/// seeded by the create block and moved by `SMSG_FORCE_WALK_SPEED_CHANGE`); this ratio never
/// reaches a real session (decision 1752).
pub(super) const WALK_RATIO: f32 = 2.5 / 7.0;
/// Turn rate (rad/s) **fallback** — how fast A/D rotate the facing when not mouse-looking, used
/// only until the mover's own sixth movement speed arrives.
///
@@ -520,6 +528,23 @@ pub(crate) struct Player {
/// places the other forward sources do — the direction vector, the wire flags, the swim
/// amounts, and the turn-rate's "am I translating" test.
pub(super) autorun: bool,
/// **Walk mode latched on** — the reference's `MOVEMENTFLAGS` bit `0x100`
/// (`CMovement+0x40`), flipped by `ToggleRun 0x513d50` → `0x60e080`, which reads the CURRENT
/// bit out of the cached word (`0x60e083 mov eax,[ecx+0x9e8]; 0x60e08c and eax,0x100`) and
/// hands it down to be inverted. The other true *toggle* in the movement family, beside
/// [`autorun`](Self::autorun) — and unlike it, this one is a real wire bit rather than an
/// input-word bit, so it rides every outbound packet ([`super::flags`]) and observers read our
/// gait straight off it.
///
/// **Typed state, not the flag word.** [`Player::move_flags`] is last-streamed wire
/// bookkeeping, rebuilt from state every frame, so a latch parked only there is gone by the
/// next frame — the same trap [`MoveModes`] exists to avoid. It is *also* inside the
/// `SERVER_AUTHORED` merge mask, so a server-authored move for our mover can flip it; that
/// merge lands here, in [`super::wire_in`], not in the word.
///
/// It is **not** a [`MoveModes`] entry: that family is the four ack'd server grants, each with
/// its own SMSG/ack pair. This one owes no ack, is granted by nobody, and is ours to set.
pub(super) walking: bool,
/// **`/follow` is holding the forward key this frame** (decision 0890). Not a mode of its own:
/// the reference's follow owns no translation and simply pushes the same move-forward bit the W
/// key does (`0x60e790`), so this folds into [`forward_axis`] beside `autorun` and the
+108
View File
@@ -0,0 +1,108 @@
//! **The walk/run gait toggle** — the `TOGGLERUN` keybind, the reference's `ToggleRun 0x513d50`.
//!
//! One latched bit ([`Player::walking`]) and one refusal chain, kept here beside its concern like
//! [`super::posture`] and [`super::gait`]. Everything downstream is already built and reads the
//! bit through the ordinary paths: the flag word ([`super::flags`]) carries `MOVEFLAG_WALK_MODE`
//! `0x100` out onto every packet, the speed cascade ([`crate::net::current_speed`]) turns it into
//! `min(MOVE_WALK, MOVE_RUN)`, and the animation selector needs nothing at all — it picks Walk(4)
//! over Run(5) purely on `speed > 2 × walkSpeed`, so a correct speed *is* the correct clip and a
//! correct playback rate (wow-re `rf57-movement-anim-select.md`: "Walk-vs-run is SPEED-driven,
//! NOT the WALK-mode bit").
//!
//! **The one thing the toggle owns that the differ does not**: nothing else in the client would
//! ever announce this. The reference's own move-state broadcaster gates every send on the
//! locomotion nibble (`0x61a99d test al,0xf`), so a toggle pressed while standing still would
//! never reach the server through it — which is exactly why `ToggleRun` enqueues its own move
//! event (`0x513dd2 call 0x60e080` → `0x60e060` → `0x617de0` → `0x617570`, kind `0xf` when the
//! walk bit is currently clear and `0xe` when it is set). Ours goes out of the flag differ in
//! [`super::movement_net`], which is likewise not nibble-gated.
use super::{BodyQuery, Player};
/// Does the reference **refuse** this `ToggleRun` press? The handler's own guard chain, in its byte
/// order (`0x513d50`–`0x513dcf`; every gate falls to the same silent `0x513dd7` — no packet, no
/// local flip, no message, exactly like the sheath and stand-state chains):
///
/// ```text
/// 513d8e mov esi,[descr+0x40] ; test esi,esi ; jle → health <= 0
/// 513d95 mov ecx,[unit+0x118] ; mov ecx,[ecx+0xa4] → the active spline object, if any
/// 513da5 test byte [ecx+0x18],0x4 ; je → …its FINALIZE latch must be SET
/// 513dab (health, re-read and re-tested)
/// 513dbe mov ebx,[cmov+0x40] ; test bh,0x12 ; jne → MOVEMENTFLAGS & 0x1200
/// 513dc6 cmp byte [descr+0x210],0x7 ; je → UNIT_FIELD_BYTES_1.standState == DEAD
/// ```
///
/// `descr+0x40` is `UNIT_FIELD_HEALTH`, not a guess: benilla already byte-pinned it at the
/// reference's own death predicate `0x605f90` ("really dead — `[descr+0x40] <= 0`", see
/// `ObjectFields::unit_reads_dead`), and `descr+0x210` is `UNIT_FIELD_BYTES_1` (field index
/// `0x84`), whose byte 0 is the stand state. So the death leg is health-or-standstate — and
/// notably **not** the feign-death dynflag that `unit_reads_dead` also carries, which is why this
/// takes the narrower [`ObjectFields::unit_is_dead`]. A **ghost** is health 1 and stand state 0,
/// so a corpse run may be walked, and the corpse it is running to may not toggle anything.
///
/// The spline gate reads the other way round from how it looks: bit `0x4` of the spline flags is
/// the **finalize / `MOVE_SPLINE_DONE` latch** (wow-re `rf57-movement-anim-select.md`, the FLY
/// gate's `(flags & 0x204) == 0x200`), so "the latch must be set" means *the spline is finished* —
/// i.e. **a mover on a live server spline is refused**. Ours is [`Player::server_riding`]: the
/// taxi flight and the charge/knock hand-offs are the states where a spline is actually flying.
///
/// `0x1200` is two bits and only one of them is settled: `0x1000` is `MOVEFLAG_ROOT`
/// (VERIFIED — `SetRoot 0x7c7340` writes it, wow-re `moveflag-family.md`), and `0x200` is a bit
/// benilla does not model at all. The same pair gates the autorun emitter `0x514560`, where
/// [`super::input`] records the identical caveat, and a wow-re §5 is pinning it; until it lands
/// the modelled half is the whole of what we can refuse on.
pub(super) fn toggle_refused(dead: bool, rooted: bool, on_spline: bool) -> bool {
dead || rooted || on_spline
}
/// Run this frame's `TOGGLERUN` press. Nothing else in the client writes [`Player::walking`]
/// except the server-authored merge in [`super::wire_in`].
pub(super) fn update(
player: &mut Player,
body: &BodyQuery,
binds: &crate::bindings::BindingsState,
) {
if !binds.fired(crate::bindings::cmd::TOGGLE_RUN) {
return;
}
let dead = body
.single()
.ok()
.and_then(|(.., store, _, _, _, _, _)| store.map(|s| s.0.unit_is_dead()))
.unwrap_or(false);
let (rooted, on_spline) = (player.modes.rooted, player.server_riding);
// A read-and-invert of the *current* bit, which is the reference's shape exactly: `0x60e080`
// fetches `[unit+0x9e8] & 0x100` and `0x617de0` picks the event kind off it (`neg`/`sbb`/
// `add 0xf` → `0xe` when set, `0xf` when clear). There is no "walk on"/"walk off" command in
// 1.12 — one binding, one flip.
let want = !player.walking;
if toggle_refused(dead, rooted, on_spline) {
// Silent, like the reference — no packet, no flip, no message. The trace tag is the only
// way a refusal is readable from a live run ([`super::move_trace::gait`]).
super::move_trace::gait("REFUSED", want, dead, rooted, on_spline);
return;
}
player.walking = want;
super::move_trace::gait("commit", want, dead, rooted, on_spline);
}
#[cfg(test)]
mod tests {
use super::toggle_refused;
/// The three refusals we can actually state, and the control that says the predicate is a
/// refusal chain rather than a blanket "not while anything is unusual".
#[test]
fn the_guard_chain_refuses_exactly_the_three_states_it_names() {
assert!(!toggle_refused(false, false, false), "standing: granted");
assert!(toggle_refused(true, false, false), "a corpse cannot toggle");
assert!(
toggle_refused(false, true, false),
"rooted: `0x1200`'s modelled half"
);
assert!(
toggle_refused(false, false, true),
"a live server spline (taxi, charge) refuses — the finalize latch is clear"
);
}
}
+8
View File
@@ -582,6 +582,14 @@ fn apply_self_move(
// runs `0x61a1af → 0x61a230 → SetSwim` (wow-re `swim-transition.md`, "the local unit's server
// echo"). This pair is GM flight: `.cheat fly` sends SWIMMING + LEVITATING together.
player.swimming = player.move_flags & move_flags::SWIMMING != 0;
// …and the walk gait rides out on the same lift (decision 1752). `0x100` is inside the
// `SERVER_AUTHORED` mask like the modes above, so a move the server authors for our mover
// carries whatever walk bit *it* last saw — normally our own, echoed back from the
// `m_movementInfo` our last packet refreshed, which makes this idempotent. Dropping the lift
// instead would let one server-authored move silently clear the bit out of the word while the
// latch stayed set, and the next frame would rebuild the word and re-announce it: a
// SET_RUN_MODE / SET_WALK_MODE pair per teleport.
player.walking = player.move_flags & move_flags::WALK_MODE != 0;
player.modes.merge_from_wire(player.move_flags);
// Neither mode touches `settling`: the settle release is the terrain streamer's, keyed on the
// destination's residency in every mover mode alike (decision 0737). The pre-0737 special case
File diff suppressed because it is too large Load Diff
@@ -741,77 +741,60 @@ fn macro_name_line_follows_get_action_text_through_the_xml() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// The shipped **bag BAR** — `assets/ui/BagFrame.xml` — materialized frame for frame, and then
/// driven end to end: the bar's own backpack toggle opens the backpack window, the fed stack
/// paints in its slot's well, the slot's clicks queue the right intents, and the toggle shuts it
/// again. It lives in this file because the bar seats on `MainMenuBarArtFrame` (ActionBar.xml) —
/// the toggle's anchor arithmetic below is the reason.
///
/// **What decision 1751 changed.** This asserted `report.frames == 259` over a breakdown that
/// counted five bag WINDOWS and a keyring window (37 + 4×42 + 42, plus the bar's own handful).
/// Those windows are gone from this file: the live ones are the reference's `ContainerFrame1..12`,
/// executed off the player's own patch chain, and `BagFrame.xml` is the BAR and nothing else —
/// this client's stand-in for the reference's `MainMenuBarBagButtons.xml`. So the count is
/// recounted from what the file declares today, and the drive reaches the reference's window
/// through the bar's own button rather than showing one of ours by name.
#[test]
fn shipped_bag_frame_drives_end_to_end() {
let _data = benilla_formats::wow_data_or_skip!();
use super::test_ui::{bag_open, bag_slot_button, centre_of, load_ui, BAG_UI};
use benilla_ui::script::{ContainerSlot, ContainerState};
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The bag toggle now seats on the main action bar (MainMenuBarArtFrame, ActionBar.xml);
// load the bar FIRST so the toggle's cross-file relativeTo resolves to the real art frame at
// SetPoint time. The app loads ActionBar before BagFrame (load_default_ui order); without it the
// anchor would silently fall back to the screen root (it only lands right here by the 1024-wide
// coincidence — screen BOTTOMRIGHT == the full-width bar's art-frame BOTTOMRIGHT).
for file in ["Cooldown.xml", "ActionBar.xml"] {
let abtext = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let abdoc = benilla_ui::framexml::parse(&abtext).unwrap();
benilla_ui::loader::load(&s, &abdoc, &|_| None);
// [`BAG_UI`] is `benilla.toc`'s own order for everything a bag window needs. Three files join
// it at the positions the manifest gives them:
// * ActionBar.xml straight after Cooldown.xml — the bag bar is anchored INTO
// MainMenuBarArtFrame, so the bar must exist before BagFrame.xml loads or the toggle's
// cross-file `relativeTo` silently falls back to the screen root (which would land in the
// right place here anyway, by the 1024-wide coincidence: screen BOTTOMRIGHT == the
// full-width bar's art-frame BOTTOMRIGHT — so the failure would be invisible);
// * StackSplit.xml and MerchantFrame.xml after the bags — the reference's
// `ContainerFrameItemButton_OnClick` reads `StackSplitFrame` on both arms and
// `MerchantFrame:IsShown()` on the right one, so a slot click raises without them. That
// dependency is the reference's, not ours; MerchantFrame.xml is additionally the home of
// the `BenillaMoney_*` coin rig this chain's windows call.
let mut bar_frames = 0;
for file in BAG_UI {
let frames = load_ui(&s, file);
if *file == "Cooldown.xml" {
load_ui(&s, "ActionBar.xml");
}
if *file == "BagFrame.xml" {
bar_frames = frames;
}
}
// UiPanels.xml before the bag file: `ToggleBackpack` calls the GLOBAL `CloseAllBags`, which
// lives there (homed in the panel glue rather than the bag's own file, deliberately — see its
// comment). Production loads both; this test was under-loading relative to it, and started
// failing the moment the toggle went through the reference's names instead of a local body.
// Loading it is the honest fix: the dependency is real, not an artifact.
{
let panels = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/UiPanels.xml"),
)
.unwrap();
let pdoc = benilla_ui::framexml::parse(&panels).unwrap();
let _ = benilla_ui::loader::load(&s, &pdoc, &|_| None);
}
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/BagFrame.xml"),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"loader errors: {:?}",
report.errors
);
load_ui(&s, "StackSplit.xml");
load_ui(&s, "MerchantFrame.xml");
assert_eq!(
report.frames, 259,
"backpack (window + 16 slots × 2 [button + Cooldown child] + 3 money coins \
+ close = 37) + 4 equipped-bag windows (window + 20 slots × 2 + close = 42 each = 168) \
+ the KEYRING window (another 42 — the same template, decision 0765) \
+ the bag-bar toggle (1) + its 4 bar slots (4) + the keyring button (1) \
— 0216 slice 2 + the slot Cooldown children \
+ one $parentItemAnim card per bag-bar button (6 — decision 0887)"
bar_frames, 12,
"the bag bar is six CheckButtons — MainMenuBarBackpackButton, CharacterBag0..3Slot, \
KeyRingButton — each carrying one $parentItemAnim push card (decision 0887): 6 + 6. \
Every window this file used to declare (the backpack, four equipped bags, the keyring) \
is the reference's ContainerFrame now, and its slot buttons, close buttons and money \
rows went with it (decision 1751)"
);
// The re-skinned bag purse reuses BenillaMoney_Set/_Clear (they live in MerchantFrame.xml); load
// it too so the shared helper is defined when BenillaBagFrame_Update runs (the same reuse the loot
// window makes — at runtime every FrameXML file is loaded before any event fires).
let mtext = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/MerchantFrame.xml"),
)
.unwrap();
let mdoc = benilla_ui::framexml::parse(&mtext).unwrap();
benilla_ui::loader::load(&s, &mdoc, &|_| None);
// The slot OnClick hides the hover tooltip on pickup; load GameTooltip.xml so GameTooltip
// exists (the runtime always loads it before the bag frame — ui_script::load_default_ui).
let ttext = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/GameTooltip.xml"),
)
.unwrap();
let tdoc = benilla_ui::framexml::parse(&ttext).unwrap();
benilla_ui::loader::load(&s, &tdoc, &|_| None);
// The app's feed: a backpack with Tough Jerky ×5 in slot 1.
let mut slots = std::collections::HashMap::new();
@@ -847,7 +830,7 @@ fn shipped_bag_frame_drives_end_to_end() {
s.fire_event("BAG_UPDATE", vec![ScriptValue::Int(0)]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Hidden by default: no jerky on screen.
// Nothing open at load: no jerky on screen.
s.resolve();
let jerky_visible = |quads: &[benilla_ui::script::ExtractedQuad]| {
quads.iter().any(|q| {
@@ -855,20 +838,21 @@ fn shipped_bag_frame_drives_end_to_end() {
if p.contains("INV_Misc_Food_16"))
})
};
assert!(!jerky_visible(&s.extract()), "bag window starts hidden");
assert!(!jerky_visible(&s.extract()), "no bag window at load");
// Click the toggle → the window opens, slot 1 paints the jerky in the TOP-LEFT well. The toggle
// now seats on the bar's art frame BOTTOMRIGHT +(-6,2), 37×37: art frame BOTTOMRIGHT is the bar's
// (full-width, bottom-anchored) corner (1024,0) ⇒ toggle x[981,1018] y[2,39], center (999.5,20.5).
// The bag frame is unchanged: 192×240 at BOTTOMRIGHT (0,70) ⇒ x[832,1024] y[70,310]; the real
// backpack chain (ContainerFrame.lua l.425-444) seats the top-left button (game slot 1) at
// BOTTOMRIGHT frame +(-138,153), a 37×37 icon ⇒ x[849,886] y[223,260].
// Click the toggle → the backpack window opens and slot 1 paints the jerky. The toggle seats
// on the bar's art frame BOTTOMRIGHT +(-6,2), 37×37: art frame BOTTOMRIGHT is the bar's
// (full-width, bottom-anchored) corner (1024,0) ⇒ toggle x[981,1018] y[2,39], center
// (999.5,20.5). That arithmetic is THIS file's — the button is `BagFrame.xml`'s and its seat
// is `ActionBar.xml`'s — so the click stays at literal coordinates rather than going through
// `centre_of`: hitting them is part of what is being tested.
s.mouse_button(999.0, 20.0, "LeftButton", true);
s.mouse_button(999.0, 20.0, "LeftButton", false);
s.resolve();
let quads = s.extract();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
let r = quads
assert!(bag_open(&s, 0), "the bar's toggle opened the backpack");
let quads = s.extract();
let icon = quads
.iter()
.find(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. }
@@ -876,9 +860,16 @@ fn shipped_bag_frame_drives_end_to_end() {
})
.and_then(|q| q.rect)
.expect("slot 1 icon visible after toggle");
assert_eq!(
(r.left, r.bottom, r.right, r.top),
(849.0, 223.0, 886.0, 260.0)
// WHERE inside the window that well sits is the reference's arithmetic, not ours
// (`ContainerFrame_GenerateFrame` + `updateContainerFrameAnchors`), so it is asked of the
// button rather than pinned to numbers this tree no longer owns — and asked by GetID, since
// the reference numbers its buttons backwards (`…Item1` is the bag's LAST slot). The property
// is what it always was: the fed stack paints in the well that says it is game slot 1.
let button = bag_slot_button(&s, 0, 1);
let (bx, by) = centre_of(&mut s, &button);
assert!(
icon.left <= bx && bx <= icon.right && icon.bottom <= by && by <= icon.top,
"the jerky icon {icon:?} is not painted on slot 1's button ({button} at {bx},{by})"
);
// The stack count renders as text.
assert!(
@@ -888,10 +879,10 @@ fn shipped_bag_frame_drives_end_to_end() {
"stack count shows"
);
// LEFT-click picks the item up onto the cursor — a local drag, no wire use queued until a place.
// The top-left well center is (867,241).
s.mouse_button(867.0, 241.0, "LeftButton", true);
s.mouse_button(867.0, 241.0, "LeftButton", false);
// LEFT-click picks the item up onto the cursor — a local drag, no wire use queued until a
// place (ref ContainerFrameItemButton_OnClick's left arm: PickupContainerItem).
s.mouse_button(bx, by, "LeftButton", true);
s.mouse_button(bx, by, "LeftButton", false);
assert!(
s.take_container_uses().is_empty(),
"left-click is a pickup, not a use"
@@ -905,25 +896,38 @@ fn shipped_bag_frame_drives_end_to_end() {
"a pickup alone queues no move"
);
// RIGHT-click while holding cancels the pickup (client-side; nothing sent).
s.mouse_button(867.0, 241.0, "RightButton", true);
s.mouse_button(867.0, 241.0, "RightButton", false);
assert!(
s.cursor_item().is_none(),
"right-click cancelled the held cursor"
// RIGHT-click while holding. **This assertion inverted with 1751, and the inversion is the
// point of recording it.** Our own `BenillaBagSlot_OnClick` had a cursor-cancel arm — a
// benilla divergence — so this used to assert that the pickup was cancelled and nothing sent.
// The reference's right arm has no cursor test at all: it falls through to
// `UseContainerItem(bag, slot)` like any other right-click, and the cursor keeps its payload.
// Pinned as what it now IS rather than dropped, so the swap is visible here. Whether the host
// should treat a use-while-holding as a place is a fidelity question for the bag arc, not
// this file's to answer.
s.mouse_button(bx, by, "RightButton", true);
s.mouse_button(bx, by, "RightButton", false);
assert_eq!(
s.take_container_uses(),
vec![(0, 1)],
"the reference's right arm uses the slot even with a full cursor"
);
assert!(s.take_container_uses().is_empty(), "cancel sends nothing");
assert!(
s.cursor_item().is_some(),
"…and leaves the held item where it was"
);
s.run("ClearCursor()").unwrap();
// RIGHT-click with an empty cursor uses slot 1 (UseContainerItem → the app's use/equip fork).
s.mouse_button(867.0, 241.0, "RightButton", true);
s.mouse_button(867.0, 241.0, "RightButton", false);
s.mouse_button(bx, by, "RightButton", true);
s.mouse_button(bx, by, "RightButton", false);
assert_eq!(s.take_container_uses(), vec![(0, 1)]);
// Toggle again → hidden.
// Toggle again → shut.
s.mouse_button(999.0, 20.0, "LeftButton", true);
s.mouse_button(999.0, 20.0, "LeftButton", false);
s.resolve();
assert!(!jerky_visible(&s.extract()), "toggle closes the window");
assert!(!bag_open(&s, 0), "toggle closes the window");
assert!(!jerky_visible(&s.extract()), "…and its slots with it");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+46 -12
View File
@@ -69,12 +69,12 @@ const ADDONS_DIR: &str = "AddOns";
/// loading screen with zero diagnostics (B271's class).
pub(crate) const LOAD_INSTRUCTION_BUDGET: u64 = 200_000_000;
/// Where one addon's files come from.
/// Where one interface's files come from.
///
/// The two arms are the same question asked of two stores, and keeping them one enum is what lets
/// [`Addon::load`] be written once: our own interface and a third-party folder differ in where the
/// bytes live, not in how they load.
enum Source {
/// The three arms are the same question asked of three stores, and keeping them one enum is what
/// lets [`Addon::load`] be written once: our own interface, a third-party folder and the player's
/// own install differ in where the bytes live, not in how they load.
pub(super) enum Source {
/// benilla's own interface — the compiled-in tree, which a dev build shadows with `assets/ui`
/// on disk so editing FrameXML costs no recompile ([`content`], 1175).
Builtin,
@@ -83,6 +83,11 @@ enum Source {
/// `BagBrother/core/core.xml` it includes are both expressible — which is the point, since a
/// shared library addon exists to be reached from its dependents.
Dir(PathBuf),
/// **The player's own installed patch chain** — the reference FrameXML this client sources
/// rather than shipping a copy of (decision 1751, [`super::reference_ui`]). Paths are full
/// chain-internal paths (`Interface\FrameXML\ContainerFrame.xml`), so the prefix is empty and
/// the chain resolves `/` and `\` alike, case-insensitively.
Chain,
}
/// One loadable interface: a name, its parsed manifest, and where its files come from.
@@ -96,6 +101,12 @@ pub(super) struct Addon {
}
impl Addon {
/// An interface from an explicit source — [`super::reference_ui::addon`]'s constructor, and
/// the only one that is not derived from a `.toc` on some store.
pub(super) fn new(name: String, toc: Toc, source: Source) -> Self {
Addon { name, toc, source }
}
/// benilla's own interface. Always present — it is in the binary (1175), so unlike a
/// discovered addon it cannot be missing.
pub(super) fn builtin() -> Self {
@@ -144,18 +155,40 @@ impl Addon {
.map(String::into_bytes)
}
Source::Dir(root) => read_under(root, req),
Source::Chain => super::reference_ui::read(req),
}
}
/// This addon's own folder in its source's path space — the `base` its manifest entries are
/// relative to. `""` for the builtin's flat tree, the addon's folder name for a `Dir`.
/// relative to. `""` for the builtin's flat tree, the addon's folder name for a `Dir`, and
/// `""` again for the chain, whose manifest entries are already full internal paths.
fn prefix(&self) -> &str {
match &self.source {
Source::Builtin => "",
Source::Builtin | Source::Chain => "",
Source::Dir(_) => &self.name,
}
}
/// The name Lua gives a chunk from this source — **not cosmetic**, because addons parse it.
///
/// An addon's file is named the way the client names it, `Interface\AddOns\<Folder>\<File>`
/// ([`benilla_ui::script::addon_chunk_name`], whose header carries the FuBar `debugstack`
/// story). A **chain** file is named after its own chain path, `@Interface\FrameXML\…`, which
/// is both what the real client names it and what keeps FrameXML *out* of the `\AddOns\`
/// pattern those addons match against — FrameXML is not an addon.
///
/// The loader gives an inline `<Script>` and a `<Script file=>` exactly this shape already
/// (`loader::Loader::run`), so a chain document's Lua is named identically whether it arrives
/// as a manifest entry or through its own `<Script file=>`.
fn chunk_name(&self, file: &str) -> String {
match &self.source {
Source::Builtin | Source::Dir(_) => {
benilla_ui::script::addon_chunk_name(&self.name, file)
}
Source::Chain => format!("@{}", file.replace('/', "\\")),
}
}
/// Load this addon's `.toc`-listed files into the VM, in listed order. Returns per-file
/// errors, each tagged `"<Addon>/<file>: <error>"`.
///
@@ -193,7 +226,11 @@ impl Addon {
// a broken addon must not read as a client ERROR, which is gate-fatal to
// `smoke.sh`'s zero-ERROR count (1450).
match self.source {
Source::Builtin => error!("ui_script: {e}"),
// Ours, and the chain list is ours too: a `benilla.toc` line naming a file the
// player's install does not hold is our manifest being wrong about the 1.12
// chain, not the player's copy being wrong. (No install at all is a different
// thing, warned once by `reference_ui::chain`.)
Source::Builtin | Source::Chain => error!("ui_script: {e}"),
Source::Dir(_) => warn!("ui_script: {e}"),
}
// Retained where the player can read it (1495). This is the single commonest way
@@ -207,10 +244,7 @@ impl Addon {
// The same execution `<Script file=>` gets (`loader::mod.rs`): one chunk, run in
// the one global state, in manifest order. The reference does exactly this —
// `AddOn_Load 0x51f240` hands each listed file to `0x6edb90` regardless of kind.
match script.run_chunk_named(
&bytes,
&benilla_ui::script::addon_chunk_name(&self.name, file),
) {
match script.run_chunk_named(&bytes, &self.chunk_name(file)) {
Ok(()) => info!("ui_script: {}/{file} ran", self.name),
Err(e) => {
let e = format!("{}/{file}: {e}", self.name);
File diff suppressed because it is too large Load Diff
+97 -51
View File
@@ -1,47 +1,58 @@
//! The shipped **bank window** driven end-to-end, engine-only (no Bevy): the real
//! `assets/ui/BankFrame.xml` loaded behind `UiPanels.xml`/`BagFrame.xml`/`MerchantFrame.xml` (the
//! coin rig + the container-slot family it reuses) — decision 0604 phase 4's machine checks. Split
//! from `merchant_tests`/`bag_tests` along the folder's one-file-per-window convention.
//! `assets/ui/BankFrame.xml` loaded behind the bag chain (the coin rig, our bag bar, and the
//! reference's own `ContainerFrame.xml` off the player's install) — decision 0604 phase 4's
//! machine checks. Split from `merchant_tests`/`bag_tests` along the folder's one-file-per-window
//! convention.
//!
//! **What decision 1751 changed in here.** The bank used to own six popout windows of its own —
//! `BenillaBankBagFrame1..6`, six copies of `BagFrame.xml`'s window template — and the tests drove
//! them by name. They are gone: a bank bag is an ordinary `ContainerFrame` at container id
//! `NUM_BAG_SLOTS + slot` (5..10), which is what the real client always did and what decision 0604
//! already said it did. So the popout assertions below ask [`bag_open`] — the reference's own
//! `IsBagOpen` scan — instead of naming a window. The twelve windows are RECYCLED across every
//! container, so which `ContainerFrame<N>` a bank bag lands in is a coincidence and never an
//! assertion.
//!
//! The knock-on for the fixtures: the reference generates a window only for a container that
//! exists (`OpenBag`'s `size > 0` gate), where our own `BenillaBagFrame*` were static frames that
//! `Show()` regardless. So a test that wants the backpack open has to feed a backpack, and one
//! that wants a bank bag open has to feed a bag at id 5 — which is the honest fixture anyway: a
//! player always has a 16-slot backpack, and an empty bank-bag slot has no window in the real
//! client either.
use benilla_ui::script::{
BankState, ContainerSlot, ContainerState, ExtractedQuad, QuadContent, ScriptValue,
SoundRequest, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the folder's own
/// per-file copy of the same helper — merchant_tests/bag_tests keep one each too).
fn load_xml(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
report.frames
use super::test_ui::{bag_open, load_ui as load_xml, BAG_UI};
/// A container fixture with no items in it — the shape every "is this window open" test wants.
fn empty_bag(name: &str, num_slots: u32) -> Option<ContainerState> {
Some(ContainerState {
name: Some(name.into()),
num_slots,
slots: std::collections::HashMap::new(),
})
}
/// The bank's own dependency chain (the app's real load order — `ui_script/mod.rs`): Fonts before
/// anything with a font `inherits`, UiPanels before any UIPanel/StaticPopup use, Cooldown+BagFrame
/// before BankFrame reuses their slot/window templates and Lua family, GameTooltip before any
/// slot's hover, MerchantFrame before BankFrame's `BenillaMoney_*` coin-rig calls.
/// The bank's own dependency chain, in `benilla.toc` order: [`BAG_UI`] carries it whole — Fonts
/// before anything with a font `inherits`, the four templates stock `ContainerFrame.xml` inherits,
/// UiPanels before any UIPanel/StaticPopup use, GameTooltip before any slot's hover, the
/// reference's own container file, our bag bar, and `BankFrame.xml` itself (which BAG_UI already
/// needs, because `updateContainerFrameAnchors` measures every open bag against
/// `BankFrame:GetRight()`). Then `MerchantFrame.xml` for the `BenillaMoney_*` coin rig the bank's
/// purse and cost rows call — a global resolved at call time, so it may land after.
///
/// **Every caller needs client data**: `BAG_UI` names a chain entry, so each test below opens with
/// `wow_data_or_skip!`.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "GameTooltip.xml");
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "BankFrame.xml");
s
}
@@ -53,10 +64,15 @@ fn has_icon(quads: &[ExtractedQuad], needle: &str) -> bool {
/// Test 1 — BANKFRAME_OPENED shows the frame (through ShowUIPanel, landing at the left slot — the same
/// NPC-session chain Merchant/Trainer use), sets the title to the event arg (falling back to
/// "Banker" before any event ever fires), and opens the backpack windows (the 0561 assertion
/// "Banker" before any event ever fires), and opens the backpack window (the 0561 assertion
/// pattern — `vendor_opens_and_closes_all_equipped_bags` in merchant_tests).
///
/// The backpack it opens is the reference's own `ContainerFrame` now (1751), so the assertion is
/// `IsBagOpen(0)` rather than a window name — and the backpack has to be FED, because the
/// reference builds a window only for a container that exists.
#[test]
fn bankframe_opened_shows_sets_title_and_opens_backpack() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
// Pre-open default: the XML-authored placeholder.
@@ -70,6 +86,7 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
let _ = s.take_sounds(); // ignore anything from load (every frame is hidden; nothing should fire)
s.set_money(0);
s.set_container(0, empty_bag("Backpack", 16));
s.set_bank(Some(BankState::default()));
s.fire_event(
"BANKFRAME_OPENED",
@@ -84,7 +101,7 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
"Grumnus Steelshaper"
);
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
bag_open(&s, 0),
"opening the bank opens the backpack (the 0561 OpenBackpack contract)"
);
assert!(
@@ -103,24 +120,34 @@ fn bankframe_opened_shows_sets_title_and_opens_backpack() {
}
/// Test 2 — Hiding the frame (BANKFRAME_CLOSED, routed through HideUIPanel) queues the CloseBankFrame
/// intent (`take_bank_close()`), closes any open bank-bag popout with it, and plays the close
/// intent (`take_bank_close()`), closes any open bank bag with it, and plays the close
/// sound — the reference's own OnHide order (CloseBankBagFrames(); CloseBankFrame(); PlaySound).
///
/// **"Popout" is this test's own history, not a widget any more** (1751). It was
/// `BenillaBankBagFrame1`, one of six copies of `BagFrame.xml`'s window template that this file
/// owned, and the assertion was `:Show()` it then check `:IsShown()`. A bank bag is an ordinary
/// container now — id `NUM_BAG_SLOTS + 1` = 5 — so it is OPENED with the reference's own `OpenBag`
/// and asked for with `IsBagOpen`, and `CloseBankBagFrames` is the reference's verbatim
/// `CloseBag(5..10)` loop. The property is unchanged and is the point of the test: closing the
/// bank must close the bank BAGS, not merely play a sound.
#[test]
fn bankframe_closed_queues_close_closes_open_popouts_and_plays_the_close_kit() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_money(0);
// The backpack (the bank's own OnShow opens it) and a bag in the FIRST bank bag slot —
// container 5, which is where `BenillaBankBagButton_OnClick` sends `BankBagButton1`.
s.set_container(0, empty_bag("Backpack", 16));
s.set_container(5, empty_bag("Bank Bag", 8));
s.set_bank(Some(BankState::default()));
s.fire_event("BANKFRAME_OPENED", vec![ScriptValue::Str("Banker".into())]);
let _ = s.take_sounds();
let _ = s.take_bank_close(); // nothing queued yet — the drain would otherwise see a stale flag
// A bank-bag popout open when the main window closes (ref CloseBankBagFrames' own reason to
// exist) — Show() directly, exercising the SAME BenillaBagFrame_OnShow the equipped-bag
// windows use (BagFrame.xml, reused verbatim).
s.run("BenillaBankBagFrame1:Show()").unwrap();
assert!(s
.eval::<bool>("return BenillaBankBagFrame1:IsShown()")
.unwrap());
// A bank bag open when the main window closes (ref CloseBankBagFrames' own reason to exist) —
// opened through the reference's own verb, which is what the bag button calls.
s.run("OpenBag(5)").unwrap();
assert!(bag_open(&s, 5), "the bank bag's window is up");
let _ = s.take_sounds();
s.fire_event("BANKFRAME_CLOSED", vec![]);
@@ -131,9 +158,8 @@ fn bankframe_closed_queues_close_closes_open_popouts_and_plays_the_close_kit() {
"BANKFRAME_CLOSED hides the window"
);
assert!(
!s.eval::<bool>("return BenillaBankBagFrame1:IsShown()")
.unwrap(),
"closing the bank closes the popout it left open (CloseBankBagFrames)"
!bag_open(&s, 5),
"closing the bank closes the bank bag it left open (CloseBankBagFrames)"
);
assert!(
s.take_bank_close(),
@@ -154,6 +180,7 @@ fn bankframe_closed_queues_close_closes_open_popouts_and_plays_the_close_kit() {
/// PLAYERBANKSLOTS_CHANGED(3) repaints it after a later change.
#[test]
fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_changed() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_money(0);
s.set_bank(Some(BankState::default()));
@@ -223,6 +250,7 @@ fn item_slot_paints_from_container_minus_one_and_repaints_on_playerbankslots_cha
/// button 1's icon.
#[test]
fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_feed() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_money(0);
let mut state = BankState {
@@ -305,6 +333,7 @@ fn bag_buttons_tint_by_purchase_count_and_texture_from_the_bank_bag_feed() {
/// 6 (`GetNumBankSlots()`'s `full`).
#[test]
fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_money(1_000_000);
s.set_bank(Some(BankState {
@@ -360,12 +389,20 @@ fn purchase_flow_shows_popup_queues_the_intent_and_the_row_hides_when_full() {
/// `BankItemButtonBagTemplate` OnLoad runs `BankFrameBagButton_OnLoad` →
/// `BankFrameBaseButton_OnLoad`, which registers `("LeftButtonUp","RightButtonUp")`
/// (BankFrame.lua:12), and `BankFrameItemButtonBag_OnClick` reads no button. Ours registered
/// nothing, so the widget default (`{"LeftButtonUp"}`) swallowed every right-click. Asserted on
/// the click sound the handler plays before any of its own forks — the one observable that does
/// not need a bag object fed into the slot.
/// nothing, so the widget default (`{"LeftButtonUp"}`) swallowed every right-click.
///
/// It was asserted on the click sound alone, because "the one observable that does not need a bag
/// object fed into the slot" was all there was while the popout was a `BenillaBankBagFrame` this
/// window drove by hand. 1751 gave the handler a real tail — `ToggleBag(NUM_BAG_SLOTS + id)` — so
/// a bag IS fed here now and the right-click is followed all the way to the window it opens.
/// That is strictly more of the same property: the right button reaches the whole OnClick body,
/// not just its first line.
#[test]
fn a_bank_bag_button_answers_the_right_button_too() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
// A bag in bank slot 1 ⇒ container id NUM_BAG_SLOTS + 1 = 5, the id the button toggles.
s.set_container(5, empty_bag("Bank Bag", 8));
s.set_bank(Some(BankState::default()));
s.fire_event("BANKFRAME_OPENED", vec![]);
s.resolve();
@@ -382,10 +419,18 @@ fn a_bank_bag_button_answers_the_right_button_too() {
assert!(consumed, "the right-click lands on the button");
assert_eq!(
s.take_sounds(),
vec![benilla_ui::script::SoundRequest::KitName(
"BAGMENUBUTTONPRESS".into()
)],
"a right-click runs the same OnClick a left-click does"
vec![
benilla_ui::script::SoundRequest::KitName("BAGMENUBUTTONPRESS".into()),
benilla_ui::script::SoundRequest::KitName("igBackPackOpen".into()),
],
"a right-click runs the same OnClick a left-click does — the button's own press kit, \
then the window the ToggleBag tail opened"
);
assert!(bag_open(&s, 5), "the right-click opened the bank bag");
assert!(
s.eval::<bool>("return BankBagButton1:GetChecked() and true or false")
.unwrap(),
"…and lit the button (UpdateBagButtonHighlight, this file's SetChecked divergence)"
);
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
}
@@ -396,6 +441,7 @@ fn a_bank_bag_button_answers_the_right_button_too() {
/// actually under test).
#[test]
fn clicking_item_slot_one_with_an_empty_cursor_queues_the_pickup() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = setup();
s.set_money(0);
@@ -188,6 +188,15 @@ const BOTTOM_EXEMPT: &[(&str, &str)] = &[
("ChatFrame5", "as ChatFrame3"),
("ChatFrame6", "as ChatFrame3"),
("ChatFrame7", "as ChatFrame3"),
(
"BlackoutWorld",
"the world map's full-screen cover (WorldMapFrame.xml) — TOPLEFT+BOTTOMRIGHT is how it \
fills the screen, not a seat in the contested band, and clearance is the opposite of \
what it wants: a blackout lifted over the action bars would leave a strip of world \
showing under the map. It joins this population at all only because decision 1757 took \
its parent=UIParent away, which is what lets it survive the UIParent:Hide() that \
showing the map performs",
),
];
/// **A top-level frame anchored to the screen's bottom edge is either managed, or exempt with a
@@ -226,3 +226,50 @@ fn a_cinematic_hides_the_hud_through_uiparent_and_spares_the_cinematic_frame() {
assert!(visible(&s, "UIParent"), "and the world's UI comes back");
assert!(visible(&s, "StaticPopup1"), "with the child that was up");
}
/// **The screenshot confirmation survives the HUD hide — because the reference seats it outside
/// the HUD.** `WorldFrame.xml`'s own header states the law in one line: *"Children of the world
/// frame are visible even when the UI is turned off."* `ScreenshotStatus` is declared inside that
/// frame, and the case that needs it is this file's: `CinematicFrame`'s `OnKeyDown` hands the
/// SCREENSHOT key back to its binding by hand (1701) precisely so a fly-by can be captured, so the
/// one moment the confirmation is most certainly wanted is the one where `UIParent` is hidden.
///
/// Ours sat on `UIParent` until decision 1757, on a note that read the two seats as equivalent
/// because both frames are full-screen and their CENTERs coincide — true of the geometry, and
/// silent about the hide.
#[test]
fn the_screenshot_confirmation_shows_during_a_cinematic() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
let failures = super::load_default_ui(&s);
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.resolve();
let visible = |s: &UiScript, f: &str| {
s.eval::<i64>(&format!(
"local x = getglobal('{f}') if not x then return -1 end return x:IsVisible() and 1 or 0"
))
.unwrap()
};
start_cinematic(&mut s);
assert_eq!(visible(&s, "UIParent"), 0, "the fly-by hid the HUD");
// The engine's own report of a finished capture (`crate::screenshot` → SCREENSHOT_SUCCEEDED).
s.fire_event("SCREENSHOT_SUCCEEDED", vec![]);
s.resolve();
assert_eq!(
visible(&s, "ScreenshotStatus"),
1,
"\"Screen Captured\" is readable over the fly-by — a confirmation that cannot appear \
while the UI is hidden is no confirmation, and the cinematic is the case the reference \
hands the key back for"
);
assert_eq!(
s.eval::<String>("return ScreenshotStatusText:GetText()")
.unwrap(),
"Screen Captured",
"with the reference's own string"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+5 -1
View File
@@ -92,6 +92,10 @@ pub(crate) fn digest() -> String {
eat(toc.as_bytes());
}
for name in super::addons::Addon::builtin().toc.files {
// A chain entry (1751) contributes its NAME only: its bytes are the player's install,
// which does not move between two runs on one machine — and this stamp's only question is
// *were these two runs looking at the same interface*. Migrating a window still changes the
// digest, because the manifest line changes.
eat(name.as_bytes());
if let Some(text) = read(&name) {
eat(text.as_bytes());
@@ -116,7 +120,7 @@ mod tests {
/// `WOW_CAPTURE_UI=1`, so nothing failed. Now a name that is not in the binary fails here.
#[test]
fn every_manifest_entry_is_compiled_in() {
for name in crate::ui_script::manifest::manifest_files() {
for name in crate::ui_script::manifest::shipped_manifest_files() {
assert!(
super::read(&name).is_some(),
"benilla.toc names {name}, which is not in assets/ui"
@@ -12,24 +12,7 @@
use benilla_ui::script::{ContainerSlot, ContainerState, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the panel/loot
/// tests' loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
report.frames
}
use super::test_ui::{bag_open, bag_slot_button, load_ui as load_xml, BAG_UI};
/// A one-item, one-slot backpack holding `item_id`/`name` at `quality`, so the confirm text and
/// the wire's destroy count are exercisable end to end. Quality is what forks the driver, so it is
@@ -64,15 +47,33 @@ fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
}
}
/// The popup engine and its driver, and nothing else: `UiPanels.xml` owns both `StaticPopup*` and
/// `BenillaDeleteItemConfirmDriver`, and every test but the repaint one below only ever asks about
/// the dialog. It carried `MerchantFrame.xml` + `Cooldown.xml` + `BagFrame.xml` until 1751 for one
/// reason — the repaint test used to count `BenillaBagFrame_Update` — and that test now brings the
/// reference's own bag stack itself ([`bag_setup`]), so the whole tail went with it.
fn setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set, BagFrame's isolation dep
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
s
}
/// [`setup`] plus the reference's bag stack — the harness for the one test that has to watch a bag
/// WINDOW repaint. The windows are `ContainerFrame1..12` off the player's chain now (1751), so
/// this needs client data and the caller opens with `wow_data_or_skip!`.
fn bag_setup() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for file in BAG_UI {
load_xml(&s, file);
}
// `ContainerFrame_Update` reads `MerchantFrame:IsShown()` on any slot the tooltip owns, and
// `BankFrame` (BAG_UI's last entry) fills its purse through `BenillaMoney_Set`.
load_xml(&s, "MerchantFrame.xml");
s.set_money(0);
s
}
@@ -142,17 +143,39 @@ fn delete_item_confirm_shows_the_real_strings_and_yes_deletes() {
/// No runs the entry's OnCancel (`ClearCursor()`) instead — the item is dropped, not destroyed (no
/// wire send) — and the bag REPAINTS off the clear's `ITEM_LOCK_CHANGED`, un-darkening the source
/// slot. The repaint is the 0218 stuck-darkening regression: the popup's No is a clear the bag
/// never clicked through, so only the event wiring (BagFrame's ITEM_LOCK_CHANGED registration)
/// can reach it.
/// never clicked through, so only the event wiring (the container window's own ITEM_LOCK_CHANGED
/// registration) can reach it.
///
/// **What 1751 changed, and what it did not.** The repaint counted here used to be
/// `BenillaBagFrame_Update`; the window is the reference's `ContainerFrame` now, so the body is
/// its own `ContainerFrame_Update(frame)` (`ContainerFrame.lua:234`) off the player's chain. Two
/// consequences, both faithful: the window has to be OPEN (`ContainerFrame_OnEvent` gates the
/// repaint on `this:IsShown()` at l.39-42, where ours repainted a hidden window too), and the
/// darkening the repaint undoes is the reference's own `SetItemButtonDesaturated(button, locked)`
/// at l.245 — so the slot's greyscale flag is asserted directly here rather than only through the
/// repaint that clears it.
#[test]
fn delete_item_confirm_no_clears_without_destroying() {
let mut s = setup();
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_setup();
s.set_container(0, Some(one_item_backpack(117, "Tough Jerky", 1)));
s.run("ToggleBackpack()").unwrap();
assert!(bag_open(&s, 0), "the backpack window is up");
// Asked of the buttons' own GetID: `ContainerFrame_GenerateFrame` numbers a window's buttons
// BACKWARDS, so slot 1 is the LAST `…Item<j>`, and index arithmetic would pin a coincidence.
let slot1 = bag_slot_button(&s, 0, 1);
pick_up_and_drop_in_world(&mut s);
assert!(
desaturated(&mut s, &slot1),
"held on the cursor, the source slot draws dark (ref SetItemButtonDesaturated(_, locked))"
);
// Count repaints from here — the No-click path must trigger one via the event, not a click.
s.run(
"repaints = 0\n\
local real = BenillaBagFrame_Update\n\
BenillaBagFrame_Update = function(...) repaints = repaints + 1; return real(...) end",
local real = ContainerFrame_Update\n\
ContainerFrame_Update = function(...) repaints = repaints + 1; return real(...) end",
)
.unwrap();
@@ -173,9 +196,31 @@ fn delete_item_confirm_no_clears_without_destroying() {
.unwrap(),
"the source slot reads unlocked again"
);
assert!(
!desaturated(&mut s, &slot1),
"…and the repaint un-darkened it — the 0218 stuck-dark slot"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// Is `button`'s icon drawn greyscale? The renderer-facing answer, off the quad stream, because
/// the engine publishes `Texture:SetDesaturated` with no getter to ask — and a *drawn* grey is
/// what "the slot is stuck dark" means to the player anyway.
fn desaturated(s: &mut UiScript, button: &str) -> bool {
s.resolve();
s.extract()
.into_iter()
.filter(|q| s.quad_owner_name(q.target).as_deref() == Some(button))
.any(|q| match &q.content {
benilla_ui::script::QuadContent::Texture {
path: Some(p),
desaturated,
..
} => p.contains("INV_Misc_Food_16") && *desaturated,
_ => false,
})
}
/// ESC routes through the ref's `StaticPopup_EscapePressed` — DELETE_ITEM is `hideOnEscape`, so
/// its OnCancel runs with reason "clicked" exactly as a real No-click would. (`ToggleGameMenu`'s
/// existing unconditional `ClearCursor()` already empties the cursor before the popup branch, so
@@ -5,32 +5,42 @@
//! wearing the item, SHIFT posts its link into an open chat edit box — and, at a bag slot, SHIFT
//! with chat *closed* still opens the stack splitter it always did (the reference's own `else`,
//! and the regression these two features could most easily cause).
//!
//! **The bag-slot fork is the REFERENCE's own code since 1751.** Both forks used to run through
//! benilla's `BenillaBagSlot_OnClick`; the bag windows are the reference's recycled
//! `ContainerFrame1..12` now and the handler is its `ContainerFrameItemButton_OnClick(button,
//! ignoreModifiers)` — which takes the MOUSE button as its first argument and reads the frame from
//! `this`, so a test cannot call it directly any more. These drive the mouse instead
//! ([`super::test_ui::click`]), which is the stronger test regardless: it puts the template's own
//! `RegisterForClicks("LeftButtonUp", "RightButtonUp")` and script wiring under test too. The doll
//! slots below are still ours (`BenillaPaperDollSlot_OnClick`) and are still called directly.
use benilla_ui::script::{
ContainerSlot, ContainerState, DressUpIntent, InvSlotView, InventorySlots, SoundRequest,
UiScript,
};
use super::test_ui::{bag_slot_button, click, load_ui as load_xml, BAG_UI};
/// The jerky stack every bag fixture here uses — a real 1.12 link, quality white.
const JERKY_LINK: &str = "|cffffffff|Hitem:117|h[Tough Jerky]|h|r";
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The room's own files, past whatever bag interface the caller wants — both loaders below end
/// with these, in manifest order.
const ROOM_UI: &[&str] = &[
"UIDropDownMenu.xml",
"UnitPopup.xml",
"ItemRef.xml",
"MerchantFrame.xml",
"StackSplit.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
];
/// The shipped files the dressing room's click sites need, in manifest order.
/// The shipped files the dressing room's click sites need, in manifest order — **with no bag
/// window**, for the sites that never touch a container (a chat link, the room's own controls).
/// Deliberately install-free: those tests run on a bare checkout, which [`load_room_with_bags`]
/// cannot.
fn load_room(s: &UiScript) {
for file in [
"Fonts.xml",
@@ -38,22 +48,43 @@ fn load_room(s: &UiScript) {
"UiPanels.xml",
"UIParent.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"ItemRef.xml",
"Cooldown.xml",
"MerchantFrame.xml",
"BagFrame.xml",
"StackSplit.xml",
"DressUpFrame.xml",
"ChatFrame.xml",
] {
load_xml(s, file);
}
for file in ROOM_UI {
load_xml(s, file);
}
}
/// A backpack holding a 5-stack of Tough Jerky in slot 1, opened; returns that button's centre.
fn backpack_with_jerky(s: &mut UiScript) -> (f32, f32) {
/// [`load_room`] over the REFERENCE's bag windows (1751). `BAG_UI` is the ordered set a test needs
/// before it can open one and it already carries the fonts, panels, tooltip, item-button template
/// and bag bar the room wants, so only the room's own files follow it. `UIParent.xml` leads because
/// it inherits nothing and every window below parents to it — `BAG_UI` has no line for it.
///
/// **Needs client data**: `BAG_UI` names a chain entry, so its callers open with
/// `wow_data_or_skip!`.
fn load_room_with_bags(s: &UiScript) {
load_xml(s, "UIParent.xml");
for file in BAG_UI {
load_xml(s, file);
}
for file in ROOM_UI {
load_xml(s, file);
}
}
/// A backpack holding a 5-stack of Tough Jerky in slot 1, opened; returns the NAME of the button
/// showing that slot.
///
/// **Asked, never derived** (1751). The window is one of the reference's recycled
/// `ContainerFrame1..12` — which one depends on what else is open — and its buttons are numbered
/// BACKWARDS (`ContainerFrame_GenerateFrame`: `index = size - j + 1`, so `…Item1` is the bag's last
/// slot), so [`bag_slot_button`] finds the button whose own `GetID()` is the slot. The old body
/// scanned `BenillaBagSlot<N>` for a `.slot` field and handed back a centre; neither those frames
/// nor that field exist, and the centre is [`click`]'s business now.
fn backpack_with_jerky(s: &mut UiScript) -> String {
s.set_money(0);
let mut slots = std::collections::HashMap::new();
slots.insert(
@@ -79,35 +110,29 @@ fn backpack_with_jerky(s: &mut UiScript) -> (f32, f32) {
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
s.resolve();
s.run(
"BENILLA_TEST_BTN = nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 1 then BENILLA_TEST_BTN = b end\n\
end",
)
.unwrap();
s.eval(
"return (BENILLA_TEST_BTN:GetLeft() + BENILLA_TEST_BTN:GetRight()) / 2, \
(BENILLA_TEST_BTN:GetTop() + BENILLA_TEST_BTN:GetBottom()) / 2",
)
.unwrap()
bag_slot_button(s, 0, 1)
}
/// CTRL + left-click on a bag item opens the dressing room and puts that item on — the ref's
/// `DressUpItemLink(GetContainerItemLink(...))` (ContainerFrame.lua:565-566). The two intents are
/// ordered: `Dress` (the window was closed, so it re-dresses in the player's own gear first) then
/// `TryOn` — reversed, the room would show the player's own gear and not the clicked item.
///
/// Since 1751 that arm is the reference's OWN `ContainerFrameItemButton_OnClick` rather than a
/// transcription of it, so the cited line numbers are now the live code's.
#[test]
fn ctrl_click_on_a_bag_item_opens_the_room_wearing_it() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
let (x, y) = backpack_with_jerky(&mut s);
load_room_with_bags(&s);
let btn = backpack_with_jerky(&mut s);
assert!(!s.eval::<bool>("return DressUpFrame:IsVisible()").unwrap());
// The modifier is pushed BEFORE the mouse, exactly as the app's input pass does it: the click
// handler's fork reads the state as of the click.
s.set_modifiers(false, true, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
click(&mut s, &btn, "LeftButton");
s.set_modifiers(false, false, false);
assert!(
@@ -130,17 +155,23 @@ fn ctrl_click_on_a_bag_item_opens_the_room_wearing_it() {
/// opens the stack splitter when it is not — the reference's own if/else (ContainerFrame.lua:
/// 567-577). Both halves in one test because it is the *fork* that matters: the split was the
/// behaviour that already shipped, and the insert must not have eaten it.
///
/// Since 1751 the fork is the reference's own body and the splitter it opens is still ours
/// (`OpenStackSplitFrame`, StackSplit.xml): the reference reaches it through the `this.SplitStack`
/// closure `ContainerFrameItemButton_OnLoad` installs, so what this now also covers is that seam
/// holding — a stack split confirmed from a bag slot still calls `SplitContainerItem` with the
/// window's own bag id and the button's own slot id.
#[test]
fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_room(&s);
let (x, y) = backpack_with_jerky(&mut s);
load_room_with_bags(&s);
let btn = backpack_with_jerky(&mut s);
// Chat closed → the splitter, exactly as before.
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
click(&mut s, &btn, "LeftButton");
s.set_modifiers(false, false, false);
assert!(
s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap(),
@@ -151,8 +182,7 @@ fn shift_click_posts_the_link_with_chat_open_and_splits_with_it_closed() {
// Chat open → the link, and no splitter.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.mouse_button(x, y, "LeftButton", true);
s.mouse_button(x, y, "LeftButton", false);
click(&mut s, &btn, "LeftButton");
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -1,23 +1,22 @@
//! **The ESCAPE ladder** — `ToggleGameMenu`'s `elseif` chain (`UIParent.lua:1465-1497`) and the
//! host-glue precedence around it (a focused EditBox eats the key before the binding ever runs).
//!
//! The window these tests park in the ladder's way is a BAG, and since decision 1751 that is the
//! reference's own `ContainerFrame1..12`, executed off the player's installed patch chain — not
//! `BenillaBagFrame`, which no longer exists. Two consequences run through the file:
//!
//! * a bag test loads [`BAG_UI`] and opens with `wow_data_or_skip!`, because a chain entry needs
//! client data; and
//! * "is the bag open?" is asked of [`bag_open`] (the reference's own `IsBagOpen` scan), never of
//! a frame by name — the twelve windows are RECYCLED, so which one a bag lands in is a
//! coincidence and not a property.
//!
//! The ladder itself is unchanged by that swap: `CloseAllWindows` (ours, `UiPanels.xml`) sweeps
//! `ContainerFrame1..NUM_CONTAINER_FRAMES` where it used to sweep our five named windows.
use benilla_ui::script::{ContainerSlot, ContainerState, LootRow, LootState, UiScript};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the panel/loot
/// tests' loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
report.frames
}
use super::test_ui::{bag_open, bag_slot_button, click, load_ui as load_xml, BAG_UI};
/// A one-item backpack (slot 1 holds a resolved item, so it is pickable onto the cursor).
fn one_item_backpack() -> ContainerState {
@@ -55,21 +54,23 @@ fn one_item_backpack() -> ContainerState {
/// the left slot, and an item on the cursor, ESC closes the bag AND the panel slot, releases the
/// loot (OnHide → CloseLoot), and drops the held cursor. Also asserts the host-glue precedence: no
/// EditBox focused ⇒ `key_input("ESCAPE")` does not consume, so the app runs the binding.
///
/// The bag half is the reference's window now (1751), so what `CloseAllWindows` must sweep is a
/// `ContainerFrame` — the same assertion, asked through `IsBagOpen` instead of a frame name.
#[test]
fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set, used by the bag's Update
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
for file in BAG_UI {
load_xml(&s, file);
}
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize — the
// shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.110 vs 478), and the
// kit's backdrop reads TOOLTIP_DEFAULT_COLOR out of GameTooltip.xml (already in BAG_UI).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set, BankFrame's purse helper
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
@@ -96,10 +97,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
// Pick up slot 1 onto the cursor.
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "cursor holds the picked item");
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"bag is open before ESC"
);
assert!(bag_open(&s, 0), "bag is open before ESC");
assert!(
s.eval::<bool>("return GetLeftFrame():GetName() == \"LootFrame\"")
.unwrap(),
@@ -115,10 +113,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
// The escape binding (what the host runs on the unconsumed ESC).
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"ESC closed the bag"
);
assert!(!bag_open(&s, 0), "ESC closed the bag");
assert!(
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"ESC vacated the panel slot"
@@ -141,16 +136,16 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
/// clears the focus (chat_tests covers the box's submit/close contract itself).
#[test]
fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "ChatFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
assert!(
@@ -164,7 +159,7 @@ fn escape_is_consumed_by_a_focused_editbox_and_leaves_windows_open() {
// The box's OnEscapePressed cleared its own focus, but the bag window is untouched.
assert!(!s.has_keyboard_focus(), "the box cleared its focus");
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
bag_open(&s, 0),
"the bag stays open (the escape binding never ran)"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -220,17 +215,22 @@ fn escape_closes_the_options_window_before_opening_the_menu() {
/// has no plain-frame keyboard capture (the real client's own `StackSplitFrame` OnKeyDown ESCAPE
/// arm can't be driven), so the hook rides `ToggleGameMenu`'s shared chain instead — checked
/// right after the confirm popup and before the world map, both similarly transient overlays.
///
/// The spinner is opened by the REFERENCE's own shift fork now (1751,
/// `ContainerFrame.lua:567-577`), reached by a real SHIFT + left-click on a `ContainerFrame` item
/// button — which is why `ChatFrame.xml` is loaded: that fork's first test is
/// `ChatFrameEditBox:IsShown()` (a shift-click links the item into an open chat box instead of
/// splitting it), and the reference indexes that global unguarded.
#[test]
fn escape_closes_an_open_stack_split_frame() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "MerchantFrame.xml"); // ContainerFrameItemButton_OnClick reads MerchantFrame
load_xml(&s, "ChatFrame.xml"); // …and ChatFrameEditBox, the shift fork's first test
load_xml(&s, "StackSplit.xml");
s.set_money(0);
@@ -265,19 +265,14 @@ fn escape_closes_an_open_stack_split_frame() {
}),
);
s.run("BenillaBagToggle_OnClick()").unwrap();
s.resolve();
assert!(bag_open(&s, 0), "the backpack window is up");
// Open the spinner the same way bag_tests.rs's split tests do: SHIFT + left-click on slot 1
// (the reference fork, driven through the modifier mirror).
// Open the spinner with a real SHIFT + left-click on the button holding slot 1 — asked of the
// buttons' own `GetID()`, never by index (`ContainerFrame_GenerateFrame` numbers them
// backwards). The click goes through the engine because the reference's handler reads `this`.
let slot1 = bag_slot_button(&s, 0, 1);
s.set_modifiers(true, false, false);
s.run(
"BENILLA_TEST_BTN = nil\n\
for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i)\n\
if b and b.slot == 1 then BENILLA_TEST_BTN = b end\n\
end\n\
BenillaBagSlot_OnClick(BENILLA_TEST_BTN, \"LeftButton\")",
)
.unwrap();
click(&mut s, &slot1, "LeftButton");
s.set_modifiers(false, false, false);
assert!(s.eval::<bool>("return StackSplitFrame:IsShown()").unwrap());
@@ -304,21 +299,21 @@ fn escape_closes_an_open_stack_split_frame() {
fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
use benilla_ui::script::UnitState;
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR — the menu backdrop's OnLoad reads it
for file in BAG_UI {
load_xml(&s, file);
}
// GameTooltip.xml (BAG_UI's, for the bag slots' tooltips) also carries TOOLTIP_DEFAULT_COLOR,
// which the dropdown backdrop's OnLoad reads — so the kit can load straight after it.
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "MerchantFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
assert!(s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
assert!(bag_open(&s, 0));
s.set_unit(
"target",
Some(UnitState {
@@ -342,7 +337,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
CloseMenus sits before the cast rung)"
);
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
bag_open(&s, 0),
"the menu press must not close windows either"
);
assert!(!s.take_target_clear());
@@ -351,7 +346,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
s.set_casting(true);
s.run("ToggleGameMenu()").unwrap();
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
bag_open(&s, 0),
"ESC mid-cast is eaten by SpellStopCasting — the bag stays open"
);
assert!(
@@ -367,7 +362,7 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
s.set_casting(false);
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
!bag_open(&s, 0),
"idle ESC falls through SpellStopCasting's nil to CloseAllWindows"
);
assert!(!s.take_spell_stop(), "no stray stop request when idle");
@@ -401,19 +396,18 @@ fn escape_ladder_cast_then_windows_then_target_one_eater_per_press() {
/// rung's: an idle press must fall straight through both.
#[test]
fn escape_ladder_targeting_rung_after_cast_before_windows() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
assert!(s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
assert!(bag_open(&s, 0));
// SpellIsTargeting() mirrors the pushed state — the PetFrame-family readers' predicate.
assert!(!s.eval::<bool>("return SpellIsTargeting()").unwrap_or(false));
@@ -431,7 +425,7 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
"the cast rung answered nil — nothing casting"
);
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
bag_open(&s, 0),
"ESC while targeting must NOT also close windows"
);
@@ -452,7 +446,7 @@ fn escape_ladder_targeting_rung_after_cast_before_windows() {
s.run("ToggleGameMenu()").unwrap();
assert!(!s.take_stop_targeting(), "no stray trigger when idle");
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
!bag_open(&s, 0),
"the idle press falls through both spell rungs to CloseAllWindows"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -18,7 +18,9 @@
//! `ui/scratch/scripts-auto-enable.md` §1, VERIFIED — an `<OnEnter>`/`<OnLeave>`/`<OnMouseDown>`/
//! `<OnMouseUp>`/`<OnDragStart>` reaches the same enable primitive `0x76af00(2,-1)` the attribute
//! does) makes `enableMouse=` a poor proxy for whether the frame actually takes the mouse. Asking
//! the loaded engine — `IsToplevel()`, `IsMouseEnabled()`, `GetID()` — is immune to both.
//! the loaded engine — `IsToplevel()`, `IsMouseEnabled()`, `GetID()`, `GetParent()` — is immune to
//! both, and to a third: a `parent=` can arrive from a template or from nesting, and `GetParent()`
//! answers the same whichever way it came.
//!
//! **Divergences are an explicit list with a reason each, never a pattern.** [`KNOWN`] carries
//! them, in both directions; a new one cannot hide inside a tolerance. The list is where the
@@ -26,16 +28,36 @@
//! those want the handler (and its tooltip), never a bare `enableMouse="true"` that would swallow
//! the click and give nothing back.
//!
//! ## Three flags, and only three
//! ## Four flags, and only four
//!
//! `toplevel`, the effective mouse enable, and `id` are mechanical: the reference's value is right
//! for any frame we transcribe, and a difference is a defect. The neighbours are **not**, and are
//! deliberately out of scope rather than silently tolerated — `movable` is an inert flag for all
//! but two reference frames (nothing calls `StartMoving` on the rest, so copying it would be
//! cargo-cult), `frameStrata` decides what draws over what and is the director's call,
//! `setAllPoints` has an exact `<Size>`+`<Anchors>` equivalent that five of our pages deliberately
//! use, and `hidden` is equivalent whenever the reference hides in `OnLoad` instead. Decision 1739
//! carries the reasoning per attribute.
//! `toplevel`, the effective mouse enable, `id` and the **parent** are mechanical: the reference's
//! value is right for any frame we transcribe, and a difference is a defect. The neighbours are
//! **not**, and are deliberately out of scope rather than silently tolerated — `movable` is an
//! inert flag for all but two reference frames (nothing calls `StartMoving` on the rest, so
//! copying it would be cargo-cult), `frameStrata` decides what draws over what and is the
//! director's call, `setAllPoints` has an exact `<Size>`+`<Anchors>` equivalent that five of our
//! pages deliberately use, and `hidden` is equivalent whenever the reference hides in `OnLoad`
//! instead. Decision 1739 carries the reasoning per attribute.
//!
//! **The parent joined them at decision 1757, and it is the flag with teeth.** Until 1734 restored
//! `SetFullScreenFrame`'s `UIParent:Hide()`, a `parent=` was little more than a coordinate space;
//! after it, the seat decides whether a frame can be on screen *at all* while a fullscreen panel
//! is up. 1734 swept the direction it went looking for — 72 declarations the reference has and we
//! had dropped — and nothing asked the opposite question, so the frames we parent that the
//! reference leaves top-level went the other way in silence: opening the world map hid the map's
//! own blackout (the 3D world came back through the margins beside the 4:3 sheet — the director
//! reported it), the shared dropdown lists could not open over it, and the screenshot
//! confirmation could not appear during the fly-by whose SCREENSHOT key `CinematicFrame` hands
//! back by name. A gate that reads one direction of a two-directional property is a gate someone
//! has to remember to run backwards.
//!
//! Both ways a frame acquires a parent count, because the XML makes them look unrelated: the
//! `parent=` attribute — **on the element or on a template it inherits**, which is where the
//! reference keeps the chat frames' — and nesting inside another frame's `<Frames>`. Reading only
//! instances reports all fourteen chat frames as top-level; reading only attributes reports
//! `ScreenshotStatus` as parentless when the reference nests it in `WorldFrame` for the property
//! `WorldFrame.xml`'s header states outright: *"Children of the world frame are visible even when
//! the UI is turned off."*
//!
//! ## What it can and cannot see
//!
@@ -118,7 +140,7 @@ const MOUSE_BY_CTOR: &[&str] = &[
/// One accepted difference between benilla and the reference, with the reason it is accepted.
///
/// `frame` is benilla's name for it; `flag` is which of the three; `why` is why the difference is
/// `frame` is benilla's name for it; `flag` is which of the four; `why` is why the difference is
/// right (or, for the handler gaps, why the honest fix is not this flag).
struct Known {
frame: &'static str,
@@ -131,6 +153,7 @@ enum Flag {
Toplevel,
Mouse,
Id,
Parent,
}
impl std::fmt::Display for Flag {
@@ -139,6 +162,7 @@ impl std::fmt::Display for Flag {
Flag::Toplevel => "toplevel",
Flag::Mouse => "mouse",
Flag::Id => "id",
Flag::Parent => "parent",
})
}
}
@@ -360,6 +384,36 @@ const KNOWN: &[Known] = &[
why: "id=\"-1\" is ours: the bank window's own sentinel for \"not a container slot\", \
which the reference has no equivalent of",
},
// ── parent ─────────────────────────────────────────────────────────────────────────────────
//
// Three, and each is a seat inside the SAME tree the reference seats it in — which is the
// question this flag exists to ask (decision 1757). A frame whose seat crosses the boundary
// between UIParent's tree and the top level is a defect, because `SetFullScreenFrame` hides
// `UIParent` and everything below it; a frame seated one rung along inside that tree is not.
Known {
frame: "FriendsDropDown",
flag: Flag::Parent,
why: "the reference nests this menu HOST in FriendsFrame; ours sits on UIParent in \
ItemRef.xml, beside the SetItemRef branch that is its only caller — and it has to, \
because FriendsFrame.xml loads 262 lines further down the manifest and a forward \
parent reference resolves to nothing. Both seats are inside UIParent's tree, and a \
host frame is never shown: ToggleDropDownMenu seats the visible DropDownList1, \
which is top-level on both sides.",
},
Known {
frame: "SendMailBodyEditBox",
flag: Flag::Parent,
why: "the reference interposes SendMailScrollChildFrame between the pane and its content; \
our mail panes are the render approximation MailFrame.xml names at the site (flat \
art, no live scrollbar), so the body hangs off SendMailScrollFrame directly. Wants \
the real scroll child, not a re-seat.",
},
Known {
frame: "OpenMailInvoiceFrame",
flag: Flag::Parent,
why: "as SendMailBodyEditBox — OpenMailScrollChildFrame is the scroll child we do not \
build",
},
];
/// The extracted reference FrameXML directory, or `None` when the install isn't there.
@@ -414,6 +468,64 @@ fn collect_named(el: &Element, out: &mut HashMap<String, Element>) {
}
}
/// Every named reference frame's **enclosing frame**, if it is nested inside one.
///
/// A frame acquires its parent one of two ways, and the XML gives no hint that they are the same
/// question: `parent="X"` (on the element, or on a template it inherits — see [`resolved_attr`]),
/// or *nesting* inside another frame's `<Frames>`. The reference uses both heavily —
/// `DropDownList1` is top-level with neither, `ScreenshotStatus` carries no attribute at all and
/// is nested in `WorldFrame` — so reading only one of them is worse than reading neither. This
/// half is the nesting; `resolved_attr` is the other, and the attribute wins where both exist.
/// `enclosing` is the nearest named frame ancestor, threaded down the walk.
fn collect_nesting(
el: &Element,
enclosing: Option<&str>,
out: &mut HashMap<String, Option<String>>,
) {
let mut inner = enclosing;
if FRAME_TAGS.iter().any(|t| t.eq_ignore_ascii_case(&el.tag)) {
if let Some(name) = el.attr("name") {
if !name.contains("$parent") {
out.entry(name.to_string())
.or_insert_with(|| enclosing.map(str::to_string));
inner = Some(name);
}
}
}
for child in &el.children {
collect_nesting(child, inner, out);
}
}
/// The reference's nesting table, built over the same corpus [`reference_frames`] reads.
fn reference_nesting() -> Option<HashMap<String, Option<String>>> {
let dir = reference_dir()?;
let mut paths: Vec<PathBuf> = std::fs::read_dir(&dir)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e.eq_ignore_ascii_case("xml")))
.collect();
paths.sort();
let mut out: HashMap<String, Option<String>> = HashMap::new();
for path in paths {
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let text = String::from_utf8_lossy(&bytes);
let Ok(doc) = framexml::parse(text.trim_start_matches('\u{feff}')) else {
continue;
};
for item in &doc.items {
if let TopLevel::Template(el) | TopLevel::Instance(el) = item {
collect_nesting(el, None, &mut out);
}
}
}
Some(out)
}
/// The templates `el` inherits, **right to left** — a later name in the list overrides an earlier
/// one, so the search for an inherited value has to try the last one first.
fn inherits(el: &Element) -> impl Iterator<Item = &str> {
@@ -503,16 +615,18 @@ fn describe(frame: &str, flag: Flag, ours: &str, theirs: &str) -> String {
format!(" {frame} — {flag}: ours {ours}, reference {theirs}")
}
/// **Every frame both UIs name carries the reference's `toplevel`, mouse enable and `id`** — read
/// off the loaded engine, with [`KNOWN`] the only accepted differences.
/// **Every frame both UIs name carries the reference's `toplevel`, mouse enable, `id` and
/// parent** — read off the loaded engine, with [`KNOWN`] the only accepted differences.
///
/// Verified to fail: delete `toplevel="true"` from `CharacterFrame.xml` and this names
/// `CharacterFrame`; delete `id="1"` from `ActionButton1` and it names that.
/// `CharacterFrame`; delete `id="1"` from `ActionButton1` and it names that; put
/// `parent="UIParent"` back on `BlackoutWorld` and it names that.
#[test]
fn the_shipped_frames_carry_the_references_flags() {
let Some(reference) = reference_frames() else {
return; // no install — the same skip every client-data test here takes
};
let nesting = reference_nesting().expect("the same corpus the frames came from");
assert!(
reference.len() > 500,
"only {} reference frames parsed — the corpus scan broke, and a sweep over nothing \
@@ -526,7 +640,19 @@ fn the_shipped_frames_carry_the_references_flags() {
assert!(failures.is_empty(), "manifest load errors: {failures:#?}");
s.resolve();
// Read the three flags off the ENGINE, not off our XML — the whole point of the module.
// The loaded frame's own parent, by name — `GetParent()`, not the `parent=` attribute, so a
// frame that acquires its parent by NESTING reads the same as one that declares it. An empty
// string is a top-level frame.
let parent_of = |n: &str| -> Option<String> {
s.eval::<String>(&format!(
"local f = getglobal(\"{n}\") if not f or not f.GetParent then return \"\" end \
local p = f:GetParent() return (p and p:GetName()) or \"\""
))
.ok()
.filter(|v| !v.is_empty())
};
// Read the flags off the ENGINE, not off our XML — the whole point of the module.
let flags = |n: &str| -> Option<(bool, bool, i64)> {
s.eval::<i64>(&format!(
"local f = getglobal(\"{n}\") \
@@ -566,6 +692,24 @@ fn the_shipped_frames_carry_the_references_flags() {
let want_id: i64 = resolved_attr(theirs, &reference, "id", 0)
.and_then(|v| v.trim().parse().ok())
.unwrap_or(0);
// The declared parent wins over the nesting, and `resolved_attr` follows `inherits=` —
// which is not a detail: the reference puts the chat frames' `parent="UIParent"` on
// `FloatingChatFrameTemplate`/`ChatTabTemplate`, and reading instances alone would report
// all fourteen as top-level. That template blind spot is the one 1734's own gap analysis
// fell into (commit "the chat templates carry parent=UIParent").
//
// Parent names are then compared through the same rename map the frames themselves are: a
// benilla-only prefix on the PARENT would otherwise report every child of it as diverged.
let want_parent = resolved_attr(theirs, &reference, "parent", 0)
.map(str::to_string)
.or_else(|| nesting.get(theirs).cloned().flatten());
let parent = parent_of(name);
let parent_matches = match (&parent, &want_parent) {
(None, None) => true,
(Some(p), Some(w)) => p == w || p.strip_prefix("Benilla") == Some(w.as_str()),
_ => false,
};
let show = |p: &Option<String>| p.clone().unwrap_or_else(|| "(top-level)".into());
for (flag, differs, ours_s, theirs_s) in [
(
@@ -581,6 +725,12 @@ fn the_shipped_frames_carry_the_references_flags() {
want_mouse.to_string(),
),
(Flag::Id, id != want_id, id.to_string(), want_id.to_string()),
(
Flag::Parent,
!parent_matches,
show(&parent),
show(&want_parent),
),
] {
if !differs {
continue;
@@ -8,11 +8,19 @@
//! else); the micro button's `clicked` toggle; the native-center rule that makes the menu take
//! the screen (windows close on the way in, and nothing opens while it is up); and the camp/quit
//! countdown dialogs end to end. The Options button's own click path is options_tests'.
//!
//! The **window** most of these park in the menu's way is a bag, and since decision 1751 that is
//! the reference's own `ContainerFrame1..12` off the player's installed patch chain — so those
//! tests take [`bag_harness_with`], gate on `wow_data_or_skip!`, and ask [`bag_open`] rather than
//! naming a frame (the twelve windows are recycled). The bag BAR the greying test measures —
//! `MainMenuBarBackpackButton` and `CharacterBag0Slot`..`3Slot` — is still ours, unchanged.
use benilla_ui::script::{
ContainerSlot, ContainerState, LootRow, LootState, SessionRequest, SoundRequest, UiScript,
};
use super::test_ui::{bag_open, load_ui as load_xml, BAG_UI};
/// The engine the menu needs behind it: fonts, the panel manager + popup engine, the shared widget
/// kit, and the menu. `extra` adds the files a given test wants in the way (a bag, a loot window).
///
@@ -20,6 +28,11 @@ use benilla_ui::script::{
/// lives there (the reference's own file for it) since the colour picker needed it from above
/// GameMenuFrame.xml's deliberately-LAST seat in the manifest. Without it every rung comes out
/// sizeless and the ladder geometry below reads nil — which is exactly how the move was caught.
///
/// **`extra` is DEDUPED against this base list**, and that is what lets a caller hand it
/// [`BAG_UI`] whole (which names `Fonts.xml`, `MoneyFrame.xml`, `UiPanels.xml` and
/// `UIPanelTemplates.xml` for its own reasons) without loading a file — and re-running its
/// OnLoads — twice. Order is still the caller's: the first mention of a file is where it lands.
fn harness_with(extra: &[&str]) -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -36,20 +49,11 @@ fn harness_with(extra: &[&str]) -> UiScript {
.chain(extra.iter().copied())
.chain(std::iter::once("GameMenuFrame.xml"))
.collect();
let mut loaded = std::collections::HashSet::new();
for file in files {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(&s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
if loaded.insert(file) {
load_xml(&s, file);
}
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s
@@ -59,6 +63,24 @@ fn harness() -> UiScript {
harness_with(&[])
}
/// [`harness_with`], with the reference's bag stack folded in — the harness for every test below
/// that parks a BAG in the menu's way. Since decision 1751 those windows are the reference's own
/// `ContainerFrame1..12`, executed off the player's installed patch chain, so `BAG_UI` names
/// `Interface\FrameXML\ContainerFrame.xml` and every caller opens with `wow_data_or_skip!`.
///
/// `before` loads AHEAD of the stack, for the one file that has to: `ActionBar.xml` declares
/// `MainMenuBarArtFrame`, which `BagFrame.xml`'s bag bar anchors into and seats itself above.
/// `after` is the ordinary tail (a merchant, a loot window).
fn bag_harness_with(before: &[&str], after: &[&str]) -> UiScript {
let files: Vec<&str> = before
.iter()
.copied()
.chain(BAG_UI.iter().copied())
.chain(after.iter().copied())
.collect();
harness_with(&files)
}
/// The seven buttons, top to bottom — the ERA ladder, its own shape (the director's call on the
/// 0951 review; GameMenuFrame.xml's header SCOPE note quotes the era source), minus two seats:
/// 0997's carved Key Bindings seat left with its standalone window (decision 1008 folded key
@@ -176,7 +198,8 @@ fn the_unbacked_entries_are_disabled_and_the_rest_are_live() {
/// (igMainMenuQuit — the reference's own choice of kit) without reaching any rung below.
#[test]
fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_it() {
let mut s = harness_with(&["MerchantFrame.xml", "Cooldown.xml", "BagFrame.xml"]);
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_harness_with(&[], &["MerchantFrame.xml"]);
s.set_money(0);
s.set_container(0, Some(backpack()));
@@ -184,10 +207,7 @@ fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_
s.run("BenillaBagToggle_OnClick()").unwrap();
let _ = s.take_sounds();
s.run("ToggleGameMenu()").unwrap();
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"the press closed the bag"
);
assert!(!bag_open(&s, 0), "the press closed the bag");
assert!(
!s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"and did NOT also open the menu — one eater per press"
@@ -215,17 +235,15 @@ fn escape_opens_the_menu_only_when_nothing_else_wants_the_press_and_then_closes_
/// rule is the ESC key's, not the button's.
#[test]
fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
let mut s = harness_with(&["MerchantFrame.xml", "Cooldown.xml", "BagFrame.xml"]);
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_harness_with(&[], &["MerchantFrame.xml"]);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
assert!(s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap());
assert!(bag_open(&s, 0));
s.run("ToggleGameMenu(1)").unwrap();
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"the click closed the bag"
);
assert!(!bag_open(&s, 0), "the click closed the bag");
assert!(
s.eval::<bool>("return GameMenuFrame:IsVisible()").unwrap(),
"…and opened the menu in the SAME click"
@@ -241,16 +259,17 @@ fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
/// "you can't open your bags with the ESC menu up" true rather than merely greyed.
#[test]
fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
let mut s = harness_with(&[
"MerchantFrame.xml",
// LootFrame.xml owns GroupLootDropDown, whose OnLoad needs the dropdown kit — which in
// turn reads TOOLTIP_DEFAULT_COLOR from GameTooltip.xml. Shipped toc order.
"GameTooltip.xml",
"UIDropDownMenu.xml",
"LootFrame.xml",
"Cooldown.xml",
"BagFrame.xml",
]);
let _data = benilla_formats::wow_data_or_skip!();
let mut s = bag_harness_with(
&[],
&[
"MerchantFrame.xml",
// LootFrame.xml owns GroupLootDropDown, whose OnLoad needs the dropdown kit — which
// in turn reads TOOLTIP_DEFAULT_COLOR from GameTooltip.xml (BAG_UI's, ahead of this).
"UIDropDownMenu.xml",
"LootFrame.xml",
],
);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.run("BenillaBagToggle_OnClick()").unwrap();
@@ -278,10 +297,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
s.eval::<bool>("return GetLeftFrame() == nil").unwrap(),
"the panel slot vacated on the way in"
);
assert!(
!s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"and the bags closed (CloseAllBags)"
);
assert!(!bag_open(&s, 0), "and the bags closed (CloseAllBags)");
assert!(
s.eval::<bool>("return GetCenterFrame():GetName() == \"GameMenuFrame\"")
.unwrap(),
@@ -610,12 +626,11 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
/// one is tinted grey. And on the way back out, tinted white again.
#[test]
fn the_bag_row_greys_under_the_menu_without_any_of_it_disappearing() {
let mut s = harness_with(&[
"MerchantFrame.xml",
"Cooldown.xml",
"ActionBar.xml",
"BagFrame.xml",
]);
let _data = benilla_formats::wow_data_or_skip!();
// ActionBar.xml goes AHEAD of the bag stack: it declares MainMenuBarArtFrame, which the bag
// bar anchors into and seats itself above (`BenillaActionBarArt_SeatAbove`, nil-guarded in
// BagFrame.xml precisely because most harnesses load no bar).
let mut s = bag_harness_with(&["Cooldown.xml", "ActionBar.xml"], &["MerchantFrame.xml"]);
s.set_money(0);
s.set_container(0, Some(backpack()));
s.resolve();
@@ -0,0 +1,226 @@
//! The instance-lockout **Era surface** (decision 1748): the three engine bindings, and the one
//! place in the shipped UI that reads them — the SELF menu's "Reset all instances" row and the
//! `CONFIRM_RESET_INSTANCES` dialog behind it.
//!
//! The four chat lines the family's other packets raise are engine-composed and have no Lua at
//! all (no FrameXML file mentions `RAID_INSTANCE_WELCOME` or its siblings); they are tested at the
//! composer, in `crate::ui_instance`.
use benilla_ui::script::UiScript;
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error.
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
/// The three bindings answer with the reference's own shapes — `IsInInstance` a PAIR, both
/// readers `1`/`nil` rather than `true`/`false` (the reference pushes the double 1.0), and
/// `ResetInstances` a queue.
#[test]
fn the_three_bindings_have_the_reference_shapes() {
let mut s = UiScript::new().unwrap();
// Nothing pushed yet: the app has not said where we are.
assert_eq!(
s.eval::<(Option<f64>, String)>("return IsInInstance()")
.unwrap(),
(None, "none".into()),
"no map pushed reads as not-an-instance"
);
for (ty, inside, name) in [
(0u32, None, "none"),
(1, Some(1.0), "party"),
(2, Some(1.0), "raid"),
(3, Some(1.0), "pvp"),
// Past the reference's own `cmp esi,4; jae` guard.
(4, Some(1.0), "none"),
] {
s.set_instance_type(Some(ty));
assert_eq!(
s.eval::<(Option<f64>, String)>("return IsInInstance()")
.unwrap(),
(inside, name.into()),
"InstanceType {ty}"
);
}
assert_eq!(
s.eval::<Option<f64>>("return CanShowResetInstances()")
.unwrap(),
None,
"false by default — no bind, no dungeon behind us"
);
s.set_can_reset_instances(true);
assert_eq!(
s.eval::<Option<f64>>("return CanShowResetInstances()")
.unwrap(),
Some(1.0)
);
assert_eq!(s.take_reset_instance_asks(), 0);
s.run("ResetInstances(); ResetInstances()").unwrap();
assert_eq!(s.take_reset_instance_asks(), 2, "each call is one send");
assert_eq!(s.take_reset_instance_asks(), 0, "the drain is a take");
}
/// The whole shipped path, through the real hit test: right-click your own portrait, the row is
/// there only while `CanShowResetInstances()` is true, clicking it raises the reference's confirm,
/// and Yes is the only thing that sends.
#[test]
fn the_self_menu_row_gates_on_the_binding_and_confirms_before_sending() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// The row labels a bare harness has no GlobalStrings.lua for. Verbatim 1.12 values
// (`RESET_INSTANCES` l.3342, `CONFIRM_RESET_INSTANCES` l.851, `YES` l.5463, `NO` l.2794) —
// production runs the player's own string table at boot.
s.run(
r#"
RESET_INSTANCES = "Reset all instances"
CONFIRM_RESET_INSTANCES = "Do you really want to reset all of your instances?"
YES = "Yes"
NO = "No"
CANCEL = "Cancel"
"#,
)
.unwrap();
for file in [
"Fonts.xml",
"UIParent.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"UIDropDownMenu.xml",
"UnitPopup.xml",
"UnitFrames.xml",
"RaidFrame.xml",
] {
load_xml(&s, file);
}
s.resolve();
assert!(s.errors().is_empty(), "load errors: {:?}", s.errors());
let open_self_menu = |s: &mut UiScript| {
s.run("CloseDropDownMenus()").unwrap();
s.run(r#"PlayerFrame_OnClick("RightButton")"#).unwrap();
s.resolve();
};
let row_labels = |s: &mut UiScript| -> Vec<String> {
let n = s.eval::<i64>("return DropDownList1.numButtons").unwrap();
(1..=n)
.map(|i| {
s.eval::<String>(&format!("return DropDownList1Button{i}:GetText() or \"\""))
.unwrap()
})
.collect()
};
// Off: the row is not in the menu at all — hidden, not greyed (the reference's
// `UnitPopupShown[index] = 0`). Solo, it is the ONLY row of the SELF menu that could show
// (the loot trio, Leave and the raid marks all need a party), so the menu's "nothing but
// CANCEL" early-out fires and no menu opens at all. That is the reference's behaviour too,
// and it is the sharpest possible assertion that the row is really gone.
open_self_menu(&mut s);
assert!(
s.eval::<bool>("return not DropDownList1:IsVisible()")
.unwrap(),
"solo with no lockout to reset, the SELF menu has nothing to show"
);
// On: the row appears, and with it the menu.
s.set_can_reset_instances(true);
open_self_menu(&mut s);
assert!(
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
"the one showable row is enough to open the SELF menu"
);
let labels = row_labels(&mut s);
let row = labels
.iter()
.position(|l| l == "Reset all instances")
.unwrap_or_else(|| panic!("the row shows once the binding says so: {labels:?}"));
// Clicking it sends NOTHING — it raises the confirm.
let button = format!("DropDownList1Button{}", row + 1);
let (cx, cy) = s
.eval::<(f64, f64)>(&format!("return {button}:GetCenter()"))
.unwrap();
s.mouse_button(cx as f32, cy as f32, "LeftButton", true);
s.mouse_button(cx as f32, cy as f32, "LeftButton", false);
s.resolve();
assert_eq!(
s.take_reset_instance_asks(),
0,
"the row itself never sends — it asks"
);
assert!(
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
"the row raises CONFIRM_RESET_INSTANCES"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Text:GetText()")
.unwrap(),
"Do you really want to reset all of your instances?"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Button1:GetText()")
.unwrap(),
"Yes"
);
assert_eq!(
s.eval::<String>("return StaticPopup1Button2:GetText()")
.unwrap(),
"No"
);
// No sends nothing; Yes sends exactly one.
let (nx, ny) = s
.eval::<(f64, f64)>("return StaticPopup1Button2:GetCenter()")
.unwrap();
s.mouse_button(nx as f32, ny as f32, "LeftButton", true);
s.mouse_button(nx as f32, ny as f32, "LeftButton", false);
s.resolve();
assert_eq!(
s.take_reset_instance_asks(),
0,
"No is not an answer that sends"
);
open_self_menu(&mut s);
let labels = row_labels(&mut s);
let row = labels
.iter()
.position(|l| l == "Reset all instances")
.expect("still offered");
let button = format!("DropDownList1Button{}", row + 1);
let (cx, cy) = s
.eval::<(f64, f64)>(&format!("return {button}:GetCenter()"))
.unwrap();
s.mouse_button(cx as f32, cy as f32, "LeftButton", true);
s.mouse_button(cx as f32, cy as f32, "LeftButton", false);
s.resolve();
let (yx, yy) = s
.eval::<(f64, f64)>("return StaticPopup1Button1:GetCenter()")
.unwrap();
s.mouse_button(yx as f32, yy as f32, "LeftButton", true);
s.mouse_button(yx as f32, yy as f32, "LeftButton", false);
s.resolve();
assert_eq!(
s.take_reset_instance_asks(),
1,
"Yes is the one call that sends CMSG_RESET_INSTANCES"
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+114 -19
View File
@@ -1,34 +1,57 @@
//! **benilla's own interface** — its manifest, and the boot split that loads it in two phases.
//! **The in-game interface** — its manifest, and the boot split that loads it in two phases.
//!
//! The *how* of loading any interface lives in [`super::addons`]: an [`Addon`] is a name, a parsed
//! `.toc`, and a source its files come from, and ours is simply the one whose source is the
//! compiled-in tree (decision 1184). What is specific to us, and therefore still here, is the
//! **seam at index 0** — see [`load_default_ui`].
//! `.toc`, and a source its files come from (decision 1184). What is specific to the default UI,
//! and therefore still here, is the **seam at index 0** — see [`load_default_ui`] — and the
//! **two sources one manifest names**, see [`load_manifest`].
//!
//! The manifest itself is not here either. It is [`MANIFEST`] — `assets/ui/benilla.toc`, an
//! ordinary addon manifest read by the ordinary `.toc` parser ([`benilla_ui::toc`]), exactly as a
//! third-party addon's is (decision 1178). Until then it was a hand-ordered `&[&str]` in this file,
//! which meant our own interface loaded by a private door and the addon path was untested by
//! construction.
//!
//! ## One ordered list, two stores (decision 1751)
//!
//! The end state for this interface is the stock 1.12 FrameXML run off the player's own install;
//! `assets/ui` is scaffolding that retires file by file ([`super::reference_ui`], whose header is
//! the rule). A manifest entry carrying a **path** is sourced off the chain, a **bare filename** is
//! one we ship — so the migration of a window is one line changing in `benilla.toc` plus the
//! deletion of our copy, and the manifest stays the single ordered truth of what loads when. That
//! ordering is the point: stock `ContainerFrame.xml` inherits four templates our earlier files
//! declare, so "source the reference first" — the only order a Lua-only mechanism could express —
//! is not a position it can load at.
use bevy::prelude::*;
use benilla_ui::script::UiScript;
use super::addons::Addon;
use super::reference_ui;
/// The built-in interface's manifest, relative to `assets/ui`. Its `## Interface:`/`## Title:`
/// directives are what `GetAddOnInfo` will read once the AddOn API lands (1178 step 4); nothing
/// consumes them yet.
pub(super) const MANIFEST: &str = "benilla.toc";
/// The built-in manifest's file list, in load order — a convenience over [`Addon::builtin`] for the
/// tests and the content sweep, which want the names rather than a loader.
/// The manifest's file list, in load order — a convenience over [`Addon::builtin`] for the tests
/// and the content sweep, which want the names rather than a loader. **Both stores**: filter with
/// [`reference_ui::is_chain_entry`] for one or the other.
#[cfg(test)]
pub(super) fn manifest_files() -> Vec<String> {
Addon::builtin().toc.files
}
/// The manifest's entries that name a file **we ship** — everything [`reference_ui`] does not
/// source off the player's install. This is what a check about `assets/ui` wants.
#[cfg(test)]
pub(super) fn shipped_manifest_files() -> Vec<String> {
manifest_files()
.into_iter()
.filter(|f| !reference_ui::is_chain_entry(f))
.collect()
}
/// Run decision 0272's load-time `UIParent_ManageFramePositions()` pass.
///
/// Only meaningful once the frames that table names exist, so the font-registry-only load
@@ -67,13 +90,44 @@ fn bootstrap_positions(script: &UiScript) -> Vec<String> {
/// the addon harness ([`crate::addon_harness`]), which needs our entire interface under each
/// surveyed addon.
pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
super::reference_ui::load_sourced(script);
let builtin = Addon::builtin();
let mut failures = builtin.load_files(script, &builtin.toc.files);
let mut failures = load_manifest(script, &Addon::builtin().toc.files);
failures.extend(bootstrap_positions(script));
failures
}
/// Load a slice of [`MANIFEST`] entries, **each from its own store** (decision 1751).
///
/// A bare filename is a file we ship and comes from [`Addon::builtin`]; a path is the reference's
/// own file and comes off the player's installed chain ([`reference_ui`]). The dispatch is
/// per-entry rather than per-run so the manifest's order is the load order verbatim — the whole
/// reason the list is a manifest and not two lists.
///
/// Both stores answer `<Include>` / `<Script file=>` the same way: the loader resolves a reference
/// against the *including document's own* directory in its source's path space (1186), which for a
/// chain document means `Interface\FrameXML\ContainerFrame.xml`'s `<Script
/// file="ContainerFrame.lua"/>` reaches `Interface\FrameXML\ContainerFrame.lua` without anything
/// here knowing about it.
fn load_manifest(script: &UiScript, files: &[String]) -> Vec<String> {
let builtin = Addon::builtin();
let reference = reference_ui::addon(
files
.iter()
.filter(|f| reference_ui::is_chain_entry(f))
.cloned()
.collect(),
);
let mut failures = Vec::new();
for file in files {
let from = if reference_ui::is_chain_entry(file) {
&reference
} else {
&builtin
};
failures.extend(from.load_files(script, std::slice::from_ref(file)));
}
failures
}
/// The font-object registry alone (`Fonts.xml`), loaded at `Startup` — see [`load_default_ui`].
///
/// Verified lossless for the atlas: the full manifest and this file alone both yield the **same 19
@@ -81,8 +135,10 @@ pub(crate) fn load_default_ui(script: &UiScript) -> Vec<String> {
/// (`GameFontNormalMed1` 13, `OptionsFontHighlightMedium` 14, `OptionsFontHighlightHuge` 20) are
/// un-outlined and their heights are already declared here, so they add nothing to the bake plan.
pub(crate) fn load_font_registry(script: &UiScript) -> Vec<String> {
let builtin = Addon::builtin();
builtin.load_files(script, builtin.toc.files.get(..1).unwrap_or_default())
load_manifest(
script,
Addon::builtin().toc.files.get(..1).unwrap_or_default(),
)
}
/// The in-game UI — everything after the font registry — loaded on entering the world, and then
@@ -117,13 +173,10 @@ pub(crate) fn load_ingame_ui(
// the client on the loading screen; the caller disarms once the edge is done
// (`lifecycle::load_ingame_ui_on_world_entry`), so the session's steady state runs unhooked.
script.set_instruction_budget(super::addons::LOAD_INSTRUCTION_BUDGET);
// The reference FrameXML this client SOURCES off the patch chain rather than transcribing
// ([`super::reference_ui`], whose header is the rule). It runs FIRST, before our own files,
// precisely so that every global we define ourselves overwrites the reference's — its
// `ToggleBackpack` walks twelve `ContainerFrame`s we do not build; ours walks our windows.
super::reference_ui::load_sourced(script);
let builtin = Addon::builtin();
let mut failures = builtin.load_files(script, builtin.toc.files.get(1..).unwrap_or_default());
let mut failures = load_manifest(
script,
Addon::builtin().toc.files.get(1..).unwrap_or_default(),
);
failures.extend(bootstrap_positions(script));
// `&mut` from here down: each addon's `ADDON_LOADED` fires as that addon finishes, which is
// the reference's own interleaving (`0x51f5ad`, per addon) rather than a batch at the end.
@@ -163,7 +216,7 @@ mod tests {
/// simply never loaded, and the symptom is a window that does not exist rather than an error.
#[test]
fn the_manifest_lists_every_shipped_file_and_nothing_else() {
let mut listed = manifest_files();
let mut listed = shipped_manifest_files();
let mut shipped: Vec<String> = super::super::content::shipped_files()
.filter(|f| f.ends_with(".xml"))
.map(str::to_owned)
@@ -173,6 +226,42 @@ mod tests {
assert_eq!(listed, shipped);
}
/// **The shipped tree is FLAT, and that is what makes the two stores tellable apart.**
///
/// [`reference_ui::is_chain_entry`] decides where a manifest entry comes from by asking
/// whether it carries a path separator (1751). That is only decidable while every file we
/// ship is a bare name — the day somebody adds `assets/ui/templates/Foo.xml`, its manifest
/// entry would be read as a chain path, and the symptom would be a window that silently does
/// not exist rather than an error. This is that day's failing test.
#[test]
fn every_file_we_ship_is_a_bare_name_so_a_path_can_only_mean_the_chain() {
for name in super::super::content::shipped_files() {
assert!(
!reference_ui::is_chain_entry(name),
"assets/ui is flat by construction, but ships {name} — a manifest entry with a separator is read as a file to source off the player's install"
);
}
}
/// Every `Interface\…` entry the manifest names is really in the 1.12 chain, and is really
/// **not** something we also ship under that basename.
///
/// Skips without client data, like every other test that reads the install.
#[test]
fn every_chain_entry_resolves_off_the_players_install() {
let _data = benilla_formats::wow_data_or_skip!();
let chain: Vec<String> = manifest_files()
.into_iter()
.filter(|f| reference_ui::is_chain_entry(f))
.collect();
for entry in &chain {
assert!(
reference_ui::read(entry).is_some(),
"benilla.toc sources {entry} off the patch chain, which does not hold it"
);
}
}
/// **Nothing may declare `parent="UIParent"` before `UIParent.xml` has loaded.**
///
/// A parent name is resolved at LOAD, and a name that does not exist yet is not an error: the
@@ -193,6 +282,12 @@ mod tests {
.expect("the manifest lists UIParent.xml");
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
for early in &files[..at] {
// A chain entry is the player's file, not one on this machine — and the reference's
// own load order is what this test exists to preserve, so it cannot be the thing that
// violates it. `every_chain_entry_resolves_off_the_players_install` covers those.
if reference_ui::is_chain_entry(early) {
continue;
}
let text = std::fs::read_to_string(dir.join(early)).unwrap();
assert!(
!text.contains(r#"parent="UIParent""#),
@@ -9,23 +9,21 @@ use benilla_ui::script::{
QuadContent, ScriptValue, SoundRequest, UiScript,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error and returning the
/// frame count it materialized (each test file keeps its own small copy — the folder's pattern).
fn load_xml(s: &UiScript, file: &str) -> usize {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
use super::test_ui::{bag_open, load_ui as load_xml, BAG_UI};
/// Put a bag carrying `num_slots` empty slots in bag `id` — the fixture the three bag tests below
/// need, and one they did NOT need before 1751's swap. The reference's `OpenBag` opens nothing for
/// a container whose `GetContainerNumSlots` is 0 (ContainerFrame.lua l.144-165); our five
/// hand-authored windows showed either way, so these tests used to name no container at all.
fn equip_bag(s: &mut UiScript, id: i64, name: &str, num_slots: u32) {
s.set_container(
id,
Some(ContainerState {
name: Some(name.into()),
num_slots,
slots: std::collections::HashMap::new(),
}),
);
report.frames
}
/// Find a bare frame's own rect via its `QuadContent::Frame` entry (every frame emits one, at its
@@ -366,26 +364,33 @@ fn merchant_show_hide_plays_open_and_close_kits() {
);
}
/// With the bag loaded, opening the vendor also opens your bags (the real MerchantFrame_OnShow →
/// OpenBackpack, decision 0095; ALL equipped bags since decision 0561 — here only the backpack
/// exists), so the backpack kit plays ALONGSIDE the panel kit — the "two sounds go together" the
/// With the bags loaded, opening the vendor also opens them (the real MerchantFrame_OnShow →
/// OpenBackpack, decision 0095; ALL equipped bags since decision 0561 — here only the backpack is
/// carried), so the backpack kit plays ALONGSIDE the panel kit — the "two sounds go together" the
/// director heard. On close, CloseBackpack hides the bag it opened, so both close kits play. The
/// bag's own OnShow/OnHide fire first (OpenBackpack shows it before the panel sound), so the
/// bag window's own OnShow/OnHide fire first (OpenBackpack shows it before the panel sound), so the
/// backpack kit leads each pair.
///
/// **What 1751's swap changed, and what it did not.** The behaviour is the same and the sound
/// pairs are unchanged; the window is now the reference's own recycled `ContainerFrame` rather than
/// `BenillaBagFrame`, so the open assertion asks `IsBagOpen(0)` instead of naming a frame, and the
/// two bag kits come from `ContainerFrame_OnShow`/`_OnHide`'s own `PlaySound` calls
/// (ContainerFrame.lua l.140 / l.120). The seeded backpack is new and load-bearing — see
/// [`equip_bag`].
#[test]
fn vendor_open_opens_the_backpack_and_layers_the_sound() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
// `BAG_UI` already carries GameTooltip.xml, so the app's tooltip-before-merchant order holds.
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "MerchantFrame.xml");
s.set_money(0);
equip_bag(&mut s, 0, "Backpack", 16);
let _ = s.take_sounds(); // ignore anything from load (frames are hidden; nothing should)
s.set_money(0);
s.set_merchant(Some(MerchantState::default()));
s.fire_event("MERCHANT_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -397,10 +402,7 @@ fn vendor_open_opens_the_backpack_and_layers_the_sound() {
],
"vendor open opens the backpack (bag kit) then plays its own panel kit"
);
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"the backpack is open alongside the vendor"
);
assert!(bag_open(&s, 0), "the backpack is open alongside the vendor");
s.fire_event("MERCHANT_CLOSED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -412,27 +414,36 @@ fn vendor_open_opens_the_backpack_and_layers_the_sound() {
],
"vendor close closes the backpack it opened, both close kits play"
);
assert!(!bag_open(&s, 0), "…and the window goes with it");
}
/// A backpack the player already had open is NOT closed when the vendor closes (the real
/// `backpackWasOpen` guard): opening the vendor over an already-open bag plays only the panel kit,
/// and closing plays only the panel close kit — the bag stays.
/// A backpack the player already had open is NOT closed when the vendor closes: opening the vendor
/// over an already-open bag plays only the panel kit, and closing plays only the panel close kit —
/// the bag stays.
///
/// The guard is a **was-open memory**, and 1751 left two spellings of it in the tree: the
/// reference's own single `ContainerFrame1.backpackWasOpen` flag (ContainerFrame.lua l.187-202) and
/// `UiPanels.xml`'s bag-id-keyed `BENILLA_BAG_WAS_OPEN`, which 0561's all-bags `OpenBackpack` needs
/// because the windows are recycled now (frame N is a different bag every time it opens). Whichever
/// body the manifest leaves live, they spell bag 0's case identically — so this test pins the
/// PROPERTY and stays honest either way. Which one is actually live is the next test's subject,
/// not this one's.
#[test]
fn vendor_leaves_an_already_open_backpack_alone() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "MerchantFrame.xml");
s.set_money(0);
equip_bag(&mut s, 0, "Backpack", 16);
// Open the bag first (the 'B' toggle), then the vendor over it.
s.run("BenillaBagToggle_OnClick()").unwrap();
assert!(bag_open(&s, 0), "fixture: the player's own bag is up first");
let _ = s.take_sounds();
s.set_money(0);
s.set_merchant(Some(MerchantState::default()));
s.fire_event("MERCHANT_SHOW", vec![]);
assert_eq!(
@@ -448,7 +459,7 @@ fn vendor_leaves_an_already_open_backpack_alone() {
"the pre-opened bag is left open → only the panel close kit"
);
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
bag_open(&s, 0),
"the player's own open bag survives the vendor closing"
);
}
@@ -457,56 +468,41 @@ fn vendor_leaves_an_already_open_backpack_alone() {
/// with it — not only the backpack like the ref's OpenBackpack — and closes every bag it opened.
/// Backpack + one equipped bag in slot 2 (bags 1/3/4 unequipped → no window): MERCHANT_SHOW opens
/// both windows, MERCHANT_CLOSED closes both.
///
/// This is also the test that decides WHOSE `OpenBackpack` is live. 1751 keeps 0561 by shadowing:
/// `UiPanels.xml` redefines `OpenBackpack`/`CloseBackpack`/`CloseAllBags` over the sourced
/// `ContainerFrame.lua`'s, and its body now loops the reference's `OpenBag`/`CloseBag` rather than
/// five windows by name — so the "unequipped slot" arm below is the reference's own `size > 0` test
/// rather than ours. A shadow only wins if it loads LATER (`reference_ui`'s rule: order decides,
/// the manifest is the order), so this test is exactly the falsifier for that ordering: with the
/// reference's own body live, bag 2 never opens and only the backpack does.
#[test]
fn vendor_opens_and_closes_all_equipped_bags() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml");
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "BagFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
for file in BAG_UI {
load_xml(&s, file);
}
load_xml(&s, "MerchantFrame.xml");
s.set_container(
0,
Some(ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: std::collections::HashMap::new(),
}),
);
s.set_container(
2,
Some(ContainerState {
name: Some("Small Pouch".into()),
num_slots: 6,
slots: std::collections::HashMap::new(),
}),
);
let shown =
|s: &mut UiScript, name: &str| s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap();
s.set_money(0);
equip_bag(&mut s, 0, "Backpack", 16);
equip_bag(&mut s, 2, "Small Pouch", 6);
s.set_merchant(Some(MerchantState::default()));
s.fire_event("MERCHANT_SHOW", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(shown(&mut s, "BenillaBagFrame"), "backpack opens");
assert!(bag_open(&s, 0), "backpack opens");
assert!(bag_open(&s, 2), "the equipped bag opens with the vendor");
assert!(
shown(&mut s, "BenillaBagFrame2"),
"the equipped bag opens with the vendor"
);
assert!(
!shown(&mut s, "BenillaBagFrame1"),
"an unequipped slot's window stays hidden"
!bag_open(&s, 1) && !bag_open(&s, 3) && !bag_open(&s, 4),
"an unequipped slot has no container, so nothing opens for it"
);
s.fire_event("MERCHANT_CLOSED", vec![]);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!shown(&mut s, "BenillaBagFrame") && !shown(&mut s, "BenillaBagFrame2"),
!bag_open(&s, 0) && !bag_open(&s, 2),
"every bag the vendor opened closes with it"
);
}
+5
View File
@@ -764,6 +764,9 @@ impl benilla_ui::script::TextMeasure for FixedWidthFont {
}
}
#[cfg(test)]
mod test_ui;
#[cfg(test)]
mod cinematic_tests;
@@ -907,6 +910,8 @@ mod options_tests;
#[cfg(test)]
mod delete_item_tests;
#[cfg(test)]
mod instance_tests;
#[cfg(test)]
mod static_popup_tests;
@@ -430,7 +430,7 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
.eval::<bool>("return QuestFrameAcceptButton:IsEnabled() ~= 0")
.unwrap());
assert_eq!(
s.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
s.eval::<f32>("return TextAlphaDependentFrame:GetAlpha()")
.unwrap(),
1.0,
"the block snaps straight to opaque — no QUESTINFO_FADE_IN ramp in instant mode"
@@ -498,7 +498,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
.eval::<bool>("return QuestFrameAcceptButton:IsEnabled() ~= 0")
.unwrap());
let alpha = s
.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
.eval::<f32>("return TextAlphaDependentFrame:GetAlpha()")
.unwrap();
assert!(
alpha < 1.0,
@@ -506,7 +506,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
);
s.tick(1.1);
assert_eq!(
s.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
s.eval::<f32>("return TextAlphaDependentFrame:GetAlpha()")
.unwrap(),
1.0,
"the fade completes at opaque"
+156 -135
View File
@@ -1,154 +1,171 @@
//! **Reference FrameXML this client EXECUTES off the patch chain, rather than transcribing.**
//! **The reference FrameXML this client EXECUTES off the player's own patch chain**, rather than
//! shipping a copy of it — the mechanism half of decision 1751.
//!
//! ## The rule, and why it is a rule
//! ## The rule
//!
//! Some of `Interface\FrameXML` is not *our* logic to re-derive — it is a library of globals that
//! every 1.12 addon calls, or replaces, by name. `ContainerFrameItemButton_OnEnter` is one: Bagnon
//! builds its own item buttons and then hands them to the client's own handler, and four other
//! corpus addons overwrite that global outright. Those bodies do not encode anything benilla wants
//! to decide differently; they encode *the contract addons were written against*.
//! The end state for the in-game interface is the stock 1.12 FrameXML, run off the file the player
//! already owns. `assets/ui` is scaffolding: it retires file by file, and a migrated window means
//! *its stock XML + Lua run off the chain and our counterpart file is deleted* (1751 §2). Fidelity
//! by construction — the reference's text cannot drift from itself, and every frame name, id,
//! template and stratum an addon reaches for is right because it **is** the reference's.
//!
//! Two ways to have them, and only one is available to us:
//! What stays ours permanently: the glue screens (0068 §8 — GlueXML is a separate engine surface
//! even in the real client), dev-only frames, and adapter shims only while a genuine engine
//! difference forces one.
//!
//! | | |
//! |---|---|
//! | transcribe the file into `assets/ui` | **forbidden.** Blizzard's Lua, committed to a repo that mirrors public under MIT/Apache. the contract: never commit Blizzard assets. |
//! | read it out of the player's own installed patch chain at runtime | what we do. Nothing is committed; a player runs the file they already own. |
//! ## Where the list lives — the manifest, not a second list in Rust
//!
//! This is not a new mechanism — it is [`super::load_global_strings`]'s, generalised. That already
//! executes the real `GlobalStrings.lua` off the chain at boot for exactly the same reason
//! (~5,000 localized globals nobody should retype), and this module is that pattern with a list.
//! Until 1751 this module carried its own `SOURCED` array and ran it *before* `assets/ui`, which
//! was the only ordering a Lua-only mechanism could express. Sourcing **XML** needs a real
//! position in the load order instead: stock `ContainerFrame.xml` inherits `ItemButtonTemplate`,
//! `CooldownFrameTemplate`, `SmallMoneyFrameTemplate` and `UIPanelCloseButton`, so it has to load
//! *after* the files that declare them.
//!
//! ## Where the reference file and our own UI collide, and which one wins
//! So there is exactly one ordered list of what loads and when, and it is `assets/ui/benilla.toc`
//! — the manifest that already had that job. **A manifest entry carrying a path separator is
//! sourced off the chain; a bare filename is a file we ship** ([`is_chain_entry`]). Our tree is
//! flat, so the two can never be confused, and the migration reads as what it is: the line
//! `BagFrame.xml` becomes `Interface\FrameXML\ContainerFrame.xml`, and `BagFrame.xml` is deleted.
//!
//! `ContainerFrame.lua` is not only handlers. Two thirds of it drives the reference's OWN twelve
//! `ContainerFrame1..12` windows, which benilla does not build — our bag UI is `BagFrame.xml`
//! (decision 0068 T2). So sourcing the file wholesale would put reference bodies on top of working
//! benilla ones. **The rule, applied consistently and stated here once:**
//! Everything else — the XML parse, `<Include>` / `<Script file=>` resolution against the
//! document's own directory, chunk naming, the error reporting that reaches the player — is
//! [`super::addons::Addon`]'s, unchanged. This module is only a third [`super::addons::Source`]:
//! *the player's install*.
//!
//! 1. **Order decides.** The sourced reference file runs **before** our own `assets/ui`, so every
//! global we define ourselves — `ToggleBag`, `ToggleBackpack`, `OpenAllBags`, `OpenBag`,
//! `CloseBag`, `IsBagOpen`, `CloseAllBags`, `OpenBackpack`, `CloseBackpack`, `ToggleKeyRing`,
//! `PutKeyInKeyRing`, `GetKeyRingSize` — **overwrites** the reference's. Ours drive our windows.
//! 2. **What only the reference defines, we keep, unmodified.** The item-button family
//! (`ContainerFrameItemButton_OnLoad`/`_OnClick`/`_OnEnter`/`_OnUpdate`, `KeyRingItemButton_OnClick`)
//! is frame-agnostic: every one of them works on whatever button the caller passes, through the
//! contract "the button's own ID is the slot, its parent's ID is the bag". Bagnon's buttons obey
//! it. So does anything else written for the real client. It also brings the file's constants
//! (`NUM_BAG_FRAMES`, read by 8 corpus addons; `NUM_CONTAINER_FRAMES` by 5) for free.
//! 3. **Where a reference body needs something benilla genuinely does not have, we ADAPT it in our
//! own file** — `assets/ui/ContainerFrameAdapters.xml`, which loads after this and wraps the
//! global. Exactly one function needs that today; the file names it and says why.
//! 4. **Nothing is stubbed silently.** The reference's frame-driven functions
//! (`ContainerFrame_GetOpenFrame`, `updateContainerFrameAnchors`, `ContainerFrame_OnLoad` and
//! kin) are left as the reference wrote them. Driven against frames that do not exist they raise
//! — naming the frame — which is loud, correct, and strictly better than a no-op that pretends
//! (1203, 1205, 1211, 1230 are four records of exactly that pretending). `ContainerFrame_Update`
//! and `ContainerFrame_GenerateFrame` are NOT in that set: both take the frame as an argument
//! and touch nothing global, so an addon that passes its own conforming frame gets the real
//! behaviour.
//! ## Where a reference file and our own UI still collide, and which one wins
//!
//! **Order decides, and the manifest is the order.** A name defined by both goes to whichever line
//! is later. That is the whole rule; there is no precedence machinery. A file sourced *before* our
//! own (`PaperDollFrame.lua`, which is there for one frame-agnostic button family and collides on
//! eighteen names we drive ourselves) has its collisions overwritten by ours; a file sourced at the
//! position of the window it replaces owns its names outright, which is what migrating a window
//! means.
//!
//! **Nothing is stubbed silently.** A reference body that reaches for something this client does
//! not have raises, naming it — which is loud, correct, and strictly better than a no-op that
//! pretends (1203, 1205, 1211, 1230). The answer is to build the verb, or to adapt the body in one
//! of our own files and say why at the site.
//!
//! ## No install, no file
//!
//! A machine with no client data (CI, a bare checkout) simply does not get these globals, and says
//! so once, loudly. Every consumer already had to survive that: it is the same condition under
//! which `GlobalStrings` is absent, and the addon survey prints which mode it ran in for that
//! reason. Tests that need the file gate on the install the way every other client-data test does.
//! A machine with no client data (CI, a bare checkout) simply does not get these files, and says so
//! once, loudly. It is the same condition under which `GlobalStrings` is absent, and the addon
//! survey already prints which mode it ran in for that reason. An install-less checkout cannot run
//! most meaningful UI tests anyway — the art, fonts and MPQs come from the install too — so tests
//! that need these files gate on the install like every other client-data test.
use benilla_ui::script::UiScript;
use std::sync::OnceLock;
use benilla_formats::Chain;
use benilla_ui::toc::Toc;
use bevy::prelude::*;
/// The reference FrameXML files benilla sources instead of transcribing, in load order.
///
/// **Adding to this list is a decision, not a convenience.** A file earns a place when its globals
/// are a *contract addons call by name* AND benilla has no reason to compute them differently.
/// Anything we want to own — because our own windows are the consumer — belongs in `assets/ui` as
/// our own code, under the reference's names (`BagFrame.xml`'s `ToggleBackpack` is the pattern).
pub(crate) const SOURCED: &[&str] = &[
"Interface\\FrameXML\\ContainerFrame.lua",
// PaperDollFrame.lua earns its place for ONE family: `PaperDollItemSlotButton_OnLoad`/
// `_OnEvent`/`_OnClick`/`_Update`/`_UpdateLock`/`_OnEnter`/`_OnUpdate`. Every one of them is
// frame-agnostic in exactly the sense rule 2 above means — it acts on `this` and `this:GetID()`
// through live APIs, never on a named reference window — which is what makes it safe to hand
// an addon's own button. `PaperDollItemSlotButtonTemplate` and `BagSlotButtonTemplate` are
// declared over them (CharacterFrame.xml, BagFrame.xml), and pfUI builds its bag bar from the
// latter.
//
// Checked before adding, because this list is a decision:
// · TOP-LEVEL is four constants (NUM_RESISTANCE_TYPES, NUM_STATS, NUM_SHOPPING_TOOLTIPS,
// ATTACK_POWER_MAGIC_NUMBER). Nothing touches a frame at load, so sourcing cannot raise.
// · 18 of its 29 functions COLLIDE with ours (the PaperDollFrame_Set* stat family, the two
// damage-frame hovers, PaperDollFormatStat, PaperDollStatTooltip). Order settles all of
// them the documented way: this runs before assets/ui, so OUR bodies overwrite the
// reference's and keep driving our character sheet. Not one of the seven names we are here
// for is in that overlap.
// · Our own doll slots keep calling `BenillaPaperDollSlot_*` and are untouched — the sourced
// family is new surface for addons, not a replacement for ours.
"Interface\\FrameXML\\PaperDollFrame.lua",
];
use super::addons::{Addon, Source};
/// Execute every [`SOURCED`] file into `script`, read off the install's patch chain.
/// The addon name the reference's own files load under — the reference's word for its interface.
///
/// Returns whether the chain was available at all. Read **once per process** and cached: the
/// addon survey stands up 218 VMs, and this is per-VM work otherwise.
pub(crate) fn load_sourced(script: &UiScript) -> bool {
let Some(files) = sources() else {
warn!(
"ui_script: no client data — the reference FrameXML this client sources ({}) is \
absent, so its globals are nil; addons that call them will raise",
SOURCED.join(", ")
);
return false;
};
for (path, src) in files {
if let Err(e) = script.run_chunk_named(src.as_bytes(), path) {
error!("ui_script: sourced reference file {path} failed to run: {e}");
}
}
true
/// It is not `Interface\AddOns\…` anything: FrameXML is not an addon, gets no `ADDON_LOADED`, and
/// an addon that derives its folder from a `debugstack` pattern (`"\\AddOns\\(.*)\\"` —
/// `benilla_ui::script::addon_chunk_name`'s reason for existing) must not match a FrameXML frame.
/// [`Addon::chunk_name`] is what keeps that true: a chain file's chunk is named after its own
/// chain path, which is exactly what the real client names it.
pub(super) const NAME: &str = "FrameXML";
/// Is this manifest entry **sourced off the player's chain**, rather than shipped by us?
///
/// The test is a path separator, and it is decidable because our own shipped tree is *flat*: every
/// `assets/ui` entry is a bare filename, and every chain entry is a full internal path
/// (`Interface\FrameXML\ContainerFrame.xml`). `manifest::tests` pins both halves so the day
/// somebody adds a subdirectory to `assets/ui` is a failing test rather than a file that silently
/// stops loading.
pub(super) fn is_chain_entry(entry: &str) -> bool {
entry.contains('\\') || entry.contains('/')
}
/// The [`SOURCED`] files' text, or `None` with no install to read them from.
/// The reference interface as an [`Addon`] whose files come off the chain — the peer of
/// [`Addon::builtin`], and the thing [`super::manifest`] hands a manifest's chain entries to.
///
/// A file listed here and missing from the chain is an `error!`, not a silent skip: the list is
/// short and deliberate, so a miss means the chain is not what we think it is.
fn sources() -> Option<&'static [(&'static str, String)]> {
use std::sync::OnceLock;
static SRC: OnceLock<Option<Vec<(&'static str, String)>>> = OnceLock::new();
SRC.get_or_init(|| {
let data = benilla_formats::wow_data()?;
let mut chain = benilla_formats::open_chain(&data).ok()?;
let mut out = Vec::new();
for &path in SOURCED {
match chain.read_file(path) {
Ok(bytes) => out.push((path, benilla_ui::source::decode(&bytes).into_owned())),
/// `files` are full chain paths, so the addon's prefix is empty and each entry is already in its
/// source's path space.
pub(super) fn addon(files: Vec<String>) -> Addon {
Addon::new(
NAME.to_string(),
Toc {
directives: Vec::new(),
files,
},
Source::Chain,
)
}
/// One file's bytes, read off the player's installed patch chain by internal path.
///
/// **Bytes, not text** (1193): a `.lua` chunk goes to Lua as it sits in the archive, and only an
/// XML parse decodes — a `read_to_string` here would not make a cp1252 file lose a glyph, it would
/// make the file *not exist*.
pub(super) fn read(req: &str) -> Option<Vec<u8>> {
let chain = chain()?;
match chain.read(req) {
Ok(bytes) => Some(bytes),
Err(e) => {
debug!("ui_script: {req} is not in the patch chain: {e:#}");
None
}
}
}
/// The player's patch chain, opened once per process and cached.
///
/// Cached because the addon survey stands up 218 VMs and this would otherwise be per-VM work. A
/// process-local chain rather than the one [`benilla_assets`] holds: the interface loads from
/// places that have no Bevy world to ask (the tests, the addon harness, a bare `UiScript`), and
/// `Chain`'s reads are `&self` and lock-free, so a second handle costs the mount and nothing else.
fn chain() -> Option<&'static Chain> {
static CHAIN: OnceLock<Option<Chain>> = OnceLock::new();
CHAIN
.get_or_init(|| {
let Some(data) = benilla_formats::wow_data() else {
warn!(
"ui_script: no client data — every interface file this client SOURCES off the \
player's install (benilla.toc's `Interface\\…` entries) is absent, so the \
windows they build do not exist and addons that call their globals will raise"
);
return None;
};
match benilla_formats::open_chain(&data) {
Ok(chain) => Some(chain),
Err(e) => {
error!("ui_script: sourced reference file {path} not in the chain: {e:#}")
error!("ui_script: opening the patch chain to source the reference UI: {e:#}");
None
}
}
}
Some(out)
})
.as_deref()
})
.as_ref()
}
#[cfg(test)]
mod tests {
use super::*;
use benilla_ui::script::UiScript;
/// **The item-button family arrives, and the bag verbs stay OURS** — rule 1 and rule 2 of this
/// module's header, asserted rather than described, because the order between the two is the
/// whole design and nothing else would notice if it flipped.
/// **A chain entry really loads off the player's install, and order really decides.**
///
/// The two halves of this module's rule, asserted rather than described, because nothing else
/// would notice if either flipped: a chain entry that silently resolved to nothing would leave
/// its globals nil (the failure mode is a window that does not exist, not an error), and the
/// collision direction is invisible until an addon calls the wrong body.
///
/// Skips without client data, like every other test that reads the install.
#[test]
fn sourcing_the_reference_file_adds_its_handlers_without_taking_our_bag_verbs() {
fn a_chain_entry_loads_and_the_later_line_owns_the_collision() {
let _data = benilla_formats::wow_data_or_skip!();
let mut s = UiScript::new().expect("VM");
s.set_screen_size(1024.0, 768.0);
assert!(load_sourced(&s), "the chain opened");
// Rule 2: what only the reference defines is now here.
let failures = super::super::manifest::load_default_ui(&s);
assert!(failures.is_empty(), "the default UI: {failures:#?}");
// The item-button family the `ContainerFrame.lua` line is there for — nothing but the
// sourced file defines these.
for name in [
"ContainerFrameItemButton_OnEnter",
"ContainerFrameItemButton_OnClick",
@@ -162,33 +179,37 @@ mod tests {
"{name} must come from the sourced reference file"
);
}
// …including its constants, which the corpus reads directly.
// …and its constants, which the corpus reads directly.
assert_eq!(s.eval::<i64>("return NUM_BAG_FRAMES").unwrap(), 4);
assert_eq!(s.eval::<i64>("return NUM_CONTAINER_FRAMES").unwrap(), 12);
// Rule 1: the reference's own bag verbs are here for the moment…
// The `PaperDollFrame.lua` line's own reason to exist.
assert!(s
.eval::<bool>("return type(ToggleBackpack) == \"function\"")
.eval::<bool>("return type(PaperDollItemSlotButton_OnLoad) == \"function\"")
.unwrap());
// …and our interface, loading after, takes them over. `BENILLA_BAG_WINDOWS` is the tell:
// it is the table OUR ToggleBackpack walks, and the reference's body cannot mention it.
let failures = super::super::load_default_ui(&s);
assert!(failures.is_empty(), "our own FrameXML: {failures:#?}");
// Order decides: `PaperDollFrame.lua` is sourced ABOVE CharacterFrame.xml, so OUR body of
// a colliding name is the live one. `PaperDollFrame_SetLevel` is in the 18-name overlap.
assert!(
s.eval::<bool>(
"local f = ToggleBackpack \
for _, name in ipairs(BENILLA_BAG_WINDOWS) do end \
return BenillaBagFrame ~= nil"
"return type(PaperDollFrame_SetLevel) == \"function\" \
and BenillaPaperDollSlot_OnLoad ~= nil"
)
.unwrap(),
"our bag windows exist"
);
s.run("ToggleBackpack()")
.expect("ours runs — the reference's body would die on a nil ContainerFrame1");
assert!(
s.eval::<bool>("return BenillaBagFrame:IsShown()").unwrap(),
"ToggleBackpack must be OURS after our files load, not the reference's"
"our character sheet's own bodies must still be the live ones"
);
assert!(s.errors().is_empty(), "{:#?}", s.errors());
}
/// A path is a chain entry; a bare name is ours. The one-line rule the manifest rests on.
#[test]
fn a_separator_is_what_makes_an_entry_the_players_own_file() {
assert!(super::is_chain_entry(
"Interface\\FrameXML\\ContainerFrame.xml"
));
assert!(super::is_chain_entry(
"Interface/FrameXML/ContainerFrame.xml"
));
assert!(!super::is_chain_entry("BagFrame.xml"));
assert!(!super::is_chain_entry("Fonts.xml"));
}
}
+180
View File
@@ -0,0 +1,180 @@
//! **The tests' interface loader — one copy, both stores** (decision 1751).
//!
//! Half a dozen test files carried an identical private `load_xml` that read
//! `assets/ui/<name>` off disk, parsed it, and asserted the loader reported no errors — each with
//! the comment "duplicated so this file is self-contained". That was fine while `assets/ui` was
//! the only place an interface file could come from. It is not any more: a migrated window's file
//! lives in the player's own patch chain, so a test that wants bag windows has to name
//! `Interface\FrameXML\ContainerFrame.xml`, and six copies of a disk-only reader cannot say that.
//!
//! [`load_ui`] is that reader, generalised the same way [`super::manifest::load_manifest`] is:
//! **a bare filename is a file we ship, a path is one off the player's install.** The two are told
//! apart by [`super::reference_ui::is_chain_entry`], the manifest's own rule, so a test loads the
//! entries in the order `benilla.toc` names them and gets what the client gets.
//!
//! It reads the SOURCE TREE rather than the compiled-in copy, deliberately: these tests exist to
//! catch a mistake in a file somebody just edited, and `content::read`'s dev-build probe already
//! prefers disk for the same reason.
use benilla_ui::script::UiScript;
/// Load one interface file into `s`, panicking on any loader error, and return how many frames it
/// materialized (`0` for a `.lua` entry, which materializes none).
///
/// `entry` is a manifest entry: `"BagFrame.xml"` for one of ours, or
/// `"Interface\\FrameXML\\ContainerFrame.xml"` for the reference's own — which also brings its
/// `<Script file="ContainerFrame.lua"/>` off the chain, exactly as it does in a real load.
///
/// **A chain entry needs client data**, so a test that names one has to open with
/// `benilla_formats::wow_data_or_skip!()`; [`BAG_UI`] is a list that always does.
pub(super) fn load_ui(s: &UiScript, entry: &str) -> usize {
let path = entry.replace('\\', "/");
let bytes = read(&path).unwrap_or_else(|| panic!("{entry}: not found"));
if path.to_ascii_lowercase().ends_with(".lua") {
s.run_chunk_named(&bytes, &format!("@{entry}"))
.unwrap_or_else(|e| panic!("{entry}: {e}"));
return 0;
}
let doc = benilla_ui::framexml::parse(&benilla_ui::source::decode(&bytes))
.unwrap_or_else(|e| panic!("{entry}: {e}"));
let provider = |req: &str| -> Option<Vec<u8>> { read(req) };
let report = benilla_ui::loader::load_in(s, &doc, &path, &provider);
assert!(
report.errors.is_empty(),
"{entry}: loader errors: {:?}",
report.errors
);
report.frames
}
/// One file's bytes, from whichever store its path names — the chain for a path, this crate's
/// `assets/ui` for a bare name. Also the `<Include>` / `<Script file=>` provider, which is why it
/// takes an already-resolved path in either space.
fn read(req: &str) -> Option<Vec<u8>> {
if super::reference_ui::is_chain_entry(req) {
return super::reference_ui::read(req);
}
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui");
std::fs::read(dir.join(req)).ok()
}
/// The files a test needs before it can open a bag window, in manifest order: the four templates
/// stock `ContainerFrame.xml` inherits, then the reference's own file, then our bag bar.
///
/// Named as a list rather than folded into a `setup()` because the callers all want to add their
/// own files around it (a merchant, the bank, the action bar art), and the ORDER is the thing
/// being reused — it is `benilla.toc`'s, trimmed to what the bags actually reach for.
pub(super) const BAG_UI: &[&str] = &[
"Fonts.xml",
// `UIParent` itself: the twelve `ContainerFrame`s declare `parent="UIParent"`, and
// `updateContainerFrameAnchors` anchors each open bag to `frame:GetParent()` while
// `OpenAllBags` opens with `if not UIParent:IsVisible() then return end`. Without it the
// windows fall out of the cascade and the reference's own layout pass has nothing to measure.
"UIParent.xml",
"ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"Cooldown.xml",
// The bag BAR hangs off `MainMenuBarArtFrame` and seats itself one frame level above the bar
// art (`BenillaActionBarArt_SeatAbove`), both at LOAD — so without this the five buttons fall
// back to UIParent and sit at a level no production run ever puts them at.
"ActionBar.xml",
// `UpdateMicroButtons` — the KEYRING's own OnShow/OnHide calls it (ContainerFrame.lua l.117,
// l.137), because in the reference the keyring's existence moves the micro-button row.
"MicroMenu.xml",
"UIPanelTemplates.xml",
"Interface\\FrameXML\\ContainerFrame.xml",
"BagFrame.xml",
// `StackSplitFrame` is not optional either: the reference's own
// `ContainerFrameItemButton_OnClick` calls `StackSplitFrame:Hide()` on EVERY plain click
// (ContainerFrame.lua l.581) before the pickup, and opens it on the shift fork.
"StackSplit.xml",
// …nor is the chat edit box. The reference's SHIFT arm opens with
// `if ( ChatFrameEditBox:IsShown() )` (ContainerFrame.lua l.569) to decide between posting the
// item's link and splitting the stack, so a VM without it raises before either.
"ChatFrame.xml",
// Our adapters over the reference's container files — one wrapper for the keyring tooltip, and
// the three bag verbs 0561 shadows (`OpenBackpack`/`CloseBackpack`/`CloseAllBags`). It has to
// be AFTER `ContainerFrame.xml` for either job, which is why it is here and not up with
// UiPanels.xml.
"ContainerFrameAdapters.xml",
// `updateContainerFrameAnchors` measures every open bag against `BankFrame:GetRight()`
// (ContainerFrame.lua l.505) on EVERY open and close, so the bank window is not optional
// scenery for a bag test — it is a hard dependency of the reference's own layout pass. It is
// one in the real client too; the manifest just satisfies it far below the bags.
"BankFrame.xml",
];
/// The name of the `ContainerFrame` currently showing bag `id`, or `None` if it is not open.
///
/// **Ask, never assume.** The reference recycles twelve windows across every container
/// (`ContainerFrame_GetOpenFrame`), so which one a bag lands in depends on what else is open —
/// there is no `BenillaBagFrame2` to name any more, and pinning `ContainerFrame3` in a test would
/// pin a coincidence. `IsBagOpen` is the reference's own published scan.
pub(super) fn bag_window(s: &UiScript, id: i64) -> Option<String> {
s.eval::<Option<i64>>(&format!("return IsBagOpen({id})"))
.unwrap()
.map(|i| format!("ContainerFrame{i}"))
}
/// Is bag `id` open? [`bag_window`]'s predicate half.
pub(super) fn bag_open(s: &UiScript, id: i64) -> bool {
bag_window(s, id).is_some()
}
/// The item button in bag `id`'s open window that holds game slot `slot`.
///
/// Asked of the buttons' own `GetID`, never derived: `ContainerFrame_GenerateFrame` numbers them
/// backwards (`index = size - j + 1`, so `…Item1` is the bag's LAST slot, bottom-right), and a
/// window generated for a different bag size numbers them differently.
pub(super) fn bag_slot_button(s: &UiScript, id: i64, slot: u32) -> String {
let w = bag_window(s, id).unwrap_or_else(|| panic!("bag {id} is not open"));
s.eval::<String>(&format!(
"for j = 1, MAX_CONTAINER_ITEMS do \
local b = getglobal(\"{w}Item\"..j) \
if b and b:IsShown() and b:GetID() == {slot} then return \"{w}Item\"..j end \
end return \"\""
))
.inspect(|n| assert!(!n.is_empty(), "no {w}Item* is bag {id} slot {slot}"))
.expect("the item-button scan")
}
/// The centre of a named frame, in the y-up UI space `mouse_move`/`mouse_button` take.
pub(super) fn centre_of(s: &mut UiScript, name: &str) -> (f32, f32) {
s.resolve();
let r: Vec<f32> = s
.eval(&format!(
"local f = getglobal(\"{name}\") \
return {{ f:GetLeft(), f:GetBottom(), f:GetWidth(), f:GetHeight() }}"
))
.unwrap_or_else(|e| panic!("{name}: no resolved rect: {e}"));
assert_eq!(r.len(), 4, "{name}: unresolved rect {r:?}");
(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)
}
/// Move the mouse onto the centre of `name` — the whole engine path (hit test → `OnEnter`), never
/// `s.run("Handler(button)")`.
///
/// **This is not a style preference any more.** The reference's own handlers read `this`
/// (`ContainerFrameItemButton_OnClick(button, ignoreModifiers)` takes the MOUSE button as its first
/// argument and gets the frame from `this`), and only the engine sets `this`. A migrated window's
/// tests therefore drive the mouse, which is also the stronger test — it puts the
/// `RegisterForClicks` gate and the template's own script wiring under test.
pub(super) fn hover(s: &mut UiScript, name: &str) {
let (x, y) = centre_of(s, name);
s.mouse_move(x, y);
}
/// Move the mouse well clear of everything — the `OnLeave` half of [`hover`].
pub(super) fn unhover(s: &mut UiScript) {
s.mouse_move(-500.0, -500.0);
}
/// Press and release `button` over the centre of `name`, the way a player's mouse does.
pub(super) fn click(s: &mut UiScript, name: &str, button: &str) {
let (x, y) = centre_of(s, name);
s.mouse_move(x, y);
s.mouse_button(x, y, button, true);
s.mouse_button(x, y, button, false);
}
@@ -3,32 +3,25 @@
//! (ref PaperDollFrame.lua:621-640) seats `ShoppingTooltip1` on the matching slot, the armed
//! `SetInventoryItem` renders the byte law's compare shape over the template's own adopted
//! small-font ladder, and `SetItemRef` fills the parked `ItemRefTooltip`.
//!
//! **The bag end of that flow is the REFERENCE's own code since 1751.** The hover source used to be
//! benilla's `BenillaBagSlot_OnEnter` on a `BenillaBagFrame`; it is the reference's
//! `ContainerFrameItemButton_OnEnter` on one of its recycled `ContainerFrame1..12` now. Same two
//! calls arm the compare (`GameTooltip:SetOwner` + `SetBagItem`), so what these tests pin is
//! unchanged — but the handler reads `this`, so they drive the mouse
//! ([`super::test_ui::hover`]) instead of calling it. The doll end is still ours
//! (`BenillaPaperDollSlot_OnEnter`) and is still called directly.
use benilla_ui::script::{
ContainerSlot, ContainerState, InvSlotView, InventorySlots, ItemTemplateView, UiScript,
UnitState,
};
/// Load one shipped `assets/ui/<file>` into `s`, panicking on any loader error (the bag/panel
/// tests' loader, duplicated so this file is self-contained).
fn load_xml(s: &UiScript, file: &str) {
let text = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("assets/ui")
.join(file),
)
.unwrap();
let doc = benilla_ui::framexml::parse(&text).unwrap();
let report = benilla_ui::loader::load(s, &doc, &|_| None);
assert!(
report.errors.is_empty(),
"{file}: loader errors: {:?}",
report.errors
);
}
use super::test_ui::{bag_slot_button, hover, load_ui as load_xml, BAG_UI};
/// The full window set the compare flow crosses: fonts, UIParent, panels, both tooltip files,
/// the bag (hover source) and the character window (the listener's doll slots).
/// The window set the compare flow crosses **without a bag window**: fonts, UIParent, panels, both
/// tooltip files and the character window (the listener's doll slots). Deliberately install-free,
/// so the chat-link test still runs on a bare checkout.
fn harness() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
@@ -50,6 +43,27 @@ fn harness() -> UiScript {
s
}
/// [`harness`] with the REFERENCE's bag windows (1751) — the hover source. `BAG_UI` is the ordered
/// set a test needs before it can open one and already carries the fonts, panels, tooltip,
/// item-button template and bag bar; `UIParent.xml` leads because it inherits nothing and every
/// window below parents to it.
///
/// **Needs client data**: `BAG_UI` names a chain entry, so its callers open with
/// `wow_data_or_skip!`.
fn harness_with_bags() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "UIParent.xml");
for f in BAG_UI {
load_xml(&s, f);
}
for f in ["ItemRef.xml", "MerchantFrame.xml", "CharacterFrame.xml"] {
load_xml(&s, f);
}
s.set_money(0);
s
}
/// An equipped helm in the head slot + a better helm in the backpack, both with templates —
/// the compare pair.
fn seed_items(s: &mut UiScript) {
@@ -136,19 +150,18 @@ fn seed_items(s: &mut UiScript) {
/// the 1.12 behavior.
#[test]
fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
let mut s = harness();
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness_with_bags();
s.set_unit("player", Some(UnitState::default()));
seed_items(&mut s);
// Open the bag and hover the helm slot.
// Open the bag and hover the helm slot. The button is ASKED for by its own `GetID()` — the
// reference numbers a window's buttons backwards and recycles the windows (1751), so neither
// the frame name nor the button index is a property to assume.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.take_sounds();
s.run(
"for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i) \
if b and b.slot == 1 then BENILLA_TEST_BTN = b end end \
BenillaBagSlot_OnEnter(BENILLA_TEST_BTN)",
)
.unwrap();
let btn = bag_slot_button(&s, 0, 1);
hover(&mut s, &btn);
assert!(s.errors().is_empty(), "hover errors: {:?}", s.errors());
// Character window CLOSED: the shift edge fires, no listener answers, nothing shows.
@@ -162,7 +175,7 @@ fn shift_compare_over_a_bag_item_seats_on_the_doll_slot() {
// Open the window; the shift edge now seats the compare on the head slot.
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
s.run("BenillaBagSlot_OnEnter(BENILLA_TEST_BTN)").unwrap();
hover(&mut s, &btn);
s.set_modifiers(true, false, false);
assert!(s.errors().is_empty(), "compare errors: {:?}", s.errors());
let ok: bool = s
@@ -253,7 +266,8 @@ fn item_ref_tooltip_renders_a_chat_link() {
/// by an earlier bag hover.
#[test]
fn doll_hover_renders_the_live_instance_and_never_self_compares() {
let mut s = harness();
let _data = benilla_formats::wow_data_or_skip!();
let mut s = harness_with_bags();
s.set_unit("player", Some(UnitState::default()));
seed_items(&mut s);
// Break the equipped helm: instance pair (0, 40); the template stays authored-full.
@@ -289,17 +303,23 @@ fn doll_hover_renders_the_live_instance_and_never_self_compares() {
},
);
// Arm a compare first through a BAG hover (the stale-arm hazard the doll hover must clear).
// Arm a compare first through a BAG hover (the stale-arm hazard the doll hover must clear) —
// the reference's own `ContainerFrameItemButton_OnEnter` since 1751, reached by moving the
// mouse onto the button rather than by calling the handler.
s.run("BenillaBagToggle_OnClick()").unwrap();
s.take_sounds();
s.run(
"for i = 1, 16 do local b = getglobal(\"BenillaBagSlot\" .. i) \
if b and b.slot == 1 then BENILLA_TEST_BTN = b end end \
BenillaBagSlot_OnEnter(BENILLA_TEST_BTN)",
)
.unwrap();
let btn = bag_slot_button(&s, 0, 1);
hover(&mut s, &btn);
s.run(r#"ToggleCharacter("PaperDollFrame")"#).unwrap();
s.take_sounds();
// …and the arm really IS live at this point — asserted, not assumed, because the whole test
// below is a NEGATIVE and would pass just as well if the bag hover had quietly armed nothing.
s.set_modifiers(true, false, false);
assert!(
s.eval::<bool>("return ShoppingTooltip1:IsShown()").unwrap(),
"fixture: the bag hover armed a compare, so the doll hover has something to clear"
);
s.set_modifiers(false, false, false);
// The doll hover: the live pair, not the template's 40/40.
s.run("BenillaPaperDollSlot_OnEnter(CharacterHeadSlot)")
@@ -288,3 +288,67 @@ fn party_blips_sit_at_their_map_positions_and_hide_when_absent() {
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Opening the map must not hide the map's own furniture.** (Director report: the world showed
/// through at the left and right of the sheet, where black bars had always been.)
///
/// `WorldMapFrame` is an `area = "full"` panel, so `ShowUIPanel` routes it to `SetFullScreenFrame`
/// — which hides `UIParent` (decision 1734 restored that line). Anything the map needs *while it
/// is up* therefore must not hang off `UIParent`, and the reference is emphatic about it in three
/// places: `BlackoutWorld` is a texture inside `WorldMapFrame` itself, `WorldMapTooltip` is
/// `parent="WorldMapFrame"` where the shared `GameTooltip` is `parent="UIParent"`, and the
/// dropdown lists carry `toplevel="true"` with no parent at all.
#[test]
fn the_maps_own_furniture_survives_the_hide_that_showing_it_performs() {
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.resolve();
let visible = |s: &UiScript, f: &str| {
s.eval::<i64>(&format!(
"local x = getglobal('{f}') if not x then return -1 end return x:IsVisible() and 1 or 0"
))
.unwrap()
};
s.run("ShowUIPanel(WorldMapFrame)").unwrap();
s.resolve();
assert_eq!(visible(&s, "WorldMapFrame"), 1, "the map itself is up");
assert_eq!(
visible(&s, "UIParent"),
0,
"and it took the screen from the HUD"
);
assert_eq!(
visible(&s, "BlackoutWorld"),
1,
"the blackout is up WITH it — it is what stops the 3D world showing through the margins \
beside the 4:3 sheet, and a blackout that hides when the map opens is no blackout"
);
// The map's own header carries two `UIDropDownMenu` pickers, and every menu in the game is
// seated on the shared `DropDownList1`. `ToggleDropDownMenu` ends in `listFrame:Show()`, so
// the property that decides whether a continent list can ever appear over the map is whether
// that Show survives — which is what a `parent="UIParent"` took away. Asserted at the Show
// rather than through `ToggleDropDownMenu` on purpose: the toggle bails early on an empty
// list (`numButtons == 0`), so driving it here would pass on a host with no continents
// pushed and prove nothing about the seat.
s.run("DropDownList1:Show()").unwrap();
s.resolve();
assert_eq!(
visible(&s, "DropDownList1"),
1,
"the shared dropdown list can open over a full-screen panel — the continent and zone \
pickers in the map's own header have no other seat"
);
s.run("CloseDropDownMenus()").unwrap();
s.run("HideUIPanel(WorldMapFrame)").unwrap();
s.resolve();
assert_eq!(visible(&s, "UIParent"), 1, "and the HUD comes back after");
assert_eq!(visible(&s, "BlackoutWorld"), 0, "with the blackout down");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+36 -3
View File
@@ -36,6 +36,13 @@ pub struct MapCatalog {
names: HashMap<u32, String>,
/// `mapId → LoadingScreenID` FK into `LoadingScreens.dbc` (only maps with a non-zero FK).
loading_screens: HashMap<u32, u32>,
/// `mapId → InstanceType` (field 2, `+0x8` — the offset the binary reads at `0x48a772`,
/// `0x495cb9` and `0x495d33`). VERIFIED by dumping the shipped patch-2 `Map.dbc`, 2026-08-30:
/// **0** none (Azeroth, Kalimdor, Deeprun Tram), **1** party dungeon (Deadmines, Scholomance),
/// **2** raid (Molten Core, Naxxramas), **3** battleground (Alterac Valley, Warsong Gulch) —
/// the same four the client's own `IsInInstance` string table spells `none`/`party`/`raid`/
/// `pvp` (`0x83de58`, indexed by this value with a `< 4` guard).
instance_types: HashMap<u32, u32>,
}
impl MapCatalog {
@@ -59,6 +66,21 @@ impl MapCatalog {
self.loading_screens.get(&map_id).copied()
}
/// `InstanceType` for `map_id` (see [`MapCatalog::instance_types`]), or `None` for a map id
/// the DBC has no row for. The client treats a missing row as "not an instance" everywhere it
/// asks — it null-checks the record pointer first and takes the same branch as type 0.
pub fn instance_type(&self, map_id: u32) -> Option<u32> {
self.instance_types.get(&map_id).copied()
}
/// Whether `map_id` is a **party dungeon** (`InstanceType == 1`). This exact predicate — not
/// "is an instance" — is the one the reference's lockout bookkeeping runs on: `cmp [rec+8],1`
/// gates both what `SMSG_UPDATE_LAST_INSTANCE` records and both halves of
/// `CanShowResetInstances` (decision 1748).
pub fn is_party_dungeon(&self, map_id: u32) -> bool {
self.instance_type(map_id) == Some(1)
}
pub fn len(&self) -> usize {
self.dirs.len()
}
@@ -71,15 +93,19 @@ impl MapCatalog {
/// Field index of the enUS `MapName_Lang` string (`+0x10`, see [`MapCatalog::names`]).
const MAP_NAME_FIELD: usize = 4;
/// 42 fields total; field 0 = ID, field 1 = Directory string, field 4 = MapName (enUS),
/// field 38 = LoadingScreenID (FK). Remaining fields are placeholders (4-byte dwords; the schema
/// only needs to total 168 B).
/// Field index of `InstanceType` (`+0x8`, see [`MapCatalog::instance_types`]).
const INSTANCE_TYPE_FIELD: usize = 2;
/// 42 fields total; field 0 = ID, field 1 = Directory string, field 2 = InstanceType,
/// field 4 = MapName (enUS), field 38 = LoadingScreenID (FK). Remaining fields are placeholders
/// (4-byte dwords; the schema only needs to total 168 B).
fn map_schema() -> Schema {
let mut s = Schema::new("Map");
s.add_field(SchemaField::new("ID", FieldType::UInt32));
s.add_field(SchemaField::new("Directory", FieldType::String));
for i in 2..42 {
match i {
INSTANCE_TYPE_FIELD => s.add_field(SchemaField::new("InstanceType", FieldType::UInt32)),
MAP_NAME_FIELD => s.add_field(SchemaField::new("MapName", FieldType::String)),
LOADING_SCREEN_FIELD => {
s.add_field(SchemaField::new("LoadingScreenID", FieldType::UInt32))
@@ -99,6 +125,7 @@ pub fn load_map_catalog(chain: &mut Chain) -> Result<MapCatalog> {
let mut dirs = HashMap::with_capacity(rs.records().len());
let mut names = HashMap::with_capacity(rs.records().len());
let mut loading_screens = HashMap::new();
let mut instance_types = HashMap::with_capacity(rs.records().len());
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(dir) = str_at(&rs, r, 1) {
@@ -107,6 +134,11 @@ pub fn load_map_catalog(chain: &mut Chain) -> Result<MapCatalog> {
if let Some(name) = str_at(&rs, r, MAP_NAME_FIELD).filter(|n| !n.is_empty()) {
names.insert(id, name);
}
// Every row's type is recorded, 0 included: "0" and "no such map" are different answers
// to `instance_type`, and the second is what a bad map id must give.
if let Some(ty) = u32_at(r, INSTANCE_TYPE_FIELD) {
instance_types.insert(id, ty);
}
// 0 = "no loading screen" (dev/test maps); only record real FKs.
if let Some(ls) = u32_at(r, LOADING_SCREEN_FIELD).filter(|&v| v != 0) {
loading_screens.insert(id, ls);
@@ -116,5 +148,6 @@ pub fn load_map_catalog(chain: &mut Chain) -> Result<MapCatalog> {
dirs,
names,
loading_screens,
instance_types,
})
}
+22
View File
@@ -1170,6 +1170,28 @@ pub enum SessionEvent {
RaidInstanceInfo {
entries: Vec<crate::messages::RaidInstanceEntry>,
},
// ── The instance/raid lockout family (decision 1748) ────────────────────────────────────
//
// Four of these become a `CHAT_MSG_SYSTEM` line the client composes itself out of
// GlobalStrings; two are pure bookkeeping behind `CanShowResetInstances()`. The map NAME is
// never on the wire — the app resolves the id through `Map.dbc`.
/// A raid lockout's welcome/countdown line (`SMSG_RAID_INSTANCE_MESSAGE`).
RaidInstanceMessage {
message: crate::messages::RaidInstanceMessage,
},
/// We just became permanently saved to the instance we are in (`SMSG_INSTANCE_SAVE_CREATED`).
/// `flag` is the wire value: vmangos always sends 0, and 1 is the reference's debug wrapper.
InstanceSaveCreated { flag: u32 },
/// An instance reset took (`SMSG_INSTANCE_RESET`) — `map` is the `Map.dbc` id.
InstanceReset { map: u32 },
/// An instance reset was refused (`SMSG_INSTANCE_RESET_FAILED`).
InstanceResetFailed {
failure: crate::messages::InstanceResetFailed,
},
/// The `Map.dbc` id of the dungeon we were last inside (`SMSG_UPDATE_LAST_INSTANCE`).
UpdateLastInstance { map: u32 },
/// Whether we hold any permanent bind (`SMSG_UPDATE_INSTANCE_OWNERSHIP`).
UpdateInstanceOwnership { owns: bool },
/// A duel challenge (`SMSG_DUEL_REQUESTED`, decision 0633) — delivered to challenger and
/// challenged alike. `arbiter` is the duel-flag GameObject that identifies the duel and is
/// echoed on accept/cancel; `challenger` equal to our own guid means we are the one asking.
@@ -555,6 +555,22 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
arbiter,
challenger,
}],
// The instance/raid lockout family (decision 1748) — straight relays; the ownership flag
// is narrowed to a bool here because the reference's own reader is a `test eax,eax`.
ServerPacket::RaidInstanceMessage { message } => {
vec![SessionEvent::RaidInstanceMessage { message }]
}
ServerPacket::InstanceSaveCreated { flag } => {
vec![SessionEvent::InstanceSaveCreated { flag }]
}
ServerPacket::InstanceReset { map } => vec![SessionEvent::InstanceReset { map }],
ServerPacket::InstanceResetFailed { failure } => {
vec![SessionEvent::InstanceResetFailed { failure }]
}
ServerPacket::UpdateLastInstance { map } => vec![SessionEvent::UpdateLastInstance { map }],
ServerPacket::UpdateInstanceOwnership { owns } => {
vec![SessionEvent::UpdateInstanceOwnership { owns: owns != 0 }]
}
ServerPacket::DuelOutOfBounds => vec![SessionEvent::DuelOutOfBounds],
ServerPacket::DuelInBounds => vec![SessionEvent::DuelInBounds],
ServerPacket::DuelComplete { started } => vec![SessionEvent::DuelComplete { started }],
@@ -0,0 +1,168 @@
//! The instance/raid **lockout message** family — the six server packets the 1.12 client turns
//! into a `CHAT_MSG_SYSTEM` line or into its own lockout bookkeeping, plus the one thing the
//! player can ask for (`CMSG_RESET_INSTANCES`). Decision 1748.
//!
//! | opcode | direction | body | what the client does with it |
//! |---|---|---|---|
//! | `SMSG_INSTANCE_SAVE_CREATED` 0x2cb | in | `u32` flag | chat line `INSTANCE_SAVED` |
//! | `SMSG_RAID_INSTANCE_MESSAGE` 0x2fa | in | `u32` type, `u32` mapId, `u32` secsToReset | chat line, one of four |
//! | `CMSG_RESET_INSTANCES` 0x31d | out | empty | — |
//! | `SMSG_INSTANCE_RESET` 0x31e | in | `u32` mapId | chat line `INSTANCE_RESET_SUCCESS` + clears the last-instance latch |
//! | `SMSG_INSTANCE_RESET_FAILED` 0x31f | in | `u32` reason, `u32` mapId | chat line, one of three |
//! | `SMSG_UPDATE_LAST_INSTANCE` 0x320 | in | `u32` mapId | records the last dungeon we were in |
//! | `SMSG_UPDATE_INSTANCE_OWNERSHIP` 0x32b | in | `u32` flag | the "do I hold any permanent bind" latch |
//!
//! **The client side is VERIFIED at the bytes** (WoW.exe build 5875): the six handlers are
//! registered together at `0x498680`-`0x4986cf` (`0x49e1c0` 0x2fa, `0x49e470` 0x31e, `0x49e540`
//! 0x31f, `0x49e670` 0x320, `0x49e6c0` 0x32b) with the save-created one registered apart, in the
//! chat-spam-filter TU, at `0x4e7e48` (`0x4e7e60`). Every one reads its fields with
//! `CDataStore::GetInt32 0x418e30` in the order below, so the layouts here are the client's own
//! read order, not an inference from the server. The server side is VERIFIED vmangos
//! `Server/Packets/Misc.{h,cpp}` (`RaidInstanceMessage`, `InstanceReset`, `InstanceResetFailed`,
//! `UpdateLastInstance`, `UpdateInstanceOwnership`) + `Maps/Map.cpp:2288,2388` for the
//! save-created flag, and the two agree field for field.
//!
//! **No map NAME is ever on the wire** — every one of these carries a `Map.dbc` id and the client
//! looks the display name up itself (`[0xc0daa8][id]`, field `+0x10` = `MapName_Lang[locale]`),
//! falling back to the id printed through `"%d"` when the row is missing. Ours resolves through
//! `MapCatalog::name` with the same fallback.
use std::io;
use crate::wire::read_u32_le;
/// `SMSG_RAID_INSTANCE_MESSAGE`'s `type` — which of the four warning templates the line uses
/// (VERIFIED: the client's `switch (type - 1)` jump table at `0x49e45c`, four entries; the same
/// numbering vmangos writes in `Objects/Player.h:568-573`).
///
/// **Type 5 (`RAID_INSTANCE_EXPIRED` in later clients) and type 0 print nothing**: the dispatch is
/// `dec eax; cmp eax,3; ja <return>`, so anything outside 1..=4 leaves the handler silently.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RaidInstanceWarning {
/// 1 — `RAID_INSTANCE_WARNING_HOURS`, filled with `resetTime / 3600`.
Hours,
/// 2 — `RAID_INSTANCE_WARNING_MIN`, filled with `resetTime / 60`.
Minutes,
/// 3 — `RAID_INSTANCE_WARNING_MIN_SOON`, filled with `resetTime / 60`.
MinutesSoon,
/// 4 — `RAID_INSTANCE_WELCOME`, filled with the `d`/`h`/`m` breakdown of `resetTime`.
Welcome,
}
impl RaidInstanceWarning {
/// The wire `type` → the template, or `None` for the values the reference's jump table drops
/// (0 and ≥ 5). A dropped type is not an error: the packet is well-formed, the client just
/// has no line for it.
pub fn from_wire(ty: u32) -> Option<Self> {
match ty {
1 => Some(Self::Hours),
2 => Some(Self::Minutes),
3 => Some(Self::MinutesSoon),
4 => Some(Self::Welcome),
_ => None,
}
}
/// The GlobalStrings token the reference passes to `GetText` for this template (the four
/// `mov ecx, <string>` at `0x49e276`/`0x49e2e5`/`0x49e354`/`0x49e40b`).
pub fn token(self) -> &'static str {
match self {
Self::Hours => "RAID_INSTANCE_WARNING_HOURS",
Self::Minutes => "RAID_INSTANCE_WARNING_MIN",
Self::MinutesSoon => "RAID_INSTANCE_WARNING_MIN_SOON",
Self::Welcome => "RAID_INSTANCE_WELCOME",
}
}
}
/// `SMSG_RAID_INSTANCE_MESSAGE` — a raid lockout's welcome/countdown line.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RaidInstanceMessage {
/// The raw wire type. Kept raw rather than pre-resolved so a type the reference drops still
/// round-trips through the event layer (and so a golden can assert the drop).
pub message_type: u32,
/// `Map.dbc` id of the instance the warning is about.
pub map: u32,
/// **Seconds remaining** until the reset, not a timestamp (vmangos passes
/// `resetTime` = a remaining duration into `SendInstanceResetWarning`).
pub reset: u32,
}
/// `SMSG_INSTANCE_RESET_FAILED`'s `reason` — which of the three refusal templates the line uses
/// (VERIFIED: the client's `sub eax,0; je / dec eax; je / dec eax; jne` ladder at `0x49e5b7`;
/// vmangos `Maps/MapPersistentStateMgr.h:270-276`, whose fourth value `_SILENTLY` = 3 is
/// deliberately outside the ladder).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InstanceResetFailure {
/// 0 — `INSTANCE_RESET_FAILED`: players still inside.
General,
/// 1 — `INSTANCE_RESET_FAILED_OFFLINE`: someone in the party is offline.
Offline,
/// 2 — `INSTANCE_RESET_FAILED_ZONING`: someone in the party is zoning in.
Zoning,
}
impl InstanceResetFailure {
/// The wire `reason` → the template, or `None` for ≥ 3 (`INSTANCERESET_FAIL_SILENTLY` and
/// above). The reference *falls through* those to its chat call with an **uninitialized**
/// stack buffer — a real 1.12 bug we do not reproduce: silence is what the server means by
/// them, and printing a garbage line is not fidelity to an intent, only to an accident.
pub fn from_wire(reason: u32) -> Option<Self> {
match reason {
0 => Some(Self::General),
1 => Some(Self::Offline),
2 => Some(Self::Zoning),
_ => None,
}
}
/// The GlobalStrings token (the three `mov ecx, <string>` at `0x49e61b`/`0x49e5f6`/`0x49e5d1`).
pub fn token(self) -> &'static str {
match self {
Self::General => "INSTANCE_RESET_FAILED",
Self::Offline => "INSTANCE_RESET_FAILED_OFFLINE",
Self::Zoning => "INSTANCE_RESET_FAILED_ZONING",
}
}
}
/// `SMSG_INSTANCE_RESET_FAILED` — the group leader's reset was refused.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct InstanceResetFailed {
/// The raw wire reason (see [`InstanceResetFailure::from_wire`]).
pub reason: u32,
/// `Map.dbc` id of the instance that would not reset.
pub map: u32,
}
/// Read `SMSG_RAID_INSTANCE_MESSAGE`: `u32 type`, `u32 mapId`, `u32 secondsUntilReset` — the
/// client's own read order at `0x49e1cd`/`0x49e1d8`/`0x49e1e3`.
pub(super) fn read_raid_instance_message(r: &mut &[u8]) -> io::Result<RaidInstanceMessage> {
Ok(RaidInstanceMessage {
message_type: read_u32_le(r)?,
map: read_u32_le(r)?,
reset: read_u32_le(r)?,
})
}
/// Read `SMSG_INSTANCE_RESET_FAILED`: `u32 reason`, `u32 mapId` (`0x49e54d`/`0x49e558`).
pub(super) fn read_instance_reset_failed(r: &mut &[u8]) -> io::Result<InstanceResetFailed> {
Ok(InstanceResetFailed {
reason: read_u32_le(r)?,
map: read_u32_le(r)?,
})
}
/// Read a body that is one bare `u32` — `SMSG_INSTANCE_RESET`'s map id (`0x49e481`),
/// `SMSG_UPDATE_LAST_INSTANCE`'s map id (`0x49e676`), `SMSG_UPDATE_INSTANCE_OWNERSHIP`'s flag
/// (`0x49e6c6`) and `SMSG_INSTANCE_SAVE_CREATED`'s flag (`0x4e7e6c`) are all exactly this.
pub(super) fn read_u32_body(r: &mut &[u8]) -> io::Result<u32> {
read_u32_le(r)
}
/// Body of `CMSG_RESET_INSTANCES` (VERIFIED both ways: the client's `ResetInstances` binding
/// `0x48a6b0` builds a `CDataStore` and sends opcode `0x31d` without writing a field; vmangos
/// `HandleResetInstancesOpcode` reads nothing): empty.
pub fn reset_instances() -> Vec<u8> {
Vec::new()
}
@@ -33,6 +33,7 @@ mod gm_ticket;
mod gossip;
mod group;
mod guild;
mod instance;
mod items;
mod loot;
mod mail;
@@ -142,6 +143,10 @@ pub use guild::{
GUILD_MOTD_MAX_LENGTH, GUILD_NAME_MAX_LENGTH, GUILD_NOTE_MAX_LENGTH, GUILD_RANKS_MAX_COUNT,
GUILD_RANKS_MIN_COUNT, GUILD_RANK_MAX_LENGTH, GUILD_RANK_RIGHT_ORDER,
};
pub use instance::{
reset_instances, InstanceResetFailed, InstanceResetFailure, RaidInstanceMessage,
RaidInstanceWarning,
};
pub use items::{
auto_equip_item, auto_store_bag_item, destroy_item, item_query, open_item, set_ammo,
split_item, swap_inv_item, swap_item, use_item, ItemDamage, ItemInfo, ItemSpellEntry,
@@ -1243,3 +1243,33 @@ pub const SMSG_AUCTION_REMOVED_NOTIFICATION: u16 = 0x028D; // 653
// later.
pub const SMSG_INIT_WORLD_STATES: u16 = 0x02C2; // 706
pub const SMSG_UPDATE_WORLD_STATE: u16 = 0x02C3; // 707
// ── The instance/raid lockout family (decision 1748) ──────────────────────────────────────────
//
// The five inbound handlers WoW.exe registers together at `0x498680`-`0x4986cf`, plus the
// save-created one registered apart at `0x4e7e48`, plus the one thing the player can ask for.
// Bodies (and the client's own read order) in [`super::instance`]; server side VERIFIED vmangos
// `Server/Packets/Misc.{h,cpp}` + `Maps/Map.cpp`.
/// "You are now saved to this instance" (`u32` flag; handler `0x4e7e60`). vmangos always sends
/// `0`; the client's `== 1` arm wraps the same string in a `"(Debug-Only Lock Notice) %s"`
/// literal, and anything ≥ 2 prints an uninitialized buffer (a real 1.12 bug — we print nothing).
pub const SMSG_INSTANCE_SAVE_CREATED: u16 = 0x02CB; // 715
/// The raid-lockout welcome/countdown line: `u32 type`, `u32 mapId`, `u32 secondsUntilReset`
/// (handler `0x49e1c0`; the four types in [`super::instance::RaidInstanceWarning`]).
pub const SMSG_RAID_INSTANCE_MESSAGE: u16 = 0x02FA; // 762
/// "Reset all instances" — empty body (the `ResetInstances` binding `0x48a6b0`; vmangos
/// `HandleResetInstancesOpcode`).
pub const CMSG_RESET_INSTANCES: u16 = 0x031D; // 797
/// "%s has been reset." — `u32 mapId` (handler `0x49e470`, which also clears the last-instance
/// latch `0x495d00` *before* reading the body).
pub const SMSG_INSTANCE_RESET: u16 = 0x031E; // 798
/// The reset refusal: `u32 reason`, `u32 mapId` (handler `0x49e540`; the three reasons in
/// [`super::instance::InstanceResetFailure`]).
pub const SMSG_INSTANCE_RESET_FAILED: u16 = 0x031F; // 799
/// The dungeon we were last in: `u32 mapId` (handler `0x49e670` → `0x495d10`). Shows no line —
/// it is half of what `CanShowResetInstances()` reads.
pub const SMSG_UPDATE_LAST_INSTANCE: u16 = 0x0320; // 800
/// Whether we hold any permanent bind: `u32` flag (handler `0x49e6c0` → `0x495d50`). The other
/// half of `CanShowResetInstances()`.
pub const SMSG_UPDATE_INSTANCE_OWNERSHIP: u16 = 0x032B; // 811
@@ -1116,6 +1116,31 @@ pub enum ServerPacket {
RaidInstanceInfo {
entries: Vec<super::group::RaidInstanceEntry>,
},
/// `SMSG_RAID_INSTANCE_MESSAGE` — a raid lockout's welcome/countdown line (decision 1748).
RaidInstanceMessage {
message: super::instance::RaidInstanceMessage,
},
/// `SMSG_INSTANCE_SAVE_CREATED` — "you are now saved to this instance". vmangos always sends
/// `0`; the flag is kept raw because the client's `== 1` arm is a different line.
InstanceSaveCreated {
flag: u32,
},
/// `SMSG_INSTANCE_RESET` — the reset took; the payload is the `Map.dbc` id that was reset.
InstanceReset {
map: u32,
},
/// `SMSG_INSTANCE_RESET_FAILED` — the reset was refused (decision 1748).
InstanceResetFailed {
failure: super::instance::InstanceResetFailed,
},
/// `SMSG_UPDATE_LAST_INSTANCE` — the `Map.dbc` id of the dungeon we were last in.
UpdateLastInstance {
map: u32,
},
/// `SMSG_UPDATE_INSTANCE_OWNERSHIP` — non-zero while we hold at least one permanent bind.
UpdateInstanceOwnership {
owns: u32,
},
/// `SMSG_DUEL_REQUESTED` — a duel challenge. Sent to challenger and challenged alike; which
/// one we are is `challenger == our guid` (decision 0633).
DuelRequested {
@@ -1613,6 +1638,12 @@ impl ServerPacket {
}
ServerPacket::RaidInstanceInfo { .. } => "SMSG_RAID_INSTANCE_INFO".into(),
ServerPacket::DuelRequested { .. } => "SMSG_DUEL_REQUESTED".into(),
ServerPacket::RaidInstanceMessage { .. } => "SMSG_RAID_INSTANCE_MESSAGE".into(),
ServerPacket::InstanceSaveCreated { .. } => "SMSG_INSTANCE_SAVE_CREATED".into(),
ServerPacket::InstanceReset { .. } => "SMSG_INSTANCE_RESET".into(),
ServerPacket::InstanceResetFailed { .. } => "SMSG_INSTANCE_RESET_FAILED".into(),
ServerPacket::UpdateLastInstance { .. } => "SMSG_UPDATE_LAST_INSTANCE".into(),
ServerPacket::UpdateInstanceOwnership { .. } => "SMSG_UPDATE_INSTANCE_OWNERSHIP".into(),
ServerPacket::DuelOutOfBounds => "SMSG_DUEL_OUTOFBOUNDS".into(),
ServerPacket::DuelInBounds => "SMSG_DUEL_INBOUNDS".into(),
ServerPacket::DuelComplete { .. } => "SMSG_DUEL_COMPLETE".into(),
+21 -1
View File
@@ -12,7 +12,7 @@ use crate::wire::{
use super::{
action_bar, area_trigger, attack, auction, bank, binder, channel, chat, combat_log, death,
duel, gameobject, gm_ticket, gossip, group, guild, items, loot, mail, mirror_timer,
duel, gameobject, gm_ticket, gossip, group, guild, instance, items, loot, mail, mirror_timer,
monster_move, movement, opcode, page_text, pet, petition, progression, pvp, quest, social,
spellbook, spells, stable, summon, taxi, trade, trainer, update_object, vendor, world_state,
Character, CreatureQueryInfo, JumpInfo, MoveMode, ServerPacket, SpeedKind,
@@ -1098,6 +1098,26 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
ServerPacket::ReadyCheckAnswer { guid, ready }
}
},
// The instance/raid lockout family (decision 1748; bodies in `instance`). Every one of
// these is read here in the client's own field order.
opcode::SMSG_RAID_INSTANCE_MESSAGE => ServerPacket::RaidInstanceMessage {
message: instance::read_raid_instance_message(&mut r)?,
},
opcode::SMSG_INSTANCE_SAVE_CREATED => ServerPacket::InstanceSaveCreated {
flag: instance::read_u32_body(&mut r)?,
},
opcode::SMSG_INSTANCE_RESET => ServerPacket::InstanceReset {
map: instance::read_u32_body(&mut r)?,
},
opcode::SMSG_INSTANCE_RESET_FAILED => ServerPacket::InstanceResetFailed {
failure: instance::read_instance_reset_failed(&mut r)?,
},
opcode::SMSG_UPDATE_LAST_INSTANCE => ServerPacket::UpdateLastInstance {
map: instance::read_u32_body(&mut r)?,
},
opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP => ServerPacket::UpdateInstanceOwnership {
owns: instance::read_u32_body(&mut r)?,
},
// The duel family (decision 0633; bodies in `duel`). Both empty-body arms carry their
// whole meaning in the opcode.
opcode::SMSG_DUEL_REQUESTED => {
@@ -0,0 +1,30 @@
//! The instance/raid lockout family's one outbound verb (decision 1748).
//!
//! Everything else in the family is the server telling us something; the only thing the player can
//! *do* is press "Reset all instances" on their own portrait, which is this.
use anyhow::Result;
use crate::messages::{self, opcode};
use super::WorldWriter;
impl WorldWriter {
/// Reset every dungeon we are the owner of (`CMSG_RESET_INSTANCES`, empty body) — the
/// `CONFIRM_RESET_INSTANCES` dialog's Yes.
///
/// The answer is per instance, not one verdict: the server walks every bind and sends an
/// `SMSG_INSTANCE_RESET` for each map that reset, so a mixed outcome is normal — and **no
/// answer at all is the ordinary case**, because a character with nothing resettable gets
/// nothing back. Refusals only exist on the group path: vmangos `Group::ResetInstances` sends
/// `INSTANCERESET_FAIL_OFFLINE` (2187) and `INSTANCERESET_FAIL_GENERAL` (2205), while the solo
/// path `Player::ResetInstances` sends successes only. `INSTANCERESET_FAIL_ZONING` is a code
/// the client can render and this server never sends.
///
/// Both sides also refuse a RAID here — `entry->mapType == MAP_RAID` is skipped outright —
/// which is the server's face of the same party-dungeon rule `CanShowResetInstances()` gates
/// the menu row on.
pub fn reset_instances(&mut self) -> Result<()> {
self.send(opcode::CMSG_RESET_INSTANCES, &messages::reset_instances())
}
}
@@ -40,6 +40,7 @@
//! | [`gameobject`] | the one USE verb + the template ask |
//! | [`group`] | party/raid: invite, kick, leader, loot method, icons, ready checks |
//! | [`guild`] | the guild: the two cache asks, invitations, member verbs, rank administration |
//! | [`instance`] | the one lockout verb: reset all instances |
//! | [`trade`] | the player-trade dance |
//! | [`duel`] | accept and cancel |
//! | [`mail`] | the mailbox |
@@ -70,6 +71,7 @@ mod gm_ticket;
mod gossip;
mod group;
mod guild;
mod instance;
mod items;
mod lifecycle;
mod loot;
+172
View File
@@ -0,0 +1,172 @@
//! Oracle-free golden tests for the instance/raid **lockout message** family (decision 1748): the
//! six inbound bodies, the one outbound body, and the two enums the client's own dispatch tables
//! define.
//!
//! Every layout here is the *client's* read order, taken from the five handlers registered at
//! `0x498680`-`0x4986cf` plus `0x4e7e60`, and cross-checked against vmangos
//! `Server/Packets/Misc.{h,cpp}`. `hx(...)` bodies round-tripped through `parse_server`, the
//! idiom of `tests/bank.rs`.
use benilla_protocol::events::{decode, SessionEvent};
use benilla_protocol::messages::{
self, opcode, InstanceResetFailure, RaidInstanceMessage, RaidInstanceWarning,
};
use benilla_protocol::ServerPacket;
fn hx(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
.collect()
}
/// `CMSG_RESET_INSTANCES` — empty, both in the binding (`0x48a6b0` writes no field before its
/// send) and in vmangos's handler (which reads none).
#[test]
fn reset_instances_body_is_empty() {
assert_eq!(messages::reset_instances(), Vec::<u8>::new());
}
/// `SMSG_RAID_INSTANCE_MESSAGE`: `u32 type`, `u32 mapId`, `u32 secondsUntilReset`, in that order
/// — the client reads the three at `0x49e1cd`/`0x49e1d8`/`0x49e1e3` and uses the SECOND for the
/// `Map.dbc` lookup, which is what fixes the order (a type/map swap would name the wrong instance
/// and pick the wrong template, silently).
#[test]
fn raid_instance_message_wire() {
// type 4 (WELCOME), map 409 (Molten Core), 3 d 2 h 0 m = 266400 s.
let body = hx(concat!("04000000", "99010000", "a0100400"));
let p = messages::parse_server(opcode::SMSG_RAID_INSTANCE_MESSAGE, &body).unwrap();
match &p {
ServerPacket::RaidInstanceMessage { message } => {
assert_eq!(
*message,
RaidInstanceMessage {
message_type: 4,
map: 409,
reset: 266_400,
}
);
}
other => panic!("expected RaidInstanceMessage, got {}", other.name()),
}
match decode(p).as_slice() {
[SessionEvent::RaidInstanceMessage { message }] => {
assert_eq!(message.map, 409);
assert_eq!(message.reset, 266_400);
}
other => panic!("raid instance message decode: {other:?}"),
}
// A body one field short is a parse failure, not a zero-filled message.
assert!(
messages::parse_server(opcode::SMSG_RAID_INSTANCE_MESSAGE, &hx("0400000099010000"))
.is_err()
);
}
/// The four warning types and the two the reference's jump table drops. `dec eax; cmp eax,3; ja`
/// at `0x49e246` means 0 and ≥ 5 leave the handler in silence — including the value later clients
/// call `RAID_INSTANCE_EXPIRED` (5).
#[test]
fn raid_instance_warning_types() {
use RaidInstanceWarning as W;
assert_eq!(W::from_wire(1), Some(W::Hours));
assert_eq!(W::from_wire(2), Some(W::Minutes));
assert_eq!(W::from_wire(3), Some(W::MinutesSoon));
assert_eq!(W::from_wire(4), Some(W::Welcome));
assert_eq!(W::from_wire(0), None);
assert_eq!(
W::from_wire(5),
None,
"RAID_INSTANCE_EXPIRED prints nothing"
);
assert_eq!(W::from_wire(u32::MAX), None);
assert_eq!(W::Hours.token(), "RAID_INSTANCE_WARNING_HOURS");
assert_eq!(W::Minutes.token(), "RAID_INSTANCE_WARNING_MIN");
assert_eq!(W::MinutesSoon.token(), "RAID_INSTANCE_WARNING_MIN_SOON");
assert_eq!(W::Welcome.token(), "RAID_INSTANCE_WELCOME");
}
/// `SMSG_INSTANCE_RESET`: one `u32` map id (`0x49e481`).
#[test]
fn instance_reset_wire() {
let p = messages::parse_server(opcode::SMSG_INSTANCE_RESET, &hx("24000000")).unwrap();
match &p {
ServerPacket::InstanceReset { map } => assert_eq!(*map, 36), // Deadmines
other => panic!("expected InstanceReset, got {}", other.name()),
}
match decode(p).as_slice() {
[SessionEvent::InstanceReset { map: 36 }] => {}
other => panic!("instance reset decode: {other:?}"),
}
assert!(messages::parse_server(opcode::SMSG_INSTANCE_RESET, &hx("2400")).is_err());
}
/// `SMSG_INSTANCE_RESET_FAILED`: `u32 reason` FIRST, then `u32 mapId` (`0x49e54d`/`0x49e558`) —
/// the opposite order to the reset success packet's single field, and the pair vmangos writes in
/// `InstanceResetFailed::AppendBodyTo`.
#[test]
fn instance_reset_failed_wire() {
let body = hx(concat!("01000000", "24000000")); // OFFLINE, Deadmines
let p = messages::parse_server(opcode::SMSG_INSTANCE_RESET_FAILED, &body).unwrap();
match &p {
ServerPacket::InstanceResetFailed { failure } => {
assert_eq!(failure.reason, 1);
assert_eq!(failure.map, 36);
}
other => panic!("expected InstanceResetFailed, got {}", other.name()),
}
match decode(p).as_slice() {
[SessionEvent::InstanceResetFailed { failure }] => {
assert_eq!(
InstanceResetFailure::from_wire(failure.reason),
Some(InstanceResetFailure::Offline)
);
}
other => panic!("instance reset failed decode: {other:?}"),
}
}
/// The three refusal reasons, and the silent tail. vmangos's own enum names 3
/// `INSTANCERESET_FAIL_SILENTLY` "as well as any above this"; the reference falls through those
/// to its chat call with an uninitialized buffer, which we deliberately do not reproduce.
#[test]
fn instance_reset_failure_reasons() {
use InstanceResetFailure as F;
assert_eq!(F::from_wire(0), Some(F::General));
assert_eq!(F::from_wire(1), Some(F::Offline));
assert_eq!(F::from_wire(2), Some(F::Zoning));
assert_eq!(F::from_wire(3), None, "INSTANCERESET_FAIL_SILENTLY");
assert_eq!(F::from_wire(99), None);
assert_eq!(F::General.token(), "INSTANCE_RESET_FAILED");
assert_eq!(F::Offline.token(), "INSTANCE_RESET_FAILED_OFFLINE");
assert_eq!(F::Zoning.token(), "INSTANCE_RESET_FAILED_ZONING");
}
/// The three bare-`u32` bodies that are not a map name: the save-created flag (`0x4e7e6c`), the
/// last-instance map (`0x49e676`) and the ownership flag (`0x49e6c6`). The ownership flag is
/// narrowed to a bool at the event layer because the reference's reader is a `test eax,eax`.
#[test]
fn save_created_last_instance_and_ownership_wire() {
let p = messages::parse_server(opcode::SMSG_INSTANCE_SAVE_CREATED, &hx("00000000")).unwrap();
match decode(p).as_slice() {
[SessionEvent::InstanceSaveCreated { flag: 0 }] => {}
other => panic!("save created decode: {other:?}"),
}
let p = messages::parse_server(opcode::SMSG_UPDATE_LAST_INSTANCE, &hx("99010000")).unwrap();
match decode(p).as_slice() {
[SessionEvent::UpdateLastInstance { map: 409 }] => {}
other => panic!("last instance decode: {other:?}"),
}
for (body, owns) in [("00000000", false), ("01000000", true), ("07000000", true)] {
let p = messages::parse_server(opcode::SMSG_UPDATE_INSTANCE_OWNERSHIP, &hx(body)).unwrap();
match decode(p).as_slice() {
[SessionEvent::UpdateInstanceOwnership { owns: got }] => assert_eq!(*got, owns),
other => panic!("ownership decode: {other:?}"),
}
}
}
+31 -1
View File
@@ -295,6 +295,36 @@ pub fn join_ref(base: &str, path: &str) -> String {
out.join("/")
}
/// The frame KIND an element materializes as — its tag, with one substitution.
///
/// **A `<Model>` playing the cooldown indicator IS this engine's `<Cooldown>` widget.** The
/// reference draws a cooldown sweep as a `Model` running
/// `Interface\Cooldown\UI-Cooldown-Indicator.mdx` — that is what `CooldownFrameTemplate` is, and
/// every consumer writes `<Model … inherits="CooldownFrameTemplate"/>`: the reference's own
/// `ContainerFrameItemButtonTemplate` (ContainerFrame.xml l.13), its action buttons, and three
/// corpus addons. benilla renders no FrameXML models at all, and it does not need to for this one:
/// decision 0263 put the whole scrub/finish-flash machine inside a NATIVE `Cooldown` widget, which
/// is the same picture by a different route.
///
/// So the mapping is on the MODEL FILE, not on the template name — the file is the thing's
/// identity, an addon may reach the same art without inheriting the template, and a template name
/// is a string anyone can shadow. Anything else keeps its tag and stays an inert `Model`.
///
/// Without this, decision 1751's bag swap silently loses the bag-slot cooldown sweep: our own
/// `BenillaBagSlotTemplate` carried a real `<Cooldown>` child, the reference's carries a `<Model>`,
/// and `CooldownFrame_SetTimer`'s Model branch shows and hides the frame and nothing more.
fn frame_kind_of(el: &Element) -> String {
const COOLDOWN_MODEL: &str = "ui-cooldown-indicator.mdx";
if el.tag.eq_ignore_ascii_case("Model")
&& el
.attr("file")
.is_some_and(|f| f.to_ascii_lowercase().ends_with(COOLDOWN_MODEL))
{
return "Cooldown".to_string();
}
el.tag.clone()
}
/// The `.lua` test `0x6ede10` opens with: `strrchr(path, '.')` on the **whole resolved path**,
/// then a **case-insensitive** compare against `".lua"` (`0x8710c8`, through `0x64a4c0`).
///
@@ -726,7 +756,7 @@ impl Loader<'_> {
}
};
let wrapper: Table =
match create.call((el.tag.clone(), resolved_name.clone(), parent.cloned())) {
match create.call((frame_kind_of(el), resolved_name.clone(), parent.cloned())) {
Ok(w) => w,
Err(e) => {
self.report.errors.push(format!(
+66 -1
View File
@@ -13,7 +13,7 @@
//! numeric FileDataID — 5875 has no FileDataIDs, and every measured consumer feeds the value
//! straight to `SetTexture`, which takes both shapes in the live client.
use mlua::{Lua, Value};
use mlua::{Lua, Table, Value};
use super::cursor::{self, CursorItem, CursorPayload};
use super::Model;
@@ -449,6 +449,71 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// SetBagPortaitTexture(texture, containerID) — `0x4fa4f0` (374 B), the reference's own
// spelling, typo included, and CARVED (wow-re `system/ui/scratch/bag-portrait-and-appendtext.md`,
// §5 four-worker round). The bag window's portrait: `ContainerFrame_GenerateFrame` sets it for
// every container except the keyring, which takes `SetPortraitToTexture` with a fixed path one
// line above (ContainerFrame.lua l.418-423). benilla runs that file off the player's chain
// (1751), so this is a live call site.
//
// Three arms, and the FIRST is the one nobody would have guessed:
//
// 1 · **`id == 0` does nothing at all.** `0x4fa5b8 dec edi` / `0x4fa5b9 js 0x4fa65d` returns
// before the clear at `0x4fa5c8` — no texture is set and none is cleared. It is not an
// oversight: `ContainerFrame.xml:67` gives `$parentPortrait` no `file=`, and the backpack
// takes a different ARTWORK background (`UI-BackpackBackground`) that covers the portrait
// slot outright. (`Button-Backpack-Up` is nowhere on this path — its only stock use is the
// main bar's own button, MainMenuBarBagButtons.xml:79.)
// 2 · **`id` in 1..=10** clears the texture first, then sets
// `Interface\Icons\<ItemDisplayInfo.InventoryIcon_1>` for the bag in that inventory slot
// — `StringLookups.dbc` row 3 measured as `"Interface\Icons"`, `.TGA` stripped,
// `INV_Misc_QuestionMark` on a display-info miss. Ids 5..10 are additionally gated on the
// bank being open. **An empty slot leaves the texture CLEARED** — there is no fallback art.
// 3 · **`id >= 11` raises** `"Invalid slot in SetBagPortaitTexture"`.
//
// Our icon comes from the same place the item's own button icon does (`GetInventoryItemTexture`
// reads the item template's icon), so the DBC walk above is the reference's route to the value
// this client already has resolved; the observable — which icon lands on the portrait — is the
// same one.
lua.globals().set(
"SetBagPortaitTexture",
lua.create_function(|lua, (region, container): (Table, i64)| {
// Arm 3, before anything is touched.
if container >= 11 {
return Err(mlua::Error::runtime("Invalid slot in SetBagPortaitTexture"));
}
// Arm 1: the backpack is a silent return, not a clear.
if container <= 0 {
return Ok(());
}
// `ContainerIDToInventoryID`'s arithmetic, deliberately re-expressed rather than
// called: that binding is a Lua global an addon may replace, and the engine's own
// portrait read does not go through Lua.
let t = container.wrapping_sub(1);
let inv = if t < 4 {
t.wrapping_add(20)
} else {
t.wrapping_add(60)
};
let path = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
usize::try_from(inv)
.ok()
.and_then(|slot| model.inv_slot("player", slot))
.and_then(|v| v.icon.clone())
};
let rh = crate::script::region::region_handle_of(lua, &region)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let data = model.region_data.entry(rh).or_default();
// Arm 2. `path` is `None` for an empty slot, and that IS the answer: the reference
// clears unconditionally and only then tries to set.
data.texture = path;
data.circular = true;
data.portrait_unit = None;
Ok(())
})?,
)?;
lua.globals().set(
"GetContainerNumSlots",
lua.create_function(|lua, bag: i64| {
+112
View File
@@ -0,0 +1,112 @@
//! The instance-lockout **Era API surface** (decision 1748) — three globals, no snapshot struct.
//!
//! `IsInInstance`, `CanShowResetInstances` and `ResetInstances` are all engine bindings in 1.12
//! (`reference/1.12-globals.tsv` lists the three as `function`/`engine`), and between them they
//! are the whole Lua side of the lockout family: everything else it does arrives as a
//! `CHAT_MSG_SYSTEM` line the client composes itself, with no event and no getter.
//!
//! Both readers answer off state the app pushes, because both are `Map.dbc` questions and the DBC
//! is the app's to read — the same split [`super::party::SavedInstanceInfo`] already makes.
//!
//! **`IsInInstance` returns a pair**: `1`/`nil` for "is this an instance at all", and the type as a
//! string. The four strings are the reference's own table at `0x83de58`, indexed by
//! `Map.dbc`'s `InstanceType` behind a `< 4` guard: `none` (0), `party` (1), `raid` (2),
//! `pvp` (3) — `IsInInstance 0x48a750`.
use mlua::{Lua, MultiValue, Value};
use super::Model;
/// `Map.dbc`'s `InstanceType` → the word `IsInInstance()` returns for it — the reference's
/// `0x83de58` string table, with its own out-of-range fallback (`cmp esi,4; jae` → `"none"`).
pub fn instance_type_name(instance_type: u32) -> &'static str {
match instance_type {
1 => "party",
2 => "raid",
3 => "pvp",
// 0 and anything the table cannot index.
_ => "none",
}
}
impl super::UiScript {
/// Push the current map's `Map.dbc` `InstanceType` — `IsInInstance()`'s whole input.
/// `None` = the map id has no DBC row, which the reference reaches by null-checking the record
/// pointer; it then returns without pushing anything at all (a real 1.12 stack bug). We answer
/// `nil, "none"` there, which is what every caller of it actually means to read.
pub fn set_instance_type(&mut self, instance_type: Option<u32>) {
let mut model = self.model_mut();
if model.instance_type != instance_type {
model.instance_type = instance_type;
}
}
/// Push `CanShowResetInstances()`'s answer — the app owns every term of it (the ownership
/// latch, the last dungeon, its age, and the map we are standing on).
pub fn set_can_reset_instances(&mut self, can: bool) {
let mut model = self.model_mut();
if model.can_reset_instances != can {
model.can_reset_instances = can;
}
}
/// Drain the `ResetInstances()` calls queued since the last drain — each one is a
/// `CMSG_RESET_INSTANCES`. A count, like [`super::UiScript::take_binder_confirms`]: the intent
/// has no payload of its own.
pub fn take_reset_instance_asks(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().reset_instance_asks)
}
}
/// Register the three lockout globals ([`super::binder`]'s style).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// IsInInstance() → inInstance, instanceType. The first return is the NUMBER 1 rather than a
// boolean because the reference pushes 1.0 (`push 0x3ff00000; push 0` = the double 1.0), and
// 1.12 UI code compares it numerically.
g.set(
"IsInInstance",
lua.create_function(|lua, ()| {
let ty = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.instance_type
};
let name = instance_type_name(ty.unwrap_or(0));
let inside = match ty {
Some(t) if t != 0 => Value::Number(1.0),
_ => Value::Nil,
};
Ok(MultiValue::from_vec(vec![
inside,
Value::String(lua.create_string(name)?),
]))
})?,
)?;
// CanShowResetInstances() — UnitPopup's gate on the SELF menu's "Reset all instances" row.
// 1.0/nil for the same reason as above.
g.set(
"CanShowResetInstances",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.can_reset_instances {
Value::Number(1.0)
} else {
Value::Nil
})
})?,
)?;
// ResetInstances() — the CONFIRM_RESET_INSTANCES dialog's Yes.
g.set(
"ResetInstances",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.reset_instance_asks += 1;
Ok(())
})?,
)?;
Ok(())
}
+2
View File
@@ -62,6 +62,7 @@ mod cursor;
mod death;
pub mod diagnostics;
mod editbox;
pub mod instance;
pub(crate) use editbox::{adopt_text_region, editbox_text_region_wrapper};
pub(crate) mod addon;
pub mod addon_gate;
@@ -569,6 +570,7 @@ impl UiScript {
binder::install(&lua)?;
summon::install(&lua)?;
bind_confirm::install(&lua)?;
instance::install(&lua)?;
gm_ticket::install(&lua)?;
duel::install(&lua)?;
follow::install(&lua)?;
+12
View File
@@ -448,6 +448,15 @@ pub(crate) struct Model {
pub(crate) saved_instances: Vec<party::SavedInstanceInfo>,
/// `SetRaidRosterSelection`'s cursor — a client-side raid row index, never sent anywhere.
pub(crate) raid_selection: i64,
/// The current map's `Map.dbc` `InstanceType`, pushed by the app — `IsInInstance()`'s whole
/// input ([`instance`]). `None` = the map id has no DBC row.
pub(crate) instance_type: Option<u32>,
/// `CanShowResetInstances()`'s answer, pushed by the app (decision 1748) — the four-term
/// predicate the reference computes at `0x495c90` over state only the app holds.
pub(crate) can_reset_instances: bool,
/// `ResetInstances()` calls queued since the app's last
/// [`super::UiScript::take_reset_instance_asks`] drain — the outbound seam ([`instance`]).
pub(crate) reset_instance_asks: u32,
/// The social snapshot the app pushes (friends, ignores, the last `/who` — decision 0668):
/// `GetNumFriends`/`GetFriendInfo`/`GetWhoInfo`/… read it ([`social`]). Already
/// display-resolved (names, class/zone names) because the reference resolves them
@@ -1546,6 +1555,9 @@ impl Model {
party_requests: Vec::new(),
saved_instances: Vec::new(),
raid_selection: 0,
instance_type: None,
can_reset_instances: false,
reset_instance_asks: 0,
social: social::SocialState::default(),
social_requests: Vec::new(),
guild: guild::GuildState::default(),
+6 -1
View File
@@ -266,7 +266,12 @@ fn ensure_lines(lua: &Lua, this: &Table, n: usize) -> mlua::Result<()> {
}
/// One text cell's write: text + color + shown. `color` alpha defaults opaque.
fn write_cell(model: &mut Model, rh: crate::widget::RegionHandle, text: &str, color: [f32; 4]) {
pub(super) fn write_cell(
model: &mut Model,
rh: crate::widget::RegionHandle,
text: &str,
color: [f32; 4],
) {
let d = model.region_data.entry(rh).or_default();
d.text = Some(text.to_string());
d.vertex_color = Some(color);
+45 -1
View File
@@ -13,7 +13,8 @@ use crate::widget::{KindState, TOOLTIP_LINE_GAP, TOOLTIP_PAD};
use super::{
append_line, bool_arg, cancel_fade, clear_content, fire_cleared, hide_tooltip, now,
parse_line_color, parse_line_tail, set_shown, text_of, tip_mut, with_tip, REG_TOOLTIP_METHODS,
parse_line_color, parse_line_tail, set_shown, text_of, tip_mut, with_tip, write_cell,
REG_TOOLTIP_METHODS,
};
pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
@@ -181,6 +182,49 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// AppendText(text) — `0x531e30`. The reference's bag/bar tooltips build a title with SetText
// and then hang the keybinding on the end of that same line rather than adding a second one:
//
// GameTooltip:SetText(TEXT(BACKPACK_TOOLTIP), 1.0, 1.0, 1.0);
// if ( GetBindingKey("TOGGLEBACKPACK") ) then
// GameTooltip:AppendText(" "..NORMAL_FONT_COLOR_CODE.."("..key..")"..FONT_COLOR_CODE_CLOSE)
// end
//
// — ContainerFrame.xml l.188-206 (the bag window's portrait button, one arm per bag id) and
// MainMenuBarBagButtons.xml l.96 / .lua l.91 (the bar's). Both files run off the player's own
// chain (1751), so those are live call sites in this VM, not transcriptions we could adjust.
//
// LINE 1's left cell, appended in place, keeping its colour: the escape codes in the argument
// are what colour the suffix, which only works if it lands INSIDE an existing line. A tooltip
// with no lines yet is a no-op rather than a raise — the reference's own callers always
// SetText first, and nothing is carved about the empty case.
//
// INFERRED, pending the byte carve of `0x531e30`: that it is line 1 rather than the last line
// added. Every attested call site sets exactly one line before appending, so the two readings
// are indistinguishable from the corpus alone; line 1 is chosen because the reference's own
// name for the target is `GameTooltipTextLeft1`. Re-measure is unconditional either way.
m.set(
"AppendText",
lua.create_function(|lua, (this, text): (Table, Value)| {
let h = frame_handle_of(lua, &this)?;
let extra = text_of(Some(&text));
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let lh = match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Tooltip(t)) if t.num_lines > 0 => t.left_lines[0],
Some(KindState::Tooltip(_)) => return Ok(()),
_ => return Err(mlua::Error::runtime("not a GameTooltip")),
};
let (current, color) = match model.region_data.get(&lh) {
Some(d) => (
d.text.clone().unwrap_or_default(),
d.vertex_color.unwrap_or([1.0, 1.0, 1.0, 1.0]),
),
None => (String::new(), [1.0, 1.0, 1.0, 1.0]),
};
write_cell(&mut model, lh, &(current + &extra), color);
Ok(())
})?,
)?;
// AddLine(text [, r, g, b [, wrap]]) — positional; the corpus' archaic `(text, "", r, g, b)`
// shape has a non-number in the r-slot, so the real binding drops the colour tail and the
// line renders the default GOLD (the ref's zone/taxi/repair tooltips — director-matched).