71 more key commands, and the quest windows wear the reference's names

- the binding registry goes 130 → 201 of the client's 228 live commands: a coverage gate makes registered ∪ recorded-absent exactly the 1.12 surface, each absence naming the Lua globals whose arrival refutes it — which is how 37 rows whose mechanism had already landed came home (the keyring, the pet book, the raid tab, the six action-bar pages, both vertical multibars) — and then four mechanisms bring 71 more: the five camera views with the reference's default table and its sixteen camera CVars read out of the binary (NextView/PrevView do NOT wrap), self-cast riding UseAction's third argument with autoSelfCast a real CVar at last, the friendly target cone / assist / last-hostile as one cycler with a side (the two sides use different deadness tests on purpose, and assist neither requires an assistable target nor swings), and REPLY2 — reply to the last person you told; the friendly cone also stops borrowing the enemy side's in-combat score bonus, which would have shoved a healer targeting you to the head of every cycle
- the key names close their gaps: NUMPADEQUALS, F13-F24 and BUTTON6+ were keys the binding window listed and the file saved that the dispatcher could never press — named both ways now, with the namer and the codec structurally unable to disagree again; platform= becomes the OS gate it really is (off a Mac build the iTunes-remote category never even exists), and the Mac 1.12 slice settles that its F13 IS print-screen while F14/F15 map to UNKNOWN on purpose and stay unbindable; and the last-enemy stamp gains the four conjuncts it shipped without — you resolve, are not dead, not a ghost, not mounted, and the new target is alive-or-feigning — so TargetLastEnemy can no longer land somewhere the real client's never would
- the quest windows wear the reference's names: ~40 of the quest window's globals were ours (the reference's name usually sitting in the comment beside them), its two six-item reward pools merge into the reference's single ten-item run with the update function ported line-for-line, the four panels and the quest log take REAL scroll frames (the thumb drags now), the gossip window gets its greeting panel, the two mail panes' inert painted-at-rest scrollbars become real ones, the Skills detail pane nests as the reference has it, and nine private constants take their real names; the one that broke a feature detect: our QuestLogCount — a name 1.12 doesn't publish — made pfUI conclude it was running on TBC and die in that branch
- an EditBox is born with its constructor's five regions (the text, three selection quads and the caret), so GetRegions answers the reference's own count and authored regions start at index 6 — and a bare <NormalTexture/> now builds a live blank texture, since the element is what creates the region, not its file=; OpacityFrame lands, its first customer having arrived; and the equip / auto-equip / use bind confirms complete the soulbind arc — three different predicates with no quality leg (borrowing the loot arm's would have silenced the confirm on every white bind-on-equip item), Accept re-issuing the original action with the reference's suppress flag because 1.12 ships no confirm packet, and the soulstone use going through the same gate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-08-31 20:16:49 +07:00
co-authored by Claude Fable 5
parent 542c2ac04b
commit 8ae42cc7ab
69 changed files with 6829 additions and 1479 deletions
@@ -105,6 +105,17 @@
-->
<Script><![CDATA[
-- ── Constants (ref-lua l.1-11) ──────────────────────────────────────────────────────────
-- EACH LINE'S COMMENT IS THE REFERENCE'S OWN NAME FOR THE CONSTANT, and the reference
-- publishes them as globals. Nine of this codebase's other `BENILLA_*` constants took their
-- reference names this session (NUM_STATS, INBOXITEMS_TO_DISPLAY, MERCHANT_ITEMS_PER_PAGE,
-- MAX_TRADE_ITEMS, …) because 1189 says a name the reference has is the name we publish —
-- and because pfUI reads `INBOXITEMS_TO_DISPLAY` straight out of `_G`.
--
-- THESE EIGHT DID NOT, and the reason is verification, not preference: `AuctionFrame.lua`
-- ships in `Blizzard_AuctionUI`, which is not in the extracted reference tree here, so the
-- names below rest on this file's own comments and nothing else. A rename is a claim about
-- the reference; the eight claims are unchecked, so they wait for the extraction rather than
-- being taken on trust (§4). No corpus caller reads any of them today.
BENILLA_BROWSE_ROWS = 8 -- NUM_BROWSE_TO_DISPLAY
BENILLA_BID_ROWS = 9 -- NUM_BIDS_TO_DISPLAY
BENILLA_AUCTIONS_ROWS = 9 -- NUM_AUCTIONS_TO_DISPLAY
@@ -237,8 +237,8 @@
-- verified this session), loaded before every window.
-- ── This window's own constants (ref-PaperDollFrame.lua:1-4, Benilla-prefixed) ────────────
BENILLA_NUM_STATS = 5 -- NUM_STATS
BENILLA_NUM_RESISTANCE_TYPES = 5 -- NUM_RESISTANCE_TYPES
NUM_STATS = 5 -- NUM_STATS
NUM_RESISTANCE_TYPES = 5 -- NUM_RESISTANCE_TYPES
ATTACK_POWER_MAGIC_NUMBER = 14 -- ATTACK_POWER_MAGIC_NUMBER
-- MagicResFrame1..5's ref `id=` attrs (ref-PaperDollFrame.xml:654-759) — arcane/fire/nature/
-- frost/shadow; Holy(1) never rides the paper doll (only 5 of the 6 schools show here).
@@ -541,7 +541,7 @@
-- ref PaperDollFrame_SetStats (l.131-174), including its per-refresh label SetText (l.140)
-- — the labels' one source of text (see PaperDollFrame_OnLoad's header comment).
function PaperDollFrame_SetStats()
for i = 1, BENILLA_NUM_STATS do
for i = 1, NUM_STATS do
local label = getglobal("CharacterStatFrame" .. i .. "Label")
local text = getglobal("CharacterStatFrame" .. i .. "StatText")
local frame = getglobal("CharacterStatFrame" .. i)
@@ -591,7 +591,7 @@
-- ref PaperDollFrame_SetResistances (l.176-228).
function PaperDollFrame_SetResistances()
for i = 1, BENILLA_NUM_RESISTANCE_TYPES do
for i = 1, NUM_RESISTANCE_TYPES do
local resistId = BENILLA_MAGICRES_IDS[i]
local text = getglobal("MagicResText" .. i)
local frame = getglobal("MagicResFrame" .. i)
+5 -5
View File
@@ -1938,11 +1938,11 @@
the ref's PanelTemplates_TabResize(5, tab) from FCF_DockUpdate). Hidden by alpha
(hover reveals).
ChatFrame2Tab ("Combat Log") is deliberately NOT built: the combat log renders nothing
real yet (window 2 gets its chat-cache groups from the router, but the combat-event →
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. ── -->
ChatFrame2Tab ("Combat Log") is built below; what is still missing is what goes IN it —
window 2 gets its chat-cache groups from the router, but the combat-event → line pipeline
does not exist, so the tab selects a window the combat log never writes to. That is also
why the four 1.12 COMBATLOG* bindings are recorded absent rather than registered
(`bindings::commands::ABSENT`). ── -->
<Button name="BenillaChatTabTemplate" virtual="true" parent="UIParent" frameStrata="LOW" alpha="0">
<Size><AbsDimension x="70" y="32"/></Size>
<Layers>
@@ -77,12 +77,18 @@
`ColorPickerCancelButton:Click()` — the reference's own idiom for this (its game-menu
chain cancel-clicks `OptionsFrameCancel` the same way). ESC therefore CANCELS, it does not
merely hide, which is the half people get wrong.
· **`OpacityFrame` (ref l.223-335) is not transcribed.** The second frame in the reference's
file: a standalone 80x180 opacity popup with its own slider and a fullscreen
click-off-to-close button, used by FloatingChatFrame's chat-window alpha. **Zero corpus
callers** and no benilla consumer, so it waits for its first customer (decision 1195). Its
globals are `OpacityFrame`, `OpacityFrameSlider`, `OpacityFrameCloseButton`, and
`OpacityFrame.opacityFunc`/`.saveOpacityFunc`.
· **`OpacityFrame` (ref l.223-335) IS transcribed now** — the second frame in the reference's
file: a standalone 80x180 opacity popup with its own vertical slider and a fullscreen
click-off-to-close button, used by FloatingChatFrame's chat-window alpha. This note used
to read "**zero corpus callers** and no benilla consumer, so it waits for its first
customer (decision 1195)". Its first customer arrived: pfUI skins it in two files —
`skins/blizzard/miscellaneous.lua` l.36-41 (`CreateBackdrop(OpacityFrame)`,
`SkinSlider(OpacityFrameSlider)`, then a re-anchor) and
`skins/blizzard/battlefield_minimap.lua` l.23-24 (re-anchors `OpacityFrame` off the
battlefield minimap). 1195's own bar is demand, so demand is what moves it (1719).
It is transcribed verbatim, `opacityFunc`/`saveOpacityFunc` hooks included — those are
the whole contract with a caller, and a popup that drops them would move a slider that
changes nothing.
· **The unguarded `ColorPickerFrame.func()` in Okay's OnClick (ref l.89) is KEPT unguarded.**
It is one of the few places quirk-for-quirk beats tidying: an addon that opened this window
without setting `func` errors in the real client too, and a guard here would swallow that
@@ -295,4 +301,90 @@
<TexCoords left="0.25" right="1.0" top="0" bottom="0.875"/>
</ColorValueThumbTexture>
</ColorSelect>
<!-- ═══ ref l.223-335, verbatim ═══ The opacity popup: a standalone toplevel the colour
picker's own file ships beside it, opened by whatever wants an alpha (FloatingChatFrame's
chat-window alpha in the reference; pfUI re-anchors it off the battlefield minimap). It is
NOT a child of ColorPickerFrame — `parent="UIParent"`, its own strata, its own show/hide.
The two hook fields are the contract: `opacityFunc` fires on every slider tick,
`saveOpacityFunc` once on the click-off close. Both are read UNGUARDED-if-set exactly as
the reference writes them (an `if ( ... ) then` around each), so a caller that sets neither
gets a slider that moves and does nothing — which is the reference's own behaviour, not a
gap.
`OpacityFrameCloseButton` is the reference's click-off catcher: declared inside this
frame's `<Frames>` but RE-PARENTED to UIParent by its own `parent=` attr, `setAllPoints`
over the whole screen, and dropped one frame level in OnLoad so it sits under the popup it
closes. `RegisterForClicks("LeftButtonDown","RightButtonDown")` — it fires on the press,
not the release, so a click anywhere outside dismisses immediately. -->
<Frame name="OpacityFrame" toplevel="true" parent="UIParent" movable="true" enableMouse="true" hidden="true">
<Size><AbsDimension x="80" y="180"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor></Anchors>
<Backdrop bgFile="Interface\DialogFrame\UI-DialogBox-Background" edgeFile="Interface\DialogFrame\UI-DialogBox-Border" tile="true">
<BackgroundInsets><AbsInset left="11" right="12" top="12" bottom="11"/></BackgroundInsets>
<TileSize><AbsValue val="32"/></TileSize>
<EdgeSize><AbsValue val="32"/></EdgeSize>
</Backdrop>
<Frames>
<Slider name="OpacityFrameSlider" orientation="VERTICAL" minValue="0" maxValue="1" valueStep="0.01" defaultValue="1">
<Size><AbsDimension x="16" y="128"/></Size>
<Anchors><Anchor point="TOP"><Offset><AbsDimension x="-10" y="-35"/></Offset></Anchor></Anchors>
<Backdrop bgFile="Interface\Buttons\UI-SliderBar-Background" edgeFile="Interface\Buttons\UI-SliderBar-Border" tile="true">
<EdgeSize><AbsValue val="8"/></EdgeSize>
<TileSize><AbsValue val="8"/></TileSize>
<BackgroundInsets><AbsInset left="3" right="3" top="6" bottom="6"/></BackgroundInsets>
</Backdrop>
<Layers>
<Layer level="ARTWORK">
<!-- The title hangs off the FRAME, not the slider — the ref anchors it
`relativeTo="OpacityFrame"` from inside the slider's own layers. -->
<FontString name="$parentText" inherits="GameFontNormalSmall" text="OPACITY">
<Anchors><Anchor point="TOP" relativeTo="OpacityFrame"><Offset><AbsDimension x="0" y="-15"/></Offset></Anchor></Anchors>
</FontString>
<!-- The two end labels are UNNAMED in the ref, and the literals are the
ref's own: "0%"/"100%" are written as text, not as global strings. -->
<FontString inherits="GameFontHighlightSmall" text="0%">
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="2" y="3"/></Offset></Anchor></Anchors>
</FontString>
<FontString inherits="GameFontHighlightSmall" text="100%">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-2" y="3"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Scripts>
<OnValueChanged>
if ( OpacityFrame.opacityFunc ) then
OpacityFrame.opacityFunc();
end
</OnValueChanged>
</Scripts>
<ThumbTexture file="Interface\Buttons\UI-SliderBar-Button-Vertical">
<Size><AbsDimension x="32" y="32"/></Size>
</ThumbTexture>
</Slider>
<Button name="OpacityFrameCloseButton" parent="UIParent" setAllPoints="true" hidden="true">
<Scripts>
<OnLoad>
this:SetFrameLevel(this:GetFrameLevel()-1);
this:RegisterForClicks("LeftButtonDown", "RightButtonDown");
</OnLoad>
<OnClick>
OpacityFrame:Hide();
if ( OpacityFrame.saveOpacityFunc ) then
OpacityFrame.saveOpacityFunc();
end
</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnShow>
OpacityFrameCloseButton:Show();
</OnShow>
<OnHide>
OpacityFrameCloseButton:Hide();
</OnHide>
</Scripts>
</Frame>
</Ui>
+196 -164
View File
@@ -24,7 +24,7 @@
over our OWN flatter seam (`benilla-ui` `script/gossip.rs`): the app already resolves the wire's
available/active split and delivers ONE ordered `GetNumGossipQuests()`/`GetGossipQuestInfo(i)`
list (quest rows first, in wire order), so `BenillaGossipFrame_Update` below runs a single loop
over the shared `GossipRow1..8` pool — quest rows fill from i=1, option rows fill after
over the shared `GossipTitleButton1..8` pool — quest rows fill from i=1, option rows fill after
them — instead of the ref's three separate counters; a row's `.kind` ("quest"/"option") plus its
`.itemIndex` (the row's 1-based position within ITS OWN list) is all `BenillaGossipRow_OnClick`
needs to call the right one of `SelectGossipQuest`/`SelectGossipOption` (ref's three intents
@@ -62,7 +62,7 @@
-->
<Script><![CDATA[
-- The shared row pool's cap (decision 0088 §3): quest rows fill from slot 1, option rows fill
-- after them, both drawing from the SAME GossipRow1..32 buttons (ref's single
-- after them, both drawing from the SAME GossipTitleButton1..32 buttons (ref's single
-- GossipTitleButton pool, l.24-29/63-128). This IS the reference's number
-- (`NUMGOSSIPBUTTONS = 32`, ref-GossipFrame.lua l.1); benilla ran a cap of 8 only because an
-- overflowing menu had nowhere to go — with the scroll frame below, it does.
@@ -73,7 +73,7 @@
-- quest window's per-panel lists.
BENILLA_GOSSIP_SCROLL_WIDGETS = (function()
local t = { "GossipGreetingText" }
for i = 1, BENILLA_GOSSIP_MAX do table.insert(t, "GossipRow" .. i) end
for i = 1, BENILLA_GOSSIP_MAX do table.insert(t, "GossipTitleButton" .. i) end
return t
end)()
@@ -141,7 +141,7 @@
-- (the ref grows its child with the 15px GossipSpacerFrame under the last row instead —
-- ref-GossipFrame.lua l.39-44; the shared kit's BENILLA_SCROLL_PAD is that breathing room).
function BenillaGossipFrame_SizeScrollChild()
BenillaScroll_ResizeChild("GossipGreetingScroll", BENILLA_GOSSIP_SCROLL_WIDGETS)
BenillaScroll_ResizeChild("GossipGreetingScrollFrame", BENILLA_GOSSIP_SCROLL_WIDGETS)
end
-- The convergence driver (the tab-fit / quest-panel idiom): while the menu is up, re-fit
@@ -151,7 +151,7 @@
-- so the scroll range sees their settled heights in the same pass.
function BenillaGossipFrame_OnUpdate()
for i = 1, BENILLA_GOSSIP_MAX do
local button = getglobal("GossipRow" .. i)
local button = getglobal("GossipTitleButton" .. i)
if button:IsShown() then
BenillaGossipRow_Resize(button)
end
@@ -178,7 +178,7 @@
local opts = { GetGossipOptions() }
local numOptions = #opts / 2
for i = 1, BENILLA_GOSSIP_MAX do
local button = getglobal("GossipRow" .. i)
local button = getglobal("GossipTitleButton" .. i)
if i <= numQuests then
-- Quest rows fill first (ref l.27-28: available then active, already ordered by
-- the app before it reaches GetGossipQuestInfo) — the row's OWN 1-based position
@@ -217,7 +217,7 @@
end
-- The ref's GossipFrameUpdate tail (ref-GossipFrame.lua l.46-48): a repaint always lands
-- back at the TOP of the menu, then re-ranges the bar off the new content.
getglobal("GossipGreetingScroll"):SetVerticalScroll(0)
getglobal("GossipGreetingScrollFrame"):SetVerticalScroll(0)
BenillaGossipFrame_SizeScrollChild()
end
@@ -234,7 +234,7 @@
-- The gossip NPC's name arrives as arg1 of GOSSIP_SHOW (the app resolves the NPC guid through
-- the NameCache); nil/empty until it lands → a blank banner (mirrors the merchant title).
function BenillaGossipFrame_SetTitle(name)
getglobal("GossipNpcNameText"):SetText(name or "")
getglobal("GossipFrameNpcNameText"):SetText(name or "")
end
function GossipFrame_OnEvent()
@@ -266,7 +266,59 @@
ref l.75/99; option → the per-type GossipIcon). The label is QuestFont, LEFT, at LEFT +20 (ref
ButtonText, l.112-124). The highlight is the real UI-QuestTitleHighlight ADD glow (ref l.111).
-->
<Button name="BenillaGossipRowTemplate" virtual="true">
<!-- ref GossipFrame.xml l.4-85. The gossip window's panel is a template of its own in the
reference, carrying the four QuestGreeting quadrant slabs AND the four `$parentMaterial*`
overlays. Ours had neither: the corner art sat directly on `GossipFrame` and there was no
panel at all, so the reference's own `GossipFrameGreetingPanel` and its four Material
regions did not exist. pfUI's `skins/blizzard/gossipquest.lua` walks
`_G[frameName..panel.."Panel"]` and then its four `Material*` children for the Gossip
window exactly as it does for Quest.
The materials are fileless and unshown for the same reason as QuestFrame's — Parchment is
the branch this client always takes (`QuestFrame_GetMaterial`), which hides all four. -->
<Frame name="GossipFramePanelTemplate" virtual="true">
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\QuestFrame\UI-QuestGreeting-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"/></Anchors>
</Texture>
</Layer>
<Layer level="BORDER">
<Texture name="$parentMaterialTopLeft" hidden="true">
<Size><AbsDimension x="239" y="241"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="21" y="-75"/></Offset></Anchor></Anchors>
</Texture>
<Texture name="$parentMaterialTopRight" hidden="true">
<Size><AbsDimension x="64" y="241"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentMaterialTopLeft" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture name="$parentMaterialBotLeft" hidden="true">
<Size><AbsDimension x="239" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentMaterialTopLeft" relativePoint="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture name="$parentMaterialBotRight" hidden="true">
<Size><AbsDimension x="64" y="128"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="$parentMaterialTopLeft" relativePoint="BOTTOMRIGHT"/></Anchors>
</Texture>
</Layer>
</Layers>
</Frame>
<Button name="GossipTitleButtonTemplate" virtual="true">
<Size><AbsDimension x="300" y="16"/></Size>
<Layers>
<Layer level="BACKGROUND">
@@ -311,36 +363,13 @@
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor>
</Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-TopRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-BotLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestGreeting-BotRight">
<Size><AbsDimension x="128" y="256"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"/></Anchors>
</Texture>
<!-- Ref GossipFrame.xml:191-202: gossip has no bottom-LEFT button, but the shared
UI-QuestGreeting-BotLeft art bakes the button recess in — the ref covers it
with this patch (the director's "bottom left hole"). -->
<Texture file="Interface\QuestFrame\UI-Quest-BotLeftPatch">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="22" y="68"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- NPC name banner (ref GossipFrameNpcNameText l.157-178): GameFontHighlight, centred
on the window at TOP -23. The app delivers the name as arg1 of GOSSIP_SHOW; blank
until it lands. CENTER + the single-point (~zero-width) rect keeps the name centred
regardless of length, exactly like the merchant's title. -->
<FontString name="GossipNpcNameText" inherits="GameFontHighlight" justifyH="CENTER">
<FontString name="GossipFrameNpcNameText" inherits="GameFontHighlight" justifyH="CENTER">
<Anchors>
<Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-23"/></Offset></Anchor>
</Anchors>
@@ -348,108 +377,145 @@
</Layer>
</Layers>
<Frames>
<!--
The greeting + row list live inside a REAL ScrollFrame — the reference's own structure
(ref-GossipFrame.xml l.223-436, extracted via mpqx this session), which benilla omitted
on the grounds that "our short greeting needs no scrolling". A gossip menu of wrapped
roleplay options is exactly the case that breaks: once each row grew to its text
(BenillaGossipRow_Resize), four long options ran off the parchment and out of the
window (the director's second screenshot). The ref's answer is this scroll frame, and
it is now ours: 300x334 at the window's TOPLEFT +(23,-81) (ref l.223-233) over a child
the same size (ref l.235-239), the greeting at the child's +(10,-10) (ref l.241-252),
and the row chain hanging off the greeting exactly as before (ref l.256-269).
Content taller than the frame is CLIPPED to it and panned (engine mechanism, decision
0112) instead of overflowing; BenillaGossipFrame_SizeScrollChild sizes the child to the
measured content each repaint, which re-ranges the bar through OnScrollRangeChanged.
The ref grows its child with a 15px GossipSpacerFrame under the last row (ref l.421-432,
re-anchored per update by GossipFrame.lua l.39-44); ours is the shared kit's
BENILLA_SCROLL_PAD, the same breathing room by a plainer mechanism.
-->
<ScrollFrame name="GossipGreetingScroll">
<Size><AbsDimension x="300" y="334"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="23" y="-81"/></Offset></Anchor></Anchors>
<Scripts>
<OnLoad>self:SetScrollChild(getglobal(self:GetName() .. "Child"))</OnLoad>
<OnMouseWheel>BenillaScroll_OnMouseWheel(this, arg1)</OnMouseWheel>
<OnVerticalScroll>BenillaScroll_SyncBar(this)</OnVerticalScroll>
<OnScrollRangeChanged>BenillaScroll_SyncBar(this)</OnScrollRangeChanged>
</Scripts>
<!-- ref l.188-204. The greeting PANEL — the parchment page the whole gossip window
is drawn on. It did not exist here: the corner art sat on `GossipFrame` itself and
the scroll hung straight off the window, so the reference's own
`GossipFrameGreetingPanel` (and the four Material regions it inherits) had no
counterpart. The art moved onto `GossipFramePanelTemplate` above with it, which is
where the reference keeps it. The BotLeftPatch stays a per-instance ARTWORK
texture, as the ref declares it here rather than on the template. -->
<Frame name="GossipFrameGreetingPanel" inherits="GossipFramePanelTemplate">
<Layers>
<Layer level="ARTWORK">
<!-- Gossip has no bottom-LEFT button, but the shared UI-QuestGreeting-BotLeft
art bakes the button recess in — the ref covers it with this patch
(the director's "bottom left hole"). -->
<Texture file="Interface\QuestFrame\UI-Quest-BotLeftPatch">
<Size><AbsDimension x="128" y="64"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="22" y="68"/></Offset></Anchor></Anchors>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The scrollbar rides the ScrollFrame as a child, the ref's UIPanelScrollFrameTemplate
shape (ref-UIPanelTemplates.xml l.166-181: $parentScrollBar, TOPLEFT->TOPRIGHT
+(6,-16) / BOTTOMLEFT->BOTTOMRIGHT +(6,16)) — so the bar's PARENT is the frame it
scrolls, with no name derivation. It wears the shared Slider bar (drag + arrows,
decision 0250); only OnValueChanged is overridden, off the faux kit's row-offset
handler onto the real-scroll one. Hidden until the content overflows (SyncBar). -->
<Slider name="$parentBar" inherits="BenillaScrollBarTemplate" hidden="true">
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="16"/></Offset></Anchor>
</Anchors>
<Scripts><OnValueChanged>BenillaScrollBar_OnValueChanged(self, arg1)</OnValueChanged></Scripts>
</Slider>
<Frame name="$parentChild">
<Size><AbsDimension x="300" y="334"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- Greeting body: the real GossipGreetingText (ref l.241-252) — QuestFont,
LEFT, 270 wide so the engine wraps it, at the child's +(10,-10). -->
<FontString name="GossipGreetingText" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="10" y="-10"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The shared row pool, now the reference's full NUMGOSSIPBUTTONS = 32
(ref-GossipFrame.lua l.1; ours capped at 8 only because an overflowing
menu had nowhere to go). Quest rows fill it first, option rows after
(decision 0088 sec.3). Row 1 hangs off the GREETING's BOTTOMLEFT +(-10,-20),
the rest off the previous row's BOTTOMLEFT (ref l.256-269) — one static
chain, so rows re-flow off each other's measured heights with no runtime
repositioning. -->
<Button name="GossipRow1" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipGreetingText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="-10" y="-20"/></Offset></Anchor></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow2" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow1" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow3" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow2" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow4" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow3" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow5" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow4" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow6" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow5" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow7" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow6" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow8" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow7" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow9" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow8" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow10" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow9" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow11" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow10" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow12" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow11" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow13" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow12" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow14" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow13" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow15" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow14" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow16" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow15" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow17" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow16" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow18" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow17" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow19" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow18" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow20" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow19" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow21" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow20" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow22" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow21" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow23" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow22" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow24" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow23" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow25" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow24" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow26" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow25" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow27" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow26" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow28" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow27" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow29" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow28" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow30" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow29" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow31" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow30" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipRow32" inherits="BenillaGossipRowTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipRow31" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
</Frames>
</Frame>
<Button name="GossipFrameGreetingGoodbyeButton">
<Size><AbsDimension x="78" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-39" y="73"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentText" inherits="GameFontNormal" justifyH="CENTER" text="Goodbye">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<!-- The button face occupies only the top-left 0.625×0.6875 of each file — the real
UIPanelButton*Texture templates slice it with exactly these TexCoords
(ref-UIPanelTemplates.xml l.3-17). Drawing the whole file (transparent padding
included) squashed the face into the scuffed blob the director's A/B caught. -->
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up">
<TexCoords left="0" right="0.625" top="0" bottom="0.6875"/>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down">
<TexCoords left="0" right="0.625" top="0" bottom="0.6875"/>
</PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD">
<TexCoords left="0" right="0.625" top="0" bottom="0.6875"/>
</HighlightTexture>
<Scripts>
<OnClick>BenillaGossipCloseButton_OnClick()</OnClick>
</Scripts>
</Button>
<!--
The greeting + row list live inside a REAL ScrollFrame — the reference's own structure
(ref-GossipFrame.xml l.223-436, extracted via mpqx this session), which benilla omitted
on the grounds that "our short greeting needs no scrolling". A gossip menu of wrapped
roleplay options is exactly the case that breaks: once each row grew to its text
(BenillaGossipRow_Resize), four long options ran off the parchment and out of the
window (the director's second screenshot). The ref's answer is this scroll frame, and
it is now ours: 300x334 at the window's TOPLEFT +(23,-81) (ref l.223-233) over a child
the same size (ref l.235-239), the greeting at the child's +(10,-10) (ref l.241-252),
and the row chain hanging off the greeting exactly as before (ref l.256-269).
Content taller than the frame is CLIPPED to it and panned (engine mechanism, decision
0112) instead of overflowing; BenillaGossipFrame_SizeScrollChild sizes the child to the
measured content each repaint, which re-ranges the bar through OnScrollRangeChanged.
The ref grows its child with a 15px GossipSpacerFrame under the last row (ref l.421-432,
re-anchored per update by GossipFrame.lua l.39-44); ours is the shared kit's
BENILLA_SCROLL_PAD, the same breathing room by a plainer mechanism.
-->
<ScrollFrame name="GossipGreetingScrollFrame" inherits="UIPanelScrollFrameTemplate">
<Size><AbsDimension x="300" y="334"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="23" y="-81"/></Offset></Anchor></Anchors>
<ScrollChild>
<Frame name="GossipGreetingScrollChildFrame">
<Size><AbsDimension x="300" y="334"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers>
<Layer level="ARTWORK">
<!-- Greeting body: the real GossipGreetingText (ref l.241-252) — QuestFont,
LEFT, 270 wide so the engine wraps it, at the child's +(10,-10). -->
<FontString name="GossipGreetingText" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="10" y="-10"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The shared row pool, now the reference's full NUMGOSSIPBUTTONS = 32
(ref-GossipFrame.lua l.1; ours capped at 8 only because an overflowing
menu had nowhere to go). Quest rows fill it first, option rows after
(decision 0088 sec.3). Row 1 hangs off the GREETING's BOTTOMLEFT +(-10,-20),
the rest off the previous row's BOTTOMLEFT (ref l.256-269) — one static
chain, so rows re-flow off each other's measured heights with no runtime
repositioning. -->
<Button name="GossipTitleButton1" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipGreetingText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="-10" y="-20"/></Offset></Anchor></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton2" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton1" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton3" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton2" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton4" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton3" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton5" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton4" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton6" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton5" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton7" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton6" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton8" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton7" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton9" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton8" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton10" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton9" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton11" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton10" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton12" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton11" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton13" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton12" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton14" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton13" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton15" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton14" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton16" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton15" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton17" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton16" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton18" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton17" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton19" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton18" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton20" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton19" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton21" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton20" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton22" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton21" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton23" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton22" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton24" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton23" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton25" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton24" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton26" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton25" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton27" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton26" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton28" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton27" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton29" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton28" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton30" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton29" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton31" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton30" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
<Button name="GossipTitleButton32" inherits="GossipTitleButtonTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="GossipTitleButton31" relativePoint="BOTTOMLEFT"/></Anchors><Scripts><OnLoad>BenillaGossipRow_OnLoad(self)</OnLoad></Scripts></Button>
</Frames>
</Frame>
</ScrollChild>
</ScrollFrame>
</Frames>
</ScrollFrame>
</Frame>
<!-- Close: the real GossipFrameCloseButton position (CENTER on the window's TOPRIGHT,
-42,-31; ref l.179-187), on the minimize-button art our close already used (the real
UIPanelCloseButton art rides the tooltip/dialog arc). -->
<Button name="GossipCloseButton">
<Button name="GossipFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-42" y="-31"/></Offset></Anchor>
@@ -469,40 +535,6 @@
(GlobalStrings GOODBYE = "Goodbye") is an OVERLAY FontString centred on the button (the
real ButtonText draws in the OVERLAY layer). Wired to the same close path as the close
button (CloseGossip → HideUIPanel), mirroring the ref's OnClick HideUIPanel(GossipFrame). -->
<Button name="GossipGoodbyeButton">
<Size><AbsDimension x="78" y="22"/></Size>
<Anchors>
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-39" y="73"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="OVERLAY">
<FontString name="$parentText" inherits="GameFontNormal" justifyH="CENTER" text="Goodbye">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"/>
</Anchors>
</FontString>
</Layer>
</Layers>
<!-- The button face occupies only the top-left 0.625×0.6875 of each file — the real
UIPanelButton*Texture templates slice it with exactly these TexCoords
(ref-UIPanelTemplates.xml l.3-17). Drawing the whole file (transparent padding
included) squashed the face into the scuffed blob the director's A/B caught. -->
<NormalFont inherits="GameFontNormal"/>
<HighlightFont inherits="GameFontHighlight"/>
<DisabledFont inherits="GameFontDisable"/>
<NormalTexture file="Interface\Buttons\UI-Panel-Button-Up">
<TexCoords left="0" right="0.625" top="0" bottom="0.6875"/>
</NormalTexture>
<PushedTexture file="Interface\Buttons\UI-Panel-Button-Down">
<TexCoords left="0" right="0.625" top="0" bottom="0.6875"/>
</PushedTexture>
<HighlightTexture file="Interface\Buttons\UI-Panel-Button-Highlight" alphaMode="ADD">
<TexCoords left="0" right="0.625" top="0" bottom="0.6875"/>
</HighlightTexture>
<Scripts>
<OnClick>BenillaGossipCloseButton_OnClick()</OnClick>
</Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>GossipFrame_OnLoad()</OnLoad>
+15 -57
View File
@@ -44,9 +44,9 @@
CMSG_MAIL_CREATE_TEXT_ITEM, once on this list, is implemented — ref l.1732-1757.)
-->
<Script><![CDATA[
BENILLA_INBOXITEMS_TO_DISPLAY = 7 -- INBOXITEMS_TO_DISPLAY (ref MailFrame.lua l.1)
BENILLA_MAX_COD_AMOUNT = 10000 -- MAX_COD_AMOUNT (ref l.6)
BENILLA_STATIONERY_PATH = "Interface\\Stationery\\" -- STATIONERY_PATH (ref l.5)
INBOXITEMS_TO_DISPLAY = 7 -- INBOXITEMS_TO_DISPLAY (ref MailFrame.lua l.1)
MAX_COD_AMOUNT = 10000 -- MAX_COD_AMOUNT (ref l.6)
STATIONERY_PATH = "Interface\\Stationery\\" -- STATIONERY_PATH (ref l.5)
-- The money entry uses the shared BenillaMoneyInput_* kit (MerchantFrame.xml, loaded first) —
-- the send tab's three numeric edit boxes are named SendMailMoneyGold/Silver/Copper.
@@ -158,8 +158,8 @@
-- every getglobal below reads MailItem<i>Button* / MailItem<i>Sender/Subject/ExpireTime. ─
function InboxFrame_Update()
local numItems = GetInboxNumItems()
local index = ((MailFrame.pageNum - 1) * BENILLA_INBOXITEMS_TO_DISPLAY) + 1
for i = 1, BENILLA_INBOXITEMS_TO_DISPLAY do
local index = ((MailFrame.pageNum - 1) * INBOXITEMS_TO_DISPLAY) + 1
for i = 1, INBOXITEMS_TO_DISPLAY do
if index <= numItems then
local packageIcon, stationeryIcon, sender, subject, money, CODAmount, daysLeft, hasItem, wasRead, x, y, z, isGM = GetInboxHeaderInfo(index)
local icon
@@ -230,7 +230,7 @@
-- Page arrows (ref l.188-198): back is dead on the first page, forward is dead once
-- this page reaches the end of the list. `lastOnPage` is the list index this page's
-- last row would carry if it were full.
local lastOnPage = MailFrame.pageNum * BENILLA_INBOXITEMS_TO_DISPLAY
local lastOnPage = MailFrame.pageNum * INBOXITEMS_TO_DISPLAY
if MailFrame.pageNum == 1 then InboxPrevPageButton:Disable() else InboxPrevPageButton:Enable() end
if lastOnPage < numItems then InboxNextPageButton:Enable() else InboxNextPageButton:Disable() end
-- InboxCurrentPage is declared in the ref XML (l.329) but NO reference Lua ever writes
@@ -396,8 +396,8 @@
-- nothing to sit on top of.
OpenMailBodyText:SetText(bodyText)
if texture then
OpenStationeryBackgroundLeft:SetTexture(BENILLA_STATIONERY_PATH..texture.."1")
OpenStationeryBackgroundRight:SetTexture(BENILLA_STATIONERY_PATH..texture.."2")
OpenStationeryBackgroundLeft:SetTexture(STATIONERY_PATH..texture.."1")
OpenStationeryBackgroundRight:SetTexture(STATIONERY_PATH..texture.."2")
end
local hasAttachments
-- The letter button — "make a permanent copy" (ref l.364-376): a takeable body not yet
@@ -607,7 +607,7 @@
if string.len(SendMailSubjectEditBox:GetText()) > 0 then checks = checks + 1 end
if SendMailCODButton:GetChecked() then
checksRequired = 3
if BenillaMoneyInput_GetCopper("SendMailMoney") > BENILLA_MAX_COD_AMOUNT * 10000 then
if BenillaMoneyInput_GetCopper("SendMailMoney") > MAX_COD_AMOUNT * 10000 then
SendMailErrorText:Show()
SendMailErrorCoin:Show()
else
@@ -946,7 +946,7 @@
<!-- ref SendMailScrollFrame l.544-661: the 296×257 scroll region at (21,-97) with
the stationery backdrop + scrollbar art; the body is a plain EditBox inside (no
live scrollbar — the render approximation). -->
<Frame name="SendMailScrollFrame">
<ScrollFrame name="SendMailScrollFrame" inherits="UIPanelScrollFrameTemplate">
<Size><AbsDimension x="296" y="257"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="21" y="-97"/></Offset></Anchor></Anchors>
<Layers>
@@ -972,30 +972,6 @@
<TexCoords left="0.515625" right="1.0" top="0" bottom="0.4140625"/>
</Texture>
</Layer>
<!-- The ref ScrollFrame inherits UIPanelScrollFrameTemplate: a live
Slider (TOPLEFT→TOPRIGHT 6,-16 / BOTTOMLEFT→BOTTOMRIGHT 6,16) with
16×16 up/down buttons capping it and a 16×16 knob (UIPanelTemplates
l.104-181). The body doesn't scroll here (the render approximation),
so the slider's RESTING look is quoted as inert art — both arrows in
their Disabled state, the knob parked at the slider top — filling
the recesses the trough art authors for them. -->
<Layer level="OVERLAY">
<Texture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Disabled">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</Texture>
<Texture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Disabled">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</Texture>
<Texture file="Interface\Buttons\UI-ScrollBar-Knob">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor></Anchors>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- ref SendMailBodyEditBox l.616-649: 270×200 at (20,-10), 500 letters. -->
@@ -1012,7 +988,7 @@
</Scripts>
</EditBox>
</Frames>
</Frame>
</ScrollFrame>
<!-- ref SendMailNameEditBox l.662-738: 122×20 at (105,-46) + the To label +
Common-Input-Border art. -->
<EditBox name="SendMailNameEditBox" letters="12" historyLines="0" autoFocus="false">
@@ -1363,8 +1339,8 @@
the Stationery.dbc default, decision 0544). The picker itself is the named
omission; the default parchment is not. -->
<OnShow>
StationeryBackgroundLeft:SetTexture(BENILLA_STATIONERY_PATH.."STATIONERYTEST1")
StationeryBackgroundRight:SetTexture(BENILLA_STATIONERY_PATH.."STATIONERYTEST2")
StationeryBackgroundLeft:SetTexture(STATIONERY_PATH.."STATIONERYTEST1")
StationeryBackgroundRight:SetTexture(STATIONERY_PATH.."STATIONERYTEST2")
SendMailNameEditBox:SetFocus()
SendMailRadioButton_OnClick(1)
</OnShow>
@@ -1492,7 +1468,7 @@
<!-- ref OpenMailScrollFrame l.1468-1731 (296×257 at 21,-97; stationery backdrop + scrollbar
art quoted, the body a plain FontString inside — the render approximation; the auction
INVOICE sub-frame l.1556-1727 is a named omission, dropped). -->
<Frame name="OpenMailScrollFrame">
<ScrollFrame name="OpenMailScrollFrame" inherits="UIPanelScrollFrameTemplate">
<Size><AbsDimension x="296" y="257"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="21" y="-97"/></Offset></Anchor></Anchors>
<Layers>
@@ -1507,24 +1483,6 @@
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-2" y="-2"/></Offset></Anchor></Anchors>
<TexCoords left="0.515625" right="1.0" top="0" bottom="0.4140625"/>
</Texture>
<!-- The ref's UIPanelScrollFrameTemplate slider at rest, quoted as inert
art (see the send tab's twin for the geometry cites): disabled up/down
arrows capping the slider span, the knob parked at its top. -->
<Texture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Disabled">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</Texture>
<Texture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Disabled">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</Texture>
<Texture file="Interface\Buttons\UI-ScrollBar-Knob">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="-16"/></Offset></Anchor></Anchors>
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</Texture>
</Layer>
<Layer level="BACKGROUND">
<Texture name="OpenStationeryBackgroundLeft">
@@ -1614,7 +1572,7 @@
</Frames>
</Frame>
</Frames>
</Frame>
</ScrollFrame>
<!-- ref OpenMailLetterButton l.1732-1757 / OpenMailPackageButton l.1758-1796 /
OpenMailMoneyButton l.1797-1835 (ItemButtonTemplate; inlined 37px icon + slot +
count). The row chains letter → package → money off the attachment caption;
@@ -32,8 +32,8 @@
opening one over the other REPLACES it at the left slot.
-->
<Script><![CDATA[
BENILLA_MERCHANT_MAX = 10 -- MERCHANT_ITEMS_PER_PAGE (ref MerchantFrame.lua l.1)
BENILLA_BUYBACK_MAX = 12 -- BUYBACK_ITEMS_PER_PAGE (ref l.2; rows 11/12 are buyback-only)
MERCHANT_ITEMS_PER_PAGE = 10 -- MERCHANT_ITEMS_PER_PAGE (ref MerchantFrame.lua l.1)
BUYBACK_ITEMS_PER_PAGE = 12 -- BUYBACK_ITEMS_PER_PAGE (ref l.2; rows 11/12 are buyback-only)
-- The coin display, quoted from the real SmallMoneyFrame (ref MoneyFrame.xml/lua): a copper
-- amount renders as a number + a 13px coin icon per NONZERO denomination, cut from the one
@@ -187,7 +187,7 @@
-- The merchant-list index a row button shows on the current page (ref index arithmetic,
-- MerchantFrame.lua l.74).
function BenillaMerchantFrame_ItemIndex(button)
return ((MerchantFrame.page or 1) - 1) * BENILLA_MERCHANT_MAX + button.merchantIndex
return ((MerchantFrame.page or 1) - 1) * MERCHANT_ITEMS_PER_PAGE + button.merchantIndex
end
function BenillaMerchantItem_OnClick(button, mouseButton)
@@ -330,9 +330,9 @@
local page = frame.page or 1
local numItems = GetMerchantNumItems()
for i = 1, BENILLA_MERCHANT_MAX do
for i = 1, MERCHANT_ITEMS_PER_PAGE do
local button = getglobal("MerchantItem" .. i)
local index = (page - 1) * BENILLA_MERCHANT_MAX + i
local index = (page - 1) * MERCHANT_ITEMS_PER_PAGE + i
if index <= numItems then
local name, texture, price, quantity, numAvailable, isUsable = GetMerchantItemInfo(index)
BenillaMerchantRow_Fill(button, name, texture, price, quantity, numAvailable == 0, isUsable)
@@ -413,10 +413,10 @@
local prevButton = getglobal("MerchantPrevPageButton")
local nextButton = getglobal("MerchantNextPageButton")
local pageText = getglobal("MerchantPageText")
if numItems > BENILLA_MERCHANT_MAX then
if numItems > MERCHANT_ITEMS_PER_PAGE then
pageText:SetText(string.format("Page %d", page)) -- PAGE_NUMBER
if page == 1 then prevButton:Disable() else prevButton:Enable() end
if page >= math.ceil(numItems / BENILLA_MERCHANT_MAX) then
if page >= math.ceil(numItems / MERCHANT_ITEMS_PER_PAGE) then
nextButton:Disable()
else
nextButton:Enable()
@@ -459,7 +459,7 @@
getglobal("MerchantItem9"):SetPoint("TOPLEFT", getglobal("MerchantItem7"), "BOTTOMLEFT", 0, -15)
local numBuyback = GetNumBuybackItems()
for i = 1, BENILLA_BUYBACK_MAX do
for i = 1, BUYBACK_ITEMS_PER_PAGE do
local button = getglobal("MerchantItem" .. i)
if i <= numBuyback then
local name, texture, price, quantity, numAvailable, isUsable = GetBuybackItemInfo(i)
@@ -86,7 +86,7 @@
PET_DIET_TEMPLATE = PET_DIET_TEMPLATE or "Diet: %s" -- :3037
NUM_PET_STATS = 5 -- (ref l.2)
BENILLA_NUM_PET_RESISTANCE_TYPES = 5 -- NUM_PET_RESISTANCE_TYPES (ref l.1)
NUM_PET_RESISTANCE_TYPES = 5 -- NUM_PET_RESISTANCE_TYPES (ref l.1)
-- ── The page ────────────────────────────────────────────────────────────────────────────
-- ref PetPaperDollFrame_OnLoad (l.4-29). The five labels live ONLY in the row template's
@@ -233,7 +233,7 @@
-- line at all) and colours the NUMBER off its own sign rather than off pos-vs-neg
-- magnitude — two real differences in the reference, kept rather than unified.
function PetPaperDollFrame_SetResistances()
for i = 1, BENILLA_NUM_PET_RESISTANCE_TYPES do
for i = 1, NUM_PET_RESISTANCE_TYPES do
local resistId = BENILLA_MAGICRES_IDS[i]
local text = getglobal("PetMagicResText" .. i)
local frame = getglobal("PetMagicResFrame" .. i)
File diff suppressed because it is too large Load Diff
+185 -242
View File
@@ -21,9 +21,11 @@
doc). `ToggleQuestLog()` (pin §8, `QuestLogFrame.lua:24-30`) is the show/hide entry point the
app's 'L' binding already calls (`ui_script/mod.rs`).
Faux-scroll (pin §2): 6 title rows (`QUESTS_DISPLAYED`), a Lua-side `questLogOffset` upvalue
standing in for the real `QuestLogListScrollFrame`/`FauxScrollFrame_Update` (no real
ScrollFrame/FauxScrollFrame engine exists yet) — `<OnMouseWheel>` adjusts it directly. The
Faux-scroll (pin §2): 6 title rows (`QUESTS_DISPLAYED`) under a REAL
`QuestLogListScrollFrame` (a `FauxScrollFrameTemplate` instance, ref l.291-307) driven by
`FauxScrollFrame_Update`/`_GetOffset`. This paragraph used to say the offset was "a Lua-side
`questLogOffset` upvalue standing in for [them] (no real ScrollFrame/FauxScrollFrame engine
exists yet)" — false since the faux kit landed; six other windows ride it. The
wheel handler lives on the window Frame itself: the engine bubbles a spin UP the parent chain
(`script/pointer.rs` `mouse_wheel`), so a spin over a title row (no handler of its own) rises to
this window and scrolls. (A bare `<Frame>` is not a mouse HIT target — only
@@ -34,10 +36,9 @@
now only gives the list's EMPTY tail a hit-target whose spin also bubbles here.
v1 scope/deviations from the pin (each cited again at its own widget below):
- The LIST page still has no real ScrollFrame — `questLogOffset`'s faux-scroll stands in
(this comment's own "Faux-scroll" paragraph above); real real-estate accounting there
would need a genuine header/collapse model this v1 doesn't ship. The DETAIL page now
DOES sit on a real ScrollFrame (`QuestLogDetailScroll`, decision 0112's mechanism)
- Both pages sit on real scroll frames now: the LIST on a `FauxScrollFrameTemplate`
(`QuestLogListScrollFrame`) and the DETAIL on a `UIPanelScrollFrameTemplate`
(`QuestLogDetailScrollFrame`, decision 0112's mechanism)
— a quest with many objectives/a long description/many rewards scrolls and clips at the
pane's own edge instead of running past the window's bottom (pin §3's doNotScroll
asymmetry is implemented — see `BenillaQuestLogDetail_Update`'s own comment).
@@ -69,11 +70,12 @@
-->
<Script><![CDATA[
QUESTS_DISPLAYED = 6 -- QUESTS_DISPLAYED (pin §2, QuestLogFrame.lua:1)
QUESTLOG_QUEST_HEIGHT = 16 -- the row pitch the faux kit scrolls by (ref l.4)
MAX_QUESTLOG_QUESTS = 20 -- MAX_QUESTLOG_QUESTS (pin §2, :6) — display cap only
MAX_OBJECTIVES = 10 -- MAX_OBJECTIVES (pin §3, QuestLogFrame.lua:3)
-- Reward/choice row cap: the SAME v1 cap `QuestFrame.xml`'s BENILLA_QUEST_MAX_ITEMS uses (the
-- ref's own MAX_NUM_ITEMS is 10, QuestFrame.lua:2 — we halve it there too).
BENILLA_QUESTLOG_MAX_ITEMS = 6
MAX_NUM_ITEMS = 6
-- MAX_WATCHABLE_QUESTS (pin §2/§5, QuestLogFrame.lua:8) — the SAME cap `quest_log.rs`'s
-- AddQuestWatch already enforces engine-side; this is just this file's own guard so the
-- right-click handler can skip the AddQuestWatch call (and its error) locally.
@@ -98,14 +100,14 @@
BENILLA_QUESTLOG_DETAIL_OBJ_ROW = 12 -- one objective line (Objective1's own comment)
BENILLA_QUESTLOG_DETAIL_OBJ_STEP = 2 -- gap between objective lines
BENILLA_QUESTLOG_DETAIL_BOTTOM_PAD = 10 -- v1: a little breathing room past the last row
-- The detail pane's minimal scrollbar (`BenillaQuestLogDetailScroll_SyncBar`'s own comment).
BENILLA_QUESTLOG_SCROLL_STEP = 20 -- px per wheel notch / arrow click
BENILLA_QUESTLOG_SCROLL_TRACK_H = 229 -- ScrollFrame height 261 - 16 (up) - 16 (down)
BENILLA_QUESTLOG_SCROLL_THUMB_H = 16
-- The faux-scroll offset (pin §2's FauxScrollFrame stand-in) — a plain chunk-local upvalue
-- every function below closes over, the same pattern `UiPanels.xml`'s `slots` table uses.
local questLogOffset = 0
-- The list's scroll offset lives on the REAL `QuestLogListScrollFrame` now (a
-- FauxScrollFrameTemplate instance, ref l.291-307), read with `FauxScrollFrame_GetOffset`.
-- What stood here was `local questLogOffset = 0`, a chunk-local upvalue standing in for it
-- "because no real ScrollFrame/FauxScrollFrame engine exists yet" — which stopped being
-- true when the faux kit landed (SkillFrame.xml, TrainerFrame.xml and four more ride it).
-- The stand-in also meant this window published no `QuestLogListScrollFrame` at all, which
-- pfUI's `skins/blizzard/questlog.lua` indexes at l.135-136, l.149-153, l.163-164 and l.181.
-- The detail pane's last-computed resize inputs (`BenillaQuestLogDetail_ResizeChild`'s own
-- comment on WHY a re-check is needed at all: `GetHeight()` on the auto-height FontStrings
@@ -164,7 +166,7 @@
-- ── The list page ────────────────────────────────────────────────────────────────────────
-- `mouseButton` is arg1 off RegisterForClicks("LeftButtonUp", "RightButtonUp")
-- (BenillaQuestLogTitleTemplate's OnLoad). The watch toggle is SHIFT+left-click, the ref's
-- (QuestLogTitleButtonTemplate's OnLoad). The watch toggle is SHIFT+left-click, the ref's
-- own fork (QuestLogFrame.lua:469-505) — the modifier plumbing this file's header once
-- named as the missing engine slice landed with the cursor arc (0216/0218 follow-up), so
-- the documented right-click substitute is retired.
@@ -275,8 +277,8 @@
-- pill's middle piece then shrinks to the text (ref sizes it every update). The >10
-- guard skips the first-ever paint, where the host hasn't measured the string yet
-- (GetWidth 0 would collapse the pill; the next update — and every one after — sizes it).
getglobal("QuestLogCount"):SetText(string.format("Quests: |cffffffff%d/%d|r", numQuests, MAX_QUESTLOG_QUESTS))
local countWidth = getglobal("QuestLogCount"):GetWidth()
getglobal("QuestLogQuestCount"):SetText(string.format("Quests: |cffffffff%d/%d|r", numQuests, MAX_QUESTLOG_QUESTS))
local countWidth = getglobal("QuestLogQuestCount"):GetWidth()
if countWidth and countWidth > 10 then
getglobal("QuestLogCountMiddle"):SetWidth(countWidth)
end
@@ -288,12 +290,16 @@
-- convention predates region Show/Hide (`region.rs` has the real pair now, and the
-- loader honours hidden= on regions); an empty string draws nothing, so it is equivalent
-- either way and the idiom stays for consistency with the sibling windows.
local abandonButton = getglobal("QuestLogAbandonButton")
-- ref QuestLogFrame.lua l.108-119, its own two arms: the empty page is a FRAME that
-- shows or hides, not a string that is written or blanked. (The blank-text convention
-- above still holds for this window's other text; it is the EmptyQuestLogFrame case
-- that the reference does differently, because its page carries art as well as a line.)
local abandonButton = getglobal("QuestLogFrameAbandonButton")
if numEntries == 0 then
getglobal("QuestLogEmptyText"):SetText("Your quest log is empty.")
getglobal("EmptyQuestLogFrame"):Show()
abandonButton:Disable()
else
getglobal("QuestLogEmptyText"):SetText("")
getglobal("EmptyQuestLogFrame"):Hide()
abandonButton:Enable()
end
@@ -306,11 +312,13 @@
-- button we wrongly lit would push a quest the party is then refused (decision 1733).
BenillaQuestLogPushButton_Update(numEntries)
-- Clamp the faux-scroll offset (pin §2's FauxScrollFrame_Update clamp).
local maxOffset = numEntries - QUESTS_DISPLAYED
if maxOffset < 0 then maxOffset = 0 end
if questLogOffset > maxOffset then questLogOffset = maxOffset end
if questLogOffset < 0 then questLogOffset = 0 end
-- ref QuestLogFrame.lua l.127, its own call: the kit owns the clamp, the bar range
-- and the arrows. (The ref passes QuestLogHighlightFrame + two offsets as the
-- highlight-repositioning tail; ours repositions its own highlight below, so those
-- trailing arguments stay off.)
local listScroll = getglobal("QuestLogListScrollFrame")
FauxScrollFrame_Update(listScroll, numEntries, QUESTS_DISPLAYED, QUESTLOG_QUEST_HEIGHT)
local questLogOffset = FauxScrollFrame_GetOffset(listScroll)
local selection = GetQuestLogSelection()
@@ -375,7 +383,7 @@
-- scrolls back to it. Ref l.337-339 verbatim: the glow rides at (5,0) on the row and is
-- vertex-colored to the row's own difficulty color (that green bar in the ref shot IS
-- this vertex color on the white ADD gradient).
local hl = getglobal("QuestLogSelectHighlight")
local hl = getglobal("QuestLogHighlightFrame")
if selection > questLogOffset and selection <= questLogOffset + QUESTS_DISPLAYED
and selection <= numEntries then
local selButton = getglobal("QuestLogTitle" .. (selection - questLogOffset))
@@ -396,20 +404,16 @@
function BenillaQuestLogFrame_OnMouseWheel(delta)
-- WoW wheel convention: +1 per notch up, -1 down (ChatFrame.xml's own handler quotes the
-- same convention). Up = earlier entries (offset toward 0); down = later entries.
if delta > 0 then
questLogOffset = questLogOffset - 1
else
questLogOffset = questLogOffset + 1
end
BenillaQuestLogList_Update()
-- same convention). Up = earlier entries (offset toward 0); down = later entries. The
-- shared kit's own wheel step, off the real frame — the same call SkillFrame.xml makes.
BenillaFauxScrollFrame_OnMouseWheel(getglobal("QuestLogListScrollFrame"), delta)
end
-- ── The detail page ──────────────────────────────────────────────────────────────────────
-- The detail pane's real ScrollFrame (decision 0112's mechanism, landed this session — this
-- window used to be a fixed layout that simply clipped past the window's bottom edge, this
-- file's header comment). `QuestLogDetailScroll`'s ScrollChild is
-- `QuestLogDetailChild`: every FontString/Button below that used to live flat on
-- file's header comment). `QuestLogDetailScrollFrame`'s ScrollChild is
-- `QuestLogDetailScrollChildFrame`: every FontString/Button below that used to live flat on
-- `QuestLogFrame` now lives on that child instead (the 0088 §2 flat-frame constraint
-- this file's header comment quoted DIED with the 0112 resolver fixpoint — child frames
-- render their own `<Layers>` regions again), so its subtree clips at the ScrollFrame's own
@@ -451,14 +455,14 @@
-- FontString's measured `GetHeight()` + `rewardSectionHeight` (BenillaQuestLogRewards_Update's
-- own return — its header-through-money layout has its own conditional branches, cheaper to
-- total right where those branches already live than to re-derive them here). Then
-- `UpdateScrollChildRect()` so the scrollbar (BenillaQuestLogDetailScroll_SyncBar) hears the
-- `UpdateScrollChildRect()` so the scrollbar (the template's OnScrollRangeChanged) hears the
-- new range.
function BenillaQuestLogDetail_ResizeChild(objAnchorIndex, rewardSectionHeight)
detailObjAnchorIndex = objAnchorIndex
detailRewardHeight = rewardSectionHeight
local title = getglobal("QuestLogQuestTitle")
local objectivesText = getglobal("QuestLogObjectivesText")
local description = getglobal("QuestLogDescription")
local description = getglobal("QuestLogQuestDescription")
local objSpan
if objAnchorIndex > 0 then
@@ -492,12 +496,12 @@
if height ~= detailLastAppliedHeight then
detailLastAppliedHeight = height
getglobal("QuestLogDetailChild"):SetHeight(height)
getglobal("QuestLogDetailScroll"):UpdateScrollChildRect()
getglobal("QuestLogDetailScrollChildFrame"):SetHeight(height)
getglobal("QuestLogDetailScrollFrame"):UpdateScrollChildRect()
end
end
-- The re-check `QuestLogDetailScroll`'s own `<OnUpdate>` calls every frame the log
-- The re-check `QuestLogDetailScrollFrame`'s own `<OnUpdate>` calls every frame the log
-- window is open (`UiScript::tick`'s OnUpdate gate gates it on effective-visibility, so this
-- costs nothing while the window is closed): re-sums the SAME inputs the last full populate
-- computed (`BenillaQuestLogDetail_ResizeChild`'s own header comment on why a single populate
@@ -527,45 +531,12 @@
end
end
-- Steps the detail ScrollFrame by `delta` px — the wheel handler and both scrollbar arrow
-- buttons share this one path; SetVerticalScroll's own clamp (benilla-ui's `scrollframe.rs`)
-- does the range-bounding, this function just supplies the signed step.
function BenillaQuestLogDetailScroll_Step(delta)
local sf = getglobal("QuestLogDetailScroll")
sf:SetVerticalScroll(sf:GetVerticalScroll() + delta)
end
-- Repaints the minimal scrollbar (this window's own scrollbar comment: VISUAL only, no
-- drag) from the CURRENT offset/range — wired to both OnVerticalScroll (the value changed)
-- and OnScrollRangeChanged (the content resized), the ref UIPanelScrollFrameTemplate's own
-- pair (UIPanelTemplates.xml). Hides the whole bar when nothing overflows (range 0); disables
-- either arrow at its own end, mirroring the ref's identical two Enable/Disable calls off a
-- real Slider's min/max (there's no Slider here — this window's own comment on why).
function BenillaQuestLogDetailScroll_SyncBar()
local sf = getglobal("QuestLogDetailScroll")
local value = sf:GetVerticalScroll()
local range = sf:GetVerticalScrollRange()
local bar = getglobal("QuestLogDetailScrollBar")
if range <= 0 then
bar:Hide()
return
end
bar:Show()
if value > 0 then
getglobal("QuestLogDetailScrollUp"):Enable()
else
getglobal("QuestLogDetailScrollUp"):Disable()
end
if value < range then
getglobal("QuestLogDetailScrollDown"):Enable()
else
getglobal("QuestLogDetailScrollDown"):Disable()
end
local travel = BENILLA_QUESTLOG_SCROLL_TRACK_H - BENILLA_QUESTLOG_SCROLL_THUMB_H
local frac = value / range
getglobal("QuestLogDetailScrollThumb"):SetPoint(
"TOPLEFT", getglobal("QuestLogDetailScrollTrack"), "TOPLEFT", 0, -(frac * travel))
end
-- The detail pane's scrollbar is the ref's own `UIPanelScrollFrameTemplate` Slider now
-- (ref l.276-289): the wheel, the arrows, the draggable thumb and the range bookkeeping all
-- come from the template. What stood here was `BenillaQuestLogDetailScroll_Step`/`_SyncBar`
-- over a hand-rolled, no-drag `QuestLogDetailScrollBar` — a private name pfUI's
-- `skins/blizzard/questlog.lua` l.199 falls through, since it skins
-- `QuestLogDetailScrollFrameScrollBar`.
function BenillaQuestLogDetail_Clear()
getglobal("QuestLogQuestTitle"):SetText("")
@@ -576,16 +547,15 @@
BenillaQuestLogHasTimer = nil
getglobal("QuestLogTimerText"):SetText("")
getglobal("QuestLogDescriptionTitle"):SetText("")
getglobal("QuestLogDescription"):SetText("")
getglobal("QuestLogQuestDescription"):SetText("")
for i = 1, MAX_OBJECTIVES do
getglobal("QuestLogObjective" .. i):SetText("")
end
getglobal("QuestLogRewardTitleText"):SetText("")
getglobal("QuestLogItemChooseText"):SetText("")
getglobal("QuestLogItemReceiveText"):SetText("")
for i = 1, BENILLA_QUESTLOG_MAX_ITEMS do
getglobal("QuestLogChoice" .. i):Hide()
getglobal("QuestLogReward" .. i):Hide()
for i = 1, MAX_NUM_ITEMS do
getglobal("QuestLogItem" .. i):Hide()
end
BenillaMoney_Clear("QuestLogMoney")
-- Nothing selected: the header-only span (BenillaQuestLogRewards_Update's own
@@ -651,7 +621,7 @@
-- The reward-panel layout, ported from the ref's shared `QuestFrameItems_Update`
-- (QuestFrame.lua:311-522, the same routine the live QuestFrame reward panel uses) — this
-- window's v1 subset of it: no spell-reward row (GetQuestLogRewardSpell has no v1 caller,
-- QuestLogFrame.xml's header comment), capped at BENILLA_QUESTLOG_MAX_ITEMS (6, half the ref's
-- QuestLogFrame.xml's header comment), capped at MAX_NUM_ITEMS (6, half the ref's
-- MAX_NUM_ITEMS=10, same halving QuestFrame.xml's own BENILLA_QUEST_MAX_ITEMS does). Every
-- SetPoint below mirrors the ref's own line-for-line (cited per block) — this is the mechanism
-- the ref itself uses; a static XML band was never right, and now that a runtime SetPoint
@@ -682,25 +652,26 @@
-- the row two back (0,-2) — starts a new row; each EVEN index anchors TOPRIGHT of the
-- previous (1,0) — sits beside it. ──
local chooseText = getglobal("QuestLogItemChooseText")
local lastChoice = math.min(numChoices, BENILLA_QUESTLOG_MAX_ITEMS)
local lastChoice = math.min(numChoices, MAX_NUM_ITEMS)
if numChoices > 0 then
chooseText:SetText("You may choose one of these rewards:")
else
chooseText:SetText("")
end
for i = 1, BENILLA_QUESTLOG_MAX_ITEMS do
local choice = getglobal("QuestLogChoice" .. i)
if i <= lastChoice then
BenillaQuestLogItem_Set(choice, GetQuestLogChoiceInfo, i, "choice")
if i == 1 then
choice:SetPoint("TOPLEFT", chooseText, "BOTTOMLEFT", -3, -5)
elseif i % 2 == 1 then
choice:SetPoint("TOPLEFT", getglobal("QuestLogChoice" .. (i - 2)), "BOTTOMLEFT", 0, -2)
else
choice:SetPoint("TOPLEFT", getglobal("QuestLogChoice" .. (i - 1)), "TOPRIGHT", 1, 0)
end
-- ref l.358-361: hide the tail of the pool BEFORE laying any of it out.
local totalRewards = numChoices + numRewards
for i = totalRewards + 1, MAX_NUM_ITEMS do
getglobal("QuestLogItem" .. i):Hide()
end
for i = 1, lastChoice do
local choice = getglobal("QuestLogItem" .. i)
BenillaQuestLogItem_Set(choice, GetQuestLogChoiceInfo, i, "choice")
if i == 1 then
choice:SetPoint("TOPLEFT", chooseText, "BOTTOMLEFT", -3, -5)
elseif i % 2 == 1 then
choice:SetPoint("TOPLEFT", getglobal("QuestLogItem" .. (i - 2)), "BOTTOMLEFT", 0, -2)
else
choice:Hide()
choice:SetPoint("TOPLEFT", getglobal("QuestLogItem" .. (i - 1)), "TOPRIGHT", 1, 0)
end
end
@@ -715,7 +686,7 @@
receiveText:SetText("You will also receive:")
local anchorIndex = lastChoice
if anchorIndex % 2 == 0 then anchorIndex = anchorIndex - 1 end
receiveText:SetPoint("TOPLEFT", getglobal("QuestLogChoice" .. anchorIndex), "BOTTOMLEFT", 3, -5)
receiveText:SetPoint("TOPLEFT", getglobal("QuestLogItem" .. anchorIndex), "BOTTOMLEFT", 3, -5)
else
receiveText:SetText("You will receive:")
receiveText:SetPoint("TOPLEFT", header, "BOTTOMLEFT", 3, -5)
@@ -726,20 +697,18 @@
-- ── Fixed rewards (QuestFrame.lua:474-513): row 1 anchors below ItemReceiveText (-3,-5);
-- the same odd/even 2-per-row pattern as the choice rows above, chained off each other. ──
local lastReward = math.min(numRewards, BENILLA_QUESTLOG_MAX_ITEMS)
for i = 1, BENILLA_QUESTLOG_MAX_ITEMS do
local reward = getglobal("QuestLogReward" .. i)
if i <= lastReward then
BenillaQuestLogItem_Set(reward, GetQuestLogRewardInfo, i, "reward")
if i == 1 then
reward:SetPoint("TOPLEFT", receiveText, "BOTTOMLEFT", -3, -5)
elseif i % 2 == 1 then
reward:SetPoint("TOPLEFT", getglobal("QuestLogReward" .. (i - 2)), "BOTTOMLEFT", 0, -2)
else
reward:SetPoint("TOPLEFT", getglobal("QuestLogReward" .. (i - 1)), "TOPRIGHT", 1, 0)
end
-- The fixed rows CONTINUE the pool after the choices (ref's own running `rewardsCount`).
local lastReward = math.min(numRewards, MAX_NUM_ITEMS - lastChoice)
for i = 1, lastReward do
local index = i + lastChoice
local reward = getglobal("QuestLogItem" .. index)
BenillaQuestLogItem_Set(reward, GetQuestLogRewardInfo, i, "reward")
if i == 1 then
reward:SetPoint("TOPLEFT", receiveText, "BOTTOMLEFT", -3, -5)
elseif i % 2 == 1 then
reward:SetPoint("TOPLEFT", getglobal("QuestLogItem" .. (index - 2)), "BOTTOMLEFT", 0, -2)
else
reward:Hide()
reward:SetPoint("TOPLEFT", getglobal("QuestLogItem" .. (index - 1)), "TOPRIGHT", 1, 0)
end
end
@@ -788,7 +757,7 @@
if selection == 0 or selection > GetNumQuestLogEntries() then
BenillaQuestLogDetail_Clear()
if not doNotScroll then
getglobal("QuestLogDetailScroll"):SetVerticalScroll(0)
getglobal("QuestLogDetailScrollFrame"):SetVerticalScroll(0)
end
return
end
@@ -825,7 +794,7 @@
timerText:SetText("")
objective1:SetPoint("TOPLEFT", getglobal("QuestLogObjectivesText"), "BOTTOMLEFT", 0, -10)
end
getglobal("QuestLogDescription"):SetText(description or "")
getglobal("QuestLogQuestDescription"):SetText(description or "")
-- Lua-managed header (ref text=QUEST_DESCRIPTION is static since the ref always has a real
-- selection whenever this pane is even visible; our flat window keeps the same FontString
-- alive with no selection, so THIS function — not a static `text=` — owns "Description",
@@ -874,7 +843,7 @@
local rewardHeight = BenillaQuestLogRewards_Update()
BenillaQuestLogDetail_ResizeChild(objAnchorIndex, rewardHeight)
if not doNotScroll then
getglobal("QuestLogDetailScroll"):SetVerticalScroll(0)
getglobal("QuestLogDetailScrollFrame"):SetVerticalScroll(0)
end
end
@@ -1109,7 +1078,7 @@
if not numEntries then
numEntries = GetNumQuestLogEntries()
end
local pushButton = getglobal("QuestLogPushQuestButton")
local pushButton = getglobal("QuestFramePushQuestButton")
if numEntries == 0 then
pushButton:Disable()
elseif GetQuestLogPushable() and GetNumPartyMembers() > 0 then
@@ -1126,6 +1095,10 @@
-- the selected quest can be shared without changing the quest log at all, so
-- QUEST_LOG_UPDATE alone would leave the button stale (decision 1733).
frame:RegisterEvent("PARTY_MEMBERS_CHANGED")
-- The faux kit's owner hook (ScrollTemplates.xml l.37): a bar drag or arrow click sets
-- the frame's offset and then calls this to repaint the rows. SkillFrame.xml wires its
-- own the same way.
getglobal("QuestLogListScrollFrame").updateFunc = BenillaQuestLogList_Update
end
function BenillaQuestLogFrame_OnEvent(frame)
@@ -1231,7 +1204,7 @@
this mirrors the ref's own duplication, not just an engine workaround. Keep the two shapes in
sync by hand if either is revisited.
-->
<Button name="BenillaQuestLogItemTemplate" virtual="true" hidden="true">
<Button name="QuestLogRewardItemTemplate" virtual="true" hidden="true">
<!-- Ref-verbatim geometry (QuestFrameTemplates.xml:85-138): a bare 39×39 icon at TOPLEFT
(no slot-well texture behind it — the earlier `UI-EmptySlot` here was our own addition
and read as a heavy black box on the parchment), the name plate overlapping the icon's
@@ -1286,7 +1259,7 @@
<!--
One list row (pin §2's QuestLogTitleButtonTemplate, QuestLogFrame.xml:4-107, ~300×16). Text
lives on a NAMED `$parentText` FontString (not the auto ButtonText slot — same convention
`GossipFrame.xml`'s `BenillaGossipRowTemplate` uses for its `$parentLabel`), so it can carry
`GossipFrame.xml`'s `GossipTitleButtonTemplate` uses for its `$parentLabel`), so it can carry
an explicit LEFT justify. v1 simplification: the ref's Complete/Failed tag rides a SEPARATE
right-justified `$parentTag` FontString the client shrinks the title to make room for; we
append the tag straight onto the one title string instead (`BenillaQuestLogList_Update`) —
@@ -1297,7 +1270,7 @@
16x16 UI-PlusButton-Hilight at the same spot is the ONLY hover art (quest rows clear it
too — the ref has NO row-wide hover glow), and the text starts at LEFT+20 (the real
indent; quest rows add the ref's own two-space prefix on top). The PERSISTENT selection bar
is the separate `QuestLogSelectHighlight` frame `BenillaQuestLogList_Update` moves.
is the separate `QuestLogHighlightFrame` frame `BenillaQuestLogList_Update` moves.
`$parentCheck` (pin §2, ref `QuestLogFrame.xml:34-45`) is the per-row watch checkbox — a 16×16
`UI-CheckBox-Check`, no `file=` here (where the ref uses `hidden="true"`): it starts with
@@ -1315,7 +1288,7 @@
title+tag string means a "(Complete)" row seats the check after the tag, not between
title and tag.
-->
<Button name="BenillaQuestLogTitleTemplate" virtual="true" hidden="true">
<Button name="QuestLogTitleButtonTemplate" virtual="true" hidden="true">
<Size><AbsDimension x="300" y="16"/></Size>
<Layers>
<Layer level="ARTWORK">
@@ -1354,7 +1327,7 @@
<!-- A panel action button (Abandon/Exit/Yes/No) — the UIPanelButtonTemplate face from
`QuestFrame.xml`'s `BenillaQuestPanelButtonTemplate`, duplicated for the same per-window
test-isolation reason as `BenillaQuestLogItemTemplate` above. UNLIKE the giver window's
test-isolation reason as `QuestLogRewardItemTemplate` above. UNLIKE the giver window's
template this one is NOT hidden="true": the giver's panel-switch Lua re-Show()s each
panel's pair every switch, but this window's Abandon/Exit are always-visible chrome (ref
QuestLogFrame.xml:451/482) with no code path that would ever Show() them — inheriting
@@ -1412,20 +1385,11 @@
<Anchors><Anchor point="RIGHT" relativeTo="QuestLogCountMiddle" relativePoint="LEFT"/></Anchors>
<TexCoords left="0" right="0.0625" top="0" bottom="0.625"/>
</Texture>
<FontString name="QuestLogCount" inherits="GameFontNormalSmall" justifyH="RIGHT">
<FontString name="QuestLogQuestCount" inherits="GameFontNormalSmall" justifyH="RIGHT">
<Anchors><Anchor point="RIGHT" relativeTo="QuestLogCountRight" relativePoint="RIGHT"><Offset><AbsDimension x="-6" y="0"/></Offset></Anchor></Anchors>
</FontString>
<!-- v1 empty state (pin §2's EmptyQuestLogFrame, simplified — this file's header
comment): one centered message over the row area. No `text=`/`hidden=` here —
`BenillaQuestLogList_Update` owns this entirely via SetText(""), this file's
blank-text convention (see that function's comment). -->
<FontString name="QuestLogEmptyText" inherits="GameFontNormal" justifyH="CENTER">
<Size><AbsDimension x="300" y="0"/></Size>
<Anchors><Anchor point="TOP" relativePoint="TOP"><Offset><AbsDimension x="0" y="-90"/></Offset></Anchor></Anchors>
</FontString>
<!-- Detail page (pin §3): NOW lives on `QuestLogDetailChild`, a real
ScrollFrame's scroll child (`QuestLogDetailScroll`, below in this window's
<!-- Detail page (pin §3): NOW lives on `QuestLogDetailScrollChildFrame`, a real
ScrollFrame's scroll child (`QuestLogDetailScrollFrame`, below in this window's
own `<Frames>`) — the 0088 §2 flat-frame constraint this file used to quote here
DIED with the 0112 resolver fixpoint (child frames render their own `<Layers>`
regions again), so the whole chain (QuestTitle → ItemReceiveText, the choice/
@@ -1448,7 +1412,7 @@
<!-- pin §1: the top-right close X (UIPanelCloseButton in the ref; our engine ships no such
template — QuestFrame.xml/BagFrame.xml already draw it by hand on the same art). -->
<Button name="QuestLogCloseButton">
<Button name="QuestLogFrameCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-30" y="-8"/></Offset></Anchor></Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
@@ -1466,7 +1430,7 @@
<Size><AbsDimension x="54" y="32"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="70" y="-48"/></Offset></Anchor></Anchors>
<Frames>
<Button name="QuestLogCollapseAllButton" inherits="BenillaQuestLogTitleTemplate" hidden="false" text="All">
<Button name="QuestLogCollapseAllButton" inherits="QuestLogTitleButtonTemplate" hidden="false" text="All">
<Size><AbsDimension x="40" y="22"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="-2"/></Offset></Anchor></Anchors>
<Layers>
@@ -1551,7 +1515,7 @@
the title text and washed it out — 0112's capture gate caught the wash, this scrub
found the cause). Moved onto the selected row (at the ref's (5,0)) and vertex-colored
to the row's difficulty color by `BenillaQuestLogList_Update` (ref lua l.337-339). -->
<Frame name="QuestLogSelectHighlight" hidden="true">
<Frame name="QuestLogHighlightFrame" hidden="true">
<Size><AbsDimension x="293" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers><Layer level="ARTWORK">
@@ -1564,14 +1528,30 @@
<!-- pin §2, geometry byte-for-byte (ref QuestLogFrame.xml:535-588): 6 title rows,
300×16, row1 TOPLEFT of the frame at (19,-75); rows 2-6 chain TOPLEFT→prior BOTTOMLEFT
at (0,+1) — a TIGHT 1px OVERLAP, not a gap (the ref's own offset is positive). -->
<Button name="QuestLogTitle1" inherits="BenillaQuestLogTitleTemplate" id="1">
<!-- ref l.291-307. The list's real faux-scroll frame: an invisible container over the
6 title rows whose right-hand Slider is the only part that draws, sized to the row
span and anchored where row 1 starts (SkillFrame.xml's own convention). It replaces
a `questLogOffset` upvalue this file kept "because no real ScrollFrame/
FauxScrollFrame engine exists yet" — long false, and the reason this window
published no QuestLogListScrollFrame for addons to find. -->
<Frame name="QuestLogListScrollFrame" inherits="FauxScrollFrameTemplate">
<Size><AbsDimension x="300" y="93"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="19" y="-75"/></Offset></Anchor></Anchors>
<Scripts>
<OnVerticalScroll>
FauxScrollFrame_OnVerticalScroll(QUESTLOG_QUEST_HEIGHT, BenillaQuestLogList_Update)
</OnVerticalScroll>
</Scripts>
</Frame>
<Button name="QuestLogTitle1" inherits="QuestLogTitleButtonTemplate" id="1">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="19" y="-75"/></Offset></Anchor></Anchors>
</Button>
<Button name="QuestLogTitle2" inherits="BenillaQuestLogTitleTemplate" id="2"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<Button name="QuestLogTitle3" inherits="BenillaQuestLogTitleTemplate" id="3"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<Button name="QuestLogTitle4" inherits="BenillaQuestLogTitleTemplate" id="4"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<Button name="QuestLogTitle5" inherits="BenillaQuestLogTitleTemplate" id="5"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<Button name="QuestLogTitle6" inherits="BenillaQuestLogTitleTemplate" id="6"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<Button name="QuestLogTitle2" inherits="QuestLogTitleButtonTemplate" id="2"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle1" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<Button name="QuestLogTitle3" inherits="QuestLogTitleButtonTemplate" id="3"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle2" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<Button name="QuestLogTitle4" inherits="QuestLogTitleButtonTemplate" id="4"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle3" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<Button name="QuestLogTitle5" inherits="QuestLogTitleButtonTemplate" id="5"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle4" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<Button name="QuestLogTitle6" inherits="QuestLogTitleButtonTemplate" id="6"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogTitle5" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="1"/></Offset></Anchor></Anchors></Button>
<!-- The detail pane's real ScrollFrame (decision 0112's mechanism): 300×261, TOPLEFT of
this window at (19,-175) — the ref's own `QuestLogDetailScrollFrame` position, this
@@ -1582,10 +1562,10 @@
any wheel/click landing inside its own rect (this file's header comment on hit-test
z-order) — scrolling over the detail text no longer scrolls the LIST underneath it,
a latent mix-up the flat layout had no way to avoid. -->
<ScrollFrame name="QuestLogDetailScroll">
<ScrollFrame name="QuestLogDetailScrollFrame" inherits="UIPanelScrollFrameTemplate">
<Size><AbsDimension x="300" y="261"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="19" y="-175"/></Offset></Anchor></Anchors>
<Frames>
<ScrollChild>
<!-- The scroll child (`SetScrollChild`'d in this ScrollFrame's own OnLoad below):
300 wide, matching the ref's `QuestLogDetailScrollChildFrame`; HEIGHT is
runtime-set by `BenillaQuestLogDetail_ResizeChild` (a Frame's height is only
@@ -1598,7 +1578,7 @@
subtree; everything downstream still chains by NAME exactly as before (anchor
resolution is name-keyed, not parent-scoped — unaffected by which frame owns
which region). -->
<Frame name="QuestLogDetailChild">
<Frame name="QuestLogDetailScrollChildFrame">
<Size><AbsDimension x="300" y="261"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<Layers>
@@ -1686,7 +1666,7 @@
<Size><AbsDimension x="285" y="18"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogObjectivesText" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-10"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="QuestLogDescription" inherits="QuestFont" justifyH="LEFT">
<FontString name="QuestLogQuestDescription" inherits="QuestFont" justifyH="LEFT">
<Size><AbsDimension x="270" y="0"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogDescriptionTitle" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-5"/></Offset></Anchor></Anchors>
</FontString>
@@ -1694,7 +1674,7 @@
(toggles "Rewards" / "" — this file's blank-text convention). -->
<FontString name="QuestLogRewardTitleText" inherits="QuestTitleFont" justifyH="LEFT">
<Size><AbsDimension x="300" y="18"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogDescription" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-15"/></Offset></Anchor></Anchors>
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogQuestDescription" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-15"/></Offset></Anchor></Anchors>
</FontString>
<!-- REWARD_CHOICES (ref QuestLogFrame.xml:821-832) — our literal transcription of the
token's string. Lua-managed (`BenillaQuestLogRewards_Update` toggles the text). -->
@@ -1717,21 +1697,22 @@
`BenillaQuestLogRewards_Update` SetPoints every shown row every repaint, porting
`QuestFrameItems_Update`'s own SetPoint math verbatim (QuestFrame.lua:311-522,
cited per-line in that function). -->
<Button name="QuestLogChoice1" inherits="BenillaQuestLogItemTemplate">
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogItemChooseText" relativePoint="BOTTOMLEFT"/></Anchors>
</Button>
<Button name="QuestLogChoice2" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogChoice1" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogChoice3" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogChoice2" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogChoice4" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogChoice3" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogChoice5" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogChoice4" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogChoice6" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogChoice5" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogReward1" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogItemReceiveText" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogReward2" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogReward1" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogReward3" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogReward2" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogReward4" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogReward3" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogReward5" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogReward4" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<Button name="QuestLogReward6" inherits="BenillaQuestLogItemTemplate"><Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogReward5" relativePoint="BOTTOMLEFT"/></Anchors></Button>
<!-- ONE pool of 10, the ref's own (ref l.837-887): the detail page packs CHOICES,
then the reward-spell row, then FIXED rewards into a single numbered run and
positions each at runtime, so every row carries a bare TOPLEFT anchor. This was
two static 6-deep pools (QuestLogChoice*/QuestLogReward*), names the reference
does not have; pfUI's skins/blizzard/questlog.lua l.219-224 walks QuestLogItem
1..6 and indexes :GetWidth() unguarded. -->
<Button name="QuestLogItem1" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestLogItem2" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestLogItem3" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestLogItem4" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestLogItem5" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestLogItem6" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestLogItem7" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestLogItem8" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestLogItem9" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<Button name="QuestLogItem10" inherits="QuestLogRewardItemTemplate"><Anchors><Anchor point="TOPLEFT"/></Anchors></Button>
<!-- Reward money (pin §3, GetQuestLogRewardMoney) — ref QuestLogMoneyFrame anchors LEFT
to QuestLogItemReceiveText's RIGHT, offset (15,0) (QuestLogFrame.xml:896-903). -->
@@ -1761,91 +1742,53 @@
</Frame>
</Frames>
</Frame>
<!-- A minimal ref-shaped scrollbar (ref UIPanelScrollFrameTemplate/
UIPanelScrollBarTemplate, UIPanelTemplates.xml): up/down step buttons + a
thumb texture positioned from GetVerticalScroll/GetVerticalScrollRange. VISUAL
only — no drag: this engine has no Slider widget (no drag-capture affordance
anywhere yet, the same class of gap this file's header comment already notes
for modifier keys), so the ref's real `Slider` track is a named DEFERRED
follow-up, not modeled here; the wheel + the two arrow buttons are the only
interactive paths to scroll, which is already the ENTIRE ask (the ref's own
mouse-drag-the-thumb affordance is the one piece missing). Hidden whenever
nothing overflows (`BenillaQuestLogDetailScroll_SyncBar`). -->
<Frame name="QuestLogDetailScrollBar" hidden="true">
<Size><AbsDimension x="16" y="261"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogDetailScroll" relativePoint="TOPRIGHT"><Offset><AbsDimension x="6" y="0"/></Offset></Anchor></Anchors>
<Frames>
<Button name="QuestLogDetailScrollUp">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<NormalTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Up"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Down"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Disabled"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-ScrollBar-ScrollUpButton-Highlight" alphaMode="ADD"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></HighlightTexture>
<Scripts><OnClick>BenillaQuestLogDetailScroll_Step(-BENILLA_QUESTLOG_SCROLL_STEP)</OnClick></Scripts>
</Button>
<Button name="QuestLogDetailScrollDown">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"/></Anchors>
<NormalTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Up"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></NormalTexture>
<PushedTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Down"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></PushedTexture>
<DisabledTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Disabled"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></DisabledTexture>
<HighlightTexture file="Interface\Buttons\UI-ScrollBar-ScrollDownButton-Highlight" alphaMode="ADD"><TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/></HighlightTexture>
<Scripts><OnClick>BenillaQuestLogDetailScroll_Step(BENILLA_QUESTLOG_SCROLL_STEP)</OnClick></Scripts>
</Button>
<!-- The track: BENILLA_QUESTLOG_SCROLL_TRACK_H (229) tall, between the two
arrow buttons (261 - 16 - 16). The thumb texture is a plain child
region SetPoint'd by BenillaQuestLogDetailScroll_SyncBar off the
CURRENT GetVerticalScroll()/GetVerticalScrollRange() fraction. -->
<Frame name="QuestLogDetailScrollTrack">
<Size><AbsDimension x="16" y="229"/></Size>
<Anchors><Anchor point="TOPLEFT" relativeTo="QuestLogDetailScrollUp" relativePoint="BOTTOMLEFT"/></Anchors>
<Layers><Layer level="OVERLAY">
<Texture name="QuestLogDetailScrollThumb" file="Interface\Buttons\UI-ScrollBar-Knob">
<Size><AbsDimension x="16" y="16"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"/></Anchors>
<!-- The ref thumb samples the knob art's CENTER quarter
(UIPanelScrollBarButton's TexCoords, UIPanelTemplates.xml
:104-106 + :160-164) — full UVs squash the 32px canvas
into 16px and the knob reads half-size (director's
"scroll cube is too small"). -->
<TexCoords left="0.25" right="0.75" top="0.25" bottom="0.75"/>
</Texture>
</Layer></Layers>
</Frame>
</Frames>
</Frame>
</Frames>
<Scripts>
<OnLoad>self:SetScrollChild(getglobal("QuestLogDetailChild"))</OnLoad>
<OnMouseWheel>
if arg1 > 0 then
BenillaQuestLogDetailScroll_Step(-BENILLA_QUESTLOG_SCROLL_STEP)
else
BenillaQuestLogDetailScroll_Step(BENILLA_QUESTLOG_SCROLL_STEP)
end
</OnMouseWheel>
<OnVerticalScroll>BenillaQuestLogDetailScroll_SyncBar()</OnVerticalScroll>
<OnScrollRangeChanged>BenillaQuestLogDetailScroll_SyncBar()</OnScrollRangeChanged>
<!-- BenillaQuestLogDetail_OnUpdate's own comment: the FIRST populate of a
freshly-selected quest's text sizes off a one-tick-stale (zero, brand-new
text) GetHeight() — nothing else re-asks once the quest log's data itself
stops changing, so this keeps re-checking every frame the window is open
until the real measurement lands and the resize stops changing (its own
guard makes every further call here a no-op). -->
<OnUpdate>BenillaQuestLogDetail_OnUpdate()</OnUpdate>
</Scripts>
</ScrollChild>
</ScrollFrame>
<!-- ref l.370-440. The empty-log page: four `UI-QuestLog-Empty-*` slabs and the
"no quests" line, shown as ONE frame when the log is empty and hidden otherwise
(ref QuestLogFrame.lua l.108-119). This window used to carry only the FontString,
loose on its own Layers and blanked with SetText("") — the simplification the
header called "pin §2's EmptyQuestLogFrame, simplified". pfUI's
`skins/blizzard/questlog.lua` l.118-131 strips it, hooks its OnShow/OnHide, and
drives its own expand button off them, so the FRAME is the contract, not the text. -->
<Frame name="EmptyQuestLogFrame" hidden="true">
<Size><AbsDimension x="384" y="512"/></Size>
<Anchors><Anchor point="TOPLEFT"><Offset><AbsDimension x="19" y="-73"/></Offset></Anchor></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture file="Interface\QuestFrame\UI-QuestLog-Empty-TopLeft">
<Size><AbsDimension x="256" y="256"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestLog-Empty-TopRight">
<Size><AbsDimension x="64" y="256"/></Size>
<Anchors><Anchor point="TOPRIGHT"><Offset><AbsDimension x="-64" y="0"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestLog-Empty-BotLeft">
<Size><AbsDimension x="256" y="128"/></Size>
<Anchors><Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="0" y="128"/></Offset></Anchor></Anchors>
</Texture>
<Texture file="Interface\QuestFrame\UI-QuestLog-Empty-BotRight">
<Size><AbsDimension x="64" y="128"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-64" y="128"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="QuestLogNoQuestsText" inherits="GameFontHighlight" text="Your quest log is empty.">
<Size><AbsDimension x="200" y="16"/></Size>
<Anchors><Anchor point="TOP" relativeTo="QuestLogTitleText"><Offset><AbsDimension x="-20" y="-90"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
</Frame>
<!-- pin §4, geometry byte-for-byte (ref QuestLogFrame.xml:451-498): Abandon 125×21 at
(17,54), Exit 77×21 at (-43,54). numEntries==0 disables Abandon. -->
<Button name="QuestLogAbandonButton" inherits="BenillaQuestLogPanelButtonTemplate" text="Abandon Quest">
<Button name="QuestLogFrameAbandonButton" inherits="BenillaQuestLogPanelButtonTemplate" text="Abandon Quest">
<Size><AbsDimension x="125" y="21"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="17" y="54"/></Offset></Anchor></Anchors>
<Scripts><OnClick>BenillaQuestLogAbandonButton_OnClick()</OnClick></Scripts>
</Button>
<Button name="QuestLogExitButton" inherits="BenillaQuestLogPanelButtonTemplate" text="Exit">
<Button name="QuestFrameExitButton" inherits="BenillaQuestLogPanelButtonTemplate" text="Exit">
<Size><AbsDimension x="77" y="21"/></Size>
<Anchors><Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-43" y="54"/></Offset></Anchor></Anchors>
<Scripts><OnClick>HideUIPanel(getglobal("QuestLogFrame"))</OnClick></Scripts>
@@ -1856,9 +1799,9 @@
BenillaQuestLogPushButton_Update re-tests it on every list update and every
PARTY_MEMBERS_CHANGED. The newbie tooltip is the ref's (NEWBIE_TOOLTIP_SHAREQUEST,
GlobalStrings.lua:2764), through the same GameTooltip_AddNewbieTip the ref calls. -->
<Button name="QuestLogPushQuestButton" inherits="BenillaQuestLogPanelButtonTemplate" text="Share Quest">
<Button name="QuestFramePushQuestButton" inherits="BenillaQuestLogPanelButtonTemplate" text="Share Quest">
<Size><AbsDimension x="123" y="21"/></Size>
<Anchors><Anchor point="RIGHT" relativeTo="QuestLogExitButton" relativePoint="LEFT"/></Anchors>
<Anchors><Anchor point="RIGHT" relativeTo="QuestFrameExitButton" relativePoint="LEFT"/></Anchors>
<Scripts>
<OnLoad>self:Disable()</OnLoad>
<OnClick>QuestLogPushQuest()</OnClick>
@@ -1911,7 +1854,7 @@
too (it's the log's own on-screen echo, driven by the same Lua functions/getters as the log
window above), and this file's tests already load nothing else — a second file would need its
own duplicate test-harness load list for zero isolation benefit (contrast the
`BenillaQuestLogItemTemplate`/`BenillaQuestLogPanelButtonTemplate` duplication above, which
`QuestLogRewardItemTemplate`/`BenillaQuestLogPanelButtonTemplate` duplication above, which
buys real per-window test isolation).
v1 placement: TOPRIGHT of the screen, offset (-10,-170) — no minimap frame exists in benilla
@@ -385,7 +385,13 @@
-- every-frame write would fight the resolver).
function BenillaScroll_ResizeChild(scrollName, widgets)
local sf = getglobal(scrollName)
local child = getglobal(scrollName .. "Child")
-- ASK THE FRAME, don't guess a name. This used to be
-- `getglobal(scrollName .. "Child")`, which only ever worked because every caller wore
-- a private name in that exact shape — the reference's own convention is
-- `<X>ScrollFrame` / `<X>ScrollChildFrame`, not a suffix, so the derivation broke the
-- moment QuestFrame took the reference's names. A ScrollFrame knows its own child.
local child = sf:GetScrollChild()
if not child then return false end
local top = child:GetTop()
if not top then return false end
local bottom = nil
+175 -68
View File
@@ -37,24 +37,49 @@
ref's own real-client compositing (SkillFrame is a nested child there too, relying on the
same cutout rather than redrawing the portrait itself).
FLAT layout (TrainerFrame.xml/TradeSkillFrame.xml's own house constraint): this window has no
DETAIL FontStrings needing a nested child Frame in the first place — the minimal detail pane
(below) is a single StatusBar instance whose own name/rank FontStrings live on ITS OWN Layers
(a StatusBar, like a Button, renders its own Layers fine — only a plain child `<Frame>` loses
them), the exact `BenillaTradeSkillRankFrame` shape TradeSkillFrame.xml already proved.
THE DETAIL PANE NESTS, as the ref's does. This file used to carry the FLAT-layout claim —
"only a plain child `<Frame>` loses [its Layers]" — quoted from TrainerFrame.xml. That claim
is FALSE and was retracted in decision 1517, which corrected TrainerFrame/TradeSkillFrame/
CraftFrame and missed this file: a fourth restatement, still asserting the constraint five
weeks after it was disproved. A runtime-shown child `<Frame>` renders its own `<Layers>`
FontStrings in this engine, and the ref's `SkillDetailScrollFrame` +
`SkillDetailScrollChildFrame` are therefore built here verbatim (below).
1517 §2 says a retracted reason is not by itself cause to move approved geometry — so the
mover here is not the retraction, it is DEMAND: pfUI's `skins/blizzard/character.lua` l.299-320
indexes `SkillDetailScrollFrameScrollBar`, `SkillDetailScrollChildFrame`, `SkillDetailCostText`
and `SkillDetailStatusBarUnlearnButton` by name, and its own skin then hides the scroll child
and reparents the three detail regions onto the scroll frame — i.e. it flattens the pane
ITSELF, which it can only do if the pane is nested to begin with. The pane also could not
keep its old size inside the ref's 296-wide viewport: the old detail bar reused the 271-wide
LIST-ROW template, whose 281-wide Border put the abandon button at x 312..344 against a
viewport ending at 318 — 26 of its 32 px clipped. So the detail bar is now the ref's OWN
declaration (211×15, Border 220×32, centred under the list) rather than a borrowed list row,
which is both what fits and what the real client draws. LOOK CHANGE, called out for the
director: the detail bar narrows and centres, and the ref's two `UI-ClassTrainer-ScrollBar`
trough textures now flank the pane's right edge (the ref draws them unconditionally, bar or
no bar). Reverting is one anchor + one Size.
v1 DEVIATIONS (each re-cited at its own site below):
1. THE DETAIL PANE IS MINIMAL-PLUS-DESCRIPTION-PLUS-ABANDON: the selected skill's rank
StatusBar + name + the DESCRIPTION BODY (`SkillDetailDescriptionText` — ref
`SkillDetailDescriptionText`/`SKILL_DESCRIPTION`, real since `GetSkillLineInfo`'s 13th
return carries `SkillLine.dbc` col 12) + the ABANDON BUTTON
(`SkillDetailUnlearnButton` → the `UNLEARN_SKILL` confirm → `AbandonSkill` →
1. THE DETAIL PANE OMITS THE TRAINING-POINT SPEND ROW, and nothing else. Its structure
(`SkillDetailScrollFrame` → `SkillDetailScrollChildFrame` → `SkillDetailCostText` +
`SkillDetailDescriptionText` + `SkillDetailStatusBar` → `$parentUnlearnButton`) is the
ref's, verbatim. What stays OUT is the ref's `$parentLeftArrow`/`$parentRightArrow`/
`$parentLearnSkillButton` trio and the `AddSkillUp`/`RemoveSkillUp`/`BuySkillTier`
calls behind them — driven by `stepCost`/`rankCost`/`numTempPoints`, which
`GetSkillLineInfo` always reports nil/0 (0437's own named out-of-scope: 1.12 training
points are dead data).
`SkillDetailCostText` IS built, because the ref's own no-cost path is a real path, not
a stub: ref `SkillFrame.lua` l.361 `SkillDetailCostText:Hide()` is the branch a live
1.12 server always lands on, and `BenillaSkillFrame_UpdateDetail` takes exactly that
branch every time. An empty hidden FontString here is the reference's own behaviour,
not a name published to satisfy a lookup (1189/1203).
The ABANDON BUTTON is real: `SkillDetailStatusBarUnlearnButton` (the ref's
`$parentUnlearnButton`) → the `UNLEARN_SKILL` confirm → `AbandonSkill` →
`CMSG_UNLEARN_SKILL`; the isAbandonable predicate is `SkillRaceClassInfo.flags & 0x20`
— the server's own gate, real 5875 data: primary professions only). STILL OMITTED:
the training-point spend row (the ref's `LeftArrow`/`RightArrow`/`LearnSkillButton`/
`SkillDetailCostText`, all driven by `stepCost`/`rankCost`, which `GetSkillLineInfo`
always reports nil/0 — 0437's own named out-of-scope: 1.12 training points are dead
data).
— the server's own gate, real 5875 data: primary professions only. It used to be
published as `SkillDetailUnlearnButton`, a name the reference does not have, while the
comment directly above it cited `$parentUnlearnButton` correctly — the fifth instance
this session of right-behaviour-under-a-private-name (1189).
2. NO SORT CHECKBOX (ref l.215-241's own XML-commented-out "Replace once code is ready..."
`SkillSortButton`): the ref itself ships this dead/commented, so there is nothing to
transcribe (TradeSkillFrame.xml's own "ref's own dead code is dropped" precedent).
@@ -197,7 +222,12 @@
function BenillaSkillFrame_UpdateDetail()
local bar = getglobal("SkillDetailStatusBar")
local desc = getglobal("SkillDetailDescriptionText")
local unlearn = getglobal("SkillDetailUnlearnButton")
local unlearn = getglobal("SkillDetailStatusBarUnlearnButton")
-- The ref's own no-cost branch (ref SkillFrame.lua l.361), taken unconditionally:
-- stepCost/rankCost are always nil on a 1.12 server (deviation 1). Hidden here rather
-- than left `hidden="true"` from XML so the branch is visible as a branch — the ref
-- shows this string on the paths we don't have, and this is where it would be re-shown.
getglobal("SkillDetailCostText"):Hide()
local index = GetSelectedSkill()
if index == 0 then
bar:Hide()
@@ -439,16 +469,6 @@
local frame = this
frame:RegisterEvent("SKILL_LINES_CHANGED")
getglobal("SkillListScrollFrame").updateFunc = BenillaSkillFrame_Update
-- The description body hangs under the detail bar — anchored here, not in XML,
-- because the bar is a child frame declared after the page's layers and anchors
-- resolve at SetPoint time (an XML forward reference silently lands on the page;
-- the MailFrame error-text OnLoad precedent, ref MailFrame.xml l.1040). Cleared
-- first: the load-time implicit anchor (decision 1310) seated this justifyH="LEFT"
-- string at the page's LEFT, and SetPoint replaces only its own point -- without the
-- clear both anchors stand and the left/center edges resolve off the page, not the
-- bar. (The ref needs no clear on ITS file only because its XML anchors the string.)
SkillDetailDescriptionText:ClearAllPoints()
SkillDetailDescriptionText:SetPoint("TOPLEFT", "SkillDetailStatusBar", "BOTTOMLEFT", -2, -10)
end
function SkillFrame_OnShow()
@@ -570,16 +590,6 @@
<Anchors><Anchor point="LEFT" relativeTo="SkillHorizontalBarLeft" relativePoint="RIGHT"/></Anchors>
<TexCoords left="0" right="0.29296875" top="0.25" bottom="0.5"/>
</Texture>
<!-- The detail pane's description body (ref SkillDetailDescriptionText, l.579-591:
GameFontHighlightSmall, 275 wide, justified LEFT/TOP). The ref hangs it inside
its SkillDetailScrollChildFrame; this page's flat layout (header comment) has
no such child, so it hangs under the detail bar instead — the same pane
position, minus the (never-needed for one sentence) scroll machinery. The
anchor is set in SkillFrame_OnLoad, NOT here: the bar is a child frame
declared below and an XML forward reference silently lands on the page. -->
<FontString name="SkillDetailDescriptionText" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP" hidden="true">
<Size><AbsDimension x="275" y="0"/></Size>
</FontString>
</Layer>
</Layers>
<Frames>
@@ -720,38 +730,135 @@
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="22" y="-79"/></Offset></Anchor></Anchors>
</Frame>
<!-- The minimal detail pane (this file's header comment, deviation 1): the selected
skill's own rank StatusBar + name — no description/abandon/spend row. Reuses the SAME
rank template the list rows use (its Border click is harmless here: no .skillIndex is
ever set on this instance, so BenillaSkillRankFrame_OnClick's guard no-ops it). -->
<StatusBar name="SkillDetailStatusBar" inherits="BenillaSkillRankTemplate" hidden="true">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="38" y="-325"/></Offset></Anchor></Anchors>
<Frames>
<!-- The unlearn (abandon) button (ref $parentUnlearnButton, ref SkillFrame.xml
l.705-737, verbatim art/geometry): the red circle-slash right of the bar's
Border, shown by UpdateDetail only for an abandonable line. HitRectInsets
transcribed-inert (no loader wiring — CharacterFrame.xml's engine-gap note).
Handlers are chunk functions (the chunk-boundary rule — OnEnter reads the
chunk-local UNLEARN_SKILL_TOOLTIP). -->
<Button name="SkillDetailUnlearnButton" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="SkillDetailStatusBarBorder" relativePoint="RIGHT">
<Offset><AbsDimension x="-2" y="-1"/></Offset>
</Anchor>
</Anchors>
<HitRectInsets><AbsInset left="9" right="7" top="-7" bottom="10"/></HitRectInsets>
<Scripts>
<OnClick>BenillaSkillUnlearnButton_OnClick(self)</OnClick>
<OnEnter>BenillaSkillUnlearnButton_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<NormalTexture file="Interface\Buttons\CancelButton-Up"/>
<PushedTexture file="Interface\Buttons\CancelButton-Down"/>
<HighlightTexture file="Interface\Buttons\CancelButton-Highlight" alphaMode="ADD"/>
</Button>
</Frames>
</StatusBar>
<!-- ═══ THE DETAIL PANE ═══ ref SkillFrame.xml l.513-747, transcribed as the ref
declares it (header: the FLAT-layout claim this file used to carry is retracted,
decision 1517). A real ScrollFrame over a real scroll child; `scrollBarHideable`
keeps the bar off, which for a one-paragraph description is every time. The two
flanking textures are the ref's own trough art on the SCROLL FRAME's own Layers
(ItemTextFrame.xml's ItemTextScrollFrame is this file's precedent for both) — the
ref draws them unconditionally, so they stand whether or not the bar does.
The scroll range is the child's whole SUBTREE box (decision 1338), which is why
the ref's 320x50 child is not the undersized thing it looks like. -->
<ScrollFrame name="SkillDetailScrollFrame" inherits="UIPanelScrollFrameTemplate">
<Size><AbsDimension x="296" y="107"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativeTo="SkillListScrollFrame" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="0" y="-8"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-ScrollBar">
<Size><AbsDimension x="30" y="100"/></Size>
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-3" y="5"/></Offset></Anchor></Anchors>
<TexCoords left="0" right="0.46875" top="0" bottom="0.78125"/>
</Texture>
<Texture file="Interface\ClassTrainerFrame\UI-ClassTrainer-ScrollBar">
<Size><AbsDimension x="30" y="100"/></Size>
<Anchors><Anchor point="BOTTOMLEFT" relativePoint="BOTTOMRIGHT"><Offset><AbsDimension x="-3" y="-2"/></Offset></Anchor></Anchors>
<TexCoords left="0.53125" right="1.0" top="0.21875" bottom="1.0"/>
</Texture>
</Layer>
</Layers>
<Scripts>
<!-- A handler declared on an instance REPLACES the template's, it does not
chain (ItemTextFrame.xml's own note), so the template's OnLoad is re-called
by hand before this instance's own line. -->
<OnLoad>
ScrollFrame_OnLoad()
self.scrollBarHideable = 1
</OnLoad>
</Scripts>
<ScrollChild>
<Frame name="SkillDetailScrollChildFrame">
<Size><AbsDimension x="320" y="50"/></Size>
<Layers>
<Layer level="ARTWORK">
<!-- The training-point price tag (ref l.567-577). Built, never
filled: `GetSkillLineInfo` reports no stepCost/rankCost on a
1.12 server, so BenillaSkillFrame_UpdateDetail takes the ref's
OWN no-cost branch (ref SkillFrame.lua l.361) and hides it —
deviation 1. It still earns its place as the DESCRIPTION's
anchor target, which is the ref's own arrangement. -->
<FontString name="SkillDetailCostText" inherits="GameFontNormalSmall" justifyH="CENTER" hidden="true">
<Size><AbsDimension x="200" y="0"/></Size>
<Anchors><Anchor point="CENTER" relativeTo="$parent" relativePoint="TOP"><Offset><AbsDimension x="-10" y="-40"/></Offset></Anchor></Anchors>
</FontString>
<!-- The description body (ref l.579-591). Its anchor is XML now:
the flat layout had to set it in SkillFrame_OnLoad because the
bar was a forward reference on the page, and inside the child
the ref's own static TOP anchor resolves. -->
<FontString name="SkillDetailDescriptionText" inherits="GameFontHighlightSmall" justifyH="LEFT" justifyV="TOP" hidden="true">
<Size><AbsDimension x="275" y="0"/></Size>
<Anchors><Anchor point="TOP" relativeTo="$parent" relativePoint="TOP"><Offset><AbsDimension x="-5" y="-35"/></Offset></Anchor></Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The selected skill's rank bar (ref l.594-747), the ref's OWN
declaration rather than the borrowed 271-wide list row it used to
be: 211x15 under a 220x32 Border, centred on the child's TOP. The
Border is a TEXTURE here, as the ref has it — the list row's
Border is a Button because a row is clickable, and the detail bar
is not, so the old "its Border click is harmless here" hedge is
gone with the borrowing. Names match the list template's
($parentSkillName/$parentSkillRank/$parentBackground), so
BenillaSkillFrame_PaintBar drives both unchanged. -->
<StatusBar name="SkillDetailStatusBar" drawLayer="BACKGROUND" minValue="0" maxValue="1" defaultValue="0" hidden="true">
<Size><AbsDimension x="211" y="15"/></Size>
<Anchors>
<Anchor point="CENTER" relativeTo="SkillDetailScrollChildFrame" relativePoint="TOP"><Offset><AbsDimension x="-10" y="-20"/></Offset></Anchor>
</Anchors>
<Layers>
<Layer level="ARTWORK">
<Texture name="$parentBorder" file="Interface\PaperDollInfoFrame\UI-Character-Skills-BarBorder">
<Size><AbsDimension x="220" y="32"/></Size>
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="-5" y="0"/></Offset></Anchor></Anchors>
</Texture>
<FontString name="$parentSkillName" inherits="GameFontNormalSmall">
<Anchors><Anchor point="LEFT"><Offset><AbsDimension x="6" y="1"/></Offset></Anchor></Anchors>
</FontString>
<FontString name="$parentSkillRank" inherits="GameFontHighlightSmall">
<Size><AbsDimension x="128" y="0"/></Size>
</FontString>
</Layer>
<Layer level="BACKGROUND">
<Texture name="$parentBackground">
<Color r="1.0" g="1.0" b="1.0" a="0.2"/>
</Texture>
</Layer>
</Layers>
<Frames>
<!-- The abandon button (ref $parentUnlearnButton, l.705-737,
verbatim art/geometry): the red circle-slash right of the
Border, shown by UpdateDetail only for an abandonable line.
HitRectInsets transcribed-inert (no loader wiring —
CharacterFrame.xml's engine-gap note). Handlers are chunk
functions (the chunk-boundary rule — OnEnter reads the
chunk-local UNLEARN_SKILL_TOOLTIP). -->
<Button name="$parentUnlearnButton" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="$parentBorder" relativePoint="RIGHT">
<Offset><AbsDimension x="-2" y="-1"/></Offset>
</Anchor>
</Anchors>
<HitRectInsets><AbsInset left="9" right="7" top="-7" bottom="10"/></HitRectInsets>
<Scripts>
<OnClick>BenillaSkillUnlearnButton_OnClick(self)</OnClick>
<OnEnter>BenillaSkillUnlearnButton_OnEnter(self)</OnEnter>
<OnLeave>GameTooltip:Hide()</OnLeave>
</Scripts>
<NormalTexture file="Interface\Buttons\CancelButton-Up"/>
<PushedTexture file="Interface\Buttons\CancelButton-Down"/>
<HighlightTexture file="Interface\Buttons\CancelButton-Highlight" alphaMode="ADD"/>
</Button>
</Frames>
<BarTexture name="$parentBar" file="Interface\PaperDollInfoFrame\UI-Character-Skills-Bar"/>
<BarColor r="0.25" g="0.25" b="0.75"/>
</StatusBar>
</Frames>
</Frame>
</ScrollChild>
</ScrollFrame>
</Frames>
<Scripts>
<OnLoad>SkillFrame_OnLoad()</OnLoad>
+3 -3
View File
@@ -21,7 +21,7 @@
are P2. The enchant-slot spell name (slot 7's "enchantment") is decision 0592 P3.
-->
<Script><![CDATA[
BENILLA_MAX_TRADE_ITEMS = 7 -- MAX_TRADE_ITEMS (ref TradeFrame.lua l.1)
MAX_TRADE_ITEMS = 7 -- MAX_TRADE_ITEMS (ref TradeFrame.lua l.1)
-- One side's accept glow. A column's highlight and the enchant slot's below it are one
-- indicator in two frames — they light and drop together, never apart — so both the repaint
@@ -107,7 +107,7 @@
SetPortraitTexture(TradeFrameRecipientPortrait, "npc")
TradeFramePlayerNameText:SetText(UnitName("player") or "")
TradeFrameRecipientNameText:SetText(GetTradePartnerName() or "")
for i = 1, BENILLA_MAX_TRADE_ITEMS do
for i = 1, MAX_TRADE_ITEMS do
TradeFrame_UpdatePlayerItem(i)
TradeFrame_UpdateTargetItem(i)
end
@@ -127,7 +127,7 @@
local icon = getglobal(prefix.."ItemButtonIcon")
local count = getglobal(prefix.."ItemButtonCount")
-- The enchant slot (id 7): the enchant name (P3) or the "will not be traded" note.
if id == BENILLA_MAX_TRADE_ITEMS then
if id == MAX_TRADE_ITEMS then
if name then
if enchantment then
nameText:SetText("|cff20ff20"..enchantment.."|r")
+102
View File
@@ -1530,6 +1530,91 @@
end,
}
-- ── The equip / auto-equip / use soulbind confirms (decision 1750) ────────────────────
-- ref GlobalStrings.lua l.1459 / l.4453 / l.2818 / l.465, fallback form.
EQUIP_NO_DROP = EQUIP_NO_DROP or "Equipping this item will bind it to you."
USE_NO_DROP = USE_NO_DROP or "Using this item will bind it to you."
OKAY = OKAY or "Okay"
CANCEL = CANCEL or "Cancel"
-- ref StaticPopup.lua l.612-647, verbatim, twice: EQUIP_BIND and AUTOEQUIP_BIND are the
-- SAME entry down to the last field — same text, same three hooks — and differ only in
-- which event raises them. Built from one factory rather than transcribed twice, because
-- two copies of a nine-field table is exactly how the two drift apart.
--
-- `data` is a 0-based index into the client's pending-equip array, NOT a slot (the RE
-- corrected benilla's assumption here). It is opaque: the event hands it out and these
-- hooks hand it straight back.
--
-- **OnCancel AND OnHide both cancel, and that is deliberate, not redundant.** OnHide is
-- what retires a record when `exclusive = 1` makes a second bind question hide this one;
-- without it the superseded item's pending record leaks. The doubling it causes on the
-- ordinary paths is harmless by construction — an Accept runs OnAccept then OnHide, so the
-- app sees [accept, cancel] for one index and the cancel finds the record already taken.
local function equip_bind_entry()
return {
text = EQUIP_NO_DROP,
button1 = OKAY,
button2 = CANCEL,
OnAccept = function(index)
EquipPendingItem(index)
end,
OnCancel = function(index)
CancelPendingEquip(index)
end,
OnHide = function(index)
CancelPendingEquip(index)
end,
timeout = 0,
exclusive = 1,
whileDead = 1,
hideOnEscape = 1,
}
end
StaticPopupDialogs["EQUIP_BIND"] = equip_bind_entry()
StaticPopupDialogs["AUTOEQUIP_BIND"] = equip_bind_entry()
-- ref StaticPopup.lua l.648-658. No index, no cancel hook: the use arm's pending state is
-- a single cell in the client rather than an array element, and declining just drops it.
StaticPopupDialogs["USE_BIND"] = {
text = USE_NO_DROP,
button1 = OKAY,
button2 = CANCEL,
OnAccept = function()
ConfirmBindOnUse()
end,
timeout = 0,
exclusive = 1,
hideOnEscape = 1,
}
-- ref UIParent.lua l.324-343 + l.356-360, the four arms on one driver. Each equip arm
-- hides the OTHER dialog first — the two questions are mutually exclusive by construction
-- (one item, one destination), and the hide is what cancels the record the new question
-- replaces.
function BenillaBindConfirmDriver_OnEvent(frame)
if event == "EQUIP_BIND_CONFIRM" then
StaticPopup_Hide("AUTOEQUIP_BIND")
local dialog = StaticPopup_Show("EQUIP_BIND")
if dialog then
dialog.data = arg1
end
elseif event == "AUTOEQUIP_BIND_CONFIRM" then
StaticPopup_Hide("EQUIP_BIND")
local dialog = StaticPopup_Show("AUTOEQUIP_BIND")
if dialog then
dialog.data = arg1
end
elseif event == "USE_BIND_CONFIRM" then
StaticPopup_Show("USE_BIND")
elseif event == "CURSOR_UPDATE" then
-- ref l.356-360: the player picked something else up, so the standing question is
-- stale. Both hides run their OnHide, which cancels the record.
StaticPopup_Hide("EQUIP_BIND")
StaticPopup_Hide("AUTOEQUIP_BIND")
end
end
-- BenillaDeleteItemConfirmDriver's OnEvent — DELETE_ITEM_CONFIRM(name, quality) is the
-- engine's world-drop of an item payload (decision 0216 §3, cursor.rs::world_drop) →
-- ref UIParent.lua:344-352, now whole: the ref's `arg2 >= 3` fork routes RARE-or-better
@@ -2215,4 +2300,21 @@
<OnEvent>BenillaDeleteItemConfirmDriver_OnEvent(self)</OnEvent>
</Scripts>
</Frame>
<!-- The equip/auto-equip/use soulbind confirms (decision 1750). Same shape and same reason as
the delete driver above: the reference handles all four of these events in UIParent.lua's
own OnEvent, and none of them names a frame, so they get a dedicated hidden listener. -->
<Frame name="BenillaBindConfirmDriver" hidden="true">
<Size><AbsDimension x="1" y="1"/></Size>
<Anchors><Anchor point="TOPLEFT"/></Anchors>
<Scripts>
<OnLoad>
self:RegisterEvent("EQUIP_BIND_CONFIRM")
self:RegisterEvent("AUTOEQUIP_BIND_CONFIRM")
self:RegisterEvent("USE_BIND_CONFIRM")
self:RegisterEvent("CURSOR_UPDATE")
</OnLoad>
<OnEvent>BenillaBindConfirmDriver_OnEvent(self)</OnEvent>
</Scripts>
</Frame>
</Ui>
+22 -1
View File
@@ -247,7 +247,15 @@ fn seed_bindings(
script.seed_binding_set(1, Some(store::resolve(&[])));
}
script.load_binding_set(1);
info!("bindings: {} commands registered", SPECS.len());
// The pair, not just the first half: `SPECS` ∪ `ABSENT` is the client's whole 1.12 command
// surface, and a log line that says only how many landed cannot say how much is left
// (decision 1745).
info!(
"bindings: {} of {} 1.12 commands registered ({} recorded absent)",
SPECS.len(),
SPECS.len() + commands::ABSENT.len(),
commands::ABSENT.len()
);
}
/// Read + parse one diff file; `None` when absent/unreadable (defaults).
@@ -378,6 +386,19 @@ fn sync_dispatch(script: Option<NonSendMut<UiScript>>, mut dispatch: ResMut<Bind
}
for (name, keys) in script.keybind_snapshot() {
let Some(&bound) = by_name.get(name.as_str()) else {
// A name with no home: either an uninstalled addon's row (1201 keeps those on
// purpose) or a 1.12 command this client does not implement. The second case is the
// one that used to mystify — a player carrying their own bindings over presses the
// key they have always pressed and nothing happens, with nothing anywhere saying
// why. `ABSENT` knows why, so say it (decision 1745).
if let Some(absent) = commands::ABSENT.iter().find(|a| a.name == name) {
if !keys.is_empty() {
warn!(
"bindings: {name} is bound to {keys:?} but benilla does not implement it \u{2014} {}",
absent.why
);
}
}
continue;
};
for key in keys {
+175 -14
View File
@@ -173,6 +173,49 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
F10 => "F10",
F11 => "F11",
F12 => "F12",
// **On a Mac keyboard the print-screen key IS F13 — read out of the Mac binary now, not
// inferred.** The 1.12.1 Mac slice's key table `0x5bf320` maps Mac virtual keycode `0x69`
// (F13) to `0x212`, the same `PRINTSCREEN` the Windows table reaches from `VK_SNAPSHOT`.
// (This arm shipped as an inference off `KEY_PRINTSCREEN_MAC = "F13"`; wow-re's
// `keycode-origin-law.md` settled it at the bytes.) macOS has no PrintScreen keycode at
// all, so without this the byte-real `PRINTSCREEN SCREENSHOT` default is a dead key on
// every Mac — not fidelity, a broken key.
#[cfg(target_os = "macos")]
F13 => "PRINTSCREEN",
// **F13-F24: bindable strings, and `F16` a real reference code.**
// `IsValidBindingKeyString` arm 3 takes `F` + digits with no ceiling (`0x846c04`), and the
// Mac table really does reach `F16` — keycode `0x6A` → `0x30d`. Naming only F1-F12 meant
// `SetBinding("F16", …)` stored a chord this codec could never press: the key vanished
// into `unpressable chord` at the next seed.
//
// **Above F12 this is a benilla extension, deliberately.** The reference cannot reach
// them: Windows' fixed table stops at `VK_F12` and everything past it falls to
// `MapVirtualKeyA(vk, MAPVK_VK_TO_CHAR)`, which returns 0 for an F-key and drops the
// message outright. That is a 2004 lookup table's silence, not a rule about keys.
#[cfg(not(target_os = "macos"))]
F13 => "F13",
// **F14 and F15 are the Mac's ScrollLock and Pause, and those two are unbindable.** The
// Mac table sends keycode `0x6B` (F14) to `0x210` and `0x71` (F15) to `0x211` — the exact
// pair the namer calls `UNKNOWN` — so on a Mac they are refused for the same reason
// ScrollLock and Pause are refused below: they ARE those keys. (1.12's
// `KEY_SCROLLLOCK_MAC` / `KEY_PAUSE_MAC` are dead labels for the same reason; neither
// binary carries a `SCROLLLOCK` or `PAUSE` string at all.)
//
// The line that draws: a key the reference deliberately maps to `UNKNOWN` stays
// unbindable; a key it simply never mapped, we may name.
#[cfg(not(target_os = "macos"))]
F14 => "F14",
#[cfg(not(target_os = "macos"))]
F15 => "F15",
F16 => "F16",
F17 => "F17",
F18 => "F18",
F19 => "F19",
F20 => "F20",
F21 => "F21",
F22 => "F22",
F23 => "F23",
F24 => "F24",
Space => "SPACE",
Tab => "TAB",
Enter | NumpadEnter => "ENTER",
@@ -203,20 +246,12 @@ pub(crate) fn key_token(k: KeyCode) -> Option<&'static str> {
NumpadDivide => "NUMPADDIVIDE",
NumpadMultiply => "NUMPADMULTIPLY",
NumpadDecimal => "NUMPADDECIMAL",
// The Mac numeric keypad's `=` key. It is in the reference's namer (`0x30c`) and in
// `IsValidBindingKeyString`'s 26-name table, and it was in OUR validator too — but not
// here, so it was the one token `SetBinding` accepted and the dispatcher could not press.
NumpadEqual => "NUMPADEQUALS",
NumLock => "NUMLOCK",
PrintScreen => "PRINTSCREEN",
// **On a Mac keyboard the print-screen key IS F13, and the reference says so itself.**
// 1.12 ships `KEY_PRINTSCREEN_MAC = "F13"` beside a whole family of Mac key-label
// overrides (GlobalStrings.lua:2476-2526 — Backspace→Delete, Insert→Help, NumLock→Clear,
// Pause→F15, ScrollLock→F14), which is the Mac client mapping its physical keys onto the
// PC binding TOKENS and relabelling them for display. macOS has no PrintScreen keycode at
// all — a PC keyboard's PrtSc arrives as F13 — so without this the faithful `PRINTSCREEN`
// default is a dead binding on every Mac, which is not fidelity, it is a broken key.
// `_MAC` is a label table, so this is the mapping the reference implies rather than one it
// was read out of; the falsifier is cheap (bind F13 in the Keybindings page — it must
// write `PRINTSCREEN`, not `F13`).
#[cfg(target_os = "macos")]
F13 => "PRINTSCREEN",
// ScrollLock and Pause are deliberately absent: the reference's namer calls their key
// codes (`0x210`/`0x211`) `UNKNOWN`, so they are unbindable there (wow-re
// `keybinding-dispatch-law.md` §2.3), and `IsValidBindingKeyString` would refuse the
@@ -263,10 +298,25 @@ pub(crate) fn mouse_token(b: MouseButton) -> Option<&'static str> {
MouseButton::Middle => "BUTTON3",
MouseButton::Forward => "BUTTON4",
MouseButton::Back => "BUTTON5",
MouseButton::Other(_) => return None,
// **The extra buttons on a real mouse.** The reference names them the same way it names
// the first five and then some: `0x4b6aa0`'s fallback arm bit-scans from bit 3 upward and
// `sprintf("BUTTON%{d}", n)`, and `IsValidBindingKeyString` arm 3 takes `BUTTON` +
// digits with no ceiling — so `BUTTON6` was always a storable, listable, *unpressable*
// chord here. winit hands the platform's own button number through `Other(n)`, and on
// macOS that is `NSEvent.buttonNumber` — 0-4 already spoken for by the five named
// variants, so the sixth physical button arrives as `Other(5)` and is `BUTTON6`.
MouseButton::Other(n) => return EXTRA_BUTTONS.get(usize::from(n).wrapping_sub(5)).copied(),
})
}
/// `BUTTON6`…`BUTTON20`, indexed by `Other(n) - 5`. A bounded table rather than a formatted
/// string because these names are `&'static str` on both sides of the codec; twenty buttons is
/// past every mouse anyone ships and the reference's own namer stops at the first gap too.
const EXTRA_BUTTONS: &[&str] = &[
"BUTTON6", "BUTTON7", "BUTTON8", "BUTTON9", "BUTTON10", "BUTTON11", "BUTTON12", "BUTTON13",
"BUTTON14", "BUTTON15", "BUTTON16", "BUTTON17", "BUTTON18", "BUTTON19", "BUTTON20",
];
/// Token → base input (the parse side of [`key_token`]/[`mouse_token`], plus the wheel pair).
fn token_key(t: &str) -> Option<BindKey> {
use KeyCode::*;
@@ -276,7 +326,11 @@ fn token_key(t: &str) -> Option<BindKey> {
"BUTTON3" => Some(MouseButton::Middle),
"BUTTON4" => Some(MouseButton::Forward),
"BUTTON5" => Some(MouseButton::Back),
_ => None,
_ => EXTRA_BUTTONS
.iter()
.position(|n| *n == t)
.and_then(|i| u16::try_from(i + 5).ok())
.map(MouseButton::Other),
} {
return Some(BindKey::Mouse(b));
}
@@ -334,6 +388,18 @@ fn token_key(t: &str) -> Option<BindKey> {
"F10" => F10,
"F11" => F11,
"F12" => F12,
"F13" => F13,
"F14" => F14,
"F15" => F15,
"F16" => F16,
"F17" => F17,
"F18" => F18,
"F19" => F19,
"F20" => F20,
"F21" => F21,
"F22" => F22,
"F23" => F23,
"F24" => F24,
"SPACE" => Space,
"TAB" => Tab,
"ENTER" => Enter,
@@ -364,6 +430,7 @@ fn token_key(t: &str) -> Option<BindKey> {
"NUMPADDIVIDE" => NumpadDivide,
"NUMPADMULTIPLY" => NumpadMultiply,
"NUMPADDECIMAL" => NumpadDecimal,
"NUMPADEQUALS" => NumpadEqual,
"NUMLOCK" => NumLock,
"PRINTSCREEN" => PrintScreen,
"CAPSLOCK" => CapsLock,
@@ -380,6 +447,22 @@ fn token_key(t: &str) -> Option<BindKey> {
"`" => Backquote,
_ => return None,
};
// **Through the alias fold, and only if the namer agrees.** Two invariants, both of which
// this codec broke somewhere before 1745:
//
// 1. A chord parsed from a token must compare equal to the one a press BUILDS, and a press is
// normalized ([`normalize_key`]) — so `ENTER` has to land on the same `KeyCode` the
// numpad's Enter folds to.
// 2. A token this side accepts must be one [`key_token`] would produce, on THIS platform.
// Otherwise the two tables drift: on a Mac `F13` is the print-screen key and `F14`/`F15`
// are the unbindable ScrollLock/Pause pair, so parsing those strings to their raw
// `KeyCode`s would make a chord the capture arm can never write and the window can never
// show, reachable only by hand-editing the file. Asking the namer closes it structurally
// rather than by remembering to cfg both sides.
let k = normalize_key(k);
if key_token(k) != Some(t) {
return None;
}
Some(BindKey::Key(k))
}
@@ -468,6 +551,84 @@ mod tests {
assert_eq!(key_token(KeyCode::Pause), None);
}
/// **And the way back — the direction that actually bit.** The test above walks the codec's
/// output into the validator; nothing walked the validator's *input space* back into the
/// codec, and three families lived in the gap: `NUMPADEQUALS` (in the reference's namer at
/// `0x30c`, in the 26-name table, in our validator — and nowhere here), `F13`-`F24`
/// (`IsValidBindingKeyString` arm 3 is `F` + digits with no ceiling), and `BUTTON6`+ (arm 3
/// again, and `0x4b6aa0`'s bit-scan fallback). Each was a key `SetBinding` accepted, the
/// window listed, the file saved — and the dispatcher could never press, so it came back as
/// `bindings: <cmd>: unpressable chord` and the player's key did nothing.
///
/// The accept set is infinite (any single character, `F`/`NUMPAD`/`BUTTON` + any digits), so
/// this pins the families that name a key a real keyboard or mouse HAS. Past those — `F25`,
/// `BUTTON21`, `NUMPAD11` — the validator still accepts and the codec still refuses, which is
/// correct and deliberate: there is no physical key to press, and inventing a `KeyCode` for
/// one would be inventing hardware.
#[test]
fn every_token_setbinding_accepts_for_a_real_key_is_one_the_codec_can_press() {
use benilla_ui::script::keybind::normalize_binding_key;
let mut tokens: Vec<String> = Vec::new();
tokens.extend((1..=24).map(|n| format!("F{n}")));
// On a Mac those three are other keys: F13 IS print-screen (`0x5bf320`: keycode `0x69` →
// `0x212`) and F14/F15 are the unbindable ScrollLock/Pause pair (`0x210`/`0x211`). The
// codec refuses them there on purpose, so the list has to as well.
if cfg!(target_os = "macos") {
tokens.retain(|t| !matches!(t.as_str(), "F13" | "F14" | "F15"));
}
tokens.extend((0..=9).map(|n| format!("NUMPAD{n}")));
tokens.extend((1..=20).map(|n| format!("BUTTON{n}")));
tokens.extend(
[
"SPACE",
"NUMPADPLUS",
"NUMPADMINUS",
"NUMPADMULTIPLY",
"NUMPADDIVIDE",
"NUMPADDECIMAL",
"NUMPADEQUALS",
"ESCAPE",
"ENTER",
"BACKSPACE",
"TAB",
"LEFT",
"UP",
"RIGHT",
"DOWN",
"INSERT",
"DELETE",
"HOME",
"END",
"PAGEUP",
"PAGEDOWN",
"NUMLOCK",
"CAPSLOCK",
"PRINTSCREEN",
"MOUSEWHEELDOWN",
"MOUSEWHEELUP",
]
.map(str::to_string),
);
tokens.extend(
"ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-=[]\\;',./`"
.chars()
.map(String::from),
);
for token in tokens {
assert_eq!(
normalize_binding_key(&token).as_deref(),
Some(token.as_str()),
"the test's own list has a token SetBinding refuses: '{token}'"
);
assert!(
Chord::parse(&token).is_some(),
"'{token}' is a key SetBinding stores and this codec cannot press"
);
}
}
#[test]
fn the_codec_round_trips_every_keyboard_token() {
// Every key with a token parses back to itself (through the alias fold).
File diff suppressed because it is too large Load Diff
+94 -1
View File
@@ -103,6 +103,85 @@ pub(crate) const REGISTERED: &[(&str, &str)] = &[
// pitch invert). `autoLootDefault` is era's — no 1.12 CVar exists, vanilla only had the
// shift gesture — default off, like era's engine registrar.
("deselectOnClick", "1"),
// 1.12's own `autoSelfCast` (`0x870dc0`) — a friendly cast that binds nothing falls back to
// the caster. The behaviour has been here since the cast arm landed, welded to a Resource
// default; it is a CVar now because 1.12's `TOGGLEAUTOSELFCAST` binding is `GetCVar`/`SetCVar`
// over this exact name and there was nothing for it to toggle (decision 1745).
//
// **benilla ships it ON and the reference registers "0"** — a named deviation that predates
// this row (`cast_target::AutoSelfCast`): with it off, an unbindable friendly cast falls into
// the reference's targeting-cursor machine, which is unmodeled, leaving no path at all. Flip
// to the reference's default when that machine lands.
("autoSelfCast", "1"),
// The five saved camera views and the live index (decision 1745) — the reference's own
// sixteen names and its own shipped default strings, both read out of `WoW.exe` and owned by
// [`crate::player::camera_view`], which is also the only writer. Registered here so they are
// ordinary CVars: persisted as a diff like everything else, readable from a macro, and
// reachable by `SetCVar` — which is what makes a `SaveView` survive a restart.
(
crate::player::camera_view::CVAR_ACTIVE_VIEW,
crate::player::camera_view::ACTIVE_VIEW_DEFAULT,
),
(
crate::player::camera_view::VIEW_CVARS[0][0],
crate::player::camera_view::VIEW_DEFAULTS[0][0],
),
(
crate::player::camera_view::VIEW_CVARS[0][1],
crate::player::camera_view::VIEW_DEFAULTS[0][1],
),
(
crate::player::camera_view::VIEW_CVARS[0][2],
crate::player::camera_view::VIEW_DEFAULTS[0][2],
),
(
crate::player::camera_view::VIEW_CVARS[1][0],
crate::player::camera_view::VIEW_DEFAULTS[1][0],
),
(
crate::player::camera_view::VIEW_CVARS[1][1],
crate::player::camera_view::VIEW_DEFAULTS[1][1],
),
(
crate::player::camera_view::VIEW_CVARS[1][2],
crate::player::camera_view::VIEW_DEFAULTS[1][2],
),
(
crate::player::camera_view::VIEW_CVARS[2][0],
crate::player::camera_view::VIEW_DEFAULTS[2][0],
),
(
crate::player::camera_view::VIEW_CVARS[2][1],
crate::player::camera_view::VIEW_DEFAULTS[2][1],
),
(
crate::player::camera_view::VIEW_CVARS[2][2],
crate::player::camera_view::VIEW_DEFAULTS[2][2],
),
(
crate::player::camera_view::VIEW_CVARS[3][0],
crate::player::camera_view::VIEW_DEFAULTS[3][0],
),
(
crate::player::camera_view::VIEW_CVARS[3][1],
crate::player::camera_view::VIEW_DEFAULTS[3][1],
),
(
crate::player::camera_view::VIEW_CVARS[3][2],
crate::player::camera_view::VIEW_DEFAULTS[3][2],
),
(
crate::player::camera_view::VIEW_CVARS[4][0],
crate::player::camera_view::VIEW_DEFAULTS[4][0],
),
(
crate::player::camera_view::VIEW_CVARS[4][1],
crate::player::camera_view::VIEW_DEFAULTS[4][1],
),
(
crate::player::camera_view::VIEW_CVARS[4][2],
crate::player::camera_view::VIEW_DEFAULTS[4][2],
),
("mouseInvertPitch", "0"),
("autoLootDefault", "0"),
// The overhead-name trio (0992): 1.12's own UnitName* CVars (UIOptionsFrame.lua indices
@@ -488,6 +567,7 @@ pub(crate) struct KnobParams<'w> {
tex_filter: ResMut<'w, benilla_assets::TexFilterSetting>,
pane_rate: ResMut<'w, PaneRate>,
guild_notify: ResMut<'w, crate::ui_guild::GuildMemberNotify>,
auto_self_cast: ResMut<'w, crate::ui_action::AutoSelfCast>,
realmlist: ResMut<'w, crate::realmlist::Realmlist>,
}
@@ -521,6 +601,7 @@ impl KnobParams<'_> {
tex_filter: &mut self.tex_filter,
pane_rate: &mut self.pane_rate,
guild_notify: &mut self.guild_notify,
auto_self_cast: &mut self.auto_self_cast,
realmlist: &mut self.realmlist,
}
}
@@ -550,6 +631,7 @@ struct Knobs<'a> {
tex_filter: &'a mut benilla_assets::TexFilterSetting,
pane_rate: &'a mut PaneRate,
guild_notify: &'a mut crate::ui_guild::GuildMemberNotify,
auto_self_cast: &'a mut crate::ui_action::AutoSelfCast,
realmlist: &'a mut crate::realmlist::Realmlist,
}
@@ -599,6 +681,12 @@ fn apply_to_knobs(name: &str, value: &str, knobs: &mut Knobs) -> bool {
"uiscale" => knobs.scale.0 = v.clamp(0.5, 1.5),
"farclip" => knobs.view.farclip = v.clamp(*FARCLIP_RANGE.start(), *FARCLIP_RANGE.end()),
"deselectonclick" => knobs.click.deselect_on_click = v != 0.0,
"autoselfcast" => knobs.auto_self_cast.0 = v != 0.0,
// The sixteen camera-view CVars have no knob to apply to: `CameraViews` is their writer,
// not their reader (it seeds itself from the persisted file at startup, and `SaveView`
// writes back). They are claimed here so the table's own "not a knob this build knows"
// warning stays meaningful — an unclaimed name would look like a typo every session.
_ if crate::player::camera_view::is_view_cvar(name) => {}
"mouseinvertpitch" => knobs.look.invert_pitch = v != 0.0,
"cameradistancemaxfactor" => knobs.zoom.set_factor(v),
// The three stops are 1 Smart / 2 Always / 3 Never; anything else reads as the registrar
@@ -916,6 +1004,7 @@ fn sync_cvars(
render_scale,
pane_rate,
guild_notify,
auto_self_cast,
msaa,
msaa_formats,
tex_filter,
@@ -952,7 +1041,7 @@ fn sync_cvars(
.collect(),
);
let flag = |b: bool| if b { "1" } else { "0" }.to_string();
let session: [(&str, String); 40] = [
let session: [(&str, String); 41] = [
("MasterVolume", sound.master.to_string()),
("SoundVolume", sound.sfx.to_string()),
("MusicVolume", sound.music.to_string()),
@@ -965,6 +1054,7 @@ fn sync_cvars(
("uiScale", scale.0.to_string()),
("farclip", view.farclip.to_string()),
("deselectOnClick", flag(click.deselect_on_click)),
("autoSelfCast", flag(auto_self_cast.0)),
("mouseInvertPitch", flag(look.invert_pitch)),
("mousespeed", look.sensitivity.to_string()),
("cameraDistanceMaxFactor", zoom.factor().to_string()),
@@ -1323,8 +1413,10 @@ mod tests {
// a shell that happens to export it must not decide what this test asserts against.
let mut realmlist =
crate::realmlist::Realmlist::unpinned(crate::realmlist::DEFAULT_REALMLIST);
let mut auto_self_cast = crate::ui_action::AutoSelfCast::default();
let mut knobs = Knobs {
sound: &mut sound,
auto_self_cast: &mut auto_self_cast,
scale: &mut scale,
view: &mut view,
look: &mut look,
@@ -1618,6 +1710,7 @@ mod tests {
))
.init_resource::<PaneRate>()
.init_resource::<crate::ui_guild::GuildMemberNotify>()
.init_resource::<crate::ui_action::AutoSelfCast>()
.add_plugins(CvarPlugin);
app.insert_non_send_resource(UiScript::new().unwrap());
app
+12 -1
View File
@@ -514,6 +514,10 @@ fn drain_death(
net: Res<NetCommands>,
targeting: crate::ui_action::cast_target::CastTargeting,
mut ladder: crate::ui_action::CastLadder,
// The soulstone leg is a real arm-290 caller in the reference's own census (wow-re
// `bind-confirm-law.md`: 290 ← UseSoulstone · UseInventoryItem · UseAction · UseContainerItem),
// so it goes through the shared send carrying the same bind gate every other item use does.
mut gate: crate::ui_bind_confirm::BindGate,
) {
let Some(mut script) = script else {
return;
@@ -586,7 +590,14 @@ fn drain_death(
on_object: None,
is_charter: false,
};
crate::ui_items::send_item_use(it, &targeting.context(), &mut ladder);
crate::ui_items::send_item_use(
it,
&targeting.context(),
&mut ladder,
&mut script,
&mut gate,
false,
);
Ok(())
}
// Nothing to spend — the reference's silent return (it pushes nothing and
+1
View File
@@ -110,6 +110,7 @@ mod ui_action;
mod ui_auction;
mod ui_aura;
mod ui_bank;
mod ui_bind_confirm;
mod ui_binder;
mod ui_cast;
mod ui_char;
+5
View File
@@ -51,6 +51,10 @@ mod world_focus;
// The remembered camera pose (decision 1131) — it lives inside `player/` so it can read the rig's
// own `pub(super)` fields instead of widening them for a module outside.
mod camera_saved;
// The five NAMED camera poses the player can jump between (decision 1745) — `camera_saved`'s
// complement: that one remembers where you left the camera, this one where you decided it should
// be able to go. Same reason for living inside `player/`: it writes the rig's `pub(super)` fields.
pub(crate) mod camera_view;
mod drunk;
// Which single unit the client embodies (decision 1277) — the `Embodied` marker's owner.
mod embody;
@@ -248,6 +252,7 @@ impl Plugin for PlayerPlugin {
fn build(&self, app: &mut App) {
follow::plugin(app);
camera_saved::plugin(app);
camera_view::plugin(app);
// 1160's wire (a), both directions (see `world_focus`): the game answers the world's
// "where do I stream from" before the stream stage, and reads the residency the world
// publishes after it to end its own post-snap hold.
@@ -73,12 +73,19 @@ pub(super) struct CameraPoseFile {
/// UP.** Our forward is `Quat::from_euler(YXZ, yaw, pitch, 0)` applied to `−Z`, whose Y component is
/// `+sin(pitch)`, and `camera::control` seats the camera at `pivot − forward·distance`. Two opposite
/// conventions meeting, which is exactly what these two functions exist to bridge.
fn pitch_from_file(degrees: f32) -> f32 {
///
/// **Two carriers, one encoding.** The saved camera views ([`super::camera_view`]) store their
/// pitch in the same units through a different door — the archived `cameraPitch{,A..D}` CVars,
/// whose writer `0x50f990` scales the *same* internal field by `180/pi` before its `"%f"`
/// (`0x50f9dc`) and whose loader `0x50fb80` multiplies back by `pi/180` (`0x50fba3`). So these two
/// functions are the app's single bridge for both, and "file" in their names is the first carrier,
/// not the only one.
pub(super) fn pitch_from_file(degrees: f32) -> f32 {
(-degrees.to_radians()).clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT)
}
/// The live [`FlyCam::pitch`] → the persisted `cameraPitch` (see [`pitch_from_file`]).
fn pitch_to_file(radians: f32) -> f32 {
pub(super) fn pitch_to_file(radians: f32) -> f32 {
-radians.to_degrees()
}
@@ -0,0 +1,672 @@
//! The **five camera views** — `SetView` / `SaveView` / `ResetView` / `NextView` / `PrevView`, and
//! `FlipCameraYaw`. The engine half of [`benilla_ui::script::CameraViewRequest`]; the Lua half (and
//! the argument ABI) is `benilla-ui`'s `script::camera_view`.
//!
//! [`super::camera_saved`]'s neighbour, and its complement: that file remembers the **one live
//! pose** per character; this one remembers the **five named poses** the player can jump between.
//! The reference splits them exactly the same way and for the same reason — one is where you left
//! the camera, the other is where you decided it should be able to go.
//!
//! # The five views, byte-real
//!
//! The reference's camera ctor reads the `cameraView` CVar and indexes a five-row table of default
//! *strings* at `0x84f488` (stride 12, three `const char*` per row — distance, pitch, yaw), with
//! the row names at `0x84f848`. Read straight out of `WoW.exe` 5875 (decision 1138 §2 records the
//! first two columns; the third is dumped here for the first time):
//!
//! | `SetView` arg | internal | name | distance | pitch | yaw |
//! |---|---|---|---|---|---|
//! | 1 | 0 | `FIRST_PERSON` | `0.0` | `0.0` | `0.0` |
//! | 2 | 1 | `THIRD_PERSON_A` | `5.55` | `10.0` | `0.0` |
//! | 3 | 2 | `THIRD_PERSON_B` | `5.55` | `20.0` | `0.0` |
//! | 4 | 3 | `THIRD_PERSON_C` | `13.88` | `30.0` | `0.0` |
//! | 5 | 4 | `THIRD_PERSON_D` | `13.88` | `10.0` | `0.0` |
//!
//! The pitch column is **degrees, positive = looking DOWN** — the reference's own convention, which
//! is the opposite of [`FlyCam::pitch`]'s. Decision 1138 is the whole story; the conversion is
//! [`super::camera_saved::pitch_from_file`] and this module does not grow a second one.
//!
//! View 1 is `distance 0.0`, and that is **reachable, not a rounding of "very close"**:
//! [`CAM_DIST_MIN`] is `0.0`, the eye sits on the framing pivot (inside the head) and the avatar
//! fades out through [`benilla_world::model_fade::self_model_fade_alpha`]. `SetView(1)` is real
//! first person, not a clamp to some near stop.
//!
//! # Persistence — the reference's own fifteen CVars
//!
//! 1138 §2 recorded that the saved views survive a restart as archived `config.wtf` CVars and left
//! the name→view mapping unpinned. It is pinned now, at `0x50b9b0`'s 5x3 registration loop, and the
//! names are the reference's:
//!
//! ```text
//! 50b9c1 SStrCopy(buf, 0x84fdc0) ; "camera"
//! 50b9d3 SStrCat (buf, [esi+0x84f468]) ; "Distance" | "Pitch" | "Yaw" (inner, esi 0..12)
//! 50b9e1 SStrCat (buf, [ebx]) ; "" | "A" | "B" | "C" | "D" (outer, per view)
//! 50b9ec default = [esi + edi + 0x84f488] ; the SAME table as the ctor's, 12·view + 4·field
//! 50ba07 record -> [esi + edi + 0xbe0f7c]
//! ```
//!
//! → `cameraDistance`/`cameraPitch`/`cameraYaw` for view 0, and `…A`/`…B`/`…C`/`…D` for views 1–4;
//! the active view is `cameraView` (`0x84fdc8`, registrar default `"1"` — an *internal* index, so
//! the shipped view is `THIRD_PERSON_A`, i.e. Lua `SetView(2)`). Verified by reading the tables out
//! of the binary, not inferred from the loop shape. benilla registers exactly those sixteen names
//! with exactly those defaults ([`crate::cvars::REGISTERED`], welded by test below) — 0954's CVar
//! store *is* our persistence, so being faithful here costs nothing and buys a `config.toml` a
//! player can read against a real `config.wtf`.
//!
//! **No host knob** — the `lastCharacterIndex`/`checkAddonVersion` posture in [`crate::cvars`]:
//! [`CameraViews`] is the authority, seeded once from the persisted values at startup and writing
//! every change back through `set_cvar_engine` (the minimap-zoom pattern, so the write dirties
//! `config.toml`). A live `SetCVar("cameraPitchB", …)` therefore does *not* reach the in-memory
//! slot — which is the reference's behaviour too: `0x50fb80` loads the slot array from the CVar
//! records at camera construction and nothing re-reads them afterwards.
//!
//! # Three things the reference does that a reader will expect it not to
//!
//! - **`SaveView(1)` and `ResetView(1)` work.** All three indexed entry points take `1..=5`
//! (`0x50b5d2`/`0x50b626`/`0x50b666`: `test eax,eax; jle` then `cmp eax,5; jg`), and `0x50fa30`
//! — SaveView's body — has no view-0 gate. 1.12 ships no `SAVEVIEW1`/`RESETVIEW1` *binding*,
//! which is a `Bindings.xml` decision; a macro reaches what no key can.
//! - **`NextView`/`PrevView` do not wrap.** `0x50faa0` is `eax = view + 1; cmp eax,5; jge <ret>`,
//! `0x50fac0` is `test eax,eax; jle <ret>; dec eax`. Holding `END` walks 1→2→3→4→5 and stops.
//! - **The saved yaw is an OFFSET from the subject's facing, not a world angle.** The reference's
//! yaw channel `cam+0xf0` is relative while `cam+0xa4 == 0` and the final view resolver re-adds
//! the followed unit's facing every frame (`0x50f7f2`); `0x512e90` adds it explicitly on the
//! absolute-mode leg (`0x512fff`). Ours stores `wrap_pi(cam.yaw − face_yaw)` for the same reason.
//!
//! # The active view is remembered, and not applied at startup
//!
//! [`load_saved_views`] seeds [`CameraViews`] and stops — it never calls [`apply`]. So a
//! `cameraView` of `2` means "`NextView` steps from there", not "open the camera at view 2"; the
//! pose you actually get is the one [`super::camera_saved`] restores, which is where you left the
//! camera. That is not a divergence in *effect*: the reference builds its camera from
//! `cameraView`'s row at construction and then `camera-settings.txt`'s reader lands distance and
//! pitch over the top of it at UI load (1138), so the remembered pose wins there too. It is stated
//! because the two modules are neighbours and a reader will otherwise expect them to fight.
//!
//! # Two deliberate divergences, both stated
//!
//! - **`ResetView` writes the CVars back to their defaults; the reference does not.** `0x50fae0`
//! re-parses the default strings into the slot array and re-applies the view, and touches no
//! CVar — so in the real client a reset comes *back* at the next launch, out of the value
//! `SaveView` archived. Here the CVar store is the persistence (0954), so a reset that does not
//! persist is a bug rather than a quirk worth aping; writing the default string verbatim also
//! makes [`crate::cvars`]'s diff-shaped file drop the key entirely.
//! - **`SetView` glides the distance and snaps pitch and yaw.** The reference arms all three of its
//! smooth channels (`0x513189`/`0x5131a3`/`0x5131b8`, on `cameraViewBlendStyle`'s default `1`).
//! benilla's rig has exactly one such channel — the wheel-zoom glide at `cameraDistanceMoveSpeed`
//! — and setting `target_distance` rides it for free; there is no pitch or yaw channel to arm, so
//! those land immediately. A second and a third channel is a real piece of work and a *look*
//! call; it is named here rather than half-built.
use bevy::prelude::*;
use benilla_ui::script::{CameraViewRequest, UiScript, CAMERA_VIEW_COUNT};
use benilla_world::view::WorldCamera;
use crate::creature_anim::wrap_pi;
use super::camera::{CameraControl, FlyCam, CAM_DIST_MAX, CAM_DIST_MIN, CAM_PITCH_LIMIT};
use super::camera_saved::{pitch_from_file, pitch_to_file};
use super::Player;
/// How many views there are — the reference's default table `0x84f488` is five rows, and every
/// entry point is bounded by it. Named through the UI crate's constant so the two ends of the
/// queue cannot disagree about the range the Lua side already checked.
const VIEW_COUNT: usize = CAMERA_VIEW_COUNT as usize;
/// The registered CVar name of the active view — the reference's own (`0x84fdc8`), holding the
/// **internal** index as `"%d"` (`0x512ef1`, format `0x835154`).
pub(crate) const CVAR_ACTIVE_VIEW: &str = "cameraView";
/// The fifteen saved-view CVar names, `[view][field]` with field = distance, pitch, yaw —
/// `"camera" + {"Distance","Pitch","Yaw"} + {"","A","B","C","D"}`, composed at `0x50b9b0` (see the
/// module header). Written out rather than composed so [`crate::cvars::REGISTERED`] can hold the
/// same `&'static str`s and a test can weld the two tables together.
pub(crate) const VIEW_CVARS: [[&str; 3]; VIEW_COUNT] = [
["cameraDistance", "cameraPitch", "cameraYaw"],
["cameraDistanceA", "cameraPitchA", "cameraYawA"],
["cameraDistanceB", "cameraPitchB", "cameraYawB"],
["cameraDistanceC", "cameraPitchC", "cameraYawC"],
["cameraDistanceD", "cameraPitchD", "cameraYawD"],
];
/// The shipped defaults, **as the reference's own strings** — `0x84f488`, read out of `WoW.exe`
/// 5875. Strings, not floats, for two reasons: they are what the binary stores (its ctor calls
/// `SStrToFloat` on them), and [`crate::cvars`]'s file is a diff against the registered default
/// *string*, so a reset can only strip the key by writing this exact text back.
pub(crate) const VIEW_DEFAULTS: [[&str; 3]; VIEW_COUNT] = [
["0.0", "0.0", "0.0"],
["5.55", "10.0", "0.0"],
["5.55", "20.0", "0.0"],
["13.88", "30.0", "0.0"],
["13.88", "10.0", "0.0"],
];
/// The registrar default of [`CVAR_ACTIVE_VIEW`] — `"1"` (`[0x84f484]`), the *internal* index, so
/// the client opens on `THIRD_PERSON_A`.
pub(crate) const ACTIVE_VIEW_DEFAULT: &str = "1";
/// True if `name` is one of this module's sixteen CVars (case-insensitively, like every CVar
/// lookup). [`crate::cvars::apply_to_knobs`] asks so a `SaveView` marks the config dirty and the
/// view is actually persisted; there is nothing to apply, because [`CameraViews`] is the writer.
pub(crate) fn is_view_cvar(name: &str) -> bool {
name.eq_ignore_ascii_case(CVAR_ACTIVE_VIEW)
|| VIEW_CVARS
.iter()
.flatten()
.any(|n| n.eq_ignore_ascii_case(name))
}
/// One view's pose, in **benilla's** units: yards, and radians with [`FlyCam::pitch`]'s
/// positive-is-UP sign. `yaw` is an offset from the subject's facing (see the module header), so a
/// view whose yaw is `0.0` is "directly behind" whichever way the character is pointing.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct ViewPose {
pub(crate) distance: f32,
pub(crate) pitch: f32,
pub(crate) yaw: f32,
}
impl ViewPose {
/// A pose out of the reference's three stored strings — the units the CVars (and the default
/// table) carry: yards, degrees-positive-down, degrees. Clamped on the way in, at this one
/// edge, so nothing downstream can be handed an unreachable view: the distance to the zoom
/// range and the pitch to +-89 deg (the reference's own CVar validators are `0x50b310`'s
/// `[0, 50]` and `0x50b380`'s +-89 deg; ours are the rig's, which is the tighter, honest pair).
///
/// The **yaw is wrapped rather than range-checked**: the reference's validator is `[0, 360]`
/// (`0x50b3c0`) while the value its `SaveView` writes is a signed offset, so both encodings are
/// in the wild and `wrap_pi` reads either.
fn from_reference(distance: f32, pitch_deg: f32, yaw_deg: f32) -> Self {
Self {
distance: distance.clamp(CAM_DIST_MIN, CAM_DIST_MAX),
// The one bridge, shared with the per-character pose file (decision 1138).
pitch: pitch_from_file(pitch_deg),
yaw: wrap_pi(yaw_deg.to_radians()),
}
}
/// The three strings to persist, in the reference's own `"%f"` (`0x50f9f3`, format `0x835160`)
/// — six decimals, which is also what [`super::camera_saved`] writes for the same numbers.
fn to_reference(self) -> [String; 3] {
[
format!("{:.6}", self.distance),
format!("{:.6}", pitch_to_file(self.pitch)),
format!("{:.6}", self.yaw.to_degrees()),
]
}
/// The shipped default for one view, parsed from the reference's own default strings.
fn shipped(view: usize) -> Self {
let row = VIEW_DEFAULTS[view];
let parse = |s: &str| {
s.parse::<f32>()
.expect("the reference's default strings are floats")
};
Self::from_reference(parse(row[0]), parse(row[1]), parse(row[2]))
}
}
/// The five views and which one is live — the reference's `[cam+0xb0 .. +0xdc]` slot array and its
/// `[cam+0xac]` index, as a resource.
#[derive(Resource, Debug, PartialEq)]
pub(crate) struct CameraViews {
views: [ViewPose; VIEW_COUNT],
/// The live view, `0..VIEW_COUNT` — the reference's `cam+0xac`, persisted as [`CVAR_ACTIVE_VIEW`].
current: usize,
}
impl Default for CameraViews {
fn default() -> Self {
Self {
views: std::array::from_fn(ViewPose::shipped),
current: ACTIVE_VIEW_DEFAULT
.parse::<usize>()
.expect("the registrar default is an index"),
}
}
}
impl CameraViews {
/// The pose of one view.
fn pose(&self, view: usize) -> ViewPose {
self.views[view]
}
/// `SetView` — make `view` live and hand back the pose to seat. Returns whether the index
/// actually moved, which is the reference's own gate on writing [`CVAR_ACTIVE_VIEW`]
/// (`0x512ee6 cmp [esi+0xac],edi; je`).
fn set(&mut self, view: usize) -> (ViewPose, bool) {
let moved = self.current != view;
self.current = view;
(self.views[view], moved)
}
/// `SaveView` — store a live pose into `view`. The reference reads the camera's three *target*
/// fields here (`0x50fa30`: `+0x198` distance, `+0x1e0` pitch, `+0x210` yaw), never the live
/// ones — which is why a save made with your back to a wall keeps your chosen zoom and not the
/// collision-pulled arm.
fn save(&mut self, view: usize, pose: ViewPose) {
self.views[view] = pose;
}
/// `ResetView` — put `view` back to its shipped default. `true` when it was the live view, in
/// which case the reference re-applies it on the spot (`0x50fb5b`) and so do we.
fn reset(&mut self, view: usize) -> bool {
self.views[view] = ViewPose::shipped(view);
self.current == view
}
/// `NextView` / `PrevView` — the neighbouring view, or `None` at either end. **No wrap**:
/// `0x50faa0`'s `cmp eax,5; jge <ret>` and `0x50fac0`'s `test eax,eax; jle <ret>`.
fn step(&self, forward: bool) -> Option<usize> {
if forward {
(self.current + 1 < VIEW_COUNT).then(|| self.current + 1)
} else {
self.current.checked_sub(1)
}
}
}
/// Startup: seed the five views (and the live index) from `config.toml`.
///
/// Reads [`crate::cvars::CvarPersist::stored`] rather than the VM's table for the reason 1622's
/// remembered-character row does: the VM's CVar table is a per-VM `Update` seed and does not exist
/// yet, while the persisted values are a resource from `CvarLoad` onward and stay current across a
/// VM replacement (1291). An absent key is the shipped default, which is every first run.
fn load_saved_views(persist: Res<crate::cvars::CvarPersist>, mut views: ResMut<CameraViews>) {
for (view, slot) in views.views.iter_mut().enumerate() {
// Each field is independently optional, like the pose file's two keys: an absent or
// hand-mangled value costs that one number, not the whole view.
let field = |i: usize| {
persist
.stored(VIEW_CVARS[view][i])
.and_then(|s| s.trim().parse::<f32>().ok())
.filter(|v| v.is_finite())
};
let shipped_row = VIEW_DEFAULTS[view];
let raw = |i: usize| {
field(i).unwrap_or_else(|| {
shipped_row[i]
.parse()
.expect("the reference's default strings are floats")
})
};
*slot = ViewPose::from_reference(raw(0), raw(1), raw(2));
}
if let Some(v) = persist
.stored(CVAR_ACTIVE_VIEW)
.and_then(|s| s.trim().parse::<usize>().ok())
.filter(|v| *v < VIEW_COUNT)
{
views.current = v;
}
}
/// Everything one drained request may write, fetched once (the app's bundled-param convention).
#[derive(bevy::ecs::system::SystemParam)]
struct ViewTargets<'w, 's> {
views: ResMut<'w, CameraViews>,
rig: ResMut<'w, CameraControl>,
cam: Query<'w, 's, &'static mut FlyCam, With<WorldCamera>>,
player: Res<'w, Player>,
}
/// Per frame: drain the Lua queue and apply each request to the rig.
///
/// Ordered before [`super::control`] so a view taken this frame is the pose this frame's camera
/// seat is computed from, exactly like the pose file's restore.
fn drain_view_requests(script: Option<NonSendMut<UiScript>>, mut targets: ViewTargets) {
let Some(mut script) = script else {
return;
};
let requests = script.take_camera_view_requests();
if requests.is_empty() {
return;
}
let Ok(mut cam) = targets.cam.single_mut() else {
return; // no camera entity — the reference's `0x4818f0` miss, same silent drop
};
let face_yaw = targets.player.facing();
// What to mirror into the CVar table once the batch is applied: the slots a save or a reset
// moved, and the live index if it changed. Collected rather than written inline because the VM
// handle is borrowed for the drain.
let mut write_slots: Vec<usize> = Vec::new();
let mut write_active = false;
for request in requests {
match request {
CameraViewRequest::Set(n) => {
write_active |= apply(
&mut targets.views,
&mut targets.rig,
&mut cam,
face_yaw,
usize::from(n),
);
}
CameraViewRequest::Save(n) => {
let view = usize::from(n);
targets.views.save(
view,
ViewPose {
// `target_distance`, never `distance`: the reference saves `cam+0x198`,
// the chosen zoom, not the collision-pulled arm (`camera_saved`'s own
// reason, one field over).
distance: targets.rig.target_distance,
pitch: cam.pitch,
yaw: wrap_pi(cam.yaw - face_yaw),
},
);
write_slots.push(view);
}
CameraViewRequest::Reset(n) => {
let view = usize::from(n);
if targets.views.reset(view) {
apply(
&mut targets.views,
&mut targets.rig,
&mut cam,
face_yaw,
view,
);
}
write_slots.push(view);
}
CameraViewRequest::Next | CameraViewRequest::Prev => {
let forward = request == CameraViewRequest::Next;
if let Some(view) = targets.views.step(forward) {
write_active |= apply(
&mut targets.views,
&mut targets.rig,
&mut cam,
face_yaw,
view,
);
}
}
CameraViewRequest::FlipYaw(degrees) => {
// `cam[+0x100] += arg * pi/180` (`0x50b6a0`). **A named divergence**: the
// reference accumulates into a yaw field of its OWN, added to the resolved view
// yaw at `0x50f7de` *outside* the follow channel, so its flip survives the camera
// re-centring behind a moving character. benilla's rig stores one absolute yaw and
// has no second channel, so the flip lands there and the auto-follow will reel it
// back in on Smart/Always (never on Never). Adding that second channel touches
// `seat_camera`, the follow and the right-drag carry, and changes how the view
// reads — a look call, not a silent one.
cam.yaw = wrap_pi(cam.yaw + degrees.to_radians());
}
}
}
// Mirror into the CVar table so the change dirties `config.toml` and the view survives a
// restart — `set_cvar_engine` is the minimap-zoom pattern (the engine's own value moved, so
// the table follows AND queues the change). The resource stays authoritative either way, so a
// run with no VM at all (a bare test app) still has working views.
for view in write_slots {
// A reset writes the reference's default STRING verbatim, not a re-rendered `%f` of it:
// `crate::cvars`'s file is a diff against the registered default, and only the exact text
// makes the key disappear again.
let reset_to_default = targets.views.pose(view) == ViewPose::shipped(view);
let rendered = targets.views.pose(view).to_reference();
for field in 0..3 {
let value = if reset_to_default {
VIEW_DEFAULTS[view][field]
} else {
rendered[field].as_str()
};
script.set_cvar_engine(VIEW_CVARS[view][field], value);
}
}
if write_active {
script.set_cvar_engine(CVAR_ACTIVE_VIEW, &targets.views.current.to_string());
}
}
/// Seat one view on the rig, and say whether the live index moved (the [`CVAR_ACTIVE_VIEW`] write's
/// own gate).
///
/// Only `target_distance` is written: the rig's wheel glide then walks `distance` there at
/// `cameraDistanceMoveSpeed`, which is the same channel the reference's `SetView` arms for the
/// distance. Pitch and yaw land immediately (see the module header's second divergence).
fn apply(
views: &mut CameraViews,
rig: &mut CameraControl,
cam: &mut FlyCam,
face_yaw: f32,
view: usize,
) -> bool {
let (pose, moved) = views.set(view);
rig.target_distance = pose.distance;
cam.pitch = pose.pitch.clamp(-CAM_PITCH_LIMIT, CAM_PITCH_LIMIT);
cam.yaw = wrap_pi(face_yaw + pose.yaw);
moved
}
pub(super) fn plugin(app: &mut App) {
app.init_resource::<CameraViews>()
// After the config fold, for the same reason the camera's own spawn is: the persisted
// views have to be in the resource before anything can select one.
.add_systems(Startup, load_saved_views.after(crate::cvars::CvarLoad))
.add_systems(
Update,
drain_view_requests
.before(super::control)
// Capture parks the camera itself (`capture::probe_cam`), and `control` is gated
// off there for the same reason; a queued view must not steal a parked pose.
.run_if(not(resource_exists::<crate::run_mode::CaptureMode>)),
);
}
#[cfg(test)]
mod tests {
use super::*;
/// Every shipped view lands the pose the reference's own default table carries — the numbers
/// out of `0x84f488`, through the one pitch bridge (decision 1138), clamped where the rig
/// clamps. This is the test that would catch a transcription slip in [`VIEW_DEFAULTS`].
#[test]
fn each_shipped_view_lands_its_recorded_pose() {
// (Lua arg, distance yd, reference pitch degrees — positive is looking DOWN.)
let table = [
(1, 0.0_f32, 0.0_f32),
(2, 5.55, 10.0),
(3, 5.55, 20.0),
(4, 13.88, 30.0),
(5, 13.88, 10.0),
];
let views = CameraViews::default();
for (lua_arg, distance, pitch_deg) in table {
let pose = views.pose(lua_arg - 1);
assert_eq!(pose.distance, distance, "SetView({lua_arg}) distance");
assert!(
(pose.pitch - pitch_from_file(pitch_deg)).abs() < 1e-6,
"SetView({lua_arg}) pitch: {} vs {}",
pose.pitch,
pitch_from_file(pitch_deg)
);
assert_eq!(
pose.yaw, 0.0,
"SetView({lua_arg}) yaw is behind the character"
);
// The sign 1138 exists for: the reference's positive pitch is OUR negative.
assert!(
pose.pitch <= 0.0,
"a positive saved pitch looks DOWN for us"
);
}
}
/// **First person is reachable**, and view 1 is it: distance 0.0 survives the clamp because
/// [`CAM_DIST_MIN`] is `0.0` and the rig seats the eye on the framing pivot there.
#[test]
fn view_one_is_real_first_person_not_a_near_stop() {
assert_eq!(CAM_DIST_MIN, 0.0);
assert_eq!(CameraViews::default().pose(0).distance, 0.0);
}
/// The shipped view is `THIRD_PERSON_A` — `cameraView`'s registrar default `"1"` is an
/// *internal* index, i.e. Lua `SetView(2)`.
#[test]
fn the_shipped_view_is_third_person_a() {
assert_eq!(CameraViews::default().current, 1);
assert_eq!(CameraViews::default().pose(1).distance, 5.55);
}
/// Save → move the camera → set: the saved pose comes back. Then reset puts the shipped
/// default back. The round trip goes through the persisted *strings*, so it also pins that
/// what we write is what we can read.
#[test]
fn a_saved_view_round_trips_and_reset_restores_the_default() {
let mut views = CameraViews::default();
let saved = ViewPose {
distance: 12.25,
pitch: -0.42,
yaw: 0.75,
};
views.save(2, saved);
// The camera moves somewhere else entirely...
views.save(
3,
ViewPose {
distance: 1.0,
pitch: 0.1,
yaw: -2.0,
},
);
assert_eq!(views.pose(2), saved);
// ...and through the CVar text, which is the only form that survives a restart.
let [d, p, y] = saved.to_reference();
let reloaded =
ViewPose::from_reference(d.parse().unwrap(), p.parse().unwrap(), y.parse().unwrap());
assert!((reloaded.distance - saved.distance).abs() < 1e-4);
assert!((reloaded.pitch - saved.pitch).abs() < 1e-4);
assert!((reloaded.yaw - saved.yaw).abs() < 1e-4);
assert!(!views.reset(2), "view 2 was not the live one");
assert_eq!(views.pose(2), ViewPose::shipped(2));
// Resetting the LIVE view reports so — the reference re-applies it on the spot.
views.set(3);
assert!(views.reset(3));
}
/// **`SaveView(1)`/`ResetView(1)` are real** — the engine's range is `1..=5` for all three
/// indexed entry points (`0x50b5d2`/`0x50b626`/`0x50b666`), and `0x50fa30` has no view-0 gate.
/// Only the *binding* table stops at 2; a macro does not.
#[test]
fn view_one_is_saveable_and_resettable() {
let mut views = CameraViews::default();
let pose = ViewPose {
distance: 3.0,
pitch: -0.2,
yaw: 0.0,
};
views.save(0, pose);
assert_eq!(views.pose(0), pose);
views.reset(0);
assert_eq!(views.pose(0), ViewPose::shipped(0));
assert_eq!(views.pose(0).distance, 0.0);
}
/// **Next/Prev clamp; they do not wrap** — `0x50faa0`'s `jge` and `0x50fac0`'s `jle` are hard
/// stops. The walk is 1→2→3→4→5 and then nothing.
#[test]
fn next_and_prev_stop_at_the_ends() {
let mut views = CameraViews::default();
views.set(0);
assert_eq!(views.step(false), None, "PrevView at view 1 is a no-op");
for expected in 1..VIEW_COUNT {
let next = views.step(true).expect("a view above this one");
assert_eq!(next, expected);
views.set(next);
}
assert_eq!(views.step(true), None, "NextView at view 5 is a no-op");
// ...and back down, one at a time, to the bottom.
for expected in (0..VIEW_COUNT - 1).rev() {
let prev = views.step(false).expect("a view below this one");
assert_eq!(prev, expected);
views.set(prev);
}
assert_eq!(views.step(false), None);
}
/// `FlipCameraYaw(180)` twice is the identity, mod 2pi — the binding body 1.12 ships, applied
/// the way [`drain_view_requests`] applies it.
#[test]
fn flipping_the_yaw_twice_returns_the_original() {
let flip = |yaw: f32, degrees: f32| wrap_pi(yaw + degrees.to_radians());
for start in [0.0_f32, 1.3, -2.9, std::f32::consts::PI] {
let once = flip(start, 180.0);
assert!(
(wrap_pi(once - start).abs() - std::f32::consts::PI).abs() < 1e-5,
"one flip is half a turn"
);
let twice = flip(once, 180.0);
assert!(
wrap_pi(twice - start).abs() < 1e-5,
"two flips return: {twice} vs {start}"
);
}
}
/// A hand-edited `config.toml` can never land an unreachable view: the distance clamps to the
/// zoom range, the pitch to +-89 deg, and any yaw encoding (the reference's `[0, 360]`
/// validator or `SaveView`'s signed offset) wraps into range.
#[test]
fn a_hand_edited_view_cannot_land_an_illegal_pose() {
let wild = ViewPose::from_reference(999.0, 400.0, 720.0 + 90.0);
assert_eq!(wild.distance, CAM_DIST_MAX);
assert_eq!(wild.pitch, -CAM_PITCH_LIMIT);
assert!((wild.yaw - std::f32::consts::FRAC_PI_2).abs() < 1e-5);
assert_eq!(
ViewPose::from_reference(-5.0, -400.0, 0.0).distance,
CAM_DIST_MIN
);
assert_eq!(
ViewPose::from_reference(-5.0, -400.0, 0.0).pitch,
CAM_PITCH_LIMIT
);
}
/// **The weld** ([`crate::cvars`]'s own convention): every one of the sixteen names is
/// registered, with the reference's default string, and nothing here has drifted from the
/// table `is_view_cvar` answers from.
#[test]
fn the_view_cvars_are_registered_with_the_references_defaults() {
let registered = |name: &str| {
crate::cvars::REGISTERED
.iter()
.find(|(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, d)| *d)
};
for view in 0..VIEW_COUNT {
for field in 0..3 {
let name = VIEW_CVARS[view][field];
assert_eq!(
registered(name),
Some(VIEW_DEFAULTS[view][field]),
"{name} is not registered with its shipped default"
);
assert!(
is_view_cvar(name),
"{name} is not recognised as a view cvar"
);
assert!(
is_view_cvar(&name.to_ascii_uppercase()),
"{name} is case-sensitive"
);
}
}
assert_eq!(registered(CVAR_ACTIVE_VIEW), Some(ACTIVE_VIEW_DEFAULT));
assert!(is_view_cvar(CVAR_ACTIVE_VIEW));
assert!(
!is_view_cvar("cameraDistanceMaxFactor"),
"the zoom ceiling is not a view"
);
assert!(
!is_view_cvar("cameraDistanceE"),
"there are only five views"
);
}
}
+41 -25
View File
@@ -479,7 +479,6 @@ pub(super) fn script_target_by_name_requests(
pub(super) fn assist_requests(
mut requests: MessageReader<AssistRequest>,
scan_params: ByNameScan,
index: Res<GuidIndex>,
mut commit: SelectCommit,
) {
for request in requests.read() {
@@ -496,32 +495,11 @@ pub(super) fn assist_requests(
None => commit.selection.target,
};
let Some(basis) = basis else {
info!("by-name: /assist — no basis unit; nothing to assist");
info!("assist (/assist): no basis unit; nothing to assist");
continue;
};
// Its own target. A zero/absent guid is the silent no-op — the reference's shared tail
// bails here before any send.
let Some(guid) = commit
.stores
.get(basis)
.ok()
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
else {
info!("by-name: /assist — the basis unit is targeting nothing; silent no-op");
continue;
};
// "Select if it resolves" (`0x489a40`): only a streamed unit can be selected — the
// reference's group-roster fallback for an unstreamed guid needs the guid-only selection
// benilla does not have yet (see the module header).
let Some(entity) = index.0.get(&guid).copied() else {
info!(
"by-name: /assist — the basis is targeting guid {guid:#x}, which is not streamed"
);
continue;
};
info!("by-name: /assist -> the basis unit's target, guid {guid:#x}");
commit.commit(entity, guid);
// Everything from here is `AssistUnit`'s tail too — one function, as in the reference.
commit.assist(basis, "/assist");
}
}
@@ -624,11 +602,49 @@ pub(crate) struct SelectCommit<'w, 's> {
With<SelfPlayer>,
>,
pub(super) stores: Query<'w, 's, &'static ObjectStore>,
/// The guid → entity map — `0x489a40`'s own `0x468460` lookup, which every one of this tail's
/// callers needs and none of them should re-derive.
index: Res<'w, GuidIndex>,
factions: Option<Res<'w, super::Factions>>,
reputations: Res<'w, crate::net::Reputations>,
}
impl SelectCommit<'_, '_> {
/// **The shared assist tail** (`0x489bb2`–`0x489c07`, byte-identical to `AssistByName`'s
/// `0x489cae`–`0x489d07`): read the basis unit's `UNIT_FIELD_TARGET` (`[[obj+0x110]+0x28]`),
/// bail **silently** on zero, then select it if it resolves.
///
/// One function because the reference has one — the two Assist bindings differ only in how the
/// *basis* is found (a name through the resolver, a unit token through `0x515940`, or the
/// current selection for the bare form), never in what happens after. Three ways to no-op, all
/// of them silent and none of them a deselect: no basis, a basis targeting nothing, and a
/// target guid that is not streamed (`0x489a40`'s arm 3 is a bare `ret`).
///
/// The `assistAttack` swing leg is correctly absent — the CVar's registered default is `"0"`
/// (VERIFIED at `0x48fc50`), so stock assist selects and does not swing. See the drain below.
pub(super) fn assist(&mut self, basis: Entity, how: &str) {
let Some(guid) = self
.stores
.get(basis)
.ok()
.and_then(|s| s.0.unit_target())
.filter(|g| *g != 0)
else {
info!("assist ({how}): the basis unit is targeting nothing; silent no-op");
return;
};
// "Select if it resolves" (`0x489a40`): only a streamed unit can be selected — the
// reference's group-roster fallback for an unstreamed guid needs the guid-only selection
// benilla does not have yet (see the module header).
let Some(entity) = self.index.0.get(&guid).copied() else {
info!("assist ({how}): the basis is targeting guid {guid:#x}, which is not streamed");
return;
};
info!("assist ({how}) -> the basis unit's target, guid {guid:#x}");
self.commit(entity, guid);
}
/// Select a guid that is already resolved — `0x489a40`'s arm 1, plus [`scan::commit`]'s law.
pub(super) fn commit(&mut self, entity: Entity, guid: u64) {
let me = self.me.single().ok();
// The new-target classification `scan::commit` expects from its callers (it does not
+171 -23
View File
@@ -1,9 +1,12 @@
//! The click/USE router — the input half of targeting: a clean left-click selects the
//! [`super::Hovered`] unit, a clean right-click dispatches the context action (attack, NPC
//! interact, GameObject USE with the lock/refusal ladder), and the UI's `TargetUnit` requests +
//! Esc drain into the same [`super::Selection`]. Split from `mod.rs` (which keeps the state
//! resources and the plugin) along the state-vs-input seam; the systems here are registered by
//! [`super::TargetPlugin`] in the target chain after the hover picks and the cursor classifier.
//! interact, GameObject USE with the lock/refusal ladder), and the UI's selection asks
//! (`TargetUnit` / `AssistUnit` / `TargetLastEnemy`) + Esc drain into the same
//! [`super::Selection`]. Split from `mod.rs` (which keeps the state resources and the plugin)
//! along the state-vs-input seam; the systems here are registered by [`super::TargetPlugin`] in
//! the target chain after the hover picks and the cursor classifier.
use benilla_ui::script::SelectionRequest;
use super::lock::GoLockInputs;
use super::*;
@@ -857,23 +860,40 @@ pub(super) fn clear_target_requests(
}
}
/// Drain the UI's `TargetUnit(token)` requests and commit each through the shared SetSelection path
/// ([`scan::commit`]) — the app half of the reference's `TargetUnit` Lua shim. Callers: the player
/// frame's left-click (`TargetUnit("player")`) and the party frames' (`TargetUnit("partyN")`,
/// decision 0434 phase 5). Only tokens resolving to a STREAMED unit act: `"player"` → our avatar;
/// `"target"` → the current selection (a dedup no-op); `"partyN"`/`"raidN"` → that roster slot
/// when its entity is in range (an out-of-range member needs the guid-only selection the phase-4
/// out-of-range slice owns — until then the click no-ops, like the real client on a nonexistent
/// unit); `"pet"` → the bar's cached pet guid (decision 0990, the pet frame's left click);
/// `"targettarget"` → the selection's own `UNIT_FIELD_TARGET` (decision 1576, the ToT frame's).
/// Everything else (mouseover/name) waits for its wire.
#[allow(clippy::too_many_arguments)]
pub(super) fn target_unit_requests(
/// Drain the UI's **selection asks** — `TargetUnit(token)`, `AssistUnit(token)` and
/// `TargetLastEnemy()` — and commit each through the shared SetSelection path ([`scan::commit`]).
///
/// One drain for the three because the reference has one function for them: `TargetUnit
/// 0x4899d0`, `AssistUnit 0x489b80`, `TargetLastEnemy` and `TargetLastTarget` all reach selection
/// through the "select if it resolves" helper `0x489a40` (wow-re
/// `object-layer/scratch/selection-attack-seam.md` §1), whose three arms — resolves → commit;
/// doesn't resolve but is on the roster → commit anyway; **neither, including guid 0 → a bare
/// `ret`** — are the same for every caller. That third arm is the one worth naming: a token,
/// basis or remembered guid that does not resolve is a **no-op, not a deselect**. Draining them
/// in one ordered queue also keeps their relative order, which a macro can observe.
///
/// `TargetUnit` callers: the player frame's left-click (`TargetUnit("player")`), the party frames'
/// (`TargetUnit("partyN")`, decision 0434 phase 5), the TARGETSELF binding. Only tokens resolving
/// to a STREAMED unit act: `"player"` → our avatar; `"target"` → the current selection (a dedup
/// no-op); `"partyN"`/`"raidN"` → that roster slot when its entity is in range (an out-of-range
/// member needs the guid-only selection the phase-4 out-of-range slice owns — until then the click
/// no-ops, like the real client on a nonexistent unit); `"pet"` → the bar's cached pet guid
/// (decision 0990, the pet frame's left click); `"targettarget"` → the selection's own
/// `UNIT_FIELD_TARGET` (decision 1576, the ToT frame's). Everything else (mouseover/name) waits
/// for its wire.
///
/// `AssistUnit` resolves the same token and then runs the shared assist tail
/// ([`super::by_name::SelectCommit::assist`]) — the basis's own `UNIT_FIELD_TARGET`, which is the
/// hop `"targettarget"` already makes for the ToT frame. `TargetLastEnemy` skips the token
/// grammar entirely and reads [`scan::LastEnemy`].
pub(super) fn selection_requests(
script: Option<NonSendMut<UiScript>>,
// The one unit-token resolver (`crate::ui_unit::UnitTokens`) — the arms this drain used to
// spell out inline. It is shared with the reach feed precisely so `TargetUnit("target")` and
// `CheckInteractDistance("target", …)` can never mean two different units (B304).
tokens: crate::ui_unit::UnitTokens,
// `TargetLastEnemy`'s memory — `[0xb4e2e8]/[0xb4e2ec]`, stamped by `scan::remember_last_enemy`.
last_enemy: Res<scan::LastEnemy>,
// The one SetSelection tail, shared with `/target` and `/assist` (decision 1583). It carries
// the classification too, which is why this drain no longer states one: hand-stating it here
// is exactly how a `false` that the binary refutes got written down.
@@ -882,16 +902,47 @@ pub(super) fn target_unit_requests(
let Some(mut script) = script else {
return;
};
let requests = script.take_target_requests();
let requests = script.take_selection_requests();
if requests.is_empty() {
return;
}
for token in requests {
// Resolved before the commit borrows the selection mutably. An unstreamed unit — an
// out-of-range party member, a despawned pet — resolves to nothing and no-ops.
let resolved = tokens.resolve(&token, &commit.selection);
if let Some((entity, guid)) = resolved {
commit.commit(entity, guid);
for request in requests {
match request {
// Resolved before the commit borrows the selection mutably. An unstreamed unit — an
// out-of-range party member, a despawned pet — resolves to nothing and no-ops.
SelectionRequest::Unit(token) => {
if let Some((entity, guid)) = tokens.resolve(&token, &commit.selection) {
commit.commit(entity, guid);
}
}
// `0x489ba9 call 0x515940(token)` — the same token grammar, then the shared tail. An
// unresolvable token is silent here: the reference emits game-message `0xb8`, whose
// id→string table is runtime-populated BSS wow-re could not statically recover — the
// known deviation `TargetByName` already carries on this path.
SelectionRequest::Assist(token) => match tokens.resolve(&token, &commit.selection) {
Some((basis, _)) => commit.assist(basis, "AssistUnit"),
None => info!("assist (AssistUnit): \"{token}\" names nothing; silent no-op"),
},
// `0x489b45` reads the last-attackable pair and hands it to the same `0x489a40`.
// **Empty memory is a no-op, and that is derived now**: the shim `0x489b40` is
// thirteen bytes with no emptiness test at all, where `TargetLastTarget 0x489b00`
// really does reach `0x493540(0,0)` and deselect. The two shims differ exactly here
// (wow-re `targeting-friend-and-lastenemy.md`, §5 trio — dispatched from this work).
SelectionRequest::LastEnemy => {
let Some(guid) = last_enemy.0 else {
info!("TargetLastEnemy: nothing hostile has been targeted yet; no-op");
continue;
};
match tokens.held(guid) {
Some((entity, guid)) => commit.commit(entity, guid),
// The stale-guid arm: the remembered unit has streamed out or despawned. The
// memory is deliberately kept (the reference never clears its globals either)
// and this is a bare no-op, never a deselect.
None => info!(
"TargetLastEnemy: {guid:#x} is no longer streamed; target left untouched"
),
}
}
}
}
}
@@ -1131,4 +1182,101 @@ mod tests {
Some(ClientCommand::BankerActivate { guid: 0x42 })
));
}
/// **The selection queue, end to end** — Lua in, `Selection` out — over the four ways it is
/// reachable from a player macro. Every one of them is a shipped binding body: `AssistUnit`
/// is ASSISTTARGET's (default `F`), `TargetLastEnemy` is TARGETLASTHOSTILE's (default `G`).
///
/// What it pins, claim by claim:
/// * `AssistUnit("target")` on a basis that has a target selects **that unit** — the shared
/// assist tail's one hop off `UNIT_FIELD_TARGET`;
/// * a basis with **no** target is a silent no-op — the reference's tail bails before any send;
/// * a **garbage token** resolves to nothing and is a no-op, never a deselect (`0x489a40`'s
/// arm 3 is a bare `ret`) — and, being reachable from any macro, must not panic;
/// * `TargetLastEnemy()` re-selects the remembered guid, and with a **stale** one — the unit
/// despawned or streamed out — leaves the target exactly where it was.
#[test]
fn the_selection_queue_runs_assist_and_last_enemy_without_ever_deselecting() {
use crate::net::NetCommands;
use benilla_ui::script::UiScript;
use bevy::ecs::system::RunSystemOnce;
const ME: u64 = 1;
const BASIS: u64 = 0xB0A2;
const VICTIM: u64 = 0xC0DE;
const GONE: u64 = 0x6017;
// `UNIT_FIELD_TARGET` is a 2-field guid at index 16; HEALTH/MAXHEALTH keep the units live.
let store =
|pairs: &[(u16, u32)]| ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs));
let (tx, _rx) = crossbeam_channel::unbounded();
let mut world = World::new();
world.insert_resource(NetCommands(tx));
world.init_resource::<crate::ui_cast::QueuedMeleeSpell>();
world.init_resource::<crate::ui_action::AutoRepeatActive>();
world.init_resource::<Messages<crate::creature_anim::SheathRequest>>();
world.init_resource::<crate::ui_party::GroupState>();
world.init_resource::<crate::net::GuidIndex>();
world.init_resource::<crate::net::Reputations>();
world.init_resource::<Selection>();
world.init_resource::<scan::LastEnemy>();
world.insert_non_send_resource(UiScript::new().expect("a bare VM"));
world.spawn((SelfPlayer, Guid(ME), store(&[(22, 100), (28, 100)])));
// The basis is pointing at the victim; the victim points at nobody.
let basis = world
.spawn((
Guid(BASIS),
store(&[(22, 100), (28, 100), (16, VICTIM as u32)]),
))
.id();
let victim = world
.spawn((Guid(VICTIM), store(&[(22, 100), (28, 100)])))
.id();
let index = &mut world.resource_mut::<crate::net::GuidIndex>().0;
index.insert(BASIS, basis);
index.insert(VICTIM, victim);
let run = |world: &mut World, lua: &str| {
world
.non_send_resource_mut::<UiScript>()
.eval::<()>(lua)
.expect("the binding runs");
world
.run_system_once(selection_requests)
.expect("the drain runs as a one-shot system");
world.resource::<Selection>().guid
};
let set = |world: &mut World, target: Option<(Entity, u64)>| {
let mut sel = world.resource_mut::<Selection>();
sel.target = target.map(|(e, _)| e);
sel.guid = target.map(|(_, g)| g);
};
// Assist the current target: one hop onto ITS target.
set(&mut world, Some((basis, BASIS)));
assert_eq!(run(&mut world, r#"AssistUnit("target")"#), Some(VICTIM));
// The victim targets nobody — assisting it is a silent no-op, not a deselect.
assert_eq!(run(&mut world, r#"AssistUnit("target")"#), Some(VICTIM));
// A token that names nothing: a no-op, and above all not a panic.
assert_eq!(run(&mut world, r#"AssistUnit("nosuchunit")"#), Some(VICTIM));
assert_eq!(run(&mut world, r#"TargetUnit("nosuchunit")"#), Some(VICTIM));
// TargetLastEnemy with nothing remembered: also a no-op.
assert_eq!(run(&mut world, "TargetLastEnemy()"), Some(VICTIM));
// Remember the victim, drop the target, and press it: back onto the victim.
world.resource_mut::<scan::LastEnemy>().0 = Some(VICTIM);
set(&mut world, None);
assert_eq!(run(&mut world, "TargetLastEnemy()"), Some(VICTIM));
// A STALE memory — the unit despawned, so the guid is in no index. The target stays put;
// the reference's helper returns without touching the selection.
world.resource_mut::<scan::LastEnemy>().0 = Some(GONE);
assert_eq!(run(&mut world, "TargetLastEnemy()"), Some(VICTIM));
set(&mut world, None);
assert_eq!(run(&mut world, "TargetLastEnemy()"), None);
}
}
+18 -8
View File
@@ -75,11 +75,11 @@ pub(crate) use lock::GO_FLAG_LOCKED;
// nameplate colour gate (`crate::nameplates`), the flash's only two consumers (byte-verified).
pub(crate) use flash::CombatFlash;
// The action layer's "attack pressed with no target" request — the nearest-enemy auto-acquire
// (`scan`) answers it with the same core TAB uses. `attack_order_target` + `EnemyScan` are that
// (`scan`) answers it with the same core TAB uses. `attack_order_target` + `TargetScan` are that
// same core called *synchronously*, by the pet bar's ATTACK arm: the pet's order has to leave in
// the frame it was pressed carrying the acquired guid, so it cannot go round through a request.
pub(crate) use relations::{can_assist, can_attack, can_interact};
pub(crate) use scan::{attack_order_target, AttackNearestRequest, EnemyScan};
pub(crate) use scan::{attack_order_target, AttackNearestRequest, TargetScan};
// The chat layer's by-name selection asks (`/target`, `/assist` — decision 0886), answered by the
// shared resolver the reference parameterises per caller.
pub(crate) use by_name::{AssistRequest, TargetByNameRequest};
@@ -309,6 +309,7 @@ impl Plugin for TargetPlugin {
.init_resource::<WorldCursor>()
.init_resource::<CombatFlash>()
.init_resource::<scan::TabHistory>()
.init_resource::<scan::LastEnemy>()
.add_message::<AttackNearestRequest>()
.add_message::<TargetByNameRequest>()
.add_message::<AssistRequest>()
@@ -359,11 +360,13 @@ impl Plugin for TargetPlugin {
click::act_on_right_click,
click::clear_target_requests,
// The two unit-token drains, grouped as one element (the outer chain is at
// Bevy's 20-tuple limit): `TargetUnit`'s selection commit, and
// `DropItemOnUnit`'s pet-leg feed (decision 1055). Independent of each other —
// one writes `Selection`, the other sends a cast — so they need no order.
// Bevy's 20-tuple limit): the selection asks' commit (`TargetUnit`,
// `AssistUnit`, `TargetLastEnemy` — one queue, one drain, as the reference has
// one `0x489a40`), and `DropItemOnUnit`'s pet-leg feed (decision 1055).
// Independent of each other — one writes `Selection`, the other sends a cast —
// so they need no order.
(
click::target_unit_requests,
click::selection_requests,
crate::ui_action::drop_item::drop_item_on_unit,
),
// The chat layer's by-name asks (decision 0886), beside the UI's token asks:
@@ -384,10 +387,17 @@ impl Plugin for TargetPlugin {
)
.chain(),
scan::auto_acquire_attacker,
scan::tab_target,
// The two sides of the one cycler (`0x493f60`, mode 1 / mode 2), grouped as
// one element (the outer chain is at Bevy's 20-tuple limit) and chained
// because they share `TabHistory`, whose side switch clears it.
(scan::tab_target, scan::target_nearest_friend_requests).chain(),
scan::acquire_and_attack,
flash::drive_flash,
ring::update_ring,
// `TargetLastEnemy`'s stamp, then the ring. The order is load-bearing: the
// ring's death-clear drops the selection, and a hostile that dies while
// selected must still be remembered (the reference's shim has no liveness
// gate — re-targeting the corpse is faithful).
(scan::remember_last_enemy, ring::update_ring).chain(),
)
.chain()
.in_set(TargetUpdate)
+659 -61
View File
@@ -52,10 +52,10 @@ use bevy::prelude::*;
use crate::creature_anim::{Engaged, SwingMessage};
use crate::names::NameCache;
use crate::net::{ClientCommand, Guid, NetEntity, ObjectStore, Reputations, SelfPlayer};
use crate::net::{ClientCommand, Guid, GuidIndex, NetEntity, ObjectStore, Reputations, SelfPlayer};
use benilla_assets::{LockRecover, WorldAssets};
use super::relations::can_attack;
use super::relations::{can_assist, can_attack};
use super::ring::reaction_from_player;
use super::{Factions, Selection};
@@ -98,6 +98,38 @@ fn tab_trace_on() -> bool {
*ON.get_or_init(|| std::env::var_os("WOW_TAB_TRACE").is_some())
}
/// Which side a press is looking for — the reference's **mode** argument, and the only thing that
/// forks on the whole path.
///
/// The four `TargetNearest*` shims are one function with one changed immediate: `0x489a80`
/// (`TargetNearestEnemy`) pushes `edx = 1`, `0x489aa0` (`TargetNearestFriend`) pushes `edx = 2`,
/// and `0x489ac0`/`0x489ae0` push 3 and 4 for the party and raid siblings — every one of them then
/// calls the single cycler `0x493f60(ecx = reverse, edx = mode)`. The mode reaches exactly one
/// place: `0x493e40`'s jump table (`0x493f50 = {0x493e73, 0x493eca, 0x493eed, 0x493f15}`). The
/// enumeration, the creature-type table, the range cvars, the scene-attach gate, the scorer
/// `0x494200`, the comparator `0x494450` and the commit `0x493540` are literally the same
/// instructions for both sides — so this is a parameter on the one scan, never a second scanner
/// (wow-re `object-layer/scratch/targeting-nearest-and-autoacquire.md` PART A;
/// `object-layer/scratch/targeting-by-name.md` "the mode filter").
///
/// Modes 3 and 4 (party / raid) are not built: they need the roster-only candidate set, and the
/// two commands that drive them stay in the binding registry's absent table until they are.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum ScanSide {
/// **Mode 1** (`0x493e73`) — the liveness triple (`0x605f90`, health, dynflag/stand-state) and
/// `CanAttack 0x606980`.
Enemy,
/// **Mode 2** (`0x493eca`) — `CanAssist 0x6066f0`, then `UNIT_FIELD_HEALTH > 0`, and nothing
/// else. Two things fall out of that being *shorter* than mode 1 rather than its mirror: a
/// **feigning** friendly (health > 0, `UNIT_DYNFLAG_DEAD` set) is still a candidate, because
/// mode 2 has no dynflag leg; and `CanAssist`'s own ladder is **friendly or better** — neutral
/// fails (`0x60671e cmp eax,4; jge`, on the internal 0-based rank, so Lua's FRIENDLY(5) is
/// internal 4). Its NPC arm is `IsPvP 0x605ff0` on the candidate (owner-chased), so a friendly
/// creature with no `UNIT_FLAG_PVP` is NOT a `TargetNearestFriend` candidate at all — the
/// same predicate that already denies its buffs (decision 1035).
Friend,
}
/// One scored candidate. `score`: lower is better. `on_screen`: inside the pulled-in frustum
/// (the tier-1 gate). The fighting-me bonus is already folded into `score` at build time.
#[derive(Clone, Copy, Debug)]
@@ -167,6 +199,23 @@ fn pick_forward(
Some((0, true))
}
/// The **backward** pick (Shift-TAB / `TargetNearestFriend(1)` — the reference's `reverse` flag):
/// walk the history newest-first for a guid that is neither the current selection nor gone from
/// the pool. `None` = nothing to step back to, and the caller falls through to [`pick_forward`],
/// which is what makes the very first reverse press behave like a forward one.
///
/// The reference cycles a *snapshot list* by a cursor and simply decrements it (`cursor == 0 ?
/// count-1 : cursor-1`, no timer check — the asymmetry is its own); decision 0567 replaced the
/// snapshot with this history, so "back" means "the target before this one" rather than "the
/// previous array slot". Pure, and beside its forward twin, so the two are read together.
fn pick_back(visited: &[u64], pool: &[Candidate], current: Option<u64>) -> Option<usize> {
visited.iter().rev().find_map(|g| {
(Some(*g) != current)
.then(|| pool.iter().position(|c| c.guid == *g))
.flatten()
})
}
/// "Fighting me": the unit's UNIT_FIELD_TARGET is my guid AND its in-combat flag
/// (UNIT_FLAG_IN_COMBAT, bit 19) is set — the `TargetPriorityCombatLock=2` notion ("in-combat
/// with player"), the one that matters solo.
@@ -175,11 +224,17 @@ fn combat_with_me(store: &ObjectStore, me: Option<u64>) -> bool {
}
/// The CreatureType.dbc flags table (see `benilla-formats`); absent (load failure) = no
/// critter/totem filtering, like the missing-catalog fallbacks elsewhere.
/// critter filtering, like the missing-catalog fallbacks elsewhere.
///
/// **Critter alone** — the flag column is 1 for CreatureType 8 and for nothing else in the shipped
/// 5875 DBC (Totem reads 0, and 1.12 has no non-combat-pet row). This prose said "critter/totem",
/// copying a wow-re gloss that its own `targeting-friend-and-lastenemy.md` has since closed
/// against the file; the code was always reading the flag rather than a type list, so only the
/// words were wrong.
#[derive(Resource)]
pub(crate) struct CreatureTypes(CreatureTypeFlags);
/// Startup (after the MPQ chain opens): load CreatureType.dbc for the critter/totem TAB filter.
/// Startup (after the MPQ chain opens): load CreatureType.dbc for the critter TAB filter.
pub(super) fn load_creature_types(mut commands: Commands, world_assets: Option<Res<WorldAssets>>) {
let Some(world_assets) = world_assets else {
return;
@@ -199,9 +254,26 @@ pub(super) fn load_creature_types(mut commands: Commands, world_assets: Option<R
/// `pub(crate)` rather than `pub(super)` because the **pet bar's** ATTACK arm runs the same
/// `TargetNearestEnemy()` the player's does ([`attack_order_target`]), and its drain lives in
/// `ui_pet`.
///
/// # `Without<SelfPlayer>` is a divergence, and a deliberate one
///
/// **The reference's friendly cycler can select YOU.** Nothing on the `0x493f60` chain excludes
/// the player: `CanAssist(P, P)` is true (`0x6061e0` returns 4 when A == B) and the cone test
/// `0x47f220(p, p)` returns exactly π/2, so the self entry is in-cone whenever your facing falls
/// in (π/3, 2π/3) and — at distance² 0 — sorts first. Plain TAB is spared only because
/// `CanAttack(P, P)` is false. (wow-re `targeting-friend-and-lastenemy.md`, verified
/// exhaustively; its own write-up asks a re-implementation to add the player to both candidate
/// sets.)
///
/// We do not, and the reason is 0567: that behaviour is an artifact of the ±30° facing cone, and
/// 0567 replaced the cone with screen-space priority. Under our scoring the player is at distance
/// 0 and screen-centre *unconditionally*, so porting the byte-level rule would make `CTRL-TAB`
/// self-target on **every** press rather than facing-dependently — faithful to the bytes,
/// unfaithful to the behaviour, which is the trade `CLAUDE.md` §7 hands to the director rather
/// than to this file. Recorded here so it is a known divergence and not a gap (decision 1745).
#[derive(SystemParam)]
#[allow(clippy::type_complexity)] // one bundled system param — the app's convention for big query sets
pub(crate) struct EnemyScan<'w, 's> {
pub(crate) struct TargetScan<'w, 's> {
units: Query<
'w,
's,
@@ -237,23 +309,60 @@ pub(crate) struct EnemyScan<'w, 's> {
reputations: Res<'w, Reputations>,
names: Res<'w, NameCache>,
creature_types: Option<Res<'w, CreatureTypes>>,
/// Every store, ours included — read only by the friend arm's `CanAssist`, whose `IsPvP` leg
/// chases the candidate's owner. `units` above cannot serve: it excludes our own body, which
/// is exactly who owns our pet.
stores: Query<'w, 's, &'static ObjectStore>,
/// The guid → entity map the owner chase lands in. `Option` for the same reason
/// [`crate::ui_unit::UnitTokens`] carries it that way — a UI-only harness runs with no net
/// stack, and a bare `Res` would turn that into a system-validation panic.
index: Option<Res<'w, GuidIndex>>,
}
impl EnemyScan<'_, '_> {
/// The `0x493e40` mode-1 validity filter — liveness + hostility (kept byte-law).
fn is_valid(&self, store: Option<&ObjectStore>, self_store: Option<&ObjectStore>) -> bool {
// The liveness leg is the reference's own reads-dead triple `0x605f90` — health, the
// `UNIT_DYNFLAG_DEAD` bit (feign death) and stand state 7 — now the shared predicate
// rather than a third transcription of it (decision 1022).
if store.is_some_and(|s| s.0.unit_reads_dead()) {
return false;
impl TargetScan<'_, '_> {
/// The `0x493e40` per-candidate validity filter, both arms — the one thing [`ScanSide`] forks.
fn is_valid(
&self,
side: ScanSide,
store: Option<&ObjectStore>,
self_store: Option<&ObjectStore>,
) -> bool {
match side {
// Mode 1 (`0x493e73`) — liveness + hostility (kept byte-law). The liveness leg is the
// reference's own reads-dead triple `0x605f90` — health, the `UNIT_DYNFLAG_DEAD` bit
// (feign death) and stand state 7 — the shared predicate rather than a third
// transcription of it (decision 1022).
ScanSide::Enemy => {
if store.is_some_and(|s| s.0.unit_reads_dead()) {
return false;
}
can_attack(
store,
self.factions.as_deref(),
&self.reputations,
self_store,
)
}
// Mode 2 (`0x493eca`) — `CanAssist 0x6066f0` first, then `[[cand+0x110]+0x40] > 0`.
// The order is the reference's and so is the *narrowness* of the liveness leg: it
// reads raw HEALTH, not the reads-dead triple, so this deliberately does NOT reuse
// `unit_reads_dead` (see [`ScanSide::Friend`]).
ScanSide::Friend => {
can_assist(
store,
self.factions.as_deref(),
&self.reputations,
self_store,
|owner| self.store_of(owner).cloned(),
) && !store.is_some_and(|s| s.0.unit_is_dead())
}
}
can_attack(
store,
self.factions.as_deref(),
&self.reputations,
self_store,
)
}
/// A guid's streamed descriptor, whichever entity holds it — `CanAssist`'s owner chase.
fn store_of(&self, guid: u64) -> Option<&ObjectStore> {
let entity = *self.index.as_ref()?.0.get(&guid)?;
self.stores.get(entity).ok()
}
/// Our own descriptor, for whichever leg needs the reaction's second party outside [`build`].
@@ -308,7 +417,17 @@ impl EnemyScan<'_, '_> {
/// The full build: walk every known unit, filter (the kept 1.12 legality laws), project
/// through the live camera, score, sort (tier, then score). Fresh every press — the live
/// world is the list (no snapshot to go stale).
fn build(&self) -> Vec<Candidate> {
///
/// `side` reaches exactly one line — [`Self::is_valid`] — which is the reference's own shape:
/// the mode byte forks `0x493e40` and nothing else on the path.
///
/// **Our own body is never a candidate**: the query is `Without<SelfPlayer>`. For the enemy
/// side that is free (`CanAttack(me, me)` is false anyway); for the friend side it is a
/// deliberate divergence, because the reference's enumeration walks ClntObjMgr table #1 —
/// which holds the player's own object — and no self-compare has been derived on the mode-2
/// path. Self sits at dist² 0 dead-centre, so a faithful `TargetNearestFriend` would appear to
/// always self-target; we refuse to ship that on an underived gate. RE dispatched.
fn build(&self, side: ScanSide) -> Vec<Candidate> {
let Ok((self_tf, self_store, self_guid)) = self.self_q.single() else {
return Vec::new();
};
@@ -348,8 +467,15 @@ impl EnemyScan<'_, '_> {
if !matches!(net.kind, EntityKind::Unit | EntityKind::Player) {
continue;
}
if !self.is_valid(store, self_store) {
trace_unit(guid_c.0, tf, "REJECT dead-or-unattackable");
if !self.is_valid(side, store, self_store) {
trace_unit(
guid_c.0,
tf,
match side {
ScanSide::Enemy => "REJECT dead-or-unattackable",
ScanSide::Friend => "REJECT dead-or-unassistable",
},
);
continue;
}
// The creature-type gate (CreatureType.dbc flag bit 0 — critters; kept byte-law).
@@ -377,7 +503,13 @@ impl EnemyScan<'_, '_> {
continue;
}
let off_center = project(tf.translation + Vec3::Y);
let cwm = store.is_some_and(|s| combat_with_me(s, me));
// **Enemy side only.** The bonus exists to surface *the thing attacking me*
// (`TargetPriorityCombatLock`'s spirit, decisions 0567/0568) — a notion with no
// friendly meaning, and the reference has no such term on either side. Left ungated it
// would fire on an ally who is in combat and targeting me — a healer on you — and
// shove them to the head of the CTRL-TAB pool ahead of everyone, by accident rather
// than by anybody's decision.
let cwm = side == ScanSide::Enemy && store.is_some_and(|s| combat_with_me(s, me));
let score = priority_score(off_center, dist, cwm);
if trace {
let screen = off_center
@@ -422,12 +554,24 @@ impl EnemyScan<'_, '_> {
pub(super) struct TabHistory {
/// `(guid, when)` — insertion-ordered; the back is the most recent (Shift-TAB's walk).
visited: Vec<(u64, f64)>,
/// Which side the standing history belongs to. **One history, cleared on a side switch** —
/// the reference's `cachedMode != mode` rebuild condition (`0x493f60`), which resets the
/// cursor to 0 and drops the candidate list whenever the mode byte changes. Without it a
/// CTRL-TAB through the friendly pool would poison the enemy cycle's skip set.
side: Option<ScanSide>,
}
impl TabHistory {
fn prune(&mut self, now: f64) {
self.visited.retain(|&(_, t)| now - t < HISTORY_SECS);
}
/// `0x493f60`'s cached-mode check: a press on the other side starts from nothing.
fn enter(&mut self, side: ScanSide) {
if self.side != Some(side) {
self.visited.clear();
self.side = Some(side);
}
}
fn guids(&self) -> Vec<u64> {
self.visited.iter().map(|&(g, _)| g).collect()
}
@@ -513,35 +657,35 @@ pub(super) fn commit(
}
}
/// TARGETNEARESTENEMY / TARGETPREVIOUSENEMY (0997: two commands through the binding table now,
/// defaults TAB / SHIFT-TAB — no shift fork here anymore), Classic-priority style: re-score the
/// live world, pool by tier, skip the recent history forward (or walk it backward), commit
/// through the byte-law [`commit`]. The dispatch already applied the typing gate (a focused
/// EditBox owns TAB) and the exact-modifier law.
pub(super) fn tab_target(
binds: Res<crate::bindings::BindingsState>,
time: Res<Time>,
scan: EnemyScan,
mut history: ResMut<TabHistory>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
/// **One press, either side** — the whole of the Classic-priority cycle, shared by the enemy TAB
/// ([`tab_target`]) and the friendly one ([`target_nearest_friend_requests`]) because the
/// reference shares it too: all four `TargetNearest*` shims are one call into `0x493f60` with a
/// different mode byte, and the byte reaches only the per-candidate filter ([`ScanSide`]).
///
/// Re-score the live world, pool by tier, skip the recent history forward (or walk it backward),
/// commit through the byte-law [`commit`].
#[allow(clippy::too_many_arguments)] // the shared core's full input set, one press's worth
fn cycle(
side: ScanSide,
reverse: bool,
now: f64,
scan: &TargetScan,
history: &mut TabHistory,
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
engaged: bool,
) {
let reverse = binds.fired(crate::bindings::cmd::TARGET_PREVIOUS_ENEMY);
if !reverse && !binds.fired(crate::bindings::cmd::TARGET_NEAREST_ENEMY) {
return;
}
let now = time.elapsed_secs_f64();
history.enter(side);
history.prune(now);
let trace = tab_trace_on();
if trace {
info!(
"tab-trace: TAB press (reverse={reverse}), selection {:?}, history {}",
"tab-trace: {side:?} press (reverse={reverse}), selection {:?}, history {}",
selection.guid.map(|g| format!("{g:#x}")),
history.visited.len()
);
}
let cands = scan.build();
let cands = scan.build(side);
if cands.is_empty() {
if trace {
info!("tab-trace: no candidates — target unchanged");
@@ -561,20 +705,14 @@ pub(super) fn tab_target(
}
);
}
// The pick. Reverse walks the history back (most recent visited that is not current and
// still in the pool); an empty walk falls through to the forward rule.
// The pick. Reverse walks the history back ([`pick_back`]); an empty walk falls through to the
// forward rule.
let visited = history.guids();
let back = reverse
.then(|| {
history.visited.iter().rev().find_map(|&(g, _)| {
(Some(g) != selection.guid)
.then(|| pool.iter().find(|c| c.guid == g).copied())
.flatten()
})
})
.then(|| pick_back(&visited, &pool, selection.guid))
.flatten();
let (entity, guid, wrapped) = match back {
Some(c) => (c.entity, c.guid, false),
Some(i) => (pool[i].entity, pool[i].guid, false),
None => {
let Some((i, wrapped)) = pick_forward(&pool, &visited, selection.guid) else {
return;
@@ -591,13 +729,20 @@ pub(super) fn tab_target(
history.push(old, now);
}
let out = commit(
&mut selection,
&mut seam,
selection,
seam,
entity,
guid,
!engaged.is_empty(),
engaged,
None,
true,
// Attack `0x5ecb70`'s new-target validation, which is what this flag carries: a mode-2
// pick can never pass it. `CanAssist` demands reaction ≥ 4 and `CanAttack`'s mixed arm
// demands < 4, so the two candidate sets are disjoint on that leg — a CTRL-TAB in the
// middle of a fight moves the selection and must NOT re-point the swing at an ally.
// (Disclosed corner: `CanAttack`'s duel / FFA-PvP arms don't read the reaction, so a
// duelling friendly is attackable AND assistable; we still refuse the re-swing there,
// which is the conservative half.)
side == ScanSide::Enemy,
);
if out.changed {
history.push(guid, now);
@@ -616,6 +761,77 @@ pub(super) fn tab_target(
}
}
/// TARGETNEARESTENEMY / TARGETPREVIOUSENEMY (0997: two commands through the binding table now,
/// defaults TAB / SHIFT-TAB — no shift fork here anymore). The dispatch already applied the typing
/// gate (a focused EditBox owns TAB) and the exact-modifier law.
pub(super) fn tab_target(
binds: Res<crate::bindings::BindingsState>,
time: Res<Time>,
scan: TargetScan,
mut history: ResMut<TabHistory>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
) {
let reverse = binds.fired(crate::bindings::cmd::TARGET_PREVIOUS_ENEMY);
if !reverse && !binds.fired(crate::bindings::cmd::TARGET_NEAREST_ENEMY) {
return;
}
cycle(
ScanSide::Enemy,
reverse,
time.elapsed_secs_f64(),
&scan,
&mut history,
&mut selection,
&mut seam,
!engaged.is_empty(),
);
}
/// Drain `TargetNearestFriend([reverse])` — the same cycle with mode 2 (`0x489aa0` →
/// `0x493f60(reverse, 2)`).
///
/// Lua-driven rather than binding-driven, because that is where the reference puts it: TAB reaches
/// the enemy scan through a Rust command here only because 0997 wired the two enemy commands
/// straight to [`tab_target`]; the friendly pair's `Bindings.xml` bodies are literally
/// `TargetNearestFriend()` and `TargetNearestFriend(1)`, so a binding row for them needs no new
/// mechanism — it needs the global, which now exists. Every FrameXML and addon caller lands here
/// too.
///
/// Each queued press is one cycle, in call order, exactly as repeated key presses would be.
pub(super) fn target_nearest_friend_requests(
script: Option<NonSendMut<benilla_ui::script::UiScript>>,
time: Res<Time>,
scan: TargetScan,
mut history: ResMut<TabHistory>,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
engaged: Query<(), (With<Engaged>, With<SelfPlayer>)>,
) {
let Some(mut script) = script else {
return;
};
let presses = script.take_target_nearest_friend_requests();
if presses.is_empty() {
return;
}
let now = time.elapsed_secs_f64();
let engaged = !engaged.is_empty();
for reverse in presses {
cycle(
ScanSide::Friend,
reverse,
now,
&scan,
&mut history,
&mut selection,
&mut seam,
engaged,
);
}
}
/// `0x6130b5` — **`TargetNearestEnemy()` itself**, and the whole of "pressing Attack at nothing
/// finds something in front of you".
///
@@ -635,12 +851,12 @@ pub(super) fn tab_target(
/// world every call and always hands back the head. That is decision 0567's trade for the whole
/// module (history cycling replaces the snapshot cursor), and an acquire is a fresh pick in both.
fn acquire_nearest_enemy(
scan: &EnemyScan,
scan: &TargetScan,
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
errors: &mut crate::ui_action::UiErrorKeys,
) -> Option<(Entity, u64)> {
let cands = scan.build();
let cands = scan.build(ScanSide::Enemy);
let Some(c) = cands.first() else {
// `0x6130d9` — the acquire ran and came back empty, so the selection re-read at `0x6130c1`
// still finds nothing: errorId `0xa0` `ERR_NO_ATTACK_TARGET`.
@@ -688,7 +904,7 @@ fn acquire_nearest_enemy(
/// a reason to go find something else. `0x613159`'s odd second leg is verbatim — a zero-health
/// target passes iff `UNIT_DYNAMIC_FLAGS` bit 5 is set.
pub(crate) fn attack_order_target(
scan: &EnemyScan,
scan: &TargetScan,
selection: &mut Selection,
seam: &mut crate::creature_anim::AttackSeam,
errors: &mut crate::ui_action::UiErrorKeys,
@@ -766,7 +982,7 @@ pub(crate) struct AttackNearestRequest;
#[allow(clippy::too_many_arguments)] // one Bevy system's full input set
pub(super) fn acquire_and_attack(
mut requests: MessageReader<AttackNearestRequest>,
scan: EnemyScan,
scan: TargetScan,
mut selection: ResMut<Selection>,
mut seam: crate::creature_anim::AttackSeam,
self_store: Query<&crate::net::ObjectStore, With<SelfPlayer>>,
@@ -810,6 +1026,86 @@ pub(super) fn acquire_and_attack(
seam.start(guid, false, false);
}
/// **`TargetLastEnemy`'s memory** — the last *attackable* unit that was selected.
///
/// The reference keeps two guid pairs beside the current selection, both written inside
/// `SetSelection 0x493540`: `[0xb4e2e0]/[0xb4e2e4]`, the plain outgoing target (`TargetLastTarget`
/// reads it, `0x493622`/`0x493628` write it), and `[0xb4e2e8]/[0xb4e2ec]`, the last **attackable**
/// one (`TargetLastEnemy` reads it at `0x489b45`, `0x49377d` writes it) — wow-re
/// `object-layer/scratch/selection-attack-seam.md` §3.1. So the memory belongs at the **selection
/// commit**, which is where this puts it, and it is deliberately not a second thing to remember at
/// each of [`commit`]'s six call sites: [`remember_last_enemy`] reads the frame's settled
/// selection instead, so a selection writer added later cannot forget to stamp.
///
/// **A stale guid is never cleared and never resurrects anything.** The reference's globals are
/// plain guids that nothing zeroes when the unit despawns; what protects it is the shim's route
/// through the select-if-resolves helper `0x489a40`, whose third arm — the guid resolves to no
/// streamed object and is on no roster — is a bare `ret`: **not a deselect**. So we keep the guid
/// and let the drain no-op on it (`crate::target::click`), which is the same observable.
#[derive(Resource, Default)]
pub(crate) struct LastEnemy(pub(crate) Option<u64>);
/// Stamp [`LastEnemy`] from the frame's settled selection — the `0x49377d` write, sampled at the
/// end of the target chain rather than threaded through [`commit`].
///
/// Sampling is not a shortcut: the observable "the last attackable guid I had selected" is the
/// same either way, because the only guid a commit could stamp is the one still standing here.
/// What it buys is that every present and future selection writer — the click, TAB, the two
/// auto-acquires, `TargetUnit`, `/target`, `/assist` — is covered without knowing this exists.
///
/// **Ordered before `ring::update_ring`'s death-clear on purpose**: a hostile that dies while
/// selected must still be remembered, because the reference's shim has no liveness gate either —
/// `TargetLastEnemy` back onto a corpse is faithful, and it is what you want a second after a kill.
///
/// **The whole gate, not just the attackability leg.** `0x49372f`–`0x493778` is five conjuncts and
/// this shipped with one of them (wow-re `targeting-friend-and-lastenemy.md`, §5 trio — the note
/// was dispatched from this work and landed after it): the player object resolves, **the player is
/// not dead or a ghost**, **the player is not mounted**, the new target reads
/// `HEALTH > 0 || UNIT_DYNFLAG_DEAD`, and `CanAttack(player, new)`. Without the middle three we
/// remembered in three states the reference does not — targeting a hostile while mounted, while
/// dead or ghost, or targeting an already-dead one — which is a `TargetLastEnemy` that lands
/// somewhere the real client's would not.
///
/// The odd-looking fourth conjunct is transcribed rather than simplified: `HEALTH > 0` **or** the
/// dead-looking dynflag, so a feigning unit stays remembered while a genuinely dead one does not.
///
/// One disclosed divergence from a commit-time stamp remains, and it is the sampling itself: a
/// unit selected while neutral that *turns* hostile while still selected is remembered here and
/// would not be at the reference's write, which runs once at `SetSelection`. That is the better
/// answer of the two, and it is the only case where they differ.
pub(super) fn remember_last_enemy(
selection: Res<Selection>,
stores: Query<&ObjectStore>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
mut last: ResMut<LastEnemy>,
) {
let Some((entity, guid)) = selection.target.zip(selection.guid) else {
return;
};
// Conjunct 1: the player object resolves at all.
let Some(me) = self_store.iter().next() else {
return;
};
// Conjuncts 2 and 3 — the two states of OUR body that suppress the stamp. `player_is_ghost`
// is its own read because a ghost's wire health is 1, so `unit_is_dead` is false for one
// (`player.rs`'s note on the UnitIsDead/UnitIsGhost/UnitIsDeadOrGhost trio).
if me.0.unit_is_dead() || me.0.player_is_ghost() || me.0.unit_mount_display_id() != 0 {
return;
}
let store = stores.get(entity).ok();
// Conjunct 4 — the new target is not a corpse. Verbatim: health, OR the dead-looking flag.
let alive_enough = store.is_some_and(|s| !s.0.unit_is_dead() || s.0.unit_dynflag_dead());
if !alive_enough {
return;
}
// Conjunct 5.
if can_attack(store, factions.as_deref(), &reputations, Some(me)) {
last.0 = Some(guid);
}
}
/// Auto-acquire the attacker (behavior 2): the ATTACKERSTATEUPDATE victim handler's
/// self-defense block (`6259c9`–`6259fe`) — packet victim == me AND my selection strictly
/// empty → `SetSelection(attacker)`. Selection only: no counter-attack, never overwrites.
@@ -1115,6 +1411,308 @@ mod tests {
));
}
/// The **backward** pick, beside its forward twin: newest-first through the history, skipping
/// the current selection and anything that has left the pool; nothing to step back to ⇒ `None`,
/// which is what makes the first reverse press fall through to the forward rule.
///
/// This is the whole of `reverse` — the flag `TargetNearestEnemy`/`TargetNearestFriend` take as
/// their optional Lua argument (`0x489a80`/`0x489aa0` fetch it with `0x6f1c10`, default 0) and
/// hand to the one cycler as `ecx`. Forward and backward walk the same order in opposite
/// directions: with A,B,C visited in that order, back from C is B and back from B is A.
#[test]
fn pick_back_walks_the_history_newest_first() {
let pool = [
cand(0xA, 0.1, true),
cand(0xB, 0.2, true),
cand(0xC, 0.3, true),
];
// Forward over a fresh pool visits A, then B, then C — the order `pick_back` reverses.
assert_eq!(pick_forward(&pool, &[], None), Some((0, false)));
assert_eq!(pick_forward(&pool, &[0xA], Some(0xA)), Some((1, false)));
assert_eq!(
pick_forward(&pool, &[0xA, 0xB], Some(0xB)),
Some((2, false))
);
// Back from C is B; back from B is A — the exact reverse of the walk above.
assert_eq!(pick_back(&[0xA, 0xB, 0xC], &pool, Some(0xC)), Some(1));
assert_eq!(pick_back(&[0xA, 0xB], &pool, Some(0xB)), Some(0));
// Nothing behind us: the caller falls through to the forward rule.
assert_eq!(pick_back(&[], &pool, None), None);
assert_eq!(pick_back(&[0xA], &pool, Some(0xA)), None);
// A remembered guid that has left the pool (died, streamed out) is skipped, not picked.
let shrunk = [cand(0xA, 0.1, true), cand(0xC, 0.3, true)];
assert_eq!(pick_back(&[0xA, 0xB, 0xC], &shrunk, Some(0xC)), Some(0));
}
/// The history is **per side**: a friendly press wipes the enemy cycle's skip set and vice
/// versa — the reference's `cachedMode != mode` rebuild condition (`0x493f60`), which drops the
/// candidate list and resets the cursor whenever the mode byte changes.
#[test]
fn a_side_switch_clears_the_history() {
let mut h = TabHistory::default();
h.enter(ScanSide::Enemy);
h.push(0xA, 0.0);
h.push(0xB, 1.0);
h.enter(ScanSide::Enemy); // same side: nothing happens
assert_eq!(h.guids(), [0xA, 0xB]);
h.enter(ScanSide::Friend); // the other side: start from nothing
assert!(h.guids().is_empty());
}
/// Field indices, for the store builders below (`benilla-protocol`'s own numbering).
const F_TYPE: u16 = 2;
const F_HEALTH: u16 = 22;
const F_MAXHEALTH: u16 = 28;
const F_FLAGS: u16 = 46;
const F_DYNFLAGS: u16 = 143;
const F_MOUNTDISPLAYID: u16 = 133;
const F_DUEL_ARBITER: u16 = 188;
const F_PLAYER_FLAGS: u16 = 190;
const F_DUEL_TEAM: u16 = 196;
/// `UNIT_FLAG_PVP_ATTACKABLE` — behaviourally "player-controlled"; the wire always carries it
/// on a player, which is what selects `CanAttack`'s and `CanAssist`'s player arms.
const CONTROLLED: u32 = 0x8;
/// `OBJECT_FIELD_TYPE` for a Player object (OBJECT|UNIT|PLAYER).
const TYPE_PLAYER: u32 = 0x19;
fn store(pairs: &[(u16, u32)]) -> ObjectStore {
ObjectStore(benilla_protocol::ObjectFields::from_pairs(pairs))
}
/// **The two sides of one scan, over one world** — the mode byte is the only fork, so this
/// drives the real [`TargetScan::build`] twice and asserts each side's pool.
///
/// With no `FactionTemplate.dbc` loaded every reaction resolves to neutral, which is exactly
/// the regime that separates the two filters: neutral is **attackable** (`CanAttack`'s mixed
/// arm is `< 4`) and **not assistable** (`CanAssist`'s ladder is `>= 4`). To get a genuinely
/// friendly reaction with no catalog the ally here is a **same-team duel partner** — the one
/// rung of `UnitReaction 0x6061e0` that answers 4 off descriptor fields alone (decision 0633).
/// It is also a fair model of the real case: `TargetNearestFriend` is mostly about players.
///
/// Four candidates pin four separate claims:
/// * the neutral mob is an enemy candidate and never a friendly one — **the control that must
/// not change**;
/// * the friendly player is a friendly candidate and never an enemy one;
/// * a friendly **corpse** is out — mode 2's `UNIT_FIELD_HEALTH > 0` leg;
/// * a friendly **feigner** is IN — mode 2 has no `UNIT_DYNFLAG_DEAD` leg, though mode 1's
/// reads-dead triple does. That asymmetry is transcribed, not tidied.
#[test]
fn the_two_sides_of_the_scan_never_pick_each_others_units() {
use bevy::ecs::system::RunSystemOnce;
const ARBITER: u32 = 7;
const MOB: u64 = 0xF0E;
const ALLY: u64 = 0xA11;
const ALLY_CORPSE: u64 = 0xDEAD;
const ALLY_FEIGN: u64 = 0xFE16;
let mut world = World::new();
world.init_resource::<Reputations>();
world.init_resource::<NameCache>();
// Us: a live player, mid-duel on team 1.
world.spawn((
SelfPlayer,
Transform::default(),
Guid(1),
store(&[
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
]),
));
let unit = |guid: u64, x: f32, fields: &[(u16, u32)]| {
(
NetEntity {
kind: EntityKind::Unit,
display_id: None,
scale: 1.0,
},
Guid(guid),
Transform::from_xyz(x, 0.0, 0.0),
store(fields),
)
};
let ally_fields = |extra: &[(u16, u32)]| {
let mut v = vec![
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
];
v.extend_from_slice(extra);
v
};
world.spawn(unit(MOB, 5.0, &[(F_HEALTH, 100), (F_MAXHEALTH, 100)]));
world.spawn(unit(ALLY, 10.0, &ally_fields(&[])));
world.spawn(unit(ALLY_CORPSE, 15.0, &ally_fields(&[(F_HEALTH, 0)])));
world.spawn(unit(
ALLY_FEIGN,
20.0,
&ally_fields(&[(F_DYNFLAGS, 1 << 5)]),
));
let pool = |world: &mut World, side: ScanSide| {
world
.run_system_once(move |scan: TargetScan| {
scan.build(side).iter().map(|c| c.guid).collect::<Vec<_>>()
})
.expect("the scan runs as a one-shot system")
};
// Mode 1: the neutral mob only. Every duel-team ally is refused by `CanAttack`'s
// both-player-controlled arm (a friendly reaction returns 0 outright).
assert_eq!(pool(&mut world, ScanSide::Enemy), [MOB]);
// Mode 2: the live ally and the feigning one — never the mob, never the corpse. With no
// camera in the world nothing projects, so the order is pure distance (ALLY at 10 yd
// before ALLY_FEIGN at 20).
assert_eq!(pool(&mut world, ScanSide::Friend), [ALLY, ALLY_FEIGN]);
}
/// [`LastEnemy`] tracks the last **attackable** selection and nothing else — `TargetLastEnemy`'s
/// whole memory (`[0xb4e2e8]/[0xb4e2ec]`, `0x49377d`).
///
/// The three claims that make the verb behave: it follows a hostile switch; a **friendly**
/// selection does not overwrite it (that is what separates it from `TargetLastTarget`'s pair);
/// and **clearing the target leaves it standing**, which is the whole point — you press `G`
/// after an Esc, not before one.
#[test]
fn last_enemy_remembers_the_last_attackable_selection_across_a_clear() {
use bevy::ecs::system::RunSystemOnce;
const ARBITER: u32 = 7;
let mut world = World::new();
world.init_resource::<Reputations>();
world.init_resource::<Selection>();
world.init_resource::<LastEnemy>();
world.spawn((
SelfPlayer,
store(&[
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
]),
));
let mob = |world: &mut World| {
world
.spawn(store(&[(F_HEALTH, 100), (F_MAXHEALTH, 100)]))
.id()
};
let a = mob(&mut world);
let b = mob(&mut world);
// A same-team duel partner: friendly reaction, so `CanAttack`'s PvP arm refuses.
let friend = world
.spawn(store(&[
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
]))
.id();
let select = |world: &mut World, target: Option<(Entity, u64)>| {
let mut sel = world.resource_mut::<Selection>();
sel.target = target.map(|(e, _)| e);
sel.guid = target.map(|(_, g)| g);
world
.run_system_once(remember_last_enemy)
.expect("the sampler runs as a one-shot system");
world.resource::<LastEnemy>().0
};
assert_eq!(
world.resource::<LastEnemy>().0,
None,
"nothing hostile targeted yet"
);
assert_eq!(select(&mut world, Some((a, 0xA))), Some(0xA));
assert_eq!(select(&mut world, Some((b, 0xB))), Some(0xB));
// A friendly selection is not an enemy — the memory holds at B.
assert_eq!(select(&mut world, Some((friend, 0xF))), Some(0xB));
// …and neither is an empty one. This is the case the verb exists for.
assert_eq!(select(&mut world, None), Some(0xB));
// **The four conjuncts besides `CanAttack`** (`0x49372f`-`0x493778`). Each is asserted by
// moving one state and re-selecting an ordinary hostile that would otherwise stamp: the
// memory has to hold at B every time. Written as one test rather than four because the
// whole point is that they are one gate, and a gate with a leg missing is what shipped.
let dead_mob = world
.spawn(store(&[(F_HEALTH, 0), (F_MAXHEALTH, 100)]))
.id();
assert_eq!(
select(&mut world, Some((dead_mob, 0xD))),
Some(0xB),
"a corpse is not remembered — the target's health leg"
);
// …unless it carries the dead-looking dynflag, which is a FEIGN and stays remembered.
let feigner = world
.spawn(store(&[
(F_HEALTH, 0),
(F_MAXHEALTH, 100),
(F_DYNFLAGS, 0x20),
]))
.id();
assert_eq!(
select(&mut world, Some((feigner, 0xE))),
Some(0xE),
"`HEALTH > 0 || dynflag 0x20` is an OR, transcribed not simplified"
);
// Our own body's three states. Each is set, a live hostile selected, and the memory must
// not move off 0xE.
let with_self = |world: &mut World, fields: &[(u16, u32)]| {
let me = world
.query_filtered::<Entity, With<SelfPlayer>>()
.single(world)
.expect("one self player");
let mut base = vec![
(F_TYPE, TYPE_PLAYER),
(F_FLAGS, CONTROLLED),
(F_HEALTH, 100),
(F_MAXHEALTH, 100),
(F_DUEL_ARBITER, ARBITER),
(F_DUEL_TEAM, 1),
];
base.extend_from_slice(fields);
world.entity_mut(me).insert(store(&base));
};
let c = mob(&mut world);
with_self(&mut world, &[(F_MOUNTDISPLAYID, 1234)]);
assert_eq!(
select(&mut world, Some((c, 0xC))),
Some(0xE),
"mounted: the reference does not stamp"
);
with_self(&mut world, &[(F_HEALTH, 0)]);
assert_eq!(
select(&mut world, Some((c, 0xC))),
Some(0xE),
"dead: the reference does not stamp"
);
with_self(&mut world, &[(F_PLAYER_FLAGS, 0x10)]);
assert_eq!(
select(&mut world, Some((c, 0xC))),
Some(0xE),
"ghost: a ghost's wire health is 1, so this needs its own leg"
);
// The control: put the body back and the same selection stamps.
with_self(&mut world, &[]);
assert_eq!(
select(&mut world, Some((c, 0xC))),
Some(0xC),
"the gate is the three states, not the selection"
);
}
/// The history: pruning honors the window, a re-visit moves to most-recent.
#[test]
fn history_prunes_and_reorders() {
+56 -6
View File
@@ -163,6 +163,33 @@ pub(super) fn drain_chain_casts(
}
}
/// The **self-cast modifier**, applied to a cast's targeting inputs: `UseAction`'s third argument
/// (1.12's `SELFACTIONBUTTON1`-`12`, `ALT-1`…`ALT-=`, through `ActionButtonUp(id, 1)`).
///
/// It substitutes the caster for the selection as the bind candidate — both halves of the
/// substitution, the guid *and* the store the relation checks read, which is exactly the pair
/// `resolve_cast_target`'s own autoSelfCast fallback swaps at `0x6e53d7`. So a self-cast runs the
/// ordinary binder against the ordinary candidate and needs no leg of its own; a spell that
/// cannot target a friendly unit refuses for the reason it always would, and one that needs no
/// target at all is still `SelfImplicit`.
///
/// **The behaviour is not in doubt; the byte attribution is INFERRED.** The reference's
/// `UseAction 0x4f1460` was not traced for this — the modelling here is that `onSelf` fills
/// `ArmCast 0x6e5250`'s explicit-guid argument (`6e5393`), the slot every benilla caster passes
/// zero in, because that is the one input that already means "bind this unit instead of the
/// selection". A forced-self leg of its own would be observationally identical, so nothing
/// downstream rests on which it is.
fn self_bound<'a>(
mut ctx: cast_target::CastContext<'a>,
press: benilla_ui::script::ActionUse,
) -> cast_target::CastContext<'a> {
if press.on_self {
ctx.selection_guid = ctx.self_guid;
ctx.rel.target_store = ctx.rel.self_store;
}
ctx
}
pub(super) fn drain_action_uses(
script: Option<NonSendMut<UiScript>>,
actions: Res<PlayerActions>,
@@ -172,12 +199,14 @@ pub(super) fn drain_action_uses(
// (`ladder.cast_errors` is the reason-coded one, `ladder.ground` the targeting mode).
mut ui_errors: ResMut<UiErrorKeys>,
mut ladder: CastLadder,
mut gate: crate::ui_bind_confirm::BindGate,
) {
let selection = &targeting.selection;
let Some(mut script) = script else {
return;
};
for action in script.take_action_uses() {
for press in script.take_action_uses() {
let action = press.action;
let slot = match u8::try_from(action.saturating_sub(1)) {
Ok(s) => s,
Err(_) => continue,
@@ -267,8 +296,17 @@ pub(super) fn drain_action_uses(
}
}
}
debug!("ui_action: cast {} (target {:?})", b.action, selection.guid);
ladder.send(b.action, &targeting.context(), CastCommit::Spell);
debug!(
"ui_action: cast {} (target {:?}{})",
b.action,
selection.guid,
if press.on_self { ", on self" } else { "" }
);
ladder.send(
b.action,
&self_bound(targeting.context(), press),
CastCommit::Spell,
);
}
// An item action names an item ENTRY, not a position, so the click has to find a copy
// — [`item_action_route`] is that law. A miss (the copy left the bags between the
@@ -313,10 +351,19 @@ pub(super) fn drain_action_uses(
// equippable quest-starter on the bar equips, exactly as the reference does
// (decision 0664).
debug!("ui_action: item action {action} auto-equip (wire {bag_index}/{slot0})");
let _ = ladder.commands.0.send(ClientCommand::AutoEquipItem {
// Through the one auto-equip sender (decision 1750), which carries the ammo
// fork and the soulbind deferral: a BoE dragged to the bar and pressed asks
// before it binds, exactly as the same item right-clicked in the bag does.
crate::ui_items::send_auto_equip(
&mut script,
&mut gate,
&mut ladder.items,
&ladder.commands,
bag_index,
slot: slot0,
});
slot0,
Some(guid),
false,
);
} else {
// …then the shared use fork (`CGItem::Use` — the bar's engine calls the very
// same function at `0x4e607b`), so a quest-starter on the bar offers its quest
@@ -346,6 +393,9 @@ pub(super) fn drain_action_uses(
},
&targeting.context(),
&mut ladder,
&mut script,
&mut gate,
false,
);
}
}
+1
View File
@@ -64,6 +64,7 @@ pub(crate) struct CooldownEvents;
// SystemParam and commits through [`CastCommit`] — `send_spell_cast` itself is private to
// `cast_send`, so a second send path cannot be written by accident (decision 0914).
pub(crate) use cast_send::{CastCommit, CastLadder};
pub(crate) use cast_target::AutoSelfCast;
pub(crate) use errors::{
attack_actor_refusal, reagent_totem_refusal, show_messages, ui_error_text, CastErrors,
CastFail, MountErrors, MsgSurface, UiError, UiErrorKeys, UiErrorTexts,
+249
View File
@@ -0,0 +1,249 @@
//! The **equip / auto-equip / use soulbind confirmations** — `EQUIP_BIND`, `AUTOEQUIP_BIND` and
//! `USE_BIND` (decision 1750). The loot arm's siblings, and NOT built the same way, because the
//! reference does not build them the same way either.
//!
//! ## The three predicates are three different predicates
//!
//! VERIFIED at the 1.12.1 bytes (wow-re `system/object-layer/scratch/bind-confirm-law.md`), one
//! `item_template + 0x194` compare per arm and **no `+0x1c` (quality) read in any of the three**:
//!
//! | arm | event | fires from | predicate | site |
//! |---|---|---|---|---|
//! | loot (decision 1744) | `LOOT_BIND_CONFIRM` 287 | `0x4c2790` | `bonding == 1` **and** `quality >= 2` | `4c28f2`/`4c28fb` |
//! | equip | `EQUIP_BIND_CONFIRM` 288 | `0x5e0c40` `SwapItem` | `bonding == 2` | `5e0e54` |
//! | auto-equip | `AUTOEQUIP_BIND_CONFIRM` 289 | `0x5e1480` `AutoEquipCursorItem` | `bonding == 2` | `5e163b` |
//! | use | `USE_BIND_CONFIRM` 290 | `0x5d8d00` `CGItem::Use` | `bonding == 3` | `5d91d6` |
//!
//! Carrying the loot arm's `quality >= 2` across would have been wrong twice over: it would have
//! silenced the confirm on a white BoE, and it would have asked about the wrong `bonding` value
//! entirely. (This was benilla's working assumption until the RE refuted it; that is why the table
//! is here and not a sentence.)
//!
//! ## The other conjuncts, and where benilla already had them
//!
//! The equip arms' full set is: container and item resolve · the item is **not already soulbound**
//! · the call is not itself a suppressed re-issue · the item template is **cached** · `bonding == 2`
//! · the player **can equip it**. Both of the interesting ones were already in this codebase, byte-
//! derived for other reasons and reused here rather than re-written:
//!
//! - *not already soulbound* is [`crate::items::already_bound`] — literally `0x5da2c0`, built for
//! the enchant cursor's own bind question (decision 0928) and the tooltip's Soulbound override
//! (B310/1562). The reference calls the same function from all three places.
//! - *can equip it* is `0x5ea930`, [`benilla_ui::script::UiScript::item_usable`] — built for the
//! merchant's red rows (decision 0299).
//!
//! ## Accept is a re-issue, not a confirm packet
//!
//! There is no `CMSG_CONFIRM_*` in 1.12. `EquipPendingItem(index)` re-runs the *original action*
//! with the suppress flag set, which is why [`PendingEquip`] stores the action's own coordinates
//! and the accept path calls the very same sender the click did — `suppress` threaded through as a
//! real parameter, exactly as `0x5e0c40`/`0x5e1480` take it. `CancelPendingEquip(index)` sends
//! nothing and only drops the record.
//!
//! **A superseded record is CANCELLED, not overwritten** — the entries are `exclusive = 1`, so
//! `StaticPopup_Show` hides the standing dialog and runs its `OnHide`, which is
//! `CancelPendingEquip(slot)`. That is why both `OnCancel` and `OnHide` name it, and why this store
//! is an index-addressed array of optional records rather than a single cell: two questions can be
//! live in principle, and the second one's arrival is what retires the first.
//!
//! ## The index space is the reference's own
//!
//! `arg1` is a **0-based index into the pending array**, not a slot (wow-re corrected benilla's
//! assumption here). It is opaque on both sides — the event hands it out, `dialog.data` carries it,
//! and the two verbs hand it straight back — so unlike the loot arm's row number (1744, translated
//! into benilla's display space) there is nothing to gain by re-basing it, and it is kept as-is.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use benilla_ui::script::{ScriptValue, UiScript};
use crate::items::{Enchants, Items};
/// `item_template.bonding` — the values the three arms compare against (vmangos
/// `ItemPrototype.h`'s `ItemBondingType`; the client reads the same field at `+0x194`).
pub(crate) const BIND_WHEN_EQUIPPED: u32 = 2;
/// The use arm's value (`0x5d91d6`).
pub(crate) const BIND_WHEN_USE: u32 = 3;
/// One deferred action, held until the player answers. Stored as the action's own **Lua-space**
/// coordinates rather than as a built packet, because accept is a re-issue: the same sender runs
/// again with `suppress` set and re-reads the world, so a slot that changed under the open dialog
/// is re-judged instead of being sent stale.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum PendingEquip {
/// Arm 288 — a placement into an equipment/bag/bank-bag slot (`EQUIP_BIND_CONFIRM`), in
/// **Lua** space. Lua, because the re-issue goes back through the move drain's own sender,
/// which re-derives the wire pair AND takes the pending-op lock — one code path for the
/// deferred and the undeferred swap rather than two that must be kept agreeing.
Swap {
src_bag: i64,
src_slot: u32,
dst_bag: i64,
dst_slot: u32,
},
/// Arm 289 — an auto-equip, in **wire** space plus the item's guid. Wire, because one of the
/// three auto-equip senders is the action bar's, which resolves an item *entry* to a position
/// and so has no Lua pair to re-derive from; the guid, so the re-issue can re-take the ammo
/// fork off the item's own template exactly as the first attempt did.
AutoEquip { bag_index: u8, slot: u8, guid: u64 },
}
/// The client's pending-equip array (`0xc4c290`-`0xc4c29c`, stride `0x20`), modelled as what it
/// is: an index-addressed store whose free elements are reused. The reference's "free ⇔ the src
/// container guid is 0" is our `None`; its grow-on-demand is our `push`.
///
/// It is deliberately NOT reset on world enter/leave, matching the reference (whose array is zeroed
/// once at CRT static-init and freed at shutdown) — a dialog cannot outlive its session anyway,
/// because the UI is torn down with it.
#[derive(Resource, Default)]
pub(crate) struct PendingEquips {
slots: Vec<Option<PendingEquip>>,
}
impl PendingEquips {
/// File a record into the first free index, growing only when none is free — the reference's
/// own allocator shape. Returns the index the event carries out.
pub(crate) fn add(&mut self, rec: PendingEquip) -> u32 {
let idx = match self.slots.iter().position(Option::is_none) {
Some(i) => {
self.slots[i] = Some(rec);
i
}
None => {
self.slots.push(Some(rec));
self.slots.len() - 1
}
};
u32::try_from(idx).unwrap_or(u32::MAX)
}
/// Take the record at `index`, freeing the element. `None` for an index nobody filed — which is
/// the reference's own answer too (`0x5e1be0` bounds-checks against the live element count and
/// returns), so a stray `EquipPendingItem(99)` from an addon does nothing.
pub(crate) fn take(&mut self, index: u32) -> Option<PendingEquip> {
self.slots.get_mut(index as usize)?.take()
}
/// How many records are live — the step-back check that nothing leaks (a cancelled or accepted
/// question must free its element).
#[cfg(test)]
pub(crate) fn live(&self) -> usize {
self.slots.iter().flatten().count()
}
}
/// Arm 290's pending state. The reference does **not** use the array here: it stamps two globals at
/// the fire site (the item guid `0xc4c240`, the target guid `0xc4c1d0`) and `ConfirmBindOnUse()`
/// re-issues `CGItem::Use(&target, suppress = 1)` off them. One cell, no index, and no cancel verb
/// at all — declining just drops the question.
#[derive(Resource, Default)]
pub(crate) struct PendingBindOnUse(pub(crate) Option<PendingUse>);
/// The deferred use — the *original action*, stored verbatim, because that is what the accept
/// re-issues. The reference stamps the item guid and the target guid and re-runs `CGItem::Use` off
/// them; [`crate::ui_items::ItemUse`] is benilla's whole equivalent of that pair of arguments, and
/// it is `Copy`, so the record is literally the call that was about to be made.
pub(crate) type PendingUse = crate::ui_items::ItemUse;
/// The three resources the deferral needs, bundled so a drain can take them in one parameter
/// (the shape `crate::ui_action::CastLadder` established). [`Items`] is deliberately NOT in here:
/// every caller already holds it, and a second `ResMut<Items>` in one system is a conflict.
#[derive(SystemParam)]
pub(crate) struct BindGate<'w> {
pub(crate) equips: ResMut<'w, PendingEquips>,
pub(crate) on_use: ResMut<'w, PendingBindOnUse>,
/// `already_bound`'s enchant catalog — an enchant can soulbind the item it lands on, and the
/// reference's `0x5da2c0` walks all seven slots. Absent (no client data) degrades to the
/// instance flag alone, which is the same posture every other consumer takes.
pub(crate) enchants: Option<Res<'w, Enchants>>,
}
impl BindGate<'_> {
/// Does taking this equip action have to ask first? `item_guid` is the item that would BIND —
/// for a swap that is the occupant of the non-equip side, for an auto-equip the clicked slot.
///
/// `false` on every "we cannot tell" (no such item, no template yet): the reference's own
/// cache-**hit** conjunct fails open the same way, and a miss there re-runs the whole decision
/// when the template lands rather than asking about an item it cannot name.
pub(crate) fn equip_binds(
&self,
script: &UiScript,
items: &mut Items,
commands: &crate::net::NetCommands,
item_guid: u64,
) -> bool {
let Some(fields) = items.object(item_guid) else {
return false;
};
if crate::items::already_bound(fields, self.enchants.as_deref()) {
return false;
}
let Some(entry) = items.object(item_guid).and_then(|o| o.object_entry()) else {
return false;
};
let Some(t) = items.template(entry, item_guid, commands) else {
return false;
};
t.bonding == BIND_WHEN_EQUIPPED && script.item_usable(entry)
}
/// Arm 290's predicate, VERIFIED as an **earned census**: `[0x5d91d3, 0x5d91f2)` is 31
/// contiguous bytes holding exactly three `jcc` — `5d91dd` (`bonding == 3`), `5d91e9`
/// (`0x5da2c0` says not already bound) and `5d91f0` (`suppress == 0`) — all three jumping to
/// the cast, with no other branch and no other call. Nothing narrower, nothing wider.
///
/// The `0x5ea930` (can-use) leg is genuinely absent and is not an omission: a `0x5ea930`
/// failure is one of the rungs that **exits** `0x5d8d00` ahead of the bind arm, so by the time
/// the fire site is reached the item has already passed it.
pub(crate) fn use_binds(
&self,
items: &mut Items,
commands: &crate::net::NetCommands,
item_guid: u64,
) -> bool {
let Some(fields) = items.object(item_guid) else {
return false;
};
if crate::items::already_bound(fields, self.enchants.as_deref()) {
return false;
}
let Some(entry) = items.object(item_guid).and_then(|o| o.object_entry()) else {
return false;
};
items
.template(entry, item_guid, commands)
.is_some_and(|t| t.bonding == BIND_WHEN_USE)
}
/// File a deferred equip and raise its dialog. `auto` picks the event, which is the only thing
/// that differs between arms 288 and 289 on this side — `UIParent.lua:324-339` shows the two
/// branches are otherwise identical, down to each hiding the other's dialog first.
pub(crate) fn defer_equip(&mut self, script: &mut UiScript, rec: PendingEquip) {
let index = self.equips.add(rec);
let event = match rec {
PendingEquip::Swap { .. } => "EQUIP_BIND_CONFIRM",
PendingEquip::AutoEquip { .. } => "AUTOEQUIP_BIND_CONFIRM",
};
debug!("ui_bind_confirm: {event} index {index} for {rec:?}");
// QUEUED, not fired (see `UiScript::queue_event`): the place that triggered this deferral
// has already queued its own `CURSOR_UPDATE`, and `UIParent.lua:356-360` hides both equip
// dialogs on it. Firing immediately would put the question AHEAD of that cursor change and
// the next tick would cancel it — proven by a test, not reasoned about.
script.queue_event(event, vec![ScriptValue::Int(i64::from(index))]);
}
/// File the deferred use and raise `USE_BIND`. No index and no argument: arm 290's state is one
/// cell, and a second deferred use simply replaces the first (there is no cancel verb to
/// retire it with, which is the reference's own shape).
pub(crate) fn defer_use(&mut self, script: &mut UiScript, pending: PendingUse) {
debug!(
"ui_bind_confirm: USE_BIND_CONFIRM for wire {}/{}",
pending.bag_index, pending.slot
);
self.on_use.0 = Some(pending);
// Queued for the same reason as the equip arms, though `USE_BIND` is not on the
// CURSOR_UPDATE arm: one rule for the three of them beats one rule and an exception.
script.queue_event("USE_BIND_CONFIRM", vec![]);
}
}
+21 -8
View File
@@ -561,11 +561,14 @@ pub(super) fn parse_type_switch(
}
/// The open commands through the binding table (0997; 1.12 defaults OPENCHAT = Enter,
/// OPENCHATSLASH = `/`, REPLY = R): OPENCHAT opens with the sticky type, downgrading an invalid
/// PARTY/RAID to SAY against the live group state (the ref's `ChatEdit_UpdateHeader`
/// invalid-type law; the sticky itself keeps its value, so rejoining a group restores it —
/// GUILD/BG downgrades wait on their arcs' state); OPENCHATSLASH opens pre-slashed; REPLY opens
/// as a reply to the last teller. The dispatch already gated on nothing owning the keyboard
/// OPENCHATSLASH = `/`, REPLY = R, REPLY2 = Shift-R): OPENCHAT opens with the sticky type,
/// downgrading an invalid PARTY/RAID to SAY against the live group state (the ref's
/// `ChatEdit_UpdateHeader` invalid-type law; the sticky itself keeps its value, so rejoining a
/// group restores it — GUILD/BG downgrades wait on their arcs' state); OPENCHATSLASH opens
/// pre-slashed; REPLY opens as a reply to the last teller, and **REPLY2 to the last person YOU
/// told** — the reference's own split, `ChatEdit_GetLastTellTarget` against
/// `ChatEdit_GetLastToldTarget` (ChatFrame.lua l.1627/1645), and the reason they are two
/// commands rather than one. The dispatch already gated on nothing owning the keyboard
/// (a focused box's Enter is the box's), and Shift-Enter no longer opens chat — `SHIFT-ENTER`
/// is a different chord than `ENTER`, the exact-modifier law the reference applies.
pub(super) fn open_chat_keys(
@@ -581,12 +584,22 @@ pub(super) fn open_chat_keys(
let open_plain = binds.fired(cmd::OPEN_CHAT);
let open_slash = binds.fired(cmd::OPEN_CHAT_SLASH);
let open_reply = binds.fired(cmd::REPLY) && !state.last_tell.is_empty();
if !(open_plain || open_slash || open_reply) {
// REPLY2 rides `last_told`, which the send path has been keeping all along
// ([`super::input::send`]) with nothing reading it. Same guard shape as REPLY's: with nobody
// told yet the reference falls into its own empty-string else-branch and opens nothing.
let open_reply2 = binds.fired(cmd::REPLY2) && state.last_told.is_some();
if !(open_plain || open_slash || open_reply || open_reply2) {
return;
}
if open_reply {
if open_reply || open_reply2 {
state.chat_type = SendType::Whisper;
state.tell_target = state.last_tell.front().cloned().unwrap_or_default();
// REPLY wins a same-frame tie, which is the order the reference's two bindings can only
// be pressed in anyway (they are different chords); stated rather than left to the `if`.
state.tell_target = if open_reply {
state.last_tell.front().cloned().unwrap_or_default()
} else {
state.last_told.clone().unwrap_or_default()
};
} else {
state.chat_type = sticky_on_open(state.sticky, &group);
}
File diff suppressed because it is too large Load Diff
+36
View File
@@ -51,6 +51,7 @@ mod drain;
mod equip_error;
pub(crate) mod feed;
pub(crate) use drain::send_auto_equip;
use drain::{
drain_container_autoequips, drain_container_destroys, drain_container_moves,
drain_container_uses, drain_inventory_uses,
@@ -772,6 +773,9 @@ pub(crate) fn send_item_use(
it: ItemUse,
ctx: &crate::ui_action::cast_target::CastContext,
ladder: &mut crate::ui_action::CastLadder,
script: &mut benilla_ui::script::UiScript,
gate: &mut crate::ui_bind_confirm::BindGate,
suppress: bool,
) -> bool {
// The toggle predicate, resolved once against the caster's live aura slots. Both inputs are
// already here: the spell's ActiveIconID column and the caster's descriptor.
@@ -806,6 +810,27 @@ pub(crate) fn send_item_use(
.send(crate::net::ClientCommand::PetitionShowSignatures { item });
true
}
// **The bind-on-use deferral** (`0x5d91d3`-`0x5d91f2`, decision 1750), and its POSITION is
// half the law. The reference's bind arm is the last rung of `0x5d8d00`: every arm above —
// the gift, the quest offer, the petition, the readable, the charge/slot rungs and the aura
// toggle — has already claimed the click and exited before a bind question can be asked, so
// re-pressing a bind-on-use trinket to cancel its aura must NOT ask you to bind it.
//
// **But it covers `Nothing` as well as `Cast`, and that is not an accident.** `0x5d91d3`
// has five predecessors and four of them are on-use-spell lookup *failures* (`5d9166`
// id < 0, `5d916e` id past the table, `5d917a` no Spell.dbc row, `5d9184` no ActiveIconID);
// the reference asks the bind question even when the item has no usable on-use spell at
// all. Gating this on `Cast(spell)` alone — which is where it was first written — would be
// narrower than the reference, and wow-re said so in as many words.
ItemUseRoute::Nothing | ItemUseRoute::Cast(_)
if !suppress
&& it
.guid
.is_some_and(|g| gate.use_binds(&mut ladder.items, &ladder.commands, g)) =>
{
gate.defer_use(script, it);
false
}
ItemUseRoute::Nothing => {
debug!(
"ui_items: the item at wire {}/{} has no ON_USE block — nothing sent (TryCast's null-rec bail)",
@@ -1045,6 +1070,10 @@ impl Plugin for UiItemsPlugin {
// renderer already loads (one parse serves the world and the bags).
app.init_resource::<EquipErrors>()
.init_resource::<PendingItemOps>()
// The soulbind confirmations' pending records (decision 1750) — the client's own
// pending-equip array and its one bind-on-use cell.
.init_resource::<crate::ui_bind_confirm::PendingEquips>()
.init_resource::<crate::ui_bind_confirm::PendingBindOnUse>()
.init_resource::<LockClearedByFailure>()
// AFTER the chain opens — a bare Startup slot raced AssetSet::Open and, when it
// won, silently skipped every item DBC (no ItemSets/ItemSubClasses resource for the
@@ -1086,6 +1115,13 @@ impl Plugin for UiItemsPlugin {
// UseInventoryItem's queue (decision 0208 phase 1b) → CMSG_USE_ITEM against
// the equipped position.
drain_inventory_uses.after(UiInput),
// EquipPendingItem/CancelPendingEquip/ConfirmBindOnUse — the soulbind
// confirmations' answers (decision 1750). After the input pass like every
// other drain, and after the drains whose deferrals it answers: a dialog
// raised this frame is answered in a LATER one, so the order between them is
// not load-bearing, but keeping it last matches the flow.
drain::drain_bind_confirm_answers.after(UiInput),
drain::drain_bind_on_use_confirms.after(UiInput),
),
);
}
+1 -1
View File
@@ -62,7 +62,7 @@ pub(super) fn drain_pet_actions(
pet: PetUnit,
spells: Option<Res<Spells>>,
mut ui_errors: ResMut<crate::ui_action::UiErrorKeys>,
scan: crate::target::EnemyScan,
scan: crate::target::TargetScan,
mut seam: crate::creature_anim::AttackSeam,
) {
let Some(mut script) = script else {
@@ -1,5 +1,11 @@
use benilla_ui::script::{ActionSlot, QuadContent, ScriptValue, UiScript};
/// The queued action ids alone — `take_action_uses` carries `UseAction`'s self-cast modifier
/// beside the id since 1745, and every assertion in this file is about the id.
fn action_ids(s: &mut UiScript) -> Vec<u32> {
s.take_action_uses().into_iter().map(|u| u.action).collect()
}
/// Load the real `assets/ui/ActionBar.xml` (the shipped default bar) into a bare engine and
/// drive it with a synthetic action snapshot — the slice-1 chain minus Bevy: template
/// expansion over 12 instances, the vanilla bonus-page formula, icon paint on events, empty
@@ -95,7 +101,7 @@ fn shipped_action_bar_drives_end_to_end() {
// center is (8+18, 4+18) = (26, 22).
s.mouse_button(26.0, 22.0, "LeftButton", true);
s.mouse_button(26.0, 22.0, "LeftButton", false);
assert_eq!(s.take_action_uses(), vec![73]);
assert_eq!(action_ids(&mut s), vec![73]);
// The keybinding entry (the app's key feed runs `ActionButtonDown/Up(i)` on the two
// key edges — the ref's ACTIONBUTTONn binding, ActionButton.lua:15-45): UP fires UseAction
@@ -123,7 +129,7 @@ fn shipped_action_bar_drives_end_to_end() {
"PUSHED"
);
s.run("ActionButtonUp(2)").unwrap();
assert_eq!(s.take_action_uses(), vec![74], "key '2' fires action 74");
assert_eq!(action_ids(&mut s), vec![74], "key '2' fires action 74");
assert_eq!(depressed(&s), 0, "key UP restores the normal state");
// Stance drops (offset 0): the bar re-pages to actions 1..12 — all empty here, icons clear.
@@ -335,7 +341,7 @@ fn a_right_click_on_an_action_button_uses_the_action() {
s.mouse_button(26.0, 22.0, "RightButton", true);
s.mouse_button(26.0, 22.0, "RightButton", false);
assert_eq!(
s.take_action_uses(),
action_ids(&mut s),
vec![1],
"right-click queues the same UseAction a left-click does"
);
@@ -1439,11 +1445,24 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
// Keys route to the overlay immediately (ref ActionButton.lua:15-45's IsShown fork).
s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap();
assert_eq!(
s.take_action_uses(),
action_ids(&mut s),
vec![73],
"a key pressed mid-slide already drives the bonus page"
);
// …and the same button with the SELF-CAST modifier. `SELFACTIONBUTTON1`-`12` (`ALT-1`…`ALT-=`)
// are `ActionButtonUp(id, 1)` and nothing else, so this is the whole of what those twelve
// bindings do that the plain twelve do not (1745).
s.run("ActionButtonDown(1) ActionButtonUp(1, 1)").unwrap();
assert_eq!(
s.take_action_uses()
.into_iter()
.map(|u| (u.action, u.on_self))
.collect::<Vec<_>>(),
vec![(73, true)],
"ActionButtonUp's onSelf reaches UseAction's third argument"
);
// Half the slide: the replica is half-risen (top = 0.5 * 43 over the bar's bottom edge at
// y=0), the paint suppression still holds, still silent.
s.tick(0.075);
@@ -1536,7 +1555,7 @@ fn bonus_bar_slides_up_with_sound_and_down_without() {
// A key mid-descent still drives the old form's page — ref GetPagedID's lastBonusBar
// stand-in while the frame is still shown.
s.run("ActionButtonDown(1) ActionButtonUp(1)").unwrap();
assert_eq!(s.take_action_uses(), vec![97]);
assert_eq!(action_ids(&mut s), vec![97]);
s.tick(0.2);
assert!(
!s.eval::<bool>("return BonusActionBarFrame:IsShown()")
@@ -665,6 +665,10 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "ScrollTemplates.xml");
// Not optional, though this list ran without it: a MISSING template is a loader *warning*, not
// an error, so an under-loaded list passes load_xml's assert and then fails later on geometry
// that silently never got built. SkillDetailScrollFrame inherits UIPanelScrollFrameTemplate.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "CharacterFrame.xml");
load_xml(&s, "SkillFrame.xml");
s.set_unit("player", Some(player_unit()));
@@ -772,10 +776,10 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
// name; accepting queues the CMSG_UNLEARN_SKILL intent BY SKILL ID and removes nothing
// locally (the server's SetSkill(id,0,0) round trip owns the removal).
assert!(
shown(&mut s, "SkillDetailUnlearnButton"),
shown(&mut s, "SkillDetailStatusBarUnlearnButton"),
"the unlearn button shows for a profession"
);
s.run("SkillDetailUnlearnButton:Click()").unwrap();
s.run("SkillDetailStatusBarUnlearnButton:Click()").unwrap();
assert!(
shown(&mut s, "StaticPopup1"),
"the UNLEARN_SKILL confirm opens"
@@ -802,7 +806,7 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
let defense = text_center(&mut s, "Defense");
click(&mut s, defense);
assert!(
!shown(&mut s, "SkillDetailUnlearnButton"),
!shown(&mut s, "SkillDetailStatusBarUnlearnButton"),
"no unlearn button for Defense"
);
// Restore the profession selection so the round trip below leaves familiar state.
@@ -130,6 +130,11 @@ fn an_error_toast_draws_over_an_open_panel_window() {
load_xml(&s, "GameTooltip.xml");
load_xml(&s, "Cooldown.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_* — QuestLogDetail's reward money row
// ScrollTemplates.xml (the faux kit the list rides) + UIPanelTemplates.xml (the detail
// pane's UIPanelScrollFrameTemplate). A MISSING template is a loader *warning*, so an
// under-loaded list passes and then dies on the first FauxScrollFrame_Update.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
// A left-slot panel open, and the toast raised after it — the order that must not decide.
@@ -317,15 +317,9 @@ const KNOWN: &[Known] = &[
why: "as FriendsFrameFriendsScrollFrame",
},
Known {
frame: "OpenMailScrollFrame",
frame: "QuestLogListScrollFrame",
flag: Flag::Mouse,
why: "our open-mail body is flat art plus a FontString — the render approximation \
MailFrame.xml names; nothing scrolls it on either side",
},
Known {
frame: "SendMailScrollFrame",
flag: Flag::Mouse,
why: "as OpenMailScrollFrame",
why: "as FriendsFrameFriendsScrollFrame",
},
// ── id ─────────────────────────────────────────────────────────────────────────────────────
//
@@ -200,14 +200,18 @@ fn the_capture_flow_binds_steals_and_refuses_like_112() {
.unwrap(),
"Unbind arms with the selection"
);
// The host hands back a canonical chord: F binds into slot 1, W's old seat; UP survives
// The host hands back a canonical chord: J binds into slot 1, W's old seat; UP survives
// in slot 2; the capture disarms; the table is LIVE and the bind COMMITTED (Save queued —
// 1008's live-commit law, where 0997 waited for Okay).
s.run(r#"KeyBindings_OnHostKey("F")"#).unwrap();
//
// **`J`, not `F`** — this leg is about the plain bind, so its key has to be one nothing else
// holds, and `F` stopped being one when 1745 registered `ASSISTTARGET` on its byte-real
// default. The steal path has its own leg below (`T`), which is where a taken key belongs.
s.run(r#"KeyBindings_OnHostKey("J")"#).unwrap();
assert!(!s.bind_capture_armed(), "a completed bind disarms");
assert!(s
.eval::<bool>(
r#"local k1, k2 = GetBindingKey("MOVEFORWARD"); return k1 == "F" and k2 == "UP""#
r#"local k1, k2 = GetBindingKey("MOVEFORWARD"); return k1 == "J" and k2 == "UP""#
)
.unwrap());
assert_eq!(
@@ -710,3 +714,34 @@ fn the_pet_lane_is_registered_under_the_action_bar_header() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// **Every registry body is real Lua.** 167 rows carry a Lua chunk as a Rust string literal, and
/// nothing compiled them: a typo in one shipped as a command that errors on its first press, in a
/// file whose tests all live one layer up at the page. `loadstring` is the whole check — running
/// them needs the FrameXML the harness deliberately does not load — and it is exactly the check
/// that a hand-typed string wants.
#[test]
fn every_command_body_compiles() {
use crate::bindings::commands::Kind;
let s = UiScript::new().unwrap();
for spec in SPECS {
let bodies: Vec<&str> = match &spec.kind {
Kind::Held | Kind::Host => Vec::new(),
Kind::Edge(body) => vec![*body],
Kind::EdgeUpDown(down, up) => vec![*down, *up],
};
for body in bodies {
s.run(&format!(
"local f, err = loadstring({body:?}); if not f then BenillaBodyError = {name:?} .. \": \" .. err end",
body = body,
name = spec.name
))
.unwrap_or_else(|e| panic!("{}: {e}", spec.name));
}
}
let bad: Vec<String> = s.errors();
assert!(bad.is_empty(), "script errors: {bad:?}");
s.run("if BenillaBodyError then error(BenillaBodyError) end")
.unwrap_or_else(|e| panic!("a command body is not valid Lua: {e}"));
}
@@ -165,13 +165,22 @@ fn shipped_multibars_drive_end_to_end() {
// a bonus page is active (the ref's fork: only the main bar re-pages).
s.mouse_button(26.0, 75.0, "LeftButton", true);
s.mouse_button(26.0, 75.0, "LeftButton", false);
assert_eq!(s.take_action_uses(), vec![61]);
assert_eq!(
s.take_action_uses()
.into_iter()
.map(|u| u.action)
.collect::<Vec<_>>(),
vec![61]
);
s.set_bonus_bar_offset(1);
s.fire_event("UPDATE_BONUS_ACTIONBAR", vec![]);
s.mouse_button(26.0, 75.0, "LeftButton", true);
s.mouse_button(26.0, 75.0, "LeftButton", false);
assert_eq!(
s.take_action_uses(),
s.take_action_uses()
.into_iter()
.map(|u| u.action)
.collect::<Vec<_>>(),
vec![61],
"a bonus page never re-pages a multibar"
);
+53 -22
View File
@@ -55,14 +55,18 @@ fn shipped_gossip_frame_drives_end_to_end() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// The window + its scroll frame (bar + child) + the 32-row shared pool (quest rows and option
// rows both draw from it, decision 0088 §3 — the reference's own NUMGOSSIPBUTTONS) + the close
// button + the GOODBYE button. The greeting and the NPC-name banner are FontString layers (the
// real GossipGreetingText ref l.241 / GossipFrameNpcNameText ref l.170) — not their own frames.
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "UIPanelTemplates.xml");
// The window + its scroll frame (bar + child) + the 32-row shared pool (quest rows and option
// rows both draw from it, decision 0088 §3 — the reference's own NUMGOSSIPBUTTONS) + the close
// button + the GOODBYE button. The greeting and the NPC-name banner are FontString layers (the
// real GossipGreetingText ref l.241 / GossipFrameNpcNameText ref l.170) — not their own frames.
assert_eq!(
load_xml(&s, "GossipFrame.xml"),
40,
"window + scroll + bar (+2 arrows) + child + 32 rows + close + goodbye"
41,
"window + greeting panel + scroll + bar (+2 arrows) + child + 32 rows + close + goodbye"
);
// Hidden by default: no gossip icon on screen.
@@ -111,8 +115,8 @@ fn shipped_gossip_frame_drives_end_to_end() {
// so the shared pool starts filling from option 1 at row 1 — decision 0088 §3).
let states: (bool, bool, bool, bool) = s
.eval(
"return GossipRow1:IsVisible(), GossipRow1:IsEnabled() ~= 0,\n\
GossipRow2:IsEnabled() ~= 0, GossipRow3:IsVisible()",
"return GossipTitleButton1:IsVisible(), GossipTitleButton1:IsEnabled() ~= 0,\n\
GossipTitleButton2:IsEnabled() ~= 0, GossipTitleButton3:IsVisible()",
)
.unwrap();
assert_eq!(
@@ -233,6 +237,10 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "GossipFrame.xml");
s.set_gossip(Some(GossipMenu {
@@ -269,10 +277,10 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
bool,
) = s
.eval(
"return GossipRow1Label:GetText(), GossipRow1:IsVisible(),\n\
GossipRow2Label:GetText(), GossipRow2:IsVisible(),\n\
GossipRow3Label:GetText(), GossipRow3:IsVisible(),\n\
GossipRow4:IsVisible()",
"return GossipTitleButton1Label:GetText(), GossipTitleButton1:IsVisible(),\n\
GossipTitleButton2Label:GetText(), GossipTitleButton2:IsVisible(),\n\
GossipTitleButton3Label:GetText(), GossipTitleButton3:IsVisible(),\n\
GossipTitleButton4:IsVisible()",
)
.unwrap();
assert_eq!((r1_text.as_str(), r1_vis), ("Report to Goldshire", true));
@@ -323,7 +331,8 @@ fn shipped_gossip_frame_renders_quest_rows_above_options() {
// Click quest row 2 (available, "A Threat Within") → SelectGossipQuest(2) queues the seam's
// 1-based quest-row position; the app maps it to the quest id + guid and sends
// CMSG_QUESTGIVER_QUERY_QUEST (decision 0088 §3).
s.run("BenillaGossipRow_OnClick(GossipRow2)").unwrap();
s.run("BenillaGossipRow_OnClick(GossipTitleButton2)")
.unwrap();
assert_eq!(s.take_gossip_quest_selects(), vec![2]);
assert!(
s.take_gossip_selects().is_empty(),
@@ -349,6 +358,10 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "GossipFrame.xml");
// Three long options — the shape of a real judgement/roleplay menu, every one of them wrapping
@@ -410,8 +423,8 @@ fn shipped_gossip_rows_grow_to_their_wrapped_labels() {
let row = |i: u32| -> (f32, f32, f32) {
s.eval::<(f32, f32, f32)>(&format!(
"return GossipRow{i}:GetTop(), GossipRow{i}:GetBottom(), \
GossipRow{i}Label:GetHeight()"
"return GossipTitleButton{i}:GetTop(), GossipTitleButton{i}:GetBottom(), \
GossipTitleButton{i}Label:GetHeight()"
))
.unwrap()
};
@@ -456,6 +469,10 @@ fn gossip_show_hide_plays_open_and_close_kits() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "GossipFrame.xml");
// Hidden at load: no open sound (never transitions on startup).
@@ -493,6 +510,10 @@ fn shipped_panel_slot_replaces_gossip_with_merchant() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "GossipFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -575,6 +596,10 @@ fn displacing_an_npc_window_ends_the_displaced_session() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "GossipFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -719,6 +744,10 @@ fn gossip_bank_option_hands_the_left_slot_to_the_bank() {
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "BankFrame.xml");
load_xml(&s, "ScrollTemplates.xml"); // the shared scroll kit the window rides
// UIPanelScrollFrameTemplate lives here, and the gossip scroll frame inherits it. NOT
// optional: a missing template is a loader *warning*, not an error, so an under-loaded
// list passes load_xml and then loses the scrollbar silently.
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "GossipFrame.xml");
// The gossip menu is open on the banker (its bank option showing).
@@ -786,6 +815,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml"); // UIPanelScrollFrameTemplate — see the note above
load_xml(&s, "GossipFrame.xml");
// Eight wrapping options: ~4 lines each, far past the 334 px scroll frame.
@@ -830,8 +860,8 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
// The child grew past the 334 px frame, so there is a range to scroll …
let (child_h, range): (f32, f32) = s
.eval(
"return GossipGreetingScrollChild:GetHeight(), \
GossipGreetingScroll:GetVerticalScrollRange()",
"return GossipGreetingScrollChildFrame:GetHeight(), \
GossipGreetingScrollFrame:GetVerticalScrollRange()",
)
.unwrap();
assert!(
@@ -841,7 +871,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
// … and the bar is up for it (it stays hidden when everything fits — the fit case is covered by
// `shipped_gossip_frame_drives_end_to_end`'s two-option menu).
assert!(
s.eval::<bool>("return GossipGreetingScrollBar:IsVisible()")
s.eval::<bool>("return GossipGreetingScrollFrameScrollBar:IsVisible()")
.unwrap(),
"the scrollbar shows once the menu overflows"
);
@@ -852,7 +882,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
// out with: it must be the scroll frame's own rect, and rows past the bottom must be entirely
// outside it (nothing of them survives the clip).
let (frame_top, frame_bottom): (f32, f32) = s
.eval("return GossipGreetingScroll:GetTop(), GossipGreetingScroll:GetBottom()")
.eval("return GossipGreetingScrollFrame:GetTop(), GossipGreetingScrollFrame:GetBottom()")
.unwrap();
let painted_rows =
|s: &mut UiScript| -> Vec<(benilla_ui::layout::Rect, benilla_ui::layout::Rect)> {
@@ -885,9 +915,9 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
// Scrolling pans the content up under that clip by exactly the scroll amount. Rects are y-up
// (`ExtractedQuad::rect`), so "up" means row 1's top EDGE VALUE grows as it slides off the top.
let first_top =
|s: &mut UiScript| -> f32 { s.eval::<f32>("return GossipRow1:GetTop()").unwrap() };
|s: &mut UiScript| -> f32 { s.eval::<f32>("return GossipTitleButton1:GetTop()").unwrap() };
let before = first_top(&mut s);
s.run("BenillaScroll_Step(GossipGreetingScroll, 100)")
s.run("BenillaScroll_Step(GossipGreetingScrollFrame, 100)")
.unwrap();
s.resolve();
let after = first_top(&mut s);
@@ -897,7 +927,7 @@ fn an_overflowing_gossip_menu_scrolls_instead_of_spilling() {
);
// The bar followed the scroll (SyncBar off OnVerticalScroll), and the rows are still clipped.
assert_eq!(
s.eval::<f32>("return GossipGreetingScrollBar:GetValue()")
s.eval::<f32>("return GossipGreetingScrollFrameScrollBar:GetValue()")
.unwrap(),
100.0,
"the bar seats at the scroll offset"
@@ -932,6 +962,7 @@ fn an_addons_own_frame_registered_in_uipanelwindows_takes_the_left_slot() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml"); // UIPanelScrollFrameTemplate — see the note above
load_xml(&s, "GossipFrame.xml");
// The addon's three lines, in the order an addon writes them.
@@ -5,7 +5,9 @@
//! of these test: `UNIT_PET` names the OWNER (`arg1 == "player"`, wow-re §9), every other `UNIT_*`
//! names the pet itself, and a frame that mixes the two repaints off the player's health.
use benilla_ui::script::{AuraState, QuadContent, ScriptValue, UiScript, UnitState};
use benilla_ui::script::{
AuraState, QuadContent, ScriptValue, SelectionRequest, UiScript, UnitState,
};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error (the unit-frame tests'
/// loader).
@@ -322,11 +324,14 @@ fn left_clicking_the_pet_frame_targets_it() {
s.fire_event("UNIT_PET", vec![ScriptValue::Str("player".into())]);
s.run("PetFrame_OnClick(\"LeftButton\")").unwrap();
assert_eq!(s.take_target_requests(), vec!["pet".to_string()]);
assert_eq!(
s.take_selection_requests(),
vec![SelectionRequest::Unit("pet".into())]
);
// The right button is the deferred PET menu — it must do nothing at all, not target.
s.run("PetFrame_OnClick(\"RightButton\")").unwrap();
assert!(s.take_target_requests().is_empty());
assert!(s.take_selection_requests().is_empty());
}
/// **All three legs of `PetFrame_OnClick` survive the click** — B208's "dropping food from the bag
@@ -394,7 +399,7 @@ fn every_leg_of_the_pet_frame_click_reaches_a_live_binding() {
"a held item + a pet click queues the drop"
);
// …and it is the DROP leg, not the target leg — the reference's if/elseif is exclusive.
assert!(s.take_target_requests().is_empty());
assert!(s.take_selection_requests().is_empty());
}
/// The GlobalStrings the happiness tooltip resolves by key. Loaded from the MPQ in production; the
+44 -49
View File
@@ -100,10 +100,10 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
load_xml(&s, "QuestFrame.xml");
let panels = [
"QuestGreetingPanel",
"QuestDetailPanel",
"QuestProgressPanel",
"QuestRewardPanel",
"QuestFrameGreetingPanel",
"QuestFrameDetailPanel",
"QuestFrameProgressPanel",
"QuestFrameRewardPanel",
];
let is_shown =
|s: &UiScript, name: &str| s.eval::<bool>(&format!("return {name}:IsShown()")).unwrap();
@@ -122,12 +122,12 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
for name in panels {
assert_eq!(
is_shown(&s, name),
name == "QuestGreetingPanel",
name == "QuestFrameGreetingPanel",
"only the greeting panel is shown after QUEST_GREETING"
);
}
assert_eq!(
s.eval::<String>("return QuestNpcNameText:GetText()")
s.eval::<String>("return QuestFrameNpcNameText:GetText()")
.unwrap(),
"Deputy Willem"
);
@@ -148,7 +148,7 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
for name in panels {
assert_eq!(
is_shown(&s, name),
name == "QuestDetailPanel",
name == "QuestFrameDetailPanel",
"only the detail panel is shown after QUEST_DETAIL"
);
}
@@ -158,10 +158,13 @@ fn panel_events_show_exactly_one_child_panel_and_hide_the_others() {
);
}
/// The reward/choice grid, ported from the ref's shared `QuestFrameItems_Update` (the SAME idiom the
/// quest log's own reward rows already prove out) — now on REAL per-panel widgets
/// (`QuestDetailChoice*`/`QuestDetailReward*`), exercised on the Detail panel with the
/// fixture shape the task's capture drives: 2 choices + 1 fixed reward + money.
/// The reward grid, ported from the ref's shared `QuestFrameItems_Update` — on ONE numbered pool
/// per panel (`QuestDetailItem1..10`), the reference's own shape: choices first, then the
/// reward-spell row, then fixed rewards, each positioned at runtime. The fixture is the one the
/// task's capture drives — 2 choices + 1 fixed reward + money — so the pool reads
/// `Item1`/`Item2` = the choices, `Item3` = the fixed reward, `Item4`+ unused. It used to run on
/// two 6-deep pools named `QuestDetailChoice*`/`QuestDetailReward*`, names the reference does not
/// have and a split the ref's own function cannot be written against.
#[test]
fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
let mut s = UiScript::new().unwrap();
@@ -198,17 +201,11 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return QuestDetailChoice1:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return QuestDetailChoice2:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return QuestDetailChoice3:IsShown()")
.unwrap());
assert!(s.eval::<bool>("return QuestDetailItem1:IsShown()").unwrap());
assert!(s.eval::<bool>("return QuestDetailItem2:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestDetailItem4:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return QuestDetailChoice1Name:GetText()")
s.eval::<String>("return QuestDetailItem1Name:GetText()")
.unwrap(),
"Worn Sword"
);
@@ -223,14 +220,10 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
"You will also receive:",
"choices present -> the ref's \"also\" wording (QuestFrame.lua:461-462)"
);
assert!(s
.eval::<bool>("return QuestDetailReward1:IsShown()")
.unwrap());
assert!(!s
.eval::<bool>("return QuestDetailReward2:IsShown()")
.unwrap());
assert!(s.eval::<bool>("return QuestDetailItem3:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestDetailItem4:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return QuestDetailReward1Name:GetText()")
s.eval::<String>("return QuestDetailItem3Name:GetText()")
.unwrap(),
"Militia Hammer"
);
@@ -244,7 +237,7 @@ fn detail_panel_reward_grid_follows_the_refs_two_per_row_layout() {
// select). They DO carry an OnClick now (`QuestItem_OnClick`, the ref's own
// QuestItemTemplate script — the ctrl/shift fork, decisions 1059/1060), but it has no select
// arm at all: an unmodified click must not raise and must not set an itemChoice.
s.run("QuestDetailChoice1:Click()").ok();
s.run("QuestDetailItem1:Click()").ok();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
@@ -289,26 +282,26 @@ fn reward_panel_choice_click_selects_and_completes_with_zero_based_index() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return QuestRewardChoiceHighlight:IsShown()")
!s.eval::<bool>("return QuestRewardItemHighlight:IsShown()")
.unwrap(),
"no row picked yet"
);
// Completing without a choice picked is a no-op (ref: QuestChooseRewardError guard).
s.run("QuestRewardCompleteButton:Click()").unwrap();
s.run("QuestFrameCompleteQuestButton:Click()").unwrap();
assert!(
s.take_quest_actions().is_empty(),
"must pick a choice first"
);
s.run("QuestRewardChoice2:Click()").unwrap();
s.run("QuestRewardItem2:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return QuestRewardChoiceHighlight:IsShown()")
s.eval::<bool>("return QuestRewardItemHighlight:IsShown()")
.unwrap(),
"picking a choice row shows the highlight"
);
s.run("QuestRewardCompleteButton:Click()").unwrap();
s.run("QuestFrameCompleteQuestButton:Click()").unwrap();
assert_eq!(
s.take_quest_actions(),
vec![benilla_ui::script::QuestAction::Reward(1)],
@@ -341,7 +334,7 @@ fn greeting_goodbye_button_closes_the_window() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s.eval::<bool>("return QuestFrame:IsVisible()").unwrap());
s.run("QuestGreetingGoodbyeButton:Click()").unwrap();
s.run("QuestFrameGreetingGoodbyeButton:Click()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
!s.eval::<bool>("return QuestFrame:IsVisible()").unwrap(),
@@ -375,10 +368,10 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return QuestAcceptButton:IsShown()")
.eval::<bool>("return QuestFrameAcceptButton:IsShown()")
.unwrap());
assert!(s
.eval::<bool>("return QuestDeclineButton:IsShown()")
.eval::<bool>("return QuestFrameDeclineButton:IsShown()")
.unwrap());
s.resolve();
@@ -434,7 +427,7 @@ fn detail_panel_action_buttons_resolve_to_real_onscreen_rects() {
"instant text keeps the writing sound: one scratch on the wake tick"
);
assert!(s
.eval::<bool>("return QuestAcceptButton:IsEnabled() ~= 0")
.eval::<bool>("return QuestFrameAcceptButton:IsEnabled() ~= 0")
.unwrap());
assert_eq!(
s.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
@@ -494,7 +487,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
"the write-on scratches the WriteQuest quill each tick"
);
assert!(!s
.eval::<bool>("return QuestAcceptButton:IsEnabled() ~= 0")
.eval::<bool>("return QuestFrameAcceptButton:IsEnabled() ~= 0")
.unwrap());
// Half a second more runs the 13-char gradient off (40 chars/s): Accept wakes and the block
@@ -502,7 +495,7 @@ fn write_on_still_fades_when_instant_text_is_off() {
s.tick(0.5);
assert!(s.errors().is_empty(), "tick errors: {:?}", s.errors());
assert!(s
.eval::<bool>("return QuestAcceptButton:IsEnabled() ~= 0")
.eval::<bool>("return QuestFrameAcceptButton:IsEnabled() ~= 0")
.unwrap());
let alpha = s
.eval::<f32>("return QuestDetailTextAlphaFrame:GetAlpha()")
@@ -546,7 +539,7 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
);
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert_eq!(
s.eval::<String>("return getglobal('QuestNpcNameText'):GetText() or ''")
s.eval::<String>("return getglobal('QuestFrameNpcNameText'):GetText() or ''")
.unwrap(),
"Marshal McBride",
"the panel-open event's arg1 lands in the title bar"
@@ -568,13 +561,14 @@ fn npc_name_reaches_the_title_bar_on_open_and_on_refresh() {
);
// The late-name path: open with an empty name, the refresh brings it.
s.run("getglobal('QuestNpcNameText'):SetText('')").unwrap();
s.run("getglobal('QuestFrameNpcNameText'):SetText('')")
.unwrap();
s.fire_event(
"QUEST_ITEM_UPDATE",
vec![ScriptValue::Str("Marshal McBride".into())],
);
assert_eq!(
s.eval::<String>("return getglobal('QuestNpcNameText'):GetText() or ''")
s.eval::<String>("return getglobal('QuestFrameNpcNameText'):GetText() or ''")
.unwrap(),
"Marshal McBride",
"the QUEST_ITEM_UPDATE refresh also carries the name"
@@ -718,14 +712,14 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// The regression this fork could break: a PLAIN click still picks the choice (the ref's third
// arm). Row 2 is picked here so the modified clicks below — aimed at row 1 — would visibly
// move it if they leaked into the select arm.
s.run("QuestRewardChoice2:Click()").unwrap();
s.run("QuestRewardItem2:Click()").unwrap();
assert_eq!(
s.eval::<i64>("return QuestFrame.itemChoice").unwrap(),
2,
"a plain click still selects the reward choice"
);
assert!(s
.eval::<bool>("return QuestRewardChoiceHighlight:IsShown()")
.eval::<bool>("return QuestRewardItemHighlight:IsShown()")
.unwrap());
assert!(
s.take_dressup_intents().is_empty(),
@@ -735,7 +729,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// SHIFT + chat open → the row's full escaped link, and the selection is untouched.
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("QuestRewardChoice1:Click()").unwrap();
s.run("QuestRewardItem1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -749,10 +743,11 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
"the shift arm returns — it must NOT also select the clicked choice"
);
// A FIXED reward row (plain QuestItemTemplate → QuestItem_OnClick) posts too.
// A FIXED reward row posts too — same pool, same template now, and the select arm is gated
// by `button.kind == "choice"` rather than by which pool the row came from.
s.run("ChatFrameEditBox:SetText(\"\")").unwrap();
s.set_modifiers(true, false, false);
s.run("QuestRewardReward1:Click()").unwrap();
s.run("QuestRewardItem3:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -763,7 +758,7 @@ fn reward_rows_preview_and_post_without_selecting_the_choice() {
// CTRL → the dressing room wearing the clicked reward, and STILL no reselect. The intent pair
// is ordered: Dress (the room was closed, so it re-dresses in the player's own gear) then TryOn.
s.set_modifiers(false, true, false);
s.run("QuestRewardChoice1:Click()").unwrap();
s.run("QuestRewardItem1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
@@ -102,6 +102,12 @@ fn shipped_questlog_frame_loads_clean() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
}
@@ -117,6 +123,12 @@ fn shipped_questlog_frame_drives_end_to_end() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -145,7 +157,8 @@ fn shipped_questlog_frame_drives_end_to_end() {
.unwrap()
.contains("Quest 1"));
assert_eq!(
s.eval::<String>("return QuestLogCount:GetText()").unwrap(),
s.eval::<String>("return QuestLogQuestCount:GetText()")
.unwrap(),
"Quests: |cffffffff8/20|r"
);
assert_eq!(s.eval::<i64>("return GetQuestLogSelection()").unwrap(), 1);
@@ -163,7 +176,7 @@ fn shipped_questlog_frame_drives_end_to_end() {
);
// The reward row (QuestFrame.xml's item-row pattern, reused) + its money.
assert_eq!(
s.eval::<String>("return QuestLogReward1Name:GetText()")
s.eval::<String>("return QuestLogItem1Name:GetText()")
.unwrap(),
"Militia Hammer"
);
@@ -192,7 +205,9 @@ fn shipped_questlog_frame_drives_end_to_end() {
.unwrap(),
""
);
assert!(!s.eval::<bool>("return QuestLogChoice1:IsShown()").unwrap());
// No choices, so the pool's first slot IS the fixed reward and the SECOND is the first
// unused one. (One pool now — the ref's shape; there is no separate "choice row 1".)
assert!(!s.eval::<bool>("return QuestLogItem2:IsShown()").unwrap());
// Wheel DOWN over an actual list ROW (delta -1) advances the faux-scroll offset by one: row 1 now
// shows entry 2's title. Aiming over a row (not the empty margin) is the case that matters — a spin
@@ -292,6 +307,12 @@ fn shift_click_toggles_the_watch_checkbox_and_the_tracker_hud() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -407,6 +428,12 @@ fn watch_guards_no_op_without_erroring() {
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
load_xml(&s, "ErrorsFrame.xml"); // the guards' red-line surface
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -494,6 +521,12 @@ fn progress_auto_watches_for_five_minutes() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -550,6 +583,12 @@ fn the_auto_watch_flag_is_the_references_uvar_and_gates_the_watch() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
assert_eq!(s.eval::<String>("return AUTO_QUEST_WATCH").unwrap(), "1");
@@ -583,6 +622,12 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(QuestLogState::default());
@@ -592,13 +637,13 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
// A bare FontString region has no Show/Hide/IsVisible in this engine (only Frames/Buttons do —
// `region.rs`'s method table) — the empty-state message is a toggled SetText, read back here.
assert_eq!(
s.eval::<String>("return QuestLogEmptyText:GetText()")
s.eval::<String>("return QuestLogNoQuestsText:GetText()")
.unwrap(),
"Your quest log is empty."
);
assert!(!s.eval::<bool>("return QuestLogTitle1:IsVisible()").unwrap());
assert!(!s
.eval::<bool>("return QuestLogAbandonButton:IsEnabled() ~= 0")
.eval::<bool>("return QuestLogFrameAbandonButton:IsEnabled() ~= 0")
.unwrap());
// The Description header is now Lua-managed (BenillaQuestLogDetail_Clear blanks it) rather than
// a static `text=` — it used to float "Description" over the empty-log parchment with no
@@ -609,7 +654,8 @@ fn empty_quest_log_hides_rows_and_disables_abandon() {
""
);
assert_eq!(
s.eval::<String>("return QuestLogCount:GetText()").unwrap(),
s.eval::<String>("return QuestLogQuestCount:GetText()")
.unwrap(),
"Quests: |cffffffff0/20|r"
);
}
@@ -626,6 +672,12 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
let mut state = eight_entries();
@@ -669,16 +721,18 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
// Both choice rows show, the rest of the 6-row cap stays hidden.
assert!(s.eval::<bool>("return QuestLogChoice1:IsShown()").unwrap());
assert!(s.eval::<bool>("return QuestLogChoice2:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestLogChoice3:IsShown()").unwrap());
// 2 choices then 1 fixed reward, in ONE pool: Item1/Item2 are the choices, Item3 the reward,
// Item4 the first unused slot.
assert!(s.eval::<bool>("return QuestLogItem1:IsShown()").unwrap());
assert!(s.eval::<bool>("return QuestLogItem2:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestLogItem4:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return QuestLogChoice1Name:GetText()")
s.eval::<String>("return QuestLogItem1Name:GetText()")
.unwrap(),
"Worn Sword"
);
assert_eq!(
s.eval::<String>("return QuestLogChoice2Name:GetText()")
s.eval::<String>("return QuestLogItem2Name:GetText()")
.unwrap(),
"Worn Mace"
);
@@ -700,10 +754,10 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
);
// One fixed reward shows, chained under the receive text (its own SetPoint target).
assert!(s.eval::<bool>("return QuestLogReward1:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestLogReward2:IsShown()").unwrap());
assert!(s.eval::<bool>("return QuestLogItem3:IsShown()").unwrap());
assert!(!s.eval::<bool>("return QuestLogItem4:IsShown()").unwrap());
assert_eq!(
s.eval::<String>("return QuestLogReward1Name:GetText()")
s.eval::<String>("return QuestLogItem3Name:GetText()")
.unwrap(),
"Militia Hammer"
);
@@ -715,7 +769,7 @@ fn reward_rows_follow_the_refs_two_per_row_layout() {
);
}
/// One entry whose selected detail overflows the 261px-tall `QuestLogDetailScroll`: 10
/// One entry whose selected detail overflows the 261px-tall `QuestLogDetailScrollFrame`: 10
/// objective lines (each a FIXED 12px slot, `QuestLogObjective1`'s own XML comment) plus a
/// long description and a reward row push the child's summed height (`BenillaQuestLogDetail_
/// ResizeChild`) well past the pane — load-bearing for the scroll/clip tests below even
@@ -761,8 +815,8 @@ fn overflowing_entry() -> QuestLogState {
}
/// The ScrollFrame clip (decision 0112 §4): every quad in the moved detail chain's subtree —
/// `QuestLogDetailChild` and its descendants (here, a reward row's icon-slot texture) —
/// carries `QuestLogDetailScroll`'s own resolved rect as its clip, so content that overflows
/// `QuestLogDetailScrollChildFrame` and its descendants (here, a reward row's icon-slot texture) —
/// carries `QuestLogDetailScrollFrame`'s own resolved rect as its clip, so content that overflows
/// the pane never draws past its bottom edge; a sibling entirely outside the scroll child (the
/// window's own book-icon chrome) stays unclipped, mirroring `scrollframe.rs`'s own unit test for
/// the mechanism, just through the shipped window instead of a synthetic fixture.
@@ -774,6 +828,12 @@ fn overflowing_detail_content_clips_to_the_scrollframe_rect() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
@@ -830,6 +890,12 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
@@ -843,14 +909,14 @@ fn wheel_over_the_detail_pane_changes_vertical_scroll() {
let (x, y) = (scroll_rect.left + 10.0, scroll_rect.top - 10.0);
assert_eq!(
s.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap(),
0.0
);
s.mouse_wheel(x, y, -1.0); // WoW convention: -1 = down
assert!(s.errors().is_empty(), "wheel errors: {:?}", s.errors());
let after = s
.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap();
assert!(
after > 0.0,
@@ -870,6 +936,12 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(overflowing_entry());
@@ -877,9 +949,10 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
s.resolve(); // GetVerticalScrollRange (SetVerticalScroll's clamp) reads resolved rects.
s.run("QuestLogDetailScroll:SetVerticalScroll(10)").unwrap();
s.run("QuestLogDetailScrollFrame:SetVerticalScroll(10)")
.unwrap();
assert_eq!(
s.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap(),
10.0
);
@@ -888,7 +961,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
s.fire_event("QUEST_LOG_UPDATE", vec![]);
assert!(s.errors().is_empty(), "refresh errors: {:?}", s.errors());
assert_eq!(
s.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap(),
10.0,
"QUEST_LOG_UPDATE's data-refresh path must not yank the scroll position (pin §5)"
@@ -899,7 +972,7 @@ fn selection_change_resets_detail_scroll_but_a_quest_log_update_refresh_does_not
.unwrap();
assert!(s.errors().is_empty(), "click errors: {:?}", s.errors());
assert_eq!(
s.eval::<f32>("return QuestLogDetailScroll:GetVerticalScroll()")
s.eval::<f32>("return QuestLogDetailScrollFrame:GetVerticalScroll()")
.unwrap(),
0.0,
"a manual reselect snaps the detail pane's scroll back to the top"
@@ -925,6 +998,12 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(eight_entries());
@@ -1053,6 +1132,12 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "DressUpFrame.xml"); // DressUpItemLink lives here
@@ -1062,13 +1147,13 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
s.run("ToggleQuestLog()").unwrap();
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
assert!(
s.eval::<bool>("return QuestLogReward1:IsShown()").unwrap(),
s.eval::<bool>("return QuestLogItem1:IsShown()").unwrap(),
"the fixture's one fixed reward row is laid out"
);
let _ = s.take_dressup_intents();
// A PLAIN click is inert: no room, no chat text, no error (the row has no select arm).
s.run("QuestLogReward1:Click()").unwrap();
s.run("QuestLogItem1:Click()").unwrap();
assert!(
s.take_dressup_intents().is_empty(),
"a plain click never opens the dressing room"
@@ -1084,7 +1169,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
// SHIFT + chat open → the reward's full escaped link (ref l.547-550).
assert!(s.focus_editbox("ChatFrameEditBox"));
s.set_modifiers(true, false, false);
s.run("QuestLogReward1:Click()").unwrap();
s.run("QuestLogItem1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.eval::<String>("return ChatFrameEditBox:GetText()")
@@ -1099,7 +1184,7 @@ fn reward_rows_preview_and_post_and_a_plain_click_stays_inert() {
// CTRL → the room wearing the reward (ref l.543-546): re-dress first, then try it on.
s.set_modifiers(false, true, false);
s.run("QuestLogReward1:Click()").unwrap();
s.run("QuestLogItem1:Click()").unwrap();
s.set_modifiers(false, false, false);
assert_eq!(
s.take_dressup_intents(),
@@ -1126,6 +1211,12 @@ fn shift_click_on_a_title_posts_the_quest_name_with_chat_open_and_watches_with_i
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
load_xml(&s, "UIParent.xml"); // BenillaChatEdit_InsertLink lives here
load_xml(&s, "ChatFrame.xml"); // ChatFrameEditBox lives here
@@ -1210,6 +1301,12 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
// Solo, quest 1 sharable and selected by the auto-selection.
@@ -1226,7 +1323,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
"quest 1 carries the sharable bit"
);
assert!(
!s.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
!s.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap(),
"solo: sharable is not enough"
);
@@ -1235,7 +1332,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
s.set_party(party(2));
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
assert!(
s.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
s.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap(),
"PARTY_MEMBERS_CHANGED alone re-tests the button"
);
@@ -1249,7 +1346,7 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
"quest 2 has no sharable bit"
);
assert!(
!s.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
!s.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap(),
"in a party: an unsharable selection is still dark"
);
@@ -1258,12 +1355,12 @@ fn share_quest_needs_both_a_sharable_selection_and_a_party() {
s.run("SelectQuestLogEntry(1)").unwrap();
s.run("BenillaQuestLogFrame_Update()").unwrap();
assert!(s
.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap());
s.set_party(PartyState::default());
s.fire_event("PARTY_MEMBERS_CHANGED", vec![]);
assert!(!s
.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap());
}
@@ -1278,13 +1375,19 @@ fn share_quest_is_dark_on_an_empty_log_even_in_a_party() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(QuestLogState::default());
s.set_party(party(4));
s.run("ToggleQuestLog()").unwrap();
assert!(!s
.eval::<bool>("return QuestLogPushQuestButton:IsEnabled() ~= 0")
.eval::<bool>("return QuestFramePushQuestButton:IsEnabled() ~= 0")
.unwrap());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1300,6 +1403,12 @@ fn share_quest_click_queues_the_selected_quests_id() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
// an under-loaded list passes load_xml and then loses the wheel, the arrows and the bar
// silently.
load_xml(&s, "ScrollTemplates.xml");
load_xml(&s, "UIPanelTemplates.xml");
load_xml(&s, "QuestLogFrame.xml");
s.set_quest_log(entries_sharable(&[1, 5]));
@@ -1308,7 +1417,7 @@ fn share_quest_click_queues_the_selected_quests_id() {
s.run("SelectQuestLogEntry(5)").unwrap();
s.run("BenillaQuestLogFrame_Update()").unwrap();
s.run("QuestLogPushQuestButton:Click()").unwrap();
s.run("QuestFramePushQuestButton:Click()").unwrap();
assert_eq!(s.take_quest_log_pushes(), vec![5]);
assert!(s.take_quest_log_pushes().is_empty(), "drained");
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
@@ -12,7 +12,8 @@
//! sweep. Each test below fails on exactly one of those.
use benilla_ui::script::{
PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo, UiScript,
PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo, SelectionRequest,
UiScript,
};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error **or unknown-template
@@ -600,7 +601,10 @@ fn left_clicking_a_row_targets_its_raid_token() {
open_raid_tab(&mut s);
push_raid(&mut s, twelve());
s.run("RaidGroupButton7:Click(\"LeftButton\")").unwrap();
assert_eq!(s.take_target_requests(), vec!["raid7".to_string()]);
assert_eq!(
s.take_selection_requests(),
vec![SelectionRequest::Unit("raid7".into())]
);
}
/// Right-clicking a row opens the RAID menu, and each of its four rows appears for exactly the
@@ -64,12 +64,17 @@ fn skill(
fn shown_skills_page() -> UiScript {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
// UIPanelTemplates.xml is NOT optional even though this list ran without it for a year: a
// MISSING template is a loader *warning*, not an error, so an under-loaded list passes the
// assert in load_xml and then fails later on geometry that silently never got built.
// SkillDetailScrollFrame inherits UIPanelScrollFrameTemplate from that file.
for f in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"GameTooltip.xml",
"ScrollTemplates.xml",
"UIPanelTemplates.xml",
"CharacterFrame.xml",
"SkillFrame.xml",
] {
@@ -14,8 +14,8 @@
//! `TargetFrame_OnUpdate`'s one-compare reconcile is for, and it has a test.
use benilla_ui::script::{
AuraState, PartyMemberInfo, PartyState, QuadContent, RaidMemberInfo, ScriptValue, UiScript,
UnitState,
AuraState, PartyMemberInfo, PartyState, QuadContent, RaidMemberInfo, ScriptValue,
SelectionRequest, UiScript, UnitState,
};
/// Load one shipped `assets/ui/<file>`, panicking on any loader error (the unit-frame tests').
@@ -378,11 +378,14 @@ fn the_left_click_targets_the_unit() {
let mut s = load_tot();
switch_on(&mut s);
s.run(r#"TargetofTarget_OnClick("LeftButton")"#).unwrap();
assert_eq!(s.take_target_requests(), vec!["targettarget".to_string()]);
assert_eq!(
s.take_selection_requests(),
vec![SelectionRequest::Unit("targettarget".into())]
);
// A right click is not a menu here: the reference gives this frame none.
s.run(r#"TargetofTarget_OnClick("RightButton")"#).unwrap();
assert!(s.take_target_requests().is_empty());
assert!(s.take_selection_requests().is_empty());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
@@ -1,4 +1,6 @@
use benilla_ui::script::{QuadContent, ScriptValue, SoundRequest, UiScript, UnitState};
use benilla_ui::script::{
QuadContent, ScriptValue, SelectionRequest, SoundRequest, UiScript, UnitState,
};
/// Load one shipped `assets/ui/<file>` (the panel tests' loader, duplicated to stay
/// self-contained), panicking on any loader error.
@@ -383,7 +385,7 @@ fn left_clicking_the_player_frame_targets_self() {
s.fire_event("UNIT_HEALTH", vec![ScriptValue::Str("player".into())]);
s.resolve();
assert!(
s.take_target_requests().is_empty(),
s.take_selection_requests().is_empty(),
"no request before any click"
);
@@ -394,7 +396,7 @@ fn left_clicking_the_player_frame_targets_self() {
s.mouse_button(cx as f32, cy as f32, "RightButton", true);
s.mouse_button(cx as f32, cy as f32, "RightButton", false);
assert!(
s.take_target_requests().is_empty(),
s.take_selection_requests().is_empty(),
"right-click queues no target"
);
// Solo, every SELF row is gated off (only CANCEL survives) — the ref opens no menu. 1.12 has
@@ -408,8 +410,8 @@ fn left_clicking_the_player_frame_targets_self() {
s.mouse_button(cx as f32, cy as f32, "LeftButton", true);
s.mouse_button(cx as f32, cy as f32, "LeftButton", false);
assert_eq!(
s.take_target_requests(),
vec!["player"],
s.take_selection_requests(),
vec![SelectionRequest::Unit("player".into())],
"left-click queues a self-target"
);
+4 -2
View File
@@ -543,8 +543,10 @@ impl UnitTokens<'_, '_> {
self.me.iter().next().map(|(e, g)| (e, g.0))
}
/// The guid → entity map, when the net stack is present.
fn held(&self, guid: u64) -> Option<(Entity, u64)> {
/// The guid → entity map, when the net stack is present — `0x468460`'s lookup. `pub(crate)`
/// for the selection drain's `TargetLastEnemy` arm, which starts from a remembered **guid**
/// rather than a token and must land in the very same index every token arm below does.
pub(crate) fn held(&self, guid: u64) -> Option<(Entity, u64)> {
Some((*self.index.as_ref()?.0.get(&guid)?, guid))
}
+1 -1
View File
@@ -182,7 +182,7 @@ fn resistance_rows_colour_by_which_half_is_bigger() {
// re-point these assertions.
let row_of = |s: &mut UiScript, school: i64| -> usize {
s.eval::<i64>(&format!(
"for i = 1, BENILLA_NUM_RESISTANCE_TYPES do \
"for i = 1, NUM_RESISTANCE_TYPES do \
if BENILLA_MAGICRES_IDS[i] == {school} then return i end \
end return 0"
))
@@ -400,6 +400,17 @@ impl ObjectFields {
pub fn unit_dynamic_flags(&self) -> u32 {
self.get_u32(FIELD_UNIT_DYNAMIC_FLAGS).unwrap_or(0)
}
/// `UNIT_DYNAMIC_FLAGS` bit `0x20` alone — the **dead-looking** flag, without
/// [`Self::unit_reads_dead`]'s two other legs.
///
/// It has its own accessor because one caller wants exactly this bit and not that predicate:
/// `SetSelection`'s last-enemy stamp gates on `HEALTH > 0 || dynflag 0x20`
/// (`0x49372f`-`0x493778`), with no health-zero leg and no stand-state leg, so a feigning unit
/// is remembered and a genuinely dead one is not. Reaching for `unit_reads_dead` there would
/// quietly add a third condition the reference does not have.
pub fn unit_dynflag_dead(&self) -> bool {
self.unit_dynamic_flags() & UNIT_DYNFLAG_DEAD != 0
}
/// `UNIT_DYNAMIC_FLAGS` bit `0x4` — **tapped**: this unit is someone's kill credit.
/// `UnitIsTapped 0x519c90` is exactly `0x519cd0 test BYTE PTR [eax+0x224],0x4` behind an
/// object-presence check, nothing else — no ownership, GUID compare, party/raid or health
+29 -5
View File
@@ -42,10 +42,17 @@
//! - **`hidden="true"` keeps a binding out of the Key Bindings window** while leaving it bindable
//! and dispatchable — 1.12 uses it for the debug toggles and for the three mouselook bindings
//! (`TURNORACTION`, `CAMERAORSELECTORMOVE`, …) that the mouse owns rather than the player.
//! - `debug` and `platform` are **read and not acted on**: both appear only in the client's own
//! file, which we never load (our registry is the Rust `SPECS` table — decision 0997's honest
//! tree), and no addon in the wild uses them. Recording that they exist is the point; inventing
//! a meaning for them here would be a guess.
//! - **`platform="mac"` is the file's own OS gate**, and its five uses are the whole of it: the
//! `ITUNES_REMOTE` block, which remotes the *system* music player and therefore exists only in
//! the Mac build. It is carried on [`AddonBinding::platform`] and acted on at registration
//! ([`crate::script::UiScript::register_addon_bindings`]) — a row for another platform is not
//! registered at all, so it never reaches the Key Bindings window, a save file, or a chord.
//! Registering it instead would list a command whose body calls functions this build does not
//! have, which is a row that can only ever error on its first press.
//! - `debug="true"` is **read and not acted on**: it appears only on the client's own nine
//! hidden dev toggles, always together with `hidden="true"` (which IS acted on and is what
//! keeps them out of the window), and no addon in the wild uses it. Recording that it exists is
//! the point; inventing a second meaning for it would be a guess.
//!
//! ## Why this lives beside `toc` and `framexml` rather than under `script/`
//!
@@ -82,6 +89,9 @@ pub struct AddonBinding {
pub run_on_up: bool,
/// `hidden="true"` — bindable and dispatchable, but not listed in the Key Bindings window.
pub hidden: bool,
/// `platform="mac"` — the row belongs to one build only, lower-cased. `None` on every row
/// that carries no such attribute, which is every row but five.
pub platform: Option<String>,
/// The element's own text: one Lua chunk, verbatim (entities already decoded, so a body that
/// wrote `&lt;` arrives as `<`).
pub body: String,
@@ -137,6 +147,9 @@ pub fn parse(text: &str) -> Result<Vec<AddonBinding>, Error> {
header: attr_ci(node, "header").filter(|h| !h.is_empty()),
run_on_up: attr_bool(node, "runOnUp"),
hidden: attr_bool(node, "hidden"),
platform: attr_ci(node, "platform")
.filter(|p| !p.is_empty())
.map(|p| p.to_ascii_lowercase()),
body: direct_text(node),
});
}
@@ -206,13 +219,16 @@ mod tests {
<Binding name="PROBELOOSE" runOnUp="1">
Probe();
</Binding>
<Binding name="ITUNES_PLAYPAUSE" header="ITUNES_REMOTE" platform="mac">
MusicPlayer_PlayPause();
</Binding>
</Bindings>"#,
)
.expect("well-formed");
assert_eq!(
binds.len(),
5,
6,
"one per <Binding>, comments are not bindings"
);
@@ -224,6 +240,14 @@ mod tests {
"the RAW attribute — BINDING_HEADER_ is registration's prefix to add, not ours"
);
assert!(fwd.run_on_up && !fwd.hidden);
assert_eq!(fwd.platform, None, "no platform= is every row but five");
let itunes = &binds[5];
assert_eq!(itunes.name, "ITUNES_PLAYPAUSE");
assert_eq!(
itunes.platform.as_deref(),
Some("mac"),
"platform= is the file's own OS gate; registration acts on it"
);
assert!(fwd.body.contains("MoveForwardStart();"));
assert!(fwd.body.contains("MoveForwardStop();"));
assert!(
+38 -9
View File
@@ -114,16 +114,45 @@ impl Loader<'_> {
let rname: Option<String> = region
.name()
.map(|raw| framexml::resolve_name(raw, parent_name));
let region_wrapper: Table = match wrapper
.call_method("CreateFontString", (rname, Some("OVERLAY".to_string())))
{
Ok(w) => w,
Err(e) => {
self.report
.errors
.push(format!("{dbg}: CreateFontString (special): {e}"));
continue;
// **An EditBox's direct-child `<FontString>` creates NOTHING.** RF-0028
// (`scratch/rf28-typed-widget-loadxml.md`, "EditBox — LoadXML `0x779fb0`") lists it as
// the *embedded* font string, and its one attribute (`bytes`) writes the box's own
// `maxBytes` at `E+0x33c` — it is the declaration of the object the ctor already built
// at `0x779bee`, exactly as a `<SimpleHTML>`'s `<FontString>` declares that widget's
// element font (the guard at the top of this function). Creating a second region here
// would put an orphan on the frame AND push the authored `<Layers>` regions one place
// down the list `GetRegions` hands Lua.
let existing_text = el
.tag
.eq_ignore_ascii_case("EditBox")
.then(|| crate::script::editbox_text_region_wrapper(self.lua(), wrapper))
.flatten();
let region_wrapper: Table = match existing_text {
Some(w) => {
// The embedded string is nameless in the client's own chain; an authored
// `name=` still publishes both ways, exactly like a setter-created sub-texture
// (`apply_slider`'s thumb).
if let Some(n) = rname {
crate::script::region::publish_region_name(self.lua(), &n, &w);
if let Err(e) = self.lua().globals().set(n.clone(), w.clone()) {
self.report
.warnings
.push(format!("{dbg}: embedded FontString global '{n}': {e}"));
}
}
w
}
None => match wrapper
.call_method("CreateFontString", (rname, Some("OVERLAY".to_string())))
{
Ok(w) => w,
Err(e) => {
self.report
.errors
.push(format!("{dbg}: CreateFontString (special): {e}"));
continue;
}
},
};
self.apply_region_layout(region, &region_wrapper, parent_name, dbg);
self.apply_fontstring_font(region, &region_wrapper, dbg);
+49
View File
@@ -1616,6 +1616,55 @@ mod loader_tests {
assert_eq!(widths, [Some((270.0, 0.0)); 2]);
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A state-texture element with **no `file=`, no `<Color>` and no `name=`** still builds its
/// region: `<NormalTexture/>` is the reference's own form on `SpellBookSkillLineTabTemplate`
/// (ref SpellBookFrame.xml l.36), and the real client creates from the ELEMENT, not from any
/// attribute on it — `Button::LoadXML 0x7788c0` routes all four `<...Texture>` children through
/// the same texture adder the `<Layers>` walker uses (`0x6f26f0`), each followed only by the slot
/// store, which does no geometry (wow-re `system/ui/scratch/region-implicit-anchor.md` §3).
///
/// The report is pfUI's spellbook skin, which takes `SpellBookSkillLineTab<i>:GetNormalTexture()`
/// and immediately `:SetTexCoord()`s it; the corpus carries 38 bare `<DisabledTexture />` besides.
/// Before this, the loader materialized only on `file=`/`<Color>`/`name=`, so the getter answered
/// nil where the real client answers a live blank texture.
#[test]
fn a_bare_state_texture_element_still_builds_its_region() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(800.0, 600.0);
let doc = parse(
r#"<Ui>
<Button name="BareBtn">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<NormalTexture/>
<DisabledTexture />
</Button>
</Ui>"#,
);
let report = load(&s, &doc, &no_files);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
assert!(
s.eval::<bool>("return BareBtn:GetNormalTexture() ~= nil")
.unwrap(),
"a bare <NormalTexture/> builds its region"
);
assert!(
s.eval::<bool>("return BareBtn:GetDisabledTexture() ~= nil")
.unwrap(),
"a bare <DisabledTexture /> builds its region"
);
// Created, not painted: the region is live enough to take SetTexCoord (pfUI's next call) and
// still carries no art of its own.
s.run("BareBtn:GetNormalTexture():SetTexCoord(0.07, 0.93, 0.07, 0.93)")
.unwrap();
assert!(
s.eval::<bool>("return BareBtn:GetNormalTexture():GetTexture() == nil")
.unwrap(),
"blank, not painted"
);
}
}
mod layer_blend_tests {
+22 -7
View File
@@ -95,14 +95,29 @@ impl Loader<'_> {
this.call(wrapper, method, file.to_string(), dbg);
} else if let Some(c) = children_named(t, "Color").next().map(color_of) {
this.call(wrapper, method, (c[0], c[1], c[2], c[3]), dbg);
} else if t.name().is_some() {
// A NAMED state texture with no art of its own — `<NormalTexture
} else {
// A state texture with no art of its own — a NAMED one (`<NormalTexture
// name="$parentIcon">` on the ref's own MacroFrameButtonTemplate, whose art
// arrives later through `SetTexture`. The setter is what MATERIALIZES the
// slot's region, so without this call the getter below finds nothing and the
// name never publishes: `getglobal("MacroButton1Icon")` would be nil and the
// window's whole Update would die on it. `""` is the live API's own blank form
// (`set_slot_texture`'s empty-string arm), so this creates without painting.
// arrives later through `SetTexture`) or a BARE one (`<NormalTexture/>`, the
// ref's own SpellBookSkillLineTabTemplate l.36). **The ELEMENT is what creates
// the region; `file=` is not a gate.** wow-re `system/ui/ui.md` + `scratch/
// region-implicit-anchor.md` §3: `Button::LoadXML 0x7788c0` routes all four
// `<...Texture>` children through the SAME texture adder the `<Layers>` walker
// uses (`0x6f26f0` — `0x778903` <NormalTexture> tag `0x879a30`, `0x778935`
// <PushedTexture>, `0x778967` <DisabledTexture>, `0x778999` <HighlightTexture>),
// "each followed only by the slot store (`0x778fd0` state-texture family /
// `0x779110` highlight install), which does no geometry". The adder constructs;
// nothing in that path reads an attribute first.
//
// This arm used to require `t.name().is_some()`, so a bare element built
// nothing and `GetNormalTexture()` answered nil where the real client answers a
// live blank texture. pfUI's spellbook skin is the report — it takes
// `SpellBookSkillLineTab<i>:GetNormalTexture()` and immediately
// `:SetTexCoord()`s it — and the corpus carries 38 more bare
// `<DisabledTexture />` besides.
//
// `""` is the live API's own blank form (`set_slot_texture`'s empty-string arm,
// which runs `ensure_slot` and then clears), so this creates without painting.
this.call(wrapper, method, String::new(), dbg);
}
// alphaMode / <Size> / <Anchors> / <TexCoords> apply to the region the setter just
+65 -10
View File
@@ -95,6 +95,21 @@ pub(crate) struct StoredActionState {
pub(crate) cooldown: Option<(f64, f64, bool)>,
}
/// One queued `UseAction` press — the action id, and whether it carried the **self-cast
/// modifier**.
///
/// A struct rather than a `(u32, bool)` because a bare bool in a tuple is exactly the argument
/// that gets swapped silently; the neighbouring `take_action_sets` pairs two `u32`s, where the
/// positions are self-describing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ActionUse {
/// The 1-based Lua action id, as `UseAction` was given it.
pub action: u32,
/// `UseAction`'s third argument — 1.12's `SELFACTIONBUTTON1`-`12` (`ALT-1`…`ALT-=`) reach the
/// bar through `ActionButtonUp(id, 1)`, and every other caller passes nothing.
pub on_self: bool,
}
impl super::UiScript {
/// Push (or clear) one action slot, keyed by Lua action id (1..120).
pub fn set_action(&mut self, action: u32, slot: Option<ActionSlot>) {
@@ -140,8 +155,8 @@ impl super::UiScript {
self.model_mut().bonus_bar_offset = offset;
}
/// Drain the action ids queued by `UseAction` since the last call.
pub fn take_action_uses(&mut self) -> Vec<u32> {
/// Drain the presses queued by `UseAction` since the last call.
pub fn take_action_uses(&mut self) -> Vec<ActionUse> {
std::mem::take(&mut self.model_mut().action_uses)
}
@@ -257,8 +272,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// UseAction(action [, checkCursor [, onSelf]]) — `onSelf` is accepted and ignored (the
// self-cast modifier isn't modeled yet). `checkCursor` TRUTHY (numeric nonzero — the
// UseAction(action [, checkCursor [, onSelf]]). **`onSelf` is the self-cast modifier** — the
// third argument `ActionBar.xml`'s `ActionButtonUp(id, onSelf)` has always forwarded and this
// host used to drop on the floor, which is why 1.12's twelve `SELFACTIONBUTTON` bindings
// (`ALT-1`…`ALT-=`) had no home. It rides out on [`ActionUse::on_self`] and the app's cast
// resolver reads it. `checkCursor` TRUTHY (numeric nonzero — the
// reference's own convention, not Lua's: `0` reads falsy here even though Lua truthiness
// would call it true) AND a payload is held routes to [`cursor::place_action`] instead of
// queuing the use (decision 0216 §7's INTERIM: the reference passes 1 from a mouse click and
@@ -270,11 +288,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"UseAction",
lua.create_function(|lua, (action, rest): (u32, MultiValue)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let check_cursor = rest.front().is_some_and(truthy_nonzero);
let mut rest = rest.iter();
let check_cursor = rest.next().is_some_and(truthy_nonzero);
// `onSelf` reads with the SAME truthiness as `checkCursor` — numeric-nonzero, the
// reference's convention rather than Lua's — because it arrives from the same
// callers by the same route. `ActionButtonUp(id, 1)` is the only shipped caller
// that passes it.
let on_self = rest.next().is_some_and(truthy_nonzero);
if check_cursor && model.cursor.is_some() {
super::cursor::place_action(&mut model, action);
} else {
model.action_uses.push(action);
model.action_uses.push(ActionUse { action, on_self });
}
Ok(())
})?,
@@ -410,6 +434,37 @@ mod tests {
use super::ActionSlot;
use crate::script::UiScript;
/// `(action, on_self)` pairs, which is what these assertions are actually about.
fn uses(s: &mut UiScript) -> Vec<(u32, bool)> {
s.take_action_uses()
.into_iter()
.map(|u| (u.action, u.on_self))
.collect()
}
/// **`UseAction`'s third argument is the self-cast modifier** (1745) — 1.12's twelve
/// `SELFACTIONBUTTON` bindings (`ALT-1`…`ALT-=`) reach the bar as `ActionButtonUp(id, 1)`,
/// and this host dropped it on the floor until the day those bindings needed a home.
///
/// It reads with `checkCursor`'s truthiness, not Lua's — **numeric-nonzero** — because it
/// arrives from the same callers by the same route. That is the assertion worth having: Lua
/// would call `0` true, and a bar where `ActionButtonUp(id, 0)` self-cast would be worse than
/// one where nothing did.
#[test]
fn use_actions_third_argument_is_the_self_cast_modifier() {
let mut s = UiScript::new().unwrap();
s.run("UseAction(1)").unwrap();
s.run("UseAction(2, 0)").unwrap();
s.run("UseAction(3, 0, 0)").unwrap();
s.run("UseAction(4, 0, 1)").unwrap();
s.run("UseAction(5, nil, 1)").unwrap();
assert_eq!(
uses(&mut s),
vec![(1, false), (2, false), (3, false), (4, true), (5, true)],
"only a numeric-nonzero third argument is the modifier"
);
}
#[test]
fn action_snapshot_reads_and_use_queues() {
let mut s = UiScript::new().unwrap();
@@ -438,8 +493,8 @@ mod tests {
assert_eq!(s.eval::<i64>("return GetBonusBarOffset()").unwrap(), 1);
s.run("UseAction(73)").unwrap();
s.run("UseAction(74, 0, 1)").unwrap(); // extras accepted, ignored
assert_eq!(s.take_action_uses(), vec![73, 74]);
s.run("UseAction(74, 0, 1)").unwrap(); // the self-cast modifier rides out
assert_eq!(uses(&mut s), vec![(73, false), (74, true)]);
assert!(s.take_action_uses().is_empty());
s.set_action(73, None);
@@ -480,7 +535,7 @@ mod tests {
// checkCursor 0 (a keybind's own convention): queues the use, cursor untouched.
s.run("UseAction(9, 0)").unwrap();
assert_eq!(s.take_action_uses(), vec![9]);
assert_eq!(uses(&mut s), vec![(9, false)]);
assert!(s.cursor_payload().is_some(), "checkCursor 0 never places");
// checkCursor 1 (a mouse click) with a payload held: places instead of queuing.
@@ -491,7 +546,7 @@ mod tests {
// checkCursor 1 with an EMPTY cursor: the ordinary use (nothing to place).
s.run("UseAction(10, 1)").unwrap();
assert_eq!(s.take_action_uses(), vec![10]);
assert_eq!(uses(&mut s), vec![(10, false)]);
}
// ── `GetActionText` (wow-re `bag-language-combat-action-bindings.md` §4) ────────────────────
@@ -0,0 +1,156 @@
//! The **item soulbind confirmations** other than the loot one — the Era API surface behind
//! `EQUIP_BIND` / `AUTOEQUIP_BIND` / `USE_BIND` (decision 1750). Three globals, no snapshot: the
//! question arrives as an event and the answer goes straight back out, exactly like
//! [`super::binder`] and [`super::duel`].
//!
//! ## What the three verbs mean, at the 1.12 bytes
//!
//! `EquipPendingItem(index)` `0x4898f0` and `CancelPendingEquip(index)` `0x489960` both delegate to
//! `0x5e1be0(index, accept)`. **`index` is not a slot** — it is a 0-based index into the client's
//! own growable pending-equip array (capacity `0xc4c290`, count `0xc4c294`, base `0xc4c298`, stride
//! `0x20`), which is also what `EQUIP_BIND_CONFIRM`'s and `AUTOEQUIP_BIND_CONFIRM`'s `arg1` carries.
//! `UIParent.lua:324-339` hangs it on `dialog.data` and the entries hand it straight back, so the
//! number is opaque on both sides — which is why benilla keeps the reference's index space here
//! instead of translating it the way the loot arm's row number is translated (1744).
//!
//! **Accept is a re-issue of the original action with a suppress flag, not a confirm packet** —
//! there is no `CMSG_CONFIRM_*` in 1.12. Cancel sends nothing at all; it only releases the item
//! locks the deferred action took (`UnlockItem 0x495420`).
//!
//! `ConfirmBindOnUse()` `0x48d770` is the use arm's answer and takes **no** argument, because that
//! arm's pending state is not the array: it is two globals the fire site stamps (the item guid at
//! `0xc4c240`, the target guid at `0xc4c1d0`), and the accept is `CGItem::Use(&target, suppress=1)`.
//! `USE_BIND_CONFIRM` carries no arguments either, and the reference ships **no** cancel binding for
//! it — declining simply drops the question.
//!
//! All three decisions are **client-local**: the client reads the cached item template's `bonding`
//! and defers its own send. No packet raises any of them (VERIFIED, wow-re
//! `system/object-layer/scratch/bind-confirm-law.md`).
//!
//! The app owns the pending records, so this module holds only the intents.
use mlua::Lua;
use super::Model;
/// One answer to a pending-equip question: the 0-based array index the event handed out, and
/// whether the player accepted.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct PendingEquipAnswer {
/// The index `EQUIP_BIND_CONFIRM`/`AUTOEQUIP_BIND_CONFIRM` carried out on `arg1`.
pub index: u32,
/// `true` from `EquipPendingItem` (re-issue the action), `false` from `CancelPendingEquip`
/// (release the locks, send nothing).
pub accept: bool,
}
impl super::UiScript {
/// Drain the `EquipPendingItem`/`CancelPendingEquip` answers queued since the last drain, in
/// call order. An index the app is not holding a record for is the app's to ignore — the
/// reference's `0x5e1be0` bounds-checks against the live element count and returns.
pub fn take_pending_equip_answers(&mut self) -> Vec<PendingEquipAnswer> {
std::mem::take(&mut self.model_mut().pending_equip_answers)
}
/// Drain the `ConfirmBindOnUse()` calls queued since the last drain — a count, because the verb
/// has no payload ([`super::UiScript::take_binder_confirms`]'s shape and its reason).
pub fn take_bind_on_use_confirms(&mut self) -> u32 {
std::mem::take(&mut self.model_mut().bind_on_use_confirms)
}
/// The client's item-usable predicate `0x5ea930` for an item known by entry — one of the two
/// non-trivial conjuncts on the equip arms' deferral (an item the player *cannot* equip is
/// never asked about, because the action it would confirm is one the server would refuse).
///
/// Public because the deferral is decided app-side, where the send is: the engine holds the
/// templates and the player's requirement state, so the question has to be asked here.
/// Same conventions as every other caller ([`super::item_stats::item_usable_by_id`]): entry `0`
/// and an unanswered template are both usable.
pub fn item_usable(&self, item_id: u32) -> bool {
super::item_stats::item_usable_by_id(&self.model_ref(), item_id)
}
}
/// Register the three globals (the style [`super::binder`] registers its two).
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// EquipPendingItem(index) — `StaticPopupDialogs["EQUIP_BIND"].OnAccept` and its AUTOEQUIP twin
// (`StaticPopup.lua:612-647`). Re-issues the deferred action; the app owns what that action was.
g.set(
"EquipPendingItem",
lua.create_function(|lua, index: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pending_equip_answers.push(PendingEquipAnswer {
index,
accept: true,
});
Ok(())
})?,
)?;
// CancelPendingEquip(index) — the same two entries' `OnCancel` AND their `OnHide`, which is not
// redundancy: `exclusive = 1` means a second bind question hides the standing dialog, and the
// reference relies on that `OnHide` to cancel the record it supersedes. A superseded pending
// equip is CANCELLED, never silently overwritten — without it the old item's locks leak.
g.set(
"CancelPendingEquip",
lua.create_function(|lua, index: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.pending_equip_answers.push(PendingEquipAnswer {
index,
accept: false,
});
Ok(())
})?,
)?;
// ConfirmBindOnUse() — `StaticPopupDialogs["USE_BIND"].OnAccept` (`StaticPopup.lua:648-658`).
// No argument and no cancel twin: the use arm's pending state is a single client global, and
// declining just drops it.
g.set(
"ConfirmBindOnUse",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.bind_on_use_confirms += 1;
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::script::UiScript;
/// The two equip verbs share one queue and are distinguished only by their answer — the app
/// needs the call ORDER as well as the verdicts, because a supersede is a cancel of one record
/// arriving between two others.
#[test]
fn the_equip_answers_queue_in_call_order_with_their_verdicts() {
let mut s = UiScript::new().unwrap();
s.run("EquipPendingItem(0) CancelPendingEquip(1) EquipPendingItem(2)")
.unwrap();
let answers = s.take_pending_equip_answers();
assert_eq!(
answers
.iter()
.map(|a| (a.index, a.accept))
.collect::<Vec<_>>(),
vec![(0, true), (1, false), (2, true)]
);
assert!(s.take_pending_equip_answers().is_empty(), "drained");
}
/// `ConfirmBindOnUse` is a count, like `ConfirmBinder`: the verb carries no payload because the
/// pending item is the app's.
#[test]
fn confirm_bind_on_use_counts() {
let mut s = UiScript::new().unwrap();
assert_eq!(s.take_bind_on_use_confirms(), 0);
s.run("ConfirmBindOnUse() ConfirmBindOnUse()").unwrap();
assert_eq!(s.take_bind_on_use_confirms(), 2);
assert_eq!(s.take_bind_on_use_confirms(), 0, "drained");
}
}
+193
View File
@@ -0,0 +1,193 @@
//! The **five camera views** — `SetView` / `SaveView` / `ResetView` / `NextView` / `PrevView`,
//! plus `FlipCameraYaw`: the outbound half of the reference's `UIUtil\Camera.cpp` Lua surface.
//!
//! Six globals, no state — [`super::follow`]'s shape exactly. Nothing here reads the camera: every
//! one of these is an **engine action** over state this VM does not hold (the orbit arm, the pitch,
//! the character's facing, the zoom ceiling), so each call queues a [`CameraViewRequest`] the app
//! drains ([`super::UiScript::take_camera_view_requests`]) and applies to the rig
//! (`benilla_app::player::camera_view`). The alternative — reaching into the ECS from inside the
//! VM — is the thing decision 0068 §3 exists to forbid.
//!
//! **Where they come from.** The reference registers them in one table, `0x84f7a0` (`{const char*
//! name; handler}` pairs, dumped from `WoW.exe` 5875): rows 15…20 are `SetView 0x50b5b0`,
//! `SaveView 0x50b600`, `ResetView 0x50b640`, `NextView 0x50b680`, `PrevView 0x50b690`,
//! `FlipCameraYaw 0x50b6a0`. Sixteen 1.12 binding commands have one of these as their whole body
//! (`SETVIEW1`…`5`, `SAVEVIEW2`…`5`, `RESETVIEW2`…`5`, `NEXTVIEW`, `PREVVIEW`, `FLIPCAMERAYAW`).
//!
//! ## The argument ABI is a third shape, and it is neither of [`super::binding_abi`]'s two
//!
//! The four indexed entry points share one prologue, byte-identical across `0x50b5b0` /
//! `0x50b600` / `0x50b640`:
//!
//! ```text
//! 50b5b8 call 0x6f34d0 ; is-number(L, 1) — a number, or a coercible string
//! 50b5bf je <exit> ; NOT a number -> xor eax,eax ; ret (silent, zero Lua values)
//! 50b5c8 call 0x6f3620 ; tonumber(L, 1)
//! 50b5cd call 0x40a2b0 ; double -> int32, truncating toward zero
//! 50b5d4 jle <exit> ; n <= 0 -> silent
//! 50b5d9 jg <exit> ; n > 5 -> silent
//! 50b5e5 dec eax ; Lua 1..5 -> internal view 0..4
//! ```
//!
//! So the failure edge **returns without raising** — it never reaches `luaL_error 0x6f4940`, which
//! is the discriminator [`super::binding_abi`]'s header sets out. A macro doing `SetView(0)`,
//! `SetView(9)`, `SetView("left")` or `SetView()` is a silent no-op in the real client, and is one
//! here. That rules out `binding_abi::number_arg` (shape A — raises) and makes
//! `binding_abi::coerced_number` (shape C — no guard, defaults to `0.0`) merely *accidentally*
//! right, so the guard is written out rather than borrowed.
//!
//! `FlipCameraYaw` takes the same is-number guard and then the raw float — `0x6f3620` straight to
//! `fstp [ebp-4]`, with no `0x40a2b0` — so its argument is **not** truncated to an integer.
//!
//! ## Two verified facts that a reader will expect to be otherwise
//!
//! - **The range is 1…5 for all three of Set/Save/Reset.** `SaveView(1)` and `ResetView(1)` are
//! accepted by the engine and act on `FIRST_PERSON`; `0x50fa30` (SaveView's body) has no view-0
//! gate at all. 1.12 ships no `SAVEVIEW1`/`RESETVIEW1` *binding*, which is a `Bindings.xml`
//! decision, not an engine one — a macro reaches what the keybinding cannot.
//! - **`NextView`/`PrevView` do not wrap.** `0x50faa0` is `eax = view + 1; cmp eax,5; jge <ret>`
//! and `0x50fac0` is `test eax,eax; jle <ret>; dec eax` — each is a hard stop at its end of the
//! range, so holding `END` walks 1→2→3→4→5 and stays there. The app enforces it (this side only
//! queues the verb).
use mlua::{Lua, Value};
use super::Model;
/// The number of camera views — the reference's default-string table `0x84f488` is five rows, and
/// every entry point above is bounded by it.
pub const CAMERA_VIEW_COUNT: u8 = 5;
/// One camera-view intent queued by the Lua surface, drained by the app
/// ([`super::UiScript::take_camera_view_requests`]). Plain data — [`super::follow::FollowRequest`]'s
/// twin.
///
/// The indices are the **internal** ones, `0..CAMERA_VIEW_COUNT`, already range-checked here
/// exactly where the reference range-checks them (its Lua handler, `dec eax`). The app applies
/// them; it never re-validates a number that cannot be out of range.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum CameraViewRequest {
/// `SetView(n)` — make view `n-1` the live one (distance, pitch and the saved yaw offset).
Set(u8),
/// `SaveView(n)` — store the live pose into view `n-1`.
Save(u8),
/// `ResetView(n)` — put view `n-1` back to its shipped default.
Reset(u8),
/// `NextView()` — the view above the current one, if there is one (no wrap).
Next,
/// `PrevView()` — the view below the current one, if there is one (no wrap).
Prev,
/// `FlipCameraYaw(degrees)` — add this many **degrees** to the camera's yaw. The binding body
/// 1.12 ships is `FlipCameraYaw(180)`.
FlipYaw(f32),
}
impl super::UiScript {
/// Drain the camera-view intents queued since the last call — the app applies each to the
/// camera rig.
pub fn take_camera_view_requests(&mut self) -> Vec<CameraViewRequest> {
std::mem::take(&mut self.model_mut().camera_view_requests)
}
}
/// The shared prologue of `SetView`/`SaveView`/`ResetView`: `is-number` → `tonumber` → truncate to
/// `i32` → the `1 ..= 5` window → the internal index. `None` is every silent-return edge in one.
fn view_arg(lua: &Lua, v: Value) -> Option<u8> {
// `0x6f34d0` is-number, then `0x6f3620` tonumber, then `0x40a2b0`'s chop-toward-zero cast —
// `lua.coerce_number` is the first two (Lua coerces a numeric string), `as i64 as i32` the
// third (see `binding_abi::number_arg`, whose only difference from this is that it raises).
let n = lua.coerce_number(v).ok().flatten()? as i64 as i32;
// `test eax,eax; jle` then `cmp eax,5; jg` — both silent.
(1..=i32::from(CAMERA_VIEW_COUNT))
.contains(&n)
.then(|| (n - 1) as u8)
}
/// Register the six camera-view globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// SetView(n) — `0x50b5b0`. Load view n (1..5). The reference then calls `0x512e90(view,
// cameraViewBlendStyle)`, which also writes the `cameraView` CVar so the active view survives
// a restart; the app does both halves.
g.set(
"SetView",
lua.create_function(|lua, n: Value| {
if let Some(view) = view_arg(lua, n) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.camera_view_requests
.push(CameraViewRequest::Set(view));
}
Ok(())
})?,
)?;
// SaveView(n) — `0x50b600` → `0x50fa30`, which reads the camera's three TARGET fields
// (`+0x198` distance, `+0x1e0` pitch, `+0x210` yaw) into the slot array and writes each to its
// archived CVar. Accepts n = 1 (see the module header).
g.set(
"SaveView",
lua.create_function(|lua, n: Value| {
if let Some(view) = view_arg(lua, n) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.camera_view_requests
.push(CameraViewRequest::Save(view));
}
Ok(())
})?,
)?;
// ResetView(n) — `0x50b640` → `0x50fae0`, which re-parses the shipped default strings at
// `0x84f488 + 12·view` into the slot and re-applies the view if it is the live one.
g.set(
"ResetView",
lua.create_function(|lua, n: Value| {
if let Some(view) = view_arg(lua, n) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.camera_view_requests
.push(CameraViewRequest::Reset(view));
}
Ok(())
})?,
)?;
// NextView() / PrevView() — `0x50b680` → `0x50faa0` and `0x50b690` → `0x50fac0`. No arguments
// at all: neither handler touches the Lua stack. Neither wraps.
g.set(
"NextView",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.camera_view_requests.push(CameraViewRequest::Next);
Ok(())
})?,
)?;
g.set(
"PrevView",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.camera_view_requests.push(CameraViewRequest::Prev);
Ok(())
})?,
)?;
// FlipCameraYaw(degrees) — `0x50b6a0`, the whole body being
// `cam[+0x100] += arg × 0.01745329238474369`. Its argument keeps its fraction (no `0x40a2b0`),
// and the is-number guard's failure edge is the same silent return the four above take.
g.set(
"FlipCameraYaw",
lua.create_function(|lua, degrees: Value| {
if let Some(d) = lua.coerce_number(degrees).ok().flatten() {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.camera_view_requests
.push(CameraViewRequest::FlipYaw(d as f32));
}
Ok(())
})?,
)?;
Ok(())
}
+39 -13
View File
@@ -472,6 +472,24 @@ fn write_inset_anchors(lua: &Lua, h: FrameHandle, rh: RegionHandle, [l, r, t, b]
}
}
/// The wrapper for an EditBox's **embedded** text FontString — the region its ctor built at
/// `0x779bee` (`Arena::build_editbox_engine_regions`). `None` when `frame` is not a live EditBox.
///
/// The loader's special-`<FontString>` pass uses this instead of `CreateFontString`: RF-0028 lists
/// `<FontString>` as the EditBox's *embedded* font string (its `bytes` attr writes the box's own
/// `maxBytes`), so the element DECLARES the ctor's object rather than adding a region. Creating a
/// second one would leave an orphan on the frame and push the authored `<Layers>` regions one place
/// down the creation-ordered list `GetRegions` walks.
pub(crate) fn editbox_text_region_wrapper(lua: &Lua, frame: &Table) -> Option<Table> {
let h = frame_handle_of(lua, frame).ok()?;
let rh = ensure_text_region(lua, h)?;
let id = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.region_id(rh)
};
super::region::region_wrapper(lua, id).ok()
}
/// The loader's special-`<FontString>` slot assignment: adopt `region` as `frame`'s text region,
/// anchored by the current insets. The engine ASSIGNS an EditBox's font string at LoadXML — it
/// never searches the region list (a find-first here once grabbed a `<Layers>` FontString, the
@@ -504,20 +522,28 @@ pub(super) fn ensure_text_region(lua: &Lua, h: FrameHandle) -> Option<RegionHand
Some(KindState::EditBox(eb)) => (eb.text_region, eb.multi_line),
_ => return None,
};
if existing.is_some() {
return existing;
}
let rh = model
.arena
.create_region(h, RegionKind::FontString, DrawLayer::Overlay, 0)?;
let mut data = RegionData {
// Some("") from birth — the empty box still emits its Text quad for the host caret.
text: Some(String::new()),
..RegionData::default()
// The ctor already built it (`Arena::build_editbox_engine_regions` — the client's own
// `0x779bee`, the first of the five regions a CSimpleEditBox is born with). The REGION exists
// from birth; its `RegionData` does not, because the arena has no access to it — so seed that
// here, once, on whichever call arrives first.
let rh = match existing {
Some(rh) => rh,
// A box whose ctor pass could not run (a dead handle, or a non-EditBox that slipped the
// guard above) still gets a region rather than silently rendering nothing.
None => model
.arena
.create_region(h, RegionKind::FontString, DrawLayer::Overlay, 0)?,
};
apply_text_region_justify(&mut data, multi_line);
model.region_data.insert(rh, data);
model.touch_layout(); // a region entered the layout gate's read set (decision 0740)
if let std::collections::hash_map::Entry::Vacant(slot) = model.region_data.entry(rh) {
let mut data = RegionData {
// Some("") from birth — the empty box still emits its Text quad for the host caret.
text: Some(String::new()),
..RegionData::default()
};
apply_text_region_justify(&mut data, multi_line);
slot.insert(data);
model.touch_layout(); // a region entered the layout gate's read set (decision 0740)
}
if let Some(KindState::EditBox(eb)) = model.arena.frame_mut(h).map(|f| &mut f.kind_state) {
eb.text_region = Some(rh);
}
@@ -1369,3 +1369,84 @@ fn set_max_letters_gates_the_argument_count_and_coerces_the_value() {
"too many raises too"
);
}
/// **A `CSimpleEditBox` is born with FIVE regions, and `GetRegions` hands them to Lua before any
/// authored one.** wow-re `scratch/rf85-editbox-caret.md` §1: the ctor builds the text FontString
/// (`E+0x328`, `0x779bee`), three selection-highlight `CSimpleTexture`s (`E+0x350/0x354/0x358`,
/// loop `0x779c41`–`0x779c72`) and the caret (`E+0x368`, `0x779c86`) — in that order. `GetRegions
/// 0x773f60` walks `[frame+0x1b8]`, one flat creation-ordered list, oldest first, no filter
/// (`scratch/widget-list-bindings.md`), and insertion is at the TAIL. So the authored `<Layers>`
/// regions start at index 6.
///
/// The report is pfUI's `skins/blizzard/friends.lua` l.379 —
/// `local _,_,_,_,_,left,right = GuildControlPopupFrameEditBox:GetRegions()` — which skips exactly
/// those five and takes the box's two border textures. It died on a nil `left` for as long as
/// benilla returned only the authored regions.
#[test]
fn an_editbox_is_born_with_the_ctors_five_regions_ahead_of_its_authored_ones() {
let s = script();
let doc = crate::framexml::parse(
r#"<Ui>
<EditBox name="Box">
<Size><AbsDimension x="120" y="20"/></Size>
<Anchors><Anchor point="CENTER"/></Anchors>
<Layers>
<Layer level="BACKGROUND">
<Texture name="BoxLeft" file="Interface\Left"/>
<Texture name="BoxRight" file="Interface\Right"/>
</Layer>
</Layers>
<FontString inherits="ChatFontNormal"/>
</EditBox>
</Ui>"#,
)
.unwrap();
let report = crate::loader::load(&s, &doc, &|_| None);
assert!(report.errors.is_empty(), "errors: {:?}", report.errors);
// Five engine regions, then the two authored textures — and NOT a sixth from the embedded
// `<FontString>`, which declares the ctor's object rather than adding one (RF-0028).
assert_eq!(
s.eval::<i64>("return Box:GetNumRegions()").unwrap(),
7,
"5 ctor regions + 2 authored textures, and the <FontString> adds none"
);
assert_eq!(
s.eval::<i64>("return select('#', Box:GetRegions())")
.unwrap(),
7,
"GetNumRegions is exactly the length GetRegions enumerates"
);
// pfUI's own read, verbatim in shape: skip five, take the authored pair.
let (left, right) = s
.eval::<(String, String)>(
"local _,_,_,_,_,l,r = Box:GetRegions() return l:GetTexture(), r:GetTexture()",
)
.unwrap();
assert_eq!(
left, r"Interface\Left",
"region 6 is the first authored one"
);
assert_eq!(right, r"Interface\Right", "region 7 is the second");
// The first is the text FontString the box actually types into.
s.run(r#"Box:SetText("typed")"#).unwrap();
assert_eq!(
s.eval::<String>("local t = Box:GetRegions() return t:GetText()")
.unwrap(),
"typed",
"region 1 is the ctor's embedded text FontString"
);
// 2..5 are the three selection quads and the caret: real regions, carrying no art of their own
// (benilla paints both host-side — the gap named on EditBoxState).
assert!(
s.eval::<bool>(
"local a,b,c,d,e = Box:GetRegions() \
return b:GetTexture() == nil and c:GetTexture() == nil \
and d:GetTexture() == nil and e:GetTexture() == nil"
)
.unwrap(),
"the selection trio and the caret are blank quads"
);
}
+59 -2
View File
@@ -373,8 +373,19 @@ impl KeybindState {
fn register_addon(&mut self, addon: &str, bindings: &[AddonBinding]) {
let mut section = addon.to_string();
for b in bindings {
// Before the skip: a duplicate name still *closes* a section the same way, because the
// header is a property of the file's order, not of the row that survived it.
// **`platform` skips the whole NODE, header and all** — `0x4b70c3`-`0x4b70e5` bails
// out of the element before anything on it is read. It matters here and nowhere else:
// `header="ITUNES_REMOTE"` rides on `ITUNES_PLAYPAUSE`, the first of the five
// `platform="mac"` rows, so on a build that is not the Mac one that category does not
// merely come up empty — it never exists. Keeping the header of a skipped row would
// leave its section open over whatever followed.
if b.platform.as_deref().is_some_and(|p| p != THIS_PLATFORM) {
continue;
}
// Before the duplicate skip below: a duplicate name still *closes* a section the same
// way, because the header is a property of the file's order, not of the row that
// survived it. (Not the platform case above — a duplicate is a node the loader read
// and rejected; a foreign-platform node it never read at all.)
if let Some(h) = &b.header {
section = format!("BINDING_HEADER_{h}");
}
@@ -411,6 +422,17 @@ impl KeybindState {
}
}
/// What `platform="…"` has to equal for a binding to register here — the reference's own two
/// spellings, read out of both binaries: the Windows build compares against `"windows"`
/// (`0x846f98`, tested at `0x4b70c3`-`0x4b70e5`) and the Mac slice against `"mac"` (`0x4e28f8`).
/// Every other OS benilla runs on takes the `"windows"` name rather than inventing a third — the
/// only thing this attribute has ever distinguished is a 2006 Carbon app from everything else.
const THIS_PLATFORM: &str = if cfg!(target_os = "macos") {
"mac"
} else {
"windows"
};
impl super::UiScript {
/// Register the host-implemented commands (boot-time, order = the 1.12 `Bindings.xml`
/// order). Idempotent per name; live keys seed from the defaults.
@@ -984,6 +1006,40 @@ mod tests {
assert!(bodies[0].body.contains(r#"keystate == "down""#));
}
/// **The file's own OS gate.** `platform="mac"` is 1.12's one OS-specific device in
/// `Bindings.xml` — five `ITUNES_REMOTE` rows in the Mac build and nowhere else — and a row
/// for the other build is not registered at all. Not hidden, not registered-and-inert:
/// registering it would list a command in the Key Bindings window whose body calls functions
/// this build does not have, and let the player spend a key on it.
#[test]
fn a_binding_for_another_platform_does_not_register() {
let mut s = script();
let parsed = crate::bindings_xml::parse(
r#"<Bindings>
<Binding name="PROBEHERE" platform="THIS">Here();</Binding>
<Binding name="PROBEELSEWHERE" platform="elsewhere">Elsewhere();</Binding>
<Binding name="PROBEANYWHERE">Anywhere();</Binding>
</Bindings>"#
.replace("THIS", super::THIS_PLATFORM)
.as_str(),
)
.unwrap();
s.register_addon_bindings("Probe", &parsed);
let names: Vec<String> = s.keybind_snapshot().into_iter().map(|(n, _)| n).collect();
assert!(names.iter().any(|n| n == "PROBEHERE"), "{names:?}");
assert!(names.iter().any(|n| n == "PROBEANYWHERE"), "{names:?}");
assert!(
!names.iter().any(|n| n == "PROBEELSEWHERE"),
"a foreign-platform row registered: {names:?}"
);
// …and it is not merely hidden from the window either: nothing can bind it.
assert!(!s
.addon_binding_bodies()
.iter()
.any(|b| b.name == "PROBEELSEWHERE"));
}
#[test]
fn host_seeding_feeds_load_and_the_capture_arm_reads_back() {
let mut s = script();
@@ -1022,6 +1078,7 @@ mod addon_persistence_tests {
header: None,
run_on_up: false,
hidden: false,
platform: None,
body: "AddonBindingRan = true".into(),
}
}
+12 -3
View File
@@ -43,9 +43,12 @@ mod auction;
mod aura;
mod backdrop;
mod bank;
mod bind_confirm;
mod binder;
mod binding_abi;
// The five camera views + FlipCameraYaw — the reference's `UIUtil\Camera.cpp` Lua surface.
mod button;
mod camera_view;
mod channel;
mod char_stats;
mod chat_send;
@@ -59,7 +62,7 @@ mod cursor;
mod death;
pub mod diagnostics;
mod editbox;
pub(crate) use editbox::adopt_text_region;
pub(crate) use editbox::{adopt_text_region, editbox_text_region_wrapper};
pub(crate) mod addon;
pub mod addon_gate;
mod client;
@@ -143,7 +146,7 @@ mod worldmap;
mod worldstate;
mod worn_display;
pub use action::{ActionSlot, ActionState};
pub use action::{ActionSlot, ActionState, ActionUse};
pub use addon::AddOnInfo;
pub use addon_message::{AddonDistribution, AddonSend};
pub use auction::{
@@ -153,6 +156,8 @@ pub use auction::{
pub use aura::{AuraState, TrackingState};
pub use backdrop::{inset_atlas_bleed, pieces, Backdrop, BackdropPiece, Insets};
pub use bank::BankState;
pub use bind_confirm::PendingEquipAnswer;
pub use camera_view::{CameraViewRequest, CAMERA_VIEW_COUNT};
pub use char_stats::{
weapon_subclass_skill, InvSlotView, InventorySlots, UnitCombatStats, INVENTORY_SLOT_COUNT,
SKILL_DEFENSE, SKILL_UNARMED,
@@ -233,7 +238,9 @@ pub use types::{
ScriptValue, TexCoords,
};
pub(crate) use types::{FontExplicit, MeasuredText, RegionData};
pub use unit::{grey_band, level_reads_unknown, power_token, unit_is_grey, UnitState};
pub use unit::{
grey_band, level_reads_unknown, power_token, unit_is_grey, SelectionRequest, UnitState,
};
pub use weapon_enchant::WeaponEnchant;
pub use worldmap::{
WorldMapContinentView, WorldMapLandmarkView, WorldMapOverlayView, WorldMapState,
@@ -561,9 +568,11 @@ impl UiScript {
petition::install(&lua)?;
binder::install(&lua)?;
summon::install(&lua)?;
bind_confirm::install(&lua)?;
gm_ticket::install(&lua)?;
duel::install(&lua)?;
follow::install(&lua)?;
camera_view::install(&lua)?;
session::install(&lua)?;
pvp::install(&lua)?;
worn_display::install(&lua)?;
+36 -13
View File
@@ -4,12 +4,12 @@ use crate::layout::{LayoutInput, LayoutSolver, Rect};
use crate::widget::{FrameHandle, RegionHandle, WidgetArena};
use super::{
auction, backdrop, bank, char_stats, chat_window, colorselect, container, craft, cursor, death,
duel, follow, gossip, guild, inspect, item_text, loot, loot_roll, macros, mail, merchant,
party, petition, pvp, quest, quest_log, reputation, session, simplehtml, skills, slider,
social, spellbook, stable, taxi, trade, tradeskill, trainer, weapon_enchant, ActionSlot,
AuraState, FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest,
UnitState,
auction, backdrop, bank, bind_confirm, camera_view, char_stats, chat_window, colorselect,
container, craft, cursor, death, duel, follow, gossip, guild, inspect, item_text, loot,
loot_roll, macros, mail, merchant, party, petition, pvp, quest, quest_log, reputation, session,
simplehtml, skills, slider, social, spellbook, stable, taxi, trade, tradeskill, trainer,
weapon_enchant, ActionSlot, AuraState, FontObject, ItemTemplateView, PlayerReqState,
RegionData, ScriptValue, SoundRequest, UnitState,
};
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -402,11 +402,17 @@ pub(crate) struct Model {
/// [`super::aura::TrackingState`]), pushed by the app's aura feed beside `auras`. `None` =
/// no tracking active.
pub(crate) tracking: Option<super::aura::TrackingState>,
/// Unit tokens `TargetUnit` queued since the app's last [`UiScript::take_target_requests`] drain
/// — the outbound twin of `units` above (the reference's `TargetUnit` Lua shim → SetSelection).
/// The app resolves each token to a streamed entity and commits the selection; a token it can't
/// resolve is a no-op, as the real client no-ops `TargetUnit` on a nonexistent unit ([`unit`]).
pub(crate) target_requests: Vec<String>,
/// Selection asks queued since the app's last [`UiScript::take_selection_requests`] drain — the
/// outbound twin of `units` above, carrying `TargetUnit` / `AssistUnit` / `TargetLastEnemy` in
/// call order because the reference reaches selection through one helper for all three
/// ([`super::SelectionRequest`]). The app resolves each to a streamed entity and commits the
/// selection; one it can't resolve is a no-op, as the real client no-ops `TargetUnit` on a
/// nonexistent unit ([`unit`]).
pub(crate) selection_requests: Vec<super::SelectionRequest>,
/// `TargetNearestFriend([reverse])` presses queued since the app's last
/// [`UiScript::take_target_nearest_friend_requests`] drain — one entry per call, `true` for the
/// reverse (backward) cycle. The app runs the friendly half of the TAB scan ([`unit`]).
pub(crate) target_nearest_friend_requests: Vec<bool>,
/// `(name, exactMatch)` pairs `TargetByName` queued since the app's last
/// [`UiScript::take_target_by_name_requests`] drain — the by-NAME twin of
/// [`Self::target_requests`], which takes unit tokens. The app runs the shared by-name
@@ -509,6 +515,13 @@ pub(crate) struct Model {
/// queue's twin, snapshot-less for the same reason: the only thing the UI reads back is the
/// `AUTOFOLLOW_BEGIN`/`AUTOFOLLOW_END` pair the app fires, which carries its own argument.
pub(crate) follow_requests: Vec<follow::FollowRequest>,
/// Camera-view intents (`SetView`/`SaveView`/`ResetView`/`NextView`/`PrevView`/
/// `FlipCameraYaw`) queued since the app's last
/// [`super::UiScript::take_camera_view_requests`] drain — the outbound seam
/// ([`camera_view`]). Snapshot-less like the follow queue: the five views live on the app's
/// camera rig, and they reach Lua the way the reference's do — as the
/// `cameraView` / `camera{Distance,Pitch,Yaw}{,A..D}` CVars — not as a second copy here.
pub(crate) camera_view_requests: Vec<camera_view::CameraViewRequest>,
/// Session-exit intents (`Logout`/`Quit`/`CancelLogout`/`ForceQuit`) queued since the app's
/// last [`super::UiScript::take_session_requests`] drain — the outbound seam ([`session`]).
/// Snapshot-less like the duel queue above: what the UI reads back (the camp/quit countdown)
@@ -616,7 +629,7 @@ pub(crate) struct Model {
/// reads it ([`action`]). Keyed like `actions`; an absent action reads cold/nil.
pub(crate) action_states: HashMap<u32, super::action::StoredActionState>,
pub(crate) bonus_bar_offset: u8,
pub(crate) action_uses: Vec<u32>,
pub(crate) action_uses: Vec<super::action::ActionUse>,
/// `(lua action id, packed)` pairs queued by `PickupAction`/`PlaceAction` (decision 0216 §7,
/// slice 4) — one entry per local slot mutation, `packed == 0` clearing the slot. Drained by
/// the app into `CMSG_SET_ACTION_BUTTON`, one send per entry (client-authoritative, per-change
@@ -981,6 +994,12 @@ pub(crate) struct Model {
/// drain.
pub(crate) craft_close: bool,
/// The `EquipPendingItem`/`CancelPendingEquip` answers the app drains, in call order, and the
/// `ConfirmBindOnUse()` count — the non-loot soulbind confirmations' whole Lua side
/// ([`bind_confirm`], decision 1750). The pending records themselves are the app's.
pub(crate) pending_equip_answers: Vec<bind_confirm::PendingEquipAnswer>,
pub(crate) bind_on_use_confirms: u32,
/// The open loot's row snapshot the app pushes (`None` = no loot open), the row-pick intents it
/// drains, and whether `CloseLoot` was called — the loot seam ([`loot`]).
///
@@ -1517,7 +1536,8 @@ impl Model {
auras: HashMap::new(),
cancel_aura_requests: Vec::new(),
tracking: None,
target_requests: Vec::new(),
selection_requests: Vec::new(),
target_nearest_friend_requests: Vec::new(),
target_by_name_requests: Vec::new(),
drop_item_on_unit: Vec::new(),
target_clear: false,
@@ -1543,6 +1563,7 @@ impl Model {
default_language: None,
duel_requests: Vec::new(),
follow_requests: Vec::new(),
camera_view_requests: Vec::new(),
session_requests: Vec::new(),
pvp_toggles: 0,
honor: None,
@@ -1658,6 +1679,8 @@ impl Model {
craft_selection: 0,
craft_close: false,
loot: None,
pending_equip_answers: Vec::new(),
bind_on_use_confirms: 0,
loot_picks: Vec::new(),
loot_confirms: Vec::new(),
loot_close: false,
+22
View File
@@ -8,6 +8,28 @@ use crate::widget::FrameHandle;
use super::{editbox, event, tooltip, ScriptValue, UiScript};
impl UiScript {
/// Queue an event to fire at the **start of the next tick**, into the same
/// [`super::Model::pending_events`] list the engine's own bindings use — so it lands *after*
/// everything queued earlier in the frame and before that tick's `OnUpdate` pass.
///
/// **Ordering, not laziness, is the whole reason this is public** (decision 1750). A benilla
/// drain runs a step behind the input pass that fed it, so an event it fires *immediately*
/// arrives BEFORE events the same input already queued — the reverse of the reference, where
/// the deferral happens inside the very call that queued them. That inversion is not
/// hypothetical: the soulbind confirm raised by placing an item into a worn slot was cancelled
/// one tick later by that same place's own `CURSOR_UPDATE`, whose `StaticPopup_Hide` is
/// `UIParent.lua:356-360` transcribed correctly. Queueing restores the reference's relative
/// order — cursor-change first, question second — while a *later* cursor change still retires
/// the question, which is exactly what that arm is for.
///
/// Use [`Self::fire_event`] for anything whose ordering against in-flight queued events does
/// not matter, which is nearly everything.
pub fn queue_event(&mut self, event: &str, args: Vec<ScriptValue>) {
self.model_mut()
.pending_events
.push((event.to_string(), args));
}
/// Fire an event to every frame registered for it, invoking their `OnEvent` handlers (RF-0025):
/// both the legacy `this`/`event`/`arg1..argN` globals *and* the modern `(self, event, ...)`
/// arguments. Events reach registered frames regardless of visibility, **in registration
+84 -2
View File
@@ -7,7 +7,7 @@ use mlua::{Lua, Value};
use super::super::Model;
use super::{
check_unit_token, classification_word, grey_band, level_reads_unknown, pick_unit_token,
power_token, with_unit,
power_token, with_unit, SelectionRequest,
};
/// The two class ids `GetComboPoints 0x51a190` accepts — the literals `4` and `0xb` it compares
@@ -889,12 +889,94 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
lua.create_function(|lua, token: Option<String>| {
if let Some(token) = token {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.target_requests.push(token);
model.selection_requests.push(SelectionRequest::Unit(token));
}
Ok(())
})?,
)?;
// AssistUnit(unit) — select the *token's own* target (`0x489b80`; wow-re
// `object-layer/scratch/targeting-by-name.md` PART C, §5-cross-checked). The ASSISTTARGET
// binding's body is `AssistUnit("target")`, and `/assist`'s bare form is the same call.
//
// The shared assist tail (`0x489bb2`–`0x489c07`, byte-identical to `AssistByName`'s) is three
// steps and no more: read `UNIT_FIELD_TARGET` off the basis (`[[obj+0x110]+0x28]`), bail
// **silently** if it is zero, then hand the guid to the select-if-resolves helper `0x489a40`.
// Four things it deliberately is not:
//
// * **not gated by `CanAssist`** — a whole-binary census of the 25 `call 0x6066f0` sites puts
// none of them on this path (VERIFIED negative), so `AssistUnit("target")` on a hostile
// creature assists it, and that is the common case in play.
// * **not players-only** — unlike `AssistByName`'s typemask `0x10`, the token resolver takes
// whatever the token names.
// * **not a deselect on failure** — an unresolvable token, a basis with no target, and a
// target that is not streamed all leave the current selection exactly where it was
// (`0x489a40`'s arm 3 is a bare `ret`).
// * **not an attack** — the tail's swing leg is gated on the `assistAttack` CVar, whose
// registered default is `"0"` (VERIFIED at the registration bytes `0x48fc50`). Stock assist
// selects and does not swing; the leg becomes reachable only if benilla grows the CVar.
//
// A nil/absent unit is ignored here rather than queued, like `TargetUnit`'s: the reference
// emits game-message `0xb8` for the token it cannot parse, and that id→string table is
// runtime-populated BSS wow-re could not statically recover — the same known deviation
// `TargetByName` already carries, and silence is better than an invented line.
g.set(
"AssistUnit",
lua.create_function(|lua, token: Option<String>| {
if let Some(token) = token {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.selection_requests
.push(SelectionRequest::Assist(token));
}
Ok(())
})?,
)?;
// TargetLastEnemy() — re-select the last **attackable** unit that was targeted (`0x489b45`
// reads the pair `[0xb4e2e8]/[0xb4e2ec]`, which `SetSelection 0x493540` stamps at `0x49377d`
// alongside the plain last-target pair `TargetLastTarget` reads). The TARGETLASTHOSTILE
// binding (default `G`) is its only shipped caller.
//
// Takes no argument and routes through the same select-if-resolves helper, so a remembered
// guid whose unit has since streamed out is a silent no-op rather than a deselect. The app
// owns the memory itself (`crate::target::scan`'s `LastEnemy`) — the VM holds no guids.
g.set(
"TargetLastEnemy",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.selection_requests.push(SelectionRequest::LastEnemy);
Ok(())
})?,
)?;
// TargetNearestFriend([reverse]) — the friendly half of the TAB scan. `0x489aa0` is
// byte-for-byte `TargetNearestEnemy`'s shim with one changed immediate: both fetch the
// optional Lua arg #1 as the reverse flag (`0x6f1c10`, default 0) and call the one cycler
// `0x493f60(ecx = reverse, edx = mode)` — `edx = 1` for enemy, **`2` for friend** (3 and 4 are
// the party and raid siblings). Everything downstream is the same code; only the
// per-candidate filter `0x493e40` forks on the mode, and mode 2's arm (`0x493eca`) is
// `CanAssist(player, cand)` then `UNIT_FIELD_HEALTH > 0`.
//
// TARGETNEARESTFRIEND (`CTRL-TAB`) calls it bare; TARGETPREVIOUSFRIEND (`CTRL-SHIFT-TAB`)
// calls `TargetNearestFriend(1)` — 1.12's own `Bindings.xml` comments the argument
// `-- 1 (or "true") means reverse!`, so the truthiness test below takes a number OR a boolean.
// A numeric 0 is forward, matching `0x6f1c10`'s "absent == 0" reading.
g.set(
"TargetNearestFriend",
lua.create_function(|lua, reverse: Option<Value>| {
let reverse = match reverse {
None | Some(Value::Nil) | Some(Value::Boolean(false)) => false,
Some(Value::Integer(n)) => n != 0,
Some(Value::Number(n)) => n != 0.0,
Some(_) => true,
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.target_nearest_friend_requests.push(reverse);
Ok(())
})?,
)?;
// TargetByName(name [, exactMatch]) — select by NAME through the shared resolver `0x493aa0`
// (`0x489d60`; wow-re `object-layer/scratch/targeting-by-name.md`, a §5 trio + two pairs).
// The app already owns that resolver for `/target` (decision 0886, `crate::target::by_name`);
+35 -4
View File
@@ -24,6 +24,27 @@ use mlua::Lua;
use super::Model;
/// One queued **selection ask** from Lua, in call order — the app resolves it to a guid and
/// commits it through the one SetSelection tail.
///
/// The three verbs share one queue because the reference shares one *function*: `TargetUnit`
/// (`0x4899d0`), `AssistUnit` (`0x489b80`), `TargetLastEnemy` and `TargetLastTarget` all reach
/// selection through the "select if it resolves" helper `0x489a40`, whose three arms are the same
/// for every caller — resolve the guid and commit, else fall back to the group roster, else a
/// **silent return** (wow-re `object-layer/scratch/selection-attack-seam.md` §1). Splitting them
/// into a queue each would also lose their relative order, which a macro can observe
/// (`TargetUnit("party1"); AssistUnit("target")` is not the same as the reverse).
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum SelectionRequest {
/// `TargetUnit(unit)` — select the unit the token names.
Unit(String),
/// `AssistUnit(unit)` — select the token's own `UNIT_FIELD_TARGET`; a basis with no target is
/// a silent no-op (the reference's shared assist tail bails before any send).
Assist(String),
/// `TargetLastEnemy()` — select the last *attackable* unit that was committed.
LastEnemy,
}
/// One unit-token's game-state snapshot, pushed by the app each frame and read by the `Unit*`
/// bindings. Plain data (no mlua handles, no ECS types) — the engine-free seam decision 0068 §3
/// draws between the app's net/ECS feed and the Lua API.
@@ -450,10 +471,20 @@ impl super::UiScript {
model.combo_target = target;
}
/// Drain the unit tokens `TargetUnit` queued since the last call — the app resolves each to a
/// streamed entity and commits the selection (the reference's `TargetUnit` → SetSelection path).
pub fn take_target_requests(&mut self) -> Vec<String> {
std::mem::take(&mut self.model_mut().target_requests)
/// Drain the selection asks queued since the last call — the app resolves each to a guid and
/// commits it through the one SetSelection tail. See [`SelectionRequest`] for why the three
/// verbs share one ordered queue.
pub fn take_selection_requests(&mut self) -> Vec<SelectionRequest> {
std::mem::take(&mut self.model_mut().selection_requests)
}
/// Drain the `TargetNearestFriend([reverse])` presses queued since the last call — one `bool`
/// per call, the cycle direction (`false` forward, `true` backward). Its own queue rather than
/// a [`SelectionRequest`] variant because it names no unit: the reference runs it through the
/// TAB cycler `0x493f60(reverse, mode 2)` and straight into `SetSelection`, never through the
/// select-if-resolves helper the other three share.
pub fn take_target_nearest_friend_requests(&mut self) -> Vec<bool> {
std::mem::take(&mut self.model_mut().target_nearest_friend_requests)
}
/// Drain the `(name, exactMatch)` pairs `TargetByName` queued since the last call — the app
+52 -3
View File
@@ -276,16 +276,65 @@ fn corpse_can_attack_and_green_range_bindings() {
#[test]
fn target_unit_queues_the_token_for_the_app_to_resolve() {
use crate::script::SelectionRequest;
let mut s = UiScript::new().unwrap();
// Nothing queued until a call lands.
assert!(s.take_target_requests().is_empty());
assert!(s.take_selection_requests().is_empty());
// Each call queues its raw token, in order; a nil token is ignored.
s.eval::<()>(r#"TargetUnit("player")"#).unwrap();
s.eval::<()>(r#"TargetUnit("target")"#).unwrap();
s.eval::<()>(r#"TargetUnit(nil)"#).unwrap();
assert_eq!(s.take_target_requests(), vec!["player", "target"]);
assert_eq!(
s.take_selection_requests(),
vec![
SelectionRequest::Unit("player".into()),
SelectionRequest::Unit("target".into()),
]
);
// The drain is a take — a second read is empty.
assert!(s.take_target_requests().is_empty());
assert!(s.take_selection_requests().is_empty());
}
/// The three verbs share ONE queue, and the queue keeps their call order — which is the whole
/// reason it is one queue (`0x489a40` is one function for all of them, and a macro can observe
/// the order). A nil `AssistUnit` argument is dropped like `TargetUnit`'s, and
/// `TargetLastEnemy()` names no unit at all.
#[test]
fn the_selection_queue_carries_all_three_verbs_in_call_order() {
use crate::script::SelectionRequest;
let mut s = UiScript::new().unwrap();
s.eval::<()>(r#"TargetUnit("party1")"#).unwrap();
s.eval::<()>(r#"AssistUnit("target")"#).unwrap();
s.eval::<()>(r#"AssistUnit(nil)"#).unwrap();
s.eval::<()>("TargetLastEnemy()").unwrap();
assert_eq!(
s.take_selection_requests(),
vec![
SelectionRequest::Unit("party1".into()),
SelectionRequest::Assist("target".into()),
SelectionRequest::LastEnemy,
]
);
assert!(s.take_selection_requests().is_empty());
}
/// `TargetNearestFriend`'s reverse flag, read exactly as `0x6f1c10(idx 1, default 0)` does:
/// absent or nil is forward, and 1.12's own `Bindings.xml` note — *"1 (or \"true\") means
/// reverse!"* — is why a boolean and a number both count. A numeric `0` is forward.
#[test]
fn target_nearest_friend_queues_its_reverse_flag() {
let mut s = UiScript::new().unwrap();
assert!(s.take_target_nearest_friend_requests().is_empty());
s.eval::<()>("TargetNearestFriend()").unwrap();
s.eval::<()>("TargetNearestFriend(1)").unwrap();
s.eval::<()>("TargetNearestFriend(true)").unwrap();
s.eval::<()>("TargetNearestFriend(0)").unwrap();
s.eval::<()>("TargetNearestFriend(nil)").unwrap();
assert_eq!(
s.take_target_nearest_friend_requests(),
vec![false, true, true, false, false]
);
assert!(s.take_target_nearest_friend_requests().is_empty());
}
#[test]
+36 -2
View File
@@ -118,9 +118,39 @@ pub struct EditBoxState {
/// `maxBytes` (`E+0x33c`; the client's `-1` sentinel = unlimited → `None` here): trim from the
/// end while the byte length exceeds this, applied before `maxLetters`.
pub max_bytes: Option<usize>,
/// The implicit FontString the text renders through (`E+0x324`, the EditBox's analogue of
/// ButtonText) — created lazily on first text mutation, or wired from a declared `<FontString>`.
/// The implicit FontString the text renders through (`E+0x328`, the EditBox's analogue of
/// ButtonText). **Built by the ctor**, not lazily: `0x779bee` constructs it from allocator
/// `0xcf4d10` with tag `0x846544` = `".?AVCSimpleFontString@@"` via `0x770d30(E, 2, 1)`, and
/// that is the FIRST of the five regions a `CSimpleEditBox` is born with (wow-re
/// `system/ui/scratch/rf85-editbox-caret.md` §1). Creation order is what `GetRegions` hands
/// Lua — one flat creation-ordered list off `[frame+0x1b8]`, oldest first, no filter
/// (`scratch/widget-list-bindings.md`) — so these five precede every `<Layers>` region.
///
/// **DEFERRED, and named rather than silently taken:** the ctor's draw layer is 2 sub 1
/// (ARTWORK), while this region is still created at OVERLAY sub 0 — what ships today and what
/// the director has looked at. Moving it is a draw-order change to every EditBox in the client
/// with nothing asking for it, so it is a separate change, not a rider on this one.
pub text_region: Option<RegionHandle>,
/// The **three selection-highlight quads** (`E+0x350`/`0x354`/`0x358`), built by the ctor loop
/// `0x779c41–0x779c72` as `CSimpleTexture`s via `0x76fc40(E, 2, 0)` — allocator `0xcf4ce0`, tag
/// `0x846588` = `".?AVCSimpleTexture@@"`, class-identical to the caret (RF-0085 §1). They are
/// members of the frame's region list like any other region (`0x76fc40`→`0x77f640`→`0x77fd10`,
/// the single linker into `[frame+0x1b8]`), which is why they are built here.
///
/// **They carry no art of their own and paint nothing.** benilla draws the selection highlight
/// host-side, from [`sel_start`](Self::sel_start)/[`sel_end`](Self::sel_end) through the seam —
/// so these are the real regions the client has, not yet the regions the highlight rides.
/// Routing the paint through them is a separate, larger change (the host would have to read a
/// region rect instead of a seam field); this is stated, not stubbed.
pub selection_regions: [Option<RegionHandle>; 3],
/// The **caret** (`E+0x368`), the ctor's fifth and last region: a `CSimpleTexture` built at
/// `0x779c86–0x779cac` via `0x76fc40(E, 3, 1)` — a solid vertex-coloured quad, never a glyph
/// (no texture path is ever assigned to `E+0x368`, band-wide census; RF-0085 §1). Layer 3 over
/// the text's 2 is why the caret draws above the text and the selection quads below it.
///
/// Same standing as [`selection_regions`](Self::selection_regions): the region is real and in
/// the list, the *painting* is still host-side off [`caret_shown`](Self::caret_shown).
pub caret_region: Option<RegionHandle>,
/// The submitted-line history (`AddHistoryLine`; the XML `historyLines` cap) — oldest first,
/// newest last; UP recalls older from the end, DOWN newer. The exact recall keys + draft
/// model are INFERRED (wow-re's rf82 flags the 1.12 history controller as an untraced
@@ -289,6 +319,10 @@ impl Default for EditBoxState {
blink_period: 0.5,
blink_accum: 0.0,
caret_shown: true,
// Filled by `Arena::create`'s ctor pass — `Default` cannot reach the arena, and a
// handle-less default is what a not-yet-created box has.
selection_regions: [None; 3],
caret_region: None,
highlight_color: [96.0 / 255.0, 96.0 / 255.0, 96.0 / 255.0, 1.0],
// The EditBox ctor's `0x211` minus the unread bit 0x200 — `LEFT | MIDDLE`. LEFT, not
// the generic font default CENTER: `0x779be4` overrides the H axis at construction.
+47
View File
@@ -712,6 +712,9 @@ impl WidgetArena {
// Non-overwriting: first writer wins.
self.names.entry(n).or_insert(handle);
}
if matches!(kind, FrameKind::EditBox) {
self.build_editbox_engine_regions(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
@@ -723,6 +726,50 @@ impl WidgetArena {
handle
}
/// Build the **five engine-owned regions of a fresh EditBox**, in the ctor's own order — the
/// same posture as [`Self::build_minimap_engine_children`] below, and for the same reason: the
/// client builds them in `CSimpleEditBox`'s ctor, so a `CreateFrame("EditBox")` with no XML
/// behind it has them too, before its OnLoad runs.
///
/// wow-re `system/ui/scratch/rf85-editbox-caret.md` §1, all VERIFIED off bytes:
///
/// | # | region | ctor site | built by |
/// |---|---|---|---|
/// | 1 | text FontString `E+0x328` | `0x779bee` | `0x770d30(E, 2, 1)` |
/// | 2-4 | selection-highlight quads `E+0x350/0x354/0x358` | `0x779c41`–`0x779c72` | `0x76fc40(E, 2, 0)` |
/// | 5 | caret `E+0x368` | `0x779c86`–`0x779cac` | `0x76fc40(E, 3, 1)` |
///
/// **Why the ORDER is the whole point.** `GetRegions 0x773f60` walks `[frame+0x1b8]` — one flat
/// creation-ordered list, oldest first, with **no filter** (hidden regions are returned and
/// counted), and insertion is at the TAIL (`scratch/widget-list-bindings.md`). All five reach
/// that list through `0x77fd10`, the single linker. So on a real client the authored `<Layers>`
/// regions start at index **6**, and an addon may index them there.
///
/// pfUI's `skins/blizzard/friends.lua` l.379 is the report:
/// `local _,_,_,_,_,left,right = GuildControlPopupFrameEditBox:GetRegions()` — it skips five and
/// takes the box's two `UI-ChatInputBorder` textures. Before this, benilla returned only the
/// authored regions, so `left` was nil and the whole guild-control skin died on it.
///
/// The four quads are created blank — no texture, no fill — so they emit nothing; benilla's
/// caret and selection highlight are still painted host-side. That gap is named on the
/// [`EditBoxState`](kinds::EditBoxState) fields rather than papered over.
fn build_editbox_engine_regions(&mut self, eb: FrameHandle) {
// The text region keeps the OVERLAY/0 layer it has always shipped with; the ctor's own
// 2/1 is recorded on the field and deferred there (a draw-order change to every EditBox
// is not a rider on a region-list fix).
let text = self.create_region(eb, RegionKind::FontString, DrawLayer::Overlay, 0);
let mut selection = [None; 3];
for slot in &mut selection {
*slot = self.create_region(eb, RegionKind::Texture, DrawLayer::Artwork, 0);
}
let caret = self.create_region(eb, RegionKind::Texture, DrawLayer::Overlay, 1);
if let Some(KindState::EditBox(s)) = self.frame_mut(eb).map(|f| &mut f.kind_state) {
s.text_region = text;
s.selection_regions = selection;
s.caret_region = caret;
}
}
/// 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