Comments in benilla-ui: one line of fact each, no history, no record numbers (#143)

Part of #41: the comments of `benilla-ui` (the Lua UI engine, its FrameXML loader and its widgets) are rewritten to the rule in `docs/METHOD.md`, which is what the code does and the reference fact behind it, in a line. 43,140 comment lines become 15,161.

- **The mechanical strip.** The citations that were only a private record number go first, and each edit is shown to be a pure removal.
- **Wrong facts corrected.** The rewrite corrects the comments that stated wrong facts. Among them:
  - `0x64a480` is the case-sensitive compare (a byte `repz cmpsb`), and `0x64a4c0` the case-insensitive one.
  - A frame's id sits at `+0xb0`, not `+0xb4`. `EnableKeyboard`/`IsKeyboardEnabled` are `0x776ec0`/`0x776f90`.
  - A region's unpinned edge has no fallback to its owner's rect: the owner supplies only the scale.
  - `$parent` expands to the nearest named ancestor, or "Top" when none is named (`0x76c5b0`), not simply to the parent.
  - A StatusBar fills from its internal normalised fraction, not from `GetValue()`.
  - The addresses of `StartDuel` and `StartDuelUnit` were swapped.
  - Many "untraced" notes now give the traced reference fact: the drag threshold, the per-grip sizing edges, the empty-`autoFocus` skip, and `SetTexCoordModifiesRect`'s default.
  - Stock FrameXML line citations are checked against the stock 1.12 files and corrected. `ref-` file names become the stock file names.
- **Deviation labels.** "Deviation:" now marks only a choice with a reason. A surface that exists only for the retired Era addon target says so plainly: it is not 1.12.
- **Verification.** Every file strips to identical code, and `cargo fmt --check` is clean. Nine second readers compared every file old against new, restored the facts the rewrite had dropped, and checked each new citation against the code, the stock files, vmangos and the reference.

Behaviour differences the rewrite surfaced are filed as their own issues (#131–#142), and more are being validated. The comments here state what the code does. String literals are untouched; they are #92.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-25 01:58:49 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 8505187668
commit fd386e75ae
233 changed files with 14108 additions and 42094 deletions
+42 -151
View File
@@ -1,27 +1,4 @@
//! The three BAG-SLOT verbs — `PutItemInBag`, `PutItemInBackpack`, `PickupBagFromSlot`.
//!
//! CARVED, not inferred. The three findings that shape everything below:
//!
//! 1 · **`PutItemInBackpack()` IS `PutItemInBag(0xFF)`.** `0x4c7ed0` is a ten-byte thunk —
//! `mov ecx,0xff` / `jmp 0x4c7c00` — so there is one worker here too, and only the destination
//! differs. The `0xFF` path is *not* "bag 0": the destination guid is the player's own, which
//! makes the empty-slot leg unreachable and `0x4c7d3e je 0x4c7e0c` skip the swap fork outright,
//! so a bag dropped on the backpack button is auto-stored INTO it, never swapped with it.
//!
//! 2 · **`PutItemInBag` forks three ways**, on the state of the target slot and the held object:
//! empty slot → the paper-doll handler equips the held bag ([`super::doll::pickup_inventory_item`],
//! which is `0x4c7300` — the same function the byte path calls); occupied + the held object is a
//! CONTAINER → a bag↔bag swap; occupied + an ordinary item → auto-store into that container's
//! contents, with the client picking no destination slot at all.
//!
//! 3 · **The return value answers "was this click consumed as a placement against an existing
//! container?", not "did something get placed".** It is the Lua **number `1`**, never the boolean
//! `true` — and it is `nil` on the empty-slot leg that really does equip the bag, and whenever
//! the cursor is empty. That last one is the whole reason a plain left-click opens a bag:
//! `BagSlotButton_OnClick`'s `if ( not hadItem ) then ToggleBag(...)`.
//!
//! `PickupBagFromSlot` has its own, much shorter worker (`0x4c7b00`) and is a *destructive*
//! pickup: it drops whatever the cursor already held before it looks at the slot.
//! The bag-slot verbs: `PutItemInBag`, `PutItemInBackpack` and `PickupBagFromSlot`.
use mlua::{Lua, MultiValue, Value};
@@ -31,21 +8,9 @@ use crate::script::Model;
use super::{clear_cursor, queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload};
use super::{doll::pickup_inventory_item, EQUIPMENT_BAG};
/// The live-API inventory ids the two verbs' shared argument reader (`0x4c8520`) accepts, minus
/// what each binding's own floor gate (`cmp ecx,0x13; jl` on the 0-based value — `0x4c8f1f`,
/// `0x4c8fbf`) then cuts off. In the space a Lua caller writes:
///
/// | band | what it is |
/// |---|---|
/// | 20..=23 | the four EQUIPPED bag slots (`Bag0Slot`..`Bag3Slot`) |
/// | 40..=63 | the bank's 24 generic slots |
/// | 64..=69 | the six BANK BAG slots |
/// | 82..=113 | the keyring |
///
/// The reader rejects the backpack's own item slots (24..=39) and buyback (70..=81); the floor
/// cuts equipment (1..=19) and ammo. **So a macro may legally hand either verb a bank item slot
/// or a keyring slot, and nothing downstream catches it** — the reference sends a packet the
/// server refuses. See [`autostore_container`] for the one place benilla is tighter, and why.
/// The ids `PutItemInBag` and `PickupBagFromSlot` take: what their argument reader (`0x4c8520`)
/// accepts above each floor gate (`0x4c8f1f`, `0x4c8fbf`), so the equipped bags 20-23, the
/// bank's slots 40-63, its bag slots 64-69 and the keyring 82-113.
fn accepts(live: u32) -> bool {
(20..=23).contains(&live)
|| (40..=63).contains(&live)
@@ -53,19 +18,9 @@ fn accepts(live: u32) -> bool {
|| (82..=113).contains(&live)
}
/// The live-API **container** id a bag slot's own inventory id names — the inverse of
/// `ContainerIDToInventoryID` (`crate::script::container`'s own table): 20..=23 → 1..=4, 64..=69 →
/// 5..=10. `None` for an inventory id that is not a bag slot.
///
/// **This is the one place benilla's gate is tighter than the engine's**, and it is deliberate.
/// [`accepts`] admits the bank's generic slots and the keyring because the reference's argument
/// reader does; the reference then auto-stores *into* them, sending
/// `CMSG_AUTOSTORE_BAG_ITEM(…, dstbag = live − 1)` for a `dstbag` that names no container, which
/// the server refuses (`EQUIP_ERR_ITEM_DOESNT_GO_TO_SLOT`). benilla has no container id to name
/// there, so [`put_item_in_bag`]'s auto-store leg answers `nil` and sends nothing. No shipped
/// window produces the call — the reference's own two callers are `BagSlotButton_OnClick` and
/// `BankFrameItemButtonBag_OnClick`, both of which pass a bag slot — and a refusal the player
/// never sees is not worth a packet.
/// The live container id of a bag slot, the inverse of `ContainerIDToInventoryID`. Deviation:
/// an auto-store into a bank item or keyring slot sends nothing, because no container id names
/// it and no stock caller passes one; the reference sends it and the server refuses.
fn autostore_container(live: u32) -> Option<i64> {
match live {
20..=23 => Some(i64::from(live) - 19),
@@ -74,34 +29,23 @@ fn autostore_container(live: u32) -> Option<i64> {
}
}
/// `Bag0Slot` — the first of the four EQUIPPED bag slots, and the tell that an item is a
/// container. See [`is_container`].
/// `Bag0Slot`, the first equipped bag slot: only a container names it in `equip_slots`.
const BAG0_SLOT: u8 = 20;
/// The six BANK BAG slots' live-API ids (`BankButtonIDToInvSlotID(1..6, isBag)`).
/// The six bank bag slots' ids (`BankButtonIDToInvSlotID(1..6, isBag)`).
pub(super) const BANK_BAG_INV_SLOTS: std::ops::RangeInclusive<u32> = 64..=69;
/// Whether the held item is a CONTAINER — the fork the reference reads straight off the object
/// (`0x4c7dc4 shr eax,2; test al,1`, `OBJECT_FIELD_TYPE`'s `TYPEMASK_CONTAINER` bit).
///
/// benilla has no object-type field on a cursor payload, and asks the equivalent question of the
/// data it does carry: `equip_slots` is the server's own `Player::FindEquipSlot`, and
/// `INVTYPE_BAG` is the ONLY inventory type in that table that yields the bag slots
/// (`ui_items::find_equip_slot`, arm 18). So "this item names Bag0Slot as a place it could be
/// worn" and "this item is a container" are the same set.
/// Whether the held item is a container, the reference's `TYPEMASK_CONTAINER` test (`0x4c7dc4`):
/// only `INVTYPE_BAG` maps to the bag slots in `FindEquipSlot`, so no other item names Bag0Slot.
fn is_container(item: &CursorItem) -> bool {
item.equip_slots.contains(&BAG0_SLOT)
}
/// Whether the held item may be dropped on doll slot `id` — the fit rule, one band wider than
/// `equip_slots` alone.
///
/// The four EQUIPPED bag slots are in `equip_slots` because the server's `FindEquipSlot` names
/// them; the six BANK BAG slots never are, because `FindEquipSlot` does not know about the bank
/// (it is `CanBankItem`'s business there). The client's own predicate `CursorCanGoInSlot`
/// (`0x5ea720`) is 0216 §5's unresolved residual, so the rule here is stated from the observable:
/// a bank bag slot takes a container and nothing else, and the server's `CanBankItem` referees
/// either way through `SMSG_INVENTORY_CHANGE_FAILURE`.
/// Whether the held item may go in doll slot `id`: its `equip_slots`, or a bank bag slot for a
/// container, which `FindEquipSlot` never names. The reference's fit check behind
/// `CursorCanGoInSlot` (`0x5ea720`) passes any item at a bank bag slot, as `IsValidForSlot`
/// answers 1 for every 0-based slot from 23 (`0x5da215`); the server's `CanBankItem` referees
/// either way.
pub(super) fn fits_slot(item: &CursorItem, id: u32) -> bool {
if BANK_BAG_INV_SLOTS.contains(&id) {
return is_container(item);
@@ -109,17 +53,16 @@ pub(super) fn fits_slot(item: &CursorItem, id: u32) -> bool {
u8::try_from(id).is_ok_and(|id| item.equip_slots.contains(&id))
}
/// `PutItemInBag(inventorySlot)` — module doc, finding 2. Returns the reference's own `1`/`nil`
/// as a bool the binding maps ("was this click consumed as a placement against an existing
/// container?", NOT "did something get placed").
/// `PutItemInBag(inventorySlot)` (`0x4c7c00`): true (the reference's `1`) when the click was
/// consumed against an occupied slot, else false (nil). An empty cursor's nil is what lets
/// `BagSlotButton_OnClick` open the bag.
pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
if !accepts(live) {
return false;
}
// `0x4c7ce4 je 0x4c7eaf` — an empty cursor is the FIRST thing tested, and it answers `nil`
// with nothing sent. A Spell/Action/Macro/PetAction/StablePet payload is not the client's
// item cursor either, and takes the same exit: this is what leaves a plain click free to open
// the bag.
// An empty or non-item cursor answers nil and sends nothing. At an occupied slot the
// reference clears such a cursor (`0x4c7ce4`, `0x4c7eaf`), buying a held vendor row first
// (`0x4c7e66`).
let Some(CursorPayload::Item(held)) = model.cursor.clone() else {
return false;
};
@@ -129,16 +72,13 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
.is_some();
if !occupied {
// Leg 1 — the EMPTY slot. The byte path calls `0x4c7300`, the paper-doll handler, which
// is `pickup_inventory_item`'s place arm here: the fit predicate, the queued move, the
// clear and both events, all of it already the doll's. `0x4c7cae xor eax,eax` is
// unconditional, so this answers `nil` whether or not the bag actually went in.
// An empty slot: the doll handler equips the bag (`0x4c7300`), and the answer is nil
// whether or not it went in (`0x4c7cae xor eax,eax`).
pickup_inventory_item(model, live);
return false;
}
// A pure cancel: the held item IS this bag (put back where it came from). Sends nothing, and
// answers `1` — the click WAS consumed.
// The held bag is this slot's own: a cancel that sends nothing and answers 1.
if held.bag == EQUIPMENT_BAG && held.slot == live {
model.cursor = None;
queue_cursor_update(model);
@@ -147,12 +87,8 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
}
if is_container(&held) || held.bag == EQUIPMENT_BAG {
// Leg 2 — bag↔bag SWAP (`0x5e0c40`, `CMSG_SWAP_ITEM`/`CMSG_SWAP_INV_ITEM`). "Came from a
// bag equipment slot" is the reference's second entry to this fork, hence the `||`. The
// existing move drain already picks the opcode off the two endpoints.
//
// A split carry cannot swap: half a stack is not a bag. The reference reaches its SPLIT
// sender only from the auto-store leg, so this falls through to a refusal, kept.
// A swap (`0x5e0c40`); an item from a bag equipment slot enters this fork too, hence the
// `||`. A split stack cannot swap and stays held: the reference splits only when storing.
if held.count.is_none() {
model.cursor = None;
model.container_moves.push(ContainerMove {
@@ -170,9 +106,8 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
return true;
}
// Leg 3 — AUTO-STORE into the container's contents (`CMSG_AUTOSTORE_BAG_ITEM 0x10B`, or
// `CMSG_SPLIT_ITEM 0x10E` for a split carry). **The client never picks a destination slot**:
// AUTOSTORE carries none and SPLIT carries the literal `0xFF`.
// Store into the bag: `CMSG_AUTOSTORE_BAG_ITEM` (`0x10B`), or `CMSG_SPLIT_ITEM` (`0x10E`) for
// a split. The client picks no slot: the store carries none, the split carries `0xFF`.
let Some(dst_bag) = autostore_container(live) else {
return false;
};
@@ -188,9 +123,9 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
true
}
/// `PutItemInBackpack()` — module doc, finding 1: the same worker with the destination fixed to
/// the player's own guid, which makes the empty-slot and swap legs unreachable. Everything that
/// lands here auto-stores into the backpack.
/// `PutItemInBackpack()`, a thunk to `PutItemInBag(0xFF)` (`0x4c7ed0`) whose target is the
/// player's own guid: the empty-slot leg is unreachable and `0x4c7d3e` skips the swap, so even a
/// bag is stored into the backpack.
pub(super) fn put_item_in_backpack(model: &mut Model) -> bool {
let Some(CursorPayload::Item(held)) = model.cursor.clone() else {
return false;
@@ -207,23 +142,10 @@ pub(super) fn put_item_in_backpack(model: &mut Model) -> bool {
true
}
/// `PickupBagFromSlot(inventorySlot)` — a **destructive** pickup, not a restricted
/// `PickupInventoryItem` (`0x4c7b00`). Returns nothing.
///
/// Three things it does that the doll pickup does not:
///
/// · **`ClearCursor(1, 1)` unconditionally, before it looks at anything** (`0x4c7b79`) — whatever
/// was held is dropped and unlocked, even if the slot then turns out to be empty.
/// · Resolves the occupant with **`TYPEMASK_CONTAINER`** (`0x4c7bb3 mov ecx,4`, where
/// `PickupInventoryItem` uses `ecx = 2`). This is what makes it silently no-op at the bank item
/// slots and the keyring that its floor-only gate still admits: those hold ordinary items.
/// · Has **no place/swap path at all**, and none of `0x4c7300`'s targeting reroute, repair-cursor
/// leg or slot-`−1` ammo leg.
///
/// There is **no client-side "a bag must be empty to be moved" rule** — both workers were read end
/// to end and neither inspects container occupancy. That refusal is the server's
/// `Player::CanUnequipItem` (`EQUIP_ERR_CAN_ONLY_DO_WITH_EMPTY_BAGS`), arriving as
/// `SMSG_INVENTORY_CHANGE_FAILURE`.
/// `PickupBagFromSlot(inventorySlot)` (`0x4c7b00`): clears the cursor before it looks at the slot,
/// where the reference clears only a held item (`0x4c7b70`, `0x4c7b79`); takes only a container
/// (`0x4c7bb3`), and never places or swaps. The client has no empty-bag rule: the server refuses
/// with `EQUIP_ERR_CAN_ONLY_DO_WITH_EMPTY_BAGS`.
pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
if !accepts(live) {
return;
@@ -244,8 +166,6 @@ pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
})
// TYPEMASK_CONTAINER, applied after the view is built so the predicate reads off the same
// `equip_slots` every other bag decision here does ([`is_container`]).
.filter(is_container);
if let Some(item) = picked {
model.cursor = Some(CursorPayload::Item(item));
@@ -254,14 +174,11 @@ pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
}
}
/// Register the three verbs — top-level globals, like every other cursor binding.
/// Register the three verbs as globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
// The number `1` or nil — `0x6f3810` writes Lua tag 3 (number) with the IEEE-754 double
// `0x3FF0000000000000`; tag 1 (boolean) appears in neither body. FrameXML only ever tests it
// for truthiness, but an addon that compares against `true` must see what the reference gives
// it.
// The number 1 or nil, never a boolean: `0x6f3810` pushes Lua tag 3, a number.
fn consumed(yes: bool) -> mlua::Result<MultiValue> {
Ok(MultiValue::from_vec(vec![if yes {
Value::Number(1.0)
@@ -310,8 +227,7 @@ mod tests {
InventorySlots, UiScript,
};
/// An item that IS a bag — `equip_slots` is `find_equip_slot(INVTYPE_BAG)`, the only inventory
/// type in that table that names Bag0Slot, which is how [`super::is_container`] reads it.
/// An item that is a bag: `equip_slots` is `find_equip_slot(INVTYPE_BAG)`.
fn bag_slot_view(item_id: u32) -> InvSlotView {
InvSlotView {
item_id,
@@ -359,9 +275,6 @@ mod tests {
bags
}
/// Finding 3, the one every caller depends on: an empty cursor answers **nil** and sends
/// nothing, which is what leaves `BagSlotButton_OnClick`'s `if ( not hadItem ) then
/// ToggleBag(...)` free to open the bag.
#[test]
fn an_empty_cursor_answers_nil_and_sends_nothing() {
let mut s = UiScript::new().unwrap();
@@ -373,9 +286,7 @@ mod tests {
assert!(s.take_bag_autostores().is_empty());
}
/// Leg 1 — the EMPTY slot equips the held bag, and answers **nil** while doing it
/// (`0x4c7cae xor eax,eax` is unconditional). That pairing is the reference's, not a slip:
/// the click both puts the bag in and opens the window it just created.
/// The nil after the equip is the reference's (`0x4c7cae`), so the same click opens the bag.
#[test]
fn an_empty_bag_slot_equips_the_held_bag_and_still_answers_nil() {
let mut s = UiScript::new().unwrap();
@@ -398,9 +309,6 @@ mod tests {
assert!(s.take_bag_autostores().is_empty(), "an equip, not a store");
}
/// Leg 1's fit rule: a bank bag slot takes a CONTAINER and nothing else. An ordinary item
/// dropped on an empty bank bag slot is refused and kept — the server's `CanBankItem` would
/// refuse it anyway, and there is nothing to auto-store into.
#[test]
fn an_empty_bag_slot_refuses_an_ordinary_item() {
let mut s = UiScript::new().unwrap();
@@ -413,7 +321,6 @@ mod tests {
assert!(s.take_bag_autostores().is_empty());
}
/// Leg 2 — an OCCUPIED slot plus a held container is a bag↔bag swap, and answers `1`.
#[test]
fn an_occupied_bag_slot_swaps_with_a_held_bag_and_answers_one() {
let mut s = UiScript::new().unwrap();
@@ -435,8 +342,6 @@ mod tests {
);
}
/// Leg 3 — an OCCUPIED slot plus an ORDINARY item auto-stores INTO that bag. The intent
/// carries no destination slot, because the wire has none.
#[test]
fn an_occupied_bag_slot_auto_stores_an_ordinary_item() {
let mut s = UiScript::new().unwrap();
@@ -452,15 +357,13 @@ mod tests {
vec![BagAutoStore {
src_bag: 0,
src_slot: 2,
// Live container id 5 — bank bag slot 1 (`ContainerIDToInventoryID(5) == 64`).
// Container 5 is bank bag slot 1 (`ContainerIDToInventoryID(5) == 64`).
dst_bag: 5,
count: None,
}]
);
}
/// Finding 1 — `PutItemInBackpack()` skips both the empty-slot and the swap legs outright: a
/// held BAG dropped on the backpack button is auto-stored, never swapped with it.
#[test]
fn the_backpack_button_always_auto_stores_even_a_bag() {
let mut s = UiScript::new().unwrap();
@@ -480,9 +383,6 @@ mod tests {
);
}
/// Finding 3's literal shape: the reference pushes the Lua **number** `1` (`0x6f3810` writes
/// tag 3 with the IEEE-754 double `0x3FF0…`), never the boolean `true`. FrameXML only tests
/// truthiness; an addon comparing against `true` must see what the reference gives it.
#[test]
fn the_answer_is_the_number_one_not_the_boolean_true() {
let mut s = UiScript::new().unwrap();
@@ -494,8 +394,6 @@ mod tests {
.unwrap());
}
/// The floor gate (`cmp ecx,0x13; jl`): equipment slots and ammo are below it, so the verb
/// never reaches the paper-doll handler through them.
#[test]
fn equipment_slots_are_below_the_floor() {
let mut s = UiScript::new().unwrap();
@@ -512,7 +410,6 @@ mod tests {
assert!(s.take_bag_autostores().is_empty());
}
/// `PickupBagFromSlot` picks a bank bag up, returns NOTHING, and locks the slot.
#[test]
fn pickup_bag_from_slot_takes_the_bag_and_returns_nothing() {
let mut s = UiScript::new().unwrap();
@@ -530,9 +427,6 @@ mod tests {
assert!(s.eval::<bool>("return IsInventoryItemLocked(64)").unwrap());
}
/// **Destructive, and that is the finding**: `ClearCursor(1,1)` runs unconditionally before
/// the slot is even looked at (`0x4c7b79`), so a held payload is dropped even when the slot
/// turns out to hold nothing to pick up.
#[test]
fn pickup_bag_from_slot_drops_whatever_was_held_first() {
let mut s = UiScript::new().unwrap();
@@ -546,13 +440,10 @@ mod tests {
assert!(s.take_container_moves().is_empty(), "no place path exists");
}
/// The `TYPEMASK_CONTAINER` resolve (`mov ecx,4`, where `PickupInventoryItem` uses 2): the
/// bank's own item slots are inside the gate's range and hold ordinary items, so the verb
/// silently no-ops there. A locked bag is refused too.
#[test]
fn pickup_bag_from_slot_only_takes_containers_and_never_a_locked_one() {
let mut s = UiScript::new().unwrap();
// Bank vault slot 3 → live id 42, an ordinary item: inside the gate, not a container.
// Bank slot 3 is live id 42: inside the gate, but an ordinary item.
let mut vault = std::collections::HashMap::new();
vault.insert(
3,
+22 -110
View File
@@ -1,18 +1,7 @@
//! The action bar (decision 0216 §7, slice 4; byte-verified 0218 §2/§4): `PickupAction`/
//! `PlaceAction` join the ONE payload space as the [`CursorAction`] arm. Two things set this
//! surface apart from bags/doll:
//!
//! - **The bar is client-authoritative and actions HOP** (0218 §4, `PlaceAction 0x4e62e0`) — the
//! opposite of the post-0218 item swap: placing onto an occupied slot puts the DISPLACED action
//! on the cursor rather than clearing, because there is no server round-trip to wait on (the
//! 120-slot table is ours; [`place_action`] IS the mutation, not a request for one).
//! - **The engine owns the payload + transition, the app owns the 120-table** (0216 §7's
//! ownership split): `model.actions` here is the engine's own OPTIMISTIC mirror of the app's
//! authoritative store, kept only so `HasAction`/`GetActionTexture`/&c. read right the instant a
//! local pickup/place happens, without waiting a frame for the app to re-feed. Every mutation
//! also queues `(lua id, packed)` onto [`Model::action_sets`] — the wire intent the app drains
//! into `CMSG_SET_ACTION_BUTTON`, one send per queued entry (0218 §4: a drag-swap is two sends,
//! never atomic — this module never coalesces them).
//! The action-bar cursor verbs. The bar is client-authoritative: placing onto an occupied slot
//! hops the displaced action onto the cursor (`PlaceAction`, `0x4e62e0`). `model.actions` is an
//! optimistic mirror of the app's table; each change queues one `CMSG_SET_ACTION_BUTTON`, so a
//! drag-swap is two sends.
use mlua::Lua;
@@ -21,30 +10,18 @@ use crate::script::{ActionSlot, Model};
use super::{queue_cursor_update, CursorAction, CursorPayload};
/// The GlobalStrings key the reference's passive-spell refusal shows — errorId `0x9e`'s entry in
/// the errorId→key table (`0xb4b498 + 0x9e*0x14 = 0xb4c0f0` ← key ptr `0x84133c`), i.e.
/// `ERR_PASSIVE_ABILITY` = "You can't put a passive ability in the action bar.". Queued onto
/// `Model::ui_errors` for the app's action feed to resolve and fire.
/// The passive-spell refusal's error, errorId `0x9e`: entry `0xb4c0f0` of the table at `0xb4b498`
/// (stride `0x14`) names this key (`0x84133c`).
const PASSIVE_ON_BAR_ERROR: &str = "ERR_PASSIVE_ABILITY";
/// Pack `(kind, action)` into the wire's `u32` slot word (`kind<<24 | action`, decision 0216 §1).
/// The `CMSG_SET_ACTION_BUTTON` slot word.
fn pack(kind: u8, action: u32) -> u32 {
(u32::from(kind) << 24) | (action & 0x00FF_FFFF)
}
/// `PickupAction(id)` — the action-bar slot's shift-click/drag-start entry point (ref
/// `ActionBarFrame.xml:12-38`'s `IsShiftKeyDown()` fork, `OnDragStart`).
///
/// - **A payload is already held** → falls through to [`place_action`] (the reference's own
/// contract: a shift-click while carrying just places, `ActionButtonTemplate`'s `OnClick` never
/// special-cases it).
/// - **Empty cursor, an occupied slot** → picks it up: payload `Action { src_slot: id, kind,
/// action, texture }`, the slot removed from `model.actions` (optimistic — the app re-pushes an
/// agreeing snapshot once `action_sets` lands), and `action_sets.push((id, 0))` queued — picking
/// an action OFF the bar empties it immediately on the wire too (the classic drag-off).
/// - **Empty cursor, an empty slot** → no-op (nothing to pick up).
///
/// Returns whether the caller should repaint (mirrors the container/doll pickup contract).
/// `PickupAction(id)`, the slot's shift-click and drag start (`ActionBarFrame.xml:12-38`). With a
/// payload held it places instead; a pickup also clears the slot on the wire at once. Returns
/// whether to repaint.
pub(super) fn pickup_action(model: &mut Model, id: u32) -> bool {
if model.cursor.is_some() {
return place_action(model, id);
@@ -65,40 +42,14 @@ pub(super) fn pickup_action(model: &mut Model, id: u32) -> bool {
true
}
/// `PlaceAction(id)` — the action-bar slot's click-with-payload/`OnReceiveDrag` entry point (ref
/// `UseAction(id, checkCursor=1)`'s place fork, `ActionBarFrame.xml`'s `OnReceiveDrag`).
///
/// Every arm writes the held action into `model.actions[id]` optimistically and queues
/// `action_sets.push((id, packed))`; what happens to the cursor afterward is the byte-verified
/// divergence from every other surface (0218 §4): an OCCUPIED destination puts the DISPLACED
/// action on the cursor (referencing `id` as its new `src_slot` — the slot it can now be placed
/// FROM), an empty destination just clears.
///
/// - Payload **Action** → `(kind, action)` straight off the held payload.
/// - Payload **Item** → `packed = item_id | ITEM<<24`; the item came from a BAG and STAYS there —
/// a bar item action is a reference, not a move, so no container move is queued here (the app's
/// `drain_action_sets` never touches `container_moves` either).
/// - Payload **Spell** → `packed = spell_id | SPELL<<24` (the producer, `PickupSpell`, lands in
/// slice 5 — this arm already works once something populates the payload).
/// - **Empty cursor** → no-op.
///
/// Returns whether the caller should repaint.
/// `PlaceAction(id)`, the slot's `OnReceiveDrag` and `UseAction(id, 1)`'s place fork. An occupied
/// slot's action hops onto the cursor with `id` as its source; an item stays in its bag, since a
/// bar item is a reference, not a move. Returns whether to repaint.
pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
// The two accept filters, byte-read (`PlaceAction 0x4e62e0` — decision 0666). Both
// reject with a **bare return**: no store, no clear, no packet — mechanically identical to
// clicking with an empty cursor, and the refused payload STAYS on the cursor.
//
// - ITEM: placeable iff it has an on-use spell OR is equippable (`4e6571`–`4e6598`). There is
// no quality, bind, class/subclass, container or level test anywhere on that path — a BAG
// is placeable; a grey trade good with neither is silently refused.
// - SPELL: a passive (`Attributes & 0x40`) is refused (`4e63ad`), and — unlike the ITEM
// refusal, which is mute — it ALSO raises errorId `0x9e` through `CGGameUI::DisplayError`
// (`4e63ad: push 0x9e; call 0x496720`). That id resolves through the errorId→key table at
// base `0xb4b498`, stride `0x14`: entry `0xb4b498 + 0x9e*0x14 = 0xb4c0f0`, whose static-init
// `4861ff: mov [0xb4c0f0], 0x84133c` names [`PASSIVE_ON_BAR_ERROR`] — "You can't put a
// passive ability in the action bar." (The same arithmetic reproduces the independently
// recorded `ERR_ATTACK_MOUNTED` anchor: `0xa4` → `0xb4c168`.) Closes 0666's named
// divergence, which refused silently while the key was unpinned.
// The two accept filters (`0x4e62e0`) refuse with a bare return: nothing stored or sent, and
// the payload stays held. An item needs an on-use spell or an equip slot (`0x4e6571`), so a
// bag is placeable; a passive spell (`Attributes & 0x40`) also raises errorId `0x9e` through
// `DisplayError` (`0x4e63ad`, `0x496720`), where the item refusal is mute.
match &model.cursor {
Some(CursorPayload::Item(i)) if !i.bar_placeable => return false,
Some(CursorPayload::Spell(s)) if s.passive => {
@@ -114,24 +65,13 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
CursorPayload::Action(a) => Some((a.kind, a.action, a.texture.clone())),
CursorPayload::Item(i) => Some((ACTION_KIND_ITEM, i.item_id, i.texture.clone())),
CursorPayload::Spell(s) => Some((ACTION_KIND_SPELL, s.spell_id, s.texture.clone())),
// Mode 8 — the one non-item/non-spell payload the reference's `PlaceAction` accepts
// (`0x4e62e0`'s accept table: pet actions and class abilities are refused,
// macros are not). It packs the bare macro id under the MACRO tag, exactly as the SPELL
// and ITEM arms pack theirs.
// `PlaceAction` accepts modes 1, 3, 7 and 8 (`0x4e62e0`): a macro, mode 8, packs its id
// under the macro tag. Pet actions (4), coins (2), stabled pets (10) and vendor rows (5)
// are refused and stay held.
CursorPayload::Macro(m) => Some((ACTION_KIND_MACRO, m.index, m.texture.clone())),
// Mode 4 — the other half of that same table's refusal (decision 1010). A pet action has
// no `CMSG_SET_ACTION_BUTTON` encoding at all, so there is nothing to pack: the payload
// goes straight back on the cursor, where the pet bar can still take it.
CursorPayload::PetAction(_) => None,
// Mode 10 (decision 1677) — a stabled pet has no `CMSG_SET_ACTION_BUTTON` encoding either,
// so the action bar refuses it and it goes back on the cursor.
CursorPayload::StablePet(_) => None,
// Mode 2 (1962) — `PlaceAction`'s table takes modes 1, 2 and 8 … where its mode 2 is the
// BAR-slot pickup, not coins; the money payload has no encoding and goes back.
CursorPayload::Money(_) => None,
// Mode 5 — a vendor row is not a bar action. `PlaceAction`'s payload table takes modes
// 1, 2 and 8 only, and the merchant grab is none of them, so it goes back on the cursor
// where the vendor window can still take it.
CursorPayload::Merchant(_) => None,
};
let Some((kind, action, texture)) = placeable else {
@@ -144,9 +84,7 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
texture,
kind,
action,
// The engine holds no item knowledge, so neither half of the Count pair can be
// answered here: the app's next-frame re-feed resolves the real bag count and the
// `IsConsumableAction` gate together, off the item template (decision 1301).
// Count and consumable wait for the app's next-frame re-feed, which knows the item.
count: 0,
consumable: false,
},
@@ -164,9 +102,7 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
true
}
/// Register the action-bar's cursor globals — top-level, matching the reference
/// (`PickupAction`/`PlaceAction` are not namespaced any more than `PickupContainerItem`'s cursor
/// siblings are).
/// Register `PickupAction` and `PlaceAction` as globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -249,9 +185,6 @@ mod tests {
assert_eq!(s.take_action_sets(), vec![(7, 133)]);
}
/// The byte-verified divergence from bags/doll (0218 §4): placing onto an OCCUPIED action
/// slot HOPS the displaced action onto the cursor — two `action_sets` entries across the
/// gesture (the pickup's clear, then the place's write), never a container move.
#[test]
fn place_action_onto_occupied_hops_the_displaced_action() {
let mut s = UiScript::new().unwrap();
@@ -262,12 +195,10 @@ mod tests {
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
assert!(s.eval::<bool>("return PlaceAction(2)").unwrap());
// Slot 2 now shows the placed action (111).
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
);
// The displaced action (222) is now the held payload, sourced from slot 2.
let (kind, src) = s
.eval::<(String, i64)>("local k, slot = GetCursorInfo() return k, slot")
.unwrap();
@@ -282,9 +213,6 @@ mod tests {
}))
);
assert_eq!(s.take_action_sets(), vec![(2, 111)]);
// A hop is Some→Some: no HIDE+SHOW churn out of one gesture.
// (Exercised directly by the SHOWGRID/HIDEGRID test below.)
}
#[test]
@@ -336,8 +264,6 @@ mod tests {
assert!(s.take_action_sets().is_empty());
}
/// Shift-click while ALREADY holding just places (the reference's own `OnClick` never
/// special-cases it): `PickupAction` on a held cursor routes straight to [`super::place_action`].
#[test]
fn pickup_action_with_a_payload_held_falls_through_to_place() {
let mut s = UiScript::new().unwrap();
@@ -353,9 +279,6 @@ mod tests {
assert_eq!(s.take_action_sets(), vec![(5, 111)]);
}
/// `ACTIONBAR_SHOWGRID`/`ACTIONBAR_HIDEGRID` fire on the cursor's None↔Some edges (decision
/// 0216 §7) — a pickup shows the grid, a place-onto-empty hides it, and a HOP (Some→Some)
/// fires neither (no HIDE+SHOW churn out of one gesture).
#[test]
fn showgrid_hidegrid_fire_on_gain_and_loss_not_on_a_hop() {
let mut s = UiScript::new().unwrap();
@@ -391,9 +314,6 @@ mod tests {
assert_eq!(s.eval::<i64>("return hides").unwrap(), 1);
}
/// `PlaceAction`'s ITEM filter (decision 0666): an item with neither an on-use spell nor an
/// equip slot — a grey trade good — is refused, and the refusal is a **bare return**: nothing
/// stored, nothing sent, and the payload is still on the cursor afterwards.
#[test]
fn a_non_usable_non_equippable_item_is_refused_and_stays_held() {
let mut s = UiScript::new().unwrap();
@@ -423,10 +343,6 @@ mod tests {
);
}
/// The SPELL twin: a passive cannot go on the bar (`Attributes & 0x40`) — and unlike the item
/// arm this one SPEAKS, raising errorId `0x9e` = `ERR_PASSIVE_ABILITY` (see
/// [`super::place_action`]). The refusal and the toast are separate halves: the refusal is
/// still a bare return, so nothing is stored, nothing is sent, and the spell stays held.
#[test]
fn a_passive_spell_is_refused_with_the_refs_error_and_stays_held() {
let mut s = UiScript::new().unwrap();
@@ -453,8 +369,6 @@ mod tests {
);
}
/// The other half of the same law: an ACTIVE spell places normally and raises nothing. Guards
/// the obvious over-correction — a toast on every spell drop.
#[test]
fn an_active_spell_places_without_an_error() {
let mut s = UiScript::new().unwrap();
@@ -471,8 +385,6 @@ mod tests {
assert!(s.take_ui_errors().is_empty());
}
/// The positive control on the other side of both filters — a BAG (`InventoryType` 18, no
/// on-use spell) IS placeable, which is the one answer people find surprising.
#[test]
fn a_bag_is_placeable() {
let mut s = UiScript::new().unwrap();
+40 -121
View File
@@ -1,9 +1,5 @@
//! The paper doll (decision 0208 phase 1b): equipment joins the ONE payload space via
//! [`super::EQUIPMENT_BAG`]. One extra transition ([`pickup_inventory_item`]) plus four small
//! satellites ([`equip_cursor_item`], [`cursor_can_go_in_slot`], [`auto_equip_cursor_item`],
//! [`use_inventory_item`], [`is_inventory_item_locked`]) — everything else (cancel, clear, the
//! world drop, `SplitContainerItem`) already generalizes from [`super`] because it only ever
//! matches on the `CursorPayload` enum, never on `bag`.
//! The paper-doll cursor verbs: an equipped slot is an item source under
//! [`super::EQUIPMENT_BAG`], so the shared cursor code handles its cancel, clear and world drop.
use mlua::Lua;
@@ -13,45 +9,18 @@ use crate::script::Model;
use super::{queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload, EQUIPMENT_BAG};
/// `PickupInventoryItem(id)` — the paper-doll slot's left-click entry point (decision 0208 phase
/// 1b). Mirrors `container::pickup_container_item`'s click model exactly, keyed on the sentinel
/// [`EQUIPMENT_BAG`] rather than a real bag id (decision 0216 §1's ONE payload space, extended to
/// equipment). `id` is the 1-based live-API inventory slot (`GetInventorySlotInfo`'s own
/// numbering — HeadSlot=1 … TabardSlot=19, the four equipped-bag icons Bag0Slot=20 … Bag3Slot=23).
/// The bag icons ride the SAME transition (a bag's `equip_slots` is `[20,21,22,23]` from
/// `find_equip_slot(INVTYPE_BAG)`, so the fit rule already routes a bag onto any bag slot, and
/// `wire_pos`'s `EQUIPMENT_BAG` arm maps live id 20..23 → the wire's equipped-bag inventory slots
/// 19..22); ammo (0) stays a named deferral — refused outright, cursor untouched.
///
/// **The bank-bag band (64..=69) rides this too**, and only through one door: `PutItemInBag`'s
/// empty-slot leg IS this function in the reference (`0x4c7c00` calls `0x4c7300` there), which is
/// how a held bag reaches a bank bag slot at all. Its fit rule is one band wider than
/// `equip_slots` alone — see [`super::bag_verbs::fits_slot`].
///
/// - empty cursor + an occupied, UNLOCKED doll slot → picks it up (payload `Item { bag:
/// EQUIPMENT_BAG, slot: id, … }`), the same lock/event pair as a bag pickup.
/// - holding + the SAME slot → cancel (mirrors `pickup_container_item`'s own same-slot branch).
/// - holding an Item payload whose `equip_slots` contains `id` (the fit rule — `equip_slots` rides
/// the payload from wherever it was picked up, doll or bag alike) → queues the move (dst =
/// `(EQUIPMENT_BAG, id)`) and CLEARS (decision 0218: a plain clear, no hop) — UNLESS it's a
/// split carry (`count: Some(_)`): refused outright, kept (you can't equip a partial stack).
/// - holding a non-fitting Item, or any Spell/Action payload → no-op, kept (mirrors
/// `pickup_container_item`'s own refusal of a payload a bag slot can't take).
///
/// Returns whether the caller should repaint (the source lock, or the held payload, changed).
/// `PickupInventoryItem(id)` (`0x4c7300`), the doll slot's click, on the bag click's model: a
/// fitting whole item moves in and the cursor clears, with no hop; a split or a misfit stays
/// held. Returns whether to repaint.
pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
// Equipment slots (1..=19) + the four equipped-bag icons (20..=23) + the six BANK bag slots
// (64..=69, reached only through `PutItemInBag`); ammo (0) is out of scope.
// Head 1 to Tabard 19, the bag slots 20-23, and the bank bag slots, which only `PutItemInBag`
// reaches (`0x4c7c00` calls `0x4c7300`). Ammo (0) is refused; its leg is not built.
if !(1..=23).contains(&id) && !super::bag_verbs::BANK_BAG_INV_SLOTS.contains(&id) {
return false;
}
// The targeting cursor's item half, on the doll (decision 0923). The reference's doll pickup
// `0x4c7300` carries the byte-IDENTICAL rung to the bag one — `4c76df: call 0x6e48a0`
// (IsTargeting), `4c76e8: call 0x6e6330` (TargetingWantsItem), `4c76fb: call 0x495d60` (bind
// this item), then return with nothing picked up — and it is what makes poisoning or
// sharpening the weapon you are *wearing* possible at all. Reported in the ONE bag space
// ([`EQUIPMENT_BAG`] + the 1-based slot), so the app's drain resolves both seams identically.
// A held payload wins, exactly as in the bag path (the ref's own `4c73af` pair precedes it).
// An item-targeting spell binds this slot instead of picking it up, as the bag click does
// (`0x4c76df`: `0x6e48a0` IsTargeting, `0x6e6330` TargetingWantsItem, `0x495d60` bind), so a
// worn weapon can be poisoned. A held payload wins: `0x4c73af` tests it first.
if model.item_pick_armed && model.cursor.is_none() {
model.item_picks.push((EQUIPMENT_BAG, id));
return false;
@@ -88,9 +57,7 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
true
}
Some(CursorPayload::Item(held)) => {
// A split carry can't equip a partial stack, and a non-fitting item has nowhere to
// land on this slot — both refuse, kept exactly as picked up (no event: nothing
// transitioned).
// A split or a misfit stays held, with no event: nothing changed.
if held.count.is_some() || !super::bag_verbs::fits_slot(&held, id) {
model.cursor = Some(CursorPayload::Item(held));
return false;
@@ -106,14 +73,9 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
queue_lock_changed(model, held.bag, held.slot);
true
}
// Mode 5 — the buy, aimed at an equipment slot. `0x4c78fd` tests `[0xb4d900] == 5` inside
// the paper-doll click and sends the same `CMSG_BUY_ITEM_IN_SLOT 0x1a3` the bag drop does;
// the server equips it or refuses. Reported in the ONE bag space ([`EQUIPMENT_BAG`] + the
// 1-based inventory slot), the seam this file already uses for every other doll intent.
//
// Of the reference's three drop consumers this is the one that handles a stale vendor row
// correctly — the other two dereference it (`0x4f9f35`, `0x4c7ea5`) — so its refusal is
// what we reproduce in all three.
// Mode 5, a buy into this slot: `0x4c78fd` sends `CMSG_BUY_ITEM_IN_SLOT` (`0x1a3`) and the
// server equips or refuses. A stale row buys nothing: of the reference's three drop sites
// only this one checks it; the other two (`0x4f9f35`, `0x4c7ea5`) dereference it.
Some(CursorPayload::Merchant(held)) => {
let entry = model
.merchant
@@ -132,10 +94,11 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)
// Mode 10 (decision 1677) — a stabled pet refuses a bag/doll slot exactly as the
// spell/action family does, and stays on the cursor for the stable window to take.
// Mode 10: a stabled pet stays held for the stable window.
| CursorPayload::StablePet(_)
// Mode 2 (1962) — coins have no slot to land in; a money frame's DropFunc takes them.
// Mode 2: coins stay held for a money frame's drop. On an occupied slot the reference
// picks the item up over either, as it tests only for a held item (`0x4c769a`) or a
// preview row (`0x4c76a5`) before the pickup (`0x4c7838`).
| CursorPayload::Money(_)),
) => {
model.cursor = Some(other);
@@ -144,27 +107,15 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
}
}
/// `EquipCursorItem(id)` — the ref's item-context "Equip" popup entry (not built this slice: no
/// context menu ships yet). Same transition as placing the held item directly onto doll slot
/// `id` — [`pickup_inventory_item`]'s place arm already IS that transition, so this simply routes
/// there (decision 0208 phase 1b: "route both through the same transition").
/// `EquipCursorItem(id)`: placing the held item on doll slot `id`.
pub(super) fn equip_cursor_item(model: &mut Model, id: u32) -> bool {
pickup_inventory_item(model, id)
}
/// `CursorCanGoInSlot(id)` — whether the held payload could be dropped on doll slot `id`
/// (decision 0208 phase 1b; the reference's `CURSOR_UPDATE` → `LockHighlight`/`UnlockHighlight`
/// driver, `PaperDollFrame.lua:609-615`). An Item payload whose `equip_slots` contains `id`;
/// empty cursor or a Spell/Action payload both answer `false` (the same fit-blind refusal
/// [`pickup_inventory_item`] gives them — asking a hypothetical doesn't relax the rule).
///
/// INTERIM: the byte body's terminal equip-fit check (`0x5da1d0`) was left unresolved by 0216 §5
/// (0218's own residual, §4 "confirmed as built" — everything else on that list is pinned, this
/// one function wasn't among the byte-verified findings). `equip_slots` derives from the item
/// template's `inventoryType` via the SERVER's own mapping instead (`ui_items::find_equip_slot`,
/// transcribed from vmangos `Player::FindEquipSlot`/`ItemPrototype::GetAllowedEquipSlots`) — a
/// verifiable authority `SMSG_INVENTORY_CHANGE_FAILURE` referees either way, corrected if a
/// future pin disagrees with the client's own table.
/// `CursorCanGoInSlot(id)`, the doll slot's highlight on `CURSOR_UPDATE`
/// (`PaperDollFrame.lua:609-615`): `equip_slots`, which follows vmangos `Player::FindEquipSlot`,
/// where the reference's `IsValidForSlot` (`0x5da1d0`) tests a static slot mask per
/// `InventoryType`.
pub(super) fn cursor_can_go_in_slot(model: &Model, id: u32) -> bool {
match &model.cursor {
Some(CursorPayload::Item(item)) => super::bag_verbs::fits_slot(item, id),
@@ -172,17 +123,10 @@ pub(super) fn cursor_can_go_in_slot(model: &Model, id: u32) -> bool {
}
}
/// `AutoEquipCursorItem()` — the model-pane's click-with-payload path (decision 0208 phase 1b,
/// ref `PaperDollFrame.xml`'s frame-level `OnReceiveDrag`/`OnMouseUp`): an Item payload picked up
/// from a CONTAINER (`bag >= 0`, a whole stack) queues its `(bag, slot)` source as a NEW
/// `container_autoequips` intent (the app sends `CMSG_AUTOEQUIP_ITEM` — the server picks the
/// destination slot itself and swaps any displaced piece back, vmangos `ItemHandler.cpp:138-228`)
/// and clears the cursor — a plain clear, the same contract as every other placement since
/// decision 0218. A payload already carried FROM the equipment (`bag == EQUIPMENT_BAG` — a
/// doll→doll re-drop has nothing for the server to "auto" pick) or a split carry (can't
/// auto-equip a partial stack) is a no-op, kept; so is any Spell/Action payload.
///
/// Returns whether the caller should repaint.
/// `AutoEquipCursorItem()`, a click or drop on the doll's model (`PaperDollFrame.lua:31-35`): a
/// whole stack from a bag becomes `CMSG_AUTOEQUIP_ITEM`: the server picks the slot and swaps
/// the displaced piece back (`ItemHandler.cpp:138-228`); an equipped item or a split stays held.
/// Returns whether to repaint.
pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool {
match model.cursor.take() {
Some(CursorPayload::Item(item)) if item.bag >= 0 && item.count.is_none() => {
@@ -198,25 +142,15 @@ pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool {
}
}
/// `UseInventoryItem(id)` — the doll slot's right-click entry point (ref
/// `PaperDollItemSlotButton_OnClick`, `PaperDollFrame.lua:658-659`): queues `id` for the app to
/// resolve to the equipped item's guid and send (`CMSG_USE_ITEM`, bag 255 + the 0-based wire slot
/// — vmangos `HandleUseItemOpcode` takes equipped positions the same as bag ones). No engine-side
/// refusal — an empty/out-of-range slot is a harmless no-op the app's drain silently drops
/// (mirrors `UseContainerItem`'s own contract: it never checks the slot either).
/// `UseInventoryItem(id)`, the doll slot's right-click (`PaperDollFrame.lua:658-659`): the app
/// sends `CMSG_USE_ITEM` with bag 255 and the 0-based slot, and drops an empty slot unsent.
pub(super) fn use_inventory_item(model: &mut Model, id: u32) {
model.inventory_uses.push(id);
}
/// `IsInventoryItemLocked(id)` — true while `id` is the ACTIVE cursor payload's source (the
/// picked-up slot dims immediately, no server round-trip — the doll twin of
/// `GetContainerItemInfo`'s `held_here` derivation) OR the app's fed `InvSlotView.locked` says so
/// (an outstanding pending op the app's `PendingItemOps` tracks, decision 0216 §4/0218 §3).
///
/// Reads through [`Model::inv_slot`], not the doll array, so it answers over the WHOLE live-id
/// space that binding covers — the reference's `BankFrameItemButton_UpdateLock` calls it with
/// `BankButtonIDToInvSlotID(...)` for every bank slot, and a lock the vault feed reports has to
/// dim the button the same as an equipped one.
/// `IsInventoryItemLocked(id)`: true while `id` is the held item's source or the app reports a
/// pending operation on it. Reads [`Model::inv_slot`], since `BankFrameItemButton_UpdateLock`
/// asks about bank slots too.
pub(super) fn is_inventory_item_locked(model: &Model, id: u32) -> bool {
let held_here = matches!(&model.cursor, Some(CursorPayload::Item(c)) if c.bag == EQUIPMENT_BAG && c.slot == id);
let fed = usize::try_from(id)
@@ -226,9 +160,7 @@ pub(super) fn is_inventory_item_locked(model: &Model, id: u32) -> bool {
held_here || fed
}
/// Register the paper-doll's cursor globals — all top-level, matching the reference
/// (`PickupInventoryItem` &c. are not namespaced any more than `PickupContainerItem`'s cursor
/// siblings are).
/// Register the paper-doll verbs as globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -284,8 +216,7 @@ mod tests {
use crate::script::cursor::{CursorAction, CursorPayload, CursorSpell, EQUIPMENT_BAG};
use crate::script::{ContainerMove, ContainerSlot, ContainerState, InvSlotView, UiScript};
/// Slots 1 (Head) and 19 (Tabard) occupied, plus the two finger rings (11/12) — a doll
/// fixture wide enough to exercise the fit rule, the same-slot cancel, and a doll↔doll swap.
/// Head (1), a ring in finger slot 11 that also fits 12, and Tabard (19).
fn doll_slots() -> crate::script::InventorySlots {
let mut slots: crate::script::InventorySlots = Default::default();
slots[1] = Some(InvSlotView {
@@ -320,7 +251,7 @@ mod tests {
name: Some("Test Ring".into()),
link: Some("|cffffffff|Hitem:555:0:0:0|h[Test Ring]|h|r".into()),
locked: false,
equip_slots: vec![11, 12], // FINGER1|FINGER2 — the doll↔doll swap fixture
equip_slots: vec![11, 12], // either finger: the doll-to-doll swap
creator: None,
enchants: Vec::new(),
});
@@ -513,7 +444,7 @@ mod tests {
});
s.set_inventory_slots(slots);
// Ammo (0) is still refused; the bag icon (20) now picks up the equipped bag.
// Ammo (0) is refused; Bag0Slot (20) picks up the equipped bag.
assert!(!s.eval::<bool>("return PickupInventoryItem(0)").unwrap());
assert!(s.eval::<bool>("return PickupInventoryItem(20)").unwrap());
let held = s.cursor_item().expect("bag picked up from the bar");
@@ -523,9 +454,6 @@ mod tests {
);
}
/// Drag-to-equip: a bag carried from the backpack drops onto an empty bag slot (id 21), queuing
/// the move to `(EQUIPMENT_BAG, 21)` — the same transition the paper doll uses, and the wire the
/// app maps onto equipped-bag inventory slot 20.
#[test]
fn place_a_bag_onto_a_bag_slot_queues_the_equip_move() {
let mut s = UiScript::new().unwrap();
@@ -583,17 +511,14 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
// Empty cursor: false everywhere.
assert!(!s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
// Item arm: true only for a slot its equip_slots names.
s.set_container(0, Some(one_fitting_bag_item()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(s.eval::<bool>("return CursorCanGoInSlot(1)").unwrap());
assert!(!s.eval::<bool>("return CursorCanGoInSlot(2)").unwrap());
s.run("ClearCursor()").unwrap();
// Spell/Action arms: always false (the fit-blind refusal).
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
book_slot: 1,
@@ -627,14 +552,12 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
// From the doll itself: nothing for the server to "auto" pick.
s.run("PickupInventoryItem(1)").unwrap();
assert!(!s.eval::<bool>("return AutoEquipCursorItem()").unwrap());
assert!(s.cursor_item().is_some(), "kept");
assert!(s.take_container_autoequips().is_empty());
s.run("ClearCursor()").unwrap();
// A split carry: can't auto-equip a partial stack.
let mut state = one_fitting_bag_item();
state.slots.get_mut(&1).unwrap().count = 5;
s.set_container(0, Some(state));
@@ -694,11 +617,8 @@ mod tests {
);
}
/// The targeting cursor's item half reroutes BOTH pickup seams (decision 0923), and the
/// held-payload check precedes it in both — the reference's own order (`4f9c38` before
/// `4f9c54`; `4c73af` before `4c76df`). Without the doll seam a rogue could not poison the
/// weapon they are wearing; without the payload gate, dragging an item across a bag while a
/// poison is armed would silently fire the cast at whatever you dropped it on.
/// The held-payload test runs first in both seams: `0x4f9c38` before `0x4f9c54` in the bag,
/// `0x4c73af` before `0x4c76df` on the doll.
#[test]
fn the_armed_item_half_reroutes_bag_and_doll_clicks() {
let mut s = UiScript::new().unwrap();
@@ -710,7 +630,7 @@ mod tests {
assert!(s.cursor_item().is_some());
assert!(s.take_item_picks().is_empty());
// Armed but HOLDING: the payload wins, exactly as the reference orders it.
// Armed but holding: the payload wins.
s.set_item_pick_armed(true);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(
@@ -718,8 +638,7 @@ mod tests {
"a click while carrying an item is a place, not a bind"
);
// Armed with an empty cursor: both seams bind instead of picking up, and neither
// disturbs the cursor.
// Armed with an empty cursor: both seams bind and leave the cursor empty.
s.run("ClearCursor()").unwrap();
assert!(s.cursor_item().is_none());
assert!(!s.eval::<bool>("return PickupInventoryItem(1)").unwrap());
+32 -103
View File
@@ -1,19 +1,13 @@
//! The drag gesture (decision 0216 §3) — `RegisterForDrag`/`OnDragStart`/`OnDragStop`/
//! `OnReceiveDrag`'s mechanics: arm on press, start past a threshold, resolve on release. Split
//! out of [`super`] purely for size — [`super::pointer`](crate::script::pointer) is the only
//! external caller (via [`super`]'s re-exports), and the payload types/transition seam this
//! drives live one level up.
//! The `RegisterForDrag` gesture: armed on a press, started past a threshold, ended on release.
use crate::script::Model;
use crate::widget::FrameHandle;
/// INTERIM pixel drag-start threshold (0216 §5 (d) is the byte-verified trigger; the reference
/// almost certainly uses a small OS/engine drag-distance constant, not this one). Compared with
/// **strict** `>` — a move exactly at the threshold does not start the gesture.
/// The drag-start distance in pixels, exceeded strictly; the reference starts at 0.01 frame units
/// or more from the press (`0x81c468`, squared by `0x76b300`).
pub(crate) const DRAG_START_THRESHOLD: f32 = 4.0;
/// An in-flight drag gesture: armed at mouse-down on a [`Model::drag_registered`] frame,
/// `started` once the cursor has moved past [`DRAG_START_THRESHOLD`] from the press point.
/// A drag armed at a press on a [`Model::drag_registered`] frame, `started` past the threshold.
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DragGesture {
pub(crate) button: String,
@@ -22,22 +16,15 @@ pub(crate) struct DragGesture {
pub(crate) started: bool,
}
/// A resolved drag release (a gesture whose button matched the release) — [`Model::drag`] is
/// always cleared alongside this, win or lose.
/// A gesture whose button matched the release; [`Model::drag`] is cleared either way.
pub(crate) struct DragRelease {
/// Whether the gesture crossed [`DRAG_START_THRESHOLD`] (so `OnDragStart` fired). A released
/// gesture that never started fires nothing — the normal click path proceeds untouched.
/// `OnDragStart` fired; an unstarted release fires nothing and the click proceeds.
pub(crate) started: bool,
pub(crate) source: FrameHandle,
}
/// Arm (or clear) the drag gesture on a mouse-DOWN. `hit` is the pressed frame (`None` = the
/// press hit nothing); a press always REPLACES any leftover in-flight gesture — its matching
/// release should already have cleared it, but a press is the one moment we know for certain no
/// gesture should still be armed against a *different* earlier press.
///
/// **The replaced gesture is abandoned, not forgotten**: the caller runs [`abandon_drag`] first
/// and fires `OnDragStop` for a started one. See that function for why a silent drop is the bug.
/// Arm or clear the gesture on a press, replacing any leftover one; the caller runs
/// [`abandon_drag`] first so a started gesture still gets its `OnDragStop`.
pub(crate) fn arm_drag(model: &mut Model, hit: Option<FrameHandle>, button: &str, pos: (f32, f32)) {
model.drag = hit
.filter(|&h| {
@@ -54,10 +41,8 @@ pub(crate) fn arm_drag(model: &mut Model, hit: Option<FrameHandle>, button: &str
});
}
/// Advance the armed gesture on a mouse-move: once the cursor has moved past
/// [`DRAG_START_THRESHOLD`] from the press point, mark it started. Returns `(source id, button)`
/// to fire `OnDragStart` with, exactly once per gesture (already-started and no-gesture both
/// answer `None`); a source that died before starting fires nothing.
/// Start the armed gesture once the cursor passes the threshold, returning the `OnDragStart`
/// source id and button once per gesture; a source that died fires nothing.
pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u32, String)> {
let (source, button) = {
let g = model.drag.as_ref()?;
@@ -72,11 +57,8 @@ pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u3
(g.source, g.button.clone())
};
model.drag.as_mut().expect("checked Some above").started = true;
// **The button's drag-start edge** — `CSimpleButton` overrides `+0x74` with `0x7793f0`, which
// un-presses (`0x779410`, guarded on `locked == 0 && state != DISABLED`) and then forwards to
// the base notify that fires `<OnDragStart>`. So a button you drag off releases its pushed art
// at the THRESHOLD crossing, which is here — not when the cursor leaves its rect, and not for
// a frame that never registered for drag (decision 2134).
// A dragged button un-presses at the threshold, not on leaving its rect: `CSimpleButton`'s
// `+0x74` override (`0x7793f0`) un-presses (`0x779410`) before firing `OnDragStart`.
super::super::button::edge(model, source, crate::widget::ButtonState::on_drag_start);
let id = model
.arena
@@ -86,26 +68,14 @@ pub(crate) fn maybe_start_drag(model: &mut Model, pos: (f32, f32)) -> Option<(u3
Some((id, button))
}
/// **Abandon** an in-flight gesture that will never see its release, and report whether anything
/// has to be told. Returns the source of a gesture that had **started**, so the caller fires the
/// same `OnDragStop` a real release would; an armed-but-unstarted press answers `None`, exactly as
/// releasing one does.
///
/// Two callers, and both are situations the reference simply cannot be in: the OS captures the
/// pointer for the whole of a button-held drag there, so the release always arrives. Ours does
/// not — the cursor can walk out of the window, and a second button can be pressed mid-gesture —
/// and dropping the gesture *silently* in either case is what leaves the UI stuck rather than
/// merely cancelled: the addon's `OnDragStop → StopMovingOrSizing` never runs, so the engine's
/// single [`Model::moving`] slot stays taken, [`super::super::object::movable::advance_move`]
/// keeps gluing that frame to the cursor for the rest of the session, and it swallows every press
/// aimed at anything underneath. (B310 — the raid grid, where one drag off the window edge cost
/// every later drag.)
/// Drop a gesture whose release will never come, because the pointer left the window or a second
/// button was pressed; the reference cannot lose one, as the OS captures the pointer. Returns a
/// started one's source, which must get `OnDragStop` or a frame it is moving follows the cursor.
pub(crate) fn abandon_drag(model: &mut Model) -> Option<FrameHandle> {
model.drag.take().filter(|g| g.started).map(|g| g.source)
}
/// Resolve (and clear) the in-flight drag gesture matching a mouse-button release's `button`, if
/// any (case-insensitive, the `RegisterForClicks` precedent).
/// Take the gesture whose button matches the release.
pub(crate) fn take_drag(model: &mut Model, button: &str) -> Option<DragRelease> {
let g = model
.drag
@@ -151,10 +121,10 @@ mod tests {
s.resolve();
s.mouse_button(100.0, 300.0, "LeftButton", true); // press inside A
s.mouse_move(102.0, 300.0); // 2px — under the threshold
s.mouse_move(102.0, 300.0); // 2px: under the threshold
assert_eq!(s.eval::<i64>("return drag_starts").unwrap(), 0);
s.mouse_move(110.0, 300.0); // 10px from the press point — starts
s.mouse_move(110.0, 300.0); // 10px from the press: starts
assert_eq!(s.eval::<i64>("return drag_starts").unwrap(), 1);
assert_eq!(
s.eval::<String>("return drag_button").unwrap(),
@@ -192,9 +162,7 @@ mod tests {
#[test]
fn drag_release_on_the_source_still_suppresses_onclick() {
// Without the suppression, a same-frame press+release Up would normally fire OnClick
// (a plain Frame's OnClick fires on any same-frame release — see `input.rs`'s click
// tests) — proving the drag path takes precedence over that default.
// A plain Frame fires OnClick on a same-frame release; a started drag suppresses it.
let mut s = drag_script();
s.run(
r#"
@@ -231,7 +199,7 @@ mod tests {
s.resolve();
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_move(101.0, 300.0); // 1px — never starts
s.mouse_move(101.0, 300.0); // 1px: never starts
s.mouse_button(100.0, 300.0, "LeftButton", false);
assert_eq!(
s.eval::<i64>("return clicks").unwrap(),
@@ -241,9 +209,7 @@ mod tests {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// A drag released over the world does NOTHING to the payload — no popup, no clear (decision
/// 0218, byte-verified: `0x495300` runs on the WorldFrame click release only; a drag release
/// routes as a drag, never a click — the director's "it should require an outside up+down").
/// The world drop (`0x495300`) runs on a world click's release, never on a drag release.
#[test]
fn drag_release_over_nothing_keeps_carrying_no_popup() {
let mut s = drag_script();
@@ -279,7 +245,7 @@ mod tests {
assert_eq!(s.eval::<i64>("return heard").unwrap(), 0, "no popup");
assert!(s.cursor_item().is_some(), "the payload keeps carrying");
// The follow-up CLICK on the world (down + up, both over nothing) is the trigger.
// The trigger is a full click on the world, down and up over nothing.
s.mouse_button(-50.0, -50.0, "LeftButton", true);
assert_eq!(
s.eval::<i64>("return heard").unwrap(),
@@ -332,17 +298,8 @@ mod tests {
assert!(s.cursor_item().is_some(), "the payload stays held");
}
/// **The world frame IS the world** — B380 / decision 2089, the regression this file's
/// other world-drop tests were structurally blind to.
///
/// Every one of them clicks at `(-50, -50)`, where the hit test answers `None`, and the gate
/// used to be spelled `hit_id.is_none() && pressed.is_none()`. Decision 1983 put the stock
/// `WorldFrame` on the manifest — full-screen, mouse-enabled, `SetAllPoints` — so from that
/// day every click a player makes on the world hits a frame, the gate went permanently false
/// and dropping an item on the ground stopped doing anything at all. It is exactly the
/// reference's own click target for a world click (1984: "the press runs the frame's Lua
/// `OnMouseDown` (never consuming), then the `BUTTON1`/`BUTTON2` binding — which **is** the
/// world click"), so it belongs on the yes side of the predicate.
/// A click on the full-screen stock `WorldFrame` is a world click: in the reference, its press
/// runs the frame's `OnMouseDown` without consuming, then the `BUTTON1`/`BUTTON2` binding.
#[test]
fn a_click_on_the_world_frame_is_a_world_drop() {
let mut s = drag_script();
@@ -369,7 +326,7 @@ mod tests {
equip_slots: Vec::new(),
}));
// The fixture's own premise: the click really does land on the world frame.
// The premise: the click lands on the world frame.
let hit = s
.hit_test(400.0, 300.0)
.expect("the world frame is full-screen");
@@ -383,9 +340,6 @@ mod tests {
assert!(s.cursor_item().is_some(), "the payload stays held");
}
/// …and a UI frame ON TOP of the world frame is still the UI: press and release both land on
/// the plate, so no drop — the other half of [`super::over_world`]'s gate, which a predicate
/// that merely asked "is a world frame anywhere under the cursor" would get wrong.
#[test]
fn a_click_on_a_frame_above_the_world_frame_is_not_a_world_drop() {
let mut s = drag_script();
@@ -424,8 +378,7 @@ mod tests {
"the plate ate the click — that IS the UI consuming it"
);
// The mixed pair, both ways round: a press on the plate released over the world, and a
// press on the world released over the plate. Neither is a completed world click.
// A press and release split across the plate and the world, either way: no world click.
s.mouse_button(100.0, 300.0, "LeftButton", true);
s.mouse_button(600.0, 300.0, "LeftButton", false);
s.mouse_button(600.0, 300.0, "LeftButton", true);
@@ -435,9 +388,6 @@ mod tests {
assert!(s.cursor_item().is_some());
}
/// A click on the world frame with an EMPTY cursor is not the UI consuming anything — the
/// return the app's arbiter would read if it ever asked. 1984: the world frame's press
/// "never consum[es]".
#[test]
fn a_world_frame_click_with_no_payload_consumes_nothing() {
let mut s = drag_script();
@@ -451,10 +401,7 @@ mod tests {
);
}
/// A world object (unit/GameObject) under the cursor suppresses the world drop entirely
/// (decisions 0571 + 0574): the reference's object-leg dispatcher (`0x492ce0`) keeps every
/// real payload and runs SELECT — no `DELETE_ITEM_CONFIRM`, payload untouched. The app
/// feeds the pick (`set_world_pick`); tests/captures default `Nothing`.
/// Over a world object the reference's object leg (`0x492ce0`) selects and keeps the payload.
#[test]
fn world_click_over_a_world_object_is_not_a_world_drop() {
let mut s = drag_script();
@@ -492,8 +439,6 @@ mod tests {
assert!(s.cursor_item().is_some(), "the item payload survives");
}
/// A press over a FRAME whose release lands on the world is neither a click on the frame nor
/// a world click — the payload is untouched.
#[test]
fn frame_press_world_release_is_not_a_world_drop() {
let mut s = drag_script();
@@ -528,8 +473,7 @@ mod tests {
assert!(s.cursor_item().is_some());
}
/// A spell/action payload world-CLICK clears silently (`0x495300`'s non-item arms); a drag
/// release over the world keeps it carrying, same as an item.
/// A non-item payload's world click clears it silently (`0x495300`); a drag release keeps it.
#[test]
fn world_click_with_a_spell_payload_clears_silently() {
let mut s = drag_script();
@@ -570,15 +514,6 @@ mod tests {
assert!(s.cursor_payload().is_none(), "cleared silently");
}
/// **A drag the pointer carries out of the window ENDS, and the frame it was moving stops
/// following the cursor** (B310).
///
/// The reference cannot reach this state — the OS holds the pointer for a button-held drag, so
/// the release always arrives — which is exactly why nothing in FrameXML defends against it and
/// why the engine has to. Abandoning the gesture *silently* (what this used to do) skips the
/// canonical `OnDragStop → StopMovingOrSizing`, so the single move slot stays taken and
/// `advance_move` glues the frame to the cursor for the rest of the session, on top of whatever
/// the player is trying to click next.
#[test]
fn a_gesture_the_pointer_carries_out_of_the_window_still_fires_its_stop() {
let mut s = drag_script();
@@ -597,10 +532,8 @@ mod tests {
s.resolve();
s.mouse_button(150.0, 150.0, "LeftButton", true);
s.mouse_move(200.0, 200.0); // past the threshold ⇒ OnDragStart ⇒ StartMoving
// `StartMoving` samples the cursor where it is CALLED, so the starting move moves nothing
// (`tests::movable`'s own note) — this is the one that carries the frame.
s.mouse_move(250.0, 250.0);
s.mouse_move(200.0, 200.0); // past the threshold: OnDragStart, StartMoving
s.mouse_move(250.0, 250.0); // StartMoving anchors on its call; this move carries
s.resolve();
let carried: f32 = s.eval("return A:GetLeft()").unwrap();
assert_eq!(carried, 150.0, "the frame followed the cursor's 50px");
@@ -612,7 +545,7 @@ mod tests {
"the abandon fires the same OnDragStop a release would"
);
// …and the move slot is free, so nothing follows the cursor any more.
// The move slot is free: nothing follows the cursor.
s.mouse_move(600.0, 500.0);
s.resolve();
assert_eq!(
@@ -626,9 +559,6 @@ mod tests {
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
}
/// The same abandon, by the other door: a fresh press REPLACES an in-flight gesture, and a
/// started one has to be told. Pressing a second button mid-drag is an ordinary slip of the
/// hand, and it used to leave the same stuck move slot.
#[test]
fn a_press_that_replaces_an_in_flight_gesture_stops_it_first() {
let mut s = drag_script();
@@ -657,8 +587,7 @@ mod tests {
"the left drag the right press displaced was stopped, not forgotten"
);
// An armed-but-UNSTARTED press fires nothing when it is replaced — the same silence a
// release of one gets.
// Replacing an unstarted press fires nothing, as releasing it would not.
s.mouse_button(150.0, 150.0, "LeftButton", true);
s.mouse_button(150.0, 150.0, "MiddleButton", true);
assert_eq!(s.eval::<i64>("return stops").unwrap(), 1);
+15 -44
View File
@@ -1,28 +1,6 @@
//! The money cursor — payload mode 2 (decisions 1962, 1965): coins picked up off a money frame,
//! and everything the reference does with them. VERIFIED at the bytes throughout:
//!
//! * **The purse is never debited.** Nothing on this path writes `PLAYER_FIELD_COINAGE`; the
//! backpack's figure is `GetMoney() − GetCursorMoney() − GetPlayerTradeMoney()`, FrameXML
//! arithmetic. Dropping credits nothing back because nothing was taken.
//! * `PickupPlayerMoney(amount)` raises on a non-number, truncates, and refuses SILENTLY on a zero
//! amount, on lost player control, and on more than the purse holds (unsigned). It clears the
//! cursor first — whose tail fires `CURSOR_UPDATE` before the money lands — then installs mode 2,
//! plays `LOOTWINDOWCOINSOUND`, sets the coin bitmap by magnitude and fires `PLAYER_MONEY`.
//! * `DropCursorMoney()` is an absolute no-op without money on the cursor; otherwise the coin
//! sound, `PLAYER_MONEY`, then mode 0 and `CURSOR_UPDATE`. No drop kit, no packet.
//! * `CursorHasMoney()` answers the number `1` or `nil`, never a boolean.
//! * `AddTradeMoney()` (0 args) folds the coins into the trade offer as an ABSOLUTE
//! `CMSG_SET_TRADE_GOLD` of `offer + cursor`, gated on the purse covering the total; a refusal
//! leaves the money held. Success clears the cursor with no `PLAYER_MONEY`.
//! * `PickupTradeMoney(amount)` raises on a non-number; `amount <= 0` or `> offer` (both SIGNED)
//! refuse silently; else `0x11F` with `offer − amount`, then the coins onto the cursor with
//! `PLAYER_MONEY`.
//! * `GetCoinIcon(amount)` raises on a non-number and answers `INV_Misc_Coin_0N` by the signed
//! thresholds of [`coin_icon`] — the cursor's own bitmap goes by the same table.
//!
//! One thing this engine's deferred event lane cannot reproduce: the reference's `CURSOR_UPDATE`
//! handler runs synchronously inside the clear, before the coins land, so `CursorHasMoney()` is
//! nil there; ours runs at the next dispatch and sees the coins. Named, not hidden.
//! The money cursor, payload mode 2: coins taken off a money frame; the purse is never debited.
//! Deviation: a `CURSOR_UPDATE` handler already sees the coins, because events are queued; the
//! reference runs it inside the clear, before they land.
use mlua::{Lua, Value};
@@ -30,9 +8,8 @@ use super::{queue_cursor_update, CursorMoney, CursorPayload};
use crate::script::binding_abi::{flag, number_arg};
use crate::script::Model;
/// The coin icon for an amount of copper — `GetCoinIcon 0x48d4e0`'s table, SIGNED thresholds
/// (a negative amount reads as the smallest coin). The loot window's coin slot and the money
/// cursor's bitmap use the same table.
/// The coin icon for an amount of copper: `GetCoinIcon`'s table (`0x48d4e0`), thresholds signed.
/// The loot window's coin slot and the money cursor use it too.
pub fn coin_icon(copper: i64) -> &'static str {
if copper < 10 {
"Interface\\Icons\\INV_Misc_Coin_05"
@@ -49,7 +26,6 @@ pub fn coin_icon(copper: i64) -> &'static str {
}
}
/// The copper on the cursor, `0` when it holds anything else or nothing.
pub(crate) fn cursor_money(model: &Model) -> u32 {
match &model.cursor {
Some(CursorPayload::Money(m)) => m.copper,
@@ -57,17 +33,15 @@ pub(crate) fn cursor_money(model: &Model) -> u32 {
}
}
/// Queue `PLAYER_MONEY` — the purse frames' repaint, which is how the held coins leave the
/// backpack's figure without the purse moving.
/// Queue `PLAYER_MONEY`, which repaints the money frames less the held coins.
fn queue_player_money(model: &mut Model) {
model
.pending_events
.push(("PLAYER_MONEY".to_string(), Vec::new()));
}
/// Put `copper` on the cursor the way `0x494cc0` does: the inner clear (whose tail fires
/// `CURSOR_UPDATE`, and which fires a `PLAYER_MONEY` of its own when the cursor already held
/// coins), then the payload, then `PLAYER_MONEY`.
/// Put coins on the cursor as `0x494cc0` does: the inner clear's `CURSOR_UPDATE`, after its own
/// `PLAYER_MONEY` if coins were already held, then the payload and `PLAYER_MONEY`.
fn install_money(model: &mut Model, copper: u32) {
if matches!(model.cursor, Some(CursorPayload::Money(_))) {
queue_player_money(model);
@@ -78,7 +52,7 @@ fn install_money(model: &mut Model, copper: u32) {
queue_player_money(model);
}
/// `PickupPlayerMoney`'s body after the argument gate.
/// `PickupPlayerMoney` after the argument gate; each refusal is silent.
pub(crate) fn pickup_player_money(model: &mut Model, copper: u32) -> bool {
if copper == 0 || !model.player_control || u64::from(copper) > model.money {
return false;
@@ -87,7 +61,7 @@ pub(crate) fn pickup_player_money(model: &mut Model, copper: u32) -> bool {
true
}
/// `DropCursorMoney`'s body.
/// `DropCursorMoney`: `PLAYER_MONEY`, then the clear's `CURSOR_UPDATE`, and no packet.
pub(crate) fn drop_cursor_money(model: &mut Model) -> bool {
if !matches!(model.cursor, Some(CursorPayload::Money(_))) || !model.player_control {
return false;
@@ -98,9 +72,8 @@ pub(crate) fn drop_cursor_money(model: &mut Model) -> bool {
true
}
/// `AddTradeMoney`'s body, and the money arm `ClickTradeButton` / `ClickTargetTradeButton` and
/// the trade window's open leg run first: the coins into the offer as an absolute total, gated
/// on the purse covering it; a refusal leaves them held. Returns whether the coins moved.
/// `AddTradeMoney`, also run first by `ClickTradeButton`, `ClickTargetTradeButton` and the trade
/// window's open: the offer becomes offer plus cursor, if the purse covers it.
pub(crate) fn add_trade_money(model: &mut Model) -> bool {
let copper = cursor_money(model);
if copper == 0 {
@@ -122,8 +95,8 @@ pub(crate) fn add_trade_money(model: &mut Model) -> bool {
true
}
/// `PickupTradeMoney`'s body after the argument gate: signed gates, the trimmed offer on the
/// wire first, then the coins onto the cursor.
/// `PickupTradeMoney` after the argument gate: signed gates, then `CMSG_SET_TRADE_GOLD` (`0x11F`)
/// with the trimmed offer, then the coins onto the cursor.
pub(crate) fn pickup_trade_money(model: &mut Model, amount: i32) -> bool {
let offer = model.trade.as_ref().map_or(0, |t| t.player.gold) as i32;
if amount <= 0 || amount > offer {
@@ -139,8 +112,7 @@ pub(crate) fn pickup_trade_money(model: &mut Model, amount: i32) -> bool {
}
impl crate::script::UiScript {
/// The trade window's open leg (`SetTradePartner 0x4bf4e0`): coins held on the cursor
/// fold into the offer before anything else. Answers the new offer when they did.
/// The trade window's open (`SetTradePartner`, `0x4bf4e0`) folds held coins into the offer.
pub fn fold_cursor_money_into_trade(&mut self) -> Option<u32> {
let mut model = self.model_mut();
add_trade_money(&mut model).then(|| model.trade.as_ref().map_or(0, |t| t.player.gold))
@@ -238,7 +210,6 @@ mod tests {
);
assert_eq!(s.eval::<i64>("return GetMoney()").unwrap(), 12_345);
assert!(s.cursor_item().is_none(), "coins are not an item cursor");
// The drop: coin sound and PLAYER_MONEY (the app's), mode 0, CURSOR_UPDATE — no packet.
s.run("DropCursorMoney()").unwrap();
assert_eq!(s.eval::<i64>("return GetCursorMoney()").unwrap(), 0);
assert_eq!(s.eval::<i64>("return GetMoney()").unwrap(), 12_345);
+55 -194
View File
@@ -1,35 +1,8 @@
//! The pet bar's drag (decision 1010) — **one** Lua verb, `PickupPetAction`, which forks on
//! whether the cursor is already carrying a pet payload. The reference's own bar calls it from all
//! three gestures (`PetActionButton_OnClick`
//! under shift, `OnDragStart`, and `OnReceiveDrag` — `PetActionBarFrame.lua:252-283`), so "pick up"
//! and "drop" are the same binding seen from either side of an empty cursor.
//!
//! **Three things set this surface apart from [`super::bar`]'s**, and each is why this is its own
//! module rather than an arm of that one:
//!
//! - **The payload is a WORD, forwarded verbatim.** `0x4bce00` hands the cursor's dword straight to
//! the assign core without reading a field of it, and the core's only write is
//! `bar[target] = *source`. There is no drop-time encoding — see [`CursorPetAction`].
//! - **The bar's contents are the SERVER's.** Nothing but a pet slot can produce this payload and
//! nothing but a pet slot accepts it, so the drag can only ever *rearrange* ten words the last
//! `SMSG_PET_SPELLS` delivered. That is the opposite of the action bar, where the 120-slot table
//! is ours and a drag populates it.
//! - **A displaced occupant is RELOCATED inside the bar before it is handed back.** The action bar
//! hops the displaced action onto the cursor unconditionally; here the core first tries to keep
//! it on the bar (a duplicate's slot, or an empty non-token slot), and only a target that was
//! *not* a token hands its occupant to the cursor.
//!
//! **A drop always empties the cursor first** (`0x495190 ClearCursor(1,1)`, run by all three of
//! `0x4bce00`'s callers *before* the accept decision), and only a write that relocated nothing
//! re-fills it, with the displaced occupant (`0x4bce38 test eax,eax; je`). So every refusal loses
//! the payload — read at the bytes, all six of `0x4bc9a0`'s exits: the no-op on an equal dword
//! (`0x4bc9ed xor eax,eax`), the passive-source reject (`0x4bca28`), the token abort, and both
//! never-blocking front gates all return `EAX = 0`. 1010 shipped the inverse of this as a named
//! divergence — a refused drop kept its payload — and the director retired it (1016): the case it
//! was protecting against is a drop the reference simply does not make, and dropping a pet spell
//! *away* from the bar is how you take it off the bar in the first place.
//!
//! (The **clamp** on slot 11 is still ours, and still deliberate — see [`PET_SLOTS`].)
//! The pet bar's one drag verb, `PickupPetAction`: a pickup with an empty cursor, a drop with a
//! pet payload held (`PetActionBarFrame.lua:252-283`). Only a pet slot makes or takes the
//! payload, a word carried verbatim (`0x4bce00`), so a drag only rearranges the bar the server
//! sent. A drop clears the cursor before deciding (`0x495190`) and only a write that relocated
//! nothing refills it (`0x4bce38`), so a refused drop loses the payload, as in the reference.
use mlua::Lua;
@@ -37,51 +10,39 @@ use crate::script::Model;
use super::{queue_cursor_update, CursorPayload, CursorPetAction};
/// Pet bar slots — the reference's `NUM_PET_ACTION_SLOTS` and vmangos's
/// `MAX_UNIT_ACTION_BAR_INDEX`, the same 10.
///
/// **The clamp lives here**, and it is a deliberate divergence from the reference's own arithmetic:
/// its callers gate `cmp esi,0xa; jbe`, which admits index 10 (Lua argument 11), and neither
/// `0x4bce00` nor `0x4bc9a0` bounds-checks again — so a real client writes one dword *past* the
/// ten-slot array. Not reproduced.
/// Pet bar slots (`NUM_PET_ACTION_SLOTS`, vmangos `MAX_UNIT_ACTION_BAR_INDEX`). Deviation: slot
/// 11 is refused, because the reference's gate (`cmp esi,0xa; jbe`) admits it and neither
/// `0x4bce00` nor `0x4bc9a0` bounds-checks again, so it writes one dword past the array.
const PET_SLOTS: u32 = 10;
/// The client's slot type, masked as it masks it (`(packed >> 24) & 0x3F`).
/// The slot type as the client masks it.
fn kind(packed: u32) -> u8 {
((packed >> 24) & 0x3F) as u8
}
/// A **token** slot — a command (7) or a reaction (6). The core treats the two identically
/// everywhere it asks the question: a token occupant is what forces the relocation search, and a
/// token is what disqualifies a slot from being a relocation candidate.
/// A command (7) or reaction (6) word, which the core treats alike.
fn is_token(packed: u32) -> bool {
matches!(kind(packed), 6 | 7)
}
/// Can this word ride the cursor at all? `0x494e20`'s own jump table (`0x494f40`) routes types
/// 1–5 / 6 / 7 and **bails on type 0 or ≥ 8** — so an empty or nonsense word produces no payload,
/// which is what makes "displaced an empty slot" hand back nothing rather than a blank carry.
/// The word as a payload, if it can be one: `0x494e20`'s jump table (`0x494f40`) takes types 1-7
/// only, so displacing an empty slot carries nothing.
fn payload_word(packed: u32) -> Option<u32> {
(1..=7).contains(&kind(packed)).then_some(packed)
}
/// The duplicate scan's comparison key (`0x4bca44`/`0x4bca57`, mask `0x3FFFFFFF`) — everything but
/// the two autocast bits. **Not** type-1-only: the mask covers the whole type+action field for
/// any source type.
/// The duplicate scan's compare (`0x4bca44`, `0x4bca57`): all but the autocast bits, any type.
fn same_action(a: u32, b: u32) -> bool {
a & 0x3FFF_FFFF == b & 0x3FFF_FFFF
}
/// The blanked-spell shape `PickupPetAction` writes back into a picked-from slot: type 1 with a
/// zero id. The duplicate scan is skipped for exactly this source (`0x4bca34`–`0x4bca3e`) — it
/// would otherwise match every other blanked slot on the bar.
/// The word a spell pickup leaves in its slot: type 1, id 0. The duplicate scan skips this
/// source (`0x4bca34`), which would otherwise match every other blanked slot.
fn is_blanked_spell(packed: u32) -> bool {
kind(packed) == 1 && packed & 0xFFFF == 0
}
/// A relocation **candidate** (`0x4bca8b`–`0x4bcab0`): not a token, and its low 16 bits zero. So an
/// all-zero slot qualifies and so does a blanked type-1 slot — not simply "token-or-empty" in
/// either direction.
/// A slot a token occupant may move to (`0x4bca8b`), such as an empty or blanked-spell slot.
fn is_relocation_candidate(packed: u32) -> bool {
!is_token(packed) && packed & 0xFFFF == 0
}
@@ -90,31 +51,15 @@ fn is_relocation_candidate(packed: u32) -> bool {
struct Assigned {
/// The slot the target's previous occupant was moved to, if it was moved.
reloc: Option<usize>,
/// The occupant left with nowhere to go — the cursor's new load. `None` when it was relocated,
/// and `None` when the target was empty (nothing to carry).
/// The occupant with nowhere to go, for the cursor; `None` if it was relocated or empty.
displaced: Option<u32>,
}
/// The assign core `0x4bc9a0(target, source, sendFlag = 1)` — "set a pet action", and the whole of
/// what a drop does. `None` = nothing was written and nothing should be sent.
///
/// The gates, in the binary's order:
///
/// 1. **no-op** when the source equals the target's current word, compared as the **full unmasked
/// dword** (`0x4bc9e2`) — autocast bits included, so re-dropping a slot on itself is inert.
/// 2. **silent reject** of a passive spell source (`0x4bc9f8`–`0x4bca2e`). No error id, unlike the
/// player bar's `PlaceAction`, which raises `0x9e` for the same test.
/// 3. **duplicate scan**, slots ascending: the first slot that carries the same action (and is not
/// the target) becomes the relocation slot, and the token search is **short-circuited entirely**
/// (`0x4bca78`). This is what makes the ordinary drag a swap.
/// 4. otherwise, if the target's occupant **is a token**, search for a candidate slot to move it
/// to; **no candidate ⇒ abort**, nothing written and nothing sent.
/// 5. write `bar[reloc] = old occupant` (when there is one) and `bar[target] = source`.
///
/// The reference's return value is `EAX = 1` iff **no** relocation happened (`0x4bcb92 setl`) —
/// so an abort and a successful relocation are indistinguishable at the call. That is the one thing
/// a re-implementation must not read as "did the write happen", which is why this returns
/// [`Assigned`] and never a bool.
/// The assign core (`0x4bc9a0`), the whole of a drop; `None` is no write and no send. In order:
/// an identical dword is a no-op (`0x4bc9e2`), a passive spell is refused silently (`0x4bc9f8`),
/// a duplicate's slot takes the occupant, which makes a drag a swap (`0x4bca78`), else a token
/// occupant needs a candidate slot or the drop aborts. The reference's return (`0x4bcb92`), 1
/// only when nothing was relocated, cannot tell an abort from a relocation; this one can.
fn assign(slots: &mut [u32], target: usize, source: u32, passive: bool) -> Option<Assigned> {
let occupant = *slots.get(target)?;
if occupant == source {
@@ -129,11 +74,9 @@ fn assign(slots: &mut [u32], target: usize, source: u32, passive: bool) -> Optio
reloc = (0..slots.len()).find(|&i| i != target && same_action(slots[i], source));
}
if reloc.is_none() && is_token(occupant) {
// No `j != target` guard, and none is needed: this branch only runs when the target's own
// occupant IS a token, which disqualifies it as a candidate. The bytes have no guard here
// either.
// No `j != target` guard, as in the reference: the target's token is never a candidate.
reloc = (0..slots.len()).find(|&j| is_relocation_candidate(slots[j]));
reloc?; // the abort — a token occupant with nowhere to go writes and sends nothing
reloc?; // the abort: a token with nowhere to go writes and sends nothing
}
if let Some(j) = reloc {
@@ -146,21 +89,10 @@ fn assign(slots: &mut [u32], target: usize, source: u32, passive: bool) -> Optio
})
}
/// `PickupPetAction(slot)` — the pet bar's one drag verb, 1-based (ref
/// `PetActionBarFrame.lua:252-283`; the binding `0x4be180`).
///
/// - **`UNIT_FLAG_POSSESSED` set** ⇒ nothing at all. The gate sits above the cursor fork
/// (`0x4be1c1`), so it blocks the drop as well as the pick-up.
/// - **carrying a pet payload** ⇒ [`place_pet_action`].
/// - **carrying anything else** ⇒ nothing. The reference only enters its drop trampoline when the
/// *pet* payload global is non-empty; a held spell or item is not that global and simply stays
/// held. (Which is the same fact as "a pet bar cannot be populated, only rearranged".)
/// - **empty cursor** ⇒ pick the slot up: the payload is its word verbatim, and — **for a spell
/// slot only** (`0x4be268 cmp cl,1`) — the slot is written back with its id zeroed, through the
/// same assign core and with the same send. A token slot keeps its contents while its word rides
/// the cursor, which is how a command ends up *copied* onto another slot rather than moved.
///
/// Returns whether the caller should repaint.
/// `PickupPetAction(slot)` (`0x4be180`), 1-based. `UNIT_FLAG_POSSESSED` blocks both ends, its
/// gate sitting above the cursor fork (`0x4be20a`). Another payload stays held here, where the
/// reference clears it (`0x4be220`) and picks the slot up. A pickup blanks only a spell slot
/// (`0x4be268`), so a dragged token is copied, not moved.
pub(super) fn pickup_pet_action(model: &mut Model, slot: u32) -> bool {
if !model.pet_bar.pickup_allowed {
return false;
@@ -184,13 +116,8 @@ pub(super) fn pickup_pet_action(model: &mut Model, slot: u32) -> bool {
passive: view.passive,
texture: view.texture.clone(),
}));
// A SPELL slot empties as it is picked up — id zeroed, type and autocast bits kept — and the
// server is told. Everything else keeps its slot.
//
// `passive: false` is not a shortcut: the assign core's passive gate reads the record of the
// **source word's own spell id**, and the blanked word's id is 0, which resolves to no record
// at all. So a passive spell still empties its slot when picked up — the refusal comes later,
// when the payload is dropped somewhere.
// A spell slot keeps its type and autocast bits with id 0, and the server is told. `false` is
// right: the passive gate reads the source's spell record, and id 0 has none.
if kind(packed) == 1 {
write_slot(model, index, packed & 0xFFFF_0000, false);
}
@@ -198,31 +125,19 @@ pub(super) fn pickup_pet_action(model: &mut Model, slot: u32) -> bool {
true
}
/// The drop half. Runs the assign core against the engine's optimistic mirror of the ten words,
/// queues the resulting `(0-based position, word)` pairs for the app to send as one
/// `CMSG_PET_SET_ACTION`, and disposes of the cursor:
///
/// - **write, occupant homeless** ⇒ the occupant goes onto the cursor (the trampoline's
/// `0x4bce3d`, reached only on the core's `EAX = 1`) — the familiar swap-in-hand.
/// - **write, occupant relocated** ⇒ the cursor empties; the displaced word is still on the bar.
/// - **no write** (self-drop, passive source, or the token abort) ⇒ the cursor empties too, and
/// the payload is gone. `ClearCursor` ran before the decision and `EAX = 0` never re-fills.
///
/// The cursor is therefore emptied on **every** path out of here, including the ones that return
/// `false` — `false` is "nothing was written", not "nothing happened".
/// The drop: the cursor always empties, and only a homeless occupant refills it (`0x4bce3d`).
/// Returns whether anything was written; the cursor empties on `false` too.
fn place_pet_action(model: &mut Model, slot: u32) -> bool {
let Some(CursorPayload::PetAction(held)) = model.cursor.clone() else {
return false;
};
// `ClearCursor(1,1)` — unconditional, and *before* anything is decided. Past the possessed
// gate in `PickupPetAction` there is no way back out of here still holding the payload.
// `ClearCursor(1,1)`, unconditionally, before anything is decided.
model.cursor = None;
let Some(index) = slot_index(model, slot) else {
queue_cursor_update(model);
return false;
};
// The target's own view, read BEFORE the write — a displaced word's passive bit and icon are
// its slot's, not the payload's we are about to consume.
// Read before the write: a displaced word's passive bit and icon are its slot's.
let occupant_view = model.pet_bar.slots[index].view.clone();
let Some(assigned) = write_slot(model, index, held.packed, held.passive) else {
queue_cursor_update(model);
@@ -241,7 +156,7 @@ fn place_pet_action(model: &mut Model, slot: u32) -> bool {
true
}
/// The 1-based button index → an in-range slot of a bar that exists.
/// The 0-based index of a 1-based slot on a bar that exists.
fn slot_index(model: &Model, slot: u32) -> Option<usize> {
if !model.pet_bar.has_bar || slot == 0 || slot > PET_SLOTS {
return None;
@@ -250,20 +165,14 @@ fn slot_index(model: &Model, slot: u32) -> Option<usize> {
(index < model.pet_bar.slots.len()).then_some(index)
}
/// Run [`assign`] against the engine's mirror and queue what changed. Split out because the pickup
/// path needs it too — the reference routes its own slot-blanking through the same core, with the
/// same send (`0x4be268`+).
///
/// The mirror is optimistic in exactly [`super::bar`]'s sense: the app owns the authoritative ten
/// words and re-pushes them, but a drag must read right in the same frame it happens.
/// Run [`assign`] on the engine's optimistic mirror of the ten words and queue the send; the
/// pickup's blank goes through it too, as in the reference (`0x4be268`).
fn write_slot(model: &mut Model, target: usize, source: u32, passive: bool) -> Option<Assigned> {
let mut words: Vec<u32> = model.pet_bar.slots.iter().map(|s| s.view.packed).collect();
let assigned = assign(&mut words, target, source, passive)?;
// One send carrying one or two `(position, data)` pairs — the optional relocation pair FIRST,
// then the mandatory target pair, exactly the order `0x4bcad4`+ builds them and exactly what
// `CMSG_PET_SET_ACTION` accepts. The relocation pair goes out whenever there was a relocation,
// without re-testing whether the word changed: that is the binary's own `if (relocSlot >= 0)`.
// One `CMSG_PET_SET_ACTION`: the relocation pair first whenever there was one, changed or
// not, then the target pair (`0x4bcad4`).
let mut entries: Vec<(u32, u32)> = Vec::with_capacity(2);
if let Some(j) = assigned.reloc {
entries.push((j as u32, words[j]));
@@ -277,14 +186,8 @@ fn write_slot(model: &mut Model, target: usize, source: u32, passive: bool) -> O
Some(assigned)
}
/// A **token** word's `texture` is the NAME of a global, not a path — `GetPetActionInfo 0x4bdc50`'s
/// own convention: the two `char[32]` tables hand back `PET_ATTACK_TEXTURE`, and the bar paints
/// with `getglobal(texture)`. The cursor takes a PATH, so a command or reaction word that reaches
/// the cursor has to carry one, or the drag is invisible — you pick Attack up and nothing sticks
/// to the pointer. Resolved here, the one place a slot's view becomes a cursor payload, exactly as
/// `PetActionBar.xml` resolves it in the one place a slot's view becomes a button.
///
/// A spell word already carries a real `Interface\Icons\…` path and is left alone.
/// A token's texture is a global's name (`GetPetActionInfo`, `0x4bdc50`), which the bar resolves
/// with `getglobal` (`PetActionBarFrame.lua:102`); the cursor needs the path, resolved here.
fn resolve_token_icon(lua: &Lua) {
let name = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
@@ -312,8 +215,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_pet_action(&mut model, slot)
};
// Both halves can leave a TOKEN on the cursor — the pick-up directly, the drop through
// its displaced occupant — so the resolution hangs off the binding, not off either arm.
// Both the pickup and a drop's displaced occupant can put a token on the cursor.
resolve_token_icon(lua);
Ok(took)
})?,
@@ -326,9 +228,8 @@ mod tests {
use super::*;
use crate::script::{PetActionView, UiScript};
/// The words a hunter's bar actually carries. `FILLER` is vmangos's own unused-slot shape
/// (`ACT_DISABLED` + spell id 0) — masked type 1 with a zero low word, which is exactly the
/// relocation candidate the core hunts for, and the reason it must not be flattened to 0.
/// A hunter's bar words. `FILLER` is vmangos's unused slot, `ACT_DISABLED` with spell 0: type
/// 1, not a zero word, so it must stay as sent.
const ATTACK: u32 = 0x0700_0002;
const FOLLOW: u32 = 0x0700_0001;
const DEFENSIVE: u32 = 0x0600_0001;
@@ -337,9 +238,7 @@ mod tests {
const BITE: u32 = 0x8100_0EC1;
const FILLER: u32 = 0x8100_0000;
/// A source that is **still on the bar** swaps with its destination: the duplicate scan finds
/// the copy, that slot takes the occupant, and the cursor comes away empty. This is the path a
/// TOKEN drag always takes, because a token pickup does not blank its source slot.
/// A token drag always takes this path: a token pickup leaves its slot as it was.
#[test]
fn a_source_still_on_the_bar_swaps_with_its_destination() {
let mut bar = [CLAW, GROWL, FILLER, ATTACK];
@@ -350,9 +249,7 @@ mod tests {
assert_eq!(a.displaced, None, "nothing left over to carry");
}
/// A SPELL drag is the other shape, and the pickup's blank is what makes it so: a blanked
/// source is the one thing the duplicate scan skips (`0x4bca34`), so nothing matches and the
/// occupant is displaced onto the cursor instead.
/// The pickup's blank (the duplicate scan skips it, `0x4bca34`) leaves no copy to swap with.
#[test]
fn a_blanked_source_displaces_the_occupant_instead_of_swapping() {
let mut bar = [CLAW, GROWL, FILLER, ATTACK];
@@ -364,8 +261,6 @@ mod tests {
assert_eq!(a.displaced, Some(GROWL));
}
/// A TOKEN occupant is never displaced onto the cursor: the core relocates it to the first
/// candidate (not a token, low 16 zero) and the cursor comes away empty.
#[test]
fn a_token_occupant_is_relocated_not_displaced() {
let mut bar = [CLAW, ATTACK, FILLER, DEFENSIVE];
@@ -376,9 +271,6 @@ mod tests {
assert_eq!(a.displaced, None, "nothing is left over to carry");
}
/// …and with nowhere to put it, the whole assignment ABORTS: no write, no send. The reference
/// reports this identically to a successful relocation (`EAX = 0`), which is why the outcome
/// here is an `Option` and never a bool.
#[test]
fn a_token_occupant_with_no_candidate_aborts_entirely() {
let mut bar = [CLAW, ATTACK, GROWL, DEFENSIVE];
@@ -387,15 +279,12 @@ mod tests {
assert_eq!(bar, before, "nothing written");
}
/// The two silent refusals: dropping a word onto the slot it already occupies (compared as the
/// FULL dword, autocast bits and all), and a passive spell source.
#[test]
fn the_self_drop_and_the_passive_source_are_no_ops() {
let mut bar = [CLAW, GROWL, FILLER, ATTACK];
assert!(assign(&mut bar, 0, CLAW, false).is_none());
assert!(assign(&mut bar, 1, CLAW, true).is_none(), "passive source");
// The autocast bit is part of the compare — the same spell with its ring flipped is a
// different word and DOES write.
// The autocast bit is part of the compare: flipped, the same spell writes.
assert!(assign(&mut bar, 0, CLAW & !0x4000_0000, false).is_some());
}
@@ -407,8 +296,7 @@ mod tests {
}
}
/// A token slot as the feed really pushes it: `name` and `texture` are both the NAMES OF
/// GLOBALS, which is `GetPetActionInfo`'s own convention for types 6 and 7.
/// A token slot as fed: `name` and `texture` are global names, as from `GetPetActionInfo`.
fn token_view(packed: u32, name: &str, texture: &str) -> PetActionView {
PetActionView {
is_token: true,
@@ -417,8 +305,7 @@ mod tests {
}
}
/// A hunter's default bar: three commands, four spell slots (two known, two filler), three
/// reactions — the arrangement `CharmInfo::InitPetActionBar` writes.
/// A hunter's default bar, as vmangos `CharmInfo::InitPetActionBar` lays it out.
fn hunter_bar() -> Vec<PetActionView> {
vec![
token_view(ATTACK, "PET_ACTION_ATTACK", "PET_ATTACK_TEXTURE"),
@@ -440,8 +327,6 @@ mod tests {
s
}
/// Picking a SPELL slot up empties it — id zeroed, type and autocast bits kept — and tells the
/// server in the same breath. The payload is the word verbatim.
#[test]
fn picking_up_a_spell_blanks_its_slot_and_sends() {
let mut s = bar_script();
@@ -458,12 +343,6 @@ mod tests {
);
}
/// A dragged TOKEN carries a real icon PATH, not the name of the global that holds one.
///
/// `GetPetActionInfo` hands a token slot `("PET_ACTION_ATTACK", …, "PET_ATTACK_TEXTURE")` —
/// two global names — and the bar paints with `getglobal`. The cursor cannot: it needs a path
/// to decode, so an unresolved name means dragging Attack, Follow or Stay sticks *nothing* to
/// the pointer, which is what the director saw. A spell slot's path is left exactly as it is.
#[test]
fn a_dragged_token_carries_a_resolved_icon_path() {
let mut s = UiScript::new().unwrap();
@@ -481,8 +360,7 @@ mod tests {
"the global was resolved, not carried as a name"
);
// An unset global resolves to nothing rather than to its own name — better a missing icon
// than a path that cannot exist.
// An unset global resolves to no icon, not to its own name.
s.run("ClearCursor() PET_ATTACK_TEXTURE = nil PickupPetAction(1)")
.unwrap();
let Some(CursorPayload::PetAction(p)) = s.cursor_payload() else {
@@ -491,8 +369,6 @@ mod tests {
assert_eq!(p.texture, None);
}
/// A TOKEN slot keeps its contents while its word rides the cursor (`0x4be268 cmp cl,1` — the
/// blank is type-1-only), so a command ends up COPIED onto its destination rather than moved.
#[test]
fn picking_up_a_token_leaves_the_slot_alone() {
let mut s = bar_script();
@@ -503,9 +379,6 @@ mod tests {
);
}
/// The whole gesture, end to end: pick a spell up and drop it two slots along. The pickup's
/// blank and the drop's write are two separate sends, and the displaced spell comes back on
/// the cursor.
#[test]
fn a_drag_between_two_spell_slots_sends_twice_and_carries_the_occupant() {
let mut s = bar_script();
@@ -522,9 +395,8 @@ mod tests {
);
}
/// Dropping onto a COMMAND relocates that command to a filler slot instead of handing it back
/// — the cursor comes away empty, and ONE send carries both pairs (the server tells the two
/// forms apart by body size, so they must not be split).
/// The relocation and the write share one send: the server counts the pairs by body size
/// (vmangos `Server/Packets/Pet.cpp:56`).
#[test]
fn dropping_onto_a_token_relocates_it_in_one_send() {
let mut s = bar_script();
@@ -540,13 +412,8 @@ mod tests {
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
}
/// A refused drop **eats** the payload — `ClearCursor` ran before the decision and `EAX = 0`
/// never re-fills. 1010 shipped the inverse as a named divergence; 1016 retired it.
///
/// The reachable refusal is the passive-spell source: the pickup has no passive gate, so a
/// passive slot picks up fine and is then refused everywhere it is dropped. Losing it is not
/// the data loss it looks like — the pickup's blank already went to the server, which is
/// exactly how a pet spell is taken off the bar.
/// The reachable refusal is a passive spell, which the pickup does not gate; its blank
/// already went to the server, which is how a pet spell is taken off the bar.
#[test]
fn a_refused_drop_eats_the_payload() {
let mut s = UiScript::new().unwrap();
@@ -569,8 +436,6 @@ mod tests {
);
}
/// The clamp. The reference's callers admit Lua argument 11 and its assign core never bounds
/// again, so a real client writes one dword past the array; we refuse instead.
#[test]
fn slot_eleven_is_refused_rather_than_written_past_the_array() {
let mut s = bar_script();
@@ -580,8 +445,7 @@ mod tests {
assert!(s.eval::<bool>("return GetCursorInfo() == nil").unwrap());
}
/// `UNIT_FLAG_POSSESSED` blocks BOTH ends, because the reference's gate sits above the cursor
/// fork: you cannot pick a possessed unit's slot up, and you cannot drop onto its bar either.
/// The third flag, `pickup_allowed`, is false under `UNIT_FLAG_POSSESSED`.
#[test]
fn a_possessed_bar_takes_no_drag_at_all() {
let mut s = UiScript::new().unwrap();
@@ -590,9 +454,6 @@ mod tests {
assert!(s.take_pet_set_actions().is_empty());
}
/// The two payload spaces do not overlap in either direction, and the two grids follow them:
/// a held spell lights the ACTION bar's grid and cannot land on a pet slot; a held pet action
/// lights the PET bar's and cannot land on an action slot.
#[test]
fn the_pet_payload_and_the_action_bar_refuse_each_other() {
let mut s = bar_script();