Files
benilla-octo/crates/benilla-app/src/ui_loot.rs
T
SamandClaude Fable 5 cf581aa2ac the auction house opens, the Honor tab lands, and a day of fixes
- the auction house end to end: right-clicking an auctioneer opens Browse/Bids/Auctions on the server's reply — search, the category tree from your own DBCs, the reference's 50-a-page pager, multi-key sorting (directions corrected at the bytes), bid/buyout, the sell slot with deposit and duration, cancel with its fee popup; prices sit on the reference's own money-frame kit in lined-up columns, the twenty auction messages split refusals-red / outcomes-to-chat, and a won or sold auction reads as a proper mail receipt — the window's Lua re-worded in our own terms after the overlap ratchet caught the first cut leaning on the reference's text
- the PvP honor system whole: the Honor tab and the inspect page over a real wire model, with current rank and highest-ever rank correctly two different fields
- a day of reported fixes: WASD survives a /logout re-entry, addon settings save on a window close and Cmd+Q (exactly once — a mac close could fire the tail twice), clams open again, random-enchant items name themselves on every surface, the loot window's rows hold their seats with legible counts, idle players stop sinking through building floors, the shooter re-pulls between Auto Shots, portraits loosen ~19% to the client's own aspect-1.0 matrix, and big creatures thump the camera off the CameraShakes table
- character select clicks like 1.12 — captions whiten on hover, buttons fire on release, Humans face straight ahead off the stage bone's real yaw, the facing is per-character, and hunter/warlock pets stand beside you at their family-ramp size with their own emitters (the imp's flames), the Voidwalker's sequence-hidden shackles culled
- the smaller player-facing set: nameplate on/off rows in Options with the CanAttack category gate (emissaries stop plating), the bonus bar slides up with igBonusBarOpen, the camera's framing height glides on a shapeshift, the open letter evicts the centre seat, the far-band fog hull stops printing a pale horizon stripe at low view distance, the guide arrow is gold with per-source rim art, error text fades through its shadow too, and /convertraid converts the party
- README: the auction house and PvP honor move out of "not built yet"

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 19:04:27 +07:00

