//! 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, /// 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, 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, /// 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, /// 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, /// 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, /// 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, /// 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 "" line, once resolved. pub creator: Option, /// 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, /// 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, /// The enchant slots in slot order, named from `SpellItemEnchantment.dbc`; empty when none. pub enchants: Vec, /// A timed item's lifetime left in ms (`SMSG_ITEM_TIME_UPDATE`), floored to the second. pub duration_ms: Option, /// `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, /// 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 { (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, } /// 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, } /// 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, pub num_slots: u32, pub slots: std::collections::HashMap, } /// 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, } /// 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, } /// 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 = 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 { let model = lua.app_data_ref::().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) { 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, ) { 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 { 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 { 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 { 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) { 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> { 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, ) -> Option { // 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> { 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::() .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, ®ion)?; 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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::().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::("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::("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::("return SetCursor(\"Nope\") == nil").unwrap()); assert!(s.eval::("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::>(&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::>(r#"return SetCursor("Mine\\Arrow")"#) .unwrap(), None, "only a .tga: no cursor picture" ); assert_eq!(s.ui_cursor(), None); assert_eq!( s.eval::>(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, Option, Option, Option, ); let read = |s: &UiScript| { s.eval::( 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::("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, Option)>( "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::("local _, _, locked = GetContainerItemInfo(0, 1) return locked") .unwrap(), "the paper dims while the wrap stands" ); assert!( !s.eval::("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::("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::("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::("return GetContainerNumSlots(0)").unwrap(), 0); assert!(s .eval::("return GetContainerItemInfo(0, 1) == nil") .unwrap()); s.set_container(0, Some(backpack())); assert_eq!(s.eval::("return GetContainerNumSlots(0)").unwrap(), 16); s.run("BACKPACK_TOOLTIP = 'Rucksack'").unwrap(); assert_eq!( s.eval::("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::( "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::("local _, _, _, _, readable = GetContainerItemInfo(0, 1) return readable") .unwrap()); assert!(s .eval::("local _, _, _, _, readable = GetContainerItemInfo(0, 4) return readable") .unwrap()); // An in-flight slot: the question mark, the -1 quality sentinel, itemCount real. assert!(s .eval::( "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::("return GetContainerItemLink(0, 3) == nil") .unwrap()); assert!(s .eval::("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::>("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::(&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::(&format!("return GetBagName({bag}) == nil")) .unwrap(), "GetBagName({bag})" ); } assert_eq!( s.eval::("return GetBagName(4)").unwrap(), "Linen Bag" ); assert!( s.eval::("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, 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::(&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::(&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::(&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 { 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::>(), [(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::("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::("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::("return CursorHasItem()").unwrap()); assert!(s .eval::("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::("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::("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::("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| 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::("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::( "local s, d, e = GetContainerItemCooldown(0, 1)\n\ return s == 96 and d == 10 and e == 1" ) .unwrap()); assert!(s .eval::( "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::( "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::( "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::( "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::(&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::(&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::("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"); } }