druids get their relic slot, and the loot window is the reference's own

- `UnitHasRelicSlot` is real in 1.12 and benilla had no binding for it: `0x519e50` reads `ChrClasses.dbc` field 16 — set for Paladin, Shaman and Druid in `patch.MPQ`'s 17-column copy (the base `dbc.MPQ` copy has 16 columns and no such field, which is how the "always false in vanilla" belief survived twice in decisions and seven times in code) — and stock `PaperDollFrame.lua` calls it unconditionally, so the character sheet would have raised for EVERY class; a druid, paladin or shaman's sheet now hides the ammo slot, shows the relic plate where the bow was, tooltips "Relic", and reports ranged Attack/Damage/Power as N/A, all four branches transcribed from the reference
- the loot window is the reference's own `LootFrame.xml`: `LootButton` is built as a real widget type rather than aliased (a Button plus one slot dword and one overridden OnClick — right-click loots exactly like left, no shift/ctrl/alt takes, a row whose Lua handler errored still loots, a fast double-click takes two slots), `LOOT_SLOT_CLEARED` fires per row again because the stock file hangs hide-in-place AND the emptied-page auto-advance off it, `EditBox:Insert(nil)` is a no-op as `lua_tostring` makes it, and an alias `GameTooltip.xml` had quietly become a reader of would have turned every item tooltip white, silently, had it just gone nil
- the vendor cursor: `PickupMerchantItem` is two verbs behind one name, decided by what the cursor holds and never by the caller — a held bag item SELLS (index ignored, no merchant-open gate), otherwise the vendor row is grabbed as cursor mode 5, which is invisible to `CursorHasItem` (modes 1 and 9 only) so all thirteen stock gates stay closed while it is held; the drop is the buy, `CMSG_BUY_ITEM_IN_SLOT`, count 1, into a bag or an equipment slot, and every refusal is silent and clears the cursor; the four `Model_*` rotation handlers land in UIParent where the reference keeps them
- the gap report reads `:Method(` sites now — it found `SetMerchantCompareItem`, which would have raised on the first vendor-row hover with every check green, and `LootButton:SetSlot`, and 28 "free" windows became 25; the shared coin kit moved out of the merchant window into `MoneyFrame.xml` so the eight windows that draw coins through it can reach it without loading a vendor

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Sam
2026-09-02 17:00:40 +07:00
co-authored by Claude Fable 5.1
parent d017ca1cde
commit ed524ab184
59 changed files with 2084 additions and 1231 deletions
+47 -15
View File
@@ -76,10 +76,15 @@
forced-show path). A real forced-highlight affordance is a `benilla-ui` follow-up, not
blocking this slice — `BenillaPaperDollSlot_LockHighlight`/`_UnlockHighlight` below
tint the slot's Quickslot2 ring instead, flagged at their own definition.
- The relic slot: `UnitHasRelicSlot` is vanilla-always-false (there is no relic slot in
1.12), so the ref's `CharacterAmmoSlot:Hide()`/relic-texture branches are DEAD CODE even
in the real client — omitted outright rather than transcribing unreachable branches; the
ammo slot always shows.
- The relic slot: **restored, and this note used to be wrong** (decision 1785). It read
"`UnitHasRelicSlot` is vanilla-always-false (there is no relic slot in 1.12), so the
ref's branches are DEAD CODE" — they are not. `ChrClasses.dbc` field 16 is set for
Paladin, Shaman and Druid, and INVSLOT 17 doubles as their relic slot. All four of the
ref's relic branches are live for three of nine classes and are transcribed here: the
ammo slot hides (ref l.580), ranged Attack/Damage/Attack Power force `NOT_APPLICABLE`
through `PaperDollFrame.noRanged` (l.437/449/482/552), the empty ranged-slot art
becomes `UI-PaperDoll-Slot-Relic.blp` (l.680), and its empty-slot tooltip reads
"Relic" (l.744).
- Class-specific stat-tooltip flavor text (`WARRIOR_STRENGTH_TOOLTIP` and its ~20 siblings):
only the 5 `DEFAULT_*_TOOLTIP` fallbacks ship. `PaperDollStatTooltip` still looks
the class-specific global up FIRST exactly like the ref, so dropping in the per-class
@@ -232,6 +237,7 @@
SECONDARYHANDSLOT = SECONDARYHANDSLOT or "Off Hand" -- :3400
RANGEDSLOT = RANGEDSLOT or "Ranged" -- :3291
AMMOSLOT = AMMOSLOT or "Ammo" -- :53
RELICSLOT = RELICSLOT or "Relic" -- :3312
-- The font color codes/tables live in our Fonts.xml (the ref's own home for them, l.8-19 —
-- verified this session), loaded before every window.
@@ -519,11 +525,16 @@
end
-- ref PaperDollFrame_OnShow (l.568-585): SetGuild OMITTED (named deferral, this file's
-- header). The relic Hide/Show branch is DROPPED outright — UnitHasRelicSlot is
-- vanilla-always-false, so CharacterAmmoSlot simply never has a reason to hide; it ships
-- with no hidden="true" at all (see its own Button below).
-- header). The relic branch (l.580-584) is transcribed verbatim — a relic class has no
-- ranged weapon and therefore no ammo, so its ammo slot hides. This used to be dropped on
-- the belief that UnitHasRelicSlot is always false; decision 1785.
function PaperDollFrame_OnShow()
paintRows(paperDollRows)
if UnitHasRelicSlot("player") then
CharacterAmmoSlot:Hide()
else
CharacterAmmoSlot:Show()
end
end
-- ref PaperDollFrame_OnHide (l.587-592) is entirely commented-out in the ref itself (the
-- ShoppingTooltip/compare-tooltip lines, OMIT) — nothing to transcribe, no OnHide wired.
@@ -811,8 +822,12 @@
getglobal(prefix .. "AttackFrameStatText"), mainHandAttackBase, mainHandAttackMod)
end
-- ref PaperDollFrame_SetRangedAttack (l.419-466). `hasRelic`/UnitHasRelicSlot DROPPED
-- outright (this file's header comment: vanilla-always-false, dead code in the ref itself).
-- ref PaperDollFrame_SetRangedAttack (l.419-466). `hasRelic` is transcribed (it used to
-- be dropped — decision 1785). It is the whole reason a paladin's ranged block reads
-- "n/a" rather than the stats of the libram sitting in that slot: `noRanged` is set when
-- there is no ranged texture OR the class has a relic slot, and `SetRangedDamage` /
-- `SetRangedAttackPower` both read it (ref l.482 / l.552), so one flag blanks all three
-- rows.
-- The literal slot id 18 is the ref's OWN hardcoded value (l.435) — quoted verbatim; what
-- GetInventoryItemTexture resolves id 18 to is the parallel binding's call, not re-derived
-- here.
@@ -823,10 +838,11 @@
local frame = getglobal(prefix .. "RangedAttackFrame")
local text = getglobal(prefix .. "RangedAttackFrameStatText")
local paperDoll = getglobal("PaperDollFrame")
local hasRelic = UnitHasRelicSlot(unit)
local rangedAttackBase, rangedAttackMod = UnitRangedAttack(unit)
local rangedTexture = GetInventoryItemTexture("player", 18)
local oldValue = paperDoll.noRanged
if rangedTexture then
if rangedTexture and not hasRelic then
paperDoll.noRanged = nil
else
text:SetText(NOT_APPLICABLE)
@@ -837,7 +853,7 @@
PaperDollFrame_SetRangedAttackPower()
PaperDollFrame_SetRangedDamage()
end
if not rangedTexture then return end
if not rangedTexture or hasRelic then return end
setAttackRating(frame, text, rangedAttackBase, rangedAttackMod)
end
@@ -1002,7 +1018,10 @@
button.invSlotId = id
button.slotName = slotName
button.emptyTexture = texture
button.checkRelic = checkRelic -- UnitHasRelicSlot is vanilla-always-false (header OMIT): never read
-- `checkRelic` is the number 1 for the ranged slot alone (a property of the SLOT); the
-- class half is `UnitHasRelicSlot`, and the ref reads the two as a conjunction. Both
-- halves are live — decision 1785.
button.checkRelic = checkRelic
button.icon = getglobal(button:GetName() .. "IconTexture")
button.count = getglobal(button:GetName() .. "Count")
-- Equip interactions on the 19 named slots use the equip-swap wire (PickupInventoryItem
@@ -1084,7 +1103,14 @@
button.count:SetText("")
end
else
button.icon:SetTexture(button.emptyTexture)
-- ref l.678-681: the empty ranged slot of a relic class shows the relic plate,
-- not the bow plate. The art ships (`UI-PaperDoll-Slot-Relic.blp`, 3916 bytes,
-- patch.MPQ). Restored — decision 1785.
local emptyTexture = button.emptyTexture
if button.checkRelic and UnitHasRelicSlot("player") then
emptyTexture = "Interface\\Paperdoll\\UI-PaperDoll-Slot-Relic.blp"
end
button.icon:SetTexture(emptyTexture)
button.count:SetText("")
end
-- A BROKEN item (durability 0) tints icon + slot ring red — ref
@@ -1118,14 +1144,20 @@
-- arm (a worn item compared with itself is no comparison; director-caught: shift over
-- the doll popped a "Currently Equipped" 0/XX beside a template-full main tooltip).
-- The ref's repair-mode branch (l.757-762) is INTERIM-omitted with the binding's
-- repairCost. checkRelic (l.744-746) is TBC-flagged dead weight in 1.12 — omitted.
-- repairCost. checkRelic (l.744-746) is transcribed — it was called TBC-flagged dead
-- weight here, which was wrong (decision 1785): a paladin hovering their empty ranged
-- slot reads "Relic", not "Ranged".
function BenillaPaperDollSlot_OnEnter(button)
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
local hasItem = GameTooltip:SetInventoryItem("player", button.invSlotId)
if not hasItem then
-- Empty slot: the slot-name tooltip (ref strupper(strsub(name,10)) -> the matching
-- GlobalStrings key, e.g. "HeadSlot" -> HEADSLOT -> "Head").
GameTooltip:SetText(getglobal(strupper(button.slotName)) or button.slotName)
local text = getglobal(strupper(button.slotName)) or button.slotName
if button.checkRelic and UnitHasRelicSlot("player") then
text = RELICSLOT
end
GameTooltip:SetText(text)
end
GameTooltip:Show()
end
+12 -5
View File
@@ -54,12 +54,19 @@
return name or link
end
-- Reuse the quality→colour source the loot window already ships (BENILLA_LOOT_QUALITY_COLORS,
-- LootFrame.xml — itself matching ui_items.rs quality_color / the client's GetItemQualityColor).
-- Unknown/nil quality falls back to common white. All FrameXML files load before any hover
-- fires, so the table is defined by the time this runs regardless of file order.
-- The shared quality→colour table, `ITEM_QUALITY_COLORS` — Fonts.xml's, matching
-- `ui_items.rs`'s `quality_color` and the client's `GetItemQualityColor`. Unknown/nil
-- quality falls back to common white. All FrameXML files load before any hover fires, so
-- the table is defined by the time this runs regardless of file order.
--
-- This read `BENILLA_LOOT_QUALITY_COLORS` until 1751 took the loot window to the player's
-- chain. That name was only ever an alias our own `LootFrame.xml` set to this same table
-- ("the local name kept for this file's existing readers"), and the stock file does not
-- set it — so the alias went from harmless indirection to a nil the moment its definer
-- stopped shipping. The `t and` guard would have made that silent: every tooltip would
-- have quietly gone white.
function BenillaTooltip_QualityColor(quality)
local t = BENILLA_LOOT_QUALITY_COLORS
local t = ITEM_QUALITY_COLORS
local c = (t and (t[quality] or t[1])) or { r = 1, g = 1, b = 1 }
return c.r, c.g, c.b
end
+11 -2
View File
@@ -47,8 +47,17 @@
`InspectPaperDollFrame.lua:104-112`) — 0208's named deferrals, both LANDED (decisions
1059/1060): ctrl previews the inspected item on your own body, shift posts its link.
Nothing else happens on a click, which is still the reference's own shape.
- `UnitHasRelicSlot` (ref l.123, l.22) — vanilla-always-false, so the relic branch can
never fire. Dropped outright, as CharacterFrame.xml drops it.
- `UnitHasRelicSlot` (ref l.123, l.22) — this note used to say "vanilla-always-false, so
the relic branch can never fire". **That is wrong** (decision 1785): the flag is set
for Paladin, Shaman and Druid, and the binding now exists and answers for any token,
with no `"player"` fast path — inspecting an enemy druid answers 1. The branch is
still not transcribed here, but for a different and honest reason: 1.12's inspect pane
ships inside `Blizzard_InspectUI`, which is a packed `.pub` on this install, so there
is no reference source to transcribe from. Restoring it is part of migrating this
window, not something to reconstruct from memory. The shared
`PaperDollFrame_SetRangedAttack(unit, prefix)` in CharacterFrame.xml — which the ref's
inspect pane drives with the inspected unit and the "Inspect" prefix — does honour
`hasRelic` now.
- The held-button continuous spin (ref `InspectModelFrame_OnUpdate`, l.7-22) needs
`GetButtonState() == "PUSHED"`, which this engine's Button has no method for
(CharacterFrame.xml hit the identical wall) — click-only, so no OnUpdate is wired.
-727
View File
@@ -1,727 +0,0 @@
<Ui>
<!--
benilla's loot window (decision 0084), RE-SKINNED to the real 1.12 loot window: the geometry
and art below are QUOTED from the real `LootFrame.xml` (scratchpad `LootFrame.xml`; line
citations inline), turned onto our own feeds/intents. The name `LootFrame`, its panel
registration (UiPanels.xml area="left", pushable=7), the loot feeds, the 4-rows-per-page +
pager logic, and the intent functions are UNCHANGED — only the window's dressing is real now.
A hidden window that shows on LOOT_OPENED and hides on LOOT_CLOSED. It wears the real loot art:
the single UI-LootPanel slab filling the real 256×256 window (ref l.59-60, 88); each row is one
wide click button (our whole-row model) carrying the item icon and, behind the name, the real
UI-QuestItemNameFrame parchment plate (ref LootButtonTemplate, l.4-32); the pager arrows and the
minimize-button close at their real spots.
ENGINE-SLICE CAVEAT (this branch predates the dressing arc's engine slice — named `<Font>`
objects are not implemented here yet, loader.rs "named Font objects are not implemented in v1").
The real window's `inherits="GameFontNormal"` FontStrings are transcribed faithfully (they are
part of the quoted geometry), but until the font-object slice + our Fonts.xml land on this line
the loader warns "unknown template 'GameFontNormal'" and the strings render in the default font
— no worse than before. They light up when the slice merges. No <TexCoords> is used: the loot
panel is a single whole-texture slab, so this window needs none of it.
The dead-target skull sits in the panel's portrait ring: LootFramePortraitOverlay
(Interface\TargetingFrame\TargetDead), quoted from ref l.73-85 (BORDER layer, 58×58 at the
window TOPLEFT +(10,-8)). It is STATIC here — the real client swaps in FishingLoot-Icon for a
fishing loot (LootFrame.lua l.134/139), but we ship no fishing feed, so only the corpse skull
renders (and always, in the ring). Being BORDER (below the ARTWORK panel), it shows through the
panel art's ring cut-out exactly as in the real frame.
Real-window features we OMIT (no feed/model, or unchanged pager logic): the PREV/NEXT text
labels beside the pager (ref l.98-115 — our arrow buttons carry the affordance and our Update()
shows/hides only those; adding synced labels would touch the unchanged pager logic).
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), 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
(LOOT_UPDATE) and reset it to 1 only on a fresh open (LOOT_OPENED) — the merchant SHOW/UPDATE
split. (The per-button LOOT_SLOT_CLEARED event is replaced by a full re-snapshot repaint; the
app fires LOOT_UPDATE on any content change, incl. an async item name/quality landing and a
looted/coin-cleared row. The OBSERVABLE matches the ref exactly: the slot list is fixed while
the window is open — GetNumLootItems counts cleared slots, which answer neither IsItem nor
IsCoin, so a looted row's button hides IN PLACE and the rows below never shift up — and a
page left with every button hidden advances to the next page when one exists, the ref's
LOOT_SLOT_CLEARED tail, l.38-50.)
UIPanel slot system (decision 0084 §2): registered in UiPanels.xml as area="left",
pushable=7 — so it carries NO hardcoded <Anchors>; ShowUIPanel SetPoints it (and, when a
pushable=0 window like the merchant also wants the left slot, promotes loot to the center
slot rather than replacing it). Every show is ShowUIPanel, every hide HideUIPanel.
-->
<Script><![CDATA[
BENILLA_LOOT_NUMBUTTONS = 4
-- The 1.12 item-quality palette — now the shared ITEM_QUALITY_COLORS global (Fonts.xml,
-- its home since the unit popup became a second customer); the local name kept for this
-- file's existing readers. Indexed 0..6; an unknown/nil quality falls back to common.
BENILLA_LOOT_QUALITY_COLORS = ITEM_QUALITY_COLORS
local function loot_color(quality)
return BENILLA_LOOT_QUALITY_COLORS[quality] or BENILLA_LOOT_QUALITY_COLORS[1]
end
-- ── Master loot (decision 1675) ───────────────────────────────────────────────────────
-- The quality at or above which handing an item out asks for confirmation first. Ref
-- LootFrame.lua l.3 — and the misspelling is Blizzard's own, kept because the corpus
-- reads THIS name: `MASTER_LOOT_THRESHOLD` (with the S) exists nowhere in 1.12.
MASTER_LOOT_THREHOLD = 4
GIVE_LOOT = GIVE_LOOT or "Give Loot To:" -- l.2006
GROUP = GROUP or "Group" -- l.2029
-- The party bound the dropdown's party arm sweeps to (ref PartyMemberFrame.lua l.1; ours
-- is declared in PartyFrame.xml, which the manifest loads far ahead of this file). Stated
-- through the same first-writer-wins idiom as the strings above rather than assumed: the
-- dropdown's init function RUNS at this file's load (UIDropDownMenu_Initialize calls it
-- immediately), so a missing constant is an arithmetic error at load, not at first use.
MAX_PARTY_MEMBERS = MAX_PARTY_MEMBERS or 4
-- The raid arm's two bounds, same idiom and same reason (declared in PartyFrame.xml /
-- RaidFrame.xml, both loaded first). The reference writes them inline as literal `40` and
-- `5` (l.194/:195); naming them is ours, so the names have to be guaranteed here.
MAX_RAID_MEMBERS = MAX_RAID_MEMBERS or 40
MEMBERS_PER_RAID_GROUP = MEMBERS_PER_RAID_GROUP or 5
CONFIRM_LOOT_DISTRIBUTION = CONFIRM_LOOT_DISTRIBUTION
or "You wish to assign %s to %s. Is this correct?" -- l.848
-- The dialog below captures these two by VALUE at load, so a load-order slip would bake a
-- nil into its buttons rather than fail loudly. FriendsFrame.xml declares them and the
-- manifest loads it first; stated again here so this file's own dialog cannot depend on
-- that staying true.
YES = YES or "Yes" -- l.5463
NO = NO or "No" -- l.2794
-- The confirmation the dropdown raises for an at-or-above-threshold item (ref
-- StaticPopup.lua l.85-94). `data` is the candidate index the dropdown stashed; the SLOT
-- is re-read live from LootFrame.selectedSlot at accept time, exactly as the ref does —
-- which is precisely why the row click below hides this popup first, so an accept can
-- never land on a row the player has since clicked away from.
StaticPopupDialogs["CONFIRM_LOOT_DISTRIBUTION"] = {
text = CONFIRM_LOOT_DISTRIBUTION,
button1 = YES,
button2 = NO,
OnAccept = function(data)
GiveMasterLoot(LootFrame.selectedSlot, data)
end,
timeout = 0,
hideOnEscape = 1,
}
function BenillaLootItem_OnLoad(button, index)
button.lootButton = index
button.icon = getglobal(button:GetName() .. "Icon")
button.name = getglobal(button:GetName() .. "Name")
button.count = getglobal(button:GetName() .. "Count")
-- Left-click loots the row (the C LootButton behaviour, LootFrame.lua l.94/147).
button:RegisterForClicks("LeftButtonUp", "RightButtonUp")
end
-- ref LootFrameItem_OnClick (LootFrame.lua l.147-162): CTRL previews the row's item in the
-- dressing room (decision 1060), SHIFT posts its link into an open chat edit box (decision
-- 1059) — both through GetLootSlotLink, the row-link binding this arc added (loot.rs).
--
-- WHERE THE TAKE LIVES, and why the gate below reads three modifiers where FrameXML names
-- two (VERIFIED at the 1.12.1 bytes — wow-re `ui/scratch/loot-click-modifier-gate.md`,
-- decision 1067). The reference's Lua handler suppresses NOTHING, because the take is the C
-- `CLootButton`'s own click behaviour (`LootFrame.lua:94`'s `button:SetSlot(slot)` hands the
-- widget the slot). `CLootButton::OnClick 0x4c1820` runs the Lua `<OnClick>` FIRST and
-- unconditionally, then gates itself on `0x41f8f0(0/1/2)` — byte-identically the predicates
-- behind `IsShiftKeyDown`/`IsControlKeyDown`/**`IsAltKeyDown`** — and only then sends
-- (`0x4c2790` → `0x5e1ad0` → `push 0x108`, CMSG_AUTOSTORE_LOOT_ITEM).
--
-- Three consequences, all of them this handler's shape:
-- · **ALT suppresses the take too**, though no FrameXML file mentions alt. Its gate is C.
-- · The gate is on ANY modifier being HELD, not on "the arm that handled it": a shift-click
-- with the chat box closed does nothing in Lua and still must not loot.
-- · 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, 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 —
-- GroupLootDropDown reads exactly those four (l.164+). benilla ships no master loot
-- (benilla-protocol's loot module: CMSG_LOOT_MASTER_GIVE/SMSG_LOOT_MASTER_LIST out of
-- scope), so returning early skips nothing that exists.
--
-- The coin row has no link (it is synthesized, with no item behind it — ui_loot.rs), so a
-- modified click on it finds nil: DressUpItemLink returns on a nil link (DressUpFrame.lua
-- l.10-16) and the insert is skipped. It does not fall through to looting the money either,
-- which is the same law the item rows follow.
function BenillaLootItem_OnClick(button)
-- The row remembers its resolved loot slot from the last Update; act on it (LootFrame.lua
-- l.94-95 stores button.slot, the click calls LootSlot on it).
if not button.slot then return end
local modified = false
if IsControlKeyDown() then
DressUpItemLink(GetLootSlotLink(button.slot)) -- ref l.148-149
modified = true
elseif IsShiftKeyDown() then
-- ref l.150-153, through the shared helper (UIParent.xml): it carries the ref's own
-- "only when the edit box is up" guard AND our nil-link one, which this row needs
-- more than most — the coin row never has a link, and an item row has none until
-- its template lands. A shift-click with chat closed does nothing here: a loot row
-- has no stack to split, unlike a bag slot.
BenillaChatEdit_InsertLink(GetLootSlotLink(button.slot))
modified = true
elseif IsAltKeyDown() then
-- The C gate's third modifier (`0x41f8f0(2)` @ `0x4c1841`). Nothing is bound to
-- alt in FrameXML, so this arm does nothing at all — its entire job is to NOT
-- loot, which is what the real client does and what we did not.
modified = true
end
-- The ref's tail (l.155-161), and it runs on EVERY click — modified ones included,
-- because in the reference this bookkeeping is the Lua <OnClick> and the take is the
-- C handler that runs after it; the modifier gate lives in the C half, so it never
-- skipped these four. Ours folds the take into this function (1067), so the tail is
-- lifted above the early return rather than left below it — same observable.
--
-- What reads them: GroupLootDropDown, which the app opens by firing
-- OPEN_MASTER_LOOT_LIST when the clicked row's wire slot_type is MASTER. That event
-- is raised from the pick drain, i.e. AFTER this handler returns, so the four fields
-- must already be stashed by then — they are the dropdown's only knowledge of WHICH
-- row is being handed out, and its anchor.
StaticPopup_Hide("CONFIRM_LOOT_DISTRIBUTION")
LootFrame.selectedLootButton = button:GetName()
LootFrame.selectedSlot = button.slot
LootFrame.selectedQuality = button.quality
LootFrame.selectedItemName = button.name:GetText()
if modified then return end
BenillaTakeLootSlot(button.slot)
end
-- Hover: the plain engine item tooltip, ITEM rows only — the ref's own gate (its
-- LootButtonTemplate <OnEnter> runs SetLootItem only `if LootSlotIsItem(slot)`, so the
-- coin pile shows no tooltip at all). No hand-added lines: the real tooltip never shows a
-- stack size (the 0274 P1 'Stack Size' drop retired the same line from the bag hovers;
-- this file's copy predated it and survived).
function BenillaLootItem_OnEnter(button)
if not button.slot then return end
if not LootSlotIsItem(button.slot) then return end
local texture, item, quantity, quality, itemId = GetLootSlotInfo(button.slot)
button.itemId = itemId
if not item then return end
-- Snap beside the ICON, not the 160-wide row's far edge (the vendor rows' own idiom —
-- MerchantFrame.xml walks ANCHOR_RIGHT back by a negative x): 37px icon on a 160 row
-- → -123 lands the tip at the icon's right edge.
GameTooltip:SetOwner(button, "ANCHOR_RIGHT", -123, 0)
-- SetLootItem, like the ref's own LootButtonTemplate: the loot ROW is the source, not
-- just its item id, so the tooltip sees the drop's random-suffix roll. Through
-- SetItemById (a template source) a "… of the Monkey" showed `<Random enchantment>`
-- until the item reached a bag — decision 1547.
GameTooltip:SetLootItem(button.slot)
GameTooltip:Show()
end
function BenillaLootItem_OnLeave(button)
GameTooltip:Hide()
end
-- LootFrame_Update (LootFrame.lua l.67-119): fill the visible rows for the current page.
function LootFrame_Update()
local frame = getglobal("LootFrame")
local numItems = GetNumLootItems()
-- Rows per page: 4, or 3 when there's a pager to make room for (l.70-73).
local perPage = BENILLA_LOOT_NUMBUTTONS
if numItems > BENILLA_LOOT_NUMBUTTONS then
perPage = perPage - 1
end
-- Clamp the page. numItems is constant while a window is open (cleared slots stay in
-- the count — the fixed slot layout), so this only guards a stray page value.
local maxPage = 1
if numItems > 0 then
maxPage = math.ceil(numItems / perPage)
end
if frame.page > maxPage then
frame.page = maxPage
end
if frame.page < 1 then
frame.page = 1
end
for index = 1, BENILLA_LOOT_NUMBUTTONS do
local button = getglobal("LootButton" .. index)
local slot = (perPage * (frame.page - 1)) + index
if slot <= numItems and index <= perPage
and (LootSlotIsItem(slot) or LootSlotIsCoin(slot)) then
local texture, item, quantity, quality, itemId = GetLootSlotInfo(slot)
button.icon:SetTexture(texture or BENILLA_FALLBACK_ICON)
button.name:SetText(item or "...")
local color = loot_color(quality)
button.name:SetVertexColor(color.r, color.g, color.b)
-- "xN" stack count only when more than one drops (LootFrame.lua l.87-93).
if (quantity or 1) > 1 then
button.count:SetText(quantity)
else
button.count:SetText("")
end
button.slot = slot
button.itemId = itemId
-- Ref l.96. Read back by the row click into LootFrame.selectedQuality, which
-- is what decides whether handing this row out asks for confirmation first.
button.quality = quality
button:Show()
else
button:Hide()
end
end
-- Pager visibility (LootFrame.lua l.105-118): Up when past page 1, Down when more remain.
if frame.page > 1 then
getglobal("LootUpButton"):Show()
else
getglobal("LootUpButton"):Hide()
end
if frame.page < maxPage then
getglobal("LootDownButton"):Show()
else
getglobal("LootDownButton"):Hide()
end
end
function LootFrame_PageDown()
local frame = getglobal("LootFrame")
frame.page = frame.page + 1
LootFrame_Update()
end
function LootFrame_PageUp()
local frame = getglobal("LootFrame")
frame.page = frame.page - 1
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")
frame:RegisterEvent("UPDATE_MASTER_LOOT_LIST")
end
function LootFrame_OnEvent(event)
local frame = this
if event == "LOOT_OPENED" then
-- A fresh loot: reset to page 1, paint, show (LootFrame.lua l.14-16).
frame.page = 1
LootFrame_Update()
ShowUIPanel(frame)
elseif event == "LOOT_UPDATE" then
-- A content change while open (name/quality landed, a row/coin removed): repaint,
-- keeping the current page. A looted slot's button hides IN PLACE (the slot stays
-- in the layout as a gap — the ref's LOOT_SLOT_CLEARED, l.22-37).
LootFrame_Update()
-- The ref's LOOT_SLOT_CLEARED tail (l.38-50): a page left with every button hidden
-- moves down to the next page when one exists.
local allHidden = true
for index = 1, BENILLA_LOOT_NUMBUTTONS do
if getglobal("LootButton" .. index):IsVisible() then
allHidden = false
end
end
if allHidden and getglobal("LootDownButton"):IsVisible() then
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.
ToggleDropDownMenu(1, nil, GroupLootDropDown, LootFrame.selectedLootButton, 0, 0)
elseif event == "UPDATE_MASTER_LOOT_LIST" then
-- Ref l.62-64: refresh IN PLACE, never re-toggle — re-toggling an open menu would
-- close it under the player's cursor.
UIDropDownMenu_Refresh(GroupLootDropDown)
end
end
function LootFrame_OnShow()
-- Open sound — the window-sound convention (decision 0090), wrapper form (this handler
-- has real logic beyond the sound, so it can't be a bare inline PlaySound).
-- (LootFrame.lua LootFrame_OnShow, l.131-141). ONLY the empty-roll fork and the fishing
-- fork are Lua-side: an empty loot plays LOOTWINDOWOPENEMPTY (l.135-136); a fishing loot
-- (IsFishingLoot(), decision 1086) plays "FISHING REEL IN" and swaps the portrait ring's
-- skull for the FishingLoot-Icon (l.137-140) — reset to the skull first, exactly as the
-- ref does (l.133), so a corpse loot after a fishing one gets its skull back. The normal
-- (non-empty, non-fishing) loot-open sound is C-side (the server-driven loot handler),
-- so we stay silent for it — there is no kit to guess. (DR 0086: vmangos masks the
-- lootable flag for empty rolls, so a live empty window is rare — but the branch must
-- exist.)
getglobal("LootPortraitOverlay"):SetTexture("Interface\\TargetingFrame\\TargetDead")
if GetNumLootItems() == 0 then
PlaySound("LOOTWINDOWOPENEMPTY")
elseif IsFishingLoot() then
PlaySound("FISHING REEL IN")
getglobal("LootPortraitOverlay"):SetTexture("Interface\\LootFrame\\FishingLoot-Icon")
end
end
function LootFrame_OnHide()
-- Closing the window releases the loot (LootFrame.lua l.143-145). The app only sends the
-- release when a loot is actually open, so a hide after the state already cleared is a
-- harmless no-op. No close sound here: LootFrame_OnHide plays none (the loot-close kit is
-- C-side), so we stay silent — no kit to guess.
CloseLoot()
end
function BenillaLootCloseButton_OnClick()
-- Hide immediately (client-authoritative); OnHide's CloseLoot() sends the release.
HideUIPanel(getglobal("LootFrame"))
end
-- ── GroupLootDropDown — the master looter's "Give Loot To:" menu ──────────────────────
-- Ref LootFrame.lua l.164-244, transcribed. The frame keeps the reference's BARE name for
-- the same mechanical reason the four GroupLootFrames do (decision 1254/0591 §3):
-- LootFrame_OnEvent resolves it by literal global lookup, and a real 1.12 addon that
-- hooks the master-loot menu expects to find it under this name.
function GroupLootDropDown_OnLoad()
-- "MENU" style: a bare context menu, no capsule chrome — it is opened at the loot row
-- by ToggleDropDownMenu, never drawn in place. Passed by name rather than the ref's
-- bare `this`, the house calling convention (TradeSkillFrame/TrainerFrame precedent).
UIDropDownMenu_Initialize(GroupLootDropDown, GroupLootDropDown_Initialize, "MENU")
end
function GroupLootDropDown_Initialize()
local candidate, info
-- Level 2: one raid group's five candidate slots (ref l.170-184). The parent row
-- stashed the index its probe found, and this walks the five starting THERE — so a
-- group whose first present candidate is not its first slot yields a window offset by
-- the same amount. That is the reference's own arithmetic; kept as-is.
if UIDROPDOWNMENU_MENU_LEVEL == 2 then
local lastIndex = UIDROPDOWNMENU_MENU_VALUE + 5 - 1
for i = UIDROPDOWNMENU_MENU_VALUE, lastIndex do
candidate = GetMasterLootCandidate(i)
if candidate then
info = {}
info.text = candidate
info.textHeight = 12
info.value = i
info.notCheckable = 1
info.func = GroupLootDropDown_GiveLoot
UIDropDownMenu_AddButton(info, UIDROPDOWNMENU_MENU_LEVEL)
end
end
return
end
if GetNumRaidMembers() > 0 then
-- In a raid: a title, then one nested row per group that has any candidate at all
-- (ref l.186-212). The probe is "does any of this block of five answer non-nil".
info = {}
info.text = GIVE_LOOT
info.textHeight = 12
info.notCheckable = 1
info.isTitle = 1
UIDropDownMenu_AddButton(info)
for i = 1, MAX_RAID_MEMBERS, MEMBERS_PER_RAID_GROUP do
for j = i, i + MEMBERS_PER_RAID_GROUP - 1 do
candidate = GetMasterLootCandidate(j)
if candidate then
info = {}
-- The label counts from the loop step `i`, the value from the found
-- slot `j` — the ref uses the two differently on purpose (l.196/:200).
info.text = GROUP .. " " .. math.ceil(i / MEMBERS_PER_RAID_GROUP)
info.textHeight = 12
info.hasArrow = 1
info.notCheckable = 1
info.value = j
info.func = nil
UIDropDownMenu_AddButton(info)
break
end
end
end
else
-- In a party: a flat list, no title row and no nesting (ref l.214-232). The sweep
-- is MAX_PARTY_MEMBERS+1 because the master looter can hand loot to THEMSELVES —
-- the server includes us in the candidate list too (Group::MasterLoot walks the
-- whole group).
for i = 1, MAX_PARTY_MEMBERS + 1, 1 do
candidate = GetMasterLootCandidate(i)
if candidate then
info = {}
info.text = candidate
info.textHeight = 12
info.value = i
info.notCheckable = 1
info.func = GroupLootDropDown_GiveLoot
UIDropDownMenu_AddButton(info)
end
end
end
end
function GroupLootDropDown_GiveLoot()
-- Ref l.234-244. At or above the threshold the assignment is confirmed first; below
-- it, it goes straight out. The popup's message takes the quality-coloured item name
-- and the candidate's own row text.
--
-- The `selectedQuality and` guard is ours, and it is required rather than defensive:
-- benilla resolves an item template asynchronously, so GetLootSlotInfo answers a nil
-- quality (and a nil name) for a row whose template is still in flight — a state the
-- real client's C-side loot record never has. Unguarded, that comparison raises. A nil
-- quality takes the below-threshold arm, which is the safe direction: the assignment
-- still happens, it just doesn't ask first.
if LootFrame.selectedQuality and LootFrame.selectedQuality >= MASTER_LOOT_THREHOLD then
local color = ITEM_QUALITY_COLORS[LootFrame.selectedQuality]
local dialog = StaticPopup_Show("CONFIRM_LOOT_DISTRIBUTION",
color.hex .. (LootFrame.selectedItemName or "") .. FONT_COLOR_CODE_CLOSE,
this:GetText())
if dialog then
dialog.data = this.value
end
else
GiveMasterLoot(LootFrame.selectedSlot, this.value)
end
CloseDropDownMenus()
end
]]></Script>
<!--
One loot row (ref LootButtonTemplate, l.4-57). The real template inherits ItemButtonTemplate
(the 37px item icon + its "xN" count) and adds, to the right of the icon, the UI-QuestItemNameFrame
parchment plate (130×62 at the button's LEFT +30; ref l.7-18) with the item name over it
(GameFontNormal, LEFT, 93×38; ref l.19-30). The real button is just the 37px icon and the name
rides off its RIGHT edge; our whole-row click model keeps the same art but sizes the button to
span icon + plate (37 icon + 8 gap → name at LEFT +45; plate at LEFT +30, 130 wide → 160 total).
-->
<Button name="BenillaLootButtonTemplate" virtual="true">
<Size><AbsDimension x="160" y="37"/></Size>
<Layers>
<!-- The item icon at the row-left (ItemButtonTemplate's 37px $parentIconTexture, ref l.4). -->
<Layer level="BORDER">
<Texture name="$parentIcon">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors>
<Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<Layer level="ARTWORK">
<!-- The parchment name plate behind the name (ref l.7-18): 130×62 at the icon's right,
the button's LEFT +30. -->
<Texture file="Interface\QuestFrame\UI-QuestItemNameFrame">
<Size><AbsDimension x="130" y="62"/></Size>
<Anchors>
<Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="30" y="0"/></Offset></Anchor>
</Anchors>
</Texture>
<!-- The item name (GameFontNormal, LEFT; ref l.19-30), off the icon's right edge: the
button LEFT +45 (icon 37 + the real 8px gap). -->
<FontString name="$parentName" inherits="GameFontNormal" justifyH="LEFT">
<Size><AbsDimension x="93" y="38"/></Size>
<Anchors>
<Anchor point="LEFT" relativePoint="LEFT"><Offset><AbsDimension x="45" y="0"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
<Layer level="OVERLAY">
<!-- "xN" stack count INSIDE the icon's bottom-right (ItemButtonTemplate
$parentCount: NumberFontNormal, BOTTOMRIGHT -5,2 — the ref's button IS the
37px icon, ours spans the whole row, so the same inset re-bases off the
button's BOTTOMLEFT: 37-5=32. The bag slots' count is this exact shape). -->
<FontString name="$parentCount" inherits="NumberFontNormal" justifyH="RIGHT">
<Anchors>
<Anchor point="BOTTOMRIGHT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="32" y="2"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<!-- Hover art over the ICON only (the ref's real button IS the 37px icon — our whole-row
click model keeps the row hit-rect but must not glow the name plate too; director's
loot-window note). -->
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD">
<Size><AbsDimension x="37" y="37"/></Size>
<Anchors><Anchor point="LEFT" relativePoint="LEFT"/></Anchors>
</HighlightTexture>
<Scripts>
<OnClick>BenillaLootItem_OnClick(self)</OnClick>
<OnEnter>BenillaLootItem_OnEnter(self)</OnEnter>
<OnLeave>BenillaLootItem_OnLeave(self)</OnLeave>
</Scripts>
</Button>
<Frame name="LootFrame" enableMouse="true" toplevel="true" parent="UIParent" hidden="true">
<!-- The real 256×256 window (ref l.59-60). No <Anchors> here — ShowUIPanel SetPoints this to
its slot. -->
<Size><AbsDimension x="256" y="256"/></Size>
<Layers>
<!-- The dead-target skull in the panel's portrait ring (ref LootFramePortraitOverlay,
l.73-85): Interface\TargetingFrame\TargetDead, 58×58 at the window TOPLEFT
+(10,-8). BORDER (below the ARTWORK panel) so it shows through the panel's ring cut-out.
OnShow resets it to the skull and swaps a fishing loot to FishingLoot-Icon
(decision 1086), exactly as the ref's OnShow does (l.133-140). -->
<Layer level="BORDER">
<Texture name="LootPortraitOverlay" file="Interface\TargetingFrame\TargetDead">
<Size><AbsDimension x="58" y="58"/></Size>
<Anchors>
<Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="10" y="-8"/></Offset></Anchor>
</Anchors>
</Texture>
</Layer>
<!-- The single UI-LootPanel slab that IS the window art (ref l.88): no size/anchors → fills
the 256×256 owner. No <TexCoords>. The solid-colour fill is gone (the panel is opaque). -->
<Layer level="ARTWORK">
<Texture file="Interface\LootFrame\UI-LootPanel"/>
<!-- Title: the real ITEMS label (ref l.89-97) — GameFontNormal at CENTER +(-12,102). -->
<FontString name="LootTitle" inherits="GameFontNormal" text="Items">
<Anchors>
<Anchor point="CENTER" relativePoint="CENTER"><Offset><AbsDimension x="-12" y="102"/></Offset></Anchor>
</Anchors>
</FontString>
</Layer>
</Layers>
<Frames>
<!-- The real row stack (ref l.139-174): row 1 at the window TOPLEFT +(24,-80), each next
row centred on the prior row's BOTTOM -4. -->
<Button name="LootButton1" inherits="BenillaLootButtonTemplate">
<Anchors><Anchor point="TOPLEFT" relativePoint="TOPLEFT"><Offset><AbsDimension x="24" y="-80"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaLootItem_OnLoad(self, 1)</OnLoad></Scripts>
</Button>
<Button name="LootButton2" inherits="BenillaLootButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="LootButton1" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaLootItem_OnLoad(self, 2)</OnLoad></Scripts>
</Button>
<Button name="LootButton3" inherits="BenillaLootButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="LootButton2" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaLootItem_OnLoad(self, 3)</OnLoad></Scripts>
</Button>
<Button name="LootButton4" inherits="BenillaLootButtonTemplate">
<Anchors><Anchor point="TOP" relativeTo="LootButton3" relativePoint="BOTTOM"><Offset><AbsDimension x="0" y="-4"/></Offset></Anchor></Anchors>
<Scripts><OnLoad>BenillaLootItem_OnLoad(self, 4)</OnLoad></Scripts>
</Button>
<!-- Pager (ref l.175-216): Up (page back) at the window BOTTOMLEFT +(25,16), Down (page
forward) at the Up button's RIGHT +86 — both in the bottom-left of the panel. The
ChatIcon scroll arrows are the real art (ref l.191-193, 212-214). -->
<Button name="LootUpButton" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="BOTTOMLEFT" relativePoint="BOTTOMLEFT"><Offset><AbsDimension x="25" y="16"/></Offset></Anchor>
</Anchors>
<NormalTexture file="Interface\ChatFrame\UI-ChatIcon-ScrollUp-Up"/>
<PushedTexture file="Interface\ChatFrame\UI-ChatIcon-ScrollUp-Down"/>
<DisabledTexture file="Interface\ChatFrame\UI-ChatIcon-ScrollUp-Disabled"/>
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" alphaMode="ADD"/>
<Scripts><OnClick>LootFrame_PageUp()</OnClick></Scripts>
</Button>
<Button name="LootDownButton" hidden="true">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="LEFT" relativeTo="LootUpButton" relativePoint="RIGHT"><Offset><AbsDimension x="86" y="0"/></Offset></Anchor>
</Anchors>
<NormalTexture file="Interface\ChatFrame\UI-ChatIcon-ScrollDown-Up"/>
<PushedTexture file="Interface\ChatFrame\UI-ChatIcon-ScrollDown-Down"/>
<DisabledTexture file="Interface\ChatFrame\UI-ChatIcon-ScrollDown-Disabled"/>
<HighlightTexture file="Interface\Buttons\UI-Common-MouseHilight" alphaMode="ADD"/>
<Scripts><OnClick>LootFrame_PageDown()</OnClick></Scripts>
</Button>
<!-- Close: the real LootCloseButton position (CENTER on the window's TOPRIGHT, -81,-26;
ref l.119-129), on the minimize-button art it already used (the real LootCloseButton
uses this very art, ref l.135-137). -->
<Button name="LootCloseButton">
<Size><AbsDimension x="32" y="32"/></Size>
<Anchors>
<Anchor point="CENTER" relativePoint="TOPRIGHT"><Offset><AbsDimension x="-81" y="-26"/></Offset></Anchor>
</Anchors>
<NormalTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Up"/>
<PushedTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Down"/>
<HighlightTexture file="Interface\Buttons\UI-Panel-MinimizeButton-Highlight" alphaMode="ADD"/>
<Scripts><OnClick>BenillaLootCloseButton_OnClick()</OnClick></Scripts>
</Button>
</Frames>
<Scripts>
<OnLoad>LootFrame_OnLoad()</OnLoad>
<OnEvent>LootFrame_OnEvent(event)</OnEvent>
<OnShow>LootFrame_OnShow()</OnShow>
<OnHide>LootFrame_OnHide()</OnHide>
</Scripts>
</Frame>
<!--
The master looter's candidate menu (ref LootFrame.xml l.233-242). Bare reference name, and
the reason is mechanical rather than stylistic — the same one decision 1254 recorded for
GroupLootFrame1..4: LootFrame_OnEvent hands ToggleDropDownMenu this frame by literal global
lookup, and a 1.12 addon that wants to extend the master-loot menu expects to find it here.
Hidden and unanchored: ToggleDropDownMenu positions it at the clicked loot row.
No `parent=`, which is the reference's own declaration (l.233, a top-level sibling of
LootFrame) and the dropdown family's rule since 1757: a menu host under `UIParent` goes
invisible with it under an `area = "full"` panel, and a hidden host seats a hidden list.
-->
<Frame name="GroupLootDropDown" inherits="UIDropDownMenuTemplate" id="1" hidden="true">
<Anchors>
<Anchor point="TOPLEFT"/>
</Anchors>
<Scripts>
<OnLoad>GroupLootDropDown_OnLoad()</OnLoad>
</Scripts>
</Frame>
</Ui>
@@ -35,139 +35,6 @@
MERCHANT_ITEMS_PER_PAGE = 10 -- MERCHANT_ITEMS_PER_PAGE (ref MerchantFrame.lua l.1)
BUYBACK_ITEMS_PER_PAGE = 12 -- BUYBACK_ITEMS_PER_PAGE (ref l.2; rows 11/12 are buyback-only)
-- The coin display, quoted from the real SmallMoneyFrame (ref MoneyFrame.xml/lua): a copper
-- amount renders as a number + a 13px coin icon per NONZERO denomination, cut from the one
-- shared texture Interface\MoneyFrame\UI-MoneyIcons by TexCoord (gold 0..0.25, silver
-- 0.25..0.5, copper 0.5..0.75 — ref l.115/74/33), the real collapse that hides zero coins
-- (MoneyFrame.lua l.221-267). Each money display owns three position-"slots"; the XML chains
-- them either left-to-right (row prices + the quest reward: Coin1 leftmost, gold first) or
-- right-to-left (the purses: Coin1 rightmost, copper first). BenillaMoney_Set fills the
-- visible denominations into slots 1.. in DISPLAY order (highestFirst=true for the
-- left-aligned chains) and — the real shrink, MoneyFrame.lua l.202/269 — sizes each slot to
-- exactly its number + its 13px coin (`SetWidth(GetTextWidth() + iconWidth)` there; the
-- same read here, off the number FontString the slot already owns). The static ±4px
-- chain-anchor offsets are the real MONEY_BUTTON_SPACING gap, so a shrunk group packs tight,
-- left-flush in the ltr chains and right-flush in the rtl ones. BenillaMoney_Clear hides
-- all three slots (the real collapse hides, see the helper).
--
-- NOTE (for the loot window's fold): the reusable helpers are
-- BenillaMoney_Set(prefix, copper) and BenillaMoney_Clear(prefix)
-- where `prefix` names the slot frames `<prefix>Coin1..3` (each with a `<prefix>CoinNIcon`
-- texture + `<prefix>CoinNNum` fontstring child). They live in this file.
BENILLA_COIN_TC = {
{ 0.0, 0.25 }, -- [1] gold
{ 0.25, 0.5 }, -- [2] silver
{ 0.5, 0.75 }, -- [3] copper
}
-- Unused slots HIDE (the real collapse — MoneyFrame.lua l.230/247 goldButton:Hide()), never
-- SetWidth(0): a 0 width means "derive from opposing anchors" to the layout (the client's
-- own G+0x50 convention), not "zero px". Fills are contiguous from Coin1, so a chain never
-- anchors THROUGH a hidden slot.
function BenillaMoney_Clear(prefix)
for i = 1, 3 do
getglobal(prefix .. "Coin" .. i):Hide()
end
end
function BenillaMoney_Set(prefix, copper, highestFirst)
copper = copper or 0
local vals = {
math.floor(copper / 10000), -- gold
math.floor((copper % 10000) / 100), -- silver
copper % 100, -- copper
}
-- Fill slots 1.. in display order (see the module comment); a zero denomination is
-- skipped (the real collapse), except copper always shows when the whole amount is
-- zero so a free item / empty purse isn't blank.
local first, last, step = 3, 1, -1
if highestFirst then first, last, step = 1, 3, 1 end
local slot = 0
for d = first, last, step do
if vals[d] > 0 or (d == 3 and vals[1] == 0 and vals[2] == 0) then
slot = slot + 1
-- The real shrink: this slot is exactly its number + its 13px coin, and the
-- XML anchor chain re-flows off the new width.
-- Text first, then measure it: the engine answers a width read inside the
-- tick that set the text, so the number IS this number and not the last one.
local num = getglobal(prefix .. "Coin" .. slot .. "Num")
num:SetText(vals[d])
local numW = num:GetStringWidth()
local slotFrame = getglobal(prefix .. "Coin" .. slot)
slotFrame:SetWidth(numW + 13)
slotFrame:Show()
local icon = getglobal(prefix .. "Coin" .. slot .. "Icon")
icon:SetTexture("Interface\\MoneyFrame\\UI-MoneyIcons")
icon:SetTexCoord(BENILLA_COIN_TC[d][1], BENILLA_COIN_TC[d][2], 0, 1)
num:SetWidth(numW)
end
end
for i = slot + 1, 3 do
getglobal(prefix .. "Coin" .. i):Hide()
end
end
-- Recolor the coin *numbers* (the ref's SetMoneyFrameColor — the digits redden, the coin icons
-- keep their own texture). The trainer detail cost calls this white when affordable / red
-- (1.0, 0.1, 0.1) when not; a plain purse never calls it, so its coins stay default. Applies to
-- all 3 slots (hidden ones are harmless) and must be re-called after each BenillaMoney_Set,
-- which fills the numbers but does not reset their color.
function BenillaMoney_SetColor(prefix, r, g, b)
for i = 1, 3 do
getglobal(prefix .. "Coin" .. i .. "Num"):SetTextColor(r, g, b)
end
end
-- ── The editable money-input kit (ref MoneyInputFrame.lua) ────────────────────────────────
-- A <prefix> naming three numeric EditBoxes <prefix>Gold/Silver/Copper — the shared money entry
-- both the send-mail and trade windows inline (decision 0592 P2 builds it here, the money home,
-- so both reuse it). GetCopper folds the three digits into a copper total; SetCopper splits a
-- copper amount back across the boxes. SetCopper is a *programmatic* fill, so it must not re-fire
-- a box's OnTextChanged value-changed callback (the loop the reference's `expectChanges` counter
-- guards, MoneyInputFrame.lua l.44-73). Because benilla fires OnTextChanged synchronously inside
-- SetText, a bracket flag is exact and simpler than the counter: a window's box calls
-- BenillaMoneyInput_Consume(prefix) at the top of its OnTextChanged — false (swallow) while a
-- SetCopper is in flight, true for a genuine keystroke.
BENILLA_MONEYINPUT_SUPPRESS = {}
function BenillaMoneyInput_ResetMoney(prefix)
getglobal(prefix .. "Gold"):SetText("")
getglobal(prefix .. "Silver"):SetText("")
getglobal(prefix .. "Copper"):SetText("")
end
function BenillaMoneyInput_GetCopper(prefix)
local function n(box) return tonumber(getglobal(prefix .. box):GetText()) or 0 end
return n("Gold") * 10000 + n("Silver") * 100 + n("Copper")
end
function BenillaMoneyInput_ClearFocus(prefix)
getglobal(prefix .. "Gold"):ClearFocus()
getglobal(prefix .. "Silver"):ClearFocus()
getglobal(prefix .. "Copper"):ClearFocus()
end
function BenillaMoneyInput_SetCopper(prefix, money)
money = money or 0
local parts = {
Gold = math.floor(money / 10000),
Silver = math.floor((money % 10000) / 100),
Copper = money % 100,
}
BENILLA_MONEYINPUT_SUPPRESS[prefix] = true
for box, val in pairs(parts) do
local eb = getglobal(prefix .. box)
if (tonumber(eb:GetText()) or 0) ~= val then
eb:SetText(val == 0 and "" or tostring(val))
end
end
BENILLA_MONEYINPUT_SUPPRESS[prefix] = false
end
function BenillaMoneyInput_Consume(prefix)
return not BENILLA_MONEYINPUT_SUPPRESS[prefix]
end
function BenillaMerchantItem_OnLoad(button, index)
button.merchantIndex = index
local n = button:GetName()
+149 -2
View File
@@ -18,8 +18,9 @@
── THE OTHER COIN KIT, and why there are still two ──────────────────────────────────────────
Eight of our windows (merchant, bank, trade, mail, quest, quest log, taxi, trainer) do NOT use
this file. They render coins through `BenillaMoney_Set`/`_Clear`/`_SetColor` in
MerchantFrame.xml: a three-SLOT chain (`<prefix>Coin1..3`) that fills only the non-zero
this file. They render coins through `BenillaMoney_Set`/`_Clear`/`_SetColor`, which live
below in this same file (they were in MerchantFrame.xml until 1751 took that window to the
stock chain): a three-SLOT chain (`<prefix>Coin1..3`) that fills only the non-zero
denominations in display order and re-flows, rather than three fixed GoldButton/SilverButton/
CopperButton frames that hide themselves. That kit takes its coin TexCoords, its collapse and
its shrink from the same specification as this one, and its layout is the one the director has
@@ -67,6 +68,152 @@
here as they are there. They exist because the table's shape is the contract.
-->
<Script><![CDATA[
-- ══ THE THREE-SLOT COIN KIT (`BenillaMoney_*`), rehomed from MerchantFrame.xml ═══════════
--
-- The OTHER kit — see this file's header for why there are two and what the split is. It
-- lives beside `MoneyFrame_*` rather than inside a window because eight windows share it:
-- merchant, bank, trade, mail, quest, quest log, taxi and trainer. Nothing about the kit
-- changed in the move; the `SmallMoneyFrameTemplate` migration the header names is still
-- outstanding and still the director's call.
-- The coin display, quoted from the real SmallMoneyFrame (ref MoneyFrame.xml/lua): a copper
-- amount renders as a number + a 13px coin icon per NONZERO denomination, cut from the one
-- shared texture Interface\MoneyFrame\UI-MoneyIcons by TexCoord (gold 0..0.25, silver
-- 0.25..0.5, copper 0.5..0.75 — ref l.115/74/33), the real collapse that hides zero coins
-- (MoneyFrame.lua l.221-267). Each money display owns three position-"slots"; the XML chains
-- them either left-to-right (row prices + the quest reward: Coin1 leftmost, gold first) or
-- right-to-left (the purses: Coin1 rightmost, copper first). BenillaMoney_Set fills the
-- visible denominations into slots 1.. in DISPLAY order (highestFirst=true for the
-- left-aligned chains) and — the real shrink, MoneyFrame.lua l.202/269 — sizes each slot to
-- exactly its number + its 13px coin (`SetWidth(GetTextWidth() + iconWidth)` there; the
-- same read here, off the number FontString the slot already owns). The static ±4px
-- chain-anchor offsets are the real MONEY_BUTTON_SPACING gap, so a shrunk group packs tight,
-- left-flush in the ltr chains and right-flush in the rtl ones. BenillaMoney_Clear hides
-- all three slots (the real collapse hides, see the helper).
--
-- NOTE (for the loot window's fold): the reusable helpers are
-- BenillaMoney_Set(prefix, copper) and BenillaMoney_Clear(prefix)
-- where `prefix` names the slot frames `<prefix>Coin1..3` (each with a `<prefix>CoinNIcon`
-- texture + `<prefix>CoinNNum` fontstring child). They live in this file.
--
-- They lived in MerchantFrame.xml until 1751 came for that window: the stock
-- `MerchantFrame.xml` off the player's chain has no room for eight other windows'
-- shared helpers, and this file is where the reference keeps money code anyway. A pure
-- move — not the `SmallMoneyFrameTemplate` migration named at the top of this file,
-- which is a re-layout of an approved look and is the director's to call.
BENILLA_COIN_TC = {
{ 0.0, 0.25 }, -- [1] gold
{ 0.25, 0.5 }, -- [2] silver
{ 0.5, 0.75 }, -- [3] copper
}
-- Unused slots HIDE (the real collapse — MoneyFrame.lua l.230/247 goldButton:Hide()), never
-- SetWidth(0): a 0 width means "derive from opposing anchors" to the layout (the client's
-- own G+0x50 convention), not "zero px". Fills are contiguous from Coin1, so a chain never
-- anchors THROUGH a hidden slot.
function BenillaMoney_Clear(prefix)
for i = 1, 3 do
getglobal(prefix .. "Coin" .. i):Hide()
end
end
function BenillaMoney_Set(prefix, copper, highestFirst)
copper = copper or 0
local vals = {
math.floor(copper / 10000), -- gold
math.floor((copper % 10000) / 100), -- silver
copper % 100, -- copper
}
-- Fill slots 1.. in display order (see the module comment); a zero denomination is
-- skipped (the real collapse), except copper always shows when the whole amount is
-- zero so a free item / empty purse isn't blank.
local first, last, step = 3, 1, -1
if highestFirst then first, last, step = 1, 3, 1 end
local slot = 0
for d = first, last, step do
if vals[d] > 0 or (d == 3 and vals[1] == 0 and vals[2] == 0) then
slot = slot + 1
-- The real shrink: this slot is exactly its number + its 13px coin, and the
-- XML anchor chain re-flows off the new width.
-- Text first, then measure it: the engine answers a width read inside the
-- tick that set the text, so the number IS this number and not the last one.
local num = getglobal(prefix .. "Coin" .. slot .. "Num")
num:SetText(vals[d])
local numW = num:GetStringWidth()
local slotFrame = getglobal(prefix .. "Coin" .. slot)
slotFrame:SetWidth(numW + 13)
slotFrame:Show()
local icon = getglobal(prefix .. "Coin" .. slot .. "Icon")
icon:SetTexture("Interface\\MoneyFrame\\UI-MoneyIcons")
icon:SetTexCoord(BENILLA_COIN_TC[d][1], BENILLA_COIN_TC[d][2], 0, 1)
num:SetWidth(numW)
end
end
for i = slot + 1, 3 do
getglobal(prefix .. "Coin" .. i):Hide()
end
end
-- Recolor the coin *numbers* (the ref's SetMoneyFrameColor — the digits redden, the coin icons
-- keep their own texture). The trainer detail cost calls this white when affordable / red
-- (1.0, 0.1, 0.1) when not; a plain purse never calls it, so its coins stay default. Applies to
-- all 3 slots (hidden ones are harmless) and must be re-called after each BenillaMoney_Set,
-- which fills the numbers but does not reset their color.
function BenillaMoney_SetColor(prefix, r, g, b)
for i = 1, 3 do
getglobal(prefix .. "Coin" .. i .. "Num"):SetTextColor(r, g, b)
end
end
-- ── The editable money-input kit (ref MoneyInputFrame.lua) ────────────────────────────────
-- A <prefix> naming three numeric EditBoxes <prefix>Gold/Silver/Copper — the shared money entry
-- both the send-mail and trade windows inline (decision 0592 P2 builds it here, the money home,
-- so both reuse it). GetCopper folds the three digits into a copper total; SetCopper splits a
-- copper amount back across the boxes. SetCopper is a *programmatic* fill, so it must not re-fire
-- a box's OnTextChanged value-changed callback (the loop the reference's `expectChanges` counter
-- guards, MoneyInputFrame.lua l.44-73). Because benilla fires OnTextChanged synchronously inside
-- SetText, a bracket flag is exact and simpler than the counter: a window's box calls
-- BenillaMoneyInput_Consume(prefix) at the top of its OnTextChanged — false (swallow) while a
-- SetCopper is in flight, true for a genuine keystroke.
BENILLA_MONEYINPUT_SUPPRESS = {}
function BenillaMoneyInput_ResetMoney(prefix)
getglobal(prefix .. "Gold"):SetText("")
getglobal(prefix .. "Silver"):SetText("")
getglobal(prefix .. "Copper"):SetText("")
end
function BenillaMoneyInput_GetCopper(prefix)
local function n(box) return tonumber(getglobal(prefix .. box):GetText()) or 0 end
return n("Gold") * 10000 + n("Silver") * 100 + n("Copper")
end
function BenillaMoneyInput_ClearFocus(prefix)
getglobal(prefix .. "Gold"):ClearFocus()
getglobal(prefix .. "Silver"):ClearFocus()
getglobal(prefix .. "Copper"):ClearFocus()
end
function BenillaMoneyInput_SetCopper(prefix, money)
money = money or 0
local parts = {
Gold = math.floor(money / 10000),
Silver = math.floor((money % 10000) / 100),
Copper = money % 100,
}
BENILLA_MONEYINPUT_SUPPRESS[prefix] = true
for box, val in pairs(parts) do
local eb = getglobal(prefix .. box)
if (tonumber(eb:GetText()) or 0) ~= val then
eb:SetText(val == 0 and "" or tostring(val))
end
end
BENILLA_MONEYINPUT_SUPPRESS[prefix] = false
end
function BenillaMoneyInput_Consume(prefix)
return not BENILLA_MONEYINPUT_SUPPRESS[prefix]
end
-- ── What a coin is worth ─────────────────────────────────────────────────────────────────
-- Published globals: an addon that formats a price does the same split we do below, and
-- reads these rather than spelling 100 twice.
+67
View File
@@ -320,6 +320,73 @@
table.insert(UIPARENT_MANAGED_POSITION_LISTENERS, fn)
end
-- ── The model-pane rotation handlers (ref UIParent.lua l.1421-1462) ──────────────────
--
-- `Model_OnLoad` / `Model_RotateLeft` / `Model_RotateRight` / `Model_OnUpdate` — the four
-- the reference's dress-up room, pet paper doll, stable and character sheet all drive their
-- turntable with. Verbatim, in this file because this file is our `UIParent.lua`.
--
-- Everything they reach for is already here: `ROTATIONS_PER_SECOND` at the top of this
-- file (the reference puts it on its own line 2), `PI` as an engine global, and
-- `SetRotation` on the model panes that chain through `PlayerModel`
-- (`script/modelframe.rs` — a plain `<Model>` does NOT acquire it, and neither does the
-- reference's).
--
-- **What this does and does not buy, said plainly.** It removes these four names from the
-- gap list of `DressUpFrame.xml`, `PetPaperDollFrame.xml`, `PetStable.xml` and
-- `PaperDollFrame.xml`, and it makes the rotation STATE correct — the angle the API reads
-- back is the angle the reference would have. It does not make a model appear: this engine
-- holds the model pane's scene and draws no pixels into it, which is the one gap those four
-- windows still stand behind. Correct state under an unrendered pane is worth having on its
-- own — it is what an addon reading `model.rotation` gets — and it is not a stub, because
-- nothing here pretends the pane is drawn.
--
-- The two button-driven arms read `<name>RotateLeftButton`/`RotateRightButton` by
-- concatenation, so they work for any pane that follows the reference's naming and raise
-- loudly for one that does not. That is the reference's own coupling, not ours.
function Model_OnLoad()
this.rotation = 0.61
this:SetRotation(this.rotation)
end
function Model_RotateLeft(model, rotationIncrement)
if ( not rotationIncrement ) then
rotationIncrement = 0.03
end
model.rotation = model.rotation - rotationIncrement
model:SetRotation(model.rotation)
PlaySound("igInventoryRotateCharacter")
end
function Model_RotateRight(model, rotationIncrement)
if ( not rotationIncrement ) then
rotationIncrement = 0.03
end
model.rotation = model.rotation + rotationIncrement
model:SetRotation(model.rotation)
PlaySound("igInventoryRotateCharacter")
end
function Model_OnUpdate(elapsedTime, model, rotationsPerSecond)
if ( not rotationsPerSecond ) then
rotationsPerSecond = ROTATIONS_PER_SECOND
end
if ( getglobal(model:GetName() .. "RotateLeftButton"):GetButtonState() == "PUSHED" ) then
model.rotation = model.rotation + (elapsedTime * 2 * PI * rotationsPerSecond)
if ( model.rotation < 0 ) then
model.rotation = model.rotation + (2 * PI)
end
model:SetRotation(model.rotation)
end
if ( getglobal(model:GetName() .. "RotateRightButton"):GetButtonState() == "PUSHED" ) then
model.rotation = model.rotation - (elapsedTime * 2 * PI * rotationsPerSecond)
if ( model.rotation > (2 * PI) ) then
model.rotation = model.rotation - (2 * PI)
end
model:SetRotation(model.rotation)
end
end
-- ── SecondsToTimeAbbrev (ref UIParent.lua l.1034-1049) ───────────────────────────────
--
-- "2 m", "45 s" — the one-letter duration the buff bar prints under a timed aura. It lived
+68
View File
@@ -1486,6 +1486,59 @@
YES = YES or "Yes"
NO = NO or "No"
-- 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`, 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 (decision 1744).
--
-- It lives here rather than in the loot window because this file is our `StaticPopup.lua`
-- and that is the reference's own home for it. Our transcription of `LootFrame.xml`
-- declared it; the stock file does not, so 1751 moved it where it always belonged.
--
-- No OnCancel (declining leaves the row in the window, and the app drops the pending stash
-- when the window turns over) and no OnUpdate poll: the loot window's own LOOT_CLOSED
-- hides it (`LootFrame.lua: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,
}
CONFIRM_LOOT_DISTRIBUTION = CONFIRM_LOOT_DISTRIBUTION
or "You wish to assign %s to %s. Is this correct?" -- GlobalStrings.lua l.848
-- The master-loot confirmation the group-loot dropdown raises for an at-or-above-threshold
-- item (ref StaticPopup.lua l.85-94, verbatim). It lives HERE because that is the
-- reference's own home for it — this file is our `StaticPopup.lua` — and because 1751 took
-- `LootFrame.xml` to the player's chain: our transcription used to declare this dialog
-- itself, and the stock file does not.
--
-- `data` is the candidate index the dropdown stashed; the SLOT is re-read live from
-- `LootFrame.selectedSlot` at accept time, exactly as the reference does. That is not a
-- shortcut — it is why the row click hides this popup first (`LootFrame.lua:156`), so an
-- accept can never land on a row the player has since clicked away from.
StaticPopupDialogs["CONFIRM_LOOT_DISTRIBUTION"] = {
text = CONFIRM_LOOT_DISTRIBUTION,
button1 = YES,
button2 = NO,
OnAccept = function(data)
GiveMasterLoot(LootFrame.selectedSlot, data)
end,
timeout = 0,
hideOnEscape = 1,
}
-- 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
@@ -1716,6 +1769,20 @@
if dialog then
dialog.data = arg1
end
elseif event == "LOOT_BIND_CONFIRM" then
-- 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 sentence is the same
-- whatever binds; the number is only there to hand back to `LootSlot`.
--
-- This arm used to live in our own `LootFrame.xml`, on the window's event
-- listener, on the reasoning that LOOT_CLOSED already had to hide the dialog and
-- the arc stayed in one file. 1751 took that window to the chain, and the
-- reference's placement — UIParent, beside every other bind confirm — turned out
-- to be the one that survives the window being someone else's.
local dialog = StaticPopup_Show("LOOT_BIND")
if dialog then
dialog.data = arg1
end
elseif event == "USE_BIND_CONFIRM" then
StaticPopup_Show("USE_BIND")
elseif event == "CURSOR_UPDATE" then
@@ -2533,6 +2600,7 @@
self:RegisterEvent("EQUIP_BIND_CONFIRM")
self:RegisterEvent("AUTOEQUIP_BIND_CONFIRM")
self:RegisterEvent("USE_BIND_CONFIRM")
self:RegisterEvent("LOOT_BIND_CONFIRM")
self:RegisterEvent("CURSOR_UPDATE")
</OnLoad>
<OnEvent>BenillaBindConfirmDriver_OnEvent(self)</OnEvent>
+30 -11
View File
@@ -132,7 +132,7 @@ Interface\FrameXML\ItemButtonTemplate.xml
# used to sit 140 lines lower, beside the other shared widget kits, which was only ever tenable
# while nothing above it showed coins. AuctionFrame far below is laid out on it as well (its price
# columns need a money TYPE, which the other kit has no notion of); our other eight coin displays
# still run on MerchantFrame.xml's BenillaMoney_* slot kit, and MoneyFrame.xml's header is where
# still run on the BenillaMoney_* slot kit (which now lives HERE), and this file's header is where
# the two are reconciled.
MoneyFrame.xml
# ══ THE MONEY INPUT KIT — the reference's own, off the player's chain (1751, window 23) ══
@@ -650,16 +650,26 @@ FriendsFrame.xml
# same reason (its FrameXML.toc has RaidFrame.xml on the very next line). Decision 1549.
RaidFrame.xml
GossipFrame.xml
# NOT YET on the chain, and the reason is worth keeping. `PickupMerchantItem` (1786) was the
# last name the *gap report* wanted, the readiness probe then called the stock file CLEAN, and
# both were right about what they measure and wrong about the window: the stock row's `<OnEnter>`
# calls `ShoppingTooltip1:SetMerchantCompareItem(...)`, which we do not have. That is a METHOD,
# and the gap report only reads bare `Name(` globals — see 1787, which fixes the instrument.
# A hover would have raised in play with every check green. The swap waits on that one method.
#
# Its shared `BenillaMoney_*` coin kit HAS moved out, to MoneyFrame.xml where the reference keeps
# money code and where eight windows can reach it without loading this one. That is why the notes
# below now say "grouping" rather than "reuses that file's helpers".
MerchantFrame.xml
# The mail window (decision 0544 P1/P2): the mailbox inbox, open-letter, and send tab over
# the Era mail API (`benilla-ui/src/script/mail.rs`) + `crate::ui_mail`'s feed/drain. Loaded
# right after MerchantFrame because it REUSES that file's global BenillaMoney_Set/_Clear
# coin helpers (postage/enclosed/COD displays) — they must be defined first.
# the Era mail API (`benilla-ui/src/script/mail.rs`) + `crate::ui_mail`'s feed/drain. Kept
# beside MerchantFrame in the NPC-window cluster; its postage/enclosed/COD coin displays use
# the BenillaMoney_* kit, which is defined in MoneyFrame.xml far above.
MailFrame.xml
# The player-to-player trade window (decision 0592 P1): the two-sided offer over the trade
# API (`benilla-ui/src/script/trade.rs`) + `crate::ui_trade`'s feed/drain. Loaded after
# MerchantFrame because it REUSES that file's global BenillaMoney_Set coin helpers (the two
# gold displays) — they must be defined first.
# API (`benilla-ui/src/script/trade.rs`) + `crate::ui_trade`'s feed/drain. Kept beside
# MerchantFrame in the NPC-window cluster; its two gold displays use the BenillaMoney_* kit,
# defined in MoneyFrame.xml far above.
TradeFrame.xml
# ══ THE ITEM-TEXT READER — the reference's own, off the player's chain (1751, window 9) ══
# `ItemTextFrame` and its page chrome, over the `ItemTextGet*` seam
@@ -726,7 +736,7 @@ PetStable.xml
# (`benilla-ui/src/script/auction.rs`) + the app's feed/drain. Kept in the "NPC session
# windows grouped" cluster beside Trainer/Bank/Taxi, and loaded AFTER MoneyFrame (its eight
# money displays are SmallMoneyFrameTemplate, and it registers the AUCTION_DEPOSIT money type
# into that file's MoneyTypeInfo), after MerchantFrame (it reuses that file's global
# into that file's MoneyTypeInfo), after MerchantFrame (grouping only now — that file's global
# BenillaMoneyInput_* helpers for its four money inputs), after ScrollTemplates (four faux lists), after
# UIPanelTemplates (UIPanelButtonTemplate / UIPanelCloseButton / InputBoxTemplate /
# UICheckButtonTemplate), after UIDropDownMenu (the rarity capsule) and after UiPanels (the
@@ -745,7 +755,16 @@ TradeSkillFrame.xml
# TradeSkillFrame.xml defines) already exists when CraftFrame.xml reuses it without
# redefining (CraftFrame.xml's own header comment, deviation 2).
CraftFrame.xml
LootFrame.xml
# ══ THE LOOT WINDOW — the reference's own, off the player's chain (1751) ════════════════════
# It never needed an engine VERB. It needed a widget TYPE: `<LootButton>` is a real registered
# `CreateFrame` type (decision 1788), and registering it as a plain Button would have loaded the
# file and left every row dead — 1.12 has no Lua verb for "take slot N".
#
# Its ONE method, `SetSlot`, is also what the gap report's new `method=` column found from the
# call site while wow-re was deriving it from the vtable (1787). Both columns read 0 now, and
# every verb the stock file calls at runtime — not just at load — was checked by hand against
# the bindings before this line changed.
Interface\FrameXML\LootFrame.xml
# The group-loot roll popups (decision 0591): the Need/Greed/Pass dialogs, a sibling of
# LootFrame.xml (loaded right after it) over the `benilla-ui::script::loot_roll` seam
# (`crate::ui_loot_roll`'s feed/drain). NOT a UIPanel — four `frameStrata="DIALOG"` toplevel
@@ -753,7 +772,7 @@ LootFrame.xml
# GameTooltip (the item/PASS/NEED/GREED hovers), both already loaded above.
GroupLootFrame.xml
# The questgiver window (decision 0088): four sub-panels over the Era quest API. Loaded after
# MerchantFrame (the BenillaMoney coin helpers live there) and GossipFrame (shared parchment
# MerchantFrame (grouping; the BenillaMoney coin helpers now live in MoneyFrame.xml) and GossipFrame (shared parchment
# art conventions); its title/panel-button labels use the auto-centred ButtonText slot.
QuestFrame.xml
# The two guild-charter windows (decision 1672): the guild registrar — the window the "How do I
@@ -779,7 +798,7 @@ Interface\FrameXML\GuildRegistrarFrame.xml
# GuildRegistrarFrame directly above.
Interface\FrameXML\PetitionFrame.xml
# The quest log window (decision 0088 arc, the quest-log slice): durable player state, not an
# NPC session — loaded after MerchantFrame (the BenillaMoney coin helpers it reuses) and
# NPC session — loaded after MerchantFrame (grouping; the coin helpers are in MoneyFrame.xml now) and
# alongside QuestFrame (same arc); the 'L' binding (ui_script/input.rs) calls its
# ToggleQuestLog().
QuestLogFrame.xml
+46
View File
@@ -0,0 +1,46 @@
//! `ChrClasses.dbc`, loaded once and read by everything that asks the client a question about a
//! class.
//!
//! Two consumers today and they are unrelated to each other — [`crate::ui_pet_book`] wants field
//! 4's pet name token, `UnitHasRelicSlot` wants field 16's relic flag — which is exactly why the
//! table does not live inside either of them. It used to live in the pet book, back when the pet
//! token was the only column anyone read.
//!
//! The resource is **absent**, not empty, when the load fails. Both readers then fall to the
//! reference's own degraded answer (`"PET"` for the token, no relic slot for any class), so a
//! missing table costs a warlock the word "Demon" and a paladin their relic branches rather than
//! taking the client down.
use bevy::prelude::*;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
use benilla_formats::ChrClasses;
/// The parsed table — see [`benilla_formats::ChrClasses`] for what each column is and which bytes
/// say so.
#[derive(Resource)]
pub(crate) struct ChrClassTable(pub(crate) ChrClasses);
pub(crate) struct ChrClassesPlugin;
impl Plugin for ChrClassesPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, load_chr_classes.after(AssetSet::Open));
}
}
fn load_chr_classes(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_chr_classes(&mut chain)
};
match loaded {
Ok(table) => commands.insert_resource(ChrClassTable(table)),
Err(e) => warn!(
"chr_classes: ChrClasses.dbc failed to load — every pet book tab reads the client's \
own \"PET\" fallback, so a warlock's says Pet rather than Demon, and no class reads \
as having a relic slot: {e:#}"
),
}
}
+4
View File
@@ -45,6 +45,10 @@ fn payload_icon(script: &benilla_ui::script::UiScript) -> Option<String> {
// Mode 10 — the stabled pet's family icon (decision 1677). Always present: a non-empty
// icon path is the grab's own gate.
CursorPayload::StablePet(p) => Some(p.texture),
// Mode 5 — the vendor row's icon. The reference stores the row's `ItemDisplayInfo` id
// (`0xb4d8ec`) and resolves the art from it; we carry the resolved path, so this is the
// same picture one hop later.
CursorPayload::Merchant(m) => m.texture,
}
}
+3
View File
@@ -48,6 +48,7 @@ mod capture;
mod char_create;
mod char_select;
mod chat_bubble;
mod chr_classes;
mod cinematic;
mod combat_text;
mod cooldowns;
@@ -178,6 +179,7 @@ use bevy::prelude::*;
use blob_shadow::BlobShadowPlugin;
use bowstring::BowstringPlugin;
use camera_shake::CameraShakePlugin;
use chr_classes::ChrClassesPlugin;
use cinematic::CinematicPlugin;
use creature_anim::CreatureAnimPlugin;
use cursor::CursorPlugin;
@@ -705,6 +707,7 @@ pub fn run(build: BuildId) -> AppExit {
// so this renders the ten packed words the last `SMSG_PET_SPELLS` delivered and sends
// intents back. After UiActionPlugin (shares `Spells` and the cooldown triple's clock).
.add_plugins(UiPetPlugin)
.add_plugins(ChrClassesPlugin)
.add_plugins(UiPetBookPlugin)
// The pet's paper-doll stat block (happiness/loyalty/XP/training points). Its own plugin
// because it runs off descriptor fields and two DBC tables rather than off `SMSG_PET_SPELLS`.
+12
View File
@@ -1149,6 +1149,18 @@ pub(crate) enum ClientCommand {
entry: u32,
count: u8,
},
/// Buy into a **named** container slot (`CMSG_BUY_ITEM_IN_SLOT`, decision 1786) — what a
/// vendor row dragged off the merchant window and dropped into a bag or an equipment slot
/// sends, as against [`ClientCommand::BuyItem`]'s auto-place from a plain row click.
/// `bag_guid` is the container object's guid, or the player's own for the backpack and the
/// equipment slots. Same refusal shape as `BuyItem` — `SMSG_BUY_FAILED`.
BuyItemInSlot {
vendor: u64,
entry: u32,
bag_guid: u64,
bag_slot: u8,
count: u8,
},
/// Sell an item to a vendor (`CMSG_SELL_ITEM`, decision 0081 phase 4's sell affordance):
/// `item_guid` is the concrete bag item, `count` 0 = the whole stack. Success is silent (the item
/// vanishes + coinage rises via `UPDATE_OBJECT`); refusal answers `SMSG_SELL_ITEM`'s error shape
+7
View File
@@ -973,6 +973,13 @@ fn writer_loop(
entry,
count,
} => w.buy_item(vendor, entry, count),
ClientCommand::BuyItemInSlot {
vendor,
entry,
bag_guid,
bag_slot,
count,
} => w.buy_item_in_slot(vendor, entry, bag_guid, bag_slot, count),
ClientCommand::SellItem {
vendor,
item_guid,
+10 -2
View File
@@ -178,7 +178,15 @@ fn play_item_gesture_sounds(
};
let mut play = |payload: &CursorPayload, gesture: CursorGesture| match payload {
CursorPayload::Item(item) => {
// Mode 5 shares the ITEM arm, not the generic one: the vendor grab `0x4950f0` is handed
// the row's `ItemDisplayInfo` id precisely so the cursor gets that item's own pickup and
// putdown sounds, the same pair a bag item gets.
CursorPayload::Item(_) | CursorPayload::Merchant(_) => {
let item_id = match payload {
CursorPayload::Item(i) => i.item_id,
CursorPayload::Merchant(m) => m.item_id,
_ => unreachable!("guarded by the arm's own pattern"),
};
let (Some(displays), Some(sounds)) = (&displays, &sounds) else {
return;
};
@@ -186,7 +194,7 @@ fn play_item_gesture_sounds(
// lookup, not an ask; a genuinely in-flight template resolves silent, like the
// client's null-record return.
let Some(display_id) = items
.template(item.item_id, 0, &net)
.template(item_id, 0, &net)
.map(|t| t.display_info_id)
else {
return;
+20
View File
@@ -66,6 +66,26 @@ mod reticle;
pub(crate) mod ring;
mod scan;
/// The standing pair — `Faction.dbc` and our own reputation table — as ONE [`SystemParam`].
///
/// Every consumer of either takes both: [`ring_reaction`], [`can_attack`], [`can_assist`],
/// [`can_interact`] and `ui_unit::faction_group` are all `(factions, reputations)` functions, and
/// no system has ever wanted one without the other. Bundling them says that, and buys back a slot
/// against Bevy's 16-param tuple limit — which `ui_tooltip`'s hover system hit the moment it
/// legitimately needed one more resource.
///
/// `factions` is `Option` for the reason its flat form always was: it belongs to the asset layer,
/// and a UI-only harness runs with no client data rather than panicking on system validation.
///
/// `ui_unit::feed_units` is the other system that takes the pair (22 call sites). It has not
/// adopted this yet — its own crowding was solved by [`crate::ui_unit::UnitStores`] — and it
/// should, next time that file is opened for anything.
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct ReactionInputs<'w> {
pub(crate) factions: Option<Res<'w, Factions>>,
pub(crate) reputations: Res<'w, crate::net::Reputations>,
}
pub(crate) use cursor_mode::{
corpse_mouseover_eligible, CursorKind, WorldCursor, GO_TYPE_GENERIC, SERVICE_RANGE_SQ,
};
+14 -6
View File
@@ -928,12 +928,20 @@ pub(super) const INVTYPE_AMMO: u32 = 24;
/// actual equip still round-trips through `SMSG_INVENTORY_CHANGE_FAILURE`
/// (`EQUIP_ERR_CANT_DUAL_WIELD`) if the class can't. Simpler than threading class into every
/// caller for a highlight-only consequence.
/// - **`INVTYPE_RELIC` answers no slots** (the server's own table gates it per-class onto the
/// ranged slot for Paladin/Druid/Shaman/Warlock librams/idols/totems): decision 0208 already
/// established the relic slot is vanilla-UI-invisible (`UnitHasRelicSlot` always false, no
/// relic slot ever shows on the 1.12 paper doll), so resolving this precisely drives no visible
/// interaction — a named, harmless gap rather than threading class through for a slot nothing
/// ever shows.
/// - **`INVTYPE_RELIC` answers no slots, and this one is a real gap** (decision 1785). It used to
/// be excused on the grounds that the relic slot is vanilla-UI-invisible — that
/// `UnitHasRelicSlot` is always false and no relic slot ever shows on the 1.12 paper doll.
/// **That is false.** `ChrClasses.dbc` field 16 is set for Paladin, Shaman and Druid, and
/// `IsValidForSlot 0x5da1d0`'s `slot == 0x11` leg requires `(InventoryType == 28) ==
/// hasRelicSlot` — so for those three classes INVSLOT 17 takes a relic and refuses a ranged
/// weapon, and for the other six the reverse.
///
/// What remains true is the *consequence*: this function drives the `CURSOR_UPDATE` highlight
/// only, and the equip still round-trips through `SMSG_INVENTORY_CHANGE_FAILURE`. So the gap
/// costs a relic-class player the highlight on their ranged slot when dragging a libram, and
/// over-offers it when dragging a bow. Closing it needs the class threaded through
/// [`crate::ui_char::slot_view`], which is the same threading the dual-wield bullet above
/// declines — one change would close both.
pub(super) fn find_equip_slot(inventory_type: u32) -> Vec<u8> {
// Live-API ids (`char_stats::SLOT_INFO`'s own numbering): wire `EQUIPMENT_SLOT_*` + 1. The
// ammo slot is the client's own `GetInventorySlotInfo("AmmoSlot")` == 0 (not a real equip slot;
+21 -3
View File
@@ -1031,10 +1031,28 @@ fn feed_loot(
}
}
// A content change while open (async name landed, a row removed, coin cleared) → repaint,
// keeping the current page (LOOT_UPDATE, the merchant's MERCHANT_UPDATE twin — this replaces
// Blizzard's per-button LOOT_SLOT_CLEARED optimization with a full re-snapshot, exactly as
// the merchant seam replaced per-row stock updates).
// keeping the current page.
//
// **Both events fire, and the per-slot one is not optional any more** (1751). This used to
// send `LOOT_UPDATE` alone, on the reasoning that a full re-snapshot replaces Blizzard's
// per-button `LOOT_SLOT_CLEARED` optimization the way the merchant seam replaced per-row
// stock updates. That was defensible while we owned `LootFrame.xml`. The STOCK file hangs
// real behaviour off `LOOT_SLOT_CLEARED` and only off it: its arm hides the button in
// place AND, when that empties the page, calls `LootFrame_PageDown` (`LootFrame.lua:38-50`
// — "try to move second page of loot items to the first page"). `LOOT_UPDATE` reaches
// none of that. Send only the summary and looting out a page leaves the player staring at
// an empty window with a live Down arrow.
//
// So the reference's vocabulary is spoken as the reference speaks it: one
// `LOOT_SLOT_CLEARED` per row that went away, carrying its 1-based row as `arg1`, and then
// the summary repaint.
(Some(before), Some(after)) => {
for (i, was) in before.rows.iter().enumerate() {
let gone = was.is_some() && after.rows.get(i).is_none_or(Option::is_none);
if gone {
script.fire_event("LOOT_SLOT_CLEARED", vec![ScriptValue::Int(i as i64 + 1)]);
}
}
script.fire_event("LOOT_UPDATE", vec![]);
// The reference keeps the two apart: `LOOT_UPDATE` repaints the rows, while a changed
// candidate list refreshes the open dropdown in place without re-toggling it
+64 -4
View File
@@ -28,10 +28,11 @@ use benilla_ui::script::{ItemStatsHead, MerchantItem, MerchantState, ScriptValue
use crate::entities::ItemDisplays;
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{ClientCommand, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_items::item_link;
use crate::net::{ClientCommand, Guid, NetCommands, ObjectStore, SelfPlayer};
use crate::ui_items::{item_link, slot_guid, wire_pos};
use crate::ui_script::UiInput;
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
use benilla_protocol::messages::BAG_PLAYER_INVENTORY;
/// The wire's "unlimited stock" sentinel for a vendor row's `current_count` (vmangos
/// `ItemHandler.cpp`); the Lua side sees it as `numAvailable == -1`.
@@ -528,14 +529,17 @@ fn drain_merchant(
script: Option<NonSendMut<UiScript>>,
mut open: ResMut<MerchantOpen>,
commands: Res<NetCommands>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
items: Res<Items>,
) {
let Some(mut script) = script else {
return;
};
let self_pair = self_q.iter().next();
let self_store = self_pair.map(|(store, _)| store);
for index in script.take_merchant_buybacks() {
let Some(vendor) = open.vendor else { continue };
let Some(store) = self_q.iter().next() else {
let Some(store) = self_store else {
continue;
};
let order = buyback_order(&store.0);
@@ -577,6 +581,62 @@ fn drain_merchant(
None => debug!("ui_merchant: BuyMerchantItem({index}) out of range — ignored"),
}
}
// `PickupMerchantItem`'s SELL arm — a bag item dropped on the vendor window. Resolved the
// same way the bag-click sell route resolves it: the wire addresses the item by its concrete
// guid, not by a slot, so a slot that emptied under us is a no-op rather than a wrong sell.
//
// **No merchant-open gate**, deliberately: `0x4fb760`'s sell arm runs before the vendor check
// (`0x4fb7f7` gates only the grab). The vendor guid is still needed to address the packet, so
// in practice a closed window drops it — but the ORDER matters, because it is why dropping an
// item on a vendor window that is closing still sells.
for (bag, slot) in script.take_merchant_cursor_sells() {
let Some(vendor) = open.vendor else { continue };
let slot0 = u8::try_from(slot.saturating_sub(1)).unwrap_or(0);
match self_store.and_then(|s| slot_guid(&s.0, bag, slot0, &items)) {
Some(item_guid) => {
debug!("ui_merchant: cursor sell bag {bag} slot {slot} (item {item_guid:#x})");
let _ = commands.0.send(ClientCommand::SellItem {
vendor,
item_guid,
count: 0,
});
}
None => debug!("ui_merchant: cursor sell on an empty slot ({bag}, {slot}) — ignored"),
}
}
// A held vendor row dropped into a slot — `CMSG_BUY_ITEM_IN_SLOT`, count 1 (the reference's
// own hardcoded stack count on all three drop paths).
for (bag, slot, entry) in script.take_merchant_slot_buys() {
let Some(vendor) = open.vendor else { continue };
let (Some(store), Some((bag_index, bag_slot))) = (self_store, wire_pos(bag, slot)) else {
debug!("ui_merchant: slot buy to an unaddressable slot ({bag}, {slot}) — ignored");
continue;
};
// The wire wants the destination CONTAINER'S GUID, not its slot index: the player's own
// for the backpack, the keyring, the bank and the equipment slots (all of which live in
// the player's array, `BAG_PLAYER_INVENTORY`), and the bag object's for a real bag.
let bag_guid = if bag_index == BAG_PLAYER_INVENTORY {
self_pair.map(|(_, g)| g.0)
} else {
store.0.player_inv_slot(bag_index).filter(|g| *g != 0)
};
match bag_guid {
Some(bag_guid) => {
debug!(
"ui_merchant: slot buy entry {entry} → bag {bag_guid:#x} slot {bag_slot} \
(lua {bag}, {slot})"
);
let _ = commands.0.send(ClientCommand::BuyItemInSlot {
vendor,
entry,
bag_guid,
bag_slot,
count: 1,
});
}
None => debug!("ui_merchant: slot buy into an absent bag {bag} — ignored"),
}
}
if script.take_merchant_close() {
debug!("ui_merchant: client-side close (no packet)");
open.clear();
+17 -6
View File
@@ -98,6 +98,9 @@ pub(crate) const GROUP_MEMBER_SUBGROUP: u8 = 0x07;
/// roster status byte overlays both (the descriptor never carries connected/AFK/DND).
#[allow(clippy::too_many_arguments)] // a Bevy system's param list IS its dependency set
pub(super) fn feed_party(
// `ChrClasses.dbc` field 16 — `UnitHasRelicSlot`'s only input. Absent when the client data
// failed to load, in which case no class reads as having a relic slot.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
script: Option<NonSendMut<UiScript>>,
group: Res<GroupState>,
index: Res<GuidIndex>,
@@ -123,6 +126,7 @@ pub(super) fn feed_party(
return;
};
let (fed, vm_reset) = fed.get_reset(&script);
let chr = classes.as_deref().map(|t| &t.0);
// The gate (1439): the group state, any member/self descriptor change or DESPAWN (a removed
// store is invisible to `Changed`), the streamed-guid index the merged view resolves
// through, the two catalogs, the name cache by its landed counter, and our own leaf area
@@ -292,6 +296,7 @@ pub(super) fn feed_party(
index.0.get(&m.guid).and_then(|e| stores.get(*e).ok()),
&group,
own_group.clone(),
chr,
)
});
if fed.units[i] != snap {
@@ -351,7 +356,7 @@ pub(super) fn feed_party(
if Some(*guid) == self_guid {
let (_, store) = self_pair?;
let name = names.peek(*guid).map(str::to_string);
let mut s = crate::ui_unit::snapshot(store, name, 0);
let mut s = crate::ui_unit::snapshot(store, name, 0, chr);
s.is_player = true;
s.guid = *guid;
s.raid_target = group.raid_target_index(*guid);
@@ -369,6 +374,7 @@ pub(super) fn feed_party(
index.0.get(guid).and_then(|e| stores.get(*e).ok()),
&group,
own_group.clone(),
chr,
))
}
});
@@ -720,10 +726,14 @@ fn member_unit_state(
store: Option<&ObjectStore>,
group: &GroupState,
own_group: Option<String>,
// `ChrClasses.dbc`, for the relic column alone — see [`crate::ui_unit::snapshot`]. Only the
// in-range leg can use it; the out-of-range roster record carries no class byte to key on,
// so an out-of-range paladin reads no relic slot until their object streams back.
classes: Option<&benilla_formats::ChrClasses>,
) -> UnitState {
let mut s = match store {
// In visibility range: the live descriptor is the truth (the server keeps it current).
Some(store) => crate::ui_unit::snapshot(store, Some(m.name.clone()), 0),
Some(store) => crate::ui_unit::snapshot(store, Some(m.name.clone()), 0, classes),
// Out of range: **the roster record** — which is not only the `PARTY_MEMBER_STATS` wire
// any more (decision 1640). It is seeded from the member's own live descriptor at the
// instant their object leaves the manager (`0x5f0880`, `net::apply::group::
@@ -1489,7 +1499,7 @@ mod tests {
max_power: Some(1000),
..PartyMemberStatsInfo::default()
};
let s = member_unit_state(&m, Some(&record), None, &GroupState::default(), None);
let s = member_unit_state(&m, Some(&record), None, &GroupState::default(), None, None);
assert_eq!(
(s.health, s.max_health),
(2400, 3000),
@@ -1506,7 +1516,7 @@ mod tests {
// And a member with no record at all still reads as an existing, connected player — the
// seat law means this cannot happen for a real roster, but the mapping must not invent
// numbers when it does.
let bare = member_unit_state(&m, None, None, &GroupState::default(), None);
let bare = member_unit_state(&m, None, None, &GroupState::default(), None, None);
assert_eq!((bare.health, bare.max_health, bare.power), (0, 0, 0));
assert!(bare.exists);
}
@@ -1533,7 +1543,7 @@ mod tests {
status: Some(member_status::ONLINE | member_status::DEAD),
..PartyMemberStatsInfo::default()
};
let s = member_unit_state(&m, Some(&dead), None, &GroupState::default(), None);
let s = member_unit_state(&m, Some(&dead), None, &GroupState::default(), None, None);
assert!(
s.dead,
"the record says dead even though the roster echo does not"
@@ -1544,7 +1554,7 @@ mod tests {
status: Some(member_status::ONLINE | member_status::GHOST),
..PartyMemberStatsInfo::default()
};
let s = member_unit_state(&m, Some(&ghost), None, &GroupState::default(), None);
let s = member_unit_state(&m, Some(&ghost), None, &GroupState::default(), None, None);
assert!(s.ghost);
assert!(!s.dead, "a released ghost is not `dead` — the 0308 §1 trio");
@@ -1560,6 +1570,7 @@ mod tests {
None,
&GroupState::default(),
None,
None,
);
assert!(s.dead);
}
+4 -1
View File
@@ -107,7 +107,10 @@ pub(super) fn feed_pet_unit(
.flatten()
.map(|store| {
let name = names.resolve(pet_guid, &commands).map(str::to_string);
let mut s = snapshot(store, name, 0);
// No `ChrClasses.dbc` here, and none needed: a pet is a UNIT, so the reference's
// own TYPEMASK_PLAYER gate refuses it before the class byte is ever read. Passing
// the table could not change the answer.
let mut s = snapshot(store, name, 0, None);
s.guid = pet_guid;
s
});
+20 -40
View File
@@ -29,7 +29,7 @@
//! | `GetSpellCooldown(id, "pet")` | **bank 1** — [`PetBar::cooldowns`], the pet's own store. The reference reaches it with the same `0x6e2ea0(edx = isPet)` `GetPetActionCooldown` uses (`0x4b40dd`), so a spell on the bar and the same spell in the book read one timer, and they do here too. |
//! | `GetSpellAutocast` | the raw word's bits 31/30 |
//! | `IsCurrentCast` | the pet's own auras — `0x4b36f0`'s pet arm scans `[pet.fields + 0xa4]`, which is exactly the predicate the bar's [`crate::ui_pet`] already applies per slot |
//! | `HasPetSpells`'s token | `ChrClasses.dbc` field 4 (`benilla_formats::PetNameTokens`) |
//! | `HasPetSpells`'s token | `ChrClasses.dbc` field 4, via [`crate::chr_classes`] |
//!
//! ## The two verbs
//!
@@ -50,7 +50,7 @@ use std::time::Instant;
use bevy::prelude::*;
use benilla_formats::{PetNameTokens, SpellDisplay};
use benilla_formats::SpellDisplay;
use benilla_protocol::messages::PetActionEntry;
use benilla_ui::script::{PetBookState, SpellSlotView, UiScript};
@@ -60,47 +60,25 @@ use crate::ui_action::Spells;
use crate::ui_pet::PetBar;
use crate::ui_script::UiInput;
use crate::ui_unit::UnitFeed;
use benilla_assets::{AssetSet, LockRecover, WorldAssets};
/// `ChrClasses.dbc`'s pet-name-token column — absent when the client data failed to load, in which
/// case [`benilla_ui`]'s own `"PET"` fallback stands (the reference's `0x4b44a6` arm).
#[derive(Resource)]
pub(crate) struct PetNameTokenTable(pub(crate) PetNameTokens);
pub(crate) struct UiPetBookPlugin;
impl Plugin for UiPetBookPlugin {
fn build(&self, app: &mut App) {
app.add_systems(Startup, load_pet_name_tokens.after(AssetSet::Open))
.add_systems(
Update,
(
// Rides the unit feed beside the player book's, and before the input pass so a
// tab flipped this frame already reads a populated book.
feed_pet_book
.in_set(UnitFeed)
.before(crate::ui_action::CooldownEvents)
.before(UiInput),
// After the input pass, and — like `ui_pet`'s own drain — writing back into
// `PetBar`, whose next feed carries the mirrored autocast bit.
drain_pet_book.after(UiInput),
),
);
}
}
fn load_pet_name_tokens(mut commands: Commands, assets: Option<Res<WorldAssets>>) {
let Some(assets) = assets else { return };
let loaded = {
let mut chain = assets.chain.lock_recover();
benilla_formats::load_pet_name_tokens(&mut chain)
};
match loaded {
Ok(tokens) => commands.insert_resource(PetNameTokenTable(tokens)),
Err(e) => warn!(
"ui_pet_book: ChrClasses.dbc failed to load — every pet book tab reads the client's \
own \"PET\" fallback, so a warlock's says Pet rather than Demon: {e:#}"
),
app.add_systems(
Update,
(
// Rides the unit feed beside the player book's, and before the input pass so a
// tab flipped this frame already reads a populated book.
feed_pet_book
.in_set(UnitFeed)
.before(crate::ui_action::CooldownEvents)
.before(UiInput),
// After the input pass, and — like `ui_pet`'s own drain — writing back into
// `PetBar`, whose next feed carries the mirrored autocast bit.
drain_pet_book.after(UiInput),
),
);
}
}
@@ -116,7 +94,7 @@ fn feed_pet_book(
script: Option<NonSendMut<UiScript>>,
bar: Res<PetBar>,
spells: Option<Res<Spells>>,
tokens: Option<Res<PetNameTokenTable>>,
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
self_q: Query<&ObjectStore, With<SelfPlayer>>,
index: Res<GuidIndex>,
stores: Query<&ObjectStore>,
@@ -144,7 +122,9 @@ fn feed_pet_book(
// A warlock reading "Demon" while a hunter reads "Pet" is the whole of it.
token: self_q.single().ok().and_then(|s| {
let class = u32::from(s.0.unit_class()?);
tokens.as_deref().map(|t| t.0.token(class).to_string())
classes
.as_deref()
.map(|t| t.0.pet_name_token(class).to_string())
}),
slots: Vec::new(),
};
@@ -65,11 +65,14 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
for file in BAG_UI {
load_xml(&s, file);
}
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize — the
// shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.110 vs 478), and the
// kit's backdrop reads TOOLTIP_DEFAULT_COLOR out of GameTooltip.xml (already in BAG_UI).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
// The loot window is the reference's own since 1751, and its dependency list grew with the
// swap — `LOOT_UI` carries the why for each entry. Loaded whole rather than cherry-picked:
// `PartyFrame.xml` is needed at LOAD (MAX_PARTY_MEMBERS) and skipping it fails loudly, but
// `ItemButtonTemplate` is only a warning and skipping it fails silently.
for file in super::test_ui::LOOT_UI {
load_xml(&s, file);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set, BankFrame's purse helper
s.set_money(0);
s.set_container(0, Some(one_item_backpack()));
@@ -1161,6 +1161,7 @@ pub(super) fn drive_script(
CursorPayload::Macro(m) => m.texture,
CursorPayload::PetAction(p) => p.texture,
CursorPayload::StablePet(p) => Some(p.texture),
CursorPayload::Merchant(m) => m.texture,
});
if let (Some(texture), Some(pos)) = (texture, window.cursor_position()) {
if let Some(handle) = assets
@@ -264,10 +264,13 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
&[],
&[
"MerchantFrame.xml",
// LootFrame.xml owns GroupLootDropDown, whose OnLoad needs the dropdown kit — which
// in turn reads TOOLTIP_DEFAULT_COLOR from GameTooltip.xml (BAG_UI's, ahead of this).
// The loot window is the reference's own since 1751 — `test_ui::LOOT_UI` carries
// what it needs and why, and PartyFrame's `MAX_PARTY_MEMBERS` is needed at LOAD.
"UIDropDownMenu.xml",
"LootFrame.xml",
"UnitPopup.xml",
"PartyFrame.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\LootFrame.xml",
],
);
s.set_money(0);
@@ -592,7 +595,12 @@ fn nothing_opens_behind_the_world_map_and_escape_closes_it_first() {
"ScrollTemplates.xml",
"WorldMapFrame.xml",
"MerchantFrame.xml",
"LootFrame.xml",
// The loot window is the reference's own since 1751 — see `test_ui::LOOT_UI` for what
// each of these buys; `PartyFrame`'s MAX_PARTY_MEMBERS is needed at LOAD time.
"UnitPopup.xml",
"PartyFrame.xml",
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"Interface\\FrameXML\\LootFrame.xml",
]);
s.run("ToggleWorldMap()").unwrap();
+74 -85
View File
@@ -92,17 +92,17 @@ fn coin_and_two_items() -> LootState {
fn shipped_loot_frame_drives_end_to_end() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
assert_eq!(
load_xml(&s, "LootFrame.xml"),
10,
"window + 4 rows + up + down + close + GroupLootDropDown and its template's own $parentButton"
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml"),
34,
"the STOCK file's own shape (1751): the window, its portrait overlay and Next/Prev art, \
four LootButton rows each carrying ItemButtonTemplate's sub-frames, the two pagers, the \
close button, and GroupLootDropDown with the dropdown template's own children. Our \
transcription materialized 10 — a count is a fingerprint of a file, not a property of a \
loot window, and this one is now the reference's"
);
// Hidden by default: no coin icon on screen, left slot empty.
@@ -133,7 +133,9 @@ fn shipped_loot_frame_drives_end_to_end() {
.unwrap();
assert_eq!(vis, (true, true, true, false), "coin + 2 items, 4th hidden");
// Only 3 items ⇒ no pager.
assert!(!s.eval::<bool>("return LootDownButton:IsVisible()").unwrap());
assert!(!s
.eval::<bool>("return LootFrameDownButton:IsVisible()")
.unwrap());
s.resolve();
let quads = s.extract();
@@ -240,6 +242,14 @@ fn shipped_loot_frame_drives_end_to_end() {
let mut coin_looted = coin_and_two_items();
coin_looted.rows[0] = None;
s.set_loot(Some(coin_looted));
// The app's own pair, in the app's own order (`ui_loot`): the per-slot event first — the
// stock file hangs "hide the button in place" off it and only off it — then the summary
// repaint. Firing LOOT_UPDATE alone leaves the looted row drawn, which is what a client that
// sent only the summary would actually show.
s.fire_event(
"LOOT_SLOT_CLEARED",
vec![benilla_ui::script::ScriptValue::Int(1)],
);
s.fire_event("LOOT_UPDATE", vec![]);
let vis2: (bool, bool, bool) = s
.eval(
@@ -254,7 +264,7 @@ fn shipped_loot_frame_drives_end_to_end() {
);
// The close button hides the window → OnHide → CloseLoot() queues the release intent.
s.run("BenillaLootCloseButton_OnClick()").unwrap();
s.run("LootCloseButton:Click()").unwrap();
assert!(
s.take_loot_close(),
"closing the window releases the loot (OnHide → CloseLoot)"
@@ -275,14 +285,10 @@ fn shipped_loot_frame_drives_end_to_end() {
fn loot_empty_roll_plays_the_empty_open_kit() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// A normal, non-empty loot open queues no sound (the normal open kit is C-side).
s.set_loot(Some(coin_and_two_items()));
@@ -320,14 +326,10 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
quads.iter().any(|q| {
matches!(&q.content, QuadContent::Texture { path: Some(p), .. } if p.contains(needle))
@@ -375,14 +377,10 @@ fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
fn shipped_loot_frame_pages_five_items() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
let rows: Vec<Option<LootRow>> = (0..5)
.map(|i| {
@@ -415,7 +413,7 @@ fn shipped_loot_frame_pages_five_items() {
.unwrap();
assert_eq!(page1, (true, true, true, false), "page 1 shows 3 rows");
let pager1: (bool, bool) = s
.eval("return LootUpButton:IsVisible(), LootDownButton:IsVisible()")
.eval("return LootFrameUpButton:IsVisible(), LootFrameDownButton:IsVisible()")
.unwrap();
assert_eq!(pager1, (false, true), "page 1: Up hidden, Down shown");
@@ -429,13 +427,17 @@ fn shipped_loot_frame_pages_five_items() {
.unwrap();
assert_eq!(page2, (true, true, false), "page 2 shows the last 2 rows");
let pager2: (bool, bool) = s
.eval("return LootUpButton:IsVisible(), LootDownButton:IsVisible()")
.eval("return LootFrameUpButton:IsVisible(), LootFrameDownButton:IsVisible()")
.unwrap();
assert_eq!(pager2, (true, false), "page 2: Up shown, Down hidden");
// Back on page 1, loot all three of its rows: their buttons hide in place, and the emptied
// page advances to page 2 on its own — the ref's LOOT_SLOT_CLEARED tail (LootFrame.lua
// l.38-50: every button hidden + Down visible → PageDown).
//
// The events are the app's own, in the app's own order: one LOOT_SLOT_CLEARED per row that
// went away (`ui_loot`'s emitter), then the summary LOOT_UPDATE. The auto-advance hangs off
// the per-slot event and ONLY off it, which is why the app fires both.
s.run("LootFrame_PageUp()").unwrap();
let mut cleared = rows;
cleared[0] = None;
@@ -446,6 +448,12 @@ fn shipped_loot_frame_pages_five_items() {
master_candidates: Vec::new(),
rows: cleared,
}));
for row in 1..=3 {
s.fire_event(
"LOOT_SLOT_CLEARED",
vec![benilla_ui::script::ScriptValue::Int(row)],
);
}
s.fire_event("LOOT_UPDATE", vec![]);
assert_eq!(
s.eval::<i64>("return LootFrame.page").unwrap(),
@@ -466,14 +474,10 @@ fn shipped_loot_frame_pages_five_items() {
fn shipped_loot_pushed_to_center_by_merchant() {
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
// LootFrame.xml owns GroupLootDropDown, whose OnLoad calls UIDropDownMenu_Initialize —
// the shipped manifest loads the dropdown kit far ahead of it (benilla.toc l.64 vs 383).
load_xml(&s, "GameTooltip.xml"); // TOOLTIP_DEFAULT_COLOR, which the dropdown backdrop reads
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
load_xml(&s, "MerchantFrame.xml");
@@ -546,15 +550,10 @@ fn shipped_loot_pushed_to_center_by_merchant() {
fn the_loot_window_draws_over_the_party_frames() {
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");
// PartyFrame's per-member dropdown OnLoad walks the whole popup kit (app manifest order).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "UnitPopup.xml");
load_xml(&s, "PartyFrame.xml");
load_xml(&s, "LootFrame.xml");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// The party frame up FIRST, the window second — the order that cannot be what saves it.
s.eval::<()>("PartyMemberFrame1:Show()").unwrap();
@@ -605,14 +604,12 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
const WOOL_LINK: &str = "|cffffffff|Hitem:2589:0:0:0|h[Wool Cloth]|h|r";
let mut s = UiScript::new().unwrap();
s.set_screen_size(1024.0, 768.0);
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
for file in [
"Fonts.xml",
"MoneyFrame.xml",
"UiPanels.xml",
"UIParent.xml", // BenillaChatEdit_InsertLink, the shared shift-insert helper
"GameTooltip.xml", // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
"UIDropDownMenu.xml", // LootFrame owns GroupLootDropDown, initialized at its OnLoad
"LootFrame.xml",
"Interface\\FrameXML\\LootFrame.xml",
"DressUpFrame.xml",
"Interface\\FrameXML\\UIMenu.xml", // the kit ChatMenu/EmoteMenu/VoiceMacroMenu build from
"ChatFrame.xml",
@@ -747,12 +744,10 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
fn shipped_loot_frame_hands_a_master_row_to_a_candidate() {
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");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
let row = |name: &str, quality: u32| {
Some(LootRow {
@@ -891,12 +886,10 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
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");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
// A raid, so the dropdown takes its nested arm (GetNumRaidMembers() > 0 is the whole gate).
s.set_party(PartyState {
@@ -974,12 +967,10 @@ fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
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");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
let _ = s.take_loot_picks();
@@ -1063,12 +1054,10 @@ fn the_loot_bind_confirm_raises_the_dialog_and_okay_calls_loot_slot() {
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");
for f in super::test_ui::LOOT_UI {
load_xml(&s, f);
}
load_xml(&s, "Interface\\FrameXML\\LootFrame.xml");
s.set_loot(Some(coin_and_two_items()));
s.fire_event("LOOT_OPENED", vec![]);
let quads = s.extract();
@@ -566,11 +566,13 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// The quality→colour table (BENILLA_LOOT_QUALITY_COLORS) ships in LootFrame.xml; at runtime
// The quality→colour table is `ITEM_QUALITY_COLORS`, and Fonts.xml is its home — this used
// to load the whole loot window for `BENILLA_LOOT_QUALITY_COLORS`, an alias our own
// LootFrame.xml set to that same table. 1751 took that window to the chain and the alias went
// with it (GameTooltip.xml reads the real name now), so the dependency is gone. At runtime
// every FrameXML file loads before any hover fires, so load it here too — with the dropdown
// kit its GroupLootDropDown initializes against at load (benilla.toc l.64 vs 383).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
load_xml(&s, "MerchantFrame.xml");
s.set_merchant(Some(MerchantState {
@@ -977,10 +977,11 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
load_xml(&s, "MoneyFrame.xml");
load_xml(&s, "UiPanels.xml");
load_xml(&s, "GameTooltip.xml");
// LootFrame.xml is here for BENILLA_LOOT_QUALITY_COLORS (the quality→colour table); it
// This used to load LootFrame.xml for `BENILLA_LOOT_QUALITY_COLORS`, an alias our own copy of
// that window set to `ITEM_QUALITY_COLORS`. 1751 retired the window and the alias with it, so
// Fonts.xml alone is the source now. Formerly: it
// needs the dropdown kit its GroupLootDropDown initializes against (benilla.toc l.64 vs 383).
load_xml(&s, "UIDropDownMenu.xml");
load_xml(&s, "LootFrame.xml");
load_xml(&s, "MerchantFrame.xml");
// ScrollTemplates.xml + UIPanelTemplates.xml are NOT optional: the detail pane inherits
// UIPanelScrollFrameTemplate, and a MISSING template is a loader *warning*, not an error —
@@ -389,11 +389,28 @@ mod tests {
/// mostly noise. Here the manifest is loaded first and `_G` is read after, so the answer is
/// what this client *actually* answers to.
///
/// Two crudenesses, stated because they decide how to read the output. The `Name(` scan counts
/// **Three columns, and the third one exists because its absence cost a swap.** `engine=` and
/// `fx=` read bare `Name(` globals. `method=` reads `:Name(` calls against the method surface
/// this engine actually exposes — built by walking `getmetatable(w).__index` over one widget
/// of every type, plus the tooltip.
///
/// Until 1787 the method sites were **silently dropped**: a name in neither the engine nor the
/// FrameXML half of `1.12-globals.tsv` was assumed to be a widget method and skipped, on the
/// reasoning that widget methods are not in `_G`. True, and it meant the report could not see
/// a widget method we had *not built*. `MerchantFrame.xml` read `0 engine` and
/// [`chain_readiness_report`] read CLEAN while the stock row's `<OnEnter>` called
/// `ShoppingTooltip1:SetMerchantCompareItem(...)`, which this engine does not have — so the
/// file loaded, every check passed, and hovering a vendor row would have raised in play. Both
/// instruments were right about what they measure. Neither measured the window.
///
/// Two crudenesses remain, stated because they decide how to read the output. Both scans count
/// a call inside an XML comment (1.12 comments out whole blocks — `GuildRegistrarFrame`'s
/// tabard button is one), so it can over-report; and it cannot see a name reached through
/// `getglobal`, so it can under-report. It is for ranking work, not for proving a window done —
/// [`chain_readiness_report`] and the window's own tests are that.
/// tabard button is one), so they can over-report; and neither can see a name reached through
/// `getglobal`, so they can under-report. The `method=` column is also receiver-blind: it asks
/// "does ANY widget type answer to this name", not "does *this* receiver", so a method that
/// exists on the wrong type still reads as present. It is for ranking work, not for proving a
/// window done — [`chain_readiness_report`] and the window's own tests are that, and the
/// paragraph above is what those two are worth on their own.
#[test]
#[ignore = "instrument: run by hand when choosing what to build next"]
fn chain_gap_report() {
@@ -427,6 +444,94 @@ mod tests {
.into_iter()
.collect();
// `:Name(` — a method call, receiver unknown.
let called_methods = |text: &str| -> std::collections::HashSet<String> {
let b: Vec<char> = text.chars().collect();
let mut out = std::collections::HashSet::new();
let mut i = 1;
while i < b.len() {
// `::` is not a method call, and neither is a `:` inside a word.
if b[i - 1] == ':' && (i < 2 || b[i - 2] != ':') && b[i].is_ascii_alphabetic() {
let mut j = i;
while j < b.len() && super::is_word(b[j]) {
j += 1;
}
let mut k = j;
while k < b.len() && b[k] == ' ' {
k += 1;
}
if k < b.len() && b[k] == '(' {
out.insert(b[i..j].iter().collect::<String>());
}
i = j;
continue;
}
i += 1;
}
out
};
// Whether this engine's widgets answer to a method name — **asked**, not enumerated. A
// widget's methods come through an `__index` FUNCTION, so there is no table to walk; the
// only way to know is to look the name up on a real widget. Receiver-blind by design (a
// `:Name(` site does not say what it is called on), so this answers "does ANY widget type
// answer to this name", which is the question that catches a method we never built.
let answers = |s: &UiScript, names: &[String]| -> std::collections::HashSet<String> {
let list = names
.iter()
.map(|n| format!("{n:?}"))
.collect::<Vec<_>>()
.join(",");
s.eval::<Vec<String>>(&format!(
r#"
local want = {{{list}}}
local probes = {{ GameTooltip }}
local types = {{
"Frame", "Button", "CheckButton", "LootButton", "StatusBar", "EditBox",
"ScrollFrame",
"Slider", "ColorSelect", "MessageFrame", "ScrollingMessageFrame",
"SimpleHTML", "Model", "PlayerModel", "DressUpModel", "TabardModel",
"Minimap", "MovieFrame", "Cooldown",
}}
for i = 1, table.getn(types) do
local ok, w = pcall(function()
return CreateFrame(types[i], "BenillaGapProbe" .. i, UIParent)
end)
if ok and w then table.insert(probes, w) end
end
local host = CreateFrame("Frame", "BenillaGapProbeHost", UIParent)
table.insert(probes, host:CreateTexture())
table.insert(probes, host:CreateFontString())
local out = {{}}
for i = 1, table.getn(want) do
local name, found = want[i], false
for j = 1, table.getn(probes) do
local ok, v = pcall(function() return probes[j][name] end)
if ok and type(v) == "function" then found = true break end
end
if found then table.insert(out, name) end
end
return out
"#
))
.expect("probe the widget method surface")
.into_iter()
.collect()
};
// The instrument's own tripwire: if the probe stops working, the `method=` column goes
// silently empty — which is precisely the failure mode 1787 exists to end.
let control: Vec<String> = ["SetPoint", "SetMerchantItem", "BenillaNotAMethod"]
.iter()
.map(|s| (*s).to_string())
.collect();
let got = answers(&s, &control);
assert!(
got.contains("SetPoint")
&& got.contains("SetMerchantItem")
&& !got.contains("BenillaNotAMethod"),
"the method probe is not working — it answered {got:?} for {control:?}"
);
let migrated: std::collections::HashSet<String> = super::super::addons::Addon::builtin()
.toc
.files
@@ -493,7 +598,8 @@ mod tests {
};
println!("\n=== 1751 gap report — what each unmigrated window would cost ===");
let mut rows: Vec<(usize, String, Vec<String>, Vec<String>)> = Vec::new();
#[allow(clippy::type_complexity)] // (engine count, file, engine, fx, method)
let mut rows: Vec<(usize, String, Vec<String>, Vec<String>, Vec<String>)> = Vec::new();
for f in &stock {
if migrated.contains(f) {
continue;
@@ -526,24 +632,38 @@ mod tests {
let h = home.get(&c).cloned().unwrap_or_else(|| "?".into());
fx.push(format!("{c}<{h}>"));
}
None => {} // a widget method, not a global — the reference's _G would have it
None => {} // not a global at all — the `:Name(` scan below is what sees it
}
}
rows.push((eng.len(), f.clone(), eng, fx));
// The method half. `own`/`have` do not apply: a method is never a global, so the only
// question is whether this engine's widgets answer to the name.
let mut asked: Vec<String> = called_methods(&text).into_iter().collect();
asked.sort();
let known = answers(&s, &asked);
let mut meth: Vec<String> = asked.into_iter().filter(|m| !known.contains(m)).collect();
meth.sort();
rows.push((eng.len() + meth.len(), f.clone(), eng, fx, meth));
}
rows.sort_by(|a, b| (a.0, &a.1).cmp(&(b.0, &b.1)));
for (n, f, eng, fx) in &rows {
for (n, f, eng, fx, meth) in &rows {
println!("{n:>3} engine {f}");
if !eng.is_empty() {
println!(" engine: {}", eng.join(" "));
}
if !meth.is_empty() {
println!(" method: {}", meth.join(" "));
}
if !fx.is_empty() {
println!(" fx: {}", fx.join(" "));
}
}
// "Free" now means free of BOTH — the count above is engine names plus missing methods,
// because a window blocked on a widget method is exactly as blocked as one missing a
// global, and reading it as free is the mistake 1787 is about.
let free: Vec<&String> = rows.iter().filter(|r| r.0 == 0).map(|r| &r.1).collect();
println!(
"\n=== {} windows need NO engine work at all ===",
"\n=== {} windows need NO engine work at all (no missing global AND no missing \
method) ===",
free.len()
);
for f in free {
@@ -91,6 +91,37 @@ fn read(req: &str) -> Option<Vec<u8>> {
/// Named as a list rather than folded into a `setup()` because the callers all want to add their
/// own files around it (a merchant, the bank, the action bar art), and the ORDER is the thing
/// being reused — it is `benilla.toc`'s, trimmed to what the bags actually reach for.
/// What the **loot window** needs before `Interface\FrameXML\LootFrame.xml` will load and behave —
/// the same shape as [`BAG_UI`], for the same reason, and it grew for 1751 exactly as that one did.
///
/// Three of these are load-bearing in a way that is invisible if you leave them out:
///
/// * **`GlobalStrings.lua`** — the stock file concatenates `GROUP` and `GIVE_LOOT` while building
/// the master-loot menu. The app loads the player's own copy ahead of the whole manifest at VM
/// setup; a bare `UiScript::new()` does not. Our deleted `LootFrame.xml` carried
/// `X = X or "…"` fallbacks for precisely these; the stock file carries none.
/// * **`ItemButtonTemplate.xml`** — stock `LootButtonTemplate` inherits it, and a missing template
/// is a loader *warning*, not an error. Leave it out and every row loads clean with no art and
/// no `$parentIconTexture`, which reads as a pass until an assertion looks for an icon.
/// * **`PartyFrame.xml`** — for `MAX_PARTY_MEMBERS`, which stock `LootFrame.lua:217` does
/// arithmetic on at LOAD time, not at click time. Its home is the reference's own
/// (`PartyMemberFrame.lua:1`), and it wants the dropdown kit and `UnitPopup` ahead of it — the
/// manifest's order (167 → 185 → 262), reproduced rather than short-circuited. Setting the
/// constant by hand would pass and teach nothing about the real load.
///
/// Needs client data, like [`BAG_UI`]: open with `benilla_formats::wow_data_or_skip!()`.
pub(super) const LOOT_UI: &[&str] = &[
"Interface\\FrameXML\\GlobalStrings.lua",
"Fonts.xml", // ITEM_QUALITY_COLORS — the row-name palette
"Interface\\FrameXML\\ItemButtonTemplate.xml",
"MoneyFrame.xml",
"UiPanels.xml", // StaticPopup, and the LOOT_BIND / CONFIRM_LOOT_DISTRIBUTION dialogs
"GameTooltip.xml", // TOOLTIP_DEFAULT_COLOR, read by the dropdown backdrop
"UIDropDownMenu.xml", // GroupLootDropDown's OnLoad calls UIDropDownMenu_Initialize
"UnitPopup.xml",
"PartyFrame.xml",
];
pub(super) const BAG_UI: &[&str] = &[
// The reference's own localized strings — `BACKPACK_TOOLTIP`, `EQUIP_CONTAINER`, `KEYRING`,
// the `*_FONT_COLOR_CODE` pair. The app loads this at VM setup, ahead of the manifest
+24 -8
View File
@@ -19,9 +19,9 @@ use benilla_ui::script::{TooltipTint, UiScript, UnitState};
use crate::items::Items;
use crate::names::NameCache;
use crate::net::{NetCommands, ObjectStore, Reputations, SelfPlayer};
use crate::net::{NetCommands, ObjectStore, SelfPlayer};
use crate::target::{
go_is_nearest, ring_reaction, Factions, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC,
go_is_nearest, ring_reaction, Hovered, HoveredObject, GO_FLAG_LOCKED, GO_TYPE_GENERIC,
};
use crate::ui_action::{PlayerActions, Spells};
use crate::ui_script::UiInput;
@@ -602,8 +602,9 @@ fn drive_mouseover_tooltip(
self_q: Query<&ObjectStore, With<SelfPlayer>>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
factions: Option<Res<Factions>>,
reputations: Res<Reputations>,
// The standing pair as one param — see [`crate::target::ReactionInputs`]. Bundled here and
// not elsewhere because this system sits on Bevy's tuple limit.
rx: crate::target::ReactionInputs,
// The lock chain's own data set, shared verbatim with the click router (`target::lock`) so the
// hover and the click can never disagree about whether a lock is satisfiable — the same reason
// `usable` and the click share one resolver (0752). Carries the go-template, Lock.dbc and
@@ -615,6 +616,9 @@ fn drive_mouseover_tooltip(
ui_scale: Res<crate::ui_script::UiScaleCvar>,
mut last: Local<crate::ui_script::VmMemo<LastHover>>,
mut last_lines: Local<crate::ui_script::VmMemo<Option<UnitState>>>,
// `ChrClasses.dbc` field 16 — `UnitHasRelicSlot`'s only input. Absent when the client data
// failed to load, in which case no class reads as having a relic slot.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
) {
let Some(mut script) = script else {
return;
@@ -622,15 +626,27 @@ fn drive_mouseover_tooltip(
let last = last.get(&script);
let last_lines = last_lines.get(&script);
let self_store = self_q.iter().next();
let chr = classes.as_deref().map(|t| &t.0);
// The hovered UNIT's snapshot (a hovered non-unit resolves no store here).
let unit = hovered.target.zip(hovered.guid).and_then(|(entity, guid)| {
let store = stores.get(entity).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
let reaction =
ring_reaction(factions.as_deref(), &reputations, Some(store), self_store) + 1;
let mut s = snapshot(store, name, reaction);
enrich_unit(&mut s, guid, &names, store, factions.as_deref(), self_store);
let reaction = ring_reaction(
rx.factions.as_deref(),
&rx.reputations,
Some(store),
self_store,
) + 1;
let mut s = snapshot(store, name, reaction, chr);
enrich_unit(
&mut s,
guid,
&names,
store,
rx.factions.as_deref(),
self_store,
);
Some((guid, s))
});
// The hovered GAMEOBJECT, when it is the nearer pick (the click router's own arbitration).
+59 -16
View File
@@ -26,6 +26,8 @@ use std::collections::HashMap;
use bevy::prelude::*;
use benilla_formats::ChrClasses;
use benilla_protocol::messages::ObjectType;
use benilla_ui::script::{power_token, ScriptValue, UiScript, UnitState, WornDisplay};
use crate::names::NameCache;
@@ -685,13 +687,41 @@ fn feed_unit_reach(
script.set_unit_reach(reach);
}
/// The object stores [`feed_units`] reads, and the change tracking over them.
///
/// One `SystemParam` rather than three, because Bevy's tuple limit is 16 and this feed had grown
/// to sit exactly on it — the next resource it legitimately needed (`ChrClasses.dbc`, for
/// `UnitHasRelicSlot`) turned the ceiling into a compile error with no hint that a ceiling was
/// what it hit. Grouping the three that always move together buys the room back and says why
/// they belong together, which the flat list did not. Same idiom as
/// [`crate::target::lock::GoLockInputs`].
#[derive(bevy::ecs::system::SystemParam)]
pub(crate) struct UnitStores<'w, 's> {
/// Every streamed object's descriptor, read by guid → entity.
all: Query<'w, 's, &'static ObjectStore>,
/// Whose descriptor moved this frame — the feed's dirty gate.
changed: Query<'w, 's, (), Changed<ObjectStore>>,
/// Whose object left the manager — the other half of that gate, and cleared every run.
removed: RemovedComponents<'w, 's, ObjectStore>,
}
/// Build a unit snapshot from a streamed object's descriptor (decision 0061's `ObjectFields`) plus
/// its cache-resolved name and its `UnitReaction` value (`1..8`, or `0` for tokens whose reaction we
/// don't resolve — everything but `"target"`; see [`feed_units`]).
pub(crate) fn snapshot(store: &ObjectStore, name: Option<String>, reaction: u8) -> UnitState {
///
/// `classes` is `ChrClasses.dbc`, absent when the client data failed to load. Only the relic column
/// is read off it — the class NAMES are this file's own table, because they are localized display
/// strings rather than a DBC column we can key on.
pub(crate) fn snapshot(
store: &ObjectStore,
name: Option<String>,
reaction: u8,
classes: Option<&ChrClasses>,
) -> UnitState {
let power_type = store.0.unit_power_type();
let race = store.0.unit_race().and_then(race_names);
let class = store.0.unit_class().and_then(class_names);
let class_id = store.0.unit_class();
let class = class_id.and_then(class_names);
UnitState {
exists: true,
// **This function IS the reference's `0x468460` having succeeded.** It is only ever called
@@ -740,6 +770,13 @@ pub(crate) fn snapshot(store: &ObjectStore, name: Option<String>, reaction: u8)
race_file: race.map(|(_, f)| f.to_string()),
class: class.map(|(n, _)| n.to_string()),
class_file: class.map(|(_, f)| f.to_string()),
// `UnitHasRelicSlot 0x519e50` — the class byte against `ChrClasses.dbc` field 16, under
// the reference's own gate and in its order: TYPEMASK_PLAYER first (`0x519e8d`,
// `[[obj+8]+8] >> 4 & 1` — which is exactly what [`ObjectType::Player`] decodes), the
// class byte second. The player test is not decoration: a creature carries a class byte
// too and indexes the same table, so without it a class-2 humanoid NPC answers 1.
has_relic_slot: matches!(store.0.object_type(), Some(ObjectType::Player))
&& class_id.is_some_and(|c| classes.is_some_and(|t| t.has_relic_slot(u32::from(c)))),
// The descriptor's gender byte (0 male, 1 female) on the API's `UnitSex` scale (2 male,
// 3 female; 0 = unknown → the binding's nil).
sex: match store.0.unit_gender() {
@@ -1054,17 +1091,20 @@ pub(crate) fn fire_transitions(
#[allow(clippy::too_many_arguments)]
fn feed_units(
script: Option<NonSendMut<UiScript>>,
// `ChrClasses.dbc` field 16, the only thing `UnitHasRelicSlot` reads. Absent when the client
// data failed to load, in which case no class reads as having a relic slot — the reference's
// own bounds-check-fails leg.
classes: Option<Res<crate::chr_classes::ChrClassTable>>,
self_q: Query<(&ObjectStore, &Guid), With<SelfPlayer>>,
selection: Res<Selection>,
stores: Query<&ObjectStore>,
// The guid -> entity map the `"targettarget"` hop lands in, the same index `/assist` resolves
// its basis' `UNIT_FIELD_TARGET` through (`target::by_name`). `Option` because it belongs to
// `NetPlugin` (net.rs's `init_resource`) and this feed does not: a UI-only harness runs this
// system with no net stack at all, and a bare `Res` turns that into a system-validation panic
// rather than a token that names nobody. Same shape as `factions` below, same reason.
index: Option<Res<crate::net::GuidIndex>>,
changed_stores: Query<(), Changed<ObjectStore>>,
mut removed_stores: RemovedComponents<ObjectStore>,
mut stores: UnitStores,
mut feed: ResMut<UnitFeedState>,
mut names: ResMut<NameCache>,
commands: Res<NetCommands>,
@@ -1083,6 +1123,7 @@ fn feed_units(
let Some(mut script) = script else {
return;
};
let chr = classes.as_deref().map(|t| &t.0);
// One reborrow so the memo (`feed.vm`) and `feed.warned_sideless` can be borrowed as the
// disjoint fields they are — through the `ResMut` deref they would alias.
let feed = &mut *feed;
@@ -1094,8 +1135,8 @@ fn feed_units(
let names_moved = memo.names_generation.moved(names.generation());
let guild_moved = memo.guild_generation.moved(guild.identity_generation());
let selection_changed = selection.is_changed();
let stores_changed = !changed_stores.is_empty();
let stores_removed = !removed_stores.is_empty();
let stores_changed = !stores.changed.is_empty();
let stores_removed = !stores.removed.is_empty();
let group_changed = group.is_changed();
let reps_changed = reputations.is_changed();
let factions_changed = factions.as_ref().is_some_and(|r| r.is_changed());
@@ -1124,7 +1165,7 @@ fn feed_units(
|| reps_changed
|| factions_changed,
);
removed_stores.clear();
stores.removed.clear();
if gate.skip() {
return;
}
@@ -1135,7 +1176,7 @@ fn feed_units(
let self_pair = self_q.iter().next();
let player = self_pair.map(|(store, guid)| {
let name = names.resolve(guid.0, &commands).map(str::to_string);
let mut s = snapshot(store, name, 0);
let mut s = snapshot(store, name, 0, chr);
s.is_player = true;
// The stated `is_connected` gap, closed for every token this feed pushes — the field's own
// doc names the feed as what must set it ("mirroring `exists`"), and a token we push at all
@@ -1186,7 +1227,7 @@ fn feed_units(
// (The VM-half memo was taken at the top — the gate needs its reset flag. `warned_sideless`
// stays server memory outside it, which the disjoint field borrows above preserve.)
let target = selection.target.zip(selection.guid).and_then(|(e, guid)| {
let store = stores.get(e).ok()?;
let store = stores.all.get(e).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
// The target's reaction toward us, on the `UnitReaction` 1..8 scale — the same decode the
// selection ring runs (reputation-first, else the faction-template comparator). `ring_reaction`
@@ -1198,7 +1239,7 @@ fn feed_units(
Some(store),
self_pair.map(|(s, _)| s),
) + 1;
let mut s = snapshot(store, name, reaction);
let mut s = snapshot(store, name, reaction, chr);
s.guid = guid;
s.is_connected = true; // see the player leg
s.raid_target = group.raid_target_index(guid);
@@ -1238,12 +1279,12 @@ fn feed_units(
// descriptor we already hold.
let tot = selection
.target
.and_then(|e| stores.get(e).ok())
.and_then(|e| stores.all.get(e).ok())
.and_then(|s| s.0.unit_target())
.filter(|guid| *guid != 0)
.and_then(|guid| Some((*index.as_ref()?.0.get(&guid)?, guid)))
.and_then(|(entity, guid)| {
let store = stores.get(entity).ok()?;
let store = stores.all.get(entity).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
let reaction = ring_reaction(
factions.as_deref(),
@@ -1251,7 +1292,7 @@ fn feed_units(
Some(store),
self_pair.map(|(s, _)| s),
) + 1;
let mut s = snapshot(store, name, reaction);
let mut s = snapshot(store, name, reaction, chr);
s.guid = guid;
s.is_connected = true; // see the player leg
s.raid_target = group.raid_target_index(guid);
@@ -1330,7 +1371,7 @@ fn feed_units(
.as_deref()
.and_then(|i| Some((i.0?, i.1?)))
.and_then(|(entity, guid)| {
let store = stores.get(entity).ok()?;
let store = stores.all.get(entity).ok()?;
let name = names.resolve(guid, &commands).map(str::to_string);
let reaction = ring_reaction(
factions.as_deref(),
@@ -1338,7 +1379,7 @@ fn feed_units(
Some(store),
self_pair.map(|(s, _)| s),
) + 1;
let mut s = snapshot(store, name, reaction);
let mut s = snapshot(store, name, reaction, chr);
s.guid = guid;
s.is_connected = true;
s.raid_target = group.raid_target_index(guid);
@@ -1923,6 +1964,7 @@ mod tests {
&ObjectStore(ObjectFields::from_pairs(&vitals)),
Some("Hunter".into()),
0,
None,
);
assert_eq!((alive.health, alive.max_health), (1200, 1500));
assert_eq!((alive.power, alive.max_power), (300, 900));
@@ -1934,6 +1976,7 @@ mod tests {
)),
Some("Hunter".into()),
0,
None,
);
assert_eq!(
(feigning.health, feigning.max_health),
+208
View File
@@ -0,0 +1,208 @@
//! `ChrClasses.dbc` — the per-class client table, and the two columns anything here reads off it.
//!
//! One file, one parse. Both columns are answers the *engine* gives Lua about a class, and both
//! are read by address off the same class-indexed record table at `ds:0xc0def4`, bounded by the
//! max id at `ds:0xc0def8` (store `0xc0deec`, loader `0x542360`, filename `0x85838c`).
//!
//! ## Field 4 — the pet name token
//!
//! `HasPetSpells()`'s second return, and the only thing that decides whether the spellbook's
//! second tab reads "Pet" or "Demon" (decision 1032). `HasPetSpells 0x4b4410` pushes it verbatim:
//!
//! ```text
//! 0x4b445d eax = [player + 0x110] ; the descriptor fields
//! 0x4b4463 eax = byte [fields + 0x79] ; UNIT_FIELD_BYTES_0 byte 1 = the CLASS
//! 0x4b446b if (class < 0 || class > [0xc0def8]) -> the out-of-range arm
//! 0x4b4473 ecx = [0xc0def4] ; the class record table
//! 0x4b4479 eax = ecx[class] ; 1-BASED — index 0 is never a class
//! 0x4b447c edx = [rec + 0x10] ; <- the token string
//! 0x4b44a6 (player did not resolve) edx = "PET" ; the literal 0x846a40
//! ```
//!
//! **`rec + 0x10` is field 4, and the file says which field that is**: dumped from the real 5875
//! `ChrClasses.dbc` (9 rows, 17 columns, 0x44-byte records), field 4 is `"PET"` in every row except
//! Warlock (id 9), which is `"DEMON"`. Field 5 is the class name (`Warrior`/`Paladin`/…), so a
//! one-column slip would put "Warlock" on the tab — plausible enough to ship, which is why
//! [`tests`] anchors both columns.
//!
//! FrameXML then does `getglobal("PET_TYPE_"..token)` (`SpellBookFrame.lua:173`) against
//! `PET_TYPE_PET = "Pet"` / `PET_TYPE_DEMON = "Demon"` (`GlobalStrings.lua:3057-3058`) — so the
//! token is a **key**, never display text, and it is right that it is not localized here.
//!
//! ## Field 16 — the relic-slot flag
//!
//! `UnitHasRelicSlot 0x519e50` reads it and nothing else. Same walk — typemask bit 4 (PLAYER),
//! `UNIT_FIELD_BYTES_0` byte 1 for the class, bound against `0xc0def8`, index `0xc0def4` — then:
//!
//! ```text
//! 0x519ebb mov ecx,[row + 0x40] ; <- field 16, the relic flag
//! ```
//!
//! Non-zero pushes the **number 1.0**; zero pushes **nil** (never `false`). There is no `cmp`
//! against a class id anywhere in the function: 1.12 is entirely data-driven here, and this table
//! is the data. In the shipped file the column is 1 for exactly **2 Paladin, 7 Shaman, 11 Druid**
//! — Libram, Totem, Idol.
//!
//! **Why this was believed impossible, and the trap worth keeping.** The base `dbc.MPQ` copy of
//! this file is **16 fields / 64-byte records and has no field 16 at all**; `patch.MPQ` supersedes
//! it with the 17-field / 68-byte version, and the 5875 loader asserts exactly those two numbers
//! (`0x54240e`/`0x542446`), so the patch copy is the only one the client can read. Read the base
//! archive alone — or an early-vanilla 16-column struct — and the flag simply is not there, which
//! is how "the relic slot post-dates 1.12" became a settled belief in this codebase and stayed one
//! across two decision records. It is false; see decision 1785. [`CHR_CLASSES_FIELDS`] is what
//! keeps us on the patch copy, and it is load-bearing, not defensive.
//!
//! The flag is engine-enforced, not a UI conceit: `IsValidForSlot 0x5da1d0`'s `slot == 0x11` leg
//! requires `(InventoryType == 28 RELIC) == hasRelicSlot`, so **INVSLOT 17 takes a relic for those
//! three classes and a ranged weapon for everyone else** — one slot, two meanings. 1.12 has no
//! *separate* relic slot, which is the one true fragment inside the old belief.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{parse, str_at, u32_at};
const CHR_CLASSES: &str = "DBFilesClient\\ChrClasses.dbc";
/// `ChrClasses.dbc`'s column count in 5875 (the header's `field_count`; `benilla-dbc` enforces it).
///
/// This is the **patch** copy's shape. The base archive's 16-field copy fails the check rather
/// than silently reading one column short of the relic flag — see the module header.
const CHR_CLASSES_FIELDS: usize = 17;
/// The pet-name-token column — byte `0x10` in the client's own record read, i.e. field 4.
const PET_NAME_TOKEN_FIELD: usize = 0x10 / 4;
/// The relic-slot column — byte `0x40` in `UnitHasRelicSlot`'s read, i.e. field 16.
const RELIC_SLOT_FIELD: usize = 0x40 / 4;
/// The literal the client pushes when the player object does not resolve (`0x846a40`). Also the
/// value nine of the ten class rows carry, which is why an unloaded table degrades invisibly.
pub const PET_NAME_TOKEN_FALLBACK: &str = "PET";
/// One class row, narrowed to the columns anything reads.
#[derive(Debug, Clone)]
struct ChrClass {
pet_name_token: Option<String>,
has_relic_slot: bool,
}
/// `ChrClasses.dbc` → class id ⇒ the columns we read.
#[derive(Debug, Default, Clone)]
pub struct ChrClasses(HashMap<u32, ChrClass>);
impl ChrClasses {
/// The pet name token for a class id, or the client's own [`PET_NAME_TOKEN_FALLBACK`] for a
/// class with no row. Mirrors `0x4b44a6`: the reference never answers nil here, only ever a
/// string.
pub fn pet_name_token(&self, class: u32) -> &str {
self.0
.get(&class)
.and_then(|c| c.pet_name_token.as_deref())
.unwrap_or(PET_NAME_TOKEN_FALLBACK)
}
/// Whether a class id's INVSLOT 17 is a relic slot — `UnitHasRelicSlot`'s whole body.
///
/// A class with no row answers `false`, which is the reference's own shape: its bound check
/// against `0xc0def8` falls to the nil leg, and an unloaded table reads every class as an
/// ordinary ranged wielder rather than inventing a relic slot for one.
pub fn has_relic_slot(&self, class: u32) -> bool {
self.0.get(&class).is_some_and(|c| c.has_relic_slot)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
fn schema() -> Schema {
let mut s = Schema::new("ChrClasses");
for i in 0..CHR_CLASSES_FIELDS {
// Only the two read columns are typed; everything else stays an opaque dword. The class
// NAME block (field 5 up) is deliberately not decoded — nothing here displays it.
let ty = if i == PET_NAME_TOKEN_FIELD {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// Load `ChrClasses.dbc`'s read columns from the patch chain.
pub fn load_chr_classes(chain: &mut Chain) -> Result<ChrClasses> {
let bytes = chain
.read_file(CHR_CLASSES)
.with_context(|| format!("reading {CHR_CLASSES}"))?;
let rs = parse(&bytes, schema(), "ChrClasses.dbc")?;
let mut by_id = HashMap::new();
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
by_id.insert(
id,
ChrClass {
pet_name_token: str_at(&rs, r, PET_NAME_TOKEN_FIELD),
has_relic_slot: u32_at(r, RELIC_SLOT_FIELD).is_some_and(|v| v != 0),
},
);
}
Ok(ChrClasses(by_id))
}
#[cfg(test)]
mod tests {
use super::*;
fn chain() -> Option<crate::chain::Chain> {
let data = crate::wow_data_or_skip!(None);
Some(crate::open_chain(&data).expect("open chain"))
}
/// The real 5875 table, byte-anchored on the one column that matters. The neighbouring field
/// holds the class NAME, so a one-column slip reads "Warlock"/"Hunter" — which would still
/// resolve a `PET_TYPE_*` global lookup to nil and blank the tab, i.e. fail silently.
#[test]
fn warlocks_pet_is_a_demon_and_everyone_elses_is_a_pet() {
let Some(mut chain) = chain() else { return };
let t = load_chr_classes(&mut chain).expect("load ChrClasses.dbc");
assert!(!t.is_empty());
assert_eq!(t.pet_name_token(9), "DEMON", "Warlock");
for (class, who) in [(1, "Warrior"), (3, "Hunter"), (11, "Druid")] {
assert_eq!(t.pet_name_token(class), "PET", "{who}");
}
// 6 and 10 have no row in 1.12 (Death Knight / Monk arrive later); the client's own
// out-of-range arm answers the literal, and so does this.
assert_eq!(t.pet_name_token(6), PET_NAME_TOKEN_FALLBACK);
assert_eq!(t.pet_name_token(0), PET_NAME_TOKEN_FALLBACK);
}
/// Field 16, anchored against the shipped file the same way — and against the belief it
/// replaces. Three classes, and exactly three: this asserts the whole column, because a
/// half-right answer here (say, Paladin alone) is the shape the old claim would decay into.
#[test]
fn libram_totem_and_idol_are_the_three_relic_classes() {
let Some(mut chain) = chain() else { return };
let t = load_chr_classes(&mut chain).expect("load ChrClasses.dbc");
for (class, who) in [(2, "Paladin"), (7, "Shaman"), (11, "Druid")] {
assert!(t.has_relic_slot(class), "{who} carries a relic");
}
for (class, who) in [
(1, "Warrior"),
(3, "Hunter"),
(4, "Rogue"),
(5, "Priest"),
(8, "Mage"),
(9, "Warlock"),
] {
assert!(!t.has_relic_slot(class), "{who} wields a ranged weapon");
}
// No row, and no invented slot — the reference's nil leg.
assert!(!t.has_relic_slot(6));
assert!(!t.has_relic_slot(0));
}
}
+2 -2
View File
@@ -105,8 +105,8 @@ pub use lock::{
};
mod lock_type;
pub use lock_type::{load_lock_type_catalog, LockTypeCatalog};
mod pet_name_token;
pub use pet_name_token::{load_pet_name_tokens, PetNameTokens, PET_NAME_TOKEN_FALLBACK};
mod chr_classes;
pub use chr_classes::{load_chr_classes, ChrClasses, PET_NAME_TOKEN_FALLBACK};
mod pet_stats;
pub use pet_stats::{
load_pet_loyalty_names, load_pet_personalities, PetHappiness, PetLoyaltyNames,
@@ -1,121 +0,0 @@
//! `ChrClasses.dbc`'s **pet name token** — `HasPetSpells()`'s second return, and the only thing
//! that decides whether the spellbook's second tab reads "Pet" or "Demon" (decision 1032).
//!
//! `HasPetSpells 0x4b4410` pushes it verbatim off a class-indexed record table:
//!
//! ```text
//! 0x4b445d eax = [player + 0x110] ; the descriptor fields
//! 0x4b4463 eax = byte [fields + 0x79] ; UNIT_FIELD_BYTES_0 byte 1 = the CLASS
//! 0x4b446b if (class < 0 || class > [0xc0def8]) -> the out-of-range arm
//! 0x4b4473 ecx = [0xc0def4] ; the class record table
//! 0x4b4479 eax = ecx[class] ; 1-BASED — index 0 is never a class
//! 0x4b447c edx = [rec + 0x10] ; <- the token string
//! 0x4b44a6 (player did not resolve) edx = "PET" ; the literal 0x846a40
//! ```
//!
//! **`rec + 0x10` is field 4, and the file says which field that is**: dumped from the real 5875
//! `ChrClasses.dbc` (9 rows, 17 columns, 0x44-byte records), field 4 is `"PET"` in every row except
//! Warlock (id 9), which is `"DEMON"`. Field 5 is the class name (`Warrior`/`Paladin`/…), so a
//! one-column slip would put "Warlock" on the tab — plausible enough to ship, which is why
//! [`tests`] anchors both columns.
//!
//! FrameXML then does `getglobal("PET_TYPE_"..token)` (`SpellBookFrame.lua:173`) against
//! `PET_TYPE_PET = "Pet"` / `PET_TYPE_DEMON = "Demon"` (`GlobalStrings.lua:3057-3058`) — so the
//! token is a **key**, never display text, and it is right that it is not localized here.
use std::collections::HashMap;
use anyhow::{Context, Result};
use benilla_dbc::{FieldType, Schema, SchemaField};
use crate::chain::Chain;
use crate::dbc::{parse, str_at, u32_at};
const CHR_CLASSES: &str = "DBFilesClient\\ChrClasses.dbc";
/// `ChrClasses.dbc`'s column count in 5875 (the header's `field_count`; `benilla-dbc` enforces it).
const CHR_CLASSES_FIELDS: usize = 17;
/// The pet-name-token column — byte `0x10` in the client's own record read, i.e. field 4.
const PET_NAME_TOKEN_FIELD: usize = 0x10 / 4;
/// The literal the client pushes when the player object does not resolve (`0x846a40`). Also the
/// value nine of the ten class rows carry, which is why an unloaded table degrades invisibly.
pub const PET_NAME_TOKEN_FALLBACK: &str = "PET";
/// `ChrClasses.dbc` → class id ⇒ pet name token.
#[derive(Debug, Default, Clone)]
pub struct PetNameTokens(HashMap<u32, String>);
impl PetNameTokens {
/// The token for a class id, or the client's own [`PET_NAME_TOKEN_FALLBACK`] for a class with
/// no row. Mirrors `0x4b44a6`: the reference never answers nil here, only ever a string.
pub fn token(&self, class: u32) -> &str {
self.0
.get(&class)
.map_or(PET_NAME_TOKEN_FALLBACK, String::as_str)
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
fn schema() -> Schema {
let mut s = Schema::new("ChrClasses");
for i in 0..CHR_CLASSES_FIELDS {
// Only the token column is read; everything else stays an opaque dword. The class NAME
// block (field 5 up) is deliberately not decoded — nothing here displays it.
let ty = if i == PET_NAME_TOKEN_FIELD {
FieldType::String
} else {
FieldType::UInt32
};
s.add_field(SchemaField::new(format!("F{i}"), ty));
}
s
}
/// Load `ChrClasses.dbc`'s pet-name-token column from the patch chain.
pub fn load_pet_name_tokens(chain: &mut Chain) -> Result<PetNameTokens> {
let bytes = chain
.read_file(CHR_CLASSES)
.with_context(|| format!("reading {CHR_CLASSES}"))?;
let rs = parse(&bytes, schema(), "ChrClasses.dbc")?;
let mut by_id = HashMap::new();
for r in rs.records() {
let Some(id) = u32_at(r, 0) else { continue };
if let Some(token) = str_at(&rs, r, PET_NAME_TOKEN_FIELD) {
by_id.insert(id, token);
}
}
Ok(PetNameTokens(by_id))
}
#[cfg(test)]
mod tests {
use super::*;
fn chain() -> Option<crate::chain::Chain> {
let data = crate::wow_data_or_skip!(None);
Some(crate::open_chain(&data).expect("open chain"))
}
/// The real 5875 table, byte-anchored on the one column that matters. The neighbouring field
/// holds the class NAME, so a one-column slip reads "Warlock"/"Hunter" — which would still
/// resolve a `PET_TYPE_*` global lookup to nil and blank the tab, i.e. fail silently.
#[test]
fn warlocks_pet_is_a_demon_and_everyone_elses_is_a_pet() {
let Some(mut chain) = chain() else { return };
let t = load_pet_name_tokens(&mut chain).expect("load ChrClasses.dbc");
assert!(!t.is_empty());
assert_eq!(t.token(9), "DEMON", "Warlock");
for (class, who) in [(1, "Warrior"), (3, "Hunter"), (11, "Druid")] {
assert_eq!(t.token(class), "PET", "{who}");
}
// 6 and 10 have no row in 1.12 (Death Knight / Monk arrive later); the client's own
// out-of-range arm answers the literal, and so does this.
assert_eq!(t.token(6), PET_NAME_TOKEN_FALLBACK);
assert_eq!(t.token(0), PET_NAME_TOKEN_FALLBACK);
}
}
+2 -2
View File
@@ -246,8 +246,8 @@ pub use update_object::{
PLAYER_QUEST_LOG_SLOTS, PLAYER_SKILL_SLOTS, UNIT_AURA_POSITIVE_SLOTS, UNIT_AURA_SLOTS,
};
pub use vendor::{
buy_item, buy_result, buyback_item, list_inventory, repair_item, sell_item, sell_result,
VendorItem,
buy_item, buy_item_in_slot, buy_result, buyback_item, list_inventory, repair_item, sell_item,
sell_result, VendorItem,
};
pub use world_state::InitWorldStates;
@@ -780,8 +780,9 @@ pub const SMSG_LIST_INVENTORY: u16 = 0x019F; // 415
pub const CMSG_SELL_ITEM: u16 = 0x01A0; // 416
pub const SMSG_SELL_ITEM: u16 = 0x01A1; // 417
pub const CMSG_BUY_ITEM: u16 = 0x01A2; // 418
/// Buy into a specific bag slot — value pinned for completeness; unused (out of scope, plain
/// `CMSG_BUY_ITEM` auto-place is sufficient for v1, decision 0081).
/// Buy into a **specific** bag slot. This is what a vendor row dropped out of the merchant
/// cursor sends (`PickupMerchantItem` mode 5 → the container/doll drop), as against
/// `CMSG_BUY_ITEM`'s auto-place, which is what clicking the row sends. Both ship (decision 1786).
pub const CMSG_BUY_ITEM_IN_SLOT: u16 = 0x01A3; // 419
pub const SMSG_BUY_ITEM: u16 = 0x01A4; // 420
pub const SMSG_BUY_FAILED: u16 = 0x01A5; // 421
+30 -3
View File
@@ -59,9 +59,8 @@ pub fn list_inventory(vendor_guid: u64) -> Vec<u8> {
/// Body of `CMSG_BUY_ITEM` (vmangos `Item.cpp:104-110`): `u64 vendorGuid, u32 item` (the template
/// **entry**, not the vendor row's `muid`), `u8 count` (stacks to buy), `u8 unk1` (always 0).
/// Auto-places into the first free bag slot — the specific-bag-slot variant
/// (`CMSG_BUY_ITEM_IN_SLOT`, [`super::opcode::CMSG_BUY_ITEM_IN_SLOT`]) is out of scope for v1
/// (decision 0081). Handler → `BuyItemFromVendor` (`ItemHandler.cpp:688`).
/// Auto-places into the first free bag slot — the specific-bag-slot variant is
/// [`buy_item_in_slot`]. Handler → `BuyItemFromVendor` (`ItemHandler.cpp:688`).
pub fn buy_item(vendor_guid: u64, entry: u32, count: u8) -> Vec<u8> {
let mut body = Vec::with_capacity(14);
body.extend_from_slice(&vendor_guid.to_le_bytes());
@@ -71,6 +70,34 @@ pub fn buy_item(vendor_guid: u64, entry: u32, count: u8) -> Vec<u8> {
body
}
/// Body of `CMSG_BUY_ITEM_IN_SLOT` (vmangos `Item.cpp:113-120`): `u64 vendorGuid, u32 item`
/// (the template **entry**, like [`buy_item`]), `u64 bagGuid`, `u8 bagSlot`, `u8 count`.
///
/// The buy-into-a-named-slot variant, and the packet the **merchant cursor** sends: the reference
/// puts a vendor row on the cursor as payload mode 5 (`PickupMerchantItem 0x4fb760`) and the
/// three container drop handlers each call the sender `0x5e1f30` with `count = 1`
/// (wow-re `system/ui/scratch/merchant-cursor-law.md` §5). Clicking a row is the auto-place
/// [`buy_item`]; dragging it into a slot is this.
///
/// `bag_guid` is the **container object's** guid, or the player's own for the backpack and the
/// equipment slots; `bag_slot` is the destination within it. Handler → `BuyItemFromVendor`, the
/// same one `CMSG_BUY_ITEM` reaches, with the slot pair filled instead of `NULL_BAG`/`NULL_SLOT`.
pub fn buy_item_in_slot(
vendor_guid: u64,
entry: u32,
bag_guid: u64,
bag_slot: u8,
count: u8,
) -> Vec<u8> {
let mut body = Vec::with_capacity(22);
body.extend_from_slice(&vendor_guid.to_le_bytes());
body.extend_from_slice(&entry.to_le_bytes());
body.extend_from_slice(&bag_guid.to_le_bytes());
body.push(bag_slot);
body.push(count);
body
}
/// Body of `CMSG_SELL_ITEM` (vmangos `Item.cpp:87-92`): `u64 vendorGuid, u64 itemGuid, u8 count`
/// (0 = sell the whole stack). Handler `ItemHandler.cpp:442`.
pub fn sell_item(vendor_guid: u64, item_guid: u64, count: u8) -> Vec<u8> {
@@ -502,6 +502,21 @@ impl WorldSession {
)
}
/// `CMSG_BUY_ITEM_IN_SLOT` — buy into a named container slot, the merchant cursor's drop.
pub fn buy_item_in_slot(
&mut self,
vendor_guid: u64,
entry: u32,
bag_guid: u64,
bag_slot: u8,
count: u8,
) -> Result<()> {
self.send(
opcode::CMSG_BUY_ITEM_IN_SLOT,
&messages::buy_item_in_slot(vendor_guid, entry, bag_guid, bag_slot, count),
)
}
/// Sell an item to a vendor (`CMSG_SELL_ITEM`) — the unsplit twin of [`WorldWriter::sell_item`];
/// `count` 0 = the whole stack.
pub fn sell_item(&mut self, vendor_guid: u64, item_guid: u64, count: u8) -> Result<()> {
@@ -36,6 +36,24 @@ impl WorldWriter {
)
}
/// Buy into a **named** container slot (`CMSG_BUY_ITEM_IN_SLOT`, layout in
/// [`messages::buy_item_in_slot`]) — the merchant cursor's drop, as against [`Self::buy_item`]'s
/// auto-place from a row click. `bag_guid` is the container object's guid, or the player's own
/// for the backpack and the equipment slots.
pub fn buy_item_in_slot(
&mut self,
vendor_guid: u64,
entry: u32,
bag_guid: u64,
bag_slot: u8,
count: u8,
) -> Result<()> {
self.send(
opcode::CMSG_BUY_ITEM_IN_SLOT,
&messages::buy_item_in_slot(vendor_guid, entry, bag_guid, bag_slot, count),
)
}
/// Sell an item to a vendor (`CMSG_SELL_ITEM`, layout in [`messages::sell_item`]): `count` 0 =
/// sell the whole stack. Success is silent (the item vanishes + coinage rises via
/// `UPDATE_OBJECT`); refusal answers `SMSG_SELL_ITEM`'s error shape (a `VendorSellFailed`
+67 -2
View File
@@ -142,6 +142,29 @@ fn with_button<T>(
}
}
/// Write [`ButtonState::loot_slot`] — `LootButton:SetSlot`'s whole body, kept here beside the
/// other `ButtonState` writers rather than reaching into the arena from `script::loot`.
pub(super) fn set_loot_slot(lua: &Lua, this: &Table, slot: Option<u32>) -> mlua::Result<()> {
let h = frame_handle_of(lua, this)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let frame = model
.arena
.frame_mut(h)
.ok_or_else(|| mlua::Error::runtime("stale frame handle"))?;
match (&frame.kind, &mut frame.kind_state) {
(crate::widget::FrameKind::LootButton, KindState::Button(bs)) => {
bs.loot_slot = slot;
Ok(())
}
// The reference's own guard: `SetSlot` type-checks `this` against the LootButton tag via
// vtable slot 4 (`0x4c18ee`), so calling it on a plain Button raises rather than writing a
// field nothing would ever read.
_ => Err(mlua::Error::runtime(
"SetSlot: 'this' is not a LootButton widget",
)),
}
}
/// Get-or-create the region behind `slot`; returns its id.
fn ensure_slot(lua: &Lua, this: &Table, slot: Slot) -> mlua::Result<u32> {
let h = frame_handle_of(lua, this)?;
@@ -754,7 +777,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
let btn = button.unwrap_or_else(|| "LeftButton".to_string());
// A programmatic Click() always emulates a completed (released) click.
click_button(lua, id, &btn, false);
// A programmatic Click() — `scripted = true`, which only a LootButton reads.
click_button(lua, id, &btn, false, true);
Ok(())
})?,
)?;
@@ -866,12 +890,42 @@ pub(super) fn press_held(model: &Model, h: FrameHandle) -> bool {
/// `self:GetChecked()` sees the new state); then `OnClick(self, button, down)` — `down` is `true`
/// only for a press that fired via a `"<Button>ButtonDown"` registration, `false` for every
/// release-fired or programmatic click (the Era signature: `down` mirrors which transition fired).
pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool) {
///
/// `scripted` says the click came from Lua `:Click()` rather than from hardware — the reference's
/// second `OnClick` argument (`0` from `0x779280`/`0x7793a4`, `1` from the `Button:Click` binding
/// `0x7826c0`). Only [`crate::widget::FrameKind::LootButton`] reads it, and it reads it as a hard
/// gate: `0x4c182b` returns before the base call, so **a scripted click on a loot row does
/// nothing at all — not even run the row's own Lua `OnClick`**. Every other kind ignores it, which
/// is the reference's shape too (the base `0x779540` never looks at the flag).
pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool, scripted: bool) {
let mut take_loot = None;
let fire = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(&h) = model.id_to_frame.get(&id) else {
return;
};
let is_loot = model
.arena
.frame(h)
.is_some_and(|f| f.kind == crate::widget::FrameKind::LootButton);
if is_loot {
if scripted {
return;
}
// Read the take BEFORE the handler runs, but queue it after: the reference reads
// `[esi+0x4dc]` after the base call, and a handler that re-slots its own row mid-click
// is not a case the shipped UI produces — reading first keeps the borrow simple and
// the two cannot disagree. The modifier gate is `0x4c183a`/`0x4c1848`/`0x4c1856`:
// shift, ctrl OR alt suppresses, and it is the whole reason the shipped
// `LootFrameItem_OnClick` never calls a take itself.
let (shift, ctrl, alt) = model.modifiers;
if !shift && !ctrl && !alt {
take_loot = model.arena.frame(h).and_then(|f| match &f.kind_state {
KindState::Button(bs) => bs.loot_slot,
_ => None,
});
}
}
let Some(frame) = model.arena.frame_mut(h) else {
return;
};
@@ -906,4 +960,15 @@ pub(super) fn click_button(lua: &Lua, id: u32, button: &str, down: bool) {
.errors
.push(e.to_string());
}
// …and then the take, unconditionally on the handler's outcome. `0x4c1867` is not guarded by
// anything the Lua side did: the base call at `0x4c1833` returns void and its result is never
// tested. A row whose `OnClick` errored still loots, which is the reference's behaviour and
// the reason a broken addon hook cannot silently eat your loot.
if let Some(slot) = take_loot {
// The 0-based store, back to the 1-based display row the take queue speaks.
lua.app_data_mut::<Model>()
.expect("model app_data")
.loot_picks
.push(slot + 1);
}
}
+8 -3
View File
@@ -1108,9 +1108,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// GetInventorySlotInfo(slotName) → (slotId, textureName, checkRelic) — the client's own
// PaperDollItemFrame.dbc rows (SLOT_INFO above). checkRelic is always false in vanilla
// (UnitHasRelicSlot is a later-era concept; the 0208 deferral). An unknown name is a Lua
// error, the client's own behavior.
// PaperDollItemFrame.dbc rows (SLOT_INFO above). An unknown name is a Lua error, the client's
// own behavior.
//
// `checkRelic` is the number 1 for the ranged slot and nil elsewhere, unconditionally — it is
// a property of the SLOT, not of the player. It pairs with `UnitHasRelicSlot("player")`,
// which is the class half, and stock reads the two as a conjunction
// (`PaperDollFrame.lua:680`/`:744`). This comment used to say `UnitHasRelicSlot` is a
// later-era concept, always false in vanilla; it is not — decision 1785.
g.set(
"GetInventorySlotInfo",
lua.create_function(|lua, name: String| {
+25
View File
@@ -392,6 +392,31 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool
}
}
}
// Mode 5 — **the buy**. A held vendor row dropped into a container slot is the whole
// point of the merchant cursor: `0x4f9efa` tests `[0xb4d900] == 5` right here and calls
// `0x5e1f30` = `CMSG_BUY_ITEM_IN_SLOT 0x1a3`, `{vendorGuid, itemEntry, bagGuid, bagSlot,
// count = 1}`. The cursor clears whether or not the server honours it.
//
// Deliberately NOT gated on the destination being empty. The reference does not look at
// the slot at all — the server decides, and answers `SMSG_BUY_FAILED` when it will not.
// What we DO refuse is a stale row: the reference's two container drop consumers
// dereference the vendor row without re-checking it against a list that may have been
// rewritten by a fresh `SMSG_LIST_INVENTORY`, which is a latent null deref there
// (`0x4f9f35`, `0x4c7ea5`). We treat a row that no longer resolves as a refusal — that is
// the third consumer `0x4c7300`'s own behaviour, and the one of the three that is right.
Some(CursorPayload::Merchant(held)) => {
let entry = model
.merchant
.as_ref()
.and_then(|m| m.items.get(held.row as usize))
.filter(|it| it.item_id == held.item_id)
.map(|it| it.item_id);
if let Some(entry) = entry {
model.merchant_slot_buys.push((bag, slot, entry));
}
cursor::queue_cursor_update(model);
true
}
Some(
other @ (CursorPayload::Spell(_)
| CursorPayload::Action(_)
+46 -1
View File
@@ -55,6 +55,13 @@ pub enum CursorPayload {
Action(CursorAction),
Macro(CursorMacro),
PetAction(CursorPetAction),
/// A vendor row grabbed off the merchant window — **mode 5** (see [`CursorMerchantItem`]).
///
/// Deliberately NOT `Item`: the reference's `CursorHasItem 0x4895d0` answers truthy for modes
/// **1 and 9 only**, so all thirteen stock `CursorHasItem()` gates (TradeFrame, MailFrame,
/// PlayerFrame, TargetFrame, PetFrame, StaticPopup, the keyring…) exclude a vendor cursor.
/// Making this an `Item` variant would silently open every one of them.
Merchant(CursorMerchantItem),
/// A pet picked up from the stable window — **mode 10** (see [`CursorStablePet`]).
///
/// Mode 10 was recorded as "class/talent ability (DBC)" until wow-re's stable-master carve
@@ -66,6 +73,31 @@ pub enum CursorPayload {
StablePet(CursorStablePet),
}
/// A vendor row held on the cursor — payload **mode 5**, set by `PickupMerchantItem 0x4fb760`.
///
/// **A buy-on-drop cursor, not an item cursor.** The reference writes three globals and this
/// mirrors all three: the item entry (`0xb4b41c`), the display id that drives the icon
/// (`0xb4d8ec` — we carry the resolved texture path, which is the same thing one hop later), and
/// the **0-based** vendor row (`0xb4b420`). Dropping it on a bag slot sends
/// `CMSG_BUY_ITEM_IN_SLOT 0x1a3`; dropping it on the world, pressing ESC, or the vendor window
/// closing is a plain clear with **no packet**.
///
/// No Lua predicate reports it. `CursorHasItem`, `CursorHasSpell`, `CursorHasMoney` and
/// `CursorCanGoInSlot` are all nil for mode 5 — no registered binding exposes it at all.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CursorMerchantItem {
/// The row's item template entry — `0xb4b41c`, and what the buy addresses.
pub item_id: u32,
/// The vendor row this came from, **0-based** (`0xb4b420` = `trunc(arg) - 1`). Kept because
/// the reference keeps it: a second `PickupMerchantItem` naming the same row is a toggle-off,
/// which is the only thing that reads it back.
pub row: u32,
/// The icon drawn at the mouse. The reference stores `ItemDisplayInfo`'s id (`0xb4d8ec`) and
/// resolves the art from it; we hold the already-resolved path, which is the same value one
/// hop later and the shape every other payload here uses.
pub texture: Option<String>,
}
/// A pet held on the cursor from the stable window — payload **mode 10** (`0x495020`, the grab
/// `0x495010`).
///
@@ -299,7 +331,11 @@ pub(crate) fn clear_cursor(model: &mut Model) {
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)
| CursorPayload::StablePet(_),
| CursorPayload::StablePet(_)
// Mode 5 clears like every other non-item payload, and clearing it sends NO packet —
// a vendor cursor abandoned on the world, on ESC, or by the window closing costs
// nothing. `ClearCursor`'s own mode-5 arm is `0x49525f`.
| CursorPayload::Merchant(_),
) => {
queue_cursor_update(model);
}
@@ -595,6 +631,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Some(CursorPayload::StablePet(_)) => {
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
}
// Mode 5 — the vendor grab. Reported as nothing, for the same reason and with a
// stronger warrant: **no registered binding in the 5875 image exposes mode 5 to
// Lua at all.** `CursorHasItem`, `CursorHasSpell`, `CursorHasMoney` and
// `CursorCanGoInSlot` are each nil for it, by census. A vendor cursor is invisible
// from Lua and that invisibility is load-bearing — it is what keeps all thirteen
// stock `CursorHasItem()` gates closed while one is held.
Some(CursorPayload::Merchant(_)) => {
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
}
None => Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil)),
}
})?,
@@ -126,6 +126,10 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
// Mode 10 (decision 1677) — a stabled pet has no `CMSG_SET_ACTION_BUTTON` encoding either,
// so the action bar refuses it and it goes back on the cursor.
CursorPayload::StablePet(_) => None,
// Mode 5 — a vendor row is not a bar action. `PlaceAction`'s payload table takes modes
// 1, 2 and 8 only, and the merchant grab is none of them, so it goes back on the cursor
// where the vendor window can still take it.
CursorPayload::Merchant(_) => None,
};
let Some((kind, action, texture)) = placeable else {
model.cursor = Some(held);
@@ -105,6 +105,27 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
queue_lock_changed(model, held.bag, held.slot);
true
}
// Mode 5 — the buy, aimed at an equipment slot. `0x4c78fd` tests `[0xb4d900] == 5` inside
// the paper-doll click and sends the same `CMSG_BUY_ITEM_IN_SLOT 0x1a3` the bag drop does;
// the server equips it or refuses. Reported in the ONE bag space ([`EQUIPMENT_BAG`] + the
// 1-based inventory slot), the seam this file already uses for every other doll intent.
//
// Of the reference's three drop consumers this is the one that handles a stale vendor row
// correctly — the other two dereference it (`0x4f9f35`, `0x4c7ea5`) — so its refusal is
// what we reproduce in all three.
Some(CursorPayload::Merchant(held)) => {
let entry = model
.merchant
.as_ref()
.and_then(|m| m.items.get(held.row as usize))
.filter(|it| it.item_id == held.item_id)
.map(|it| it.item_id);
if let Some(entry) = entry {
model.merchant_slot_buys.push((EQUIPMENT_BAG, id, entry));
}
queue_cursor_update(model);
true
}
Some(
other @ (CursorPayload::Spell(_)
| CursorPayload::Action(_)
@@ -53,11 +53,26 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(text.trim().parse::<f64>().unwrap_or(0.0))
})?,
)?;
// Insert(text) — **a nil is a no-op, not an error.** The reference's C `Insert` reads its
// argument through `lua_tostring`, which answers NULL for a nil and leaves the buffer alone;
// stock FrameXML relies on that. `LootFrame.lua:152` is the plain case:
//
// ChatFrameEditBox:Insert(GetLootSlotLink(this.slot));
//
// with no guard, on a coin row whose `GetLootSlotLink` is nil. Typed as `String`, this raised
// — which the loot window only survived while we owned the file and could add a guard the
// reference does not have. It bit the moment `LootFrame.xml` came off the player's chain
// (1751), and it would bite any addon writing the same unguarded line.
//
// A NUMBER still inserts its digits: `lua_tostring` converts one in place, and mlua's
// `Option<String>` coercion follows it.
m.set(
"Insert",
lua.create_function(|lua, (this, s): (Table, String)| {
lua.create_function(|lua, (this, s): (Table, Option<String>)| {
let h = frame_handle_of(lua, &this)?;
insert(lua, h, &s, true);
if let Some(s) = s {
insert(lua, h, &s, true);
}
Ok(())
})?,
)?;
+240 -1
View File
@@ -41,10 +41,15 @@
//! `OPEN_MASTER_LOOT_LIST` when a picked row's wire `slot_type` is `MASTER`, which is where the
//! real client puts it too — its take dispatcher branches on the same byte before any Lua runs.
use mlua::{Lua, MultiValue, Value};
use mlua::{Lua, MultiValue, Table, Value};
use super::Model;
/// `CLootButton`'s own Lua method table (`0x847ce4`) — see [`crate::widget::FrameKind::LootButton`].
/// Exactly one entry, and the count is read off the registrar's `mov edx,1` rather than off a run
/// length, so it cannot drift.
pub(super) const REG_LOOTBUTTON_METHODS: &str = "__benilla_lootbutton_methods";
/// One loot-window row, resolved by the app (decision 0084). Plain data — its 1-based order in the
/// window is its position in [`LootState::rows`]; the coin row (when present) is always position 1.
#[derive(Clone, Debug, Default, PartialEq)]
@@ -271,6 +276,42 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ── `LootButton`'s own method table — ONE entry, and this is it ─────────────────────────────
//
// `SetSlot(index)` (`0x4c1880`, table `0x847ce4`, the registrar's own `mov edx,1` says count
// 1). 1-based in, **0-based stored** (`ftol` then `dec eax` into `[this+0x4dc]`), and it
// pushes nothing. `LootFrame.lua:94`'s `button:SetSlot(slot)` is its only caller in the
// shipped UI — the row's `id="N"` does NOT feed it.
//
// A non-number raises the client's own usage string. That is worth transcribing exactly: it
// is the one place this class talks to a caller, and `LootFrame.lua` would surface a typo'd
// call as this text.
{
let m = lua.create_table()?;
m.set(
"SetSlot",
lua.create_function(|lua, (this, index): (Table, Value)| {
let n = match &index {
Value::Integer(i) => *i as f64,
Value::Number(n) => *n,
// `lua_isnumber` accepts a numeric string, as everywhere else in this image.
Value::String(s) => match s.to_str().ok().and_then(|s| s.parse::<f64>().ok()) {
Some(n) => n,
None => return Err(mlua::Error::runtime("Usage: SetSlot(index)")),
},
_ => return Err(mlua::Error::runtime("Usage: SetSlot(index)")),
};
// `ftol` truncates toward zero, then `dec`. A slot below 1 leaves the row taking
// nothing rather than wrapping — the reference stores the decrement raw, but its
// consumer is a bounds-checked table walk and ours is an Option.
let slot = (n.trunc() >= 1.0).then(|| n.trunc() as u32 - 1);
super::button::set_loot_slot(lua, &this, slot)?;
Ok(())
})?,
)?;
lua.set_named_registry_value(REG_LOOTBUTTON_METHODS, m)?;
}
// 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.
//
@@ -624,4 +665,202 @@ mod tests {
assert_eq!(s.eval::<i64>("return GetNumLootItems()").unwrap(), 0);
assert!(s.eval::<bool>("return GetLootSlotInfo(1) == nil").unwrap());
}
/// A real HARDWARE click on a named frame — through the pointer path, so `scripted` is false
/// and the LootButton gate sees what it would see in play. Positions the frame first: the
/// input path is a hit test, and an unpositioned frame is nowhere.
fn hardware_click(s: &mut UiScript, name: &str, button: &str) {
s.set_screen_size(1024.0, 768.0);
s.run(&format!(
"{name}:ClearAllPoints() {name}:SetPoint(\"BOTTOMLEFT\", 100, 100) \
{name}:SetWidth(50) {name}:SetHeight(50) {name}:EnableMouse(true) {name}:Show()"
))
.unwrap();
s.resolve();
s.mouse_button(125.0, 125.0, button, true);
s.mouse_button(125.0, 125.0, button, false);
}
/// `LootButton` is a real `CreateFrame` type with its own identity — not an alias for Button.
#[test]
fn loot_button_is_its_own_registered_type() {
let s = UiScript::new().unwrap();
s.run(r#"lb = CreateFrame("LootButton", "LB1", UIParent)"#)
.unwrap();
assert_eq!(
s.eval::<String>("return lb:GetObjectType()").unwrap(),
"LootButton",
"0x495b60 returns its own name, not \"Button\""
);
// `0x495af0` prepends its name to the base's three.
for t in ["LootButton", "Button", "Frame", "Region"] {
assert!(
s.eval::<bool>(&format!("return lb:IsObjectType({t:?}) and true or false"))
.unwrap(),
"IsObjectType({t:?})"
);
}
assert!(!s
.eval::<bool>(r#"return lb:IsObjectType("CheckButton") and true or false"#)
.unwrap());
// Its one method of its own, plus all of Button's through the chain.
assert_eq!(
s.eval::<String>("return type(lb.SetSlot)").unwrap(),
"function"
);
assert_eq!(
s.eval::<String>("return type(lb.SetText)").unwrap(),
"function"
);
// …and the method is NOT on a plain Button — the chain runs derived → base only.
s.run(r#"b = CreateFrame("Button", "PlainB", UIParent)"#)
.unwrap();
assert_eq!(s.eval::<String>("return type(b.SetSlot)").unwrap(), "nil");
}
/// An unmodified hardware click takes the row's slot. The Lua `OnClick` runs first and
/// unconditionally, and its outcome does not gate the take.
#[test]
fn an_unmodified_click_runs_the_handler_and_then_takes() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
lb = CreateFrame("LootButton", "LB1", UIParent)
lb:SetSlot(3)
ran = 0
lb:SetScript("OnClick", function() ran = ran + 1 end)
"#,
)
.unwrap();
hardware_click(&mut s, "LB1", "LeftButton");
assert_eq!(s.eval::<i64>("return ran").unwrap(), 1, "the handler ran");
assert_eq!(s.take_loot_picks(), vec![3], "and then the take, 1-based");
// The handler erroring does not eat the loot — `0x4c1833`'s result is never tested.
s.run(r#"lb:SetScript("OnClick", function() error("boom") end)"#)
.unwrap();
hardware_click(&mut s, "LB1", "LeftButton");
assert_eq!(s.take_loot_picks(), vec![3], "a broken hook still loots");
}
/// Right-click loots exactly like left-click: `0x4c1820` reads the button code once and
/// forwards it, with no `cmp` against it anywhere in the body.
#[test]
fn right_click_loots_like_left_click() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
lb = CreateFrame("LootButton", "LB1", UIParent)
lb:SetSlot(2)
lb:RegisterForClicks("LeftButtonUp", "RightButtonUp")
"#,
)
.unwrap();
hardware_click(&mut s, "LB1", "RightButton");
assert_eq!(s.take_loot_picks(), vec![2]);
}
/// Any of the three modifiers suppresses the take — and only the take. The handler still runs,
/// which is exactly how the shipped `LootFrameItem_OnClick` gets to own the ctrl and shift
/// cases without the C take firing underneath it.
#[test]
fn any_modifier_suppresses_the_take_but_not_the_handler() {
for (i, (shift, ctrl, alt)) in [
(true, false, false),
(false, true, false),
(false, false, true),
]
.into_iter()
.enumerate()
{
let mut s = UiScript::new().unwrap();
s.run(
r#"
lb = CreateFrame("LootButton", "LB1", UIParent)
lb:SetSlot(1)
ran = 0
lb:SetScript("OnClick", function() ran = ran + 1 end)
"#,
)
.unwrap();
s.set_modifiers(shift, ctrl, alt);
hardware_click(&mut s, "LB1", "LeftButton");
assert_eq!(
s.eval::<i64>("return ran").unwrap(),
1,
"case {i}: handler ran"
);
assert!(s.take_loot_picks().is_empty(), "case {i}: no take");
}
}
/// **A scripted `:Click()` is a complete no-op** — it does not even run the row's `OnClick`.
/// `0x4c182b` returns before the base call. Surprising enough that it is asserted rather than
/// left to be rediscovered.
#[test]
fn a_scripted_click_does_nothing_at_all() {
let mut s = UiScript::new().unwrap();
s.run(
r#"
lb = CreateFrame("LootButton", "LB1", UIParent)
lb:SetSlot(1)
ran = 0
lb:SetScript("OnClick", function() ran = ran + 1 end)
lb:Click()
"#,
)
.unwrap();
assert_eq!(
s.eval::<i64>("return ran").unwrap(),
0,
"the handler never ran"
);
assert!(s.take_loot_picks().is_empty(), "and nothing was taken");
// A plain Button's Click() is unaffected — the gate is this type's alone.
s.run(
r#"
b = CreateFrame("Button", "PlainB", UIParent)
bran = 0
b:SetScript("OnClick", function() bran = bran + 1 end)
b:Click()
"#,
)
.unwrap();
assert_eq!(s.eval::<i64>("return bran").unwrap(), 1);
}
/// `SetSlot` is 1-based in and 0-based stored, takes a numeric string, and refuses `this` that
/// is not a LootButton (the reference's own `IsA` guard at `0x4c18ee`).
#[test]
fn set_slot_converts_and_guards() {
let mut s = UiScript::new().unwrap();
s.run(r#"lb = CreateFrame("LootButton", "LB1", UIParent)"#)
.unwrap();
// `ftol` truncates toward zero: 4.9 is row 4, not 5.
s.run("lb:SetSlot(4.9)").unwrap();
hardware_click(&mut s, "LB1", "LeftButton");
assert_eq!(s.take_loot_picks(), vec![4]);
// A numeric string is a number to `lua_isnumber`.
s.run(r#"lb:SetSlot("2")"#).unwrap();
hardware_click(&mut s, "LB1", "LeftButton");
assert_eq!(s.take_loot_picks(), vec![2]);
// A non-number raises the client's own usage text.
let e = s.run("lb:SetSlot('x')").unwrap_err().to_string();
assert!(e.contains("Usage: SetSlot(index)"), "{e}");
// Never slotted → takes nothing, rather than silently taking row 1.
s.run(r#"fresh = CreateFrame("LootButton", "LB2", UIParent)"#)
.unwrap();
hardware_click(&mut s, "LB2", "LeftButton");
assert!(s.take_loot_picks().is_empty());
// And it is a LootButton method, not a Button one.
s.run(r#"b = CreateFrame("Button", "PlainB", UIParent)"#)
.unwrap();
let e = s.run("LB1.SetSlot(b, 1)").unwrap_err().to_string();
assert!(e.contains("not a LootButton"), "{e}");
}
}
+243
View File
@@ -28,6 +28,7 @@
use mlua::{Lua, MultiValue, Value};
use super::container::UiCursorMode;
use super::cursor::CursorPayload;
use super::Model;
/// One vendor row, resolved by the app from the wire `VendorItem` (decision 0081). Plain data —
@@ -132,6 +133,20 @@ impl super::UiScript {
std::mem::take(&mut self.model_mut().merchant_buys)
}
/// Drain the `(bag, slot)` sells `PickupMerchantItem`'s cursor arm queued — the item the
/// cursor was holding when it was dropped on the vendor window. The app resolves the concrete
/// item guid from the pair and sends `CMSG_SELL_ITEM`, the same resolution the bag-click sell
/// route already does.
pub fn take_merchant_cursor_sells(&mut self) -> Vec<(i64, u32)> {
std::mem::take(&mut self.model_mut().merchant_cursor_sells)
}
/// Drain the `(bag, slot, item entry)` buys a held vendor row queued by being dropped into a
/// container slot — `CMSG_BUY_ITEM_IN_SLOT 0x1a3`.
pub fn take_merchant_slot_buys(&mut self) -> Vec<(i64, u32, u32)> {
std::mem::take(&mut self.model_mut().merchant_slot_buys)
}
/// Whether `CloseMerchant` was called since the last drain (and clear the flag). vanilla's
/// client-side close sends no packet — the app just clears its local merchant state.
pub fn take_merchant_close(&mut self) -> bool {
@@ -327,6 +342,86 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// PickupMerchantItem(index) — **two verbs behind one name** (`0x4fb760`, wow-re
// `system/ui/scratch/merchant-cursor-law.md`). `0x4fb787` calls `GetCursorItem 0x494c60`,
// which answers for **mode 1 only**, and the result forks the whole function:
//
// * cursor holds a real bag item → **SELL it** (`CMSG_SELL_ITEM 0x1a0`), then clear the
// cursor. The index is IGNORED on this arm and there is no merchant-open gate on it. This
// is what stock `MerchantFrame.xml:743`'s `<OnMouseUp>PickupMerchantItem(0)</OnMouseUp>`
// is for: dropping a bag item anywhere on the vendor window sells it.
// * otherwise → **grab** vendor row `index - 1` as cursor mode 5.
//
// Stock FrameXML never guards this call with `CursorHasItem()` — `MerchantFrame.lua:329`'s
// `else` is the else of the ctrl/shift modifier chain. An unmodified left-click calls it
// whatever the cursor holds, and the binding decides.
//
// **Every refusal is silent, and every refusal CLEARS THE CURSOR** — there is no `luaL_error`
// anywhere in the range, unlike its neighbour `BuyMerchantItem`. The ladder, in order:
// non-number argument (`0x4fb7cb`; a numeric *string* is accepted), `index < 1` (`0x4fb7e1`),
// `index > count` (`0x4fb7e9`), no merchant open (`0x4fb7f7`), a dead row (`0x4fb80b`), and a
// re-click on the row already held (`0x4fb818`, a toggle-off). Conversion is `_ftol` —
// **truncate toward zero**, then `-1`, bounded signed.
//
// It does **not** test stock and does **not** test the item cache: a sold-out row grabs fine
// and the buy still goes out, and the server refuses it. `numAvailable` is read exactly once
// image-wide, by `GetMerchantItemInfo`, for display.
g.set(
"PickupMerchantItem",
lua.create_function(|lua, index: Option<f64>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The sell fork, and it comes FIRST — before the argument is even looked at.
if let Some(CursorPayload::Item(item)) = &model.cursor {
let pair = (item.bag, item.slot);
model.cursor = None;
model.merchant_cursor_sells.push(pair);
return Ok(());
}
// From here every leg clears the cursor and answers nothing. Holding a mode-5 payload
// and calling this again is the toggle-off, which is the same clear.
let held = match &model.cursor {
Some(CursorPayload::Merchant(m)) => Some(m.row),
_ => None,
};
model.cursor = None;
let Some(index) = index else { return Ok(()) };
// `_ftol` truncates toward zero; the bound is then applied signed, so a negative or
// a zero index falls out here rather than wrapping into a row. NaN is written as its
// own test rather than as a negated `>=` — same answer, and the intent is on the page.
//
// The upper bound is ours and it is load-bearing, not defensive: without it a Lua
// index of `2^32 + 1` truncates on the cast to row 0 and would grab the FIRST row.
// The reference cannot have that bug — its bound is against the vendor count, which
// it applies before any narrowing.
let row = index.trunc();
if row.is_nan() || row < 1.0 || row > f64::from(u32::MAX) {
return Ok(());
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let row0 = row as u32 - 1;
let Some(merchant) = model.merchant.as_ref() else {
return Ok(());
};
let Some(item) = merchant.items.get(row0 as usize) else {
return Ok(());
};
// The toggle-off: naming the row already on the cursor puts nothing back.
if held == Some(row0) {
return Ok(());
}
let payload = CursorPayload::Merchant(super::cursor::CursorMerchantItem {
item_id: item.item_id,
row: row0,
texture: item.texture.clone(),
});
model.cursor = Some(payload);
Ok(())
})?,
)?;
// CloseMerchant() — client-side close (no packet, vanilla): flag it so the app clears its state.
g.set(
"CloseMerchant",
@@ -908,4 +1003,152 @@ mod tests {
s.run("ShowMerchantSellCursor(99)").unwrap();
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy));
}
/// `PickupMerchantItem` is TWO verbs, and which one runs is decided by the cursor, never by
/// the caller — stock FrameXML never guards it (wow-re `merchant-cursor-law.md`).
#[test]
fn pickup_merchant_item_grabs_a_row_as_mode_5() {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
s.run("PickupMerchantItem(1)").unwrap();
// Mode 5 is INVISIBLE to Lua. This is the load-bearing assertion in the whole file: all
// thirteen stock `CursorHasItem()` gates stay closed while a vendor cursor is held.
assert!(
s.eval::<bool>("return not CursorHasItem()").unwrap(),
"CursorHasItem is nil for mode 5"
);
assert!(s.eval::<bool>("return not CursorHasSpell()").unwrap());
assert!(
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
"GetCursorInfo reports nothing — no binding exposes mode 5"
);
// …but it IS held: a second call naming the same row is the toggle-off (`0x4fb818`), and
// a third call re-grabs it. Nothing else can observe the difference from Lua, so the
// toggle is what the test reads it through.
s.run("PickupMerchantItem(1)").unwrap();
s.run("PickupMerchantItem(1)").unwrap();
assert!(s.take_merchant_slot_buys().is_empty(), "no drop yet");
}
/// Every refusal is silent AND clears the cursor — there is no `luaL_error` in the range.
#[test]
fn pickup_merchant_item_refuses_silently_and_clears() {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
for bad in [
"PickupMerchantItem(0)",
"PickupMerchantItem(-1)",
"PickupMerchantItem(99)",
] {
s.run("PickupMerchantItem(1)").unwrap(); // hold something first
s.run(bad)
.unwrap_or_else(|e| panic!("{bad} must not raise: {e}"));
assert!(
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
"{bad} cleared the cursor"
);
}
// A missing argument is the same silent clear, not an error.
s.run("PickupMerchantItem(1)").unwrap();
s.run("PickupMerchantItem()").unwrap();
// A wrapping index must not become a valid row: `2^32 + 1` narrows to 0 on a naive cast
// and would grab row 1. It refuses, and the cursor stays empty.
s.run("PickupMerchantItem(4294967297)").unwrap();
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(
s.take_merchant_slot_buys().is_empty(),
"the wrapping index grabbed nothing"
);
// A numeric STRING is accepted (`_ftol` over a converted argument), and `1.9` truncates
// toward zero to row 1 — not rounds to 2.
s.run(r#"PickupMerchantItem("1")"#).unwrap();
s.run("PickupMerchantItem(1.9)").unwrap();
// …which the toggle proves: 1.9 named the row 1 already held, so it toggled off.
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
}
/// The sell arm: a bag item on the cursor makes this a sell, the index is ignored, and the
/// cursor empties. This is what stock `MerchantFrame.xml:743`'s `PickupMerchantItem(0)` is —
/// an index the grab arm would refuse outright.
#[test]
fn pickup_merchant_item_sells_what_the_cursor_holds() {
use crate::script::cursor::{CursorItem, CursorPayload};
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
s.model_mut().cursor = Some(CursorPayload::Item(CursorItem {
bag: 0,
slot: 7,
item_id: 4540,
texture: None,
link: None,
count: None,
quality: None,
equip_slots: Vec::new(),
bar_placeable: false,
}));
s.run("PickupMerchantItem(0)").unwrap();
assert_eq!(
s.take_merchant_cursor_sells(),
vec![(0, 7)],
"index 0 is ignored — the cursor decides"
);
assert!(s.eval::<bool>("return not CursorHasItem()").unwrap());
assert!(s.take_merchant_cursor_sells().is_empty(), "drained");
}
/// The drop is the buy. A held row placed into a bag slot queues `CMSG_BUY_ITEM_IN_SLOT`.
#[test]
fn a_held_vendor_row_dropped_in_a_bag_is_a_buy() {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
s.run("PickupMerchantItem(1)").unwrap();
s.run("PickupContainerItem(0, 3)").unwrap();
assert_eq!(
s.take_merchant_slot_buys(),
vec![(0, 3, 159)],
"the row's item entry, aimed at the dropped-on slot"
);
assert!(
s.eval::<bool>("return GetCursorInfo() == nil").unwrap(),
"the cursor clears on the drop"
);
}
/// A stale row is a refusal, not a wrong buy. The reference has a latent null deref on two of
/// its three drop paths for exactly this case (a fresh `SMSG_LIST_INVENTORY` rewrites the
/// count without clearing the cursor); we take the third path's behaviour on all of them.
#[test]
fn a_stale_vendor_row_drops_without_buying() {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
s.run("PickupMerchantItem(2)").unwrap();
// The vendor list is replaced while the cursor still holds row 2 of the old one.
let mut shorter = stock();
shorter.items.truncate(1);
s.set_merchant(Some(shorter));
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(
s.take_merchant_slot_buys().is_empty(),
"no buy goes out for a row that no longer resolves"
);
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
}
/// A vendor cursor is not a bar action and not an equip: `PlaceAction` refuses it and hands it
/// back, exactly as it does the pet-action and stable-pet payloads.
#[test]
fn the_action_bar_refuses_a_vendor_row_and_keeps_it_held() {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
s.run("PickupMerchantItem(1)").unwrap();
s.run("PlaceAction(1)").unwrap();
// Still held, proven positively: dropping it in a bag afterwards still buys. An asserted
// *absence* here would pass just as well if the bar had eaten the payload.
s.run("PickupContainerItem(0, 5)").unwrap();
assert_eq!(s.take_merchant_slot_buys(), vec![(0, 5, 159)]);
}
}
+5 -2
View File
@@ -171,8 +171,8 @@ pub use container::{
};
pub use craft::{CraftReagent, CraftRecipe, CraftState, CraftTooltip};
pub use cursor::{
CursorAction, CursorItem, CursorMacro, CursorPayload, CursorPetAction, CursorSpell,
CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
CursorAction, CursorItem, CursorMacro, CursorMerchantItem, CursorPayload, CursorPetAction,
CursorSpell, CursorStablePet, EnchantConfirm, WorldPick, EQUIPMENT_BAG,
};
pub use cvars::{MultisampleFormat, CVAR_NAMEPLATE_ENEMIES, CVAR_NAMEPLATE_FRIENDS};
pub use death::{DeathAction, DeathUiState};
@@ -1379,6 +1379,9 @@ impl UiScript {
crate::widget::FrameKind::Frame => "Frame",
crate::widget::FrameKind::Button => "Button",
crate::widget::FrameKind::CheckButton => "CheckButton",
// `GetObjectType 0x495b60` is two instructions and returns `"LootButton"`
// (`[0x847ce0]` → `0x843414`). It does NOT answer "Button".
crate::widget::FrameKind::LootButton => "LootButton",
crate::widget::FrameKind::EditBox => "EditBox",
crate::widget::FrameKind::StatusBar => "StatusBar",
crate::widget::FrameKind::Slider => "Slider",
+8
View File
@@ -901,6 +901,12 @@ pub(crate) struct Model {
/// seam ([`merchant`]).
pub(crate) merchant: Option<merchant::MerchantState>,
pub(crate) merchant_buys: Vec<(u32, u32)>,
/// `PickupMerchantItem`'s SELL arm — the `(bag, slot)` the cursor was holding when it was
/// called over the vendor window. The app resolves the guid and sends `CMSG_SELL_ITEM`.
pub(crate) merchant_cursor_sells: Vec<(i64, u32)>,
/// A held vendor row dropped into a bag — `(bag, slot, item entry)`, the app's
/// `CMSG_BUY_ITEM_IN_SLOT`.
pub(crate) merchant_slot_buys: Vec<(i64, u32, u32)>,
pub(crate) merchant_close: bool,
/// `BuybackItem` intents (1-based buyback slots), the `RepairAllItems` flag, and the
/// client-side repair-mode latch (`ShowRepairCursor`/`InRepairMode`) — the rest of the
@@ -1667,6 +1673,8 @@ impl Model {
gossip_quest_selects: Vec::new(),
merchant: None,
merchant_buys: Vec::new(),
merchant_cursor_sells: Vec::new(),
merchant_slot_buys: Vec::new(),
merchant_close: false,
merchant_buybacks: Vec::new(),
repair_all: false,
+11
View File
@@ -197,6 +197,11 @@ fn frame_kind_from_str(s: &str) -> Option<FrameKind> {
"FRAME" => FrameKind::Frame,
"BUTTON" => FrameKind::Button,
"CHECKBUTTON" => FrameKind::CheckButton,
// A real registered type (`0x4959a6`), not an alias for Button — decision 1788. Registering
// it AS a Button would load `LootFrame.xml` and leave every row dead, because 1.12 has no
// Lua verb for "take slot N": `LootSlot` is the bind-confirm continuation, hard-wired to
// `flag = 1`.
"LOOTBUTTON" => FrameKind::LootButton,
"EDITBOX" => FrameKind::EditBox,
"STATUSBAR" => FrameKind::StatusBar,
"SLIDER" => FrameKind::Slider,
@@ -347,6 +352,12 @@ fn kind_method_registries(lua: &Lua, this: &Table) -> &'static [&'static str] {
Some(FrameKind::Slider) => &[super::slider::REG_SLIDER_METHODS],
Some(FrameKind::ColorSelect) => &[super::colorselect::REG_COLORSELECT_METHODS],
Some(FrameKind::Button) => &[super::button::REG_BUTTON_METHODS],
// One method of its own, then all of Button's — the miss order of `0x4c1be0`, which probes
// its own 1-entry map `0xb71b64` and tail-calls `Button`'s `0x782c90`.
Some(FrameKind::LootButton) => &[
super::loot::REG_LOOTBUTTON_METHODS,
super::button::REG_BUTTON_METHODS,
],
Some(FrameKind::CheckButton) => &[
super::button::REG_CHECKBUTTON_METHODS,
super::button::REG_BUTTON_METHODS,
@@ -117,6 +117,10 @@ pub(super) fn install(lua: &Lua, m: &Table) -> mlua::Result<()> {
FrameKind::Frame => &["Frame", "Region"],
FrameKind::Button => &["Button", "Frame", "Region"],
FrameKind::CheckButton => &["CheckButton", "Button", "Frame", "Region"],
// `CLootButton::IsObjectType 0x495af0` prepends its own name to the base's three
// (`"Button"` `[0x879954]`, `"Frame"` `[0x878560]`, `"Region"` `[0x878870]`) — read
// off the function, not assumed from the class hierarchy.
FrameKind::LootButton => &["LootButton", "Button", "Frame", "Region"],
FrameKind::EditBox => &["EditBox", "Frame", "Region"],
FrameKind::StatusBar => &["StatusBar", "Frame", "Region"],
FrameKind::Slider => &["Slider", "Frame", "Region"],
+1 -1
View File
@@ -702,7 +702,7 @@ impl UiScript {
if let Some(id) = click_id {
// One click home for the physical and programmatic (`Click()`) paths: gated on a
// Button's enabled flag, a CheckButton toggles before OnClick fires (button.rs).
button::click_button(&self.lua, id, button, down);
button::click_button(&self.lua, id, button, down, false);
}
// `OnDoubleClick(self, button)` — fired where `OnClick` would have been and instead of it
// (`0x77938d call [edx+0x98]`, the mouse-UP dispatcher's exclusive second leg). One
@@ -587,6 +587,36 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
}
})?,
)?;
// UnitHasRelicSlot(unit) → **the number 1, or nil** (`0x519e50`). Whether this unit's
// INVSLOT 17 holds a Libram/Totem/Idol instead of a bow.
//
// The whole body is: typemask bit 4 (PLAYER) → `UNIT_FIELD_BYTES_0` byte 1 (the class) →
// bounds against `ds:0xc0def8` → `ds:0xc0def4`[class] → `[row+0x40]`, `ChrClasses.dbc` field
// 16. There is **no `cmp` against a class id anywhere in the function** — 1.12 is entirely
// data-driven here, and `benilla_formats::ChrClasses` is that data. The app resolves it and
// hands us the bit, exactly as it does for `class_file`.
//
// **This shipped absent for a long time, on a claim that was simply false** — that the relic
// slot post-dates 1.12 and `UnitHasRelicSlot` is a later-era verb. The base `dbc.MPQ` copy of
// `ChrClasses.dbc` really does have no field 16; the `patch.MPQ` copy the client actually
// reads has it, set for Paladin, Shaman and Druid. Decision 1785. Stock `PaperDollFrame.lua`
// calls this **unconditionally** at l.429 and l.580 — so while it was missing, the character
// sheet raised `attempt to call global 'UnitHasRelicSlot' (a nil value)` for *every* class,
// not just the three.
//
// No `"player"` fast path (unlike `UnitClass 0x5183b0`, which has one), so this answers for
// any token — `UnitHasRelicSlot("target")` on an enemy druid is 1, and the stock inspect path
// at `PaperDollFrame.lua:429` depends on exactly that.
//
// The truthy leg is the NUMBER 1, never a boolean; the false leg is nil, never `false`.
g.set(
"UnitHasRelicSlot",
lua.create_function(|lua, token: Option<String>| {
let has = with_unit(lua, &token, false, |u| u.has_relic_slot)?;
Ok(if has { Value::Integer(1) } else { Value::Nil })
})?,
)?;
// UnitSex(unit) → 2 male, 3 female (1 = neuter — no 1.12 unit feed produces it); nil when the
// unit is absent or the sex hasn't streamed (`0`), the API's "can't tell".
g.set(
+12
View File
@@ -163,6 +163,18 @@ pub struct UnitState {
/// the ref's stat-tooltip lookups key on (`getglobal(strupper(class).."_STAT_TOOLTIP")` —
/// the ref uppercases it again defensively; we store it already uppercase).
pub class_file: Option<String>,
/// This unit's INVSLOT 17 is a **relic** slot rather than a ranged-weapon slot —
/// `UnitHasRelicSlot`'s whole answer (`0x519e50`).
///
/// Resolved app-side off `ChrClasses.dbc` field 16, for the same reason [`Self::class_file`]
/// is resolved app-side: it is a static per-class column of a table this crate cannot read.
/// The reference gates on TYPEMASK_PLAYER before it looks at the class byte, so the feed sets
/// this only for a player — a creature, a pet and an unstreamed unit all leave it `false`,
/// which is the nil leg.
///
/// True for exactly Paladin, Shaman and Druid in 1.12 (Libram, Totem, Idol). It was believed
/// here for a long time that no 1.12 class had one; see decision 1785 for how that survived.
pub has_relic_slot: bool,
/// The unit's sex on the `UnitSex` scale: `2` male, `3` female (`1` = neuter/unknown). `0`
/// (the unfilled default) reports as nil — the API's "can't tell", like [`Self::reaction`].
/// The app maps `UNIT_FIELD_BYTES_0` byte 2 (0 male / 1 female) onto this shape.
@@ -1291,3 +1291,56 @@ fn unit_is_party_leader_ors_two_legs_and_answers_one_when_solo() {
// A bad token still raises through the shared resolver — shape C with a shape-A tail.
assert!(s.run(r#"UnitIsPartyLeader("notatoken")"#).is_err());
}
/// `UnitHasRelicSlot` — the number 1 or nil, per token, never a boolean.
///
/// This shipped **absent** for months on the belief that the relic slot post-dates 1.12, which is
/// false (decision 1785). Stock `PaperDollFrame.lua` calls it unconditionally at l.429 and l.580,
/// so while it was missing the character sheet raised `attempt to call global` for every class —
/// which is why the nil-global case is asserted here too, not just the answer.
#[test]
fn unit_has_relic_slot_answers_one_or_nil() {
let mut s = UiScript::new().unwrap();
let mut druid = player();
druid.class = Some("Druid".into());
druid.class_file = Some("DRUID".into());
druid.has_relic_slot = true;
s.set_unit("player", Some(druid));
let mut warrior = player();
warrior.has_relic_slot = false;
s.set_unit("target", Some(warrior));
// The truthy leg is the NUMBER 1 (`lua_pushnumber`, `0x519ec8`) — a caller comparing it to 1
// is stock idiom, and a boolean would silently fail that.
assert_eq!(
s.eval::<String>(r#"return tostring(UnitHasRelicSlot("player"))"#)
.unwrap(),
"1"
);
assert_eq!(
s.eval::<String>(r#"return type(UnitHasRelicSlot("player"))"#)
.unwrap(),
"number"
);
// The false leg is nil, never `false` (`lua_pushnil`, `0x519edb`).
assert_eq!(
s.eval::<String>(r#"return type(UnitHasRelicSlot("target"))"#)
.unwrap(),
"nil"
);
// No `"player"` fast path in the reference, so every token is answered the same way — the
// stock inspect path at `PaperDollFrame.lua:429` passes the inspected unit, not "player".
assert_eq!(
s.eval::<String>(r#"return type(UnitHasRelicSlot("party1"))"#)
.unwrap(),
"nil"
);
// And it EXISTS — the regression that actually bit. A nil global here takes the whole
// character sheet down for every class, not just the three that answer 1.
assert_eq!(
s.eval::<String>("return type(UnitHasRelicSlot)").unwrap(),
"function"
);
}
+37
View File
@@ -21,6 +21,35 @@ pub enum FrameKind {
/// Plain `CSimpleFrame` — the base container.
Frame,
Button,
/// `CLootButton` (`Ui\\LootFrame.h:21`, factory `0x495a30`, size `0x4e0`) — a
/// **registered `CreateFrame` type**, one of the eight the client registers through
/// `0x495940` (pair `("LootButton" @0x843414, 0x495a30)` at site `0x4959a6`), and the reason
/// the stock `LootFrame.xml` cannot load against a plain `<Button>`.
///
/// It is a `CSimpleButton` **plus one dword and one overridden virtual**, and that is the
/// whole class. The dword at `+0x4dc` is the 0-based loot slot; the virtual is primary slot 37
/// `OnClick 0x4c1820`. Everything else it overrides (slots 0, 2, 4, 6, 7 and the secondary
/// adjustor thunk) is compiler-mandated identity and lifetime plumbing. A binary-wide census
/// of `+0x4dc` finds exactly three sites in its code: the ctor's zero, `SetSlot`'s write, and
/// `OnClick`'s read.
///
/// **What it does NOT have**, each proven by the vtable dwords being *identical* to
/// `CSimpleButton`'s: no C-side `OnEnter`/`OnLeave` (slots 19/20), so the hover tooltip is
/// purely the FrameXML `<OnEnter>`; no drag (29/30/31); no `OnLoad`/`OnShow`/`OnHide`/
/// `OnUpdate` (9/12/13/14). And no button-code test anywhere in its 79-byte click body — it
/// reads `[ebp+8]` once and immediately forwards it — so **right-click loots exactly like
/// left-click**, and with no `<OnDoubleClick>` a fast double-click takes two slots.
///
/// The click, in order (`0x4c1820`): a scripted click returns immediately and does **nothing,
/// not even run the Lua `<OnClick>`** (`0x4c182b`); otherwise the base fires the Lua handler
/// unconditionally, and then — only with **no shift, ctrl or alt** held — the take runs. That
/// gate is what keeps the C take and `LootFrameItem_OnClick`'s ctrl/shift arms from firing
/// together, and it is why the shipped Lua handler deliberately never calls a take itself.
///
/// Its whole Lua surface of its own is **one method, `SetSlot(index)`** (table `0x847ce4`,
/// count 1 read off the registrar's own `mov edx,1`) — 1-based in, 0-based stored, no returns.
/// `LootFrame.lua:94` is its only caller. Decision 1788; wow-re `e5338caf`.
LootButton,
CheckButton,
EditBox,
StatusBar,
@@ -590,6 +619,13 @@ pub struct ButtonState {
/// answers "NORMAL" here where the real engine's one state variable would say "PUSHED".
/// Nothing in the transcribed FrameXML reads the state mid-mouse-press.
pub pushed_state: bool,
/// [`FrameKind::LootButton`]'s own field — `CLootButton +0x4dc`, the **0-based** loot slot
/// this row takes when clicked. `None` until `SetSlot` writes it (the ctor's zero is a slot
/// id in the reference; ours is `None`, so a row that was never given one takes nothing
/// rather than silently taking slot 0). Unused on every other button kind, the same way
/// [`Self::checked`] below is unused on a plain Button — the client's subclasses add their one
/// field the same way, and our flat state mirrors that rather than growing a variant.
pub loot_slot: Option<u32>,
/// CheckButton's checked flag (`+0x4dc`; XML `checked`). Unused on a plain Button.
pub checked: bool,
/// `<NormalTexture>`/`SetNormalTexture` (`+0x4bc`).
@@ -659,6 +695,7 @@ pub struct ButtonState {
impl Default for ButtonState {
fn default() -> Self {
ButtonState {
loot_slot: None,
enabled: true,
pushed_state: false,
checked: false,
+3 -1
View File
@@ -660,7 +660,9 @@ impl WidgetArena {
insertion_seq,
kind_state: match kind {
FrameKind::StatusBar => KindState::StatusBar(StatusBarState::default()),
FrameKind::Button | FrameKind::CheckButton => {
// `CLootButton` and `CSimpleCheckbox` both extend `CSimpleButton` and both add
// exactly one field, which `ButtonState` carries for them (`loot_slot`/`checked`).
FrameKind::Button | FrameKind::CheckButton | FrameKind::LootButton => {
KindState::Button(ButtonState::default())
}
FrameKind::EditBox => KindState::EditBox(EditBoxState::default()),