//! 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, Table, Value}; use super::cursor::{self, CursorItem, CursorPayload}; use super::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_*`. The signature-count line /// is not built: its source is untraced, and vmangos's write of the on-item count is commented out. #[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, } /// 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 (the API reports `nil`). pub quality: Option, /// The item's template entry; 0 while unresolved. pub item_id: u32, /// An `|Hitem:…|h[Name]|h` link once the name is known. pub link: Option, pub locked: bool, /// 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, /// Current and max durability; `None` when the max is 0 or the item object has not arrived. pub durability: Option<(u32, u32)>, /// The use cooldown, `(start_ms on the GetTime clock, duration_ms, enabled)`; `None` if cold. pub cooldown: Option<(i64, u32, bool)>, /// The instance carries `ITEM_FIELD_ITEM_TEXT_ID` (a mail copy): `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, /// `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, /// 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, /// 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, } /// 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` (the bag item's own name; the backpack is the client-local "Backpack"). 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, } impl super::UiScript { /// Drain the auto-stores `PutItemInBag`/`PutItemInBackpack` queued since the last call. pub fn take_bag_autostores(&mut self) -> Vec { std::mem::take(&mut self.model_mut().bag_autostores) } /// Push one bag's snapshot, or remove it with `None`; cooldowns arrive in ms on the `GetTime` /// clock and are stored in seconds. pub fn set_container(&mut self, bag: i64, state: Option) { let mut model = self.model_mut(); model.container_cooldowns.retain(|&(b, _), _| b != bag); match state { Some(s) => { for (&slot, cs) in &s.slots { let Some((start_ms, duration_ms, enabled)) = cs.cooldown else { continue; }; let abs = ( start_ms as f64 / 1000.0, f64::from(duration_ms) / 1000.0, enabled, ); model.container_cooldowns.insert((bag, slot), abs); } model.containers.insert(bag, s); } None => { model.containers.remove(&bag); } } } /// Push `HasKey()`'s answer: whether the player owns a `BagFamily` key item anywhere. pub fn set_has_key(&mut self, has_key: bool) { self.model_mut().has_key = has_key; } /// Drain the `(bag, slot)` pairs queued by `UseContainerItem` since the last call. pub fn take_container_uses(&mut self) -> Vec<(i64, u32)> { std::mem::take(&mut self.model_mut().container_uses) } /// Drain the pick/place/swap moves queued by `PickupContainerItem` since the last call. pub fn take_container_moves(&mut self) -> Vec { std::mem::take(&mut self.model_mut().container_moves) } /// Drain the `(bag, slot)` sources `AutoEquipCursorItem` queued, for `CMSG_AUTOEQUIP_ITEM`. pub fn take_container_autoequips(&mut self) -> Vec<(i64, u32)> { std::mem::take(&mut self.model_mut().container_autoequips) } /// Drain the `(bag, slot)` clicks repair mode queued, for `CMSG_REPAIR_ITEM`. pub fn take_container_repairs(&mut self) -> Vec<(i64, u32)> { std::mem::take(&mut self.model_mut().container_repairs) } /// Drain the `(bag, slot, count)` destroys `DeleteCursorItem` queued, a 0 count being the whole /// stack, for `CMSG_DESTROYITEM`. pub fn take_container_destroys(&mut self) -> Vec<(i64, u32, u32)> { std::mem::take(&mut self.model_mut().container_destroys) } /// 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 mut model = self.model_mut(); model.pending_wrap = Some(PendingWrap { bag, slot }); model.ui_cursor = Some(UiCursorMode::Cast); model.ui_cursor_dirty = true; cursor::queue_lock_changed(&mut model, bag, slot); } /// Disarm a gift wrap, unlocking the paper and resetting the cursor, but keeping any held /// payload, which `ClearCursor` would drop. How the reference's right-click cancels a wrap is /// untraced. pub fn cancel_gift_wrap(&mut self) -> Option { let mut model = self.model_mut(); let wrap = model.pending_wrap.take()?; cursor::queue_lock_changed(&mut model, wrap.bag, wrap.slot); model.ui_cursor = None; model.ui_cursor_dirty = true; Some(wrap) } /// The armed gift wrap, if any; the app reads it to tell whether a right-click cancels one. pub fn gift_wrap_armed(&self) -> Option { self.model_ref().pending_wrap } /// Drain the `(giftBag, giftSlot, itemBag, itemSlot)` wraps queued, for `CMSG_WRAP_ITEM`. pub fn take_container_wraps(&mut self) -> Vec<(i64, u32, i64, u32)> { std::mem::take(&mut self.model_mut().container_wraps) } /// The mode the last FrameXML cursor call 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 } /// 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, } /// 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, } /// `PickupContainerItem`'s gesture (`0x4f9b30`): /// - an empty cursor picks up a resolved item whose slot is not locked; /// - 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 an empty or same-item slot, and stays held over another item; /// - a spell or action payload stays held. /// /// Returns whether the caller should repaint. fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> bool { // 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 !model .containers .get(&bag) .and_then(|c| c.slots.get(&slot)) .is_some_and(|s| s.item_id != 0) { return false; } // A vanished paper also bails, the wrap armed and the paper locked (`0x5edfef`). if !model .containers .get(&wrap.bag) .and_then(|c| c.slots.get(&wrap.slot)) .is_some_and(|s| s.item_id != 0) { return false; } // No eligibility test: 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.container_wraps.push((wrap.bag, wrap.slot, bag, slot)); // The paper unlocks and the cursor resets right after the send (`0x5eded0` at // `0x5ee0aa`), not on the answer; the target never locks. cursor::queue_lock_changed(model, wrap.bag, wrap.slot); model.ui_cursor = None; model.ui_cursor_dirty = true; return true; } match model.cursor.take() { None => { let picked = model .containers .get(&bag) .and_then(|c| c.slots.get(&slot)) .filter(|s| s.item_id != 0 && !s.locked) .map(|s| CursorItem { bag, slot, item_id: s.item_id, texture: s.texture.clone(), link: s.link.clone(), quality: s.quality, count: None, bar_placeable: s.bar_placeable, equip_slots: s.equip_slots.clone(), }); match picked { Some(item) => { model.cursor = Some(CursorPayload::Item(item)); cursor::queue_cursor_update(model); cursor::queue_lock_changed(model, bag, slot); true } None => false, } } Some(CursorPayload::Item(held)) if held.bag == bag && held.slot == slot => { cursor::queue_cursor_update(model); cursor::queue_lock_changed(model, held.bag, held.slot); true } Some(CursorPayload::Item(held)) => { // Cloned so the moves below can borrow `model`; an unresolved slot reads as empty. let dest = model .containers .get(&bag) .and_then(|c| c.slots.get(&slot)) .cloned() .filter(|d| d.item_id != 0); match (held.count, dest) { // A split carry cannot swap with a different item: it stays held. (Some(_), Some(d)) if d.item_id != held.item_id => { model.cursor = Some(CursorPayload::Item(held)); false } // Anything else queues the move and clears: a split, a merge the server tops up, // or a swap that lands the displaced item in the source slot. (count, _) => { queue_move(model, &held, bag, slot, count); cursor::queue_cursor_update(model); cursor::queue_lock_changed(model, held.bag, held.slot); true } } } // Mode 5, 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. Deviation: a row that no longer resolves is refused, as `0x4c7300` does, // because this path dereferences it unchecked (`0x4f9f35`), a latent null deref. Some(CursorPayload::Merchant(held)) => { let entry = model .merchant .as_ref() .and_then(|m| m.items.get(held.row as usize)) .filter(|it| it.item_id == held.item_id) .map(|it| it.item_id); if let Some(entry) = entry { model.merchant_slot_buys.push((bag, slot, entry)); } cursor::queue_cursor_update(model); true } Some( other @ (CursorPayload::Spell(_) | CursorPayload::Action(_) | CursorPayload::Macro(_) | CursorPayload::PetAction(_) // Mode 10, a stabled pet, stays held for the stable window. | CursorPayload::StablePet(_) // Mode 2, money, stays held for a money frame's DropFunc. | CursorPayload::Money(_)), ) => { model.cursor = Some(other); false } } } /// Queue one move, the shared tail of every clearing branch above. fn queue_move( model: &mut super::Model, held: &CursorItem, dst_bag: i64, dst_slot: u32, count: Option, ) { model.container_moves.push(ContainerMove { src_bag: held.bag, src_slot: held.slot, dst_bag, dst_slot, count, }); } /// Register the container verbs as bare globals, as `reference/1.12-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. The reference also needs the bank open for 5-10, which is not checked here. lua.globals().set( "SetBagPortaitTexture", lua.create_function(|lua, (region, container): (Table, i64)| { 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"); usize::try_from(inv) .ok() .and_then(|slot| model.inv_slot("player", slot)) .and_then(|v| v.icon.clone()) }; let rh = crate::script::region::region_handle_of(lua, ®ion)?; let mut model = lua.app_data_mut::().expect("model app_data"); let data = model.region_data.entry(rh).or_default(); // An empty slot's `None` is the answer: the reference clears first, then sets. data.texture = path; data.circular = true; data.portrait_unit = None; Ok(()) })?, )?; lua.globals().set( "GetContainerNumSlots", lua.create_function(|lua, bag: i64| { let model = lua.app_data_ref::().expect("model app_data"); Ok(model.containers.get(&bag).map_or(0, |c| c.num_slots)) })?, )?; lua.globals().set( "GetBagName", lua.create_function(|lua, bag: i64| { let name = { let model = lua.app_data_ref::().expect("model app_data"); model.containers.get(&bag).and_then(|c| c.name.clone()) }; match name { Some(n) => Ok(Value::String(lua.create_string(&n)?)), None => Ok(Value::Nil), } })?, )?; // GetContainerItemCooldown(bag, slot) (`0x4f99b0`) → start, duration, enable on the `GetTime` // clock, as `GetActionCooldown`; enable 0 is a held cooldown. An expired one reads cold, so a // re-feed cannot replay the finish flash. lua.globals().set( "GetContainerItemCooldown", lua.create_function(|lua, (bag, slot): (i64, u32)| { let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0); let model = lua.app_data_ref::().expect("model app_data"); Ok(match model.container_cooldowns.get(&(bag, slot)) { Some(&(start, duration, enabled)) if start + duration > now || !enabled => { (start, duration, i32::from(enabled)) } _ => (0.0, 0.0, 1), }) })?, )?; lua.globals().set( "GetContainerItemLink", lua.create_function(|lua, (bag, slot): (i64, u32)| { let link = { let model = lua.app_data_ref::().expect("model app_data"); model .containers .get(&bag) .and_then(|c| c.slots.get(&slot)) .and_then(|s| s.link.clone()) }; match link { Some(l) => Ok(Value::String(lua.create_string(&l)?)), None => Ok(Value::Nil), } })?, )?; // GetContainerItemInfo(bag, slot) (`0x4f9670`) → texture, itemCount, locked, quality, // readable, the five `ContainerFrame.lua:241` reads; no values for an empty or unknown slot. lua.globals().set( "GetContainerItemInfo", lua.create_function(|lua, (bag, slot): (i64, u32)| { let (info, held_here) = { let model = lua.app_data_ref::().expect("model app_data"); let held = matches!( &model.cursor, Some(CursorPayload::Item(c)) if c.bag == bag && c.slot == slot ) || // An armed wrap's paper reads locked until the send or a cancel (`0x4953e0` at // `0x5edeb5`). matches!( &model.pending_wrap, Some(w) if w.bag == bag && w.slot == slot ); ( model .containers .get(&bag) .and_then(|c| c.slots.get(&slot)) .cloned(), held, ) }; let Some(s) = info else { return Ok(mlua::MultiValue::new()); }; Ok(mlua::MultiValue::from_vec(vec![ match &s.texture { Some(p) => Value::String(lua.create_string(p.as_str())?), None => Value::Nil, }, Value::Integer(i64::from(s.count)), // A held item's source slot reads locked, derived from the cursor so the app's // re-push cannot wipe it. Value::Boolean(s.locked || held_here), match s.quality { Some(q) => Value::Integer(i64::from(q)), None => Value::Nil, }, Value::Boolean(s.readable), ])) })?, )?; // BenillaGetContainerItemID(bag, slot): the item id behind a slot. Not a 1.12 verb, where the // id is parsed from `GetContainerItemLink`; the `Benilla` prefix keeps it off the 1.12 surface. lua.globals().set( "BenillaGetContainerItemID", lua.create_function(|lua, (bag, slot): (i64, u32)| { let model = lua.app_data_ref::().expect("model app_data"); // nil, not 0, while the template is in flight: a caller would index a table with 0. Ok(model .containers .get(&bag) .and_then(|c| c.slots.get(&slot)) .map(|s| s.item_id) .filter(|&id| id != 0) .map(i64::from)) })?, )?; lua.globals().set( "UseContainerItem", lua.create_function(|lua, (bag, slot, _rest): (i64, u32, mlua::MultiValue)| { let mut model = lua.app_data_mut::().expect("model app_data"); model.container_uses.push((bag, slot)); Ok(()) })?, )?; // PickupContainerItem(bag, slot) (`0x4f9b30`): pick up, or place and swap. Its boolean return, // whether to repaint, is not 1.12's, whose binding returns nothing. lua.globals().set( "PickupContainerItem", lua.create_function(|lua, (bag, slot): (i64, u32)| { let mut model = lua.app_data_mut::().expect("model app_data"); // The reference tests a held payload first (`0x4f9c38`), then an armed item-targeting // spell, which takes the item (`0x4f9c54`, `0x495d60`), then repair mode (`0x4f9c7b`), // so poisoning at a repair vendor binds the poison. if model.item_pick_armed && model.cursor.is_none() { model.item_picks.push((bag, slot)); return Ok(false); } // Repair mode, until `HideRepairCursor`, queues a repair and picks nothing up. Unlike // the reference, it does not first check for a held item. if model.repair_mode { model.container_repairs.push((bag, slot)); return Ok(false); } Ok(pickup_container_item(&mut model, bag, slot)) })?, )?; // ShowContainerSellCursor(bag, slot) (`0x4fa460`): Buy (3) over an occupied slot, with no // price check, so selling never greys; an empty slot leaves the cursor alone. lua.globals().set( "ShowContainerSellCursor", lua.create_function(|lua, (bag, slot): (i64, u32)| { let mut model = lua.app_data_mut::().expect("model app_data"); let occupied = model .containers .get(&bag) .and_then(|c| c.slots.get(&slot)) .is_some(); // An armed spell suppresses it first (`0x4fa469`); the mode is sticky, so a Buy here // would stamp out the cast cursor for good. if occupied && !model.spell_targeting { 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 model.has_key { Value::Integer(1) } else { Value::Nil }) })?, )?; // SetCursor(name) (`0x489490`): POINT, CAST, BUY and ATTACK are modes 1-4, each `*_ERROR` 20 // higher, and no argument is a `ResetCursor`. The stock UI calls it only from the unit-frame // hover (`UnitFrame.lua:50`). lua.globals().set( "SetCursor", lua.create_function(|lua, name: Option| { let mode = match name.as_deref() { None => None, // no-arg == ResetCursor Some("POINT_CURSOR") => Some(UiCursorMode::Point), Some("CAST_CURSOR") => Some(UiCursorMode::Cast), Some("CAST_ERROR_CURSOR") => Some(UiCursorMode::CastError), Some("BUY_CURSOR") => Some(UiCursorMode::Buy), Some("BUY_ERROR_CURSOR") => Some(UiCursorMode::UnableBuy), // Ignored: the reference loads an unknown name as a custom cursor bitmap, which is // not built. Some(_) => return Ok(()), }; let mut model = lua.app_data_mut::().expect("model app_data"); model.ui_cursor = mode; model.ui_cursor_dirty = true; Ok(()) })?, )?; // 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::UiScript; fn backpack() -> ContainerState { let mut slots = std::collections::HashMap::new(); slots.insert( 1, ContainerSlot { duration_ms: None, petition: None, already_bound: false, bar_placeable: true, durability: None, texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()), count: 5, quality: Some(1), item_id: 117, link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()), locked: false, equip_slots: Vec::new(), cooldown: None, readable: false, creator: None, flags: 0, enchants: Vec::new(), }, ); // An in-flight slot: the create arrived, the template answer hasn't. slots.insert(3, ContainerSlot::default()); // A readable letter, a mail copy with item text. slots.insert( 4, ContainerSlot { item_id: 8383, count: 1, readable: true, ..Default::default() }, ); ContainerState { name: Some("Backpack".into()), num_slots: 16, slots, } } #[test] fn an_armed_gift_wrap_spends_on_the_next_container_click() { let mut s = UiScript::new().unwrap(); s.set_container(0, Some(backpack())); s.arm_gift_wrap(0, 1); assert_eq!(s.gift_wrap_armed(), Some(PendingWrap { bag: 0, slot: 1 })); assert!(s.take_container_wraps().is_empty(), "arming sends nothing"); assert_eq!(s.take_cursor_write(), Some(Some(UiCursorMode::Cast))); assert!( s.eval::("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 })); 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" ); } /// `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); assert_eq!( s.eval::("return GetBagName(0)").unwrap(), "Backpack" ); // 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"); assert_eq!((count, quality), (5, 1)); assert_eq!( s.eval::("return BenillaGetContainerItemID(0, 1)") .unwrap(), 117 ); assert!(s .eval::("return GetContainerItemLink(0, 1) ~= nil") .unwrap()); // 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: texture and quality nil, itemCount real. assert!(s .eval::( "local texture, itemCount, locked, quality = GetContainerItemInfo(0, 3)\n\ return texture == nil and quality == nil and itemCount ~= nil", ) .unwrap()); assert!(s .eval::("return BenillaGetContainerItemID(0, 3) == nil") .unwrap()); assert!(s .eval::("return GetContainerItemInfo(0, 2) == nil") .unwrap()); } #[test] fn use_container_item_queues_intents() { let mut s = UiScript::new().unwrap(); s.set_container(0, Some(backpack())); s.run("UseContainerItem(0, 1)").unwrap(); s.run("UseContainerItem(0, 3, 'target')").unwrap(); assert_eq!(s.take_container_uses(), vec![(0, 1), (0, 3)]); assert!(s.take_container_uses().is_empty(), "drained"); } #[test] fn pickup_then_place_swaps_and_clears_cursor() { let mut s = UiScript::new().unwrap(); s.set_container(0, Some(backpack())); // slot 1 = Tough Jerky (resolved), slot 3 = in-flight assert!(s.cursor_item().is_none()); assert!(!s.eval::("return CursorHasItem()").unwrap()); assert!(s.eval::("return PickupContainerItem(0, 1)").unwrap()); let held = s.cursor_item().expect("cursor holds the picked item"); assert_eq!((held.bag, held.slot, held.item_id), (0, 1, 117)); assert_eq!( held.texture.as_deref(), Some("Interface\\Icons\\INV_Misc_Food_16") ); assert!(s.eval::("return CursorHasItem()").unwrap()); assert!(s .eval::("local _, _, locked = GetContainerItemInfo(0, 1) return locked") .unwrap()); assert!(s.take_container_moves().is_empty()); let (kind, id) = s .eval::<(String, i64)>("local k, id = GetCursorInfo() return k, id") .unwrap(); assert_eq!((kind.as_str(), id), ("item", 117)); assert!(s.eval::("return 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 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(); let mut state = backpack(); // slot 1 = item 117 (A) state.slots.insert( 5, ContainerSlot { duration_ms: None, petition: None, already_bound: false, bar_placeable: true, durability: None, texture: Some("Interface\\Icons\\INV_Misc_Gem_01".into()), count: 1, quality: Some(2), item_id: 200, // item B, a different id link: Some("|Hitem:200|h[Shiny Gem]|h".into()), locked: false, equip_slots: Vec::new(), cooldown: None, readable: false, creator: None, flags: 0, enchants: Vec::new(), }, ); s.set_container(0, Some(state)); assert!(s.eval::("return PickupContainerItem(0, 1)").unwrap()); assert_eq!(s.cursor_item().unwrap().item_id, 117); assert!(s.eval::("return PickupContainerItem(0, 5)").unwrap()); assert!( s.cursor_item().is_none(), "a swap clears the cursor — the displaced item never hops on" ); assert_eq!( s.take_container_moves(), vec![ContainerMove { src_bag: 0, src_slot: 1, dst_bag: 0, dst_slot: 5, count: None, }] ); assert!(!s .eval::("local _, _, locked = GetContainerItemInfo(0, 1) return locked") .unwrap()); } /// 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(); let mut state = backpack(); state.slots.insert( 5, ContainerSlot { durability: None, item_id: 117, // same id as slot 1 count: 2, ..Default::default() }, ); s.set_container(0, Some(state)); s.run("PickupContainerItem(0, 1)").unwrap(); assert!(s.eval::("return PickupContainerItem(0, 5)").unwrap()); assert!( s.cursor_item().is_none(), "same-item merge clears the cursor" ); assert_eq!( s.take_container_moves(), vec![ContainerMove { src_bag: 0, src_slot: 1, dst_bag: 0, dst_slot: 5, count: None, }] ); } #[test] fn pickup_refuses_a_locked_slot() { let mut s = UiScript::new().unwrap(); let mut state = backpack(); state.slots.get_mut(&1).unwrap().locked = true; s.set_container(0, Some(state)); assert!(!s.eval::("return PickupContainerItem(0, 1)").unwrap()); assert!(s.cursor_item().is_none()); } #[test] fn split_container_item_carries_a_partial_stack() { let mut s = UiScript::new().unwrap(); let mut state = backpack(); // slot 1: a 5-stack of item 117 state.slots.insert( 7, ContainerSlot { durability: None, item_id: 117, count: 1, ..Default::default() }, ); state.slots.insert( 9, ContainerSlot { durability: None, item_id: 999, // a different item count: 1, ..Default::default() }, ); s.set_container(0, Some(state)); assert!(s .eval::("return SplitContainerItem(0, 1, 3)") .unwrap()); let held = s.cursor_item().expect("the split carry"); assert_eq!( (held.bag, held.slot, held.item_id, held.count), (0, 1, 117, Some(3)) ); assert!(s.eval::("return PickupContainerItem(0, 2)").unwrap()); assert!(s.cursor_item().is_none()); assert_eq!( s.take_container_moves(), vec![ContainerMove { src_bag: 0, src_slot: 1, dst_bag: 0, dst_slot: 2, count: Some(3), }] ); s.run("SplitContainerItem(0, 1, 3)").unwrap(); assert!(!s.eval::("return PickupContainerItem(0, 9)").unwrap()); let held = s.cursor_item().expect("kept — can't swap a partial stack"); assert_eq!(held.count, Some(3)); assert!(s.take_container_moves().is_empty()); assert!(s.eval::("return PickupContainerItem(0, 7)").unwrap()); assert!(s.cursor_item().is_none()); assert_eq!( s.take_container_moves(), vec![ContainerMove { src_bag: 0, src_slot: 1, dst_bag: 0, dst_slot: 7, count: Some(3), }] ); } #[test] fn split_of_the_whole_stack_is_a_plain_pickup() { let mut s = UiScript::new().unwrap(); s.set_container(0, Some(backpack())); // slot 1: a 5-stack assert!(s .eval::("return SplitContainerItem(0, 1, 5)") .unwrap()); assert_eq!(s.cursor_item().unwrap().count, None); // A split of more than the stack is a plain pickup too. s.run("ClearCursor()").unwrap(); assert!(s .eval::("return SplitContainerItem(0, 1, 99)") .unwrap()); assert_eq!(s.cursor_item().unwrap().count, None); } #[test] fn pickup_same_slot_cancels_no_move() { let mut s = UiScript::new().unwrap(); s.set_container(0, Some(backpack())); assert!(s.eval::("return PickupContainerItem(0, 1)").unwrap()); assert!(s.cursor_item().is_some()); assert!(s.eval::("return PickupContainerItem(0, 1)").unwrap()); assert!(s.cursor_item().is_none()); assert!(s.take_container_moves().is_empty()); } #[test] fn pickup_empty_slot_holds_nothing() { let mut s = UiScript::new().unwrap(); s.set_container(0, Some(backpack())); // Slot 2 is empty and slot 3 unresolved: neither can be picked up. assert!(!s.eval::("return PickupContainerItem(0, 2)").unwrap()); assert!(s.cursor_item().is_none()); assert!(!s.eval::("return PickupContainerItem(0, 3)").unwrap()); assert!(s.cursor_item().is_none()); } #[test] fn clear_cursor_drops_the_held_item() { let mut s = UiScript::new().unwrap(); s.set_container(0, Some(backpack())); s.run("PickupContainerItem(0, 1)").unwrap(); assert!(s.cursor_item().is_some()); s.run("ClearCursor()").unwrap(); assert!(s.cursor_item().is_none()); assert!(s.take_container_moves().is_empty()); } #[test] fn removing_a_bag_empties_it() { let mut s = UiScript::new().unwrap(); s.set_container(2, Some(backpack())); s.set_container(2, None); assert_eq!(s.eval::("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)); 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, 3)\n\ return s == 0 and d == 0 and e == 1" ) .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}" ); } } }