Files
benilla-octo/crates/benilla-ui/src/script/container.rs
T
SamandClaude Fable 5.1 5af3a15d0e plate text settles, mobs stop sinking, and select goes with Lua 5.1
- a nameplate's name and level stop popping a pixel every other step as the plate slides, and the whole plate no longer jumps 19 px to the top of the screen on a rounding tie; the chat bubble takes the same fix (2172)
- creatures and other players stop sinking through the ground while the world is still streaming in, where a Stormwind patroller had been 31 yd under its street: a body our world cannot yet answer for holds the server's pose (2174)
- select and the string metatable are gone, as in 1.12's Lua 5.0: pfUI's heal prediction parses its messages with the right format, BuffCheck2's own select wins its fallback, and a method call on a string raises the reference's own error (2171)
- the camera's water collision comes back out for now: with the pivot a hair under the plane, a surface swimmer's camera snapped in and out against the waterline, so the boom ignores water again (2173)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 21:03:05 +07:00

1544 lines
74 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The container bindings (decision 0068 T2) — the 1.12 bag verbs, every one of them a plain
//! top-level global (decision 1198; measured on the corpus: `GetContainerItemLink`,
//! `GetContainerNumSlots` and `GetContainerItemInfo` are the three most-wanted engine verbs of
//! all, and Bagnon's data engine is built on exactly these). Same two-way seam as
//! [`super::action`]: the app pushes a **container snapshot** per bag id
//! ([`UiScript::set_container`] — slots already resolved to icon/count/quality by the app's
//! item stores; the engine holds no item knowledge), and `UseContainerItem` queues an outbound
//! **intent** the app drains into the wire ([`UiScript::take_container_uses`]).
//!
//! Bag ids are the live API's space: `0` the backpack, `1..4` the equipped bags (bank later).
//! Slots are 1-based. One deliberate divergence from the 1.14 documentation:
//! `containerInfo.iconFileID` carries our icon **texture path** (`Interface\Icons\…`), not a
//! numeric FileDataID — 5875 has no FileDataIDs, and every measured consumer feeds the value
//! straight to `SetTexture`, which takes both shapes in the live client.
use mlua::{Lua, Table, Value};
use super::cursor::{self, CursorItem, CursorPayload};
use super::Model;
/// The charter/petition lines an item tooltip prints under its name — wow-re
/// `system/ui/scratch/tooltip-content-law.md`'s **line 3**: *"(petition/guild-charter items) Title /
/// Creator / Num-signatures (white; title wraps) — a resolved petition object; keys
/// `PETITION_*`/`GUILD_CHARTER_*`"*.
///
/// The two key families are picked by [`Self::is_charter`], the same record bit that picks
/// `GetPetitionInfo`'s first return — so a charter reads *"Guild Name:"* / *"Guild Master:"* and a
/// plain petition reads *"Petition:"* / *"Created by"*.
///
/// **The third line, the signature count, is deliberately NOT here.** The law names it, but neither
/// the carve nor the packet says where the number comes from: the petition record carries no count,
/// and the only candidate — the item's `ITEM_FIELD_ENCHANTMENT` slot-0 *charges* dword, which
/// vmangos's own source documents as "the on-item signature count" — has its write **commented
/// out** server-side, so it is always zero and the guess could never be falsified here. Omitted
/// rather than invented; the line is one field away the day someone pins the source.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionSlotView {
/// The record's charter bit: `true` picks the `GUILD_CHARTER_*` keys, `false` the `PETITION_*`
/// ones.
pub is_charter: bool,
/// The proposed guild's (or petition's) name.
pub title: String,
/// The owner's name, app-resolved through the name cache. `None` while that query is in
/// flight — the line is withheld, as the creator line is, rather than printed with a hole.
pub owner: Option<String>,
}
/// One occupied bag slot, resolved by the app (icon from ItemDisplayInfo, count/quality from the
/// item object + template). Plain data.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ContainerSlot {
/// Icon texture path (`Interface\Icons\…`); `None` while the template answer is in flight.
pub texture: Option<String>,
pub count: u32,
/// Item quality 0..6; `None` while unresolved (the API reports `nil`).
pub quality: Option<u32>,
/// The item's template entry (`containerInfo.itemID`); 0 while unresolved.
pub item_id: u32,
/// An `|Hitem:…|h[Name]|h` link once the name is known; Bagnon's search reads it.
pub link: Option<String>,
pub locked: bool,
/// The 1-based live-API inventory slot ids this item could be EQUIPPED into (empty = not
/// equippable) — the app-resolved "fit rule" decision 0208 phase 1b's cursor arc needs
/// (`cursor::CursorItem::equip_slots` captures it at pickup). Derived from the item
/// template's `inventoryType` via `ui_items::find_equip_slot`; the engine holds no item
/// knowledge of its own.
pub equip_slots: Vec<u8>,
/// Whether this item may be placed on an ACTION-BAR slot — app-resolved from the template
/// exactly like [`Self::equip_slots`] (the engine holds no item knowledge of its own).
/// `PlaceAction`'s only item filter, byte-read: an on-use spell OR equippable
/// (`ItemInfo::placeable_on_action_bar`, wow-re `action-item-slot.md` §5 — decision 0666).
pub bar_placeable: bool,
/// The instance's live durability `(current, max)` — `ITEM_FIELD_DURABILITY`/`MAXDURABILITY`
/// off the streamed item object; `None` for indestructible items (max 0) or while the create
/// hasn't landed. The real-instance tooltip's "Durability X / Y" line reads it (a template
/// hover keeps the template's full max/max).
pub durability: Option<(u32, u32)>,
/// The item's running use-cooldown as `(start_ms on the GetTime clock, duration_ms,
/// enabled)` — the same app-computed triple [`super::ActionState::cooldown`] carries;
/// `None` = cold. Stored at push ([`super::UiScript::set_container`]) so
/// `GetContainerItemCooldown` answers the reference's `(start, duration, enable)`.
pub cooldown: Option<(i64, u32, bool)>,
/// Right-clicking reads this item (an instance carrying `ITEM_FIELD_ITEM_TEXT_ID` — a mail
/// permanent copy). `GetContainerItemInfo`'s `isReadable`; the bag hover shows the Inspect
/// magnifier off it (ref ContainerFrame.lua l.638 `this.readable → ShowInspectCursor()`),
/// and the tooltip's WRITTEN_BY/READABLE gates key on it.
pub readable: bool,
/// The RESOLVED `ITEM_FIELD_CREATOR` name (app: ask-once name cache) — the tooltip's
/// "Written by %s" (a letter) / "<Made by %s>" (anything crafted) line. `None` = authorless
/// or the name query is in flight (no line; the re-push repaints the hover when it lands).
pub creator: Option<String>,
/// The instance's `ITEM_FIELD_FLAGS` — the tooltip's openable lock sub-gate reads
/// UNLOCKED `0x4`, the wrapped-gift arm WRAPPED `0x8` (see
/// [`super::char_stats::InvSlotView::flags`], the doll twin).
pub flags: u32,
/// `0x5da2c0` — **the instance is runtime-bound**: `ITEM_FIELD_FLAGS & 1` (soulbound), or a
/// live enchant slot naming a `SpellItemEnchantment` row that binds. App-resolved off the raw
/// descriptor ([`crate::items::already_bound`] in benilla-app — the same predicate the enchant
/// cursor's bind question asks), because the binding half needs a DBC join and the engine
/// holds no item knowledge of its own. The tooltip's §6 bind line overrides to **Soulbound**
/// on it (B310); `false` on any source with no streamed item object.
pub already_bound: bool,
/// The instance's enchant slots, resolved by the app ([`super::EnchantView`]) and in
/// enchant-slot order: it joins the id through `SpellItemEnchantment.dbc`'s name column and
/// hands over the row's name plus the three facts the line law needs to place and paint it.
/// Empty = unenchanted, or the enchant DBC never loaded. Decisions 0915/0920.
pub enchants: Vec<super::EnchantView>,
/// **The instance's remaining LIFETIME in milliseconds** — a duration-limited item counting
/// down to its own destruction (a conjured stone, a holiday gift, a timed quest item). Its
/// only feed is `SMSG_ITEM_TIME_UPDATE`, parked as an absolute deadline and recomputed on
/// read ([`crate::items::Items::duration_remaining_display_ms`] in benilla-app), floored to
/// the whole second so the snapshot moves once a second rather than every frame.
/// `None` = no timer, which is nearly every item. Decision 1933.
pub duration_ms: Option<u64>,
/// The petition this item names, when it is a signable charter — **line 3 of the tooltip's
/// emission law**, between the NAME and the green `ITEM_SIGNABLE` line.
///
/// `None` for every ordinary item, and also for a charter whose petition record has not
/// arrived: the hover is what ISSUES that query, so the first hover of an unopened charter
/// shows the name and the green line, and the two guild lines appear on the repaint. Exactly
/// [`Self::creator`]'s shape one field up, for exactly its reason.
pub petition: Option<PetitionSlotView>,
}
/// One enchant slot as the tooltip renders it (wow-re `ui/scratch/tooltip-content-law.md` §E3,
/// byte-verified; decision 0920). The app resolves the DBC row; every rule below is the engine's.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct EnchantView {
/// `ITEM_FIELD_ENCHANTMENT` slot: **0** permanent · **1** temporary · **2..6** the
/// random-property suffix. It decides the colour band — only 0 and 1 are ever coloured.
pub slot: u8,
/// The enchant id was NEGATIVE. `abs(id)` names the row either way — the sign's only effect is
/// to paint slots 0/1 pure-red instead of green (`0x52ca29–0x52ca49`).
pub negative: bool,
/// The `SpellItemEnchantment` row's name, verbatim (`"Agility +15"`, `"Crusader"`) — the
/// reference copies the string with no format at all (`0x52ca8b–0x52caa1`).
pub name: String,
/// The slot's `ITEM_FIELD_ENCHANTMENT` charges dword — nonzero appends " (N Charges)".
pub charges: u32,
/// Milliseconds left on a TEMPORARY enchant, from `SMSG_ITEM_ENCHANT_TIME_UPDATE` (the item's
/// own duration field is never read for this). `Some` replaces the plain name with the
/// countdown phrasing; `None` = no timer, or expired.
pub remaining_ms: Option<u64>,
}
/// One `ItemRandomProperties` row as the engine renders it — the **random-suffix roll** a dropped,
/// linked, auctioned or mailed item carries (decision 1547). The app resolves the DBC (both of
/// them: the roll's table and the `SpellItemEnchantment` names its five ids land on) and pushes the
/// whole table once at load; a tooltip source supplies only the id, exactly as in the reference.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RandomPropertyView {
/// The roll's suffix — `"of the Monkey"`. The reference joins it onto the item name through
/// `ITEM_SUFFIX_TEMPLATE` (`"%s %s"`) in its one name formatter `0x5d8b00`; benilla does that
/// join app-side, where every display name is composed, so this is carried for reference and
/// for the surfaces that name an item the app never composed.
pub suffix: String,
/// The enchant lines the roll grants, already named and seated in enchant slots **2..6** —
/// the five dwords the builder copies out of the suffix row (`0x52b7e0–0x52b7fb`). Slots 2..6
/// are always white, whatever the sign.
pub enchants: Vec<EnchantView>,
}
/// One bag's snapshot: its name, capacity, and occupied slots (1-based).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ContainerState {
/// `GetBagName` (the bag item's own name; the backpack is the client-local "Backpack").
pub name: Option<String>,
pub num_slots: u32,
pub slots: std::collections::HashMap<u32, ContainerSlot>,
}
/// One queued backpack pick/place/swap/split: move the item from `(src_bag, src_slot)` to
/// `(dst_bag, dst_slot)` (live-API space, 1-based slots). The app maps a backpack-internal move
/// (both bags 0) onto `CMSG_SWAP_INV_ITEM` player-array slots. `count`: `None` = a whole-stack
/// move/swap (including a same-item merge — the wire tops the stack up itself); `Some(n)` = a
/// split placement (`SplitContainerItem`) the app maps onto `CMSG_SPLIT_ITEM` instead.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ContainerMove {
pub src_bag: i64,
pub src_slot: u32,
pub dst_bag: i64,
pub dst_slot: u32,
pub count: Option<u32>,
}
/// One queued **auto-store into a bag** — `PutItemInBag`'s third leg and the whole of
/// `PutItemInBackpack` (`crate::script::cursor::bag_verbs`): take the item at `(src_bag, src_slot)`
/// and put it anywhere inside container `dst_bag`.
///
/// **There is no destination slot, and that is the finding, not an omission**: the reference's
/// `CMSG_AUTOSTORE_BAG_ITEM 0x10B` carries `(srcbag, srcslot, dstbag)` and nothing else, and its
/// split sibling `CMSG_SPLIT_ITEM 0x10E` carries the literal `0xFF` where a slot would go. The
/// server picks. `count`: `None` = the whole stack (AUTOSTORE); `Some(n)` = a split carry, which
/// the drain sends as SPLIT instead — the same fork the reference makes on `[0xb4b40c]`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BagAutoStore {
pub src_bag: i64,
pub src_slot: u32,
/// Live-API container id (0 = backpack, 1..=4 an equipped bag, 5..=10 a bank bag).
pub dst_bag: i64,
pub count: Option<u32>,
}
impl super::UiScript {
/// Drain the auto-stores `PutItemInBag`/`PutItemInBackpack` queued since the last call.
pub fn take_bag_autostores(&mut self) -> Vec<BagAutoStore> {
std::mem::take(&mut self.model_mut().bag_autostores)
}
/// Push (or remove, with `None`) one bag's snapshot, keyed by live-API bag id (0 = backpack).
/// Each slot's cooldown arrives with its absolute start already on the `GetTime` clock (ms) —
/// storing is a pure unit conversion, the same seam shape as
/// [`super::UiScript::set_action_state`] — so `GetContainerItemCooldown` answers absolute
/// `(start, duration, enable)` without drift.
pub fn set_container(&mut self, bag: i64, state: Option<ContainerState>) {
let mut model = self.model_mut();
model.container_cooldowns.retain(|&(b, _), _| b != bag);
match state {
Some(s) => {
for (&slot, cs) in &s.slots {
let Some((start_ms, duration_ms, enabled)) = cs.cooldown else {
continue;
};
let abs = (
start_ms as f64 / 1000.0,
f64::from(duration_ms) / 1000.0,
enabled,
);
model.container_cooldowns.insert((bag, slot), abs);
}
model.containers.insert(bag, s);
}
None => {
model.containers.remove(&bag);
}
}
}
/// Push the app's answer to `HasKey()` — whether the player owns any `BagFamily` KEYS item
/// anywhere the reference's own search reaches. The keyring UI's one gate (decision 0765).
pub fn set_has_key(&mut self, has_key: bool) {
self.model_mut().has_key = has_key;
}
/// Drain the `(bag, slot)` pairs queued by `UseContainerItem` since the last call.
pub fn take_container_uses(&mut self) -> Vec<(i64, u32)> {
std::mem::take(&mut self.model_mut().container_uses)
}
/// Drain the pick/place/swap moves queued by `PickupContainerItem` since the last call.
pub fn take_container_moves(&mut self) -> Vec<ContainerMove> {
std::mem::take(&mut self.model_mut().container_moves)
}
/// Drain the `(bag, slot)` sources `AutoEquipCursorItem` queued (decision 0208 phase 1b) —
/// the app resolves each to wire bag/slot and sends `CMSG_AUTOEQUIP_ITEM`.
pub fn take_container_autoequips(&mut self) -> Vec<(i64, u32)> {
std::mem::take(&mut self.model_mut().container_autoequips)
}
/// Drain the `(bag, slot)` repair clicks the repair-mode pickup intercept queued — the app
/// resolves each to its item guid and sends `CMSG_REPAIR_ITEM`.
pub fn take_container_repairs(&mut self) -> Vec<(i64, u32)> {
std::mem::take(&mut self.model_mut().container_repairs)
}
/// Drain the `(bag, slot, count)` destroys `DeleteCursorItem` queued (`count == 0` = the
/// whole stack) — the app resolves each to its item guid and sends `CMSG_DESTROYITEM`.
pub fn take_container_destroys(&mut self) -> Vec<(i64, u32, u32)> {
std::mem::take(&mut self.model_mut().container_destroys)
}
/// **Arm the gift-wrap cursor** on the paper at `(bag, slot)` — the app's call when a
/// right-click takes the reference's begin-wrap arm (`ItemInfo::begins_gift_wrap`).
///
/// Three acts, exactly the three `0x5edea0` performs (wow-re
/// `object-layer/scratch/gift-wrap-law.md`): the paper's slot reads **locked**, the displayed
/// cursor becomes **mode 2** (`Interface\Cursor\Cast.blp` — the same art the cast cursor
/// uses, table `0x853b88` slot 2), and **nothing goes on the wire**. What completes it is the
/// next left-click on a container slot. Decision 1934.
pub fn arm_gift_wrap(&mut self, bag: i64, slot: u32) {
let mut model = self.model_mut();
model.pending_wrap = Some(PendingWrap { bag, slot });
model.ui_cursor = Some(UiCursorMode::Cast);
model.ui_cursor_dirty = true;
cursor::queue_lock_changed(&mut model, bag, slot);
}
/// **Cancel an armed gift wrap and nothing else** — disarm, unlock the paper, reset the
/// cursor. Deliberately narrower than `ClearCursor`, which also drops any held payload:
/// what wow-re records is that a right-click "uses the item and cancels the wrap", not by
/// which of the two routes, so this touches only the half the finding actually names.
pub fn cancel_gift_wrap(&mut self) -> Option<PendingWrap> {
let mut model = self.model_mut();
let wrap = model.pending_wrap.take()?;
cursor::queue_lock_changed(&mut model, wrap.bag, wrap.slot);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
Some(wrap)
}
/// Is a gift wrap armed, and on which slot? Read by the app to decide whether a right-click
/// is cancelling one.
pub fn gift_wrap_armed(&self) -> Option<PendingWrap> {
self.model_ref().pending_wrap
}
/// Drain the `(giftBag, giftSlot, itemBag, itemSlot)` quads a completed wrap queued — the app
/// resolves each pair to the wire's bag/slot addressing and sends `CMSG_WRAP_ITEM`.
pub fn take_container_wraps(&mut self) -> Vec<(i64, u32, i64, u32)> {
std::mem::take(&mut self.model_mut().container_wraps)
}
/// The mode the last FrameXML cursor call armed — `None` after a `ResetCursor`. This is the
/// *value* of the most recent write; whether a write happened at all is
/// [`Self::take_cursor_write`], and that is what the app acts on (decision 1061).
pub fn ui_cursor(&self) -> Option<UiCursorMode> {
self.model_ref().ui_cursor
}
/// Drain the pending cursor write: `Some(mode)` = a `Show*Cursor` armed `mode`, `Some(None)` =
/// a `ResetCursor` asked for the base mode, `None` = **no FrameXML cursor call happened**, so
/// the sticky mode stands untouched.
///
/// The three-state return is the point. A UI element with no cursor handler must leave the
/// cursor exactly as it was — that is how an armed spell keeps its cast cursor while the mouse
/// crosses a spellbook button, and reading a two-state latch instead is what made it snap to
/// Point (decision 1061).
#[allow(clippy::option_option)]
pub fn take_cursor_write(&mut self) -> Option<Option<UiCursorMode>> {
let mut model = self.model_mut();
std::mem::take(&mut model.ui_cursor_dirty).then_some(model.ui_cursor)
}
}
/// **The armed gift wrap** — which piece of wrapping paper the next container click will spend.
///
/// The reference holds this as globals beside the displayed-cursor mode rather than as a cursor
/// *payload*; the distinction is load-bearing and is why this is its own type instead of a
/// [`cursor::CursorPayload`] variant (see [`super::Model::pending_wrap`]).
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct PendingWrap {
/// The paper's container id, in the same bag space every cursor verb speaks.
pub bag: i64,
/// The paper's 1-based slot within that container.
pub slot: u32,
}
/// A **displayed-cursor override** the FrameXML cursor family arms (wow-re cursor-system.md §7): the
/// single "displayed mode" (`0xbe2c2c`) the real client swaps to while a UI element wants a non-base
/// cursor, restored to the base mode by `ResetCursor`. The app maps each to the matching
/// `Interface\Cursor\*` art over the world classifier's Point.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum UiCursorMode {
/// Buy(3) — the coin/pouch: selling a bag item to a vendor (`ShowContainerSellCursor`, no
/// affordability gate), or a merchant/buyback item you can afford (`Show*SellCursor`).
Buy,
/// UnableBuy(23) — the grayed coin: a merchant/buyback item you can't afford (`Show*SellCursor`,
/// player coin < price).
UnableBuy,
/// Inspect(7) — the magnifier the Ctrl-hover shows over an item (`ShowInspectCursor`).
Inspect,
/// Point(1) — `SetCursor("POINT_CURSOR")`, and what a `ResetCursor` resolves to.
Point,
/// Cast(2) — `SetCursor("CAST_CURSOR")`: the lit cast cursor a unit frame shows while a spell
/// that CAN bind that unit is armed (`UnitFrame_OnEnter`).
Cast,
/// UnableCast(22) — `SetCursor("CAST_ERROR_CURSOR")`: the greyed twin, for a unit the armed
/// spell cannot bind, and for `UnitFrame_OnLeave` while still targeting.
CastError,
}
/// The pick/place/swap gesture behind `PickupContainerItem` (decision 0216 §2, amended by 0218;
/// unit-testable without Lua). Mirrors the real client's single entry point:
/// - an empty cursor over a resolved, UNLOCKED slot picks it up (a locked slot refuses — the real
/// client's refusal; today only the app's `locked` flag drives this, the engine's own
/// pending-move lock lands with slice 2);
/// - holding, a click on the SAME slot cancels (a split carry included);
/// - holding the WHOLE stack (`count: None`), a click elsewhere queues the move and CLEARS —
/// empty, same-item (the wire merges), and different-item (the wire swaps: the displaced item
/// lands where the held one came from) all alike. The displaced item never hops onto the
/// cursor: 0216 §2 shipped that exchange off 0091's gloss; the director's eye caught it and
/// the same-day §5 verdict refuted it at the bytes — no `SetCursorItem` on the place branch
/// (`0x5e0c40`/`0x4f9b30`), `ClearCursor(0)`, one put-down sound (decision 0218; wow-re
/// cursor-dragdrop-slots.md). Bag placements are server-authoritative; only the ACTION bar
/// hops its displaced payload (client-authoritative — the slice-4 note in 0218).
/// - holding a SPLIT carry (`count: Some(n)`), a click onto an empty/unresolved/same-item
/// destination queues the split move and clears; onto a DIFFERENT item it's a no-op (kept —
/// you can't swap a partial stack).
/// - a spell/action payload refuses a bag slot outright (no-op, kept).
///
/// Returns whether the caller should repaint (the source-slot lock or the held payload changed).
fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool {
// **The gift-wrap completion, ahead of the whole gesture** (decision 1934). `0x4f9b30` is the
// *only* entry to the wrap sender `0x5edfc0` image-wide — which is why the paper doll cannot
// finish a wrap and an equipped item is therefore unwrappable — and it takes this branch
// before it looks at the cursor at all.
if let Some(wrap) = model.pending_wrap {
// An empty or unresolved slot **bails and leaves the wrap armed** — the reference's own
// edge. Nothing is spent and the cursor keeps its mode, so the next click can still land
// on a real item.
if !model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.is_some_and(|s| s.item_id != 0)
{
return false;
}
// **The paper has to still be there.** `0x5edfef` bails when the stashed gift GUID no
// longer resolves, and — the edge worth transcribing — it then sends *and clears*
// nothing: the wrap stays armed and the paper's slot stays locked. So this returns
// without touching either.
if !model
.containers
.get(&wrap.bag)
.and_then(|c| c.slots.get(&wrap.slot))
.is_some_and(|s| s.item_id != 0)
{
return false;
}
// No eligibility test of any kind. The wrap arm reads each item's `ITEM_FIELD_CONTAINED`
// and nothing else — not flags, not stack count, not bind state — and the client ships six
// `ERR_CANT_WRAP_*` strings for the server to answer with (`SMSG_INVENTORY_CHANGE_FAILURE`
// reasons 43-48). Same law as the openable click (decision 0896): gating locally would eat
// the click in silence and cost the player their error line.
model.pending_wrap = None;
model.container_wraps.push((wrap.bag, wrap.slot, bag, slot));
// The paper unlocks and the cursor resets **immediately**, not on the server's answer:
// `0x5edfc0` calls `0x5eded0` at `0x5ee0aa`, right after the send. The target is never
// locked at all.
cursor::queue_lock_changed(model, wrap.bag, wrap.slot);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
return true;
}
match model.cursor.take() {
None => {
let picked = model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.filter(|s| s.item_id != 0 && !s.locked)
.map(|s| CursorItem {
bag,
slot,
item_id: s.item_id,
texture: s.texture.clone(),
link: s.link.clone(),
quality: s.quality,
count: None,
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
});
match picked {
Some(item) => {
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
true
}
None => false,
}
}
Some(CursorPayload::Item(held)) if held.bag == bag && held.slot == slot => {
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, held.bag, held.slot);
true
}
Some(CursorPayload::Item(held)) => {
// Cloned out from under `model.containers` so the moves below can borrow `model`
// mutably; a slot present but unresolved (item_id == 0, an in-flight template
// answer) reads as absent, same as a truly empty slot.
let dest = model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.cloned()
.filter(|d| d.item_id != 0);
match (held.count, dest) {
// A split carry onto a DIFFERENT item can't swap a partial stack — no-op, kept.
(Some(_), Some(d)) if d.item_id != held.item_id => {
model.cursor = Some(CursorPayload::Item(held));
false
}
// Every other placement queues its move and clears: empty and same-item take the
// carry's count through to the wire (a split placement or a merge the server
// tops up); a whole-stack place onto a different item is the plain SWAP — the
// displaced item lands where the held one came from, and the cursor empties
// (decision 0218, the director's eye over 0216 §2's hop).
(count, _) => {
queue_move(model, &held, bag, slot, count);
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, held.bag, held.slot);
true
}
}
}
// Mode 5 — **the buy**. A held vendor row dropped into a container slot is the whole
// point of the merchant cursor: `0x4f9efa` tests `[0xb4d900] == 5` right here and calls
// `0x5e1f30` = `CMSG_BUY_ITEM_IN_SLOT 0x1a3`, `{vendorGuid, itemEntry, bagGuid, bagSlot,
// count = 1}`. The cursor clears whether or not the server honours it.
//
// Deliberately NOT gated on the destination being empty. The reference does not look at
// the slot at all — the server decides, and answers `SMSG_BUY_FAILED` when it will not.
// What we DO refuse is a stale row: the reference's two container drop consumers
// dereference the vendor row without re-checking it against a list that may have been
// rewritten by a fresh `SMSG_LIST_INVENTORY`, which is a latent null deref there
// (`0x4f9f35`, `0x4c7ea5`). We treat a row that no longer resolves as a refusal — that is
// the third consumer `0x4c7300`'s own behaviour, and the one of the three that is right.
Some(CursorPayload::Merchant(held)) => {
let entry = model
.merchant
.as_ref()
.and_then(|m| m.items.get(held.row as usize))
.filter(|it| it.item_id == held.item_id)
.map(|it| it.item_id);
if let Some(entry) = entry {
model.merchant_slot_buys.push((bag, slot, entry));
}
cursor::queue_cursor_update(model);
true
}
Some(
other @ (CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)
// Mode 10 (decision 1677) — a stabled pet refuses a bag/doll slot exactly as the
// spell/action family does, and stays on the cursor for the stable window to take.
| CursorPayload::StablePet(_)
// Mode 2 (1962) — coins have no slot to land in; a money frame's DropFunc takes them.
| CursorPayload::Money(_)),
) => {
model.cursor = Some(other);
false
}
}
}
/// Queue one pick/place/swap/split move (the shared tail of every clearing branch above).
fn queue_move(
model: &mut super::Model,
held: &CursorItem,
dst_bag: i64,
dst_slot: u32,
count: Option<u32>,
) {
model.container_moves.push(ContainerMove {
src_bag: held.bag,
src_slot: held.slot,
dst_bag,
dst_slot,
count,
});
}
/// Register the container verbs — **top-level globals, because that is where 1.12 puts them**
/// (decision 1198). Every name below is `function engine` in `reference/1.12-globals.tsv` as a
/// bare global: `GetContainerNumSlots`, never `C_Container.GetContainerNumSlots`. The
/// `C_Container` namespace 1187 reached for while chasing an Era addon is a *Dragonflight*
/// reorganisation, and an addon that feature-detects it concludes it is on Dragonflight.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// ContainerIDToInventoryID(containerID) → the equipment slot the bag is worn in.
//
// **Two linear arms, one signed compare, and nothing else** (`0x4f94e0`, 124 bytes; wow-re
// `bag-language-combat-action-bindings.md` §1, §5-cross-checked). Written as the reference's
// own three instructions — `t = id - 1`, `if t < 4` (SIGNED), then `+20` or `+60` — rather
// than as the two closed forms `id+19` / `id+59`, because the wrap at the `i32` edge is then
// reproduced rather than approximated.
//
// | containerID | slot |
// |---|---|
// | **−2 (keyring)** | **17** |
// | −1 | 18 |
// | **0 (backpack)** | **19** |
// | 1 · 2 · 3 · 4 | 20 · 21 · 22 · 23 |
// | **5** (first bank bag) | **64** |
// | 6 … 10 | 65 … 69 |
//
// **There is no special case for 0 or for −2** — the "backpack is 0, keyring is −2"
// convention is the *caller's*, and this arithmetic merely happens to land on 19 and 17.
// **And there is no range check of any kind**: the only guard in the function is the up-front
// type test, so an out-of-range id is not clamped, not rejected, and never nil — it returns
// `id+19` or `id+59` as an ordinary number, and whatever the receiving binding does with an
// invalid slot is that binding's business.
lua.globals().set(
"ContainerIDToInventoryID",
lua.create_function(|lua, id: Value| {
let id = super::binding_abi::number_arg(
lua,
id,
"Usage: ContainerIDToInventoryID(containerID)",
)?;
// `0x4f951b dec eax` · `0x4f951f cmp eax,4` · `0x4f9524 jl` — the compare is signed
// and lands on the `id <= 4` arm.
let t = id.wrapping_sub(1);
Ok(i64::from(if t < 4 {
t.wrapping_add(20)
} else {
t.wrapping_add(60)
}))
})?,
)?;
// SetBagPortaitTexture(texture, containerID) — `0x4fa4f0` (374 B), the reference's own
// spelling, typo included, and CARVED (wow-re `system/ui/scratch/bag-portrait-and-appendtext.md`,
// §5 four-worker round). The bag window's portrait: `ContainerFrame_GenerateFrame` sets it for
// every container except the keyring, which takes `SetPortraitToTexture` with a fixed path one
// line above (ContainerFrame.lua l.418-423). benilla runs that file off the player's chain
// (1751), so this is a live call site.
//
// Three arms, and the FIRST is the one nobody would have guessed:
//
// 1 · **`id == 0` does nothing at all.** `0x4fa5b8 dec edi` / `0x4fa5b9 js 0x4fa65d` returns
// before the clear at `0x4fa5c8` — no texture is set and none is cleared. It is not an
// oversight: `ContainerFrame.xml:67` gives `$parentPortrait` no `file=`, and the backpack
// takes a different ARTWORK background (`UI-BackpackBackground`) that covers the portrait
// slot outright. (`Button-Backpack-Up` is nowhere on this path — its only stock use is the
// main bar's own button, MainMenuBarBagButtons.xml:79.)
// 2 · **`id` in 1..=10** clears the texture first, then sets
// `Interface\Icons\<ItemDisplayInfo.InventoryIcon_1>` for the bag in that inventory slot
// — `StringLookups.dbc` row 3 measured as `"Interface\Icons"`, `.TGA` stripped,
// `INV_Misc_QuestionMark` on a display-info miss. Ids 5..10 are additionally gated on the
// bank being open. **An empty slot leaves the texture CLEARED** — there is no fallback art.
// 3 · **`id >= 11` raises** `"Invalid slot in SetBagPortaitTexture"`.
//
// Our icon comes from the same place the item's own button icon does (`GetInventoryItemTexture`
// reads the item template's icon), so the DBC walk above is the reference's route to the value
// this client already has resolved; the observable — which icon lands on the portrait — is the
// same one.
lua.globals().set(
"SetBagPortaitTexture",
lua.create_function(|lua, (region, container): (Table, i64)| {
// Arm 3, before anything is touched.
if container >= 11 {
return Err(mlua::Error::runtime("Invalid slot in SetBagPortaitTexture"));
}
// Arm 1: the backpack is a silent return, not a clear.
if container <= 0 {
return Ok(());
}
// `ContainerIDToInventoryID`'s arithmetic, deliberately re-expressed rather than
// called: that binding is a Lua global an addon may replace, and the engine's own
// portrait read does not go through Lua.
let t = container.wrapping_sub(1);
let inv = if t < 4 {
t.wrapping_add(20)
} else {
t.wrapping_add(60)
};
let path = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
usize::try_from(inv)
.ok()
.and_then(|slot| model.inv_slot("player", slot))
.and_then(|v| v.icon.clone())
};
let rh = crate::script::region::region_handle_of(lua, &region)?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let data = model.region_data.entry(rh).or_default();
// Arm 2. `path` is `None` for an empty slot, and that IS the answer: the reference
// clears unconditionally and only then tries to set.
data.texture = path;
data.circular = true;
data.portrait_unit = None;
Ok(())
})?,
)?;
lua.globals().set(
"GetContainerNumSlots",
lua.create_function(|lua, bag: i64| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.containers.get(&bag).map_or(0, |c| c.num_slots))
})?,
)?;
lua.globals().set(
"GetBagName",
lua.create_function(|lua, bag: i64| {
let name = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model.containers.get(&bag).and_then(|c| c.name.clone())
};
match name {
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// The reference's `GetContainerItemCooldown(bag, slot)` — the bag twin of
// `GetActionCooldown`, identical conventions: `GetTime`-clock `(start, duration, enable)`,
// enable 0 = an on-hold record (parked, full duration), and the cold-at-expiry guard so an
// event-driven re-feed can never replay the finish flash.
lua.globals().set(
"GetContainerItemCooldown",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match model.container_cooldowns.get(&(bag, slot)) {
Some(&(start, duration, enabled)) if start + duration > now || !enabled => {
(start, duration, i32::from(enabled))
}
_ => (0.0, 0.0, 1),
})
})?,
)?;
lua.globals().set(
"GetContainerItemLink",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let link = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.and_then(|s| s.link.clone())
};
match link {
Some(l) => Ok(Value::String(lua.create_string(&l)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// `GetContainerItemInfo(bag, slot)` → **`texture, itemCount, locked, quality, readable`**
// — the 1.12 shape, five values (decision 1199).
//
// It used to return a single 1.14-shaped `containerInfo` TABLE, inherited from decision 1187's
// reach for the Classic Era surface. That was wrong in a way neither instrument could see:
// **36 corpus addons call this and every one of them uses the 1.12 shape. Not one uses the
// table.** 34 destructure (`local texture, itemCount = GetContainerItemInfo(bag, slot)`), and
// the four that assign a single name take the *first return* — `local texture = …`, or
// `GetContainerItemInfo(bag, i) ~= nil` as an occupancy test. All 36 were getting
// `texture = <table>` and, where they destructured, `itemCount = nil`. They load clean, so the
// harness scores them as passes, and they misbehave silently in play.
//
// (Decision 1199 §1 says "only one uses the table shape" — that came from a first reading of
// the call sites and a recount disproves it. The corrected number makes the case stronger, not
// weaker; the record is a point-in-time snapshot and this is where the true count lives.)
//
// The shipped 1.12 FrameXML settles the shape: `ContainerFrame.lua:241` reads exactly these
// five names in exactly this order.
//
// **A signature is part of the API.** Decision 1198 §3 made that argument about *names*; this
// is the same argument one level down, and it is the more dangerous half, because a wrong name
// fails loudly and a wrong shape does not.
//
// `nil` for an empty or unknown slot — the reference returns no values there, and a caller's
// `if texture then` reads the same either way.
lua.globals().set(
"GetContainerItemInfo",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let (info, held_here) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let held = matches!(
&model.cursor,
Some(CursorPayload::Item(c)) if c.bag == bag && c.slot == slot
) ||
// An armed gift wrap locks its paper for as long as it stands (`0x4953e0` at
// `0x5edeb5`) — the same dimming a held item's source slot shows, and released by
// the send or by any cancel rather than by the server (decision 1934).
matches!(
&model.pending_wrap,
Some(w) if w.bag == bag && w.slot == slot
);
(
model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.cloned(),
held,
)
};
let Some(s) = info else {
return Ok(mlua::MultiValue::new());
};
Ok(mlua::MultiValue::from_vec(vec![
match &s.texture {
Some(p) => Value::String(lua.create_string(p.as_str())?),
None => Value::Nil,
},
Value::Integer(i64::from(s.count)),
// The picked-up source slot reads locked (the real client dims it while the cursor
// carries the item) — derived from the cursor, not a mutated snapshot, so the
// app's per-frame re-push cannot wipe it.
Value::Boolean(s.locked || held_here),
match s.quality {
Some(q) => Value::Integer(i64::from(q)),
None => Value::Nil,
},
Value::Boolean(s.readable),
]))
})?,
)?;
// `BenillaGetContainerItemID(bag, slot)` — the item id behind a slot, for OUR OWN FrameXML.
//
// 1.12 has no such verb: an addon there takes the id out of `GetContainerItemLink`'s
// `|Hitem:12345:…` payload. Our tooltip and delete paths want the id directly, so it rides the
// `Benilla` host-bridge prefix — the sanctioned escape hatch for a verb only our own
// transcription calls, and the one `reference_surface` covers by prefix rather than by
// exception. An addon that wants it parses the link, exactly as it would on the real client.
lua.globals().set(
"BenillaGetContainerItemID",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
// `nil` for an unresolved id, not `0` — a slot can carry an entry whose template has
// not landed yet, and `0` is a number a caller will happily index a table with.
Ok(model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.map(|s| s.item_id)
.filter(|&id| id != 0)
.map(i64::from))
})?,
)?;
lua.globals().set(
"UseContainerItem",
lua.create_function(|lua, (bag, slot, _rest): (i64, u32, mlua::MultiValue)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.container_uses.push((bag, slot));
Ok(())
})?,
)?;
// The left-click drag gesture: pick up from an occupied slot, or place/swap when holding.
// Returns whether the caller should repaint (the source-slot lock changed) — the OnClick calls
// the bag's `_Update` on true so the picked slot dims / un-dims immediately (no server round
// trip for the local cursor state).
lua.globals().set(
"PickupContainerItem",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// Two intercepts live IN the pickup path, in the reference's own order (decision
// 0923 re-read `PickupContainerItem 0x4f9b30` whole; the targeting rung is at
// `4f9c54` and the repair test at `4f9c7b`, so targeting wins — a poisoning click at
// a repair vendor binds the poison, it does not repair):
//
// 4f9c54 call 0x6e48a0 ; IsTargeting
// 4f9c5d call 0x6e6330 ; TargetingWantsItem (word & 0x4010)
// 4f9c6d call 0x495d60 ; bind THIS item — then return, nothing picked up
//
// The held-payload check precedes BOTH (`4f9c38`: a non-empty cursor jumps to the
// place/swap arm long before either), so a click while carrying an item is a place —
// transcribed by the `cursor.is_none()` gate. The word is one-shot: the app clears it
// on completion, cancel, or close.
if model.item_pick_armed && model.cursor.is_none() {
model.item_picks.push((bag, slot));
return Ok(false);
}
// Repair mode (`0x4f9c7b`, wow-re repair-machinery.md): while the repair cursor is
// armed, the click means "repair this item" — queued for the app to send — and
// nothing is picked up. The mode STICKS across clicks (only HideRepairCursor /
// merchant-close clears it).
if model.repair_mode {
model.container_repairs.push((bag, slot));
return Ok(false);
}
Ok(pickup_container_item(&mut model, bag, slot))
})?,
)?;
// The cursor globals (`GetCursorInfo`/`CursorHasItem`/`CursorHasSpell`/`ClearCursor`/
// `SplitContainerItem`/`DeleteCursorItem`) live in [`super::cursor`], the one payload seam
// every surface routes through — top-level there for exactly the same reason.
// ShowContainerSellCursor(bag, slot) — arm the pouch cursor for a sellable hover (5875
// `0x4fa460`, wow-re cursor-system.md §7: Buy(3) only when the slot actually holds an item —
// an empty slot leaves the cursor unchanged; no Unable twin, no SellPrice check — selling
// never grays), and the reference's own `IsTargeting` bail at its first instruction.
lua.globals().set(
"ShowContainerSellCursor",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let occupied = model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.is_some();
// `IsTargeting` bails at the function's FIRST instruction — an armed spell suppresses
// the sell cursor outright (wow-re `item-target-cursor-and-dropitemonunit.md`). Now
// that the mode is sticky, this gate has to be here rather than app-side: a write of
// Buy would otherwise stamp over the cast cursor and there would be no per-frame world
// write to put it back.
if occupied && !model.spell_targeting {
model.ui_cursor = Some(UiCursorMode::Buy);
model.ui_cursor_dirty = true;
}
Ok(())
})?,
)?;
// ShowInspectCursor() — the Ctrl-hover magnifier (5875 `0x48ac60`: an unconditional
// `CursorSetMode(Inspect=7)`). Shared across surfaces (the merchant window's Ctrl-hover, a bag
// item's Ctrl-hover, the generic UIParent item hover), so it lives here beside `ResetCursor`
// rather than in any one seam. It takes no arguments and reads no state.
lua.globals().set(
"ShowInspectCursor",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = Some(UiCursorMode::Inspect);
model.ui_cursor_dirty = true;
Ok(())
})?,
)?;
// HasKey() — "does this player own a key at all?" (5875 `0x48ae90`), the gate the main bar
// reads to decide whether the keyring exists in the UI. The reference pushes the NUMBER 1 on a
// hit and nil otherwise, not a boolean, and FrameXML only ever tests it for truth — so the
// shape is reproduced exactly rather than normalized to a bool. The search itself (BagFamily
// == 9 across equipment/bags/backpack/bank/keyring) is the app's, like every other item fact.
lua.globals().set(
"HasKey",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if model.has_key {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// SetCursor(name) — the reference's `0x489490`: a name->mode table (POINT/CAST/BUY/ATTACK =
// 1..4, each with an `*_ERROR` twin at +20) into `CursorSetMode`, falling back to a custom
// bitmap for an unknown name. Only the modes FrameXML actually names are mapped here; anything
// else is ignored rather than guessed, and a **no-arg call is a `ResetCursor`**, which is the
// reference's own documented shape.
//
// Its one shipped caller is the unit-frame hover pair (`UnitFrame_OnEnter`/`OnLeave`), which is
// the ONLY lit/grey cursor split over a UI element in 1.12 — and it is authored in Lua, not in
// C++ (wow-re `item-target-cursor-and-dropitemonunit.md` §4.3).
lua.globals().set(
"SetCursor",
lua.create_function(|lua, name: Option<String>| {
let mode = match name.as_deref() {
None => None, // no-arg == ResetCursor
Some("POINT_CURSOR") => Some(UiCursorMode::Point),
Some("CAST_CURSOR") => Some(UiCursorMode::Cast),
Some("CAST_ERROR_CURSOR") => Some(UiCursorMode::CastError),
Some("BUY_CURSOR") => Some(UiCursorMode::Buy),
Some("BUY_ERROR_CURSOR") => Some(UiCursorMode::UnableBuy),
// An unmapped name: the reference would load a custom bitmap. We have no such art,
// and silently painting the wrong stock cursor would be worse than leaving the
// sticky mode alone, so this is a no-op rather than a guess.
Some(_) => return Ok(()),
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = mode;
model.ui_cursor_dirty = true;
Ok(())
})?,
)?;
// ResetCursor — displayed mode back to the base (the world classifier's) mode (5875
// `0x48ac70` → `0x523d30`): clear the whole override, whichever `Show*Cursor` armed it.
lua.globals().set(
"ResetCursor",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = None;
model.ui_cursor_dirty = true;
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{ContainerMove, ContainerSlot, ContainerState, PendingWrap, UiCursorMode};
use crate::script::UiScript;
fn backpack() -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
locked: false,
equip_slots: Vec::new(),
cooldown: None,
readable: false,
creator: None,
flags: 0,
enchants: Vec::new(),
},
);
// An in-flight slot: the create arrived, the template answer hasn't.
slots.insert(3, ContainerSlot::default());
// A readable letter (a mail permanent copy — instance item-text): isReadable true.
slots.insert(
4,
ContainerSlot {
item_id: 8383,
count: 1,
readable: true,
..Default::default()
},
);
ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots,
}
}
/// **The gift-wrap state machine** (decision 1934), all four of its edges. Armed by the app's
/// right-click on a wrapper, the arm locks the paper and paints cursor mode 2 and sends
/// nothing; the next LEFT-click on a container slot spends it; an empty slot bails and leaves
/// it armed; and any `ClearCursor` cancels it.
#[test]
fn an_armed_gift_wrap_spends_on_the_next_container_click() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
// The arm: nothing on the wire, the paper reads locked, the cursor is mode 2, and the
// held-item predicate stays FALSE — this is a cursor mode, not a payload.
s.arm_gift_wrap(0, 1);
assert_eq!(s.gift_wrap_armed(), Some(PendingWrap { bag: 0, slot: 1 }));
assert!(s.take_container_wraps().is_empty(), "arming sends nothing");
assert_eq!(s.take_cursor_write(), Some(Some(UiCursorMode::Cast)));
assert!(
s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap(),
"the paper dims while the wrap stands"
);
assert!(
!s.eval::<bool>("return CursorHasItem()").unwrap(),
"the wrap writes no payload, so every stock CursorHasItem() gate stays shut"
);
// A click on an EMPTY slot bails and leaves the wrap armed — the reference's own edge.
s.eval::<()>("PickupContainerItem(0, 9)").unwrap();
assert_eq!(s.gift_wrap_armed(), Some(PendingWrap { bag: 0, slot: 1 }));
assert!(s.take_container_wraps().is_empty());
// A click on a real item spends it: the paper's pair leads, as the wire does.
s.eval::<()>("PickupContainerItem(0, 4)").unwrap();
assert_eq!(s.take_container_wraps(), vec![(0, 1, 0, 4)]);
assert_eq!(s.gift_wrap_armed(), None);
assert_eq!(
s.take_cursor_write(),
Some(None),
"the cursor resets on the send, not on the server's answer"
);
assert!(
s.cursor_payload().is_none(),
"the completing click is spent on the wrap, not on picking the target up"
);
}
/// Any `ClearCursor` cancels an armed wrap — the reference tests it FIRST inside
/// `ClearCursor 0x495190` and does so ungated by either parameter, so all 70 of its call
/// sites cancel (decision 1934).
#[test]
fn clear_cursor_cancels_an_armed_gift_wrap() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.arm_gift_wrap(0, 1);
let _ = s.take_cursor_write();
s.eval::<()>("ClearCursor()").unwrap();
assert_eq!(s.gift_wrap_armed(), None);
assert_eq!(s.take_cursor_write(), Some(None), "and the mode goes back");
assert!(
!s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap(),
"the paper unlocks with the cancel — the lock never waited on a server"
);
// …and the next container click is an ordinary pickup again.
s.eval::<()>("PickupContainerItem(0, 1)").unwrap();
assert!(s.take_container_wraps().is_empty());
assert!(s.cursor_payload().is_some(), "a plain pickup, as before");
}
#[test]
fn container_snapshot_reads() {
let mut s = UiScript::new().unwrap();
// No bag pushed: capacity 0, info nil.
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 0);
assert!(s
.eval::<bool>("return GetContainerItemInfo(0, 1) == nil")
.unwrap());
s.set_container(0, Some(backpack()));
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(0)").unwrap(), 16);
assert_eq!(
s.eval::<String>("return GetBagName(0)").unwrap(),
"Backpack"
);
// The 1.12 five-value shape (decision 1199): texture, itemCount, locked, quality,
// readable — the names `ContainerFrame.lua:241` destructures into, in its order.
let (icon, count, quality) = s
.eval::<(String, i64, i64)>(
"local texture, itemCount, locked, quality = GetContainerItemInfo(0, 1)\n\
return texture, itemCount, quality",
)
.unwrap();
assert_eq!(icon, "Interface\\Icons\\INV_Misc_Food_16");
assert_eq!((count, quality), (5, 1));
// The item id is NOT one of the five — 1.12 has no verb for it, and an addon takes it out
// of the link. Ours rides the host-bridge prefix.
assert_eq!(
s.eval::<i64>("return BenillaGetContainerItemID(0, 1)")
.unwrap(),
117
);
assert!(s
.eval::<bool>("return GetContainerItemLink(0, 1) ~= nil")
.unwrap());
// isReadable mirrors the slot's readable bit (the letter in 4, not the jerky in 1).
assert!(!s
.eval::<bool>("local _, _, _, _, readable = GetContainerItemInfo(0, 1) return readable")
.unwrap());
assert!(s
.eval::<bool>("local _, _, _, _, readable = GetContainerItemInfo(0, 4) return readable")
.unwrap());
// **The in-flight slot**: an entry exists but its template has not landed, so texture and
// quality are nil while itemCount is real. This is the state that made the five-value
// shape's occupancy test subtle (decision 1199) — the reference's own `if texture then`
// would read this as empty, which is why our own FrameXML tests `texture or itemCount`.
assert!(s
.eval::<bool>(
"local texture, itemCount, locked, quality = GetContainerItemInfo(0, 3)\n\
return texture == nil and quality == nil and itemCount ~= nil",
)
.unwrap());
assert!(s
.eval::<bool>("return BenillaGetContainerItemID(0, 3) == nil")
.unwrap());
// Empty slot: nil.
assert!(s
.eval::<bool>("return GetContainerItemInfo(0, 2) == nil")
.unwrap());
}
#[test]
fn use_container_item_queues_intents() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.run("UseContainerItem(0, 1)").unwrap();
s.run("UseContainerItem(0, 3, 'target')").unwrap();
assert_eq!(s.take_container_uses(), vec![(0, 1), (0, 3)]);
assert!(s.take_container_uses().is_empty(), "drained");
}
#[test]
fn pickup_then_place_swaps_and_clears_cursor() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack())); // slot 1 = Tough Jerky (resolved), slot 3 = in-flight
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
// Pick up slot 1 → cursor holds it, the source slot reads locked, no move queued yet.
assert!(s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
let held = s.cursor_item().expect("cursor holds the picked item");
assert_eq!((held.bag, held.slot, held.item_id), (0, 1, 117));
assert_eq!(
held.texture.as_deref(),
Some("Interface\\Icons\\INV_Misc_Food_16")
);
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
assert!(s
.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap());
assert!(s.take_container_moves().is_empty());
// GetCursorInfo reports the item.
let (kind, id) = s
.eval::<(String, i64)>("local k, id = GetCursorInfo() return k, id")
.unwrap();
assert_eq!((kind.as_str(), id), ("item", 117));
// Place onto slot 5 → a move (0,1)->(0,5) queues, cursor clears, source un-locks.
assert!(s.eval::<bool>("return PickupContainerItem(0, 5)").unwrap());
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 5,
count: None,
}]
);
// `local _, _, locked` — the 1.12 five-value shape, the same destructuring the positive
// assertion above uses. This read `local i = … return i.isLocked` until 2171: a leftover
// of the 1.14 `containerInfo` TABLE that 1187 reached for and 1199 corrected in the
// binding without correcting it here. It passed anyway, because 5.1's string metatable
// made indexing the `texture` string a silent `nil` and `nil` is a passing `!bool`. Taking
// the metatable away — 1.12 has none — is what turned a test that asserted nothing into a
// test that raises.
assert!(!s
.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap());
}
/// The sibling of the test above onto an OCCUPIED, DIFFERENT-item slot: the plain SWAP
/// (decision 0218, superseding 0216 §2's hop — byte-verified: no `SetCursorItem` on the place
/// branch; the wire swaps and the cursor empties, exactly like an empty destination). The
/// displaced item never lands on the cursor.
#[test]
fn pickup_place_onto_occupied_different_item_swaps_and_clears() {
let mut s = UiScript::new().unwrap();
let mut state = backpack(); // slot 1 = item 117 (A)
state.slots.insert(
5,
ContainerSlot {
duration_ms: None,
petition: None,
already_bound: false,
bar_placeable: true,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
count: 1,
quality: Some(2),
item_id: 200, // item B, a different id
link: Some("|Hitem:200|h[Shiny Gem]|h".into()),
locked: false,
equip_slots: Vec::new(),
cooldown: None,
readable: false,
creator: None,
flags: 0,
enchants: Vec::new(),
},
);
s.set_container(0, Some(state));
assert!(s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
assert_eq!(s.cursor_item().unwrap().item_id, 117);
// Place A onto occupied slot 5 (item B) → move (1→5) queues, the cursor EMPTIES (the
// server's swap lands B in slot 1), and the source un-locks.
assert!(s.eval::<bool>("return PickupContainerItem(0, 5)").unwrap());
assert!(
s.cursor_item().is_none(),
"a swap clears the cursor — the displaced item never hops on"
);
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 5,
count: None,
}]
);
// The 1.12 five-value shape — see the sibling test above for what this used to read and
// why it passed while asserting nothing (2171).
assert!(!s
.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap());
}
/// Placing onto a SAME-item_id destination merges (the wire tops the stack up itself) —
/// still a plain clear-on-move.
#[test]
fn pickup_place_onto_same_item_merges_and_clears() {
let mut s = UiScript::new().unwrap();
let mut state = backpack();
state.slots.insert(
5,
ContainerSlot {
durability: None,
item_id: 117, // same id as slot 1
count: 2,
..Default::default()
},
);
s.set_container(0, Some(state));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return PickupContainerItem(0, 5)").unwrap());
assert!(
s.cursor_item().is_none(),
"same-item merge clears the cursor"
);
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 5,
count: None,
}]
);
}
#[test]
fn pickup_refuses_a_locked_slot() {
let mut s = UiScript::new().unwrap();
let mut state = backpack();
state.slots.get_mut(&1).unwrap().locked = true;
s.set_container(0, Some(state));
assert!(!s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
assert!(s.cursor_item().is_none());
}
#[test]
fn split_container_item_carries_a_partial_stack() {
let mut s = UiScript::new().unwrap();
let mut state = backpack(); // slot 1: a 5-stack of item 117
state.slots.insert(
7,
ContainerSlot {
durability: None,
item_id: 117,
count: 1,
..Default::default()
},
);
state.slots.insert(
9,
ContainerSlot {
durability: None,
item_id: 999, // a different item
count: 1,
..Default::default()
},
);
s.set_container(0, Some(state));
assert!(s
.eval::<bool>("return SplitContainerItem(0, 1, 3)")
.unwrap());
let held = s.cursor_item().expect("the split carry");
assert_eq!(
(held.bag, held.slot, held.item_id, held.count),
(0, 1, 117, Some(3))
);
// Placed onto an empty slot: the move carries the split count, and clears.
assert!(s.eval::<bool>("return PickupContainerItem(0, 2)").unwrap());
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 2,
count: Some(3),
}]
);
// Re-split, then place onto a DIFFERENT item: no-op, the split carry is kept.
s.run("SplitContainerItem(0, 1, 3)").unwrap();
assert!(!s.eval::<bool>("return PickupContainerItem(0, 9)").unwrap());
let held = s.cursor_item().expect("kept — can't swap a partial stack");
assert_eq!(held.count, Some(3));
assert!(s.take_container_moves().is_empty());
// Placed onto a SAME-item slot: the server merges — the move queues, cursor clears.
assert!(s.eval::<bool>("return PickupContainerItem(0, 7)").unwrap());
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 7,
count: Some(3),
}]
);
}
#[test]
fn split_of_the_whole_stack_is_a_plain_pickup() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack())); // slot 1: a 5-stack
assert!(s
.eval::<bool>("return SplitContainerItem(0, 1, 5)")
.unwrap());
assert_eq!(s.cursor_item().unwrap().count, None);
// Split of MORE than the stack clamps the same way.
s.run("ClearCursor()").unwrap();
assert!(s
.eval::<bool>("return SplitContainerItem(0, 1, 99)")
.unwrap());
assert_eq!(s.cursor_item().unwrap().count, None);
}
#[test]
fn pickup_same_slot_cancels_no_move() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
assert!(s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
assert!(s.cursor_item().is_some());
// Click the same slot again → cancel: cursor clears, nothing queued.
assert!(s.eval::<bool>("return PickupContainerItem(0, 1)").unwrap());
assert!(s.cursor_item().is_none());
assert!(s.take_container_moves().is_empty());
}
#[test]
fn pickup_empty_slot_holds_nothing() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
// Slot 2 is empty, slot 3 is in-flight (unresolved, item_id 0) — neither is pickable.
assert!(!s.eval::<bool>("return PickupContainerItem(0, 2)").unwrap());
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return PickupContainerItem(0, 3)").unwrap());
assert!(s.cursor_item().is_none());
}
#[test]
fn clear_cursor_drops_the_held_item() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some());
s.run("ClearCursor()").unwrap();
assert!(s.cursor_item().is_none());
assert!(s.take_container_moves().is_empty());
}
#[test]
fn removing_a_bag_empties_it() {
let mut s = UiScript::new().unwrap();
s.set_container(2, Some(backpack()));
s.set_container(2, None);
assert_eq!(s.eval::<i64>("return GetContainerNumSlots(2)").unwrap(), 0);
}
/// `GetContainerItemCooldown` — the `GetActionCooldown` conventions on the bag twin: the
/// absolute-start triple stored verbatim in `GetTime` seconds, the cold `(0, 0, 1)` shape
/// for no-cooldown slots, the stale-refeed guard at expiry, and the re-push replacing a
/// bag's prior triples.
#[test]
fn container_item_cooldown_reads_the_gettime_triple() {
let mut s = UiScript::new().unwrap();
s.tick(100.0); // an arbitrary clock epoch
let mut state = backpack();
// Slot 1's item is 4 s into a 10 s use-cooldown: started at GetTime 96.
state.slots.get_mut(&1).unwrap().cooldown = Some((96_000, 10_000, true));
s.set_container(0, Some(state));
// The pushed absolute start reads back verbatim in seconds.
assert!(s
.eval::<bool>(
"local s, d, e = GetContainerItemCooldown(0, 1)\n\
return s == 96 and d == 10 and e == 1"
)
.unwrap());
// A slot with no cooldown reads cold.
assert!(s
.eval::<bool>(
"local s, d, e = GetContainerItemCooldown(0, 3)\n\
return s == 0 and d == 0 and e == 1"
)
.unwrap());
// Past expiry, the same stored triple reads cold — the no-replayed-flash guard.
s.tick(7.0);
assert!(s
.eval::<bool>(
"local s, d, e = GetContainerItemCooldown(0, 1)\n\
return s == 0 and d == 0 and e == 1"
)
.unwrap());
// A re-push without the cooldown clears the stored triple.
s.set_container(0, Some(backpack()));
assert!(s
.eval::<bool>(
"local s, d = GetContainerItemCooldown(0, 1)\n\
return s == 0 and d == 0"
)
.unwrap());
}
// ── `ContainerIDToInventoryID` (wow-re `bag-language-combat-action-bindings.md` §1) ─────────
/// Two linear arms and no range check. **−2 → 17 and 0 → 19 are not special cases** — they are
/// ordinary points on the `id <= 4` line that the caller-side "keyring is −2 / backpack is 0"
/// convention happens to land on, which is why they are asserted beside the bag slots rather
/// than as exceptions.
#[test]
fn container_id_to_inventory_id_is_two_lines_with_no_clamp() {
let s = UiScript::new().unwrap();
let slot = |id: &str| {
s.eval::<i64>(&format!("return ContainerIDToInventoryID({id})"))
.unwrap()
};
// The `id <= 4` line: keyring, the odd −1, the backpack, the four worn bags.
assert_eq!(slot("-2"), 17, "keyring — an ordinary point, not a case");
assert_eq!(slot("-1"), 18);
assert_eq!(slot("0"), 19, "backpack — likewise");
assert_eq!(
(slot("1"), slot("2"), slot("3"), slot("4")),
(20, 21, 22, 23)
);
// The `id >= 5` line: the bank bags.
assert_eq!(slot("5"), 64, "the jump — +59, not +19");
assert_eq!((slot("6"), slot("10")), (65, 69));
// No range check, no clamp, and never nil: out of range is an ordinary number.
assert_eq!(
slot("99"),
158,
"the value `SetInventoryItem` would receive"
);
assert_eq!(slot("-100"), -81, "negatives run off the line too");
assert_eq!(
s.arity("ContainerIDToInventoryID(99)").unwrap(),
1,
"one value on every non-raising path"
);
// `0x40a2b0` truncates toward zero — a C cast, not `floor`.
assert_eq!(slot("2.9"), 21, "2.9 -> 2");
assert_eq!(slot("-2.9"), 17, "-2.9 -> -2, NOT -3");
}
/// A missing or non-number argument **raises** — the only guard in the function is the type
/// test, and `0x6f4940` does not return.
#[test]
fn container_id_to_inventory_id_raises_on_a_bad_argument() {
let s = UiScript::new().unwrap();
for call in ["ContainerIDToInventoryID()", "ContainerIDToInventoryID({})"] {
let err = s
.eval::<mlua::Value>(&format!("return {call}"))
.unwrap_err();
assert!(
format!("{err}").contains("Usage: ContainerIDToInventoryID(containerID)"),
"{call} must raise, got {err}"
);
}
}
}