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