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:
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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![]);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 `<` 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!(
|
||||
|
||||
@@ -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, ®ion_wrapper, parent_name, dbg);
|
||||
self.apply_fontstring_font(region, ®ion_wrapper, dbg);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user