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:
Sam
2026-08-31 20:15:38 +07:00
co-authored by Claude Fable 5
parent a0d2fc383b
commit 20f38e1f7e
21 changed files with 1369 additions and 92 deletions
+2 -2
View File
@@ -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.
+60 -11
View File
@@ -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.
+30 -23
View File
@@ -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>
+125 -21
View File
@@ -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>
+451 -1
View File
@@ -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");
+9
View File
@@ -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());
+65 -10
View File
@@ -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.
+5 -2
View File
@@ -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",
+14 -2
View File
@@ -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(),
+12
View File
@@ -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)| {
+25 -1
View File
@@ -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,
+14
View File
@@ -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,