1395 lines
66 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 → [`LootErrors`]; `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 errors + receive lines on the red UI error line (the
//! equip-error path's exact shape — an ErrorsFrame-style v1 stopgap that migrates to the chat frame
//! next arc), 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::{ItemPushResult, LootItem, BAG_PLAYER_INVENTORY, SLOT_BAG_FIRST};
use bevy::prelude::*;
use benilla_ui::script::{LootRow, LootState as LootSnapshot, ScriptValue, UiScript};
use crate::entities::ItemDisplays;
use crate::items::{Items, RollCatalogs};
use crate::net::{ClientCommand, NetCommands};
use crate::ui_items::KEYRING_CONTAINER;
use crate::ui_script::UiInput;
/// The coin-pile row icons (direct `Interface\Icons` paths — `SetTexture` takes them as-is, no DBC),
/// one per denomination; [`coin_icon`] picks by the highest nonzero denomination. All three VERIFIED
/// to extract from `interface.MPQ` (`INV_Misc_Coin_01`=gold pile, `_03`=silver, `_05`=copper).
///
/// **Stated approximation (documented gap).** The real 1.12 client chooses the coin-pile art from the
/// exact amount inside `GetLootSlotInfo` — a client-internal C selection that is **not** RE-recorded
/// (no wow-re node owns it; not dispatched). Mapping the icon to the highest nonzero denomination is
/// our faithful-shaped stand-in: a gold amount shows the gold pile, a silver-only amount the silver
/// pile, a copper-only amount the copper pile.
const COIN_ICON_GOLD: &str = "Interface\\Icons\\INV_Misc_Coin_01";
const COIN_ICON_SILVER: &str = "Interface\\Icons\\INV_Misc_Coin_03";
const COIN_ICON_COPPER: &str = "Interface\\Icons\\INV_Misc_Coin_05";
/// The fixed quality of the synthesized coin row (common/white — the money text reads as plain).
const COIN_QUALITY: u32 = 1;
/// 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)` → the live-API **container id** the bag bar speaks (`0` backpack,
/// `1..=4` an equipped bag, [`KEYRING_CONTAINER`] the keyring) — `ITEM_PUSH`'s `arg1`.
///
/// **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)
/// ```
///
/// The one translation: the reference emits `bag + 1`, which is the **inventory-slot** id its bag
/// buttons carry (`CharacterBag0Slot` = 20 … `Bag3Slot` = 23, `GetInventorySlotInfo`); benilla's bag
/// bar is keyed by container id like every other bag surface here (`BAG_UPDATE(bagID)`,
/// `BenillaBagBarSlot_InvSlot` = `19 + bagId`), so the same wire slots 19..22 become `1..=4`. Both
/// vocabularies agree on `0` and `-2`, and both leave a non-equipped-bag container (a bank bag,
/// wire 63..68) landing 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 {
return i64::from(bag) - i64::from(SLOT_BAG_FIRST) + 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,
}
/// 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).
enum LootAction {
Money,
Item { wire_slot: u8, display_id: u32 },
}
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.fishing = loot_type == benilla_protocol::messages::loot_type::FISHING;
}
/// 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;
}
/// 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,
})
}
}
/// A loot refusal (`SMSG_LOOT_RESPONSE`'s error shape) queued by the net bridge for the UI error line
/// — the loot twin of [`crate::ui_merchant::MerchantErrors`]. Carries the wire `u8` `LootError` code.
#[derive(Resource, Default)]
pub(crate) struct LootErrors(pub Vec<u8>);
/// 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.
#[derive(Resource, Default)]
pub(crate) struct LootConfig {
pub(crate) auto_loot: bool,
}
/// 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::<LootErrors>()
.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),
),
);
}
}
/// The client's message string for a `LootError` refusal (`SMSG_LOOT_RESPONSE`'s error shape); values
/// from [`benilla_protocol::messages::loot_error`] (VERIFIED vmangos `LootMgr.h`). Only the
/// subset a plain `CMSG_LOOT` can surface is spelled out; the rest print their code.
fn loot_error_text(reason: u8) -> String {
use benilla_protocol::messages::loot_error as e;
match reason {
e::DIDNT_KILL => "You don't have permission to loot that corpse.".into(),
e::TOO_FAR => "You are too far away to loot that.".into(),
e::BAD_FACING => "You can't loot that from there.".into(),
e::LOCKED => "Someone is already looting that corpse.".into(),
e::NOTSTANDING => "You need to be standing up to loot.".into(),
e::STUNNED => "You can't do that while stunned.".into(),
e::PLAYER_NOT_FOUND => "You can't loot that right now.".into(),
e::ALREADY_PICKPOCKETED => "Those pockets are already empty.".into(),
other => format!("You can't loot that ({other})."),
}
}
/// 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 highest nonzero denomination's pile (gold ▸ silver ▸
/// copper). See [`COIN_ICON_GOLD`] for the stated-approximation note.
fn coin_icon(copper: u32) -> &'static str {
if copper >= 10000 {
COIN_ICON_GOLD
} else if copper >= 100 {
COIN_ICON_SILVER
} else {
COIN_ICON_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,
}
}
/// 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,
) -> 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()),
quantity: 1,
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,
})
}
/// 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 errors: ResMut<LootErrors>,
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>>,
) {
let Some(mut script) = script else {
return;
};
let last = last.get(&script);
let rolls = RollCatalogs {
props: props.as_deref(),
enchants: enchants.as_deref(),
};
// Loot refusals + "You receive …" lines migrate to the chat window (decision 0084's chat arc):
// refusals as informational SYSTEM-yellow lines, receive lines as LOOT-green. The ErrorsFrame
// keeps only the cast/equip red toasts.
for reason in errors.0.drain(..) {
chat.push_event(crate::ui_chat::ChatEvent::text_only(
crate::ui_chat::ChatEventKind::System,
loot_error_text(reason),
));
}
drain_receives(
&mut loot,
&mut items,
icons.as_deref(),
&commands,
&mut chat,
&mut script,
rolls,
);
let fresh = snapshot(&loot, &mut items, icons.as_deref(), &commands, rolls);
if fresh == *last {
return;
}
script.set_loot(fresh.clone());
match (&*last, &fresh) {
(None, Some(snap)) => {
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 {
for index in 1..=snap.rows.len() as u32 {
match loot.action_at(index) {
Some(LootAction::Money) => {
let _ = commands.0.send(ClientCommand::LootMoney);
}
Some(LootAction::Item {
wire_slot,
display_id,
}) => {
let _ = commands
.0
.send(ClientCommand::AutostoreLootItem { slot: wire_slot });
pickup.write(crate::sound::LootPickupSound { display_id });
}
None => {}
}
}
}
}
// A content change while open (async name landed, a row removed, coin cleared) → repaint,
// keeping the current page (LOOT_UPDATE, the merchant's MERCHANT_UPDATE twin — this replaces
// Blizzard's per-button LOOT_SLOT_CLEARED optimization with a full re-snapshot, exactly as
// the merchant seam replaced per-row stock updates).
(Some(_), Some(_)) => script.fire_event("LOOT_UPDATE", vec![]),
(Some(_), None) => script.fire_event("LOOT_CLOSED", vec![]),
(None, None) => {}
}
*last = fresh;
}
/// 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`.
fn drain_loot(
script: Option<NonSendMut<UiScript>>,
mut loot: ResMut<LootState>,
mut latch: ResMut<LootLatch>,
commands: Res<NetCommands>,
mut pickup: MessageWriter<crate::sound::LootPickupSound>,
) {
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);
}
Some(LootAction::Item {
wire_slot,
display_id,
}) => {
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: LootSlot({index}) out of range — ignored"),
}
}
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::ObjectFields;
use benilla_protocol::EntityKind;
/// 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);
}
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,
}
}
/// 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; the
// reference emits 20..23 (its buttons' inventory-slot ids), ours the container ids 1..=4.
assert_eq!(push_container(19, 0), 1);
assert_eq!(push_container(22, 5), 4);
// `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!(!(0..=4).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");
}
#[test]
fn coin_icon_picks_the_highest_nonzero_denomination() {
assert_eq!(coin_icon(4), COIN_ICON_COPPER); // copper only
assert_eq!(coin_icon(99), COIN_ICON_COPPER);
assert_eq!(coin_icon(500), COIN_ICON_SILVER); // 5 silver
assert_eq!(coin_icon(10_025), COIN_ICON_GOLD); // gold present ⇒ gold pile
assert_eq!(coin_icon(20_000), COIN_ICON_GOLD);
}
#[test]
fn coin_row_uses_real_words_and_copper_pile_icon() {
let mut items = Items::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut loot = LootState::default();
// A pure-copper drop: name reads "4 Copper", icon is the copper coin pile (_05).
loot.open(0x42, loot_type::CORPSE, 4, vec![]);
let snap = snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("open");
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_COPPER));
}
#[test]
fn snapshot_prepends_coin_and_resolves_items() {
let mut items = Items::default();
let (tx, _rx) = crossbeam_channel::unbounded();
let commands = NetCommands(tx);
let mut loot = LootState::default();
// Closed → no snapshot.
assert!(snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).is_none());
loot.open(0x42, loot_type::CORPSE, 12_345, vec![item(0, 117, 3)]);
let snap = snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("open");
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_GOLD));
// 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).expect("still open");
assert_eq!(snap.rows.len(), 2, "the layout keeps both slots");
assert!(snap.rows[0].is_none(), "the looted coin slot is a gap");
assert!(snap.rows[1].is_some(), "the item stays at position 2");
// Looting the item empties the layout entirely (both gaps) — and arms the auto-close.
loot.remove_slot(0);
let snap =
snapshot(&loot, &mut items, None, &commands, RollCatalogs::NONE).expect("still open");
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);
}
}