- benilla loads real 1.12 addons from Interface/AddOns — TOC, XML and Lua through one loader, with per-addon saved variables, Bindings.xml and the lifecycle events addons expect - both AddOns screens are in: the character-select list with per-addon enable state, and the ESC menu's in-game list - the API surface is measured, not guessed — a corpus harness runs hundreds of real 1.12 addons and ranks exactly which globals, templates and verbs they demand - the Lua dialect is the client's own, read off the binary — 5.0 semantics with the 1.12 additions, and UI source files are bytes, not UTF-8 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
801 lines
37 KiB
Rust
801 lines
37 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 bar;
|
||
mod doll;
|
||
mod drag;
|
||
mod pet;
|
||
|
||
pub(crate) use bar::place_action;
|
||
pub(crate) use 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**; our Action arm is the client's bar-slot pickup; the money/preview arms stay 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),
|
||
}
|
||
|
||
/// 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,
|
||
}
|
||
|
||
/// 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) {
|
||
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(_),
|
||
) => {
|
||
queue_cursor_update(model);
|
||
}
|
||
None => {}
|
||
}
|
||
}
|
||
|
||
/// 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 over no frame, 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;
|
||
}
|
||
|
||
/// `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,
|
||
)),
|
||
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)?;
|
||
bar::install(lua)?;
|
||
pet::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 {
|
||
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,
|
||
}
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
}
|