From c0b7a613a945310016535abc56afd2ffc3fb296d Mon Sep 17 00:00:00 2001 From: Sam Date: Sat, 29 Aug 2026 17:19:45 +0700 Subject: [PATCH] the stable master is in, and master loot hands drops to the raid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - the stable master: `MSG_LIST_STABLED_PETS` both ways, the four mutations answered by one result byte, two slots priced from `StableSlotPrices.dbc` (500 and 50000 copper — the cap falls out of the data, corroborated by vmangos's `MAX_PET_STABLES` and the reference's `NUM_PET_STABLE_SLOTS`), rows seated by slot never by position (a petless hunter's list starts at a stabled row), slot 0 read from the wire because a dismissed pet has a row and no live unit, a re-list after every success because no result carries one, and `GetPetIcon` reading `CreatureFamily.dbc`'s icon column — which ships `Ability_Druid_CatForm` for both warlock families, asserted as shipped - master loot: `CMSG_LOOT_MASTER_GIVE` (the slot is a byte here where the roll family's is a u32) and `SMSG_LOOT_MASTER_LIST`; the click gate is the wire's slot-type byte and nothing else — the reference never asks who the master looter is — so a MASTER row fires `OPEN_MASTER_LOOT_LIST` instead of a take and auto-loot skips every non-ALLOW row; the candidate array is bucketed by raid subgroup into blocks of five (a dense list would have filed every candidate under the wrong "Group N"), an unresolved name is nil so `UPDATE_MASTER_LOOT_LIST` has something to repaint, and a give re-checks the row is still MASTER before sending; found on the way: a master looter in another raid subgroup lit the crown on your own portrait - `SetLootMethod` takes its third (threshold) argument; the reference's silent reset to Uncommon when it is absent is flagged and not adopted; the stable's model pane draws nothing yet — a stabled pet is a database row with no world entity, and a booth that renders from a bare display id is the next arc Co-Authored-By: Claude Fable 5 --- crates/benilla-app/assets/ui/LootFrame.xml | 210 ++++- crates/benilla-app/assets/ui/PartyFrame.xml | 5 + crates/benilla-app/assets/ui/PetStable.xml | 744 ++++++++++++++++++ crates/benilla-app/assets/ui/UiPanels.xml | 13 +- crates/benilla-app/assets/ui/benilla.toc | 11 + crates/benilla-app/src/capture/fixtures.rs | 8 + crates/benilla-app/src/creature_anim/lod.rs | 1 + crates/benilla-app/src/entities.rs | 4 + crates/benilla-app/src/lib.rs | 3 + crates/benilla-app/src/names.rs | 22 + crates/benilla-app/src/net.rs | 21 + crates/benilla-app/src/net/apply.rs | 25 +- crates/benilla-app/src/net/apply/loot.rs | 8 + crates/benilla-app/src/net/apply/names.rs | 2 + crates/benilla-app/src/net/apply/npc.rs | 48 +- crates/benilla-app/src/net/io.rs | 12 + crates/benilla-app/src/ui_loot.rs | 468 ++++++++++- crates/benilla-app/src/ui_party/feed.rs | 66 +- crates/benilla-app/src/ui_party/mod.rs | 5 +- crates/benilla-app/src/ui_pet_stats.rs | 70 +- .../benilla-app/src/ui_script/escape_tests.rs | 5 + .../src/ui_script/game_menu_tests.rs | 5 + .../benilla-app/src/ui_script/loot_tests.rs | 263 ++++++- .../src/ui_script/merchant_tests.rs | 4 +- .../src/ui_script/pet_paperdoll_tests.rs | 1 + .../src/ui_script/questlog_tests.rs | 5 +- crates/benilla-app/src/ui_stable.rs | 298 +++++++ crates/benilla-app/src/ui_unit.rs | 1 + .../benilla-formats/src/creature_families.rs | 52 ++ crates/benilla-formats/src/lib.rs | 2 + .../benilla-formats/src/stable_slot_prices.rs | 127 +++ .../src/bin/benilla-world/probes/loot.rs | 13 + crates/benilla-protocol/src/events.rs | 31 +- crates/benilla-protocol/src/events/decode.rs | 18 +- crates/benilla-protocol/src/messages/loot.rs | 103 ++- crates/benilla-protocol/src/messages/mod.rs | 11 +- .../benilla-protocol/src/messages/opcode.rs | 23 +- .../benilla-protocol/src/messages/packet.rs | 44 +- crates/benilla-protocol/src/messages/parse.rs | 32 +- .../benilla-protocol/src/messages/stable.rs | 198 +++++ .../benilla-protocol/src/world/writer/loot.rs | 16 + .../benilla-protocol/src/world/writer/mod.rs | 2 + .../src/world/writer/stable.rs | 93 +++ crates/benilla-protocol/tests/client.rs | 25 +- crates/benilla-protocol/tests/stable.rs | 206 +++++ crates/benilla-ui/src/script/loot.rs | 168 ++++ crates/benilla-ui/src/script/mod.rs | 3 + crates/benilla-ui/src/script/model.rs | 16 +- crates/benilla-ui/src/script/party.rs | 40 +- crates/benilla-ui/src/script/pet.rs | 52 ++ crates/benilla-ui/src/script/stable.rs | 604 ++++++++++++++ .../script/tests/tooltip_item/mechanics.rs | 2 + 52 files changed, 4093 insertions(+), 116 deletions(-) create mode 100644 crates/benilla-app/assets/ui/PetStable.xml create mode 100644 crates/benilla-app/src/ui_stable.rs create mode 100644 crates/benilla-formats/src/stable_slot_prices.rs create mode 100644 crates/benilla-protocol/src/messages/stable.rs create mode 100644 crates/benilla-protocol/src/world/writer/stable.rs create mode 100644 crates/benilla-protocol/tests/stable.rs create mode 100644 crates/benilla-ui/src/script/stable.rs diff --git a/crates/benilla-app/assets/ui/LootFrame.xml b/crates/benilla-app/assets/ui/LootFrame.xml index 726cbf8c..1f40c982 100644 --- a/crates/benilla-app/assets/ui/LootFrame.xml +++ b/crates/benilla-app/assets/ui/LootFrame.xml @@ -66,6 +66,50 @@ 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") @@ -117,9 +161,10 @@ -- 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 - return + 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 @@ -127,13 +172,32 @@ -- 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)) - return + 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. - return + 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 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 LootSlot(button.slot) end @@ -204,6 +268,9 @@ 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() @@ -241,6 +308,10 @@ frame:RegisterEvent("LOOT_OPENED") frame:RegisterEvent("LOOT_UPDATE") frame:RegisterEvent("LOOT_CLOSED") + -- 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) @@ -268,6 +339,14 @@ end elseif event == "LOOT_CLOSED" then HideUIPanel(frame) + 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 @@ -304,6 +383,115 @@ -- 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 ]]> + + + + + + GroupLootDropDown_OnLoad() + + diff --git a/crates/benilla-app/assets/ui/PartyFrame.xml b/crates/benilla-app/assets/ui/PartyFrame.xml index d1c05c7e..a327fc8d 100644 --- a/crates/benilla-app/assets/ui/PartyFrame.xml +++ b/crates/benilla-app/assets/ui/PartyFrame.xml @@ -52,6 +52,11 @@ -- as one. NUM_RAID_GROUPS = 8 MEMBERS_PER_RAID_GROUP = 5 + -- The party's own bound (ref PartyMemberFrame.lua l.1) — four *other* members, so a full + -- party sweep that includes the player runs `1, MAX_PARTY_MEMBERS+1`, which is exactly how + -- GroupLootDropDown_Initialize walks the master-loot candidates (LootFrame.lua l.219). + -- Published for the same reason as its raid siblings: the addon corpus reads it. + MAX_PARTY_MEMBERS = 4 -- ── The group-invite popup ──────────────────────────────────────────────────────────── -- The strings and the three flags are the reference's own (INVITATION = "%s invites you to diff --git a/crates/benilla-app/assets/ui/PetStable.xml b/crates/benilla-app/assets/ui/PetStable.xml new file mode 100644 index 00000000..a8522d2f --- /dev/null +++ b/crates/benilla-app/assets/ui/PetStable.xml @@ -0,0 +1,744 @@ + + + + + + + + + + + + + + + + + + + + + + this:RegisterForDrag("LeftButton") + + -- xml l.31-38. Guarded on `this.tooltip` because Update has not necessarily run + -- yet: a hover before the first repaint has nothing to say and says nothing. + if this.tooltip then + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText(this.tooltip) + GameTooltip:AddLine(this.tooltipSubtext, "", 1.0, 1.0, 1.0) + GameTooltip:Show() + end + + GameTooltip:Hide() + + if ClickStablePet(this:GetID()) then + PetStable_Update() + end + + PickupStablePet(this:GetID()) + + if ClickStablePet(this:GetID()) then + PetStable_Update() + end + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + GameTooltip:SetOwner(this, "ANCHOR_RIGHT") + GameTooltip:SetText(this.tooltip) + + GameTooltip:Hide() + + + + + + + this.setFacing = BenillaPetStableModel_SetFacing + BenillaPaperDollModel_OnLoad(this) + BenillaSetBoothTexture(getglobal(this:GetName() .. "Texture"), "stable") + + BenillaPaperDollModel_OnUpdate(this, arg1) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SmallMoneyFrame_OnLoad() MoneyFrame_SetType("STATIC") + + + + + + + + PetStable_OnLoad() + PlaySound("igCharacterInfoOpen") + ClosePetStables() PlaySound("igCharacterInfoClose") + PetStable_OnEvent() + + + diff --git a/crates/benilla-app/assets/ui/UiPanels.xml b/crates/benilla-app/assets/ui/UiPanels.xml index f921154f..c80f9fbb 100644 --- a/crates/benilla-app/assets/ui/UiPanels.xml +++ b/crates/benilla-app/assets/ui/UiPanels.xml @@ -26,9 +26,9 @@