the stable master is in, and master loot hands drops to the raid
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 <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
|
||||
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
|
||||
]]></Script>
|
||||
|
||||
<!--
|
||||
@@ -467,4 +655,20 @@
|
||||
<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.
|
||||
-->
|
||||
<Frame name="GroupLootDropDown" parent="UIParent" inherits="UIDropDownMenuTemplate" id="1" hidden="true">
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"/>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>GroupLootDropDown_OnLoad()</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,744 @@
|
||||
<Ui>
|
||||
<!--
|
||||
benilla's stable-master window (decision 1676) — the hunter's three-slot pet stable, dressed
|
||||
from the real 1.12 `PetStable.xml`/`PetStable.lua`. Every rule below is cited back to that
|
||||
extraction: `xml l.N` is `ref-PetStable.xml`, `lua l.N` is `ref-PetStable.lua`, and
|
||||
`GlobalStrings l.N` is the shipped `GlobalStrings.lua`.
|
||||
|
||||
**The Lua lives here, inline.** The reference splits it (`<Script file="PetStable.lua"/>`,
|
||||
xml l.3); benilla ships one file per window, so the transcription sits in the CDATA block
|
||||
below in the reference's own order (OnLoad, OnEvent, Update, NoPetsAllowed).
|
||||
|
||||
**An NPC session, reached through gossip**, and it behaves like every other one here
|
||||
(TrainerFrame.xml/MerchantFrame.xml's shape): a left-slot UIPanel (registered in
|
||||
UiPanels.xml, the reference's own `UIParent.lua:39` row), a "npc" portrait bake, and an
|
||||
`OnHide` that ends the session. The one thing that differs from its neighbours is that the
|
||||
close sends NO PACKET: `ClosePetStables()` is purely client-side (there is no close opcode
|
||||
in 5875 — `benilla-ui/src/script/stable.rs`'s own note), so the hide just clears the app's
|
||||
session.
|
||||
|
||||
**Three slots, indices 0..2.** `NUM_PET_STABLE_SLOTS = 2` (lua l.1) is the count of *stable*
|
||||
slots a hunter can buy; slot 0 is the pet at their side, which is why every loop below runs
|
||||
`1..NUM_PET_STABLE_SLOTS` for the stabled pair and treats 0 separately.
|
||||
|
||||
Named deferrals / deviations, each re-cited at its own site below:
|
||||
|
||||
- **The model pane is a booth-texture stand-in, not a live `<PlayerModel>`** — the same
|
||||
0208 §5 decision the character and pet paper dolls already ship under. The reference
|
||||
declares `<PlayerModel name="PetStableModel">` (xml l.182-280); ours is a plain
|
||||
`<Frame>` whose one texture samples an app-side bake, so the widget's own script trio
|
||||
goes with the widget: `Model_OnLoad`/`Model_OnUpdate` (xml l.194-203) and the
|
||||
`DISPLAY_SIZE_CHANGED` → `this:RefreshUnit()` pair (xml l.196-200) have no analogue
|
||||
here, exactly as `PetPaperDollFrame.xml` records for its own pane. `Model_RotateLeft`/
|
||||
`Model_RotateRight` (xml l.222/241) become `BenillaPaperDollModel_RotateLeft`/`_Right`,
|
||||
the shared helpers `CharacterFrame.xml` defines.
|
||||
- **The booth itself does not exist yet** (the one open dependency): there is no
|
||||
`"stable"` slot in `crates/benilla-app/src/portrait` and no `BenillaStableModel_SetFacing` yaw
|
||||
binding beside the paperdoll/petdoll/inspect three. `BenillaSetBoothTexture` accepts any
|
||||
token and simply resolves to no image, so the pane loads and lays out correctly and
|
||||
draws nothing until the app side lands; the yaw goes through the forwarding shim below
|
||||
so a rotate click cannot raise on a nil global in the meantime. Both are app-side work,
|
||||
deliberately not faked here.
|
||||
- **`GetPetIcon()` does not exist in this client** (reference/1.12-globals.tsv has it as
|
||||
an engine global; benilla has never needed it). `PetStable_Update` calls it at lua l.51
|
||||
and l.161-162 and it is transcribed verbatim rather than guarded: the window's current
|
||||
pet slot cannot paint without it, and a guard here would hide a missing binding instead
|
||||
of reporting it.
|
||||
- `parent="UIParent"` (xml l.72) is dropped: this engine reads no XML `parent=` attribute
|
||||
(`PetPaperDollFrame.xml`'s header). The frame's own `<Anchors>` are kept verbatim even
|
||||
though `ShowUIPanel` re-`SetPoint`s it into the left slot — they name the same
|
||||
TOPLEFT (0,-104) the slot manager writes, so the two agree (ItemTextFrame.xml keeps its
|
||||
reference anchor for the same reason).
|
||||
- `PetStableMoneyFrame`'s instance `<OnLoad>SmallMoneyFrame_OnLoad()</OnLoad>` (xml
|
||||
l.360-362) is dropped, and the cost frame's OnLoad keeps BOTH calls. benilla's
|
||||
`SmallMoneyFrameTemplate` already wires `SmallMoneyFrame_OnLoad` as its own OnLoad
|
||||
(MoneyFrame.xml) where the reference's template does not, and an instance `<Scripts>`
|
||||
block SetScripts over the inherited handler rather than adding to it — so the purse
|
||||
wants no OnLoad at all, and any instance that needs `MoneyFrame_SetType` must re-call
|
||||
the loader beside it. That is the idiom AuctionFrame.xml and MailFrame.xml already use.
|
||||
- Two housekeeping fixes inside `PetStable_Update`, both behaviour-identical: `background`
|
||||
is a LOCAL here (lua l.76 leaks it as a global), and the reference's `numSlots`/`numPets`
|
||||
locals (lua l.69-70) are dropped because nothing reads them — the loop asks
|
||||
`GetNumStableSlots()` directly, exactly as the reference's own l.79 does.
|
||||
- The GlobalStrings the window reads are declared below with the `X = X or "..."` idiom
|
||||
this codebase uses: the real localized file loads off the patch chain before our own
|
||||
files (`ui_script/lifecycle.rs`'s `load_global_strings`) and always wins; the fallbacks
|
||||
are what make the strings resolve in the test harnesses, which load no chain. They
|
||||
matter at LOAD, not just at runtime: a `text=` attribute is a global-string lookup
|
||||
performed while the frames below are being built.
|
||||
-->
|
||||
<Script><![CDATA[
|
||||
-- ── GlobalStrings this window uses (values + line cites from the shipped file) ───────────
|
||||
STABLES = STABLES or "Stables" -- :4189
|
||||
CURRENT_PET = CURRENT_PET or "Current Pet:" -- :881
|
||||
STABLED_PETS = STABLED_PETS or "Stabled Pets:" -- :4188
|
||||
EMPTY_STABLE_SLOT = EMPTY_STABLE_SLOT or "Empty Stable Slot" -- :1434
|
||||
STABLE_SLOT_TEXT = STABLE_SLOT_TEXT
|
||||
or "Do you wish to purchase another stable slot?" -- :4190
|
||||
COSTS_LABEL = COSTS_LABEL or "Cost:" -- :870
|
||||
PURCHASE = PURCHASE or "Purchase" -- :3128
|
||||
PET_DIET_TEMPLATE = PET_DIET_TEMPLATE or "Diet: %s" -- :3037
|
||||
UNIT_LEVEL_TEMPLATE = UNIT_LEVEL_TEMPLATE or "Level %d" -- :4436
|
||||
|
||||
-- lua l.1. The count of STABLE slots (the two a hunter buys), not of window slots: the
|
||||
-- buttons run 0..2 with 0 being the pet at the player's side, which is why the loops below
|
||||
-- iterate 1..NUM_PET_STABLE_SLOTS and handle 0 on its own.
|
||||
NUM_PET_STABLE_SLOTS = 2
|
||||
|
||||
-- ── The model pane's yaw (header, deferral 2) ────────────────────────────────────────────
|
||||
-- Every booth pane names its own engine yaw binding in `frame.setFacing`
|
||||
-- (`BenillaPaperDollModel_SetFacing`, `BenillaPetPaperDollModel_SetFacing`,
|
||||
-- `BenillaInspectModel_SetFacing`); the stable booth's twin is not built yet. This forwards
|
||||
-- to it the moment it exists and swallows the yaw until then, so `BenillaPaperDollModel_OnLoad`'s
|
||||
-- opening `frame.setFacing(0.61)` and every rotate click stay callable. It is the ONE line
|
||||
-- in this file that changes when the binding lands.
|
||||
function BenillaPetStableModel_SetFacing(radians)
|
||||
if BenillaStableModel_SetFacing then
|
||||
BenillaStableModel_SetFacing(radians)
|
||||
end
|
||||
end
|
||||
|
||||
-- ── The window ──────────────────────────────────────────────────────────────────────────
|
||||
-- lua l.3-9, verbatim. UNIT_PET is here because a pet summoned or dismissed while the
|
||||
-- window is open changes slot 0 without the server sending a stable list.
|
||||
function PetStable_OnLoad()
|
||||
this:RegisterEvent("PET_STABLE_SHOW")
|
||||
this:RegisterEvent("PET_STABLE_UPDATE")
|
||||
this:RegisterEvent("PET_STABLE_UPDATE_PAPERDOLL")
|
||||
this:RegisterEvent("PET_STABLE_CLOSED")
|
||||
this:RegisterEvent("UNIT_PET")
|
||||
end
|
||||
|
||||
-- lua l.11-32, all four arms. NOTE the SHOW arm's shape: `ShowUIPanel` can REFUSE (dead
|
||||
-- player, a native-center frame holding the screen — UiPanels.xml's `AdmitPanel`), and the
|
||||
-- reference reads that refusal back off `IsVisible()` and ends the session rather than
|
||||
-- leaving one open behind a window nobody can see. Ours can refuse for the same reasons and
|
||||
-- has the same session to end (`crate::ui_stable`'s), so the check is load-bearing here
|
||||
-- too, not defensive.
|
||||
function PetStable_OnEvent()
|
||||
if event == "PET_STABLE_SHOW" then
|
||||
ShowUIPanel(this)
|
||||
if not this:IsVisible() then
|
||||
ClosePetStables()
|
||||
return
|
||||
end
|
||||
PetStable_Update()
|
||||
elseif event == "PET_STABLE_UPDATE" or (event == "UNIT_PET" and arg1 == "player") then
|
||||
PetStable_Update()
|
||||
elseif event == "PET_STABLE_UPDATE_PAPERDOLL" then
|
||||
-- So warlock pets don't show (lua l.23-27, the reference's own comment). A warlock
|
||||
-- standing at a stable master has a pet but no pet UI, and the window must not
|
||||
-- offer to stable it.
|
||||
if UnitExists("pet") and not HasPetUI() then
|
||||
PetStable_NoPetsAllowed()
|
||||
return
|
||||
end
|
||||
SetPetStablePaperdoll(PetStableModel)
|
||||
elseif event == "PET_STABLE_CLOSED" then
|
||||
HideUIPanel(this)
|
||||
end
|
||||
end
|
||||
|
||||
-- lua l.34-209 — the whole repaint, in the reference's own order and with its own
|
||||
-- structure. Read it as five passes: the portrait, the warlock gate, the auto-selection,
|
||||
-- the two stabled slots, the current-pet slot, and the purchase row.
|
||||
function PetStable_Update()
|
||||
-- Set stablemaster portrait (lua l.35-36): the "npc" unit token, the same live model
|
||||
-- bake every other NPC-session window binds.
|
||||
SetPortraitTexture(PetStableFramePortrait, "npc")
|
||||
|
||||
-- So warlock pets don't show (lua l.38-46, the reference's own comment). NOTE this arm
|
||||
-- reads `isHunterPet`, the SECOND return, where the paperdoll arm above reads the
|
||||
-- first: a warlock's minion answers hasPetUI true and isHunterPet false, and it is the
|
||||
-- second that decides whether this window has anything to do.
|
||||
local hasPetUI, isHunterPet = HasPetUI()
|
||||
if UnitExists("pet") and not isHunterPet then
|
||||
PetStable_NoPetsAllowed()
|
||||
PetStableCurrentPet:Disable()
|
||||
return
|
||||
else
|
||||
PetStableCurrentPet:Enable()
|
||||
end
|
||||
|
||||
-- If no selected pet try to set one (lua l.48-63). The -1 sentinel IS the API
|
||||
-- (`GetSelectedStablePet` returns it, and `stable.rs` defaults its selection to it for
|
||||
-- exactly this branch): a freshly opened window picks the current pet if there is one,
|
||||
-- otherwise the first occupied stable slot, so the pane is never blank when it has
|
||||
-- something to show.
|
||||
local selectedPet = GetSelectedStablePet()
|
||||
if selectedPet == -1 then
|
||||
if GetPetIcon() then
|
||||
selectedPet = 0
|
||||
ClickStablePet(0)
|
||||
else
|
||||
for i = 0, NUM_PET_STABLE_SLOTS do
|
||||
if GetStablePetInfo(i) then
|
||||
selectedPet = i
|
||||
ClickStablePet(i)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Set slot cost (lua l.65-66).
|
||||
MoneyFrame_Update("PetStableCostMoneyFrame", GetNextStableSlotCost())
|
||||
|
||||
-- Set slot statuseses (lua l.68-119, the reference's own spelling of the comment).
|
||||
-- An UNBOUGHT slot is drawn differently from an EMPTY one: bought-and-empty keeps its
|
||||
-- white empty-slot art and stays clickable, unbought goes red and disabled. That is the
|
||||
-- whole visual difference between "you may stable a pet here" and "buy this first".
|
||||
local button
|
||||
local background
|
||||
local icon, name, level, family, loyalty
|
||||
for i = 1, NUM_PET_STABLE_SLOTS do
|
||||
button = getglobal("PetStableStabledPet" .. i)
|
||||
background = getglobal("PetStableStabledPet" .. i .. "Background")
|
||||
icon, name, level, family, loyalty = GetStablePetInfo(i)
|
||||
SetItemButtonTexture(button, icon)
|
||||
if i <= GetNumStableSlots() then
|
||||
background:SetVertexColor(1.0, 1.0, 1.0)
|
||||
button:Enable()
|
||||
if icon then
|
||||
button.tooltip = name
|
||||
button.tooltipSubtext = format(TEXT(UNIT_LEVEL_TEMPLATE), level) .. " " .. family
|
||||
else
|
||||
button.tooltip = EMPTY_STABLE_SLOT
|
||||
button.tooltipSubtext = ""
|
||||
end
|
||||
if i == selectedPet then
|
||||
if icon then
|
||||
button:SetChecked(1)
|
||||
PetStableLevelText:SetText(
|
||||
name .. " " .. format(TEXT(UNIT_LEVEL_TEMPLATE), level) .. " " .. family)
|
||||
PetStableLoyaltyText:SetText(loyalty)
|
||||
SetPetStablePaperdoll(PetStableModel)
|
||||
PetStablePetInfo.tooltip =
|
||||
format(PET_DIET_TEMPLATE, BuildListString(GetStablePetFoodTypes(i)))
|
||||
if not PetStableModel:IsShown() then
|
||||
PetStableModel:Show()
|
||||
end
|
||||
else
|
||||
button:SetChecked(nil)
|
||||
PetStableLevelText:SetText("")
|
||||
PetStableLoyaltyText:SetText("")
|
||||
PetStableModel:Hide()
|
||||
end
|
||||
else
|
||||
button:SetChecked(nil)
|
||||
end
|
||||
-- lua l.109-114: if this button's tooltip is the one currently up, rebuild it
|
||||
-- in place. A repaint that changed a slot under a resting cursor would
|
||||
-- otherwise leave the previous pet's name on screen.
|
||||
if GameTooltip:IsOwned(button) then
|
||||
GameTooltip:SetOwner(button, "ANCHOR_RIGHT")
|
||||
GameTooltip:SetText(button.tooltip)
|
||||
GameTooltip:AddLine(button.tooltipSubtext, "", 1.0, 1.0, 1.0)
|
||||
GameTooltip:Show()
|
||||
end
|
||||
else
|
||||
background:SetVertexColor(1.0, 0.1, 0.1)
|
||||
button:Disable()
|
||||
end
|
||||
end
|
||||
|
||||
-- Current pet slot (lua l.121-159). Three cases, and the middle one is the interesting
|
||||
-- one: a hunter whose pet is dismissed still has a slot-0 row from the server (read off
|
||||
-- the character-pet cache), so the reference falls back to `GetStablePetInfo(0)` rather
|
||||
-- than showing an empty current-pet slot for a pet that merely is not out.
|
||||
if selectedPet == 0 then
|
||||
if UnitExists("pet") then
|
||||
PetStableCurrentPet:SetChecked(1)
|
||||
local family = UnitCreatureFamily("pet")
|
||||
if not family then
|
||||
family = ""
|
||||
end
|
||||
PetStableLevelText:SetText(
|
||||
UnitName("pet") .. " " .. format(TEXT(UNIT_LEVEL_TEMPLATE), UnitLevel("pet"))
|
||||
.. " " .. family)
|
||||
PetStableLoyaltyText:SetText(GetPetLoyalty())
|
||||
SetPetStablePaperdoll(PetStableModel)
|
||||
if not PetStableModel:IsShown() then
|
||||
PetStableModel:Show()
|
||||
end
|
||||
if GetPetFoodTypes() then
|
||||
PetStablePetInfo.tooltip =
|
||||
format(PET_DIET_TEMPLATE, BuildListString(GetPetFoodTypes()))
|
||||
end
|
||||
elseif GetStablePetInfo(0) then
|
||||
-- If pet doesn't exist it might be dismissed, so check stable slot 0 for
|
||||
-- current pet info (lua l.139, the reference's own comment).
|
||||
PetStableCurrentPet:SetChecked(1)
|
||||
icon, name, level, family, loyalty = GetStablePetInfo(0)
|
||||
PetStableLevelText:SetText(
|
||||
name .. " " .. format(TEXT(UNIT_LEVEL_TEMPLATE), level) .. " " .. family)
|
||||
PetStableLoyaltyText:SetText(loyalty)
|
||||
SetPetStablePaperdoll(PetStableModel)
|
||||
if not PetStableModel:IsShown() then
|
||||
PetStableModel:Show()
|
||||
end
|
||||
if GetStablePetFoodTypes(0) then
|
||||
PetStablePetInfo.tooltip =
|
||||
format(PET_DIET_TEMPLATE, BuildListString(GetStablePetFoodTypes(0)))
|
||||
end
|
||||
else
|
||||
PetStableCurrentPet:SetChecked(nil)
|
||||
PetStableLevelText:SetText("")
|
||||
PetStableLoyaltyText:SetText("")
|
||||
PetStableModel:Hide()
|
||||
end
|
||||
else
|
||||
PetStableCurrentPet:SetChecked(nil)
|
||||
end
|
||||
|
||||
-- Set tooltip and icon info (lua l.160-181). The same live/dismissed/absent ladder as
|
||||
-- the block above, for the BUTTON's own icon and hover rather than the pane.
|
||||
if GetPetIcon() then
|
||||
SetItemButtonTexture(PetStableCurrentPet, GetPetIcon())
|
||||
PetStableCurrentPet.tooltip = UnitName("pet")
|
||||
PetStableCurrentPet.tooltipSubtext =
|
||||
format(TEXT(UNIT_LEVEL_TEMPLATE), UnitLevel("pet")) .. " " .. UnitCreatureFamily("pet")
|
||||
elseif GetStablePetInfo(0) then
|
||||
icon, name, level, family, loyalty = GetStablePetInfo(0)
|
||||
SetItemButtonTexture(PetStableCurrentPet, icon)
|
||||
PetStableCurrentPet.tooltip = name
|
||||
PetStableCurrentPet.tooltipSubtext =
|
||||
format(TEXT(UNIT_LEVEL_TEMPLATE), level) .. " " .. family
|
||||
else
|
||||
SetItemButtonTexture(PetStableCurrentPet, "")
|
||||
PetStableCurrentPet.tooltip = EMPTY_STABLE_SLOT
|
||||
PetStableCurrentPet.tooltipSubtext = ""
|
||||
PetStableCurrentPet:SetChecked(nil)
|
||||
end
|
||||
if GameTooltip:IsOwned(PetStableCurrentPet) then
|
||||
GameTooltip:SetOwner(PetStableCurrentPet, "ANCHOR_RIGHT")
|
||||
GameTooltip:SetText(PetStableCurrentPet.tooltip)
|
||||
GameTooltip:AddLine(PetStableCurrentPet.tooltipSubtext, "", 1.0, 1.0, 1.0)
|
||||
GameTooltip:Show()
|
||||
end
|
||||
|
||||
-- If no selected pet clear everything out (lua l.183-189). Reachable even after the
|
||||
-- auto-selection above: a hunter with no pet and an empty stable has nothing to select.
|
||||
if selectedPet == -1 then
|
||||
-- no pet
|
||||
PetStableModel:Hide()
|
||||
PetStableLevelText:SetText("")
|
||||
PetStableLoyaltyText:SetText("")
|
||||
end
|
||||
|
||||
-- Enable, disable, or hide purchase button (lua l.191-208). Three states, and the first
|
||||
-- is a HIDE of the whole row: with both slots bought there is nothing left to sell, so
|
||||
-- the button, the "Cost:" label, its coins and the slot-purchase line all go. Below
|
||||
-- that the button only enables when the player can afford it, and the coins turn red
|
||||
-- when they cannot. The leading unconditional Show() is the reference's own and it is
|
||||
-- what brings the button back after a NoPetsAllowed() pass hid it.
|
||||
PetStablePurchaseButton:Show()
|
||||
if GetNumStableSlots() == NUM_PET_STABLE_SLOTS then
|
||||
PetStablePurchaseButton:Hide()
|
||||
PetStableCostLabel:Hide()
|
||||
PetStableCostMoneyFrame:Hide()
|
||||
PetStableSlotText:Hide()
|
||||
elseif GetMoney() >= GetNextStableSlotCost() then
|
||||
PetStablePurchaseButton:Enable()
|
||||
PetStableCostLabel:Show()
|
||||
PetStableCostMoneyFrame:Show()
|
||||
SetMoneyFrameColor("PetStableCostMoneyFrame",
|
||||
HIGHLIGHT_FONT_COLOR.r, HIGHLIGHT_FONT_COLOR.g, HIGHLIGHT_FONT_COLOR.b)
|
||||
else
|
||||
PetStablePurchaseButton:Disable()
|
||||
PetStableCostLabel:Show()
|
||||
PetStableCostMoneyFrame:Show()
|
||||
SetMoneyFrameColor("PetStableCostMoneyFrame",
|
||||
RED_FONT_COLOR.r, RED_FONT_COLOR.g, RED_FONT_COLOR.b)
|
||||
end
|
||||
end
|
||||
|
||||
-- lua l.211-229 — the warlock face of the window, called from BOTH the paperdoll event arm
|
||||
-- and the top of Update. It is not "clear the window": it is the window with nothing on
|
||||
-- offer — every slot unchecked and tooltipped as empty, the pane hidden, and the whole
|
||||
-- purchase row gone, because a warlock cannot buy a stable slot either.
|
||||
function PetStable_NoPetsAllowed()
|
||||
local button
|
||||
for i = 1, NUM_PET_STABLE_SLOTS do
|
||||
button = getglobal("PetStableStabledPet" .. i)
|
||||
button.tooltip = EMPTY_STABLE_SLOT
|
||||
button:SetChecked(nil)
|
||||
end
|
||||
|
||||
PetStableCurrentPet:SetChecked(nil)
|
||||
PetStableLevelText:SetText("")
|
||||
PetStableLoyaltyText:SetText("")
|
||||
PetStableModel:Hide()
|
||||
SetItemButtonTexture(PetStableCurrentPet, "")
|
||||
PetStableCurrentPet.tooltip = EMPTY_STABLE_SLOT
|
||||
PetStableCurrentPet:SetChecked(nil)
|
||||
PetStablePurchaseButton:Hide()
|
||||
PetStableCostLabel:Hide()
|
||||
PetStableCostMoneyFrame:Hide()
|
||||
PetStableSlotText:Hide()
|
||||
end
|
||||
]]></Script>
|
||||
|
||||
<!--
|
||||
One slot button (xml l.4-71): a 37x37 CheckButton wearing the standard quickslot ring, with
|
||||
the empty-slot art behind it and the pet's icon in front. `$parentIconTexture` is the name
|
||||
`SetItemButtonTexture` addresses (ItemButtonTemplate.xml's naming contract), and
|
||||
`$parentBackground` is the one Update tints red for an unbought slot.
|
||||
|
||||
**`ClickStablePet` is wired to BOTH `OnClick` and `OnReceiveDrag`, and the frame repaints
|
||||
only when it returns true.** That is the reference's own arrangement and it is transcribed
|
||||
exactly, deliberately: the binding carries two jobs behind one name (commit a drag if one is
|
||||
in flight, otherwise select) and its drag half is still being reverse-engineered
|
||||
(`benilla-ui/src/script/stable.rs`'s `drag_verb`, INFERRED). Baking an assumption about
|
||||
which it does into this XML would put the wrong answer in two places instead of one, so the
|
||||
XML asks the same question the reference asks and repaints on the same answer.
|
||||
|
||||
`RegisterForDrag("LeftButton")` is what arms `OnDragStart` on a press-and-move; the pickup
|
||||
it starts is FRAME-LOCAL, not the global cursor (there is no stable-pet cursor mode in
|
||||
1.12 — stable.rs's module doc), so nothing else on screen can be holding a stable pet.
|
||||
-->
|
||||
<CheckButton name="PetStableSlotTemplate" virtual="true">
|
||||
<Size><AbsDimension x="37" y="37"/></Size>
|
||||
<Layers>
|
||||
<Layer level="BORDER">
|
||||
<Texture name="$parentIconTexture"/>
|
||||
</Layer>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="$parentBackground" file="Interface\Buttons\UI-EmptySlot">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="-13" y="13"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnLoad>this:RegisterForDrag("LeftButton")</OnLoad>
|
||||
<OnEnter>
|
||||
-- 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
|
||||
</OnEnter>
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
<OnClick>
|
||||
if ClickStablePet(this:GetID()) then
|
||||
PetStable_Update()
|
||||
end
|
||||
</OnClick>
|
||||
<OnDragStart>PickupStablePet(this:GetID())</OnDragStart>
|
||||
<OnReceiveDrag>
|
||||
if ClickStablePet(this:GetID()) then
|
||||
PetStable_Update()
|
||||
end
|
||||
</OnReceiveDrag>
|
||||
</Scripts>
|
||||
<NormalTexture name="$parentNormalTexture" file="Interface\Buttons\UI-Quickslot2">
|
||||
<Size><AbsDimension x="64" y="64"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="CENTER"><Offset><AbsDimension x="0" y="-1"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</NormalTexture>
|
||||
<PushedTexture file="Interface\Buttons\UI-Quickslot-Depress"/>
|
||||
<HighlightTexture file="Interface\Buttons\ButtonHilight-Square" alphaMode="ADD"/>
|
||||
<CheckedTexture file="Interface\Buttons\CheckButtonHilight" alphaMode="ADD"/>
|
||||
</CheckButton>
|
||||
|
||||
<Frame name="PetStableFrame" toplevel="true" movable="true" enableMouse="true" hidden="true">
|
||||
<!-- xml l.72-85: the standard 384x512 panel window. The HitRectInsets pull the mouse rect
|
||||
in from the art's transparent right edge and bottom skirt. -->
|
||||
<Size><AbsDimension x="384" y="512"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="-104"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<HitRectInsets><AbsInset left="0" right="34" top="0" bottom="75"/></HitRectInsets>
|
||||
<Layers>
|
||||
<!-- The stable master's portrait, 60x60 TOPLEFT (7,-6) (xml l.88-99). Bound in Update
|
||||
via SetPortraitTexture(region, "npc") — the live model bake of whoever we are
|
||||
talking to, the same binding every NPC-session window here uses. -->
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="PetStableFramePortrait">
|
||||
<Size><AbsDimension x="60" y="60"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="7" y="-6"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
<Layer level="BORDER">
|
||||
<!-- The four quadrant slabs that ARE the window art (xml l.102-133): 256-wide left
|
||||
halves, 128-wide right halves, each 256 tall, pinned to their own corner. -->
|
||||
<Texture file="Interface\PetStableFrame\UI-PetStable-TopLeft">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture file="Interface\PetStableFrame\UI-PetStable-TopRight">
|
||||
<Size><AbsDimension x="128" y="256"/></Size>
|
||||
<Anchors><Anchor point="TOPRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="PetStableFrameBottomLeft" file="Interface\PetStableFrame\UI-PetStable-BottomLeft">
|
||||
<Size><AbsDimension x="256" y="256"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMLEFT"/></Anchors>
|
||||
</Texture>
|
||||
<Texture name="PetStableFrameBottomRight" file="Interface\PetStableFrame\UI-PetStable-BottomRight">
|
||||
<Size><AbsDimension x="128" y="256"/></Size>
|
||||
<Anchors><Anchor point="BOTTOMRIGHT"/></Anchors>
|
||||
</Texture>
|
||||
<!-- Title (xml l.134-142). -->
|
||||
<FontString name="PetStableTitleLabel" inherits="GameFontHighlight" text="STABLES">
|
||||
<Anchors>
|
||||
<Anchor point="TOP"><Offset><AbsDimension x="0" y="-18"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<!-- The selected pet's name/level/family line and its loyalty line (xml l.143-160).
|
||||
The reference ships them carrying PLACEHOLDER text ("Bobby The Fleshripper
|
||||
(Level 99 Raptor)", "Loyalty: Mans Best Friend") — an authoring artifact that
|
||||
is on screen for the instant before the first Update, and one this engine would
|
||||
render literally (a `text=` that misses the global string table falls back to
|
||||
the literal). Dropped: the strings are Update's to write. -->
|
||||
<FontString name="PetStableLevelText" inherits="GameFontNormal">
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativeTo="PetStableTitleLabel" relativePoint="BOTTOM">
|
||||
<Offset><AbsDimension x="0" y="-8"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<FontString name="PetStableLoyaltyText" inherits="GameFontNormalSmall">
|
||||
<Anchors>
|
||||
<Anchor point="TOP" relativeTo="PetStableLevelText" relativePoint="BOTTOM">
|
||||
<Offset><AbsDimension x="0" y="-1"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<!-- "Do you wish to purchase another stable slot?" and the "Cost:" label
|
||||
(xml l.161-178) — both hidden by Update once both slots are bought. -->
|
||||
<FontString name="PetStableSlotText" inherits="GameFontHighlightSmall" text="STABLE_SLOT_TEXT">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOM"><Offset><AbsDimension x="-10" y="132"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
<FontString name="PetStableCostLabel" inherits="GameFontNormal" text="COSTS_LABEL">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="74" y="110"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- The model pane (ref `<PlayerModel name="PetStableModel">`, xml l.182-280): 313x223
|
||||
at TOPLEFT (23,-76). A plain Frame sampling a booth bake, not a live 3D widget
|
||||
(header, deferral 1) — so the reference's `Model_OnLoad`, its
|
||||
`DISPLAY_SIZE_CHANGED`/`RefreshUnit` pair and its `Model_OnUpdate` all drop with
|
||||
the widget, exactly as they do on the two paper dolls.
|
||||
|
||||
The pane keeps its reference NAME because the Lua passes it by name to
|
||||
`SetPetStablePaperdoll` at four sites, and an addon may too. -->
|
||||
<Frame name="PetStableModel">
|
||||
<Size><AbsDimension x="313" y="223"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="23" y="-76"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<!-- BACKGROUND, not ARTWORK — decision 1070, and PetPaperDollFrame.xml's own
|
||||
pane carries the same note. The booth quad stands in for a widget whose 3D
|
||||
content the reference renders outside the layer batches with a transparent
|
||||
background; ours is one OPAQUE texture inside them, and the draw layer is
|
||||
BUCKET-WIDE and outranks the frame (0884). At ARTWORK this quad would paint
|
||||
over the diet icon below it, which is exactly the bug that pane hit. At
|
||||
BACKGROUND the two tie and declaration order decides, which restores the
|
||||
reference's own ordering. -->
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture name="$parentTexture">
|
||||
<Size><AbsDimension x="313" y="223"/></Size>
|
||||
<Anchors><Anchor point="TOPLEFT"/></Anchors>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Frames>
|
||||
<!-- Rotate buttons (xml l.206-247): 35x35, the left one at the pane's own
|
||||
TOPLEFT and the right one chained off its TOPRIGHT. The click helpers are
|
||||
CharacterFrame.xml's shared `BenillaPaperDollModel_Rotate*` in place of the
|
||||
reference's `Model_RotateLeft/Right`, and they drive `frame.setFacing`,
|
||||
which the pane names for itself in OnLoad below. -->
|
||||
<Button name="PetStableModelRotateLeftButton">
|
||||
<Size><AbsDimension x="35" y="35"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT"><Offset><AbsDimension x="0" y="0"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<NormalTexture file="Interface\Buttons\UI-RotationLeft-Button-Up"/>
|
||||
<PushedTexture file="Interface\Buttons\UI-RotationLeft-Button-Down"/>
|
||||
<HighlightTexture file="Interface\Buttons\ButtonHilight-Round" alphaMode="ADD"/>
|
||||
<Scripts>
|
||||
<OnLoad>this:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
|
||||
<OnClick>BenillaPaperDollModel_RotateLeft(getglobal("PetStableModel"))</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<Button name="PetStableModelRotateRightButton">
|
||||
<Size><AbsDimension x="35" y="35"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="PetStableModelRotateLeftButton" relativePoint="TOPRIGHT"/>
|
||||
</Anchors>
|
||||
<NormalTexture file="Interface\Buttons\UI-RotationRight-Button-Up"/>
|
||||
<PushedTexture file="Interface\Buttons\UI-RotationRight-Button-Down"/>
|
||||
<HighlightTexture file="Interface\Buttons\ButtonHilight-Round" alphaMode="ADD"/>
|
||||
<Scripts>
|
||||
<OnLoad>this:RegisterForClicks("LeftButtonDown", "LeftButtonUp")</OnLoad>
|
||||
<OnClick>BenillaPaperDollModel_RotateRight(getglobal("PetStableModel"))</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<!-- The diet icon (xml l.248-278): 24x23 under the rotate buttons, its texcoords
|
||||
STATIC. This is the pet's DIET affordance (its tooltip lists what it eats),
|
||||
not a happiness readout — the happiness face that does move lives on the pet
|
||||
unit frame (decisions 1005/0990), and PetPaperDollFrame.xml's twin carries
|
||||
the same note.
|
||||
|
||||
Kept as a CHILD of the pane, where the reference has it, and not lifted to a
|
||||
sibling the way the pet paper doll's was: Update hides the pane whenever
|
||||
there is no pet to show, and being a child is what makes the icon go with
|
||||
it. Declared after the pane's own quad, so at the shared BACKGROUND layer
|
||||
the link stamp draws it on top. -->
|
||||
<Frame name="PetStablePetInfo" enableMouse="true" hidden="false">
|
||||
<Size><AbsDimension x="24" y="23"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="TOPLEFT" relativeTo="PetStableModelRotateLeftButton" relativePoint="BOTTOMLEFT">
|
||||
<Offset><AbsDimension x="7" y="0"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<Texture file="Interface\PetPaperDollFrame\UI-PetHappiness" setAllPoints="true">
|
||||
<Size><AbsDimension x="24" y="23"/></Size>
|
||||
<TexCoords left="0" right="0.1875" top="0" bottom="0.359375"/>
|
||||
</Texture>
|
||||
</Layer>
|
||||
</Layers>
|
||||
<Scripts>
|
||||
<OnEnter>
|
||||
GameTooltip:SetOwner(this, "ANCHOR_RIGHT")
|
||||
GameTooltip:SetText(this.tooltip)
|
||||
</OnEnter>
|
||||
<OnLeave>GameTooltip:Hide()</OnLeave>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Frames>
|
||||
<Scripts>
|
||||
<!-- The pane's booth + its yaw. `BenillaPaperDollModel_OnLoad` seeds the
|
||||
reference's own 0.61 default facing and pushes it through `setFacing`; the
|
||||
OnUpdate is the shared held-button continuous spin. The booth token is
|
||||
"stable" and the yaw goes through this file's forwarding shim — neither the
|
||||
slot nor the binding exists app-side yet (header, deferral 2), so the pane
|
||||
lays out and stays inert rather than drawing another window's subject. -->
|
||||
<OnLoad>
|
||||
this.setFacing = BenillaPetStableModel_SetFacing
|
||||
BenillaPaperDollModel_OnLoad(this)
|
||||
BenillaSetBoothTexture(getglobal(this:GetName() .. "Texture"), "stable")
|
||||
</OnLoad>
|
||||
<OnUpdate>BenillaPaperDollModel_OnUpdate(this, arg1)</OnUpdate>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
|
||||
<!-- The three slots (xml l.281-333). `id=` is what `this:GetID()` reads in the
|
||||
template's handlers, and it IS the binding's slot index: 0 is the pet at your side,
|
||||
1 and 2 the bought stable slots. The two labels are extra regions on the instances,
|
||||
anchored above their buttons; note the reference hangs "Stabled Pets:" off slot 1
|
||||
offset +24 so it centres over the PAIR. -->
|
||||
<CheckButton name="PetStableCurrentPet" inherits="PetStableSlotTemplate" id="0">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMLEFT"><Offset><AbsDimension x="95" y="150"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<FontString inherits="GameFontNormalSmall" text="CURRENT_PET">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOM" relativePoint="TOP">
|
||||
<Offset><AbsDimension x="0" y="6"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetStableStabledPet1" inherits="PetStableSlotTemplate" id="1">
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="PetStableCurrentPet" relativePoint="RIGHT">
|
||||
<Offset><AbsDimension x="53" y="0"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Layers>
|
||||
<Layer level="BACKGROUND">
|
||||
<FontString inherits="GameFontNormalSmall" text="STABLED_PETS">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOM" relativePoint="TOP">
|
||||
<Offset><AbsDimension x="24" y="6"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</FontString>
|
||||
</Layer>
|
||||
</Layers>
|
||||
</CheckButton>
|
||||
<CheckButton name="PetStableStabledPet2" inherits="PetStableSlotTemplate" id="2">
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="PetStableStabledPet1" relativePoint="RIGHT">
|
||||
<Offset><AbsDimension x="15" y="0"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
</CheckButton>
|
||||
|
||||
<!-- Buy the next stable slot (xml l.334-350). The server prices it and picks which slot
|
||||
it is; Update decides whether this button is even on screen. -->
|
||||
<Button name="PetStablePurchaseButton" inherits="UIPanelButtonTemplate" text="PURCHASE">
|
||||
<Size><AbsDimension x="80" y="22"/></Size>
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-110" y="105"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnClick>BuyStableSlot()</OnClick>
|
||||
</Scripts>
|
||||
</Button>
|
||||
<!-- The player's purse (xml l.351-364). NO <Scripts> here: benilla's
|
||||
SmallMoneyFrameTemplate already wires SmallMoneyFrame_OnLoad as its own OnLoad
|
||||
where the reference's template does not, and an instance block would SetScript OVER
|
||||
it rather than add to it (header, deferral 5). Type PLAYER by default, which is
|
||||
what a purse wants. -->
|
||||
<Frame name="PetStableMoneyFrame" inherits="SmallMoneyFrameTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="BOTTOMRIGHT"><Offset><AbsDimension x="-45" y="84"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Frame>
|
||||
<!-- The next slot's price (xml l.365-379): a STATIC frame, so it shows whatever
|
||||
MoneyFrame_Update last parked on it rather than tracking the purse. This one DOES
|
||||
declare an OnLoad, and therefore has to re-call the template's loader beside
|
||||
MoneyFrame_SetType (the AuctionFrame.xml/MailFrame.xml idiom). -->
|
||||
<Frame name="PetStableCostMoneyFrame" inherits="SmallMoneyFrameTemplate">
|
||||
<Anchors>
|
||||
<Anchor point="LEFT" relativeTo="PetStableCostLabel" relativePoint="RIGHT">
|
||||
<Offset><AbsDimension x="5" y="0"/></Offset>
|
||||
</Anchor>
|
||||
</Anchors>
|
||||
<Scripts>
|
||||
<OnLoad>SmallMoneyFrame_OnLoad() MoneyFrame_SetType("STATIC")</OnLoad>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
<!-- xml l.380-388. UIPanelCloseButton's own OnClick is HideUIPanel(this:GetParent()),
|
||||
and this window's OnHide is what ends the session — so the X needs no handler. -->
|
||||
<Button name="PetStableFrameCloseButton" inherits="UIPanelCloseButton">
|
||||
<Anchors>
|
||||
<Anchor point="TOPRIGHT"><Offset><AbsDimension x="-29" y="-8"/></Offset></Anchor>
|
||||
</Anchors>
|
||||
</Button>
|
||||
</Frames>
|
||||
<!-- xml l.390-404. The NPC-session lifecycle: OnHide fires ClosePetStables() so the session
|
||||
ends whenever this window hides for ANY reason (ESC, the X, PET_STABLE_CLOSED, or
|
||||
another left-slot window displacing it). Unlike its neighbours that close is purely
|
||||
client-side — no packet exists for it in 5875. -->
|
||||
<Scripts>
|
||||
<OnLoad>PetStable_OnLoad()</OnLoad>
|
||||
<OnShow>PlaySound("igCharacterInfoOpen")</OnShow>
|
||||
<OnHide>ClosePetStables() PlaySound("igCharacterInfoClose")</OnHide>
|
||||
<OnEvent>PetStable_OnEvent()</OnEvent>
|
||||
</Scripts>
|
||||
</Frame>
|
||||
</Ui>
|
||||
@@ -26,9 +26,9 @@
|
||||
<Script><![CDATA[
|
||||
-- UIParent.lua l.14-42: UIPanelWindows["FrameName"] = { area, pushable, whileDead }. Only
|
||||
-- the rows benilla ships today; the ref rows with no benilla window yet (Tabard, Petition,
|
||||
-- GuildRegistrar, PetStable, Battlefield, Help, Cinematic, WorldStateScore, Minigame — and
|
||||
-- the doublewide AuctionFrame) join as their windows land (audited row-by-row against the
|
||||
-- bytes, decision 1507).
|
||||
-- GuildRegistrar, Battlefield, Help, Cinematic, WorldStateScore, Minigame) join as their
|
||||
-- windows land (audited row-by-row against the bytes, decision 1507). Two have since left
|
||||
-- that list: the doublewide AuctionFrame with decision 1511, PetStableFrame with 1676.
|
||||
UIPanelWindows = {}
|
||||
UIPanelWindows["GossipFrame"] = { area = "left", pushable = 0 }
|
||||
UIPanelWindows["MerchantFrame"] = { area = "left", pushable = 0 }
|
||||
@@ -75,6 +75,13 @@
|
||||
-- registration line (`UIPanelWindows["TaxiFrame"] = { area = "left", pushable = 0 }`),
|
||||
-- quoted verbatim — same left-slot competition as Gossip/Merchant/Trainer above.
|
||||
UIPanelWindows["TaxiFrame"] = { area = "left", pushable = 0 }
|
||||
-- The stable-master window (decision 1676, PetStable.xml): the ref's own UIParent.lua l.39
|
||||
-- registration line (`UIPanelWindows["PetStableFrame"] = { area = "left", pushable = 0 }`),
|
||||
-- quoted verbatim — the same left-slot competition as Gossip/Merchant/Trainer/Taxi above,
|
||||
-- which is what makes the stable master's gossip window give way to the stable it opens.
|
||||
-- The reference lists it between MailFrame (l.37) and DressUpFrame (l.41), which is where
|
||||
-- it sits here too, inside the NPC-session cluster those rows are grouped into.
|
||||
UIPanelWindows["PetStableFrame"] = { area = "left", pushable = 0 }
|
||||
-- The auction house (decision 1511, AuctionFrame.xml): the reference's own registration,
|
||||
-- quoted verbatim from `Blizzard_AuctionUI.lua` l.13 rather than UIParent.lua — the window
|
||||
-- ships as a loadable addon there, so it registers its own row. **The one and only
|
||||
|
||||
@@ -357,6 +357,17 @@ BankFrame.xml
|
||||
# Loaded right after TrainerFrame per the same "NPC session windows grouped" posture — it
|
||||
# needs only Fonts/UiPanels/GameTooltip (SetTooltipMoney), both already loaded above.
|
||||
TaxiFrame.xml
|
||||
# The stable-master window (decision 1676): the hunter's three-slot pet stable over the stable
|
||||
# API (`benilla-ui/src/script/stable.rs`) + the app's feed/drain. Kept in the "NPC session
|
||||
# windows grouped" cluster beside Trainer/Bank/Taxi. Two of its dependencies are resolved at
|
||||
# LOAD and so must sit above it: MoneyFrame (its purse and cost displays inherit
|
||||
# SmallMoneyFrameTemplate) and UIPanelTemplates (UIPanelButtonTemplate / UIPanelCloseButton).
|
||||
# A third is resolved at load too, and is the reason this cannot sit up with the other NPC
|
||||
# windows' dependencies: the model pane's own <OnLoad> calls CharacterFrame.xml's shared
|
||||
# `BenillaPaperDollModel_OnLoad`, so that file must already have run. The rest
|
||||
# (ItemButtonTemplate's SetItemButtonTexture, GameTooltip, UiPanels' ShowUIPanel/HideUIPanel,
|
||||
# UIParent's BuildListString) are resolved when the window repaints, so their order is free.
|
||||
PetStable.xml
|
||||
# The auction house (decision 1511): the three-tab doublewide window over the auction API
|
||||
# (`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
|
||||
|
||||
@@ -147,6 +147,7 @@ pub(super) fn seed_ui_fixture(
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
// Eight rows across both columns: long names to exercise the wrap, mixed prices to
|
||||
@@ -211,6 +212,7 @@ pub(super) fn seed_ui_fixture(
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
gossip.npc = Some(NPC_GUID);
|
||||
@@ -276,6 +278,7 @@ pub(super) fn seed_ui_fixture(
|
||||
type_flags: 0,
|
||||
civilian: true,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
// Vault items (bank wire slots 0, 1, 2, 7 → fields 564+2i) + the held bank bag in bag
|
||||
@@ -369,6 +372,7 @@ pub(super) fn seed_ui_fixture(
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
// A QUEST_DETAILS-shaped view (the accept panel): a description + objectives, one choice
|
||||
@@ -413,6 +417,7 @@ pub(super) fn seed_ui_fixture(
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
// The multi-quest greeting: two AVAILABLE quests (not in the log → bullet rows under the
|
||||
@@ -459,6 +464,7 @@ pub(super) fn seed_ui_fixture(
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
// The item objective's target + the reward rows (log icons resolve through the item
|
||||
@@ -922,6 +928,7 @@ pub(super) fn seed_ui_fixture(
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
let wolf = commands
|
||||
@@ -1310,6 +1317,7 @@ pub(super) fn seed_ui_fixture(
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
commands.spawn((
|
||||
|
||||
@@ -499,6 +499,7 @@ mod tests {
|
||||
type_flags: 0x20,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
app.update();
|
||||
|
||||
@@ -991,6 +991,10 @@ fn setup_entities(
|
||||
Ok(prices) => commands.insert_resource(crate::ui_bank::BankPrices(prices)),
|
||||
Err(e) => warn!("bank bag slot prices unavailable, the purchase row shows 0: {e:#}"),
|
||||
}
|
||||
match benilla_formats::load_stable_slot_prices(&mut chain) {
|
||||
Ok(prices) => commands.insert_resource(crate::ui_stable::StableSlotPrices(prices)),
|
||||
Err(e) => warn!("stable slot prices unavailable, the purchase row shows 0: {e:#}"),
|
||||
}
|
||||
match benilla_formats::load_stationery_catalog(&mut chain) {
|
||||
Ok(catalog) => commands.insert_resource(crate::ui_mail::Stationery(catalog)),
|
||||
Err(e) => warn!("stationery catalog unavailable, mail uses the default backdrop: {e:#}"),
|
||||
|
||||
@@ -147,6 +147,7 @@ mod ui_session;
|
||||
mod ui_shapeshift;
|
||||
mod ui_social;
|
||||
mod ui_spellbook;
|
||||
mod ui_stable;
|
||||
mod ui_talent;
|
||||
mod ui_talent_wipe;
|
||||
mod ui_taxi;
|
||||
@@ -217,6 +218,7 @@ use ui_script::UiScriptPlugin;
|
||||
use ui_shapeshift::UiShapeshiftPlugin;
|
||||
use ui_social::UiSocialPlugin;
|
||||
use ui_spellbook::UiSpellbookPlugin;
|
||||
use ui_stable::UiStablePlugin;
|
||||
use ui_talent::UiTalentPlugin;
|
||||
use ui_talent_wipe::UiTalentWipePlugin;
|
||||
use ui_taxi::UiTaxiPlugin;
|
||||
@@ -719,6 +721,7 @@ pub fn run(build: BuildId) -> AppExit {
|
||||
// copies) read in the reference reader window over the shared ask-once item-text cache.
|
||||
.add_plugins(UiItemTextPlugin)
|
||||
.add_plugins(UiSavedPlugin)
|
||||
.add_plugins(UiStablePlugin)
|
||||
.add_plugins(UiTrainerPlugin)
|
||||
// The taxi map (decision 0484 phases 1-2): the SMSG_SHOWTAXINODES-fed TaxiState resource, the
|
||||
// NPC-session range guard, and the TaxiFrame.xml window feed/drain (catalogs, node
|
||||
|
||||
@@ -74,6 +74,27 @@ pub(crate) struct CreatureRecord {
|
||||
pub(crate) civilian: bool,
|
||||
/// Racial leader — the tooltip's white LEADER line (`0x6125c0`).
|
||||
pub(crate) racial_leader: bool,
|
||||
/// `CreatureDisplayInfo.dbc` id — the template's model. The **only** way to draw a creature
|
||||
/// with no world object to read `UNIT_FIELD_DISPLAYID` off, which is exactly a stabled pet
|
||||
/// (decision 1676): its wire row names a template entry and nothing else. `0` when the
|
||||
/// template ships none.
|
||||
///
|
||||
/// For anything actually on screen the unit's own descriptor field still wins — vmangos picks
|
||||
/// among a template's four display ids at spawn, and this is the first.
|
||||
///
|
||||
/// **Stored but not yet read** — the stable window's model pane is the consumer, and it needs a
|
||||
/// booth that renders a creature from a display id with **no world entity** to mirror. Every
|
||||
/// in-world booth today takes an `Option<Entity>` and copies that unit's dressed look
|
||||
/// ([`crate::portrait`]'s `sync_body_booth`); the only display-id path that exists is the glue
|
||||
/// stage's select pet (`portrait::glue_booth`, decisions 1536/1538/1539), which is a different
|
||||
/// system. Wiring the two together is its own arc, named as a deferral in decision 1676 rather
|
||||
/// than half-built here. The field is filled now because the wire already carries it and the
|
||||
/// query is ask-once: dropping it would mean re-querying every template later.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "the stable model pane's booth arc — decision 1676"
|
||||
)]
|
||||
pub(crate) display_id: u32,
|
||||
}
|
||||
|
||||
/// The client's **creature-rank getter**, `0x605620` — 33 bytes, and the single source every rank
|
||||
@@ -410,6 +431,7 @@ mod tests {
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
assert_eq!(cache.resolve(wolf_b, &cmds), Some("Young Wolf"));
|
||||
|
||||
@@ -1126,6 +1126,22 @@ pub(crate) enum ClientCommand {
|
||||
/// answers `SMSG_TRAINER_BUY_SUCCEEDED` and delivers the spell via `SMSG_LEARNED_SPELL`; refusal
|
||||
/// answers `SMSG_TRAINER_BUY_FAILED` (a `TrainerBuyFailed` event → the window's error line).
|
||||
TrainerBuySpell { trainer: u64, spell_id: u32 },
|
||||
/// Ask (or re-ask) a stable master's pet list (`MSG_LIST_STABLED_PETS`, decision 1676): one
|
||||
/// NPC guid. The window first opens off the gossip stable option's own inbound send of the
|
||||
/// same opcode; this is the *refresh* verb, re-requested after every successful mutation
|
||||
/// because none of them is answered with a fresh list.
|
||||
ListStabledPets { npc: u64 },
|
||||
/// Put the current pet away (`CMSG_STABLE_PET`, decision 1676). **No destination slot** — the
|
||||
/// server takes the first free bought one, so there is none to carry.
|
||||
StablePet { npc: u64 },
|
||||
/// Summon a stabled pet (`CMSG_UNSTABLE_PET`, decision 1676): the NPC guid + the pet's own
|
||||
/// number. Valid only with no current pet; with one out the verb is [`Self::StableSwapPet`].
|
||||
UnstablePet { npc: u64, pet_number: u32 },
|
||||
/// Trade the current pet for a stabled one in one step (`CMSG_STABLE_SWAP_PET`, decision 1676).
|
||||
StableSwapPet { npc: u64, pet_number: u32 },
|
||||
/// Buy the next stable slot (`CMSG_BUY_STABLE_SLOT`, decision 1676). The *which* is implicit,
|
||||
/// as with the bank's bag slots; a refusal answers `SMSG_STABLE_RESULT`'s `ERR_MONEY`.
|
||||
BuyStableSlot { npc: u64 },
|
||||
/// Spend talent points (`CMSG_LEARN_TALENT`, decision 0304): a `Talent.dbc` row id + the
|
||||
/// requested rank (0-based, learn-up-to — the click sends the current rank count). Sent by
|
||||
/// the talent window's click-to-learn. No dedicated reply: success arrives as the rank
|
||||
@@ -1204,6 +1220,11 @@ pub(crate) enum ClientCommand {
|
||||
item_slot: u32,
|
||||
roll_type: u8,
|
||||
},
|
||||
/// Hand a loot row to a group member (`CMSG_LOOT_MASTER_GIVE`, decision 1675): the master
|
||||
/// looter's dropdown pick. `guid` is the open loot source, `slot` the row's **wire** slot, and
|
||||
/// `target` the recipient's guid. Success arrives as an ordinary `SMSG_LOOT_REMOVED` (the item
|
||||
/// traffic goes to the recipient, not to us); a refusal as a `MASTER_*` loot error.
|
||||
LootMasterGive { guid: u64, slot: u8, target: u64 },
|
||||
/// Look at an available quest (`CMSG_QUESTGIVER_QUERY_QUEST`, decision 0088): a greeting/gossip
|
||||
/// quest row click. Answered by `SMSG_QUESTGIVER_QUEST_DETAILS` (a `QuestDetail` event → the
|
||||
/// accept panel).
|
||||
|
||||
@@ -41,8 +41,8 @@ mod world;
|
||||
// body; the match stays the dispatcher, one call per arm — see the child modules).
|
||||
use loot::{
|
||||
inventory_failure, item_push_result, item_template, loot_all_passed, loot_clear_money,
|
||||
loot_error, loot_money_notify, loot_release_response, loot_removed, loot_response, loot_roll,
|
||||
loot_roll_won, loot_start_roll,
|
||||
loot_error, loot_master_list, loot_money_notify, loot_release_response, loot_removed,
|
||||
loot_response, loot_roll, loot_roll_won, loot_start_roll,
|
||||
};
|
||||
use quests::{
|
||||
quest_complete, quest_detail, quest_failed, quest_giver_failed, quest_giver_invalid,
|
||||
@@ -140,7 +140,12 @@ pub(super) fn apply_net_updates(
|
||||
ResMut<crate::items::Items>,
|
||||
ResMut<crate::ui_gossip::GossipState>,
|
||||
ResMut<crate::ui_merchant::MerchantOpen>,
|
||||
ResMut<crate::ui_trainer::TrainerOpen>,
|
||||
// Nested pair: the two gossip-reached NPC service sessions the net drain fills whose
|
||||
// state is a whole open window (the tuple is at the 16-param ceiling).
|
||||
(
|
||||
ResMut<crate::ui_trainer::TrainerOpen>,
|
||||
ResMut<crate::ui_stable::StableOpen>,
|
||||
),
|
||||
// Nested triple (the tuple is at the 16-param ceiling): the loot window state, the
|
||||
// client-local loot-target latch (the kneel's self trigger, decision 0515), and the open
|
||||
// group-loot rolls (decision 0591).
|
||||
@@ -368,7 +373,7 @@ pub(super) fn apply_net_updates(
|
||||
mut items,
|
||||
mut gossip,
|
||||
mut merchant,
|
||||
mut trainer_open,
|
||||
(mut trainer_open, mut stable_open),
|
||||
(mut loot, mut loot_latch, mut loot_rolls),
|
||||
mut chat_log,
|
||||
mut quest,
|
||||
@@ -789,6 +794,7 @@ pub(super) fn apply_net_updates(
|
||||
pet_family,
|
||||
rank,
|
||||
type_flags,
|
||||
display_id,
|
||||
civilian,
|
||||
racial_leader,
|
||||
} => names::creature_name(
|
||||
@@ -801,6 +807,7 @@ pub(super) fn apply_net_updates(
|
||||
type_flags,
|
||||
civilian,
|
||||
racial_leader,
|
||||
display_id,
|
||||
&mut names,
|
||||
),
|
||||
SessionEvent::GameObjectInfo {
|
||||
@@ -1109,6 +1116,8 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::LootRoll(p) => loot_roll(p, &mut loot_rolls),
|
||||
SessionEvent::LootRollWon(p) => loot_roll_won(p, &mut loot_rolls),
|
||||
SessionEvent::LootAllPassed(p) => loot_all_passed(p, &mut loot_rolls),
|
||||
// ── Master loot (decision 1675) — the candidate list, ahead of its LootResponse ───
|
||||
SessionEvent::LootMasterList { candidates } => loot_master_list(candidates, &mut loot),
|
||||
// ── The death arc (decision 0308) — arm bodies in `death` ─────────────────────────
|
||||
SessionEvent::CorpseQuery {
|
||||
found,
|
||||
@@ -1528,6 +1537,14 @@ pub(super) fn apply_net_updates(
|
||||
SessionEvent::TrainerBuyFailed { error, .. } => {
|
||||
npc::trainer_buy_failed(error, &mut ui_actions.8)
|
||||
}
|
||||
SessionEvent::ListStabledPets {
|
||||
npc,
|
||||
num_stable_slots,
|
||||
pets,
|
||||
} => npc::list_stabled_pets(npc, num_stable_slots, pets, &mut stable_open),
|
||||
SessionEvent::StableResult { result } => {
|
||||
npc::stable_result(result, &mut stable_open, &net_commands)
|
||||
}
|
||||
SessionEvent::TaxiNodesShown {
|
||||
flightmaster,
|
||||
nearest_node,
|
||||
|
||||
@@ -189,6 +189,14 @@ pub(super) fn loot_all_passed(p: LootAllPassed, rolls: &mut LootRolls) {
|
||||
rolls.all_passed(p);
|
||||
}
|
||||
|
||||
/// The master-loot candidate list (`SMSG_LOOT_MASTER_LIST`, decision 1675) — who the master looter
|
||||
/// may hand a row to. It arrives from inside the server's `SendLoot`, so it lands just AHEAD of the
|
||||
/// `SMSG_LOOT_RESPONSE` it belongs to; `LootState` stages it and the open claims it.
|
||||
pub(super) fn loot_master_list(candidates: Vec<u64>, loot: &mut LootState) {
|
||||
debug!("net: master-loot candidates: {} eligible", candidates.len());
|
||||
loot.set_master_candidates(candidates);
|
||||
}
|
||||
|
||||
/// Is this push **ours**? The outermost gate in `CGGameUI::OnItemPush` (1.12.1 `WoW.exe` 5875,
|
||||
/// `0x491a60`): `0x491b56`/`0x491b61` compare the packet guid against the active player's and fork,
|
||||
/// and the self arm is the only one that prints "You receive …" *or* animates a bag button. A group
|
||||
|
||||
@@ -38,6 +38,7 @@ pub(super) fn creature_name(
|
||||
type_flags: u32,
|
||||
civilian: bool,
|
||||
racial_leader: bool,
|
||||
display_id: u32,
|
||||
names: &mut NameCache,
|
||||
) {
|
||||
names.insert_creature(
|
||||
@@ -51,6 +52,7 @@ pub(super) fn creature_name(
|
||||
type_flags,
|
||||
civilian,
|
||||
racial_leader,
|
||||
display_id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
//! 0237), and the taxi-map session (decision 0484 phase 1). Each `pub(super)` fn here is exactly
|
||||
//! one arm's body; the match at the call site stays the dispatcher, one call per arm.
|
||||
|
||||
use benilla_protocol::messages::{GossipOption, NpcTextBlock, TaxiMask, TrainerSpell, VendorItem};
|
||||
use benilla_protocol::messages::{
|
||||
GossipOption, NpcTextBlock, StabledPet, TaxiMask, TrainerSpell, VendorItem,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::ui_bank::{BankErrors, BankOpen};
|
||||
use crate::ui_gossip::GossipState;
|
||||
use crate::ui_merchant::{MerchantErrors, MerchantOpen, MerchantRefusal};
|
||||
use crate::ui_quest::QuestGiver;
|
||||
use crate::ui_stable::StableOpen;
|
||||
use crate::ui_taxi::TaxiState;
|
||||
use crate::ui_trainer::{TrainerErrors, TrainerOpen};
|
||||
|
||||
@@ -192,6 +195,49 @@ pub(super) fn trainer_buy_succeeded(
|
||||
}
|
||||
}
|
||||
|
||||
/// A stable master's pet list (`MSG_LIST_STABLED_PETS`): fill the [`StableOpen`] the stable feed
|
||||
/// (`crate::ui_stable`) reads. Arrives unprompted off the gossip stable option — that is how the
|
||||
/// window opens — and again in answer to our own refresh send.
|
||||
pub(super) fn list_stabled_pets(
|
||||
npc: u64,
|
||||
num_stable_slots: u8,
|
||||
pets: Vec<StabledPet>,
|
||||
stable_open: &mut StableOpen,
|
||||
) {
|
||||
debug!(
|
||||
"net: stable master {npc:#x} listed {} pets ({num_stable_slots} slots bought)",
|
||||
pets.len()
|
||||
);
|
||||
stable_open.open(npc, num_stable_slots, pets);
|
||||
}
|
||||
|
||||
/// The answer to a stable verb (`SMSG_STABLE_RESULT`) — one byte, and the *whole* answer: no
|
||||
/// success carries an updated list (VERIFIED vmangos, every `HandleStable*` path ends in
|
||||
/// `SendStableResult` and nothing else). So a success **must** be followed by a re-request or the
|
||||
/// window would go on showing the pre-action arrangement — a pet that is now stabled still standing
|
||||
/// in slot 0. That re-request is forced by the server's behaviour, not a choice.
|
||||
///
|
||||
/// The refusal half is where the client's own behaviour is still open (decision 1676): benilla
|
||||
/// surfaces `ERR_MONEY` on the error line and swallows the generic `ERR_STABLE`, which carries no
|
||||
/// reason at all — dead player, out of range, no free bought slot, untameable, and "you already
|
||||
/// have a pet" are one indistinguishable code. What the reference does with each is a wow-re carve
|
||||
/// item; showing a wrong-but-confident sentence for a code that means five things would be worse
|
||||
/// than the silence.
|
||||
pub(super) fn stable_result(result: u8, stable_open: &mut StableOpen, net_commands: &NetCommands) {
|
||||
use benilla_protocol::messages::stable_result as code;
|
||||
let Some(npc) = stable_open.npc else {
|
||||
debug!("net: stable result {result} with no open stable — ignored");
|
||||
return;
|
||||
};
|
||||
match result {
|
||||
code::SUCCESS_STABLE | code::SUCCESS_UNSTABLE | code::SUCCESS_BUY_SLOT => {
|
||||
debug!("net: stable action succeeded (code {result}) — re-listing");
|
||||
let _ = net_commands.0.send(ClientCommand::ListStabledPets { npc });
|
||||
}
|
||||
_ => debug!("net: stable action refused (code {result})"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A trainer refused a purchase — the trainer window's error line.
|
||||
pub(super) fn trainer_buy_failed(error: u32, errors: &mut TrainerErrors) {
|
||||
debug!("net: trainer buy failed (code {error})");
|
||||
|
||||
@@ -815,6 +815,15 @@ fn writer_loop(
|
||||
ClientCommand::TrainerBuySpell { trainer, spell_id } => {
|
||||
w.trainer_buy_spell(trainer, spell_id)
|
||||
}
|
||||
ClientCommand::ListStabledPets { npc } => w.list_stabled_pets(npc),
|
||||
ClientCommand::StablePet { npc } => w.stable_pet(npc),
|
||||
ClientCommand::UnstablePet { npc, pet_number } => {
|
||||
w.unstable_pet(npc, pet_number)
|
||||
}
|
||||
ClientCommand::StableSwapPet { npc, pet_number } => {
|
||||
w.stable_swap_pet(npc, pet_number)
|
||||
}
|
||||
ClientCommand::BuyStableSlot { npc } => w.buy_stable_slot(npc),
|
||||
ClientCommand::LearnTalent { talent_id, rank } => {
|
||||
w.learn_talent(talent_id, rank)
|
||||
}
|
||||
@@ -843,6 +852,9 @@ fn writer_loop(
|
||||
spell_id,
|
||||
item_guid,
|
||||
} => w.cast_spell_item(spell_id, item_guid),
|
||||
ClientCommand::LootMasterGive { guid, slot, target } => {
|
||||
w.loot_master_give(guid, slot, target)
|
||||
}
|
||||
ClientCommand::Loot { guid } => w.loot(guid),
|
||||
ClientCommand::AutostoreLootItem { slot } => w.autostore_loot_item(slot),
|
||||
ClientCommand::LootMoney => w.loot_money(),
|
||||
|
||||
@@ -22,15 +22,19 @@
|
||||
//! → coin ? [`ClientCommand::LootMoney`] : [`ClientCommand::AutostoreLootItem`] (the clicked 1-based
|
||||
//! row mapped to the item's **wire** loot slot); `CloseLoot` → [`ClientCommand::LootRelease`].
|
||||
|
||||
use benilla_protocol::messages::{ItemPushResult, LootItem, BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST};
|
||||
use benilla_protocol::messages::{
|
||||
slot_type, ItemPushResult, LootItem, BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{LootRow, LootState as LootSnapshot, ScriptValue, UiScript};
|
||||
|
||||
use crate::entities::ItemDisplays;
|
||||
use crate::items::{Items, RollCatalogs};
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::ui_items::KEYRING_CONTAINER;
|
||||
use crate::ui_party::{GroupState, GROUPTYPE_RAID, GROUP_MEMBER_SUBGROUP};
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
/// The coin-pile row icons (direct `Interface\Icons` paths — `SetTexture` takes them as-is, no DBC),
|
||||
@@ -171,13 +175,36 @@ pub(crate) struct LootState {
|
||||
/// snapshot as `IsFishingLoot()`, which `LootFrame_OnShow` keys the "FISHING REEL IN" sound
|
||||
/// and the FishingLoot portrait overlay on (decision 1086).
|
||||
fishing: bool,
|
||||
/// The master-loot candidates for the OPEN window (`SMSG_LOOT_MASTER_LIST`), in wire order —
|
||||
/// the guids `GiveMasterLoot`'s 1-based candidate index resolves against (decision 1675).
|
||||
/// Empty under every other loot method.
|
||||
master_candidates: Vec<u64>,
|
||||
/// The candidate list that arrived but has no window yet. `SMSG_LOOT_MASTER_LIST` is sent from
|
||||
/// *inside* `Player::SendLoot` (`Player.cpp:8077-8081`), so it lands **before** the
|
||||
/// `SMSG_LOOT_RESPONSE` it belongs to; [`LootState::open`] takes it from here. Staging it
|
||||
/// rather than writing `master_candidates` directly is what keeps one window's list from
|
||||
/// leaking into the next window opened under a different loot method.
|
||||
pending_master_candidates: Vec<u64>,
|
||||
}
|
||||
|
||||
/// The master-loot candidate array's fixed width — 40 slots of 8 bytes at `0xc4dc38`, zeroed at
|
||||
/// every `SMSG_LOOT_MASTER_LIST` and bound-checked by the getter `0x61c660` (`cmp ecx,0x28`).
|
||||
const MASTER_LOOT_CANDIDATE_SLOTS: usize = 40;
|
||||
/// The raid subgroup's stride within that array (`n*5 .. n*5+5`, `0x61c609`) — 8 groups of 5.
|
||||
const MEMBERS_PER_RAID_GROUP: usize = 5;
|
||||
|
||||
/// A resolved loot-row pick: the coin pile, or an item at a concrete **wire** loot slot (carrying
|
||||
/// its display id, so the pick can play the item's pickup sound without a second lookup).
|
||||
/// its display id, so the pick can play the item's pickup sound without a second lookup, and the
|
||||
/// wire's `slot_type`, which decides whether the row is takeable at all).
|
||||
enum LootAction {
|
||||
Money,
|
||||
Item { wire_slot: u8, display_id: u32 },
|
||||
Item {
|
||||
wire_slot: u8,
|
||||
display_id: u32,
|
||||
/// The wire's per-row [`slot_type`]. `MASTER` diverts the click to the master-loot
|
||||
/// dropdown instead of a take (decision 1675).
|
||||
slot_type: u8,
|
||||
},
|
||||
}
|
||||
|
||||
impl LootState {
|
||||
@@ -192,6 +219,21 @@ impl LootState {
|
||||
self.taken.clear();
|
||||
self.auto_release = false; // empty-at-open stays open — only a removal auto-closes
|
||||
self.fishing = loot_type == benilla_protocol::messages::loot_type::FISHING;
|
||||
// The master-loot candidate list arrives just AHEAD of this response (the server sends it
|
||||
// from inside `SendLoot`), so the window claims whatever was staged and leaves the staging
|
||||
// empty — a later window under a non-master method then correctly has no candidates.
|
||||
self.master_candidates = std::mem::take(&mut self.pending_master_candidates);
|
||||
}
|
||||
|
||||
/// A master-loot candidate list arrived (`SMSG_LOOT_MASTER_LIST`). Normally this precedes the
|
||||
/// `SMSG_LOOT_RESPONSE` it belongs to and is staged for [`LootState::open`]; if a window is
|
||||
/// already up it is also applied in place, which is the case the reference's
|
||||
/// `UPDATE_MASTER_LOOT_LIST` event exists for.
|
||||
pub(crate) fn set_master_candidates(&mut self, candidates: Vec<u64>) {
|
||||
if self.source.is_some() {
|
||||
self.master_candidates.clone_from(&candidates);
|
||||
}
|
||||
self.pending_master_candidates = candidates;
|
||||
}
|
||||
|
||||
/// A row was taken by anyone (`SMSG_LOOT_REMOVED`, keyed by the **wire** slot): its position
|
||||
@@ -258,6 +300,8 @@ impl LootState {
|
||||
self.taken.clear();
|
||||
self.auto_release = false;
|
||||
self.fishing = false;
|
||||
self.master_candidates.clear();
|
||||
self.pending_master_candidates.clear();
|
||||
}
|
||||
|
||||
/// Disconnect: drop the open window **and** any pending receive lines (mirrors the merchant/gossip
|
||||
@@ -304,8 +348,62 @@ impl LootState {
|
||||
(!self.taken.contains(&it.slot)).then_some(LootAction::Item {
|
||||
wire_slot: it.slot,
|
||||
display_id: it.display_info_id,
|
||||
slot_type: it.slot_type,
|
||||
})
|
||||
}
|
||||
|
||||
/// The candidate slots as the client lays them out — **not** simply the wire order (VERIFIED
|
||||
/// in the 5875 binary, `SMSG_LOOT_MASTER_LIST`'s handler `0x61c550`). The array is 40 fixed
|
||||
/// 8-byte slots at `0xc4dc38`, zeroed per packet, and filled by one of two paths chosen once
|
||||
/// from the live raid-member count `[0xb713e0]`:
|
||||
///
|
||||
/// - **Not in a raid** (`0x61c5b9`): the wire's own loop counter is the slot. Dense, and with
|
||||
/// no bound check at all — a party can only ever fill 0..4.
|
||||
/// - **In a raid** (`0x61c5c9`-`0x61c637`): each guid is looked up in the raid roster, its
|
||||
/// subgroup `n` read, and it is written to the **first free slot of `[n*5, n*5+5)`**, bound
|
||||
/// checked against 40. So the array is BUCKETED BY SUBGROUP, with holes.
|
||||
///
|
||||
/// The holes are the point: `GroupLootDropDown_Initialize` walks `1..40` in blocks of five and
|
||||
/// keeps a "Group N" submenu only where the block has an occupant (`LootFrame.lua:190-201`).
|
||||
/// Packing the list densely would have labelled every candidate with the wrong raid group.
|
||||
///
|
||||
/// One placement function serves both readers — the feed (which turns slots into names) and
|
||||
/// the drain (which turns a clicked index back into a guid) — so the index the Lua hands back
|
||||
/// can never mean something different from the index it was shown.
|
||||
fn placed_candidates(&self, group: &GroupState) -> Vec<Option<u64>> {
|
||||
if group.group_type != GROUPTYPE_RAID {
|
||||
return self.master_candidates.iter().copied().map(Some).collect();
|
||||
}
|
||||
let mut slots: Vec<Option<u64>> = vec![None; MASTER_LOOT_CANDIDATE_SLOTS];
|
||||
for &guid in &self.master_candidates {
|
||||
// Everyone else's subgroup rides their roster entry; ours rides `own_flags`, and the
|
||||
// fallback IS us — `SMSG_GROUP_LIST`'s member array is the *other* members, so the one
|
||||
// candidate guid that can never be found in it is our own (the server puts us in the
|
||||
// candidate list: `Group::MasterLoot` walks the whole group).
|
||||
let flags = group
|
||||
.members
|
||||
.iter()
|
||||
.find(|m| m.guid == guid)
|
||||
.map_or(group.own_flags, |m| m.flags);
|
||||
let base = usize::from(flags & GROUP_MEMBER_SUBGROUP) * MEMBERS_PER_RAID_GROUP;
|
||||
let end = (base + MEMBERS_PER_RAID_GROUP).min(MASTER_LOOT_CANDIDATE_SLOTS);
|
||||
if let Some(free) = (base..end).find(|&i| slots[i].is_none()) {
|
||||
slots[free] = Some(guid);
|
||||
}
|
||||
}
|
||||
while slots.last().is_some_and(Option::is_none) {
|
||||
slots.pop(); // trailing empties read as nil either way; keep the snapshot small
|
||||
}
|
||||
slots
|
||||
}
|
||||
|
||||
/// The candidate at a 1-based menu index, through [`LootState::placed_candidates`].
|
||||
fn master_candidate(&self, index_1based: u32, group: &GroupState) -> Option<u64> {
|
||||
self.placed_candidates(group)
|
||||
.get(index_1based.checked_sub(1)? as usize)
|
||||
.copied()
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
/// A loot refusal (`SMSG_LOOT_RESPONSE`'s error shape) queued by the net bridge for the UI error line
|
||||
@@ -492,6 +590,14 @@ fn loot_error_text(reason: u8) -> String {
|
||||
e::STUNNED => "You can't do that while stunned.".into(),
|
||||
e::PLAYER_NOT_FOUND => "You can't loot that right now.".into(),
|
||||
e::ALREADY_PICKPOCKETED => "Those pockets are already empty.".into(),
|
||||
// The master looter's three refusals (decision 1675). These reach only the master looter,
|
||||
// in answer to a `CMSG_LOOT_MASTER_GIVE` the server would not honour
|
||||
// (`LootHandler.cpp:718-729`), and unlike the lines above they are QUOTED from 1.12's own
|
||||
// GlobalStrings (l.1679-1681) rather than composed — the reference has real strings for
|
||||
// exactly this trio.
|
||||
e::MASTER_INV_FULL => "That player's inventory is full".into(),
|
||||
e::MASTER_UNIQUE_ITEM => "Player has too many of that item already".into(),
|
||||
e::MASTER_OTHER => "Can't assign item to that player".into(),
|
||||
other => format!("You can't loot that ({other})."),
|
||||
}
|
||||
}
|
||||
@@ -600,6 +706,7 @@ fn snapshot(
|
||||
icons: Option<&ItemDisplays>,
|
||||
commands: &NetCommands,
|
||||
rolls: RollCatalogs,
|
||||
who: Candidates,
|
||||
) -> Option<LootSnapshot> {
|
||||
loot.source?;
|
||||
let mut rows = Vec::with_capacity(loot.items.len() + 1);
|
||||
@@ -626,9 +733,53 @@ fn snapshot(
|
||||
Some(LootSnapshot {
|
||||
rows,
|
||||
fishing: loot.fishing,
|
||||
master_candidates: who.names_for(loot),
|
||||
})
|
||||
}
|
||||
|
||||
/// The two name sources a master-loot candidate guid can resolve through (decision 1675). The
|
||||
/// roster is the primary one — `SMSG_GROUP_LIST` carries every other member's name outright, so no
|
||||
/// query is needed — and the name cache covers the one guid the roster never lists: **our own**,
|
||||
/// which vmangos includes in the candidate list (`Group::MasterLoot` walks the whole group,
|
||||
/// `Group.cpp:919-937`) but excludes from the member array it sends us.
|
||||
#[derive(Clone, Copy)]
|
||||
struct Candidates<'a> {
|
||||
group: &'a GroupState,
|
||||
names: &'a NameCache,
|
||||
}
|
||||
|
||||
impl Candidates<'_> {
|
||||
/// The open window's candidate slots as NAMES, with two kinds of hole preserved: an empty
|
||||
/// slot, and an occupied slot whose name has not resolved yet.
|
||||
///
|
||||
/// Both read as `nil` from `GetMasterLootCandidate`, which is what the real binding does —
|
||||
/// `0x4c2f10` takes its name from the guid→name cache `0x55f080` and pushes **nil** on a miss
|
||||
/// (`0x4c2f91`), the same value it pushes for an empty slot. The miss is transient: the cache
|
||||
/// queues a lookup with `0x4c2fb0` as its completion callback, and that callback fires event
|
||||
/// `0x1f8` — `UPDATE_MASTER_LOOT_LIST`, whose whole job is to repaint the menu once a name
|
||||
/// lands. Answering an empty string instead would put a blank row in the dropdown and make
|
||||
/// that event pointless.
|
||||
fn names_for(&self, loot: &LootState) -> Vec<Option<String>> {
|
||||
loot.placed_candidates(self.group)
|
||||
.into_iter()
|
||||
.map(|slot| slot.and_then(|guid| self.name(guid)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A candidate's display name, or `None` while it is unresolved. The roster is the primary
|
||||
/// source — `SMSG_GROUP_LIST` carries every other member's name outright — and the name cache
|
||||
/// covers our own guid, which that array never lists.
|
||||
fn name(&self, guid: u64) -> Option<String> {
|
||||
self.group
|
||||
.members
|
||||
.iter()
|
||||
.find(|m| m.guid == guid)
|
||||
.map(|m| m.name.clone())
|
||||
.or_else(|| self.names.peek(guid).map(str::to_string))
|
||||
.filter(|n| !n.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
/// Compose one `CHAT_MSG_LOOT` receive line — the whole of `CGGameUI::OnItemPush`'s self branch,
|
||||
/// VERIFIED against the 1.12.1 client binary (`WoW.exe` 5875, `0x491a60`, self arm `0x491bfb`).
|
||||
///
|
||||
@@ -754,6 +905,9 @@ fn feed_loot(
|
||||
// only source either can come from.
|
||||
props: Option<Res<crate::items::RandomProperties>>,
|
||||
enchants: Option<Res<crate::items::Enchants>>,
|
||||
// The two master-loot candidate name sources (decision 1675) — see [`Candidates`].
|
||||
group: Res<GroupState>,
|
||||
names: Res<NameCache>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -782,7 +936,11 @@ fn feed_loot(
|
||||
rolls,
|
||||
);
|
||||
|
||||
let fresh = snapshot(&loot, &mut items, icons.as_deref(), &commands, rolls);
|
||||
let who = Candidates {
|
||||
group: &group,
|
||||
names: &names,
|
||||
};
|
||||
let fresh = snapshot(&loot, &mut items, icons.as_deref(), &commands, rolls, who);
|
||||
if fresh == *last {
|
||||
return;
|
||||
}
|
||||
@@ -802,16 +960,23 @@ fn feed_loot(
|
||||
Some(LootAction::Money) => {
|
||||
let _ = commands.0.send(ClientCommand::LootMoney);
|
||||
}
|
||||
// Only an ALLOW_LOOT row: the reference's auto-loot sweep processes a
|
||||
// record exactly when the wire's slot-type getter answers 0
|
||||
// (`0x4c2180`/`0x4c2196 test eax,eax; jne` — wow-re
|
||||
// `ui/scratch/loot-slot-record.md` §2). So a master-loot row is not
|
||||
// swept into a dropdown, and a roll-in-progress row is not sent as a
|
||||
// take the server would refuse.
|
||||
Some(LootAction::Item {
|
||||
wire_slot,
|
||||
display_id,
|
||||
}) => {
|
||||
slot_type,
|
||||
}) if slot_type == slot_type::ALLOW_LOOT => {
|
||||
let _ = commands
|
||||
.0
|
||||
.send(ClientCommand::AutostoreLootItem { slot: wire_slot });
|
||||
pickup.write(crate::sound::LootPickupSound { display_id });
|
||||
}
|
||||
None => {}
|
||||
Some(LootAction::Item { .. }) | None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -820,7 +985,16 @@ fn feed_loot(
|
||||
// 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).
|
||||
(Some(_), Some(_)) => script.fire_event("LOOT_UPDATE", vec![]),
|
||||
(Some(before), Some(after)) => {
|
||||
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
|
||||
// (`LootFrame_OnEvent`'s `UIDropDownMenu_Refresh(GroupLootDropDown)`,
|
||||
// `LootFrame.lua:63`). Firing it only on a real change keeps a closed menu untouched.
|
||||
if before.master_candidates != after.master_candidates {
|
||||
script.fire_event("UPDATE_MASTER_LOOT_LIST", vec![]);
|
||||
}
|
||||
}
|
||||
(Some(_), None) => script.fire_event("LOOT_CLOSED", vec![]),
|
||||
(None, None) => {}
|
||||
}
|
||||
@@ -839,6 +1013,8 @@ fn drain_loot(
|
||||
mut latch: ResMut<LootLatch>,
|
||||
commands: Res<NetCommands>,
|
||||
mut pickup: MessageWriter<crate::sound::LootPickupSound>,
|
||||
// The candidate placement is raid-shaped, so resolving a clicked menu index needs the roster.
|
||||
group: Res<GroupState>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
@@ -851,9 +1027,22 @@ fn drain_loot(
|
||||
debug!("ui_loot: loot coin (row {index})");
|
||||
let _ = commands.0.send(ClientCommand::LootMoney);
|
||||
}
|
||||
// A master-loot row is not takeable by anyone — it is ASSIGNED. The click opens the
|
||||
// candidate dropdown instead of sending a take, and the decision is made here, on the
|
||||
// wire's slot-type byte, rather than in Lua: the real client branches on the same byte
|
||||
// inside its take dispatcher before any Lua runs (`0x4c2790`, which already reads the
|
||||
// getter at `0x4c28a9` — wow-re `ui/scratch/loot-slot-record.md` §2), and the
|
||||
// reference `LootFrame.lua` never consults `GetLootMethod` at all. The row's
|
||||
// `LootFrame.selected*` bookkeeping is already stashed by the Lua `OnClick` that ran
|
||||
// before this drain, so the event's `ToggleDropDownMenu` has its anchor button.
|
||||
Some(LootAction::Item { slot_type, .. }) if slot_type == slot_type::MASTER => {
|
||||
debug!("ui_loot: row {index} is master-loot — opening the candidate list");
|
||||
script.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
|
||||
}
|
||||
Some(LootAction::Item {
|
||||
wire_slot,
|
||||
display_id,
|
||||
..
|
||||
}) => {
|
||||
debug!("ui_loot: autostore row {index} (wire slot {wire_slot})");
|
||||
let _ = commands
|
||||
@@ -866,6 +1055,44 @@ fn drain_loot(
|
||||
None => debug!("ui_loot: LootSlot({index}) out of range — ignored"),
|
||||
}
|
||||
}
|
||||
// The master looter's assignments (`GiveMasterLoot(slot, candidateIndex)`): the Lua hands two
|
||||
// 1-based display numbers, the app turns them into the wire slot and the recipient guid. Both
|
||||
// must resolve — a stale row or a candidate index past the end of the list is dropped here
|
||||
// rather than sent, since the app could not address either.
|
||||
for (index, candidate) in script.take_loot_master_gives() {
|
||||
let Some(guid) = loot.source else {
|
||||
continue;
|
||||
};
|
||||
let (
|
||||
Some(LootAction::Item {
|
||||
wire_slot,
|
||||
slot_type,
|
||||
..
|
||||
}),
|
||||
Some(target),
|
||||
) = (
|
||||
loot.action_at(index),
|
||||
loot.master_candidate(candidate, &group),
|
||||
)
|
||||
else {
|
||||
debug!("ui_loot: GiveMasterLoot({index}, {candidate}) unresolvable — ignored");
|
||||
continue;
|
||||
};
|
||||
// The row must still BE a master row. The real sender checks exactly this before it
|
||||
// builds anything — `0x4c2940` re-reads the slot type through `0x5ebce0(record+0x18)`
|
||||
// and bails unless it is 2 — so a give aimed at an ordinary row sends nothing at all
|
||||
// rather than a packet the server would refuse.
|
||||
if slot_type != slot_type::MASTER {
|
||||
debug!("ui_loot: GiveMasterLoot({index}) is not a master row — ignored");
|
||||
continue;
|
||||
}
|
||||
debug!("ui_loot: master-give row {index} (wire slot {wire_slot}) to {target:#x}");
|
||||
let _ = commands.0.send(ClientCommand::LootMasterGive {
|
||||
guid,
|
||||
slot: wire_slot,
|
||||
target,
|
||||
});
|
||||
}
|
||||
if script.take_loot_close() {
|
||||
if let Some(guid) = loot.source {
|
||||
debug!("ui_loot: release loot {guid:#x}");
|
||||
@@ -893,9 +1120,17 @@ fn drain_loot(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use benilla_protocol::messages::loot_type;
|
||||
use benilla_protocol::messages::GroupMemberEntry;
|
||||
use benilla_protocol::messages::ObjectFields;
|
||||
use benilla_protocol::EntityKind;
|
||||
|
||||
/// An empty group + name cache — what every loot test that is not about master loot wants:
|
||||
/// no candidates resolve, and a snapshot's `master_candidates` comes out empty. Master-loot
|
||||
/// tests build their own roster.
|
||||
fn nobody() -> (GroupState, NameCache) {
|
||||
(GroupState::default(), NameCache::default())
|
||||
}
|
||||
|
||||
/// Descriptor field indices the predicate-B table reads.
|
||||
const F_GO_TYPE_ID: u16 = 21;
|
||||
const F_UNIT_HEALTH: u16 = 22;
|
||||
@@ -983,6 +1218,150 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// The same row, stamped MASTER — what vmangos sends every group member for every row once
|
||||
/// the group's loot method is master loot.
|
||||
fn master_item(slot: u8, entry: u32, count: u32) -> LootItem {
|
||||
LootItem {
|
||||
slot_type: slot_type::MASTER,
|
||||
..item(slot, entry, count)
|
||||
}
|
||||
}
|
||||
|
||||
/// The candidate list rides AHEAD of the response it belongs to (the server sends it from
|
||||
/// inside `SendLoot`), so the window has to claim what was staged before it opened — and a
|
||||
/// later window opened with no list of its own must not inherit the previous one's.
|
||||
#[test]
|
||||
fn the_candidate_list_arrives_before_its_window_and_does_not_outlive_it() {
|
||||
let mut loot = LootState::default();
|
||||
// A default GroupState is a plain party (`group_type` 0) — the flat placement path.
|
||||
let party = GroupState::default();
|
||||
|
||||
// Staged with no window open: nothing to read yet.
|
||||
loot.set_master_candidates(vec![0xA, 0xB, 0xC]);
|
||||
assert_eq!(
|
||||
loot.master_candidate(1, &party),
|
||||
None,
|
||||
"no window, no candidates"
|
||||
);
|
||||
|
||||
// The response lands: the window claims the staged list, 1-based.
|
||||
loot.open(0x42, loot_type::CORPSE, 0, vec![master_item(0, 117, 1)]);
|
||||
assert_eq!(loot.master_candidate(1, &party), Some(0xA));
|
||||
assert_eq!(loot.master_candidate(3, &party), Some(0xC));
|
||||
assert_eq!(loot.master_candidate(4, &party), None, "past the end");
|
||||
assert_eq!(
|
||||
loot.master_candidate(0, &party),
|
||||
None,
|
||||
"0 is not a Lua index"
|
||||
);
|
||||
|
||||
// A refresh while the window is up applies in place (the ref's UPDATE_MASTER_LOOT_LIST).
|
||||
loot.set_master_candidates(vec![0xA, 0xB]);
|
||||
assert_eq!(loot.master_candidate(2, &party), Some(0xB));
|
||||
assert_eq!(loot.master_candidate(3, &party), None, "the list shrank");
|
||||
|
||||
// Close, then a plain corpse under a different loot method: no candidates leak across.
|
||||
loot.clear();
|
||||
loot.open(0x43, loot_type::CORPSE, 0, vec![item(0, 117, 1)]);
|
||||
assert_eq!(
|
||||
loot.master_candidate(1, &party),
|
||||
None,
|
||||
"a fresh window starts empty"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The raid placement — the finding that corrected this code** (VERIFIED, the 5875
|
||||
/// binary's `SMSG_LOOT_MASTER_LIST` handler `0x61c550`). In a raid the candidate array is not
|
||||
/// the wire order: each guid is filed into the first free slot of its own subgroup's block of
|
||||
/// five. It was built dense first, which would have labelled every candidate with the wrong
|
||||
/// raid group in the dropdown's "Group N" submenus.
|
||||
#[test]
|
||||
fn raid_candidates_file_into_their_own_subgroup_block() {
|
||||
let member = |guid: u64, name: &str, subgroup: u8| GroupMemberEntry {
|
||||
name: name.into(),
|
||||
guid,
|
||||
status: 0,
|
||||
flags: subgroup,
|
||||
};
|
||||
let mut raid = GroupState {
|
||||
group_type: GROUPTYPE_RAID,
|
||||
own_flags: 0, // we are in subgroup 0
|
||||
..GroupState::default()
|
||||
};
|
||||
raid.members = vec![
|
||||
member(0xB, "Cairne", 0),
|
||||
member(0xC, "Vol", 2),
|
||||
member(0xD, "Sylvanas", 2),
|
||||
];
|
||||
|
||||
let mut loot = LootState::default();
|
||||
// Wire order deliberately interleaves the subgroups — placement must ignore it.
|
||||
loot.set_master_candidates(vec![0xC, 0xA, 0xD, 0xB]);
|
||||
loot.open(0x42, loot_type::CORPSE, 0, vec![master_item(0, 117, 1)]);
|
||||
|
||||
// Subgroup 0 fills slots 1-2 (us at 0xA, then Cairne); subgroup 2 fills 11-12, in the
|
||||
// order the wire listed them.
|
||||
assert_eq!(loot.master_candidate(1, &raid), Some(0xA), "us, group 1");
|
||||
assert_eq!(
|
||||
loot.master_candidate(2, &raid),
|
||||
Some(0xB),
|
||||
"Cairne, group 1"
|
||||
);
|
||||
for empty in [3, 4, 5, 6, 7, 8, 9, 10] {
|
||||
assert_eq!(
|
||||
loot.master_candidate(empty, &raid),
|
||||
None,
|
||||
"slot {empty} belongs to an empty group"
|
||||
);
|
||||
}
|
||||
assert_eq!(loot.master_candidate(11, &raid), Some(0xC), "Vol, group 3");
|
||||
assert_eq!(
|
||||
loot.master_candidate(12, &raid),
|
||||
Some(0xD),
|
||||
"Sylvanas, group 3"
|
||||
);
|
||||
assert_eq!(loot.master_candidate(13, &raid), None);
|
||||
|
||||
// The same list in a plain PARTY is flat — the binary picks the path once, off the raid
|
||||
// member count, and the party path just uses the wire's own loop counter.
|
||||
let party = GroupState {
|
||||
members: raid.members.clone(),
|
||||
..GroupState::default()
|
||||
};
|
||||
assert_eq!(loot.master_candidate(1, &party), Some(0xC));
|
||||
assert_eq!(loot.master_candidate(4, &party), Some(0xB));
|
||||
assert_eq!(loot.master_candidate(5, &party), None);
|
||||
}
|
||||
|
||||
/// A master-loot row is not takeable — the click has to divert. The state layer's half of
|
||||
/// that is carrying the wire's `slot_type` out to the drain alongside the wire slot.
|
||||
#[test]
|
||||
fn a_master_row_reports_its_slot_type() {
|
||||
let mut loot = LootState::default();
|
||||
loot.open(
|
||||
0x42,
|
||||
loot_type::CORPSE,
|
||||
0,
|
||||
vec![master_item(0, 117, 1), item(1, 2589, 5)],
|
||||
);
|
||||
assert!(matches!(
|
||||
loot.action_at(1),
|
||||
Some(LootAction::Item {
|
||||
wire_slot: 0,
|
||||
slot_type: slot_type::MASTER,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
loot.action_at(2),
|
||||
Some(LootAction::Item {
|
||||
wire_slot: 1,
|
||||
slot_type: slot_type::ALLOW_LOOT,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
/// One `SMSG_ITEM_PUSH_RESULT` as the loot path sees it (self, shown in chat, no random
|
||||
/// property) — the net bridge's guid/`showInChat` gates are tested at their own seam.
|
||||
fn push(entry: u32, count: u32, from_npc: bool, created: bool) -> ItemPushResult {
|
||||
@@ -1025,14 +1404,16 @@ mod tests {
|
||||
loot.action_at(2),
|
||||
Some(LootAction::Item {
|
||||
wire_slot: 0,
|
||||
display_id: 1117
|
||||
display_id: 1117,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
loot.action_at(3),
|
||||
Some(LootAction::Item {
|
||||
wire_slot: 1,
|
||||
display_id: 3589
|
||||
display_id: 3589,
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(loot.action_at(4).is_none());
|
||||
@@ -1343,10 +1724,22 @@ mod tests {
|
||||
let mut items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let (grp, nm) = nobody();
|
||||
let mut loot = LootState::default();
|
||||
// A pure-copper drop: name reads "4 Copper", icon is the copper coin pile (_05).
|
||||
loot.open(0x42, loot_type::CORPSE, 4, vec![]);
|
||||
let snap = snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("open");
|
||||
let snap = snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
Candidates {
|
||||
group: &grp,
|
||||
names: &nm,
|
||||
},
|
||||
)
|
||||
.expect("open");
|
||||
assert_eq!(snap.rows.len(), 1, "coin row only");
|
||||
let coin = snap.rows[0].as_ref().expect("coin row present");
|
||||
assert!(coin.is_coin);
|
||||
@@ -1359,11 +1752,34 @@ mod tests {
|
||||
let mut items = Items::default();
|
||||
let (tx, _rx) = crossbeam_channel::unbounded();
|
||||
let commands = NetCommands(tx);
|
||||
let (grp, nm) = nobody();
|
||||
let mut loot = LootState::default();
|
||||
// Closed → no snapshot.
|
||||
assert!(snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).is_none());
|
||||
assert!(snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
Candidates {
|
||||
group: &grp,
|
||||
names: &nm,
|
||||
},
|
||||
)
|
||||
.is_none());
|
||||
loot.open(0x42, loot_type::CORPSE, 12_345, vec![item(0, 117, 3)]);
|
||||
let snap = snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("open");
|
||||
let snap = snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
Candidates {
|
||||
group: &grp,
|
||||
names: &nm,
|
||||
},
|
||||
)
|
||||
.expect("open");
|
||||
assert_eq!(snap.rows.len(), 2, "coin + one item");
|
||||
let coin = snap.rows[0].as_ref().expect("coin row present");
|
||||
assert!(coin.is_coin);
|
||||
@@ -1378,16 +1794,36 @@ mod tests {
|
||||
// Looting the coin turns row 1 into a gap — the item KEEPS its position (the reference's
|
||||
// fixed slot array; the director's report was exactly this row sliding up).
|
||||
loot.clear_money();
|
||||
let snap =
|
||||
snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("still open");
|
||||
let snap = snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
Candidates {
|
||||
group: &grp,
|
||||
names: &nm,
|
||||
},
|
||||
)
|
||||
.expect("still open");
|
||||
assert_eq!(snap.rows.len(), 2, "the layout keeps both slots");
|
||||
assert!(snap.rows[0].is_none(), "the looted coin slot is a gap");
|
||||
assert!(snap.rows[1].is_some(), "the item stays at position 2");
|
||||
|
||||
// Looting the item empties the layout entirely (both gaps) — and arms the auto-close.
|
||||
loot.remove_slot(0);
|
||||
let snap =
|
||||
snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("still open");
|
||||
let snap = snapshot(
|
||||
&loot,
|
||||
&mut items,
|
||||
None,
|
||||
&commands,
|
||||
RollCatalogs::NONE,
|
||||
Candidates {
|
||||
group: &grp,
|
||||
names: &nm,
|
||||
},
|
||||
)
|
||||
.expect("still open");
|
||||
assert_eq!(snap.rows, vec![None, None]);
|
||||
assert!(loot.take_auto_release(), "nothing lootable left");
|
||||
}
|
||||
|
||||
@@ -85,12 +85,12 @@ pub(crate) const RAID_TOKENS: [&str; 40] = [
|
||||
];
|
||||
|
||||
/// `GROUPTYPE_RAID` — `SMSG_GROUP_LIST`'s first byte (`0` party, `1` raid; vmangos `Group.h:116`).
|
||||
const GROUPTYPE_RAID: u8 = 1;
|
||||
pub(crate) const GROUPTYPE_RAID: u8 = 1;
|
||||
|
||||
/// The subgroup index in a member's flags byte — bits 0-2 (`GroupMemberEntry::flags`'s own doc;
|
||||
/// the assistant bit `0x80` is its neighbour, and the `0x7f` mask `party_slots` uses is a
|
||||
/// different question, "same subgroup AND same assistant state").
|
||||
const GROUP_MEMBER_SUBGROUP: u8 = 0x07;
|
||||
pub(crate) const GROUP_MEMBER_SUBGROUP: u8 = 0x07;
|
||||
|
||||
/// Push the roster snapshot + the `party1..party4` unit snapshots into the VM and fire the party
|
||||
/// events on their edges. The per-member unit state is the 0434 §2 **merged view**: a streamed
|
||||
@@ -207,16 +207,29 @@ pub(super) fn feed_party(
|
||||
4 => "needbeforegreed",
|
||||
_ => "group",
|
||||
};
|
||||
let master = (loot.method == 2 && loot.master != 0).then(|| {
|
||||
if Some(loot.master) == self_guid {
|
||||
0
|
||||
} else {
|
||||
slots
|
||||
.iter()
|
||||
.position(|m| m.guid == loot.master)
|
||||
.map_or(0, |i| i as u32 + 1)
|
||||
}
|
||||
});
|
||||
// `GetLootMethod`'s second return, and it is deliberately narrow: the binding
|
||||
// (`0x4e91b0`) pushes 0 when the stored looter guid is our own, else searches ONLY
|
||||
// the four party slots (`0x4e81a0`, bounded to 4 even in a raid) and pushes index+1
|
||||
// on a hit — and pushes **nil** on a miss, the same value it pushes when there is no
|
||||
// master looter at all. So a master looter sitting in another raid subgroup is not
|
||||
// expressible here; that is a real, verified asymmetry with `SetLootMethod`, which
|
||||
// does accept raid members.
|
||||
//
|
||||
// The miss arm is what this used to get wrong: it folded an unfound master to 0, and
|
||||
// 0 means "the player" — so a raid master looter in another subgroup lit the master
|
||||
// crown on OUR portrait (`PlayerFrame_UpdatePartyLeader` shows it on `lootMaster == 0`).
|
||||
let master = (loot.method == 2 && loot.master != 0)
|
||||
.then(|| {
|
||||
if Some(loot.master) == self_guid {
|
||||
Some(0)
|
||||
} else {
|
||||
slots
|
||||
.iter()
|
||||
.position(|m| m.guid == loot.master)
|
||||
.map(|i| i as u32 + 1)
|
||||
}
|
||||
})
|
||||
.flatten();
|
||||
(method.to_string(), master, u32::from(loot.threshold))
|
||||
}
|
||||
None => ("group".to_string(), None, 0),
|
||||
@@ -846,6 +859,7 @@ pub(super) fn drain_party(
|
||||
PartyRequest::LootMethod {
|
||||
method,
|
||||
master_name,
|
||||
threshold: asked,
|
||||
} => {
|
||||
let Some(method_id) = loot_method_id(&method) else {
|
||||
continue;
|
||||
@@ -865,11 +879,22 @@ pub(super) fn drain_party(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let threshold = group
|
||||
.loot
|
||||
.map(|l| u32::from(l.threshold))
|
||||
.filter(|t| *t >= 2)
|
||||
.unwrap_or(2);
|
||||
// The caller's own threshold wins when it passed one. Absent, we keep the
|
||||
// group's current floor.
|
||||
//
|
||||
// **Stated divergence** (decision 1675): the real binding defaults the absent
|
||||
// argument to a literal 2, so on the reference client changing the loot method
|
||||
// with no third argument silently RESETS the threshold to Uncommon. Ours is
|
||||
// sticky. The reference behaviour is one line (`.unwrap_or(2)` on `asked` alone);
|
||||
// it is left to the director's call because it is quietly destructive and nothing
|
||||
// in this arc needs it.
|
||||
let threshold = asked.unwrap_or_else(|| {
|
||||
group
|
||||
.loot
|
||||
.map(|l| u32::from(l.threshold))
|
||||
.filter(|t| *t >= 2)
|
||||
.unwrap_or(2)
|
||||
});
|
||||
let _ = commands.0.send(ClientCommand::LootMethod {
|
||||
method: method_id,
|
||||
master,
|
||||
@@ -1024,6 +1049,7 @@ fn test_apply_local(
|
||||
PartyRequest::LootMethod {
|
||||
method,
|
||||
master_name,
|
||||
threshold,
|
||||
} => {
|
||||
if let Some(method_id) = loot_method_id(method) {
|
||||
let master = if method_id == 2 {
|
||||
@@ -1041,7 +1067,10 @@ fn test_apply_local(
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let threshold = group.loot.map_or(2, |l| l.threshold.max(2));
|
||||
// Same rule as the live drain: the caller's threshold wins when it passed one,
|
||||
// else the group keeps its current floor.
|
||||
let threshold = threshold
|
||||
.map_or_else(|| group.loot.map_or(2, |l| l.threshold.max(2)), |t| t as u8);
|
||||
group.loot = Some(GroupLootInfo {
|
||||
method: method_id as u8,
|
||||
master,
|
||||
@@ -1601,6 +1630,7 @@ mod tests {
|
||||
&PartyRequest::LootMethod {
|
||||
method: "needbeforegreed".into(),
|
||||
master_name: None,
|
||||
threshold: None,
|
||||
},
|
||||
me,
|
||||
None,
|
||||
|
||||
@@ -34,7 +34,10 @@ use bevy::prelude::*;
|
||||
use crate::ui_script::UiInput;
|
||||
|
||||
mod feed;
|
||||
pub(crate) use feed::{raid_row_guid, synthetic_raid, synthetic_roster, PARTY_TOKENS, RAID_TOKENS};
|
||||
pub(crate) use feed::{
|
||||
raid_row_guid, synthetic_raid, synthetic_roster, GROUPTYPE_RAID, GROUP_MEMBER_SUBGROUP,
|
||||
PARTY_TOKENS, RAID_TOKENS,
|
||||
};
|
||||
|
||||
pub(crate) struct UiPartyPlugin;
|
||||
|
||||
|
||||
@@ -116,32 +116,38 @@ impl Plugin for UiPetStatsPlugin {
|
||||
///
|
||||
/// This is the **resolving** read — it issues the ask-once creature query on a miss — and it is the
|
||||
/// only one for the pet, so the answer lands in the shared cache for anything else that wants it.
|
||||
/// A miss returns `(None, vec![])`, which is the same shape as "this template has no family": the
|
||||
/// binding's nil either way, and the query is in flight for the next frame.
|
||||
/// A miss returns the all-absent triple, which is the same shape as "this template has no family":
|
||||
/// the binding's nil either way, and the query is in flight for the next frame.
|
||||
///
|
||||
/// The **icon** rides here rather than in a lookup of its own because it comes off the very same
|
||||
/// family row as the word — `CreatureFamily.dbc`'s own icon column, which is a pet's only icon
|
||||
/// (there is no item behind it). `GetPetIcon`'s answer, and the stable window's slot art
|
||||
/// (decision 1676).
|
||||
fn family_for(
|
||||
pet: Option<&ObjectStore>,
|
||||
names: &mut NameCache,
|
||||
commands: &NetCommands,
|
||||
tables: Option<&PetFamilyTables>,
|
||||
) -> (Option<String>, Vec<String>) {
|
||||
) -> (Option<String>, Option<String>, Vec<String>) {
|
||||
let Some(entry) = pet.and_then(|s| s.0.object_entry()).filter(|&e| e != 0) else {
|
||||
return (None, Vec::new());
|
||||
return (None, None, Vec::new());
|
||||
};
|
||||
// The guid argument is the query body's second field and the server ignores it entirely
|
||||
// (vmangos `HandleCreatureQueryOpcode` answers off `packet.entry` alone), so the template-only
|
||||
// `0` convention `Items::template` already uses applies here too.
|
||||
let _ = names.resolve_creature(entry, 0, commands);
|
||||
let Some(tables) = tables else {
|
||||
return (None, Vec::new());
|
||||
return (None, None, Vec::new());
|
||||
};
|
||||
let Some(family) = names
|
||||
.creature_record(entry)
|
||||
.and_then(|r| tables.families.get(r.pet_family))
|
||||
else {
|
||||
return (None, Vec::new());
|
||||
let Some(family_id) = names.creature_record(entry).map(|r| r.pet_family) else {
|
||||
return (None, None, Vec::new());
|
||||
};
|
||||
let Some(family) = tables.families.get(family_id) else {
|
||||
return (None, None, Vec::new());
|
||||
};
|
||||
(
|
||||
Some(family.name.clone()),
|
||||
tables.families.icon(family_id).map(str::to_string),
|
||||
tables
|
||||
.foods
|
||||
.for_mask(family.pet_food_mask)
|
||||
@@ -164,9 +170,9 @@ fn stats_for(
|
||||
pet: Option<&ObjectStore>,
|
||||
self_store: Option<&ObjectStore>,
|
||||
tables: Option<&PetStatTables>,
|
||||
family: (Option<String>, Vec<String>),
|
||||
family: (Option<String>, Option<String>, Vec<String>),
|
||||
) -> (bool, PetStats) {
|
||||
let (family, food_types) = family;
|
||||
let (family, icon, food_types) = family;
|
||||
let Some(fields) = pet.map(|s| &s.0) else {
|
||||
return (false, PetStats::default());
|
||||
};
|
||||
@@ -193,6 +199,9 @@ fn stats_for(
|
||||
has_ui,
|
||||
PetStats {
|
||||
family,
|
||||
// The icon rides past the gate with the WORD, not with the diet — same family
|
||||
// row, same reasoning (decision 1676; the placement is INFERRED, see `PetStats`).
|
||||
icon,
|
||||
..PetStats::default()
|
||||
},
|
||||
);
|
||||
@@ -220,6 +229,7 @@ fn stats_for(
|
||||
training_points: fields.unit_training_points(),
|
||||
experience: fields.unit_pet_experience(),
|
||||
family,
|
||||
icon,
|
||||
food_types,
|
||||
},
|
||||
)
|
||||
@@ -385,8 +395,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// The "no family resolved" pair, for the stat tests that are not about the family.
|
||||
fn no_family() -> (Option<String>, Vec<String>) {
|
||||
(None, Vec::new())
|
||||
fn no_family() -> (Option<String>, Option<String>, Vec<String>) {
|
||||
(None, None, Vec::new())
|
||||
}
|
||||
|
||||
/// A `NameCache` with `entry`'s creature record already cached, carrying `pet_family` —
|
||||
@@ -404,6 +414,7 @@ mod tests {
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
names
|
||||
@@ -604,13 +615,29 @@ mod tests {
|
||||
let mut names = cache_with(IMP_ENTRY, 23);
|
||||
assert_eq!(
|
||||
family_for(Some(&pet), &mut names, &cmds, Some(&t)),
|
||||
(Some("Imp".into()), Vec::new())
|
||||
(
|
||||
Some("Imp".into()),
|
||||
// The family row's own icon column (decision 1676) — and the shipped value for a
|
||||
// warlock minion is **`Ability_Druid_CatForm`**, not any imp art. That is not a
|
||||
// misread: rows 15 (Felhunter) and 23 (Imp) both carry it in the real 5875 file,
|
||||
// where every one of the 22 hunter families carries its correct
|
||||
// `Ability_Hunter_Pet_<Family>`. It is placeholder data Blizzard never filled in,
|
||||
// and nothing in the reference can show it — the stable is the column's only
|
||||
// consumer and it early-returns for warlocks before reading one. Asserted as
|
||||
// shipped rather than as expected.
|
||||
Some("Interface\\Icons\\Ability_Druid_CatForm".into()),
|
||||
Vec::new()
|
||||
)
|
||||
);
|
||||
|
||||
// 5. A hunter's boar (entry 113 → family 5): a word AND the six-diet list, in bit order.
|
||||
let mut names = cache_with(BOAR_ENTRY, 5);
|
||||
let (name, diet) = family_for(Some(&boar()), &mut names, &cmds, Some(&t));
|
||||
let (name, icon, diet) = family_for(Some(&boar()), &mut names, &cmds, Some(&t));
|
||||
assert_eq!(name.as_deref(), Some("Boar"));
|
||||
assert_eq!(
|
||||
icon.as_deref(),
|
||||
Some("Interface\\Icons\\Ability_Hunter_Pet_Boar")
|
||||
);
|
||||
assert_eq!(diet, ["Meat", "Fish", "Cheese", "Bread", "Fungus", "Fruit"]);
|
||||
|
||||
// 6. No DBC tables at all: nil, degraded to exactly the blank level line 1057 shipped.
|
||||
@@ -641,7 +668,7 @@ mod tests {
|
||||
Some(&boar()),
|
||||
Some(&warlock()),
|
||||
Some(&t),
|
||||
(Some("Boar".into()), boar_diet.clone()),
|
||||
(Some("Boar".into()), None, boar_diet.clone()),
|
||||
);
|
||||
assert!(!s.hunter_pet);
|
||||
assert_eq!(s.family.as_deref(), Some("Boar"), "the word is ungated");
|
||||
@@ -657,7 +684,7 @@ mod tests {
|
||||
Some(&boar()),
|
||||
Some(&hunter()),
|
||||
Some(&t),
|
||||
(Some("Boar".into()), boar_diet.clone()),
|
||||
(Some("Boar".into()), None, boar_diet.clone()),
|
||||
);
|
||||
assert_eq!(s.family.as_deref(), Some("Boar"));
|
||||
assert_eq!(s.food_types, boar_diet);
|
||||
@@ -667,7 +694,12 @@ mod tests {
|
||||
/// would outlive the pet on a page that is about to close.
|
||||
#[test]
|
||||
fn no_pet_drops_the_family_too() {
|
||||
let (_, s) = stats_for(None, Some(&hunter()), None, (Some("Imp".into()), vec![]));
|
||||
let (_, s) = stats_for(
|
||||
None,
|
||||
Some(&hunter()),
|
||||
None,
|
||||
(Some("Imp".into()), None, vec![]),
|
||||
);
|
||||
assert_eq!(s.family, None);
|
||||
assert!(s.food_types.is_empty());
|
||||
}
|
||||
|
||||
@@ -62,6 +62,10 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "MerchantFrame.xml"); // BenillaMoney_Set, used by the bag's Update
|
||||
// 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");
|
||||
load_xml(&s, "Cooldown.xml");
|
||||
load_xml(&s, "BagFrame.xml");
|
||||
@@ -72,6 +76,7 @@ fn escape_closes_bag_and_panel_releases_loot_and_clears_cursor() {
|
||||
s.run("BenillaBagToggle_OnClick()").unwrap();
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![Some(LootRow {
|
||||
item_id: 0,
|
||||
name: Some("Wool Cloth".into()),
|
||||
|
||||
@@ -240,6 +240,10 @@ fn the_clicked_form_closes_everything_and_opens_the_menu_in_one_go() {
|
||||
fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
|
||||
let mut s = harness_with(&[
|
||||
"MerchantFrame.xml",
|
||||
// LootFrame.xml owns GroupLootDropDown, whose OnLoad needs the dropdown kit — which in
|
||||
// turn reads TOOLTIP_DEFAULT_COLOR from GameTooltip.xml. Shipped toc order.
|
||||
"GameTooltip.xml",
|
||||
"UIDropDownMenu.xml",
|
||||
"LootFrame.xml",
|
||||
"Cooldown.xml",
|
||||
"BagFrame.xml",
|
||||
@@ -249,6 +253,7 @@ fn the_open_menu_takes_the_screen_and_refuses_every_other_panel() {
|
||||
s.run("BenillaBagToggle_OnClick()").unwrap();
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![Some(LootRow {
|
||||
item_id: 0,
|
||||
name: Some("Wool Cloth".into()),
|
||||
|
||||
@@ -64,6 +64,7 @@ fn text_color(quads: &[ExtractedQuad], t: &str) -> Option<[f32; 4]> {
|
||||
fn coin_and_two_items() -> LootState {
|
||||
LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![
|
||||
Some(LootRow {
|
||||
item_id: 0,
|
||||
@@ -111,11 +112,14 @@ fn shipped_loot_frame_drives_end_to_end() {
|
||||
load_xml(&s, "Fonts.xml"); // ITEM_QUALITY_COLORS (LootFrame's palette), app load order
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
// window + 4 rows + up + down + close.
|
||||
// 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");
|
||||
assert_eq!(
|
||||
load_xml(&s, "LootFrame.xml"),
|
||||
8,
|
||||
"window + 4 rows + up + down + close"
|
||||
10,
|
||||
"window + 4 rows + up + down + close + GroupLootDropDown and its template's own $parentButton"
|
||||
);
|
||||
|
||||
// Hidden by default: no coin icon on screen, left slot empty.
|
||||
@@ -291,6 +295,10 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
|
||||
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");
|
||||
|
||||
// A normal, non-empty loot open queues no sound (the normal open kit is C-side).
|
||||
@@ -309,6 +317,7 @@ fn loot_empty_roll_plays_the_empty_open_kit() {
|
||||
// Re-open with an EMPTY roll: OnShow's numItems==0 fork queues exactly LOOTWINDOWOPENEMPTY.
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![],
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
@@ -331,6 +340,10 @@ fn fishing_loot_open_plays_the_reel_and_swaps_the_portrait() {
|
||||
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");
|
||||
let has_icon = |quads: &[ExtractedQuad], needle: &str| {
|
||||
quads.iter().any(|q| {
|
||||
@@ -382,6 +395,10 @@ fn shipped_loot_frame_pages_five_items() {
|
||||
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");
|
||||
|
||||
let rows: Vec<Option<LootRow>> = (0..5)
|
||||
@@ -400,6 +417,7 @@ fn shipped_loot_frame_pages_five_items() {
|
||||
.collect();
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: rows.clone(),
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
@@ -442,6 +460,7 @@ fn shipped_loot_frame_pages_five_items() {
|
||||
cleared[2] = None;
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: cleared,
|
||||
}));
|
||||
s.fire_event("LOOT_UPDATE", vec![]);
|
||||
@@ -467,6 +486,10 @@ fn shipped_loot_pushed_to_center_by_merchant() {
|
||||
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");
|
||||
load_xml(&s, "GameTooltip.xml"); // app load order: tooltip before merchant
|
||||
load_xml(&s, "MerchantFrame.xml");
|
||||
@@ -603,6 +626,8 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
|
||||
"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",
|
||||
"DressUpFrame.xml",
|
||||
"ChatFrame.xml",
|
||||
@@ -613,6 +638,7 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
|
||||
// A coin row + one resolved item: the item's link is fed exactly as `ui_loot.rs` builds it.
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![
|
||||
Some(LootRow {
|
||||
item_id: 0,
|
||||
@@ -722,3 +748,234 @@ fn ctrl_and_shift_on_a_loot_row_preview_and_post_without_looting() {
|
||||
);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
}
|
||||
|
||||
/// **Master loot, end to end through the shipped XML** (decision 1675).
|
||||
///
|
||||
/// The whole interaction in one pass: a row click stashes the four `LootFrame.selected*` fields
|
||||
/// the dropdown reads, `OPEN_MASTER_LOOT_LIST` raises the menu anchored on that row, a candidate
|
||||
/// row hands the item straight out when the item is below `MASTER_LOOT_THREHOLD`, and an at-or-
|
||||
/// above-threshold item raises `CONFIRM_LOOT_DISTRIBUTION` first and only sends on accept.
|
||||
///
|
||||
/// The two thresholds matter: `MASTER_LOOT_THREHOLD` is 4 (epic), so an uncommon goes straight
|
||||
/// out and an epic asks. The misspelling is the reference's own (`LootFrame.lua:3`).
|
||||
#[test]
|
||||
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");
|
||||
|
||||
let row = |name: &str, quality: u32| {
|
||||
Some(LootRow {
|
||||
item_id: 17182,
|
||||
name: Some(name.into()),
|
||||
texture: Some("Interface\\Icons\\INV_Sword_39".into()),
|
||||
quantity: 1,
|
||||
quality: Some(quality),
|
||||
is_coin: false,
|
||||
link: None,
|
||||
random_property_id: 0,
|
||||
})
|
||||
};
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: vec![Some("Thrall".into()), Some("Cairne".into())],
|
||||
rows: vec![row("Wool Cloth", 2), row("Thunderfury", 4)],
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
let _ = s.take_loot_picks();
|
||||
|
||||
// ── The row click stashes the selection the dropdown will read ────────────────────────────
|
||||
let click = |s: &mut UiScript, frame: &str| {
|
||||
let (x, y) = s
|
||||
.eval::<(f64, f64)>(&format!("return {frame}:GetCenter()"))
|
||||
.unwrap();
|
||||
s.mouse_button(x as f32, y as f32, "LeftButton", true);
|
||||
s.mouse_button(x as f32, y as f32, "LeftButton", false);
|
||||
s.resolve();
|
||||
};
|
||||
click(&mut s, "LootButton1");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(s.eval::<i64>("return LootFrame.selectedSlot").unwrap(), 1);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return LootFrame.selectedQuality").unwrap(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return LootFrame.selectedItemName")
|
||||
.unwrap(),
|
||||
"Wool Cloth"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return LootFrame.selectedLootButton")
|
||||
.unwrap(),
|
||||
"LootButton1",
|
||||
"the dropdown anchors on the clicked row, not the window"
|
||||
);
|
||||
// The click still queues the pick — deciding whether it is a take or a master-loot open is
|
||||
// the app's, off the wire slot type, exactly as the real client decides it in C.
|
||||
assert_eq!(s.take_loot_picks(), vec![1]);
|
||||
|
||||
// ── The app answers a MASTER row with the event; the menu lists the candidates ─────────────
|
||||
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
|
||||
s.resolve();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(
|
||||
s.eval::<bool>("return DropDownList1:IsVisible()").unwrap(),
|
||||
"the candidate menu is up"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return DropDownList1Button1:GetText()")
|
||||
.unwrap(),
|
||||
"Thrall"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return DropDownList1Button2:GetText()")
|
||||
.unwrap(),
|
||||
"Cairne"
|
||||
);
|
||||
|
||||
// ── Below the threshold: straight out, no confirmation ────────────────────────────────────
|
||||
click(&mut s, "DropDownList1Button2");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.take_loot_master_gives(),
|
||||
vec![(1, 2)],
|
||||
"row 1 to candidate 2"
|
||||
);
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"an uncommon item is under MASTER_LOOT_THREHOLD — no confirmation"
|
||||
);
|
||||
|
||||
// ── At the threshold: the confirmation stands between the click and the send ──────────────
|
||||
click(&mut s, "LootButton2");
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return LootFrame.selectedQuality").unwrap(),
|
||||
4
|
||||
);
|
||||
let _ = s.take_loot_picks();
|
||||
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
|
||||
s.resolve();
|
||||
click(&mut s, "DropDownList1Button1");
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert!(
|
||||
s.take_loot_master_gives().is_empty(),
|
||||
"an epic asks first — nothing is sent on the dropdown click"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"CONFIRM_LOOT_DISTRIBUTION is up"
|
||||
);
|
||||
s.run("StaticPopup1Button1:Click()").unwrap(); // Yes
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
assert_eq!(
|
||||
s.take_loot_master_gives(),
|
||||
vec![(2, 1)],
|
||||
"row 2 to candidate 1, only after the accept"
|
||||
);
|
||||
|
||||
// A fresh row click hides a standing confirmation, so an accept can never land on a row the
|
||||
// player has since clicked away from (the ref's own reason for the StaticPopup_Hide).
|
||||
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
|
||||
s.resolve();
|
||||
click(&mut s, "DropDownList1Button1");
|
||||
assert!(s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap());
|
||||
click(&mut s, "LootButton1");
|
||||
assert!(
|
||||
!s.eval::<bool>("return StaticPopup1:IsVisible()").unwrap(),
|
||||
"clicking another row closes the standing confirmation"
|
||||
);
|
||||
}
|
||||
|
||||
/// **The raid arm of the master-loot menu** (decision 1675) — the half the wow-re §5 corrected.
|
||||
///
|
||||
/// In a raid the candidate array is not the wire order: the client files each candidate into its
|
||||
/// own subgroup's five-slot block, leaving holes. `GroupLootDropDown_Initialize` reads those holes
|
||||
/// as raid-group membership — it walks `1..40` in blocks of five and keeps a "Group N" submenu only
|
||||
/// where the block has an occupant. This drives the shipped XML with a hole-shaped list and asserts
|
||||
/// the menu that comes out, which a densely-packed list could not produce.
|
||||
#[test]
|
||||
fn the_master_loot_menu_groups_raid_candidates_by_subgroup() {
|
||||
use benilla_ui::script::{PartyState, RaidMemberInfo};
|
||||
|
||||
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");
|
||||
|
||||
// A raid, so the dropdown takes its nested arm (GetNumRaidMembers() > 0 is the whole gate).
|
||||
s.set_party(PartyState {
|
||||
raid: vec![RaidMemberInfo::default(); 7],
|
||||
loot_method: "master".into(),
|
||||
master_looter: Some(0),
|
||||
loot_threshold: 2,
|
||||
..PartyState::default()
|
||||
});
|
||||
|
||||
// Subgroup 1 holds slots 1-2, subgroup 3 holds slot 11 — everything else is a hole. That is
|
||||
// the placement `SMSG_LOOT_MASTER_LIST`'s handler produces for a raid.
|
||||
let mut candidates = vec![None; 12];
|
||||
candidates[0] = Some("Thrall".to_string());
|
||||
candidates[1] = Some("Cairne".to_string());
|
||||
candidates[10] = Some("Sylvanas".to_string());
|
||||
s.set_loot(Some(LootState {
|
||||
fishing: false,
|
||||
master_candidates: candidates,
|
||||
rows: vec![Some(LootRow {
|
||||
item_id: 17182,
|
||||
name: Some("Thunderfury".into()),
|
||||
texture: Some("Interface\\Icons\\INV_Sword_39".into()),
|
||||
quantity: 1,
|
||||
quality: Some(2),
|
||||
is_coin: false,
|
||||
link: None,
|
||||
random_property_id: 0,
|
||||
})],
|
||||
}));
|
||||
s.fire_event("LOOT_OPENED", vec![]);
|
||||
let (x, y) = s
|
||||
.eval::<(f64, f64)>("return LootButton1:GetCenter()")
|
||||
.unwrap();
|
||||
s.mouse_button(x as f32, y as f32, "LeftButton", true);
|
||||
s.mouse_button(x as f32, y as f32, "LeftButton", false);
|
||||
s.resolve();
|
||||
let _ = s.take_loot_picks();
|
||||
s.fire_event("OPEN_MASTER_LOOT_LIST", vec![]);
|
||||
s.resolve();
|
||||
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
||||
|
||||
// A title row, then ONE row per occupied block — not one per candidate, and labelled by the
|
||||
// block's own number rather than by position in the list.
|
||||
assert_eq!(
|
||||
s.eval::<String>("return DropDownList1Button1:GetText()")
|
||||
.unwrap(),
|
||||
"Give Loot To:",
|
||||
"the raid arm opens with the title row the party arm has no such thing for"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return DropDownList1Button2:GetText()")
|
||||
.unwrap(),
|
||||
"Group 1"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return DropDownList1Button3:GetText()")
|
||||
.unwrap(),
|
||||
"Group 3",
|
||||
"the empty group 2 block contributes no row — and group 3 is NOT relabelled 2"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return DropDownList1.numButtons").unwrap(),
|
||||
3,
|
||||
"title + two occupied groups, out of eight blocks"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -569,7 +569,9 @@ fn shipped_merchant_hover_scopes_highlight_and_anchors_item_tooltip() {
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
// The quality→colour table (BENILLA_LOOT_QUALITY_COLORS) ships in LootFrame.xml; at runtime
|
||||
// every FrameXML file loads before any hover fires, so load it here too.
|
||||
// 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");
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ fn pet_unit() -> UnitState {
|
||||
/// for a Boar (id 5, food mask 63) resolved through `ItemPetFood.dbc` (decision 1062).
|
||||
fn hunter_pet_stats() -> PetStats {
|
||||
PetStats {
|
||||
icon: None,
|
||||
hunter_pet: true,
|
||||
happiness: Some(3),
|
||||
damage_percentage: 125.0,
|
||||
|
||||
@@ -920,7 +920,10 @@ fn reward_row_hover_serves_the_shared_item_tooltip() {
|
||||
load_xml(&s, "MoneyFrame.xml");
|
||||
load_xml(&s, "UiPanels.xml");
|
||||
load_xml(&s, "GameTooltip.xml");
|
||||
load_xml(&s, "LootFrame.xml"); // BENILLA_LOOT_QUALITY_COLORS (the quality->color table)
|
||||
// LootFrame.xml is here for BENILLA_LOOT_QUALITY_COLORS (the quality→colour table); 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");
|
||||
load_xml(&s, "QuestLogFrame.xml");
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! The app-side **stable feed** (decision 1676) — `PetStableFrame`'s data, the inward half of the
|
||||
//! stable seam around [`benilla_ui::script`]'s `stable` module, and the twin of
|
||||
//! [`crate::ui_trainer`]'s trainer feed.
|
||||
//!
|
||||
//! The net bridge fills [`StableOpen`] from the wire (`MSG_LIST_STABLED_PETS`, which the server
|
||||
//! sends unprompted when the gossip stable option is chosen — the trainer's and the vendor's
|
||||
//! arrangement exactly). Each frame [`feed_stable`] resolves each wire [`StabledPet`] into a
|
||||
//! Lua-facing [`StablePetSlot`], pushes the snapshot, and fires `PET_STABLE_SHOW` on open /
|
||||
//! `PET_STABLE_UPDATE` on a content change / `PET_STABLE_CLOSED` on clear. [`drain_stable`] pulls
|
||||
//! the intents back out into the four wire verbs.
|
||||
//!
|
||||
//! ## The resolve, and the one round trip it needs
|
||||
//!
|
||||
//! The wire names a `creature_template` **entry** and a loyalty **level**; the window wants an
|
||||
//! icon, a family word, a loyalty name and a diet. Three of those come from tables already loaded
|
||||
//! for the live pet ([`PetFamilyTables`], [`PetStatTables`] — decisions 1062/1005); the fourth,
|
||||
//! the family *id*, is not on the wire at all and must come from the creature query
|
||||
//! ([`NameCache::resolve_creature`], ask-once).
|
||||
//!
|
||||
//! **So a stabled pet's row fills in two steps**, and that is not a defect to paper over: the name
|
||||
//! and level render immediately off the wire, and the icon/family/diet appear when the template
|
||||
//! answer lands a frame or two later. The feed diffs, so the arrival re-pushes and fires
|
||||
//! `PET_STABLE_UPDATE` on its own. The reference has the same two-step (its own template cache
|
||||
//! misses the first time too); what it does not have is our *empty* first step, because
|
||||
//! `creaturecache.wdb` persists across sessions and ours does not yet.
|
||||
//!
|
||||
//! ## Why the current pet's row is read from the wire, not from the live pet
|
||||
//!
|
||||
//! Slot 0 can be occupied while `UnitExists("pet")` is false — a dismissed pet, or one left too far
|
||||
//! away to be summoned, still gets a row from the server's character-pet cache. The reference
|
||||
//! prefers the live unit and falls back to `GetStablePetInfo(0)` (`PetStable.lua:120-146`), so both
|
||||
//! sources must be right; this feed fills the wire half for every row uniformly and lets the Lua
|
||||
//! choose, rather than special-casing slot 0 into a live-unit read that would be blank for exactly
|
||||
//! the hunter who needs the window most.
|
||||
|
||||
use benilla_protocol::messages::StabledPet;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use benilla_ui::script::{StableIntent, StablePetSlot, StableState, UiScript, NUM_STABLE_SLOTS};
|
||||
|
||||
use crate::names::NameCache;
|
||||
use crate::net::{ClientCommand, NetCommands};
|
||||
use crate::ui_pet_stats::{PetFamilyTables, PetStatTables};
|
||||
use crate::ui_script::UiInput;
|
||||
use crate::ui_session::{close_npc_session_out_of_range, npc_switched, NpcSession};
|
||||
|
||||
pub(crate) struct UiStablePlugin;
|
||||
|
||||
impl Plugin for UiStablePlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<StableOpen>().add_systems(
|
||||
Update,
|
||||
(
|
||||
// Range-close before the feed so the clear becomes PET_STABLE_CLOSED the same
|
||||
// frame; push before the input pass so an open/close is on screen the same frame;
|
||||
// drain after it (the trainer/merchant/gossip ordering).
|
||||
close_npc_session_out_of_range::<StableOpen>.before(feed_stable),
|
||||
feed_stable.before(UiInput),
|
||||
drain_stable.after(UiInput),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The open stable, filled by the net bridge ([`crate::net`]) and read by [`feed_stable`]. Holds
|
||||
/// the stable master's guid and the pet rows exactly as the wire delivered them. Cleared on a
|
||||
/// client-side close and on disconnect.
|
||||
#[derive(Resource, Default)]
|
||||
pub(crate) struct StableOpen {
|
||||
/// The stable master whose window is open; `None` = no stable open.
|
||||
pub(crate) npc: Option<u64>,
|
||||
/// Stable slots **purchased** (0..=2) — the wire's `numStableSlots`, not a count of occupied
|
||||
/// ones.
|
||||
pub(crate) num_stable_slots: u8,
|
||||
/// The wire rows, each already carrying its rebased client slot index.
|
||||
pub(crate) pets: Vec<StabledPet>,
|
||||
}
|
||||
|
||||
impl StableOpen {
|
||||
/// Open (or replace) the window with a stable master's freshly-listed pets.
|
||||
pub(crate) fn open(&mut self, npc: u64, num_stable_slots: u8, pets: Vec<StabledPet>) {
|
||||
self.npc = Some(npc);
|
||||
self.num_stable_slots = num_stable_slots;
|
||||
self.pets = pets;
|
||||
}
|
||||
|
||||
pub(crate) fn clear(&mut self) {
|
||||
self.npc = None;
|
||||
self.num_stable_slots = 0;
|
||||
self.pets.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// The stable window is an NPC session: the standardized range guard ([`crate::ui_session`])
|
||||
/// client-side-closes it when the player walks out of the stable master's service range or the NPC
|
||||
/// despawns. This matters more here than for most windows — the server re-checks the interaction on
|
||||
/// **every** stable verb (`CheckStableMaster`), so a window left open at a distance would fail each
|
||||
/// button with an indistinguishable generic error instead of simply not being there.
|
||||
impl NpcSession for StableOpen {
|
||||
fn npc(&self) -> Option<u64> {
|
||||
self.npc
|
||||
}
|
||||
|
||||
fn close(&mut self) {
|
||||
self.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve one wire row into the Lua-facing slot. The template answer drives icon/family/diet and
|
||||
/// arrives asynchronously (module doc), so each of those is independently `None`-able rather than
|
||||
/// gating the whole row: a pet whose query is in flight still shows its name and level.
|
||||
fn resolve_pet(
|
||||
wire: &StabledPet,
|
||||
names: &mut NameCache,
|
||||
families: Option<&PetFamilyTables>,
|
||||
stats: Option<&PetStatTables>,
|
||||
commands: &NetCommands,
|
||||
) -> StablePetSlot {
|
||||
// Ask-once for the template; `guid: 0` is the template-only convention (this pet has no spawn
|
||||
// to name — it is asleep in a stable, which is the whole point).
|
||||
let _ = names.resolve_creature(wire.creature_entry, 0, commands);
|
||||
let family_id = names
|
||||
.creature_record(wire.creature_entry)
|
||||
.map(|r| r.pet_family)
|
||||
.unwrap_or(0);
|
||||
let family_row = families.and_then(|t| t.families.get(family_id));
|
||||
StablePetSlot {
|
||||
pet_number: wire.pet_number,
|
||||
// The family's own `CreatureFamily.dbc` icon column — a pet has no item behind it, so this
|
||||
// is the only icon there is. `None` until the query lands; the reference's
|
||||
// `SetItemButtonTexture` renders the empty-slot art for it, which is right (decision 1046:
|
||||
// a path resolving to nothing would draw WHITE).
|
||||
icon: families
|
||||
.and_then(|t| t.families.icon(family_id))
|
||||
.map(str::to_string),
|
||||
// The pet's given name, straight off the wire — never the template's.
|
||||
name: wire.name.clone(),
|
||||
level: wire.level,
|
||||
family: family_row.map(|f| f.name.clone()),
|
||||
// The wire carries a loyalty LEVEL; the window shows the `PetLoyalty.dbc` name for it, the
|
||||
// same table `GetPetLoyalty` reads for the live pet (decision 1005).
|
||||
loyalty: stats
|
||||
.and_then(|t| t.loyalty.name(wire.loyalty))
|
||||
.map(str::to_string),
|
||||
// The family row's food mask expanded to localized diet names — `GetPetFoodTypes`'s law
|
||||
// (decision 1062). Legitimately empty for a zero mask; the reference guards on that.
|
||||
diet: family_row
|
||||
.map(|f| {
|
||||
families
|
||||
.map(|t| {
|
||||
t.foods
|
||||
.for_mask(f.pet_food_mask)
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the Lua-facing snapshot from [`StableOpen`] — `None` when no stable is open.
|
||||
fn snapshot(
|
||||
open: &StableOpen,
|
||||
names: &mut NameCache,
|
||||
families: Option<&PetFamilyTables>,
|
||||
stats: Option<&PetStatTables>,
|
||||
next_slot_cost: u32,
|
||||
commands: &NetCommands,
|
||||
) -> Option<StableState> {
|
||||
open.npc?;
|
||||
let mut slots: [Option<StablePetSlot>; NUM_STABLE_SLOTS] = Default::default();
|
||||
for wire in &open.pets {
|
||||
// Seat each row by its OWN slot, never by position: the current-pet row is absent for a
|
||||
// petless hunter, so `pets[0]` is not slot 0 (decision 1676's decode note). A slot byte
|
||||
// past the window's three is dropped rather than panicking — the server never sends one,
|
||||
// and a malformed list must not take the window down.
|
||||
match slots.get_mut(usize::from(wire.slot)) {
|
||||
Some(seat) => *seat = Some(resolve_pet(wire, names, families, stats, commands)),
|
||||
None => debug!(
|
||||
"ui_stable: pet {} arrived in slot {} — past the {NUM_STABLE_SLOTS} the window has, dropped",
|
||||
wire.pet_number, wire.slot
|
||||
),
|
||||
}
|
||||
}
|
||||
Some(StableState {
|
||||
num_stable_slots: u32::from(open.num_stable_slots),
|
||||
next_slot_cost,
|
||||
slots,
|
||||
})
|
||||
}
|
||||
|
||||
/// Push the current stable into the VM and fire the show/update/close events on a transition (or a
|
||||
/// content change). Diffed against a `Local` memory, exactly like the trainer/merchant feeds.
|
||||
#[allow(clippy::too_many_arguments)] // the resolver's full catalog set
|
||||
fn feed_stable(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
open: Res<StableOpen>,
|
||||
families: Option<Res<PetFamilyTables>>,
|
||||
stats: Option<Res<PetStatTables>>,
|
||||
prices: Option<Res<StableSlotPrices>>,
|
||||
commands: Res<NetCommands>,
|
||||
mut names: ResMut<NameCache>,
|
||||
mut last: Local<crate::ui_script::VmMemo<Option<StableState>>>,
|
||||
mut last_npc: Local<crate::ui_script::VmMemo<Option<u64>>>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
let last = last.get(&script);
|
||||
let last_npc = last_npc.get(&script);
|
||||
|
||||
// The next slot's price is the client's own job (the wire never sends it) — `StableSlotPrices`
|
||||
// row `purchased + 1`, the bank's arrangement. `0` past the table, a state in which the
|
||||
// reference has already hidden the row that would show it.
|
||||
let next_slot_cost = prices
|
||||
.as_deref()
|
||||
.and_then(|p| p.0.next_slot_price(open.num_stable_slots))
|
||||
.unwrap_or(0);
|
||||
|
||||
let fresh = snapshot(
|
||||
&open,
|
||||
&mut names,
|
||||
families.as_deref(),
|
||||
stats.as_deref(),
|
||||
next_slot_cost,
|
||||
&commands,
|
||||
);
|
||||
let switched = npc_switched(*last_npc, open.npc);
|
||||
if fresh == *last && !switched {
|
||||
return;
|
||||
}
|
||||
// PUSH before firing: `fire_event` dispatches the Lua handlers synchronously, so the snapshot
|
||||
// must already be in the VM when `PetStable_Update` reads it back (decision 1073's rule).
|
||||
script.set_stable(fresh.clone());
|
||||
if switched {
|
||||
// A different stable master while the window is open is a real close+open — the reference's
|
||||
// ShowUIPanel early-returns when visible, so the open sound only re-plays after a hide
|
||||
// (decision 0096). Consume the close intent the CLOSED→OnHide→ClosePetStables round queues,
|
||||
// so the drain does not clear the stable we just re-opened to.
|
||||
script.fire_event("PET_STABLE_CLOSED", vec![]);
|
||||
script.fire_event("PET_STABLE_SHOW", vec![]);
|
||||
let _ = script.take_stable_close();
|
||||
} else {
|
||||
match (&*last, &fresh) {
|
||||
(None, Some(_)) => script.fire_event("PET_STABLE_SHOW", vec![]),
|
||||
// The paperdoll event rides the content change: the reference repaints the model pane
|
||||
// from it, and a stabled pet's icon/family arriving late is exactly when it must.
|
||||
(Some(_), Some(_)) => {
|
||||
script.fire_event("PET_STABLE_UPDATE", vec![]);
|
||||
script.fire_event("PET_STABLE_UPDATE_PAPERDOLL", vec![]);
|
||||
}
|
||||
(Some(_), None) => script.fire_event("PET_STABLE_CLOSED", vec![]),
|
||||
(None, None) => {}
|
||||
}
|
||||
}
|
||||
*last = fresh;
|
||||
*last_npc = open.npc;
|
||||
}
|
||||
|
||||
/// Drain the Lua intents into the four wire verbs, and the close into a local clear (no packet
|
||||
/// exists — vmangos has no close opcode).
|
||||
///
|
||||
/// Every verb is addressed to the **open session's** NPC: the guid is the app's, never Lua's, so an
|
||||
/// addon cannot aim a stable verb at an arbitrary unit.
|
||||
fn drain_stable(
|
||||
script: Option<NonSendMut<UiScript>>,
|
||||
mut open: ResMut<StableOpen>,
|
||||
commands: Res<NetCommands>,
|
||||
) {
|
||||
let Some(mut script) = script else {
|
||||
return;
|
||||
};
|
||||
for intent in script.take_stable_intents() {
|
||||
let Some(npc) = open.npc else {
|
||||
debug!("ui_stable: {intent:?} with no open stable — ignored");
|
||||
continue;
|
||||
};
|
||||
debug!("ui_stable: {intent:?} (stable master {npc:#x})");
|
||||
let command = match intent {
|
||||
StableIntent::Stable => ClientCommand::StablePet { npc },
|
||||
StableIntent::Unstable(pet_number) => ClientCommand::UnstablePet { npc, pet_number },
|
||||
StableIntent::Swap(pet_number) => ClientCommand::StableSwapPet { npc, pet_number },
|
||||
StableIntent::BuySlot => ClientCommand::BuyStableSlot { npc },
|
||||
};
|
||||
let _ = commands.0.send(command);
|
||||
}
|
||||
if script.take_stable_close() {
|
||||
debug!("ui_stable: client-side close (no packet)");
|
||||
open.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// `StableSlotPrices.dbc`, loaded once at startup — the purchase row's price oracle. A missing
|
||||
/// table quotes `0`, which is the same shape as "past the ladder": degraded, never wrong.
|
||||
#[derive(Resource)]
|
||||
pub(crate) struct StableSlotPrices(pub(crate) benilla_formats::StableSlotPrices);
|
||||
@@ -1808,6 +1808,7 @@ mod tests {
|
||||
type_flags: 0,
|
||||
civilian: false,
|
||||
racial_leader: false,
|
||||
display_id: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -109,6 +109,17 @@ const MAX_FOOD_BITS: u32 = 8;
|
||||
pub struct CreatureFamily {
|
||||
/// The localized family word — "Wolf", "Imp", "Wind Serpent". What `UnitCreatureFamily` pushes.
|
||||
pub name: String,
|
||||
/// The family's icon path — `Interface\\Icons\\Ability_Hunter_Pet_<Family>` for every one of
|
||||
/// the 22 real families (verified by dumping the shipped table this session; the column is
|
||||
/// field 17, `0x44`). This is a pet's icon everywhere one is shown without an item behind it:
|
||||
/// the stable window's slot buttons and `GetPetIcon` (decision 1676). The schema already
|
||||
/// declared the column so the parser aligned past it; only the read into this struct is new.
|
||||
///
|
||||
/// Empty for a row that ships no icon — the same "render the empty-slot art" case as a pet
|
||||
/// whose creature query has not landed, which is why consumers treat it as absent rather than
|
||||
/// passing an empty path to a texture setter (decision 1046: a path resolving to nothing draws
|
||||
/// WHITE).
|
||||
pub icon: String,
|
||||
/// The pet-food bitfield (field 7). **`0` for every warlock minion** — Imp, Voidwalker,
|
||||
/// Succubus, Felhunter, Doomguard all ship a zero mask, and so does row 28 ("Remote Control") —
|
||||
/// which is why a diet list can be legitimately empty without anything having gone wrong.
|
||||
@@ -170,6 +181,15 @@ impl CreatureFamilies {
|
||||
}
|
||||
|
||||
/// The localized family word for an id — [`Self::get`]'s name half.
|
||||
/// The family's icon path, or `None` for an unknown family or a row that ships none —
|
||||
/// the stable window's slot art and `GetPetIcon`'s answer (decision 1676).
|
||||
pub fn icon(&self, id: u32) -> Option<&str> {
|
||||
self.0
|
||||
.get(&id)
|
||||
.map(|f| f.icon.as_str())
|
||||
.filter(|i| !i.is_empty())
|
||||
}
|
||||
|
||||
pub fn name(&self, id: u32) -> Option<&str> {
|
||||
self.get(id).map(|f| f.name.as_str())
|
||||
}
|
||||
@@ -273,6 +293,7 @@ pub fn load_creature_families(chain: &mut Chain) -> Result<CreatureFamilies> {
|
||||
id,
|
||||
CreatureFamily {
|
||||
name,
|
||||
icon: str_at(&rs, r, FAMILY_ICON_FIELD).unwrap_or_default(),
|
||||
pet_food_mask: u32_at(r, FAMILY_FOOD_MASK_FIELD).unwrap_or(0),
|
||||
min_scale: f32_at(r, FAMILY_MIN_SCALE_FIELD).unwrap_or(1.0),
|
||||
min_scale_level: u32_at(r, FAMILY_MIN_SCALE_LEVEL_FIELD).unwrap_or(0),
|
||||
@@ -316,6 +337,37 @@ mod tests {
|
||||
/// The load-bearing shape is that **all 22 real families ramp 1 → 60**, warlock minions
|
||||
/// included — they are flat only because `min == max`, not because their level columns are
|
||||
/// zero. Row 28 ("Remote Control") is the sole 0/0/0/0 row, and the sole `None`.
|
||||
/// The icon column, off the shipped file (decision 1676). It is the stable window's slot art
|
||||
/// and `GetPetIcon`'s answer, and it sits at field 17 behind a locale block the schema types as
|
||||
/// dwords — so a misread there returns an empty string, and an empty texture path draws WHITE
|
||||
/// (decision 1046). Asserting real paths is what catches that.
|
||||
#[test]
|
||||
fn every_shipped_family_carries_a_pet_icon() {
|
||||
let Some(mut chain) = chain() else { return };
|
||||
let fams = load_creature_families(&mut chain).expect("families");
|
||||
|
||||
assert_eq!(
|
||||
fams.icon(1),
|
||||
Some("Interface\\Icons\\Ability_Hunter_Pet_Wolf")
|
||||
);
|
||||
// A warlock minion has one too — the stable never shows them, but `GetPetIcon` does.
|
||||
assert!(fams
|
||||
.icon(15)
|
||||
.is_some_and(|i| i.starts_with("Interface\\Icons\\")));
|
||||
|
||||
// No real family is missing one, and none is a bare empty string masquerading as a path.
|
||||
for id in 1..=27 {
|
||||
let Some(f) = fams.get(id) else { continue };
|
||||
assert!(
|
||||
fams.icon(id).is_some(),
|
||||
"family {id} ({}) has no icon",
|
||||
f.name
|
||||
);
|
||||
}
|
||||
// An unknown family is absent, not empty.
|
||||
assert_eq!(fams.icon(9999), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_family_size_ramp_reads_off_the_shipped_file() {
|
||||
let Some(mut chain) = chain() else { return };
|
||||
|
||||
@@ -83,6 +83,8 @@ mod exhaustion;
|
||||
pub use exhaustion::{load_exhaustion, ExhaustionRow};
|
||||
mod bank_bag_slot_prices;
|
||||
pub use bank_bag_slot_prices::{load_bank_bag_slot_prices, BankBagSlotPrices};
|
||||
mod stable_slot_prices;
|
||||
pub use stable_slot_prices::{load_stable_slot_prices, StableSlotPrices};
|
||||
mod page_text_material;
|
||||
pub use page_text_material::{load_page_text_material_catalog, PageTextMaterialCatalog};
|
||||
mod stationery;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
//! `StableSlotPrices.dbc` — the hunter stable's purchase-ladder price table (decision 1676).
|
||||
//!
|
||||
//! **2 rows × 2 `u32` columns** (`ID`, price in copper), read out of the real 5875 `dbc.MPQ` this
|
||||
//! session: row 1 = `500` (5 silver), row 2 = `50000` (5 gold). That is the whole table — there is
|
||||
//! no sentinel tail and no third row, which is the [`super::bank_bag_slot_prices`] ladder's one
|
||||
//! structural difference from this one (that table ships 12 rows of which only 6 are reachable).
|
||||
//!
|
||||
//! **Two slots is corroborated three ways**, which is why nothing here hardcodes the cap: the DBC
|
||||
//! has two rows, vmangos's `MAX_PET_STABLES` is 2 (`src/game/Objects/Pet.h:37`), and the reference
|
||||
//! UI's `NUM_PET_STABLE_SLOTS` is 2 (`PetStable.lua:1`). The client's job is only to price the
|
||||
//! *next* slot; `CMSG_BUY_STABLE_SLOT` carries no index at all — the server buys
|
||||
//! `m_stableSlots + 1` itself (`NPCHandler.cpp:704-729`) — so a row id here IS a slot number.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use benilla_dbc::{FieldType, Schema, SchemaField};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::dbc::{parse, u32_at};
|
||||
use crate::Chain;
|
||||
|
||||
const STABLE_SLOT_PRICES: &str = "DBFilesClient\\StableSlotPrices.dbc";
|
||||
|
||||
/// Row id (1-based slot number) → price in copper.
|
||||
pub struct StableSlotPrices(HashMap<u32, u32>);
|
||||
|
||||
impl StableSlotPrices {
|
||||
/// The cost of the *next* stable slot, given `purchased_count` already bought (slot
|
||||
/// `purchased_count + 1`, 1-based to match the DBC's row ids). `None` past the table — which is
|
||||
/// the "both slots owned" state the reference reads by comparing `GetNumStableSlots()` against
|
||||
/// `NUM_PET_STABLE_SLOTS` and hiding the purchase button outright (`PetStable.lua:196-199`).
|
||||
/// The absence is a data fact, not a cap this reader knows.
|
||||
pub fn next_slot_price(&self, purchased_count: u8) -> Option<u32> {
|
||||
self.0.get(&(u32::from(purchased_count) + 1)).copied()
|
||||
}
|
||||
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len()
|
||||
}
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> Schema {
|
||||
let mut s = Schema::new("StableSlotPrices");
|
||||
s.add_field(SchemaField::new("ID", FieldType::UInt32));
|
||||
s.add_field(SchemaField::new("Price", FieldType::UInt32));
|
||||
s.set_key_field("ID");
|
||||
s
|
||||
}
|
||||
|
||||
/// Load `StableSlotPrices.dbc` from the patch chain.
|
||||
pub fn load_stable_slot_prices(chain: &mut Chain) -> Result<StableSlotPrices> {
|
||||
let bytes = chain
|
||||
.read_file(STABLE_SLOT_PRICES)
|
||||
.with_context(|| format!("reading {STABLE_SLOT_PRICES}"))?;
|
||||
table_from(&bytes)
|
||||
}
|
||||
|
||||
/// The parse itself, split from the chain read so the golden test drives the identical path.
|
||||
fn table_from(bytes: &[u8]) -> Result<StableSlotPrices> {
|
||||
let rs = parse(bytes, schema(), "StableSlotPrices")?;
|
||||
let mut prices = HashMap::with_capacity(rs.records().len());
|
||||
for r in rs.records() {
|
||||
if let (Some(id), Some(price)) = (u32_at(r, 0), u32_at(r, 1)) {
|
||||
prices.insert(id, price);
|
||||
}
|
||||
}
|
||||
Ok(StableSlotPrices(prices))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A hand-built WDBC carrying the real 5875 table, byte for byte as `dbc.MPQ` ships it
|
||||
/// (extracted and dumped this session: 37 bytes total — a 20-byte header, two 8-byte records,
|
||||
/// and a 1-byte string block holding the empty string).
|
||||
fn synthesize() -> Vec<u8> {
|
||||
let rows: &[[u32; 2]] = &[[1, 500], [2, 50_000]];
|
||||
let mut dbc = Vec::new();
|
||||
dbc.extend_from_slice(b"WDBC");
|
||||
dbc.extend_from_slice(&(rows.len() as u32).to_le_bytes());
|
||||
dbc.extend_from_slice(&2u32.to_le_bytes()); // field count
|
||||
dbc.extend_from_slice(&8u32.to_le_bytes()); // record size
|
||||
dbc.extend_from_slice(&1u32.to_le_bytes()); // string block size
|
||||
for r in rows {
|
||||
for v in r {
|
||||
dbc.extend_from_slice(&v.to_le_bytes());
|
||||
}
|
||||
}
|
||||
dbc.push(0); // the string block's lone terminator
|
||||
dbc
|
||||
}
|
||||
|
||||
/// The shipped ladder, as the window prices it: a hunter who owns nothing is quoted 5 silver,
|
||||
/// one who owns a slot is quoted 5 gold, and one who owns both is quoted nothing at all.
|
||||
#[test]
|
||||
fn the_shipped_ladder_prices_each_next_slot() {
|
||||
let t = table_from(&synthesize()).expect("parse");
|
||||
assert_eq!(t.len(), 2, "5875 ships exactly two stable slots");
|
||||
assert_eq!(t.next_slot_price(0), Some(500));
|
||||
assert_eq!(t.next_slot_price(1), Some(50_000));
|
||||
// Past the table: the purchase button is hidden here, and a `None` is what says so.
|
||||
assert_eq!(t.next_slot_price(2), None);
|
||||
}
|
||||
|
||||
/// The real file, when there is an install to read it from. The synthesized golden above is
|
||||
/// what the logic is tested against; this is the tripwire that the golden still *matches the
|
||||
/// shipped bytes* — a silent divergence would otherwise price the window wrong forever.
|
||||
#[test]
|
||||
fn the_golden_matches_the_installed_table() {
|
||||
let data = crate::wow_data_or_skip!();
|
||||
let mut chain = Chain::open(&data).expect("open patch chain");
|
||||
let real = load_stable_slot_prices(&mut chain).expect("load real StableSlotPrices.dbc");
|
||||
let golden = table_from(&synthesize()).expect("parse golden");
|
||||
assert_eq!(real.len(), golden.len());
|
||||
for purchased in 0..3u8 {
|
||||
assert_eq!(
|
||||
real.next_slot_price(purchased),
|
||||
golden.next_slot_price(purchased),
|
||||
"slot {} price drifted from the golden",
|
||||
purchased + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -179,6 +179,19 @@ impl Probe for Loot {
|
||||
}
|
||||
response = Some((loot_type, gold, items));
|
||||
}
|
||||
// The master-loot candidate list rides the window OPEN, ahead of the
|
||||
// response it belongs to, and it is the one part of the master-loot arc
|
||||
// that cannot be staged from a single probe account (decision 1675). It
|
||||
// is printed unconditionally — it carries no loot guid to match on.
|
||||
SessionEvent::LootMasterList { candidates } => {
|
||||
println!(
|
||||
"SMSG_LOOT_MASTER_LIST: {} eligible looter(s)",
|
||||
candidates.len()
|
||||
);
|
||||
for (i, guid) in candidates.iter().enumerate() {
|
||||
println!(" candidate {:>2} guid {guid:#x}", i + 1);
|
||||
}
|
||||
}
|
||||
SessionEvent::LootError { guid: g, error } if g == target => {
|
||||
println!("SMSG_LOOT_RESPONSE (error): guid {g:#x} error {error}");
|
||||
loot_refusal = Some(error);
|
||||
|
||||
@@ -19,8 +19,8 @@ use crate::messages::{
|
||||
MirrorTimerStart, MonsterMoveFacing, ObjectFields, PartyMemberStatsInfo, PeriodicAuraLog,
|
||||
PetMode, PetSpells, PvpCredit, QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward,
|
||||
QuestRequestItems, QuestTemplate, SpellDamageLog, SpellEnergizeLog, SpellHealLog, SpellLogMiss,
|
||||
TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem,
|
||||
WhoResults, XpGain,
|
||||
StabledPet, TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose,
|
||||
VendorItem, WhoResults, XpGain,
|
||||
};
|
||||
|
||||
/// Coarse entity classification, free of wire types so the app can branch on it without depending on
|
||||
@@ -436,6 +436,10 @@ pub enum SessionEvent {
|
||||
rank: u32,
|
||||
/// The template type flags — bit `0x10` hides the tooltip's faction-name line. `0` on a miss.
|
||||
type_flags: u32,
|
||||
/// The template's model (`CreatureDisplayInfo.dbc` id) — the only way to draw a creature
|
||||
/// with no world object to read `UNIT_FIELD_DISPLAYID` off, which is exactly a stabled pet
|
||||
/// (decision 1676). `0` when the template ships none, and `0` on a miss.
|
||||
display_id: u32,
|
||||
/// The civilian flag (the tooltip's green CIVILIAN line). `false` on a miss.
|
||||
civilian: bool,
|
||||
/// The racial-leader flag (the tooltip's white LEADER line). `false` on a miss.
|
||||
@@ -829,6 +833,22 @@ pub enum SessionEvent {
|
||||
services: Vec<TrainerSpell>,
|
||||
greeting: String,
|
||||
},
|
||||
/// A stable master's pet list (`MSG_LIST_STABLED_PETS`, decision 1676) — the current pet and
|
||||
/// the stabled ones, each already carrying its rebased client slot (`0` = current), plus how
|
||||
/// many stable slots the player has **bought**. Arrives unprompted when the gossip stable
|
||||
/// option is chosen (that is how the window opens) and again in answer to our own refresh send.
|
||||
///
|
||||
/// Read the rows **by slot, never by position**: the current-pet row is absent for a petless
|
||||
/// hunter, so `pets[0]` is not necessarily slot 0.
|
||||
ListStabledPets {
|
||||
npc: u64,
|
||||
num_stable_slots: u8,
|
||||
pets: Vec<StabledPet>,
|
||||
},
|
||||
/// The answer to every stable verb (`SMSG_STABLE_RESULT`, decision 1676) — one
|
||||
/// [`crate::messages::stable_result`] code and nothing else. No success carries an updated
|
||||
/// list, so repainting the window takes a fresh `MSG_LIST_STABLED_PETS` send.
|
||||
StableResult { result: u8 },
|
||||
/// A trainer taught a service (`SMSG_TRAINER_BUY_SUCCEEDED`, answering `CMSG_TRAINER_BUY_SPELL`):
|
||||
/// confirmation only — the spell itself arrives via `SMSG_LEARNED_SPELL` (already in the book).
|
||||
/// The app re-requests `CMSG_TRAINER_LIST` on this to repaint the bought row green→gray (decision
|
||||
@@ -876,7 +896,8 @@ pub enum SessionEvent {
|
||||
/// A loot window opened (`SMSG_LOOT_RESPONSE`'s normal shape), answering our `CMSG_LOOT`:
|
||||
/// `loot_type` is a `loot::loot_type` code, `items` the row list (quest rows ride the same
|
||||
/// list, `slot = items.len() + i`). A row still under a group roll arrives with
|
||||
/// `slot_type == ROLL_ONGOING` (decision 0591); master loot stays out of scope.
|
||||
/// `slot_type == ROLL_ONGOING` (decision 0591); under master loot every row vmangos shows a
|
||||
/// group member arrives `slot_type == MASTER` (decision 1675).
|
||||
LootResponse {
|
||||
guid: u64,
|
||||
loot_type: u8,
|
||||
@@ -906,6 +927,10 @@ pub enum SessionEvent {
|
||||
/// Everyone passed (`SMSG_LOOT_ALL_PASSED`) — closes that roll's frame; the item returns to
|
||||
/// the corpse for ordinary looting.
|
||||
LootAllPassed(LootAllPassed),
|
||||
/// The group members eligible to receive an item from the loot window that is opening
|
||||
/// (`SMSG_LOOT_MASTER_LIST`) — it arrives *before* the `LootResponse` it belongs to, because
|
||||
/// the server sends it from inside `SendLoot` (decision 1675).
|
||||
LootMasterList { candidates: Vec<u64> },
|
||||
/// An item landed in our bags — looted or received from an NPC (`SMSG_ITEM_PUSH_RESULT`);
|
||||
/// drives the "You receive loot: …" chat line.
|
||||
ItemPushResult(ItemPushResult),
|
||||
|
||||
@@ -364,6 +364,16 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
services,
|
||||
greeting: title,
|
||||
}],
|
||||
ServerPacket::ListStabledPets {
|
||||
npc,
|
||||
num_stable_slots,
|
||||
pets,
|
||||
} => vec![SessionEvent::ListStabledPets {
|
||||
npc,
|
||||
num_stable_slots,
|
||||
pets,
|
||||
}],
|
||||
ServerPacket::StableResult { result } => vec![SessionEvent::StableResult { result }],
|
||||
ServerPacket::TrainerBuySucceeded { trainer, spell_id } => {
|
||||
vec![SessionEvent::TrainerBuySucceeded { trainer, spell_id }]
|
||||
}
|
||||
@@ -433,6 +443,9 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
ServerPacket::LootRoll(p) => vec![SessionEvent::LootRoll(p)],
|
||||
ServerPacket::LootRollWon(p) => vec![SessionEvent::LootRollWon(p)],
|
||||
ServerPacket::LootAllPassed(p) => vec![SessionEvent::LootAllPassed(p)],
|
||||
ServerPacket::LootMasterList { candidates } => {
|
||||
vec![SessionEvent::LootMasterList { candidates }]
|
||||
}
|
||||
ServerPacket::ItemPushResult(p) => vec![SessionEvent::ItemPushResult(p)],
|
||||
ServerPacket::CorpseQuery(loc) => vec![SessionEvent::CorpseQuery {
|
||||
found: loc.found,
|
||||
@@ -700,6 +713,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
pet_family,
|
||||
rank,
|
||||
type_flags,
|
||||
display_id,
|
||||
civilian,
|
||||
racial_leader,
|
||||
) = match info {
|
||||
@@ -718,10 +732,11 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
i.pet_family,
|
||||
i.rank,
|
||||
i.type_flags,
|
||||
i.display_id,
|
||||
i.civilian,
|
||||
i.racial_leader,
|
||||
),
|
||||
None => (None, None, None, 0, 0, 0, false, false),
|
||||
None => (None, None, None, 0, 0, 0, 0, false, false),
|
||||
};
|
||||
vec![SessionEvent::CreatureName {
|
||||
entry,
|
||||
@@ -731,6 +746,7 @@ pub fn decode(packet: ServerPacket) -> Vec<SessionEvent> {
|
||||
pet_family,
|
||||
rank,
|
||||
type_flags,
|
||||
display_id,
|
||||
civilian,
|
||||
racial_leader,
|
||||
}]
|
||||
|
||||
@@ -4,8 +4,9 @@
|
||||
//! (`operator<<(LootItem)`/`operator<<(LootView)`) and `Player.cpp` (`SendLoot`/`SendLootError`).
|
||||
//! The group-roll family (opcodes 670-674 — `SMSG_LOOT_START_ROLL`/`SMSG_LOOT_ROLL`/
|
||||
//! `SMSG_LOOT_ROLL_WON`/`SMSG_LOOT_ALL_PASSED` + our `CMSG_LOOT_ROLL`) rides here too, from
|
||||
//! vmangos `Group/Group.cpp`'s four senders (decision 0591). Master loot (`CMSG_LOOT_MASTER_GIVE`
|
||||
//! 675 / `SMSG_LOOT_MASTER_LIST` 676) remains out of scope.
|
||||
//! vmangos `Group/Group.cpp`'s four senders (decision 0591). Master loot
|
||||
//! (`CMSG_LOOT_MASTER_GIVE` 675 / `SMSG_LOOT_MASTER_LIST` 676) lands here too, from
|
||||
//! `Group::MasterLoot` + `WorldSession::HandleLootMasterGiveOpcode` (decision 1675).
|
||||
|
||||
use std::io;
|
||||
|
||||
@@ -28,7 +29,8 @@ pub struct LootItem {
|
||||
pub display_info_id: u32,
|
||||
pub random_property_id: u32,
|
||||
/// A [`slot_type`] code. Solo paths (`ALL_PERMISSION`/`OWNER_PERMISSION`) always write
|
||||
/// `ALLOW_LOOT` (`LootMgr.cpp:918-926`); the others are group-loot only, out of scope for v1.
|
||||
/// `ALLOW_LOOT` (`LootMgr.cpp:918-926`); `MASTER` rides the master-loot path (decision 1675),
|
||||
/// `ROLL_ONGOING` a group roll in flight, `LOCKED` a requirement the viewer fails.
|
||||
pub slot_type: u8,
|
||||
}
|
||||
|
||||
@@ -39,7 +41,16 @@ pub mod slot_type {
|
||||
pub const ALLOW_LOOT: u8 = 0;
|
||||
/// A group roll is ongoing on this item — shown, not yet lootable.
|
||||
pub const ROLL_ONGOING: u8 = 1;
|
||||
/// Only the group's loot master can distribute this item.
|
||||
/// Only the group's loot master can distribute this item — the row is not takeable by a
|
||||
/// `CMSG_AUTOSTORE_LOOT_ITEM` from anyone, and the master looter assigns it with
|
||||
/// [`loot_master_give`] instead (decision 1675).
|
||||
///
|
||||
/// **vmangos stamps this on *every* row it shows a group member under master loot**
|
||||
/// (`LootMgr.cpp:917-941`, the `MASTER_PERMISSION` arm, which never consults
|
||||
/// `is_underthreshold`) — even greys, which the server's own take handler would have allowed
|
||||
/// (`LootHandler.cpp:171-182`). `LootItem::GetSlotTypeForSharedLoot` (`LootMgr.cpp:443-459`)
|
||||
/// encodes the threshold-aware answer but is not on this path. So under vmangos the whole
|
||||
/// window reads as master-only; we render the wire (decision 0086's rule).
|
||||
pub const MASTER: u8 = 2;
|
||||
/// Shown red — not lootable (a missing-requirement item in a group).
|
||||
pub const LOCKED: u8 = 3;
|
||||
@@ -68,9 +79,10 @@ pub mod loot_type {
|
||||
/// creature/player/corpse, or the creature check fails — `LootHandler.cpp:342-346`,
|
||||
/// `Player.cpp:7938-7943`), `PLAYER_NOT_FOUND` (dead/not-in-world — `LootHandler.cpp:349-353`),
|
||||
/// `PLAY_TIME_EXCEEDED`, `NOTSTANDING`, `STUNNED` (`LootHandler.cpp:358-373`), and `TOO_FAR`
|
||||
/// (range check in `Player.cpp:7938-7947`). The rest (`BAD_FACING`/`LOCKED`/the `MASTER_*`
|
||||
/// trio/`ALREADY_PICKPOCKETED`/`NOT_WHILE_SHAPESHIFTED`) ride other loot sources or group/master
|
||||
/// loot — out of scope for v1, pinned for completeness.
|
||||
/// (range check in `Player.cpp:7938-7947`). The `MASTER_*` trio answers a refused
|
||||
/// [`loot_master_give`] and reaches only the master looter (`LootHandler.cpp:618-750`, decision
|
||||
/// 1675); `BAD_FACING`/`LOCKED`/`ALREADY_PICKPOCKETED`/`NOT_WHILE_SHAPESHIFTED` ride other loot
|
||||
/// sources, pinned for completeness.
|
||||
pub mod loot_error {
|
||||
pub const DIDNT_KILL: u8 = 0;
|
||||
pub const TOO_FAR: u8 = 4;
|
||||
@@ -158,6 +170,22 @@ pub fn loot_roll(looted_target: u64, item_slot: u32, roll_type: u8) -> Vec<u8> {
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_LOOT_MASTER_GIVE` (VERIFIED vmangos `Server/Packets/Loot.{h:50-59,cpp:25-30}`,
|
||||
/// `LootMasterGive::ReadFromWorldPacket`): `u64 lootGuid, u8 slotId, u64 playerGuid` — the open
|
||||
/// loot source, the **wire** loot slot (see [`LootItem::slot`], not the display row), and the
|
||||
/// group member to hand the item to.
|
||||
///
|
||||
/// Sent only by the master looter; the server re-checks all three (`LootHandler.cpp:618-750`) and
|
||||
/// answers a refusal with a [`loot_error`] `MASTER_*` code on `SMSG_LOOT_RESPONSE`. Note the slot
|
||||
/// width: this one is a `u8` where the roll family's `item_slot` is a `u32`, so the two are not
|
||||
/// interchangeable even though they index the same array.
|
||||
pub fn loot_master_give(loot_guid: u64, slot: u8, player_guid: u64) -> Vec<u8> {
|
||||
let mut body = loot_guid.to_le_bytes().to_vec();
|
||||
body.push(slot);
|
||||
body.extend_from_slice(&player_guid.to_le_bytes());
|
||||
body
|
||||
}
|
||||
|
||||
/// `SMSG_LOOT_START_ROLL` (VERIFIED vmangos `Server/Packets/Loot.{h:78-90,cpp:43-51}`) — a group
|
||||
/// roll opened on one drop; sent to every eligible roller. `countdown_ms` is the server's
|
||||
/// `LOOT_ROLL_TIMEOUT`, a flat `1*MINUTE*IN_MILLISECONDS` = `60_000` at 5875
|
||||
@@ -338,6 +366,24 @@ pub(super) fn read_loot_all_passed(r: &mut &[u8]) -> io::Result<LootAllPassed> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Read `SMSG_LOOT_MASTER_LIST` (VERIFIED vmangos `Server/Packets/Group.{h:288-296,cpp:182-187}`,
|
||||
/// `LootMasterList::AppendBodyTo`): `u8 count`, then `count` full guids — the group members
|
||||
/// eligible to be handed an item from the loot window that is opening.
|
||||
///
|
||||
/// It rides the **open**, not a click: `Player::SendLoot` calls `Group::MasterLoot`
|
||||
/// (`Player.cpp:8077-8081`) before it writes `SMSG_LOOT_RESPONSE`, so the candidate list is
|
||||
/// already in hand by the time the window paints. vmangos sends it to *every* group member who
|
||||
/// opens the corpse, not only the master looter (the `MASTER_PERMISSION` arm is unconditional),
|
||||
/// and filters the list by loot-XP distance and `IsAllowedLooter` (`Group.cpp:914-940`).
|
||||
pub(super) fn read_loot_master_list(r: &mut &[u8]) -> io::Result<Vec<u64>> {
|
||||
let count = read_u8(r)?;
|
||||
let mut candidates = Vec::with_capacity(count as usize);
|
||||
for _ in 0..count {
|
||||
candidates.push(read_u64_le(r)?);
|
||||
}
|
||||
Ok(candidates)
|
||||
}
|
||||
|
||||
/// Read `SMSG_LOOT_RESPONSE` — both shapes (see [`LootResponseBody`]). Wire order (VERIFIED
|
||||
/// vmangos `Player.cpp:8135-8138`): `u64 guid, u8 lootType`, then either the error tail (`lootType
|
||||
/// == 0`) or `u32 gold, u8 itemCount` + `itemCount` × [`LootItem`] rows (each `u8 slot, u32
|
||||
@@ -496,9 +542,21 @@ mod tests {
|
||||
hx("f0debc9a785634120200000002"),
|
||||
"CMSG_LOOT_ROLL body"
|
||||
);
|
||||
|
||||
// CMSG_LOOT_MASTER_GIVE (Loot.cpp:25-30): u64 lootGuid, u8 slotId, u64 playerGuid. The
|
||||
// slot is a BYTE here — the roll family's itemSlot is a u32 over the same array, so the
|
||||
// two builders are not interchangeable and the byte count proves which one this is.
|
||||
let give = loot_master_give(0x1234_5678_9abc_def0, 2, 0x0fed_cba9_8765_4321);
|
||||
assert_eq!(
|
||||
give,
|
||||
hx("f0debc9a785634120221436587a9cbed0f"),
|
||||
"CMSG_LOOT_MASTER_GIVE body"
|
||||
);
|
||||
assert_eq!(give.len(), 17, "8 + 1 + 8, not 8 + 4 + 8");
|
||||
}
|
||||
|
||||
/// The five group-roll opcode values (VERIFIED vmangos `Opcodes_1_12_1.h:671-675`).
|
||||
/// The five group-roll opcode values (VERIFIED vmangos `Opcodes_1_12_1.h:671-675`) and the
|
||||
/// master-loot pair that follows them (`:676-677`).
|
||||
#[test]
|
||||
fn roll_opcode_values() {
|
||||
assert_eq!(opcode::SMSG_LOOT_ALL_PASSED, 670);
|
||||
@@ -506,6 +564,35 @@ mod tests {
|
||||
assert_eq!(opcode::CMSG_LOOT_ROLL, 672);
|
||||
assert_eq!(opcode::SMSG_LOOT_START_ROLL, 673);
|
||||
assert_eq!(opcode::SMSG_LOOT_ROLL, 674);
|
||||
assert_eq!(opcode::CMSG_LOOT_MASTER_GIVE, 675);
|
||||
assert_eq!(opcode::SMSG_LOOT_MASTER_LIST, 676);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_master_list_decodes() {
|
||||
// Group.cpp:182-187: u8 count, then count full guids.
|
||||
let mut body = vec![3u8];
|
||||
for guid in [0xAAu64, 0xBB, 0xCC] {
|
||||
body.extend_from_slice(&guid.to_le_bytes());
|
||||
}
|
||||
|
||||
match parse_server(opcode::SMSG_LOOT_MASTER_LIST, &body).unwrap() {
|
||||
ServerPacket::LootMasterList { candidates } => {
|
||||
assert_eq!(candidates, vec![0xAA, 0xBB, 0xCC]);
|
||||
}
|
||||
other => panic!("expected LootMasterList, got {}", other.name()),
|
||||
}
|
||||
}
|
||||
|
||||
/// An empty candidate list is a legal body, not a truncation: `Group::MasterLoot`
|
||||
/// (`Group.cpp:925-937`) filters by loot-XP distance and `IsAllowedLooter`, so a group whose
|
||||
/// other members are all out of range sends `count = 0` and nothing else.
|
||||
#[test]
|
||||
fn loot_master_list_accepts_an_empty_list() {
|
||||
match parse_server(opcode::SMSG_LOOT_MASTER_LIST, &[0u8]).unwrap() {
|
||||
ServerPacket::LootMasterList { candidates } => assert!(candidates.is_empty()),
|
||||
other => panic!("expected LootMasterList, got {}", other.name()),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -54,6 +54,7 @@ mod skills;
|
||||
mod social;
|
||||
mod spellbook;
|
||||
mod spells;
|
||||
mod stable;
|
||||
mod taxi;
|
||||
mod trade;
|
||||
mod trainer;
|
||||
@@ -139,9 +140,9 @@ pub use items::{
|
||||
ITEM_FLAG_LOOTABLE, ITEM_FLAG_WRAPPER, SLOT_BAG_FIRST, SLOT_PACK_FIRST,
|
||||
};
|
||||
pub use loot::{
|
||||
autostore_loot_item, loot, loot_error, loot_money, loot_release, loot_roll, loot_type,
|
||||
roll_vote, slot_type, ItemPushResult, LootAllPassed, LootItem, LootResponseBody, LootRoll,
|
||||
LootRollWon, LootStartRoll,
|
||||
autostore_loot_item, loot, loot_error, loot_master_give, loot_money, loot_release, loot_roll,
|
||||
loot_type, roll_vote, slot_type, ItemPushResult, LootAllPassed, LootItem, LootResponseBody,
|
||||
LootRoll, LootRollWon, LootStartRoll,
|
||||
};
|
||||
pub use mail::{
|
||||
get_mail_list, item_text_query, mail_action, mail_create_text_item, mail_delete, mail_error,
|
||||
@@ -200,6 +201,10 @@ pub use spells::{
|
||||
cancel_aura, cast_spell, cast_spell_at_dest, cast_spell_gameobject, cast_spell_item,
|
||||
CastOutcome, SpellCastTargets, SpellChainTargets, SpellGo, SpellStart,
|
||||
};
|
||||
pub use stable::{
|
||||
buy_stable_slot, list_stabled_pets, stable_pet, stable_result, stable_swap_pet, unstable_pet,
|
||||
StabledPet,
|
||||
};
|
||||
pub use taxi::{
|
||||
activate_taxi, activate_taxi_express, taxi_node_status_query, taxi_query_available_nodes,
|
||||
taxi_reply, TaxiMask,
|
||||
|
||||
@@ -739,6 +739,20 @@ pub const SMSG_BUY_BANK_SLOT_RESULT: u16 = 0x01BA; // 442
|
||||
pub const CMSG_AUTOSTORE_BANK_ITEM: u16 = 0x0282; // 642
|
||||
pub const CMSG_AUTOBANK_ITEM: u16 = 0x0283; // 643
|
||||
|
||||
// The pet-stable set (VERIFIED vmangos `Opcodes_1_12_1.h`: 623-629). `MSG_LIST_STABLED_PETS` is one
|
||||
// opcode in BOTH directions: the gossip stable option makes the server send the list unprompted
|
||||
// (that is how the window opens), and the client sends the same number back — one guid — to
|
||||
// refresh. The four mutations are all answered by a single `SMSG_STABLE_RESULT` byte and nothing
|
||||
// else, so a successful one is a cue to re-ask the list. Bodies in [`super::stable`]
|
||||
// (decision 1676). `CMSG_STABLE_REVIVE_PET` (0x0274, 628) is deliberately absent: vmangos's handler
|
||||
// is an empty no-op and whether the 5875 client ever sends it is an open RE question.
|
||||
pub const MSG_LIST_STABLED_PETS: u16 = 0x026F; // 623
|
||||
pub const CMSG_STABLE_PET: u16 = 0x0270; // 624
|
||||
pub const CMSG_UNSTABLE_PET: u16 = 0x0271; // 625
|
||||
pub const CMSG_BUY_STABLE_SLOT: u16 = 0x0272; // 626
|
||||
pub const SMSG_STABLE_RESULT: u16 = 0x0273; // 627
|
||||
pub const CMSG_STABLE_SWAP_PET: u16 = 0x0275; // 629
|
||||
|
||||
/// Spend talent points (VERIFIED vmangos `Opcodes_1_12_1.h`: 593) — body in
|
||||
/// [`super::progression::learn_talent`]; the server answers with the rank spell's learn effects
|
||||
/// (`SMSG_LEARNED_SPELL` etc.) and the refreshed `PLAYER_CHARACTER_POINTS1`. Decision 0304.
|
||||
@@ -804,14 +818,19 @@ pub const SMSG_ITEM_PUSH_RESULT: u16 = 0x0166; // 358
|
||||
|
||||
// The group-loot roll family (VERIFIED vmangos `Opcodes_1_12_1.h:671-675`) — the Need/Greed/Pass
|
||||
// flow the `GroupLootFrame`s drive when the group's loot method is group/need-before-greed and a
|
||||
// drop is at or above the quality threshold (decision 0591). Bodies in [`super::loot`]. Master
|
||||
// loot (`CMSG_LOOT_MASTER_GIVE` 675 / `SMSG_LOOT_MASTER_LIST` 676) stays out of scope.
|
||||
// drop is at or above the quality threshold (decision 0591). Bodies in [`super::loot`].
|
||||
pub const SMSG_LOOT_ALL_PASSED: u16 = 0x029E; // 670
|
||||
pub const SMSG_LOOT_ROLL_WON: u16 = 0x029F; // 671
|
||||
pub const CMSG_LOOT_ROLL: u16 = 0x02A0; // 672
|
||||
pub const SMSG_LOOT_START_ROLL: u16 = 0x02A1; // 673
|
||||
pub const SMSG_LOOT_ROLL: u16 = 0x02A2; // 674
|
||||
|
||||
// Master loot (VERIFIED vmangos `Opcodes_1_12_1.h:676-677`) — the other answer to an
|
||||
// above-threshold drop: no roll, the master looter is handed the eligible-member list at
|
||||
// window-open and assigns each row from a dropdown (decision 1675). Bodies in [`super::loot`].
|
||||
pub const CMSG_LOOT_MASTER_GIVE: u16 = 0x02A3; // 675
|
||||
pub const SMSG_LOOT_MASTER_LIST: u16 = 0x02A4; // 676
|
||||
|
||||
// The death arc (decision 0308) — release/repop, corpse query, reclaim, spirit healer, resurrect
|
||||
// requests (all VERIFIED vmangos `Opcodes_1_12_1.h`: 346-348, 466, 534, 540, 546, 617). Bodies in
|
||||
// [`super::death`]. `CMSG_REPOP_REQUEST` and our `MSG_CORPSE_QUERY` request are EMPTY bodies.
|
||||
|
||||
@@ -15,8 +15,9 @@ use super::{
|
||||
MoveMode, Object, PartyMemberStatsInfo, PeriodicAuraLog, PetMode, PetSpells, PvpCredit,
|
||||
QuestComplete, QuestDetails, QuestGiverList, QuestOfferReward, QuestOption, QuestRequestItems,
|
||||
QuestTemplate, ResurrectRequestBody, SpeedKind, SpellChainTargets, SpellCooldown,
|
||||
SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, TaxiMask,
|
||||
TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem, WhoResults, XpGain,
|
||||
SpellDamageLog, SpellEnergizeLog, SpellGo, SpellHealLog, SpellLogMiss, SpellStart, StabledPet,
|
||||
TaxiMask, TradeStatus, TradeStatusExtended, TrainerSpell, TransportPose, VendorItem,
|
||||
WhoResults, XpGain,
|
||||
};
|
||||
|
||||
/// The **final facing** a `SMSG_MONSTER_MOVE` dictates (its `moveType`): the unit snaps to face this
|
||||
@@ -61,6 +62,14 @@ pub struct CreatureQueryInfo {
|
||||
pub civilian: bool,
|
||||
/// The racial-leader flag (the tooltip's white LEADER line; `0x6125c0` on the record's `+0x31`).
|
||||
pub racial_leader: bool,
|
||||
/// `CreatureDisplayInfo.dbc` id (`creature_template.display_id[0]`, vmangos
|
||||
/// `QueryHandler.cpp:179`); `0` when the template has none. The one field that lets the client
|
||||
/// draw a creature it has **never seen in the world** — a stabled pet, whose wire record names
|
||||
/// only a `creature_template` entry and has no world object to read a display id from
|
||||
/// (decision 1676). vmangos randomizes among a template's four display ids when it *spawns* a
|
||||
/// creature, so this is the template's first entry, not necessarily what a given live unit
|
||||
/// shows: for anything actually on screen, the unit's own `UNIT_FIELD_DISPLAYID` still wins.
|
||||
pub display_id: u32,
|
||||
}
|
||||
|
||||
/// A decoded server packet (only the opcodes benilla handles; everything else is [`Self::Other`]).
|
||||
@@ -796,6 +805,28 @@ pub enum ServerPacket {
|
||||
spell_id: u32,
|
||||
error: u32,
|
||||
},
|
||||
/// `MSG_LIST_STABLED_PETS` — the stable master's pet list (layout in
|
||||
/// [`super::stable::read_list_stabled_pets`], decision 1676). Arrives **unprompted** when the
|
||||
/// gossip stable option is chosen — that is how the window opens — and again in answer to our
|
||||
/// own send of the same opcode, which is the only refresh there is (a successful mutation
|
||||
/// answers with nothing but a [`Self::StableResult`] byte).
|
||||
///
|
||||
/// `num_stable_slots` is how many stable slots the player has *bought* (0..=2), not how many
|
||||
/// are occupied. `pets` carries the current pet **and** the stabled ones, each keyed by its own
|
||||
/// already-rebased [`StabledPet::slot`] (`0` = current) — the current-pet row is absent for a
|
||||
/// petless hunter or a warlock, so the list must be read by slot, never by position.
|
||||
ListStabledPets {
|
||||
npc: u64,
|
||||
num_stable_slots: u8,
|
||||
pets: Vec<StabledPet>,
|
||||
},
|
||||
/// `SMSG_STABLE_RESULT` — the whole answer to a stable/unstable/swap/buy-slot ask (vmangos
|
||||
/// `StableResult::AppendBodyTo`). `result` is a [`super::stable::stable_result`] code; success
|
||||
/// codes carry no updated list, so repainting the window takes a fresh
|
||||
/// `MSG_LIST_STABLED_PETS` send.
|
||||
StableResult {
|
||||
result: u8,
|
||||
},
|
||||
/// `SMSG_LOOT_RESPONSE`, normal shape — a loot window opened, answering `CMSG_LOOT` (layout in
|
||||
/// [`super::loot::read_loot_response`]). `loot_type` is a [`super::loot::loot_type`] code; `items` includes
|
||||
/// any quest-item rows riding the same list.
|
||||
@@ -838,6 +869,12 @@ pub enum ServerPacket {
|
||||
/// `SMSG_LOOT_ALL_PASSED` — everyone passed; the roll closes and the item returns to the
|
||||
/// corpse for ordinary looting (layout in [`LootAllPassed`]).
|
||||
LootAllPassed(LootAllPassed),
|
||||
/// `SMSG_LOOT_MASTER_LIST` — the group members eligible to be handed an item from the loot
|
||||
/// window that is opening (layout in [`super::loot::read_loot_master_list`]). It precedes the
|
||||
/// `SMSG_LOOT_RESPONSE` it belongs to.
|
||||
LootMasterList {
|
||||
candidates: Vec<u64>,
|
||||
},
|
||||
/// `SMSG_ITEM_PUSH_RESULT` — an item landed in our bags (looted or received from an NPC);
|
||||
/// drives the "You receive loot: …" chat line (layout in [`ItemPushResult`]).
|
||||
ItemPushResult(ItemPushResult),
|
||||
@@ -1374,6 +1411,8 @@ impl ServerPacket {
|
||||
ServerPacket::TrainerList { .. } => "SMSG_TRAINER_LIST".into(),
|
||||
ServerPacket::TrainerBuySucceeded { .. } => "SMSG_TRAINER_BUY_SUCCEEDED".into(),
|
||||
ServerPacket::TrainerBuyFailed { .. } => "SMSG_TRAINER_BUY_FAILED".into(),
|
||||
ServerPacket::ListStabledPets { .. } => "MSG_LIST_STABLED_PETS".into(),
|
||||
ServerPacket::StableResult { .. } => "SMSG_STABLE_RESULT".into(),
|
||||
ServerPacket::LootResponse { .. } => "SMSG_LOOT_RESPONSE".into(),
|
||||
ServerPacket::LootError { .. } => "SMSG_LOOT_RESPONSE (error)".into(),
|
||||
ServerPacket::LootReleaseResponse { .. } => "SMSG_LOOT_RELEASE_RESPONSE".into(),
|
||||
@@ -1384,6 +1423,7 @@ impl ServerPacket {
|
||||
ServerPacket::LootRoll(_) => "SMSG_LOOT_ROLL".into(),
|
||||
ServerPacket::LootRollWon(_) => "SMSG_LOOT_ROLL_WON".into(),
|
||||
ServerPacket::LootAllPassed(_) => "SMSG_LOOT_ALL_PASSED".into(),
|
||||
ServerPacket::LootMasterList { .. } => "SMSG_LOOT_MASTER_LIST".into(),
|
||||
ServerPacket::ItemPushResult(_) => "SMSG_ITEM_PUSH_RESULT".into(),
|
||||
ServerPacket::CorpseQuery(_) => "MSG_CORPSE_QUERY".into(),
|
||||
ServerPacket::CorpseReclaimDelay { .. } => "SMSG_CORPSE_RECLAIM_DELAY".into(),
|
||||
|
||||
@@ -13,9 +13,9 @@ use crate::wire::{
|
||||
use super::{
|
||||
action_bar, area_trigger, attack, auction, bank, binder, channel, chat, combat_log, death,
|
||||
duel, gameobject, gossip, group, guild, items, loot, mail, mirror_timer, monster_move,
|
||||
movement, opcode, page_text, pet, progression, pvp, quest, social, spellbook, spells, taxi,
|
||||
trade, trainer, update_object, vendor, world_state, Character, CreatureQueryInfo, MoveMode,
|
||||
ServerPacket, SpeedKind,
|
||||
movement, opcode, page_text, pet, progression, pvp, quest, social, spellbook, spells, stable,
|
||||
taxi, trade, trainer, update_object, vendor, world_state, Character, CreatureQueryInfo,
|
||||
MoveMode, ServerPacket, SpeedKind,
|
||||
};
|
||||
|
||||
/// Read one `SMSG_FORCE_*_SPEED_CHANGE` body — `[packed mover guid][u32 counter][f32 speed]`,
|
||||
@@ -679,6 +679,18 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
error,
|
||||
}
|
||||
}
|
||||
opcode::MSG_LIST_STABLED_PETS => {
|
||||
let (npc, num_stable_slots, pets) = stable::read_list_stabled_pets(&mut r)?;
|
||||
ServerPacket::ListStabledPets {
|
||||
npc,
|
||||
num_stable_slots,
|
||||
pets,
|
||||
}
|
||||
}
|
||||
opcode::SMSG_STABLE_RESULT => {
|
||||
let result = stable::read_stable_result(&mut r)?;
|
||||
ServerPacket::StableResult { result }
|
||||
}
|
||||
opcode::SMSG_LOOT_RESPONSE => {
|
||||
let (guid, body) = loot::read_loot_response(&mut r)?;
|
||||
match body {
|
||||
@@ -714,6 +726,9 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
opcode::SMSG_LOOT_ALL_PASSED => {
|
||||
ServerPacket::LootAllPassed(loot::read_loot_all_passed(&mut r)?)
|
||||
}
|
||||
opcode::SMSG_LOOT_MASTER_LIST => ServerPacket::LootMasterList {
|
||||
candidates: loot::read_loot_master_list(&mut r)?,
|
||||
},
|
||||
opcode::SMSG_ITEM_PUSH_RESULT => {
|
||||
ServerPacket::ItemPushResult(loot::read_item_push_result(&mut r)?)
|
||||
}
|
||||
@@ -817,16 +832,18 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
// (the `CreatureType.dbc` id — the TAB-target filter's input), `pet_family` (the
|
||||
// `CreatureFamily.dbc` id behind `UnitCreatureFamily` and the diet tooltip,
|
||||
// decision 1062), `rank` (the unit tooltip's Elite/Boss word, decision 0276's
|
||||
// level-line law), `type_flags` (bit 0x10 hides its faction line), and the
|
||||
// `civilian`/`racial_leader` pair (its green CIVILIAN / white LEADER lines); the
|
||||
// rest stays alignment-only.
|
||||
// level-line law), `type_flags` (bit 0x10 hides its faction line), the
|
||||
// `civilian`/`racial_leader` pair (its green CIVILIAN / white LEADER lines), and
|
||||
// `display_id` — the template's model, which is the ONLY way to draw a creature
|
||||
// that has no world object to read `UNIT_FIELD_DISPLAYID` off: a stabled pet
|
||||
// (decision 1676). The `unk`/`pet_spell_list_id` pair stays alignment-only.
|
||||
let type_flags = read_u32_le(&mut r)?;
|
||||
let creature_type = read_u32_le(&mut r)?;
|
||||
let pet_family = read_u32_le(&mut r)?;
|
||||
let rank = read_u32_le(&mut r)?;
|
||||
let _unk = read_u32_le(&mut r)?;
|
||||
let _pet_spell_list = read_u32_le(&mut r)?;
|
||||
let _display_id = read_u32_le(&mut r)?;
|
||||
let display_id = read_u32_le(&mut r)?;
|
||||
let civilian = read_u8(&mut r)? != 0;
|
||||
let racial_leader = read_u8(&mut r)? != 0;
|
||||
ServerPacket::CreatureQueryResponse {
|
||||
@@ -838,6 +855,7 @@ pub fn parse_server(opcode: u16, body: &[u8]) -> io::Result<ServerPacket> {
|
||||
pet_family,
|
||||
rank,
|
||||
type_flags,
|
||||
display_id,
|
||||
civilian,
|
||||
racial_leader,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
//! Stable-master messages — the hunter pet stable's wire (opcodes 623-629, vmangos
|
||||
//! `Opcodes_1_12_1.h`, VERIFIED). Bodies from vmangos `NPCHandler.cpp` (`SendStablePet` +
|
||||
//! the five `HandleStable*` handlers) and the hand-serialized `Server/Packets/Npc.{h,cpp}`
|
||||
//! (`ListStabledPets`/`StablePet`/`UnstablePet`/`BuyStableSlot`/`StableSwapPet`/`StableResult`).
|
||||
//!
|
||||
//! **The shape of the system.** A stable master keeps a hunter's pets in **three places**: the one
|
||||
//! *current* pet walking beside the player, plus up to two *stable* slots that must each be bought
|
||||
//! with gold (`Player::m_stableSlots`, `StableSlotPrices.dbc`). The window (`PetStableFrame`) is
|
||||
//! opened by the gossip stable option, which makes the server send `MSG_LIST_STABLED_PETS`
|
||||
//! unprompted — the same opcode the client sends back as the *refresh* verb (it is an `MSG_`, one
|
||||
//! number in both directions). Every mutation is a single-verb ask answered by one
|
||||
//! `SMSG_STABLE_RESULT` byte and **nothing else**: the list is not resent, so a client that acted
|
||||
//! successfully re-asks with [`list_stabled_pets`] to repaint.
|
||||
//!
|
||||
//! **Slots are 1-based on the wire and 0-based in the client**, and this module absorbs the
|
||||
//! difference exactly once — see [`StabledPet::slot`]. The rest of benilla only ever sees the
|
||||
//! client's own indices (`0` = the current pet, `1..=NUM_PET_STABLE_SLOTS`).
|
||||
//!
|
||||
//! Only *hunter* pets reach the stable: vmangos gates every verb on `HUNTER_PET`, so a warlock at a
|
||||
//! stable master gets an empty list. Decision 1676.
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::wire::{read_cstring, read_u32_le, read_u64_le, read_u8};
|
||||
|
||||
/// One row of `MSG_LIST_STABLED_PETS` (vmangos `WorldSession::SendStablePet`,
|
||||
/// `NPCHandler.cpp:522-575`) — a pet the stable master can show, whether it is the one currently at
|
||||
/// the player's side or one asleep in a stable slot.
|
||||
///
|
||||
/// The wire record is `u32 petNumber, u32 creatureEntry, u32 level, cstring name, u32 loyalty,
|
||||
/// u8 slot`. Note what is *not* on it: no display id and no family — a stabled pet has no world
|
||||
/// object to read either from, so drawing its portrait takes a `CMSG_CREATURE_QUERY` on
|
||||
/// [`creature_entry`](Self::creature_entry) (which is why that response's display id is kept,
|
||||
/// decision 1676).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StabledPet {
|
||||
/// The pet's own id — vmangos's `character_pet.id`, the same number
|
||||
/// [`crate::guid::pet_number`] reads out of a live pet's guid. This, **not** the slot, is what
|
||||
/// [`unstable_pet`] and [`stable_swap_pet`] name.
|
||||
pub pet_number: u32,
|
||||
/// `creature_template` entry (`Pet::GetEntry()` / `character_pet.entry`) — **not** a display
|
||||
/// id. Feed it to `CMSG_CREATURE_QUERY` to get the name, family and display id the window's
|
||||
/// icon and diet tooltip need.
|
||||
pub creature_entry: u32,
|
||||
/// The pet's level.
|
||||
pub level: u32,
|
||||
/// The pet's name — the one the hunter gave it, not the creature template's.
|
||||
pub name: String,
|
||||
/// Loyalty **level** 1..6 (`LoyaltyLevel`, vmangos `Pet.h:57-65`: Rebellious … Best Friend) — a
|
||||
/// `PetLoyalty.dbc` row id the client renders as a string, never a string on the wire.
|
||||
pub loyalty: u32,
|
||||
/// **Client** slot index, already rebased: `0` = the current pet, `1..=2` = the two stable
|
||||
/// slots.
|
||||
///
|
||||
/// The wire byte is 1-based over vmangos's own 0-based `character_pet.slot`: `SendStablePet`
|
||||
/// writes a literal `0x01` for the current pet (`PET_SAVE_AS_CURRENT` = 0) and `it->slot + 1`
|
||||
/// for a stabled one, where `PET_SAVE_FIRST_STABLE_SLOT` = 1 and `PET_SAVE_LAST_STABLE_SLOT` =
|
||||
/// `MAX_PET_STABLES` = 2 (`Pet.h:37-48`). The reference UI indexes `0` (current) through
|
||||
/// `NUM_PET_STABLE_SLOTS`, so **client index = wire slot − 1** — done once, here, so no
|
||||
/// consumer has to rediscover a ±1.
|
||||
pub slot: u8,
|
||||
}
|
||||
|
||||
/// `SMSG_STABLE_RESULT`'s one `u8` — the whole answer to every stable verb (vmangos's
|
||||
/// `StableResultCode`, `NPCHandler.cpp:40-47`, VERIFIED). Nothing else comes back: the list is
|
||||
/// **not** resent, so a success is a cue to re-ask with [`list_stabled_pets`].
|
||||
pub mod stable_result {
|
||||
/// `STABLE_ERR_MONEY` — not enough gold for the next stable slot. Only [`super::buy_stable_slot`]
|
||||
/// produces it (`HandleBuyStableSlot`, `NPCHandler.cpp:704-729`).
|
||||
pub const ERR_MONEY: u8 = 0x01;
|
||||
/// `STABLE_ERR_STABLE` — the catch-all failure, and the answer to *every* refusal except the
|
||||
/// money one: dead player, not a real stable master (or out of interact range), no live hunter
|
||||
/// pet to stable, all bought slots full, an unknown/untameable pet number, a pet already out
|
||||
/// when unstabling, a failed load from the DB, or buying a fourth slot. It carries no reason —
|
||||
/// the client cannot tell those cases apart, and neither can we.
|
||||
pub const ERR_STABLE: u8 = 0x06;
|
||||
/// `STABLE_SUCCESS_STABLE` — the current pet went into the first free stable slot
|
||||
/// ([`super::stable_pet`]).
|
||||
pub const SUCCESS_STABLE: u8 = 0x08;
|
||||
/// `STABLE_SUCCESS_UNSTABLE` — a stabled pet is now the current pet. Sent for **both**
|
||||
/// [`super::unstable_pet`] and a successful [`super::stable_swap_pet`] (vmangos
|
||||
/// `HandleStableSwapPet` ends on this same code), so it does not identify which verb was used.
|
||||
pub const SUCCESS_UNSTABLE: u8 = 0x09;
|
||||
/// `STABLE_SUCCESS_BUY_SLOT` — a stable slot was purchased; the gold is already gone and
|
||||
/// `m_stableSlots` has advanced, visible on the next list's `num_stable_slots`.
|
||||
pub const SUCCESS_BUY_SLOT: u8 = 0x0A;
|
||||
}
|
||||
|
||||
/// Body of `MSG_LIST_STABLED_PETS` sent **client → server** (vmangos `Npc.cpp:51-54`,
|
||||
/// `ListStabledPets::ReadFromWorldPacket`): one full 8-byte stable-master guid.
|
||||
///
|
||||
/// The *refresh* verb, not the open verb: the window first appears off the gossip stable option,
|
||||
/// which has the server send this same opcode inbound unprompted. Re-ask after any successful
|
||||
/// mutation — `SMSG_STABLE_RESULT` is the entire answer to those, and the server never resends the
|
||||
/// list on its own.
|
||||
pub fn list_stabled_pets(npc_guid: u64) -> Vec<u8> {
|
||||
npc_guid.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_STABLE_PET` (vmangos `Npc.cpp:56-59`, `StablePet::ReadFromWorldPacket`): one full
|
||||
/// 8-byte stable-master guid.
|
||||
///
|
||||
/// **It carries no slot** — the surprising part. The server picks the destination itself:
|
||||
/// `HandleStablePet` (`NPCHandler.cpp:609-655`) walks `PET_SAVE_FIRST_STABLE_SLOT..=
|
||||
/// PET_SAVE_LAST_STABLE_SLOT`, takes the first slot no `character_pet` row occupies, and refuses
|
||||
/// with [`stable_result::ERR_STABLE`] if that index exceeds the number of slots the player has
|
||||
/// bought. So there is no "stable into slot 2" intent to express, and a UI that lets the player
|
||||
/// drop a pet onto a particular empty slot is describing a placement the wire cannot carry.
|
||||
pub fn stable_pet(npc_guid: u64) -> Vec<u8> {
|
||||
npc_guid.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_UNSTABLE_PET` (vmangos `Npc.cpp:61-65`, `UnstablePet::ReadFromWorldPacket`): `u64
|
||||
/// npcGuid, u32 petNumber` — the [`StabledPet::pet_number`] of the chosen row, never its slot.
|
||||
///
|
||||
/// Summons that pet as the current pet. Refused with [`stable_result::ERR_STABLE`] when the player
|
||||
/// already *has* a pet — even an unsummoned one that is merely out of range
|
||||
/// (`HandleUnstablePet`, `NPCHandler.cpp:657-702`); trading places is [`stable_swap_pet`]'s job.
|
||||
pub fn unstable_pet(npc_guid: u64, pet_number: u32) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(12);
|
||||
body.extend_from_slice(&npc_guid.to_le_bytes());
|
||||
body.extend_from_slice(&pet_number.to_le_bytes());
|
||||
body
|
||||
}
|
||||
|
||||
/// Body of `CMSG_BUY_STABLE_SLOT` (vmangos `Npc.cpp:67-70`, `BuyStableSlot::ReadFromWorldPacket`):
|
||||
/// one full 8-byte stable-master guid.
|
||||
///
|
||||
/// Like the bank's slot purchase, the *which* is implicit: the server buys slot
|
||||
/// `m_stableSlots + 1`, prices it from `StableSlotPrices.dbc` at that row, and answers
|
||||
/// [`stable_result::SUCCESS_BUY_SLOT`], [`stable_result::ERR_MONEY`], or — past `MAX_PET_STABLES`
|
||||
/// (2) — [`stable_result::ERR_STABLE`] (`HandleBuyStableSlot`, `NPCHandler.cpp:704-729`). Knowing
|
||||
/// the next price is the client's own job, read from that DBC.
|
||||
pub fn buy_stable_slot(npc_guid: u64) -> Vec<u8> {
|
||||
npc_guid.to_le_bytes().to_vec()
|
||||
}
|
||||
|
||||
/// Body of `CMSG_STABLE_SWAP_PET` (vmangos `Npc.cpp:72-76`, `StableSwapPet::ReadFromWorldPacket`):
|
||||
/// `u64 npcGuid, u32 petNumber` — same shape as [`unstable_pet`], and the verb to use when the
|
||||
/// player *already* has a current pet.
|
||||
///
|
||||
/// The current pet takes the named pet's stable slot and the named pet is summoned, in one step
|
||||
/// (`HandleStableSwapPet`, `NPCHandler.cpp:735-789`). Success answers
|
||||
/// [`stable_result::SUCCESS_UNSTABLE`] — the same code a plain unstable returns.
|
||||
pub fn stable_swap_pet(npc_guid: u64, pet_number: u32) -> Vec<u8> {
|
||||
let mut body = Vec::with_capacity(12);
|
||||
body.extend_from_slice(&npc_guid.to_le_bytes());
|
||||
body.extend_from_slice(&pet_number.to_le_bytes());
|
||||
body
|
||||
}
|
||||
|
||||
// No `CMSG_STABLE_REVIVE_PET` (0x0274) send verb, deliberately: vmangos's
|
||||
// `HandleStableRevivePet` (`NPCHandler.cpp:731-733`) is an empty no-op, and whether the 5875 client
|
||||
// ever puts that opcode on the wire is an open RE question — nothing here can be verified against a
|
||||
// handler that does nothing. If a capture ever shows the reference client sending it, the verb gets
|
||||
// added then, from that evidence (decision 1676).
|
||||
|
||||
/// Read `MSG_LIST_STABLED_PETS` arriving **server → client** (vmangos
|
||||
/// `WorldSession::SendStablePet`, `NPCHandler.cpp:522-575`): `u64 npcGuid, u8 numPets, u8
|
||||
/// numStableSlots, numPets ×` [`StabledPet`] (a variable-length record — the name is a cstring).
|
||||
/// Returns `(npc, num_stable_slots, pets)`.
|
||||
///
|
||||
/// `num_stable_slots` is `Player::m_stableSlots` — how many slots are **purchased** (0..=2), which
|
||||
/// is what greys the unbought ones and drives the "buy the next slot" price line; it is *not* a
|
||||
/// count of occupied slots.
|
||||
///
|
||||
/// **The current pet's row may be absent.** vmangos emits a slot-0 row only when the player has a
|
||||
/// live `HUNTER_PET` or a cached `character_pet` for one that has despawned; a warlock, or a hunter
|
||||
/// with no pet at all, simply gets a list whose rows are all stabled ones (or none). So `pets[0]`
|
||||
/// is not the current pet — the rows are keyed by their own [`StabledPet::slot`], and a consumer
|
||||
/// must look the slot up rather than index positionally.
|
||||
pub(super) fn read_list_stabled_pets(r: &mut &[u8]) -> io::Result<(u64, u8, Vec<StabledPet>)> {
|
||||
let npc = read_u64_le(r)?;
|
||||
let num_pets = read_u8(r)?;
|
||||
let num_stable_slots = read_u8(r)?;
|
||||
let mut pets = Vec::with_capacity(num_pets as usize);
|
||||
for _ in 0..num_pets {
|
||||
// Struct-literal fields evaluate top-to-bottom, so this reads in wire order.
|
||||
pets.push(StabledPet {
|
||||
pet_number: read_u32_le(r)?,
|
||||
creature_entry: read_u32_le(r)?,
|
||||
level: read_u32_le(r)?,
|
||||
name: read_cstring(r)?,
|
||||
loyalty: read_u32_le(r)?,
|
||||
// Wire slot is 1-based (see `StabledPet::slot`); rebase to the client's own index here,
|
||||
// once. `saturating_sub` rather than `- 1` only so a malformed 0 can't wrap to 255 —
|
||||
// vmangos never writes one.
|
||||
slot: read_u8(r)?.saturating_sub(1),
|
||||
});
|
||||
}
|
||||
Ok((npc, num_stable_slots, pets))
|
||||
}
|
||||
|
||||
/// Read `SMSG_STABLE_RESULT` (vmangos `StableResult::AppendBodyTo`, `Npc.cpp:99-102`): one
|
||||
/// [`stable_result`] byte, and the complete answer to every stable verb.
|
||||
pub(super) fn read_stable_result(r: &mut &[u8]) -> io::Result<u8> {
|
||||
read_u8(r)
|
||||
}
|
||||
@@ -58,4 +58,20 @@ impl WorldWriter {
|
||||
&messages::loot_roll(looted_target, item_slot, roll_type),
|
||||
)
|
||||
}
|
||||
|
||||
/// Hand one loot-window row to a group member (`CMSG_LOOT_MASTER_GIVE`, layout in
|
||||
/// [`messages::loot_master_give`]) — the master looter's replacement for taking the row
|
||||
/// themselves. `loot_guid` is the open loot source and `slot` the wire's 0-based row index,
|
||||
/// the same one [`WorldWriter::autostore_loot_item`] would carry.
|
||||
///
|
||||
/// Success looks like anyone else's loot from here: `SMSG_LOOT_REMOVED` clears the row for
|
||||
/// every looter, and the *recipient* (not us) gets the item-create traffic. A refusal comes
|
||||
/// back on `SMSG_LOOT_RESPONSE`'s error shape carrying a `MASTER_*`
|
||||
/// [`messages::loot_error`] code.
|
||||
pub fn loot_master_give(&mut self, loot_guid: u64, slot: u8, player_guid: u64) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_LOOT_MASTER_GIVE,
|
||||
&messages::loot_master_give(loot_guid, slot, player_guid),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
//! | [`vendor`] | buy, sell, buy back, repair |
|
||||
//! | [`bank`] | open, buy a slot, deposit, withdraw |
|
||||
//! | [`trainer`] | the service list refresh + the purchase |
|
||||
//! | [`stable`] | the hunter stable: the list refresh, stable/unstable/swap, buy a slot |
|
||||
//! | [`taxi`] | the flight master: status, map, the two flight verbs |
|
||||
//! | [`quest`] | the questgiver dialog walk + the two quest-log verbs |
|
||||
//! | [`gameobject`] | the one USE verb + the template ask |
|
||||
@@ -84,6 +85,7 @@ mod self_movement;
|
||||
mod skills;
|
||||
mod social;
|
||||
mod spells;
|
||||
mod stable;
|
||||
mod taxi;
|
||||
mod trade;
|
||||
mod trainer;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
//! The stable window's `WorldWriter` sends — the list refresh and the four mutations. Bodies in
|
||||
//! [`crate::messages::stable`], whose scope this mirrors.
|
||||
//!
|
||||
//! **Every verb names the stable master's guid**, because the server re-checks the interaction on
|
||||
//! each one (`WorldSession::CheckStableMaster`, VERIFIED vmangos `NPCHandler.cpp:584-607`): the guid
|
||||
//! must be an NPC with `UNIT_NPC_FLAG_STABLEMASTER` that the player can still interact with. A
|
||||
//! window left open while the player walks away therefore fails every button with
|
||||
//! [`messages::stable_result::ERR_STABLE`] rather than acting at a distance — which is why the app
|
||||
//! side range-guards the session rather than trusting the open window.
|
||||
//!
|
||||
//! **None of the four mutations is answered with a fresh list** — only a one-byte
|
||||
//! `SMSG_STABLE_RESULT` (VERIFIED: every `HandleStable*` path ends in `SendStableResult` and
|
||||
//! nothing else). So a success is a cue to re-ask with [`WorldWriter::list_stabled_pets`], the same
|
||||
//! shape as the trainer's post-purchase re-request.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use crate::messages::{self, opcode};
|
||||
|
||||
use super::WorldWriter;
|
||||
|
||||
impl WorldWriter {
|
||||
/// Ask (or re-ask) a stable master's pet list (`MSG_LIST_STABLED_PETS`, layout in
|
||||
/// [`messages::list_stabled_pets`]) — one 8-byte NPC guid.
|
||||
///
|
||||
/// This is the *refresh* verb. The window first *opens* off the server's own unprompted send of
|
||||
/// the same opcode, which the gossip stable option produces (`GOSSIP_OPTION_STABLEPET` →
|
||||
/// `SendStablePet`, VERIFIED vmangos `Player.cpp:12400-12402`) — exactly the trainer's and the
|
||||
/// vendor's arrangement. Answered by `MSG_LIST_STABLED_PETS` inbound.
|
||||
pub fn list_stabled_pets(&mut self, npc_guid: u64) -> Result<()> {
|
||||
self.send(
|
||||
opcode::MSG_LIST_STABLED_PETS,
|
||||
&messages::list_stabled_pets(npc_guid),
|
||||
)
|
||||
}
|
||||
|
||||
/// Put the current pet into a stable slot (`CMSG_STABLE_PET`, layout in
|
||||
/// [`messages::stable_pet`]): the NPC guid alone.
|
||||
///
|
||||
/// **There is no slot to name** — the server takes the first free one and refuses if that index
|
||||
/// exceeds the slots the player has bought (`HandleStablePet`, VERIFIED `NPCHandler.cpp:609-655`).
|
||||
/// Answers [`messages::stable_result::SUCCESS_STABLE`], or `ERR_STABLE` when the player is dead,
|
||||
/// has no live hunter pet, or has no free bought slot.
|
||||
pub fn stable_pet(&mut self, npc_guid: u64) -> Result<()> {
|
||||
self.send(opcode::CMSG_STABLE_PET, &messages::stable_pet(npc_guid))
|
||||
}
|
||||
|
||||
/// Summon a stabled pet as the current pet (`CMSG_UNSTABLE_PET`, layout in
|
||||
/// [`messages::unstable_pet`]): the NPC guid + the pet's own
|
||||
/// [`messages::StabledPet::pet_number`], never its slot.
|
||||
///
|
||||
/// Only valid with **no** current pet — vmangos refuses even when the existing pet is merely
|
||||
/// unsummoned and out of range (`HandleUnstablePet`, VERIFIED `NPCHandler.cpp:657-702`). With a
|
||||
/// pet already out, the verb is [`Self::stable_swap_pet`]. Answers
|
||||
/// [`messages::stable_result::SUCCESS_UNSTABLE`] or `ERR_STABLE`.
|
||||
pub fn unstable_pet(&mut self, npc_guid: u64, pet_number: u32) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_UNSTABLE_PET,
|
||||
&messages::unstable_pet(npc_guid, pet_number),
|
||||
)
|
||||
}
|
||||
|
||||
/// Trade the current pet for a stabled one in a single step (`CMSG_STABLE_SWAP_PET`, layout in
|
||||
/// [`messages::stable_swap_pet`]): the NPC guid + the stabled pet's
|
||||
/// [`messages::StabledPet::pet_number`].
|
||||
///
|
||||
/// The current pet goes into the slot the named pet vacates (`HandleStableSwapPet`, VERIFIED
|
||||
/// `NPCHandler.cpp:735-789`). Requires a live hunter pet to be out. Answers
|
||||
/// [`messages::stable_result::SUCCESS_UNSTABLE`] — the *same* code a plain unstable returns, so
|
||||
/// the reply cannot tell the two verbs apart.
|
||||
pub fn stable_swap_pet(&mut self, npc_guid: u64, pet_number: u32) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_STABLE_SWAP_PET,
|
||||
&messages::stable_swap_pet(npc_guid, pet_number),
|
||||
)
|
||||
}
|
||||
|
||||
/// Buy the next stable slot (`CMSG_BUY_STABLE_SLOT`, layout in
|
||||
/// [`messages::buy_stable_slot`]): the NPC guid alone.
|
||||
///
|
||||
/// The *which* is implicit, exactly as it is for the bank's bag slots (decision 0604): the
|
||||
/// server buys `m_stableSlots + 1` and prices it from `StableSlotPrices.dbc` at that row
|
||||
/// (`HandleBuyStableSlot`, VERIFIED `NPCHandler.cpp:704-729`). Answers
|
||||
/// [`messages::stable_result::SUCCESS_BUY_SLOT`], `ERR_MONEY`, or — past the two slots 5875
|
||||
/// ships — `ERR_STABLE`. No packet reports the new count: the next list's `num_stable_slots`
|
||||
/// does.
|
||||
pub fn buy_stable_slot(&mut self, npc_guid: u64) -> Result<()> {
|
||||
self.send(
|
||||
opcode::CMSG_BUY_STABLE_SLOT,
|
||||
&messages::buy_stable_slot(npc_guid),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -604,18 +604,22 @@ fn name_and_creature_query_roundtrip() {
|
||||
// (type_flags, TYPE — the TAB critter filter's input — FAMILY, RANK, unk, pet_spell_id,
|
||||
// display_id), 2 u8 tail. Family and rank are given DIFFERENT non-zero values on purpose:
|
||||
// they are adjacent dwords, so a one-column slip between them reads as plausible data and
|
||||
// shows up only as a pet whose level line names the wrong beast (decision 1062).
|
||||
// shows up only as a pet whose level line names the wrong beast (decision 1062). `display_id`
|
||||
// is non-zero for the same reason (decision 1676): it sits behind two alignment-only dwords
|
||||
// that were zeros here, so a slip into either read 0 — indistinguishable from "no model" —
|
||||
// and the stable window would have drawn an empty booth for every pet.
|
||||
let body = hx(concat!(
|
||||
"45000000",
|
||||
"596f756e6720576f6c6600", // "Young Wolf"
|
||||
"000000", // name2..4 empty
|
||||
"5465737400", // subname "Test"
|
||||
"10000000", // type_flags = 0x10 (hide-faction-tooltip)
|
||||
"01000000", // type = 1 (Beast)
|
||||
"01000000", // pet_family = 1 (Wolf)
|
||||
"02000000", // rank = 2 (rare elite)
|
||||
"000000000000000000000000", // unk, pet_spell_list_id, display_id
|
||||
"0101" // civilian, racial_leader
|
||||
"596f756e6720576f6c6600", // "Young Wolf"
|
||||
"000000", // name2..4 empty
|
||||
"5465737400", // subname "Test"
|
||||
"10000000", // type_flags = 0x10 (hide-faction-tooltip)
|
||||
"01000000", // type = 1 (Beast)
|
||||
"01000000", // pet_family = 1 (Wolf)
|
||||
"02000000", // rank = 2 (rare elite)
|
||||
"0000000000000000", // unk, pet_spell_list_id
|
||||
"15020000", // display_id = 533 — the model a stabled pet is drawn from
|
||||
"0101" // civilian, racial_leader
|
||||
));
|
||||
match messages::parse_server(messages::opcode::SMSG_CREATURE_QUERY_RESPONSE, &body).unwrap() {
|
||||
ServerPacket::CreatureQueryResponse { entry, info } => {
|
||||
@@ -631,6 +635,7 @@ fn name_and_creature_query_roundtrip() {
|
||||
type_flags: 0x10,
|
||||
civilian: true,
|
||||
racial_leader: true,
|
||||
display_id: 533,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//! Oracle-free golden tests for the stable-master arc's protocol layer (decision 1676): the five
|
||||
//! send verbs, the `MSG_LIST_STABLED_PETS` list, and the `SMSG_STABLE_RESULT` byte. Same idioms as
|
||||
//! `trainer.rs` — `hx(...)` golden CMSG bodies, hand-built SMSG bodies round-tripped through
|
||||
//! `parse_server`.
|
||||
//!
|
||||
//! Every layout here is VERIFIED against vmangos: the send bodies from
|
||||
//! `Server/Packets/Npc.cpp:51-76` (`ReadFromWorldPacket` for each `ClientPacket`), the list from
|
||||
//! `WorldSession::SendStablePet` (`Handlers/NPCHandler.cpp:522-575`), and the result byte from
|
||||
//! `StableResult::AppendBodyTo` (`Npc.cpp:99-102`) with the codes from that file's
|
||||
//! `StableResultCode` enum (`NPCHandler.cpp:40-47`).
|
||||
|
||||
use benilla_protocol::messages::{self, stable_result, StabledPet};
|
||||
use benilla_protocol::ServerPacket;
|
||||
|
||||
fn hx(s: &str) -> Vec<u8> {
|
||||
(0..s.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
|
||||
.collect()
|
||||
}
|
||||
|
||||
const NPC: u64 = 0x1234_5678_9abc_def0;
|
||||
const NPC_HEX: &str = "f0debc9a78563412";
|
||||
|
||||
#[test]
|
||||
fn stable_send_bodies_golden() {
|
||||
// The three guid-only verbs. They are byte-identical bodies on three different opcodes — the
|
||||
// opcode IS the verb — so each is asserted separately rather than through a shared helper: a
|
||||
// writer that sent `stable_pet`'s body under `CMSG_BUY_STABLE_SLOT` would still match a shared
|
||||
// assertion.
|
||||
assert_eq!(
|
||||
messages::list_stabled_pets(NPC),
|
||||
hx(NPC_HEX),
|
||||
"MSG_LIST_STABLED_PETS body"
|
||||
);
|
||||
assert_eq!(
|
||||
messages::stable_pet(NPC),
|
||||
hx(NPC_HEX),
|
||||
"CMSG_STABLE_PET body"
|
||||
);
|
||||
assert_eq!(
|
||||
messages::buy_stable_slot(NPC),
|
||||
hx(NPC_HEX),
|
||||
"CMSG_BUY_STABLE_SLOT body"
|
||||
);
|
||||
|
||||
// The two pet-number verbs: u64 npcGuid, u32 petNumber. The number is the pet's OWN id
|
||||
// (`character_pet.id`), never its slot — a body carrying a slot index would name the wrong pet
|
||||
// for every hunter whose stable is not in id order.
|
||||
assert_eq!(
|
||||
messages::unstable_pet(NPC, 42),
|
||||
hx(&format!("{NPC_HEX}2a000000")),
|
||||
"CMSG_UNSTABLE_PET body"
|
||||
);
|
||||
assert_eq!(
|
||||
messages::stable_swap_pet(NPC, 42),
|
||||
hx(&format!("{NPC_HEX}2a000000")),
|
||||
"CMSG_STABLE_SWAP_PET body"
|
||||
);
|
||||
}
|
||||
|
||||
/// Append one variable-length pet record to a `MSG_LIST_STABLED_PETS` body, in wire order:
|
||||
/// `u32 petNumber, u32 creatureEntry, u32 level, cstring name, u32 loyalty, u8 slot` — where
|
||||
/// `slot` is the **wire** value (1-based), not the client index the decode produces.
|
||||
fn push_pet(
|
||||
body: &mut Vec<u8>,
|
||||
pet_number: u32,
|
||||
entry: u32,
|
||||
level: u32,
|
||||
name: &str,
|
||||
loyalty: u32,
|
||||
wire_slot: u8,
|
||||
) {
|
||||
body.extend_from_slice(&pet_number.to_le_bytes());
|
||||
body.extend_from_slice(&entry.to_le_bytes());
|
||||
body.extend_from_slice(&level.to_le_bytes());
|
||||
body.extend_from_slice(name.as_bytes());
|
||||
body.push(0);
|
||||
body.extend_from_slice(&loyalty.to_le_bytes());
|
||||
body.push(wire_slot);
|
||||
}
|
||||
|
||||
fn list_body(num_stable_slots: u8, pets: &[(u32, u32, u32, &str, u32, u8)]) -> Vec<u8> {
|
||||
let mut body = NPC.to_le_bytes().to_vec();
|
||||
body.push(pets.len() as u8);
|
||||
body.push(num_stable_slots);
|
||||
for &(n, e, l, name, loy, slot) in pets {
|
||||
push_pet(&mut body, n, e, l, name, loy, slot);
|
||||
}
|
||||
body
|
||||
}
|
||||
|
||||
fn parse_list(body: &[u8]) -> (u64, u8, Vec<StabledPet>) {
|
||||
match messages::parse_server(messages::opcode::MSG_LIST_STABLED_PETS, body).unwrap() {
|
||||
ServerPacket::ListStabledPets {
|
||||
npc,
|
||||
num_stable_slots,
|
||||
pets,
|
||||
} => (npc, num_stable_slots, pets),
|
||||
_ => panic!("expected ListStabledPets"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A full hunter: a pet at their side and both stable slots occupied. **The slot rebasing is the
|
||||
/// assertion that matters** — the wire is 1-based (`SendStablePet` writes a literal `0x01` for the
|
||||
/// current pet and `it->slot + 1` for a stabled one) and the client is 0-based, so a decode that
|
||||
/// forwarded the wire byte would put the current pet in stable slot 1 and drop the last pet off the
|
||||
/// end of a 3-slot window.
|
||||
#[test]
|
||||
fn a_full_stable_decodes_with_client_slot_indices() {
|
||||
let body = list_body(
|
||||
2,
|
||||
&[
|
||||
(7, 299, 41, "Rex", 6, 1),
|
||||
(8, 1126, 38, "Bruiser", 4, 2),
|
||||
(9, 883, 12, "Nibbles", 1, 3),
|
||||
],
|
||||
);
|
||||
let (npc, slots, pets) = parse_list(&body);
|
||||
assert_eq!(npc, NPC);
|
||||
assert_eq!(slots, 2, "purchased slots, not occupied ones");
|
||||
assert_eq!(pets.len(), 3);
|
||||
|
||||
// Wire 1 → client 0 (the current pet), wire 3 → client 2 (the second stable slot).
|
||||
assert_eq!(
|
||||
pets[0],
|
||||
StabledPet {
|
||||
pet_number: 7,
|
||||
creature_entry: 299,
|
||||
level: 41,
|
||||
name: "Rex".into(),
|
||||
loyalty: 6,
|
||||
slot: 0,
|
||||
}
|
||||
);
|
||||
assert_eq!(pets[1].slot, 1);
|
||||
assert_eq!(pets[2].slot, 2);
|
||||
// The name is a cstring mid-record, so every field after it depends on having consumed it
|
||||
// exactly: a mis-read name shifts loyalty and slot together.
|
||||
assert_eq!(pets[2].name, "Nibbles");
|
||||
assert_eq!((pets[2].loyalty, pets[2].creature_entry), (1, 883));
|
||||
}
|
||||
|
||||
/// A hunter with **no current pet** — the row for slot 0 is simply absent (vmangos emits it only
|
||||
/// for a live `HUNTER_PET` or a cached one). The rows must therefore be read BY SLOT: a consumer
|
||||
/// that treated `pets[0]` as the current pet would show a stabled wolf standing at the player's
|
||||
/// side.
|
||||
#[test]
|
||||
fn an_absent_current_pet_leaves_no_slot_zero_row() {
|
||||
let body = list_body(1, &[(8, 1126, 38, "Bruiser", 4, 2)]);
|
||||
let (_, slots, pets) = parse_list(&body);
|
||||
assert_eq!(slots, 1);
|
||||
assert_eq!(pets.len(), 1);
|
||||
assert_eq!(
|
||||
pets[0].slot, 1,
|
||||
"the lone row is stable slot 1, not current"
|
||||
);
|
||||
assert!(!pets.iter().any(|p| p.slot == 0));
|
||||
}
|
||||
|
||||
/// A hunter who has bought a slot and stabled nothing: an empty list with a non-zero slot count.
|
||||
/// This must decode to an empty vec — the count and the rows are independent numbers, and treating
|
||||
/// `num_stable_slots` as a row count would over-read the body.
|
||||
#[test]
|
||||
fn an_empty_list_still_carries_the_purchased_slot_count() {
|
||||
let (_, slots, pets) = parse_list(&list_body(1, &[]));
|
||||
assert_eq!(slots, 1);
|
||||
assert!(pets.is_empty());
|
||||
|
||||
// And the true zero state: nothing bought, nothing stabled.
|
||||
let (_, slots, pets) = parse_list(&list_body(0, &[]));
|
||||
assert_eq!(slots, 0);
|
||||
assert!(pets.is_empty());
|
||||
}
|
||||
|
||||
/// Every `StableResultCode` vmangos can send, round-tripped. The success codes are distinct
|
||||
/// numbers but `SUCCESS_UNSTABLE` answers BOTH a plain unstable and a swap, so the reply alone
|
||||
/// never identifies which verb ran.
|
||||
#[test]
|
||||
fn stable_result_codes_round_trip() {
|
||||
for code in [
|
||||
stable_result::ERR_MONEY,
|
||||
stable_result::ERR_STABLE,
|
||||
stable_result::SUCCESS_STABLE,
|
||||
stable_result::SUCCESS_UNSTABLE,
|
||||
stable_result::SUCCESS_BUY_SLOT,
|
||||
] {
|
||||
match messages::parse_server(messages::opcode::SMSG_STABLE_RESULT, &[code]).unwrap() {
|
||||
ServerPacket::StableResult { result } => assert_eq!(result, code),
|
||||
_ => panic!("expected StableResult"),
|
||||
}
|
||||
}
|
||||
|
||||
// The values themselves, against vmangos's enum — a renumbering here would silently turn a
|
||||
// refusal into a success in the app's match.
|
||||
assert_eq!(
|
||||
[
|
||||
stable_result::ERR_MONEY,
|
||||
stable_result::ERR_STABLE,
|
||||
stable_result::SUCCESS_STABLE,
|
||||
stable_result::SUCCESS_UNSTABLE,
|
||||
stable_result::SUCCESS_BUY_SLOT,
|
||||
],
|
||||
[0x01, 0x06, 0x08, 0x09, 0x0A]
|
||||
);
|
||||
}
|
||||
@@ -23,6 +23,15 @@
|
||||
//! wire loot slot the item lives at (`CMSG_AUTOSTORE_LOOT_ITEM` addresses the **wire** slot, which is
|
||||
//! *not* the 1-based display position once a coin row is prepended or a row is removed) — the Lua side
|
||||
//! never sees the wire slot, exactly as the merchant's Lua side never sees the item entry.
|
||||
//!
|
||||
//! ## Master loot (decision 1675)
|
||||
//!
|
||||
//! Two more globals ride the same seam when the group's loot method is master loot:
|
||||
//! `GetMasterLootCandidate(index)` reads [`LootState::master_candidates`] (names, 1-based, dense)
|
||||
//! and `GiveMasterLoot(slot, candidateIndex)` queues an assignment the app resolves to a wire slot
|
||||
//! and a recipient guid. The *decision* to open the dropdown is not Lua's: the app fires
|
||||
//! `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};
|
||||
|
||||
@@ -85,6 +94,22 @@ pub struct LootState {
|
||||
/// `loot_type == 3`, decision 1086). `LootFrame_OnShow` keys the "FISHING REEL IN" sound and
|
||||
/// the FishingLoot portrait overlay on it (`LootFrame.lua:137-140`).
|
||||
pub fishing: bool,
|
||||
/// The master-loot candidate slots — what `GetMasterLootCandidate(i)` answers for a 1-based
|
||||
/// `i` (decision 1675). Empty unless the group's loot method is master loot and the server
|
||||
/// sent `SMSG_LOOT_MASTER_LIST` for this window.
|
||||
///
|
||||
/// **A `None` is a real slot that answers nil**, not padding, and the list is deliberately
|
||||
/// not packed: in a raid the client files each candidate into its own subgroup's five-slot
|
||||
/// block, so index `i` carries which raid group the candidate is in. That is what lets
|
||||
/// `GroupLootDropDown_Initialize` walk `1..40` in blocks of five and label each block
|
||||
/// "Group N" (`LootFrame.lua:186-212`). A `None` also covers a candidate whose name has not
|
||||
/// resolved yet — the binding pushes nil for that too, and `UPDATE_MASTER_LOOT_LIST` exists
|
||||
/// to repaint the menu when it lands.
|
||||
///
|
||||
/// Names, not guids: the seam's standing division is that Lua speaks names and 1-based
|
||||
/// indices while the app owns guids and wire slots, and `GroupLootDropDown_Initialize` puts
|
||||
/// this string straight into `info.text` (`LootFrame.lua:181`/`:229`).
|
||||
pub master_candidates: Vec<Option<String>>,
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
@@ -104,6 +129,13 @@ impl super::UiScript {
|
||||
pub fn take_loot_close(&mut self) -> bool {
|
||||
std::mem::take(&mut self.model_mut().loot_close)
|
||||
}
|
||||
|
||||
/// Drain the `GiveMasterLoot(slot, candidateIndex)` assignments queued since the last call —
|
||||
/// both 1-based display numbers. The app resolves the row to its wire slot and the candidate
|
||||
/// to a guid, then sends `CMSG_LOOT_MASTER_GIVE` (decision 1675).
|
||||
pub fn take_loot_master_gives(&mut self) -> Vec<(u32, u32)> {
|
||||
std::mem::take(&mut self.model_mut().loot_master_gives)
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the loot globals.
|
||||
@@ -245,6 +277,42 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetMasterLootCandidate(index) → the 1-based candidate's NAME, or nil past the end.
|
||||
//
|
||||
// `GroupLootDropDown_Initialize` probes this sparsely and relies entirely on the nil:
|
||||
// the party arm walks `1..MAX_PARTY_MEMBERS+1` and the raid arm walks `1..40` in blocks of
|
||||
// five, keeping a "Group N" submenu only where the block's first probe answered non-nil
|
||||
// (`LootFrame.lua:169-232`). So an out-of-range index must answer nil rather than raise, and
|
||||
// the list must be DENSE — a hole would silently drop a candidate from the menu.
|
||||
g.set(
|
||||
"GetMasterLootCandidate",
|
||||
lua.create_function(|lua, index: u32| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
let name = index.checked_sub(1).and_then(|i| {
|
||||
model
|
||||
.loot
|
||||
.as_ref()?
|
||||
.master_candidates
|
||||
.get(i as usize)?
|
||||
.clone()
|
||||
});
|
||||
Ok(name)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GiveMasterLoot(slot, candidateIndex) — queue the assignment; the app owns the wire slot and
|
||||
// the recipient guid. Called from the dropdown's own handler for a below-threshold item and
|
||||
// from the CONFIRM_LOOT_DISTRIBUTION popup's OnAccept above it (`LootFrame.lua:236-244`,
|
||||
// `StaticPopup.lua:85-94`).
|
||||
g.set(
|
||||
"GiveMasterLoot",
|
||||
lua.create_function(|lua, (slot, candidate): (u32, u32)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.loot_master_gives.push((slot, candidate));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -255,6 +323,7 @@ mod tests {
|
||||
|
||||
fn loot() -> LootState {
|
||||
LootState {
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![
|
||||
// The coin pile, always first. No link: there is no item to link (decision 1059).
|
||||
Some(LootRow {
|
||||
@@ -370,6 +439,105 @@ mod tests {
|
||||
assert!(s.eval::<bool>("return LootSlotIsItem(2)").unwrap());
|
||||
}
|
||||
|
||||
/// The master-loot half of the seam (decision 1675). `GetMasterLootCandidate` must answer nil
|
||||
/// past the end rather than raise — `GroupLootDropDown_Initialize` probes it sparsely (the
|
||||
/// raid arm walks 1..40 in blocks of five) and reads the nil as "nobody here".
|
||||
#[test]
|
||||
fn master_loot_candidates_read_1_based_and_nil_past_the_end() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
// No loot open at all: nil, not an error.
|
||||
assert!(s
|
||||
.eval::<bool>("return GetMasterLootCandidate(1) == nil")
|
||||
.unwrap());
|
||||
|
||||
// A window with no candidate list (any loot method but master): still nil everywhere.
|
||||
s.set_loot(Some(loot()));
|
||||
assert!(s
|
||||
.eval::<bool>("return GetMasterLootCandidate(1) == nil")
|
||||
.unwrap());
|
||||
|
||||
let mut ml = loot();
|
||||
ml.master_candidates = vec![Some("Thrall".into()), Some("Cairne".into())];
|
||||
s.set_loot(Some(ml));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetMasterLootCandidate(1)")
|
||||
.unwrap(),
|
||||
"Thrall"
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetMasterLootCandidate(2)")
|
||||
.unwrap(),
|
||||
"Cairne"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return GetMasterLootCandidate(3) == nil")
|
||||
.unwrap(),
|
||||
"past the end is nil"
|
||||
);
|
||||
assert!(
|
||||
s.eval::<bool>("return GetMasterLootCandidate(0) == nil")
|
||||
.unwrap(),
|
||||
"0 is not a Lua index"
|
||||
);
|
||||
// The raid arm's real sweep shape: 40 probes must all answer without raising.
|
||||
assert_eq!(
|
||||
s.eval::<i64>(
|
||||
"local n = 0
|
||||
for i = 1, 40 do if GetMasterLootCandidate(i) then n = n + 1 end end
|
||||
return n",
|
||||
)
|
||||
.unwrap(),
|
||||
2
|
||||
);
|
||||
|
||||
// A HOLE in the middle answers nil without ending the list — the raid layout, where a
|
||||
// candidate's index carries which subgroup they are in, so slot 6 can be occupied while
|
||||
// slots 2-5 are empty. A list that stopped at the first nil would lose them.
|
||||
let mut raid = loot();
|
||||
raid.master_candidates = vec![
|
||||
Some("Thrall".into()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some("Cairne".into()),
|
||||
];
|
||||
s.set_loot(Some(raid));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetMasterLootCandidate(1)")
|
||||
.unwrap(),
|
||||
"Thrall"
|
||||
);
|
||||
for hole in 2..=5 {
|
||||
assert!(
|
||||
s.eval::<bool>(&format!("return GetMasterLootCandidate({hole}) == nil"))
|
||||
.unwrap(),
|
||||
"slot {hole} is a hole"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetMasterLootCandidate(6)")
|
||||
.unwrap(),
|
||||
"Cairne",
|
||||
"an occupant past the holes is still reachable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn give_master_loot_queues_the_assignment() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut ml = loot();
|
||||
ml.master_candidates = vec![Some("Thrall".into())];
|
||||
s.set_loot(Some(ml));
|
||||
assert!(s.take_loot_master_gives().is_empty());
|
||||
s.run("GiveMasterLoot(2, 1)").unwrap();
|
||||
assert_eq!(s.take_loot_master_gives(), vec![(2, 1)]);
|
||||
assert!(s.take_loot_master_gives().is_empty(), "drained");
|
||||
// It is NOT a loot pick — the two intents must not cross wires.
|
||||
assert!(s.take_loot_picks().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loot_slot_queues_picks() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -117,6 +117,7 @@ mod slider;
|
||||
mod social;
|
||||
mod sound;
|
||||
mod spellbook;
|
||||
mod stable;
|
||||
mod statusbar;
|
||||
mod stdlib;
|
||||
mod talent;
|
||||
@@ -203,6 +204,7 @@ pub use sound::SoundRequest;
|
||||
pub use spellbook::{
|
||||
resolve_spell_by_name, PetBookState, SpellBookState, SpellSlotView, SpellTabView,
|
||||
};
|
||||
pub use stable::{StableIntent, StablePetSlot, StableState, NUM_STABLE_SLOTS};
|
||||
pub use talent::{TalentPrereqView, TalentTabView, TalentUiState, TalentView};
|
||||
pub use taxi::{TaxiNodeType, TaxiUiNode, TaxiUiState};
|
||||
pub use tooltip_spell::SpellTooltipView;
|
||||
@@ -566,6 +568,7 @@ impl UiScript {
|
||||
gossip::install(&lua)?;
|
||||
merchant::install(&lua)?;
|
||||
bank::install(&lua)?;
|
||||
stable::install(&lua)?;
|
||||
item_text::install(&lua)?;
|
||||
mail::install(&lua)?;
|
||||
auction::install(&lua)?;
|
||||
|
||||
@@ -7,8 +7,8 @@ use super::{
|
||||
auction, backdrop, bank, char_stats, chat_window, colorselect, container, craft, cursor, death,
|
||||
duel, follow, gossip, guild, inspect, item_text, loot, loot_roll, macros, mail, merchant,
|
||||
party, pvp, quest, quest_log, reputation, session, simplehtml, skills, slider, social,
|
||||
spellbook, taxi, trade, tradeskill, trainer, weapon_enchant, ActionSlot, AuraState, FontObject,
|
||||
ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
|
||||
spellbook, stable, taxi, trade, tradeskill, trainer, weapon_enchant, ActionSlot, AuraState,
|
||||
FontObject, ItemTemplateView, PlayerReqState, RegionData, ScriptValue, SoundRequest, UnitState,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -863,6 +863,11 @@ pub(crate) struct Model {
|
||||
pub(crate) repair_all: bool,
|
||||
pub(crate) repair_mode: bool,
|
||||
|
||||
/// The stable window's whole state — the pushed snapshot, the selection, the frame-local
|
||||
/// drag, and the queued verbs (the stable seam, [`stable`], decision 1676). One sub-struct
|
||||
/// rather than five loose fields because the close path resets several of them together.
|
||||
pub(crate) stable: stable::StableModel,
|
||||
|
||||
/// The open bank's purchase-row snapshot the app pushes (`None` = no bank open), the
|
||||
/// `PurchaseSlot` intent, and whether `CloseBankFrame` was called — the bank seam ([`bank`],
|
||||
/// decision 0604; the bank's *contents* ride the container seam as bags −1/5..=10).
|
||||
@@ -960,6 +965,11 @@ pub(crate) struct Model {
|
||||
pub(crate) loot: Option<loot::LootState>,
|
||||
pub(crate) loot_picks: Vec<u32>,
|
||||
pub(crate) loot_close: bool,
|
||||
/// The `GiveMasterLoot(slot, candidateIndex)` assignments the app drains — both 1-based and
|
||||
/// both display-side: the row as the window numbers it, and the candidate's position in
|
||||
/// [`loot::LootState::master_candidates`]. The app owns the translation to the wire slot and
|
||||
/// the recipient's guid (decision 1675).
|
||||
pub(crate) loot_master_gives: Vec<(u32, u32)>,
|
||||
|
||||
/// The open group-loot rolls the app pushes (empty = none open) and the `RollOnLoot`
|
||||
/// `(roll_id, roll_type)` votes it drains — the roll seam ([`loot_roll`], decision 0591).
|
||||
@@ -1544,6 +1554,7 @@ impl Model {
|
||||
merchant_buybacks: Vec::new(),
|
||||
repair_all: false,
|
||||
repair_mode: false,
|
||||
stable: Default::default(),
|
||||
bank: None,
|
||||
bank_purchase: false,
|
||||
bank_close: false,
|
||||
@@ -1574,6 +1585,7 @@ impl Model {
|
||||
loot: None,
|
||||
loot_picks: Vec::new(),
|
||||
loot_close: false,
|
||||
loot_master_gives: Vec::new(),
|
||||
loot_rolls: loot_roll::LootRollsState::default(),
|
||||
loot_roll_votes: Vec::new(),
|
||||
loot_roll_confirms: Vec::new(),
|
||||
|
||||
@@ -182,6 +182,10 @@ pub enum PartyRequest {
|
||||
LootMethod {
|
||||
method: String,
|
||||
master_name: Option<String>,
|
||||
/// `SetLootMethod`'s optional THIRD argument. The binding reads it whatever the method is
|
||||
/// (`0x4e92a0`, presence-checked via `0x6f34d0`), unlike the master-looter argument, which
|
||||
/// it reads only for `"master"` (decision 1675).
|
||||
threshold: Option<u32>,
|
||||
},
|
||||
/// `SetLootThreshold(n)` — the new quality floor.
|
||||
LootThreshold(u32),
|
||||
@@ -516,16 +520,22 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// SetLootMethod("method" [,master] [,threshold]) — the reference's own usage string
|
||||
// (`0x84c42c`). The master-looter argument is read ONLY for "master"; the threshold argument
|
||||
// is optional for every method (decision 1675).
|
||||
g.set(
|
||||
"SetLootMethod",
|
||||
lua.create_function(|lua, (method, master_name): (String, Option<String>)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.party_requests.push(PartyRequest::LootMethod {
|
||||
method,
|
||||
master_name,
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
lua.create_function(
|
||||
|lua, (method, master_name, threshold): (String, Option<String>, Option<u32>)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.party_requests.push(PartyRequest::LootMethod {
|
||||
method,
|
||||
master_name,
|
||||
threshold,
|
||||
});
|
||||
Ok(())
|
||||
},
|
||||
)?,
|
||||
)?;
|
||||
g.set(
|
||||
"SetLootThreshold",
|
||||
@@ -1092,6 +1102,7 @@ mod tests {
|
||||
PartyRequest::LootMethod {
|
||||
method: "master".into(),
|
||||
master_name: Some("Bob".into()),
|
||||
threshold: None,
|
||||
},
|
||||
PartyRequest::LootThreshold(3),
|
||||
]
|
||||
@@ -1109,6 +1120,19 @@ mod tests {
|
||||
vec![PartyRequest::LootMethod {
|
||||
method: "freeforall".into(),
|
||||
master_name: None,
|
||||
threshold: None,
|
||||
}]
|
||||
);
|
||||
|
||||
// The optional THIRD argument, which the real binding reads for every method — not only
|
||||
// for "master", the way the master-looter argument is read (decision 1675).
|
||||
s.run(r#"SetLootMethod("group", nil, 4)"#).unwrap();
|
||||
assert_eq!(
|
||||
s.take_party_requests(),
|
||||
vec![PartyRequest::LootMethod {
|
||||
method: "group".into(),
|
||||
master_name: None,
|
||||
threshold: Some(4),
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -131,6 +131,20 @@ pub struct PetStats {
|
||||
/// bounds check. The page guards its whole level-line `SetText` on this
|
||||
/// (ref `PetPaperDollFrame.lua:68-70`), so nil renders no line rather than a half one.
|
||||
pub family: Option<String>,
|
||||
/// `GetPetIcon()` — the pet's icon path, from the family row's own `CreatureFamily.dbc` icon
|
||||
/// column (`Interface\\Icons\\Ability_Hunter_Pet_<Family>` for all 22 shipped families). A pet
|
||||
/// has no item behind it, so this column is the only icon there is. `None` = nil, on the same
|
||||
/// four lookup-miss paths as [`Self::family`], which is why it sits beside it.
|
||||
///
|
||||
/// **Outside the hunter gate, like [`Self::family`] and unlike [`Self::food_types`] — and that
|
||||
/// placement is INFERRED, not carved** (decision 1676). `GetPetIcon 0x4beb10` is registered
|
||||
/// adjacent to both in the same table (wow-re §11b's neighbour list) and has not been read.
|
||||
/// It is grouped with the family word because it is a pure family-row lookup like that one,
|
||||
/// where the diet's gate is shared with the four *stat* bindings. The choice is unobservable
|
||||
/// in the reference's own call sites — every one of them (`PetStable.lua:51`, `161-162`) sits
|
||||
/// downstream of the window's own warlock early-return — so a warlock can never reach it there
|
||||
/// either way; only an addon could tell the difference.
|
||||
pub icon: Option<String>,
|
||||
/// `GetPetFoodTypes()` — the localized diet names the family's pet-food mask selects, in
|
||||
/// **record order** (`0x4bea10`: bit `1 << (recordID - 1)` against `CreatureFamily` column 7,
|
||||
/// the name from `ItemPetFood` column `1 + locale`). Varargs; the client returns the count and
|
||||
@@ -512,6 +526,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPetIcon() → the pet's icon path, or NIL. One return. The stable window reads it both as a
|
||||
// predicate ("is there a current pet?", `PetStable.lua:51`) and as the texture itself (`161`),
|
||||
// so the nil must be a real nil rather than an empty string — `SetItemButtonTexture` would
|
||||
// happily take "" and a path resolving to nothing draws WHITE (decision 1046).
|
||||
g.set(
|
||||
"GetPetIcon",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(match model.pet_bar.stats.icon.as_deref() {
|
||||
Some(path) => Value::String(lua.create_string(path)?),
|
||||
None => Value::Nil,
|
||||
})
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetPetFoodTypes() → the diet names as VARARGS, one Lua return per food type, in record
|
||||
// order — the shape `BuildListString(GetPetFoodTypes())` needs (ref
|
||||
// `PetPaperDollFrame.xml:269`). `0x4bea10` returns the pushed COUNT and never a nil, so the
|
||||
@@ -866,9 +895,32 @@ mod tests {
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// `GetPetIcon` — one return, a real nil when there is no pet. The stable window reads it BOTH
|
||||
/// as a predicate and as the texture it passes to `SetItemButtonTexture`, so an empty string
|
||||
/// standing in for the nil would draw a white square rather than the empty-slot art
|
||||
/// (decision 1046's law, decision 1676's consumer).
|
||||
#[test]
|
||||
fn the_pet_icon_answers_a_path_or_a_real_nil() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert!(s.eval::<bool>("return GetPetIcon() == nil").unwrap());
|
||||
|
||||
s.set_pet_stats(true, hunter_stats());
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetPetIcon()").unwrap(),
|
||||
"Interface\\Icons\\Ability_Hunter_Pet_Boar"
|
||||
);
|
||||
|
||||
// Exactly one return — the reference assigns it straight into a single local.
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return select('#', GetPetIcon())").unwrap(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
fn hunter_stats() -> PetStats {
|
||||
PetStats {
|
||||
hunter_pet: true,
|
||||
icon: Some("Interface\\Icons\\Ability_Hunter_Pet_Boar".into()),
|
||||
happiness: Some(3),
|
||||
damage_percentage: 125.0,
|
||||
loyalty_rate: 20.0,
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
//! The stable-master bindings (decision 1676) — the hunter stable window's Lua surface, the same
|
||||
//! two-way seam as [`super::bank`] and [`super::trainer`]: the app pushes a **stable snapshot**
|
||||
//! ([`UiScript::set_stable`] — the wire's pet rows already resolved to icon/family/loyalty/diet
|
||||
//! strings), and the Lua's click/purchase/close calls queue outbound **intents** the app drains.
|
||||
//!
|
||||
//! ## Three slots, and the current pet is one of them
|
||||
//!
|
||||
//! The window shows **`0..=2`**: slot `0` is the pet at the player's side, slots `1` and `2` are the
|
||||
//! two stable slots a hunter buys (5875 ships exactly two — `StableSlotPrices.dbc` has two rows,
|
||||
//! vmangos's `MAX_PET_STABLES` is 2, and the reference's `NUM_PET_STABLE_SLOTS` is 2). The wire is
|
||||
//! 1-based over these; [`benilla_protocol::messages::StabledPet::slot`] already rebased it, so
|
||||
//! everything here speaks the reference UI's own indices.
|
||||
//!
|
||||
//! **Slot 0 can be occupied while the player has no pet out.** A hunter whose pet is dismissed (or
|
||||
//! merely too far away to be summoned) still gets a slot-0 row from the server, read off the
|
||||
//! character-pet cache — which is exactly why the reference falls back to `GetStablePetInfo(0)`
|
||||
//! when `UnitExists("pet")` is false (`PetStable.lua:131-146`) instead of showing an empty slot.
|
||||
//!
|
||||
//! ## What the snapshot resolves, and why the app does it
|
||||
//!
|
||||
//! The wire names a `creature_template` entry and a loyalty *level*; the window wants an icon, a
|
||||
//! localized family word, a loyalty *name* and a diet list. Every one of those is a catalog join
|
||||
//! the app already owns for the live pet ([`super::pet`], decisions 1005/1062), so the app does the
|
||||
//! join once and pushes strings — this module never sees a DBC. The one join with no live-pet twin
|
||||
//! is the icon of a pet that is *not* summoned: it comes from the creature query's display id,
|
||||
//! which is why that field stopped being discarded (decision 1676).
|
||||
//!
|
||||
//! ## The drag is FRAME-LOCAL, not the cursor
|
||||
//!
|
||||
//! `PickupStablePet` does **not** put anything on the global cursor. wow-re's exhaustive census of
|
||||
//! every write to the payload-mode global `[0xb4d900]`
|
||||
//! (`system/ui/scratch/cursor-dragdrop-payload.md` §1) enumerates the modes — item, money, spell,
|
||||
//! pet action, macro, the three displayId previews, class/talent ability — and **there is no
|
||||
//! stable-pet mode**. So a picked-up stable pet is state the stable window keeps to itself
|
||||
//! ([`StableModel::pickup`]), and no other surface can ever be holding one. Building this on
|
||||
//! [`super::cursor::CursorPayload`] would have invented a mode the client does not have.
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::Model;
|
||||
|
||||
/// Window slots: the current pet (`0`) plus the two stable slots — the reference's
|
||||
/// `NUM_PET_STABLE_SLOTS = 2` counted inclusively from zero (`PetStable.lua:1`).
|
||||
pub const NUM_STABLE_SLOTS: usize = 3;
|
||||
|
||||
/// One row of the stable window, with every wire field already resolved to what the Lua renders.
|
||||
/// `None` in [`StableState::slots`] is an empty slot — which the window draws differently from an
|
||||
/// *unbought* one (that distinction is [`StableState::num_stable_slots`]'s).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StablePetSlot {
|
||||
/// The pet's own id — what [`StableIntent::Unstable`]/[`StableIntent::Swap`] name on the wire.
|
||||
/// Never its slot: the two disagree for any hunter whose stable is not in id order.
|
||||
pub pet_number: u32,
|
||||
/// `Interface\Icons\…` for the pet's family, or `None` while the creature query is in flight.
|
||||
/// The reference passes this straight to `SetItemButtonTexture`, which takes an empty texture
|
||||
/// for a missing icon — so `None` renders the empty-slot art, not a white square (decision
|
||||
/// 1046's sweep).
|
||||
pub icon: Option<String>,
|
||||
/// The name the hunter gave the pet, not the creature template's.
|
||||
pub name: String,
|
||||
pub level: u32,
|
||||
/// The localized `CreatureFamily.dbc` word ("Wolf", "Cat"). `None` when the creature query has
|
||||
/// not landed — the reference concatenates it into the level line unguarded, so the binding
|
||||
/// substitutes an empty string rather than handing Lua a nil to concatenate.
|
||||
pub family: Option<String>,
|
||||
/// The localized `PetLoyalty.dbc` name for the wire's loyalty level.
|
||||
pub loyalty: Option<String>,
|
||||
/// The localized pet-food names this pet's family eats — `GetStablePetFoodTypes`'s returns,
|
||||
/// which the reference joins with `BuildListString` into the diet tooltip.
|
||||
pub diet: Vec<String>,
|
||||
}
|
||||
|
||||
/// The open stable window's snapshot. Pushed whole while a stable session is open; `None` = no
|
||||
/// stable open (the window is closed).
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct StableState {
|
||||
/// Stable slots **purchased**, `0..=2` (the wire's `numStableSlots`). Not a count of occupied
|
||||
/// ones: it is what enables slot buttons `1..=num` and greys the rest, and what prices the next
|
||||
/// purchase.
|
||||
pub num_stable_slots: u32,
|
||||
/// The next slot's price in copper (`StableSlotPrices.dbc` row `num_stable_slots + 1`), or `0`
|
||||
/// past the table — a state in which the reference has already hidden the purchase row.
|
||||
pub next_slot_cost: u32,
|
||||
/// The three window slots; index `0` is the current pet.
|
||||
pub slots: [Option<StablePetSlot>; NUM_STABLE_SLOTS],
|
||||
}
|
||||
|
||||
impl StableState {
|
||||
/// How many of the three slots hold a pet — `GetNumStablePets()`.
|
||||
fn num_pets(&self) -> u32 {
|
||||
self.slots.iter().filter(|s| s.is_some()).count() as u32
|
||||
}
|
||||
}
|
||||
|
||||
/// An outbound stable verb the Lua asked for, drained by the app (which addresses it to the open
|
||||
/// session's NPC — the guid is the app's, never Lua's).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum StableIntent {
|
||||
/// `CMSG_STABLE_PET` — put the current pet away. **Carries no destination**: the server takes
|
||||
/// the first free bought slot itself, so there is no "stable into slot 2" to express.
|
||||
Stable,
|
||||
/// `CMSG_UNSTABLE_PET` — summon this pet number, valid only with no current pet.
|
||||
Unstable(u32),
|
||||
/// `CMSG_STABLE_SWAP_PET` — trade the current pet for this pet number, in one step.
|
||||
Swap(u32),
|
||||
/// `CMSG_BUY_STABLE_SLOT` — buy the next slot; the *which* is implicit, as with the bank's.
|
||||
BuySlot,
|
||||
}
|
||||
|
||||
/// The window's own transient state — selection and the frame-local drag (module doc: the cursor is
|
||||
/// not involved). Lives beside the snapshot rather than inside it because the app *replaces* the
|
||||
/// snapshot on every list packet, and a repaint must not drop what the player has selected.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct StableModel {
|
||||
pub(crate) state: Option<StableState>,
|
||||
/// The selected slot, or `-1` for none — `GetSelectedStablePet()`'s exact return, including its
|
||||
/// sentinel, because the reference tests `== -1` directly (`PetStable.lua:44`).
|
||||
pub(crate) selected: i32,
|
||||
/// The slot a drag started from, `None` when nothing is being dragged.
|
||||
pub(crate) pickup: Option<u8>,
|
||||
pub(crate) intents: Vec<StableIntent>,
|
||||
pub(crate) close: bool,
|
||||
}
|
||||
|
||||
/// **`selected` starts at `-1`, not at `0`** — a derived `Default` would make it `0`, and `0` is a
|
||||
/// real slot (the current pet). The reference reads the sentinel to decide whether to pick a slot
|
||||
/// for the player at all (`PetStable.lua:44-59`): defaulted to `0`, a freshly opened window would
|
||||
/// believe the current pet was already chosen and would never auto-select a stabled one for a
|
||||
/// hunter with no pet out — the window would open showing nothing, with no way to tell why.
|
||||
impl Default for StableModel {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
state: None,
|
||||
selected: -1,
|
||||
pickup: None,
|
||||
intents: Vec::new(),
|
||||
close: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl super::UiScript {
|
||||
/// Push (or clear, with `None`) the open stable's snapshot.
|
||||
///
|
||||
/// **Clearing resets the window's transient state too** — selection and any half-finished drag.
|
||||
/// A stale selection surviving a close would have the next stable master open on slot 2
|
||||
/// highlighted before its list arrived. A *content* change deliberately does not reset it: the
|
||||
/// list is re-requested after every successful action, and losing the selection on each one
|
||||
/// would fight the player.
|
||||
pub fn set_stable(&mut self, state: Option<StableState>) {
|
||||
let mut model = self.model_mut();
|
||||
if state.is_none() {
|
||||
model.stable.selected = -1;
|
||||
model.stable.pickup = None;
|
||||
}
|
||||
model.stable.state = state;
|
||||
}
|
||||
|
||||
/// Drain the queued stable verbs (module doc: the app addresses them to the open NPC).
|
||||
pub fn take_stable_intents(&mut self) -> Vec<StableIntent> {
|
||||
std::mem::take(&mut self.model_mut().stable.intents)
|
||||
}
|
||||
|
||||
/// Whether `ClosePetStables()` was called since the last drain (and clear the flag). No packet
|
||||
/// exists for it — the app just clears its local session, the bank/merchant pattern.
|
||||
pub fn take_stable_close(&mut self) -> bool {
|
||||
std::mem::take(&mut self.model_mut().stable.close)
|
||||
}
|
||||
|
||||
/// The selected slot (`-1` = none) — what the app's booth points the model pane at.
|
||||
pub fn stable_selection(&mut self) -> i32 {
|
||||
self.model_mut().stable.selected
|
||||
}
|
||||
}
|
||||
|
||||
/// Read a slot argument into an index into [`StableState::slots`]. Out-of-range answers `None`, so
|
||||
/// every binding below degrades to the reference's empty-slot behaviour rather than panicking on a
|
||||
/// stray addon call.
|
||||
fn slot_index(i: i64) -> Option<usize> {
|
||||
usize::try_from(i).ok().filter(|&i| i < NUM_STABLE_SLOTS)
|
||||
}
|
||||
|
||||
/// Commit a drag from `from` onto `to`, returning the verb it becomes — the **one** place the
|
||||
/// stable's move law lives.
|
||||
///
|
||||
/// **INFERRED, pending the wow-re carve of `ClickStablePet 0x4ca…` (decision 1676).** The verb set
|
||||
/// is VERIFIED (vmangos's three handlers and their preconditions); what is *not* yet read off the
|
||||
/// binary is how the reference client picks among them. This mapping is derived from the server's
|
||||
/// own constraints, each arm justified:
|
||||
///
|
||||
/// - **current → a stable slot** is `CMSG_STABLE_PET`. The destination index is *discarded*: the
|
||||
/// wire carries no slot and `HandleStablePet` takes the first free one, so dropping on slot 2
|
||||
/// while slot 1 is empty puts the pet in slot 1. That is the server's behaviour, not a
|
||||
/// simplification here.
|
||||
/// - **a stable slot → current** is `Swap` when a pet is out and `Unstable` when none is
|
||||
/// (`HandleUnstablePet` refuses outright if the player has a pet, even an unsummoned one).
|
||||
/// - **stable → stable** has no opcode in 5875 at all, so it can only be a no-op.
|
||||
///
|
||||
/// If the carve says the client instead ignores the drop target and acts on the *source* alone,
|
||||
/// only this function changes.
|
||||
fn drag_verb(from: u8, to: u8, has_current_pet: bool, pet_number: u32) -> Option<StableIntent> {
|
||||
match (from, to) {
|
||||
(0, 1..) => Some(StableIntent::Stable),
|
||||
(1.., 0) if has_current_pet => Some(StableIntent::Swap(pet_number)),
|
||||
(1.., 0) => Some(StableIntent::Unstable(pet_number)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Register the stable globals.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
|
||||
// GetStablePetInfo(i) → icon, name, level, family, loyalty — five returns, or a bare nil for an
|
||||
// empty slot (the reference tests `if ( icon )` on the first and `if ( GetStablePetInfo(i) )`
|
||||
// on the call itself, so both shapes must read as absent). `family` substitutes "" rather than
|
||||
// nil: the reference concatenates it into the level line unguarded (`PetStable.lua:100`).
|
||||
g.set(
|
||||
"GetStablePetInfo",
|
||||
lua.create_function(|lua, i: i64| {
|
||||
let pet = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
slot_index(i).and_then(|i| model.stable.state.as_ref()?.slots[i].clone())
|
||||
};
|
||||
let Some(pet) = pet else {
|
||||
return Ok(MultiValue::new());
|
||||
};
|
||||
Ok(MultiValue::from_vec(vec![
|
||||
match &pet.icon {
|
||||
Some(t) => Value::String(lua.create_string(t)?),
|
||||
None => Value::Nil,
|
||||
},
|
||||
Value::String(lua.create_string(&pet.name)?),
|
||||
Value::Integer(i64::from(pet.level)),
|
||||
Value::String(lua.create_string(pet.family.as_deref().unwrap_or(""))?),
|
||||
match &pet.loyalty {
|
||||
Some(l) => Value::String(lua.create_string(l)?),
|
||||
None => Value::Nil,
|
||||
},
|
||||
]))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetStablePetFoodTypes(i) → the localized diet names, one return each (the reference feeds the
|
||||
// lot to BuildListString). Nothing at all for an empty slot or a pet whose family has no diet —
|
||||
// the reference guards with `if ( GetStablePetFoodTypes(i) )` before formatting the tooltip.
|
||||
g.set(
|
||||
"GetStablePetFoodTypes",
|
||||
lua.create_function(|lua, i: i64| {
|
||||
let diet = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
slot_index(i)
|
||||
.and_then(|i| {
|
||||
Some(model.stable.state.as_ref()?.slots[i].as_ref()?.diet.clone())
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut out = Vec::with_capacity(diet.len());
|
||||
for d in &diet {
|
||||
out.push(Value::String(lua.create_string(d)?));
|
||||
}
|
||||
Ok(MultiValue::from_vec(out))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetNumStableSlots() → slots PURCHASED (0..=2). The reference both enables buttons `i <= n`
|
||||
// and hides the purchase row at `n == NUM_PET_STABLE_SLOTS` off this one number.
|
||||
g.set(
|
||||
"GetNumStableSlots",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(i64::from(
|
||||
model
|
||||
.stable
|
||||
.state
|
||||
.as_ref()
|
||||
.map_or(0, |s| s.num_stable_slots),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetNumStablePets() → how many of the three slots hold a pet.
|
||||
g.set(
|
||||
"GetNumStablePets",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(i64::from(
|
||||
model.stable.state.as_ref().map_or(0, StableState::num_pets),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetNextStableSlotCost() → the next slot's price in copper (the app read it from
|
||||
// StableSlotPrices.dbc). 0 with no stable open, and 0 past the table — where the reference has
|
||||
// already hidden the row that would show it.
|
||||
g.set(
|
||||
"GetNextStableSlotCost",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(i64::from(
|
||||
model.stable.state.as_ref().map_or(0, |s| s.next_slot_cost),
|
||||
))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// GetSelectedStablePet() → the selected slot, or -1. The sentinel is the API: the reference
|
||||
// tests `selectedPet == -1` to decide whether to pick a slot for the player.
|
||||
g.set(
|
||||
"GetSelectedStablePet",
|
||||
lua.create_function(|lua, ()| {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
Ok(i64::from(model.stable.selected))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ClickStablePet(i) → did anything change (the reference repaints only on true).
|
||||
//
|
||||
// Two jobs, because the reference wires this to BOTH `OnClick` and `OnReceiveDrag`: commit a
|
||||
// drag if one is in flight, otherwise select. The commit half's verb choice is `drag_verb`'s —
|
||||
// and INFERRED until the carve lands (see there).
|
||||
g.set(
|
||||
"ClickStablePet",
|
||||
lua.create_function(|lua, i: i64| {
|
||||
let Some(to) = slot_index(i) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let to = to as u8;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(state) = model.stable.state.as_ref() else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
if let Some(from) = model.stable.pickup {
|
||||
// A drag lands. It is consumed whatever it turns into — including the
|
||||
// stable→stable no-op, which must still put the cursor down rather than leave the
|
||||
// window holding a pet nothing can drop.
|
||||
let has_current = state.slots[0].is_some();
|
||||
let pet_number = state.slots[from as usize]
|
||||
.as_ref()
|
||||
.map_or(0, |p| p.pet_number);
|
||||
model.stable.pickup = None;
|
||||
if from == to || pet_number == 0 {
|
||||
return Ok(false);
|
||||
}
|
||||
if let Some(intent) = drag_verb(from, to, has_current, pet_number) {
|
||||
model.stable.intents.push(intent);
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// A plain click selects. Selecting the already-selected slot changes nothing, and
|
||||
// saying so keeps the reference from repainting the whole window on every click.
|
||||
if model.stable.selected == i32::from(to) {
|
||||
return Ok(false);
|
||||
}
|
||||
model.stable.selected = i32::from(to);
|
||||
Ok(true)
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// PickupStablePet(i) — begin a frame-local drag (module doc: NOT the global cursor). Picking up
|
||||
// an empty slot is ignored, exactly as the reference's own empty buttons carry no pet to drag.
|
||||
g.set(
|
||||
"PickupStablePet",
|
||||
lua.create_function(|lua, i: i64| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let occupied = slot_index(i)
|
||||
.and_then(|i| Some(model.stable.state.as_ref()?.slots[i].is_some()))
|
||||
.unwrap_or(false);
|
||||
if occupied {
|
||||
model.stable.pickup = Some(i as u8);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// SetPetStablePaperdoll(model) — inert here, deliberately, and this is the same divergence
|
||||
// PetPaperDollFrame.xml records for `PetModelFrame:SetUnit("pet")`: benilla's model panes are
|
||||
// app-side booths that follow the selection every frame, so there is no VM-side unit to point.
|
||||
// The binding still exists because the reference calls it (four sites) and an addon may.
|
||||
g.set(
|
||||
"SetPetStablePaperdoll",
|
||||
lua.create_function(|_, _model: Value| Ok(()))?,
|
||||
)?;
|
||||
|
||||
// BuyStableSlot() — queue the purchase. The server prices and picks the slot; a refusal comes
|
||||
// back as SMSG_STABLE_RESULT's ERR_MONEY.
|
||||
g.set(
|
||||
"BuyStableSlot",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.stable.intents.push(StableIntent::BuySlot);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ClosePetStables() — client-side close, no packet exists (vmangos has no close opcode): flag
|
||||
// the app to clear its session.
|
||||
g.set(
|
||||
"ClosePetStables",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.stable.close = true;
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
fn pet(number: u32, name: &str, level: u32) -> StablePetSlot {
|
||||
StablePetSlot {
|
||||
pet_number: number,
|
||||
icon: Some("Interface\\Icons\\Ability_Hunter_Pet_Wolf".into()),
|
||||
name: name.into(),
|
||||
level,
|
||||
family: Some("Wolf".into()),
|
||||
loyalty: Some("(Loyalty Level 6) Best Friend".into()),
|
||||
diet: vec!["Meat".into(), "Fish".into()],
|
||||
}
|
||||
}
|
||||
|
||||
/// A hunter with a pet out and one stabled, one slot bought.
|
||||
fn open(s: &mut UiScript) {
|
||||
s.set_stable(Some(StableState {
|
||||
num_stable_slots: 1,
|
||||
next_slot_cost: 50_000,
|
||||
slots: [Some(pet(7, "Rex", 41)), Some(pet(8, "Bruiser", 38)), None],
|
||||
}));
|
||||
}
|
||||
|
||||
/// The read surface, through the reference's own destructuring. A closed window answers the
|
||||
/// absent shape everywhere rather than erroring — the frame's OnLoad runs before any list.
|
||||
#[test]
|
||||
fn the_read_surface_answers_the_reference_calls() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert_eq!(s.eval::<i64>("return GetNumStableSlots()").unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>("return GetNumStablePets()").unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>("return GetNextStableSlotCost()").unwrap(), 0);
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), -1);
|
||||
assert!(s.eval::<bool>("return GetStablePetInfo(0) == nil").unwrap());
|
||||
|
||||
open(&mut s);
|
||||
assert_eq!(s.eval::<i64>("return GetNumStableSlots()").unwrap(), 1);
|
||||
assert_eq!(s.eval::<i64>("return GetNumStablePets()").unwrap(), 2);
|
||||
assert_eq!(
|
||||
s.eval::<i64>("return GetNextStableSlotCost()").unwrap(),
|
||||
50_000
|
||||
);
|
||||
|
||||
// `PetStable.lua:76` — the exact five-value destructuring the window renders from.
|
||||
assert_eq!(
|
||||
s.eval::<(String, String, i64, String, String)>(
|
||||
"local i, n, l, f, loy = GetStablePetInfo(1) return i, n, l, f, loy"
|
||||
)
|
||||
.unwrap(),
|
||||
(
|
||||
"Interface\\Icons\\Ability_Hunter_Pet_Wolf".into(),
|
||||
"Bruiser".into(),
|
||||
38,
|
||||
"Wolf".into(),
|
||||
"(Loyalty Level 6) Best Friend".into()
|
||||
)
|
||||
);
|
||||
// The empty third slot reads absent on both tests the reference makes.
|
||||
assert!(s.eval::<bool>("return GetStablePetInfo(2) == nil").unwrap());
|
||||
// An out-of-range index is absent too, not an error.
|
||||
assert!(s.eval::<bool>("return GetStablePetInfo(9) == nil").unwrap());
|
||||
|
||||
// The diet list, as BuildListString receives it.
|
||||
assert_eq!(
|
||||
s.eval::<(String, String)>("return GetStablePetFoodTypes(1)")
|
||||
.unwrap(),
|
||||
("Meat".into(), "Fish".into())
|
||||
);
|
||||
assert!(s
|
||||
.eval::<bool>("return GetStablePetFoodTypes(2) == nil")
|
||||
.unwrap());
|
||||
}
|
||||
|
||||
/// A plain click selects, and reports whether anything changed — the reference repaints only
|
||||
/// on true, so a click on the already-selected slot must answer false or the window redraws
|
||||
/// itself on every click.
|
||||
#[test]
|
||||
fn a_plain_click_selects_once() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
open(&mut s);
|
||||
assert!(s.eval::<bool>("return ClickStablePet(1)").unwrap());
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 1);
|
||||
assert!(!s.eval::<bool>("return ClickStablePet(1)").unwrap());
|
||||
assert!(s.eval::<bool>("return ClickStablePet(0)").unwrap());
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 0);
|
||||
// Selecting queues nothing on the wire.
|
||||
assert!(s.take_stable_intents().is_empty());
|
||||
}
|
||||
|
||||
/// Dragging the current pet onto a stable slot stables it — and the destination is *not* sent,
|
||||
/// because the wire cannot carry one.
|
||||
#[test]
|
||||
fn dragging_the_current_pet_out_stables_it() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
open(&mut s);
|
||||
s.eval::<()>("PickupStablePet(0) ClickStablePet(2)")
|
||||
.unwrap();
|
||||
assert_eq!(s.take_stable_intents(), vec![StableIntent::Stable]);
|
||||
}
|
||||
|
||||
/// Dragging a stabled pet onto the current slot forks on whether a pet is already out: a swap
|
||||
/// when one is, a plain unstable when none is. The server refuses an unstable outright while a
|
||||
/// pet exists, so picking the wrong one here fails every drag for a hunter with a pet.
|
||||
#[test]
|
||||
fn dragging_a_stabled_pet_in_forks_on_having_a_pet() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
open(&mut s);
|
||||
s.eval::<()>("PickupStablePet(1) ClickStablePet(0)")
|
||||
.unwrap();
|
||||
assert_eq!(s.take_stable_intents(), vec![StableIntent::Swap(8)]);
|
||||
|
||||
// Same drag with slot 0 empty — the hunter has no pet out.
|
||||
s.set_stable(Some(StableState {
|
||||
num_stable_slots: 1,
|
||||
next_slot_cost: 50_000,
|
||||
slots: [None, Some(pet(8, "Bruiser", 38)), None],
|
||||
}));
|
||||
s.eval::<()>("PickupStablePet(1) ClickStablePet(0)")
|
||||
.unwrap();
|
||||
assert_eq!(s.take_stable_intents(), vec![StableIntent::Unstable(8)]);
|
||||
}
|
||||
|
||||
/// A drag that has no verb still puts the pet down. Stable→stable has no opcode in 5875, and a
|
||||
/// drag onto its own slot is a cancel; neither may leave the window holding a pet forever.
|
||||
#[test]
|
||||
fn a_verbless_drag_is_still_consumed() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_stable(Some(StableState {
|
||||
num_stable_slots: 2,
|
||||
next_slot_cost: 0,
|
||||
slots: [None, Some(pet(8, "Bruiser", 38)), None],
|
||||
}));
|
||||
s.eval::<()>("PickupStablePet(1) ClickStablePet(2)")
|
||||
.unwrap();
|
||||
assert!(s.take_stable_intents().is_empty());
|
||||
// The pickup is gone, so the NEXT click selects rather than committing a stale drag.
|
||||
assert!(s.eval::<bool>("return ClickStablePet(1)").unwrap());
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 1);
|
||||
assert!(s.take_stable_intents().is_empty());
|
||||
}
|
||||
|
||||
/// An empty slot carries nothing to drag, so picking it up leaves no pickup armed — otherwise
|
||||
/// the next click would commit a move for a pet that does not exist.
|
||||
#[test]
|
||||
fn an_empty_slot_cannot_be_picked_up() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
open(&mut s);
|
||||
s.eval::<()>("PickupStablePet(2) ClickStablePet(0)")
|
||||
.unwrap();
|
||||
assert!(s.take_stable_intents().is_empty());
|
||||
assert_eq!(s.eval::<i64>("return GetSelectedStablePet()").unwrap(), 0);
|
||||
}
|
||||
|
||||
/// The purchase and close intents, and the close's state reset: a selection must not survive
|
||||
/// into the next stable master's window.
|
||||
#[test]
|
||||
fn purchase_and_close_intents_drain() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
open(&mut s);
|
||||
s.eval::<()>("ClickStablePet(1) BuyStableSlot()").unwrap();
|
||||
assert_eq!(s.take_stable_intents(), vec![StableIntent::BuySlot]);
|
||||
assert!(s.take_stable_intents().is_empty(), "drain clears");
|
||||
|
||||
assert!(!s.take_stable_close());
|
||||
s.eval::<()>("ClosePetStables()").unwrap();
|
||||
assert!(s.take_stable_close());
|
||||
assert!(!s.take_stable_close(), "drain clears");
|
||||
|
||||
assert_eq!(s.stable_selection(), 1);
|
||||
s.set_stable(None);
|
||||
assert_eq!(s.stable_selection(), -1, "a close forgets the selection");
|
||||
}
|
||||
|
||||
/// A content refresh — which benilla does after every successful action — must KEEP the
|
||||
/// selection. Resetting it there would move the player's highlight on every button press.
|
||||
#[test]
|
||||
fn a_refresh_keeps_the_selection() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
open(&mut s);
|
||||
s.eval::<()>("ClickStablePet(1)").unwrap();
|
||||
open(&mut s);
|
||||
assert_eq!(s.stable_selection(), 1);
|
||||
}
|
||||
|
||||
/// `SetPetStablePaperdoll` exists and is harmless — the reference calls it at four sites, and a
|
||||
/// missing global would be a Lua error mid-repaint.
|
||||
#[test]
|
||||
fn the_paperdoll_setter_is_callable() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.eval::<()>("SetPetStablePaperdoll(nil)").unwrap();
|
||||
}
|
||||
}
|
||||
@@ -640,6 +640,7 @@ fn a_looted_roll_shows_its_lines_and_never_the_placeholder() {
|
||||
},
|
||||
);
|
||||
s.set_loot(Some(LootState {
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![Some(LootRow {
|
||||
name: Some("Bloodrazor of the Monkey".into()),
|
||||
texture: None,
|
||||
@@ -724,6 +725,7 @@ fn a_looted_item_with_no_roll_shows_neither_line_nor_placeholder() {
|
||||
},
|
||||
);
|
||||
s.set_loot(Some(LootState {
|
||||
master_candidates: Vec::new(),
|
||||
rows: vec![Some(LootRow {
|
||||
name: Some("Bloodrazor".into()),
|
||||
quantity: 1,
|
||||
|
||||
Reference in New Issue
Block a user