- /afk and /dnd send instead of printing Unknown chat type: the flag sets, <AFK> shows over your head at once, the four echo lines are the client's own, and moving clears AFK under the real autoClearAFK CVar (2082, 2088) - the stun swirl and the other state-kit visuals show — a cache the login map change cleared lost them nine runs in ten — and a state kit's animation id is the base recompute the reference runs, never a play, so the stray Stun one-shot is gone (2085) - dropping an item on the world brings up the destroy prompt again: since the stock WorldFrame landed, the world is a frame, and the drop's gate had been written for the absence of one (2089, B380) - the mouse no longer snaps to the window's centre when the loading cover drops on /logout; the cover hands the pointer back only where it knows it is (2090) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1045 lines
50 KiB
Rust
1045 lines
50 KiB
Rust
//! The cursor payload (decision 0216, slice 1) — the client's payload-mode global `[0xb4d900]`
|
||
//! as a typed enum, and the drag-gesture mechanics (`RegisterForDrag`/`OnDragStart`/`OnDragStop`/
|
||
//! `OnReceiveDrag`) that transition it. One seam for every surface — bags, the paper doll
|
||
//! ([`doll`], decision 0208 phase 1b), action bars ([`bar`], decision 0216 §7/0218 §4), and the
|
||
//! spellbook ([`super::spellbook`]'s `PickupSpell`, slice 5 — the [`CursorSpell`] producer) — so
|
||
//! sounds, `CURSOR_UPDATE`, grid events, and lock display can't drift apart per window.
|
||
//!
|
||
//! [`container`](super::container)'s `pickup_container_item`, [`doll`]'s
|
||
//! `pickup_inventory_item`, and [`bar`]'s `pickup_action`/`place_action` (the surface-specific
|
||
//! transition bodies) all route through [`queue_cursor_update`]/[`queue_lock_changed`], the one
|
||
//! place those events fire from. [`bar`], [`doll`], and [`drag`] are split out purely for size —
|
||
//! this file keeps the payload types, the shared transition seam, and `install`.
|
||
|
||
use mlua::{Lua, Value};
|
||
|
||
use super::{Model, ScriptValue};
|
||
|
||
mod bag_verbs;
|
||
mod bar;
|
||
mod doll;
|
||
mod drag;
|
||
pub(crate) mod money;
|
||
mod pet;
|
||
|
||
pub(crate) use bar::place_action;
|
||
pub(crate) use drag::{
|
||
abandon_drag, arm_drag, maybe_start_drag, take_drag, DragGesture, DragRelease,
|
||
};
|
||
|
||
/// The sentinel bag id that folds the player's EQUIPPED slots into the ONE payload space
|
||
/// (decision 0216 §1, extended to the paper doll by decision 0208 phase 1b): a
|
||
/// [`CursorItem`]/[`super::container::ContainerMove`] whose `bag` is `EQUIPMENT_BAG` addresses
|
||
/// `slot` as a 1-based live-API inventory slot id — `GetInventorySlotInfo`'s own numbering
|
||
/// (HeadSlot=1 … TabardSlot=19, `char_stats::SLOT_INFO`'s convention), not a bag's contents.
|
||
/// Disjoint from every real bag id — including the ERA's own negative container ids, which are
|
||
/// real API surface (−1 = `BANK_CONTAINER`, decision 0604; −2 = the keyring) — so a `match` on
|
||
/// `bag` can never confuse the spaces (its original value −1 collided with the bank the day the
|
||
/// bank landed; the sentinel is engine-internal and never crosses the Lua boundary as a value,
|
||
/// verified across the XML fleet at the change). Ammo (id 0) stays OUT of this space — a named
|
||
/// deferral ([`doll::pickup_inventory_item`]'s own range guard).
|
||
pub const EQUIPMENT_BAG: i64 = -100;
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// The payload
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// What the cursor carries — the client's payload-mode global [0xb4d900] as a typed enum
|
||
/// (wow-re cursor-dragdrop-payload.md §1: 1 = live item, 3 = spell, **4 = pet action**, **8 =
|
||
/// macro**, **10 = stabled pet**; our Action arm is the client's bar-slot pickup; the money arm
|
||
/// is mode 2 ([`CursorMoney`], 1962/1965) and the preview arm stays unbuilt). One transition seam
|
||
/// for every surface, so sounds, CURSOR_UPDATE, and lock display can't drift apart per window (decision 0216).
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum CursorPayload {
|
||
Item(CursorItem),
|
||
Spell(CursorSpell),
|
||
Action(CursorAction),
|
||
Macro(CursorMacro),
|
||
PetAction(CursorPetAction),
|
||
/// A vendor row grabbed off the merchant window — **mode 5** (see [`CursorMerchantItem`]).
|
||
///
|
||
/// Deliberately NOT `Item`: the reference's `CursorHasItem 0x4895d0` answers truthy for modes
|
||
/// **1 and 9 only**, so all thirteen stock `CursorHasItem()` gates (TradeFrame, MailFrame,
|
||
/// PlayerFrame, TargetFrame, PetFrame, StaticPopup, the keyring…) exclude a vendor cursor.
|
||
/// Making this an `Item` variant would silently open every one of them.
|
||
Merchant(CursorMerchantItem),
|
||
/// A pet picked up from the stable window — **mode 10** (see [`CursorStablePet`]).
|
||
///
|
||
/// Mode 10 was recorded as "class/talent ability (DBC)" until wow-re's stable-master carve
|
||
/// corrected it (`system/ui/scratch/stable-master-window.md` §9; decision 1677): `0x495020` is
|
||
/// the stabled-pet grab, `[0xb4d900] = 10` is written at exactly one site image-wide, and it is
|
||
/// inside it. benilla built the stable's drag frame-locally on the strength of the old note —
|
||
/// this variant is what puts it back on the shared cursor, so a stable pet dropped on the world
|
||
/// or another window clears through the same path as every other payload.
|
||
StablePet(CursorStablePet),
|
||
/// Coins picked up off a money frame — **mode 2** (see [`CursorMoney`]).
|
||
Money(CursorMoney),
|
||
}
|
||
|
||
/// Copper held on the cursor — payload **mode 2**, the client's `[0xb4e2f0]` amount written by
|
||
/// `0x494cc0` from `PickupPlayerMoney 0x48abc0` with `LOOTWINDOWCOINSOUND` (wow-re
|
||
/// `cursor-dragdrop-payload.md` §1's payload table). The purse is never debited by the pickup:
|
||
/// the stock `MoneyTypeInfo["PLAYER"]` shows `GetMoney() - GetCursorMoney() - …`, the subtraction
|
||
/// being how the held coins leave the display while the money stays yours (1962).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct CursorMoney {
|
||
pub copper: u32,
|
||
}
|
||
|
||
/// A vendor row held on the cursor — payload **mode 5**, set by `PickupMerchantItem 0x4fb760`.
|
||
///
|
||
/// **A buy-on-drop cursor, not an item cursor.** The reference writes three globals and this
|
||
/// mirrors all three: the item entry (`0xb4b41c`), the display id that drives the icon
|
||
/// (`0xb4d8ec` — we carry the resolved texture path, which is the same thing one hop later), and
|
||
/// the **0-based** vendor row (`0xb4b420`). Dropping it on a bag slot sends
|
||
/// `CMSG_BUY_ITEM_IN_SLOT 0x1a3`; dropping it on the world, pressing ESC, or the vendor window
|
||
/// closing is a plain clear with **no packet**.
|
||
///
|
||
/// No Lua predicate reports it. `CursorHasItem`, `CursorHasSpell`, `CursorHasMoney` and
|
||
/// `CursorCanGoInSlot` are all nil for mode 5 — no registered binding exposes it at all.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct CursorMerchantItem {
|
||
/// The row's item template entry — `0xb4b41c`, and what the buy addresses.
|
||
pub item_id: u32,
|
||
/// The vendor row this came from, **0-based** (`0xb4b420` = `trunc(arg) - 1`). Kept because
|
||
/// the reference keeps it: a second `PickupMerchantItem` naming the same row is a toggle-off,
|
||
/// which is the only thing that reads it back.
|
||
pub row: u32,
|
||
/// The icon drawn at the mouse. The reference stores `ItemDisplayInfo`'s id (`0xb4d8ec`) and
|
||
/// resolves the art from it; we hold the already-resolved path, which is the same value one
|
||
/// hop later and the shape every other payload here uses.
|
||
pub texture: Option<String>,
|
||
}
|
||
|
||
/// A pet held on the cursor from the stable window — payload **mode 10** (`0x495020`, the grab
|
||
/// `0x495010`).
|
||
///
|
||
/// Mode 10 was recorded as "class/talent ability (DBC)" until wow-re's stable-master carve
|
||
/// corrected it (`system/ui/scratch/stable-master-window.md` §9; decision 1677): `[0xb4d900] = 10`
|
||
/// is written at exactly one site image-wide and it is inside the stabled-pet grab, whose id global
|
||
/// `[0xb4e300]` holds a **stable index**, not a spell. benilla first built the stable's drag
|
||
/// frame-locally on the strength of the old note; this puts it on the shared cursor.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct CursorStablePet {
|
||
/// The slot the grab came from, in the Lua index space `PickupStablePet` takes (`0` = the
|
||
/// summoned pet, `1..=2` a stable slot) — the client's `[0xb4e300]`.
|
||
pub slot: u8,
|
||
/// The family icon drawn at the mouse. Not merely decoration: a **non-empty icon path is the
|
||
/// grab's own gate** (`0x495010` refuses without one), so a payload of this shape always has
|
||
/// one.
|
||
pub texture: String,
|
||
}
|
||
|
||
/// The item currently held on the cursor (`PickupContainerItem`/`SplitContainerItem`/
|
||
/// `PickupInventoryItem` set it). The real client's drag state: it names *where the item was
|
||
/// picked from* (so a second click routes the swap) and carries the icon the app draws at the
|
||
/// mouse. Purely transient — cleared on place/cancel; the server's field updates do the actual
|
||
/// move.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct CursorItem {
|
||
/// Live-API bag id the item was picked up from (0 = backpack, 1..=4 an equipped bag,
|
||
/// [`EQUIPMENT_BAG`] the player's own equipped slots).
|
||
pub bag: i64,
|
||
/// 1-based slot the item was picked up from.
|
||
pub slot: u32,
|
||
/// The picked item's template entry (`GetCursorInfo`'s itemID).
|
||
pub item_id: u32,
|
||
/// The icon texture path to draw at the mouse (`Interface\Icons\…`); `None` if unresolved.
|
||
pub texture: Option<String>,
|
||
/// The item link (`GetCursorInfo`'s itemLink), when known.
|
||
pub link: Option<String>,
|
||
/// A split carry (`SplitContainerItem` picked up `n` of the stack); `None` = the whole stack.
|
||
pub count: Option<u32>,
|
||
/// The item's quality (0..6) at pickup, carried so `DELETE_ITEM_CONFIRM` (a world drop) can
|
||
/// report it without a container round-trip.
|
||
pub quality: Option<u32>,
|
||
/// The 1-based live-API inventory slot ids this item could be EQUIPPED into (empty = not
|
||
/// equippable), captured at pickup from the source's own `equip_slots` (the container slot's
|
||
/// or the doll slot view's — decision 0208 phase 1b's "the fit rule").
|
||
/// [`doll::pickup_inventory_item`] reads it to decide a place-onto-doll-slot's fit;
|
||
/// [`doll::cursor_can_go_in_slot`] serves it straight to `CURSOR_UPDATE`'s highlight.
|
||
pub equip_slots: Vec<u8>,
|
||
/// Whether this item may be placed on an ACTION-BAR slot, captured at pickup from the
|
||
/// source's own `bar_placeable` — `PlaceAction`'s only item filter (decision 0666).
|
||
pub bar_placeable: bool,
|
||
}
|
||
|
||
/// How many items the cursor is actually holding, for a payload picked up from a bag.
|
||
///
|
||
/// [`CursorItem::count`] is `None` for a **whole-stack** pickup — it records only what a *split*
|
||
/// carry took — so reading it as `unwrap_or(1)` answers 1 for a stack of twenty. That is a
|
||
/// display wart on a mail attachment and a real arithmetic error on an auction deposit, which is
|
||
/// computed per stack: it would quote a twentieth of what the server then charges. So the true
|
||
/// size comes back from the source slot, which still holds the stack (a pickup locks the slot, it
|
||
/// does not empty it).
|
||
///
|
||
/// Falls back to 1 only when the source slot is genuinely unreadable — a bag the app has not
|
||
/// pushed, or a slot that changed under us.
|
||
pub(super) fn held_count(model: &super::Model, item: &CursorItem) -> u32 {
|
||
if let Some(n) = item.count {
|
||
return n.max(1);
|
||
}
|
||
model
|
||
.containers
|
||
.get(&item.bag)
|
||
.and_then(|c| c.slots.get(&item.slot))
|
||
.filter(|s| s.item_id == item.item_id)
|
||
.map_or(1, |s| s.count.max(1))
|
||
}
|
||
|
||
/// A spell payload ([`super::spellbook`]'s `PickupSpell` produces it): the spellbook slot it was
|
||
/// picked from, its book (`"spell"`/`"pet"`, the Era `GetCursorInfo` shape), and the resolved
|
||
/// spell id.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct CursorSpell {
|
||
pub book_slot: u32,
|
||
pub book_type: String,
|
||
pub spell_id: u32,
|
||
pub texture: Option<String>,
|
||
/// `Attributes & 0x40` (`SPELL_ATTR_PASSIVE`) — a passive cannot go on the action bar
|
||
/// (`PlaceAction`'s other filter, `0x4e63ad`; decision 0666).
|
||
pub passive: bool,
|
||
}
|
||
|
||
/// An action-slot payload ([`bar`]'s `PickupAction`/`place_action` hop produces it): the source
|
||
/// action slot, the packed action's kind byte (SPELL/MACRO/ITEM — decision 0216's
|
||
/// `CMSG_SET_ACTION_BUTTON` type byte), and the action id itself.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct CursorAction {
|
||
pub src_slot: u32,
|
||
pub kind: u8,
|
||
pub action: u32,
|
||
pub texture: Option<String>,
|
||
}
|
||
|
||
/// A pet-bar payload ([`pet`]'s `PickupPetAction` produces it) — the client's cursor mode **4**,
|
||
/// whose payload global `[0xb4e2f8]` is the picked slot's **packed word, copied verbatim** (its
|
||
/// sole writer `0x494f0c` is a plain `mov edx,[edi]; mov [0xb4e2f8],edx`; decision 1010).
|
||
///
|
||
/// The word is the payload, and that is the whole design: the drop trampoline `0x4bce00` forwards
|
||
/// this dword into the assign core without reading a field of it, so a slot's contents after any
|
||
/// accepted drop is a word that already existed elsewhere in client state. Nothing is encoded at
|
||
/// drop time and nothing here needs decoding.
|
||
///
|
||
/// This payload is **pet-bar-only**. `PlaceAction` refuses it (wow-re `action-item-slot.md`'s
|
||
/// payload table: pet actions and class abilities are the refused modes, macros are not), and
|
||
/// nothing else produces it — which is also why a pet bar can only ever be *rearranged*, never
|
||
/// populated from the spellbook: its contents are the server's.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct CursorPetAction {
|
||
/// The 1-based pet bar slot it was picked from.
|
||
pub src_slot: u32,
|
||
/// The slot's packed word, verbatim.
|
||
pub packed: u32,
|
||
/// `Attributes & 0x40` for a spell word — carried because the assign core's one source filter
|
||
/// tests it at DROP time, not at pickup (`0x4bc9f8`).
|
||
pub passive: bool,
|
||
pub texture: Option<String>,
|
||
}
|
||
|
||
/// A macro payload ([`super::macros`]'s `PickupMacro` produces it) — the client's cursor mode
|
||
/// **8**, whose payload global is the bare macro id (`[0xb4e2fc]`, wow-re `action-item-slot.md`'s
|
||
/// payload table, where mode 8 is the ONE non-item/non-spell payload `PlaceAction` accepts).
|
||
/// Unlike every other arm this one carries no source slot to swap back to: a macro lives in the
|
||
/// macro table, not in the surface it was dragged from, so a refused place just leaves it held.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub struct CursorMacro {
|
||
/// The 1-based macro index (1..=18 account, 19..=36 character — [`super::macros`]'s space).
|
||
pub index: u32,
|
||
pub texture: Option<String>,
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// The one transition seam: CURSOR_UPDATE / ITEM_LOCK_CHANGED / DELETE_ITEM_CONFIRM
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Queue `CURSOR_UPDATE` — fired on EVERY payload transition (pickup, place/clear, cancel,
|
||
/// `ClearCursor`, `DeleteCursorItem`, a world-drop clear). One push per transition.
|
||
///
|
||
/// Also derives `ACTIONBAR_SHOWGRID`/`ACTIONBAR_HIDEGRID` (decision 0216 §7) off
|
||
/// [`Model::cursor_grid_shown`]'s mirror against the CURRENT `model.cursor` (already the
|
||
/// post-transition state at every call site — every caller mutates `model.cursor` before calling
|
||
/// this): a None→Some edge (any payload arm, any surface — bags/doll/actions alike) shows the
|
||
/// bar's drop grid, Some→None hides it, Some→Some (the action hop) touches neither, so one
|
||
/// gesture never churns HIDE+SHOW. This is the one seam every pickup/place/clear already routes
|
||
/// through, so no call site needs to know about grid events at all.
|
||
pub(crate) fn queue_cursor_update(model: &mut Model) {
|
||
model
|
||
.pending_events
|
||
.push(("CURSOR_UPDATE".to_string(), Vec::new()));
|
||
// The two grids are derived off DIFFERENT predicates, because the reference fires them from
|
||
// different places and the payload spaces do not overlap (decision 1010):
|
||
//
|
||
// - the ACTION bar's grid follows "is anything held that could land there" — every arm except
|
||
// the pet one, which `PlaceAction` refuses outright;
|
||
// - the PET bar's grid follows the pet payload alone. The reference fires `PET_BAR_SHOWGRID`
|
||
// from inside the pet-action pickup builder itself (`0x494f28`), not from a shared cursor
|
||
// transition — so a spell on the cursor lights the action bar's empty slots and leaves the
|
||
// pet bar alone, which is also the only honest answer: you cannot drop it there.
|
||
let pet_held = matches!(model.cursor, Some(CursorPayload::PetAction(_)));
|
||
let bar_held = model.cursor.is_some() && !pet_held;
|
||
if bar_held != model.cursor_grid_shown {
|
||
model.cursor_grid_shown = bar_held;
|
||
let event = if bar_held {
|
||
"ACTIONBAR_SHOWGRID"
|
||
} else {
|
||
"ACTIONBAR_HIDEGRID"
|
||
};
|
||
model.pending_events.push((event.to_string(), Vec::new()));
|
||
}
|
||
if pet_held != model.pet_grid_shown {
|
||
model.pet_grid_shown = pet_held;
|
||
let event = if pet_held {
|
||
"PET_BAR_SHOWGRID"
|
||
} else {
|
||
"PET_BAR_HIDEGRID"
|
||
};
|
||
model.pending_events.push((event.to_string(), Vec::new()));
|
||
}
|
||
}
|
||
|
||
/// Queue `ITEM_LOCK_CHANGED(bag, slot)` — a pickup locking a source slot, or a place/cancel/
|
||
/// clear/destroy unlocking it (decision 0216 §4: the engine-derived held-here lock,
|
||
/// `container.rs`'s `GetContainerItemInfo` `held_here` check).
|
||
pub(crate) fn queue_lock_changed(model: &mut Model, bag: i64, slot: u32) {
|
||
model.pending_events.push((
|
||
"ITEM_LOCK_CHANGED".to_string(),
|
||
vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))],
|
||
));
|
||
}
|
||
|
||
/// Queue `DELETE_ITEM_CONFIRM(name, quality)` for an item payload dropped on the world (the popup
|
||
/// flow transcribed from `StaticPopup.lua`, decision 0216 §3). `name` is parsed out of the link's
|
||
/// `[...]` segment (empty string with no link); the payload is left untouched here — the popup's
|
||
/// `OnAccept`/`OnCancel` (`DeleteCursorItem`/`ClearCursor`) own clearing it.
|
||
fn queue_delete_item_confirm(model: &mut Model, item: &CursorItem) {
|
||
let name = item_link_name(item.link.as_deref());
|
||
let quality = item.quality.unwrap_or(0);
|
||
model.pending_events.push((
|
||
"DELETE_ITEM_CONFIRM".to_string(),
|
||
vec![ScriptValue::Str(name), ScriptValue::Int(i64::from(quality))],
|
||
));
|
||
}
|
||
|
||
/// Parse an item link's display name out of its `|h[Name]|h` segment; empty with no link or an
|
||
/// unrecognized shape.
|
||
pub(super) fn item_link_name(link: Option<&str>) -> String {
|
||
link.and_then(|l| l.split_once('['))
|
||
.and_then(|(_, rest)| rest.split_once(']'))
|
||
.map(|(name, _)| name.to_string())
|
||
.unwrap_or_default()
|
||
}
|
||
|
||
/// `ClearCursor()` — drops whatever the cursor holds, any arm (the right-click/ESC cancel). An
|
||
/// item payload also un-locks its source slot; an already-empty cursor is not a transition (no
|
||
/// event).
|
||
pub(crate) fn clear_cursor(model: &mut Model) {
|
||
// **An armed gift wrap dies with any cancel** (decision 1934), and this is the FIRST thing
|
||
// the reference's `ClearCursor 0x495190` does — `0x5edf10` is its opening act and it is
|
||
// **ungated by either parameter**, including `ClearCursor(0)`, the flavour that deliberately
|
||
// keeps an ordinary held item's lock. So all 70 of its call sites cancel a wrap. The paper
|
||
// unlocks and the cursor mode goes back to the base.
|
||
if let Some(wrap) = model.pending_wrap.take() {
|
||
queue_lock_changed(model, wrap.bag, wrap.slot);
|
||
model.ui_cursor = None;
|
||
model.ui_cursor_dirty = true;
|
||
}
|
||
match model.cursor.take() {
|
||
Some(CursorPayload::Item(item)) => {
|
||
queue_cursor_update(model);
|
||
queue_lock_changed(model, item.bag, item.slot);
|
||
}
|
||
Some(
|
||
CursorPayload::Spell(_)
|
||
| CursorPayload::Action(_)
|
||
| CursorPayload::Macro(_)
|
||
| CursorPayload::PetAction(_)
|
||
| CursorPayload::StablePet(_)
|
||
// Mode 2 — coins go back where they never left (the purse was not debited).
|
||
| CursorPayload::Money(_)
|
||
// Mode 5 clears like every other non-item payload, and clearing it sends NO packet —
|
||
// a vendor cursor abandoned on the world, on ESC, or by the window closing costs
|
||
// nothing. `ClearCursor`'s own mode-5 arm is `0x49525f`.
|
||
| CursorPayload::Merchant(_),
|
||
) => {
|
||
queue_cursor_update(model);
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
|
||
/// **The SELL fork's cursor clear** — `0x494b60 SetCursorItem(0, …)`, which is *not* `ClearCursor`
|
||
/// and differs from [`clear_cursor`] in exactly one way that a player can see.
|
||
///
|
||
/// Both drop the payload and fire `CURSOR_UPDATE`. `ClearCursor` also un-locks the source slot;
|
||
/// this does **not**: `0x494b60`'s mode-1 arm is entered with `param1 = 0`, which skips `0x495420`,
|
||
/// so the sold item's slot stays **greyed** until the server's own inventory update removes it
|
||
/// (wow-re `object-layer/scratch/vendor-sell-on-right-click.md` §3, the §5 trio dispatched from
|
||
/// benilla 1905). Leaving the lock set is the point: the item is gone from the cursor but not yet
|
||
/// gone from the bag, and the grey is what says so.
|
||
///
|
||
/// Shared by the two verbs that sell off the cursor — `PickupMerchantItem`'s sell fork
|
||
/// (`0x4fb760`) and the interact ladder's vendor arm (`0x5df5d0`) — because they are the same
|
||
/// clear in the binary, and two copies of it is how one of them quietly grows a different
|
||
/// behaviour. Returns the item so the caller can address the packet by its slot.
|
||
///
|
||
/// A non-item payload is left exactly where it was: the fork answers for **mode 1 only**
|
||
/// (`GetCursorItem 0x494c60`), so a spell, a macro or a vendor row on the cursor is not a sale
|
||
/// and must not be dropped by asking.
|
||
pub(crate) fn take_cursor_item_for_sale(model: &mut Model) -> Option<CursorItem> {
|
||
let item = match model.cursor.take() {
|
||
Some(CursorPayload::Item(item)) => item,
|
||
other => {
|
||
model.cursor = other;
|
||
return None;
|
||
}
|
||
};
|
||
queue_cursor_update(model);
|
||
Some(item)
|
||
}
|
||
|
||
/// What the app's world pick resolves under the cursor this frame — the reference's click-time
|
||
/// pick state `[this+0x350]` (0 = nothing / 1 = terrain / 2 = object; `0x481f60`, decisions
|
||
/// 0571 + 0574). Fed per frame by the app ([`super::UiScript::set_world_pick`]; stays
|
||
/// [`WorldPick::Nothing`] in tests/captures). Routes the left world-drop
|
||
/// ([`world_drop_click`]): over an `Object` no payload drops at all (the object leg `0x492ce0`
|
||
/// clears only the displayId-PREVIEW gate `[0xb4b41c]` — an arm benilla doesn't carry — and
|
||
/// dispatches SELECT/INTERACT with the payload still held); over `Terrain` an ITEM drops (the
|
||
/// `DELETE_ITEM_CONFIRM` popup via `0x5e0320`) and a spell/action payload clears silently
|
||
/// (decision 0843's deliberate divergence — the reference keeps it there); over `Nothing` the
|
||
/// item pops and any other payload silently clears (`0x492d30`).
|
||
#[derive(Clone, Copy, PartialEq, Eq, Default, Debug)]
|
||
pub enum WorldPick {
|
||
/// No world hit at all (sky / beyond the world) — the reference's state 0.
|
||
#[default]
|
||
Nothing,
|
||
/// The cursor ray hits the world (terrain/WMO/doodad) but no unit or GameObject — state 1.
|
||
Terrain,
|
||
/// A unit or GameObject is the pick — state 2.
|
||
Object,
|
||
}
|
||
|
||
/// A world drop: a completed left CLICK on the game world while a payload is held — press and
|
||
/// release both on the world (the world frame, or no frame at all where none is loaded — the
|
||
/// caller's `over_world`, decision 2089), no drag (the byte-verified trigger, decision 0218: the
|
||
/// client's `0x495300` runs on the WorldFrame click release only; a drag released over the world
|
||
/// routes as a drag and keeps carrying) — routed by [`Model::world_pick`] (decisions 0571 + 0574,
|
||
/// byte-verified wow-re cursor-dragdrop-payload.md §11):
|
||
///
|
||
/// - `Object`: NOTHING drops — the object leg (`0x492ce0`) keeps every real payload and
|
||
/// dispatches SELECT instead.
|
||
/// - `Terrain`: an item payload fires `DELETE_ITEM_CONFIRM(name, quality)` and STAYS held (the
|
||
/// reference popup's `OnAccept` calls `DeleteCursorItem`, `OnCancel` `ClearCursor`, and its
|
||
/// `OnUpdate` auto-hides when the cursor empties — the engine must not pre-clear); a
|
||
/// spell/action payload clears silently — a DELIBERATE divergence (decision 0843, the
|
||
/// director's call): the reference's terrain leg keeps a non-item payload on the left click
|
||
/// (`0x492c90`/`0x5e0320` clear non-items only on the right button), which leaves a spell
|
||
/// stuck to the cursor with no left-handed way off it.
|
||
/// - `Nothing`: the item pops the same popup; any other payload clears silently (`0x492d30`'s
|
||
/// non-item arm — here the reference agrees).
|
||
///
|
||
/// Returns whether the click was consumed as a drop (an empty cursor or an `Object` pick
|
||
/// consume nothing).
|
||
pub(crate) fn world_drop_click(model: &mut Model) -> bool {
|
||
match (&model.cursor, model.world_pick) {
|
||
(Some(CursorPayload::Item(item)), WorldPick::Terrain | WorldPick::Nothing) => {
|
||
let item = item.clone();
|
||
queue_delete_item_confirm(model, &item);
|
||
true
|
||
}
|
||
(
|
||
Some(
|
||
CursorPayload::Spell(_)
|
||
| CursorPayload::Action(_)
|
||
| CursorPayload::Macro(_)
|
||
| CursorPayload::PetAction(_),
|
||
),
|
||
WorldPick::Terrain | WorldPick::Nothing,
|
||
) => {
|
||
clear_cursor(model);
|
||
true
|
||
}
|
||
_ => false,
|
||
}
|
||
}
|
||
|
||
/// `SplitContainerItem(bag, slot, count)` — a stack-split pickup (`StackSplitFrame.lua`'s
|
||
/// `SplitStack`): only from an EMPTY cursor, onto a resolved and unlocked slot, `count >= 1`.
|
||
/// `count` at or past the whole stack degrades to a plain whole-stack pickup (`count: None` — the
|
||
/// client never "splits" a full stack; vmangos rejects `count == stack` as cheat). Returns
|
||
/// whether a payload was picked up (the repaint gate, matching `pickup_container_item`'s
|
||
/// contract).
|
||
pub(crate) fn split_container_item(model: &mut Model, bag: i64, slot: u32, count: i64) -> bool {
|
||
if model.cursor.is_some() || count < 1 {
|
||
return false;
|
||
}
|
||
let Some(s) = model.containers.get(&bag).and_then(|c| c.slots.get(&slot)) else {
|
||
return false;
|
||
};
|
||
if s.item_id == 0 || s.locked {
|
||
return false;
|
||
}
|
||
let n = count.min(i64::from(s.count)) as u32;
|
||
let whole = n >= s.count;
|
||
let item = CursorItem {
|
||
bag,
|
||
slot,
|
||
item_id: s.item_id,
|
||
texture: s.texture.clone(),
|
||
link: s.link.clone(),
|
||
quality: s.quality,
|
||
count: if whole { None } else { Some(n) },
|
||
bar_placeable: s.bar_placeable,
|
||
equip_slots: s.equip_slots.clone(),
|
||
};
|
||
model.cursor = Some(CursorPayload::Item(item));
|
||
queue_cursor_update(model);
|
||
queue_lock_changed(model, bag, slot);
|
||
true
|
||
}
|
||
|
||
/// `DeleteCursorItem()` — the delete popup's `OnAccept` (decision 0216 §3): an Item payload
|
||
/// queues its `(bag, slot, count)` destroy (`count == 0` = the whole stack) and clears; any other
|
||
/// payload (or an empty cursor) is a no-op — the client's contract for a stale/mismatched confirm.
|
||
pub(crate) fn delete_cursor_item(model: &mut Model) {
|
||
let Some(CursorPayload::Item(item)) = &model.cursor else {
|
||
return;
|
||
};
|
||
let item = item.clone();
|
||
model.cursor = None;
|
||
model
|
||
.container_destroys
|
||
.push((item.bag, item.slot, item.count.unwrap_or(0)));
|
||
queue_cursor_update(model);
|
||
queue_lock_changed(model, item.bag, item.slot);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// UiScript accessors
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
impl super::UiScript {
|
||
/// The item currently held on the cursor, or `None` — the Item-arm projection of
|
||
/// [`UiScript::cursor_payload`], kept for the app's existing extract/sound watchers
|
||
/// (`ui_script/extract.rs`, `sound/ui.rs`), which only ever cared about items.
|
||
pub fn cursor_item(&self) -> Option<CursorItem> {
|
||
match self.model_ref().cursor.clone() {
|
||
Some(CursorPayload::Item(item)) => Some(item),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// The full cursor payload, any arm — what the app's hardware-cursor drive
|
||
/// (`benilla::cursor`) and capture stand-in draw, and what the world-click consumers gate on.
|
||
pub fn cursor_payload(&self) -> Option<CursorPayload> {
|
||
self.model_ref().cursor.clone()
|
||
}
|
||
|
||
/// Feed: what the app's world pick resolves under the cursor this frame
|
||
/// ([`Model::world_pick`], decisions 0571 + 0574) — routes [`world_drop_click`]'s legs
|
||
/// (object keeps everything, terrain drops items only, nothing drops any arm).
|
||
pub fn set_world_pick(&mut self, pick: WorldPick) {
|
||
self.model_mut().world_pick = pick;
|
||
}
|
||
|
||
/// The sell fork's take-and-clear, for the app-side interact ladder — see
|
||
/// [`take_cursor_item_for_sale`]. `None` for an empty cursor or any non-item payload, and in
|
||
/// that case the payload is left untouched.
|
||
pub fn take_cursor_item_for_sale(&mut self) -> Option<CursorItem> {
|
||
take_cursor_item_for_sale(&mut self.model_mut())
|
||
}
|
||
|
||
/// `ClearCursor()`'s Rust seam — drops whatever the cursor holds, any arm, silently (fires
|
||
/// `CURSOR_UPDATE` + the item source un-lock, exactly the Lua `ClearCursor`). The app's
|
||
/// world-click router calls it for a clean RIGHT-click over empty world (`0x492c90`/
|
||
/// `0x492d30`'s action-4 leg: `ClearCursor 0x495190(1,1)` unconditionally — decision 0571).
|
||
pub fn clear_cursor_payload(&mut self) {
|
||
clear_cursor(&mut self.model_mut());
|
||
}
|
||
|
||
/// Test-only: set the cursor payload directly, bypassing every Lua binding — drives the
|
||
/// Spell/Action arms in tests before a spellbook/action-bar slice can produce them.
|
||
#[cfg(test)]
|
||
pub(crate) fn set_cursor_for_test(&mut self, payload: CursorPayload) {
|
||
self.model_mut().cursor = Some(payload);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
// Lua globals: GetCursorInfo / CursorHasItem / CursorHasSpell / ClearCursor / SplitContainerItem /
|
||
// DeleteCursorItem — all top-level (the real client's `GetCursorInfo` &c. are not namespaced).
|
||
impl super::UiScript {
|
||
/// Arm (or disarm) **the targeting cursor's item half** — the engine mirror of
|
||
/// `TargetingWantsItem 0x6e6330`. While armed, a bag click ([`super::container`]) or a
|
||
/// paper-doll click ([`doll::pickup_inventory_item`]) queues its `(bag, slot)` into the pick
|
||
/// list instead of running the cursor gesture, exactly as the reference's two pickup
|
||
/// functions reroute (`0x4f9b30` @ `4f9c54`, `0x4c7300` @ `4c76df`). The app owns the word:
|
||
/// it arms on a resolved item-targeted cast and clears on the bind, a cancel, or ESC
|
||
/// (decision 0923 — before it, this was the CraftFrame's private enchant pick).
|
||
pub fn set_item_pick_armed(&mut self, armed: bool) {
|
||
self.model_mut().item_pick_armed = armed;
|
||
}
|
||
|
||
/// Push what `SpellCanTargetUnit` answers — whether the standing targeting word could bind a
|
||
/// unit at all (see [`crate::script::Model::spell_can_target_unit`]).
|
||
pub fn set_spell_can_target_unit(&mut self, can: bool) {
|
||
self.model_mut().spell_can_target_unit = can;
|
||
}
|
||
|
||
/// Drain the `(bag, slot)` clicks the armed item half consumed since the last call. A doll
|
||
/// click reports as [`EQUIPMENT_BAG`] + its 1-based inventory slot — the ONE bag space, so
|
||
/// the app resolves both seams with one lookup.
|
||
pub fn take_item_picks(&mut self) -> Vec<(i64, u32)> {
|
||
std::mem::take(&mut self.model_mut().item_picks)
|
||
}
|
||
|
||
/// Drain the enchant-confirm popups' answers since the last call (decision 0928). Both are
|
||
/// answers to the *same* pick the app parked, which is why they share one queue.
|
||
pub fn take_enchant_confirms(&mut self) -> Vec<EnchantConfirm> {
|
||
std::mem::take(&mut self.model_mut().enchant_confirms)
|
||
}
|
||
}
|
||
|
||
/// A Yes on one of the enchant-apply confirms — the two Lua globals `StaticPopup.lua` calls from
|
||
/// `BIND_ENCHANT`'s and `REPLACE_ENCHANT`'s `OnAccept` (decision 0928). Plain intents; the app
|
||
/// holds the item guid they answer for (the reference's `0xb4e3c0/0xb4e3c4`).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum EnchantConfirm {
|
||
/// `BindEnchant()` — `0x48d2e0`, which re-invokes the enchant-apply gate `0x495d60` over the
|
||
/// parked guid with its "already confirmed" parameter set. So this is NOT a send: the gate
|
||
/// runs again and may raise the *replace* popup next.
|
||
Bind,
|
||
/// `ReplaceEnchant()` — `0x48d300`, which re-resolves the parked guid and calls the ordinary
|
||
/// target binder `0x6e5b40` **directly**, skipping the gate. There is no
|
||
/// `CMSG_REPLACE_ENCHANT` in 5875: the popup is pure client-side gating over the same cast.
|
||
Replace,
|
||
}
|
||
|
||
// The paper-doll globals (`PickupInventoryItem` &c.) install from [`doll`]; the action-bar
|
||
// globals (`PickupAction`/`PlaceAction`) install from [`bar`].
|
||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||
|
||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||
let g = lua.globals();
|
||
|
||
// BindEnchant() / ReplaceEnchant() — the two enchant-confirm popups' OnAccept (StaticPopup.lua
|
||
// `BIND_ENCHANT` l.1240 and `REPLACE_ENCHANT` l.1252). Both queue an intent the app answers
|
||
// over its parked item guid; neither sends anything from here.
|
||
for (name, answer) in [
|
||
("BindEnchant", EnchantConfirm::Bind),
|
||
("ReplaceEnchant", EnchantConfirm::Replace),
|
||
] {
|
||
g.set(
|
||
name,
|
||
lua.create_function(move |lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
model.enchant_confirms.push(answer);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
}
|
||
|
||
// GetCursorInfo() → per arm: Item ("item", itemID, itemLink); Spell ("spell", book_slot,
|
||
// book_type, spell_id) — the Era shape; Action ("action", src_slot). Empty ⇒ all nils. A
|
||
// fixed 4-return shape (padded with nil past each arm's own fields) so one function signature
|
||
// covers every arm; callers destructuring fewer names simply ignore the tail.
|
||
g.set(
|
||
"GetCursorInfo",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let str_or_nil = |lua: &Lua, s: &Option<String>| -> mlua::Result<Value> {
|
||
match s {
|
||
Some(s) => Ok(Value::String(lua.create_string(s)?)),
|
||
None => Ok(Value::Nil),
|
||
}
|
||
};
|
||
match &model.cursor {
|
||
Some(CursorPayload::Item(c)) => Ok((
|
||
Value::String(lua.create_string("item")?),
|
||
Value::Integer(i64::from(c.item_id)),
|
||
str_or_nil(lua, &c.link)?,
|
||
Value::Nil,
|
||
)),
|
||
Some(CursorPayload::Spell(s)) => Ok((
|
||
Value::String(lua.create_string("spell")?),
|
||
Value::Integer(i64::from(s.book_slot)),
|
||
Value::String(lua.create_string(&s.book_type)?),
|
||
Value::Integer(i64::from(s.spell_id)),
|
||
)),
|
||
Some(CursorPayload::Action(a)) => Ok((
|
||
Value::String(lua.create_string("action")?),
|
||
Value::Integer(i64::from(a.src_slot)),
|
||
Value::Nil,
|
||
Value::Nil,
|
||
)),
|
||
// The Era `GetCursorInfo` shape for a macro: the kind word + the macro index.
|
||
Some(CursorPayload::Macro(m)) => Ok((
|
||
Value::String(lua.create_string("macro")?),
|
||
Value::Integer(i64::from(m.index)),
|
||
Value::Nil,
|
||
Value::Nil,
|
||
)),
|
||
// The pet arm follows the Action arm's shape — kind word + the slot it came from.
|
||
// Nothing in the shipped 1.12 UI reads it (the pet bar's own drag never asks what
|
||
// it is carrying); it is here so the ONE payload space stays fully describable.
|
||
Some(CursorPayload::PetAction(p)) => Ok((
|
||
Value::String(lua.create_string("petaction")?),
|
||
Value::Integer(i64::from(p.src_slot)),
|
||
Value::Nil,
|
||
Value::Nil,
|
||
)),
|
||
// Mode 10 — the stabled-pet grab (decision 1677). Reported as nothing: the
|
||
// reference's `GetCursorInfo` arms are not carved for it, the stable window never
|
||
// asks, and inventing a type string would put a guess where an addon could read it.
|
||
Some(CursorPayload::StablePet(_)) => {
|
||
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
|
||
}
|
||
// Mode 2 — coins (1962). Reported as nothing for the same reason: the money arm
|
||
// of `GetCursorInfo` is not carved, and `GetCursorMoney` is the reference's own
|
||
// way of asking. Joins the carve when it lands.
|
||
Some(CursorPayload::Money(_)) => {
|
||
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
|
||
}
|
||
// Mode 5 — the vendor grab. Reported as nothing, for the same reason and with a
|
||
// stronger warrant: **no registered binding in the 5875 image exposes mode 5 to
|
||
// Lua at all.** `CursorHasItem`, `CursorHasSpell`, `CursorHasMoney` and
|
||
// `CursorCanGoInSlot` are each nil for it, by census. A vendor cursor is invisible
|
||
// from Lua and that invisibility is load-bearing — it is what keeps all thirteen
|
||
// stock `CursorHasItem()` gates closed while one is held.
|
||
Some(CursorPayload::Merchant(_)) => {
|
||
Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil))
|
||
}
|
||
None => Ok((Value::Nil, Value::Nil, Value::Nil, Value::Nil)),
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"CursorHasItem",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(matches!(model.cursor, Some(CursorPayload::Item(_))))
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"CursorHasSpell",
|
||
lua.create_function(|lua, ()| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(matches!(model.cursor, Some(CursorPayload::Spell(_))))
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"ClearCursor",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
clear_cursor(&mut model);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
g.set(
|
||
"SplitContainerItem",
|
||
lua.create_function(|lua, (bag, slot, count): (i64, u32, i64)| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
Ok(split_container_item(&mut model, bag, slot, count))
|
||
})?,
|
||
)?;
|
||
g.set(
|
||
"DeleteCursorItem",
|
||
lua.create_function(|lua, ()| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
delete_cursor_item(&mut model);
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
doll::install(lua)?;
|
||
bag_verbs::install(lua)?;
|
||
bar::install(lua)?;
|
||
pet::install(lua)?;
|
||
money::install(lua)?;
|
||
|
||
Ok(())
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::{CursorAction, CursorItem, CursorPayload, CursorSpell};
|
||
use crate::script::UiScript;
|
||
|
||
/// A one-item, one-slot backpack (mirrors `container::tests::backpack` closely enough for
|
||
/// these payload-shape tests, which don't need its in-flight slot).
|
||
fn one_item_backpack() -> crate::script::container::ContainerState {
|
||
let mut slots = std::collections::HashMap::new();
|
||
slots.insert(
|
||
1,
|
||
crate::script::container::ContainerSlot {
|
||
duration_ms: None,
|
||
petition: None,
|
||
already_bound: false,
|
||
bar_placeable: true,
|
||
durability: None,
|
||
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
|
||
count: 5,
|
||
quality: Some(3),
|
||
item_id: 117,
|
||
link: Some("|cffffffff|Hitem:117|h[Tough Jerky]|h|r".into()),
|
||
locked: false,
|
||
equip_slots: Vec::new(),
|
||
cooldown: None,
|
||
readable: false,
|
||
creator: None,
|
||
flags: 0,
|
||
enchants: Vec::new(),
|
||
},
|
||
);
|
||
crate::script::container::ContainerState {
|
||
name: Some("Backpack".into()),
|
||
num_slots: 16,
|
||
slots,
|
||
}
|
||
}
|
||
|
||
/// **The sell clear is not `ClearCursor`** — decision 1914, and the difference is the one a
|
||
/// player sees.
|
||
///
|
||
/// `0x494b60 SetCursorItem(0, …)` drops the payload and fires `CURSOR_UPDATE`, but its mode-1
|
||
/// arm skips `0x495420` with `param1 = 0`, so it never un-locks the source slot: the item is
|
||
/// off the cursor and the bag slot stays **greyed** until the server's inventory update takes
|
||
/// it away. `ClearCursor` un-locks. Firing `ITEM_LOCK_CHANGED` here would un-grey a slot whose
|
||
/// item is still sitting in it awaiting the server, which is the visible wrong.
|
||
///
|
||
/// The second half is the blast radius: the fork answers for **mode 1 only**, so asking a
|
||
/// spell or a vendor row must not drop it.
|
||
#[test]
|
||
fn the_sell_clear_keeps_the_source_slot_locked() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_container(0, Some(one_item_backpack()));
|
||
|
||
// Pick the item up, then drain whatever the pickup queued so the assert below sees only
|
||
// what the SELL clear itself fires.
|
||
s.eval::<()>("PickupContainerItem(0, 1)").unwrap();
|
||
assert!(s.cursor_item().is_some(), "the pickup did not take");
|
||
s.model_mut().pending_events.clear();
|
||
|
||
let sold = s.take_cursor_item_for_sale().expect("mode 1 is a sale");
|
||
assert_eq!((sold.bag, sold.slot), (0, 1));
|
||
assert!(
|
||
s.cursor_payload().is_none(),
|
||
"the cursor kept the sold item"
|
||
);
|
||
|
||
let fired: Vec<String> = s
|
||
.model_ref()
|
||
.pending_events
|
||
.iter()
|
||
.map(|(e, _)| e.clone())
|
||
.collect();
|
||
assert!(
|
||
fired.iter().any(|e| e == "CURSOR_UPDATE"),
|
||
"the sell clear must fire CURSOR_UPDATE — got {fired:?}"
|
||
);
|
||
assert!(
|
||
!fired.iter().any(|e| e == "ITEM_LOCK_CHANGED"),
|
||
"the sell clear must NOT un-lock the source slot (that is `ClearCursor`'s job, not \
|
||
`SetCursorItem(0, …)`'s) — got {fired:?}"
|
||
);
|
||
|
||
// Mode 1 only: every other payload is left exactly where it was.
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
|
||
book_slot: 1,
|
||
book_type: "spell".into(),
|
||
spell_id: 133,
|
||
texture: None,
|
||
passive: false,
|
||
}));
|
||
assert!(
|
||
s.take_cursor_item_for_sale().is_none(),
|
||
"a spell is not a sale"
|
||
);
|
||
assert!(
|
||
matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))),
|
||
"asking for a sale dropped a non-item payload"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn get_cursor_info_and_has_checks_per_arm() {
|
||
let mut s = UiScript::new().unwrap();
|
||
|
||
// Empty: all nils, both Has* false.
|
||
assert!(s
|
||
.eval::<bool>("local k = GetCursorInfo() return k == nil")
|
||
.unwrap());
|
||
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
assert!(!s.eval::<bool>("return CursorHasSpell()").unwrap());
|
||
|
||
// Item arm.
|
||
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
|
||
bar_placeable: true,
|
||
bag: 0,
|
||
slot: 1,
|
||
item_id: 117,
|
||
texture: None,
|
||
link: Some("|Hitem:117|h[Tough Jerky]|h".into()),
|
||
count: None,
|
||
quality: Some(3),
|
||
equip_slots: Vec::new(),
|
||
}));
|
||
let (kind, id, link) = s
|
||
.eval::<(String, i64, String)>("local k, id, link = GetCursorInfo() return k, id, link")
|
||
.unwrap();
|
||
assert_eq!(
|
||
(kind.as_str(), id, link.as_str()),
|
||
("item", 117, "|Hitem:117|h[Tough Jerky]|h")
|
||
);
|
||
assert!(s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
assert!(!s.eval::<bool>("return CursorHasSpell()").unwrap());
|
||
|
||
// Spell arm — the Era 4-tuple shape.
|
||
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
|
||
passive: false,
|
||
book_slot: 3,
|
||
book_type: "spell".into(),
|
||
spell_id: 133,
|
||
texture: None,
|
||
}));
|
||
let (kind, slot, book, spell_id) = s
|
||
.eval::<(String, i64, String, i64)>(
|
||
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id",
|
||
)
|
||
.unwrap();
|
||
assert_eq!(
|
||
(kind.as_str(), slot, book.as_str(), spell_id),
|
||
("spell", 3, "spell", 133)
|
||
);
|
||
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
assert!(s.eval::<bool>("return CursorHasSpell()").unwrap());
|
||
|
||
// Action arm.
|
||
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
|
||
src_slot: 12,
|
||
kind: 0,
|
||
action: 133,
|
||
texture: None,
|
||
}));
|
||
let (kind, slot) = s
|
||
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
|
||
.unwrap();
|
||
assert_eq!((kind.as_str(), slot), ("action", 12));
|
||
assert!(!s.eval::<bool>("return CursorHasItem()").unwrap());
|
||
assert!(!s.eval::<bool>("return CursorHasSpell()").unwrap());
|
||
}
|
||
|
||
#[test]
|
||
fn clear_cursor_widens_to_any_arm() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
|
||
passive: false,
|
||
book_slot: 1,
|
||
book_type: "spell".into(),
|
||
spell_id: 1,
|
||
texture: None,
|
||
}));
|
||
assert!(s.cursor_payload().is_some());
|
||
s.run("ClearCursor()").unwrap();
|
||
assert!(s.cursor_payload().is_none());
|
||
}
|
||
|
||
/// The world-drop pick routing (decisions 0571 + 0574 — wow-re §11, amended by 0843): a
|
||
/// spell/action payload clears silently on BOTH empty-world legs — terrain included, the
|
||
/// 0843 divergence (the reference keeps it on terrain; the director wants the left click to
|
||
/// dismiss) — while an item keeps its byte-faithful popup flow.
|
||
#[test]
|
||
fn world_drop_terrain_and_nothing_both_clear_a_spell_payload() {
|
||
use super::{world_drop_click, WorldPick};
|
||
let mut s = UiScript::new().unwrap();
|
||
for pick in [WorldPick::Terrain, WorldPick::Nothing] {
|
||
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
|
||
passive: false,
|
||
book_slot: 3,
|
||
book_type: "spell".into(),
|
||
spell_id: 133,
|
||
texture: None,
|
||
}));
|
||
s.model_mut().world_pick = pick;
|
||
assert!(
|
||
world_drop_click(&mut s.model_mut()),
|
||
"{pick:?}: the dismiss consumes the click"
|
||
);
|
||
assert!(
|
||
s.cursor_payload().is_none(),
|
||
"{pick:?}: the spell clears silently"
|
||
);
|
||
}
|
||
|
||
// An item drops (the popup path) on BOTH empty-world legs, never over an object.
|
||
let item = CursorPayload::Item(CursorItem {
|
||
bar_placeable: true,
|
||
bag: 0,
|
||
slot: 1,
|
||
item_id: 117,
|
||
texture: None,
|
||
link: None,
|
||
count: None,
|
||
quality: None,
|
||
equip_slots: Vec::new(),
|
||
});
|
||
s.set_cursor_for_test(item.clone());
|
||
s.model_mut().world_pick = WorldPick::Terrain;
|
||
assert!(
|
||
world_drop_click(&mut s.model_mut()),
|
||
"terrain: the item pops the popup"
|
||
);
|
||
assert!(s.cursor_item().is_some(), "and stays held");
|
||
s.model_mut().world_pick = WorldPick::Object;
|
||
assert!(
|
||
!world_drop_click(&mut s.model_mut()),
|
||
"object: nothing drops at all"
|
||
);
|
||
assert!(s.cursor_item().is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn delete_cursor_item_queues_destroy_and_clears() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_container(0, Some(one_item_backpack()));
|
||
s.run("PickupContainerItem(0, 1)").unwrap();
|
||
assert!(s.cursor_item().is_some());
|
||
|
||
s.run("DeleteCursorItem()").unwrap();
|
||
assert!(s.cursor_item().is_none(), "the payload clears");
|
||
assert_eq!(s.take_container_destroys(), vec![(0, 1, 0)]);
|
||
assert!(s.take_container_destroys().is_empty(), "drained");
|
||
}
|
||
|
||
#[test]
|
||
fn delete_cursor_item_is_a_no_op_off_the_item_arm() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
|
||
src_slot: 1,
|
||
kind: 0,
|
||
action: 1,
|
||
texture: None,
|
||
}));
|
||
s.run("DeleteCursorItem()").unwrap();
|
||
assert!(
|
||
s.cursor_payload().is_some(),
|
||
"non-item payload is untouched"
|
||
);
|
||
assert!(s.take_container_destroys().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn split_of_the_whole_stack_degrades_to_a_plain_pickup() {
|
||
let mut s = UiScript::new().unwrap();
|
||
s.set_container(0, Some(one_item_backpack())); // slot 1: a 5-stack
|
||
assert!(s
|
||
.eval::<bool>("return SplitContainerItem(0, 1, 5)")
|
||
.unwrap());
|
||
let held = s.cursor_item().expect("picked up");
|
||
assert_eq!(held.count, None, "count>=stack ⇒ whole-stack pickup");
|
||
}
|
||
}
|