Files
moonwell-client-core/crates/benilla-ui/src/script/container.rs
T
Sam Sung d5e37dd975 Every deviation names its reason, and a gate keeps it so (#2868)
`docs/METHOD.md` asks each deviation to name the reference fact and why benilla differs. About 130 `Deviation:` comments gave no reason, and the Warden comment lost its reason when the refusal became a read-past.

- `scripts/genmap.sh --check`, now a gate, fails a `Deviation:` comment without "because" and a `Deviates` row without a Why.
- `scripts/deviation-why.allow` lists the 43 comments whose change recorded no reason. It only shrinks: an entry that gains its reason, or is new against main's copy, fails.
- About 190 comments that gave their reason in other words now say "because", with their meaning kept.
- Nine reasons, the Warden one among them, come back from the commits and pull requests that made them.

Tested by the gate on the full tree, and by its failure on a comment with no reason and on a stale entry.

Fixes #2856
Fixes #2857

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:23:34 +07:00

3067 lines
126 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 1.12 bag verbs, all bare globals. The app pushes each bag's resolved snapshot and drains
//! the intents the verbs queue; the engine holds no item knowledge. Bag ids are the 1.12 client's
//! (0 the backpack, 1-4 the worn bags, -1 the bank's own slots, 5-10 the bank bags, -2 the
//! keyring); slots are 1-based.
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{self, flag, number_arg};
use super::cursor::{self, CursorItem, CursorPayload};
use super::item_read::read_item_or;
use super::protected::{self, Tag};
use super::{ItemCall, Model};
/// The petition lines an item tooltip prints under the name (`0x854d7c..0x854dd0`): the title and
/// the creator, keyed `GUILD_CHARTER_*` for a charter, else `PETITION_*`, then the signature
/// count.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct PetitionSlotView {
/// The record's charter bit, also `GetPetitionInfo`'s first return.
pub is_charter: bool,
/// The proposed guild's (or petition's) name.
pub title: String,
/// The owner's name from the name cache; `None`, and no line, while the query is in flight.
pub owner: Option<String>,
/// The signatures, the item object's second enchantment dword (`flds+0x44`, `0x52ba51`).
pub signatures: u32,
}
/// One occupied bag slot, resolved by the app from the item object and its template.
#[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. `GetContainerItemInfo` reports it only when
/// `inventory_type` is nonzero (`0x4f9794`), else -1.
pub quality: Option<u32>,
/// The template's InventoryType; 0 while unresolved and for an item that cannot be equipped.
pub inventory_type: u32,
/// The template's bonding (`[rec+0x194]`), 0 while unresolved: the equip arms ask on 2.
pub bonding: u32,
/// The on-use spell the dispatcher reads (`0x5d8c80`): the first template block with trigger 0,
/// 0 for none or while unresolved.
pub use_spell: u32,
/// The template's class (`[rec+0x0]`, read by `0x5d9f30`), `None` while unresolved: a
/// projectile (6) auto-equips by entry and unlocks as it goes (`0x5e1704`).
pub item_class: Option<u32>,
/// The item object's template entry, 0 until the object carries one.
pub item_id: u32,
/// An `|Hitem:…|h[Name]|h` link once the name is known.
pub link: Option<String>,
/// The link `0x52ae00` builds while the record is not cached, white and nameless (`0x52aec5`,
/// `0x5d8b40`); `None` once [`Self::link`] is set. `GetContainerItemLink` answers it
/// (`0x4f997f`), and `GetInventoryItemLink` for the vault and keyring bands (`0x4c8ce2`).
pub uncached_link: Option<String>,
/// The item object's guid, 0 while unresolved: what the client's lock (`item+0x314`) and
/// every lock read key on.
pub guid: u64,
/// The 1-based inventory slots the item equips into, from `inventoryType`; empty if none.
pub equip_slots: Vec<u8>,
/// Whether the item may go on an action bar: `PlaceAction`'s only item filter (`0x4e62e0`), an
/// on-use spell or equippable.
pub bar_placeable: bool,
/// The action bar's count gate (`IsConsumableAction`, `0x4e5250`): ammo, thrown, or an on-use
/// spell with negative charges.
pub consumable: bool,
/// Current and max durability; `None` when the max is 0 or the item object has not arrived.
pub durability: Option<(u32, u32)>,
/// The item query's answer (`GetItemCooldown`, `0x6e2ed0`), `(start_ms on the GetTime clock,
/// duration_ms, enabled)`. `None` is the all-zero answer, enable included: a template with no
/// on-use spell, a charged one with none left, or one still in flight. A cold on-use spell is
/// `Some((0, 0, true))`.
pub cooldown: Option<(i64, u32, bool)>,
/// The template's block 0 names a spell (`+0x11c`, any trigger): `GetInventoryItemCooldown`
/// runs no query without one (`0x4c8b47`).
pub spell_0: bool,
/// `ITEM_FIELD_SPELL_CHARGES`, the charges left on each spell block (`flds+0x28`): the
/// tooltip's charges lines read them (`0x52d9e9`), and the merchant price scales by block 0's
/// (`0x52e43d`).
pub spell_charges: [i32; 5],
/// The template carries PageText (a book) or the instance `ITEM_FIELD_ITEM_TEXT_ID` (a mail
/// copy), in that order (`0x4f97ba`-`0x4f97cf`): `GetContainerItemInfo`'s `readable`, which
/// shows the Inspect cursor (`ContainerFrame.lua:638`).
pub readable: bool,
/// The `ITEM_FIELD_CREATOR` name for the "Written by" or "<Made by>" line, once resolved.
pub creator: Option<String>,
/// The `ITEM_FIELD_GIFTCREATOR` name for a wrapped item's `ITEM_WRAPPED_BY` line, once
/// resolved; only a wrapped item asks for it, as the gift arm does (`0x52e29d`).
pub gift_creator: Option<String>,
/// The `ITEM_FIELD_CREATOR` guid, 0 for none: with no [`Self::creator`] yet, the name the
/// tooltip waits for.
pub creator_guid: u64,
/// The `ITEM_FIELD_GIFTCREATOR` guid, 0 for none, as [`Self::creator_guid`].
pub gift_creator_guid: u64,
/// `ITEM_FIELD_FLAGS`; the tooltip reads UNLOCKED `0x4` and WRAPPED `0x8`.
pub flags: u32,
/// Bound at runtime (`0x5da2c0`): `ITEM_FIELD_FLAGS & 1`, or an enchant whose
/// `SpellItemEnchantment` row binds. The tooltip's bind line then says Soulbound.
pub already_bound: bool,
/// For a container (`TYPEMASK_CONTAINER`), how many of its seats hold an item, which
/// `ClickTradeButton` counts (`0x4bff50`); `None` for any other item.
pub occupied_seats: Option<u32>,
/// The enchant slots in slot order, named from `SpellItemEnchantment.dbc`; empty when none.
pub enchants: Vec<super::EnchantView>,
/// A timed item's lifetime left in ms (`SMSG_ITEM_TIME_UPDATE`), floored to the second.
pub duration_ms: Option<u64>,
/// `ITEM_FIELD_DURATION` as the object carries it (`[item+0x114]+0x24`), 0 for none: a
/// nonzero one refuses the auction sell slot (`0x4ce411`).
pub field_duration: u32,
/// A signable charter's petition, printed between the name and the green `ITEM_SIGNABLE`
/// line; `None` until the record the hover queries arrives.
pub petition: Option<PetitionSlotView>,
/// The arm a right-click takes past the bank and vendor forks, which the app reads off the
/// item object and its template so `UseContainerItem` can take the arm's lock in the call.
pub use_fork: UseFork,
}
impl ContainerSlot {
/// The item object the getters read as they run, while the slot names one.
pub(crate) fn item_key(&self) -> Option<super::ItemKey> {
(self.guid != 0).then_some(super::ItemKey::Object(self.guid))
}
/// An item object whose record the cache does not hold yet, which the feed pushes with its
/// entry and no quality: a cooldown query on it asks the record with the callback `0x6e2f70`
/// (`0x6e2efb`), whose arrival runs it.
pub fn record_pending(&self) -> bool {
self.item_id != 0 && self.quality.is_none()
}
/// The whole stack of this slot as a cursor payload, as a pickup sets it.
pub(crate) fn cursor_item(&self, bag: i64, slot: u32) -> CursorItem {
CursorItem {
item_class: self.item_class,
guid: self.guid,
bag,
slot,
item_id: self.item_id,
texture: self.texture.clone(),
link: self.link.clone(),
quality: self.quality,
count: None,
bar_placeable: self.bar_placeable,
consumable: self.consumable,
equip_slots: self.equip_slots.clone(),
}
}
}
/// `UseContainerItem`'s arm past its bank and vendor forks: the equip fork (`0x4fa3b9`), else the
/// use dispatcher `0x5d8d00`'s arms in its order.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum UseFork {
/// An equippable template (`0x5d9ff0`) whose `StartQuest` is 0 (`0x4fa3bd`) auto-equips.
Equip,
/// A wrapped gift unwraps with `CMSG_OPEN_ITEM` (`0x5d8d92`), taking no lock.
Unwrap,
/// Wrapping paper arms the wrap (`0x5edea0`), which locks the paper.
WrapPaper,
/// A quest-starter offers its quest (`0x5d8dd2`).
Quest,
/// A template `PageText` book opens the reader (`0x5d8e4c`).
Read,
/// A mail letter, the instance's `ITEM_FIELD_ITEM_TEXT_ID`, opens the reader.
Letter,
/// A lootable template sends `CMSG_OPEN_ITEM` (`0x5d8f7c`) through `OpenItem`, which locks
/// the item (`0x5edcd9`) after its `SetAutoLoot` (`0x5edcb6`): the host's, as that store
/// sweeps an open window first.
Open,
/// The plain use, and a slot whose template is still in flight, which `0x5d9ff0` reads as
/// not equippable.
#[default]
Plain,
}
/// The arm a `UseContainerItem` call took, in `0x4fa0e0`'s order.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UseArm {
/// A bank-vault or bank-bag item withdraws (`0x4fa305`), the bank flag the slot resolve sets
/// for bags -1 and 5..10 (`0x4f98b3`, `0x4f98fc`).
Withdraw,
/// Any other item deposits while a banker is open (`[0xbdd038]`, `0x4fa33e`).
Deposit,
/// With a vendor open (`[0xbddfa0]`) the whole stack sells (`0x4fa378`).
Sell,
/// The item's own fork.
Item(UseFork),
}
/// One `UseContainerItem` call past its gates: the slot, the item it resolved (`0x4fa1a6`) and
/// the arm it took, whose lock the call has already taken, but the open arm's, which the host
/// takes ([`UseFork::Open`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ContainerUse {
pub bag: i64,
pub slot: u32,
pub guid: u64,
pub arm: UseArm,
/// `onSelf`: the use targets the player in place of the selection (`0x4fa17d`-`0x4fa18e`).
pub on_self: bool,
/// What the protected-action gate's tag 2 allowed the call: an item's on-use spell casts
/// through `TryCastSpell`, behind it (`0x4fa430` → `0x5d8d00` → `0x5d9258` → `0x6e5a90`).
pub may_cast: bool,
}
/// One enchant slot as the tooltip renders it (`0x52c9f9`..`0x52ca23`); the app resolves the row.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct EnchantView {
/// `ITEM_FIELD_ENCHANTMENT` slot: 0 permanent, 1 temporary, 2-6 the random suffix; only 0 and
/// 1 are coloured.
pub slot: u8,
/// The id was negative: `abs(id)` names the row, and slots 0 and 1 paint red, not green
/// (`0x52ca29`..`0x52ca49`).
pub negative: bool,
/// The `SpellItemEnchantment` row's name, copied unformatted (`0x52ca8b`..`0x52caa1`).
pub name: String,
/// The slot's charges; nonzero appends " (N Charges)".
pub charges: u32,
/// Ms left on a temporary enchant, from `SMSG_ITEM_ENCHANT_TIME_UPDATE`, never the item's own
/// duration field; `Some` prints the countdown form.
pub remaining_ms: Option<u64>,
}
/// One `ItemRandomProperties` row, an item's random-suffix roll. The app pushes the whole table at
/// load, and a tooltip source supplies only the id, as in the reference.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct RandomPropertyView {
/// The suffix (`"of the Monkey"`), joined on by `ITEM_SUFFIX_TEMPLATE` (`0x5d8b00`), for the
/// names the app does not compose itself.
pub suffix: String,
/// The roll's five enchants, named, in slots 2-6 (`0x52b7e0`..`0x52b7fb`); always white.
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` for bags 1-10 (the bag item's own name); the backpack, the bank and the
/// keyring have none.
pub name: Option<String>,
pub num_slots: u32,
pub slots: std::collections::HashMap<u32, ContainerSlot>,
}
/// One queued move from `(src_bag, src_slot)` to `(dst_bag, dst_slot)`. `count` is `None` for a
/// whole stack (a move, a swap, or a merge the server tops up), `Some(n)` for a split placement
/// (`CMSG_SPLIT_ITEM`).
#[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 (`PutItemInBag`, `PutItemInBackpack`): the item at `(src_bag, src_slot)`
/// into container `dst_bag`, with no slot: `CMSG_AUTOSTORE_BAG_ITEM` (0x10B) carries none and
/// `CMSG_SPLIT_ITEM` (0x10E) sends `0xFF`, so the server picks. `Some(n)` in `count` is a split,
/// the fork the reference makes on `[0xb4b40c]`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BagAutoStore {
pub src_bag: i64,
pub src_slot: u32,
/// The container id: 0 the backpack, 1-4 a worn bag, 5-10 a bank bag.
pub dst_bag: i64,
pub count: Option<u32>,
}
/// What `GetContainerItemCooldown` and `GetInventoryItemCooldown` push for the item query's answer
/// (`0x6e2ed0`), `(start, duration, enable)` in `GetTime` seconds. No answer is three zeros, the
/// enable included (`0x4f9ae9`, `0x4c8bdf`). An answer past its end reads cold with enable 1: the
/// query ran and found nothing left, so a re-feed cannot replay the finish flash. A held one
/// (`enable` 0) answers the call's own now (`0x6e1517`).
pub(super) fn item_cooldown_triple(query: Option<(f64, f64, bool)>, now: f64) -> (f64, f64, i64) {
match query {
None => (0.0, 0.0, 0),
Some((start, duration, enabled)) if start + duration > now || !enabled => (
crate::script::clock::held_start(start, enabled, now),
duration,
i64::from(enabled),
),
Some(_) => (0.0, 0.0, 1),
}
}
/// A slot's item query answer as its view carries it, in `GetTime` seconds.
pub(crate) fn slot_cooldown(s: &ContainerSlot) -> Option<(f64, f64, bool)> {
s.cooldown.map(|(start_ms, duration_ms, enabled)| {
(
start_ms as f64 / 1000.0,
f64::from(duration_ms) / 1000.0,
enabled,
)
})
}
/// The bank's bags, containers 5-10.
const BANK_BAGS: std::ops::RangeInclusive<i64> = 5..=10;
impl super::Model {
/// Whether container `id` is a bank bag while no banker is open: the bag lookup every
/// container binding goes through, `0x4f93e0`, answers no bag for it then (`0x4f93e9`-
/// `0x4f93f9`, the banker guid `[0xbdd038]`), whatever its bank bag slot holds.
pub(crate) fn bank_bag_shut(&self, id: i64) -> bool {
BANK_BAGS.contains(&id) && self.bank.is_none()
}
/// Whether container `id` is the vault while no banker is open: its slots are the player
/// list's 39 to 62, which `GetItemInSlot` (`0x6228a0`) answers empty then (`0x6228ad`-
/// `0x6228cf`, the banker guid `[0xbdd038]`; the list's bank flag `+0x10` is set at the
/// player's construction, `0x5dd44d`). The vault itself still resolves and keeps its size.
pub(crate) fn vault_shut(&self, id: i64) -> bool {
id == super::char_stats::BANK_CONTAINER && self.bank.is_none()
}
}
/// The slot `(bag, slot)` a container binding finds now ([`super::held::bag_slot`]).
fn live_slot(lua: &Lua, bag: i64, slot: u32) -> Option<ContainerSlot> {
let model = lua.app_data_ref::<Model>().expect("model app_data");
super::held::bag_slot(lua, &model, bag, slot)
}
impl super::UiScript {
/// Seat one bag's copy, or remove it with `None`, for a harness with no host to ask: the bag
/// getters read the bags live through the lent host ([`super::held`]), and this copy only when
/// none is lent.
pub fn set_container(&mut self, bag: i64, state: Option<ContainerState>) {
let mut model = self.model_mut();
match state {
Some(s) => {
model.containers.insert(bag, s);
}
None => {
model.containers.remove(&bag);
}
}
}
/// Seat the worn items' use cooldowns, `(start_ms on the GetTime clock, duration_ms,
/// enabled)` by live-API slot id, stored in seconds like a bag slot's, for a harness with no
/// host to ask: the getters query the list at the call through the lent host
/// ([`super::CooldownQuery::Worn`]). No event fires.
pub fn set_worn_cooldowns(
&mut self,
cooldowns: std::collections::HashMap<usize, (i64, u32, bool)>,
) {
self.model_mut().worn_cooldowns = cooldowns
.into_iter()
.map(|(slot, (start_ms, duration_ms, enabled))| {
(
slot,
(
start_ms as f64 / 1000.0,
f64::from(duration_ms) / 1000.0,
enabled,
),
)
})
.collect();
}
/// Seat `HasKey()`'s answer for a harness with no host to ask ([`super::held::has_key`]).
pub fn set_has_key(&mut self, has_key: bool) {
self.model_mut().has_key = has_key;
}
/// Take the `UseContainerItem` calls out of the call stream, as `(bag, slot)`.
pub fn take_container_uses(&mut self) -> Vec<(i64, u32)> {
self.take_calls_where(|c| match c {
super::ScriptCall::UseContainerItem(u) => Some((u.bag, u.slot)),
_ => None,
})
}
/// The item entries whose record a cooldown query found missing that a harness's VM kept
/// ([`Self::keep_calls`]), each asked with the callback `0x6e2f70`.
pub fn take_cooldown_record_asks(&mut self) -> Vec<u32> {
self.take_calls_where(|call| match call {
super::ScriptCall::RecordAsk(super::RecordAsk::CooldownItem { entry, .. }) => {
Some(*entry)
}
_ => None,
})
}
/// Arm a gift wrap with the paper at `(bag, slot)`, as `0x5edea0` does: the paper locks, the
/// cursor takes mode 2 (the cast art, table `0x853b88`), and nothing is sent. The next
/// left-click on a container slot completes it.
pub fn arm_gift_wrap(&mut self, bag: i64, slot: u32) {
let guid = {
let model = self.model_ref();
super::held::held_slot(&self.lua, &model, bag, slot).map_or(0, |s| s.guid)
};
arm_gift_wrap(&mut self.model_mut(), bag, slot, guid);
}
/// The armed gift wrap, if any; the app reads it to hold the cursor's base at Cast.
pub fn gift_wrap_armed(&self) -> Option<PendingWrap> {
self.model_ref().pending_wrap
}
/// The mode the last FrameXML cursor call set, `None` after a `ResetCursor`; the app acts on
/// [`Self::take_cursor_write`] instead.
pub fn ui_cursor(&self) -> Option<UiCursorMode> {
self.model_ref().ui_cursor
}
/// The path [`UiCursorMode::Custom`] shows: the name `SetCursor` loaded it by, as the script
/// gave it (`0xbe2c70`).
pub fn custom_cursor_name(&self) -> String {
self.model_ref().custom_cursor_name.clone()
}
/// Install the host's size of a picture the cursor code opens (`0x449610`: the name's `.blp`,
/// decoded), which `SetCursor`'s custom cursor must have at 32 by 32; without one no custom
/// cursor loads.
pub fn set_cursor_size_probe(&mut self, probe: Box<super::TextureSizeFn>) {
self.model_mut().cursor_size_probe = Some(probe.into());
}
/// Drain the pending cursor write: `Some(mode)` a set, `Some(None)` a reset, `None` no call,
/// which must leave the cursor as it was, so an armed spell keeps its cast cursor over a
/// spellbook button.
#[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's paper. The reference keeps it beside the cursor mode, not as a cursor
/// payload, so `CursorHasItem()` stays false while a wrap is armed.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct PendingWrap {
/// The paper's container id.
pub bag: i64,
/// The paper's 1-based slot within that container.
pub slot: u32,
/// The paper's guid, locked while armed (`0x5edeb5`).
pub guid: u64,
}
/// A displayed-cursor override from the FrameXML cursor verbs, the reference's one displayed mode
/// (`0xbe2c2c`), until `ResetCursor`; the app maps each to its `Interface\Cursor` art.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum UiCursorMode {
/// Buy (3): selling a bag item, or a merchant or buyback item the player can afford.
Buy,
/// UnableBuy (23): a merchant or buyback item the player cannot afford.
UnableBuy,
/// Inspect (7): the magnifier, `ShowInspectCursor`.
Inspect,
/// Point (1), `SetCursor("POINT_CURSOR")`.
Point,
/// Cast (2), `SetCursor("CAST_CURSOR")`: a unit frame the armed spell can target.
Cast,
/// UnableCast (22), `SetCursor("CAST_ERROR_CURSOR")`: a unit frame the armed spell cannot
/// target, and leaving one while still targeting.
CastError,
/// Attack (4), `SetCursor("ATTACK_CURSOR")`.
Attack,
/// UnablePoint (21), `SetCursor("POINT_ERROR_CURSOR")`.
UnablePoint,
/// UnableAttack (24), `SetCursor("ATTACK_ERROR_CURSOR")`.
UnableAttack,
/// Custom (41): the texture `SetCursor` loaded by any other name (`0x523c40`), its image
/// [`super::UiScript::custom_cursor_name`].
Custom,
}
/// `CursorSetCustom` (`0x523c40`): the name as it stands is a texture path (`0x449610`, `.blp` put
/// on, `0x523c86`) that must be 32×32 (`0x523cb9`, `0x523cbd`); it becomes mode 41 (`0x523cc9`),
/// its image the slot-41 picture (`0xc0bd80`), and its name is kept (`0x523cf5`, 259 bytes and a
/// NUL), answering true. A load failure or another size answers false and changes nothing. The
/// same name, ignoring case, while mode 41 shows answers true at once (`0x523c46`-`0x523c6d`);
/// here it also re-asserts the mode, since the world classifier, the other writer of the shown
/// mode, lives in the host.
fn custom_cursor(model: &mut super::Model, name: &str) -> bool {
if model.ui_cursor == Some(UiCursorMode::Custom)
&& model.custom_cursor_name.eq_ignore_ascii_case(name)
{
model.ui_cursor_dirty = true;
return true;
}
let size = model
.cursor_size_probe
.as_ref()
.and_then(|probe| probe(name));
if size != Some((32, 32)) {
return false;
}
model.ui_cursor = Some(UiCursorMode::Custom);
model.ui_cursor_dirty = true;
let mut end = name.len().min(0x103);
while !name.is_char_boundary(end) {
end -= 1;
}
model.custom_cursor_name = name[..end].to_string();
true
}
/// `UseContainerItem` past its clear. An empty slot or a locked item ends it, repair mode
/// included (`0x4fa1c7`, `0x4fa1cd`); then repair mode repairs (`0x4fa1da`). Every other arm takes
/// its lock here, in the call, so a later call reads it as the reference's does: the withdraw and
/// deposit (`0x4fa312`, `0x4fa34b`), the sale (`0x4fa37f`), the open (`0x5edcd9`) and the wrap
/// paper (`0x5edea0`). The equip arm is not done here: it returns its item, which the call puts on
/// the cursor ([`use_equip_arm`]). The withdraw, deposit and sale send first (`0x4fa305`,
/// `0x4fa33e`, `0x4fa378`), their call handed to the host in place ahead of the lock's signal; the
/// host sends the item arms from the call left in `call`, and the repair from its
/// [`ItemCall::Repair`].
fn use_cleared(
lua: &Lua,
model: &mut super::Model,
bag: i64,
slot: u32,
on_self: bool,
may_cast: bool,
vendor: super::merchant::VendorAtCall,
call: &mut Option<super::ScriptCall>,
) -> Option<CursorItem> {
// The slot through `GetItemInSlot` (`0x4fa1a6`): a shut vault's reads empty.
let s = super::held::bag_slot(lua, model, bag, slot)
.filter(|s| s.item_id != 0 && !cursor::item_locked(model, s.guid))?;
let (guid, fork, item_id, use_spell) = (s.guid, s.use_fork, s.item_id, s.use_spell);
let known = s.item_class.is_some();
let equip = (fork == UseFork::Equip).then(|| s.cursor_item(bag, slot));
if model.repair_mode {
model.item_call(ItemCall::Repair { bag, slot });
return None;
}
let arm = if bag == -1 || (5..=10).contains(&bag) {
UseArm::Withdraw
} else if model.bank.is_some() {
UseArm::Deposit
} else if vendor.open {
UseArm::Sell
} else {
UseArm::Item(fork)
};
if let (UseArm::Item(UseFork::Equip), Some(item)) = (arm, equip) {
// Without control the equip arm returns before it locks or sends, unless on a taxi
// (`0x4fa3ce`-`0x4fa3ec`).
return cursor::equip::has_cursor_control(lua, model).then_some(item);
}
// The use dispatcher's gates (`0x5d8d00`), each showing its line and ending the call before
// the arm's lock (`0x5edcd9`): the requirement check ahead of the read, letter, open and
// plain arms, the control gate ahead of the open and plain ones.
if let UseArm::Item(fork) = arm {
if let Some(key) =
super::use_gates::item_use_refusal(lua, model, fork, item_id, use_spell, known)
{
// Shown inside the verb, as the gate's `DisplayError` (`0x496720`) is.
*call = Some(super::ScriptCall::DisplayError(key));
return None;
}
}
let use_call = super::ScriptCall::UseContainerItem(ContainerUse {
bag,
slot,
guid,
arm,
on_self,
may_cast,
});
match arm {
UseArm::Withdraw | UseArm::Deposit | UseArm::Sell => {
model.call_in_verb(use_call);
cursor::lock_item(model, guid);
}
UseArm::Item(fork) => {
*call = Some(use_call);
if fork == UseFork::WrapPaper {
arm_gift_wrap(model, bag, slot, guid);
}
}
}
None
}
/// The equip arm (`0x4fa3b9`): `0x5e1840` puts the item on the cursor and runs
/// `AutoEquipCursorItem` (`0x4fa3ff`), which sends, asks or parks on the record in place, the send
/// ahead of its clear; then the clicked item locks unless it is a projectile (`0x4fa406`-
/// `0x4fa418`), whose clear unlocked it instead (`0x5e1711`). A bind-on-equip item sends nothing:
/// it stays held and locked under the question. An item the verb refused (in the trade window)
/// sends nothing, but still locks.
fn use_equip_arm(lua: &Lua, item: CursorItem) -> mlua::Result<()> {
let guid = item.guid;
let projectile = item.item_class == Some(cursor::ITEM_CLASS_PROJECTILE);
cursor::equip::auto_equip_from(lua, item, false)?;
super::tick::verb(lua, |model| {
if !projectile {
cursor::lock_item(model, guid);
}
});
Ok(())
}
/// The wrap arm `0x5edea0`: the paper `guid` at `(bag, slot)` locks, the cursor takes mode 2 (the
/// cast art, table `0x853b88`), and nothing is sent.
fn arm_gift_wrap(model: &mut super::Model, bag: i64, slot: u32, guid: u64) {
model.pending_wrap = Some(PendingWrap { bag, slot, guid });
model.ui_cursor = Some(UiCursorMode::Cast);
model.ui_cursor_dirty = true;
cursor::lock_item(model, guid);
}
/// `PickupContainerItem`'s gesture (`0x4f9b30`):
/// - an empty cursor, or one holding a spell, macro, pet action, stabled pet or coins, picks up a
/// resolved item whose slot is not locked, the pickup's clear dropping that payload;
/// - a click on the held item's own slot cancels;
/// - a whole stack placed anywhere else queues the move and clears the cursor; a swap lands the
/// displaced item in the source slot, never on the cursor (no `SetCursorItem`, `0x5e0c40`);
/// - a split carry places onto any slot, the item it holds unread;
/// - a vendor row buys into the slot, and an item off the bar is cleared.
///
/// Returns what the binding owes past this segment: the item a pickup puts on the cursor
/// ([`cursor::pick_up_item`]), so the setter's inner clear signals on its own, or the armed
/// spell's pick, which applies through the host inside the verb.
fn pickup_container_item(
lua: &Lua,
model: &mut super::Model,
bag: i64,
slot: u32,
may_cast: bool,
vendor: super::merchant::VendorAtCall,
) -> ContainerClick {
// Without control and off a taxi the click ends before anything is read
// (`0x4f9b87`-`0x4f9ba3`).
if !cursor::equip::has_cursor_control(lua, model) {
return ContainerClick::Done;
}
// The slot as the click finds it now, by its own place.
let here = super::held::held_slot(lua, model, bag, slot);
// An armed gift wrap completes here, before the cursor is read. `PickupContainerItem` is the
// only caller of the wrap sender `0x5edfc0`, so an equipped item cannot be wrapped.
if let Some(wrap) = model.pending_wrap {
// An empty or unresolved target leaves the wrap armed, as in the reference.
if !here.as_ref().is_some_and(|s| s.item_id != 0) {
return ContainerClick::Done;
}
// A vanished paper also bails, the wrap armed and the paper locked (`0x5edfef`).
if !super::held::held_slot(lua, model, wrap.bag, wrap.slot)
.as_ref()
.is_some_and(|s| s.item_id != 0)
{
return ContainerClick::Done;
}
// No eligibility test: the server refuses with an `ERR_CANT_WRAP_*` reason
// (`SMSG_INVENTORY_CHANGE_FAILURE` 43-48), and a local gate would eat that error line.
model.pending_wrap = None;
model.item_call(ItemCall::Wrap {
gift_bag: wrap.bag,
gift_slot: wrap.slot,
item_bag: bag,
item_slot: slot,
});
// The paper unlocks and the cursor resets right after the send (`0x5eded0` at
// `0x5ee0aa`), not on the answer; the target never locks.
cursor::unlock_item(model, wrap.guid);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
return ContainerClick::Done;
}
// Only a held item (`0x4f9c38`) or a held row, a vendor's or the bar's (`0x4f9c43`), takes the
// click first. Past them an empty slot does nothing (`0x4f9c4e`), an armed item-targeting
// spell binds the item (`0x4f9c54`) and repair mode repairs it (`0x4f9c7b`), any other payload
// staying held; the host runs the arm's `ITEM_REPAIR` and send in the call. Else the item is
// picked up, its setter's clear dropping whatever else is held (`0x4f9de9`).
let placing = matches!(
model.cursor,
Some(
CursorPayload::Item(_)
| CursorPayload::Merchant(_)
| CursorPayload::Action(_)
| CursorPayload::Ammo(_)
)
);
let item_pick = cursor::targeting_wants(lua, model).item;
if !placing && (item_pick || model.repair_mode) {
let occupied = here.as_ref().is_some_and(|s| s.item_id != 0);
if occupied && item_pick {
return ContainerClick::Pick(cursor::ItemPick {
bag,
slot,
may_cast,
});
} else if occupied {
model.item_call(ItemCall::Repair { bag, slot });
}
return ContainerClick::Done;
}
match model.cursor.clone() {
// The lock gate reads the item's own bit (`0x4f9dc4`), the app's or the client's.
None
| Some(
CursorPayload::Spell(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)
| CursorPayload::StablePet(_)
| CursorPayload::Money(_),
) => here
.as_ref()
.filter(|s| s.item_id != 0 && !cursor::item_locked(model, s.guid))
.map_or(ContainerClick::Done, |s| {
ContainerClick::PickUp(s.cursor_item(bag, slot))
}),
// The cancel is `ClearCursor(1,1)` (`0x4f9e0e`): the unlock, then `CURSOR_UPDATE`.
Some(CursorPayload::Item(held)) if held.bag == bag && held.slot == slot => {
cursor::clear_cursor(model);
ContainerClick::Done
}
Some(CursorPayload::Item(held)) => {
// Cloned so the moves below can borrow `model`; an unresolved slot reads as empty.
let dest = here.as_ref().cloned().filter(|d| d.item_id != 0);
match (held.count, dest) {
// A split places and clears whatever the slot holds (`0x4f9e1c` → `0x5e1210`,
// then `ClearCursor(0,1)` at `0x4f9e4f`): the grid hide and `CURSOR_UPDATE`, and
// no lock event.
(Some(n), _) => {
queue_split(model, &held, bag, slot, n);
cursor::clear(model, cursor::Clear::KEEP_LOCK);
}
// A locked occupant refuses the whole stack, the payload kept (`0x4f9e76`); the
// split arm above never reads it.
(None, Some(d)) if cursor::item_locked(model, d.guid) => {}
// A whole stack locks the destination's item first (`0x4f9e85`; an empty slot's
// zero guid signals nothing, `0x4953ed`), then goes through `SwapItem`
// (`0x4f9eac`), whose send clears (`ClearCursor(0,1)` at `0x5e0fe7`) and whose
// question leaves the item held; the binding clears after it either way
// (`0x4f9eb8`), past the call.
(None, dest) => {
cursor::lock_item(model, dest.map_or(0, |d| d.guid));
cursor::swap::swap_and_file(
lua,
model,
ContainerMove {
src_bag: held.bag,
src_slot: held.slot,
dst_bag: bag,
dst_slot: slot,
count: None,
},
);
return ContainerClick::Swapped;
}
}
ContainerClick::Done
}
// Mode 5, a held vendor row, buys into this slot whatever it holds (`0x4f9efa` tests
// `[0xb4d900] == 5`): `CMSG_BUY_ITEM_IN_SLOT` (0x1a3) with count 1, and the cursor clears
// either way, the item the row names now; with no vendor open the row stays held
// (`0x4f9f0f`). Deviation: a row index the list no longer holds buys nothing and drops,
// because this path reads through a null row in the reference (`0x4f9f35`).
Some(CursorPayload::Merchant(_)) if !vendor.open => ContainerClick::Done,
Some(CursorPayload::Merchant(_)) => {
if let Some(entry) = vendor.held_entry {
model.merchant_call_in_verb(super::MerchantCall::BuyInSlot { bag, slot, entry });
}
cursor::clear_cursor(model);
ContainerClick::Done
}
// Mode 7, an item off the bar, and mode 9, ammo, are held rows too, and only cleared
// (`0x4f9f41`).
Some(CursorPayload::Action(_) | CursorPayload::Ammo(_)) => {
cursor::clear_cursor(model);
ContainerClick::Done
}
}
}
/// What a bag click leaves its binding past the click's segment.
enum ContainerClick {
/// Done inside the segment.
Done,
/// A whole stack went through `SwapItem`; the binding's own `ClearCursor(0,1)` follows the
/// call (`0x4f9eb8`), so its `CURSOR_UPDATE` comes after any bind question the swap raised,
/// whose dialog that signal hides (`UIParent.lua:357-361`).
Swapped,
/// The item a pickup puts on the cursor.
PickUp(CursorItem),
/// The armed item-targeting spell's pick (`0x4f9c54`), a call for the host.
Pick(cursor::ItemPick),
}
/// Queue a split's placement (`0x5e1210`), which asks no question.
fn queue_split(model: &mut super::Model, held: &CursorItem, dst_bag: i64, dst_slot: u32, n: u32) {
model.item_call(ItemCall::Move(ContainerMove {
src_bag: held.bag,
src_slot: held.slot,
dst_bag,
dst_slot,
count: Some(n),
}));
}
/// The `(bag, slot)` the three container getters resolve (`0x4f9820`): both are numbers (else the
/// binding's usage error), the slot is `trunc(n) - 1` and fails the resolver when negative
/// (`0x4f990d`), so a zero or negative slot is no slot at all. `None` is that failure. The slot
/// is returned 1-based, as the snapshot keys it.
fn container_key(
lua: &Lua,
bag: Value,
slot: Value,
usage: &'static str,
) -> mlua::Result<Option<(i64, u32)>> {
let bag = number_arg(lua, bag, usage)?;
let slot = number_arg(lua, slot, usage)?;
Ok((slot >= 1).then_some((i64::from(bag), slot as u32)))
}
/// The item cooldown query's lookup of a record the mirror lacks, inside the call: the callback
/// `0x6e2f70`, deduped, with the active player as the owner id (`0x6e2eef`-`0x6e2f0d`).
pub(super) fn ask_cooldown_record(lua: &Lua, entry: u32) -> mlua::Result<()> {
let owner = lua
.app_data_ref::<Model>()
.expect("model app_data")
.unit_guids
.player;
super::calls::ask(lua, super::RecordAsk::CooldownItem { entry, owner })
}
/// Register the container verbs as bare globals, as `reference/lua_globals.tsv` lists them;
/// `C_Container` is a later client's namespace, and an addon that finds it assumes that client.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// ContainerIDToInventoryID(containerID) (`0x4f94e0`): `id - 1`, then +20 below 4 else +60, so
// the keyring -2 → 17, the backpack 0 → 19, bags 1-4 → 20-23 and bank bags 5-10 → 64-69, with
// no special case and no range check.
lua.globals().set(
"ContainerIDToInventoryID",
lua.create_function(|lua, id: Value| {
let id = super::binding_abi::number_arg(
lua,
id,
"Usage: ContainerIDToInventoryID(containerID)",
)?;
// The reference's steps and signed compare (`0x4f9524 jl`), wrapping at the `i32` edge.
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`, the reference's spelling): 0 and
// below return before the clear (`0x4fa5b9`), 1-10 clear and then set the bag's icon, 11 and
// up raise.
lua.globals().set(
"SetBagPortaitTexture",
lua.create_function(|lua, (region, container): (Value, Value)| {
// The texture first, the member prologue against the Texture id (`0x4fa4f9`-
// `0x4fa56e`); then `is-number` else `Usage: SetBagPortaitTexture(texture, slot)`
// (`0x84d9c4`, `0x4fa58e`).
let rh = crate::script::region::this_texture(lua, &region)?;
let container = i64::from(binding_abi::number_arg(
lua,
container,
"Usage: SetBagPortaitTexture(texture, slot)",
)?);
if container >= 11 {
return Err(mlua::Error::runtime("Invalid slot in SetBagPortaitTexture"));
}
if container <= 0 {
return Ok(());
}
// `ContainerIDToInventoryID`'s arithmetic, inlined: an addon may replace that global,
// and the reference's 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");
// A shut bank bag is no bag (`0x4fa5d8` into `0x4f93e0`): the portrait stays clear.
usize::try_from(inv)
.ok()
.filter(|_| !model.bank_bag_shut(container))
.and_then(|slot| crate::script::char_stats::own_inv_view(lua, &model, slot))
.and_then(|v| v.icon.clone())
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// An empty slot's `None` is the answer: the reference clears first, then sets.
let set = path.map_or(super::texel::TexSet::None, super::texel::TexSet::Portrait);
model.set_texel(rh, set);
let data = model.region_data.entry(rh).or_default();
data.circular = true;
data.portrait_unit = None;
model.note_art(rh);
Ok(())
})?,
)?;
lua.globals().set(
"GetContainerNumSlots",
lua.create_function(|lua, bag: Value| {
// `is-number` else `Usage: GetContainerNumSlots(index)` (`0x84d83c`, `0x4f9577`).
let bag = i64::from(binding_abi::number_arg(
lua,
bag,
"Usage: GetContainerNumSlots(index)",
)?);
// The backpack, the bank and bags 1-10 only; the keyring and anything else answer 0
// (`0x4f95c4`, `0x4f95de`, `0x4f95fb`).
if !(-1..=10).contains(&bag) {
return Ok(0);
}
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(super::held::bag(lua, &model, bag).map_or(0, |c| c.num_slots))
})?,
)?;
lua.globals().set(
"GetBagName",
lua.create_function(|lua, bag: Value| {
// `is-number` else `Usage: GetBagName(index)` (`0x84da04`, `0x4fa69a`).
let bag = i64::from(binding_abi::number_arg(
lua,
bag,
"Usage: GetBagName(index)",
)?);
// Nil with no active player (`0x4fa6cf`), `BACKPACK_TOOLTIP` for the backpack
// (`0x4fa6f0`), the bag item's name for 1-10, nil for the bank, the keyring and the
// rest (`0x4fa712`, `0x4fa717`).
let name = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match super::held::bag(lua, &model, bag) {
Some(_) if bag == 0 => {
Some(super::glue_core::global_text(lua, "BACKPACK_TOOLTIP"))
}
Some(c) if (1..=10).contains(&bag) => c.name.clone(),
_ => None,
}
};
match name {
Some(n) => Ok(Value::String(lua.create_string(&n)?)),
None => Ok(Value::Nil),
}
})?,
)?;
// GetContainerItemCooldown(bag, slot) (`0x4f99b0`) → start, duration, enable on the `GetTime`
// clock, as `GetActionCooldown`; enable 0 is a held cooldown. No resolved slot or no item in it
// pushes three zeros, the enable included (`0x4f9ae9`), and so does the slot's own answer when
// it is all zero ([`item_cooldown_triple`]). Unlike the worn getter it asks no use-spell test
// of the template (`0x4f9a48`). The query reads the list inside the call (`0x4f9a7f` → `0x6e2ed0`), through
// the lent host for an item object it holds.
lua.globals().set(
"GetContainerItemCooldown",
lua.create_function(|lua, (bag, slot): (Value, Value)| {
let now = crate::script::clock::now(lua);
let key = container_key(
lua,
bag,
slot,
"Usage: GetContainerItemCooldown(index, slot)",
)?;
let (pending, object, snapshot) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let slot = key.and_then(|k| super::held::bag_slot(lua, &model, k.0, k.1));
let pending = slot
.as_ref()
.filter(|s| s.record_pending())
.map(|s| s.item_id);
let object = slot.as_ref().and_then(ContainerSlot::item_key);
let snapshot = slot.as_ref().map(slot_cooldown);
(pending, object, snapshot)
};
let triple = match snapshot {
Some(snapshot) => {
let live = object.and_then(|key| match key {
super::ItemKey::Object(guid) => super::cooldown_read::read_live(
lua,
super::CooldownQuery::ItemObject(guid),
),
super::ItemKey::Record { .. } => None,
});
item_cooldown_triple(live.unwrap_or(snapshot), now)
}
None => (0.0, 0.0, 0),
};
// The query asks a missing record with the callback `0x6e2f70` inside the call
// (`0x6e2efb`).
if let Some(entry) = pending {
ask_cooldown_record(lua, entry)?;
}
Ok(triple)
})?,
)?;
lua.globals().set(
"GetContainerItemLink",
lua.create_function(|lua, (bag, slot): (Value, Value)| {
let Some((bag, slot)) =
container_key(lua, bag, slot, "Usage: GetContainerItemLink(index, slot)")?
else {
return Ok(MultiValue::new());
};
let Some(held) = live_slot(lua, bag, slot) else {
return Ok(MultiValue::new());
};
// The object and its record as the call runs (`0x4f997f` → `0x52ae00`).
let link = read_item_or(
lua,
held.item_key(),
|item| item.link().or_else(|| item.uncached_link()),
|f| Some(f(&held)),
)
.flatten();
// Nothing at all when the resolver fails or finds no item object (`0x4f996b`,
// `0x4f997b`, both to `0x4f99a5`); any object links (`0x4f997f` → `0x52ae00`), with
// no record white and nameless.
match link {
Some(l) => Ok(MultiValue::from_vec(vec![Value::String(
lua.create_string(&l)?,
)])),
None => Ok(MultiValue::new()),
}
})?,
)?;
// GetContainerItemInfo(bag, slot) (`0x4f9670`) → texture, itemCount, locked, quality,
// readable, the five `ContainerFrame.lua:241` reads; no values for an empty or unknown slot.
// The texture is the question mark while the template is unknown.
// `locked` and `readable` are 1 or nil (`0x4f975d`/`0x4f976b`, `0x4f97e6`/`0x4f97d3`).
lua.globals().set(
"GetContainerItemInfo",
lua.create_function(|lua, (bag, slot): (Value, Value)| {
let Some((bag, slot)) =
container_key(lua, bag, slot, "Usage: GetContainerItemInfo(index, slot)")?
else {
// The resolver failed (`0x4f990d`): nothing is pushed (`0x4f9810`).
return Ok(mlua::MultiValue::new());
};
let Some(held) = live_slot(lua, bag, slot) else {
return Ok(mlua::MultiValue::new());
};
let locked = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
cursor::item_locked(&model, held.guid)
};
// The object and its record as the call runs (`0x4f96dc`, `0x4f978b`).
let Some((texture, count, quality, readable)) = read_item_or(
lua,
held.item_key(),
|item| {
(
item.texture(),
// A slot's stack is always its object's or its push's.
item.stack_count().unwrap_or(1),
super::item_stats::gated_quality(item.quality(), item.inventory_type()),
item.readable(),
)
},
|f| Some(f(&held)),
) else {
return Ok(mlua::MultiValue::new());
};
Ok(mlua::MultiValue::from_vec(vec![
// A string on every path: the question mark while the template is unknown
// (`0x5d8af0`).
Value::String(
lua.create_string(
texture
.as_deref()
.unwrap_or(super::item_stats::MISSING_ITEM_ICON),
)?,
),
Value::Integer(i64::from(count)),
// The item's lock bit (`0x4f9752`, [`cursor::item_locked`]).
flag(locked),
// A number on every path: -1 unless the template is cached and equippable
// (`0x4f9794`).
Value::Integer(quality),
flag(readable),
]))
})?,
)?;
lua.globals().set(
"UseContainerItem",
lua.create_function(|lua, (bag, slot, rest): (Value, Value, mlua::MultiValue)| {
// `is-number` on args 1 and 2 else `Usage: UseContainerItem(index, slot[, onSelf])`
// (`0x84d940`, `0x4fa440`).
let bag = i64::from(binding_abi::number_arg(
lua,
bag,
"Usage: UseContainerItem(index, slot[, onSelf])",
)?);
let slot = binding_abi::index_arg32(
lua,
slot,
"Usage: UseContainerItem(index, slot[, onSelf])",
)?;
// `onSelf` through the client's flag coercion (`0x4fa17d`, `0x6f1c10`, default 0).
let on_self = super::binding_abi::bool_or_default(rest.front(), false);
// An unresolved bag ends the verb before its clear (`0x4fa156`, `0x4fa15d`).
let resolves = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
super::held::bag_resolves(lua, &model, bag)
};
if !resolves {
return Ok(());
}
// Every right-click clears the cursor before it reads the slot (`0x4fa198`): a held
// payload goes back, never placed, and an armed gift wrap is cancelled. The clear's
// events run first, as the reference's do before it reads the slot.
super::tick::verb(lua, cursor::clear_cursor);
// The item's on-use spell casts through `TryCastSpell`, behind the gate's tag 2.
let may_cast = protected::gate(lua, Tag::CAST);
let mut call = None;
let vendor = super::merchant::at_call(lua);
let equip = super::tick::verb(lua, |model| {
use_cleared(lua, model, bag, slot, on_self, may_cast, vendor, &mut call)
});
if let Some(call) = call {
super::calls::submit(lua, call)?;
}
if let Some(item) = equip {
use_equip_arm(lua, item)?;
}
Ok(())
})?,
)?;
// PickupContainerItem(bag, slot) (`0x4f9b30`): pick up, or place and swap; no return value on
// any path.
lua.globals().set(
"PickupContainerItem",
lua.create_function(|lua, (bag, slot): (Value, Value)| {
// `is-number` on args 1 and 2 else `Usage: PickupContainerItem(index, slot)`
// (`0x84d8e8`, `0x4f9f56`).
let bag = i64::from(binding_abi::number_arg(
lua,
bag,
"Usage: PickupContainerItem(index, slot)",
)?);
let slot =
binding_abi::index_arg32(lua, slot, "Usage: PickupContainerItem(index, slot)")?;
// An armed item-targeting spell binds the item through `BindSpellTargetObject`, behind
// the gate's tag 2.
let may_cast = protected::gate(lua, Tag::CAST);
// An unresolved bag ends the verb before anything is read (`0x4f9bb6`), and so does
// a vault slot with no banker open (`0x4f9bc6`-`0x4f9bf0`), a held item included.
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
if !super::held::bag_resolves(lua, &model, bag) || model.vault_shut(bag) {
return Ok(());
}
}
let vendor = super::merchant::at_call(lua);
let click = super::tick::verb(lua, |model| {
pickup_container_item(lua, model, bag, slot, may_cast, vendor)
});
match click {
ContainerClick::Done => {}
ContainerClick::Swapped => super::tick::verb(lua, |model| {
cursor::clear(model, cursor::Clear::KEEP_LOCK);
}),
ContainerClick::PickUp(item) => cursor::pick_up_item(lua, item),
ContainerClick::Pick(pick) => {
super::calls::submit(lua, super::ScriptCall::PickItem(pick))?
}
}
Ok(())
})?,
)?;
// ShowContainerSellCursor(bag, slot) (`0x4fa460`): Buy (3) over an occupied, unlocked slot
// (`0x4fa4bf`), with no price check, so selling never greys; an empty or locked slot leaves
// the cursor alone.
lua.globals().set(
"ShowContainerSellCursor",
lua.create_function(|lua, (bag, slot): (Value, Value)| {
// The targeting and base-mode gates run before either argument is read
// (`0x4fa469`-`0x4fa47a`).
if super::merchant::sell_cursor_shut(lua) {
return Ok(());
}
// `is-number` on args 1 and 2 else `Usage: ShowContainerSellCursor(index, slot)`
// (`0x84d970`, `0x4fa4d9`).
let bag = i64::from(binding_abi::number_arg(
lua,
bag,
"Usage: ShowContainerSellCursor(index, slot)",
)?);
let slot =
binding_abi::index_arg32(lua, slot, "Usage: ShowContainerSellCursor(index, slot)")?;
let held = live_slot(lua, bag, slot);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let occupied = held.is_some_and(|s| !cursor::item_locked(&model, s.guid));
if occupied {
model.ui_cursor = Some(UiCursorMode::Buy);
model.ui_cursor_dirty = true;
}
Ok(())
})?,
)?;
// ShowInspectCursor() (`0x48ac60`): Inspect (7), unconditionally.
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() (`0x48ae90`) → the number 1 if the player owns a key (`BagFamily` 9) anywhere, else
// nil, never a boolean; the app does the search.
lua.globals().set(
"HasKey",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(if super::held::has_key(lua, &model) {
Value::Integer(1)
} else {
Value::Nil
})
})?,
)?;
// SetCursor(name) (`0x489490`) returns nothing for nil, after the reset to the base mode
// (`0x489518`); anything `lua_tostring` cannot convert, no argument included, raises its usage
// line (`0x489537`). A number converts to its text. The name matches one of eight pairs
// ignoring case (`0x48955b`), POINT, CAST, BUY and ATTACK as modes 1-4 and each `*_ERROR` 20
// higher, and answers 1 (`0x48958f` → `0x489575`). Any other name is a custom cursor
// (`0x48956c`, [`custom_cursor`]): 1 when it loads, else nil with the cursor unchanged
// (`0x48959c`).
lua.globals().set(
"SetCursor",
lua.create_function(|lua, args: MultiValue| {
let first = args.into_iter().next();
if matches!(first, Some(Value::Nil)) {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = None;
model.ui_cursor_dirty = true;
return Ok(MultiValue::new());
}
let name = first
.as_ref()
.and_then(|v| super::binding_abi::optional_string(lua, v))
.ok_or_else(|| mlua::Error::runtime("Usage: SetCursor(\"cursor\" or nil)"))?;
const NAMED: [(&str, UiCursorMode); 8] = [
("POINT_CURSOR", UiCursorMode::Point),
("CAST_CURSOR", UiCursorMode::Cast),
("BUY_CURSOR", UiCursorMode::Buy),
("ATTACK_CURSOR", UiCursorMode::Attack),
("POINT_ERROR_CURSOR", UiCursorMode::UnablePoint),
("CAST_ERROR_CURSOR", UiCursorMode::CastError),
("BUY_ERROR_CURSOR", UiCursorMode::UnableBuy),
("ATTACK_ERROR_CURSOR", UiCursorMode::UnableAttack),
];
let Some(&(_, mode)) = NAMED.iter().find(|(n, _)| n.eq_ignore_ascii_case(&name)) else {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let loaded = custom_cursor(&mut model, &name);
return Ok(MultiValue::from_vec(vec![if loaded {
Value::Integer(1)
} else {
Value::Nil
}]));
};
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.ui_cursor = Some(mode);
model.ui_cursor_dirty = true;
Ok(MultiValue::from_vec(vec![Value::Integer(1)]))
})?,
)?;
// ResetCursor() (`0x48ac70` → `0x523d30`): back to the base mode, whatever set the override.
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::cursor::split_container_item;
use crate::script::{MerchantItem, MerchantState, UiScript};
/// `SetCursor` (`0x489490`): nil resets and returns nothing, no argument or a value with no
/// text raises the usage line, the eight names match without case and answer 1, and any other
/// name answers nil and leaves the cursor alone.
#[test]
fn set_cursor_takes_the_eight_names_and_raises_its_usage_line() {
let s = UiScript::new().unwrap();
assert_eq!(s.arity("SetCursor(\"attack_cursor\")").unwrap(), 1);
assert_eq!(
s.eval::<i64>("return SetCursor(\"attack_cursor\")")
.unwrap(),
1
);
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Attack));
for (name, mode) in [
("POINT_CURSOR", UiCursorMode::Point),
("Point_Error_Cursor", UiCursorMode::UnablePoint),
("CAST_CURSOR", UiCursorMode::Cast),
("cast_error_cursor", UiCursorMode::CastError),
("BUY_CURSOR", UiCursorMode::Buy),
("BUY_ERROR_CURSOR", UiCursorMode::UnableBuy),
("ATTACK_ERROR_CURSOR", UiCursorMode::UnableAttack),
] {
s.run(&format!("R = SetCursor(\"{name}\")")).unwrap();
assert_eq!(s.eval::<i64>("return R").unwrap(), 1, "{name}");
assert_eq!(s.ui_cursor(), Some(mode), "{name}");
}
// Another name is a custom cursor; this one does not load: nil, the cursor as it was.
assert_eq!(s.arity("SetCursor(\"Nope\")").unwrap(), 1);
assert!(s.eval::<bool>("return SetCursor(\"Nope\") == nil").unwrap());
assert!(s.eval::<bool>("return SetCursor(5) == nil").unwrap());
assert_eq!(s.ui_cursor(), Some(UiCursorMode::UnableAttack));
// Nil is the reset, with no value back.
assert_eq!(s.arity("SetCursor(nil)").unwrap(), 0);
assert_eq!(s.ui_cursor(), None);
for bad in ["SetCursor()", "SetCursor({})", "SetCursor(true)"] {
let err = s.run(bad).unwrap_err().to_string();
assert!(
err.contains("Usage: SetCursor(\"cursor\" or nil)"),
"{bad}: {err}"
);
}
assert_eq!(s.ui_cursor(), None, "a raise changes nothing");
}
/// Any other name is a texture path (`0x523c40` → `0x449610`, no folder put on): a 32×32 one
/// becomes mode 41 under its name and answers 1; a missing one or another size answers nil and
/// leaves the cursor and the name as they were; the shown name again answers 1 at once.
#[test]
fn set_cursor_loads_any_other_name_as_a_32_by_32_texture() {
let mut s = UiScript::new().unwrap();
s.set_cursor_size_probe(Box::new(|path| match path {
"Interface\\AddOns\\Mine\\Sword" | "5" => Some((32, 32)),
"Interface\\Icons\\Big" => Some((64, 64)),
_ => None,
}));
let set = |s: &UiScript, name: &str| {
s.eval::<Option<i64>>(&format!("return SetCursor({name})"))
.unwrap()
};
assert_eq!(set(&s, r#""Interface\\AddOns\\Mine\\Sword""#), Some(1));
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Custom));
assert_eq!(s.custom_cursor_name(), "Interface\\AddOns\\Mine\\Sword");
assert_eq!(s.take_cursor_write(), Some(Some(UiCursorMode::Custom)));
// The shown name, ignoring case, answers 1 without a load.
assert_eq!(set(&s, r#""INTERFACE\\ADDONS\\MINE\\SWORD""#), Some(1));
assert_eq!(s.custom_cursor_name(), "Interface\\AddOns\\Mine\\Sword");
s.run("SetCursor(\"ATTACK_CURSOR\")").unwrap();
for bad in [
r#""Interface\\Icons\\Big""#,
r#""Interface\\Cursor\\Nothing""#,
] {
assert_eq!(set(&s, bad), None, "{bad}");
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Attack), "{bad}");
assert_eq!(s.custom_cursor_name(), "Interface\\AddOns\\Mine\\Sword");
}
// A number is its text (`0x6f36b4`), a path like any other.
assert_eq!(set(&s, "5"), Some(1));
assert_eq!(s.custom_cursor_name(), "5");
}
/// `SetCursor`'s load is the cursor code's (`0x523c86` into `0x449610`), not
/// `TextureCreate`'s: its 32 by 32 test reads the cursor picture's probe, which opens only the
/// `.blp`, never the interface texture's, which would read a `.tga`.
#[test]
fn set_cursor_sizes_the_cursor_picture_not_the_interface_texture() {
let mut s = UiScript::new().unwrap();
s.set_texture_size_probe(Box::new(|_| Some((32, 32))));
s.set_cursor_size_probe(Box::new(|path| (path == "Mine\\Sword").then_some((32, 32))));
assert_eq!(
s.eval::<Option<i64>>(r#"return SetCursor("Mine\\Arrow")"#)
.unwrap(),
None,
"only a .tga: no cursor picture"
);
assert_eq!(s.ui_cursor(), None);
assert_eq!(
s.eval::<Option<i64>>(r#"return SetCursor("Mine\\Sword")"#)
.unwrap(),
Some(1)
);
}
fn backpack() -> ContainerState {
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
uncached_link: None,
use_fork: Default::default(),
item_class: None,
duration_ms: None,
field_duration: 0,
petition: None,
already_bound: false,
occupied_seats: None,
bonding: 0,
use_spell: 0,
bar_placeable: true,
consumable: false,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
count: 5,
quality: Some(1),
inventory_type: 0,
item_id: 117,
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
guid: 0x10a,
equip_slots: Vec::new(),
cooldown: None,
spell_0: false,
spell_charges: [0; 5],
readable: false,
creator: None,
gift_creator: None,
creator_guid: 0,
gift_creator_guid: 0,
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 copy with item text.
slots.insert(
4,
ContainerSlot {
item_id: 8383,
count: 1,
readable: true,
..Default::default()
},
);
ContainerState {
name: None,
num_slots: 16,
slots,
}
}
/// Containers 5-10, the bank bags, are no bag to the container bindings while no banker is
/// open: each resolves the bag through `0x4f93e0`, which tests the banker guid `[0xbdd038]`
/// (`0x4f93e9`-`0x4f93f9`): `GetContainerNumSlots` (`0x4f960b`), the slot resolver behind
/// the item verbs (`0x4f98d1`), `GetBagName` (`0x4fa720`) and `SetBagPortaitTexture`
/// (`0x4fa5d8`). The vault, container -1, keeps its size; its slots are gated one level
/// down ([`the_vault_reads_empty_until_the_bank_opens`]).
#[test]
fn a_bank_bag_is_no_bag_until_the_bank_opens() {
use crate::script::{BankBagSlots, BankState, InvSlotView};
let mut s = UiScript::new().unwrap();
s.keep_calls();
let bag = ContainerState {
name: Some("Linen Bag".into()),
..backpack()
};
s.set_container(5, Some(bag));
s.set_container(-1, Some(backpack()));
let mut bank_bags: BankBagSlots = Default::default();
bank_bags[0] = Some(InvSlotView {
item_id: 4238,
icon: Some("Interface\\Icons\\INV_Misc_Bag_09".into()),
count: 1,
guid: 0x5b,
..Default::default()
});
s.set_bank_bag_slots(bank_bags);
type Read = (
i64,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
);
let read = |s: &UiScript| {
s.eval::<Read>(
r#"local t = CreateFrame("Frame"):CreateTexture()
SetBagPortaitTexture(t, 5)
return GetContainerNumSlots(5), (GetContainerItemInfo(5, 1)),
GetContainerItemLink(5, 1), GetBagName(5), t:GetTexture()"#,
)
.unwrap()
};
assert_eq!(read(&s), (0, None, None, None, None), "the bank is closed");
assert_eq!(
s.eval::<i64>("return GetContainerNumSlots(-1)").unwrap(),
16,
"the vault is not gated"
);
s.run("PickupContainerItem(5, 1)").unwrap();
assert!(s.cursor_payload().is_none(), "no slot to pick up from");
s.run("SplitContainerItem(5, 1, 2)").unwrap();
assert!(s.cursor_payload().is_none(), "no slot to split");
s.run("UseContainerItem(5, 1)").unwrap();
assert!(s.take_container_uses().is_empty(), "no slot to use");
s.set_bank(Some(BankState::default()));
let (slots, texture, link, name, portrait) = read(&s);
assert_eq!(slots, 16);
assert_eq!(
texture.as_deref(),
Some("Interface\\Icons\\INV_Misc_Food_16")
);
assert!(link.is_some_and(|l| l.contains("Tough Jerky")));
assert_eq!(name.as_deref(), Some("Linen Bag"));
assert!(portrait.is_some(), "the portrait is the bag's icon");
s.run("PickupContainerItem(5, 1)").unwrap();
assert!(s.cursor_item().is_some_and(|i| i.bag == 5 && i.slot == 1));
}
/// The vault's slots, container -1, are the player list's 39 to 62, which `GetItemInSlot`
/// (`0x6228a0`) answers empty while no banker is open (`0x6228ad`-`0x6228cf`, the list's
/// bank flag set at the player's construction, `0x5dd44d`). So every container verb reads an
/// empty vault slot then: the getters (`0x4f96d7`, `0x4f9974`), `SplitContainerItem`
/// (`0x4fa01e`), `UseContainerItem` past its clear (`0x4fa1a6`) and
/// `ShowContainerSellCursor` (`0x4fa4b6`); `PickupContainerItem` returns before it reads
/// the cursor (`0x4f9bc6`-`0x4f9bf0`), so a held item is not placed there either.
#[test]
fn the_vault_reads_empty_until_the_bank_opens() {
use crate::script::BankState;
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(-1, Some(backpack()));
let mut pack = backpack();
pack.slots.get_mut(&1).unwrap().guid = 0x20a;
s.set_container(0, Some(pack));
let read = |s: &UiScript| {
s.eval::<(Option<String>, Option<String>)>(
"return (GetContainerItemInfo(-1, 1)), GetContainerItemLink(-1, 1)",
)
.unwrap()
};
assert_eq!(read(&s), (None, None), "the bank is closed");
s.run("PickupContainerItem(-1, 1)").unwrap();
assert!(s.cursor_payload().is_none(), "nothing to pick up");
s.run("SplitContainerItem(-1, 1, 2)").unwrap();
assert!(s.cursor_payload().is_none(), "nothing to split");
s.run("UseContainerItem(-1, 1)").unwrap();
assert!(s.take_container_uses().is_empty(), "nothing to use");
s.run("ShowContainerSellCursor(-1, 1)").unwrap();
assert_eq!(s.model_ref().ui_cursor, None, "nothing to sell");
s.run("PickupContainerItem(0, 1) PickupContainerItem(-1, 2)")
.unwrap();
assert_eq!(s.cursor_item().map(|i| i.guid), Some(0x20a), "still held");
assert!(s.take_container_moves().is_empty(), "nothing placed");
s.run("ClearCursor()").unwrap();
s.set_bank(Some(BankState::default()));
let (texture, link) = read(&s);
assert!(texture.is_some() && link.is_some());
s.run("UseContainerItem(-1, 1)").unwrap();
assert_eq!(s.take_container_uses(), vec![(-1, 1)]);
s.unlock_item(0x10a);
s.run("PickupContainerItem(-1, 1)").unwrap();
assert!(s.cursor_item().is_some_and(|i| i.bag == -1 && i.slot == 1));
}
#[test]
fn an_armed_gift_wrap_spends_on_the_next_container_click() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.arm_gift_wrap(0, 1);
assert_eq!(
s.gift_wrap_armed(),
Some(PendingWrap {
bag: 0,
slot: 1,
guid: 0x10a
})
);
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 leaves the wrap armed.
s.eval::<()>("PickupContainerItem(0, 9)").unwrap();
assert_eq!(
s.gift_wrap_armed(),
Some(PendingWrap {
bag: 0,
slot: 1,
guid: 0x10a
})
);
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"
);
assert!(
!s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap(),
"the paper unlocks on the send (`0x5ededd`)"
);
}
/// `ClearCursor` (`0x495190`) tests for an armed wrap first, whatever its arguments.
#[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"
);
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();
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);
s.run("BACKPACK_TOOLTIP = 'Rucksack'").unwrap();
assert_eq!(
s.eval::<String>("return GetBagName(0)").unwrap(),
"Rucksack",
"the backpack is named by its global, not by the snapshot (`0x4fa6f0`)"
);
// The five values `ContainerFrame.lua:241` reads, 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");
// Food has InventoryType 0, so its quality is the -1 sentinel (`0x4f9794`).
assert_eq!((count, quality), (5, -1));
assert_eq!(
s.eval::<i64>(
"local _, _, id = string.find(GetContainerItemLink(0, 1), 'item:(%d+)') \
return tonumber(id)"
)
.unwrap(),
117
);
// readable: 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());
// An in-flight slot: the question mark, the -1 quality sentinel, itemCount real.
assert!(s
.eval::<bool>(
"local texture, itemCount, locked, quality = GetContainerItemInfo(0, 3)\n\
return texture == 'Interface\\\\Icons\\\\INV_Misc_QuestionMark' \
and quality == -1 and itemCount ~= nil",
)
.unwrap());
assert!(s
.eval::<bool>("return GetContainerItemLink(0, 3) == nil")
.unwrap());
assert!(s
.eval::<bool>("return GetContainerItemInfo(0, 2) == nil")
.unwrap());
}
/// A bag item whose record the cache lacks still links: `GetContainerItemLink` pushes nothing
/// only when it finds no item object (`0x4f996b`, `0x4f997b` → `0x4f99a5`), and `0x52ae00`
/// links any object (`0x4f997f`), white and nameless without a record (`0x52aec5`, `0x5d8b40`).
#[test]
fn an_uncached_bag_item_links_white_and_nameless() {
let mut s = UiScript::new().unwrap();
let mut bag = backpack();
let nameless = "|cffffffff|Hitem:2589:0:0:0|h[]|h|r";
bag.slots.insert(
3,
ContainerSlot {
item_id: 2589,
count: 1,
uncached_link: Some(nameless.into()),
..Default::default()
},
);
s.set_container(0, Some(bag));
assert_eq!(
s.eval::<Option<String>>("return GetContainerItemLink(0, 3)")
.unwrap()
.as_deref(),
Some(nameless)
);
assert_eq!(
s.arity("GetContainerItemLink(0, 2)").unwrap(),
0,
"no object"
);
}
/// The keyring, the bank and an out-of-range id answer no slots and no name, however the
/// snapshot holds them (`0x4f95fb`, `0x4fa712`); the keyring is sized by `GetKeyRingSize`.
#[test]
fn keyring_and_bank_answer_no_slots_or_name() {
let mut s = UiScript::new().unwrap();
let named = |name: Option<&str>, num_slots| ContainerState {
name: name.map(Into::into),
num_slots,
slots: std::collections::HashMap::new(),
};
s.set_container(-2, Some(named(Some("Keyring"), 8)));
s.set_container(-1, Some(named(Some("Bank"), 24)));
s.set_container(4, Some(named(Some("Linen Bag"), 6)));
s.set_container(11, Some(named(Some("Stray"), 6)));
let slots = |bag: i64| {
s.eval::<i64>(&format!("return GetContainerNumSlots({bag})"))
.unwrap()
};
assert_eq!(slots(-2), 0, "the keyring is no container to this verb");
assert_eq!(slots(-1), 24);
assert_eq!(slots(4), 6);
assert_eq!(slots(11), 0);
for bag in [-2, -1, 11] {
assert!(
s.eval::<bool>(&format!("return GetBagName({bag}) == nil"))
.unwrap(),
"GetBagName({bag})"
);
}
assert_eq!(
s.eval::<String>("return GetBagName(4)").unwrap(),
"Linen Bag"
);
assert!(
s.eval::<bool>("return GetBagName(0) == nil").unwrap(),
"no backpack, no active player"
);
}
/// The fourth value is the template's quality only when the template is cached and its
/// InventoryType is nonzero (`0x4f9794`, `0x4f979b`), else the number -1 (`0x4f97a6`): a potion,
/// a reagent or conjured food answers -1 with its template in hand, as does an item whose
/// template has not arrived. Never nil.
#[test]
fn container_item_info_quality_is_gated_on_the_inventory_type() {
let mut s = UiScript::new().unwrap();
let slot = |quality: Option<u32>, inventory_type: u32| ContainerSlot {
count: 1,
quality,
inventory_type,
item_id: 25,
..Default::default()
};
let mut slots = std::collections::HashMap::new();
// An equippable epic, two InventoryType 0 items of quality 1 and 3, and an item whose
// template is still in flight.
slots.insert(1, slot(Some(4), 13));
slots.insert(2, slot(Some(1), 0));
slots.insert(3, slot(Some(3), 0));
slots.insert(4, slot(None, 0));
s.set_container(
0,
Some(ContainerState {
slots,
..Default::default()
}),
);
for (index, want) in [
(1, "number:4"),
(2, "number:-1"),
(3, "number:-1"),
(4, "number:-1"),
] {
assert_eq!(
s.eval::<String>(&format!(
"local c = function(...) return arg.n .. ':' .. type(arg[4]) .. ':' .. \
tostring(arg[4]) end return c(GetContainerItemInfo(0, {index}))"
))
.unwrap(),
format!("5:{want}"),
"slot {index}"
);
}
}
/// The texture is a string on every path: while the template is unknown, `0x5d8af0` falls back
/// to `Interface\Icons\INV_Misc_QuestionMark` (`0x5d88b0`, `0x847fe4`), not nil.
#[test]
fn container_item_info_texture_falls_back_to_the_question_mark() {
let mut s = UiScript::new().unwrap();
let mut slots = std::collections::HashMap::new();
slots.insert(
1,
ContainerSlot {
count: 1,
item_id: 25,
..Default::default()
},
);
slots.insert(
2,
ContainerSlot {
count: 1,
item_id: 26,
texture: Some("Interface\\Icons\\INV_Sword_01".into()),
..Default::default()
},
);
s.set_container(
0,
Some(ContainerState {
slots,
..Default::default()
}),
);
for (index, want) in [
(1, "string:Interface\\Icons\\INV_Misc_QuestionMark"),
(2, "string:Interface\\Icons\\INV_Sword_01"),
] {
assert_eq!(
s.eval::<String>(&format!(
"local c = function(...) return type(arg[1]) .. ':' .. tostring(arg[1]) end \
return c(GetContainerItemInfo(0, {index}))"
))
.unwrap(),
want,
"slot {index}"
);
}
}
/// `GetContainerItemInfo` (`0x4f9670`) pushes `locked` and `readable` as the number 1
/// (`0x4f975d`, `0x4f97e6`) or nil (`0x4f976b`, `0x4f97d3`), five values in all.
#[test]
fn container_item_info_flags_are_one_or_nil() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
// `arg.n` counts the returns (`select` is 5.1's, not a 1.12 global).
let shape = |s: &mut UiScript, slot: u32| {
s.eval::<String>(&format!(
"local function c(...) return arg.n .. ':' .. type(arg[3]) .. ':' .. tostring(arg[3]) \
.. ':' .. type(arg[5]) .. ':' .. tostring(arg[5]) end \
return c(GetContainerItemInfo(0, {slot}))"
))
.unwrap()
};
assert_eq!(shape(&mut s, 1), "5:nil:nil:nil:nil", "a plain item");
assert_eq!(
shape(&mut s, 4),
"5:nil:nil:number:1",
"the readable letter"
);
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(
shape(&mut s, 1),
"5:number:1:nil:nil",
"the held item reads locked"
);
}
/// The item's on-use spell casts through `TryCastSpell`, behind the gate's tag 2 (`0x4fa430` →
/// `0x5d8d00` → `0x5d9258` → `0x6e5a90` → `0x6e4bc1`). The call keeps its other arms, so it is
/// queued whatever the gate said, carrying the answer; an armed item-targeting spell's pick
/// carries it the same way (`0x4f9c54` → `0x495d60` → `0x6e5b9e`).
#[test]
fn container_uses_and_picks_carry_what_the_gate_allowed() {
let may_cast = |s: &mut UiScript| -> Vec<bool> {
s.take_script_calls()
.into_iter()
.map(|c| c.may_cast())
.collect()
};
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.run("UseContainerItem(0, 1)").unwrap();
s.run(r#"RunScript("UseContainerItem(0, 1)")"#).unwrap();
s.run_binding(r#"RunScript("UseContainerItem(0, 1)")"#)
.unwrap();
assert_eq!(
may_cast(&mut s),
[true, false, true],
"the client's own, a macro outside an input event, a macro inside a key's body"
);
s.set_item_pick_armed(true);
s.run("PickupContainerItem(0, 1)").unwrap();
s.run(r#"RunScript("PickupContainerItem(0, 1)")"#).unwrap();
s.run_binding(r#"RunScript("PickupContainerItem(0, 1)")"#)
.unwrap();
assert_eq!(
s.take_script_calls()
.into_iter()
.map(|c| match c {
super::super::ScriptCall::PickItem(p) => (p.bag, p.slot, p.may_cast),
other => panic!("not a pick: {other:?}"),
})
.collect::<Vec<_>>(),
[(0, 1, true), (0, 1, false), (0, 1, true)]
);
}
#[test]
fn use_container_item_queues_intents() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.run("UseContainerItem(0, 1)").unwrap();
s.run("UseContainerItem(0, 4, 'target')").unwrap();
assert_eq!(s.take_container_uses(), vec![(0, 1), (0, 4)]);
assert!(s.take_container_uses().is_empty(), "drained");
}
/// The arm is `0x4fa0e0`'s, in its order, and its lock is taken in the call: a vault item
/// withdraws (the bank flag, `0x4f98b3`), read only while a banker is open (`0x6228a0`), then
/// an open banker deposits ahead of an open vendor's sale; an item with no lock-taking fork (a
/// plain use) leaves it unlocked.
#[test]
fn use_container_item_takes_its_arm_and_the_arms_lock_in_the_call() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
let arm = |s: &mut UiScript, bag: i64| {
s.run(&format!("UseContainerItem({bag}, 1)")).unwrap();
let calls = s.take_script_calls();
let [crate::script::ScriptCall::UseContainerItem(u)] = calls.as_slice() else {
panic!("{calls:?}");
};
let locked = s.item_locked(u.guid);
s.unlock_item(u.guid);
(u.arm, locked)
};
s.set_container(0, Some(backpack()));
s.set_container(-1, Some(backpack()));
assert_eq!(
arm(&mut s, 0),
(super::UseArm::Item(super::UseFork::Plain), false)
);
s.set_merchant(Some(super::super::MerchantState::default()));
assert_eq!(arm(&mut s, 0), (super::UseArm::Sell, true));
s.set_bank(Some(super::super::BankState::default()));
assert_eq!(
arm(&mut s, 0),
(super::UseArm::Deposit, true),
"the banker first"
);
assert_eq!(arm(&mut s, -1), (super::UseArm::Withdraw, true));
}
/// One backpack slot of `fork`, its item entry 871.
fn forked_bag(fork: super::UseFork) -> ContainerState {
ContainerState {
name: None,
num_slots: 16,
slots: [(
1,
ContainerSlot {
guid: 0x11,
item_id: 871,
count: 1,
item_class: Some(0),
// Cached, as the equip fork reads the record.
quality: Some(1),
use_fork: fork,
equip_slots: vec![1],
..Default::default()
},
)]
.into(),
}
}
/// The equip arm returns without control of the character, before it locks or sends
/// (`0x4fa3ce`-`0x4fa3ec`), unless the player is on a taxi.
#[test]
fn the_equip_arm_needs_control_or_a_taxi() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(forked_bag(super::UseFork::Equip)));
s.set_player_control(false);
s.run("UseContainerItem(0, 1)").unwrap();
assert!(s.take_script_calls().is_empty(), "no send");
assert!(!s.item_locked(0x11), "no lock");
assert!(s.cursor_item().is_none(), "nothing set on the cursor");
s.set_on_taxi(true);
s.run("UseContainerItem(0, 1)").unwrap();
assert_eq!(s.take_script_calls().len(), 1, "a taxi passenger equips");
assert!(s.item_locked(0x11));
s.unlock_item(0x11);
s.set_on_taxi(false);
s.set_player_control(true);
s.run("UseContainerItem(0, 1)").unwrap();
assert_eq!(s.take_script_calls().len(), 1, "and so does a free player");
}
/// `PickupContainerItem` ends before it reads the cursor without control of the character,
/// unless the player is on a taxi (`0x4f9b87`-`0x4f9ba3`): no pickup, and a held item is
/// neither placed nor cancelled.
#[test]
fn pickup_container_item_needs_control_or_a_taxi() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(forked_bag(super::UseFork::Plain)));
s.set_player_control(false);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_none(), "no pickup");
assert!(!s.item_locked(0x11), "no lock");
s.set_on_taxi(true);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "a taxi passenger picks up");
s.set_on_taxi(false);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "the cancel is refused too");
assert!(s.take_container_moves().is_empty());
}
/// The dispatcher's requirement check (`0x5d8e0e`) and control gate (`0x5d8eaa`, error
/// `0x7f`) end the call before the open arm's lock (`0x5edcd9`), each with its line, the
/// requirement first.
#[test]
fn the_open_arm_runs_the_requirement_and_control_gates_before_its_lock() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(forked_bag(super::UseFork::Open)));
s.set_player_control(false);
s.run("UseContainerItem(0, 1)").unwrap();
assert_eq!(s.take_ui_errors(), vec!["ERR_CLIENT_LOCKED_OUT"]);
assert!(s.take_script_calls().is_empty());
assert!(!s.item_locked(0x11));
// A level-60 item for a level-10 player: `ERR_CANT_USE_ITEM` (`0x9c`), ahead of the
// control gate.
s.set_player_req_state(crate::script::PlayerReqState {
level: 10,
class_id: 1,
race_id: 1,
..Default::default()
});
let template = |class, subclass, required_level| crate::script::ItemTemplateView {
class,
subclass,
required_level,
allowable_class: -1,
allowable_race: -1,
..Default::default()
};
s.set_item_template(871, template(0, 0, 60));
s.run("UseContainerItem(0, 1)").unwrap();
assert_eq!(s.take_ui_errors(), vec!["ERR_CANT_USE_ITEM"]);
assert!(!s.item_locked(0x11));
// Control back, the requirement met: the open goes out, for the host to lock past its
// `SetAutoLoot` (`0x5edcb6`, `0x5edcd9`).
s.set_player_control(true);
s.set_item_template(871, template(0, 0, 5));
s.run("UseContainerItem(0, 1)").unwrap();
assert!(s.take_ui_errors().is_empty());
assert_eq!(s.take_script_calls().len(), 1);
assert!(!s.item_locked(0x11));
}
/// The dispatcher's order (`0x5d8d00`): the gift, wrapping paper and quest-starter arms run
/// before any gate (`0x5d8d92`, `0x5d8dcc`), the requirement check (`0x5d8e0e`) guards the
/// read, letter, open and plain arms, and the control gate (`0x5d8eaa`) only the open and
/// plain ones.
#[test]
fn each_gate_guards_the_arms_the_dispatcher_runs_after_it() {
use super::UseFork::{Equip, Letter, Open, Plain, Quest, Read, Unwrap, WrapPaper};
let refusals = |fork, level_ok: bool, control: bool| {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(forked_bag(fork)));
s.set_player_control(control);
s.set_player_req_state(crate::script::PlayerReqState {
level: 10,
class_id: 1,
race_id: 1,
..Default::default()
});
s.set_item_template(
871,
crate::script::ItemTemplateView {
required_level: if level_ok { 5 } else { 60 },
allowable_class: -1,
allowable_race: -1,
..Default::default()
},
);
s.run("UseContainerItem(0, 1)").unwrap();
s.take_ui_errors()
};
for fork in [Read, Letter, Open, Plain] {
assert_eq!(
refusals(fork, false, true),
vec!["ERR_CANT_USE_ITEM"],
"{fork:?}: the requirement"
);
}
for fork in [Unwrap, WrapPaper, Quest, Equip] {
assert!(
refusals(fork, false, true).is_empty(),
"{fork:?}: ahead of the requirement"
);
}
for fork in [Read, Letter] {
assert!(
refusals(fork, true, false).is_empty(),
"{fork:?}: ahead of the control gate"
);
}
for fork in [Open, Plain] {
assert_eq!(
refusals(fork, true, false),
vec!["ERR_CLIENT_LOCKED_OUT"],
"{fork:?}: the control gate"
);
}
}
/// Without control the item goes only if its on-use spell is one the app fed as passing
/// (`0x5d8ec0`-`0x5d8f6a`); an item with no on-use spell is refused (`0x5d8ee7`), and an
/// uncached template ends the use silently (`0x5d8d5c`).
#[test]
fn a_bypass_spell_passes_the_control_gate_and_a_plain_item_does_not() {
let mut slot = forked_bag(super::UseFork::Plain);
slot.slots.get_mut(&1).unwrap().use_spell = 7744;
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(slot.clone()));
s.set_player_control(false);
s.run("UseContainerItem(0, 1)").unwrap();
assert_eq!(s.take_ui_errors(), vec!["ERR_CLIENT_LOCKED_OUT"]);
assert!(s.take_script_calls().is_empty());
s.set_control_bypass([7744].into());
s.run("UseContainerItem(0, 1)").unwrap();
assert!(s.take_ui_errors().is_empty());
assert_eq!(s.take_script_calls().len(), 1, "the trinket goes");
slot.slots.get_mut(&1).unwrap().use_spell = 0;
s.set_container(0, Some(slot.clone()));
s.run("UseContainerItem(0, 1)").unwrap();
assert_eq!(
s.take_ui_errors(),
vec!["ERR_CLIENT_LOCKED_OUT"],
"no spell"
);
slot.slots.get_mut(&1).unwrap().item_class = None;
s.set_container(0, Some(slot));
s.run("UseContainerItem(0, 1)").unwrap();
assert!(s.take_ui_errors().is_empty(), "no cached template: silent");
}
/// The proficiency leg's own line is `ERR_PROFICIENCY_NEEDED` (`0x7`, `0x5ea9b3`).
#[test]
fn a_proficiency_refusal_names_its_own_line() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(forked_bag(super::UseFork::Open)));
s.set_player_req_state(crate::script::PlayerReqState {
level: 10,
class_id: 1,
race_id: 1,
proficiency: [(4, 0b10)].into(),
..Default::default()
});
s.set_item_template(
871,
crate::script::ItemTemplateView {
class: 4,
subclass: 0,
allowable_class: -1,
allowable_race: -1,
..Default::default()
},
);
s.run("UseContainerItem(0, 1)").unwrap();
assert_eq!(s.take_ui_errors(), vec!["ERR_PROFICIENCY_NEEDED"]);
assert!(!s.item_locked(0x11));
}
/// An empty slot or a locked item ends `UseContainerItem` past its clear (`0x4fa1c7`,
/// `0x4fa1cd`), repair mode included: nothing is used or repaired.
#[test]
fn use_container_item_skips_an_empty_slot_and_a_locked_item() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.lock_item(0x10a); // slot 1, a send's lock
s.run("UseContainerItem(0, 1) UseContainerItem(0, 3)")
.unwrap();
assert!(s.take_container_uses().is_empty(), "locked, then empty");
s.model_mut().repair_mode = true;
s.run("UseContainerItem(0, 1)").unwrap();
assert!(
s.take_container_repairs().is_empty(),
"a locked item is not repaired"
);
s.unlock_item(0x10a);
s.model_mut().repair_mode = false;
s.run("UseContainerItem(0, 1)").unwrap();
assert_eq!(s.take_container_uses(), vec![(0, 1)]);
}
/// A right-click clears the cursor before it reads the slot (`0x4fa198`): a held item goes
/// back to its slot, unlocked, and the clicked item is still used.
#[test]
fn right_click_puts_a_held_item_back_then_uses_the_clicked_one() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some());
s.run("UseContainerItem(0, 4)").unwrap();
assert!(s.cursor_payload().is_none(), "the held item went back");
assert!(
!s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap(),
"its slot unlocks"
);
assert!(s.take_container_moves().is_empty(), "never placed");
assert_eq!(s.take_container_uses(), vec![(0, 4)]);
}
/// The same clear cancels an armed gift wrap (`0x495190` opens with `0x5edf10`), and the
/// clicked item is used, not wrapped.
#[test]
fn right_click_cancels_an_armed_gift_wrap_then_uses_the_clicked_one() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.arm_gift_wrap(0, 1);
s.run("UseContainerItem(0, 4)").unwrap();
assert_eq!(s.gift_wrap_armed(), None);
assert!(s.take_container_wraps().is_empty(), "nothing wrapped");
assert_eq!(s.take_container_uses(), vec![(0, 4)]);
}
#[test]
fn pickup_then_place_swaps_and_clears_cursor() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
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());
s.run("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());
assert_eq!(s.cursor_item().map(|c| c.item_id), Some(117));
s.run("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,
}]
);
assert!(
s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap(),
"the send's clear keeps the source locked for the server's answer"
);
}
/// The reference's place runs no `SetCursorItem`: the displaced item is never held.
#[test]
fn pickup_place_onto_occupied_different_item_swaps_and_clears() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
let mut state = backpack(); // slot 1 = item 117 (A)
state.slots.insert(
5,
ContainerSlot {
uncached_link: None,
use_fork: Default::default(),
item_class: None,
duration_ms: None,
field_duration: 0,
petition: None,
already_bound: false,
occupied_seats: None,
bonding: 0,
use_spell: 0,
bar_placeable: true,
consumable: false,
durability: None,
texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()),
count: 1,
quality: Some(2),
inventory_type: 0,
item_id: 200, // item B, a different id
link: Some("|Hitem:200|h[Shiny Gem]|h".into()),
guid: 0x10b,
equip_slots: Vec::new(),
cooldown: None,
spell_0: false,
spell_charges: [0; 5],
readable: false,
creator: None,
gift_creator: None,
creator_guid: 0,
gift_creator_guid: 0,
flags: 0,
enchants: Vec::new(),
},
);
s.set_container(0, Some(state));
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.cursor_item().unwrap().item_id, 117);
s.run("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,
}]
);
assert!(
s.eval::<bool>("local _, _, locked = GetContainerItemInfo(0, 1) return locked")
.unwrap(),
"the send's clear keeps the source locked for the server's answer"
);
}
/// A same-item placement is a plain move: the server merges the stacks.
#[test]
fn pickup_place_onto_same_item_merges_and_clears() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
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();
s.run("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,
}]
);
}
/// A whole stack placed onto a locked occupant is refused, held and unsent (`0x4f9e76`); a
/// split onto the same stack goes (`0x4f9e1c` reads no lock).
#[test]
fn a_whole_stack_onto_a_locked_occupant_is_refused_and_a_split_is_not() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
let mut state = backpack(); // slot 1: a 5-stack of 117, guid 0x10a
state.slots.insert(
5,
ContainerSlot {
item_id: 117,
count: 2,
guid: 0x55,
..Default::default()
},
);
s.set_container(0, Some(state));
s.lock_item(0x55);
s.run("PickupContainerItem(0, 1) PickupContainerItem(0, 5)")
.unwrap();
assert!(s.cursor_item().is_some(), "kept");
assert!(s.take_container_moves().is_empty());
s.run("ClearCursor() SplitContainerItem(0, 1, 2) PickupContainerItem(0, 5)")
.unwrap();
assert!(s.cursor_item().is_none());
assert_eq!(s.take_container_moves().len(), 1, "the split merges");
}
#[test]
fn pickup_refuses_a_locked_slot() {
let mut s = UiScript::new().unwrap();
let state = backpack();
let guid = state.slots[&1].guid;
s.set_container(0, Some(state));
s.lock_item(guid);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_none());
}
/// `SplitContainerItem` ends first without control, unless the player is on a taxi
/// (`0x4f9fdb`-`0x4f9ff7`).
#[test]
fn split_container_item_needs_control_or_a_taxi() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.set_player_control(false);
assert!(!split_container_item(&s.lua, 0, 1, 3).unwrap());
assert!(s.cursor_item().is_none());
assert!(!s.item_locked(0x10a), "no lock");
s.set_on_taxi(true);
assert!(split_container_item(&s.lua, 0, 1, 3).unwrap());
assert_eq!(s.cursor_item().and_then(|i| i.count), Some(3));
}
#[test]
fn split_container_item_carries_a_partial_stack() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
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!(split_container_item(&s.lua, 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))
);
s.run("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),
}]
);
// The source stays locked until the server's answer, its stack-count write (`0x5d9400`).
assert!(
!split_container_item(&s.lua, 0, 1, 3).unwrap(),
"locked for the answer"
);
s.unlock_item(0x10a);
s.run("SplitContainerItem(0, 1, 3)").unwrap();
// Onto a different item the split still goes and the cursor clears (`0x4f9e1c` →
// `0x5e1210`, `0x4f9e4f`).
s.run("PickupContainerItem(0, 9)").unwrap();
assert!(s.cursor_item().is_none(), "sent and cleared");
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 9,
count: Some(3),
}]
);
s.unlock_item(0x10a);
s.run("SplitContainerItem(0, 1, 3)").unwrap();
s.run("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_and_an_oversized_count_is_refused() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack())); // slot 1: a 5-stack
assert!(split_container_item(&s.lua, 0, 1, 5).unwrap());
assert_eq!(s.cursor_item().unwrap().count, None);
// A count above the stack, or below 1, is refused before the clear (`0x4fa06f`,
// `0x4fa064`): a held macro stays held.
s.run("ClearCursor()").unwrap();
let macro_payload =
crate::script::cursor::CursorPayload::Macro(crate::script::cursor::CursorMacro {
id: 1,
texture: None,
});
s.set_cursor_for_test(macro_payload.clone());
for count in [6, 99, 0, -1] {
assert!(
!split_container_item(&s.lua, 0, 1, count).unwrap(),
"{count}"
);
}
assert_eq!(s.cursor_payload(), Some(macro_payload));
// Past the gates the split stops spell targeting (`0x4fa07d`).
s.run("ClearCursor()").unwrap();
s.set_spell_targeting(true);
assert!(split_container_item(&s.lua, 0, 1, 2).unwrap());
assert!(s.take_stop_targeting());
}
#[test]
fn pickup_same_slot_cancels_no_move() {
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("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 and slot 3 unresolved: neither can be picked up.
s.run("PickupContainerItem(0, 2)").unwrap();
assert!(s.cursor_item().is_none());
s.run("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());
}
/// A locked item shows no sell cursor (`0x4fa4bf`).
#[test]
fn a_locked_item_shows_no_sell_cursor() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.lock_item(0x10a);
s.run("ShowContainerSellCursor(0, 1)").unwrap();
assert_eq!(s.ui_cursor(), None);
s.unlock_item(0x10a);
s.run("ShowContainerSellCursor(0, 1)").unwrap();
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy));
}
#[test]
fn repair_cursor_wins_over_a_sellable_bag_hover() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.set_merchant(Some(MerchantState {
can_repair: true,
..Default::default()
}));
s.run("ShowContainerSellCursor(0, 1)").unwrap();
assert_eq!(s.ui_cursor(), Some(UiCursorMode::Buy));
s.run("ShowRepairCursor()").unwrap();
assert_eq!(
s.ui_cursor(),
None,
"repair replaces the existing Buy hover"
);
s.run("ShowContainerSellCursor(0, 1)").unwrap();
assert_eq!(
s.ui_cursor(),
None,
"a bag hover keeps the repair base cursor"
);
s.run("PickupContainerItem(0, 1)").unwrap();
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(
s.take_container_repairs(),
vec![(0, 1), (0, 1)],
"repair mode sticks across clicks"
);
}
#[test]
fn right_click_repairs_bag_item_instead_of_selling_it() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.set_merchant(Some(MerchantState {
can_repair: true,
..Default::default()
}));
s.run("ShowRepairCursor() UseContainerItem(0, 1) UseContainerItem(0, 1)")
.unwrap();
assert_eq!(s.take_container_repairs(), vec![(0, 1), (0, 1)]);
assert!(s.take_container_uses().is_empty(), "no sell/use intent");
}
#[test]
fn right_click_in_repair_mode_puts_a_held_item_back_then_repairs() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.set_merchant(Some(MerchantState {
can_repair: true,
..Default::default()
}));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some());
// A held item and repair mode together, as after a cursor-mode change.
s.model_mut().repair_mode = true;
s.run("UseContainerItem(0, 4)").unwrap();
assert!(
s.cursor_payload().is_none(),
"the held item went back (`0x4fa198`)"
);
assert!(s.take_container_moves().is_empty(), "never placed");
assert_eq!(s.take_container_repairs(), vec![(0, 4)]);
s.model_mut().repair_mode = false;
s.run("PickupContainerItem(0, 1)").unwrap();
s.model_mut().repair_mode = true;
s.run("UseContainerItem(0, 2)").unwrap();
assert!(
s.cursor_payload().is_none(),
"an empty slot still clears the cursor"
);
assert!(
s.take_container_repairs().is_empty(),
"and repairs nothing (`0x4fa1a6`)"
);
}
#[test]
fn held_vendor_row_buys_into_bag_before_repair_mode() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.set_merchant(Some(MerchantState {
items: vec![MerchantItem {
item_id: 159,
..Default::default()
}],
can_repair: true,
..Default::default()
}));
s.run("ShowRepairCursor() PickupMerchantItem(1)").unwrap();
assert!(
s.repair_mode(),
"vendor grab preserves the repair base mode"
);
assert!(s.cursor_payload().is_some());
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.take_merchant_slot_buys(), vec![(0, 1, 159)]);
assert!(s.cursor_payload().is_none());
assert!(s.take_container_repairs().is_empty());
assert!(s.take_sounds().is_empty());
// The right-click clears the vendor row with no packet and repairs instead.
s.run("PickupMerchantItem(1) UseContainerItem(0, 1)")
.unwrap();
assert!(s.take_merchant_slot_buys().is_empty());
assert!(s.cursor_payload().is_none());
assert_eq!(s.take_container_repairs(), vec![(0, 1)]);
assert!(s.take_container_uses().is_empty());
}
/// The slot buy reads the held row's item from the list as it stands (`0x4f9f35`): a row
/// whose item changed since the pickup buys what it now names, and a row the list no longer
/// holds buys nothing and drops.
#[test]
fn a_held_vendor_row_buys_into_a_slot_what_the_row_now_names() {
let vendor = |item_id: Option<u32>| MerchantState {
items: item_id
.map(|item_id| MerchantItem {
item_id,
..Default::default()
})
.into_iter()
.collect(),
..Default::default()
};
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
for (now, bought) in [(Some(2589), vec![(0, 1, 2589)]), (None, vec![])] {
s.set_merchant(Some(vendor(Some(159))));
s.run("PickupMerchantItem(1)").unwrap();
s.set_merchant(Some(vendor(now)));
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.take_merchant_slot_buys(), bought, "{now:?}");
assert!(s.cursor_payload().is_none(), "{now:?}: the row drops");
}
}
#[test]
fn held_item_places_before_repair_mode() {
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some());
// A held item and repair base mode can coexist after a cursor-mode change.
s.model_mut().repair_mode = true;
s.run("PickupContainerItem(0, 2)").unwrap();
assert_eq!(
s.take_container_moves(),
vec![ContainerMove {
src_bag: 0,
src_slot: 1,
dst_bag: 0,
dst_slot: 2,
count: None,
}]
);
assert!(s.cursor_payload().is_none());
assert!(s.take_container_repairs().is_empty());
assert!(s.take_sounds().is_empty());
}
#[test]
fn repair_mode_repairs_under_held_coins_and_keeps_them() {
use crate::script::cursor::{CursorMoney, CursorPayload};
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.model_mut().repair_mode = true;
let coins = CursorPayload::Money(CursorMoney { copper: 50 });
s.model_mut().cursor = Some(coins.clone());
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(
s.take_container_repairs(),
vec![(0, 1)],
"coins pass both payload tests (`0x4f9c38`, `0x4f9c43`)"
);
assert_eq!(s.cursor_payload(), Some(coins), "and stay held");
s.run("PickupContainerItem(0, 2)").unwrap();
assert!(
s.take_container_repairs().is_empty(),
"an empty slot does nothing (`0x4f9c4e`)"
);
}
#[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);
}
#[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));
// Slot 4's on-use spell is cold: the query answers enabled.
state.slots.get_mut(&4).unwrap().cooldown = Some((0, 0, true));
s.set_container(0, Some(state));
assert!(s
.eval::<bool>(
"local s, d, e = GetContainerItemCooldown(0, 1)\n\
return s == 96 and d == 10 and e == 1"
)
.unwrap());
assert!(s
.eval::<bool>(
"local s, d, e = GetContainerItemCooldown(0, 4)\n\
return s == 0 and d == 0 and e == 1"
)
.unwrap());
// An item the query answered cold (`Some`) reads enabled; one it left at its zeros
// (`None`: no on-use spell, a spent charged template) reads enable 0 (`0x6e2f34`).
assert!(s
.eval::<bool>(
"local s, d, e = GetContainerItemCooldown(0, 3)\n\
return s == 0 and d == 0 and e == 0"
)
.unwrap());
// Past expiry, the same stored triple reads cold.
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` (`0x4f94e0`) ─────────────────────────────────────
#[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: the keyring, -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 type test's error `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}"
);
}
}
/// A bag click over a payload that is neither an item nor a held row picks the item up, its
/// setter's clear dropping the payload (`0x4f9de9`); an item off the bar (mode 7) is only
/// cleared (`0x4f9f41`); a vendor row with no vendor open stays held (`0x4f9f0f`).
#[test]
fn a_bag_click_over_another_payload_picks_up_or_clears() {
use crate::script::cursor::{CursorAction, CursorMerchantItem, CursorPayload, CursorSpell};
let mut s = UiScript::new().unwrap();
s.keep_calls();
s.set_container(0, Some(backpack()));
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
spell_id: 133,
texture: None,
passive: false,
}));
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.cursor_item().map(|i| i.slot), Some(1));
s.run("ClearCursor()").unwrap();
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 1,
kind: 0x80,
action: 4540,
texture: None,
count: 0,
consumable: false,
state: None,
}));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_payload().is_none(), "the bar item is cleared");
let row = CursorPayload::Merchant(CursorMerchantItem {
item_id: 159,
row: 0,
texture: None,
});
s.set_cursor_for_test(row.clone());
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.cursor_payload(), Some(row), "no vendor: kept");
assert!(s.take_merchant_slot_buys().is_empty());
}
/// A held bag item answers the call's own now, whatever was pushed (`0x6e1517`).
#[test]
fn a_held_container_item_answers_the_polls_now() {
let mut s = UiScript::new().unwrap();
s.tick(100.0);
let mut state = backpack();
state.slots.get_mut(&1).unwrap().cooldown = Some((0, 1, false));
s.set_container(0, Some(state));
let poll = |s: &UiScript| {
s.eval::<(f64, f64, i32)>("return GetContainerItemCooldown(0, 1)")
.unwrap()
};
assert_eq!(poll(&s), (100.0, 0.001, 0));
s.tick(0.02);
let (start, _, _) = poll(&s);
assert_eq!(start, s.eval::<f64>("return GetTime()").unwrap());
assert!((start - 100.02).abs() < 1e-9);
}
/// `UseContainerItem`'s use arm hands its call to the lent host inside the call, and its equip
/// arm the equip's send, carrying the set's pickup.
#[test]
fn container_uses_apply_in_the_call_with_a_host_lent() {
use crate::script::ScriptCall;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
let used =
crate::script::host::test_support::applied_in_call(&mut s, "UseContainerItem(0, 1)");
let used_slot = |u: &super::ContainerUse| (u.bag, u.slot) == (0, 1);
assert!(
matches!(used.as_slice(), [ScriptCall::UseContainerItem(u)] if used_slot(u)),
"{used:?}"
);
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(forked_bag(super::UseFork::Equip)));
s.seat_item_records();
let equipped =
crate::script::host::test_support::applied_in_call(&mut s, "UseContainerItem(0, 1)");
assert!(
matches!(
equipped.as_slice(),
[ScriptCall::Item(crate::script::ItemCall::AutoEquip {
bag: 0,
slot: 1,
ammo: None,
picked: true
})]
),
"{equipped:?}"
);
}
/// The use dispatcher's gate refusal shows inside `UseContainerItem` (`0x5d8eaa` →
/// `0x496720`), and so does the equip arm's refusal of an item in the trade window
/// (`0x5e18a5`): the chunk's next line reads each.
#[test]
fn a_use_refusal_shows_inside_its_call() {
use crate::script::ui_errors::shown::seen_in_call;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(forked_bag(super::UseFork::Open)));
s.set_player_control(false);
let (seen, host) = seen_in_call(&mut s, "UseContainerItem(0, 1)");
assert_eq!(seen, "ERR_CLIENT_LOCKED_OUT");
assert!(host.calls.is_empty(), "nothing else is asked");
assert!(s.take_ui_errors().is_empty(), "nothing waits for the feed");
let mut s = UiScript::new().unwrap();
s.set_player_control(true);
s.open_trade_window(0x7);
s.set_container(0, Some(crate::script::cursor::two_item_bag()));
s.keep_calls();
s.run("PickupContainerItem(0, 2) ClickTradeButton(1) ClearCursor()")
.unwrap();
s.unlock_item(118);
let mut equip = crate::script::cursor::two_item_bag();
equip.slots.get_mut(&2).unwrap().use_fork = super::UseFork::Equip;
s.set_container(0, Some(equip));
let (seen, _) = seen_in_call(&mut s, "UseContainerItem(0, 2)");
assert_eq!(seen, "ERR_EQUIP_TRADE_ITEM");
assert!(s.take_ui_errors().is_empty(), "nothing waits for the feed");
}
}