deleting a good item asks in the reference's words
- deleting a rare-or-better item forks to the reference's typed confirm — "Type \"DELETE\" into the field to confirm": Yes starts disabled, the box takes focus, the button enables only while the field spells DELETE, and a reflexive Enter over a half-typed word is inert; the missing engine piece was one hook, the edit box's on-text-changed; beside it, the loot bind-on-pickup confirm — bind-on-pickup AND uncommon-or-better, two conjuncts and no others, so a white BoP drop is taken with no dialog and a bind-on-equip blue never asks here — with the reference's one-pending-slot latch, and the take split into two verbs the way 1.12 actually has it: the row click stays C-side, and LootSlot is only the confirmation's continuation - the message frames get their justify quartet, read out of the binary's method tables (with the Button table re-derived as the control that lacks it); Frame:EnableDrawLayer/DisableDrawLayer and Region:GetDrawLayer land — the disabled layers are a five-bit mask on the frame, so re-enabling a layer restores exactly what was showing underneath; and the Skills tab's children take the reference's names, with SKILLS_TO_DISPLAY published — pfUI loops over it and got "'for' limit must be a number" - the README's roadmap line now names what is genuinely big — battlegrounds and meeting stones, both verified still missing — plus the long tail, because the old line named four features that have since landed and small features go stale on a roadmap within a week Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -51,8 +51,8 @@ benilla is done when a 1.12.1 player can do everything here that they could in t
|
||||
client, it looks and feels the same, and it runs from a download on Windows, Linux and macOS.
|
||||
No dates; the order is what is likely, not a promise.
|
||||
|
||||
- The rest of the basics: battlegrounds, guild charters, the stable master, meeting stones,
|
||||
GM tickets, master loot, cinematics and whatever else turns up.
|
||||
- Battlegrounds and meeting stones, then the long tail of small features that separates a
|
||||
working client from a finished one.
|
||||
- Addons, options and performance, ongoing.
|
||||
- The no-brainer fixes from VanillaFixes, SuperWoW and the like.
|
||||
- Playable downloads for Windows, Linux and macOS. Linux first.
|
||||
|
||||
@@ -34,9 +34,12 @@
|
||||
The Era loot API this drives (all app-fed via the loot seam, benilla-ui `script/loot.rs`):
|
||||
GetNumLootItems(), GetLootSlotInfo(slot) -> texture,item,quantity,quality, LootSlotIsItem/
|
||||
LootSlotIsCoin(slot), GetLootSlotLink(slot) (the row's item link, read by the click's
|
||||
ctrl/shift arms — decision 1059), LootSlot(slot) (the row click's action), CloseLoot() (from
|
||||
OnHide). The synthesized coin pile is row 1 when the loot carries gold; a row click sends the
|
||||
money or the item's wire slot (the app maps it).
|
||||
ctrl/shift arms — decision 1059), BenillaTakeLootSlot(slot) (the row click's action, which
|
||||
in 1.12 is the C LootButton's and no Lua binding at all — decision 1744), LootSlot(slot) (the
|
||||
LOOT_BIND confirmation continuation, its only 1.12 meaning), CloseLoot() (from OnHide). The
|
||||
synthesized coin pile is row 1 when the loot carries gold; a row click sends the money or the
|
||||
item's wire slot (the app maps it) — unless the row binds on pickup, where it raises the
|
||||
soulbind confirm instead and the send waits for the OK.
|
||||
|
||||
Paging (LootFrame.lua l.27-31,70-73,105-118): 4 rows/page; > 4 items spends one slot on the
|
||||
pager, showing 3 rows + Up/Down. We keep the current page across content-change repaints
|
||||
@@ -139,13 +142,13 @@
|
||||
-- · The button code is never read (`0x4c1820` doesn't look at it), so a RIGHT-click loots
|
||||
-- exactly like a left one — which is why this handler takes no `mouseButton`.
|
||||
--
|
||||
-- benilla folds that C action into this Lua handler (`LootSlot(button.slot)`, this file's
|
||||
-- model since 0084), so the gate has to be an early return here. Named divergence, not a
|
||||
-- transcription slip: 1.12 exposes no plain "take slot N" binding at all — the real
|
||||
-- `LootSlot 0x4c2e70` is the LOOT_BIND *confirmation continuation* (flag 1, gated on the
|
||||
-- stashed slot at `[0x847cec]`; its only FrameXML caller is `StaticPopup.lua:606`). Moving
|
||||
-- the take host-side onto the widget, and shrinking `LootSlot()` to that continuation, is
|
||||
-- 1067's named follow-up.
|
||||
-- benilla folds that C action into this Lua handler, so the gate has to be an early return
|
||||
-- here. The verb it calls is `BenillaTakeLootSlot`, not `LootSlot`, and that is 1067's
|
||||
-- named follow-up landed (decision 1744): 1.12 exposes no plain "take slot N" binding at
|
||||
-- all — the real `LootSlot 0x4c2e70` is the LOOT_BIND *confirmation continuation* (flag 1,
|
||||
-- gated on the stashed slot at `[0x847cec]`; its only FrameXML caller is
|
||||
-- `StaticPopup.lua:606`, the entry below). Ours keeps exactly that meaning, so an addon
|
||||
-- calling `LootSlot(n)` on an ordinary row does what it does on the real client: nothing.
|
||||
--
|
||||
-- The reference's tail (StaticPopup_Hide("CONFIRM_LOOT_DISTRIBUTION") and the four
|
||||
-- LootFrame.selected* fields, l.155-161) is the MASTER-LOOT dropdown's bookkeeping —
|
||||
@@ -198,7 +201,7 @@
|
||||
LootFrame.selectedItemName = button.name:GetText()
|
||||
|
||||
if modified then return end
|
||||
LootSlot(button.slot)
|
||||
BenillaTakeLootSlot(button.slot)
|
||||
end
|
||||
|
||||
-- Hover: the plain engine item tooltip, ITEM rows only — the ref's own gate (its
|
||||
@@ -302,12 +305,53 @@
|
||||
LootFrame_Update()
|
||||
end
|
||||
|
||||
-- ── The soulbind confirm ──────────────────────────────────────────────────────────────
|
||||
-- ref GlobalStrings.lua l.2612 / l.2818 / l.465, fallback form (the player's own localized
|
||||
-- file runs off the patch chain first).
|
||||
LOOT_NO_DROP = LOOT_NO_DROP or "Looting this item will bind it to you."
|
||||
OKAY = OKAY or "Okay"
|
||||
CANCEL = CANCEL or "Cancel"
|
||||
|
||||
-- ref StaticPopup.lua l.601-611, verbatim. `OnAccept`'s argument is `dialog.data`, which
|
||||
-- the driver below sets to the event's row — and `LootSlot` is the continuation verb, so
|
||||
-- this line is the ONLY thing in the client that ever completes a bind-deferred take.
|
||||
--
|
||||
-- Nothing else is needed: no OnCancel (declining just leaves the row in the window, and the
|
||||
-- app drops the pending stash when the window turns over) and no OnUpdate poll, because the
|
||||
-- window's own LOOT_CLOSED hides it (below, ref LootFrame.lua l.53-54).
|
||||
StaticPopupDialogs["LOOT_BIND"] = {
|
||||
text = LOOT_NO_DROP,
|
||||
button1 = OKAY,
|
||||
button2 = CANCEL,
|
||||
OnAccept = function(slot)
|
||||
LootSlot(slot)
|
||||
end,
|
||||
timeout = 0,
|
||||
exclusive = 1,
|
||||
hideOnEscape = 1,
|
||||
}
|
||||
|
||||
-- ref UIParent.lua l.317-323. The row rides on `dialog.data` rather than as a text arg —
|
||||
-- LOOT_NO_DROP has no format specifier, so the dialog shows the same sentence whatever
|
||||
-- binds; the number is only there to be handed back to LootSlot.
|
||||
--
|
||||
-- The reference registers this on UIParent, which owns every bind confirm; benilla hangs it
|
||||
-- on LootFrame, whose LOOT_CLOSED already has to hide the dialog. Same two moments, one
|
||||
-- listener, and the arc stays in one file.
|
||||
function BenillaLootBindConfirm(slot)
|
||||
local dialog = StaticPopup_Show("LOOT_BIND")
|
||||
if dialog then
|
||||
dialog.data = slot
|
||||
end
|
||||
end
|
||||
|
||||
function LootFrame_OnLoad()
|
||||
local frame = this
|
||||
frame.page = 1
|
||||
frame:RegisterEvent("LOOT_OPENED")
|
||||
frame:RegisterEvent("LOOT_UPDATE")
|
||||
frame:RegisterEvent("LOOT_CLOSED")
|
||||
frame:RegisterEvent("LOOT_BIND_CONFIRM")
|
||||
-- Master loot (ref l.9-10). Neither event carries an argument: the candidate list is
|
||||
-- read back through GetMasterLootCandidate at menu-build time, not pushed.
|
||||
frame:RegisterEvent("OPEN_MASTER_LOOT_LIST")
|
||||
@@ -338,7 +382,12 @@
|
||||
LootFrame_PageDown()
|
||||
end
|
||||
elseif event == "LOOT_CLOSED" then
|
||||
-- ref l.53-54: the confirm dies with the window it addressed. Its row number would
|
||||
-- name a slot in the NEXT corpse otherwise.
|
||||
StaticPopup_Hide("LOOT_BIND")
|
||||
HideUIPanel(frame)
|
||||
elseif event == "LOOT_BIND_CONFIRM" then
|
||||
BenillaLootBindConfirm(arg1)
|
||||
elseif event == "OPEN_MASTER_LOOT_LIST" then
|
||||
-- Ref l.58-61. Anchored to the ROW that was clicked, not to the frame — the name
|
||||
-- the click stashed a moment ago.
|
||||
|
||||
@@ -96,7 +96,14 @@
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
-- ── Constants (ref SkillFrame.lua:1-2) ──────────────────────────────────────────────────────
|
||||
local SKILLFRAME_SKILLS_DISPLAYED = 12 -- SKILLS_TO_DISPLAY
|
||||
-- SKILLS_TO_DISPLAY is the reference's own GLOBAL and is published as one. It was a chunk
|
||||
-- local named SKILLS_TO_DISPLAY with the real name in a trailing comment — the
|
||||
-- value transcribed, the NAME kept private, which is the same shape that cost us
|
||||
-- BlackoutWorld and the five SkillFrame* children. Addons read it: pfUI's character skin
|
||||
-- loops `for i = 1, SKILLS_TO_DISPLAY` over the row labels
|
||||
-- (skins/blizzard/character.lua:277) and died on "'for' limit must be a number".
|
||||
-- 12 is honest here rather than copied — this file declares exactly 12 SkillTypeLabel rows.
|
||||
SKILLS_TO_DISPLAY = 12
|
||||
local SKILLFRAME_SKILL_HEIGHT = 15 -- SKILLFRAME_SKILL_HEIGHT
|
||||
|
||||
-- GlobalStrings (ref GlobalStrings.lua:3478), chunk-local per the codebase idiom — safe
|
||||
@@ -188,7 +195,7 @@
|
||||
-- same fill (ref l.220-226): shown only for an abandonable line, carrying the name the
|
||||
-- confirm popup formats and the index its accept abandons.
|
||||
function BenillaSkillFrame_UpdateDetail()
|
||||
local bar = getglobal("SkillDetailBar")
|
||||
local bar = getglobal("SkillDetailStatusBar")
|
||||
local desc = getglobal("SkillDetailDescriptionText")
|
||||
local unlearn = getglobal("SkillDetailUnlearnButton")
|
||||
local index = GetSelectedSkill()
|
||||
@@ -299,14 +306,14 @@
|
||||
function BenillaSkillFrame_Update()
|
||||
local numRows = GetNumSkillLines()
|
||||
local scrollFrame = getglobal("SkillListScrollFrame")
|
||||
FauxScrollFrame_Update(scrollFrame, numRows, SKILLFRAME_SKILLS_DISPLAYED, SKILLFRAME_SKILL_HEIGHT)
|
||||
FauxScrollFrame_Update(scrollFrame, numRows, SKILLS_TO_DISPLAY, SKILLFRAME_SKILL_HEIGHT)
|
||||
local offset = FauxScrollFrame_GetOffset(scrollFrame)
|
||||
|
||||
local highlight = getglobal("SkillHighlight")
|
||||
highlight:Hide()
|
||||
local selected = GetSelectedSkill()
|
||||
|
||||
for i = 1, SKILLFRAME_SKILLS_DISPLAYED do
|
||||
for i = 1, SKILLS_TO_DISPLAY do
|
||||
local index = i + offset
|
||||
BenillaSkillFrame_SetRow(i, index, numRows)
|
||||
if selected == index and index <= numRows then
|
||||
@@ -323,7 +330,7 @@
|
||||
allExpanded = false
|
||||
end
|
||||
end
|
||||
local collapseAll = getglobal("SkillCollapseAllButton")
|
||||
local collapseAll = getglobal("SkillFrameCollapseAllButton")
|
||||
if numRows == 0 then
|
||||
collapseAll:Disable()
|
||||
else
|
||||
@@ -338,7 +345,7 @@
|
||||
end
|
||||
|
||||
BenillaSkillFrame_UpdateDetail()
|
||||
BenillaSkillExpandButtonFrame_Fit()
|
||||
BenillaSkillFrameExpandButtonFrame_Fit()
|
||||
end
|
||||
|
||||
-- A rank-bar click selects it (ref SkillBar_OnClick, l.450-455).
|
||||
@@ -376,8 +383,8 @@
|
||||
-- The collapse-all tab (ref SkillFrameCollapseAllButton's own isExpanded flag, l.303-309,
|
||||
-- driven the TrainerFrame.xml BenillaTrainerCollapseAllButton way since this engine's
|
||||
-- CollapseSkillHeader(0)/ExpandSkillHeader(0) already fold/unfold every group at once).
|
||||
function BenillaSkillCollapseAllButton_OnClick()
|
||||
local b = getglobal("SkillCollapseAllButton")
|
||||
function BenillaSkillFrameCollapseAllButton_OnClick()
|
||||
local b = getglobal("SkillFrameCollapseAllButton")
|
||||
if b.collapsed then
|
||||
ExpandSkillHeader(0)
|
||||
else
|
||||
@@ -401,11 +408,11 @@
|
||||
-- two opposing anchors and no Lua width — UiPanels.xml's tab-highlight note — but a span
|
||||
-- can only be resolved from two anchors when both edges are frames; a text width has to be
|
||||
-- asked for.)
|
||||
function BenillaSkillExpandButtonFrame_Fit()
|
||||
local label = getglobal("SkillCollapseAllButton")
|
||||
function BenillaSkillFrameExpandButtonFrame_Fit()
|
||||
local label = getglobal("SkillFrameCollapseAllButton")
|
||||
local w = label:GetTextWidth()
|
||||
if w and w > 0 then
|
||||
getglobal("SkillExpandButtonFrame"):SetWidth(w + 45)
|
||||
getglobal("SkillFrameExpandButtonFrame"):SetWidth(w + 45)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -441,7 +448,7 @@
|
||||
-- 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", "SkillDetailBar", "BOTTOMLEFT", -2, -10)
|
||||
SkillDetailDescriptionText:SetPoint("TOPLEFT", "SkillDetailStatusBar", "BOTTOMLEFT", -2, -10)
|
||||
end
|
||||
|
||||
function SkillFrame_OnShow()
|
||||
@@ -597,13 +604,13 @@
|
||||
label's seat. Was a fixed 54 with a fixed 38 middle, copied off
|
||||
TrainerFrame.xml rather than read off THIS ref; the label's own font fix below
|
||||
(12px, not 10) would have run under the right cap at that width. -->
|
||||
<Frame name="SkillExpandButtonFrame">
|
||||
<Frame name="SkillFrameExpandButtonFrame">
|
||||
<Size><AbsDimension x="54" y="32"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="70" y="-49"/></Offset></Anchor></Anchors>
|
||||
<Layers><Layer level="BACKGROUND">
|
||||
<Texture name="SkillExpandTabLeft" file="Interface\QuestFrame\UI-QuestLogSortTab-Left"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
|
||||
<Texture name="SkillExpandTabRight" file="Interface\QuestFrame\UI-QuestLogSortTab-Right"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
|
||||
<Texture name="SkillExpandTabMiddle" file="Interface\QuestFrame\UI-QuestLogSortTab-Middle"><Size><AbsDimension x="0" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="SkillExpandTabLeft" relativePoint="RIGHT"/><Anchor point="RIGHT" relativeTo="SkillExpandTabRight" relativePoint="LEFT"/></Anchors></Texture>
|
||||
<Texture name="SkillFrameExpandTabLeft" file="Interface\QuestFrame\UI-QuestLogSortTab-Left"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
|
||||
<Texture name="SkillFrameExpandTabRight" file="Interface\QuestFrame\UI-QuestLogSortTab-Right"><Size><AbsDimension x="8" y="32"/></Size><Anchors><Anchor point="TOPRIGHT" relativePoint="TOPRIGHT"><Offset><AbsDimension x="0" y="6"/></Offset></Anchor></Anchors></Texture>
|
||||
<Texture name="SkillFrameExpandTabMiddle" file="Interface\QuestFrame\UI-QuestLogSortTab-Middle"><Size><AbsDimension x="0" y="32"/></Size><Anchors><Anchor point="LEFT" relativeTo="SkillFrameExpandTabLeft" relativePoint="RIGHT"/><Anchor point="RIGHT" relativeTo="SkillFrameExpandTabRight" relativePoint="LEFT"/></Anchors></Texture>
|
||||
</Layer></Layers>
|
||||
<Frames>
|
||||
<!-- The ALL fold (ref SkillFrameCollapseAllButton, l.292-310). Its face is the
|
||||
@@ -620,17 +627,17 @@
|
||||
texture's geometric middle, so the label rides 3px low, not 3px high — the
|
||||
+3 copied from TrainerFrame.xml put the whole "- All" 6px above where the
|
||||
reference seats it. -->
|
||||
<Button name="SkillCollapseAllButton" text="ALL">
|
||||
<Button name="SkillFrameCollapseAllButton" text="ALL">
|
||||
<Size><AbsDimension x="40" y="22"/></Size>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="SkillExpandTabLeft" relativePoint="RIGHT"><Offset><AbsDimension x="-3" y="-3"/></Offset></Anchor></Anchors>
|
||||
<Anchors><Anchor point="LEFT" relativeTo="SkillFrameExpandTabLeft" relativePoint="RIGHT"><Offset><AbsDimension x="-3" y="-3"/></Offset></Anchor></Anchors>
|
||||
<NormalTexture file="Interface\Buttons\UI-MinusButton-Up"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors></NormalTexture>
|
||||
<HighlightTexture file="Interface\Buttons\UI-PlusButton-Hilight" alphaMode="ADD"><Size><AbsDimension x="16" y="16"/></Size><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="3" y="0"/></Offset></Anchor></Anchors></HighlightTexture>
|
||||
<ButtonText><Anchors><Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="25" y="0"/></Offset></Anchor></Anchors></ButtonText>
|
||||
<NormalFont inherits="GameFontHighlight" justifyH="LEFT"/>
|
||||
<Scripts><OnClick>BenillaSkillCollapseAllButton_OnClick()</OnClick></Scripts>
|
||||
<Scripts><OnClick>BenillaSkillFrameCollapseAllButton_OnClick()</OnClick></Scripts>
|
||||
</Button>
|
||||
</Frames>
|
||||
<Scripts><OnLoad>BenillaSkillExpandButtonFrame_Fit()</OnLoad></Scripts>
|
||||
<Scripts><OnLoad>BenillaSkillFrameExpandButtonFrame_Fit()</OnLoad></Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- The page's own CLOSE button (ref SkillFrameCancelButton, l.339-357: 80x22,
|
||||
@@ -706,7 +713,7 @@
|
||||
|
||||
<!-- The faux-scroll frame (FauxScrollFrameTemplate, ScrollTemplates.xml): invisible
|
||||
container over the 12 rows; only its right-hand Slider scroll bar renders. Sized to the
|
||||
12-row span (SKILLFRAME_SKILLS_DISPLAYED × 18px pitch), TrainerFrame.xml's own
|
||||
12-row span (SKILLS_TO_DISPLAY × 18px pitch), TrainerFrame.xml's own
|
||||
same-TOPLEFT-as-row1 convention. updateFunc set in SkillFrame_OnLoad. -->
|
||||
<Frame name="SkillListScrollFrame" inherits="FauxScrollFrameTemplate">
|
||||
<Size><AbsDimension x="296" y="216"/></Size>
|
||||
@@ -717,7 +724,7 @@
|
||||
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="SkillDetailBar" inherits="BenillaSkillRankTemplate" hidden="true">
|
||||
<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
|
||||
@@ -729,7 +736,7 @@
|
||||
<Button name="SkillDetailUnlearnButton" hidden="true">
|
||||
<Size><AbsDimension x="32" y="32"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="SkillDetailBarBorder" relativePoint="RIGHT">
|
||||
<Anchor point="LEFT" relativeTo="SkillDetailStatusBarBorder" relativePoint="RIGHT">
|
||||
<Offset><AbsDimension x="-2" y="-1"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
|
||||
@@ -945,8 +945,10 @@
|
||||
-- (reason ∈ "clicked"|"timeout"|"override"), OnUpdate(elapsed, dialog), timeout,
|
||||
-- StartDelay, delayText, sound, whileDead, hideOnEscape, cancels, showAlert (the
|
||||
-- 420 widen + DialogAlertIcon, decision 1068).
|
||||
-- · hasEditBox + maxLetters + EditBoxOnEnterPressed are live (decision 0668's
|
||||
-- ADD_FRIEND/ADD_IGNORE are the first customers): the box shows, caps its length,
|
||||
-- · hasEditBox + maxLetters + all three box hooks — EditBoxOnEnterPressed,
|
||||
-- EditBoxOnTextChanged, EditBoxOnEscapePressed — are live (decision 0668's
|
||||
-- ADD_FRIEND/ADD_IGNORE are the first customers; 1743's DELETE_GOOD_ITEM is the
|
||||
-- first to gate button1 on what is typed): the box shows, caps its length,
|
||||
-- opens empty, and re-anchors the buttons under itself.
|
||||
-- · hasWideEditBox is live too (the guild arc's SET_GUILDMOTD / SET_GUILDPLAYERNOTE /
|
||||
-- SET_GUILDOFFICERNOTE, registered in FriendsFrame.xml): the 350x64 centred box
|
||||
@@ -1255,10 +1257,12 @@
|
||||
end
|
||||
end
|
||||
|
||||
-- The edit box's two keys (ref StaticPopup.xml's template scripts). Enter runs the
|
||||
-- entry's EditBoxOnEnterPressed hook, which by convention does the accept work and hides
|
||||
-- the dialog itself; Escape dismisses the dialog through the ordinary cancel path so a
|
||||
-- half-typed name never acts.
|
||||
-- The edit box's three script hooks (ref StaticPopup.xml's template scripts, ref
|
||||
-- l.1810-1829). All three are pure delegation to the entry's own hook of the same name;
|
||||
-- `this` is the EDIT BOX while they run, which is what lets an entry body say
|
||||
-- `this:GetParent()`. Enter runs the entry's EditBoxOnEnterPressed, which by convention
|
||||
-- does the accept work and hides the dialog itself; text-changed drives a live gate on
|
||||
-- button1 (DELETE_GOOD_ITEM's typed "DELETE" is the only 1.12 customer).
|
||||
function StaticPopup_EditBoxOnEnterPressed(editBox)
|
||||
local dialog = editBox:GetParent()
|
||||
local info = StaticPopupDialogs[dialog.which]
|
||||
@@ -1267,10 +1271,35 @@
|
||||
end
|
||||
end
|
||||
|
||||
function StaticPopup_EditBoxOnTextChanged(editBox)
|
||||
local dialog = editBox:GetParent()
|
||||
local info = StaticPopupDialogs[dialog.which]
|
||||
if info and info.EditBoxOnTextChanged then
|
||||
info.EditBoxOnTextChanged(dialog.data)
|
||||
end
|
||||
end
|
||||
|
||||
-- Escape delegates like its two siblings — and where the entry names no hook, falls back
|
||||
-- to what ESC would have done had the focused box not swallowed the key, i.e. the ordinary
|
||||
-- StaticPopup_EscapePressed leg: the entry's OnCancel, then hide, and only for a
|
||||
-- hideOnEscape entry. NAMED DIVERGENCE (1743): the reference has no fallback at all, so a
|
||||
-- 1.12 dialog whose entry omits the hook simply eats the key — which is what its own
|
||||
-- DELETE_GOOD_ITEM does, leaving a destroy confirm that ESC cannot dismiss. Every other
|
||||
-- benilla popup closes on ESC and this one now does too; without the OnCancel leg it would
|
||||
-- also have hidden while still holding the item, which is the half that made the old
|
||||
-- unconditional `dialog:Hide()` wrong rather than merely lenient.
|
||||
function StaticPopup_EditBoxOnEscapePressed(editBox)
|
||||
local dialog = editBox:GetParent()
|
||||
local info = StaticPopupDialogs[dialog.which]
|
||||
editBox:ClearFocus()
|
||||
dialog:Hide()
|
||||
if info and info.EditBoxOnEscapePressed then
|
||||
info.EditBoxOnEscapePressed(dialog.data)
|
||||
elseif dialog.hideOnEscape then
|
||||
if info and info.OnCancel then
|
||||
info.OnCancel(dialog.data, "clicked")
|
||||
end
|
||||
dialog:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
-- Ref l.1832-1848: the open/close pair sounds on EVERY path, then the entry's own hook.
|
||||
@@ -1387,20 +1416,29 @@
|
||||
return closed
|
||||
end
|
||||
|
||||
-- Delete-item confirm strings — the real 1.12 GlobalStrings.lua (patch-2.MPQ,
|
||||
-- Interface\FrameXML\GlobalStrings.lua), quoted verbatim (extracted via benilla-extract).
|
||||
local DELETE_ITEM_TEXT = "Do you want to destroy %s?" -- DELETE_ITEM; %s = the item name
|
||||
local YES_TEXT = "Yes" -- YES
|
||||
local NO_TEXT = "No" -- No
|
||||
-- Destroy-confirm strings — the real 1.12 GlobalStrings.lua (patch-2.MPQ,
|
||||
-- Interface\FrameXML\GlobalStrings.lua l.921-923; YES/NO are l.5463/l.2794, the same pair
|
||||
-- LootFrame.xml declares), quoted verbatim in the FALLBACK form:
|
||||
-- the player's own localized GlobalStrings.lua runs off the patch chain BEFORE this file
|
||||
-- (ui_script/mod.rs), so these only fill in for the harnesses that load no chain. They
|
||||
-- were plain locals until 1743; a local could never be the localized text, and
|
||||
-- DELETE_ITEM_CONFIRM_STRING in particular MUST be the player's own word — it is what the
|
||||
-- good-item dialog below makes them type.
|
||||
DELETE_ITEM = DELETE_ITEM or "Do you want to destroy %s?"
|
||||
DELETE_GOOD_ITEM = DELETE_GOOD_ITEM
|
||||
or "Do you want to destroy %s?\n\nType \"DELETE\" into the field to confirm."
|
||||
DELETE_ITEM_CONFIRM_STRING = DELETE_ITEM_CONFIRM_STRING or "DELETE"
|
||||
YES = YES or "Yes"
|
||||
NO = NO or "No"
|
||||
|
||||
-- Ref StaticPopup.lua l.659-679, verbatim — its own OnUpdate poll (cursor emptied by any
|
||||
-- other path → hide) replaces the old bespoke `deleteItemFlow` CURSOR_UPDATE driver.
|
||||
-- exclusive is v1-inert (engine header note); showAlert is live since 1068 — the ref's
|
||||
-- own "display the error image" case is this very dialog.
|
||||
StaticPopupDialogs["DELETE_ITEM"] = {
|
||||
text = DELETE_ITEM_TEXT,
|
||||
button1 = YES_TEXT,
|
||||
button2 = NO_TEXT,
|
||||
text = DELETE_ITEM,
|
||||
button1 = YES,
|
||||
button2 = NO,
|
||||
OnAccept = function()
|
||||
DeleteCursorItem()
|
||||
end,
|
||||
@@ -1419,14 +1457,78 @@
|
||||
hideOnEscape = 1,
|
||||
}
|
||||
|
||||
-- Ref StaticPopup.lua l.680-724, verbatim — the RARE-or-better destroy. Same accept,
|
||||
-- cancel and auto-hide poll as DELETE_ITEM above; what it adds is the typed confirmation:
|
||||
-- the edit box takes focus on open, button1 starts DISABLED, and only the exact
|
||||
-- DELETE_ITEM_CONFIRM_STRING (case-insensitively, via the ref's own strupper) enables it.
|
||||
--
|
||||
-- `this` is the dialog inside OnShow/OnHide and the EDIT BOX inside the two EditBox hooks —
|
||||
-- the reference's own convention, which is why those two reach the dialog through
|
||||
-- `this:GetParent()`. Our engine sets the same `this` on both paths (StaticPopup_OnShow /
|
||||
-- the template's <OnEnterPressed>/<OnTextChanged>), so the bodies transcribe unchanged.
|
||||
--
|
||||
-- The one guard added: the ref's OnHide reads ChatFrameEditBox unguarded, which raises in a
|
||||
-- harness that loads no ChatFrame.xml. Same `and` the bag's shift-link arm carries.
|
||||
StaticPopupDialogs["DELETE_GOOD_ITEM"] = {
|
||||
text = DELETE_GOOD_ITEM,
|
||||
button1 = YES,
|
||||
button2 = NO,
|
||||
OnAccept = function()
|
||||
DeleteCursorItem()
|
||||
end,
|
||||
OnCancel = function()
|
||||
ClearCursor()
|
||||
end,
|
||||
OnUpdate = function()
|
||||
if not CursorHasItem() then
|
||||
StaticPopup_Hide("DELETE_GOOD_ITEM")
|
||||
end
|
||||
end,
|
||||
timeout = 0,
|
||||
whileDead = 1,
|
||||
exclusive = 1,
|
||||
showAlert = 1,
|
||||
hideOnEscape = 1,
|
||||
hasEditBox = 1,
|
||||
maxLetters = 32,
|
||||
OnShow = function()
|
||||
getglobal(this:GetName() .. "Button1"):Disable()
|
||||
getglobal(this:GetName() .. "EditBox"):SetFocus()
|
||||
end,
|
||||
OnHide = function()
|
||||
if ChatFrameEditBox and ChatFrameEditBox:IsVisible() then
|
||||
ChatFrameEditBox:SetFocus()
|
||||
end
|
||||
getglobal(this:GetName() .. "EditBox"):SetText("")
|
||||
end,
|
||||
EditBoxOnEnterPressed = function()
|
||||
if getglobal(this:GetParent():GetName() .. "Button1"):IsEnabled() == 1 then
|
||||
DeleteCursorItem()
|
||||
this:GetParent():Hide()
|
||||
end
|
||||
end,
|
||||
EditBoxOnTextChanged = function()
|
||||
local editBox = getglobal(this:GetParent():GetName() .. "EditBox")
|
||||
if strupper(editBox:GetText()) == DELETE_ITEM_CONFIRM_STRING then
|
||||
getglobal(this:GetParent():GetName() .. "Button1"):Enable()
|
||||
else
|
||||
getglobal(this:GetParent():GetName() .. "Button1"):Disable()
|
||||
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. DEFERRED (0216 §3): the ref splits quality >= 3 to
|
||||
-- DELETE_GOOD_ITEM (the typed-confirmation editbox variant) — no editbox variant yet, so
|
||||
-- every quality (arg2) shows DELETE_ITEM for now.
|
||||
-- ref UIParent.lua:344-352, now whole: the ref's `arg2 >= 3` fork routes RARE-or-better
|
||||
-- (quality 3..6 — blue, epic, legendary, artifact) to the typed-confirmation variant and
|
||||
-- everything below it to the plain one. 0216 §3's deferral is closed (1743).
|
||||
function BenillaDeleteItemConfirmDriver_OnEvent(frame)
|
||||
if event == "DELETE_ITEM_CONFIRM" then
|
||||
StaticPopup_Show("DELETE_ITEM", arg1)
|
||||
if arg2 >= 3 then
|
||||
StaticPopup_Show("DELETE_GOOD_ITEM", arg1)
|
||||
else
|
||||
StaticPopup_Show("DELETE_ITEM", arg1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1990,8 +2092,9 @@
|
||||
<!-- The name-entry box (ref StaticPopup.xml l.84-140): 130x32 at BOTTOM +45, the
|
||||
chat-input three-slice border, hidden unless the entry sets hasEditBox. First
|
||||
customer: the social window's ADD_FRIEND / ADD_IGNORE dialogs (decision 0668).
|
||||
Enter runs the entry's own EditBoxOnEnterPressed (the ref's hook name) and Escape
|
||||
dismisses the whole dialog, which is the ref's EditBoxOnEscapePressed default. -->
|
||||
All three of the ref's box hooks are wired (Enter / Escape / TextChanged), each
|
||||
delegating to the entry's own hook of that name; OnTextChanged rides the NARROW
|
||||
box alone, which is the ref's own asymmetry (StaticPopup.xml l.133 vs l.190). -->
|
||||
<EditBox name="$parentEditBox" historyLines="1" hidden="true" autoFocus="false">
|
||||
<Size><AbsDimension x="130" y="32"/></Size>
|
||||
<Anchors><Anchor point="BOTTOM"><Offset><AbsDimension x="0" y="45"/></Offset></Anchor></Anchors>
|
||||
@@ -2012,6 +2115,7 @@
|
||||
<Scripts>
|
||||
<OnEnterPressed>StaticPopup_EditBoxOnEnterPressed(self)</OnEnterPressed>
|
||||
<OnEscapePressed>StaticPopup_EditBoxOnEscapePressed(self)</OnEscapePressed>
|
||||
<OnTextChanged>StaticPopup_EditBoxOnTextChanged(self)</OnTextChanged>
|
||||
</Scripts>
|
||||
<FontString inherits="ChatFontNormal"/>
|
||||
</EditBox>
|
||||
|
||||
@@ -49,6 +49,17 @@ use crate::ui_script::UiInput;
|
||||
const COIN_ICON_GOLD: &str = "Interface\\Icons\\INV_Misc_Coin_01";
|
||||
const COIN_ICON_SILVER: &str = "Interface\\Icons\\INV_Misc_Coin_03";
|
||||
const COIN_ICON_COPPER: &str = "Interface\\Icons\\INV_Misc_Coin_05";
|
||||
/// `item_template.bonding == BIND_WHEN_PICKED_UP` — the first of the two conjuncts that defer a
|
||||
/// loot take behind the LOOT_BIND confirm (VERIFIED vmangos `ItemPrototype.h`'s `ItemBondingType`:
|
||||
/// `NO_BIND` 0, `BIND_WHEN_PICKED_UP` 1, `BIND_WHEN_EQUIPPED` 2, `BIND_WHEN_USE` 3, `QUEST_ITEM` 4;
|
||||
/// the client reads the same field at `item_template + 0x194` and compares it against `1`).
|
||||
const BIND_WHEN_PICKED_UP: u32 = 1;
|
||||
|
||||
/// The second conjunct: quality **>= 2** (uncommon or better), `cmp [tmpl+0x1c], 2 / jb` at
|
||||
/// `0x4c28fb`. Below it a bind-on-pickup row is simply taken — the confirm exists to stop a player
|
||||
/// soulbinding something they meant to pass to a groupmate, and nobody passes a white.
|
||||
const BIND_CONFIRM_MIN_QUALITY: u32 = 2;
|
||||
|
||||
/// The fixed quality of the synthesized coin row (common/white — the money text reads as plain).
|
||||
const COIN_QUALITY: u32 = 1;
|
||||
/// The 1.12 coin-denomination words, QUOTED from `Interface\FrameXML\GlobalStrings.lua` (verified
|
||||
@@ -179,6 +190,13 @@ pub(crate) struct LootState {
|
||||
/// the guids `GiveMasterLoot`'s 1-based candidate index resolves against (decision 1675).
|
||||
/// Empty under every other loot method.
|
||||
master_candidates: Vec<u64>,
|
||||
/// The row a `LOOT_BIND_CONFIRM` is currently open for — 1-based, display-side, the number the
|
||||
/// event carried out and the number `LootSlot` must carry back (decision 1744). This is the
|
||||
/// reference's `[0x847cec]`, whose `-1` is our `None`: `0x4c2790`'s click arm writes it instead
|
||||
/// of sending, and its continuation arm sends only for a slot that equals it, then clears it
|
||||
/// (`0x4c281a mov [0x847cec], 0xffffffff`). Reset with the window (`0x4c1df5`, in the
|
||||
/// `SMSG_LOOT_RESPONSE` copier) so a confirm cannot survive into the next corpse.
|
||||
pending_bind_confirm: Option<u32>,
|
||||
/// The candidate list that arrived but has no window yet. `SMSG_LOOT_MASTER_LIST` is sent from
|
||||
/// *inside* `Player::SendLoot` (`Player.cpp:8077-8081`), so it lands **before** the
|
||||
/// `SMSG_LOOT_RESPONSE` it belongs to; [`LootState::open`] takes it from here. Staging it
|
||||
@@ -201,6 +219,9 @@ enum LootAction {
|
||||
Item {
|
||||
wire_slot: u8,
|
||||
display_id: u32,
|
||||
/// The template entry — the key the bind-on-pickup deferral reads `bonding` and `quality`
|
||||
/// off (decision 1744).
|
||||
item_id: u32,
|
||||
/// The wire's per-row [`slot_type`]. `MASTER` diverts the click to the master-loot
|
||||
/// dropdown instead of a take (decision 1675).
|
||||
slot_type: u8,
|
||||
@@ -218,6 +239,7 @@ impl LootState {
|
||||
self.items = items;
|
||||
self.taken.clear();
|
||||
self.auto_release = false; // empty-at-open stays open — only a removal auto-closes
|
||||
self.pending_bind_confirm = None; // ref `0x4c1df5`: the copier resets the stash to -1
|
||||
self.fishing = loot_type == benilla_protocol::messages::loot_type::FISHING;
|
||||
// The master-loot candidate list arrives just AHEAD of this response (the server sends it
|
||||
// from inside `SendLoot`), so the window claims whatever was staged and leaves the staging
|
||||
@@ -300,6 +322,7 @@ impl LootState {
|
||||
self.taken.clear();
|
||||
self.auto_release = false;
|
||||
self.fishing = false;
|
||||
self.pending_bind_confirm = None;
|
||||
self.master_candidates.clear();
|
||||
self.pending_master_candidates.clear();
|
||||
}
|
||||
@@ -348,6 +371,7 @@ impl LootState {
|
||||
(!self.taken.contains(&it.slot)).then_some(LootAction::Item {
|
||||
wire_slot: it.slot,
|
||||
display_id: it.display_info_id,
|
||||
item_id: it.item_id,
|
||||
slot_type: it.slot_type,
|
||||
})
|
||||
}
|
||||
@@ -955,6 +979,7 @@ fn feed_loot(
|
||||
// the existing last-row auto-release.
|
||||
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||||
if cfg.auto_loot != shift {
|
||||
let mut bind_confirm_fired = false;
|
||||
for index in 1..=snap.rows.len() as u32 {
|
||||
match loot.action_at(index) {
|
||||
Some(LootAction::Money) => {
|
||||
@@ -969,8 +994,27 @@ fn feed_loot(
|
||||
Some(LootAction::Item {
|
||||
wire_slot,
|
||||
display_id,
|
||||
item_id,
|
||||
slot_type,
|
||||
}) if slot_type == slot_type::ALLOW_LOOT => {
|
||||
// The sweep carries the same bind gate as a hand click, plus a
|
||||
// ONE-SHOT latch (`0x4c21c2 test ebx,ebx; jne` → the loop's continue,
|
||||
// `0x4c21e2 mov ebx,1`; decision 1744): the first bind-on-pickup row
|
||||
// raises the confirm, and every later one in the same sweep is left
|
||||
// in the window untouched — not taken, not asked about. Otherwise a
|
||||
// three-blue corpse would stack three dialogs over one pending slot.
|
||||
if bind_confirm_required(&mut items, &commands, item_id) {
|
||||
if bind_confirm_fired {
|
||||
continue;
|
||||
}
|
||||
loot.pending_bind_confirm = Some(index);
|
||||
script.fire_event(
|
||||
"LOOT_BIND_CONFIRM",
|
||||
vec![ScriptValue::Int(i64::from(index))],
|
||||
);
|
||||
bind_confirm_fired = true;
|
||||
continue;
|
||||
}
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::AutostoreLootItem { slot: wire_slot });
|
||||
@@ -1001,12 +1045,30 @@ fn feed_loot(
|
||||
*last = fresh;
|
||||
}
|
||||
|
||||
/// Whether taking this row must first raise `LOOT_BIND_CONFIRM` — the reference's two-conjunct
|
||||
/// gate at `0x4c28f2`/`0x4c28fb` (decision 1744). An unresolved template answers **false**: the
|
||||
/// reference peeks its own item cache here and cannot ask, and a row whose template has not landed
|
||||
/// has no name on it either, so it is not a row anyone has clicked. Asking (rather than peeking)
|
||||
/// costs nothing — the entry is already in flight from the snapshot — and keeps the answer right
|
||||
/// for the next click if one somehow arrives first.
|
||||
fn bind_confirm_required(items: &mut Items, commands: &NetCommands, item_id: u32) -> bool {
|
||||
items
|
||||
.template(item_id, 0, commands)
|
||||
.is_some_and(|t| t.bonding == BIND_WHEN_PICKED_UP && t.quality >= BIND_CONFIRM_MIN_QUALITY)
|
||||
}
|
||||
|
||||
/// Drain the Lua intents: a picked row → coin (`CMSG_LOOT_MONEY`) or the item's wire slot
|
||||
/// (`CMSG_AUTOSTORE_LOOT_ITEM`); a close → `CMSG_LOOT_RELEASE` + a client-authoritative local clear
|
||||
/// (the window is already hidden by its `OnHide`; the release is fire-and-forget, and the server's
|
||||
/// `SMSG_LOOT_RELEASE_RESPONSE` clears again idempotently). Also fires the **last-row auto-close**
|
||||
/// ([`LootState::auto_release`]): the client, not the server, releases when a removal empties the
|
||||
/// window — vmangos only ever releases in answer to our `CMSG_LOOT_RELEASE`.
|
||||
///
|
||||
/// **This function is the reference's take dispatcher `0x4c2790(slot, flag)`** (decision 1744), and
|
||||
/// the two Lua verbs are its two flags: `BenillaTakeLootSlot` is the row click (`flag == 0`, the C
|
||||
/// `CLootButton`'s arm) and `LootSlot` is the LOOT_BIND confirmation continuation (`flag == 1`,
|
||||
/// which sends only for the pending slot). Keeping them apart is what makes a second click on a
|
||||
/// bind-on-pickup row re-raise the confirm instead of looting behind it.
|
||||
fn drain_loot(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut loot: ResMut<LootState>,
|
||||
@@ -1015,6 +1077,10 @@ fn drain_loot(
|
||||
mut pickup: MessageWriter<crate::sound::LootPickupSound>,
|
||||
// The candidate placement is raid-shaped, so resolving a clicked menu index needs the roster.
|
||||
group: Res<GroupState>,
|
||||
// The bind-on-pickup deferral reads the row's template (`bonding`, `quality`). Already cached
|
||||
// by then in every reachable case — the snapshot asks for it to put a NAME on the row, and a
|
||||
// row with no name is a row nobody has clicked.
|
||||
mut items: ResMut<Items>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -1042,8 +1108,24 @@ fn drain_loot(
|
||||
Some(LootAction::Item {
|
||||
wire_slot,
|
||||
display_id,
|
||||
item_id,
|
||||
..
|
||||
}) => {
|
||||
// The bind-on-pickup deferral (`0x4c28f2`-`0x4c2920`, decision 1744). Two
|
||||
// conjuncts and no others: the template's `bonding == BIND_WHEN_PICKED_UP` AND its
|
||||
// `quality >= 2` (uncommon or better) — a grey or white BoP row is taken with no
|
||||
// confirm at all, which is why picking up a quest trinket never asks. The event
|
||||
// carries the row out, the row is stashed, and NOTHING is sent; not even the
|
||||
// pickup sound, which the reference plays only on the arm that actually sends.
|
||||
if bind_confirm_required(&mut items, &commands, item_id) {
|
||||
debug!("ui_loot: row {index} (wire {wire_slot}) binds on pickup — confirming");
|
||||
loot.pending_bind_confirm = Some(index);
|
||||
script.fire_event(
|
||||
"LOOT_BIND_CONFIRM",
|
||||
vec![ScriptValue::Int(i64::from(index))],
|
||||
);
|
||||
continue;
|
||||
}
|
||||
debug!("ui_loot: autostore row {index} (wire slot {wire_slot})");
|
||||
let _ = commands
|
||||
.0
|
||||
@@ -1052,9 +1134,40 @@ fn drain_loot(
|
||||
// `acquire-spend-sounds.md`): looting an item plays its ItemGroupSounds kit[0].
|
||||
pickup.write(crate::sound::LootPickupSound { display_id });
|
||||
}
|
||||
None => debug!("ui_loot: LootSlot({index}) out of range — ignored"),
|
||||
None => debug!("ui_loot: BenillaTakeLootSlot({index}) out of range — ignored"),
|
||||
}
|
||||
}
|
||||
|
||||
// `LootSlot(slot)` — the confirm continuation (`0x4c2790`'s `flag != 0` arm, `0x4c27c0`). It
|
||||
// sends for exactly one slot: the one the pending confirm names. Anything else is dropped in
|
||||
// silence, which is what the reference does with an addon that calls `LootSlot` on an ordinary
|
||||
// row. The stash clears on the send (`0x4c281a`), so a doubled OnAccept cannot loot twice.
|
||||
for index in script.take_loot_confirms() {
|
||||
if loot.pending_bind_confirm != Some(index) {
|
||||
debug!("ui_loot: LootSlot({index}) is not the pending bind confirm — ignored");
|
||||
continue;
|
||||
}
|
||||
let Some(LootAction::Item {
|
||||
wire_slot,
|
||||
display_id,
|
||||
..
|
||||
}) = loot.action_at(index)
|
||||
else {
|
||||
// The row went away under the open dialog (someone else took it, the window turned
|
||||
// over). The reference bails the same way — its continuation re-reads the record and
|
||||
// returns on an empty itemId (`0x4c27d7`) — and, like it, WITHOUT clearing the stash:
|
||||
// the clear is on the send path alone (`0x4c281a`), and the window turning over is
|
||||
// what drops a stash that never completed.
|
||||
debug!("ui_loot: bind confirm for row {index} — the row is gone, nothing sent");
|
||||
continue;
|
||||
};
|
||||
loot.pending_bind_confirm = None;
|
||||
debug!("ui_loot: bind-confirmed row {index} (wire slot {wire_slot})");
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::AutostoreLootItem { slot: wire_slot });
|
||||
pickup.write(crate::sound::LootPickupSound { display_id });
|
||||
}
|
||||
// The master looter's assignments (`GiveMasterLoot(slot, candidateIndex)`): the Lua hands two
|
||||
// 1-based display numbers, the app turns them into the wire slot and the recipient guid. Both
|
||||
// must resolve — a stale row or a candidate index past the end of the list is dropped here
|
||||
@@ -1121,8 +1234,10 @@ mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::loot_type;
|
||||
use benilla_protocol::messages::GroupMemberEntry;
|
||||
use benilla_protocol::messages::ItemInfo;
|
||||
use benilla_protocol::messages::ObjectFields;
|
||||
use benilla_protocol::EntityKind;
|
||||
use bevy::ecs::system::RunSystemOnce;
|
||||
|
||||
/// An empty group + name cache — what every loot test that is not about master loot wants:
|
||||
/// no candidates resolve, and a snapshot's `master_candidates` comes out empty. Master-loot
|
||||
@@ -1207,6 +1322,341 @@ mod tests {
|
||||
assert!(!app.world().resource::<LootKneel>().0);
|
||||
}
|
||||
|
||||
// ── The soulbind confirm (decision 1744) ──────────────────────────────────────────────────
|
||||
//
|
||||
// Real 1.12 `item_template` rows, read from the running vmangos rather than invented, so the
|
||||
// two conjuncts are exercised against numbers the server actually ships:
|
||||
// 12590 Felstriker quality 4, bonding 1 — BoP epic: confirms
|
||||
// 871 Flurry Axe quality 4, bonding 2 — BoE epic: takes, the bonding control
|
||||
// 117 Tough Jerky quality 1, bonding 0 — plain white: takes
|
||||
// and one synthetic that the database has no clean example of at this quality:
|
||||
// 9999 a white BoP quality 1, bonding 1 — the QUALITY control, which takes.
|
||||
const FELSTRIKER: u32 = 12590;
|
||||
const FLURRY_AXE: u32 = 871;
|
||||
const TOUGH_JERKY: u32 = 117;
|
||||
const WHITE_BOP: u32 = 9999;
|
||||
/// A second BoP epic, for the auto-loot sweep's one-dialog latch (18832 Brutality Blade).
|
||||
const SECOND_BOP: u32 = 18832;
|
||||
|
||||
/// A world with `rows` open on a corpse, every template already landed, and `lua` run against
|
||||
/// the loot bindings. Returns the app and the wire's receiver so a test can read what was
|
||||
/// actually sent — the whole point of the arc being that a confirm sends **nothing**.
|
||||
fn drain_with(
|
||||
gold: u32,
|
||||
rows: Vec<LootItem>,
|
||||
lua: &str,
|
||||
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.add_message::<crate::sound::LootPickupSound>()
|
||||
.init_resource::<LootState>()
|
||||
.init_resource::<LootLatch>()
|
||||
.init_resource::<GroupState>()
|
||||
.init_resource::<Items>()
|
||||
.insert_resource(NetCommands(tx));
|
||||
|
||||
let mut items = app.world_mut().resource_mut::<Items>();
|
||||
let mut tmpl = |entry: u32, name: &str, quality: u32, bonding: u32| {
|
||||
items.insert_template(
|
||||
entry,
|
||||
Some(ItemInfo {
|
||||
quality,
|
||||
bonding,
|
||||
..crate::items::test_template(name)
|
||||
}),
|
||||
);
|
||||
};
|
||||
tmpl(FELSTRIKER, "Felstriker", 4, 1);
|
||||
tmpl(FLURRY_AXE, "Flurry Axe", 4, 2);
|
||||
tmpl(TOUGH_JERKY, "Tough Jerky", 1, 0);
|
||||
tmpl(WHITE_BOP, "A White Soulbound Thing", 1, 1);
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<LootState>()
|
||||
.open(0x42, loot_type::CORPSE, gold, rows);
|
||||
|
||||
let script = UiScript::new().unwrap();
|
||||
// A listener, so the event is observed where the real dialog driver sits rather than
|
||||
// through a test-only back door.
|
||||
script
|
||||
.run(
|
||||
"BIND_CONFIRMS = {}\n\
|
||||
local f = CreateFrame(\"Frame\")\n\
|
||||
f:RegisterEvent(\"LOOT_BIND_CONFIRM\")\n\
|
||||
f:SetScript(\"OnEvent\", function() tinsert(BIND_CONFIRMS, arg1) end)",
|
||||
)
|
||||
.unwrap();
|
||||
script.run(lua).unwrap();
|
||||
app.insert_non_send_resource(script);
|
||||
app.world_mut().run_system_once(drain_loot).unwrap();
|
||||
(app, rx)
|
||||
}
|
||||
|
||||
/// The rows `LOOT_BIND_CONFIRM` has named so far, in order.
|
||||
fn confirms(app: &mut App) -> Vec<i64> {
|
||||
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
let n = script.eval::<i64>("return getn(BIND_CONFIRMS)").unwrap();
|
||||
(1..=n)
|
||||
.map(|i| {
|
||||
script
|
||||
.eval::<i64>(&format!("return BIND_CONFIRMS[{i}]"))
|
||||
.unwrap()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Everything the wire saw, in order.
|
||||
fn sent(rx: &crossbeam_channel::Receiver<ClientCommand>) -> Vec<ClientCommand> {
|
||||
rx.try_iter().collect()
|
||||
}
|
||||
|
||||
/// Which row the app is holding a confirm open for.
|
||||
fn pending_confirm(app: &App) -> Option<u32> {
|
||||
app.world().resource::<LootState>().pending_bind_confirm
|
||||
}
|
||||
|
||||
/// The click arm's deferral (`0x4c28f2`-`0x4c2920`): a BoP uncommon-or-better row fires
|
||||
/// `LOOT_BIND_CONFIRM` with its row, stashes the row, and sends **nothing** — not the
|
||||
/// autostore, and not the pickup sound either, which the reference plays only on the arm that
|
||||
/// sends.
|
||||
#[test]
|
||||
fn a_bop_row_confirms_instead_of_sending() {
|
||||
let (mut app, rx) = drain_with(0, vec![item(0, FELSTRIKER, 1)], "BenillaTakeLootSlot(1)");
|
||||
assert!(sent(&rx).is_empty(), "the deferred take sends nothing");
|
||||
assert_eq!(pending_confirm(&app), Some(1), "and stashes the row");
|
||||
|
||||
assert_eq!(
|
||||
confirms(&mut app),
|
||||
vec![1],
|
||||
"the event carries the row out, 1-based and display-side"
|
||||
);
|
||||
}
|
||||
|
||||
/// The confirm arm (`0x4c27c0`): `LootSlot` on the pending row sends the autostore for that
|
||||
/// row's WIRE slot and clears the stash — so a doubled OnAccept cannot loot twice.
|
||||
#[test]
|
||||
fn loot_slot_completes_the_pending_confirm_exactly_once() {
|
||||
let (mut app, rx) = drain_with(
|
||||
0,
|
||||
vec![item(7, FELSTRIKER, 1)],
|
||||
"BenillaTakeLootSlot(1) LootSlot(1)",
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
sent(&rx)[..],
|
||||
[ClientCommand::AutostoreLootItem { slot: 7 }]
|
||||
),
|
||||
"the accept sends the row's wire slot, once"
|
||||
);
|
||||
assert_eq!(pending_confirm(&app), None, "and the stash clears");
|
||||
|
||||
// A second accept over the same dialog: nothing left to complete.
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run("LootSlot(1)")
|
||||
.unwrap();
|
||||
app.world_mut().run_system_once(drain_loot).unwrap();
|
||||
assert!(sent(&rx).is_empty(), "a second accept sends nothing");
|
||||
}
|
||||
|
||||
/// `LootSlot` on any row that is NOT the pending confirm does nothing at all — which is
|
||||
/// exactly what it does on the real client, where the flag-1 arm opens `cmp edi,[0x847cec]`.
|
||||
/// This is the property that makes the two-verb split worth having.
|
||||
#[test]
|
||||
fn loot_slot_on_an_ordinary_row_sends_nothing() {
|
||||
let (_app, rx) = drain_with(
|
||||
0,
|
||||
vec![item(0, TOUGH_JERKY, 1), item(1, FLURRY_AXE, 1)],
|
||||
"LootSlot(1) LootSlot(2)",
|
||||
);
|
||||
assert!(
|
||||
sent(&rx).is_empty(),
|
||||
"no confirm is pending, so neither call reaches the wire"
|
||||
);
|
||||
}
|
||||
|
||||
/// Both conjuncts are load-bearing, and each fails alone. A bind-on-EQUIP epic and a
|
||||
/// bind-on-pickup WHITE are both taken outright, with no dialog — the second is why looting a
|
||||
/// grey quest trinket never asks.
|
||||
#[test]
|
||||
fn the_bind_gate_needs_both_conjuncts() {
|
||||
for (entry, why) in [
|
||||
(FLURRY_AXE, "bind-on-equip is not bind-on-pickup"),
|
||||
(WHITE_BOP, "quality 1 is below the floor of 2"),
|
||||
(TOUGH_JERKY, "neither"),
|
||||
] {
|
||||
let (app, rx) = drain_with(0, vec![item(3, entry, 1)], "BenillaTakeLootSlot(1)");
|
||||
assert!(
|
||||
matches!(
|
||||
sent(&rx)[..],
|
||||
[ClientCommand::AutostoreLootItem { slot: 3 }]
|
||||
),
|
||||
"entry {entry} should be taken outright: {why}"
|
||||
);
|
||||
assert_eq!(
|
||||
pending_confirm(&app),
|
||||
None,
|
||||
"entry {entry}: no confirm ({why})"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The stash is display-side and coin-aware. The reference's own event argument is the item
|
||||
/// ARRAY index plus one — it is written after the coin-row shift (`0x4c2885 dec edi`) and never
|
||||
/// shifted back — so on a corpse with gold its `arg1` is one below the display row. benilla
|
||||
/// speaks the display row on both halves of the round trip instead (1744), which is what makes
|
||||
/// `GetLootSlotInfo(arg1)` mean what it looks like it means.
|
||||
#[test]
|
||||
fn the_coin_row_does_not_shift_the_confirm_out_from_under_itself() {
|
||||
let (mut app, rx) = drain_with(
|
||||
120, // gold, so display row 1 is the coin and the item is row 2
|
||||
vec![item(4, FELSTRIKER, 1)],
|
||||
"BenillaTakeLootSlot(2)",
|
||||
);
|
||||
assert_eq!(
|
||||
pending_confirm(&app),
|
||||
Some(2),
|
||||
"the DISPLAY row, coin included"
|
||||
);
|
||||
assert_eq!(confirms(&mut app), vec![2]);
|
||||
assert!(sent(&rx).is_empty());
|
||||
|
||||
// And the accept, with the same number, reaches the right WIRE slot.
|
||||
app.world_mut()
|
||||
.non_send_resource_mut::<UiScript>()
|
||||
.run("LootSlot(2)")
|
||||
.unwrap();
|
||||
app.world_mut().run_system_once(drain_loot).unwrap();
|
||||
assert!(matches!(
|
||||
sent(&rx)[..],
|
||||
[ClientCommand::AutostoreLootItem { slot: 4 }]
|
||||
));
|
||||
}
|
||||
|
||||
/// The row vanishes under the open dialog — someone else in the group took it while the
|
||||
/// confirm sat there. The accept sends nothing (`0x4c27d7`: the continuation re-reads the
|
||||
/// record and returns on an empty itemId) and, like the reference, leaves the stash alone: the
|
||||
/// clear lives on the send path only (`0x4c281a`).
|
||||
#[test]
|
||||
fn an_accept_for_a_row_that_was_taken_away_sends_nothing() {
|
||||
let (mut app, rx) = drain_with(0, vec![item(2, FELSTRIKER, 1)], "BenillaTakeLootSlot(1)");
|
||||
assert_eq!(pending_confirm(&app), Some(1));
|
||||
|
||||
// SMSG_LOOT_REMOVED for that wire slot: the row becomes a gap.
|
||||
app.world_mut().resource_mut::<LootState>().remove_slot(2);
|
||||
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
script.run("LootSlot(1)").unwrap();
|
||||
app.world_mut().run_system_once(drain_loot).unwrap();
|
||||
|
||||
assert!(
|
||||
sent(&rx)
|
||||
.iter()
|
||||
.all(|c| !matches!(c, ClientCommand::AutostoreLootItem { .. })),
|
||||
"nothing is autostored for a row that is gone"
|
||||
);
|
||||
}
|
||||
|
||||
/// A pending confirm dies with the window that raised it (`0x4c1df5`: the `SMSG_LOOT_RESPONSE`
|
||||
/// copier resets the stash to -1). Its row number would name a slot in the NEXT corpse.
|
||||
#[test]
|
||||
fn a_pending_confirm_does_not_survive_the_window() {
|
||||
let (mut app, _rx) = drain_with(0, vec![item(0, FELSTRIKER, 1)], "BenillaTakeLootSlot(1)");
|
||||
assert_eq!(pending_confirm(&app), Some(1));
|
||||
|
||||
app.world_mut().resource_mut::<LootState>().clear();
|
||||
assert_eq!(pending_confirm(&app), None, "the close drops it");
|
||||
|
||||
app.world_mut().resource_mut::<LootState>().open(
|
||||
0x43,
|
||||
loot_type::CORPSE,
|
||||
0,
|
||||
vec![item(0, TOUGH_JERKY, 1)],
|
||||
);
|
||||
assert_eq!(
|
||||
pending_confirm(&app),
|
||||
None,
|
||||
"and a fresh window opens with none"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The auto-loot sweep's one-shot latch** (`0x4c21c2 test ebx,ebx; jne` → the loop's
|
||||
/// continue, `0x4c21e2 mov ebx,1`; decision 1744). A corpse with two bind-on-pickup blues and
|
||||
/// a white: the sweep takes the white, raises ONE dialog for the first blue, and leaves the
|
||||
/// second blue in the window untouched — not taken, not asked about. Without the latch it
|
||||
/// would stack two dialogs over a single pending slot, and the second would name a row the
|
||||
/// stash no longer holds.
|
||||
#[test]
|
||||
fn the_auto_loot_sweep_raises_exactly_one_bind_confirm() {
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
let mut app = App::new();
|
||||
app.add_message::<crate::sound::LootPickupSound>()
|
||||
.init_resource::<LootState>()
|
||||
.init_resource::<LootErrors>()
|
||||
.init_resource::<crate::ui_chat::ChatLog>()
|
||||
.init_resource::<GroupState>()
|
||||
.init_resource::<NameCache>()
|
||||
.init_resource::<Items>()
|
||||
.init_resource::<ButtonInput<KeyCode>>()
|
||||
.insert_resource(LootConfig {
|
||||
auto_loot: true,
|
||||
..LootConfig::default()
|
||||
})
|
||||
.insert_resource(NetCommands(tx));
|
||||
|
||||
let mut items = app.world_mut().resource_mut::<Items>();
|
||||
for (entry, name, quality, bonding) in [
|
||||
(FELSTRIKER, "Felstriker", 4, 1),
|
||||
(SECOND_BOP, "Brutality Blade", 4, 1),
|
||||
(TOUGH_JERKY, "Tough Jerky", 1, 0),
|
||||
] {
|
||||
items.insert_template(
|
||||
entry,
|
||||
Some(ItemInfo {
|
||||
quality,
|
||||
bonding,
|
||||
..crate::items::test_template(name)
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Wire slots 5/6/7 in display order: BoP epic, BoP epic, white.
|
||||
app.world_mut().resource_mut::<LootState>().open(
|
||||
0x42,
|
||||
loot_type::CORPSE,
|
||||
0,
|
||||
vec![
|
||||
item(5, FELSTRIKER, 1),
|
||||
item(6, SECOND_BOP, 1),
|
||||
item(7, TOUGH_JERKY, 1),
|
||||
],
|
||||
);
|
||||
let script = UiScript::new().unwrap();
|
||||
script
|
||||
.run(
|
||||
"BIND_CONFIRMS = {}\n\
|
||||
local f = CreateFrame(\"Frame\")\n\
|
||||
f:RegisterEvent(\"LOOT_BIND_CONFIRM\")\n\
|
||||
f:SetScript(\"OnEvent\", function() tinsert(BIND_CONFIRMS, arg1) end)",
|
||||
)
|
||||
.unwrap();
|
||||
app.insert_non_send_resource(script);
|
||||
app.world_mut().run_system_once(feed_loot).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
confirms(&mut app),
|
||||
vec![1],
|
||||
"one dialog, for the FIRST bind-on-pickup row only"
|
||||
);
|
||||
assert_eq!(pending_confirm(&app), Some(1));
|
||||
assert!(
|
||||
matches!(
|
||||
sent(&rx)[..],
|
||||
[ClientCommand::AutostoreLootItem { slot: 7 }]
|
||||
),
|
||||
"the white is swept; neither blue is sent"
|
||||
);
|
||||
}
|
||||
|
||||
fn item(slot: u8, entry: u32, count: u32) -> LootItem {
|
||||
LootItem {
|
||||
slot,
|
||||
|
||||
@@ -758,7 +758,7 @@ fn tab_round_trip_with_a_selected_skill_by_point() {
|
||||
s.eval::<i64>("return GetSelectedSkill()").unwrap() > 0,
|
||||
"the row click selects"
|
||||
);
|
||||
assert!(shown(&mut s, "SkillDetailBar"), "detail bar arms");
|
||||
assert!(shown(&mut s, "SkillDetailStatusBar"), "detail bar arms");
|
||||
// The description body renders through the SKILL_DESCRIPTION format (skillType is "" in every
|
||||
// reachable 1.12 branch) — the nil-global half of the report, pinned.
|
||||
assert_eq!(
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
//! world-drop's `DELETE_ITEM_CONFIRM` showing the ref's `DELETE_ITEM` StaticPopup entry (decision
|
||||
//! 0308 §3's engine) with the real `DELETE_ITEM`/`YES`/`NO` GlobalStrings, its Yes/No/ESC routing
|
||||
//! to `DeleteCursorItem`/`ClearCursor`, and the entry's own `OnUpdate` auto-hide poll.
|
||||
//!
|
||||
//! Plus the `arg2 >= 3` fork the driver grew in 1743 (ref `UIParent.lua:344-352`): a RARE-or-better
|
||||
//! payload raises `DELETE_GOOD_ITEM` instead, whose OKAY stays disabled until the player types
|
||||
//! `DELETE_ITEM_CONFIRM_STRING` into its edit box. The two fixtures are real 1.12 rows (vmangos
|
||||
//! `item_template`): **Tough Jerky** 117, quality 1 — the plain arm; **Flurry Axe** 871, quality 4 —
|
||||
//! the typed arm.
|
||||
|
||||
use benilla_ui::script::{ContainerSlot, ContainerState, UiScript};
|
||||
|
||||
@@ -25,9 +31,10 @@ fn load_xml(s: &UiScript, file: &str) -> usize {
|
||||
report.frames
|
||||
}
|
||||
|
||||
/// A one-item, one-slot backpack: a quality-3 item (`Tough Jerky`) so the confirm text and the
|
||||
/// wire's destroy count are exercisable end to end.
|
||||
fn one_item_backpack() -> ContainerState {
|
||||
/// A one-item, one-slot backpack holding `item_id`/`name` at `quality`, so the confirm text and
|
||||
/// the wire's destroy count are exercisable end to end. Quality is what forks the driver, so it is
|
||||
/// the fixture's only real parameter.
|
||||
fn one_item_backpack(item_id: u32, name: &str, quality: u32) -> ContainerState {
|
||||
let mut slots = std::collections::HashMap::new();
|
||||
slots.insert(
|
||||
1,
|
||||
@@ -38,9 +45,9 @@ fn one_item_backpack() -> ContainerState {
|
||||
durability: None,
|
||||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||||
count: 5,
|
||||
quality: Some(3),
|
||||
item_id: 117,
|
||||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||||
quality: Some(quality),
|
||||
item_id,
|
||||
link: Some(format!("|cffffffff|Hitem:{item_id}|h[{name}]|h|r")),
|
||||
locked: false,
|
||||
equip_slots: Vec::new(),
|
||||
cooldown: None,
|
||||
@@ -74,7 +81,13 @@ fn setup() -> UiScript {
|
||||
/// release, both over nothing; 0218's byte-verified trigger) fires the world-drop
|
||||
/// `DELETE_ITEM_CONFIRM(name, quality)` the driver listens for.
|
||||
fn pick_up_and_drop_in_world(s: &mut UiScript) {
|
||||
s.set_container(0, Some(one_item_backpack()));
|
||||
drop_in_world(s, 117, "Tough Jerky", 1);
|
||||
}
|
||||
|
||||
/// The same world drop for an arbitrary fixture item — 1743's fork reads `arg2` (the quality), so
|
||||
/// every test below picks the arm it wants by choosing the item.
|
||||
fn drop_in_world(s: &mut UiScript, item_id: u32, name: &str, quality: u32) {
|
||||
s.set_container(0, Some(one_item_backpack(item_id, name, quality)));
|
||||
s.run("PickupContainerItem(0, 1)").unwrap();
|
||||
assert!(s.cursor_item().is_some(), "fixture: the item is held");
|
||||
// Off past every frame (the bag sits bottom-right; off-screen negative is always clear).
|
||||
@@ -218,3 +231,202 @@ fn the_delete_entry_polls_itself_hidden_and_other_dialogs_are_untouched() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The RARE-or-better fork (ref `UIParent.lua:346-350`, `arg2 >= 3`): a quality-4 payload raises
|
||||
/// `DELETE_GOOD_ITEM`, not `DELETE_ITEM` — with the second GlobalString, the edit box up and
|
||||
/// focused, and OKAY **disabled** until the confirm word is typed.
|
||||
#[test]
|
||||
fn a_rare_payload_raises_the_typed_confirm_with_okay_disabled() {
|
||||
let mut s = setup();
|
||||
drop_in_world(&mut s, 871, "Flurry Axe", 4);
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1.which").unwrap(),
|
||||
"DELETE_GOOD_ITEM",
|
||||
"quality 4 forks to the typed-confirmation variant"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1Text:GetText()")
|
||||
.unwrap(),
|
||||
"Do you want to destroy Flurry Axe?\n\nType \"DELETE\" into the field to confirm.",
|
||||
"the real GlobalStrings DELETE_GOOD_ITEM text, formatted with the item name"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return StaticPopup1EditBox:IsShown()")
|
||||
.unwrap(),
|
||||
"hasEditBox raises the narrow box"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return StaticPopup1EditBox:HasFocus()")
|
||||
.unwrap(),
|
||||
"the entry's OnShow focuses the box, so the player can type straight away"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return StaticPopup1Button1:IsEnabled()")
|
||||
.unwrap(),
|
||||
0,
|
||||
"OKAY starts disabled — nothing has been typed yet"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The typed gate itself (ref `EditBoxOnTextChanged`, `StaticPopup.lua:718-723`): only the exact
|
||||
/// `DELETE_ITEM_CONFIRM_STRING` enables OKAY, it is compared through `strupper` so lower case
|
||||
/// passes, and backing away from the word disables it again. Then OKAY destroys.
|
||||
#[test]
|
||||
fn typing_the_confirm_word_enables_okay_and_untyping_it_disables_again() {
|
||||
let mut s = setup();
|
||||
drop_in_world(&mut s, 871, "Flurry Axe", 4);
|
||||
|
||||
let enabled = |s: &mut UiScript| {
|
||||
s.eval::<i64>("return StaticPopup1Button1:IsEnabled()")
|
||||
.unwrap()
|
||||
== 1
|
||||
};
|
||||
|
||||
s.run(r#"StaticPopup1EditBox:SetText("DELET")"#).unwrap();
|
||||
assert!(!enabled(&mut s), "a prefix of the word is not the word");
|
||||
|
||||
// Typed character by character through the engine's own input path, lower case: the ref
|
||||
// compares through strupper, so this arms OKAY exactly as shouting it would.
|
||||
s.run(r#"StaticPopup1EditBox:SetText("")"#).unwrap();
|
||||
for c in ["d", "e", "l", "e", "t", "e"] {
|
||||
assert!(s.char_input(c), "the focused box takes the keystroke");
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1EditBox:GetText()")
|
||||
.unwrap(),
|
||||
"delete"
|
||||
);
|
||||
assert!(
|
||||
enabled(&mut s),
|
||||
"the ref compares through strupper, so lower case passes"
|
||||
);
|
||||
|
||||
s.run(r#"StaticPopup1EditBox:SetText("deletex")"#).unwrap();
|
||||
assert!(!enabled(&mut s), "one char past the word disables again");
|
||||
|
||||
s.run(r#"StaticPopup1EditBox:SetText("DELETE")"#).unwrap();
|
||||
assert!(enabled(&mut s));
|
||||
|
||||
// OKAY now does what the plain arm's does: DeleteCursorItem().
|
||||
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"OKAY hides the popup"
|
||||
);
|
||||
assert!(s.cursor_item().is_none(), "DeleteCursorItem cleared it");
|
||||
assert_eq!(s.take_container_destroys(), vec![(0, 1, 0)]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// Enter in the box is the ref's `EditBoxOnEnterPressed` (`StaticPopup.lua:712-717`): it destroys
|
||||
/// only while OKAY is enabled, so a reflexive Enter over a half-typed word does nothing at all.
|
||||
#[test]
|
||||
fn enter_in_the_box_destroys_only_once_okay_is_enabled() {
|
||||
let mut s = setup();
|
||||
drop_in_world(&mut s, 871, "Flurry Axe", 4);
|
||||
|
||||
s.run(r#"StaticPopup1EditBox:SetText("DEL")"#).unwrap();
|
||||
assert!(s.key_input("ENTER"), "the focused box consumes ENTER");
|
||||
assert!(
|
||||
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"Enter with OKAY disabled is inert — the dialog stays up"
|
||||
);
|
||||
assert!(s.cursor_item().is_some(), "and the item is still held");
|
||||
assert!(s.take_container_destroys().is_empty());
|
||||
|
||||
s.run(r#"StaticPopup1EditBox:SetText("DELETE")"#).unwrap();
|
||||
assert!(s.key_input("ENTER"));
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"Enter with OKAY enabled destroys and hides"
|
||||
);
|
||||
assert!(s.cursor_item().is_none());
|
||||
assert_eq!(s.take_container_destroys(), vec![(0, 1, 0)]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// NO on the typed variant is the plain one's cancel — `ClearCursor()`, no wire send — and the
|
||||
/// entry's `OnHide` empties the box so the next raise opens blank (the ref's own reason: a word
|
||||
/// left over from last time would be armed before the player read the dialog).
|
||||
#[test]
|
||||
fn no_on_the_typed_confirm_clears_and_leaves_the_box_empty_for_next_time() {
|
||||
let mut s = setup();
|
||||
drop_in_world(&mut s, 871, "Flurry Axe", 4);
|
||||
s.run(r#"StaticPopup1EditBox:SetText("DELETE")"#).unwrap();
|
||||
|
||||
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
|
||||
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
||||
assert!(s.cursor_item().is_none(), "ClearCursor cleared it");
|
||||
assert!(s.take_container_destroys().is_empty(), "No never destroys");
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1EditBox:GetText()")
|
||||
.unwrap(),
|
||||
"",
|
||||
"OnHide empties the box"
|
||||
);
|
||||
|
||||
// Raise it again: blank box, OKAY disabled — the armed state did not survive.
|
||||
drop_in_world(&mut s, 871, "Flurry Axe", 4);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1EditBox:GetText()")
|
||||
.unwrap(),
|
||||
""
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return StaticPopup1Button1:IsEnabled()")
|
||||
.unwrap(),
|
||||
0,
|
||||
"and OKAY is disabled again"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// ESC out of the focused box: 1743's named divergence. The reference's `DELETE_GOOD_ITEM` names
|
||||
/// no `EditBoxOnEscapePressed`, so in 1.12 the focused box swallows the key and the destroy
|
||||
/// confirm cannot be dismissed with ESC at all. benilla's engine falls back to the ordinary
|
||||
/// hideOnEscape leg — the entry's `OnCancel` (`ClearCursor()`), then hide — so the item is
|
||||
/// released rather than left held under a dialog that will not close.
|
||||
#[test]
|
||||
fn escape_out_of_the_typed_confirm_cancels_the_way_every_other_popup_does() {
|
||||
let mut s = setup();
|
||||
drop_in_world(&mut s, 871, "Flurry Axe", 4);
|
||||
assert!(s
|
||||
.eval::<bool>("return StaticPopup1EditBox:HasFocus()")
|
||||
.unwrap());
|
||||
|
||||
assert!(s.key_input("ESCAPE"), "the focused box consumes ESCAPE");
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"ESC closes it"
|
||||
);
|
||||
assert!(s.cursor_item().is_none(), "and runs OnCancel's ClearCursor");
|
||||
assert!(s.take_container_destroys().is_empty(), "ESC never destroys");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The plain arm keeps its own edit box HIDDEN — the fork is not cosmetic, and a common-quality
|
||||
/// destroy must not grow a field to type into. The control for every test above.
|
||||
#[test]
|
||||
fn the_plain_arm_shows_no_edit_box() {
|
||||
let mut s = setup();
|
||||
pick_up_and_drop_in_world(&mut s); // Tough Jerky, quality 1
|
||||
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1.which").unwrap(),
|
||||
"DELETE_ITEM"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1EditBox:IsShown()")
|
||||
.unwrap(),
|
||||
"no hasEditBox on the plain entry"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return StaticPopup1Button1:IsEnabled()")
|
||||
.unwrap(),
|
||||
1,
|
||||
"and YES is live immediately"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -979,3 +979,122 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
|
||||
"title + two occupied groups, out of eight blocks"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The soulbind confirm's Lua half** (decision 1744, ref `StaticPopup.lua:601-611` +
|
||||
/// `UIParent.lua:317-323` + `LootFrame.lua:53-54`). `LOOT_BIND_CONFIRM` raises `LOOT_BIND` with the
|
||||
/// real `LOOT_NO_DROP` text and Okay/Cancel; the row rides on `dialog.data`, and Okay hands it back
|
||||
/// through `LootSlot` — the continuation verb, which is the only thing that can complete a deferred
|
||||
/// take. Cancel sends nothing at all, and `LOOT_CLOSED` takes the dialog down with the window.
|
||||
#[test]
|
||||
fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "UIDropDownMenu.xml");
|
||||
load_xml(&s, "LootFrame.xml");
|
||||
s.set_loot(Some(coin_and_two_items()));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
let _ = s.take_loot_picks();
|
||||
|
||||
// The app defers row 2 and says so.
|
||||
s.fire_event(
|
||||
"LOOT_BIND_CONFIRM",
|
||||
vec![benilla_ui::script::ScriptValue::Int(2)],
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"LOOT_BIND_CONFIRM raises the dialog"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1.which").unwrap(),
|
||||
"LOOT_BIND"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1Text:GetText()")
|
||||
.unwrap(),
|
||||
"Looting this item will bind it to you.",
|
||||
"the real GlobalStrings LOOT_NO_DROP, which carries no format specifier"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1Button1:GetText()")
|
||||
.unwrap(),
|
||||
"Okay"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return StaticPopup1Button2:GetText()")
|
||||
.unwrap(),
|
||||
"Cancel"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return StaticPopup1.data").unwrap(),
|
||||
2,
|
||||
"the row rides on dialog.data, not in the text"
|
||||
);
|
||||
|
||||
// Cancel: no continuation, and nothing reaches either queue.
|
||||
s.run("StaticPopup_OnClick(StaticPopup1, 2)").unwrap();
|
||||
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
||||
assert!(
|
||||
s.take_loot_confirms().is_empty(),
|
||||
"Cancel completes nothing"
|
||||
);
|
||||
assert!(s.take_loot_picks().is_empty(), "and takes nothing either");
|
||||
|
||||
// Okay: the row goes back out through LootSlot, the continuation verb.
|
||||
s.fire_event(
|
||||
"LOOT_BIND_CONFIRM",
|
||||
vec![benilla_ui::script::ScriptValue::Int(2)],
|
||||
);
|
||||
s.run("StaticPopup_OnClick(StaticPopup1, 1)").unwrap();
|
||||
assert!(!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
||||
assert_eq!(s.take_loot_confirms(), vec![2]);
|
||||
assert!(
|
||||
s.take_loot_picks().is_empty(),
|
||||
"the accept is a continuation, never a fresh take"
|
||||
);
|
||||
|
||||
// The window closing takes an open dialog with it (ref LootFrame.lua l.53-54) — its row number
|
||||
// would name a slot in the next corpse.
|
||||
s.fire_event(
|
||||
"LOOT_BIND_CONFIRM",
|
||||
vec![benilla_ui::script::ScriptValue::Int(2)],
|
||||
);
|
||||
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
||||
s.fire_event("LOOT_CLOSED", vec![]);
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"LOOT_CLOSED hides the confirm"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The row click is `BenillaTakeLootSlot`, not `LootSlot` — a real mouse click on a row must land
|
||||
/// in the TAKE queue, or every loot would be a continuation nothing is pending for and the window
|
||||
/// would sit there doing nothing (decision 1744).
|
||||
#[test]
|
||||
fn a_row_click_takes_rather_than_continues() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_screen_size(1024.0, 768.0);
|
||||
load_xml(&s, "Fonts.xml");
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "UIDropDownMenu.xml");
|
||||
load_xml(&s, "LootFrame.xml");
|
||||
s.set_loot(Some(coin_and_two_items()));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
let quads = s.extract();
|
||||
|
||||
let (ix, iy) = icon_center(&quads, "INV_Fabric_Wool_01");
|
||||
s.mouse_button(ix, iy, "LeftButton", true);
|
||||
s.mouse_button(ix, iy, "LeftButton", false);
|
||||
assert_eq!(s.take_loot_picks(), vec![2], "the click is a TAKE");
|
||||
assert!(
|
||||
s.take_loot_confirms().is_empty(),
|
||||
"and never a continuation"
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
@@ -182,13 +182,13 @@ fn a_single_rank_line_paints_gray_with_no_rank_text() {
|
||||
s.run("SetSelectedSkill(2) BenillaSkillFrame_Update()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
s.eval::<String>("return SkillDetailBarSkillRank:GetText() or \"\"")
|
||||
s.eval::<String>("return SkillDetailStatusBarSkillRank:GetText() or \"\"")
|
||||
.unwrap(),
|
||||
"",
|
||||
"the detail pane's own bar is a proficiency too"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<(f32, f32, f32, f32)>("return SkillDetailBarBackground:GetVertexColor()")
|
||||
s.eval::<(f32, f32, f32, f32)>("return SkillDetailStatusBarBackground:GetVertexColor()")
|
||||
.unwrap(),
|
||||
(1.0, 1.0, 1.0, 0.5),
|
||||
"including its trough (the shared PaintBar; ref SkillFrame.lua:376)"
|
||||
@@ -401,11 +401,13 @@ fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
|
||||
);
|
||||
|
||||
let (tab_top, tab_bottom) = s
|
||||
.eval::<(f64, f64)>("return SkillExpandTabLeft:GetTop(), SkillExpandTabLeft:GetBottom()")
|
||||
.eval::<(f64, f64)>(
|
||||
"return SkillFrameExpandTabLeft:GetTop(), SkillFrameExpandTabLeft:GetBottom()",
|
||||
)
|
||||
.unwrap();
|
||||
let (btn_top, btn_bottom, btn_left) = s
|
||||
.eval::<(f64, f64, f64)>(
|
||||
"local b = SkillCollapseAllButton \
|
||||
"local b = SkillFrameCollapseAllButton \
|
||||
return b:GetTop(), b:GetBottom(), b:GetLeft()",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -415,7 +417,7 @@ fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
|
||||
"the fold sits 3px BELOW the cap's centre (ref's -3); +3 above was the bug"
|
||||
);
|
||||
let cap_right = s
|
||||
.eval::<f64>("return SkillExpandTabLeft:GetRight()")
|
||||
.eval::<f64>("return SkillFrameExpandTabLeft:GetRight()")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
btn_left - cap_right,
|
||||
@@ -434,7 +436,7 @@ fn the_collapse_all_fold_wears_the_row_font_and_the_references_seat() {
|
||||
fn the_expand_tab_fits_its_label_once_a_measure_answers() {
|
||||
let mut s = shown_skills_page();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return SkillExpandButtonFrame:GetWidth()")
|
||||
s.eval::<f64>("return SkillFrameExpandButtonFrame:GetWidth()")
|
||||
.unwrap(),
|
||||
54.0,
|
||||
"unmeasured, the declared width stands — a 0 measure must not squash the tab to 45"
|
||||
@@ -444,7 +446,7 @@ fn the_expand_tab_fits_its_label_once_a_measure_answers() {
|
||||
s.run("BenillaSkillFrame_Update()").unwrap();
|
||||
s.resolve();
|
||||
assert_eq!(
|
||||
s.eval::<f64>("return SkillExpandButtonFrame:GetWidth()")
|
||||
s.eval::<f64>("return SkillFrameExpandButtonFrame:GetWidth()")
|
||||
.unwrap(),
|
||||
7.0 * 3.0 + 45.0,
|
||||
"then the ref's own law: the label's width + 45"
|
||||
@@ -452,8 +454,8 @@ fn the_expand_tab_fits_its_label_once_a_measure_answers() {
|
||||
// The middle slab is the span between the two caps, so the fit reaches the art for free.
|
||||
let (mid_l, mid_r, cap_r_l) = s
|
||||
.eval::<(f64, f64, f64)>(
|
||||
"return SkillExpandTabMiddle:GetLeft(), SkillExpandTabMiddle:GetRight(), \
|
||||
SkillExpandTabRight:GetLeft()",
|
||||
"return SkillFrameExpandTabMiddle:GetLeft(), SkillFrameExpandTabMiddle:GetRight(), \
|
||||
SkillFrameExpandTabRight:GetLeft()",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(mid_r, cap_r_l, "the middle stretches to the right cap");
|
||||
|
||||
@@ -140,6 +140,15 @@ impl UiScript {
|
||||
let region = model.arena.region(rh);
|
||||
let owner = region.map(|r| r.owner);
|
||||
let owner_frame = owner.and_then(|o| model.arena.frame(o));
|
||||
// A draw layer switched off with `Frame:DisableDrawLayer` hides every region
|
||||
// the frame owns in it. Skipped HERE rather than by touching the regions
|
||||
// themselves, so each one keeps its own shown state and re-enabling the layer
|
||||
// restores exactly what was visible before.
|
||||
if let (Some(r), Some(f)) = (region, owner_frame) {
|
||||
if f.disabled_layers & (1 << r.draw_layer.index()) != 0 {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Regions clip with their owner frame (decision 0112 §4).
|
||||
let clip = owner.and_then(|o| effective_clip(&model, &scroll_sources, o));
|
||||
let mut rect = owner.and_then(|o| model.resolved.get(&o).copied());
|
||||
|
||||
@@ -13,9 +13,17 @@
|
||||
//! `texture, item, quantity, quality` (`LootFrame.lua:81`), `LootSlotIsItem(slot)` /
|
||||
//! `LootSlotIsCoin(slot)` (`LootFrame.lua:80`), `GetLootSlotLink(slot)` → the row's item link (what
|
||||
//! the row click's ctrl/shift arms read, `LootFrame.lua:149`/`:152` — decision 1059),
|
||||
//! `LootSlot(slot)` (the C `LootButton` behaviour, the action a row click performs —
|
||||
//! `LootFrame.lua:94`), and `CloseLoot()` (fired from `OnHide`, `LootFrame.lua:143-145`).
|
||||
//! `slot` is **1-based**; an out-of-range slot answers `nil`.
|
||||
//! and `CloseLoot()` (fired from `OnHide`, `LootFrame.lua:143-145`). `slot` is **1-based**; an
|
||||
//! out-of-range slot answers `nil`.
|
||||
//!
|
||||
//! ## The take is TWO verbs, not one (decision 1744)
|
||||
//!
|
||||
//! `LootSlot(slot)` looks like "loot row n" and is not: in 1.12 it is the **LOOT_BIND confirmation
|
||||
//! continuation** alone. The C dispatcher `0x4c2790(slot, flag)` takes the row only on `flag == 0`,
|
||||
//! which no Lua binding reaches — the row click is the C `CLootButton`'s own behaviour — while
|
||||
//! `LootSlot 0x4c2e70` passes `flag = 1`, whose arm refuses every slot but the one a bind confirm
|
||||
//! is pending for. benilla has no `CLootButton`, so the click arm is `BenillaTakeLootSlot(slot)`
|
||||
//! and `LootSlot` keeps the reference's meaning exactly.
|
||||
//!
|
||||
//! The **coin pile is a synthesized client-side row** (first in the list when the loot carries gold):
|
||||
//! `LootSlotIsCoin` is true for it, its `item` text is the formatted money amount, and `LootSlot(1)`
|
||||
@@ -118,12 +126,20 @@ impl super::UiScript {
|
||||
self.model_mut().loot = state;
|
||||
}
|
||||
|
||||
/// Drain the 1-based row indices queued by `LootSlot` since the last call. The app maps each to
|
||||
/// either the coin (money) intent or the item's wire loot slot.
|
||||
/// Drain the 1-based row indices queued by `BenillaTakeLootSlot` since the last call — the ROW
|
||||
/// CLICK's take. The app maps each to either the coin (money) intent or the item's wire loot
|
||||
/// slot, and applies the bind-on-pickup deferral (decision 1744).
|
||||
pub fn take_loot_picks(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().loot_picks)
|
||||
}
|
||||
|
||||
/// Drain the 1-based row indices queued by `LootSlot` — the LOOT_BIND confirmation
|
||||
/// continuations. Each is honoured only if it names the row the app is actually holding a
|
||||
/// confirm open for (the reference's `[0x847cec]` gate); anything else is dropped.
|
||||
pub fn take_loot_confirms(&mut self) -> Vec<u32> {
|
||||
std::mem::take(&mut self.model_mut().loot_confirms)
|
||||
}
|
||||
|
||||
/// Whether `CloseLoot` was called since the last drain (and clear the flag). The app maps this to
|
||||
/// `CMSG_LOOT_RELEASE` when a loot is open.
|
||||
pub fn take_loot_close(&mut self) -> bool {
|
||||
@@ -255,12 +271,35 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// LootSlot(slot) — queue the 1-based row pick; the app maps it to the coin or the item wire slot.
|
||||
// BenillaTakeLootSlot(slot) — the ROW CLICK's take, queued as a 1-based display row; the app
|
||||
// maps it to the coin or the item's wire slot and applies the bind-on-pickup gate.
|
||||
//
|
||||
// Why this is not `LootSlot`, and why the name is ours (decision 1744): in 1.12 the plain take
|
||||
// is **not a Lua binding at all**. The dispatcher `0x4c2790(slot, flag)` has exactly two
|
||||
// callers — the C `CLootButton::OnClick 0x4c1820` with `flag = 0`, and the `LootSlot` binding
|
||||
// `0x4c2e70` with `flag = 1` — and only the `flag = 0` arm reaches the take. benilla has no
|
||||
// `CLootButton` widget type (our rows are ordinary XML buttons), so the click arm needs a verb,
|
||||
// and giving it the 1.12 NAME would have handed `LootSlot` a second meaning the reference does
|
||||
// not give it. `Benilla`-prefixed like the other seams the reference kept in C.
|
||||
g.set(
|
||||
"BenillaTakeLootSlot",
|
||||
lua.create_function(|lua, slot: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.loot_picks.push(slot);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// LootSlot(slot) — the LOOT_BIND **confirmation continuation**, and nothing else, exactly as
|
||||
// 1.12 has it (`0x4c2e70`: `luaL_checknumber(1)`, `dec eax`, `0x4c2790(slot, flag = 1)`; the
|
||||
// flag-1 arm at `0x4c27c0` starts `cmp edi, [0x847cec]` and returns unless the slot IS the
|
||||
// pending confirm). So an addon calling `LootSlot(n)` on an ordinary row does nothing here,
|
||||
// which is what it does on the real client; the app owns the pending-slot gate.
|
||||
g.set(
|
||||
"LootSlot",
|
||||
lua.create_function(|lua, slot: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.loot_picks.push(slot);
|
||||
model.loot_confirms.push(slot);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -539,15 +578,31 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_slot_queues_picks() {
|
||||
fn take_loot_slot_queues_picks() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_loot(Some(loot()));
|
||||
s.run("LootSlot(1)").unwrap(); // coin
|
||||
s.run("LootSlot(2)").unwrap(); // item
|
||||
s.run("BenillaTakeLootSlot(1)").unwrap(); // coin
|
||||
s.run("BenillaTakeLootSlot(2)").unwrap(); // item
|
||||
assert_eq!(s.take_loot_picks(), vec![1, 2]);
|
||||
assert!(s.take_loot_picks().is_empty(), "drained");
|
||||
}
|
||||
|
||||
/// `LootSlot` is the confirmation continuation, so it rides its OWN queue — a client that let
|
||||
/// it fall into the pick queue would loot any row an addon named, which the reference refuses
|
||||
/// (decision 1744).
|
||||
#[test]
|
||||
fn loot_slot_queues_confirms_not_picks() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_loot(Some(loot()));
|
||||
s.run("LootSlot(2)").unwrap();
|
||||
assert!(
|
||||
s.take_loot_picks().is_empty(),
|
||||
"LootSlot is not the take verb"
|
||||
);
|
||||
assert_eq!(s.take_loot_confirms(), vec![2]);
|
||||
assert!(s.take_loot_confirms().is_empty(), "drained");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn close_loot_flags_the_intent() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -36,6 +36,114 @@ pub(super) use plain::REG_MESSAGEFRAME_METHODS;
|
||||
pub(super) use scrolling::REG_SCROLLINGMESSAGEFRAME_METHODS;
|
||||
|
||||
/// Install both classes' method tables (and the chat input globals the scrolling one carries).
|
||||
/// Install `SetJustifyH`/`GetJustifyH`/`SetJustifyV`/`GetJustifyV` on a message-frame method
|
||||
/// table. Both classes carry all four (see [`crate::widget::ScrollingMessageState::justify`] for
|
||||
/// the method-table bytes that settled it), and the law they obey is
|
||||
/// [`crate::justify`]'s — shared with FontString, `<Font>` and EditBox since 1237, so the two
|
||||
/// verified traps cannot drift apart per widget:
|
||||
///
|
||||
/// · an **unrecognised token RAISES** `Usage: %s:SetJustifyH("justify")` rather than defaulting;
|
||||
/// · a token from the **other axis** parses and then masks to nothing — `SetJustifyH("TOP")`
|
||||
/// CLEARS the horizontal axis, and `GetJustifyH()` answers `"UNKNOWN"` while the glyphs keep
|
||||
/// drawing at the paint fallback.
|
||||
///
|
||||
/// `GetJustify*` reports the EFFECTIVE value — the frame's own once set, else the font object's —
|
||||
/// so an untouched frame answers what it actually draws with rather than a ctor constant its
|
||||
/// `<FontString>` may have overridden.
|
||||
use crate::script::object::frame_handle_of;
|
||||
|
||||
pub(super) fn install_justify(
|
||||
lua: &mlua::Lua,
|
||||
m: &mlua::Table,
|
||||
widget: &'static str,
|
||||
) -> mlua::Result<()> {
|
||||
use crate::justify::{self, Justify};
|
||||
|
||||
fn slot(ks: &mut KindState) -> Option<&mut Option<Justify>> {
|
||||
match ks {
|
||||
KindState::ScrollingMessage(s) => Some(&mut s.justify),
|
||||
KindState::Message(s) => Some(&mut s.justify),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
fn own(ks: &KindState) -> Option<Justify> {
|
||||
match ks {
|
||||
KindState::ScrollingMessage(s) => s.justify,
|
||||
KindState::Message(s) => s.justify,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
for (name, mask, horizontal) in [
|
||||
("SetJustifyH", justify::H_MASK, true),
|
||||
("SetJustifyV", justify::V_MASK, false),
|
||||
] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, (this, token): (mlua::Table, mlua::Value)| {
|
||||
let parsed = token
|
||||
.as_string()
|
||||
.and_then(|t| t.to_str().ok().and_then(|t| justify::parse_bits(&t)))
|
||||
.ok_or_else(|| {
|
||||
if horizontal {
|
||||
justify::usage_h(widget)
|
||||
} else {
|
||||
justify::usage_v(widget)
|
||||
}
|
||||
})?;
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let frame = model
|
||||
.arena
|
||||
.frame_mut(h)
|
||||
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
|
||||
let cur = own(&frame.kind_state).unwrap_or_default();
|
||||
let next = Justify(justify::set_axis(cur.0, mask, parsed));
|
||||
*slot(&mut frame.kind_state)
|
||||
.ok_or_else(|| mlua::Error::runtime("not a message frame"))? = Some(next);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
// The two getters differ ONLY in what an untouched frame falls back to, and the asymmetry is
|
||||
// real rather than an oversight: horizontally there IS another source — the font object the
|
||||
// lines actually draw with — so reporting anything else would have `GetJustifyH` disagree with
|
||||
// the glyphs on our own chat window, which declares `justifyH="LEFT"` on its `<FontString>`.
|
||||
// Vertically there is no such source: `emit_message_lines` pins each line to the top of its own
|
||||
// band deliberately (the band, not a FontString rect, owns the vertical rhythm), so an untouched
|
||||
// frame reports the ctor default rather than a value invented from our band math.
|
||||
m.set(
|
||||
"GetJustifyH",
|
||||
lua.create_function(move |lua, this: mlua::Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let bits = match model.arena.frame(h).and_then(|f| own(&f.kind_state)) {
|
||||
Some(j) => j.0,
|
||||
None => {
|
||||
let font = UiScript::message_frame_font(&model, h);
|
||||
justify::parse_bits(justify::name_h(font.justify_h)).unwrap_or(0)
|
||||
}
|
||||
};
|
||||
Ok(justify::name_of(bits, justify::H_MASK).to_string())
|
||||
})?,
|
||||
)?;
|
||||
m.set(
|
||||
"GetJustifyV",
|
||||
lua.create_function(move |lua, this: mlua::Table| {
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let j = model
|
||||
.arena
|
||||
.frame(h)
|
||||
.and_then(|f| own(&f.kind_state))
|
||||
.unwrap_or_default();
|
||||
Ok(justify::name_of(j.0, justify::V_MASK).to_string())
|
||||
})?,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn install(lua: &mlua::Lua) -> mlua::Result<()> {
|
||||
scrolling::install(lua)?;
|
||||
plain::install(lua)
|
||||
@@ -330,6 +438,11 @@ impl UiScript {
|
||||
return;
|
||||
}
|
||||
let font = Self::message_frame_font(model, fh);
|
||||
let own_justify = match &frame.kind_state {
|
||||
KindState::ScrollingMessage(s) => s.justify,
|
||||
KindState::Message(s) => s.justify,
|
||||
_ => None,
|
||||
};
|
||||
// The band grid lives in the frame's RESOLVED (scale-multiplied) rect, so the row pitch
|
||||
// rides the frame scale exactly like the glyphs it must coincide with (the font height
|
||||
// itself is frame-local).
|
||||
@@ -373,7 +486,13 @@ impl UiScript {
|
||||
f32::from(line.color[2]) / 255.0,
|
||||
line.alpha,
|
||||
]),
|
||||
justify_h: font.justify_h,
|
||||
// The frame's own justify wins once SetJustifyH has been called; until then
|
||||
// the font object's governs, which is what `justifyH=` on the frame's
|
||||
// `<FontString>` set and what this drew with before the verbs existed.
|
||||
justify_h: match own_justify {
|
||||
Some(j) => j.paint_h(),
|
||||
None => font.justify_h,
|
||||
},
|
||||
// The band is our own per-message construct (rows × pitch tall, not a
|
||||
// FontString rect) — keep the block at its top so the band math, not MIDDLE
|
||||
// centering, owns the vertical rhythm; the band height equals the wrapped
|
||||
|
||||
@@ -64,6 +64,10 @@ fn num_f32(v: &Value) -> f32 {
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
// The justify quartet, shared with the sibling class — see [`super::install_justify`] for the
|
||||
// method-table bytes that put all four on BOTH tables.
|
||||
super::install_justify(lua, &m, "MessageFrame")?;
|
||||
|
||||
// AddMessage(text [, r, g, b [, a]]) — binding 0x795590.
|
||||
//
|
||||
// r/g/b are required **as a trio** (three presence checks ANDed at 0x7956xx); absent ⇒ hard
|
||||
|
||||
@@ -88,6 +88,10 @@ fn scroll(
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let m = lua.create_table()?;
|
||||
|
||||
// The justify quartet, shared with the sibling class — see [`super::install_justify`] for the
|
||||
// method-table bytes that put all four on BOTH tables.
|
||||
super::install_justify(lua, &m, "ScrollingMessageFrame")?;
|
||||
|
||||
// AddMessage(text [, r, g, b [, id]]) — the id (5th arg) is accepted and ignored (v1 has no
|
||||
// per-line UpdateColorByID). rgb absent ⇒ white; the state quantizes round-half-up and forces
|
||||
// the line opaque, then the fade drives its alpha.
|
||||
|
||||
@@ -277,9 +277,12 @@ const REG_DEFAULT_ERRORHANDLER: &str = "__benilla_default_errorhandler";
|
||||
/// Names on **both** region leaves — and each leaf registers its own copy, so these are NOT on the
|
||||
/// Region map and must not be hoisted into it (wow-re
|
||||
/// `system/ui/scratch/texture-fontstring-method-split.md`, stated there as a trap in as many words).
|
||||
/// `GetDrawLayer` is in the client's pair and absent here; absent is absent.
|
||||
pub(crate) const REGION_LEAF_SHARED: [&str; 8] = [
|
||||
/// `GetDrawLayer` is the setter's pair on both leaves (Texture `0x79a6c0`, FontString `0x79c660`)
|
||||
/// and is here now — it was carved as absent, which was true and was a gap, not a decision; pfUI's
|
||||
/// `GetNoNameObject` reads it off every child of a frame it reskins.
|
||||
pub(crate) const REGION_LEAF_SHARED: [&str; 9] = [
|
||||
"SetDrawLayer",
|
||||
"GetDrawLayer",
|
||||
"SetVertexColor",
|
||||
"SetAlpha",
|
||||
"GetAlpha",
|
||||
|
||||
@@ -981,10 +981,21 @@ pub(crate) struct Model {
|
||||
/// drain.
|
||||
pub(crate) craft_close: bool,
|
||||
|
||||
/// The open loot's row snapshot the app pushes (`None` = no loot open), the `LootSlot` row-pick
|
||||
/// intents it drains, and whether `CloseLoot` was called — the loot seam ([`loot`]).
|
||||
/// 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`]).
|
||||
///
|
||||
/// **The picks come in two flavours, and the flavour is load-bearing** (decision 1744): the
|
||||
/// reference's take dispatcher `0x4c2790(slot, flag)` is one function with two entries, and the
|
||||
/// flag decides whether a bind-on-pickup row raises the LOOT_BIND confirm or is taken outright.
|
||||
/// `flag == 0` is the row click, which in 1.12 is the C `CLootButton`'s own behaviour and here
|
||||
/// is `BenillaTakeLootSlot`; `flag != 0` is the confirm continuation, which is the ONLY thing
|
||||
/// the 1.12 Lua binding `LootSlot` does (`0x4c2e70` passes `edx = 1`, and its sole FrameXML
|
||||
/// caller is `StaticPopupDialogs["LOOT_BIND"].OnAccept`).
|
||||
pub(crate) loot: Option<loot::LootState>,
|
||||
pub(crate) loot_picks: Vec<u32>,
|
||||
/// The `LootSlot(slot)` confirm continuations — 1-based display rows, drained separately from
|
||||
/// [`Self::loot_picks`] so the app can honour the reference's pending-slot gate.
|
||||
pub(crate) loot_confirms: Vec<u32>,
|
||||
pub(crate) loot_close: bool,
|
||||
/// The `GiveMasterLoot(slot, candidateIndex)` assignments the app drains — both 1-based and
|
||||
/// both display-side: the row as the window numbers it, and the candidate's position in
|
||||
@@ -1638,6 +1649,7 @@ impl Model {
|
||||
craft_close: false,
|
||||
loot: None,
|
||||
loot_picks: Vec::new(),
|
||||
loot_confirms: Vec::new(),
|
||||
loot_close: false,
|
||||
loot_master_gives: Vec::new(),
|
||||
loot_rolls: loot_roll::LootRollsState::default(),
|
||||
|
||||
@@ -180,6 +180,18 @@ pub(super) fn draw_layer_from_str(s: &str) -> Option<DrawLayer> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The inverse of [`draw_layer_from_str`] — the token `GetDrawLayer` answers, in the client's own
|
||||
/// spelling (uppercase, the five `<Layer level=>` names).
|
||||
pub(super) fn draw_layer_name(l: DrawLayer) -> &'static str {
|
||||
match l {
|
||||
DrawLayer::Background => "BACKGROUND",
|
||||
DrawLayer::Border => "BORDER",
|
||||
DrawLayer::Artwork => "ARTWORK",
|
||||
DrawLayer::Overlay => "OVERLAY",
|
||||
DrawLayer::Highlight => "HIGHLIGHT",
|
||||
}
|
||||
}
|
||||
|
||||
fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
|
||||
Some(match enum_token(s).as_str() {
|
||||
"FRAME" => FrameKind::Frame,
|
||||
|
||||
@@ -10,7 +10,7 @@ use crate::order::Strata;
|
||||
use crate::script::{event, Backdrop, Insets, Model};
|
||||
use crate::widget::FrameKind;
|
||||
|
||||
use super::{decode_id, frame_handle_of, frame_wrapper, strata_from_str};
|
||||
use super::{decode_id, draw_layer_from_str, frame_handle_of, frame_wrapper, strata_from_str};
|
||||
|
||||
/// Populate `m`'s visibility/hierarchy/strata/backdrop/mouse methods (see the module doc).
|
||||
pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
@@ -717,6 +717,38 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
// attribute, declare it on those 44 sites, then gate. Until then this is a disclosed superset
|
||||
// (1189's argument, pointed the other way), and the two corpus addons that stopped on the
|
||||
// missing *method* are unblocked either way.
|
||||
// EnableDrawLayer(layer) / DisableDrawLayer(layer) — Frame method table `0x878ec0`, entries
|
||||
// `0x7755b0` / `0x775680`, between `IsToplevel` and `Show`. See [`crate::widget::Frame`]'s
|
||||
// `disabled_layers` for what was read and why the mask lives on the frame.
|
||||
//
|
||||
// An unknown layer name is a NO-OP here rather than a raise. That is the honest state, not a
|
||||
// decision: `draw_layer_from_str` is the same parser `SetDrawLayer` and `CreateTexture` use,
|
||||
// and what the reference does with an unparseable name in THIS pair has not been read out of
|
||||
// the binary. Both corpus callers (pfUI, MoveAnything) pass literals from the five-name set.
|
||||
for (name, disable) in [("EnableDrawLayer", false), ("DisableDrawLayer", true)] {
|
||||
m.set(
|
||||
name,
|
||||
lua.create_function(move |lua, (this, layer): (Table, Value)| {
|
||||
let Some(l) = layer
|
||||
.as_string()
|
||||
.and_then(|s| s.to_str().ok().and_then(|s| draw_layer_from_str(&s)))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
let h = frame_handle_of(lua, &this)?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model");
|
||||
if let Some(frame) = model.arena.frame_mut(h) {
|
||||
let bit = 1u8 << l.index();
|
||||
if disable {
|
||||
frame.disabled_layers |= bit;
|
||||
} else {
|
||||
frame.disabled_layers &= !bit;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
m.set(
|
||||
"EnableMouseWheel",
|
||||
lua.create_function(|lua, (this, enable): (Table, bool)| {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use mlua::{Lua, MultiValue, Table, Value};
|
||||
|
||||
use crate::script::object::{as_f32, draw_layer_from_str};
|
||||
use crate::script::object::{as_f32, draw_layer_from_str, draw_layer_name};
|
||||
use crate::script::{Model, TexCoords};
|
||||
|
||||
/// Resolve `self` (a region wrapper) to its live [`RegionHandle`].
|
||||
@@ -402,6 +402,30 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetDrawLayer() — the setter's pair, present on BOTH region leaves in the client (Texture
|
||||
// `0x79a6c0`, FontString `0x79c660`, each its own copy — read off the method-table pair bytes).
|
||||
//
|
||||
// This module's own note used to end "GetDrawLayer is in the client's pair and absent here;
|
||||
// absent is absent" — accurate, and a gap rather than a decision. pfUI's `GetNoNameObject`
|
||||
// (api/api.lua:1342) reads it off every child while reskinning a Blizzard frame, and died
|
||||
// there; that is the measurement (1719).
|
||||
//
|
||||
// Returns the layer NAME alone. The sub-level we store beside it is not a second return here:
|
||||
// 1.12's pair is layer-only, and `SetDrawLayer`'s optional sub-level argument above is already
|
||||
// marked as this engine's extension point.
|
||||
m.set(
|
||||
"GetDrawLayer",
|
||||
lua.create_function(|lua, this: Table| {
|
||||
let rh = region_handle_of(lua, &this)?;
|
||||
let model = lua.app_data_ref::<Model>().expect("model");
|
||||
let dl = model
|
||||
.arena
|
||||
.region(rh)
|
||||
.map_or(crate::order::DrawLayer::Artwork, |r| r.draw_layer);
|
||||
Ok(draw_layer_name(dl))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetRotation(radians) — spin the texture about its center, counterclockwise-positive (the
|
||||
// later-era Texture API, shipped early: the world-map player arrow's stand-in rotation —
|
||||
// see `QuadContent::Texture::rotation`). No-arg/nil resets to 0.
|
||||
|
||||
@@ -43,6 +43,28 @@ pub struct MessageLine {
|
||||
/// the frame-level fade config the per-line snapshots copy at insert.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ScrollingMessageState {
|
||||
/// The frame's OWN justification, set by `SetJustifyH`/`SetJustifyV` — `None` until one of
|
||||
/// them is called.
|
||||
///
|
||||
/// VERIFIED present on both classes: the ScrollingMessageFrame method table `0x87b5c0`
|
||||
/// (the one whose `AddMessage` is `0x792900`, msgframe-runtime.md's scrolling family) carries
|
||||
/// `SetJustifyH 0x792600`/`GetJustifyH`/`SetJustifyV`/`GetJustifyV`, and the MessageFrame table
|
||||
/// `0x87b960` (`AddMessage 0x795590`, the `SetInsertMode` sibling) carries the same four. Read
|
||||
/// off the `{const char*, void*}` pair bytes; the same walk reproduces the Button table at
|
||||
/// `0x879d00` exactly as wow-re records it, and confirms Button has no `SetJustifyH` — which is
|
||||
/// why the family cannot be inferred and had to be read.
|
||||
///
|
||||
/// **`Option`, and the reason is ours not the client's.** The real object keeps ONE justify
|
||||
/// dword: its font instance's. Ours reads a message frame's font instance off its first
|
||||
/// `<FontString>` region (`message_frame_font`), which is where XML's
|
||||
/// `<FontString … justifyH="LEFT"/>` lands — but a frame built by `CreateFrame` has no such
|
||||
/// child and would have nowhere to store. So `None` means "never told, defer to the font
|
||||
/// object" and keeps the shipped chat rendering byte-for-byte what it was; `Some` is an
|
||||
/// explicit call and wins. The divergence that buys: on a frame that HAS a FontString, setting
|
||||
/// the frame's justify does not change what that FontString's own `GetJustifyH` reports, where
|
||||
/// the client would have them be one field.
|
||||
pub justify: Option<crate::justify::Justify>,
|
||||
|
||||
/// The line ring, newest at the back. Capacity is enforced by drop-oldest in [`Self::add`].
|
||||
pub lines: VecDeque<MessageLine>,
|
||||
/// The ring capacity (`maxLines`; ctor default 8, ChatFrame.xml sets 128). `SetMaxLines` is
|
||||
@@ -75,6 +97,7 @@ impl Default for ScrollingMessageState {
|
||||
// The shared CSimpleMessageScrollFrame ctor defaults (msgframe-runtime.md §"Shared ctor
|
||||
// defaults": fadingEnabled=1, timeVisible=10.0, fadeDuration=3.0; SMF maxLines=8).
|
||||
ScrollingMessageState {
|
||||
justify: None,
|
||||
lines: VecDeque::new(),
|
||||
max_lines: 8,
|
||||
time_visible: 10.0,
|
||||
@@ -375,6 +398,28 @@ pub enum InsertMode {
|
||||
/// that a message added to a zero-height frame vanishes.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct MessageFrameState {
|
||||
/// The frame's OWN justification, set by `SetJustifyH`/`SetJustifyV` — `None` until one of
|
||||
/// them is called.
|
||||
///
|
||||
/// VERIFIED present on both classes: the ScrollingMessageFrame method table `0x87b5c0`
|
||||
/// (the one whose `AddMessage` is `0x792900`, msgframe-runtime.md's scrolling family) carries
|
||||
/// `SetJustifyH 0x792600`/`GetJustifyH`/`SetJustifyV`/`GetJustifyV`, and the MessageFrame table
|
||||
/// `0x87b960` (`AddMessage 0x795590`, the `SetInsertMode` sibling) carries the same four. Read
|
||||
/// off the `{const char*, void*}` pair bytes; the same walk reproduces the Button table at
|
||||
/// `0x879d00` exactly as wow-re records it, and confirms Button has no `SetJustifyH` — which is
|
||||
/// why the family cannot be inferred and had to be read.
|
||||
///
|
||||
/// **`Option`, and the reason is ours not the client's.** The real object keeps ONE justify
|
||||
/// dword: its font instance's. Ours reads a message frame's font instance off its first
|
||||
/// `<FontString>` region (`message_frame_font`), which is where XML's
|
||||
/// `<FontString … justifyH="LEFT"/>` lands — but a frame built by `CreateFrame` has no such
|
||||
/// child and would have nowhere to store. So `None` means "never told, defer to the font
|
||||
/// object" and keeps the shipped chat rendering byte-for-byte what it was; `Some` is an
|
||||
/// explicit call and wins. The divergence that buys: on a frame that HAS a FontString, setting
|
||||
/// the frame's justify does not change what that FontString's own `GetJustifyH` reports, where
|
||||
/// the client would have them be one field.
|
||||
pub justify: Option<crate::justify::Justify>,
|
||||
|
||||
/// See [`ScrollingMessageState::lines_gen`] — the same sweep-skip token.
|
||||
pub lines_gen: u64,
|
||||
/// The display lines, **newest at the back always** — [`InsertMode`] is a *display direction*
|
||||
@@ -401,6 +446,7 @@ impl Default for MessageFrameState {
|
||||
// fadingEnabled=1, timeVisible=10.0, fadeDuration=3.0; insertMode 1 = BOTTOM). Note the
|
||||
// fade pair is shared with the scrolling class but `maxLines` has no counterpart here.
|
||||
MessageFrameState {
|
||||
justify: None,
|
||||
lines: VecDeque::new(),
|
||||
insert_mode: InsertMode::default(),
|
||||
time_visible: 10.0,
|
||||
|
||||
@@ -198,6 +198,19 @@ pub use kinds::{
|
||||
pub struct Frame {
|
||||
/// The widget subtype.
|
||||
pub kind: FrameKind,
|
||||
/// Draw layers switched OFF on this frame — bit `n` = [`DrawLayer::index`] `n`.
|
||||
///
|
||||
/// `Frame:EnableDrawLayer(layer)` / `DisableDrawLayer(layer)`, VERIFIED present on the Frame
|
||||
/// method table `0x878ec0` at `0x7755b0` / `0x775680`, sitting between `IsToplevel` and `Show`.
|
||||
/// (Read off the `{const char*, void*}` pair bytes; the same walk reproduces wow-re's recorded
|
||||
/// Button table at `0x879d00` exactly.)
|
||||
///
|
||||
/// A disabled layer hides every region the frame owns in it, and the region's own `Show`/`Hide`
|
||||
/// is untouched underneath — re-enabling the layer brings back exactly what was showing before,
|
||||
/// which is why this is a frame-level mask rather than a sweep over the regions. Both corpus
|
||||
/// consumers (pfUI, MoveAnything) use it to strip Blizzard artwork off a frame they are
|
||||
/// reskinning without disturbing what the owner does to its own regions.
|
||||
pub disabled_layers: u8,
|
||||
/// The frame's name (its global identifier), if any. See [`WidgetArena::lookup`] for the
|
||||
/// non-overwriting publish rule.
|
||||
pub name: Option<String>,
|
||||
@@ -576,6 +589,7 @@ impl WidgetArena {
|
||||
let effective_alpha = alpha;
|
||||
|
||||
let frame = Frame {
|
||||
disabled_layers: 0,
|
||||
kind,
|
||||
name: name.clone(),
|
||||
parent,
|
||||
|
||||
Reference in New Issue
Block a user