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