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:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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(®istry_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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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, ®ion)?;
|
||||
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| {
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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).
|
||||
|
||||
Reference in New Issue
Block a user