a cinematic holds your body, and all seven chat windows exist

- a cinematic holds the body while the camera flies: the streaming focus follows the eye — a Tauren's intro opens 1741 yards from the body — so the body's own ground unloaded under it and it fell (z=59.4 to −62 in four seconds, the server yanking it back); it now takes the same hold a teleport takes, released when the focus comes home to a resident tile, and the loading screen stops covering the fly-by, because "the player's ground is missing" is not a load while the eye is deliberately elsewhere; the letterbox measurement prints as an ordinary info line, and scripts/cine.sh plays any one cinematic and prints what the client did to the world while the camera was away
- a cinematic cuts the zone music: the reference asserts exactly what /console EnableMusic 0 asserts, through the same setter — a cut, not a fade, the music pump dead for the duration, resume at +3.000 s — and benilla asserts the runtime flag, never the CVar, or quitting during a 102-second intro would save your music off for good; ambience is deliberately untouched (it has the analogous flag and the cinematic never writes it); the narration has no category at all — its flags route it around every volume slider but not around the Enable Sound Effects gate, so unchecking that silences it — and the listener follows the cinematic camera for the duration
- addons can walk a UI they did not build: GetChildren/GetNumChildren/GetRegions/GetNumRegions land — the basis of the whole reskin genre, and the wall four of the twenty most-installed 1.12 addons died on: pfUI drew nothing before and now paints 495 quads, ShaguDPS 71 → 566, pfQuest 3 → 498 — with the reference's own quirks: the title region is NOT in the list (its creation path never reaches the region linker), hidden regions are returned and counted, oldest first; the Model widget gets its Lua surface (SetModel, SetSequence and siblings, read out of the shipped image, where SetRotation and SetFacing are two names for one slot); and the Minimap carries the nine engine-created Model children no XML declares, built before the frame descent, the ninth being the player arrow Questie indexes as ({Minimap:GetChildren()})[9]
- all seven chat windows come from one template, tabs and scroll buttons included — whether a window is shown has nothing to do with whether its globals resolve, and pfUI reads every piece of that furniture for all seven, unguarded; isDocked only on windows 1 and 2 (the chat-cache's own DOCKED byte), SELECTED_CHAT_FRAME seated beside DEFAULT_CHAT_FRAME (the window you last picked vs the one FrameXML prints to), and the four per-window fields ChatFrame_OnLoad seats; two more one-name walls fell — a Button's label answers to its second spelling NormalText, which KLHThreatMeter's column headers die without, and ABANDON_QUEST_WITH_ITEMS is declared because a dialog table is read at load whatever the client later shows, leaving Questie with zero errors of ours — and the addon harness gains --probe/--eval, one addon's VM answering a question in ~5 s instead of a 90 s corpus run

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 19:50:08 +07:00
co-authored by Claude Fable 5
parent 4ad8718b2a
commit f3ecb5c968
30 changed files with 2360 additions and 261 deletions
+177 -193
View File
@@ -580,6 +580,13 @@
local frame = getglobal("ChatFrame" .. i)
if i == id then frame:Show() else frame:Hide() end
end
-- `SELECTED_CHAT_FRAME` tracks the SELECTION, which is what makes it different from
-- `DEFAULT_CHAT_FRAME` (the print target, seeded once and left alone). The reference
-- writes it from the tab's own OnClick before it even reaches FCF_SelectDockFrame
-- (`FloatingChatFrame.lua:1232`) and again on a drag (`FloatingChatFrame.xml:158`);
-- ours has one selection verb that every one of those paths goes through, so it is
-- written here rather than replicated at three call sites.
SELECTED_CHAT_FRAME = getglobal("ChatFrame" .. id) or SELECTED_CHAT_FRAME
if BenillaFCF.flashTab == id then
BenillaFCF.flashTab = 0
local flash = getglobal("ChatFrame" .. id .. "TabFlash")
@@ -1185,22 +1192,31 @@
end
]]></Script>
<!-- ── The dock: ChatFrame1 (General) + ChatFrame2 (Combat Log) sharing its rect ─────── -->
<!-- ── The seven chat windows: ChatFrame1 (General) + ChatFrame2 (Combat Log) docked, 3..7
hidden and undocked ──────────────────────────────────────────────────────────────── -->
<!--
**One declaration, two windows.** The dock's two windows are the SAME widget seated
differently, and saying that twice is how three straight rounds of bugs got in: 1575 found
the Combat Log with no tab, 1579 found it with no rect and no border art, and 1588 found it
with no `OnUpdate` and no scroll column — each time because window 2 was a HAND-MIRRORED
partial copy of window 1 and the mirroring missed a part. The reference does not mirror: it
declares `FloatingChatFrameTemplate` `virtual="true"` and every `ChatFrameN` inherits it
(FloatingChatFrame.xml l.211, l.685-832). So do we, and the class of bug goes with it —
a part that exists for one docked window now cannot fail to exist for the other.
**One declaration, seven windows.** A chat window is the SAME widget seated differently, and
saying it twice is how three straight rounds of bugs got in: 1575 found the Combat Log with
no tab, 1579 found it with no rect and no border art, and 1588 found it with no `OnUpdate`
and no scroll column — each time because window 2 was a HAND-MIRRORED partial copy of
window 1 and the mirroring missed a part. The reference does not mirror: it declares
`FloatingChatFrameTemplate` `virtual="true"` and every `ChatFrameN` inherits it
(FloatingChatFrame.xml l.211, l.685-832). So do we, and the class of bug goes with it.
**All seven, not two — and that changed for a measured reason (1712).** ChatFrame3..7 were
declared bare: a `ScrollingMessageFrame` and nothing else, on the argument that nothing
reads their furniture while they stay hidden and undocked. The top-20 corpus falsifies it.
pfUI's `chat.lua:588` is `for i = 1, NUM_CHAT_WINDOWS do _G["ChatFrame"..i.."UpButton"]:Hide()`
— the scroll column, the tab, and (in `RefreshChat`) `$parentBackground`, every
`CHAT_FRAME_TEXTURES` slice and `$parentResizeBottom`, all seven, none guarded. The
reference has them because the template is shared; the visibility of the window it hangs
off has nothing to do with whether the global resolves.
Per-instance is only what genuinely differs: the seat (ChatFrame1 is the managed-position
base — `UIPARENT_MANAGED_FRAME_POSITIONS["ChatFrame1"]`, baseY 85 + the bottom-stack
offsets, decision 0272 — and ChatFrame2 takes its rect from ChatFrame1's, which is what
being docked MEANS and why it must never carry a managed row of its own, 1579), the id,
and which of the two ships shown.
and which ships shown.
The chrome the template carries: displayDuration 120 / maxLines 128 (ref ChatFrameTemplate);
a background stretched -2/+2 x and +3/-6 y past the frame (ref $parentBackground anchors)
@@ -1217,7 +1233,7 @@
one script here that is genuinely ChatFrame1's rather than the dock's, so the template
carries the gate rather than the registration.
-->
<ScrollingMessageFrame name="BenillaDockedChatFrameTemplate" virtual="true"
<ScrollingMessageFrame name="BenillaChatFrameTemplate" virtual="true"
frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128"
movable="true" resizable="true">
<!-- ref FloatingChatFrame.xml l.222-230, verbatim. The only `<ResizeBounds>` in the whole
@@ -1538,9 +1554,11 @@
this:RegisterEvent("UPDATE_CHAT_WINDOWS")
end
-- `isDocked` — the reference's own flag, and it is LOAD-BEARING now rather than an
-- advertisement. Only this template is inherited, and only ChatFrame1/ChatFrame2
-- inherit it, so every frame that reaches this line is docked: they share one rect
-- (window 2's anchors are window 1's corners) and one tab strip.
-- advertisement. **The gate below is the chat-cache's own `DOCKED` byte** (1712):
-- the shipped file marks windows 1 and 2 `DOCKED 1` and windows 3..10 `DOCKED 0`,
-- so the two that reach the assignment are exactly the two that share one rect
-- (window 2's anchors are window 1's corners) and one tab strip. It was
-- unconditional while this template had two instances; it now has seven.
--
-- This file used to withhold the flag on the grounds that it "belongs to a dock
-- MODEL (dock/undock, FCF_DockUpdate's re-anchoring) that 0288 §2 holds out", i.e.
@@ -1548,9 +1566,11 @@
-- reference's move and resize gates READ it — `chatFrame.isDocked and chatFrame ~=
-- DEFAULT_CHAT_FRAME` is the clause that keeps the Combat Log from being resized
-- out of the dock it is anchored into — so withholding it now means the gate that
-- protects window 2 silently never fires. The flag is a fact about our two windows;
-- the verbs remain out.
this.isDocked = 1
-- protects window 2 silently never fires. The flag is a fact about our two docked
-- windows; the verbs remain out.
if this:GetID() == 1 or this:GetID() == 2 then
this.isDocked = 1
end
</OnLoad>
<OnEvent>
if event == "EXECUTE_CHAT_LINE" then
@@ -1613,7 +1633,7 @@
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<ScrollingMessageFrame name="ChatFrame1" inherits="BenillaDockedChatFrameTemplate" id="1">
<ScrollingMessageFrame name="ChatFrame1" inherits="BenillaChatFrameTemplate" id="1">
<Size><AbsDimension x="430" y="120"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT">
@@ -1625,7 +1645,7 @@
<!-- ChatFrame2 "Combat Log": docked — it takes ChatFrame1's rect from these two anchors and
nothing else, ships hidden, and its tab (ChatFrame2Tab, below) selects it. Its message
groups are the chat-cache combat set, registered app-side (`ui_chat::frames`, 1571). -->
<ScrollingMessageFrame name="ChatFrame2" inherits="BenillaDockedChatFrameTemplate" hidden="true" id="2">
<ScrollingMessageFrame name="ChatFrame2" inherits="BenillaChatFrameTemplate" hidden="true" id="2">
<Anchors>
<Anchor point="TOPLEFT" relativeTo="ChatFrame1" relativePoint="TOPLEFT"/>
<Anchor point="BOTTOMRIGHT" relativeTo="ChatFrame1" relativePoint="BOTTOMRIGHT"/>
@@ -1690,87 +1710,53 @@
`local function print(text) ChatFrame3:AddMessage(text) end`, is dead code replicated
into four folders — the count that looks like 12 files is 7 real sites.
No tabs (`ChatFrame3Tab`..`ChatFrame7Tab`) and no dock regions. The reference builds
both; here they would be furniture for a dock model we do not have (0288 §2), and
nothing reads them while these frames stay hidden and undocked. Outfitter.lua:3099 is
the one addon that would touch a tab — `local vTab = getglobal("ChatFrame"..i.."Tab")`
then `vTab:GetText()`, unguarded — but only INSIDE `if vChatFrame:IsVisible() or
vChatFrame.isDocked`, so a hidden, `isDocked`-less window never reaches it.
**They now carry the full template, and that is 1712's correction.** These five used to
be declared bare — no tab, no background, no border art, no scroll column — on the
argument that the furniture is "for a dock model we do not have (0288 §2), and nothing
reads it while these frames stay hidden and undocked". The reading of Outfitter behind it
was right (`Outfitter.lua:3099` gates its `vTab:GetText()` behind `IsVisible() or
isDocked`) and the conclusion was still wrong, because one addon is not the corpus.
pfUI reads every piece of it, for all seven, unguarded:
`isDocked` is NOT set on these five, and that is the whole point of the flag now: the two
dock windows set it in the shared template's `OnLoad` (it is what the reference's move and
resize gates read), and ChatFrame3..7 are the frames that genuinely are not docked. The
Outfitter hazard the flag used to carry — `if vChatFrame:IsVisible() or vChatFrame.isDocked`
then `vTab:GetText()` on a window with no tab — is why it stays off here: these have no
tabs, and they are hidden, so neither arm reaches the nil.
chat.lua:588 `_G["ChatFrame"..i.."UpButton"]:Hide()` — and Down/Bottom, and
`_G["ChatFrame"..i.."Tab"].SetAlpha`, at module load
chat.lua:322 `local tab = _G["ChatFrame"..i.."Tab"]` → `tab:SetParent(…)`,
`tab:GetRegions()`
chat.lua:404 every `CHAT_FRAME_TEXTURES` slice, `texture:SetTexture()`
chat.lua:419 `_G["ChatFrame"..i.."ResizeBottom"]:Hide()`
No background/border art: the ref's own defaults put every one of these at window alpha
0 (COLOR's fourth byte), and BenillaFCF only fades the dock's two. A shown ChatFrame5
renders its text over the world with no box — which is what the reference gives you. ── -->
<ScrollingMessageFrame name="ChatFrame3" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="3">
Visibility has nothing to do with whether a global resolves, and pfQuest, pfQuest-turtle
and ShaguDPS embed the same library. So all seven inherit the same template, exactly as
the reference does (FloatingChatFrame.xml l.822-832 are five one-liners), and the tabs
below are declared the same way.
`isDocked` is still NOT set on these five, and that stays the whole point of the flag: the
template's `OnLoad` gates it to the two windows the chat-cache marks `DOCKED 1`, and these
are the frames that genuinely are not docked — which is what keeps Outfitter's own gate
doing what it does on a stock client.
They keep the reference's own look: window alpha 0 (the chat-cache `COLOR`'s fourth byte)
means a shown ChatFrame5 renders its text over the world with no box, and the template's
art is all alpha-0 until `BenillaFCF` fades it — which it does for the dock's two. ── -->
<ScrollingMessageFrame name="ChatFrame3" inherits="BenillaChatFrameTemplate" hidden="true" id="3">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<ScrollingMessageFrame name="ChatFrame4" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="4">
<ScrollingMessageFrame name="ChatFrame4" inherits="BenillaChatFrameTemplate" hidden="true" id="4">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<ScrollingMessageFrame name="ChatFrame5" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="5">
<ScrollingMessageFrame name="ChatFrame5" inherits="BenillaChatFrameTemplate" hidden="true" id="5">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<ScrollingMessageFrame name="ChatFrame6" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="6">
<ScrollingMessageFrame name="ChatFrame6" inherits="BenillaChatFrameTemplate" hidden="true" id="6">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<ScrollingMessageFrame name="ChatFrame7" frameStrata="BACKGROUND" displayDuration="120.0" maxLines="128" hidden="true" id="7">
<ScrollingMessageFrame name="ChatFrame7" inherits="BenillaChatFrameTemplate" hidden="true" id="7">
<Size><AbsDimension x="430" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="100" y="100"/></Offset></Anchor></Anchors>
<Scripts>
<OnMouseWheel>
if arg1 > 0 then this:ScrollUp() else this:ScrollDown() end
</OnMouseWheel>
<OnHyperlinkClick>
SetItemRef(arg1, arg2, arg3)
</OnHyperlinkClick>
</Scripts>
<FontString inherits="ChatFontNormal" justifyH="LEFT"/>
</ScrollingMessageFrame>
<!-- ── The tabs (ref ChatTabTemplate: 64×32, ChatFrameTab 3-slice quarters 16/44/16, label
@@ -1785,11 +1771,8 @@
line pipeline doesn't exist), so the tab just selected an empty window. ChatFrame2
itself stays above (the router keeps feeding it) so the tab can return whole with the
combat-log arc — director's call, 2026-07-11. The FCF loops guard the missing tab. ── -->
<Button name="ChatFrame1Tab" frameStrata="LOW" alpha="0" id="1">
<Button name="BenillaChatTabTemplate" virtual="true" frameStrata="LOW" alpha="0">
<Size><AbsDimension x="70" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrame1Background" relativePoint="TOPLEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentLeft" file="Interface\ChatFrame\ChatFrameTab">
@@ -1837,9 +1820,21 @@
without this the right-click never reaches OnClick at all. -->
<OnLoad>
this:RegisterForClicks("LeftButtonUp", "RightButtonUp")
<!-- The tab's LABEL is the window's name. The reference reads it from
`GetChatWindowInfo` (`FCF_SetWindowName`), and the shipped chat-cache holds a
NAME for windows 1 and 2 and leaves it EMPTY for 3..10 — so an undocked
window's tab has no text there either, and `GetText()` on it is nil. Ours
seats the same two from their GlobalStrings, which is where the loader's
ALL-CAPS key rule used to reach for them when the text was authored here. -->
local id = this:GetID()
if id == 1 then
this:SetText(GENERAL)
elseif id == 2 then
this:SetText(COMBAT_LOG)
end
</OnLoad>
<OnClick>
BenillaFCF_TabClick(1, arg1)
BenillaFCF_TabClick(this:GetID(), arg1)
</OnClick>
<!-- The reference's own move handle (ref FCF_Tab_OnClick's press/release arms, and the
tip below, which says "Left-click and drag to move the window" in as many words).
@@ -1862,7 +1857,7 @@
GameTooltip:Hide()
</OnLeave>
</Scripts>
<ButtonText name="$parentText" text="GENERAL">
<ButtonText name="$parentText">
<Anchors>
<Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor>
</Anchors>
@@ -1883,115 +1878,64 @@
</HighlightTexture>
</Button>
<!--
ChatFrame2Tab — the "Combat Log" dock tab. **This is the tab that was missing**: every
piece of machinery around it already existed and was written to expect it — `BenillaFCF`'s
fade, resize and flash loops all run `for i = 1, 2` over
`getglobal("ChatFrame"..i.."Tab")`, `selected = 1` is commented "2 = Combat Log", and
`FCF_SelectDockFrame` errors on any id that is not 1 or 2 — but the button itself was never
authored, and each of those loops has an `if tab then` guard that swallowed its absence
without a word. ChatFrame2 was therefore reachable only from Lua: it is `hidden`, as a
non-selected dock frame should be, and nothing on screen could select it.
<!-- ChatFrame1Tab — "General". Only the seat is per-instance, exactly as the reference's own
instance is (FloatingChatFrame.xml l.712-720: an `<Anchors>` block and nothing else). -->
<Button name="ChatFrame1Tab" inherits="BenillaChatTabTemplate" id="1">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrame1Background" relativePoint="TOPLEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor>
</Anchors>
</Button>
Labels are the GlobalString KEYS (`GENERAL`, `COMBAT_LOG`), resolved out of the player's own
`GlobalStrings.lua` by the loader's ALL-CAPS key rule — the same shape the combat log's own
format strings use (1571), and the reason neither word is quoted in this file.
Anchored LEFT-to-RIGHT off tab 1; `BenillaFCF_TabResize` sizes both to their measured label
widths, so the abutment survives a locale whose words are longer.
-->
<Button name="ChatFrame2Tab" frameStrata="LOW" alpha="0" id="2">
<Size><AbsDimension x="70" y="32"/></Size>
<!-- ChatFrame2Tab — "Combat Log". Its seat is the dock's tab strip: abutted to tab 1's right
edge, which is our standing-in for `FCF_DockUpdate`'s cumulative `FCF_SetTabPosition` x.
`BenillaFCF_TabResize` sizes both to their measured label widths, so the abutment survives
a locale whose words are longer. -->
<Button name="ChatFrame2Tab" inherits="BenillaChatTabTemplate" id="2">
<Anchors>
<Anchor point="LEFT" relativeTo="ChatFrame1Tab" relativePoint="RIGHT"/>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="$parentLeft" file="Interface\ChatFrame\ChatFrameTab">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<TexCoords left="0.0" right="0.25" top="0.0" bottom="1.0"/>
</Texture>
<Texture name="$parentMiddle" file="Interface\ChatFrame\ChatFrameTab">
<Size><AbsDimension x="38" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.25" right="0.75" top="0.0" bottom="1.0"/>
</Texture>
<Texture name="$parentRight" file="Interface\ChatFrame\ChatFrameTab">
<Size><AbsDimension x="16" y="32"/></Size>
<Anchors><Anchor point="LEFT" relativeTo="$parentMiddle" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0.75" right="1.0" top="0.0" bottom="1.0"/>
</Texture>
</Layer>
</Layers>
<Frames>
<Frame name="$parentFlash" hidden="true">
<Size><AbsDimension x="5" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentLeft"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="$parentRight"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\PaperDollInfoFrame\UI-Character-Tab-Highlight" alphaMode="ADD" setAllPoints="true"/>
</Layer>
</Layers>
</Frame>
<!-- The window's options menu (decision 1589). A child of the TAB, because
FCF_GetCurrentChatFrameID reads the open menu's parent id — the reference's own
contract, and why the tab carries `id` above. -->
<Frame name="$parentDropDown" inherits="UIDropDownMenuTemplate" hidden="true">
<Scripts>
<OnLoad>FCFOptionsDropDown_OnLoad()</OnLoad>
</Scripts>
</Frame>
</Frames>
<Scripts>
<!-- ref FloatingChatFrame.xml l.121: the tab takes the right button too, which is
what opens the options menu. A Button's default set is LeftButtonUp only, so
without this the right-click never reaches OnClick at all. -->
<OnLoad>
this:RegisterForClicks("LeftButtonUp", "RightButtonUp")
</OnLoad>
<OnClick>
BenillaFCF_TabClick(2, arg1)
</OnClick>
<!-- The reference's own move handle (ref FCF_Tab_OnClick's press/release arms, and the
tip below, which says "Left-click and drag to move the window" in as many words).
It reaches the same two verbs as the window body does; the registration that arms
it is `BenillaFCF_ApplyLock`'s, so a locked window's tab still only selects. -->
<OnDragStart>
FCF_StartMove(getglobal("ChatFrame" .. this:GetID()))
</OnDragStart>
<OnDragStop>
FCF_StopMove(getglobal("ChatFrame" .. this:GetID()))
</OnDragStop>
<!-- ref l.129-134. The tip is how the menu is DISCOVERABLE: nothing on screen says a
chat tab has a right-click menu, which is half of why B246 read as "no chat
options at all". `noNormalText` is the reference's own 6th argument. -->
<OnEnter>
BenillaTooltip_AddNewbieTipFor(this, CHAT_OPTIONS_LABEL, 1.0, 1.0, 1.0,
NEWBIE_TOOLTIP_CHATOPTIONS, 1)
</OnEnter>
<OnLeave>
GameTooltip:Hide()
</OnLeave>
</Scripts>
<ButtonText name="$parentText" text="COMBAT_LOG">
<Anchors>
<Anchor point="LEFT" relativeTo="$parentLeft" relativePoint="RIGHT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor>
</Anchors>
</ButtonText>
<NormalFont inherits="GameFontNormalSmall"/>
<HighlightTexture file="Interface\PaperDollInfoFrame\UI-Character-Tab-Highlight" alphaMode="ADD">
<Size><AbsDimension x="0" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentLeft"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
<Anchor point="RIGHT" relativeTo="$parentRight"><Offset><AbsDimension x="0" y="-7"/></Offset></Anchor>
</Anchors>
</HighlightTexture>
</Button>
<!-- ChatFrame3Tab..ChatFrame7Tab — the five undocked windows' tabs. `hidden`, because that is
the reference's `ChatTabTemplate` default and only `FCF_DockFrame`/`FCF_OpenNewWindow`
un-hide one; and seated on their own window's background at the reference's own offset —
`FCF_SetTabPosition(chatFrame, 0)` is `SetPoint("BOTTOMLEFT", <frame>Background, "TOPLEFT",
x+2, 0)` (FloatingChatFrame.lua:1263), which is the same anchor tab 1 carries above.
They exist because addons read them, not because anything here shows them: pfUI's
`chat.lua:595` is `_G["ChatFrame"..i.."Tab"].SetAlpha` for all seven at module load, and
`chat.lua:322` hands the same tab to `SetParent`/`GetRegions` (1712). -->
<Button name="ChatFrame3Tab" inherits="BenillaChatTabTemplate" hidden="true" id="3">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrame3Background" relativePoint="TOPLEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor>
</Anchors>
</Button>
<Button name="ChatFrame4Tab" inherits="BenillaChatTabTemplate" hidden="true" id="4">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrame4Background" relativePoint="TOPLEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor>
</Anchors>
</Button>
<Button name="ChatFrame5Tab" inherits="BenillaChatTabTemplate" hidden="true" id="5">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrame5Background" relativePoint="TOPLEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor>
</Anchors>
</Button>
<Button name="ChatFrame6Tab" inherits="BenillaChatTabTemplate" hidden="true" id="6">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrame6Background" relativePoint="TOPLEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor>
</Anchors>
</Button>
<Button name="ChatFrame7Tab" inherits="BenillaChatTabTemplate" hidden="true" id="7">
<Anchors>
<Anchor point="BOTTOMLEFT" relativeTo="ChatFrame7Background" relativePoint="TOPLEFT"><Offset><AbsDimension x="2" y="0"/></Offset></Anchor>
</Anchors>
</Button>
<!-- ── The UIMenu kit (ref UIMenu.xml, names kept for the addon surface): a row button
with a right-aligned shortcut string, and the tooltip-backdrop menu holding the ref's
fixed pool of 32 rows (hidden until AddButton). The label is authored LEFT-anchored:
@@ -2219,6 +2163,46 @@
-- die on `attempt to index global 'DEFAULT_CHAT_FRAME'` without it, and many more reach
-- it on a later path.
DEFAULT_CHAT_FRAME = ChatFrame1
-- ...and `SELECTED_CHAT_FRAME`, which the reference seats on the SAME line of the same
-- OnLoad (`FloatingChatFrame.xml:739-740`). The two are not synonyms and the difference
-- is why both exist: DEFAULT is where FrameXML PRINTS, SELECTED is which window the
-- player last picked, and it moves with the tabs (BenillaFCF_SelectDock, above). Seeded
-- here because `BenillaFCF_SelectDock(1)` on the next line is what first writes it —
-- pfUI reads it from a chat-tab SetAlpha hook that can fire before any tab is clicked,
-- and a nil there stopped it (and, through its embedded copies, pfQuest, pfQuest-turtle
-- and ShaguDPS) dead.
SELECTED_CHAT_FRAME = ChatFrame1
-- ── The four per-window fields `ChatFrame_OnLoad` seats on every chat frame ───────────
-- `this.tellTimer`, `this.channelList`, `this.zoneChannelList`, `this.messageTypeList`
-- (ChatFrame.lua l.1155-1158). Written here in one loop rather than in an OnLoad, because
-- ChatFrame3..7 deliberately have no OnLoad at all (see their block above) and the
-- reference's semantics are per-FRAME, not per-template — a loop over the seven windows
-- is the same set of writes with nothing left uncovered.
--
-- **The three tables are EMPTY, which is the same honest shape `ChatTypeGroup` above
-- carries and for the same reason**: our chat routes host-side (`ui_chat`), so a window
-- does not accumulate a message-group subscription list to put in `messageTypeList`, and
-- channel membership is the engine's, not a per-window Lua list. The divergence, stated:
-- an addon that reads these gets a truthful "this window subscribes to nothing in Lua"
-- rather than a nil. That is the `ChatFrame_OnEvent` architecture question again, not
-- these fields'.
--
-- Measured demand: pfUI's `RefreshChat` walks `_G["ChatFrame"..i].messageTypeList` with
-- `pairs` and NO nil guard (`modules/chat.lua:325`), counting how many of a window's
-- types look like a combat log. A nil there is `bad argument #1 to 'pairs'` — and pfUI is
-- embedded in pfQuest, pfQuest-turtle and ShaguDPS, so it is four top-20 addons on one
-- unset field. With an empty list the count is 0 and no window is classed as a combat
-- log, which is exactly right for a UI that has no combat-log window.
for i = 1, NUM_CHAT_WINDOWS do
local frame = getglobal("ChatFrame" .. i)
if frame then
frame.tellTimer = GetTime()
frame.channelList = {}
frame.zoneChannelList = {}
frame.messageTypeList = {}
end
end
BenillaFCF_SelectDock(1)
BenillaFCF_LoadWindowLooks()
@@ -14,9 +14,11 @@
frame; we draw them as Bevy UI nodes in crate::cinematic instead, because a cinematic hides
the HUD through UiHidden — which kills both of ui_pass's quad lanes wholesale, and the
FrameXML layer is one of them. Bars declared here would go dark along with the action bars
they exist to replace. The bar LAW is unchanged and still the reference's (only a screen
wider than 4:3 gets bars; the picture is cropped to 2:1, the remainder split evenly) — it
just lives where it can be drawn. See `drive_letterbox`.
they exist to replace. The bar LAW is unchanged and still the reference's — but it is BOTH
halves of it (decision 1701): the Lua's `if width/height > 4/3` guards a *recompute*, not
whether there are bars, and below it the XML's own `1024x128` textures stand on the native
1024x768 sheet, i.e. height/6 — which is the 2:1 crop evaluated at exactly 4:3. One
expression covers both: `min((h - min(w/2, h))/2, h/6)`. See `drive_letterbox`.
So this frame draws nothing at all. It is kept rather than replaced with an engine-side key
handler because the skip path IS a frame in the reference: keeping it means ESC works for
@@ -1140,6 +1140,37 @@
hideOnEscape = 1,
}
-- ── ABANDON_QUEST_WITH_ITEMS — DECLARED, never shown ────────────────────────────────────
-- `GetAbandonQuestItems()` is always nil here (`quest_log.rs`), so nothing in this client
-- reaches `StaticPopup_Show("ABANDON_QUEST_WITH_ITEMS", …)`, and the click handler below
-- says so. That was taken as a reason to omit the *entry*, and it is not one: the
-- reference's StaticPopup.lua declares this table unconditionally at load
-- (StaticPopup.lua:762-775), whatever the client later shows, and an addon reading `_G`
-- reads declarations, not behaviour.
--
-- Questie is the measurement: `QuestieQuest.lua:55` is
-- `StaticPopupDialogs["ABANDON_QUEST_WITH_ITEMS"].OnAccept` at file scope, with no guard —
-- it wraps BOTH abandon dialogs to clear its own quest cache. Absent, that is
-- `attempt to index field 'ABANDON_QUEST_WITH_ITEMS' (a nil value)` and the addon's whole
-- quest module never loads. (#3 of the top-20 corpus; the same shape as `ChatFrame3..7`'s
-- "declared because addons read them", 1497.)
--
-- Byte-identical to ABANDON_QUEST above but for `text` — the reference's two entries differ
-- in exactly that one field. `%s` twice: the quest name, then the item list.
StaticPopupDialogs["ABANDON_QUEST_WITH_ITEMS"] = {
text = "Abandon \"%s\", destroying %s?", -- ABANDON_QUEST_CONFIRM_WITH_ITEMS
button1 = "Yes", -- YES
button2 = "No", -- NO
OnAccept = function()
AbandonQuest()
PlaySound("igQuestLogAbandonQuest")
end,
timeout = 0,
whileDead = 1,
exclusive = 1,
hideOnEscape = 1,
}
function BenillaQuestLogAbandonButton_OnClick()
if GetQuestLogSelection() == 0 then return end
-- pin §4, QuestLogFrame.xml:463-472: SetAbandonQuest() pins the click-time selection;
@@ -2,6 +2,7 @@
//!
//! ```text
//! cargo run -q -p benilla-app --example addon_harness -- <folder> [--verbose] [--why <substr>] [--deep [n]] [--status <file>] [--diff <file>]
//! or: ... -- <folder> --probe <Name> [--eval <lua>]... (one addon, then ask its VM)
//! ```
//!
//! The instrument decision 1188 phase 6 asks for: *"which addons work" is a number that can be
@@ -49,6 +50,19 @@ fn ranked(rows: Vec<(String, usize)>, take: usize) {
}
}
/// One of the probe's two error lists, printed with its count — and printed even when EMPTY.
///
/// A silent absence and a list nobody asked for read identically, and the whole point of a probe
/// run is to tell "this addon loaded clean and died in a handler" from "it never loaded at all".
fn report_lines(label: &str, lines: &[String]) {
println!(" {label}: {}", lines.len());
for line in lines {
for (n, l) in line.lines().enumerate() {
println!(" {}{}", if n == 0 { "" } else { " " }, l.trim_end());
}
}
}
/// The `--deep` override, set once in `main`. `Some(n)` shows `n` rows of EVERY ranked list;
/// `--deep` with no number means all of them. Absent, each list keeps the `take` its caller chose.
static DEEP: std::sync::OnceLock<Option<usize>> = std::sync::OnceLock::new();
@@ -290,6 +304,42 @@ fn main() {
let _ = DEEP.set(deep);
let root = std::path::PathBuf::from(root);
// `--probe <Name> [--eval <lua> ...]` — ONE addon, loaded the way the survey loads it, then
// asked. Handled before the survey because it is not one: it prints no column and it is not a
// measurement (an eval can mutate the VM), so mixing the two outputs would invite a probe
// number into a record. See `addon_harness::probe`'s header for what it is worth and where it
// deliberately stops — session start, before the render and use probes touch anything.
if let Some(name) = rest
.iter()
.position(|a| a == "--probe")
.and_then(|i| rest.get(i + 1))
{
let evals: Vec<String> = rest
.iter()
.enumerate()
.filter(|(_, a)| *a == "--eval")
.filter_map(|(i, _)| rest.get(i + 1).cloned())
.collect();
let Some(out) = addon_harness::probe::probe(&root, name, &evals) else {
eprintln!(
"no manifest under {}/{name} — is that an addon folder?",
root.display()
);
std::process::exit(1);
};
println!("\n{} — probed under {}", out.name, root.display());
report_lines("load errors", &out.load_errors);
report_lines("session errors", &out.session_errors);
if out.answers.is_empty() {
println!(" (no --eval given — load and session errors only)");
}
for (chunk, answer) in &out.answers {
println!("\n {chunk}");
println!(" {answer}");
}
return;
}
let reports = addon_harness::survey(&root);
if reports.is_empty() {
eprintln!(
+35 -10
View File
@@ -70,6 +70,12 @@ use std::path::{Path, PathBuf};
use benilla_ui::script::UiScript;
use benilla_ui::toc::Toc;
/// The read-back — *why is this global nil?* One addon, loaded the way the survey loads it, then
/// arbitrary Lua evaluated against the VM that is left standing. A debugger, not a column; its
/// header says what it is worth and where it deliberately stops.
pub mod probe;
#[cfg(test)]
mod probe_tests;
/// The render column — the one question every column here was blind to: *did this addon put
/// anything on screen?* Its own module because it is its own concern (and this file is well over
/// the size budget); its header is the design and the honest bounds.
@@ -290,6 +296,34 @@ pub struct AddonReport {
/// `root` is an AddOns folder — one subfolder per addon, each with a `<Name>.toc`. Anything else
/// is skipped in silence, exactly as discovery does, because a stray `Backup/` is the common case.
pub fn survey(root: &Path) -> Vec<AddonReport> {
let (names, installed, registry) = corpus(root);
// Folder → the method names its source DEFINES, memoised across the whole survey.
//
// A dependency's definitions have to be read once per *folder*, not once per dependent: the
// corpus's library folders are declared by dozens of addons each (83 declare `FuBar`), and
// re-scanning `FuBar`'s source 83 times is the survey's runtime for nothing.
let mut defined_methods: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
names
.iter()
.map(|n| survey_one(root, n, &installed, &registry, &mut defined_methods))
.collect()
}
/// **The corpus as every VM must see it** — the folders that carry a manifest, the case-folded
/// installed set dependency resolution consults, and the AddOn registry each VM is seated with.
///
/// Its own function because [`probe`] needs the IDENTICAL environment for a single addon, and a
/// probe built against a registry of one would answer a different question from the row it exists
/// to explain (see [`probe`]'s header). The installed set is a property of the FOLDER, never of
/// the selection.
fn corpus(
root: &Path,
) -> (
Vec<String>,
BTreeSet<String>,
Vec<benilla_ui::script::AddOnInfo>,
) {
let mut names: Vec<String> = std::fs::read_dir(root)
.into_iter()
.flatten()
@@ -328,16 +362,7 @@ pub fn survey(root: &Path) -> Vec<AddonReport> {
})
.collect();
// Folder → the method names its source DEFINES, memoised across the whole survey.
//
// A dependency's definitions have to be read once per *folder*, not once per dependent: the
// corpus's library folders are declared by dozens of addons each (83 declare `FuBar`), and
// re-scanning `FuBar`'s source 83 times is the survey's runtime for nothing.
let mut defined_methods: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
names
.iter()
.map(|n| survey_one(root, n, &installed, &registry, &mut defined_methods))
.collect()
(names, installed, registry)
}
/// The method names one addon folder's source **defines** — `function T:N`, `function T.N`,
@@ -0,0 +1,129 @@
//! **The read-back column — ask the VM instead of guessing at it.**
//!
//! [`super::survey`] answers *how many* addons work and *what row* each one died on. It cannot
//! answer the next question, which is the one every fix actually turns on: **why is this
//! particular global nil?** The report's `--why` opens the error text; nothing opened the *state*
//! the error was raised against.
//!
//! So this loads **one** addon exactly the way the survey loads it, drives the same session start,
//! and then evaluates Lua of the caller's choosing against the VM that is left standing.
//!
//! ## Two properties it has to have, and both are about not lying
//!
//! **The environment is the corpus's, never the selection's.** The registry every VM is seated
//! with, and the case-folded installed set dependency resolution consults, are built from the
//! *whole folder* — exactly as [`super::survey`] builds them — so probing `KLHThreatMeter` out of
//! a 219-addon root produces the same VM its row in the full run had. Building a registry of one
//! would quietly answer a different question: `GetAddOnInfo` is how AceAddon and AceLibrary find
//! their dependencies, and against a registry of one they take the "nothing is installed" path
//! (the fault [`super::survey`]'s registry comment records at corpus scale).
//!
//! **It stops at session start, before the render and use probes.** Those two drive input into the
//! addon's own frames — hover, click, drag — and standing up the method oracle writes sixteen
//! widgets and a global into the VM. A read taken after them answers "what is true once the
//! harness has finished poking it", which is not the state a player is in and not the state an
//! error was raised in. `ADDON_LOADED` → `VARIABLES_LOADED` → `PLAYER_LOGIN` →
//! `PLAYER_ENTERING_WORLD` + the ticks is the moment this reads, and it is the moment the session
//! column's errors come from.
//!
//! ## What it is worth
//!
//! It is a **debugger, not a measurement**. An eval can mutate the VM, so nothing here prints a
//! column and no number from a probe run belongs in a record — quote [`super::survey`] for that.
use std::collections::BTreeSet;
use std::path::Path;
use benilla_ui::script::UiScript;
use benilla_ui::toc::Toc;
/// One addon, loaded and asked.
#[derive(Debug, Clone, Default)]
pub struct ProbeOutcome {
pub name: String,
/// Load-time failures, verbatim — the same list [`super::AddonReport::errors`] carries.
pub load_errors: Vec<String>,
/// What its handlers raised while the session start was driven.
pub session_errors: Vec<String>,
/// `(chunk, answer)` per `--eval`, in the order asked. An answer is `= <value>` or
/// `ERROR: <message>`; the chunk that raises does not stop the ones after it.
pub answers: Vec<(String, String)>,
}
/// Wrap a caller's chunk so a raise comes back as a value instead of killing the probe.
///
/// **`pcall` + `tostring`, and only the FIRST result.** Multi-return would need `table.getn` or
/// `select`, and this VM is deliberately 5.0-shaped (decisions 1194/1215) — a probe that depended
/// on which of those the dialect layer publishes would be an instrument with a dialect bug in it.
/// A caller that wants more concatenates its own string, which is what `..` is for.
fn wrapped(chunk: &str) -> String {
format!(
"local __ok, __v = pcall(function() {chunk} end)\n\
if __ok then return \"= \" .. tostring(__v) else return \"ERROR: \" .. tostring(__v) end"
)
}
/// Load `name` out of `root` the way the survey does, drive the session start, then evaluate each
/// chunk in `evals` against the VM that is left.
///
/// `None` when the folder has no manifest — the same refusal [`super::survey`] makes by filtering.
pub fn probe(root: &Path, name: &str, evals: &[String]) -> Option<ProbeOutcome> {
let toc_path = super::manifest_path(root, name)?;
// Decoded, not `read_to_string`'d, for the reason `survey_one` states: a cp1252 manifest read
// as UTF-8 parses as an EMPTY toc, and an addon with no files reads as a clean pass.
let toc = Toc::parse(&benilla_ui::source::decode(
&std::fs::read(&toc_path).unwrap_or_default(),
));
let (_, installed, registry) = super::corpus(root);
let mut script = match UiScript::new() {
Ok(s) => s,
Err(e) => {
return Some(ProbeOutcome {
name: name.to_string(),
load_errors: vec![format!("VM: {e}")],
..Default::default()
})
}
};
script.set_instruction_budget(super::ADDON_INSTRUCTION_BUDGET);
script.set_screen_size(1024.0, 768.0);
// `None` roots: a probe must never read or write the director's real saved variables, for the
// same reason the survey does not call `finish_ui_load` (1213 §4).
script.register_addons(registry, None, None, None);
super::seat_a_session(&mut script);
let _ = crate::ui_script::load_default_ui(&script);
let mut dep_order: Vec<String> = Vec::new();
super::load_dependencies(
&script,
root,
&toc,
&installed,
&mut BTreeSet::new(),
&mut dep_order,
);
let load_errors = super::load_addon_files(&script, root, name, &toc);
let session_errors = super::drive_session_start(&mut script, name, &dep_order);
let answers = evals
.iter()
.map(|chunk| {
let answer = script
.eval::<String>(&wrapped(chunk))
// A raise HERE is the wrapper failing to compile the caller's chunk — a syntax
// error in what they typed — not the chunk raising, which `pcall` already caught.
.unwrap_or_else(|e| format!("SYNTAX: {e}"));
(chunk.clone(), answer)
})
.collect();
Some(ProbeOutcome {
name: name.to_string(),
load_errors,
session_errors,
answers,
})
}
@@ -0,0 +1,150 @@
//! **The probe's can-it-fail proof**, plus the one property that makes its answers worth
//! anything: the VM it reads is the corpus's, not the selection's.
//!
//! A debugger that quietly answers a different question from the report it exists to explain is
//! worse than no debugger, because every conclusion drawn from it looks sourced.
use std::path::{Path, PathBuf};
use super::probe::probe;
/// One throwaway AddOns root, cleaned up on drop even if a test panics.
struct Fixtures(PathBuf);
impl Fixtures {
fn new(tag: &str) -> Self {
let root = std::env::temp_dir().join(format!(
"benilla-probe-{tag}-{}-{:?}",
std::process::id(),
std::thread::current().id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).unwrap();
Self(root)
}
fn addon(&self, name: &str, body: &str) -> &Self {
let dir = self.0.join(name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("{name}.toc")),
"## Interface: 11200\nbody.lua\n",
)
.unwrap();
std::fs::write(dir.join("body.lua"), body).unwrap();
self
}
fn root(&self) -> &Path {
&self.0
}
}
impl Drop for Fixtures {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
/// **The probe reads AFTER the session start, and a raise is a value, not a stop.**
///
/// Three claims in one addon. A global written at file scope is readable (the load ran); a global
/// written only from a `PLAYER_LOGIN` handler is readable too (**the session start ran** — this is
/// the claim that separates this from a bare load, and the moment the session column's own errors
/// come from); and an eval that raises comes back tagged `ERROR:` **without stopping the eval after
/// it**, because a debugging session that dies on its own first typo is a debugging session spent
/// re-running the command.
#[test]
fn the_probe_reads_the_vm_after_the_session_start() {
let fx = Fixtures::new("after");
fx.addon(
"Talker",
r#"
TalkerFileScope = "loaded"
local f = CreateFrame("Frame")
f:RegisterEvent("PLAYER_LOGIN")
f:SetScript("OnEvent", function() TalkerAtLogin = "logged in" end)
"#,
);
let out = probe(
fx.root(),
"Talker",
&[
"return TalkerFileScope".to_string(),
"return TalkerAtLogin".to_string(),
"return nil + 1".to_string(),
"return TalkerFileScope".to_string(),
],
)
.expect("the fixture has a manifest");
assert!(out.load_errors.is_empty(), "{:?}", out.load_errors);
assert!(out.session_errors.is_empty(), "{:?}", out.session_errors);
let answers: Vec<&str> = out.answers.iter().map(|(_, a)| a.as_str()).collect();
assert_eq!(answers[0], "= loaded", "the file scope ran");
assert_eq!(
answers[1], "= logged in",
"the PLAYER_LOGIN handler ran BEFORE the read — a load-only probe would answer nil"
);
assert!(
answers[2].starts_with("ERROR:"),
"a raise is reported, not propagated: {}",
answers[2]
);
assert_eq!(
answers[3], "= loaded",
"...and the eval after a raise still ran"
);
}
/// **The environment is the whole folder's, never the probed addon's.**
///
/// `GetAddOnInfo` is how AceAddon and AceLibrary — the two most replicated files in the corpus —
/// find their dependencies, so a probe built against a registry of one would send half the
/// ecosystem down its "nothing is installed" path and answer a different question from the survey
/// row it was opened to explain. The sibling here is never surveyed and never loaded; it must
/// still be *installed*.
#[test]
fn the_probe_sees_the_whole_folder_installed() {
let fx = Fixtures::new("registry");
fx.addon("Asker", "AskerSaw = GetAddOnInfo(\"Sibling\")\n");
fx.addon("Sibling", "SiblingRan = 1\n");
let out = probe(
fx.root(),
"Asker",
&[
"return AskerSaw".to_string(),
"return GetNumAddOns()".to_string(),
"return SiblingRan".to_string(),
],
)
.expect("the fixture has a manifest");
assert!(out.load_errors.is_empty(), "{:?}", out.load_errors);
assert_eq!(
out.answers[0].1, "= Sibling",
"the sibling must be INSTALLED in the probed VM"
);
assert_eq!(
out.answers[1].1, "= 2",
"the registry is the folder's, not the selection's"
);
// ...and installed is not loaded: one addon per VM is the survey's own isolation rule, and a
// probe that silently ran the neighbours would attribute their globals to the addon in hand.
assert_eq!(
out.answers[2].1, "= nil",
"an installed sibling must NOT have been run"
);
}
/// A folder with no manifest is `None`, not an empty outcome — the same refusal the survey makes
/// by filtering, said out loud so the caller can tell "no such addon" from "that addon is silent".
#[test]
fn a_folder_without_a_manifest_is_refused() {
let fx = Fixtures::new("nomanifest");
std::fs::create_dir_all(fx.root().join("Backup")).unwrap();
assert!(probe(fx.root(), "Backup", &[]).is_none());
assert!(probe(fx.root(), "NotThereAtAll", &[]).is_none());
}
+25 -2
View File
@@ -117,6 +117,27 @@ impl Cinematic {
}
}
#[cfg(test)]
impl Cinematic {
/// A cinematic that answers [`Cinematic::is_playing`] — for the neighbours that only ask that
/// one question (the streaming focus's hold, the HUD hide). **Shot-less on purpose:** there is
/// no way to build a [`CinematicPath`] without a real `Cameras\*.m2`, and a fixture that
/// faked one would let a test assert about a shot that does not exist. Anything that reads a
/// shot must be tested against the real corpus instead, and will panic loudly here if it is
/// not.
pub(crate) fn playing_for_test() -> Self {
Self {
pending: None,
playing: Some(Playing {
sequence_id: 0,
shots: Vec::new(),
index: 0,
elapsed: Duration::ZERO,
}),
}
}
}
/// One of the two letterbox bars — full-width, black, top or bottom.
///
/// **Bevy UI nodes, not FrameXML quads, and that is the whole point.** The HUD is hidden during a
@@ -257,11 +278,13 @@ fn drive_letterbox(
letterbox_bar(width, screen)
});
// On the edges and on a resize, never every frame: the measured crop, so the letterbox is a
// number in the log rather than something only an eye can confirm.
// number in the log rather than something only an eye can confirm. `info!` on purpose — the
// default filter stops at that level, and a number nobody's ordinary run prints is a number
// nobody checks (it fires once per cinematic, plus once per resize during one).
if height != *logged {
*logged = height;
if height > 0.0 {
debug!("cinematic: letterbox bar {height:.1} px");
info!("cinematic: letterbox bar {height:.1} px");
}
}
for (mut node, mut vis) in &mut bars {
+20 -1
View File
@@ -309,7 +309,13 @@ fn setup_loading_screen(
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
fn drive_loading_screen(
mut screen: ResMut<LoadingScreen>,
progress: Res<WorldLoadProgress>,
// The streamer's two published facts, bundled into one param (Bevy's 16-element system-param
// ceiling, the same squeeze `player::control` and `feed_ui_input` already pay): what is
// resident, and whether that residency is even ABOUT the player's own ground this frame.
stream: (
Res<WorldLoadProgress>,
Res<benilla_world::terrain_stream::ViewFocus>,
),
current_map: Option<Res<CurrentMap>>,
maps: Option<Res<MapCatalogRes>>,
screens: Option<Res<LoadingScreenCatalogRes>>,
@@ -359,6 +365,7 @@ fn drive_loading_screen(
transfer,
roster,
) = edges;
let (progress, focus) = (&stream.0, &stream.1);
let now = time.elapsed_secs();
let map_id = current_map.as_ref().map(|m| m.0);
// The physics hold releases on the same residency signal that clears this screen (decision
@@ -443,8 +450,20 @@ fn drive_loading_screen(
// an invisible screen up forever against residency that never arrives. The warm-up is not lost,
// it MOVED: the world now loads under the cover this same function raises on world entry, which
// is where a warm-up belongs. ---
//
// **And only while the focus IS the body** ([`ViewFocus::follows_body`]). The trigger's whole
// premise is "residency says the ground under the player is missing and no edge announced it,
// so a load must be happening". While the eye is deliberately elsewhere — a cinematic fly-by,
// a free-fly — residency describes the *camera's* tile, and reading it as a fact about the
// player is reading someone else's ground (decision 1336's lesson, one consumer over).
// Measured: every cinematic raised this cover the instant it started (`.debug play cinematic
// 41` → "loading screen: up (focus not resident, map None)" in the same millisecond as the
// shot), covering the opening seconds of the fly-by the deferred-start latch exists to protect
// — and, once the body took the settle hold for the same detachment, covering ALL of it,
// because this cover's clear waits on that hold and that hold waits on the focus coming home.
if !screen.active
&& !progress.focus_resident
&& focus.follows_body()
&& *state.get() == crate::char_select::ClientState::InWorld
{
screen.raise("focus not resident", false, None, now);
+26
View File
@@ -984,6 +984,29 @@ fn feed_minimap_inside(
}
}
/// Push the player's world facing onto the Minimap's engine-owned player-arrow `Model` — the
/// client's `CMinimap::SetPlayerFacing 0x4eb8e0`, which writes it verbatim into the arrow's
/// `[+0x39c]`, the field `Model:GetFacing()` reads.
///
/// **Every frame, not on an edge**, because the value is continuous: the reference calls it from
/// the per-frame minimap update with the player's live `GetFacing()`, and an addon polling it in an
/// OnUpdate (which is what reads it — Questie's `GetPlayerFacing`, pfQuest's `compat/client.lua`)
/// would otherwise steer off a stale heading. It costs one arena lookup per Minimap widget, which
/// is what `WidgetArena::minimap_kinds` exists to make true.
///
/// **The one transform, and why it is not a fudge.** `player.facing()` is benilla's `face_yaw`, an
/// unbounded accumulator — a mouse-turn just adds to it — whereas the client's unit orientation is
/// a normalized field, and the same normalization already happens on the wire (`movement_net`'s
/// `[0, 2π)` rule, which vmangos's `VerifyMovementInfo` requires). So the value published here is
/// the wire value, and an addon reading it back sees the same number the server does.
fn feed_minimap_player_facing(
script: Option<bevy::ecs::system::NonSendMut<benilla_ui::script::UiScript>>,
player: Res<Player>,
) {
let Some(mut script) = script else { return };
script.set_minimap_player_facing(player.facing().rem_euclid(std::f32::consts::TAU));
}
/// Push the live game clock into the VM when the game minute ticks — `GetGameTime()`'s backing
/// globals (the zone-text family's shape). The reference's GameTimeFrame re-reads GetGameTime
/// every OnUpdate and compares the packed minute against its cached `timeOfDay`, so a
@@ -1044,6 +1067,9 @@ impl Plugin for MinimapPlugin {
// Before the script tick, so a zoom button pressed this frame routes to the
// indoor/outdoor index that matches where the player actually is.
feed_minimap_inside.before(crate::ui_script::UiInput),
// Before the script tick, so an addon's OnUpdate steers off this frame's
// heading rather than last frame's.
feed_minimap_player_facing.before(crate::ui_script::UiInput),
// Before the script tick, and after the containment verdict it reads: the
// `MINIMAP_PING` event and `Minimap:GetPingPosition()`'s value land in the
// same tick, on a ping the renderer already drew at the end of last frame.
+117 -2
View File
@@ -65,12 +65,14 @@ pub(super) fn publish_viewer(
};
}
/// Tell the world where to stream from, once per frame, before the stream stage reads it.
/// Tell the world where to stream from, once per frame, before the stream stage reads it — and,
/// when that answer is not the body, put the body on the settle hold for as long as it is not.
pub(super) fn publish_view_focus(
mut focus: ResMut<ViewFocus>,
player: Option<Res<Player>>,
mut player: Option<ResMut<Player>>,
roster: Option<Res<crate::char_select::Roster>>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
mut was_flying: Local<bool>,
) {
let entry = roster
.as_deref()
@@ -82,6 +84,37 @@ pub(super) fn publish_view_focus(
// mode. The server does the mirror-image thing on its side: while a cinematic runs it
// re-anchors object visibility to its own copy of the flying camera (decision 0196).
let flying = cinematic.as_deref().is_some_and(|c| c.is_playing());
// **A detached focus stops keeping the body's own ground resident — so the body goes on the
// hold for as long as that lasts.** Free-fly never had to answer for this: `control` skips the
// whole controlled branch while `detached`, so nothing simulates the body there. A cinematic
// does, and the body stands with gravity on while the tiles under it unload. Measured on the
// probe: `.debug play cinematic 41` over a settled body at z=59.4 dropped it to z=-62 within
// four seconds, the server yanking it back, and it never sent a movement packet again.
//
// This is not a new fact — it is the settle hold's own ("the ground under this body is not
// there"), reached from the other side: after a teleport the body's world has not arrived
// *yet*, and here it is *leaving*. So it takes the same hold, and the release stays exactly
// where decision 0737 put it — [`release_post_snap_hold`], on residency about the body's own
// tile, which cannot become true again until the shot ends and the focus comes home. Nothing
// here ever clears the hold: starting one is the game's business, ending one is the world's.
//
// `world_stale` rides with it because it is equally true — the resident world is the flying
// camera's, not this body's — and because it is what keeps the stall backstop off. Without it
// a camera that settles mid-shot goes quiet, the 6 s no-progress budget expires, and gravity
// comes back on partway through a 102-second intro: the exact fall this prevents, delayed.
// Armed on the RISING EDGE, never re-asserted per frame — the sentence above is a rule, not a
// flourish. A shot whose camera happens to stay on the body's own tile makes that tile resident
// and the release ends the hold at once, correctly; re-arming every frame would fight it and
// leave `settling` oscillating under the loading screen's clear-gate and the zone-channel walk,
// both of which read it.
if flying && !*was_flying {
if let Some(p) = player.as_deref_mut().filter(|p| p.active) {
p.settling = true;
p.world_stale = true;
info!("cinematic: body held — the stream follows the camera off its ground");
}
}
*was_flying = flying;
// The pacing bit: spawn caps apply only to a live avatar standing in a settled world. Through
// entry, a teleport and a world swap the loading cover is absorbing the burst, and a cap there
// would only lengthen the reveal.
@@ -228,6 +261,88 @@ mod tests {
app.world().resource::<Player>().settling
}
/// **A cinematic puts the body on the settle hold, once.** The live defect this pins: with the
/// stream following the flying camera, the tiles under a standing body unload and it falls
/// through the world — measured on the probe at `.debug play cinematic 41`, z=59.4 to z=-62 in
/// four seconds, with the server yanking it back and no movement packet sent again.
///
/// `world_stale` rides along because without it the stall backstop expires the moment the
/// camera stops streaming new ground, and gravity comes back partway through the shot.
#[test]
fn a_flying_cinematic_holds_the_body_and_marks_its_world_stale() {
let mut app = App::new();
app.insert_resource(ViewFocus::default())
.insert_resource(Player {
active: true,
settling: false,
world_stale: false,
..Player::default()
})
.insert_resource(crate::cinematic::Cinematic::playing_for_test())
.add_systems(Update, publish_view_focus);
app.update();
let p = app.world().resource::<Player>();
assert!(
p.settling,
"the body kept gravity while its ground unloaded"
);
assert!(
p.world_stale,
"the stall backstop was left free to expire mid-shot"
);
}
/// The other half of the same rule: **nothing here ever ends a hold**, and nothing re-arms one.
/// The release is the world's (decision 0737), and a shot whose camera stays on the body's own
/// tile makes that tile resident — so the release ends the hold correctly, and a per-frame
/// re-arm would fight it and leave `settling` flickering under the two systems that read it
/// (the loading screen's clear gate and the zone-channel walk).
#[test]
fn the_hold_is_armed_on_the_edge_and_never_re_armed() {
let mut app = App::new();
app.insert_resource(ViewFocus::default())
.insert_resource(Player {
active: true,
..Player::default()
})
.insert_resource(crate::cinematic::Cinematic::playing_for_test())
.add_systems(Update, publish_view_focus);
app.update();
assert!(app.world().resource::<Player>().settling);
// The world releases the hold (the camera's tile IS this body's), still mid-cinematic.
app.world_mut().resource_mut::<Player>().settling = false;
app.world_mut().resource_mut::<Player>().world_stale = false;
app.update();
let p = app.world().resource::<Player>();
assert!(
!p.settling,
"the publish re-armed a hold the world had ended"
);
assert!(
!p.world_stale,
"and re-staled a world the streamer had cleared"
);
}
/// No cinematic, no hold — the ordinary frame is untouched.
#[test]
fn an_ordinary_frame_arms_nothing() {
let mut app = App::new();
app.insert_resource(ViewFocus::default())
.insert_resource(Player {
active: true,
..Player::default()
})
.insert_resource(crate::cinematic::Cinematic::default())
.add_systems(Update, publish_view_focus);
app.update();
let p = app.world().resource::<Player>();
assert!(!p.settling);
assert!(!p.world_stale);
}
/// B263 (decision 1303): a stream that keeps arriving keeps the hold, however long it takes.
/// The old fixed load budget released gravity at 6 s into a live Stormwind arrival — measured
/// 0.01 s from firing even on a fast machine — and the body fell through the not-yet-collided
+32 -9
View File
@@ -11,8 +11,12 @@
//! `pub(super)` here), and the handle is the whole point: a dropped kira handle keeps playing, so
//! a skipped cinematic whose narration was merely forgotten would keep narrating over the game for
//! another minute. The reference has exactly the same problem and the same answer — it keeps the
//! cinematic's sound in a dedicated global (`[0xb4e274]`) and *releases* it on every stop path
//! (`0x48f050`, and again in the local-abort teardown at `0x490b88`).
//! cinematic's sound in a dedicated global (`[0xb4e274]`) and *releases* it on **three** paths,
//! which are all of its references besides the write at `0x48ef3e`: `0x48efef` on the shot
//! advance — and therefore on the ordinary end, since every shipped sequence is a single shot —
//! `0x48f055` on the stop/ESC, and `0x490b8d` in the local-abort teardown. The first was missing
//! from this list; the behaviour was not (a shot change and an ended cinematic both stop the voice
//! below, which is the same three edges).
//!
//! So this module watches [`Cinematic`]'s published shot and follows it: a new shot starts its
//! narration, and the shot going away stops it. That keeps the coupling one-way — the cinematic
@@ -24,12 +28,17 @@ use benilla_assets::{LockRecover, WorldAssets};
use benilla_world::schedule::WorldStage;
use super::mixer::{self, StreamingSoundHandle};
use super::{kit::SoundCategory, kit::SoundKits, SoundConfig, SoundOutput};
use super::{kit::SoundKits, SoundConfig, SoundOutput};
use crate::cinematic::Cinematic;
/// How long a cut-off narration takes to fade. Short: an ESC skip should feel like a cut, not a
/// dissolve, but a hard stop mid-waveform clicks.
const CUT_FADE_MS: u64 = 250;
/// A cut-off narration is **cut**, with no fade at all — wow-re
/// `sound/scratch/cinematic-audio-law.md` (VERIFIED): every release on this path
/// (`0x48efef` shot-advance, `0x48f055` stop/ESC, `0x490b8d` local abort) is a plain release of
/// `[0xb4e274]`, and there is **no audio fade anywhere in the cinematic** — the 0.25 s fade the
/// reference schedules at both edges (`0x4c0d10`, `[0x804550]`) is a *screen* fade, which is what
/// decision 1695 deferred. benilla's 250 ms declick was a guess at a fade the reference does not
/// have.
const CUT_FADE_MS: u64 = 0;
/// The narration channel: which shot it belongs to, and its live handle.
#[derive(Default)]
@@ -97,9 +106,23 @@ fn drive_narration(
return;
}
};
// Speech rides the effects category, the same one every NPC voice line takes — 1.12 ships no
// separate dialog slider, so this is where a player's expectation already points.
let amp = config.category_amp(SoundCategory::Sfx) * kit_vol;
// **No category slider applies to this channel at all** (wow-re `cinematic-audio-law.md`,
// VERIFIED). The narration is opened at `0x48ef29` → `0x458a40` → `0x45ce60` with behaviour
// flags `0x15`: `or eax,4` (`0x45ce9e`) and `or eax,0x10` (`0x45cea8`), while the music bit
// `or eax,2` at `0x45ceb2` is **skipped**. Bit `0x10` is read at `0x7a5dc0`
// (`test al,0x10; jne 0x7a5e1a`) and takes the channel around the category multiply entirely —
// so Sound, Music and Ambience sliders all leave it alone, and its gain is the kit volume flat
// (`__ftol(0.69 · 1.0 · 255)` = 175/255 on the shipped narrations).
//
// It is **not** exempt from the master, though: only bit `0x2` bypasses the
// `MasterSoundEffects` gate at `0x7a529c`, and this channel does not set it — so unchecking
// "Enable Sound Effects" silences the narration outright. [`SoundConfig::enabled`] is that
// checkbox here.
//
// The old reading — the Sfx category, "the same one every NPC voice line takes" — was a
// reasonable guess at where a player's expectation points, and it was wrong: this channel has
// no category.
let amp = if config.enabled { kit_vol } else { 0.0 };
match mixer_ref.play_stream(data.volume(mixer::amp_to_db(amp))) {
Ok(h) => {
info!("cinematic voice: {path}");
+61 -3
View File
@@ -122,6 +122,35 @@ pub(crate) struct SoundConfig {
/// (1109) ride under the entry cover exactly as the real client's does: its amp is applied
/// once at start, and a playing stream never passes back through the kit starters.
pub world_hold: bool,
/// **The cinematic's music stop** — NOT a CVar, a per-frame live bit fed from
/// [`crate::cinematic::Cinematic`] by [`feed_music_suppression`], and the exact runtime
/// counterpart of the reference's `[0xb06cc8]` (wow-re `sound/scratch/cinematic-audio-law.md`,
/// §5, VERIFIED).
///
/// A cinematic asserts precisely what `/console EnableMusic 0` asserts: both reach the same
/// setter `0x4603b0` — the CVar handler at `0x4574a4`, the cinematic's own start at `0x48ed83`
/// — and on the disable edge that setter runs `0x7a5700`, which is **stop-and-destroy and
/// takes no duration**. So the zone track is CUT, never faded, and the per-tick music pump
/// bails at its first instruction (`0x460040`: `mov al,[0xb06cc8]; test al,al; jne`) for as
/// long as the flag is set.
///
/// **Separate from [`Self::music_enabled`] on purpose.** The cinematic writes the runtime
/// flag, never the CVar — and neither do we, because benilla persists a CVar *diff* to
/// `config.toml`: asserting `EnableMusic` here would save a player's music off for good if
/// they quit during a 102-second intro.
///
/// **Ambience is deliberately untouched**, and that is verified rather than assumed: ambience
/// has the analogous suppress flag (`[0x836424]`, written only from `EnableAmbience`) and the
/// cinematic never writes it.
///
/// **One thing of the reference's we deliberately do not carry: its restore latch.** It records
/// at the start whether music was already off (`[0xb4e278] = (flag == 0)`) and re-enables at
/// the stop only if it was the one that disabled it — because there the flag and the player's
/// `EnableMusic` CVar are the *same* bit, so restoring blindly would switch a player's music
/// back on. Here they are two: this is a separate runtime bit, and a player who set
/// `EnableMusic 0` is still silenced by [`Self::category_amp`] whatever this says. The latch
/// would guard against nothing, so it is left out rather than transcribed for its own sake.
pub music_suppressed: bool,
/// The **output limiter** — benilla's own `SoundOutputLimiter` CVar (decision 1551), default
/// **on**. Not a 1.12 CVar: the reference has no such DSP and does not need one, because it
/// hands its whole audible mix to FMOD 3 and carries its headroom elsewhere (the SFX-bus
@@ -160,6 +189,7 @@ impl Default for SoundConfig {
ambience_enabled: true,
reverb: false,
world_hold: false,
music_suppressed: false,
limiter: true,
}
}
@@ -181,6 +211,21 @@ fn feed_world_hold(
}
}
/// Copy the cinematic's music stop into [`SoundConfig::music_suppressed`], once per frame in
/// `PreUpdate` beside [`feed_world_hold`] — same shape, same reason: one answer per frame, read by
/// every trigger system after it.
fn feed_music_suppression(
mut config: ResMut<SoundConfig>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
) {
let playing = cinematic
.as_deref()
.is_some_and(crate::cinematic::Cinematic::is_playing);
if config.music_suppressed != playing {
config.music_suppressed = playing;
}
}
/// The backend output. `mixer` is `None` when no audio device exists (headless/CI) or
/// `$WOW_NOSOUND` is set — every consumer tolerates silence. A **non-Send** resource: the
/// backend's device stream is not `Send` on every platform, so all audio systems run on the
@@ -373,7 +418,7 @@ impl Plugin for SoundPlugin {
)
// The cover's audio hold (see [`SoundConfig::world_hold`]): fed in PreUpdate so every
// trigger system this frame — whatever stage it runs in — reads one answer.
.add_systems(PreUpdate, feed_world_hold)
.add_systems(PreUpdate, (feed_world_hold, feed_music_suppression))
.add_systems(
Update,
(
@@ -421,12 +466,25 @@ fn update_audio_listener(
mut listener: ResMut<AudioListener>,
mut out: NonSendMut<SoundOutput>,
player: Res<Player>,
cinematic: Option<Res<crate::cinematic::Cinematic>>,
self_av: Query<(&Transform, Option<&CameraPivot>), With<Embodied>>,
cam: Query<&Transform, (With<WorldCamera>, Without<Embodied>)>,
) {
// **A cinematic takes the listener to the camera, and it OVERRIDES the CVar** — wow-re
// `sound/scratch/cinematic-audio-law.md` (VERIFIED; it also promoted `benilla-pins.md` B14's
// `camera+0x50` label from INFERRED to VERIFIED). `0x483112 jne 0x4831f0` takes the camera
// branch whenever `camera+0x50 != 0`, ahead of and regardless of `SoundListenerAtCharacter`;
// the flag is armed from the cinematic's own start path (`0x48ee55` → `0x50c870` → `0x50c9f2`
// → `0x50c740`) and cleared only by `0x50ca50` at the stop.
//
// The narration itself is 2D, so what this actually moves is every *other* 3D sound during a
// fly-by that can travel 1741 yards from the body.
let flying = cinematic
.as_deref()
.is_some_and(crate::cinematic::Cinematic::is_playing);
// At-character (the default). `player.pos` is the feet; the head offset is the shared
// model-derived pivot height, and the facing is the aim yaw about world-up (world +Y).
if player.active && !player.detached {
if player.active && !player.detached && !flying {
if let Ok((t, pivot)) = self_av.single() {
listener.pos = player.pos + Vec3::Y * head_height(pivot, t.scale.x);
listener.rot = Quat::from_rotation_y(player.facing());
@@ -436,7 +494,7 @@ fn update_audio_listener(
return;
}
}
// At-camera fallback (the `SoundListenerAtCharacter=0` path).
// At-camera (the `SoundListenerAtCharacter=0` path, and the cinematic override above).
if let Ok(t) = cam.single() {
listener.pos = t.translation;
listener.rot = t.rotation;
+103 -4
View File
@@ -79,6 +79,11 @@ pub(crate) struct ExplorationSounds(pub(crate) benilla_formats::ExplorationSound
/// Day iff 05:30 ≤ clock < 21:00 (module docs — the client's hard step, B4-verified).
/// Index into the `[day, night]` DBC pairs.
/// How long after a cinematic ends before the zone track comes back — the reference's
/// `[0x836400] = tick + 0xbb8` at `0x4603b0(0)`, i.e. **3.000 s**. Its own number: not the `-1`
/// immediate sentinel a zone change arms, and not the randomized `SilenceInterval`.
const CINEMATIC_MUSIC_RESUME_SECS: f64 = 3.0;
fn phase(clock: &GameClock) -> usize {
if (330..1260).contains(&clock.minute) {
0
@@ -164,6 +169,8 @@ pub(super) struct ZoneAudio {
/// When the next zone track starts (Time::elapsed_secs_f64; None = a track is playing or
/// no music in this zone).
next_track_at: Option<f64>,
/// Last frame's [`SoundConfig::music_suppressed`] — the edge this module acts on.
music_suppressed: bool,
/// The looping ambience kit currently up (0 = none) + its handle and base volume. A static
/// loop, not a stream ([`mixer::loop_from_bytes`] — streaming loop beds die at EOF).
ambience_kit: u32,
@@ -192,6 +199,7 @@ impl Default for ZoneAudio {
music_kit: 0,
music_kit_vol: 1.0,
next_track_at: None,
music_suppressed: false,
ambience_kit: 0,
ambience: None,
ambience_kit_vol: 1.0,
@@ -279,6 +287,27 @@ fn zone_audio(
let phase = phase(&clock);
let zone = &mut *zone;
// ---- the cinematic's music stop (wow-re `sound/scratch/cinematic-audio-law.md`, VERIFIED) ----
// The flag itself is [`SoundConfig::music_suppressed`], whose doc carries the mechanism. Here
// are its two edges, and both are byte-shaped rather than chosen:
//
// * **Down: a CUT, not a fade.** The disable edge runs `0x7a5700`, stop-and-destroy, which
// takes no duration argument at all — there is nothing to fade with. Not the 4.0 s
// zone-change fade, not the 250 ms teardown declick.
// * **Up: +3.000 s, then full volume.** Re-enabling at the stop (`0x4603b0(0)`) sets
// `[0x836400] = tick + 0xbb8` — **neither** the `-1` immediate sentinel a zone change or a
// world entry arms, **nor** [`next_track_time`]'s randomized `SilenceInterval`. A third
// number, and the only place it appears.
//
// Ambience is untouched on both edges, deliberately — see the flag's doc for why that is a
// finding and not an omission.
apply_music_suppression(zone, config.music_suppressed, now);
// **Ahead of the cover's hold arm below, which returns.** The flag has to track the cinematic
// whether or not a loading screen happens to be up, or the edge is deferred until the cover
// clears — measured at 1.8 s late on a shot far enough to unload the body's ground. Under a
// cover the work itself is a no-op (the teardown already took the slot), so this is about the
// flag staying honest, not about the stream.
// The cover's audio hold ([`SoundConfig::world_hold`]): the raise kills the world
// soundscape — the reference's loading screen sits over a torn-down world, so a departure
// zone's bed playing under the destination's cover is a sound it cannot make. Resetting
@@ -350,7 +379,7 @@ fn zone_audio(
// wrongly reused it on a change, leaving a newly-entered zone silent for 3–5 min, and
// the 6 s "cold start" that used to sit here was `0x4601f0`'s unreachable arm applied
// to an entry that never calls it (1553).
if !slot_taken {
if !slot_taken && !config.music_suppressed {
if let Some(kit) = zone_music_row(&areas.0, music_row).map(|m| m.sounds[phase]) {
if kit != 0 {
start_music_stream(zone, &mut out, &mut kits, &assets, &config, kit);
@@ -419,8 +448,14 @@ fn zone_audio(
zone.next_track_at = next_track_time(zone, &areas.0, zm, phase, now);
}
}
// Silence elapsed → start the next track (same-zone cycle, or the cold-start first track).
if zone.next_track_at.is_some_and(|t| now >= t) && zone.music.is_none() {
// Silence elapsed → start the next track (same-zone cycle, the cold-start first track, or the
// 3.0 s resume after a cinematic). The pump is dead while suppressed, which is the reference's
// own shape — `0x460040` bails at its first instruction on the flag, so no track is selected,
// opened or scheduled for the duration.
if !config.music_suppressed
&& zone.next_track_at.is_some_and(|t| now >= t)
&& zone.music.is_none()
{
zone.next_track_at = None;
if let Some(m) = zone_music_row(&areas.0, zone.zone_music) {
let kit = m.sounds[phase];
@@ -462,6 +497,29 @@ fn zone_audio(
}
}
/// The two edges of [`SoundConfig::music_suppressed`] on the music slot — split out so the two
/// byte-shaped numbers in it (a cut with no fade; a resume at exactly +3.000 s) can be asserted
/// without standing up the whole soundscape.
fn apply_music_suppression(zone: &mut ZoneAudio, suppressed: bool, now: f64) {
if suppressed == zone.music_suppressed {
return;
}
zone.music_suppressed = suppressed;
if suppressed {
if let Some(mut h) = zone.music.take() {
h.stop(mixer::fade(0));
info!("zone music: cut for a cinematic");
}
// The next login's first track starts at position 0, far behind this one's baseline — the
// same reason `stop_world_soundscape` resets it (1109).
zone.music_watch.reset();
zone.next_track_at = None;
} else {
zone.next_track_at = Some(now + CINEMATIC_MUSIC_RESUME_SECS);
info!("zone music: resumes in {CINEMATIC_MUSIC_RESUME_SECS:.1}s");
}
}
fn zone_music_row(cat: &AreaSoundCatalog, _id: u32) -> Option<&benilla_formats::ZoneMusicEntry> {
// The catalog keys zone-music rows by id; expose via resolve()'s cached row instead of a
// second map walk. (Small helper so the call sites read; see AreaSoundCatalog::zone_music.)
@@ -782,9 +840,50 @@ pub(super) fn plugin(app: &mut App) {
#[cfg(test)]
mod tests {
use super::slot_holds;
use super::{apply_music_suppression, slot_holds, ZoneAudio, CINEMATIC_MUSIC_RESUME_SECS};
use kira::sound::PlaybackState;
/// **The cinematic's music stop, both edges** — wow-re `cinematic-audio-law.md`, VERIFIED.
/// Down is a CUT: `0x7a5700` is stop-and-destroy and takes no duration argument, so there is
/// nothing to fade with — not the 4.0 s zone-change fade, not the 250 ms teardown declick.
/// Up is `[0x836400] = tick + 0xbb8` = **3.000 s**, which is a third number, neither the `-1`
/// immediate sentinel a zone change arms nor the randomized `SilenceInterval`.
#[test]
fn a_cinematic_cuts_the_music_and_brings_it_back_three_seconds_later() {
let mut zone = ZoneAudio {
zone_music: 42,
next_track_at: Some(100.0),
..ZoneAudio::default()
};
// Down: the pending track is dropped and nothing is scheduled — the pump is dead, not
// waiting.
apply_music_suppression(&mut zone, true, 10.0);
assert!(zone.music_suppressed);
assert_eq!(
zone.next_track_at, None,
"a suppressed pump must schedule nothing"
);
// Held: no edge, no rescheduling, however long the intro runs.
apply_music_suppression(&mut zone, true, 90.0);
assert_eq!(zone.next_track_at, None);
// Up: exactly +3.000 s from the release, and the zone row is still remembered so the
// resume plays THIS zone's track rather than re-resolving from nothing.
apply_music_suppression(&mut zone, false, 112.5);
assert!(!zone.music_suppressed);
assert_eq!(zone.next_track_at, Some(115.5));
assert_eq!(zone.zone_music, 42);
}
/// The resume is its own number and must not be confused with the two neighbours it sits
/// between — a zone change's immediate start, and the natural-end silence interval.
#[test]
fn the_resume_delay_is_the_references_own_third_number() {
assert!((CINEMATIC_MUSIC_RESUME_SECS - 3.0).abs() < f64::EPSILON);
}
/// The repeat-push guard. The server keeps an event track looping by re-pushing its id every
/// few seconds (the Darkmoon Faire emitter: `SMSG_PLAY_MUSIC` 8440 every 5 s), so a push for
/// the track *currently playing* must do nothing — without this the faire stacked a new
+20 -12
View File
@@ -2017,8 +2017,8 @@ fn both_dock_windows_carry_the_same_chrome() {
);
}
/// **The two dock windows must come from ONE declaration.** The structural guard that ends the
/// class (decision 1588).
/// **All SEVEN chat windows must come from ONE declaration.** The structural guard that ends the
/// class (decision 1588), widened to the whole set by 1712.
///
/// Three rounds of the same bug: window 2 shipped with no tab (1575), then with no rect and no
/// border art (1579), then with no `OnUpdate` and no scroll column (1588) — each time because it
@@ -2026,11 +2026,18 @@ fn both_dock_windows_carry_the_same_chrome() {
/// tests each catch ONE missed part after someone notices it; this catches the mirroring itself,
/// which is the only check that can fire before a part is missing.
///
/// So: both windows inherit the same virtual template (the reference's own shape —
/// `FloatingChatFrameTemplate`), and neither declares any content of its own beyond its seat. A
/// `<Layers>`, `<Frames>` or `<Scripts>` block on either instance is the defect, whatever is in it.
/// **Windows 3..7 were the fourth round of it**, and the longest-lived: they were not mirrored
/// partially, they were declared bare — no tab, no background, no border art, no scroll column —
/// on the argument that nothing reads a hidden, undocked window's furniture. pfUI reads all of it,
/// for all seven, unguarded (`chat.lua:588`, `chat.lua:321-419`). This test covered exactly the two
/// windows the argument had already exempted, so it could not fire; it now covers the set the
/// reference declares.
///
/// So: every window inherits the same virtual template (the reference's own shape —
/// `FloatingChatFrameTemplate`), and none declares any content of its own beyond its seat. A
/// `<Layers>`, `<Frames>` or `<Scripts>` block on any instance is the defect, whatever is in it.
#[test]
fn the_two_dock_windows_are_one_declaration() {
fn the_seven_chat_windows_are_one_declaration() {
use benilla_ui::framexml::TopLevel;
let xml = std::fs::read_to_string(
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/ui/ChatFrame.xml"),
@@ -2046,27 +2053,28 @@ fn the_two_dock_windows_are_one_declaration() {
let Some(name) = el.attr("name") else {
continue;
};
if name != "ChatFrame1" && name != "ChatFrame2" {
if !(1..=7).any(|i| name == format!("ChatFrame{i}")) {
continue;
}
seen += 1;
assert_eq!(
el.attr("inherits"),
Some("BenillaDockedChatFrameTemplate"),
"{name} must inherit the dock template, not restate it"
Some("BenillaChatFrameTemplate"),
"{name} must inherit the window template, not restate it"
);
for child in &el.children {
assert!(
matches!(child.tag.as_str(), "Size" | "Anchors"),
"{name} declares its own <{}> — that is the mirroring this test exists to stop; \
it belongs in BenillaDockedChatFrameTemplate where BOTH windows get it",
it belongs in BenillaChatFrameTemplate where EVERY window gets it",
child.tag
);
}
}
assert_eq!(
seen, 2,
"both dock windows must be declared in ChatFrame.xml"
seen,
7, // NUM_CHAT_WINDOWS — ChatFrame.lua:5
"all seven chat windows must be declared in ChatFrame.xml"
);
}
+20
View File
@@ -802,6 +802,7 @@ impl Loader<'_> {
self.apply_editbox(el, wrapper, dbg_name);
self.apply_messageframe(el, wrapper, dbg_name);
self.apply_simplehtml(el, wrapper, dbg_name);
self.apply_minimap(el, wrapper, dbg_name);
// 6 · <Scripts> handlers (rf24 `0x769ef0`); OnLoad is captured to fire bottom-up below.
let onload = self.apply_scripts(el, wrapper, dbg_name);
@@ -890,6 +891,25 @@ pub(super) fn children_named<'a>(
.filter(move |c| c.tag.eq_ignore_ascii_case(tag))
}
/// Iterate an element's direct children whose tag matches ANY of `tags` (case-insensitively), in
/// document order.
///
/// **Two spellings, one pass.** A 1.12 `<Button>` answers to two names for its label
/// (`<ButtonText>` and `<NormalText>`) and two for each of its three state fonts
/// (`<NormalFont>`/`<NormalText>`, `<HighlightFont>`/`<HighlightText>`,
/// `<DisabledFont>`/`<DisabledText>` — `CSimpleButton::LoadXML 0x7788c0`'s tag-compare chain,
/// wow-re `scratch/fontstring-loadxml-font-attrs.md` §7). Walking one spelling and then the other
/// would apply them in tag order instead of document order, and every one of these slots is
/// last-wins — so the alternatives have to be a single filtered walk, not two.
pub(super) fn children_named_any<'a>(
el: &'a Element,
tags: &'a [&'a str],
) -> impl Iterator<Item = &'a Element> {
el.children
.iter()
.filter(move |c| tags.iter().any(|t| c.tag.eq_ignore_ascii_case(t)))
}
/// Does this `text=` value LOOK like a GlobalStrings key? `SCREAMING_SNAKE`, two characters or more —
/// at least one letter, and nothing but `A-Z`, `0-9`, `_`. The reference's whole string table is
/// written this way (`CREATE_MACROS`, `DELETE`, `EXIT_GAME`), and benilla's own literals never are
+178
View File
@@ -15,6 +15,80 @@ mod loader_tests {
framexml::parse(text).expect("valid FrameXML")
}
/// **`<Minimap>`'s two model attributes name the nine engine children** — the model half of
/// `CMinimap::LoadXML 0x4ee2b0`. `minimapArrowModel` goes to children 1–8 through `0x4ee170`'s
/// two loops (the five at `[+0x320]`, the three POI arrows at `[+0x314]`);
/// `minimapPlayerModel` goes to `[Minimap+0x338]` — child **9** — alone, through `0x4ee260`.
/// Without the split all nine read back `GetModel() == ""` and nothing in the tuple says which
/// one is the player arrow.
///
/// Both halves in one document: the shipped spelling (`Minimap.xml:97` sets both explicitly)
/// and an attribute-less `<Minimap>`, which takes the engine defaults `0x84c768` / `0x8453c0`
/// rather than staying file-less.
#[test]
fn the_minimap_model_attributes_name_the_nine_engine_children() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Minimap name="Minimap"
minimapArrowModel="Interface\Minimap\Rotating-MinimapArrow.mdl"
minimapPlayerModel="Interface\Minimap\MinimapArrow.mdl">
<Size><AbsDimension x="140" y="140"/></Size>
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
<Frames>
<Frame name="MiniMapPingHost"/>
</Frames>
</Minimap>
<Minimap name="BareMinimap">
<Size><AbsDimension x="140" y="140"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Minimap>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// 1–8 wear the arrow, 9 wears the player model — the authored spelling, unchanged.
for i in 1..=8 {
assert_eq!(
s.eval::<String>(&format!(
"return ({{Minimap:GetChildren()}})[{i}]:GetModel()"
))
.unwrap(),
"Interface\\Minimap\\Rotating-MinimapArrow.mdl",
"engine child {i} takes minimapArrowModel"
);
}
assert_eq!(
s.eval::<String>("return ({Minimap:GetChildren()})[9]:GetModel()")
.unwrap(),
"Interface\\Minimap\\MinimapArrow.mdl",
"child 9 — [Minimap+0x338] — takes minimapPlayerModel alone"
);
// The `<Frames>` child lands PAST the nine, because the factory runs before the descent
// (`0x6ee408` before `0x6ee4ea`) and both linkers append at the tail. This is the whole
// reason `[9]` is a stable address for an addon.
assert_eq!(
s.eval::<String>("return ({Minimap:GetChildren()})[10]:GetName()")
.unwrap(),
"MiniMapPingHost"
);
// No attributes: the engine defaults, not nothing.
assert_eq!(
s.eval::<String>("return ({BareMinimap:GetChildren()})[1]:GetModel()")
.unwrap(),
crate::widget::MINIMAP_DEFAULT_ARROW_MODEL
);
assert_eq!(
s.eval::<String>("return ({BareMinimap:GetChildren()})[9]:GetModel()")
.unwrap(),
crate::widget::MINIMAP_DEFAULT_PLAYER_MODEL
);
}
/// **`parent="Name"` attaches a top-level element** — the reference's own way of doing it,
/// because its FrameXML is flat (decision 1211). 79 corpus addons, 708 sites.
///
@@ -1253,6 +1327,110 @@ mod loader_tests {
);
}
/// **`<NormalText>` is the Button's label under its second name** — the spelling the reference
/// FrameXML never writes and a third-party template reaches for constantly.
///
/// `CSimpleButton::LoadXML 0x7788c0` gives this tag strictly more than `<ButtonText>` does: it
/// constructs the label `CSimpleFontString` (`0x778b6c call 0x770d30`), clears its
/// `ownsFontAttrs` (`0x778b7b`, the one site image-wide that clears it) so its own `LoadXML`
/// skips the whole font-attribute surface, and feeds the SAME node to the button's persistent
/// Normal-state `CSimpleFont` at `+0x33c` (`0x778ba9`/`0x778baf call 0x783c30`). One owner per
/// attribute: the label pass reads the element's name and geometry, the state-font pass reads
/// its `inherits=`, and neither reads the other's. (wow-re
/// `scratch/fontstring-loadxml-font-attrs.md` §3/§7 and
/// `scratch/resize-bounds-and-button-fontstring.md` §5.3 — VERIFIED off the bytes.)
///
/// **Why this could sit broken indefinitely.** The reference writes `<ButtonText>` 31 times and
/// `<NormalText>` never, so our own FrameXML could not notice the tag was unhandled — and an
/// unhandled child element is silent: the button loads, paints, and simply has no label and no
/// global. KLHThreatMeter's column-header template is exactly this shape, and the addon died at
/// `PLAYER_LOGIN` indexing the `nil` global, six frames deep, naming nothing about XML.
///
/// The document is that template, reduced: a virtual `Button` carrying a named `<NormalText>`,
/// reached through `inherits=` (never directly), so this covers the template path the addon
/// actually takes.
#[test]
fn a_button_normal_text_is_the_label_names_it_and_fonts_it() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Font name="KtmYellow" font="Fonts\FRIZQT__.TTF" virtual="true">
<FontHeight><AbsValue val="10"/></FontHeight>
<Color r="0.9" g="0.8" b="0.1" a="1"/>
</Font>
<Button name="KtmHeaderTemplate" virtual="true">
<NormalText name="$parentText" inherits="KtmYellow" text="Threat">
<Size><AbsDimension x="70" y="14"/></Size>
<Anchors><Anchor point="LEFT"/></Anchors>
</NormalText>
</Button>
<Frame name="KtmSelf">
<Size><AbsDimension x="300" y="100"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Frames>
<Button name="$parentHeaderName" inherits="KtmHeaderTemplate">
<Size><AbsDimension x="70" y="14"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Button>
</Frames>
</Frame>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "{:?}", report.errors);
// 1 · THE GLOBAL. `$parent` resolved against the instantiated button and published in `_G`
// — this single read is the one KLHThreatMeter died on.
assert!(
s.eval::<bool>("return KtmSelfHeaderNameText ~= nil")
.unwrap(),
"a named <NormalText> must publish its global"
);
// 2 · ...and it is the BUTTON'S label, not a loose FontString that merely shares the name.
// Naming a region and publishing a global are two different things (the `<ButtonText>` path
// learned that the expensive way), so both directions are asserted.
assert_eq!(
s.eval::<String>("return KtmSelfHeaderNameText:GetName()")
.unwrap(),
"KtmSelfHeaderNameText"
);
assert_eq!(
s.eval::<String>("return KtmSelfHeaderName:GetFontString():GetName()")
.unwrap(),
"KtmSelfHeaderNameText",
"the <NormalText> region must BE the button's label"
);
// 3 · The element's own geometry landed on the label — the label pass's half.
assert_eq!(
s.eval::<f64>("return KtmSelfHeaderNameText:GetWidth()")
.unwrap(),
70.0
);
// 4 · ...and its `inherits=` landed on the button's NORMAL-STATE font, which is the half a
// plain `<ButtonText>` alias would have missed. Read off the painted quad rather than a
// getter: the state font is applied at extract, so what is on screen is the only reading
// that proves the link is live.
s.resolve();
let label = s
.extract()
.into_iter()
.find_map(|q| match q.content {
QuadContent::Text {
text: Some(t),
color,
..
} if t == "Threat" => Some(color),
_ => None,
})
.expect("the <NormalText> label must paint");
assert_eq!(
label,
Some([0.9, 0.8, 0.1, 1.0]),
"<NormalText inherits=> must reach the button's Normal-state font"
);
}
/// **`text=` is a GLOBAL-STRING LOOKUP, not a literal** (wow-re rf28 l.36/l.115 →
/// `FrameScript_GetText 0x703bf0`). Every arm of [`Loader::resolve_text`] in one document:
/// a `<Button text=>`, a `<ButtonText text=>` and a `<FontString text=>` all resolve through
+57 -7
View File
@@ -2,7 +2,9 @@ use mlua::{ObjectLike, Table};
use crate::framexml::{self, Element};
use super::{abs_dim, abs_value, children_named, color_of, tex_coords_of, Loader};
use super::{
abs_dim, abs_value, children_named, children_named_any, color_of, tex_coords_of, Loader,
};
impl Loader<'_> {
/// `<StatusBar>` LoadXML extras (RF-28, byte-verified table `0x782ef0`): `minValue`/`maxValue`
@@ -149,7 +151,27 @@ impl Loader<'_> {
// back to the owner — 14 of them in one window, each a sort arrow sitting in the wrong
// place. The real client is order-free here because it resolves anchors later; we are not,
// so we order it ourselves rather than making each author work around it.
for bt in children_named(el, "ButtonText") {
// **Both spellings of the label** (`ButtonText` | `NormalText`), in document order.
//
// `<NormalText>` is not a synonym bolted on here — it is the binary's own second name for
// this slot, and it does strictly more: `CSimpleButton::LoadXML 0x7788c0` constructs the
// label `CSimpleFontString` on the `<NormalText>` leg (`0x778b6c call 0x770d30`), clears
// its `ownsFontAttrs` (`0x778b7b` — the ONE site image-wide that clears it), hands it to
// the shared adopter `0x778d20`, and runs its `LoadXML 0x770f40` with the whole
// font-attribute surface skipped, because the SAME node is fed separately to the button's
// persistent Normal-state `CSimpleFont` at `+0x33c` (`0x778ba9`/`0x778baf call 0x783c30`).
// One owner per attribute, no double application — which is why this pass reads the
// element's geometry and name and the state-font pass below reads its `inherits=`/`font=`,
// and neither reads the other's. (wow-re `scratch/fontstring-loadxml-font-attrs.md` §3/§7,
// `scratch/resize-bounds-and-button-fontstring.md` §5.3 — all VERIFIED off the bytes.)
//
// The reference FrameXML writes only `<ButtonText>` (31 sites, 16 of them
// `name="$parentText"`) and never `<NormalText>`, so nothing we ship could notice the
// second spelling was missing. A third-party template is where it shows: KLHThreatMeter's
// `<Button name="KLHTM_SelfHeaderStringTemplate" virtual="true"><NormalText
// name="$parentText" …>` left `KLHTM_SelfFrameHeaderNameText` nil, and the addon died in
// its `PLAYER_LOGIN` handler on exactly that global.
for bt in children_named_any(el, &["ButtonText", "NormalText"]) {
// Create the label slot even with no text yet (the geometry below must land on a real
// region; SetText is the slot's lazy constructor), then apply the element's own
// `<Size>/<Anchors>`/justify to it — the ref anchors ButtonText all over (the quest
@@ -228,12 +250,12 @@ impl Loader<'_> {
// The per-state label fonts (`<NormalFont inherits=>` etc. — UIPanelButtonTemplate's
// gold/white/gray label trio) → the 1.12 setter trio; every occurrence applies in
// document order (same last-wins rule as `apply_size`).
for (child, method) in [
("NormalFont", "SetTextFontObject"),
("HighlightFont", "SetHighlightFontObject"),
("DisabledFont", "SetDisabledFontObject"),
for (children, method) in [
(["NormalFont", "NormalText"], "SetTextFontObject"),
(["HighlightFont", "HighlightText"], "SetHighlightFontObject"),
(["DisabledFont", "DisabledText"], "SetDisabledFontObject"),
] {
for f in children_named(el, child) {
for f in children_named_any(el, &children) {
// These three are `<Font>`-TYPED elements — `CSimpleButton::LoadXML 0x7788c0`
// routes them at `0x778bf4` into the SAME `0x783c30` a top-level `<Font>` uses — so
// they take `inherits=` and `font=` alike, and `font=` wins: both land in one slot
@@ -356,6 +378,34 @@ impl Loader<'_> {
/// `justifyH`** are what the frame's lines bake, stack and align at (read at extract,
/// `crate::script::UiScript::extract`) — that child is how `UIErrorsFrame.xml` centres its
/// toasts and how a chat frame keeps its lines flush left.
/// `<Minimap>`'s LoadXML extras (`CMinimap::LoadXML 0x4ee2b0`): the two model-file attributes,
/// which name the nine `Model` children the arena's ctor already built
/// (`crate::widget::MINIMAP_ENGINE_CHILDREN`). Both have engine defaults, so an attribute-less
/// `<Minimap>` still gets the stock arrows — the reference reads the default string when the
/// attribute is absent rather than leaving the children file-less.
///
/// Gated on the element's own tag like every other step here, so a plain `<Frame>` wearing a
/// Minimap template simply skips it.
pub(super) fn apply_minimap(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
if !el.tag.eq_ignore_ascii_case("Minimap") {
return;
}
let arrow = el
.attr("minimapArrowModel")
.filter(|s| !s.is_empty())
.unwrap_or(crate::widget::MINIMAP_DEFAULT_ARROW_MODEL);
let player = el
.attr("minimapPlayerModel")
.filter(|s| !s.is_empty())
.unwrap_or(crate::widget::MINIMAP_DEFAULT_PLAYER_MODEL);
if let Err(e) = crate::script::apply_minimap_model_attrs(self.lua, wrapper, arrow, player) {
self.warn_once(
"minimap:models",
format!("{dbg}: <Minimap> model attributes: {e}"),
);
}
}
pub(super) fn apply_messageframe(&mut self, el: &Element, wrapper: &Table, dbg: &str) {
let scrolling = el.tag.eq_ignore_ascii_case("ScrollingMessageFrame");
let plain = el.tag.eq_ignore_ascii_case("MessageFrame");
+45 -2
View File
@@ -5,8 +5,9 @@
//! constant 6, `set_zoom` clamps at 5 and marks the tile grid dirty, and the zoom index feeds the
//! `zoom_to_scale` tables (`0x6da9b0`). The engine core carries only the index
//! ([`MinimapState`]); the app renderer maps it to a world radius and draws the tiles
//! (decision 0203). The model attrs (`minimapPlayerModel=`/`minimapArrowModel=`) are not
//! modeled yet — deferred with phase 3's blips.
//! (decision 0203). The two model attrs (`minimapArrowModel=`/`minimapPlayerModel=`) are modeled
//! only as far as the Lua surface can see them: [`apply_model_attrs`] names the nine engine
//! children the ctor built, and the app still draws every arrow from its own art.
//!
//! **The ping** (decision 1596) is the same split, one rung further out: the two methods here are
//! pure seam — `PingLocation` parks a click and `GetPingPosition` reads back what the app
@@ -54,6 +55,48 @@ fn with_minimap<T>(
}
}
/// `CMinimap::LoadXML 0x4ee2b0`'s model half: assign the two `<Minimap>` model attributes to the
/// nine engine children the ctor already built. `minimapArrowModel` (engine default
/// [`crate::widget::MINIMAP_DEFAULT_ARROW_MODEL`]) goes to children **1–8** via `0x4ee170`'s two loops
/// (`0x4ee1b7` over the five at `+0x320`, `0x4ee204` over the three at `+0x314`);
/// `minimapPlayerModel` (default [`crate::widget::MINIMAP_DEFAULT_PLAYER_MODEL`]) goes to child **9 alone**, via
/// `0x4ee260`.
///
/// The split is what makes the nine *distinguishable* from Lua: without it `GetModel()` is `""` on
/// all nine and nothing in the tuple says which one is the player arrow.
///
/// Runs before the `<Frames>` descent, because `0x4ee2b0` does its own work and only then chains to
/// `CSimpleFrame::LoadXML 0x76a2f0`, which is what recurses into the children.
pub(crate) fn apply_model_attrs(
lua: &Lua,
this: &Table,
arrow: &str,
player: &str,
) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(children) = model.arena.frame(h).map(|f| f.children.clone()) else {
return Ok(());
};
for (i, child) in children
.into_iter()
.take(crate::widget::MINIMAP_ENGINE_CHILDREN)
.enumerate()
{
let path = if i + 1 == crate::widget::MINIMAP_ENGINE_CHILDREN {
player
} else {
arrow
};
if let Some(frame) = model.arena.frame_mut(child) {
if let KindState::Model(state) = &mut frame.kind_state {
state.path = Some(path.to_string());
}
}
}
Ok(())
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
+52 -9
View File
@@ -94,6 +94,7 @@ mod merchant;
mod messageframe;
mod minimap;
mod model;
mod modelframe;
mod net_stats;
mod object;
mod party;
@@ -192,6 +193,7 @@ pub use macros::{MacroState, MacroView, MAX_MACROS, MAX_MACRO_BODY, MAX_MACRO_NA
pub use mail::{MailInboxRow, MailInvoice, MailSendRequest, MailState};
pub use measure::TextMeasure;
pub use merchant::{ItemStatsHead, MerchantItem, MerchantState};
pub(crate) use minimap::apply_model_attrs as apply_minimap_model_attrs;
pub(crate) use model::Model;
pub use model::{TextureProbe, TextureSizeProbe};
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
@@ -605,6 +607,7 @@ impl UiScript {
slider::install(&lua)?;
colorselect::install(&lua)?;
minimap::install(&lua)?;
modelframe::install(&lua)?;
cooldown::install(&lua)?;
tooltip::install(&lua)?;
worldmap::install(&lua)?;
@@ -758,9 +761,51 @@ impl UiScript {
/// moved (a widget born after the last transition still gets told, without paying the arena
/// walk every frame).
pub fn set_minimap_inside(&mut self, inside: bool) {
for (_, frame) in self.model_mut().arena.iter_frames_mut() {
if let KindState::Minimap(m) = &mut frame.kind_state {
m.inside = inside;
self.for_each_minimap(|m| m.inside = inside);
}
/// Run `f` over every live Minimap widget's state. Goes through the arena's Minimap registry
/// rather than a full `iter_frames_mut`, for the reason that registry exists: a corpus UI is
/// three to four thousand frames and there is normally exactly one Minimap, and one of these
/// feeds ([`Self::set_minimap_player_facing`]) runs every frame.
fn for_each_minimap(&mut self, mut f: impl FnMut(&mut crate::widget::MinimapState)) {
let mut model = self.model_mut();
for h in model.arena.minimap_kinds().to_vec() {
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Minimap(m) = &mut frame.kind_state {
f(m);
}
}
}
}
/// Push the player's world facing (radians) onto every Minimap's **player-arrow** `Model` —
/// the client's `CMinimap::SetPlayerFacing 0x4eb8e0`, whose whole 34-byte body is
/// `[minimap+0x33c] = arg` then, guarded on non-null, `[[minimap+0x338]+0x39c] = arg`: a raw
/// dword copy into the same field `Model:SetFacing 0x76dce0` writes, with **no negation, offset
/// or unit change**. Its one caller (`0x4eb0a4`–`0x4eb0b1`) feeds it the player's `GetFacing()`
/// straight off the FPU stack, so what an addon reads back is the unit's orientation exactly.
///
/// This is what makes `({Minimap:GetChildren()})[9]:GetFacing()` — Questie's `GetPlayerFacing`,
/// and pfQuest's `compat/client.lua` — return the truth rather than the ctor default `0`, which
/// is the difference between a waypoint arrow that points at the waypoint and one aimed
/// permanently north. Called every frame by the app, which owns the player.
pub fn set_minimap_player_facing(&mut self, facing: f32) {
let mut model = self.model_mut();
let arrows: Vec<crate::widget::FrameHandle> = model
.arena
.minimap_kinds()
.iter()
.filter_map(|&h| match model.arena.frame(h).map(|f| &f.kind_state) {
Some(KindState::Minimap(m)) => m.player_arrow,
_ => None,
})
.collect();
for h in arrows {
if let Some(frame) = model.arena.frame_mut(h) {
if let KindState::Model(state) = &mut frame.kind_state {
state.facing = facing;
}
}
}
}
@@ -801,12 +846,10 @@ impl UiScript {
pub fn set_minimap_zoom(&mut self, zoom: u8, inside_zoom: u8) {
let top = crate::widget::MINIMAP_ZOOM_LEVELS - 1;
let (zoom, inside_zoom) = (zoom.min(top), inside_zoom.min(top));
for (_, frame) in self.model_mut().arena.iter_frames_mut() {
if let KindState::Minimap(m) = &mut frame.kind_state {
m.zoom = zoom;
m.inside_zoom = inside_zoom;
}
}
self.for_each_minimap(|m| {
m.zoom = zoom;
m.inside_zoom = inside_zoom;
});
}
/// Load and run a Lua chunk (text-only; the sandbox rejects bytecode). Errors propagate to the
+280
View File
@@ -0,0 +1,280 @@
//! The `Model` widget's method surface — the 3D pane an addon or a FrameXML frame parks a model in.
//!
//! **The same split as [`super::minimap`] and [`super::cooldown`], and for the same reason.** The
//! engine core holds exactly the scene the Lua API reads and writes ([`ModelState`]); the pixels
//! are the app renderer's job. That is the posture the `<Minimap>` and cooldown widgets already
//! run under — a sized hole the game layer draws into — and this widget is the third of that
//! shape, not a new compromise.
//!
//! ## Why this surface, in this order
//!
//! It is the wall four of the twenty most-installed 1.12 addons hit at once. pfUI's action-bar
//! module builds the pet bar's autocast shine as
//!
//! ```lua
//! f.autocast = CreateFrame("Model", nil, f)
//! f.autocast:SetModel("Interface\\Buttons\\UI-AutoCastButton.mdx")
//! f.autocast:SetSequence(0)
//! ```
//!
//! and pfUI is embedded in pfQuest, pfQuest-turtle and ShaguDPS as well — so one missing verb
//! stopped all four dead, each of them *after* the whole rest of the UI had built.
//!
//! ## The names are read off the binary, not assumed
//!
//! wow-re's registered-binding scan lists the **scene** half of this widget and not the rest —
//! `SetPosition 0x76dc00`, `SetLight 0x76e1e0`, `GetLight 0x76e7d0`, `GetPosition 0x76ea40`,
//! `SetFogColor 0x76ee60`, `GetFogColor 0x76f080`. It has no row for `SetModel` or its siblings,
//! which would ordinarily mean "not a 1.12 verb" and would make publishing one a fidelity error of
//! exactly the kind decision 1189 warns about (a name we have and the reference lacks routes an
//! addon down a path the real client never takes).
//!
//! So it was checked against the shipped image rather than inferred either way: an isolated-string
//! scan of `WoW.exe` finds **one occurrence each** of `SetModel`, `ClearModel`, `GetModel`,
//! `SetSequence`, `SetSequenceTime`, `SetRotation`, `SetFacing`, `SetModelScale`, `SetCamera`,
//! `SetUnit` and `RefreshUnit`. The reference's own FrameXML corroborates the ones it uses —
//! `SetRotation` ×10 (TabardFrame, UIParent), `SetSequence` ×7, `SetSequenceTime` ×5, `SetUnit`
//! ×9, `RefreshUnit` ×3. **These are 1.12 verbs; wow-re's scan is incomplete for this widget**,
//! and that is reported back to wow-re rather than quietly worked around here.
//!
//! ## What is deliberately NOT here
//!
//! `SetFogFar`/`SetFogNear` (nothing in the shipped chain or the corpus calls them) and any
//! interpretation of `SetLight`'s numbers — the engine core has no lighting model, so the tuple is
//! stored verbatim rather than typed into a scene semantics nobody has verified.
use mlua::{Lua, MultiValue, Table, Value};
use super::object::frame_handle_of;
use super::Model;
use crate::widget::{KindState, ModelState};
/// Registry key of the Model method table (the MAXCSTACK discipline: Lua-side root, named key).
pub(super) const REG_MODEL_METHODS: &str = "__benilla_model_methods";
/// Run `f` over a frame's Model state under one short write borrow. Errors if `this` is not a live
/// Model (unreachable through the kind dispatcher, but the method table is a plain Lua value — a
/// caller can fish it out and misapply it).
fn with_model<T>(lua: &Lua, this: &Table, f: impl FnOnce(&mut ModelState) -> T) -> mlua::Result<T> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match &mut frame.kind_state {
KindState::Model(m) => Ok(f(m)),
_ => Err(mlua::Error::runtime("not a Model")),
}
}
/// A Lua number-ish → f32, `nil` and non-numbers → 0.0.
///
/// The widget's setters are all coordinates and angles, and the corpus passes them through
/// arithmetic that can produce a nil (`C.bars[...].icon_size / 25` when the config key is absent).
/// The reference marshals through `lua_tonumber`, which is this.
fn num(v: &Value) -> f32 {
match v {
Value::Number(n) => *n as f32,
Value::Integer(i) => *i as f32,
Value::String(s) => s.to_str().ok().and_then(|t| t.parse().ok()).unwrap_or(0.0),
_ => 0.0,
}
}
/// Same, as an integer (sequence and camera indices).
fn int(v: &Value) -> i32 {
num(v) as i32
}
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let m = lua.create_table()?;
// ── Content: the model, the unit, and clearing ──────────────────────────────────────────
//
// `SetModel` and `SetUnit` are the two ways a pane gets content and they are alternatives, not
// layers: setting one clears the other, so `GetModel` after a `SetUnit` cannot answer a stale
// path from three frames ago. (The dress-up and paper-doll frames drive the `SetUnit` arm; every
// addon in the corpus drives the path arm.)
m.set(
"SetModel",
lua.create_function(|lua, (this, path): (Table, Value)| {
let path = match &path {
Value::String(s) => Some(s.to_str()?.to_string()),
// `SetModel(nil)` is the documented clear, and reaching it through the same setter
// is how the corpus writes "no model" — not everything calls ClearModel.
_ => None,
};
with_model(lua, &this, |m| {
m.path = path;
m.unit = None;
})
})?,
)?;
m.set(
"GetModel",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.path.clone()))?,
)?;
m.set(
"ClearModel",
lua.create_function(|lua, this: Table| {
with_model(lua, &this, |m| {
m.path = None;
m.unit = None;
m.sequence_time = None;
})
})?,
)?;
m.set(
"SetUnit",
lua.create_function(|lua, (this, unit): (Table, Value)| {
let unit = match &unit {
Value::String(s) => Some(s.to_str()?.to_string()),
_ => None,
};
with_model(lua, &this, |m| {
m.unit = unit;
m.path = None;
})
})?,
)?;
// `RefreshUnit()` re-reads the unit the pane is already showing — for us a no-op with a live
// receiver check, because our pane holds the unit TOKEN and resolves it at render, so there is
// no cached appearance here to invalidate. Present because the reference's own
// `DressUpFrame`/`PaperDollFrame` call it (3 sites) and an addon that hooks them will too.
m.set(
"RefreshUnit",
lua.create_function(|lua, this: Table| with_model(lua, &this, |_| ()))?,
)?;
// ── Animation ───────────────────────────────────────────────────────────────────────────
m.set(
"SetSequence",
lua.create_function(|lua, (this, seq): (Table, Value)| {
let seq = int(&seq);
with_model(lua, &this, |m| {
m.sequence = seq;
// A fresh sequence starts unscrubbed: `SetSequenceTime` is a scrub INTO the
// current sequence, so carrying the old pair across a change would park the new
// animation at a time that belongs to the previous one. The cooldown indicator
// drives exactly this pair every frame and is the reason to get it right.
m.sequence_time = None;
})
})?,
)?;
m.set(
"SetSequenceTime",
lua.create_function(|lua, (this, seq, ms): (Table, Value, Value)| {
let pair = (int(&seq), int(&ms));
with_model(lua, &this, |m| {
m.sequence = pair.0;
m.sequence_time = Some(pair);
})
})?,
)?;
// ── The pane's view: yaw, scale, camera, position ───────────────────────────────────────
//
// `SetRotation` and `SetFacing` are TWO NAMES FOR ONE SLOT. Both are in the binary; the shipped
// FrameXML drives the tabard and character panes with `SetRotation` (10 sites) and calls
// `SetFacing` nowhere, while addons reach for either. A pane with two independent yaws would be
// a bug nobody could see until a frame used both.
for name in ["SetRotation", "SetFacing"] {
m.set(
name,
lua.create_function(|lua, (this, rad): (Table, Value)| {
let rad = num(&rad);
with_model(lua, &this, |m| m.facing = rad)
})?,
)?;
}
m.set(
"GetFacing",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.facing))?,
)?;
m.set(
"SetModelScale",
lua.create_function(|lua, (this, s): (Table, Value)| {
let s = num(&s);
with_model(lua, &this, |m| m.scale = s)
})?,
)?;
m.set(
"GetModelScale",
lua.create_function(|lua, this: Table| with_model(lua, &this, |m| m.scale))?,
)?;
m.set(
"SetCamera",
lua.create_function(|lua, (this, c): (Table, Value)| {
let c = int(&c);
with_model(lua, &this, |m| m.camera = c)
})?,
)?;
m.set(
"SetPosition",
lua.create_function(|lua, (this, x, y, z): (Table, Value, Value, Value)| {
let p = (num(&x), num(&y), num(&z));
with_model(lua, &this, |m| m.position = p)
})?,
)?;
m.set(
"GetPosition",
lua.create_function(|lua, this: Table| {
let (x, y, z) = with_model(lua, &this, |m| m.position)?;
Ok((x, y, z))
})?,
)?;
// ── The scene: light and fog ────────────────────────────────────────────────────────────
//
// `SetLight`'s numbers are stored VERBATIM and handed back verbatim. The engine core has no
// lighting model, so typing this tuple would be asserting a scene semantics nobody has
// verified — and a wrong typing is worse than an opaque one, because it reads as knowledge.
m.set(
"SetLight",
lua.create_function(|lua, args: MultiValue| {
let mut it = args.into_iter();
let this = match it.next() {
Some(Value::Table(t)) => t,
_ => return Err(mlua::Error::runtime("expected a Model")),
};
let nums: Vec<f32> = it.map(|v| num(&v)).collect();
with_model(lua, &this, |m| m.light = Some(nums))
})?,
)?;
m.set(
"GetLight",
lua.create_function(|lua, this: Table| {
let light = with_model(lua, &this, |m| m.light.clone())?;
let out = light
.unwrap_or_default()
.into_iter()
.map(|n| Value::Number(f64::from(n)))
.collect::<Vec<_>>();
Ok(MultiValue::from_vec(out))
})?,
)?;
m.set(
"SetFogColor",
lua.create_function(|lua, (this, r, g, b): (Table, Value, Value, Value)| {
let c = (num(&r), num(&g), num(&b));
with_model(lua, &this, |m| m.fog_color = Some(c))
})?,
)?;
m.set(
"GetFogColor",
lua.create_function(|lua, this: Table| {
let c = with_model(lua, &this, |m| m.fog_color)?;
Ok(match c {
Some((r, g, b)) => MultiValue::from_vec(vec![
Value::Number(f64::from(r)),
Value::Number(f64::from(g)),
Value::Number(f64::from(b)),
]),
None => MultiValue::new(),
})
})?,
)?;
lua.set_named_registry_value(REG_MODEL_METHODS, m)
}
+4
View File
@@ -337,6 +337,10 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
super::button::REG_CHECKBUTTON_METHODS,
super::button::REG_BUTTON_METHODS,
],
// The Model pane's own surface. Its absence is what stopped pfUI — and, through pfUI's
// embedded copies, pfQuest, pfQuest-turtle and ShaguDPS — on `SetModel` after the whole
// rest of their UI had already built (`super::modelframe`'s header).
Some(FrameKind::Model) => &[super::modelframe::REG_MODEL_METHODS],
Some(FrameKind::Minimap) => &[super::minimap::REG_MINIMAP_METHODS],
Some(FrameKind::Cooldown) => &[super::cooldown::REG_COOLDOWN_METHODS],
Some(FrameKind::GameTooltip) => &[super::tooltip::REG_TOOLTIP_METHODS],
@@ -198,6 +198,101 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
}
})?,
)?;
// ── The four structure queries: GetChildren / GetNumChildren / GetRegions / GetNumRegions ──
//
// Registered bindings in 1.12 (`GetNumRegions 0x773e60`, `GetRegions 0x773f60`,
// `GetNumChildren 0x774080`, `GetChildren 0x774180` — wow-re `ui/ledger.tsv`, classified
// ORCHESTRATION: each marshals and delegates, with no fidelity math of its own).
//
// **Why these four are worth more than their size.** They are how an addon walks a frame it did
// not build, which is the whole basis of the "reskin the default UI" genre — and that genre is
// the top of the 1.12 popularity list. `pfUI.api.StripTextures` is the canonical shape:
//
// for _, v in ipairs({ frame:GetRegions() }) do
// if v.SetTexture then ... v:SetTexture(nil) end
// end
//
// With `GetRegions` absent that is `attempt to call method 'GetRegions' (a nil value)`, raised
// out of a library file every one of pfUI, pfQuest, pfQuest-turtle and ShaguDPS embeds — four
// separate top-20 addons dying on one missing verb, plus Questie on `GetChildren`.
//
// **Two lists, one walk each, in the arena's own order.** `Frame::children` is insertion order,
// which our own arena documents as the client's `+0x300` child-list order; `Frame::regions` is
// creation order. Neither is re-sorted here — a structure query reports the structure, and the
// draw order is `crate::order`'s separate concern.
//
// **A detached region is not in the list.** `Region:SetParent(nil)` sets `Region::detached`
// rather than removing the entry, because the owner's `Vec` is what `WidgetArena::destroy`
// frees from — but the client's re-link virtual `0x77fd10` with a null parent genuinely
// **unlinks from the old parent's draw layer and region list** (wow-re
// `widget-api-batch-benilla.md` Q7, VERIFIED), so a detached region must be invisible to both
// region verbs. Our flag is a representation choice; the list these report is the client's.
//
// **The count and the list come from ONE function** ([`regions_of`] / [`children_of`]), never
// two walks with the same filter written twice. `GetNumRegions` disagreeing with
// `#{GetRegions()}` is a bug an addon would hit as an off-by-one deep inside a loop it did not
// write, and the only way to make it impossible is to have one of them BE the other.
//
// **Settled by a wow-re §5 quartet** (`ui/scratch/widget-list-bindings.md`), which confirmed two
// of these choices and corrected a third:
//
// · **The order is link order, oldest first, and no reversal.** `CSimpleFrame` embeds a punned
// `TSList` header whose ctor sets `tail = &header`, `head = (&header)|1`, and BOTH linkers
// append at the TAIL (`0x76a750` for regions, `0x76aa20` for children — one caller each).
// Our insertion-order `Vec` is the same order. (wow-re's own `draw-order-law.md` said
// "tail→head"; that was a direction mislabel and was corrected in the same round.)
// · **Hidden children and regions are returned and counted** — a VERIFIED negative: the
// counting loops never load the visibility word at all.
// · **The TITLE REGION IS NOT RETURNED**, and that one corrected us. Both creation paths
// (`0x773910`, `0x769b79`) dispatch a vtable slot that is a bare `[this+0x9c] = parent` and
// never reach the linker `0x77fd10`, so a title region is not in the list this walks —
// corroborated by `Hide`/`Show` carrying an explicit extra `[frame+0xa8]` case *because* the
// list walk misses it. Ours is in `Frame::regions` (so the arena can free it), so
// [`regions_of`] filters it out.
// · **A Button's label (`+0x338`) and its four state textures ARE in the list** — they link
// through `0x77fd10` like any region, which is what ours do too.
// · **The return shape**: N separate values, never a table; a frame with none returns ZERO
// values (no `lua_newtable` in either body), and `GetNum*` returns one number — `0` when
// empty, never nil.
//
// The one thing still out of reach is not these bindings': Questie reads
// `({Minimap:GetChildren()})[9]` for the player arrow, and the §5 found why that index works —
// `CMinimap`'s ctor `0x4edbc0` builds NINE engine-owned `Model` children before the XML
// `<Frames>` descent, the ninth (`[Minimap+0x338]`) being the arrow `SetPlayerFacing 0x4eb8e0`
// writes. We create none of them, so index 9 is nil here until the minimap grows its engine
// children.
m.set(
"GetChildren",
lua.create_function(|lua, this: Table| {
let ids = children_of(lua, &this)?;
let mut out = Vec::with_capacity(ids.len());
for id in ids {
out.push(Value::Table(frame_wrapper(lua, id)?));
}
Ok(mlua::MultiValue::from_vec(out))
})?,
)?;
m.set(
"GetNumChildren",
lua.create_function(|lua, this: Table| Ok(children_of(lua, &this)?.len()))?,
)?;
m.set(
"GetRegions",
lua.create_function(|lua, this: Table| {
let ids = regions_of(lua, &this)?;
let mut out = Vec::with_capacity(ids.len());
for id in ids {
out.push(Value::Table(crate::script::region::region_wrapper(
lua, id,
)?));
}
Ok(mlua::MultiValue::from_vec(out))
})?,
)?;
m.set(
"GetNumRegions",
lua.create_function(|lua, this: Table| Ok(regions_of(lua, &this)?.len()))?,
)?;
// SetParent — the runtime reparent, per the byte-verified law (wow-re
// `ui/scratch/setparent-runtime-strata-level.md`; decision 1323). The binding half
// (`0x7a1550`): the parent argument is a frame table, a NAME string (`0x76c760`), or an
@@ -763,3 +858,52 @@ fn set_shown(lua: &Lua, this: &Table, shown: bool) -> mlua::Result<()> {
event::fire_visibility_changes(lua, changed);
Ok(())
}
/// This frame's child frames as stable ids, in the arena's insertion order — the one walk
/// `GetChildren` and `GetNumChildren` share (see their comment on why they must share it).
fn children_of(lua: &Lua, this: &Table) -> mlua::Result<Vec<u32>> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let Some(children) = model.arena.frame(h).map(|f| f.children.clone()) else {
return Ok(Vec::new());
};
Ok(children.into_iter().map(|c| model.frame_id(c)).collect())
}
/// This frame's live regions as stable ids, in creation order — **the title region and detached
/// ones excluded** — the one walk `GetRegions` and `GetNumRegions` share.
///
/// Both exclusions are the client's list, not tidiness, and both are VERIFIED (wow-re
/// `ui/scratch/widget-list-bindings.md`, §5 quartet):
///
/// - **Detached.** `SetParent(nil)` reaches `0x77fd55` → the removal virtual `0x76a7f0`, which
/// unlinks the node and frees it, and then skips every re-link. We keep the entry only so the
/// arena can still free the slot; reporting it would hand an addon an object the reference never
/// would, and `StripTextures`-shaped code would "strip" a region already off the screen.
/// - **The title region.** Its two creation paths (`0x773910`, `0x769b79`) dispatch a vtable slot
/// that is a bare `[this+0x9c] = parent` and never reach the linker at all, so it was never in
/// this list — corroborated by `Hide`/`Show` needing an explicit extra `[frame+0xa8]` case
/// *because* the walk misses it. Ours is in [`crate::widget::Frame::regions`] so that
/// `WidgetArena::destroy` still frees it, which makes that a representation detail this walk
/// must not leak.
///
/// A Button's label and its four state textures are deliberately NOT excluded: they link through
/// `0x77fd10` like any other region in the reference, and ours go through `create_region` like any
/// other region here.
fn regions_of(lua: &Lua, this: &Table) -> mlua::Result<Vec<u32>> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model");
let Some((regions, title)) = model
.arena
.frame(h)
.map(|f| (f.regions.clone(), f.title_region))
else {
return Ok(Vec::new());
};
let live: Vec<_> = regions
.into_iter()
.filter(|r| Some(*r) != title)
.filter(|r| model.arena.region(*r).is_some_and(|reg| !reg.detached))
.collect();
Ok(live.into_iter().map(|r| model.region_id(r)).collect())
}
+72 -1
View File
@@ -2,7 +2,7 @@
use super::common::script;
use crate::script::*;
use crate::widget::{MINIMAP_DEFAULT_ZOOM, MINIMAP_ZOOM_LEVELS};
use crate::widget::{MINIMAP_DEFAULT_ZOOM, MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS};
/// A `<Minimap>`-kind frame carries its zoom out through extraction as [`QuadContent::Minimap`]
/// at the frame's own draw slot, and the zoom API clamps like the client's `set_zoom` (0..=5).
@@ -189,3 +189,74 @@ fn zooming_without_a_registered_cvar_table_is_silent() {
"no warning for an engine write"
);
}
/// **The nine engine-created `Model` children, and why `[9]` is the player arrow.**
///
/// The `CMinimap` ctor `0x4edbc0` builds nine `CSimpleModel` children parented to the Minimap
/// before anything else touches the widget, in three source-ordered groups (`__LINE__` 1424 / 1438
/// / 1450 — wow-re `ui/scratch/widget-list-bindings.md` §5, VERIFIED), the last being
/// `[Minimap+0x338]`, the player arrow. Because both linkers append at the tail and the ctor runs
/// before the XML `<Frames>` descent, `({Minimap:GetChildren()})[9]` is that arrow on a stock
/// client — which is exactly what Questie's `QuestieArrow.lua` and pfQuest's `compat/client.lua`
/// index, unguarded, to read the player's heading.
///
/// This test is written the way those addons read it, not the way we store it.
#[test]
fn a_minimap_is_born_with_nine_model_children_and_the_ninth_is_the_player_arrow() {
let mut s = script();
s.run(r#"m = CreateFrame("Minimap", "TestMinimap")"#)
.unwrap();
assert_eq!(
s.eval::<usize>("return m:GetNumChildren()").unwrap(),
MINIMAP_ENGINE_CHILDREN,
"a fresh Minimap has the ctor's nine and nothing else"
);
assert_eq!(
s.eval::<String>("return ({m:GetChildren()})[9]:GetObjectType()")
.unwrap(),
"Model",
"all nine are Models — index 9 included"
);
assert!(s
.eval::<bool>(
"local n = 0 for _, c in ipairs({m:GetChildren()}) do \
if c:GetObjectType() == 'Model' then n = n + 1 end end return n == 9"
)
.unwrap());
// Questie's `GetPlayerFacing()`, verbatim. It is `0` until the app pushes, which is the
// client's own ctor default (`[frame+0x39c] = 0` at `0x76c92d`) — not nil, and not an error.
s.run("function GetPlayerFacing() return ({Minimap:GetChildren()})[9]:GetFacing() end")
.unwrap();
s.run(r#"Minimap = m"#).unwrap();
assert_eq!(s.eval::<f32>("return GetPlayerFacing()").unwrap(), 0.0);
// `SetPlayerFacing 0x4eb8e0` writes the argument into `[[minimap+0x338]+0x39c]` VERBATIM — no
// negation, no offset, no unit change (a `mov [ecx+0x39c],edx` of the dword it was handed).
s.set_minimap_player_facing(2.5);
assert_eq!(s.eval::<f32>("return GetPlayerFacing()").unwrap(), 2.5);
// It reaches the arrow through the Minimap's own slot, so an addon appending its own child
// cannot displace it: a later `CreateFrame(_, _, Minimap)` lands at index 10.
s.run(r#"extra = CreateFrame("Frame", nil, m)"#).unwrap();
s.set_minimap_player_facing(-1.25);
assert_eq!(s.eval::<f32>("return GetPlayerFacing()").unwrap(), -1.25);
assert_eq!(
s.eval::<usize>("return m:GetNumChildren()").unwrap(),
MINIMAP_ENGINE_CHILDREN + 1
);
assert_eq!(
s.eval::<String>("return ({m:GetChildren()})[10]:GetObjectType()")
.unwrap(),
"Frame"
);
// The ctor only *constructs* them; the model files are `CMinimap::LoadXML 0x4ee2b0`'s to
// assign, so a Lua-built Minimap with no XML behind it has nine file-less Models — exactly as
// the reference does.
assert!(s
.eval::<Option<String>>("return ({m:GetChildren()})[9]:GetModel()")
.unwrap()
.is_none_or(|p| p.is_empty()));
}
@@ -23,6 +23,7 @@ mod keyboard;
mod layout_gate;
mod measure;
mod minimap;
mod modelframe;
mod movable;
mod object_model;
mod reference_surface;
@@ -36,6 +37,7 @@ mod size_changed;
mod slider;
mod statusbar;
mod stdlib;
mod structure_queries;
mod talent;
mod taxi;
mod texcoord_font;
@@ -0,0 +1,179 @@
//! **The `Model` widget's Lua surface** — the pane's scene, read back through the API that wrote it.
//!
//! The property under test throughout is that this widget is *state the app renders*, not state the
//! engine interprets: every setter's value must come back out unchanged, and the two places where
//! the widget does have an opinion (the two names sharing one yaw slot, and content being an
//! either/or) must hold.
use super::common::script;
use crate::script::UiScript;
/// The whole scene, set and read back — plus the widget's three actual behaviours: `SetRotation`
/// and `SetFacing` are one slot, `SetModel` and `SetUnit` displace each other, and `ClearModel`
/// empties both.
#[test]
fn the_model_pane_holds_the_scene_it_was_given() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(r#"m = CreateFrame("Model", "MPane", UIParent)"#)
.unwrap();
// A fresh pane: no content, unit SCALE (not zero — a model at scale 0 is invisible, which is
// why ModelState hand-writes its Default), no yaw, at the origin.
assert_eq!(
s.eval::<(Option<String>, f64, f64)>(
"return MPane:GetModel(), MPane:GetModelScale(), MPane:GetFacing()"
)
.unwrap(),
(None, 1.0, 0.0),
"a fresh pane has no model and unit scale"
);
// The path round-trips verbatim — the client's own path space, backslashes and `.mdx` intact.
// pfUI's autocast shine is exactly this call.
s.run(r#"MPane:SetModel("Interface\\Buttons\\UI-AutoCastButton.mdx")"#)
.unwrap();
assert_eq!(
s.eval::<String>("return MPane:GetModel()").unwrap(),
r"Interface\Buttons\UI-AutoCastButton.mdx"
);
// ── ONE YAW SLOT, TWO NAMES. Both are in the binary; the shipped FrameXML drives the tabard
// and character panes with SetRotation and never calls SetFacing, while addons reach for
// either. Two independent yaws would be a bug invisible until one frame used both names.
s.run("MPane:SetRotation(1.5)").unwrap();
assert_eq!(s.eval::<f64>("return MPane:GetFacing()").unwrap(), 1.5);
s.run("MPane:SetFacing(-0.25)").unwrap();
assert_eq!(
s.eval::<f64>("return MPane:GetFacing()").unwrap(),
-0.25,
"SetFacing writes the same slot SetRotation does"
);
// Scale, camera, position — plain storage, read back through their own getters.
s.run("MPane:SetModelScale(0.4) MPane:SetCamera(2) MPane:SetPosition(0.1, -0.2, 3)")
.unwrap();
assert_eq!(
s.eval::<f64>("return MPane:GetModelScale()").unwrap(),
0.4_f32 as f64
);
let (x, y, z): (f64, f64, f64) = s.eval("return MPane:GetPosition()").unwrap();
assert_eq!(
(x as f32, y as f32, z as f32),
(0.1, -0.2, 3.0),
"GetPosition returns the three numbers SetPosition took"
);
// ── CONTENT IS AN EITHER/OR. SetUnit and SetModel are two ways to fill one pane, not layers:
// after a SetUnit, GetModel must not answer a path from three frames ago.
s.run(r#"MPane:SetUnit("player")"#).unwrap();
assert_eq!(
s.eval::<Option<String>>("return MPane:GetModel()").unwrap(),
None,
"SetUnit displaces the model path"
);
s.run(r#"MPane:SetModel("Interface\\Buttons\\Other.mdx")"#)
.unwrap();
// ...and back the other way, which is the direction an addon reskinning the dress-up pane takes.
assert_eq!(
s.eval::<String>("return MPane:GetModel()").unwrap(),
r"Interface\Buttons\Other.mdx"
);
// RefreshUnit is a live no-op: our pane stores the unit TOKEN and resolves it at render, so
// there is no cached appearance to invalidate. It must still exist — the reference's own
// DressUp/PaperDoll frames call it, so an addon hooking them will too.
assert!(s.run("MPane:RefreshUnit()").is_ok());
// ClearModel empties BOTH content slots, not just the one that was last set.
s.run("MPane:ClearModel()").unwrap();
assert_eq!(
s.eval::<Option<String>>("return MPane:GetModel()").unwrap(),
None
);
}
/// **`SetSequenceTime` is a scrub INTO the current sequence**, so changing the sequence drops it.
///
/// The cooldown indicator drives this pair every frame — `SetSequence(n)` then
/// `SetSequenceTime(n, ms)` — and carrying a stale scrub across a sequence change would park the
/// new animation at a time belonging to the previous one.
#[test]
fn a_sequence_change_drops_the_scrub() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
m = CreateFrame("Model", "MSeq", UIParent)
m:SetModel("Interface\\Buttons\\UI-AutoCastButton.mdx")
m:SetSequence(0)
m:SetSequenceTime(0, 250)
"#,
)
.unwrap();
// 1.12 has no `GetSequence`, so the scrub is read off the model the way `simplehtml`'s tests
// read their blocks — through the arena, because there is no Lua getter to read it through.
let scrub = |s: &UiScript| {
let lua = s.lua();
let model = lua.app_data_ref::<crate::script::Model>().expect("model");
let fh = model.arena.lookup("MSeq").expect("MSeq frame");
match &model.arena.frame(fh).expect("live frame").kind_state {
crate::widget::KindState::Model(m) => (m.sequence, m.sequence_time),
_ => panic!("MSeq is not a Model"),
}
};
assert_eq!(scrub(&s), (0, Some((0, 250))));
s.run("MSeq:SetSequence(3)").unwrap();
assert_eq!(
scrub(&s),
(3, None),
"a new sequence starts unscrubbed — the old (sequence, ms) pair is not carried across"
);
// ...and ClearModel drops the scrub with the content.
s.run("MSeq:SetSequenceTime(3, 40) MSeq:ClearModel()")
.unwrap();
assert_eq!(scrub(&s).1, None);
}
/// `SetLight`'s numbers are stored and returned **verbatim**, however many there are.
///
/// The engine core has no lighting model; typing this tuple would assert a scene semantics nobody
/// has verified, and a wrong typing is worse than an opaque one because it reads as knowledge. So
/// the contract is exactly "what went in comes out".
#[test]
fn the_light_tuple_is_opaque_and_survives_the_round_trip() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
m = CreateFrame("Model", "MLight", UIParent)
m:SetLight(1, 0, 0, -1, -1, 0.7, 1, 1, 1, 0.8, 1, 1, 1)
"#,
)
.unwrap();
assert_eq!(
s.eval::<usize>("return table.getn({ MLight:GetLight() })")
.unwrap(),
13,
"every number handed to SetLight comes back"
);
assert_eq!(
s.eval::<(f64, f64)>("local t = { MLight:GetLight() } return t[1], t[6]")
.unwrap(),
(1.0, 0.7_f32 as f64)
);
// Fog is the same shape, and unset fog returns NOTHING rather than three zeros — a pane with no
// fog and a pane fogged to black are different states.
assert_eq!(
s.eval::<usize>("return table.getn({ MLight:GetFogColor() })")
.unwrap(),
0
);
s.run("MLight:SetFogColor(0.1, 0.2, 0.3)").unwrap();
let (r, g, b): (f64, f64, f64) = s.eval("return MLight:GetFogColor()").unwrap();
assert_eq!((r as f32, g as f32, b as f32), (0.1, 0.2, 0.3));
}
@@ -0,0 +1,170 @@
//! **`GetChildren` / `GetNumChildren` / `GetRegions` / `GetNumRegions`** — the four structure
//! queries, and the properties an addon walking a frame it did not build depends on.
//!
//! The consumer that motivated them is `pfUI.api.StripTextures`, embedded in four separate top-20
//! addons (pfUI, pfQuest, pfQuest-turtle, ShaguDPS):
//!
//! ```lua
//! for _, v in ipairs({ frame:GetRegions() }) do
//! if v.SetTexture then ... end
//! end
//! ```
//!
//! so the shape under test is the one that idiom needs: **multiple return values**, in the
//! structure's own order, each a usable widget object.
//!
//! Every claim here is VERIFIED against the reference by a wow-re §5 quartet
//! (`ui/scratch/widget-list-bindings.md`): the lists are `[frame+0x300]` and `[frame+0x1b8]`, both
//! linkers APPEND AT THE TAIL so the values come back oldest-first with no reversal, hidden nodes
//! are returned and counted, a detached region and the title region are both absent, a Button's
//! label and state textures are present, and an empty frame returns zero values while `GetNum*`
//! returns the number `0`.
use super::common::script;
/// The whole contract in one document: both lists, their order, the counts agreeing with them, the
/// empty case, and — the one that is a behaviour rather than a plumbing detail — that a region
/// detached by `SetParent(nil)` leaves both region verbs.
#[test]
fn the_structure_queries_report_the_structure() {
let mut s = script();
s.set_screen_size(800.0, 600.0);
s.run(
r#"
host = CreateFrame("Frame", "SQHost", UIParent)
-- children, in creation order; one of them hidden, because a structure query is not a
-- visibility query and an addon reskinning a frame must still see what is hidden.
a = CreateFrame("Frame", "SQChildA", host)
b = CreateFrame("Button", "SQChildB", host)
b:Hide()
c = CreateFrame("Frame", "SQChildC", host)
-- regions, in creation order, across two draw layers: a BACKGROUND texture declared FIRST
-- and an OVERLAY fontstring declared second, so a draw-ordered walk and a creation-ordered
-- one are distinguishable.
t1 = host:CreateTexture("SQTexA", "BACKGROUND")
fs = host:CreateFontString("SQFontB", "OVERLAY")
t2 = host:CreateTexture("SQTexC", "BACKGROUND")
"#,
)
.unwrap();
// ── The lists, by name and IN ORDER. Named rather than counted, so a walk that returned the
// right number of the wrong things (or the right things reversed) cannot pass.
assert_eq!(
s.eval::<String>(
"local n = '' for _, v in ipairs({ SQHost:GetChildren() }) do \
n = n .. v:GetName() .. ' ' end return n"
)
.unwrap(),
"SQChildA SQChildB SQChildC ",
"children come back in the arena's insertion order — the client's +0x300 child-list order"
);
assert_eq!(
s.eval::<String>(
"local n = '' for _, v in ipairs({ SQHost:GetRegions() }) do \
n = n .. v:GetName() .. ' ' end return n"
)
.unwrap(),
"SQTexA SQFontB SQTexC ",
"regions come back in CREATION order, not draw order — the OVERLAY fontstring stays second"
);
// ── The counts are the same walk. A count that disagrees with the list is an off-by-one deep
// inside a loop the addon did not write, so the two verbs share one function by construction
// and this pins it.
assert_eq!(
s.eval::<(usize, usize)>(
"return SQHost:GetNumChildren(), table.getn({ SQHost:GetChildren() })"
)
.unwrap(),
(3, 3)
);
assert_eq!(
s.eval::<(usize, usize)>(
"return SQHost:GetNumRegions(), table.getn({ SQHost:GetRegions() })"
)
.unwrap(),
(3, 3)
);
// ── The objects are USABLE, not opaque handles: `StripTextures` feature-tests `v.SetTexture`
// to tell a texture from a fontstring, then calls it. Both halves have to hold.
assert_eq!(
s.eval::<String>(
"local out = '' for _, v in ipairs({ SQHost:GetRegions() }) do \
if v.SetTexture then v:SetTexture(nil) out = out .. 'T' else out = out .. 'F' end \
end return out"
)
.unwrap(),
"TFT",
"a texture answers SetTexture and a fontstring does not — the StripTextures branch"
);
// ── A childless, regionless frame yields NOTHING, and counts zero. `ipairs` over it must run
// zero times rather than once over a nil.
assert_eq!(
s.eval::<(usize, usize, usize)>(
"local e = CreateFrame(\"Frame\", \"SQEmpty\", UIParent) \
return e:GetNumChildren(), e:GetNumRegions(), table.getn({ e:GetRegions() })"
)
.unwrap(),
(0, 0, 0)
);
// ── DETACHED REGIONS LEAVE BOTH VERBS. `Region:SetParent(nil)` unlinks from the parent's draw
// layer AND its region list in the client (`0x77fd10`, wow-re `widget-api-batch-benilla.md`
// Q7). We keep the entry so the arena can still free the slot — a representation choice that
// must not be observable here, or a StripTextures-shaped walk would "strip" a region that is
// already off the screen.
s.run("SQFontB:SetParent(nil)").unwrap();
assert_eq!(
s.eval::<String>(
"local n = '' for _, v in ipairs({ SQHost:GetRegions() }) do \
n = n .. v:GetName() .. ' ' end return n"
)
.unwrap(),
"SQTexA SQTexC ",
"a detached region is gone from the list"
);
assert_eq!(
s.eval::<usize>("return SQHost:GetNumRegions()").unwrap(),
2,
"...and from the count, which is the same walk"
);
// ── THE TITLE REGION IS NOT IN THE LIST, and this is the assertion that corrected us. Its two
// creation paths in the client dispatch a vtable slot that is a bare `[this+0x9c] = parent`
// and never reach the region linker, so it was never in the list `GetRegions` walks —
// corroborated by `Hide`/`Show` carrying an explicit extra `[frame+0xa8]` case *because* the
// walk misses it (wow-re `ui/scratch/widget-list-bindings.md`, §5 quartet). Ours lives in
// `Frame::regions` so the arena can still free it, which makes that a representation detail
// this walk must not leak.
s.run("SQHost:CreateTitleRegion()").unwrap();
assert_eq!(
s.eval::<usize>("return SQHost:GetNumRegions()").unwrap(),
2,
"a title region does not join the region list"
);
assert_eq!(
s.eval::<String>(
"local n = '' for _, v in ipairs({ SQHost:GetRegions() }) do \
n = n .. v:GetName() .. ' ' end return n"
)
.unwrap(),
"SQTexA SQTexC ",
"...and does not appear in it"
);
// ── A child reparented away leaves its old parent's child list too (the frame twin of the
// above, and the case an addon hits when it re-hosts a default-UI frame).
s.run("SQChildB:SetParent(UIParent)").unwrap();
assert_eq!(
s.eval::<String>(
"local n = '' for _, v in ipairs({ SQHost:GetChildren() }) do \
n = n .. v:GetName() .. ' ' end return n"
)
.unwrap(),
"SQChildA SQChildC ",
"a reparented child is gone from its old parent's list"
);
}
+124
View File
@@ -123,6 +123,11 @@ pub enum KindState {
/// `<ColorWheelTexture>`/`<ColorValueTexture>` elements carry no `file=`), so this state is the
/// widget's whole modeled behavior here; see [`crate::script::colorselect`].
ColorSelect(ColorSelectState),
/// The `Model` widget's scene state — the 3D pane an addon or a FrameXML frame parks a model
/// in. **The same split as [`KindState::Minimap`] and [`KindState::Cooldown`]**: the engine
/// core carries exactly what the Lua API reads and writes, and the pixels are the app
/// renderer's job. See [`ModelState`].
Model(ModelState),
/// The `<Minimap>` widget's zoom state (decision 0203). The engine core carries only what the
/// Lua API reads/writes (`GetZoom`/`SetZoom`/`GetZoomLevels`); the tile/blip render is app-side.
Minimap(MinimapState),
@@ -289,6 +294,83 @@ impl CooldownState {
}
}
/// The `Model` widget's scene state (`0x6eecf0`'s frame type; the `<Model>` element).
///
/// **What this is and is not.** The 1.12 `Model` widget is a viewport that draws one M2 in a
/// little scene of its own — the character pane, the tabard designer, the minimap ping, the pet
/// bar's autocast shine, and every addon that wants a 3D thing in a frame. The engine core holds
/// exactly the scene an addon can read back or write; **the render is the app's**, the same
/// contract [`MinimapState`] and [`CooldownState`] already run under, and the reason both of those
/// exist as state-only kinds here.
///
/// **Every field is a 1.12 binding's storage, not a guess at one.** The names are in the shipped
/// binary as isolated strings (`SetModel`, `ClearModel`, `GetModel`, `SetSequence`,
/// `SetSequenceTime`, `SetRotation`, `SetFacing`, `SetModelScale`, `SetCamera`, `SetPosition`,
/// `SetLight`, `SetFogColor`, `SetUnit`, `RefreshUnit`), and wow-re's ledger already has the
/// scene half registered — `SetPosition 0x76dc00`, `SetLight 0x76e1e0`, `GetLight 0x76e7d0`,
/// `GetPosition 0x76ea40`, `SetFogColor 0x76ee60`, `GetFogColor 0x76f080`. (wow-re's
/// registration scan does *not* list the model/sequence half; the strings say otherwise, and that
/// gap is reported back rather than worked around.)
#[derive(Clone, Debug, PartialEq)]
pub struct ModelState {
/// The M2/MDX path last given to `SetModel`, or `None` after `ClearModel` / before any set.
/// Stored **as written** — the client's own path space is `Interface\...\Foo.mdx`, and the
/// app's loader is what maps `.mdx` to the `.m2` that actually ships.
pub path: Option<String>,
/// `SetUnit(unit)` — the *other* way a pane gets its content, used by the dress-up and
/// paper-doll frames. Mutually exclusive with [`Self::path`] in practice: whichever was set
/// last is what the pane shows, so each setter clears the other.
pub unit: Option<String>,
/// `SetSequence(n)` — the animation index the pane plays.
pub sequence: i32,
/// `SetSequenceTime(sequence, ms)` — the scrub point, as `(sequence, milliseconds)`. The
/// cooldown indicator's whole mechanism is this pair driven per frame, which is why it is
/// stored rather than folded into [`Self::sequence`].
pub sequence_time: Option<(i32, i32)>,
/// `SetRotation`/`SetFacing` — the model's yaw in radians. **Two names, one slot**: the
/// reference FrameXML drives the tabard and character panes with `SetRotation` (10 sites) and
/// nothing in the shipped chain calls `SetFacing`, but both are in the binary and both mean
/// the pane's yaw.
pub facing: f32,
/// `SetModelScale` — the model's own scale within the pane, default 1.
pub scale: f32,
/// `SetCamera(index)` — which of the model's baked camera setups to view it through.
pub camera: i32,
/// `SetPosition(x, y, z)` — the model's offset within the pane's scene.
pub position: (f32, f32, f32),
/// `SetLight(...)` — the scene's light, stored verbatim as the 14-number tuple the binding
/// takes (`enabled, omni, dirX, dirY, dirZ, ambIntensity, ambR, ambG, ambB, dirIntensity,
/// dirR, dirG, dirB` plus the leading `enabled`). Opaque here on purpose: the engine core has
/// no lighting model, and inventing a typed one would be asserting a scene semantics we have
/// not verified.
pub light: Option<Vec<f32>>,
/// `SetFogColor(r, g, b)` — `None` until set; the client also has a `SetFogFar`/`SetFogNear`
/// pair we do not model (nothing in the corpus or the shipped chain calls them).
pub fog_color: Option<(f32, f32, f32)>,
}
/// A fresh `Model` pane: no content, sequence 0, unrotated, unit scale, camera 0, at the origin.
///
/// `scale` is the one field whose zero value would be wrong — a model at scale 0 is invisible, and
/// the widget's documented default is 1 — so [`ModelState`] cannot use a derived `Default` for it
/// and this impl exists to say why.
impl Default for ModelState {
fn default() -> Self {
Self {
path: None,
unit: None,
sequence: 0,
sequence_time: None,
facing: 0.0,
scale: 1.0,
camera: 0,
position: (0.0, 0.0, 0.0),
light: None,
fog_color: None,
}
}
}
/// The Minimap widget's modeled state — the client keeps **two independent zoom indices**, chosen by
/// whether the player is inside a WMO (wow-re minimap node, `wmo-interior-minimap.md` finding 2 Q7
/// CORRECTION): the inside flag `0xceaa60` routes the outdoor index `0x86f698` (CVar `minimapZoom`,
@@ -310,6 +392,12 @@ pub struct MinimapState {
/// Is the player inside a WMO interior (the client's `0xceaa60`)? Selects which index the Lua
/// zoom API reads and writes. Pushed down by the app, which owns the WMO containment test.
pub inside: bool,
/// The **player-arrow** `Model` — the client's `[Minimap+0x338]`, ninth and last of the nine
/// engine children the ctor builds ([`MINIMAP_ENGINE_CHILDREN`]). An explicit slot because that
/// is the shape the client keeps: `CMinimap::SetPlayerFacing 0x4eb8e0` reaches the arrow through
/// this field, never by counting children; "it is also `GetChildren()[9]`" is a *consequence* of
/// the ctor's order, not the definition.
pub player_arrow: Option<FrameHandle>,
}
/// Both minimap zoom CVars register with the default `"3"` — **not** 0 (wow-re, VERIFIED at the
@@ -327,6 +415,9 @@ impl Default for MinimapState {
zoom: MINIMAP_DEFAULT_ZOOM,
inside_zoom: MINIMAP_DEFAULT_ZOOM,
inside: false,
// Filled by the arena the moment the widget is inserted — the nine are ctor-time, so a
// Minimap is never observably without them (`WidgetArena::create`).
player_arrow: None,
}
}
}
@@ -354,6 +445,39 @@ impl MinimapState {
/// The client's minimap zoom-level count (`get_zoom_levels` `0x6da9a0` returns the constant 6).
pub const MINIMAP_ZOOM_LEVELS: u8 = 6;
/// **Nine `Model` children, built by the `CMinimap` ctor before anything else sees the widget** —
/// wow-re `ui/scratch/widget-list-bindings.md` §5 / `ui/ui.md`, VERIFIED. The ctor `0x4edbc0`
/// (TU `Ui\MinimapFrame.cpp`) allocates `CSimpleModel` (`0x76c8e0`) nine times with the Minimap as
/// parent, in three source-ordered groups whose `__LINE__` arguments give the order directly:
///
/// | # | stored at | `__LINE__` | what it is |
/// |---|---|---|---|
/// | 1–5 | `[Minimap+0x320 + 4k]` | 1424 | the rim/party arrows (`minimapArrowModel`) |
/// | 6–8 | `[Minimap+0x314 + 4k]` | 1438 | the three POI direction arrows (`minimapArrowModel`) |
/// | 9 | `[Minimap+0x338]` | 1450 | **the player arrow** (`minimapPlayerModel`) |
///
/// **Why the engine core carries them at all**, when the app draws every one of these arrows itself
/// from its own art (`benilla-app`'s `minimap::blips`): because they are *structure the Lua API can
/// see*, and two of the most-installed 1.12 addons read it. `Questie`'s `QuestieArrow.lua`
/// `GetPlayerFacing()` is `({Minimap:GetChildren()})[9]:GetFacing()`, and pfQuest's
/// `compat/client.lua` does the same — the ctor runs before the XML `<Frames>` descent
/// (`0x6ee408` before `0x6ee4ea`) and both linkers append at the tail, so on a stock client the
/// tuple is these nine then `Minimap.xml`'s six, and index 9 is the arrow. Without them that read
/// is `nil` and the addon dies calling a method on it.
///
/// The count is fixed and unconditional: an unloadable model file only skips the *file* assignment
/// (`0x4ee286` returns early), never the child.
pub const MINIMAP_ENGINE_CHILDREN: usize = 9;
/// The engine default for `<Minimap minimapArrowModel=…>` — the string at `0x84c768`, applied by
/// `0x4ee170` to engine children 1–8. (`Minimap.xml:97` sets the attribute explicitly, in the `.mdl`
/// spelling; the loader rewrites the extension either way.)
pub const MINIMAP_DEFAULT_ARROW_MODEL: &str = "Interface\\Minimap\\Rotating-MinimapArrow.mdx";
/// The engine default for `<Minimap minimapPlayerModel=…>` — the string at `0x8453c0`, applied by
/// `0x4ee260` to engine child 9 **alone**.
pub const MINIMAP_DEFAULT_PLAYER_MODEL: &str = "Interface\\Minimap\\MinimapArrow.mdx";
/// A `CSimpleScrollFrame`'s runtime state: the frame whose anchors are overridden to track the
/// scroll offset ([`crate::script::UiScript::resolve`]'s scroll-child override), and the current
/// vertical scroll position. `SetVerticalScroll` clamps into `[0, GetVerticalScrollRange()]`, where
+41 -3
View File
@@ -175,9 +175,10 @@ mod kinds;
pub use kinds::{
slider_fraction, slider_grab, ButtonFont, ButtonState, ColorSelectState, CooldownState,
EditAction, EditBoxState, EditOutcome, EditUnit, FrameKind, InsertMode, KindState,
MessageFrameState, MessageLine, MinimapState, RegionKind, ScrollFrameState,
MessageFrameState, MessageLine, MinimapState, ModelState, RegionKind, ScrollFrameState,
ScrollingMessageState, SliderState, StatusBarState, TooltipState, COOLDOWN_FLASH_SECS,
MINIMAP_DEFAULT_ZOOM, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS,
MINIMAP_DEFAULT_ARROW_MODEL, MINIMAP_DEFAULT_PLAYER_MODEL, MINIMAP_DEFAULT_ZOOM,
MINIMAP_ENGINE_CHILDREN, MINIMAP_ZOOM_LEVELS, TOOLTIP_DOUBLE_GAP, TOOLTIP_FADE_SECS,
TOOLTIP_LINE_GAP, TOOLTIP_PAD, TOOLTIP_WRAP_WIDTH,
};
@@ -418,6 +419,13 @@ pub struct WidgetArena {
/// corpus UI, to reach the two or three that exist. Appended at creation (kinds never change);
/// `destroy` removes its own handle, so the list never carries dead entries.
tooltip_kinds: Vec<FrameHandle>,
/// Every live **Minimap**, same registry shape as [`Self::ticked_kinds`] and for the same
/// reason: three state feeds (containment, the two zoom indices, and the player-arrow facing)
/// all begin "find the Minimaps", and the facing is *per frame*. The ping's own lesson
/// (decision 1596) was that a per-widget push must not walk the ~3–4k-frame arena every frame
/// to reach the one widget that exists. Appended at creation (kinds never change); `destroy`
/// removes its own handle.
minimap_kinds: Vec<FrameHandle>,
}
impl Default for WidgetArena {
@@ -437,6 +445,7 @@ impl WidgetArena {
minimap_created: 0,
ticked_kinds: Vec::new(),
tooltip_kinds: Vec::new(),
minimap_kinds: Vec::new(),
}
}
@@ -455,6 +464,11 @@ impl WidgetArena {
&self.tooltip_kinds
}
/// The live Minimap frames (see the field note).
pub fn minimap_kinds(&self) -> &[FrameHandle] {
&self.minimap_kinds
}
// ── Read access ────────────────────────────────────────────────────────────────────────────
/// Borrow a frame, or `None` if the handle is stale/unknown.
@@ -645,6 +659,7 @@ impl WidgetArena {
FrameKind::ColorSelect => {
KindState::ColorSelect(kinds::ColorSelectState::default())
}
FrameKind::Model => KindState::Model(kinds::ModelState::default()),
FrameKind::Minimap => KindState::Minimap(kinds::MinimapState::default()),
FrameKind::Cooldown => KindState::Cooldown(kinds::CooldownState::default()),
FrameKind::GameTooltip => KindState::Tooltip(kinds::TooltipState::default()),
@@ -668,7 +683,6 @@ impl WidgetArena {
if matches!(kind, FrameKind::GameTooltip) {
self.tooltip_kinds.push(handle);
}
if let Some(p) = parent {
self.frame_mut(p)
.expect("live parent")
@@ -679,9 +693,32 @@ impl WidgetArena {
// Non-overwriting: first writer wins.
self.names.entry(n).or_insert(handle);
}
if matches!(kind, FrameKind::Minimap) {
self.minimap_kinds.push(handle);
// The `CMinimap` ctor's own last act, so no observer ever sees a Minimap without its
// nine (see [`MINIMAP_ENGINE_CHILDREN`]). It sits here rather than in the loader
// because the client builds them in the *element factory* — `CreateFrame("Minimap")`
// from Lua gets them too, and gets them before its OnLoad runs.
self.build_minimap_engine_children(handle);
}
handle
}
/// Build the nine engine-owned `Model` children of a fresh Minimap and remember the ninth as
/// its player arrow. They are created **anonymous and file-less**: the ctor only constructs
/// them, and the model paths are `CMinimap::LoadXML 0x4ee2b0`'s to assign from the two
/// attributes (the loader's minimap pass) — which is why a `CreateFrame("Minimap")` with no XML
/// behind it has nine blank Models here, exactly as the reference does.
fn build_minimap_engine_children(&mut self, minimap: FrameHandle) {
let mut ninth = None;
for _ in 0..kinds::MINIMAP_ENGINE_CHILDREN {
ninth = Some(self.create(FrameKind::Model, None, Some(minimap)));
}
if let Some(KindState::Minimap(m)) = self.frame_mut(minimap).map(|f| &mut f.kind_state) {
m.player_arrow = ninth;
}
}
/// Destroy a frame and its whole subtree (child frames and all regions), unpublishing any names
/// it owns and detaching it from its parent's child list. Stale handles are ignored.
pub fn destroy(&mut self, h: FrameHandle) {
@@ -695,6 +732,7 @@ impl WidgetArena {
// Each (recursive) destroy removes its own handle — the tick registry stays dead-free.
self.ticked_kinds.retain(|&t| t != h);
self.tooltip_kinds.retain(|&t| t != h);
self.minimap_kinds.retain(|&t| t != h);
for c in children {
self.destroy(c);
@@ -342,6 +342,17 @@ impl ViewFocus {
}
}
/// **Is the stream following the body's own ground?** True only for an attached live avatar.
///
/// The question anyone asking "is the world under the *player* loaded?" actually means. While
/// the eye is detached — free-fly, a cinematic fly-by — residency describes wherever the
/// camera went, so a consumer that reads it as a fact about the body is reading someone else's
/// tile. `release_post_snap_hold` already learned that the hard way (decision 1336) and tests
/// the tile itself; this is the cheap form for a consumer that only needs the yes/no.
pub fn follows_body(&self) -> bool {
self.attached && self.body.is_some()
}
/// A live avatar whose eye has been **detached** (free-fly): the stream follows the camera,
/// the zone authority still follows the body.
pub fn detached(wow: [f32; 3], paced: bool) -> Self {