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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:#}"
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()),
|
||||
|
||||
Reference in New Issue
Block a user