- a loot refusal is a message id, not a sentence: `loot_error_text` composed eight sentences for `SMSG_LOOT_RESPONSE`'s refusals and six of them said something 1.12 never says — "too far away to loot that." for a string that ends "that corpse.", "You can't loot that from there." for "You must be facing the corpse to loot it.", and an invented "Those pockets are already empty." for a code the client has no string for; it and the `LootErrors` queue are deleted, and the refusal is raised by GlobalStrings key into `UiErrorKeys` at the net bridge, the way `fish_verdict` two functions above already did, off the reference's own eleven-entry jump table at `0x5ebc64`; a composed string had no record to read a surface or a voice off, so all eleven — `MsgKind::Error`, the red `UIErrorsFrame` line — had been going into the chat window as yellow SYSTEM lines, and the four spoken in the player's own race and gender (`DIDNT_KILL`, `BAD_FACING`, `LOCKED`, `TOO_FAR`) were silent; a second bug fell out of reading the leg: codes 4–9, 11 and the default fall through a release tail at `0x5ebac2` that zeroes `[player+0x1d28]` — our `LootLatch` — while 10, 12, 13 and 14 jump past it, so a master looter refused a hand-off no longer stands up out of the kneel at a still-open corpse; the default arm's `ja 0x5ebabd` lands on code 7's arm, so every unnamed code prints `ERR_LOOT_DIDNT_KILL` and releases (2035); and the pet has its own two message tables, not the player's with a flag: `SMSG_PET_ACTION_FEEDBACK`'s handler `0x4bdb70` decrements the byte, bounds it at 3 and jump-tables it through `0x4bdbe8` to `ERR_PET_SPELL_DEAD`, `ERR_NO_ATTACK_TARGET`, `ERR_INVALID_ATTACK_TARGET`, `ERR_PET_SPELL_NOPATH` — one per vmangos `PetFeedback` value, where our two-entry map had printed "No path available for your pet" for a dead pet, "Out of range" for no target and nothing for 3 and 4; `SMSG_PET_CAST_FAILED` is `0x6e8eb0`, a separate 142-byte index table that overrides ten reasons, six of them to `ERR_PET_SPELL_*` rows with no other raise site in the client, so `CastFail` grows a `Caster` and picks its table from it while layers 1 and 3 stay shared, as the binary shares them; `0x6e8eb0` calls neither the combat-log formatter `0x62c360` nor the error sound `0x458a50`, so a pet's refused Growl no longer prints "You fail to cast Growl: …" as the player's own failure; and the source walk that exists to catch a non-catalog key was blind to both wrong keys because all 465 catalog keys begin `ERR_`, so the tripwire moves to the funnel — `Shown::keyed` warns on a key `by_key` cannot resolve (2033)
- the hunter-pet feedback cluster, five opcodes carved off the binary where four had sat name-table-only: `SMSG_PET_TAME_FAILURE` (`0x173`) is two GlobalStrings lookups in order — `0x6e6a20` maps `reason − 1` through the eleven-arm jump table at `0x6e6ac0` to a `PETTAME_*` key and hands the resolved string to `DisplayError(ERR_TAME_FAILED)`, anything outside `1..=11` taking `PETTAME_UNKNOWNERROR`, and Call Pet and Revive Pet raise it too; `SMSG_PET_NAME_INVALID` (`0x178`) raises `ERR_INVALID_PETNAME` from the shared dispatcher `0x5e38c0`'s arm `0x5e3e33` — 1066 had called a refused rename silent on a carve that gave `0x178` the handler `0x4bdc00`, which is `0x2AF SMSG_PET_BROKEN`'s and raises `ERR_PET_BROKEN`, body unread; `SMSG_PET_ACTION_SOUND` (`0x324`) is a bark, not a sound id — its two talk values reach the bark dispatcher `0x623a40` at states 1 and 2, i.e. `CreatureSoundData` columns 28 and 27, and the shipped DBC names them from the other side: exactly four of its 406 rows carry them, the Imp, Succubus, Doomguard and Voidwalker voices, as `A_<demon>_ORDER`/`A_<demon>_KILL`; `Latch::Voice` now carries the bark state as a number and every one-shot creature vocal goes through one `play_bark` that is `0x623a40` — aggro at 0, the pet barks at 1 and 2, the death cry at 4, which had been on a bare `play_kit` off the slot so a pet bark could talk over it — with the reference's `jle` priority rule, because under the old boolean slot a demon's voice would have lost every race against its own aggro flares and been inaudible; it is a warlock-demon surface end to end — vmangos only sends it for a summoned pet, at a 10% roll per order, and a hunter's wolf has neither the columns nor the packet; and the fifth opcode surfaced because the cross-check went looking for column 29's consumer: `SMSG_PET_DISMISS_SOUND` (`0x325`), handler `0x604140`, reads column 29 twice (`0x6041c4`/`0x6041dc`) off a row resolved fresh by `CreatureModelData` id rather than the cached `[unit+0xb40]` — which is why a census over the cache had called the column dead — and plays `A_<demon>_DISMISS` at the packet's point with `z + 1.0`, no unit and no latch; column 29 is loaded, the column nothing reads is 22 (zero on all 406 rows), and the dismiss handler is built although vmangos never sends the packet (2039)
- every UV scroll and animated tint in the world had been frozen for three days: 1940's deferred realization parks a freshly built `WowModelMaterial` behind a reserved handle until something view-visible binds it, and `doodad_anim::register_uv`/`register_tint` still reached for the store alone — `Assets::get_mut` answered `None` for every registration, the slot was freed, the registry stayed empty and the shader read the pinned-zero identity row for every batch, silently, because the `else` branch was a bare `return`; the fix is the address, not the timing: `Parked::value_mut`/`value`/`holds` reach a value wherever it currently lives and `lazy::with_material_mut`/`with_material`/`holds` publish that, so the one-shot build stamp lands in the parked value and rides realization in — realizing it to stamp it would have thrown away exactly what deferral buys; the tick's eviction on `Assets::contains` read a parked material as dead and asks `lazy::holds` now, the empty-registry early-out says so out loud when marked parts exist, and two instruments ship — `WOW_MATANIM_PROBE=<secs>` prints per animated part whether it is registered, whether its loop moves, what its table row holds and whether it is drawn, and `WOW_WORLDVIEW_AT=x,y[,z]` opens the server-less viewer anywhere; measured at the reported waterfall: 0 uv / 0 tint entries before, 87 uv / 6 tint around the Elwynn valley after, its two sheets on slots 1 and 2 with live rows −0.2122 and −0.4246 — the authored 1:2 ratio (2038); and the paper doll and the inspect pane are lit from the model's right: `CGLight+0x24` is the direction the light PROPAGATES — the `CharacterModelBase` ctor `0x505680` writes `(0, 1, 0)` and `0x71bce0` negates all three components before building the SH basis — so `model_pane_light()` hands the packer the ctor's vector as written where 0638 had negated it on the way in and lit both panes from the left; the ambient `(0.7, 0.7, 0.7)`, the diffuse `(0.8, 0.8, 0.64)` and the side-light analysis all hold, which is why the A/B that approved 0638 could not tell the two readings apart (2034)
- a unit tooltip whose name query has not answered titles `UNKNOWNOBJECT`, not a blank gold row: the reference's builder `0x529fe0` reads its NAME line through `CGUnit_C::GetUnitName 0x609210` — the same function `UnitName 0x517020` delegates to — and every miss on its three legs, the null creature record included, ends at the one `FrameScript_GetText("UNKNOWNOBJECT")` tail at `0x609324`, so `unknownobject` moves into `unit` proper and the plate and the verb share it, read out of the VM's `_G` with the binary's "Unknown Being" behind a missing global; the faction line stops waiting for the creature record — its entry gate `0x612610` returns 1 when there is none, so it resolves off `UNIT_FIELD_FACTIONTEMPLATE` and keeps only `HIDE_FACTION_TOOLTIP` for when the record lands; the reference's placeholder sticks for the whole hover (stock `GameTooltip.xml` registers `UPDATE_MOUSEOVER_UNIT` alone) and our line-change repaint, which replaces it when the answer lands, is kept (2040); a unit predicate answers the NUMBER 1 or nil, never a Lua boolean: `UnitExists`, `UnitIsDead`, `UnitIsGhost`, `UnitIsDeadOrGhost` and `UnitOnTaxi` pushed a boolean and twenty-three more open-coded the right shape in eighteen places, where the reference pushes through `lua_pushnumber 0x6f3810` or `lua_pushnil 0x6f37f0`; all twenty-eight now reach 1830's one push site `binding_abi::predicate` — via `unit::unit_predicate` for a `UnitState` field, directly otherwise — `UnitIsCharmed`'s `Number(1.0)` and `ClearTarget`'s hand-branched nil included, and `UnitOnTaxi` gains the reference's `Usage:` raise (gate `0x517a48`) where a nil token had raised mlua's type error; truthiness is why it survived (`if UnitIsDead(u)` reads the same either way, and `== nil` inverts), nine `eval::<bool>` tests were vacuous under mlua's coercion and `taxi.rs` had pinned `== false`/`== true` — the one shape the reference cannot emit — so a table-driven test covers all twenty-eight on both legs, and `UnitIsAFK`/`UnitIsDND` now say they are ours (zero occurrences image-wide) (2043); and the quest log's stock title width cap — `275 − 15 − tagWidth`, live off the chain since 1944 — ellipsizes on one line rather than wrapping into the 16-unit row: 1873 declined it reading an explicit width as a wrap width, but the title FontString declares `y="10"`, and a declared height arms both overflow regimes, so a 258 px title in a 206 px box paints as one line ending in three ASCII dots — the reference's own result, its ellipsis gate at `0x771ec0` being `boxW > 0 && (maxLines != 0 || boxH > 0)`, which only an auto-height FontString escapes; two tests pin the render law on the real FRIZQT face and the geometry the stock file hands it, including the cap's per-row-button stickiness, which is the reference's too (2036)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2429 lines
114 KiB
Rust
2429 lines
114 KiB
Rust
//! The app-side **loot feed** (decision 0084) — the inward half of the loot seam around
|
||
//! [`benilla_ui::script`]'s `loot` module, the twin of [`crate::ui_merchant`]'s merchant seam.
|
||
//!
|
||
//! The net bridge ([`crate::net::apply`]) fills [`LootState`] from the wire: `SMSG_LOOT_RESPONSE` →
|
||
//! the rows + coin pile ([`LootState::open`]); `SMSG_LOOT_REMOVED` → that row becomes an empty gap
|
||
//! at its fixed position ([`LootState::remove_slot`] — the layout never compacts while open);
|
||
//! `SMSG_LOOT_CLEAR_MONEY` → the coin row becomes the same kind of gap
|
||
//! ([`LootState::clear_money`]); `SMSG_LOOT_RELEASE_RESPONSE` → the window closes
|
||
//! ([`LootState::clear`]); the error shape → a red line by GlobalStrings key, raised straight from
|
||
//! the bridge ([`crate::net::apply::loot::loot_error`] — it needs no queue on this side);
|
||
//! `SMSG_ITEM_PUSH_RESULT` → a queued "You receive loot" line ([`LootState::receives`]). A removal
|
||
//! that empties the window arms the client-authoritative **auto-close**
|
||
//! ([`LootState::auto_release`] — the real engine's close-on-last-slot), released by
|
||
//! [`drain_loot`].
|
||
//!
|
||
//! Each frame [`feed_loot`] surfaces the receive lines, resolves each wire [`LootItem`] to a
|
||
//! Lua-facing [`LootRow`] (icon straight from the wire `display_info_id` through the same
|
||
//! `ItemDisplayInfo.dbc` catalog the bags use — no template wait; name + quality via the ask-once
|
||
//! item-template cache, `None`/re-fed while in flight), prepends the synthesized coin row when the
|
||
//! loot carries gold, pushes the snapshot ([`benilla_ui::script::UiScript::set_loot`]), and fires
|
||
//! `LOOT_OPENED` on open / `LOOT_UPDATE` on a content change / `LOOT_CLOSED` on clear.
|
||
//! [`drain_loot`] pulls the Lua intents back out: `LootSlot`
|
||
//! → 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::{slot_type, ItemPushResult, LootItem, BAG_PLAYER_INVENTORY};
|
||
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),
|
||
/// **six of them, one per decade of copper**, all VERIFIED to extract from `interface.MPQ`.
|
||
///
|
||
/// This used to be three, chosen by the highest nonzero denomination, and said so in a "stated
|
||
/// approximation" note whose stated reason was that the real selection was not RE-recorded. It is:
|
||
/// `0x4c2460` sends the money row to `0x4c248a call 0x6c62d0`, whose ladder at
|
||
/// `0x6c6307`–`0x6c6386` is exactly the six thresholds below (wow-re
|
||
/// `system/ui/scratch/loot-slot-record.md` §4). The gap closed when that note landed and nothing
|
||
/// here noticed — which is why a "documented approximation" is a thing to re-check against the
|
||
/// sibling repo, not a thing to leave standing.
|
||
///
|
||
/// The icon *order* is the client's own and it is not the numeric one — `_05, _06, _03, _04, _01,
|
||
/// _02` as the amount climbs. What each of the six pieces of art depicts is not claimed here; the
|
||
/// ladder is byte-derived and the art is whatever the reference picks at that step.
|
||
/// `item_template.bonding == BIND_WHEN_PICKED_UP` — the first of the two conjuncts that defer a
|
||
/// loot take behind the LOOT_BIND confirm (VERIFIED vmangos `ItemPrototype.h`'s `ItemBondingType`:
|
||
/// `NO_BIND` 0, `BIND_WHEN_PICKED_UP` 1, `BIND_WHEN_EQUIPPED` 2, `BIND_WHEN_USE` 3, `QUEST_ITEM` 4;
|
||
/// the client reads the same field at `item_template + 0x194` and compares it against `1`).
|
||
const BIND_WHEN_PICKED_UP: u32 = 1;
|
||
|
||
/// The second conjunct: quality **>= 2** (uncommon or better), `cmp [tmpl+0x1c], 2 / jb` at
|
||
/// `0x4c28fb`. Below it a bind-on-pickup row is simply taken — the confirm exists to stop a player
|
||
/// soulbinding something they meant to pass to a groupmate, and nobody passes a white.
|
||
const BIND_CONFIRM_MIN_QUALITY: u32 = 2;
|
||
|
||
/// The quality `GetLootSlotInfo` answers for the synthesized coin row: **0, Poor** — so the money
|
||
/// text renders GREY, not white.
|
||
///
|
||
/// Byte-derived, and it corrects a stated approximation ("common/white — the money text reads as
|
||
/// plain"). `0x4c23a0`'s second guard IS its coin leg: `0x4c23da test ecx,ecx` / `0x4c23dc jne`, so
|
||
/// the money row (0-based slot 0 with `[0xb71ba0] != 0`) falls through to
|
||
/// `0x4c23de xor eax,eax; ret` and never reaches the item-cache block the item rows read
|
||
/// `[rec+0x1c]` from. The same guard is why its `quantity` is 0 (`0x4c22fd`). wow-re
|
||
/// `system/ui/scratch/loot-slot-record.md` §10, a §5 round of five workers.
|
||
///
|
||
/// Nothing downstream re-colours it: stock `LootFrame_Update` has no coin special case
|
||
/// (`LootFrame.lua:81-85`) and `ITEM_QUALITY_COLORS[0]` is `0xff9d9d9d`.
|
||
const COIN_QUALITY: u32 = 0;
|
||
/// The 1.12 coin-denomination words, QUOTED from `Interface\FrameXML\GlobalStrings.lua` (verified
|
||
/// extract): `GOLD = "Gold"` (l.2025), `SILVER = "Silver"` (l.3465), `COPPER = "Copper"` (l.865).
|
||
const GOLD_WORD: &str = "Gold";
|
||
const SILVER_WORD: &str = "Silver";
|
||
const COPPER_WORD: &str = "Copper";
|
||
/// Give up re-checking a pending receive line's item name after this many frames (a negative-cached
|
||
/// or genuinely-unknown entry never resolves; ~2s at 60fps is well past a normal template round-trip).
|
||
const RECEIVE_MAX_TRIES: u16 = 120;
|
||
/// The player-array slots the reference calls "the keyring" when deciding which bag button a push
|
||
/// animates — the literal `0x51`/`0x70` bounds compiled into `OnItemPush` (`0x491bc3`/`0x491bc8`).
|
||
/// It is the **descriptor array's** full 32-guid width (vmangos `KEYRING_SLOT_START 81` + 32), not
|
||
/// the 16 addressable positions `ui_items`'s `KEYRING_SLOTS` names (81..96) — the client tests the
|
||
/// wider range, so this transcribes the client rather than deriving from the server's stricter one.
|
||
const PUSH_KEYRING_SLOTS: std::ops::RangeInclusive<u32> = 81..=112;
|
||
|
||
/// One deferred push (`SMSG_ITEM_PUSH_RESULT`) awaiting its item template — the whole
|
||
/// `CGGameUI::OnItemPush 0x491a60` tail, not just the chat line. Carries the pushed entry + count,
|
||
/// the source flags (looted / from an NPC / created — the wording differs), the two random-property
|
||
/// wire fields the item link carries, whether the line is spoken at all, the destination container
|
||
/// ([`push_container`], for the bag-bar drop animation) and a retry budget.
|
||
///
|
||
/// Deferring **both** outputs on the template is the reference's own shape: `OnItemPush` opens with
|
||
/// an item-cache lookup (`0x491a93`) and, on a miss, copies its nine arguments into a heap record and
|
||
/// re-enters itself from the cache callback (`0x491aa7`-`0x491b05`, callback `0x491ee0`) — nothing is
|
||
/// emitted until the item is known.
|
||
struct PendingReceive {
|
||
/// `SMSG_ITEM_PUSH_RESULT`'s created flag — a crafted/conjured item ("You create: …").
|
||
created: bool,
|
||
entry: u32,
|
||
count: u32,
|
||
from_npc: bool,
|
||
/// `SMSG_ITEM_PUSH_RESULT`'s `randomPropertyId` — link field 3 (see [`receive_line`]).
|
||
random_property_id: u32,
|
||
/// `SMSG_ITEM_PUSH_RESULT`'s `suffixFactor` — link field 4 (see [`receive_line`]).
|
||
suffix_factor: u32,
|
||
/// The wire's `showInChat` — whether the chat LINE is spoken. It gates the line ONLY: the
|
||
/// reference fires `ITEM_PUSH` at `0x491be8`, before the `[ebx+0x24]` test at `0x491bf3` that
|
||
/// guards the whole chat block, so a silent push still animates. Which is why this rides in the
|
||
/// record instead of short-circuiting at the net bridge (decision 0887).
|
||
in_chat: bool,
|
||
/// Which bag-bar button the drop animation plays on — see [`push_container`].
|
||
container: i64,
|
||
tries: u16,
|
||
}
|
||
|
||
/// The wire push's `(bag, slot)` → `ITEM_PUSH`'s **`arg1`**, in the reference's own vocabulary:
|
||
/// `0` the backpack, `20..=23` an equipped bag (the INVENTORY-slot id its bag buttons carry),
|
||
/// [`KEYRING_CONTAINER`] the keyring.
|
||
///
|
||
/// **Byte-VERIFIED** at `CGGameUI::OnItemPush` `0x491bb5`-`0x491bd6`, which is the whole selector:
|
||
///
|
||
/// ```text
|
||
/// 491bb5 cmp edi,0xff ; edi = the wire `bag` byte
|
||
/// 491bbb lea eax,[edi+1] ; bag != 255 ⇒ bag + 1
|
||
/// 491bbe jne 491bd6
|
||
/// 491bc0 mov eax,[ebp-0x4] ; else look at the wire `slot`
|
||
/// 491bc3 cmp eax,0x51 ; 81 = vmangos KEYRING_SLOT_START
|
||
/// 491bc6 jl 491bd4
|
||
/// 491bc8 cmp eax,0x70 ; 112 = the last keyring position
|
||
/// 491bcb jg 491bd4
|
||
/// 491bcd mov eax,-2 ; ⇒ KEYRING_CONTAINER
|
||
/// 491bd4 xor eax,eax ; ⇒ 0 (the backpack)
|
||
/// ```
|
||
///
|
||
/// **No translation any more, and that is the point.** This used to emit benilla's own container
|
||
/// vocabulary (`1..=4` for the four bags), because benilla's bag BAR was ours and was keyed that
|
||
/// way. 1751 window 3 made the bar the reference's own `MainMenuBarBagButtons.xml`, whose
|
||
/// `ItemAnim_OnEvent` reads `this:GetParent():GetID()` — 20..23, from `GetInventorySlotInfo` — and
|
||
/// compares it to `arg1`. Against the translated value that comparison is false for every bag, so
|
||
/// the four bag cards would simply never have played, silently. The fix is not to adapt the
|
||
/// reference's body: it is to stop translating, which is also what every addon reading `ITEM_PUSH`
|
||
/// expects, so the function is now `0x491bb5`'s selector verbatim.
|
||
///
|
||
/// A non-equipped-bag container (a bank bag, wire 63..68) still lands on an id no bag-bar button
|
||
/// carries — no animation, exactly as there.
|
||
fn push_container(bag: u8, slot: u32) -> i64 {
|
||
if bag != BAG_PLAYER_INVENTORY {
|
||
// `491bbb lea eax,[edi+1]` — the wire bag byte plus one, nothing else.
|
||
return i64::from(bag) + 1;
|
||
}
|
||
if PUSH_KEYRING_SLOTS.contains(&slot) {
|
||
return KEYRING_CONTAINER;
|
||
}
|
||
0
|
||
}
|
||
|
||
/// The open loot, filled by the net bridge and read by [`feed_loot`]. Holds the looted guid, the coin
|
||
/// pile, and the rows exactly as the wire delivered them (`SMSG_LOOT_RESPONSE`); the feed resolves
|
||
/// each to a display row and the drain maps a clicked 1-based row to its wire loot slot. Cleared on
|
||
/// release and on disconnect. `receives` outlives the window (a vendor buy pushes one with no loot
|
||
/// open) and is cleared only on disconnect.
|
||
///
|
||
/// **The slot layout is FIXED at open** (the reference's own slot array): a looted row — item or
|
||
/// coin — becomes an empty *gap* at its position, never a compaction. The real client's
|
||
/// `LOOT_SLOT_CLEARED` hides one button in place (`LootFrame.lua:22-37`), `numLootItems` is read
|
||
/// once at OnShow (l.132), and a cleared slot answers neither `LootSlotIsItem` nor `LootSlotIsCoin`
|
||
/// (l.80) — so the rows below a looted one keep their positions until the window closes. Modelled
|
||
/// here as `coin_slot` (does position 1 belong to the coin pile, looted or not) + `taken` (which
|
||
/// wire slots are already gone); [`snapshot`] emits `None` rows for the gaps.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct LootState {
|
||
/// The lootable unit whose window is open; `None` = no loot open.
|
||
source: Option<u64>,
|
||
/// The coin pile in copper; `0` = no coin left to loot.
|
||
gold: u32,
|
||
/// Whether the layout's position 1 is the coin pile — fixed at open (`gold > 0` then), and it
|
||
/// stays `true` after the coin is looted: the slot becomes a gap, not a vacancy the items
|
||
/// below shift into.
|
||
coin_slot: bool,
|
||
/// The item rows (wire order); each carries its own **wire** loot slot (`LootItem::slot`).
|
||
/// Never shrinks while the window is open — a looted row's wire slot lands in `taken` instead.
|
||
items: Vec<LootItem>,
|
||
/// Wire slots already looted by anyone (`SMSG_LOOT_REMOVED`) — their rows stay in the layout
|
||
/// as empty gaps.
|
||
taken: Vec<u8>,
|
||
/// Deferred "You receive …" lines awaiting their item name.
|
||
receives: Vec<PendingReceive>,
|
||
/// A wire removal just emptied the open window (last item taken / coin line cleared with no
|
||
/// items left) — the client-authoritative auto-close is due: the real client closes the loot
|
||
/// itself when the last slot clears (the server never initiates a creature-loot release —
|
||
/// vmangos `LootHandler.cpp` releases only in `HandleLootReleaseOpcode`; and the 1.12
|
||
/// `LootFrame.lua` `LOOT_SLOT_CLEARED` handler only hides buttons, so the close is engine-side).
|
||
/// Set only on the *transition* to empty via [`LootState::remove_slot`]/[`LootState::clear_money`],
|
||
/// never at open — an empty-at-open window stays up (the reference `LootFrame_OnShow` even has a
|
||
/// dedicated `LOOTWINDOWOPENEMPTY` sound for it). Drained by [`drain_loot`].
|
||
auto_release: bool,
|
||
/// Whether the open loot came from fishing (the wire `loot_type == 3` — vmangos folds
|
||
/// `FISHINGHOLE`/`FISHING_FAIL` into `LOOT_FISHING` before sending). Carried into the Lua
|
||
/// 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 row a `LOOT_BIND_CONFIRM` is currently open for — 1-based, display-side, the number the
|
||
/// event carried out and the number `LootSlot` must carry back (decision 1744). This is the
|
||
/// reference's `[0x847cec]`, whose `-1` is our `None`: `0x4c2790`'s click arm writes it instead
|
||
/// of sending, and its continuation arm sends only for a slot that equals it, then clears it
|
||
/// (`0x4c281a mov [0x847cec], 0xffffffff`). Reset with the window (`0x4c1df5`, in the
|
||
/// `SMSG_LOOT_RESPONSE` copier) so a confirm cannot survive into the next corpse.
|
||
pending_bind_confirm: Option<u32>,
|
||
/// The candidate list that arrived but has no window yet. `SMSG_LOOT_MASTER_LIST` is sent from
|
||
/// *inside* `Player::SendLoot` (`Player.cpp:8077-8081`), so it lands **before** the
|
||
/// `SMSG_LOOT_RESPONSE` it belongs to; [`LootState::open`] takes it from here. Staging it
|
||
/// 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, 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,
|
||
/// The template entry — the key the bind-on-pickup deferral reads `bonding` and `quality`
|
||
/// off (decision 1744).
|
||
item_id: u32,
|
||
/// The wire's per-row [`slot_type`]. `MASTER` diverts the click to the master-loot
|
||
/// dropdown instead of a take (decision 1675).
|
||
slot_type: u8,
|
||
},
|
||
}
|
||
|
||
impl LootState {
|
||
/// Open (or replace) the window with a fresh loot response (`SMSG_LOOT_RESPONSE`). Quest items
|
||
/// ride the same `items` list (the wire appends them with `slot = items.len()+i`), so they need no
|
||
/// special handling here.
|
||
pub(crate) fn open(&mut self, source: u64, loot_type: u8, gold: u32, items: Vec<LootItem>) {
|
||
self.source = Some(source);
|
||
self.gold = gold;
|
||
self.coin_slot = gold > 0; // the layout is fixed here, for the window's lifetime
|
||
self.items = items;
|
||
self.taken.clear();
|
||
self.auto_release = false; // empty-at-open stays open — only a removal auto-closes
|
||
self.pending_bind_confirm = None; // ref `0x4c1df5`: the copier resets the stash to -1
|
||
self.fishing = loot_type == benilla_protocol::messages::loot_type::FISHING;
|
||
// The master-loot candidate list arrives just AHEAD of this response (the server sends it
|
||
// from inside `SendLoot`), so the window claims whatever was staged and leaves the staging
|
||
// 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
|
||
/// becomes an empty gap — the layout never compacts (the reference hides that one button in
|
||
/// place, `LootFrame.lua:22-37`). Emptying the window arms the auto-close
|
||
/// ([`LootState::auto_release`]).
|
||
pub(crate) fn remove_slot(&mut self, wire_slot: u8) {
|
||
if self.items.iter().any(|it| it.slot == wire_slot) && !self.taken.contains(&wire_slot) {
|
||
self.taken.push(wire_slot);
|
||
}
|
||
self.arm_auto_release();
|
||
}
|
||
|
||
/// The coin line disappears for everyone (`SMSG_LOOT_CLEAR_MONEY`) — its slot stays in the
|
||
/// layout as a gap (`coin_slot` holds). Emptying the window arms the auto-close
|
||
/// ([`LootState::auto_release`]).
|
||
pub(crate) fn clear_money(&mut self) {
|
||
self.gold = 0;
|
||
self.arm_auto_release();
|
||
}
|
||
|
||
/// Arm the client-authoritative auto-close when a removal just left the open window with
|
||
/// nothing lootable — every item taken and no coin left (see [`LootState::auto_release`]).
|
||
fn arm_auto_release(&mut self) {
|
||
if self.source.is_some()
|
||
&& !self.has_coin()
|
||
&& self.items.iter().all(|it| self.taken.contains(&it.slot))
|
||
{
|
||
self.auto_release = true;
|
||
}
|
||
}
|
||
|
||
/// Take the armed auto-close edge (see [`LootState::auto_release`]) — `true` at most once per
|
||
/// emptying.
|
||
fn take_auto_release(&mut self) -> bool {
|
||
std::mem::take(&mut self.auto_release)
|
||
}
|
||
|
||
/// Queue a deferred push (`SMSG_ITEM_PUSH_RESULT`) — the "You receive …" line *and* the bag-bar
|
||
/// drop animation, emitted together once the item template lands (see [`PendingReceive`]). The
|
||
/// **self** gate lives at the net bridge ([`crate::net::apply`]); the `showInChat` gate does
|
||
/// not — it rides in as `in_chat` and silences the line alone, leaving the animation to play.
|
||
pub(crate) fn push_receive(&mut self, p: &ItemPushResult) {
|
||
self.receives.push(PendingReceive {
|
||
created: p.created,
|
||
entry: p.item_entry,
|
||
count: p.count,
|
||
from_npc: p.from_npc,
|
||
random_property_id: p.random_property_id,
|
||
suffix_factor: p.suffix_factor,
|
||
in_chat: p.show_in_chat,
|
||
container: push_container(p.bag_slot, p.item_slot),
|
||
tries: 0,
|
||
});
|
||
}
|
||
|
||
/// Close the open window (a release response, or a client-authoritative close on `CloseLoot`).
|
||
/// Keeps `receives` (an in-flight receive line outlives the window).
|
||
pub(crate) fn clear(&mut self) {
|
||
self.source = None;
|
||
self.gold = 0;
|
||
self.coin_slot = false;
|
||
self.items.clear();
|
||
self.taken.clear();
|
||
self.auto_release = false;
|
||
self.fishing = false;
|
||
self.pending_bind_confirm = None;
|
||
self.master_candidates.clear();
|
||
self.pending_master_candidates.clear();
|
||
}
|
||
|
||
/// Disconnect: drop the open window **and** any pending receive lines (mirrors the merchant/gossip
|
||
/// session clears).
|
||
pub(crate) fn clear_session(&mut self) {
|
||
self.clear();
|
||
self.receives.clear();
|
||
}
|
||
|
||
/// How many pushes are queued awaiting their item template — the net bridge's test hook for
|
||
/// "did this packet get through the self gate", since neither output is emitted until the
|
||
/// template lands.
|
||
#[cfg(test)]
|
||
pub(crate) fn pending_receive_count(&self) -> usize {
|
||
self.receives.len()
|
||
}
|
||
|
||
/// Whether a coin row is shown (position 1 when present).
|
||
fn has_coin(&self) -> bool {
|
||
self.gold > 0
|
||
}
|
||
|
||
/// The guid of the loot source whose window is open (`None` = closed). Read by the GameObject
|
||
/// lid-close watcher ([`crate::go_anim`]) to close a chest's lid when its loot window closes
|
||
/// (decision 0250) — the faithful client-authoritative close, any path (player close or the
|
||
/// server's release on the last item).
|
||
pub(crate) fn source(&self) -> Option<u64> {
|
||
self.source
|
||
}
|
||
|
||
/// Resolve a clicked 1-based display row to its action: the coin pile (position 1 when the
|
||
/// layout has a coin slot) or the item at the corresponding **wire** loot slot. Positions are
|
||
/// the FIXED open-time layout; a slot already looted (the coin gone, an item in `taken`)
|
||
/// answers `None` — a click on the gap does nothing, like the reference's hidden button.
|
||
fn action_at(&self, index_1based: u32) -> Option<LootAction> {
|
||
let mut index = index_1based.checked_sub(1)? as usize; // 0-based layout position
|
||
if self.coin_slot {
|
||
if index == 0 {
|
||
return self.has_coin().then_some(LootAction::Money);
|
||
}
|
||
index -= 1;
|
||
}
|
||
let it = self.items.get(index)?;
|
||
(!self.taken.contains(&it.slot)).then_some(LootAction::Item {
|
||
wire_slot: it.slot,
|
||
display_id: it.display_info_id,
|
||
item_id: it.item_id,
|
||
slot_type: it.slot_type,
|
||
})
|
||
}
|
||
|
||
/// 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()
|
||
}
|
||
}
|
||
|
||
/// The loot player knob (decision 0961): `autoLootDefault` — era's Controls-page checkbox (no
|
||
/// 1.12 CVar exists; vanilla only had the shift-click), settable from the Options window
|
||
/// through the CVar store (0954). The reference implements auto-loot ENGINE-side (era's own
|
||
/// Lua never reads this CVar outside its settings page), and so do we: [`feed_loot`] picks
|
||
/// every row itself at the open edge. A held SHIFT inverts the setting — era's
|
||
/// `AUTOLOOTTOGGLE` modified click, default SHIFT (Bindings_Vanilla.xml l.1467), the same
|
||
/// gesture that WAS vanilla's whole auto-loot.
|
||
///
|
||
/// `show_loot_spam` is 1.12's own `showLootSpam` — the *Detailed Loot Information* checkbox, whose
|
||
/// subject is **group loot rolls**, not loot messages generally (decision 1589, the Chat page).
|
||
/// It rides here rather than on [`crate::ui_loot_roll`] because it is one loot knob among the
|
||
/// loot knobs and `cvars::KnobParams` fetches this resource already. VERIFIED at the bytes (wow-re
|
||
/// `system/object-layer/scratch/lootroll-chat-and-lifecycle.md` §4): the CVar is `0xb4e2bc`,
|
||
/// registered at `0x48fd1c` with default `"1"` and flags 5, and a byte census over the whole
|
||
/// binary finds exactly four references — one writer and three readers, all three inside the
|
||
/// loot-roll line composers.
|
||
#[derive(Resource)]
|
||
pub(crate) struct LootConfig {
|
||
pub(crate) auto_loot: bool,
|
||
pub(crate) show_loot_spam: bool,
|
||
}
|
||
|
||
impl Default for LootConfig {
|
||
fn default() -> Self {
|
||
Self {
|
||
// No 1.12 CVar; era's registrar default is off (see the doc above).
|
||
auto_loot: false,
|
||
// The reference's registered `"1"` — detail on, which is 1.12's out-of-box chat.
|
||
show_loot_spam: true,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The client-local **loot-target latch** — the mirror of the real client's `[player+0x1d28]`
|
||
/// guid (wow-re `loot-anim-leg.md` §5, the 2026-08-21 §5 trio; decisions 0515 / 1471 / **1477**).
|
||
/// It says *a loot session is open on this object*, and it is read by far more than the kneel: the
|
||
/// loot cursor, the re-loot lock-out (`0x5ec110`), `CMSG_LOOT_MONEY`'s gate, auto-loot.
|
||
///
|
||
/// **Whether we kneel at it is a separate question** — [`LootKneel`], the client's predicate B
|
||
/// `0x612710`. Arming the latch is not arming the pose; conflating the two is what 1471 got wrong.
|
||
///
|
||
/// **The arms** — the real client has five, and we model all five (1477 shipped four; the fifth is
|
||
/// decision 1531):
|
||
/// - the `CMSG_LOOT` send (`0x5df253`/`0x5df40d`) — a **corpse** or player bones, armed at the
|
||
/// click, so the kneel is client-predicted with no round-trip;
|
||
/// - **`SMSG_SPELL_GO`** for an `OPEN_LOCK` cast that lands on a **chest**
|
||
/// (`0x6e831b → SetLootTarget 0x5ed5f0`) — this, not the loot response, is a chest's real arm,
|
||
/// and it is why the reference is already kneeling by the time the window opens;
|
||
/// - the **`CMSG_OPEN_ITEM` send** (`0x5edcc0`, in emitter `0x5edc80`) — a clam, lockbox or loot
|
||
/// bag in the bags, latched on the **item's own guid**
|
||
/// ([`crate::ui_items::drain::drain_container_uses`]'s open arm). It changes no pose — predicate
|
||
/// B refuses an ITEM — but it is exactly what makes the response's gate admit the answer:
|
||
/// vmangos replies `SendLoot(item guid, LOOT_CORPSE)`, i.e. wire type **1**, which a cold latch
|
||
/// refuses. 1477 read this arm as pose-only and left it out; the window stopped opening;
|
||
/// - `CGPlayer_C::OnLootResponse 0x5eb900`, through its **admission gate** — see
|
||
/// [`crate::net::apply::loot::loot_response`]. Not unconditional: a `loot_type == 1` response
|
||
/// against a cold latch is *refused and bounced*.
|
||
///
|
||
/// Cleared on release/close (`0x48f2c9`/`0x5ec0d4`). Ours clears **guid-matched** (release
|
||
/// response / refusal / our own release sends) — the safe generalization of the client's clears
|
||
/// under our corpse-switch race, where the *old* window's release response lands after the *new*
|
||
/// loot request armed the latch — plus unconditionally at session teardown.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct LootLatch(pub(crate) Option<u64>);
|
||
|
||
/// **Predicate B `0x612710`, the local-player branch** — whether the object the [`LootLatch`]
|
||
/// currently names is one the character *kneels at* (wow-re `loot-anim-leg.md` §8, byte-verified
|
||
/// §5 trio 2026-08-21; decision 1477). The loot leg `0x5fd260` needs predicate A (a session is
|
||
/// open) **and** this one, and the split is the whole reason a fishing bobber does not kneel while
|
||
/// a chest does — the latch is armed identically for both.
|
||
///
|
||
/// The byte table, transcribed:
|
||
///
|
||
/// | latched object | kneels? |
|
||
/// |---|---|
|
||
/// | GameObject, any type but 17 (a chest, a herb node, a `FISHINGHOLE` 25) | yes |
|
||
/// | GameObject type **17 `FISHINGNODE`** — a fishing bobber (`0x612772`) | **no** |
|
||
/// | Unit with `UNIT_FIELD_HEALTH <= 0` — a creature corpse | yes |
|
||
/// | Unit with health **> 0** — pickpocketing a live target (`0x61278c`) | **no** |
|
||
/// | Item — a lockbox, a disenchant (`0x612797`, `!(TYPEMASK & 2)`) | **no** |
|
||
/// | a guid the object manager cannot resolve (`0x612732`) | **no** |
|
||
///
|
||
/// Recomputed by [`resolve_loot_kneel`] each frame, **between the net drain and the anim driver**.
|
||
/// That ordering is load-bearing, not tidiness: the reference does not poll this at all — it
|
||
/// force-plays Loot 50 *at* the chest arm (`0x5ed619`, in the same `SMSG_SPELL_GO` handler that
|
||
/// writes the latch), so the pose is up on the arming frame. Scheduling this system loose cost
|
||
/// exactly one frame of Stand at the open, which the chest probe caught as 59/60.
|
||
#[derive(Resource, Default)]
|
||
pub(crate) struct LootKneel(pub(crate) bool);
|
||
|
||
/// `GAMEOBJECT_TYPE_ID` 17 — `FISHINGNODE`, the bobber. The one GameObject type predicate B names
|
||
/// explicitly, and the reason "the latch is armed" is not the same question as "we kneel"
|
||
/// (`0x612772`; the test exists for exactly this case).
|
||
const GO_TYPE_FISHINGNODE: i32 = 17;
|
||
|
||
/// Recompute [`LootKneel`] from the latched object — predicate B `0x612710`'s local branch.
|
||
/// `pub(crate)` so [`crate::creature_anim`]'s driver chain can order itself after it (see
|
||
/// [`LootKneel`]: the reference's pose is up on the arming frame, so ours must be too).
|
||
pub(crate) fn resolve_loot_kneel(
|
||
latch: Res<LootLatch>,
|
||
index: Res<crate::net::GuidIndex>,
|
||
objects: Query<(&crate::net::NetEntity, &crate::net::ObjectStore)>,
|
||
mut kneel: ResMut<LootKneel>,
|
||
) {
|
||
let allowed = latch
|
||
.0
|
||
.and_then(|guid| index.0.get(&guid).copied())
|
||
.and_then(|e| objects.get(e).ok())
|
||
.is_some_and(|(net_entity, store)| match net_entity.kind {
|
||
// `0x612764`: a GameObject kneels unless it is the bobber.
|
||
benilla_protocol::EntityKind::GameObject => {
|
||
store.0.gameobject_type_id() != GO_TYPE_FISHINGNODE
|
||
}
|
||
// `0x61278c`: a unit kneels only once its `UNIT_FIELD_HEALTH` is not positive — read
|
||
// straight off the descriptor, as the bytes do (an unsent field is 0 in the client's
|
||
// descriptor array too, so `unwrap_or(0)` *is* the faithful read; this deliberately
|
||
// does not go through `unit_is_dead`, whose extra `max_health > 0` guard the
|
||
// reference has no counterpart for).
|
||
benilla_protocol::EntityKind::Unit | benilla_protocol::EntityKind::Player => {
|
||
store.0.unit_health().unwrap_or(0) == 0
|
||
}
|
||
// An ITEM never kneels (`0x612797`), and we stream no item entities anyway — the
|
||
// resolve above already answers `false` for a lockbox latch. Everything else
|
||
// (DynamicObject, Other) is not a loot target the reference reaches here.
|
||
_ => false,
|
||
});
|
||
if kneel.0 != allowed {
|
||
kneel.0 = allowed;
|
||
}
|
||
}
|
||
|
||
impl LootLatch {
|
||
/// Drop the latch if it still points at `guid` (a release/refusal for that loot session).
|
||
pub(crate) fn clear_for(&mut self, guid: u64) {
|
||
if self.0 == Some(guid) {
|
||
self.0 = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
pub(crate) struct UiLootPlugin;
|
||
|
||
impl Plugin for UiLootPlugin {
|
||
fn build(&self, app: &mut App) {
|
||
app.init_resource::<LootState>()
|
||
.init_resource::<LootConfig>()
|
||
.init_resource::<LootLatch>()
|
||
.init_resource::<LootKneel>()
|
||
.add_systems(
|
||
Update,
|
||
(
|
||
// Push before the input pass so an open/close is on screen the same frame; drain
|
||
// after it so a click's intent goes out the same frame (mirrors ui_merchant).
|
||
feed_loot.before(UiInput),
|
||
drain_loot.after(UiInput),
|
||
// Predicate B, per frame, after the net drain that arms the latch. The anim
|
||
// driver then orders itself after THIS (`crate::creature_anim`), closing the
|
||
// arm→pose gap to zero frames, as the reference's force-play does.
|
||
resolve_loot_kneel.after(benilla_world::schedule::WorldStage::Net),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Copper → the loot coin-row name text: each nonzero denomination as "<n> <Word>", the words the real
|
||
/// GlobalStrings coin words (`GOLD`/`SILVER`/`COPPER`), joined by a single space and dropping leading
|
||
/// zeroes (25 → "25 Copper"; 10025 → "1 Gold 25 Copper"; 0 → "0 Copper"). The coin row's name is
|
||
/// resolved app-side (the loot twin of the merchant's coin display).
|
||
///
|
||
/// **Stated approximation (documented gap).** 1.12.1's GlobalStrings has **no** `GOLD_AMOUNT` /
|
||
/// `SILVER_AMOUNT` / `COPPER_AMOUNT` "%d <Word>" patterns — those arrive in a later client. The real
|
||
/// 1.12 coin text is produced inside `GetLootSlotInfo` (a C function, not FrameXML, not RE-recorded),
|
||
/// so we compose "<n> <Word>" from the bare `GOLD`/`SILVER`/`COPPER` words and join them the way
|
||
/// [`format_money`] always has (a single space); the exact client wording/separator is the stand-in.
|
||
fn format_money(copper: u32) -> String {
|
||
let (g, s, c) = (copper / 10000, (copper % 10000) / 100, copper % 100);
|
||
let mut parts: Vec<String> = Vec::new();
|
||
if g > 0 {
|
||
parts.push(format!("{g} {GOLD_WORD}"));
|
||
}
|
||
if s > 0 {
|
||
parts.push(format!("{s} {SILVER_WORD}"));
|
||
}
|
||
if c > 0 || parts.is_empty() {
|
||
parts.push(format!("{c} {COPPER_WORD}"));
|
||
}
|
||
parts.join(" ")
|
||
}
|
||
|
||
/// The coin-pile icon for a copper amount — the reference's six-step ladder, one table for the
|
||
/// loot slot, `GetCoinIcon` and the money cursor's bitmap (`benilla_ui::script::coin_icon`, 1965).
|
||
fn coin_icon(copper: u32) -> &'static str {
|
||
benilla_ui::script::coin_icon(i64::from(copper))
|
||
}
|
||
|
||
/// Resolve one wire [`LootItem`] into the Lua-facing [`LootRow`]: the icon comes straight from the
|
||
/// wire `display_info_id` (no template wait), name + quality from the ask-once template cache (`None`
|
||
/// while in flight — the row shows a placeholder and fills in when the answer lands).
|
||
///
|
||
/// The row's **link** (`GetLootSlotLink`, decision 1059) comes off that same one template answer, out
|
||
/// of the one shared builder [`receive_line`] uses ([`crate::ui_items::item_link_full`], our
|
||
/// transcription of `0x52adb0`) and with the same arguments: enchant `0`, the wire's own
|
||
/// `randomPropertyId`, and suffix factor `0` — `SMSG_LOOT_RESPONSE`'s `randomSuffix` is a literal `0`
|
||
/// server-side (`LootMgr.cpp:841`, which is why [`LootItem`] doesn't even carry it), so there is
|
||
/// nothing else to pass. One builder, no drifting twins — the reason the zeros live in `item_link`
|
||
/// rather than at each call site.
|
||
fn resolve_item(
|
||
item: &LootItem,
|
||
items: &mut Items,
|
||
icons: Option<&ItemDisplays>,
|
||
commands: &NetCommands,
|
||
rolls: RollCatalogs,
|
||
) -> LootRow {
|
||
let (name, quality, link) = match items.template(item.item_id, 0, commands) {
|
||
Some(t) => {
|
||
// The rolled name IS the name here — the reference composes every display of an item's
|
||
// name through `0x5d8b00`, link text included, so the row, the tooltip plate and the
|
||
// shift-click link all read "Chipped Claw of the Bear" off this one string.
|
||
let name = rolls.name(&t.name, item.random_property_id);
|
||
(
|
||
Some(name.clone()),
|
||
Some(t.quality),
|
||
Some(crate::ui_items::item_link_full(
|
||
item.item_id,
|
||
0,
|
||
item.random_property_id,
|
||
0,
|
||
&name,
|
||
t.quality,
|
||
)),
|
||
)
|
||
}
|
||
None => (None, None, None),
|
||
};
|
||
let texture = icons
|
||
.and_then(|i| i.catalog.get(item.display_info_id))
|
||
.and_then(|d| d.icon.clone());
|
||
LootRow {
|
||
name,
|
||
texture,
|
||
quantity: item.count,
|
||
quality,
|
||
is_coin: false,
|
||
item_id: item.item_id,
|
||
link,
|
||
// The roll rides as the raw id, exactly as the client's own loot record keeps it: the
|
||
// tooltip resolves it against the pushed roll table (§E5). Decision 1547.
|
||
random_property_id: item.random_property_id,
|
||
}
|
||
}
|
||
|
||
/// Whether any row of this open loot is still waiting on its item-template answer — the reference's
|
||
/// outstanding-query counter `[0xb71b44]`, in predicate form (decision 1805).
|
||
///
|
||
/// A row counts as waiting while its name is absent AND the server has not yet answered at all. A
|
||
/// **negative** answer ("no such entry") releases it: the reference's counter is decremented by the
|
||
/// arrival callback either way, and a window held open forever by an entry nobody will ever describe
|
||
/// is strictly worse than one that opens showing the cache-miss sentinels — which is exactly what
|
||
/// the reference paints in that case.
|
||
///
|
||
/// The coin row never waits; it has no template.
|
||
fn templates_outstanding(items: &Items, snap: &LootSnapshot) -> bool {
|
||
snap.rows.iter().flatten().any(|r| {
|
||
!r.is_coin
|
||
&& r.item_id != 0
|
||
&& r.name.is_none()
|
||
&& !items.template_answered_unknown(r.item_id)
|
||
})
|
||
}
|
||
|
||
/// Build the Lua-facing snapshot from [`LootState`] — `None` when no loot is open. One entry per
|
||
/// slot of the **fixed** open-time layout: the coin pile first when the loot opened with gold, then
|
||
/// the items in wire order. A slot already looted stays in the list as `None` — the gap the
|
||
/// reference's hidden button leaves (`LOOT_SLOT_CLEARED` hides in place; the rows below never
|
||
/// shift up).
|
||
fn snapshot(
|
||
loot: &LootState,
|
||
items: &mut Items,
|
||
icons: Option<&ItemDisplays>,
|
||
commands: &NetCommands,
|
||
rolls: RollCatalogs,
|
||
who: Candidates,
|
||
) -> Option<LootSnapshot> {
|
||
loot.source?;
|
||
let mut rows = Vec::with_capacity(loot.items.len() + 1);
|
||
if loot.coin_slot {
|
||
rows.push(loot.has_coin().then(|| LootRow {
|
||
name: Some(format_money(loot.gold)),
|
||
texture: Some(coin_icon(loot.gold).into()),
|
||
// 0, not 1, and for the same reason as [`COIN_QUALITY`]: `0x4c22e0`'s coin guard
|
||
// returns before the record read (`0x4c22fd`). Invisible either way — stock
|
||
// `LootFrame_Update` hides the count string unless `quantity > 1` — but it is the
|
||
// value the accessor answers.
|
||
quantity: 0,
|
||
quality: Some(COIN_QUALITY),
|
||
is_coin: true,
|
||
item_id: 0,
|
||
// No link: the coin pile is a synthesized row with no item behind it, so a modified
|
||
// click on it finds nil and does nothing (decision 1059).
|
||
link: None,
|
||
random_property_id: 0,
|
||
}));
|
||
}
|
||
for it in &loot.items {
|
||
rows.push(
|
||
(!loot.taken.contains(&it.slot))
|
||
.then(|| resolve_item(it, items, icons, commands, rolls)),
|
||
);
|
||
}
|
||
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`).
|
||
///
|
||
/// The mechanic the line hangs on, and the reason the item name is **not** LOOT-green: the `%s` the
|
||
/// GlobalString takes is a full **item link**, not a bare name. `0x491c43`/`0x491ca3` call the link
|
||
/// builder `0x52adb0` ([`crate::ui_items::item_link_full`] is our transcription of it) with the item
|
||
/// id, enchant `0`, the wire `randomPropertyId`, the wire `suffixFactor`, and the resolved name. The
|
||
/// link's `|r` closes right after the `]`, so the count that follows is drawn in the line's own
|
||
/// colour — exactly the reference client's `[Chipped Claw]x2.`
|
||
///
|
||
/// The format strings are QUOTED from the extracted `Interface\FrameXML\GlobalStrings.lua`
|
||
/// (patch-2.MPQ, l.2599-2605): `LOOT_ITEM_SELF = "You receive loot: %s."` /
|
||
/// `LOOT_ITEM_SELF_MULTIPLE = "You receive loot: %sx%d."` and the PUSHED_SELF / CREATED_SELF twins —
|
||
/// note there is **no space** before the `x%d`. The key is selected by the wire's (created,
|
||
/// received) pair with created winning (`0x491c04`-`0x491c1b` / `0x491c60`-`0x491c77`), exactly as
|
||
/// the server sets them (vmangos `SendNewItem`: crafting → created, vendor/quest → received,
|
||
/// loot → neither).
|
||
fn receive_line(r: &PendingReceive, name: &str, quality: u32) -> String {
|
||
let verb = if r.created {
|
||
"You create"
|
||
} else if r.from_npc {
|
||
"You receive item"
|
||
} else {
|
||
"You receive loot"
|
||
};
|
||
let link = crate::ui_items::item_link_full(
|
||
r.entry,
|
||
0,
|
||
r.random_property_id,
|
||
r.suffix_factor,
|
||
name,
|
||
quality,
|
||
);
|
||
if r.count > 1 {
|
||
format!("{verb}: {link}x{}.", r.count)
|
||
} else {
|
||
format!("{verb}: {link}.")
|
||
}
|
||
}
|
||
|
||
/// Emit any pending pushes (`SMSG_ITEM_PUSH_RESULT`) once their item template resolves — the whole
|
||
/// `CGGameUI::OnItemPush` tail, in the reference's own order: the bag-bar drop animation first
|
||
/// (`ITEM_PUSH(container, icon)`, fired at `0x491be8` *before* the chat block, decision 0887), then —
|
||
/// if the wire asked for it — the "You receive …" line in the chat window (decision 0084's chat arc),
|
||
/// a `LOOT`-green line carrying a quality-coloured item link ([`receive_line`], decision 0888).
|
||
/// Unresolved pushes retry up to [`RECEIVE_MAX_TRIES`] frames, then drop (the reference instead
|
||
/// sleeps on the item-cache callback, so it never gives up — a stated divergence that only shows on
|
||
/// an entry the server never answers for).
|
||
fn drain_receives(
|
||
loot: &mut LootState,
|
||
items: &mut Items,
|
||
icons: Option<&ItemDisplays>,
|
||
commands: &NetCommands,
|
||
chat: &mut crate::ui_chat::ChatLog,
|
||
script: &mut UiScript,
|
||
rolls: RollCatalogs,
|
||
) {
|
||
let pending = std::mem::take(&mut loot.receives);
|
||
let mut still = Vec::new();
|
||
for mut r in pending {
|
||
// One template read serves all three outputs: the name and quality for the line, the
|
||
// display id for the animation's icon.
|
||
let resolved = items
|
||
.template(r.entry, 0, commands)
|
||
.map(|t| (t.name.clone(), t.quality, t.display_info_id));
|
||
match resolved {
|
||
Some((name, quality, display_id)) => {
|
||
// `ITEM_PUSH` is UNGATED by `show_in_chat` and by the created/received pair: the
|
||
// fire at `0x491be8` precedes the `[ebx+0x24]` chat test at `0x491bf3`, so every
|
||
// push that reaches us animates, including a silent one. `arg2` is the item's icon
|
||
// path — the reference composes it from the icon directory + the display record's
|
||
// own name (`0x491baa`, "%s%s%s"); ours is the same path out of the
|
||
// `ItemDisplayInfo.dbc` catalog the bags and the loot window already read.
|
||
let icon = icons
|
||
.and_then(|i| i.catalog.get(display_id))
|
||
.and_then(|d| d.icon.clone());
|
||
if let Some(icon) = icon {
|
||
debug!(
|
||
"ui_loot: ITEM_PUSH container {} icon {icon} (item {})",
|
||
r.container, r.entry
|
||
);
|
||
script.fire_event(
|
||
"ITEM_PUSH",
|
||
vec![ScriptValue::Int(r.container), ScriptValue::Str(icon)],
|
||
);
|
||
}
|
||
if r.in_chat {
|
||
chat.push_event(crate::ui_chat::ChatEvent::text_only(
|
||
crate::ui_chat::ChatEventKind::Loot,
|
||
receive_line(&r, &rolls.name(&name, r.random_property_id), quality),
|
||
));
|
||
}
|
||
}
|
||
None => {
|
||
r.tries += 1;
|
||
if r.tries < RECEIVE_MAX_TRIES {
|
||
still.push(r);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
loot.receives = still;
|
||
}
|
||
|
||
/// Push the current loot into the VM and fire open/update/close on a transition (or a content change
|
||
/// — an async name landing, a removed row, the coin clearing). Also routes refusals + receive lines
|
||
/// into the chat window. Diffed against a `Local` memory, exactly like the merchant/gossip feeds.
|
||
#[allow(clippy::too_many_arguments)]
|
||
fn feed_loot(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
mut loot: ResMut<LootState>,
|
||
mut items: ResMut<Items>,
|
||
icons: Option<Res<ItemDisplays>>,
|
||
commands: Res<NetCommands>,
|
||
mut chat: ResMut<crate::ui_chat::ChatLog>,
|
||
mut last: Local<crate::ui_script::VmMemo<Option<LootSnapshot>>>,
|
||
cfg: Res<LootConfig>,
|
||
keys: Res<ButtonInput<KeyCode>>,
|
||
mut pickup: MessageWriter<crate::sound::LootPickupSound>,
|
||
// The random-suffix roll's two catalogs (decision 1547) — the drop's "of the Monkey" name and
|
||
// the enchant slots 2..6 its tooltip shows. A loot slot carries no item object, so this is the
|
||
// 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;
|
||
};
|
||
let last = last.get(&script);
|
||
let rolls = RollCatalogs {
|
||
props: props.as_deref(),
|
||
enchants: enchants.as_deref(),
|
||
};
|
||
drain_receives(
|
||
&mut loot,
|
||
&mut items,
|
||
icons.as_deref(),
|
||
&commands,
|
||
&mut chat,
|
||
&mut script,
|
||
rolls,
|
||
);
|
||
|
||
let who = Candidates {
|
||
group: &group,
|
||
names: &names,
|
||
};
|
||
let fresh = snapshot(&loot, &mut items, icons.as_deref(), &commands, rolls, who);
|
||
if fresh == *last {
|
||
return;
|
||
}
|
||
script.set_loot(fresh.clone());
|
||
match (&*last, &fresh) {
|
||
(None, Some(snap)) => {
|
||
// **The window does not open until every row's item template has landed** (1805). The
|
||
// reference's copier ends `0x4c1e9f mov eax,[0xb71b44]; test eax,eax; jne 0x4c1eeb` —
|
||
// if any template query is outstanding it fires NOTHING — and the item-cache arrival
|
||
// callback `0x4c2ac0` fires `LOOT_OPENED` (`0x10b`) itself on that counter's falling
|
||
// edge to zero (`0x4c2af6 dec` / `jne`). There is no repaint path to fall back on:
|
||
// `LootFrame_Update` is reachable only through the XML `<OnShow>`, and `ShowUIPanel`
|
||
// early-returns on an already-visible frame, so a second `LOOT_OPENED` would do
|
||
// nothing. Deferring IS the mechanism (wow-re `loot-slot-record.md` §11).
|
||
//
|
||
// Returning without advancing `last` re-evaluates next frame; the queries are already
|
||
// in flight from `snapshot` above, and the auto-loot sweep below waits with the window,
|
||
// which is the reference's shape too (the same callback runs the sweep instead of
|
||
// firing when the auto-loot latch is set).
|
||
if templates_outstanding(&items, snap) {
|
||
return;
|
||
}
|
||
script.fire_event("LOOT_OPENED", vec![]);
|
||
// era's engine-side auto-loot ([`LootConfig`]): the knob decides, a held SHIFT
|
||
// inverts it, and the client "clicks" every row itself at the open edge — the
|
||
// same autostore/coin sends a hand pick makes, pickup sounds included. A refusal
|
||
// (inventory full) keeps its row and the window simply stays; emptying it fires
|
||
// the existing last-row auto-release.
|
||
let shift = keys.pressed(KeyCode::ShiftLeft) || keys.pressed(KeyCode::ShiftRight);
|
||
if cfg.auto_loot != shift {
|
||
let mut bind_confirm_fired = false;
|
||
for index in 1..=snap.rows.len() as u32 {
|
||
match loot.action_at(index) {
|
||
Some(LootAction::Money) => {
|
||
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,
|
||
item_id,
|
||
slot_type,
|
||
}) if slot_type == slot_type::ALLOW_LOOT => {
|
||
// The sweep carries the same bind gate as a hand click, plus a
|
||
// ONE-SHOT latch (`0x4c21c2 test ebx,ebx; jne` → the loop's continue,
|
||
// `0x4c21e2 mov ebx,1`; decision 1744): the first bind-on-pickup row
|
||
// raises the confirm, and every later one in the same sweep is left
|
||
// in the window untouched — not taken, not asked about. Otherwise a
|
||
// three-blue corpse would stack three dialogs over one pending slot.
|
||
if bind_confirm_required(&mut items, &commands, item_id) {
|
||
if bind_confirm_fired {
|
||
continue;
|
||
}
|
||
loot.pending_bind_confirm = Some(index);
|
||
script.fire_event(
|
||
"LOOT_BIND_CONFIRM",
|
||
vec![ScriptValue::Int(i64::from(index))],
|
||
);
|
||
bind_confirm_fired = true;
|
||
continue;
|
||
}
|
||
let _ = commands
|
||
.0
|
||
.send(ClientCommand::AutostoreLootItem { slot: wire_slot });
|
||
pickup.write(crate::sound::LootPickupSound { display_id });
|
||
}
|
||
Some(LootAction::Item { .. }) | None => {}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
// A content change while open (async name landed, a row removed, coin cleared) → repaint,
|
||
// keeping the current page.
|
||
//
|
||
// **Both events fire, and the per-slot one is not optional any more** (1751). This used to
|
||
// send `LOOT_UPDATE` alone, on the reasoning that a full re-snapshot replaces Blizzard's
|
||
// per-button `LOOT_SLOT_CLEARED` optimization the way the merchant seam replaced per-row
|
||
// stock updates. That was defensible while we owned `LootFrame.xml`. The STOCK file hangs
|
||
// real behaviour off `LOOT_SLOT_CLEARED` and only off it: its arm hides the button in
|
||
// place AND, when that empties the page, calls `LootFrame_PageDown` (`LootFrame.lua:38-50`
|
||
// — "try to move second page of loot items to the first page"). `LOOT_UPDATE` reaches
|
||
// none of that. Send only the summary and looting out a page leaves the player staring at
|
||
// an empty window with a live Down arrow.
|
||
//
|
||
// So the reference's vocabulary is spoken as the reference speaks it: one
|
||
// `LOOT_SLOT_CLEARED` per row that went away, carrying its 1-based row as `arg1`, and then
|
||
// the summary repaint.
|
||
(Some(before), Some(after)) => {
|
||
for (i, was) in before.rows.iter().enumerate() {
|
||
let gone = was.is_some() && after.rows.get(i).is_none_or(Option::is_none);
|
||
if gone {
|
||
script.fire_event("LOOT_SLOT_CLEARED", vec![ScriptValue::Int(i as i64 + 1)]);
|
||
}
|
||
}
|
||
// A changed candidate list refreshes the open dropdown in place without re-toggling
|
||
// it — `LootFrame_OnEvent`'s `UIDropDownMenu_Refresh(GroupLootDropDown)`, which the
|
||
// reference hangs off `UPDATE_MASTER_LOOT_LIST` (`LootFrame.lua:62-64`). 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) => {}
|
||
}
|
||
*last = fresh;
|
||
}
|
||
|
||
/// Whether taking this row must first raise `LOOT_BIND_CONFIRM` — the reference's two-conjunct
|
||
/// gate at `0x4c28f2`/`0x4c28fb` (decision 1744). An unresolved template answers **false**: the
|
||
/// reference peeks its own item cache here and cannot ask, and a row whose template has not landed
|
||
/// has no name on it either, so it is not a row anyone has clicked. Asking (rather than peeking)
|
||
/// costs nothing — the entry is already in flight from the snapshot — and keeps the answer right
|
||
/// for the next click if one somehow arrives first.
|
||
fn bind_confirm_required(items: &mut Items, commands: &NetCommands, item_id: u32) -> bool {
|
||
items
|
||
.template(item_id, 0, commands)
|
||
.is_some_and(|t| t.bonding == BIND_WHEN_PICKED_UP && t.quality >= BIND_CONFIRM_MIN_QUALITY)
|
||
}
|
||
|
||
/// Drain the Lua intents: a picked row → coin (`CMSG_LOOT_MONEY`) or the item's wire slot
|
||
/// (`CMSG_AUTOSTORE_LOOT_ITEM`); a close → `CMSG_LOOT_RELEASE` + a client-authoritative local clear
|
||
/// (the window is already hidden by its `OnHide`; the release is fire-and-forget, and the server's
|
||
/// `SMSG_LOOT_RELEASE_RESPONSE` clears again idempotently). Also fires the **last-row auto-close**
|
||
/// ([`LootState::auto_release`]): the client, not the server, releases when a removal empties the
|
||
/// window — vmangos only ever releases in answer to our `CMSG_LOOT_RELEASE`.
|
||
///
|
||
/// **This function is the reference's take dispatcher `0x4c2790(slot, flag)`** (decision 1744), and
|
||
/// the two Lua verbs are its two flags: `BenillaTakeLootSlot` is the row click (`flag == 0`, the C
|
||
/// `CLootButton`'s arm) and `LootSlot` is the LOOT_BIND confirmation continuation (`flag == 1`,
|
||
/// which sends only for the pending slot). Keeping them apart is what makes a second click on a
|
||
/// bind-on-pickup row re-raise the confirm instead of looting behind it.
|
||
fn drain_loot(
|
||
script: Option<NonSendMut<UiScript>>,
|
||
mut loot: ResMut<LootState>,
|
||
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>,
|
||
// The bind-on-pickup deferral reads the row's template (`bonding`, `quality`). Already cached
|
||
// by then in every reachable case — the snapshot asks for it to put a NAME on the row, and a
|
||
// row with no name is a row nobody has clicked.
|
||
mut items: ResMut<Items>,
|
||
) {
|
||
let Some(mut script) = script else {
|
||
return;
|
||
};
|
||
for index in script.take_loot_picks() {
|
||
match loot.action_at(index) {
|
||
Some(LootAction::Money) => {
|
||
// No coin play here: the coin rides the coinage-change watcher (`sound::money`)
|
||
// when `PLAYER_FIELD_COINAGE` rises — the same rule as buy/sell.
|
||
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,
|
||
item_id,
|
||
..
|
||
}) => {
|
||
// The bind-on-pickup deferral (`0x4c28f2`-`0x4c2920`, decision 1744). Two
|
||
// conjuncts and no others: the template's `bonding == BIND_WHEN_PICKED_UP` AND its
|
||
// `quality >= 2` (uncommon or better) — a grey or white BoP row is taken with no
|
||
// confirm at all, which is why picking up a quest trinket never asks. The event
|
||
// carries the row out, the row is stashed, and NOTHING is sent; not even the
|
||
// pickup sound, which the reference plays only on the arm that actually sends.
|
||
if bind_confirm_required(&mut items, &commands, item_id) {
|
||
debug!("ui_loot: row {index} (wire {wire_slot}) binds on pickup — confirming");
|
||
loot.pending_bind_confirm = Some(index);
|
||
script.fire_event(
|
||
"LOOT_BIND_CONFIRM",
|
||
vec![ScriptValue::Int(i64::from(index))],
|
||
);
|
||
continue;
|
||
}
|
||
debug!("ui_loot: autostore row {index} (wire slot {wire_slot})");
|
||
let _ = commands
|
||
.0
|
||
.send(ClientCommand::AutostoreLootItem { slot: wire_slot });
|
||
// The pickup sound plays optimistically at the click, before the send (wow-re
|
||
// `acquire-spend-sounds.md`): looting an item plays its ItemGroupSounds kit[0].
|
||
pickup.write(crate::sound::LootPickupSound { display_id });
|
||
}
|
||
None => debug!("ui_loot: BenillaTakeLootSlot({index}) out of range — ignored"),
|
||
}
|
||
}
|
||
|
||
// `LootSlot(slot)` — the confirm continuation (`0x4c2790`'s `flag != 0` arm, `0x4c27c0`). It
|
||
// sends for exactly one slot: the one the pending confirm names. Anything else is dropped in
|
||
// silence, which is what the reference does with an addon that calls `LootSlot` on an ordinary
|
||
// row. The stash clears on the send (`0x4c281a`), so a doubled OnAccept cannot loot twice.
|
||
for index in script.take_loot_confirms() {
|
||
if loot.pending_bind_confirm != Some(index) {
|
||
debug!("ui_loot: LootSlot({index}) is not the pending bind confirm — ignored");
|
||
continue;
|
||
}
|
||
let Some(LootAction::Item {
|
||
wire_slot,
|
||
display_id,
|
||
..
|
||
}) = loot.action_at(index)
|
||
else {
|
||
// The row went away under the open dialog (someone else took it, the window turned
|
||
// over). The reference bails the same way — its continuation re-reads the record and
|
||
// returns on an empty itemId (`0x4c27d7`) — and, like it, WITHOUT clearing the stash:
|
||
// the clear is on the send path alone (`0x4c281a`), and the window turning over is
|
||
// what drops a stash that never completed.
|
||
debug!("ui_loot: bind confirm for row {index} — the row is gone, nothing sent");
|
||
continue;
|
||
};
|
||
loot.pending_bind_confirm = None;
|
||
debug!("ui_loot: bind-confirmed row {index} (wire slot {wire_slot})");
|
||
let _ = commands
|
||
.0
|
||
.send(ClientCommand::AutostoreLootItem { slot: wire_slot });
|
||
pickup.write(crate::sound::LootPickupSound { display_id });
|
||
}
|
||
// The master looter's assignments (`GiveMasterLoot(slot, candidateIndex)`): the Lua hands two
|
||
// 1-based display numbers, the app turns them into the wire slot and the recipient guid. Both
|
||
// must resolve — a stale row or a candidate index past the end of the list is dropped here
|
||
// 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}");
|
||
let _ = commands.0.send(ClientCommand::LootRelease { guid });
|
||
loot.clear(); // client-authoritative close (mirrors the merchant's optimistic clear)
|
||
latch.clear_for(guid); // the kneel ends at the release send (0515)
|
||
}
|
||
}
|
||
// The last-row auto-close (`LootState::auto_release`): a wire removal just emptied the open
|
||
// window, so the client releases on its own — the real engine's close-on-last-slot
|
||
// (`0x4c2a70` empty-check → `0x48f200`; the server never initiates it). The clear makes the
|
||
// feed fire LOOT_CLOSED next pass; the window's OnHide then calls CloseLoot(), whose drain
|
||
// above no-ops on the already-cleared source.
|
||
if loot.take_auto_release() {
|
||
if let Some(guid) = loot.source {
|
||
debug!("ui_loot: loot emptied — auto-release {guid:#x}");
|
||
let _ = commands.0.send(ClientCommand::LootRelease { guid });
|
||
loot.clear();
|
||
latch.clear_for(guid);
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use benilla_protocol::messages::loot_type;
|
||
use benilla_protocol::messages::GroupMemberEntry;
|
||
use benilla_protocol::messages::ItemInfo;
|
||
use benilla_protocol::messages::ObjectFields;
|
||
use benilla_protocol::EntityKind;
|
||
use bevy::ecs::system::RunSystemOnce;
|
||
|
||
/// An empty group + name cache — what every loot test that is not about master loot wants:
|
||
/// no candidates resolve, and a snapshot's `master_candidates` comes out empty. Master-loot
|
||
/// 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;
|
||
|
||
/// Drive [`resolve_loot_kneel`] once over a world holding one latched object, and report what
|
||
/// predicate B said. `None` for `object` = the latch names a guid that does not resolve.
|
||
fn kneels_at(object: Option<(EntityKind, &[(u16, u32)])>) -> bool {
|
||
const GUID: u64 = 0xF110_0000_0000_0042;
|
||
let mut app = App::new();
|
||
app.init_resource::<LootLatch>()
|
||
.init_resource::<LootKneel>()
|
||
.init_resource::<crate::net::GuidIndex>()
|
||
.add_systems(Update, resolve_loot_kneel);
|
||
app.world_mut().resource_mut::<LootLatch>().0 = Some(GUID);
|
||
if let Some((kind, fields)) = object {
|
||
let e = app
|
||
.world_mut()
|
||
.spawn((
|
||
crate::net::NetEntity {
|
||
kind,
|
||
display_id: None,
|
||
scale: 1.0,
|
||
},
|
||
crate::net::ObjectStore(ObjectFields::from_pairs(fields)),
|
||
))
|
||
.id();
|
||
app.world_mut()
|
||
.resource_mut::<crate::net::GuidIndex>()
|
||
.0
|
||
.insert(GUID, e);
|
||
}
|
||
app.update();
|
||
app.world().resource::<LootKneel>().0
|
||
}
|
||
|
||
/// **Predicate B `0x612710`, the local branch** (wow-re `loot-anim-leg.md` §8; decision 1477).
|
||
/// The whole row set, because the *point* of this predicate is that arming the latch is not
|
||
/// the same question as kneeling: a fishing bobber and a chest arm it identically, and only
|
||
/// one of them is knelt at. Without this filter, 1471's response-arm gave benilla a kneel at
|
||
/// a bobber, over a lockbox, and while pickpocketing — none of which the reference does.
|
||
#[test]
|
||
fn predicate_b_decides_which_loot_targets_are_knelt_at() {
|
||
// A GameObject that is not the bobber — a chest, a herb node, a FISHINGHOLE(25).
|
||
assert!(kneels_at(Some((
|
||
EntityKind::GameObject,
|
||
&[(F_GO_TYPE_ID, 3)]
|
||
))));
|
||
assert!(kneels_at(Some((
|
||
EntityKind::GameObject,
|
||
&[(F_GO_TYPE_ID, 25)]
|
||
))));
|
||
// `0x612772` — GAMEOBJECT_TYPE_ID 17 FISHINGNODE, the one type named explicitly.
|
||
assert!(!kneels_at(Some((
|
||
EntityKind::GameObject,
|
||
&[(F_GO_TYPE_ID, 17)]
|
||
))));
|
||
// `0x61278c` — a corpse kneels, a live target (pickpocketing) does not.
|
||
assert!(kneels_at(Some((EntityKind::Unit, &[(F_UNIT_HEALTH, 0)]))));
|
||
assert!(!kneels_at(Some((EntityKind::Unit, &[(F_UNIT_HEALTH, 1)]))));
|
||
// `0x612732` — a guid the object manager cannot resolve (an item latch is this, for us).
|
||
assert!(!kneels_at(None));
|
||
}
|
||
|
||
/// A cold latch is not a kneel — predicate A's half, folded into the same resource so the
|
||
/// anim driver reads one boolean.
|
||
#[test]
|
||
fn a_cold_latch_never_kneels() {
|
||
let mut app = App::new();
|
||
app.init_resource::<LootLatch>()
|
||
.init_resource::<LootKneel>()
|
||
.init_resource::<crate::net::GuidIndex>()
|
||
.add_systems(Update, resolve_loot_kneel);
|
||
app.update();
|
||
assert!(!app.world().resource::<LootKneel>().0);
|
||
}
|
||
|
||
// ── The soulbind confirm (decision 1744) ──────────────────────────────────────────────────
|
||
//
|
||
// Real 1.12 `item_template` rows, read from the running vmangos rather than invented, so the
|
||
// two conjuncts are exercised against numbers the server actually ships:
|
||
// 12590 Felstriker quality 4, bonding 1 — BoP epic: confirms
|
||
// 871 Flurry Axe quality 4, bonding 2 — BoE epic: takes, the bonding control
|
||
// 117 Tough Jerky quality 1, bonding 0 — plain white: takes
|
||
// and one synthetic that the database has no clean example of at this quality:
|
||
// 9999 a white BoP quality 1, bonding 1 — the QUALITY control, which takes.
|
||
const FELSTRIKER: u32 = 12590;
|
||
const FLURRY_AXE: u32 = 871;
|
||
const TOUGH_JERKY: u32 = 117;
|
||
const WHITE_BOP: u32 = 9999;
|
||
/// A second BoP epic, for the auto-loot sweep's one-dialog latch (18832 Brutality Blade).
|
||
const SECOND_BOP: u32 = 18832;
|
||
|
||
/// A world with `rows` open on a corpse, every template already landed, and `lua` run against
|
||
/// the loot bindings. Returns the app and the wire's receiver so a test can read what was
|
||
/// actually sent — the whole point of the arc being that a confirm sends **nothing**.
|
||
fn drain_with(
|
||
gold: u32,
|
||
rows: Vec<LootItem>,
|
||
lua: &str,
|
||
) -> (App, crossbeam_channel::Receiver<ClientCommand>) {
|
||
let (tx, rx) = crossbeam_channel::unbounded();
|
||
let mut app = App::new();
|
||
app.add_message::<crate::sound::LootPickupSound>()
|
||
.init_resource::<LootState>()
|
||
.init_resource::<LootLatch>()
|
||
.init_resource::<GroupState>()
|
||
.init_resource::<Items>()
|
||
.insert_resource(NetCommands(tx));
|
||
|
||
let mut items = app.world_mut().resource_mut::<Items>();
|
||
let mut tmpl = |entry: u32, name: &str, quality: u32, bonding: u32| {
|
||
items.insert_template(
|
||
entry,
|
||
Some(ItemInfo {
|
||
quality,
|
||
bonding,
|
||
..crate::items::test_template(name)
|
||
}),
|
||
);
|
||
};
|
||
tmpl(FELSTRIKER, "Felstriker", 4, 1);
|
||
tmpl(FLURRY_AXE, "Flurry Axe", 4, 2);
|
||
tmpl(TOUGH_JERKY, "Tough Jerky", 1, 0);
|
||
tmpl(WHITE_BOP, "A White Soulbound Thing", 1, 1);
|
||
|
||
app.world_mut()
|
||
.resource_mut::<LootState>()
|
||
.open(0x42, loot_type::CORPSE, gold, rows);
|
||
|
||
let script = UiScript::new().unwrap();
|
||
// A listener, so the event is observed where the real dialog driver sits rather than
|
||
// through a test-only back door.
|
||
script
|
||
.run(
|
||
"BIND_CONFIRMS = {}\n\
|
||
local f = CreateFrame(\"Frame\")\n\
|
||
f:RegisterEvent(\"LOOT_BIND_CONFIRM\")\n\
|
||
f:SetScript(\"OnEvent\", function() tinsert(BIND_CONFIRMS, arg1) end)",
|
||
)
|
||
.unwrap();
|
||
script.run(lua).unwrap();
|
||
app.insert_non_send_resource(script);
|
||
app.world_mut().run_system_once(drain_loot).unwrap();
|
||
(app, rx)
|
||
}
|
||
|
||
/// **The window waits for the item templates** (1805) — the reference's own open rule, and the
|
||
/// reason the cache-miss sentinels beside it are a floor rather than a plan.
|
||
///
|
||
/// `0x4c1cb0` returns without firing while `[0xb71b44]` is non-zero, and the item-cache arrival
|
||
/// callback `0x4c2ac0` fires `LOOT_OPENED` on that counter's falling edge instead. There is no
|
||
/// repaint to fall back on: `LootFrame_Update` runs only from `<OnShow>`, and `ShowUIPanel`
|
||
/// early-returns on an already-visible frame.
|
||
///
|
||
/// So: a corpse whose one row is an entry we have never seen must open NOTHING on the first
|
||
/// pass — and must ask the server for the template — then open exactly once when the answer
|
||
/// lands. A **negative** answer opens it too; a window held shut forever by an entry nobody can
|
||
/// describe is our one deliberate divergence from the reference's success-gated callback.
|
||
#[test]
|
||
fn the_window_waits_for_every_item_template() {
|
||
for answer in [
|
||
Some(ItemInfo {
|
||
quality: 1,
|
||
..crate::items::test_template("Thin Cloth Gloves")
|
||
}),
|
||
// …and the negative answer, which releases the window rather than holding it shut.
|
||
None,
|
||
] {
|
||
let (tx, rx) = crossbeam_channel::unbounded();
|
||
let mut app = App::new();
|
||
app.add_message::<crate::sound::LootPickupSound>()
|
||
.init_resource::<LootState>()
|
||
.init_resource::<crate::ui_chat::ChatLog>()
|
||
.init_resource::<GroupState>()
|
||
.init_resource::<NameCache>()
|
||
.init_resource::<Items>()
|
||
.init_resource::<ButtonInput<KeyCode>>()
|
||
.init_resource::<LootConfig>()
|
||
.insert_resource(NetCommands(tx))
|
||
// Registered in a schedule rather than driven by `run_system_once`, because the
|
||
// whole question is what happens ACROSS frames and `feed_loot`'s memo of the last
|
||
// snapshot is a `Local`: `run_system_once` builds a new system each call and hands
|
||
// it a fresh memo, which reads every pass as a first open.
|
||
.add_systems(bevy::prelude::Update, feed_loot);
|
||
app.world_mut().resource_mut::<LootState>().open(
|
||
0x42,
|
||
loot_type::CORPSE,
|
||
0,
|
||
vec![item(3, TOUGH_JERKY, 1)],
|
||
);
|
||
|
||
let script = UiScript::new().unwrap();
|
||
script
|
||
.run(
|
||
"OPENS = 0\n\
|
||
local f = CreateFrame(\"Frame\")\n\
|
||
f:RegisterEvent(\"LOOT_OPENED\")\n\
|
||
f:SetScript(\"OnEvent\", function() OPENS = OPENS + 1 end)",
|
||
)
|
||
.unwrap();
|
||
app.insert_non_send_resource(script);
|
||
|
||
let opens = |app: &mut App| {
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.eval::<i64>("return OPENS")
|
||
.unwrap()
|
||
};
|
||
|
||
// Pass one: the template is unknown, so the window stays shut — and the ask goes out.
|
||
app.update();
|
||
assert_eq!(
|
||
opens(&mut app),
|
||
0,
|
||
"no LOOT_OPENED while a template is pending"
|
||
);
|
||
assert!(
|
||
sent(&rx).iter().any(
|
||
|c| matches!(c, ClientCommand::ItemQuery { entry, .. } if *entry == TOUGH_JERKY)
|
||
),
|
||
"and the template was asked for"
|
||
);
|
||
|
||
// A second pass with nothing new changes nothing — the deferral is not a one-shot.
|
||
app.update();
|
||
assert_eq!(opens(&mut app), 0);
|
||
|
||
// The answer lands: the window opens, once.
|
||
app.world_mut()
|
||
.resource_mut::<Items>()
|
||
.insert_template(TOUGH_JERKY, answer.clone());
|
||
app.update();
|
||
assert_eq!(opens(&mut app), 1, "opened when the last template answered");
|
||
app.update();
|
||
assert_eq!(opens(&mut app), 1, "and only once");
|
||
}
|
||
}
|
||
|
||
/// The rows `LOOT_BIND_CONFIRM` has named so far, in order.
|
||
fn confirms(app: &mut App) -> Vec<i64> {
|
||
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||
let n = script.eval::<i64>("return getn(BIND_CONFIRMS)").unwrap();
|
||
(1..=n)
|
||
.map(|i| {
|
||
script
|
||
.eval::<i64>(&format!("return BIND_CONFIRMS[{i}]"))
|
||
.unwrap()
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
/// Everything the wire saw, in order.
|
||
fn sent(rx: &crossbeam_channel::Receiver<ClientCommand>) -> Vec<ClientCommand> {
|
||
rx.try_iter().collect()
|
||
}
|
||
|
||
/// Which row the app is holding a confirm open for.
|
||
fn pending_confirm(app: &App) -> Option<u32> {
|
||
app.world().resource::<LootState>().pending_bind_confirm
|
||
}
|
||
|
||
/// The click arm's deferral (`0x4c28f2`-`0x4c2920`): a BoP uncommon-or-better row fires
|
||
/// `LOOT_BIND_CONFIRM` with its row, stashes the row, and sends **nothing** — not the
|
||
/// autostore, and not the pickup sound either, which the reference plays only on the arm that
|
||
/// sends.
|
||
#[test]
|
||
fn a_bop_row_confirms_instead_of_sending() {
|
||
let (mut app, rx) = drain_with(0, vec![item(0, FELSTRIKER, 1)], "BenillaTakeLootSlot(1)");
|
||
assert!(sent(&rx).is_empty(), "the deferred take sends nothing");
|
||
assert_eq!(pending_confirm(&app), Some(1), "and stashes the row");
|
||
|
||
assert_eq!(
|
||
confirms(&mut app),
|
||
vec![1],
|
||
"the event carries the row out, 1-based and display-side"
|
||
);
|
||
}
|
||
|
||
/// The confirm arm (`0x4c27c0`): `LootSlot` on the pending row sends the autostore for that
|
||
/// row's WIRE slot and clears the stash — so a doubled OnAccept cannot loot twice.
|
||
#[test]
|
||
fn loot_slot_completes_the_pending_confirm_exactly_once() {
|
||
let (mut app, rx) = drain_with(
|
||
0,
|
||
vec![item(7, FELSTRIKER, 1)],
|
||
"BenillaTakeLootSlot(1) LootSlot(1)",
|
||
);
|
||
assert!(
|
||
matches!(
|
||
sent(&rx)[..],
|
||
[ClientCommand::AutostoreLootItem { slot: 7 }]
|
||
),
|
||
"the accept sends the row's wire slot, once"
|
||
);
|
||
assert_eq!(pending_confirm(&app), None, "and the stash clears");
|
||
|
||
// A second accept over the same dialog: nothing left to complete.
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.run("LootSlot(1)")
|
||
.unwrap();
|
||
app.world_mut().run_system_once(drain_loot).unwrap();
|
||
assert!(sent(&rx).is_empty(), "a second accept sends nothing");
|
||
}
|
||
|
||
/// `LootSlot` on any row that is NOT the pending confirm does nothing at all — which is
|
||
/// exactly what it does on the real client, where the flag-1 arm opens `cmp edi,[0x847cec]`.
|
||
/// This is the property that makes the two-verb split worth having.
|
||
#[test]
|
||
fn loot_slot_on_an_ordinary_row_sends_nothing() {
|
||
let (_app, rx) = drain_with(
|
||
0,
|
||
vec![item(0, TOUGH_JERKY, 1), item(1, FLURRY_AXE, 1)],
|
||
"LootSlot(1) LootSlot(2)",
|
||
);
|
||
assert!(
|
||
sent(&rx).is_empty(),
|
||
"no confirm is pending, so neither call reaches the wire"
|
||
);
|
||
}
|
||
|
||
/// Both conjuncts are load-bearing, and each fails alone. A bind-on-EQUIP epic and a
|
||
/// bind-on-pickup WHITE are both taken outright, with no dialog — the second is why looting a
|
||
/// grey quest trinket never asks.
|
||
#[test]
|
||
fn the_bind_gate_needs_both_conjuncts() {
|
||
for (entry, why) in [
|
||
(FLURRY_AXE, "bind-on-equip is not bind-on-pickup"),
|
||
(WHITE_BOP, "quality 1 is below the floor of 2"),
|
||
(TOUGH_JERKY, "neither"),
|
||
] {
|
||
let (app, rx) = drain_with(0, vec![item(3, entry, 1)], "BenillaTakeLootSlot(1)");
|
||
assert!(
|
||
matches!(
|
||
sent(&rx)[..],
|
||
[ClientCommand::AutostoreLootItem { slot: 3 }]
|
||
),
|
||
"entry {entry} should be taken outright: {why}"
|
||
);
|
||
assert_eq!(
|
||
pending_confirm(&app),
|
||
None,
|
||
"entry {entry}: no confirm ({why})"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// The stash is display-side and coin-aware. The reference's own event argument is the item
|
||
/// ARRAY index plus one — it is written after the coin-row shift (`0x4c2885 dec edi`) and never
|
||
/// shifted back — so on a corpse with gold its `arg1` is one below the display row. benilla
|
||
/// speaks the display row on both halves of the round trip instead (1744), which is what makes
|
||
/// `GetLootSlotInfo(arg1)` mean what it looks like it means.
|
||
#[test]
|
||
fn the_coin_row_does_not_shift_the_confirm_out_from_under_itself() {
|
||
let (mut app, rx) = drain_with(
|
||
120, // gold, so display row 1 is the coin and the item is row 2
|
||
vec![item(4, FELSTRIKER, 1)],
|
||
"BenillaTakeLootSlot(2)",
|
||
);
|
||
assert_eq!(
|
||
pending_confirm(&app),
|
||
Some(2),
|
||
"the DISPLAY row, coin included"
|
||
);
|
||
assert_eq!(confirms(&mut app), vec![2]);
|
||
assert!(sent(&rx).is_empty());
|
||
|
||
// And the accept, with the same number, reaches the right WIRE slot.
|
||
app.world_mut()
|
||
.non_send_resource_mut::<UiScript>()
|
||
.run("LootSlot(2)")
|
||
.unwrap();
|
||
app.world_mut().run_system_once(drain_loot).unwrap();
|
||
assert!(matches!(
|
||
sent(&rx)[..],
|
||
[ClientCommand::AutostoreLootItem { slot: 4 }]
|
||
));
|
||
}
|
||
|
||
/// The row vanishes under the open dialog — someone else in the group took it while the
|
||
/// confirm sat there. The accept sends nothing (`0x4c27d7`: the continuation re-reads the
|
||
/// record and returns on an empty itemId) and, like the reference, leaves the stash alone: the
|
||
/// clear lives on the send path only (`0x4c281a`).
|
||
#[test]
|
||
fn an_accept_for_a_row_that_was_taken_away_sends_nothing() {
|
||
let (mut app, rx) = drain_with(0, vec![item(2, FELSTRIKER, 1)], "BenillaTakeLootSlot(1)");
|
||
assert_eq!(pending_confirm(&app), Some(1));
|
||
|
||
// SMSG_LOOT_REMOVED for that wire slot: the row becomes a gap.
|
||
app.world_mut().resource_mut::<LootState>().remove_slot(2);
|
||
let script = app.world_mut().non_send_resource_mut::<UiScript>();
|
||
script.run("LootSlot(1)").unwrap();
|
||
app.world_mut().run_system_once(drain_loot).unwrap();
|
||
|
||
assert!(
|
||
sent(&rx)
|
||
.iter()
|
||
.all(|c| !matches!(c, ClientCommand::AutostoreLootItem { .. })),
|
||
"nothing is autostored for a row that is gone"
|
||
);
|
||
}
|
||
|
||
/// A pending confirm dies with the window that raised it (`0x4c1df5`: the `SMSG_LOOT_RESPONSE`
|
||
/// copier resets the stash to -1). Its row number would name a slot in the NEXT corpse.
|
||
#[test]
|
||
fn a_pending_confirm_does_not_survive_the_window() {
|
||
let (mut app, _rx) = drain_with(0, vec![item(0, FELSTRIKER, 1)], "BenillaTakeLootSlot(1)");
|
||
assert_eq!(pending_confirm(&app), Some(1));
|
||
|
||
app.world_mut().resource_mut::<LootState>().clear();
|
||
assert_eq!(pending_confirm(&app), None, "the close drops it");
|
||
|
||
app.world_mut().resource_mut::<LootState>().open(
|
||
0x43,
|
||
loot_type::CORPSE,
|
||
0,
|
||
vec![item(0, TOUGH_JERKY, 1)],
|
||
);
|
||
assert_eq!(
|
||
pending_confirm(&app),
|
||
None,
|
||
"and a fresh window opens with none"
|
||
);
|
||
}
|
||
|
||
/// **The auto-loot sweep's one-shot latch** (`0x4c21c2 test ebx,ebx; jne` → the loop's
|
||
/// continue, `0x4c21e2 mov ebx,1`; decision 1744). A corpse with two bind-on-pickup blues and
|
||
/// a white: the sweep takes the white, raises ONE dialog for the first blue, and leaves the
|
||
/// second blue in the window untouched — not taken, not asked about. Without the latch it
|
||
/// would stack two dialogs over a single pending slot, and the second would name a row the
|
||
/// stash no longer holds.
|
||
#[test]
|
||
fn the_auto_loot_sweep_raises_exactly_one_bind_confirm() {
|
||
let (tx, rx) = crossbeam_channel::unbounded();
|
||
let mut app = App::new();
|
||
app.add_message::<crate::sound::LootPickupSound>()
|
||
.init_resource::<LootState>()
|
||
.init_resource::<crate::ui_chat::ChatLog>()
|
||
.init_resource::<GroupState>()
|
||
.init_resource::<NameCache>()
|
||
.init_resource::<Items>()
|
||
.init_resource::<ButtonInput<KeyCode>>()
|
||
.insert_resource(LootConfig {
|
||
auto_loot: true,
|
||
..LootConfig::default()
|
||
})
|
||
.insert_resource(NetCommands(tx));
|
||
|
||
let mut items = app.world_mut().resource_mut::<Items>();
|
||
for (entry, name, quality, bonding) in [
|
||
(FELSTRIKER, "Felstriker", 4, 1),
|
||
(SECOND_BOP, "Brutality Blade", 4, 1),
|
||
(TOUGH_JERKY, "Tough Jerky", 1, 0),
|
||
] {
|
||
items.insert_template(
|
||
entry,
|
||
Some(ItemInfo {
|
||
quality,
|
||
bonding,
|
||
..crate::items::test_template(name)
|
||
}),
|
||
);
|
||
}
|
||
|
||
// Wire slots 5/6/7 in display order: BoP epic, BoP epic, white.
|
||
app.world_mut().resource_mut::<LootState>().open(
|
||
0x42,
|
||
loot_type::CORPSE,
|
||
0,
|
||
vec![
|
||
item(5, FELSTRIKER, 1),
|
||
item(6, SECOND_BOP, 1),
|
||
item(7, TOUGH_JERKY, 1),
|
||
],
|
||
);
|
||
let script = UiScript::new().unwrap();
|
||
script
|
||
.run(
|
||
"BIND_CONFIRMS = {}\n\
|
||
local f = CreateFrame(\"Frame\")\n\
|
||
f:RegisterEvent(\"LOOT_BIND_CONFIRM\")\n\
|
||
f:SetScript(\"OnEvent\", function() tinsert(BIND_CONFIRMS, arg1) end)",
|
||
)
|
||
.unwrap();
|
||
app.insert_non_send_resource(script);
|
||
app.world_mut().run_system_once(feed_loot).unwrap();
|
||
|
||
assert_eq!(
|
||
confirms(&mut app),
|
||
vec![1],
|
||
"one dialog, for the FIRST bind-on-pickup row only"
|
||
);
|
||
assert_eq!(pending_confirm(&app), Some(1));
|
||
assert!(
|
||
matches!(
|
||
sent(&rx)[..],
|
||
[ClientCommand::AutostoreLootItem { slot: 7 }]
|
||
),
|
||
"the white is swept; neither blue is sent"
|
||
);
|
||
}
|
||
|
||
fn item(slot: u8, entry: u32, count: u32) -> LootItem {
|
||
LootItem {
|
||
slot,
|
||
item_id: entry,
|
||
count,
|
||
display_info_id: 1000 + entry,
|
||
random_property_id: 0,
|
||
slot_type: 0,
|
||
}
|
||
}
|
||
|
||
/// 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 {
|
||
ItemPushResult {
|
||
player_guid: 0x1,
|
||
from_npc,
|
||
created,
|
||
show_in_chat: true,
|
||
bag_slot: 0xFF,
|
||
item_slot: 0,
|
||
item_entry: entry,
|
||
suffix_factor: 0,
|
||
random_property_id: 0,
|
||
count,
|
||
}
|
||
}
|
||
|
||
fn pending(entry: u32, count: u32, from_npc: bool, created: bool) -> PendingReceive {
|
||
let mut loot = LootState::default();
|
||
loot.push_receive(&push(entry, count, from_npc, created));
|
||
loot.receives.pop().expect("queued")
|
||
}
|
||
|
||
#[test]
|
||
fn action_maps_coin_first_then_items_by_wire_slot() {
|
||
let mut loot = LootState::default();
|
||
assert!(loot.source.is_none());
|
||
// gold + two items at wire slots 0 and 1.
|
||
loot.open(
|
||
0x42,
|
||
loot_type::CORPSE,
|
||
1234,
|
||
vec![item(0, 117, 1), item(1, 2589, 5)],
|
||
);
|
||
assert!(loot.source.is_some());
|
||
// Row 1 is the coin pile; rows 2/3 are the items (wire slots 0/1). The item pick carries the
|
||
// row's display id (here `1000 + entry`) so the pickup sound resolves without a second lookup.
|
||
assert!(matches!(loot.action_at(1), Some(LootAction::Money)));
|
||
assert!(matches!(
|
||
loot.action_at(2),
|
||
Some(LootAction::Item {
|
||
wire_slot: 0,
|
||
display_id: 1117,
|
||
..
|
||
})
|
||
));
|
||
assert!(matches!(
|
||
loot.action_at(3),
|
||
Some(LootAction::Item {
|
||
wire_slot: 1,
|
||
display_id: 3589,
|
||
..
|
||
})
|
||
));
|
||
assert!(loot.action_at(4).is_none());
|
||
assert!(loot.action_at(0).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn action_maps_items_directly_when_no_coin() {
|
||
let mut loot = LootState::default();
|
||
loot.open(
|
||
0x42,
|
||
loot_type::CORPSE,
|
||
0,
|
||
vec![item(3, 117, 1), item(7, 2589, 5)],
|
||
);
|
||
// No coin → row 1 is the first item (wire slot 3), row 2 the second (wire slot 7).
|
||
assert!(matches!(
|
||
loot.action_at(1),
|
||
Some(LootAction::Item { wire_slot: 3, .. })
|
||
));
|
||
assert!(matches!(
|
||
loot.action_at(2),
|
||
Some(LootAction::Item { wire_slot: 7, .. })
|
||
));
|
||
assert!(loot.action_at(3).is_none());
|
||
}
|
||
|
||
/// A looted row becomes a GAP at its fixed position — the layout never compacts while the
|
||
/// window is open (the reference hides that one button in place, `LootFrame.lua:22-37`; the
|
||
/// director's report: on ref, looting the gold does NOT pull the items up).
|
||
#[test]
|
||
fn remove_slot_leaves_a_gap_at_the_fixed_position() {
|
||
let mut loot = LootState::default();
|
||
loot.open(
|
||
0x42,
|
||
loot_type::CORPSE,
|
||
0,
|
||
vec![item(0, 117, 1), item(1, 2589, 5), item(2, 4306, 2)],
|
||
);
|
||
// Take the middle wire slot: row 2 is now a dead gap; rows 1 and 3 keep their positions
|
||
// AND their wire slots.
|
||
loot.remove_slot(1);
|
||
assert!(matches!(
|
||
loot.action_at(1),
|
||
Some(LootAction::Item { wire_slot: 0, .. })
|
||
));
|
||
assert!(loot.action_at(2).is_none(), "the looted row is a gap");
|
||
assert!(matches!(
|
||
loot.action_at(3),
|
||
Some(LootAction::Item { wire_slot: 2, .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn clear_money_leaves_the_coin_slot_as_a_gap() {
|
||
let mut loot = LootState::default();
|
||
loot.open(0x42, loot_type::CORPSE, 500, vec![item(0, 117, 1)]);
|
||
assert!(loot.has_coin());
|
||
assert!(matches!(loot.action_at(1), Some(LootAction::Money)));
|
||
loot.clear_money();
|
||
assert!(!loot.has_coin());
|
||
// The coin slot stays in the layout as a gap; the item does NOT shift up into it.
|
||
assert!(loot.action_at(1).is_none(), "the looted coin slot is a gap");
|
||
assert!(matches!(
|
||
loot.action_at(2),
|
||
Some(LootAction::Item { wire_slot: 0, .. })
|
||
));
|
||
}
|
||
|
||
#[test]
|
||
fn auto_release_arms_only_on_the_transition_to_empty() {
|
||
let mut loot = LootState::default();
|
||
// Coin + two items: taking rows one by one arms the auto-close only at the last removal.
|
||
loot.open(
|
||
0x42,
|
||
loot_type::CORPSE,
|
||
500,
|
||
vec![item(0, 117, 1), item(1, 2589, 5)],
|
||
);
|
||
loot.remove_slot(0);
|
||
assert!(!loot.take_auto_release(), "items + coin remain");
|
||
loot.clear_money();
|
||
assert!(!loot.take_auto_release(), "an item remains");
|
||
loot.remove_slot(1);
|
||
assert!(loot.take_auto_release(), "last row gone — auto-close due");
|
||
assert!(!loot.take_auto_release(), "the edge drains once");
|
||
}
|
||
|
||
#[test]
|
||
fn auto_release_arms_when_the_coin_line_is_the_last_row() {
|
||
let mut loot = LootState::default();
|
||
loot.open(0x42, loot_type::CORPSE, 500, vec![]);
|
||
loot.clear_money();
|
||
assert!(
|
||
loot.take_auto_release(),
|
||
"coin-only loot empties → auto-close"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn empty_at_open_does_not_auto_release() {
|
||
let mut loot = LootState::default();
|
||
// The reference keeps an empty-at-open window up (LOOTWINDOWOPENEMPTY); only a removal closes.
|
||
loot.open(0x42, loot_type::CORPSE, 0, vec![]);
|
||
assert!(!loot.take_auto_release());
|
||
// …and a fresh open clears a stale armed edge.
|
||
loot.open(0x43, loot_type::CORPSE, 500, vec![]);
|
||
loot.clear_money();
|
||
loot.open(0x44, loot_type::CORPSE, 0, vec![item(0, 117, 1)]);
|
||
assert!(
|
||
!loot.take_auto_release(),
|
||
"open() disarms the previous window's edge"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn latch_clears_guid_matched_only() {
|
||
// The corpse-switch race (decision 0515): loot B was requested while A's window was open;
|
||
// A's release response must not drop the latch B's request just armed.
|
||
let mut latch = LootLatch(Some(0xB));
|
||
latch.clear_for(0xA);
|
||
assert_eq!(latch.0, Some(0xB), "a stale release leaves the new latch");
|
||
latch.clear_for(0xB);
|
||
assert_eq!(latch.0, None, "the matching release drops it");
|
||
}
|
||
|
||
/// The `IsFishingLoot()` source (decision 1086): wire `loot_type` 3 flags the open, any other
|
||
/// type doesn't, and every close path drops the flag (a stale `true` would reel-in-sound the
|
||
/// next corpse loot).
|
||
#[test]
|
||
fn fishing_loot_type_sets_and_clears_the_flag() {
|
||
let mut loot = LootState::default();
|
||
loot.open(0x42, loot_type::FISHING, 0, vec![item(0, 117, 1)]);
|
||
assert!(loot.fishing);
|
||
loot.clear();
|
||
assert!(!loot.fishing);
|
||
loot.open(0x42, loot_type::CORPSE, 0, vec![item(0, 117, 1)]);
|
||
assert!(!loot.fishing);
|
||
// A fishing open REPLACED by an ordinary one drops the flag too (no clear in between).
|
||
loot.open(0x42, loot_type::FISHING, 0, vec![]);
|
||
loot.open(0x43, loot_type::CORPSE, 0, vec![]);
|
||
assert!(!loot.fishing);
|
||
}
|
||
|
||
#[test]
|
||
fn clear_closes_but_keeps_receives() {
|
||
let mut loot = LootState::default();
|
||
loot.open(0x42, loot_type::CORPSE, 0, vec![item(0, 117, 1)]);
|
||
loot.push_receive(&push(117, 1, false, false));
|
||
loot.clear();
|
||
assert!(loot.source.is_none());
|
||
assert_eq!(loot.receives.len(), 1, "a receive line outlives the window");
|
||
loot.clear_session();
|
||
assert!(loot.receives.is_empty(), "disconnect drops receive lines");
|
||
}
|
||
|
||
/// The receive line is an item **link**, not a bare name: the quality escape opens before the
|
||
/// `[` and `|r` closes after the `]`, so the count that follows falls back to the line's own
|
||
/// LOOT green. This is the shape the reference client prints (`[Chipped Claw]x2.` — white name,
|
||
/// green `x2`), and the reason the name must not inherit the chat colour.
|
||
#[test]
|
||
fn receive_line_carries_a_quality_colored_item_link() {
|
||
// Common/white, single — LOOT_ITEM_SELF = "You receive loot: %s.".
|
||
assert_eq!(
|
||
receive_line(&pending(2589, 1, false, false), "Linen Cloth", 1),
|
||
"You receive loot: |cffffffff|Hitem:2589:0:0:0|h[Linen Cloth]|h|r."
|
||
);
|
||
// Poor/grey, stacked — LOOT_ITEM_SELF_MULTIPLE = "You receive loot: %sx%d." and the `x2`
|
||
// sits OUTSIDE the escape, with no space before it (GlobalStrings.lua l.2605, verbatim).
|
||
assert_eq!(
|
||
receive_line(&pending(7092, 2, false, false), "Chipped Claw", 0),
|
||
"You receive loot: |cff9d9d9d|Hitem:7092:0:0:0|h[Chipped Claw]|h|rx2."
|
||
);
|
||
// The (created, received) pair picks the verb, created winning over received.
|
||
assert_eq!(
|
||
receive_line(&pending(4306, 1, true, false), "Silk Cloth", 2),
|
||
"You receive item: |cff1eff00|Hitem:4306:0:0:0|h[Silk Cloth]|h|r."
|
||
);
|
||
assert_eq!(
|
||
receive_line(&pending(2320, 4, true, true), "Coarse Thread", 1),
|
||
"You create: |cffffffff|Hitem:2320:0:0:0|h[Coarse Thread]|h|rx4."
|
||
);
|
||
}
|
||
|
||
/// The link's two random-property fields ride the wire straight through
|
||
/// (`|Hitem:id:0:randomPropertyId:suffixFactor|h` — `0x52adb0`'s arg order).
|
||
#[test]
|
||
fn receive_line_carries_the_wire_random_property_fields() {
|
||
let mut loot = LootState::default();
|
||
loot.push_receive(&ItemPushResult {
|
||
random_property_id: 862,
|
||
suffix_factor: 1234,
|
||
..push(15268, 1, false, false)
|
||
});
|
||
let r = loot.receives.pop().expect("queued");
|
||
assert_eq!(
|
||
receive_line(&r, "Bloodrazor", 3),
|
||
"You receive loot: |cff0070dd|Hitem:15268:0:862:1234|h[Bloodrazor]|h|r."
|
||
);
|
||
}
|
||
|
||
/// The **rolled name** — the reference composes every display of an item's name through
|
||
/// `0x5d8b00(entry, randomPropertyId)`, which joins `ItemRandomProperties`' suffix with
|
||
/// `ITEM_SUFFIX_TEMPLATE` ("%s %s"). Byte-verified for the loot row itself (`GetLootSlotInfo`'s
|
||
/// `item` producer `0x4c2550` ends in that call, wow-re `loot-slot-record.md` §3), and the
|
||
/// tooltip's own title line makes the same call — so row text, tooltip plate and link agree by
|
||
/// construction. Decision 1547.
|
||
#[test]
|
||
fn a_rolled_drop_reads_its_suffix_in_the_row_the_link_and_the_lines() {
|
||
use benilla_formats::{RandomProperty, RandomPropertyCatalog};
|
||
// "of the Monkey" (row 584 of the shipped table) — Agility +7, Stamina +7.
|
||
let props = crate::items::RandomProperties(RandomPropertyCatalog::from_rows(
|
||
[(
|
||
584,
|
||
RandomProperty {
|
||
suffix: "of the Monkey".into(),
|
||
enchants: [74, 71, 0, 0, 0],
|
||
},
|
||
)]
|
||
.into_iter()
|
||
.collect(),
|
||
));
|
||
let enchants = crate::items::Enchants(benilla_formats::EnchantCatalog::from_rows(
|
||
std::collections::HashMap::new(),
|
||
[
|
||
(74, "Agility +7".to_string()),
|
||
(71, "Stamina +7".to_string()),
|
||
]
|
||
.into_iter()
|
||
.collect(),
|
||
[(74, 0), (71, 0)].into_iter().collect(),
|
||
));
|
||
let rolls = RollCatalogs {
|
||
props: Some(&props),
|
||
enchants: Some(&enchants),
|
||
};
|
||
assert_eq!(rolls.name("Bloodrazor", 584), "Bloodrazor of the Monkey");
|
||
// An unrolled drop keeps the plain name — the formatter's other exit.
|
||
assert_eq!(rolls.name("Bloodrazor", 0), "Bloodrazor");
|
||
// The roll's enchant lines land in slots 2..6 (the suffix band), which is what makes them
|
||
// white at the renderer.
|
||
let lines = rolls.lines(584);
|
||
assert_eq!(
|
||
lines
|
||
.iter()
|
||
.map(|l| (l.slot, l.name.as_str()))
|
||
.collect::<Vec<_>>(),
|
||
vec![(2, "Agility +7"), (3, "Stamina +7")],
|
||
);
|
||
assert!(rolls.lines(0).is_empty(), "no roll, no lines");
|
||
}
|
||
|
||
/// The client clamps a quality outside the table to index 1 (white) — `0x52ad90`'s
|
||
/// `cmpl $0x7 / jb` arm.
|
||
#[test]
|
||
fn receive_line_clamps_an_out_of_range_quality_to_white() {
|
||
assert_eq!(
|
||
receive_line(&pending(1, 1, false, false), "Odd Thing", 9),
|
||
"You receive loot: |cffffffff|Hitem:1:0:0:0|h[Odd Thing]|h|r."
|
||
);
|
||
}
|
||
|
||
/// [`push_container`] against the reference selector at `0x491bb5`-`0x491bd6`, arm by arm — the
|
||
/// value that decides which bag button the drop animation plays on.
|
||
#[test]
|
||
fn push_container_maps_the_wire_destination_onto_a_bag_bar_button() {
|
||
// `bag != 255` — an equipped bag. Wire 19..22 are the four bag inventory slots, and the
|
||
// emitted value is the reference's own `bag + 1`: 20..23, the ids its buttons carry.
|
||
assert_eq!(push_container(19, 0), 20);
|
||
assert_eq!(push_container(22, 5), 23);
|
||
// `bag == 255` with a keyring slot (the client's own 0x51..=0x70 window) — the keyring.
|
||
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 81), KEYRING_CONTAINER);
|
||
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 96), KEYRING_CONTAINER);
|
||
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 112), KEYRING_CONTAINER);
|
||
// `bag == 255` anywhere else — the backpack. 23..38 are its own slots; 80 and 113 are the
|
||
// positions just outside the keyring window, which the client resolves to 0, not -2.
|
||
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 23), 0);
|
||
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 80), 0);
|
||
assert_eq!(push_container(BAG_PLAYER_INVENTORY, 113), 0);
|
||
// A stack-merge reports no slot at all (`0xFFFF_FFFF`) — still the backpack, still animates.
|
||
assert_eq!(push_container(BAG_PLAYER_INVENTORY, u32::MAX), 0);
|
||
// A bank bag (wire 63..68) lands on an id no bag-bar button carries: no animation, which is
|
||
// exactly what the reference's `bag + 1` does with the same push.
|
||
assert!(!(20..=23).contains(&push_container(63, 0)));
|
||
}
|
||
|
||
#[test]
|
||
fn money_formats_with_real_words_dropping_zero_denominations() {
|
||
assert_eq!(format_money(0), "0 Copper");
|
||
assert_eq!(format_money(4), "4 Copper");
|
||
assert_eq!(format_money(25), "25 Copper");
|
||
assert_eq!(format_money(10025), "1 Gold 25 Copper");
|
||
assert_eq!(format_money(12_345), "1 Gold 23 Silver 45 Copper");
|
||
assert_eq!(format_money(10_000), "1 Gold");
|
||
}
|
||
|
||
/// The reference's six-step ladder at `0x6c6307`–`0x6c6386`, both sides of every boundary.
|
||
#[test]
|
||
fn coin_icon_walks_the_references_six_step_ladder() {
|
||
let icon = |n: u32| coin_icon(n).rsplit('\\').next().unwrap().to_string();
|
||
assert_eq!(icon(0), "INV_Misc_Coin_05");
|
||
assert_eq!(icon(9), "INV_Misc_Coin_05");
|
||
assert_eq!(icon(10), "INV_Misc_Coin_06");
|
||
assert_eq!(icon(99), "INV_Misc_Coin_06");
|
||
assert_eq!(icon(100), "INV_Misc_Coin_03");
|
||
assert_eq!(icon(999), "INV_Misc_Coin_03");
|
||
assert_eq!(icon(1_000), "INV_Misc_Coin_04");
|
||
assert_eq!(icon(9_999), "INV_Misc_Coin_04");
|
||
assert_eq!(icon(10_000), "INV_Misc_Coin_01");
|
||
assert_eq!(icon(99_999), "INV_Misc_Coin_01");
|
||
assert_eq!(icon(100_000), "INV_Misc_Coin_02");
|
||
assert_eq!(icon(u32::MAX), "INV_Misc_Coin_02");
|
||
}
|
||
|
||
#[test]
|
||
fn coin_row_uses_real_words_and_the_ladders_icon() {
|
||
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,
|
||
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);
|
||
assert_eq!(coin.name.as_deref(), Some("4 Copper"));
|
||
assert_eq!(coin.texture.as_deref(), Some(coin_icon(4)));
|
||
}
|
||
|
||
#[test]
|
||
fn snapshot_prepends_coin_and_resolves_items() {
|
||
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,
|
||
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,
|
||
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);
|
||
assert_eq!(coin.name.as_deref(), Some("1 Gold 23 Silver 45 Copper"));
|
||
assert_eq!(coin.texture.as_deref(), Some(coin_icon(12_345)));
|
||
// The item's name is nil while its template is in flight; its quantity is present.
|
||
let row = snap.rows[1].as_ref().expect("item row present");
|
||
assert!(!row.is_coin);
|
||
assert!(row.name.is_none());
|
||
assert_eq!(row.quantity, 3);
|
||
|
||
// 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,
|
||
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,
|
||
Candidates {
|
||
group: &grp,
|
||
names: &nm,
|
||
},
|
||
)
|
||
.expect("still open");
|
||
assert_eq!(snap.rows, vec![None, None]);
|
||
assert!(loot.take_auto_release(), "nothing lootable left");
|
||
}
|
||
|
||
#[test]
|
||
fn page_math_two_pages_of_three_and_two() {
|
||
// The window shows 4 rows/page; > 4 items ⇒ 3 rows/page (a page slot is spent on the pager).
|
||
// 5 items ⇒ ceil(5/3) = 2 pages, 3 then 2 (LootFrame.lua:70-73,112). Pure arithmetic mirror
|
||
// of the shipped XML's paging, unit-tested here so a regression in either is caught.
|
||
let num_items = 5u32;
|
||
let per_page = if num_items > 4 { 3 } else { 4 };
|
||
let pages = num_items.div_ceil(per_page);
|
||
assert_eq!(per_page, 3);
|
||
assert_eq!(pages, 2);
|
||
// Page 1 shows rows for display slots 1..=3, page 2 for 4..=5.
|
||
let page1: Vec<u32> = (1..=per_page).filter(|&i| i <= num_items).collect();
|
||
let page2: Vec<u32> = (per_page + 1..=2 * per_page)
|
||
.filter(|&i| i <= num_items)
|
||
.collect();
|
||
assert_eq!(page1, vec![1, 2, 3]);
|
||
assert_eq!(page2, vec![4, 5]);
|
||
// 4 items ⇒ a single page of 4, no pager.
|
||
assert_eq!(if 4u32 > 4 { 3 } else { 4 }, 4);
|
||
}
|
||
}
|