Cursor runs on ClearCursor and the setters' own event tails (#1038)

The cursor now runs on the client's two primitives. `ClearCursor(a,b)` (`0x495190`) cancels an armed gift wrap, then runs the payload's arm (an item's unlock fires `ITEM_LOCK_CHANGED`; coins fire `PLAYER_MONEY`), then the grid hide and `CURSOR_UPDATE`. Each setter runs that clear first, then its own tail (`0x494b60`, `0x4950f0`, `0x494d20`, `0x494f80`, `0x494e20`, `0x495010`). Every pickup, place, split, sale, trade, mail and auction path goes through them. So a `CURSOR_UPDATE` handler reads the emptied cursor, and `PickupSpell`/`PickupMacro`/`PickupPetAction`/`SplitContainerItem` clear what is held instead of refusing.

An item attached to mail or an auction stays locked until it leaves the slot (`0x4adef0`). Attaching to an occupied slot puts the old item back on the cursor.

Event-order tests cover each setter, the coin clear, and the attach lock and swap.

Fixes #1011
Fixes #1012
Fixes #1006
Fixes #1007
Part of #156

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 20:47:20 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent bb82dd1ac8
commit eb63c8861b
15 changed files with 1220 additions and 620 deletions
+78 -28
View File
@@ -8,7 +8,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{self, CursorPayload};
use super::cursor;
use super::Model;
use crate::script::binding_abi::{Idx, Idx32};
@@ -248,9 +248,13 @@ impl super::UiScript {
.map(|it| (it.bag, it.slot))
}
/// Empty the sell slot, once the auction is sent and on session close.
/// Empty the sell slot, once the auction is sent and on session close: the sell-slot clear
/// (`0x4cd640`), which lifts the attached item's lock with its `ITEM_LOCK_CHANGED`
/// (`0x495420` at `0x4cd653`).
pub fn clear_auction_sell_item(&mut self) {
self.model_mut().auction_sell_item = None;
if self.model_mut().auction_sell_item.take().is_some() {
self.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
}
}
@@ -759,12 +763,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ClickAuctionSellItemButton(): attach the cursor's item, or pick the attached one back up;
// every cursor write raises both CURSOR_UPDATE and ITEM_LOCK_CHANGED.
// ClickAuctionSellItemButton() (`0x4ce310`): attach the cursor's item, putting an earlier
// attachment back on the cursor ([`cursor::click_attach_slot`]). The app signals
// `NEW_AUCTION_UPDATE` when it sees the sell slot change.
g.set(
"ClickAuctionSellItemButton",
lua.create_function(|lua, ()| {
super::tick::verb(lua, click_auction_sell_item);
cursor::click_attach_slot(lua, |model| &mut model.auction_sell_item);
Ok(())
})?,
)?;
@@ -801,28 +806,6 @@ const AUCTION_INV_TYPES: [(u32, &str); 14] = [
(23, "INVTYPE_HOLDABLE"),
];
/// Attach the cursor's item to the sell slot, or with an empty cursor pick the attached one back
/// up; any other payload stays on the cursor.
fn click_auction_sell_item(model: &mut Model) {
match model.cursor.take() {
Some(CursorPayload::Item(item)) => {
model.auction_sell_item = Some(item);
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
}
None => {
if let Some(item) = model.auction_sell_item.take() {
model.cursor = Some(CursorPayload::Item(item));
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
}
}
Some(other) => {
model.cursor = Some(other);
}
}
}
#[cfg(test)]
mod tests {
/// The owner and bidder queries (`0x4cec80`, `0x4cecd0`) read `trunc(n)` as the page and take
@@ -1116,4 +1099,71 @@ mod tests {
assert!(!list.reversed("level"));
assert!(!list.reversed("seller"), "never sorted by: not reversed");
}
/// The attach clears with `SetCursorItem(0, …)` (`0x4ce56a`): no unlock and no
/// `ITEM_LOCK_CHANGED`, so the item stays locked in its bag and cannot be picked up again,
/// until the sell-slot clear lifts the lock with its event (`0x4cd653`).
#[test]
fn an_item_in_the_sell_slot_keeps_its_lock_until_it_leaves() {
use crate::script::cursor::{bag_lock_probe, two_item_bag};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
s.run("PickupContainerItem(0, 1)").unwrap();
heard::listen(
&s,
&["ITEM_LOCK_CHANGED", "CURSOR_UPDATE"],
Some(&bag_lock_probe(1)),
);
s.run("ClickAuctionSellItemButton()").unwrap();
assert_eq!(heard::take(&s), ["CURSOR_UPDATE 1"]);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(
s.cursor_payload().is_none(),
"a locked item is not picked up"
);
s.clear_auction_sell_item();
assert_eq!(heard::take(&s), ["ITEM_LOCK_CHANGED nil"]);
s.clear_auction_sell_item();
assert!(
heard::take(&s).is_empty(),
"an empty slot's clear signals nothing"
);
}
/// An occupied sell slot puts the old item back on the cursor through `SetCursorItem(0, …)`
/// (`0x4ce4f2`-`0x4ce56c`), both items staying locked; with nothing held and nothing attached
/// the click is `ClearCursor(1,1)` (`0x4ce523`).
#[test]
fn an_attach_onto_an_occupied_sell_slot_puts_the_old_item_on_the_cursor() {
use crate::script::cursor::{bag_lock_probe, install_kind_probe, two_item_bag};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
install_kind_probe(&s);
s.run("PickupContainerItem(0, 1) ClickAuctionSellItemButton() PickupContainerItem(0, 2)")
.unwrap();
heard::listen(
&s,
&["CURSOR_UPDATE", "ITEM_LOCK_CHANGED"],
Some("CursorKind()"),
);
s.run("ClickAuctionSellItemButton()").unwrap();
assert_eq!(
heard::take(&s),
["CURSOR_UPDATE none", "CURSOR_UPDATE item"]
);
assert_eq!(s.cursor_item().map(|i| i.slot), Some(1));
assert_eq!(s.auction_sell_item(), Some((0, 2)));
for slot in [1, 2] {
let probe = bag_lock_probe(slot);
assert!(s.eval::<bool>(&format!("return {probe} == 1")).unwrap());
}
s.run("ClearCursor() ClickAuctionSellItemButton() ClearCursor()")
.unwrap();
assert_eq!(s.auction_sell_item(), None);
heard::take(&s);
s.run("ClickAuctionSellItemButton()").unwrap();
assert_eq!(heard::take(&s), ["CURSOR_UPDATE none"]);
}
}
+50 -59
View File
@@ -268,7 +268,10 @@ pub enum UiCursorMode {
/// displaced item in the source slot, never on the cursor (no `SetCursorItem`, `0x5e0c40`);
/// - a split carry places onto an empty or same-item slot, and stays held over another item;
/// - a spell or action payload stays held.
fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
///
/// Returns the item a pickup puts on the cursor, which the binding sets past this segment
/// ([`cursor::pick_up_item`]), so the setter's inner clear signals on its own.
fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) -> Option<CursorItem> {
// An armed gift wrap completes here, before the cursor is read. `PickupContainerItem` is the
// only caller of the wrap sender `0x5edfc0`, so an equipped item cannot be wrapped.
if let Some(wrap) = model.pending_wrap {
@@ -279,7 +282,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
.and_then(|c| c.slots.get(&slot))
.is_some_and(|s| s.item_id != 0)
{
return;
return None;
}
// A vanished paper also bails, the wrap armed and the paper locked (`0x5edfef`).
if !model
@@ -288,7 +291,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
.and_then(|c| c.slots.get(&wrap.slot))
.is_some_and(|s| s.item_id != 0)
{
return;
return None;
}
// No eligibility test: the server refuses with an `ERR_CANT_WRAP_*` reason
// (`SMSG_INVENTORY_CHANGE_FAILURE` 43-48), and a local gate would eat that error line.
@@ -299,7 +302,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
cursor::raise_lock_changed(model);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
return;
return None;
}
// Only a held item (`0x4f9c38`) or vendor row (`0x4f9c43`) takes the click first. Past them
// an empty slot does nothing (`0x4f9c4e`), an armed item-targeting spell binds the item
@@ -320,36 +323,31 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
} else if occupied {
model.container_repairs.push((bag, slot));
}
return;
return None;
}
match model.cursor.take() {
None => {
let picked = model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.filter(|s| s.item_id != 0 && !s.locked)
.map(|s| CursorItem {
bag,
slot,
item_id: s.item_id,
texture: s.texture.clone(),
link: s.link.clone(),
quality: s.quality,
count: None,
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
});
if let Some(item) = picked {
model.cursor = Some(CursorPayload::Item(item));
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
}
}
match model.cursor.clone() {
// The lock gate reads the item's own bit (`0x4f9dc4`), the app's or the client's.
None => model
.containers
.get(&bag)
.and_then(|c| c.slots.get(&slot))
.filter(|s| s.item_id != 0 && !s.locked)
.filter(|_| !cursor::client_locked(model, bag, slot))
.map(|s| CursorItem {
bag,
slot,
item_id: s.item_id,
texture: s.texture.clone(),
link: s.link.clone(),
quality: s.quality,
count: None,
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
}),
// The cancel is `ClearCursor(1,1)` (`0x4f9e0e`): the unlock, then `CURSOR_UPDATE`.
Some(CursorPayload::Item(held)) if held.bag == bag && held.slot == slot => {
cursor::raise_lock_changed(model);
cursor::raise_cursor_update(model);
cursor::clear_cursor(model);
None
}
Some(CursorPayload::Item(held)) => {
// Cloned so the moves below can borrow `model`; an unresolved slot reads as empty.
@@ -361,14 +359,12 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
.filter(|d| d.item_id != 0);
match (held.count, dest) {
// A split carry cannot swap with a different item: it stays held.
(Some(_), Some(d)) if d.item_id != held.item_id => {
model.cursor = Some(CursorPayload::Item(held));
}
(Some(_), Some(d)) if d.item_id != held.item_id => {}
// A split places and clears (`0x5e1210`, then `ClearCursor(0,1)` at `0x4f9e4f`):
// the grid hide and `CURSOR_UPDATE`, and no lock event.
(Some(n), _) => {
queue_move(model, &held, bag, slot, Some(n));
cursor::raise_cursor_update(model);
cursor::clear(model, cursor::Clear::KEEP_LOCK);
}
// A whole stack locks the destination's item first (`0x4f9e85`; an empty slot's
// zero guid signals nothing, `0x4953ed`), then the swap send clears
@@ -380,10 +376,11 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
if dest.is_some() {
cursor::raise_lock_changed(model);
}
cursor::raise_cursor_update(model);
model.raise("CURSOR_UPDATE", Vec::new());
cursor::clear(model, cursor::Clear::KEEP_LOCK);
cursor::clear(model, cursor::Clear::KEEP_LOCK);
}
}
None
}
// Mode 5, a held vendor row, buys into this slot whatever it holds (`0x4f9efa` tests
// `[0xb4d900] == 5`): `CMSG_BUY_ITEM_IN_SLOT` (0x1a3) with count 1, and the cursor clears
@@ -399,20 +396,19 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
if let Some(entry) = entry {
model.merchant_slot_buys.push((bag, slot, entry));
}
cursor::raise_cursor_update(model);
cursor::clear_cursor(model);
None
}
Some(
other @ (CursorPayload::Spell(_)
CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)
// Mode 10, a stabled pet, stays held for the stable window.
| CursorPayload::StablePet(_)
// Mode 2, money, stays held for a money frame's DropFunc.
| CursorPayload::Money(_)),
) => {
model.cursor = Some(other);
}
| CursorPayload::Money(_),
) => None,
}
}
@@ -605,16 +601,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
};
let (info, held_here) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let held = matches!(
&model.cursor,
Some(CursorPayload::Item(c)) if c.bag == bag && c.slot == slot
) ||
// An armed wrap's paper reads locked until the send or a cancel (`0x4953e0` at
// `0x5edeb5`).
matches!(
&model.pending_wrap,
Some(w) if w.bag == bag && w.slot == slot
);
let held = cursor::client_locked(&model, bag, slot);
(
model
.containers
@@ -638,8 +625,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?,
),
Value::Integer(i64::from(s.count)),
// A held item's source slot reads locked, derived from the cursor so the app's
// re-push cannot wipe it.
// The client's own lock ([`cursor::client_locked`]) is kept apart from the app's
// push, so a re-push cannot wipe it.
flag(s.locked || held_here),
// A number on every path: -1 unless the template is cached and equippable
// (`0x4f9794`).
@@ -684,7 +671,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"PickupContainerItem",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
super::tick::verb(lua, |model| pickup_container_item(model, bag, slot));
if let Some(item) =
super::tick::verb(lua, |model| pickup_container_item(model, bag, slot))
{
cursor::pick_up_item(lua, item);
}
Ok(())
})?,
)?;
@@ -1256,7 +1247,7 @@ mod tests {
);
s.set_container(0, Some(state));
assert!(split_container_item(&mut s.model_mut(), 0, 1, 3));
assert!(split_container_item(&s.lua, 0, 1, 3));
let held = s.cursor_item().expect("the split carry");
assert_eq!(
(held.bag, held.slot, held.item_id, held.count),
@@ -1300,11 +1291,11 @@ mod tests {
fn split_of_the_whole_stack_is_a_plain_pickup() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack())); // slot 1: a 5-stack
assert!(split_container_item(&mut s.model_mut(), 0, 1, 5));
assert!(split_container_item(&s.lua, 0, 1, 5));
assert_eq!(s.cursor_item().unwrap().count, None);
// A split of more than the stack is a plain pickup too.
s.run("ClearCursor()").unwrap();
assert!(split_container_item(&mut s.model_mut(), 0, 1, 99));
assert!(split_container_item(&s.lua, 0, 1, 99));
assert_eq!(s.cursor_item().unwrap().count, None);
}
+417 -103
View File
@@ -1,11 +1,14 @@
//! The cursor payload: the client's payload-mode global `[0xb4d900]` as a typed enum, and the drag
//! gestures ([`drag`]) that change it. Bags, the paper doll ([`doll`]), the action bars ([`bar`])
//! and the spellbook all transition it through [`raise_cursor_update`] and [`raise_lock_changed`],
//! so sounds, `CURSOR_UPDATE`, grid events and lock display fire from one place.
//! gestures ([`drag`]) that change it. The payload changes through two primitives, as in the
//! reference: [`clear`], `ClearCursor(unlock, signal)` with its arm's events and its shared tail,
//! and [`put`], a setter's install and its own tail. Every setter runs its inner clear first
//! ([`set_cursor`] from a Lua verb, one segment each, so the clear's handlers read the emptied
//! cursor), and every verb that drops a payload clears through [`clear`].
use mlua::Lua;
use super::binding_abi::flag;
use super::tick::verb;
use super::{Model, ScriptValue};
mod bag_verbs;
@@ -158,49 +161,167 @@ pub struct CursorMacro {
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// The transition events
// The two primitives: the clear and the set
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Raise `CURSOR_UPDATE`, once per payload transition, after a grid event when what the bars can
/// take changes. Call it after `model.cursor` holds the new payload; an action hop keeps the grid
/// up rather than hiding and reshowing it. The grid event comes first, hides before shows, as
/// `ClearCursor`'s tail fires its grid hide (`0x4952ac`/`0x4952b3`) before its `CURSOR_UPDATE`
/// (`0x4952cc`) and `SetCursorItem` its `ACTIONBAR_SHOWGRID` (`0x494c3b`) before its own
/// (`0x494c54`).
pub(crate) fn raise_cursor_update(model: &mut Model) {
// The action bar's grid shows for any payload but a pet action, which `PlaceAction` refuses,
// or a vendor row, whose grab `0x4950f0` fires `ACTIONBAR_SHOWGRID` for mode 7 only
// (`0x495106`, `0x49513a`). The reference fires it (`0x4e58c0`) only from the item, spell and
// macro setters and that mode-7 grab, so coins and a stabled pet show it here and not there.
// The pet bar's follows the pet action alone (`0x494f28`).
let pet_held = matches!(model.cursor, Some(CursorPayload::PetAction(_)));
let bar_held = model.cursor.is_some()
&& !pet_held
&& !matches!(model.cursor, Some(CursorPayload::Merchant(_)));
let bar_flip = (bar_held != model.cursor_grid_shown).then_some(if bar_held {
"ACTIONBAR_SHOWGRID"
} else {
"ACTIONBAR_HIDEGRID"
});
let pet_flip = (pet_held != model.pet_grid_shown).then_some(if pet_held {
"PET_BAR_SHOWGRID"
} else {
"PET_BAR_HIDEGRID"
});
model.cursor_grid_shown = bar_held;
model.pet_grid_shown = pet_held;
// A hop between the bars hides the old grid before it shows the new one.
let (first, second) = if pet_held {
(bar_flip, pet_flip)
} else {
(pet_flip, bar_flip)
/// `ClearCursor`'s two arguments (`0x495190`): `ecx`, whether a held item's lock lifts in the
/// item arm (`0x4951c2`), and `edx`, whether held coins signal `PLAYER_MONEY` (`0x49523f`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct Clear {
pub(crate) unlock: bool,
pub(crate) signal_money: bool,
}
impl Clear {
/// `ClearCursor(1,1)`: Lua's `ClearCursor()` (`0x4895b0`), every cancel, and the inner clear
/// of each setter but `SetCursorItem`, which passes its own `edx` on (`0x494bb4`).
pub(crate) const BOTH: Clear = Clear {
unlock: true,
signal_money: true,
};
for event in [first, second].into_iter().flatten() {
model.raise(event, Vec::new());
/// `ClearCursor(0,1)`: an item send's clear, the item keeping its lock for the server's answer.
pub(crate) const KEEP_LOCK: Clear = Clear {
unlock: false,
signal_money: true,
};
/// `ClearCursor(1,0)`: the trade window's money fold, which repaints by its own events.
pub(crate) const NO_MONEY_EVENT: Clear = Clear {
unlock: true,
signal_money: false,
};
}
/// `ClearCursor(unlock, signal)` (`0x495190`), the one way a payload leaves the cursor. An armed
/// gift wrap is cancelled first (`0x495197`). Then the payload's arm: an item's unlock signals
/// `ITEM_LOCK_CHANGED` when `unlock` (`0x4951d9`, `0x495455`), coins `PLAYER_MONEY` when `signal`
/// (`0x495248`). Then the shared tail, on every call, an empty cursor included: a grid a setter
/// showed hides, the pet bar's for a held pet action and the action bar's otherwise (`0x4952ac`,
/// `0x4952b3`), and `CURSOR_UPDATE` fires on the emptied cursor (`0x4952cc`).
pub(crate) fn clear(model: &mut Model, how: Clear) {
// `0x5edf10`: the wrapping paper unlocks and the cursor mode returns to the base.
if model.pending_wrap.take().is_some() {
raise_lock_changed(model);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
}
// Coins go back to nothing, as the purse was never debited; a vendor row clears with no
// packet (`0x49525f`).
let held = model.cursor.take();
match &held {
Some(CursorPayload::Item(_)) if how.unlock => raise_lock_changed(model),
Some(CursorPayload::Money(_)) if how.signal_money => money::raise_player_money(model),
_ => {}
}
if std::mem::take(&mut model.cursor_grid) {
if matches!(held, Some(CursorPayload::PetAction(_))) {
model.raise("PET_BAR_HIDEGRID", Vec::new());
} else {
model.raise("ACTIONBAR_HIDEGRID", Vec::new());
}
}
model.raise("CURSOR_UPDATE", Vec::new());
}
/// `ClearCursor(1,1)`, Lua's `ClearCursor()` (`0x4895b0`).
pub(crate) fn clear_cursor(model: &mut Model) {
clear(model, Clear::BOTH);
}
/// A setter past its inner clear: `payload` goes on the cursor, then that setter's own tail.
/// `SetCursorItem` (`0x494b60`) shows the action bar's grid for an item the bar takes
/// (`0x494c3b`) and fires `CURSOR_UPDATE` (`0x494c54`); the row grab (`0x4950f0`) shows it for a
/// bar item, mode 7 (`0x49513a`), and fires `CURSOR_UPDATE` (`0x49515e`). The spell (`0x494dd1`)
/// and macro (`0x494fd7`) setters end on the action bar's grid, the pet-action setter on the pet
/// bar's (`0x494f28`), the money setter on `PLAYER_MONEY` (`0x494d0a`) and the stable grab on
/// nothing (`0x4950c5`), so no `CURSOR_UPDATE` follows theirs. A bar pickup takes the setter of
/// its kind (`0x4e6130`): the grab for an item, the spell or macro setter otherwise.
pub(crate) fn put(model: &mut Model, payload: CursorPayload) {
use super::action::ACTION_KIND_ITEM;
/// What a setter fires past its set.
enum Tail {
/// `SetCursorItem` and the row grab: the action bar's grid if asked, then `CURSOR_UPDATE`.
Update { grid: bool },
/// The spell and macro setters.
BarGrid,
/// The pet-action setter.
PetGrid,
/// The money setter.
Money,
/// The stable grab.
Nothing,
}
let tail = match &payload {
CursorPayload::Item(item) => Tail::Update {
grid: item.bar_placeable,
},
CursorPayload::Action(a) if a.kind == ACTION_KIND_ITEM => Tail::Update { grid: true },
CursorPayload::Merchant(_) => Tail::Update { grid: false },
CursorPayload::Action(_) | CursorPayload::Spell(_) | CursorPayload::Macro(_) => {
Tail::BarGrid
}
CursorPayload::PetAction(_) => Tail::PetGrid,
CursorPayload::Money(_) => Tail::Money,
CursorPayload::StablePet(_) => Tail::Nothing,
};
model.cursor = Some(payload);
match tail {
Tail::Update { grid } => {
if grid {
model.raise("ACTIONBAR_SHOWGRID", Vec::new());
model.cursor_grid = true;
}
model.raise("CURSOR_UPDATE", Vec::new());
}
Tail::BarGrid => {
model.raise("ACTIONBAR_SHOWGRID", Vec::new());
model.cursor_grid = true;
}
Tail::PetGrid => {
model.raise("PET_BAR_SHOWGRID", Vec::new());
model.cursor_grid = true;
}
Tail::Money => money::raise_player_money(model),
Tail::Nothing => {}
}
}
/// A cursor setter from a Lua verb. Its inner `ClearCursor(unlock, 1)` runs as one segment, so
/// that clear's handlers read the emptied cursor as the reference's do; then `payload`, read
/// past the clear, goes on with its tail as a second ([`put`]). `None` leaves the cursor clear,
/// as `SetCursorItem` with a zero guid (`0x494bc5`) or a stable grab whose slot fails its check
/// past the clear (`0x49505e`). Only `SetCursorItem` passes `unlock` through; every other setter
/// clears with `ClearCursor(1,1)`. Returns whether a payload went on.
pub(crate) fn set_cursor(
lua: &Lua,
unlock: bool,
payload: impl FnOnce(&Model) -> Option<CursorPayload>,
) -> bool {
verb(lua, |model| {
clear(
model,
Clear {
unlock,
signal_money: true,
},
);
});
verb(lua, |model| match payload(model) {
Some(payload) => {
put(model, payload);
true
}
None => false,
})
}
/// An item pickup: `SetCursorItem(1, …)`, then `LockItem` (`0x4953e0`), whose
/// `ITEM_LOCK_CHANGED` (`0x495415`) follows the set's `CURSOR_UPDATE`, as the bag (`0x4f9de9`,
/// `0x4f9df0`), doll (`0x4c7838`) and bag-slot (`0x4c7be2`) pickups and the split (`0x4fa0aa`) do.
pub(crate) fn pick_up_item(lua: &Lua, item: CursorItem) {
set_cursor(lua, true, |_| Some(CursorPayload::Item(item)));
verb(lua, raise_lock_changed);
}
/// Raise `ITEM_LOCK_CHANGED` for a slot that a pickup locks or a put-down frees. It carries no
/// arguments: the lock (`0x495415`) and the unlock (`0x495455`) are plain `SignalEvent`s, and the
/// stock buttons re-read every slot's lock.
@@ -208,6 +329,24 @@ pub(crate) fn raise_lock_changed(model: &mut Model) {
model.raise("ITEM_LOCK_CHANGED", Vec::new());
}
/// Whether the client itself holds the item at `(bag, slot)` locked, the reference's `item+0x314`
/// bit 0 that `LockItem` (`0x4953e0`) sets: while the item is on the cursor, is an armed gift
/// wrap's paper (`0x5edeb5`), or sits in the mail or auction attach slot, whose attach clears the
/// cursor with `SetCursorItem(0, …)` (`0x4ae0d2`, `0x4ce56a`) and so keeps the pickup's lock until
/// the item leaves the slot (`0x495420` at `0x4acdd6`, `0x4cd653`). The engine has no item object
/// to carry the bit, so it is read from the state that holds the item and lifts when the item
/// leaves it. The app's pending operations are the slot's `locked`.
pub(crate) fn client_locked(model: &Model, bag: i64, slot: u32) -> bool {
let at = |item: &CursorItem| item.bag == bag && item.slot == slot;
matches!(&model.cursor, Some(CursorPayload::Item(item)) if at(item))
|| model
.pending_wrap
.as_ref()
.is_some_and(|w| w.bag == bag && w.slot == slot)
|| model.mail_send_item.as_ref().is_some_and(at)
|| model.auction_sell_item.as_ref().is_some_and(at)
}
/// Raise `DELETE_ITEM_CONFIRM(name, quality)` for an item dropped on the world. The payload stays
/// held: the popup's `DeleteCursorItem` or `ClearCursor` clears it.
fn raise_delete_item_confirm(model: &mut Model, item: &CursorItem) {
@@ -227,38 +366,17 @@ pub(super) fn item_link_name(link: Option<&str>) -> String {
.unwrap_or_default()
}
/// `ClearCursor()` (`0x495190`): drops any payload and frees an item's source slot, then signals
/// `CURSOR_UPDATE` from its shared tail (`0x49529b`) on every call, an empty cursor included.
pub(crate) fn clear_cursor(model: &mut Model) {
// Any clear first cancels an armed gift wrap, whatever its parameters (`0x495190` opens with
// `0x5edf10`): the wrapping paper unlocks and the cursor mode returns to the base.
if model.pending_wrap.take().is_some() {
raise_lock_changed(model);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
}
// Coins go back to nothing, as the purse was never debited; a vendor row clears with no
// packet (`0x49525f`). An item's unlock signals in the mode-1 arm (`0x495455`), before the
// tail's grid hide and `CURSOR_UPDATE`.
if let Some(CursorPayload::Item(_)) = model.cursor.take() {
raise_lock_changed(model);
}
raise_cursor_update(model);
}
/// The sell fork's clear, `SetCursorItem(0, …)` (`0x494b60`), shared by `PickupMerchantItem`
/// (`0x4fb760`) and the interact ladder's vendor arm (`0x5df5d0`): unlike [`clear_cursor`] it
/// leaves the source slot locked, grey until the server removes the item (the mode-1 arm skips
/// `0x495420`). Any other payload is no sale and stays held (`GetCursorItem`, `0x494c60`).
/// The sell fork's clear, `SetCursorItem(0, …)` with a zero guid, so its inner `ClearCursor(0,1)`
/// alone (`0x4fb7ac`, `0x5df5fc`): `PickupMerchantItem` (`0x4fb760`) and the interact ladder's
/// vendor arm (`0x5df5d0`). Unlike [`clear_cursor`] it lifts no lock, so the source slot stays
/// grey until the server removes the item. Any other payload is no sale and stays held
/// (`GetCursorItem`, `0x494c60`).
pub(crate) fn take_cursor_item_for_sale(model: &mut Model) -> Option<CursorItem> {
let item = match model.cursor.take() {
Some(CursorPayload::Item(item)) => item,
other => {
model.cursor = other;
return None;
}
let Some(CursorPayload::Item(item)) = &model.cursor else {
return None;
};
raise_cursor_update(model);
let item = item.clone();
clear(model, Clear::KEEP_LOCK);
Some(item)
}
@@ -308,53 +426,86 @@ pub(crate) fn world_drop_click(model: &mut Model) -> bool {
}
}
/// `SplitContainerItem(bag, slot, count)`: picks up `count` of an unlocked stack onto an empty
/// cursor. A count of the whole stack or more is a plain pickup, since vmangos refuses to split a
/// `SplitContainerItem(bag, slot, count)` (`0x4f9f70`): an unlocked stack's `count` goes on the
/// cursor. Past its gates the binding clears with `ClearCursor(1,1)` (`0x4fa078`), whatever is
/// held, then picks up ([`pick_up_item`], `0x4fa0aa`), so its handlers see two clears and the
/// set. A count of the whole stack or more is a plain pickup, since vmangos refuses to split a
/// whole stack or more (`Player.cpp:11139`, `:11147`). Returns whether anything was picked up.
pub(crate) fn split_container_item(model: &mut Model, bag: i64, slot: u32, count: i64) -> bool {
if model.cursor.is_some() || count < 1 {
return false;
}
let Some(s) = model.containers.get(&bag).and_then(|c| c.slots.get(&slot)) else {
return false;
pub(crate) fn split_container_item(lua: &Lua, bag: i64, slot: u32, count: i64) -> bool {
let item = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(s) = model.containers.get(&bag).and_then(|c| c.slots.get(&slot)) else {
return false;
};
if count < 1 || s.item_id == 0 || s.locked || client_locked(&model, bag, slot) {
return false;
}
let n = count.min(i64::from(s.count)) as u32;
let whole = n >= s.count;
CursorItem {
bag,
slot,
item_id: s.item_id,
texture: s.texture.clone(),
link: s.link.clone(),
quality: s.quality,
count: if whole { None } else { Some(n) },
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
}
};
if s.item_id == 0 || s.locked {
return false;
}
let n = count.min(i64::from(s.count)) as u32;
let whole = n >= s.count;
let item = CursorItem {
bag,
slot,
item_id: s.item_id,
texture: s.texture.clone(),
link: s.link.clone(),
quality: s.quality,
count: if whole { None } else { Some(n) },
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
};
model.cursor = Some(CursorPayload::Item(item));
raise_cursor_update(model);
raise_lock_changed(model);
verb(lua, clear_cursor);
pick_up_item(lua, item);
true
}
/// `DeleteCursorItem()`, the delete popup's accept: a held item queues its `(bag, slot, count)`
/// destroy, count 0 for the whole stack, and clears; anything else is a no-op.
/// `DeleteCursorItem()` (`0x4897b0`), the delete popup's accept: a held item queues its
/// `(bag, slot, count)` destroy, count 0 for the whole stack, then `ClearCursor(1,1)`
/// (`0x4898af`) and `LockItem` on it again (`0x4898bc`) for the server's answer; anything else is
/// a no-op.
pub(crate) fn delete_cursor_item(model: &mut Model) {
let Some(CursorPayload::Item(item)) = &model.cursor else {
return;
};
let item = item.clone();
model.cursor = None;
model
.container_destroys
.push((item.bag, item.slot, item.count.unwrap_or(0)));
raise_cursor_update(model);
clear_cursor(model);
raise_lock_changed(model);
}
/// An attach slot's click, `ClickSendMailItemButton` (`0x4adef0`) and
/// `ClickAuctionSellItemButton` (`0x4ce310`) alike: `slot` names the attachment. The held item,
/// or none, is compared with the attachment (`0x4ae07a`, `0x4ce512`): the same, nothing on both
/// sides included, is a plain `ClearCursor(1,1)` (`0x4ae08b`, `0x4ce523`), which drops any other
/// payload. Otherwise the held item becomes the attachment and `SetCursorItem(0, …)` puts the old
/// one back on the cursor (`0x4ae0d4`, `0x4ce56c`): its inner `ClearCursor(0,1)` lifts no lock,
/// so the newly attached item stays locked ([`client_locked`]), and an empty old attachment
/// leaves the cursor clear. Returns whether the attachment changed, after which the caller's
/// slot signals its own update.
pub(crate) fn click_attach_slot(
lua: &Lua,
slot: impl Fn(&mut Model) -> &mut Option<CursorItem>,
) -> bool {
let (held, old) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let held = match &model.cursor {
Some(CursorPayload::Item(item)) => Some(item.clone()),
_ => None,
};
(held, slot(&mut model).clone())
};
let key = |item: &Option<CursorItem>| item.as_ref().map(|i| (i.bag, i.slot));
if key(&held) == key(&old) {
verb(lua, clear_cursor);
return false;
}
verb(lua, |model| *slot(model) = held);
set_cursor(lua, false, |_| old.map(CursorPayload::Item));
true
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
// UiScript accessors
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -488,7 +639,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"SplitContainerItem",
lua.create_function(|lua, (bag, slot, count): (i64, u32, i64)| {
super::tick::verb(lua, |model| split_container_item(model, bag, slot, count));
split_container_item(lua, bag, slot, count);
Ok(())
})?,
)?;
@@ -509,6 +660,61 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
/// Test-only: a `CursorKind()` global naming the payload a handler reads (`none`, `item`,
/// `spell`, `action`, `macro`, `pet`, `row`, `stable`, `money`), since 1.12's Lua has no getter for
/// most modes.
#[cfg(test)]
pub(crate) fn install_kind_probe(s: &super::UiScript) {
let kind = s
.lua
.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match &model.cursor {
None => "none",
Some(CursorPayload::Item(_)) => "item",
Some(CursorPayload::Spell(_)) => "spell",
Some(CursorPayload::Action(_)) => "action",
Some(CursorPayload::Macro(_)) => "macro",
Some(CursorPayload::PetAction(_)) => "pet",
Some(CursorPayload::Merchant(_)) => "row",
Some(CursorPayload::StablePet(_)) => "stable",
Some(CursorPayload::Money(_)) => "money",
})
})
.unwrap();
s.lua.globals().set("CursorKind", kind).unwrap();
}
/// Test-only: a backpack with two items, 117 in slot 1 and 118 in slot 2, both bar-placeable.
#[cfg(test)]
pub(crate) fn two_item_bag() -> super::container::ContainerState {
let item = |item_id: u32, name: &str| super::container::ContainerSlot {
item_id,
count: 1,
quality: Some(1),
bar_placeable: true,
texture: Some(format!("Interface\\Icons\\INV_{item_id}")),
link: Some(format!("|cffffffff|Hitem:{item_id}|h[{name}]|h|r")),
..Default::default()
};
super::container::ContainerState {
name: Some("Backpack".into()),
num_slots: 16,
slots: [
(1, item(117, "Tough Jerky")),
(2, item(118, "Minor Healing Potion")),
]
.into_iter()
.collect(),
}
}
/// Test-only: a Lua expression reading `GetContainerItemInfo(0, slot)`'s `locked`.
#[cfg(test)]
pub(crate) fn bag_lock_probe(slot: u32) -> String {
format!("(function() local _, _, l = GetContainerItemInfo(0, {slot}) return l end)()")
}
#[cfg(test)]
mod tests {
use super::{split_container_item, CursorAction, CursorItem, CursorPayload, CursorSpell};
@@ -687,7 +893,8 @@ mod tests {
assert!(s.cursor_payload().is_none());
}
/// A pickup's events have run by the verb's next Lua line, each handler reading the held item:
/// A pickup's events have run by the verb's next Lua line, each handler reading the cursor as
/// the reference's does: `SetCursorItem`'s inner clear on the empty cursor (`0x4952cc`), then
/// the grid before `CURSOR_UPDATE` (`0x494c3b`, `0x494c54`), then the lock (`0x495415`). The
/// same-slot click that puts it back is `ClearCursor(1,1)` (`0x4f9e0e`): the unlock
/// (`0x495455`), the grid hide (`0x4952b3`), then `CURSOR_UPDATE` (`0x4952cc`).
@@ -711,6 +918,7 @@ mod tests {
assert_eq!(
seen,
[
"CURSOR_UPDATE nil",
"ACTIONBAR_SHOWGRID 1",
"CURSOR_UPDATE 1",
"ITEM_LOCK_CHANGED 1"
@@ -876,8 +1084,114 @@ mod tests {
fn split_of_the_whole_stack_degrades_to_a_plain_pickup() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_item_backpack())); // slot 1: a 5-stack
assert!(split_container_item(&mut s.model_mut(), 0, 1, 5));
assert!(split_container_item(&s.lua, 0, 1, 5));
let held = s.cursor_item().expect("picked up");
assert_eq!(held.count, None, "count>=stack ⇒ whole-stack pickup");
}
/// `ClearCursor(1,1)`'s coin arm signals `PLAYER_MONEY` (`0x495248`) before the tail's
/// `CURSOR_UPDATE` (`0x4952cc`), both handlers reading the coins gone, so a money frame
/// repaints the purse whole: Lua's `ClearCursor()` (`0x4895b0`) and a bag right-click's clear
/// (`0x4fa198`) alike.
#[test]
fn a_clear_with_coins_held_signals_player_money() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
s.set_container(0, Some(one_item_backpack()));
heard::listen(
&s,
&["PLAYER_MONEY", "CURSOR_UPDATE"],
Some("GetCursorMoney()"),
);
for clear in ["ClearCursor()", "UseContainerItem(0, 1)"] {
s.run("PickupPlayerMoney(10)").unwrap();
heard::take(&s);
s.run(clear).unwrap();
assert_eq!(
heard::take(&s),
["PLAYER_MONEY 0", "CURSOR_UPDATE 0"],
"{clear}"
);
}
}
/// `PickupPlayerMoney`'s setter clears with `ClearCursor(1,1)` (`0x494ce0`): a held item
/// unlocks (`0x4951d9`) and an armed gift wrap is cancelled (`0x495197`) before the coins go
/// on and signal `PLAYER_MONEY` (`0x494d0a`).
#[test]
fn a_money_pickup_clears_a_held_item_and_an_armed_wrap() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
s.set_container(0, Some(one_item_backpack()));
super::install_kind_probe(&s);
s.run("PickupContainerItem(0, 1)").unwrap();
heard::listen(
&s,
&[
"ITEM_LOCK_CHANGED",
"ACTIONBAR_SHOWGRID",
"ACTIONBAR_HIDEGRID",
"CURSOR_UPDATE",
"PLAYER_MONEY",
],
Some("CursorKind()"),
);
s.run("PickupPlayerMoney(10)").unwrap();
assert_eq!(
heard::take(&s),
[
"ITEM_LOCK_CHANGED none",
"ACTIONBAR_HIDEGRID none",
"CURSOR_UPDATE none",
"PLAYER_MONEY money"
]
);
s.run("ClearCursor()").unwrap();
s.model_mut().pending_wrap =
Some(crate::script::container::PendingWrap { bag: 0, slot: 1 });
heard::take(&s);
s.run("PickupPlayerMoney(10)").unwrap();
assert!(
s.model_ref().pending_wrap.is_none(),
"the wrap is cancelled"
);
assert_eq!(
heard::take(&s),
[
"ITEM_LOCK_CHANGED none",
"CURSOR_UPDATE none",
"PLAYER_MONEY money"
]
);
}
/// The split clears with `ClearCursor(1,1)` (`0x4fa078`) whatever is held, then picks up
/// through `SetCursorItem`, whose inner clear signals again (`0x494bb6`), and `LockItem`.
#[test]
fn a_split_clears_what_is_held_then_picks_up() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_item_backpack()));
super::install_kind_probe(&s);
s.set_cursor_for_test(CursorPayload::Macro(super::CursorMacro {
index: 1,
texture: None,
}));
heard::listen(
&s,
&["CURSOR_UPDATE", "ITEM_LOCK_CHANGED"],
Some("CursorKind()"),
);
s.run("SplitContainerItem(0, 1, 2)").unwrap();
assert_eq!(
heard::take(&s),
[
"CURSOR_UPDATE none",
"CURSOR_UPDATE none",
"CURSOR_UPDATE item",
"ITEM_LOCK_CHANGED item"
]
);
assert_eq!(s.cursor_item().and_then(|i| i.count), Some(2));
}
}
@@ -6,8 +6,11 @@ use crate::script::container::ContainerMove;
use crate::script::tick::verb;
use crate::script::Model;
use super::{clear_cursor, raise_cursor_update, raise_lock_changed, CursorItem, CursorPayload};
use super::{doll::pickup_inventory_item, EQUIPMENT_BAG};
use super::doll::{doll_click, DollClick};
use super::{
clear, clear_cursor, client_locked, pick_up_item, raise_lock_changed, Clear, CursorItem,
CursorPayload, EQUIPMENT_BAG,
};
/// 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
@@ -74,17 +77,17 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
if !occupied {
// 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);
// whether or not it went in (`0x4c7cae xor eax,eax`). With an item held the doll click
// only places, so it owes no pickup.
let click = doll_click(model, live);
debug_assert!(matches!(click, DollClick::Done(_)));
return false;
}
// The held bag is this slot's own: a cancel that sends nothing and answers 1.
if held.bag == EQUIPMENT_BAG && held.slot == live {
// `ClearCursor(1,1)`: the unlock, then `CURSOR_UPDATE`.
model.cursor = None;
raise_lock_changed(model);
raise_cursor_update(model);
// `ClearCursor(1,1)` (`0x4c7cca`): the unlock, then `CURSOR_UPDATE`.
clear_cursor(model);
return true;
}
@@ -92,7 +95,6 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
// 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 {
src_bag: held.bag,
src_slot: held.slot,
@@ -100,11 +102,10 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
dst_slot: live,
count: None,
});
raise_cursor_update(model);
// The swap send's `ClearCursor(0,1)` (`0x5e0fe7`).
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
return true;
}
model.cursor = Some(CursorPayload::Item(held));
return true;
}
@@ -113,14 +114,14 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
let Some(dst_bag) = autostore_container(live) else {
return false;
};
model.cursor = None;
model.bag_autostores.push(crate::script::BagAutoStore {
src_bag: held.bag,
src_slot: held.slot,
dst_bag,
count: held.count,
});
raise_cursor_update(model);
// `ClearCursor(0,1)` past the send (`0x4c7e55`).
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
true
}
@@ -132,14 +133,13 @@ pub(super) fn put_item_in_backpack(model: &mut Model) -> bool {
let Some(CursorPayload::Item(held)) = model.cursor.clone() else {
return false;
};
model.cursor = None;
model.bag_autostores.push(crate::script::BagAutoStore {
src_bag: held.bag,
src_slot: held.slot,
dst_bag: 0,
count: held.count,
});
raise_cursor_update(model);
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
true
}
@@ -159,15 +159,22 @@ pub(super) fn pickup_bag_from_slot(lua: &Lua, live: u32) {
clear_cursor(model);
}
});
verb(lua, |model| grab_bag(model, live));
let picked = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
grab_bag(&model, live)
};
if let Some(item) = picked {
pick_up_item(lua, item);
}
}
/// `PickupBagFromSlot`'s grab of an unlocked container in `live`.
fn grab_bag(model: &mut Model, live: u32) {
let picked = usize::try_from(live)
/// `PickupBagFromSlot`'s grab: an unlocked container in `live` (`0x4c7bc1`), which it picks up
/// with `SetCursorItem(1, …)` and `LockItem` (`0x4c7be2`, `0x4c7be9`).
fn grab_bag(model: &Model, live: u32) -> Option<CursorItem> {
usize::try_from(live)
.ok()
.and_then(|slot| model.inv_slot("player", slot))
.filter(|s| s.item_id != 0 && !s.locked)
.filter(|s| s.item_id != 0 && !s.locked && !client_locked(model, EQUIPMENT_BAG, live))
.map(|s| CursorItem {
bag: EQUIPMENT_BAG,
slot: live,
@@ -179,12 +186,7 @@ fn grab_bag(model: &mut Model, live: u32) {
bar_placeable: s.bar_placeable,
equip_slots: s.equip_slots.clone(),
})
.filter(is_container);
if let Some(item) = picked {
model.cursor = Some(CursorPayload::Item(item));
raise_cursor_update(model);
raise_lock_changed(model);
}
.filter(is_container)
}
/// Register the three verbs as globals.
@@ -455,8 +457,9 @@ mod tests {
}
/// The clear's events (the unlock `0x495455`, then `0x4952cc`) run before the grab reads the
/// slot (`0x4c7be2`), so its handlers see the bag still in place and only the grab's
/// (`0x494c54`, then `0x495415`) see it held and locked.
/// slot (`0x4c7be2`), so its handlers see the bag still in place; the grab's own inner clear
/// signals once more on the empty cursor (`0x494bb6`), and only its set's events (`0x494c54`,
/// then `0x495415`) see the bag held and locked.
#[test]
fn pickup_bag_from_slot_fires_the_clear_before_the_grab() {
let mut s = UiScript::new().unwrap();
@@ -474,6 +477,7 @@ mod tests {
[
"ITEM_LOCK_CHANGED nil",
"CURSOR_UPDATE nil",
"CURSOR_UPDATE nil",
"CURSOR_UPDATE 1",
"ITEM_LOCK_CHANGED 1"
]
+112 -67
View File
@@ -9,7 +9,7 @@ use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPE
use crate::script::tick::verb;
use crate::script::{ActionSlot, Model};
use super::{raise_cursor_update, CursorAction, CursorPayload};
use super::{clear_cursor, set_cursor, CursorAction, CursorPayload};
/// The passive-spell refusal's error, errorId `0x9e`: entry `0xb4c0f0` of the table at `0xb4b498`
/// (stride `0x14`) names this key (`0x84133c`).
@@ -23,49 +23,56 @@ fn pack(kind: u8, action: u32) -> u32 {
/// `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. The cursor
/// goes first and the slot after, as `0x4e6130` runs its setter before the slot clear `0x4e5db0`,
/// so the pickup's `CURSOR_UPDATE` handler still reads the action in its slot. Returns whether to
/// repaint.
/// so the pickup's events still read the action in its slot. The setter is the one for the
/// slot's kind ([`super::put`]): its inner `ClearCursor(1,1)` signals on the empty cursor, then
/// the grid shows. Returns whether to repaint.
pub(super) fn pickup_action(lua: &Lua, id: u32) -> bool {
let held = lua
.app_data_ref::<Model>()
.expect("model app_data")
.cursor
.is_some();
if held {
let picked = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.cursor.is_some() {
None
} else {
// An empty slot returns before any setter (`0x4e61a3`).
Some(model.actions.get(&id).map(|slot| CursorAction {
src_slot: id,
kind: slot.kind,
action: slot.action,
texture: slot.texture.clone(),
}))
}
};
let Some(picked) = picked else {
return place_action(lua, id);
}
let picked = verb(lua, |model| {
let Some(slot) = model.actions.get(&id) else {
return false;
};
let payload = CursorAction {
src_slot: id,
kind: slot.kind,
action: slot.action,
texture: slot.texture.clone(),
};
model.cursor = Some(CursorPayload::Action(payload));
raise_cursor_update(model);
true
};
let Some(payload) = picked else {
return false;
};
set_cursor(lua, true, |_| Some(CursorPayload::Action(payload)));
verb(lua, |model| {
model.actions.remove(&id);
model.action_sets.push((id, 0));
});
if picked {
verb(lua, |model| {
model.actions.remove(&id);
model.action_sets.push((id, 0));
});
}
picked
true
}
/// `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. The cursor goes first and the slot after, as `0x4e62e0`
/// puts the displaced action on the cursor (or clears it) before it writes the slot, so the
/// cursor's events read the slot as it was. Returns whether to repaint.
/// slot's action hops onto the cursor with `id` as its source, through its setter, whose inner
/// `ClearCursor(1,1)` drops the held payload, hiding the grid, before the setter shows it again
/// (`0x4e6450`, `0x4e6494`, `0x4e64e2`); an empty slot's place is that clear alone (`0x4e650f`).
/// An item stays in its bag, since a bar item is a reference, not a move. The cursor goes first
/// and the slot after, as `0x4e62e0` writes the slot past the setter, so the cursor's events read
/// the slot as it was. Returns whether to repaint.
pub(crate) fn place_action(lua: &Lua, id: u32) -> bool {
let Some((kind, action, texture)) = verb(lua, |model| take_for_place(model, id)) else {
let Some((placed, displaced)) = verb(lua, |model| plan_place(model, id)) else {
return false;
};
let (kind, action, texture) = placed;
match displaced {
Some(displaced) => {
set_cursor(lua, true, |_| Some(CursorPayload::Action(displaced)));
}
None => verb(lua, clear_cursor),
}
verb(lua, |model| {
model.actions.insert(
id,
@@ -83,9 +90,12 @@ pub(crate) fn place_action(lua: &Lua, id: u32) -> bool {
true
}
/// `PlaceAction`'s cursor half: the accept filters, then the held payload swapped for the slot's
/// occupant. Returns what the slot takes, or `None` for a refusal that leaves the payload held.
fn take_for_place(model: &mut Model, id: u32) -> Option<(u8, u32, Option<String>)> {
/// What the slot takes and the action it displaces.
type PlacePlan = ((u8, u32, Option<String>), Option<CursorAction>);
/// `PlaceAction`'s decision: the accept filters, then what the slot takes from the held payload
/// and the occupant that hops. `None` is a refusal that leaves the payload held.
fn plan_place(model: &mut Model, id: u32) -> Option<PlacePlan> {
// 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
@@ -98,34 +108,26 @@ fn take_for_place(model: &mut Model, id: u32) -> Option<(u8, u32, Option<String>
}
_ => {}
}
let held = model.cursor.take()?;
let placeable = match &held {
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())),
let placed = match model.cursor.as_ref()? {
CursorPayload::Action(a) => (a.kind, a.action, a.texture.clone()),
CursorPayload::Item(i) => (ACTION_KIND_ITEM, i.item_id, i.texture.clone()),
CursorPayload::Spell(s) => (ACTION_KIND_SPELL, s.spell_id, s.texture.clone()),
// `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())),
CursorPayload::PetAction(_) => None,
CursorPayload::StablePet(_) => None,
CursorPayload::Money(_) => None,
CursorPayload::Merchant(_) => None,
CursorPayload::Macro(m) => (ACTION_KIND_MACRO, m.index, m.texture.clone()),
CursorPayload::PetAction(_)
| CursorPayload::StablePet(_)
| CursorPayload::Money(_)
| CursorPayload::Merchant(_) => return None,
};
let Some(placed) = placeable else {
model.cursor = Some(held);
return None;
};
model.cursor = model.actions.get(&id).map(|d| {
CursorPayload::Action(CursorAction {
src_slot: id,
kind: d.kind,
action: d.action,
texture: d.texture.clone(),
})
let displaced = model.actions.get(&id).map(|d| CursorAction {
src_slot: id,
kind: d.kind,
action: d.action,
texture: d.texture.clone(),
});
raise_cursor_update(model);
Some(placed)
Some((placed, displaced))
}
/// Register `PickupAction` and `PlaceAction` as globals.
@@ -334,7 +336,9 @@ mod tests {
}
#[test]
fn showgrid_hidegrid_fire_on_gain_and_loss_not_on_a_hop() {
/// The setters show the grid (`0x494dd1`) and every clear's tail hides it (`0x4952b3`), so a
/// hop's inner clear hides it before the displaced action's setter shows it again.
fn showgrid_hidegrid_fire_on_gain_and_loss_and_twice_on_a_hop() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
@@ -357,15 +361,15 @@ mod tests {
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
s.run("PlaceAction(2)").unwrap(); // Some -> Some (hop): neither
s.run("PlaceAction(2)").unwrap(); // Some -> Some (hop): HIDE, then SHOW
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 2);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 1);
s.run("ClearCursor()").unwrap(); // Some -> None: HIDE
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 1);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 2);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 2);
}
#[test]
@@ -457,4 +461,45 @@ mod tests {
assert!(place_action(&s.lua, 4));
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000u32 | 4496)]);
}
/// A bar pickup runs the setter of its slot's kind (`0x4e6130`): a spell's shows the grid and
/// signals nothing after (`0x494dd1`); an item's row grab shows it and signals after its set
/// (`0x49513a`, `0x49515e`). Each inner clear signals first, so a hop hides the grid and
/// `CURSOR_UPDATE` reads the emptied cursor before the displaced action goes on.
#[test]
fn a_bar_pickup_and_a_hop_signal_as_their_setters() {
let s = {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\INV_A", 0x80, 4540)));
s
};
crate::script::cursor::install_kind_probe(&s);
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE", "ACTIONBAR_SHOWGRID", "ACTIONBAR_HIDEGRID"],
Some("CursorKind()"),
);
s.run("PickupAction(1)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE none", "ACTIONBAR_SHOWGRID action"]
);
s.run("PlaceAction(2)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
[
"ACTIONBAR_HIDEGRID none",
"CURSOR_UPDATE none",
"ACTIONBAR_SHOWGRID action",
"CURSOR_UPDATE action"
]
);
// Onto an empty slot the place is the clear alone (`0x4e650f`).
s.run("PlaceAction(3)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["ACTIONBAR_HIDEGRID none", "CURSOR_UPDATE none"]
);
}
}
+74 -59
View File
@@ -8,16 +8,39 @@ use crate::script::container::ContainerMove;
use crate::script::tick::verb;
use crate::script::Model;
use super::{raise_cursor_update, raise_lock_changed, CursorItem, CursorPayload, EQUIPMENT_BAG};
use super::{
clear, clear_cursor, client_locked, pick_up_item, raise_lock_changed, Clear, CursorItem,
CursorPayload, EQUIPMENT_BAG,
};
/// `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 {
pub(super) fn pickup_inventory_item(lua: &Lua, id: u32) -> bool {
match verb(lua, |model| doll_click(model, id)) {
DollClick::Done(repaint) => repaint,
DollClick::PickUp(item) => {
pick_up_item(lua, item);
true
}
}
}
/// What a doll click did inside its segment, or the pickup it owes past it.
pub(super) enum DollClick {
/// Handled; whether to repaint.
Done(bool),
/// The item to pick up: `SetCursorItem(1, …)` and `LockItem` (`0x4c7838`, `0x4c783f`), whose
/// inner clear signals in a segment of its own ([`pick_up_item`]).
PickUp(CursorItem),
}
/// [`pickup_inventory_item`]'s decision and every arm but the pickup.
pub(super) fn doll_click(model: &mut Model, id: u32) -> DollClick {
// 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;
return DollClick::Done(false);
}
// Only a held item (`0x4c769a`) or vendor row (`0x4c76a5`) takes the click first. Past them
// an empty slot or a locked item does nothing (`0x4c76af`, `0x4c76d2`), an item-targeting
@@ -31,19 +54,21 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
if !placing && (model.item_pick_armed || model.repair_mode) {
let usable = model
.inv_slot("player", id as usize)
.is_some_and(|s| s.item_id != 0 && !s.locked);
.is_some_and(|s| s.item_id != 0 && !s.locked)
&& !client_locked(model, EQUIPMENT_BAG, id);
if usable && model.item_pick_armed {
model.item_picks.push((EQUIPMENT_BAG, id));
} else if usable && (1..=19).contains(&id) {
model.inventory_repairs.push(id);
}
return false;
return DollClick::Done(false);
}
match model.cursor.take() {
match model.cursor.clone() {
None => {
let picked = model
.inv_slot("player", id as usize)
.filter(|s| s.item_id != 0 && !s.locked)
.filter(|_| !client_locked(model, EQUIPMENT_BAG, id))
.map(|s| CursorItem {
bag: EQUIPMENT_BAG,
slot: id,
@@ -56,26 +81,19 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
equip_slots: s.equip_slots.clone(),
});
match picked {
Some(item) => {
model.cursor = Some(CursorPayload::Item(item));
raise_cursor_update(model);
raise_lock_changed(model);
true
}
None => false,
Some(item) => DollClick::PickUp(item),
None => DollClick::Done(false),
}
}
// The cancel is `ClearCursor(1,1)` (`0x4c794d`): the unlock, then `CURSOR_UPDATE`.
Some(CursorPayload::Item(held)) if held.bag == EQUIPMENT_BAG && held.slot == id => {
raise_lock_changed(model);
raise_cursor_update(model);
true
clear_cursor(model);
DollClick::Done(true)
}
Some(CursorPayload::Item(held)) => {
// 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;
return DollClick::Done(false);
}
model.container_moves.push(ContainerMove {
src_bag: held.bag,
@@ -84,9 +102,11 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
dst_slot: id,
count: None,
});
raise_cursor_update(model);
// The swap send clears with `ClearCursor(0,1)` (`0x5e0fe7`), then `LockItem`
// (`0x4c78c8`).
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
true
DollClick::Done(true)
}
// 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
@@ -101,11 +121,12 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
if let Some(entry) = entry {
model.merchant_slot_buys.push((EQUIPMENT_BAG, id, entry));
}
raise_cursor_update(model);
true
// `ClearCursor(1,1)` (`0x4c7954`).
clear_cursor(model);
DollClick::Done(true)
}
Some(
other @ (CursorPayload::Spell(_)
CursorPayload::Spell(_)
| CursorPayload::Action(_)
| CursorPayload::Macro(_)
| CursorPayload::PetAction(_)
@@ -114,17 +135,14 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
// 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);
false
}
| CursorPayload::Money(_),
) => DollClick::Done(false),
}
}
/// `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)
pub(super) fn equip_cursor_item(lua: &Lua, id: u32) -> bool {
pickup_inventory_item(lua, id)
}
/// `CursorCanGoInSlot(id)`, the doll slot's highlight on `CURSOR_UPDATE`
@@ -143,17 +161,14 @@ pub(super) fn cursor_can_go_in_slot(model: &Model, id: u32) -> bool {
/// 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() {
match &model.cursor {
Some(CursorPayload::Item(item)) if item.bag >= 0 && item.count.is_none() => {
model.container_autoequips.push((item.bag, item.slot));
raise_cursor_update(model);
clear(model, Clear::KEEP_LOCK);
raise_lock_changed(model);
true
}
other => {
model.cursor = other;
false
}
_ => false,
}
}
@@ -163,7 +178,7 @@ pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool {
/// dropping an empty slot unsent, or in repair mode a worn item is repaired, locked or not
/// (`0x4c79c4`).
pub(super) fn use_inventory_item(lua: &Lua, id: u32) {
verb(lua, super::clear_cursor);
verb(lua, clear_cursor);
verb(lua, |model| use_cleared(model, id));
}
@@ -184,11 +199,11 @@ fn use_cleared(model: &mut Model, id: u32) {
}
}
/// `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.
/// `IsInventoryItemLocked(id)`: true while the client holds `id` locked ([`client_locked`]) 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 held_here = client_locked(model, EQUIPMENT_BAG, id);
let fed = usize::try_from(id)
.ok()
.and_then(|i| model.inv_slot("player", i))
@@ -203,14 +218,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PickupInventoryItem",
lua.create_function(|lua, id: u32| {
verb(lua, |model| pickup_inventory_item(model, id));
pickup_inventory_item(lua, id);
Ok(())
})?,
)?;
g.set(
"EquipCursorItem",
lua.create_function(|lua, id: u32| {
verb(lua, |model| equip_cursor_item(model, id));
equip_cursor_item(lua, id);
Ok(())
})?,
)?;
@@ -389,7 +404,7 @@ mod tests {
s.set_inventory_slots(doll_slots());
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
assert!(pickup_inventory_item(&mut s.model_mut(), 1));
assert!(pickup_inventory_item(&s.lua, 1));
let held = s.cursor_item().expect("picked up");
assert_eq!(
(held.bag, held.slot, held.item_id),
@@ -404,8 +419,8 @@ mod tests {
fn pickup_same_doll_slot_cancels_no_move() {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
assert!(pickup_inventory_item(&mut s.model_mut(), 1));
assert!(pickup_inventory_item(&mut s.model_mut(), 1));
assert!(pickup_inventory_item(&s.lua, 1));
assert!(pickup_inventory_item(&s.lua, 1));
assert!(s.cursor_item().is_none());
assert!(s.take_container_moves().is_empty());
assert!(!s.eval::<bool>("return IsInventoryItemLocked(1)").unwrap());
@@ -420,7 +435,7 @@ mod tests {
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(s.cursor_item().unwrap().equip_slots, vec![1]);
assert!(pickup_inventory_item(&mut s.model_mut(), 1));
assert!(pickup_inventory_item(&s.lua, 1));
assert!(s.cursor_item().is_none(), "a plain clear, no hop");
assert_eq!(
s.take_container_moves(),
@@ -442,7 +457,7 @@ mod tests {
s.set_container(0, Some(one_fitting_bag_item()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(!pickup_inventory_item(&mut s.model_mut(), 2));
assert!(!pickup_inventory_item(&s.lua, 2));
let held = s.cursor_item().expect("kept — doesn't fit slot 2");
assert_eq!(held.item_id, 2000);
assert!(s.take_container_moves().is_empty());
@@ -453,10 +468,10 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_inventory_slots(doll_slots());
assert!(pickup_inventory_item(&mut s.model_mut(), 11));
assert!(pickup_inventory_item(&s.lua, 11));
assert_eq!(s.cursor_item().unwrap().equip_slots, vec![11, 12]);
assert!(pickup_inventory_item(&mut s.model_mut(), 12));
assert!(pickup_inventory_item(&s.lua, 12));
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
@@ -482,7 +497,7 @@ mod tests {
let held = s.cursor_item().expect("a split carry");
assert_eq!(held.count, Some(2));
assert!(!pickup_inventory_item(&mut s.model_mut(), 1));
assert!(!pickup_inventory_item(&s.lua, 1));
let held = s.cursor_item().expect("kept — can't equip a partial stack");
assert_eq!(held.count, Some(2));
assert!(s.take_container_moves().is_empty());
@@ -521,8 +536,8 @@ mod tests {
s.set_inventory_slots(slots);
// Ammo (0) is refused; Bag0Slot (20) picks up the equipped bag.
assert!(!pickup_inventory_item(&mut s.model_mut(), 0));
assert!(pickup_inventory_item(&mut s.model_mut(), 20));
assert!(!pickup_inventory_item(&s.lua, 0));
assert!(pickup_inventory_item(&s.lua, 20));
let held = s.cursor_item().expect("bag picked up from the bar");
assert_eq!(
(held.bag, held.slot, held.item_id),
@@ -569,7 +584,7 @@ mod tests {
s.run("PickupContainerItem(0, 3)").unwrap();
assert!(s.eval::<bool>("return CursorCanGoInSlot(21)").unwrap());
assert!(pickup_inventory_item(&mut s.model_mut(), 21));
assert!(pickup_inventory_item(&s.lua, 21));
assert!(s.cursor_item().is_none(), "a plain clear on place");
assert_eq!(
s.take_container_moves(),
@@ -682,8 +697,8 @@ mod tests {
}));
s.run("ShowRepairCursor()").unwrap();
assert!(!pickup_inventory_item(&mut s.model_mut(), 1));
assert!(!pickup_inventory_item(&mut s.model_mut(), 1));
assert!(!pickup_inventory_item(&s.lua, 1));
assert!(!pickup_inventory_item(&s.lua, 1));
s.run("UseInventoryItem(1)").unwrap();
assert!(s.cursor_item().is_none());
assert_eq!(s.take_inventory_repairs(), vec![1, 1, 1]);
@@ -772,7 +787,7 @@ mod tests {
s.set_container(0, Some(one_fitting_bag_item()));
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(equip_cursor_item(&mut s.model_mut(), 1));
assert!(equip_cursor_item(&s.lua, 1));
assert!(s.cursor_item().is_none());
assert_eq!(
s.take_container_moves(),
@@ -795,7 +810,7 @@ mod tests {
s.set_container(0, Some(one_fitting_bag_item()));
// Unarmed, both seams do their ordinary gesture and queue nothing.
assert!(pickup_inventory_item(&mut s.model_mut(), 1));
assert!(pickup_inventory_item(&s.lua, 1));
assert!(s.cursor_item().is_some());
assert!(s.take_item_picks().is_empty());
@@ -810,7 +825,7 @@ mod tests {
// Armed with an empty cursor: both seams bind and leave the cursor empty.
s.run("ClearCursor()").unwrap();
assert!(s.cursor_item().is_none());
assert!(!pickup_inventory_item(&mut s.model_mut(), 1));
assert!(!pickup_inventory_item(&s.lua, 1));
s.run("PickupContainerItem(0, 1)").unwrap();
assert_eq!(
s.take_item_picks(),
+16 -24
View File
@@ -2,7 +2,7 @@
use mlua::{Lua, Value};
use super::{raise_cursor_update, CursorMoney, CursorPayload};
use super::{clear, clear_cursor, set_cursor, Clear, CursorMoney, CursorPayload};
use crate::script::binding_abi::{flag, number_arg};
use crate::script::tick::verb;
use crate::script::Model;
@@ -25,6 +25,12 @@ pub fn coin_icon(copper: i64) -> &'static str {
}
}
/// Raise `PLAYER_MONEY`, which repaints the money frames less the held coins: the coin arm of
/// `ClearCursor` (`0x495248`) and the money setter's tail (`0x494d0a`).
pub(super) fn raise_player_money(model: &mut Model) {
model.raise("PLAYER_MONEY", Vec::new());
}
pub(crate) fn cursor_money(model: &Model) -> u32 {
match &model.cursor {
Some(CursorPayload::Money(m)) => m.copper,
@@ -32,25 +38,13 @@ pub(crate) fn cursor_money(model: &Model) -> u32 {
}
}
/// Raise `PLAYER_MONEY`, which repaints the money frames less the held coins.
fn raise_player_money(model: &mut Model) {
model.raise("PLAYER_MONEY", Vec::new());
}
/// Put coins on the cursor as `0x494cc0` does, in two steps. Its inner `ClearCursor(1,1)`
/// (`0x494cd9`) drops coins already held with their `PLAYER_MONEY` (`0x495243`) and fires
/// Put coins on the cursor as `0x494cc0` does. Its inner `ClearCursor(1,1)` (`0x494cd9`) drops
/// whatever is held with that arm's events, an item's unlock or coins' `PLAYER_MONEY`, and fires
/// `CURSOR_UPDATE` (`0x4952cc`) on the empty cursor, so that handler sees no coins; then the coins
/// land and `PLAYER_MONEY` fires (`0x494d0a`), with no second `CURSOR_UPDATE`.
fn install_money(lua: &Lua, copper: u32) {
verb(lua, |model| {
if matches!(model.cursor.take(), Some(CursorPayload::Money(_))) {
raise_player_money(model);
}
raise_cursor_update(model);
});
verb(lua, |model| {
model.cursor = Some(CursorPayload::Money(CursorMoney { copper }));
raise_player_money(model);
set_cursor(lua, true, |_| {
Some(CursorPayload::Money(CursorMoney { copper }))
});
}
@@ -66,14 +60,13 @@ pub(crate) fn pickup_player_money(lua: &Lua, copper: u32) -> bool {
allowed
}
/// `DropCursorMoney`: `PLAYER_MONEY`, then the clear's `CURSOR_UPDATE`, and no packet.
/// `DropCursorMoney` (`0x48aba0`), `SetCursorMoney(0)`: its `ClearCursor(1,1)` fires the coin
/// arm's `PLAYER_MONEY`, then `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;
}
model.cursor = None;
raise_player_money(model);
raise_cursor_update(model);
clear_cursor(model);
true
}
@@ -94,9 +87,8 @@ pub(crate) fn add_trade_money(model: &mut Model) -> bool {
if let Some(t) = model.trade.as_mut() {
t.player.gold = total;
}
// `ClearCursor(1, 0)`: no PLAYER_MONEY, the clear's own CURSOR_UPDATE.
model.cursor = None;
raise_cursor_update(model);
// `ClearCursor(1,0)` (`0x4bfe4b`): no PLAYER_MONEY, the clear's own CURSOR_UPDATE.
clear(model, Clear::NO_MONEY_EVENT);
true
}
+111 -85
View File
@@ -1,5 +1,5 @@
//! 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
//! The pet bar's one drag verb, `PickupPetAction`: a drop with a pet payload held, a pickup
//! otherwise (`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.
@@ -12,7 +12,7 @@ use mlua::Lua;
use crate::script::tick::verb;
use crate::script::Model;
use super::{raise_cursor_update, CursorPayload, CursorPetAction};
use super::{clear_cursor, set_cursor, CursorPayload, CursorPetAction};
/// 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
@@ -95,25 +95,42 @@ fn assign(slots: &mut [u32], target: usize, source: u32, passive: bool) -> Optio
/// `PickupPetAction(slot)` (`0x4be180`), 1-based. A pet whose object does not resolve
/// (`0x4be1f7`) or is `UNIT_FLAG_POSSESSED` (`0x4be20a`) blocks both ends, the gate sitting above
/// the cursor fork, so the cursor is left as it was. 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; the blank comes after the setter's
/// events (`0x494e20`), so they read the slot as it was. Returns whether to repaint.
/// the cursor fork, so the cursor is left as it was. Past it the binding clears with
/// `ClearCursor(1,1)` (`0x4be220`), whatever is held, before anything is decided: a held pet
/// action drops, anything else is dropped and the slot picked up. The pickup is the pet-action
/// setter (`0x494e20`), whose own inner clear signals again on the empty cursor before the set
/// and its `PET_BAR_SHOWGRID`. A pickup blanks only a spell slot (`0x4be268`), so a dragged token
/// is copied, not moved; the blank comes after the setter's events, so they read the slot as it
/// was. Returns whether to repaint.
pub(super) fn pickup_pet_action(lua: &Lua, slot: u32) -> bool {
let pick = verb(lua, |model| {
let pick = pick_or_place(model, slot);
// Both the pickup and a drop's displaced occupant can put a token on the cursor.
resolve_token_icon(lua, model);
pick
});
match pick {
PetPick::Refused => false,
PetPick::Dropping(held) => verb(lua, |model| {
let wrote = place_pet_action(model, slot, &held);
resolve_token_icon(lua, model);
let held = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if !model.pet_bar.pickup_allowed {
return false;
}
match &model.cursor {
Some(CursorPayload::PetAction(held)) => Some(held.clone()),
_ => None,
}
};
verb(lua, clear_cursor);
match held {
Some(held) => {
let (wrote, displaced) = verb(lua, |model| place_pet_action(model, slot, &held));
if let Some(displaced) = displaced {
set_pet_action(lua, displaced);
}
wrote
}),
PetPick::Picked(blank) => {
}
None => {
let picked = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
pick_pet_action(&model, slot)
};
let Some((payload, blank)) = picked else {
return false;
};
set_pet_action(lua, payload);
if let Some((index, word)) = blank {
verb(lua, |model| {
write_slot(model, index, word, false);
@@ -124,72 +141,56 @@ pub(super) fn pickup_pet_action(lua: &Lua, slot: u32) -> bool {
}
}
/// What [`pickup_pet_action`]'s cursor half did.
enum PetPick {
Refused,
/// A held pet action left the cursor, its clear signalled; the drop still owes its write.
Dropping(CursorPetAction),
/// The slot's word went on the cursor; a spell slot still owes its blank, `(index, word)`.
Picked(Option<(usize, u32)>),
/// The pet-action setter (`0x494e20`): a token's icon is resolved before its inner clear, then
/// the clear and the set run as their own segments ([`set_cursor`]).
fn set_pet_action(lua: &Lua, mut payload: CursorPetAction) {
resolve_token_icon(lua, &mut payload);
set_cursor(lua, true, |_| Some(CursorPayload::PetAction(payload)));
}
fn pick_or_place(model: &mut Model, slot: u32) -> PetPick {
if !model.pet_bar.pickup_allowed {
return PetPick::Refused;
}
match &model.cursor {
Some(CursorPayload::PetAction(held)) => {
// `ClearCursor(1,1)` (`0x4be220`), unconditionally, before anything is decided: its
// `PET_BAR_HIDEGRID` and `CURSOR_UPDATE` run before the bar write.
let held = held.clone();
model.cursor = None;
raise_cursor_update(model);
return PetPick::Dropping(held);
}
Some(_) => return PetPick::Refused,
None => {}
}
let Some(index) = slot_index(model, slot) else {
return PetPick::Refused;
};
let view = model.pet_bar.slots[index].view.clone();
let Some(packed) = payload_word(view.packed) else {
return PetPick::Refused;
};
model.cursor = Some(CursorPayload::PetAction(CursorPetAction {
/// The slot's word as a payload past the binding's clear (`0x4be23e`): the bar must exist and the
/// word be one the setter takes. A spell slot also owes its blank, `(index, word)`: it keeps its
/// type and autocast bits with id 0, and the server is told. `false` is right for that write: the
/// passive gate reads the source's spell record, and id 0 has none.
fn pick_pet_action(model: &Model, slot: u32) -> Option<(CursorPetAction, Option<(usize, u32)>)> {
let index = slot_index(model, slot)?;
let view = &model.pet_bar.slots[index].view;
let packed = payload_word(view.packed)?;
let payload = CursorPetAction {
src_slot: slot,
packed,
passive: view.passive,
texture: view.texture.clone(),
}));
raise_cursor_update(model);
// 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.
PetPick::Picked((kind(packed) == 1).then_some((index, packed & 0xFFFF_0000)))
};
Some((
payload,
(kind(packed) == 1).then_some((index, packed & 0xFFFF_0000)),
))
}
/// The drop past its clear: the bar write (`0x4bce00`), and only a homeless occupant refills the
/// cursor (`0x4bce3d`), signalling as a pickup does. Returns whether anything was written.
fn place_pet_action(model: &mut Model, slot: u32, held: &CursorPetAction) -> bool {
/// The drop past its clear: the bar write (`0x4bce00`). Only a homeless occupant goes back on the
/// cursor (`0x4bce3d`), through the setter as a pickup does. Returns whether anything was
/// written, and that occupant.
fn place_pet_action(
model: &mut Model,
slot: u32,
held: &CursorPetAction,
) -> (bool, Option<CursorPetAction>) {
let Some(index) = slot_index(model, slot) else {
return false;
return (false, None);
};
// 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 {
return false;
return (false, None);
};
if let Some(packed) = assigned.displaced {
model.cursor = Some(CursorPayload::PetAction(CursorPetAction {
src_slot: slot,
packed,
passive: occupant_view.passive,
texture: occupant_view.texture.clone(),
}));
raise_cursor_update(model);
}
true
let displaced = assigned.displaced.map(|packed| CursorPetAction {
src_slot: slot,
packed,
passive: occupant_view.passive,
texture: occupant_view.texture.clone(),
});
(true, displaced)
}
/// The 0-based index of a 1-based slot on a bar that exists.
@@ -229,19 +230,16 @@ fn write_slot(model: &mut Model, target: usize, source: u32, passive: bool) -> O
}
/// 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. A raw
/// global read touches no model state, so it runs inside the verb's segment, before its events.
fn resolve_token_icon(lua: &Lua, model: &mut Model) {
let Some(CursorPayload::PetAction(p)) = &mut model.cursor else {
return;
};
if !is_token(p.packed) {
/// with `getglobal` (`PetActionBarFrame.lua:102`); the cursor needs the path, resolved here, as
/// the setter resolves the token's string before its clear (`0x494ede`).
fn resolve_token_icon(lua: &Lua, payload: &mut CursorPetAction) {
if !is_token(payload.packed) {
return;
}
let Some(name) = p.texture.clone() else {
let Some(name) = payload.texture.clone() else {
return;
};
p.texture = lua.globals().raw_get(name.as_str()).unwrap_or_default();
payload.texture = lua.globals().raw_get(name.as_str()).unwrap_or_default();
}
/// Register the pet bar's one cursor global.
@@ -362,8 +360,9 @@ mod tests {
}
/// A drop clears first (`0x4be220`): its grid hide and `CURSOR_UPDATE` read the bar before the
/// write (`0x4bce00`), and the displaced occupant's events after it. The written word is
/// engine state, read here through a test global.
/// write (`0x4bce00`). The displaced occupant goes through the setter after it (`0x4bce40`):
/// its inner clear's `CURSOR_UPDATE`, then the set's `PET_BAR_SHOWGRID` (`0x494f28`), and no
/// `CURSOR_UPDATE` after. The written word is engine state, read here through a test global.
#[test]
fn a_drop_signals_its_clear_before_the_bar_write() {
let s = bar_script();
@@ -388,8 +387,8 @@ mod tests {
[
"PET_BAR_HIDEGRID false",
"CURSOR_UPDATE false",
"PET_BAR_SHOWGRID true",
"CURSOR_UPDATE true"
"CURSOR_UPDATE true",
"PET_BAR_SHOWGRID true"
]
);
assert!(s.eval::<bool>("return SlotFiveIsClaw()").unwrap());
@@ -626,4 +625,31 @@ mod tests {
"and writes nothing to the action bar"
);
}
/// The binding's `ClearCursor(1,1)` (`0x4be220`) drops whatever is held, then the pet-action
/// setter clears again (`0x494f00`) before the set and its `PET_BAR_SHOWGRID` (`0x494f28`):
/// two `CURSOR_UPDATE`s on the emptied cursor and none after.
#[test]
fn a_pickup_clears_twice_then_shows_the_pet_grid() {
let mut s = bar_script();
s.set_cursor_for_test(CursorPayload::Macro(crate::script::cursor::CursorMacro {
index: 1,
texture: None,
}));
crate::script::cursor::install_kind_probe(&s);
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE", "PET_BAR_SHOWGRID", "PET_BAR_HIDEGRID"],
Some("CursorKind()"),
);
assert!(pickup_pet_action(&s.lua, 4));
assert_eq!(
crate::script::tick::heard::take(&s),
[
"CURSOR_UPDATE none",
"CURSOR_UPDATE none",
"PET_BAR_SHOWGRID pet"
]
);
}
}
+59 -32
View File
@@ -9,7 +9,7 @@
use mlua::{Lua, MultiValue, Value};
use super::cursor::{raise_cursor_update, CursorMacro, CursorPayload};
use super::cursor::{set_cursor, CursorMacro, CursorPayload};
use super::Model;
use crate::script::binding_abi::Idx;
@@ -255,23 +255,23 @@ fn delete_macro(model: &mut Model, index: usize) -> bool {
true
}
/// `PickupMacro(index)`: the macro payload (cursor mode 8, `[0xb4e2fc]`), which `PlaceAction`
/// (`0x4e62e0`) packs as `macroId | 0x40000000`. Refused while the cursor holds anything; the
/// reference's setter (`0x494f80`) clears the cursor first instead.
fn pickup_macro(model: &mut Model, index: usize) -> bool {
if model.cursor.is_some() {
return false;
}
let Some(entry) = model.macros.get(index) else {
return false;
/// `PickupMacro(index)` (`0x4f1ae0`): the macro payload (cursor mode 8, `[0xb4e2fc]`), which
/// `PlaceAction` (`0x4e62e0`) packs as `macroId | 0x40000000`. A missing macro does nothing
/// (`0x4f1b1e`); otherwise the setter (`0x494f80`) clears whatever is held with its inner
/// `ClearCursor(1,1)`, then sets the macro and shows the action bar's grid. Returns whether a
/// macro went on.
fn pickup_macro(lua: &Lua, index: usize) -> bool {
let payload = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(entry) = model.macros.get(index) else {
return false;
};
CursorMacro {
index: index as u32,
texture: entry.texture.clone(),
}
};
let payload = CursorMacro {
index: index as u32,
texture: entry.texture.clone(),
};
model.cursor = Some(CursorPayload::Macro(payload));
raise_cursor_update(model);
true
set_cursor(lua, true, |_| Some(CursorPayload::Macro(payload)))
}
fn opt_string(v: Option<&Value>) -> Option<String> {
@@ -436,7 +436,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PickupMacro",
lua.create_function(|lua, index: usize| {
super::tick::verb(lua, |model| pickup_macro(model, index));
pickup_macro(lua, index);
Ok(())
})?,
)?;
@@ -660,26 +660,24 @@ mod tests {
}
#[test]
fn pickup_macro_loads_the_cursor_and_refuses_while_holding() {
fn pickup_macro_loads_the_cursor_over_whatever_is_held() {
use crate::script::cursor::CursorPayload;
let s = seeded();
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
.unwrap();
assert!(!pickup_macro(&mut s.model_mut(), 5), "empty slot");
assert!(pickup_macro(&mut s.model_mut(), 1));
assert_eq!(
s.cursor_payload(),
Some(CursorPayload::Macro(CursorMacro {
index: 1,
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
}))
);
assert!(
!pickup_macro(&mut s.model_mut(), 1),
"a macro button is a source, never a drop target"
);
assert!(!pickup_macro(&s.lua, 5), "empty slot");
assert!(s.cursor_payload().is_none());
assert!(pickup_macro(&s.lua, 1));
let ambush = Some(CursorPayload::Macro(CursorMacro {
index: 1,
texture: Some("Interface\\Icons\\Ability_Ambush".into()),
}));
assert_eq!(s.cursor_payload(), ambush);
// The setter's inner clear drops what is held, then sets (`0x494fad`).
assert!(pickup_macro(&s.lua, 1));
assert_eq!(s.cursor_payload(), ambush);
}
#[test]
@@ -693,4 +691,33 @@ mod tests {
assert!(s.eval::<bool>("return GetMacroIconInfo(4) == nil").unwrap());
assert!(s.eval::<bool>("return GetMacroIconInfo(0) == nil").unwrap());
}
/// The macro setter (`0x494f80`) signals only through its inner clear (`0x494fad`): that
/// `CURSOR_UPDATE` reads the empty cursor, and the set ends on the grid (`0x494fd7`).
#[test]
fn a_macro_pickup_signals_its_clear_then_shows_the_grid() {
let s = seeded();
s.run(r#"CreateMacro("Ambush", 1, "/cast Ambush")"#)
.unwrap();
crate::script::cursor::install_kind_probe(&s);
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE", "ACTIONBAR_SHOWGRID", "ACTIONBAR_HIDEGRID"],
Some("CursorKind()"),
);
s.run("PickupMacro(1)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE none", "ACTIONBAR_SHOWGRID macro"]
);
s.run("PickupMacro(1)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
[
"ACTIONBAR_HIDEGRID none",
"CURSOR_UPDATE none",
"ACTIONBAR_SHOWGRID macro"
]
);
}
}
+108 -25
View File
@@ -5,7 +5,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::flag;
use super::cursor::{self, CursorPayload};
use super::cursor;
use super::Model;
use crate::script::binding_abi::Idx;
@@ -647,7 +647,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"ClickSendMailItemButton",
lua.create_function(|lua, ()| {
super::tick::verb(lua, click_send_mail_item);
click_send_mail_item(lua);
Ok(())
})?,
)?;
@@ -798,30 +798,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
}
/// `ClickSendMailItemButton`: the send slot as a cursor drop target, firing `CURSOR_UPDATE` and
/// `ITEM_LOCK_CHANGED` like a bag slot.
fn click_send_mail_item(model: &mut Model) {
match model.cursor.take() {
// A held bag item attaches; it stays in its bag slot until the send.
Some(CursorPayload::Item(item)) => {
// An earlier attachment is overwritten, as in the reference; its slot has no lock.
model.mail_send_item = Some(item);
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
// `MAIL_SEND_INFO_UPDATE` (`0x4ae0de`): the stock tab re-reads the item and postage.
/// `ClickSendMailItemButton` (`0x4adef0`): the send slot as a cursor drop target, attaching the
/// held item and putting an earlier attachment back on the cursor ([`cursor::click_attach_slot`]).
/// A held item stays in its bag slot, locked, until the send. A changed attachment signals
/// `MAIL_SEND_INFO_UPDATE` (`0x4ae0de`), whose stock handler re-reads the item and postage.
fn click_send_mail_item(lua: &Lua) {
if cursor::click_attach_slot(lua, |model| &mut model.mail_send_item) {
super::tick::verb(lua, |model| {
model.raise("MAIL_SEND_INFO_UPDATE", Vec::new());
}
None => {
if let Some(item) = model.mail_send_item.take() {
model.cursor = Some(CursorPayload::Item(item));
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
model.raise("MAIL_SEND_INFO_UPDATE", Vec::new());
}
}
Some(other) => {
model.cursor = Some(other);
}
});
}
}
@@ -1381,6 +1366,104 @@ mod tests {
s.set_has_new_mail(false);
assert!(s.eval::<bool>("return HasNewMail() == nil").unwrap());
}
/// The attach clears with `SetCursorItem(0, …)` (`0x4ae0d2`): no unlock and no
/// `ITEM_LOCK_CHANGED`, so the attached item stays locked in its bag and cannot be picked up
/// again, until the compose reset lifts the lock with its event (`0x4acdd6`).
#[test]
fn an_attached_item_keeps_its_lock_until_it_leaves_the_slot() {
use crate::script::cursor::{bag_lock_probe, two_item_bag};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
s.run("PickupContainerItem(0, 1)").unwrap();
heard::listen(
&s,
&["ITEM_LOCK_CHANGED", "CURSOR_UPDATE"],
Some(&bag_lock_probe(1)),
);
s.run("ClickSendMailItemButton()").unwrap();
assert_eq!(heard::take(&s), ["CURSOR_UPDATE 1"]);
s.run("PickupContainerItem(0, 1)").unwrap();
assert!(
s.cursor_payload().is_none(),
"a locked item is not picked up"
);
assert!(heard::take(&s).is_empty());
s.reset_compose_tab();
assert_eq!(heard::take(&s), ["ITEM_LOCK_CHANGED nil"]);
}
/// An occupied attach slot puts the old attachment back on the cursor through
/// `SetCursorItem(0, …)` (`0x4ae05a`-`0x4ae0d4`): its inner clear's `CURSOR_UPDATE` reads the
/// emptied cursor, the set's the old item, and both items stay locked. An empty hand takes the
/// attachment back the same way. With nothing held and nothing attached the click is
/// `ClearCursor(1,1)` (`0x4ae08b`), which drops any other payload and signals nothing more.
#[test]
fn an_attach_onto_an_occupied_slot_puts_the_old_item_on_the_cursor() {
use crate::script::cursor::{
bag_lock_probe, install_kind_probe, two_item_bag, CursorMacro, CursorPayload,
};
use crate::script::tick::heard;
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(two_item_bag()));
install_kind_probe(&s);
s.run("PickupContainerItem(0, 1) ClickSendMailItemButton() PickupContainerItem(0, 2)")
.unwrap();
heard::listen(
&s,
&[
"ACTIONBAR_HIDEGRID",
"ACTIONBAR_SHOWGRID",
"CURSOR_UPDATE",
"ITEM_LOCK_CHANGED",
"MAIL_SEND_INFO_UPDATE",
],
Some("CursorKind() .. ' ' .. tostring(GetSendMailItem())"),
);
s.run("ClickSendMailItemButton()").unwrap();
assert_eq!(
heard::take(&s),
[
"ACTIONBAR_HIDEGRID none Minor Healing Potion",
"CURSOR_UPDATE none Minor Healing Potion",
"ACTIONBAR_SHOWGRID item Minor Healing Potion",
"CURSOR_UPDATE item Minor Healing Potion",
"MAIL_SEND_INFO_UPDATE item Minor Healing Potion"
]
);
assert_eq!(s.cursor_item().map(|i| i.slot), Some(1));
for slot in [1, 2] {
let probe = bag_lock_probe(slot);
assert!(s.eval::<bool>(&format!("return {probe} == 1")).unwrap());
}
// An empty hand takes the attachment back.
s.run("ClearCursor()").unwrap();
heard::take(&s);
s.run("ClickSendMailItemButton()").unwrap();
assert_eq!(
heard::take(&s),
[
"CURSOR_UPDATE none nil",
"ACTIONBAR_SHOWGRID item nil",
"CURSOR_UPDATE item nil",
"MAIL_SEND_INFO_UPDATE item nil"
]
);
assert_eq!(s.cursor_item().map(|i| i.slot), Some(2));
// Nothing held and nothing attached: a plain clear, here of a held macro.
s.run("ClearCursor()").unwrap();
s.set_cursor_for_test(CursorPayload::Macro(CursorMacro {
index: 1,
texture: None,
}));
heard::take(&s);
s.run("ClickSendMailItemButton()").unwrap();
assert!(s.cursor_payload().is_none());
assert_eq!(heard::take(&s), ["CURSOR_UPDATE none nil"]);
}
}
#[cfg(test)]
+16 -19
View File
@@ -334,15 +334,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// refusal, `0x495112` in the grab), not a bare drop: a held spell fires `CURSOR_UPDATE`
// and hides the bar grid, and an armed gift wrap is cancelled (`0x5edf10`). Its events
// run before the grab sets the row, so that `CURSOR_UPDATE` reads an empty cursor.
super::tick::verb(lua, crate::script::cursor::clear_cursor);
if let Some(payload) = grab {
// The grab's `SignalEvent(CURSOR_UPDATE)` (`0x49515e`); mode 5 skips the mode-7
// `ACTIONBAR_SHOWGRID` branch.
super::tick::verb(lua, |model| {
model.cursor = Some(payload);
crate::script::cursor::raise_cursor_update(model);
});
}
// The grab is the setter (`0x4950f0`): its clear, then the row and its
// `CURSOR_UPDATE` (`0x49515e`); mode 5 skips the mode-7 `ACTIONBAR_SHOWGRID` branch.
crate::script::cursor::set_cursor(lua, true, |_| grab);
Ok(())
})?,
)?;
@@ -1225,21 +1219,24 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_merchant(Some(stock()));
listen_cursor_events(&mut s);
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 133,
texture: None,
passive: false,
}));
cursor::raise_cursor_update(&mut s.model_mut()); // the pickup's own transition
assert_eq!(cursor_event_counts(&mut s), (1, 1, 0), "the spell pickup");
// The spell setter's set and tail (`0x494dd1`): the grid, no `CURSOR_UPDATE`.
cursor::put(
&mut s.model_mut(),
CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 133,
texture: None,
passive: false,
}),
);
assert_eq!(cursor_event_counts(&mut s), (0, 1, 0), "the spell pickup");
s.run("PickupMerchantItem(0)").unwrap();
assert!(s.cursor_payload().is_none());
assert_eq!(
cursor_event_counts(&mut s),
(2, 1, 1),
(1, 1, 1),
"(CURSOR_UPDATE, ACTIONBAR_SHOWGRID, ACTIONBAR_HIDEGRID) after the vendor drop"
);
}
+5 -7
View File
@@ -658,11 +658,10 @@ pub(crate) struct Model {
pub(crate) has_key: bool,
/// What the cursor carries; nothing moves locally, the server's updates settle the bags.
pub(crate) cursor: Option<cursor::CursorPayload>,
/// The action bar's drop grid: shown on the cursor's empty-to-held edge for any payload but a
/// pet action or a vendor row (mode 5; the grab setter `0x4950f0` shows it for mode 7 only).
pub(crate) cursor_grid_shown: bool,
/// The pet bar's grid, lit only by a pet-action pickup (`0x494f28`); never both grids at once.
pub(crate) pet_grid_shown: bool,
/// The cursor's one grid latch, `[0xb4da24]`: set by a setter that showed a bar's drop grid,
/// cleared by `ClearCursor`'s tail, which hides the pet bar's for a held pet action and the
/// action bar's otherwise (`0x49529b`).
pub(crate) cursor_grid: bool,
/// The world pick under the cursor (`[this+0x350]`): a world click drops no payload on an
/// object, only an item on terrain, and anything over nothing.
pub(crate) world_pick: cursor::WorldPick,
@@ -1399,8 +1398,7 @@ impl Model {
container_cooldowns: HashMap::new(),
has_key: false,
cursor: None,
cursor_grid_shown: false,
pet_grid_shown: false,
cursor_grid: false,
world_pick: cursor::WorldPick::default(),
container_moves: Vec::new(),
container_repairs: Vec::new(),
+86 -69
View File
@@ -12,7 +12,8 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{bool_or_default, flag};
use super::cursor::{raise_cursor_update, CursorPayload, CursorSpell};
use super::cursor::{clear_cursor, set_cursor, CursorPayload, CursorSpell};
use super::tick::verb;
use super::{Model, ScriptCall};
const BOOKTYPE_SPELL: &str = "spell";
@@ -287,43 +288,54 @@ fn rank_number(subtext: Option<&str>) -> u32 {
.fold(0u32, |acc, c| acc * 10 + c.to_digit(10).unwrap_or(0))
}
/// `PickupSpell(id, bookType)`, the spell button's drag and shift-click
/// (`SpellBookFrame.lua:266-290`). It refuses while the cursor holds anything, where the
/// reference's setters clear the cursor first (`0x495190`, called at `0x494dab` and `0x494f00`).
fn pickup_spell(model: &mut Model, id: u32, book_type: &str) -> bool {
if model.cursor.is_some() {
/// `PickupSpell(id, bookType)` (`0x4b42a0` into `0x4b3260`), the spell button's drag and
/// shift-click (`SpellBookFrame.lua:266-290`). It reads the held spell, then clears with
/// `ClearCursor(1,1)` (`0x4b3293`) whatever is held. Picking up the spell already held only puts
/// it down (`0x4b329a`), and with another spell held the clear is all (`0x4b32aa`). Otherwise the
/// slot's setter runs, its own inner clear signalling again on the empty cursor: the pet arm puts
/// the pet's raw word on the cursor (`0x494e20`, cursor mode 4), the player arm a spell id
/// (`0x494d20`, mode 3); only the pet word drops on the pet bar. Returns whether a payload went
/// on.
fn pickup_spell(lua: &Lua, id: u32, book_type: &str) -> bool {
let (payload, target, held) = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let held = match &model.cursor {
Some(CursorPayload::Spell(s)) => s.spell_id,
_ => 0,
};
let Some(slot) = book_slot(&model, id, book_type) else {
drop(model);
verb(lua, clear_cursor);
return false;
};
let payload = if is_pet_book(book_type) {
// `0x494e20` refuses type 0 and types 8 and up, before its clear.
let packed = slot.packed;
(1..=7).contains(&((packed >> 24) & 0x3F)).then(|| {
CursorPayload::PetAction(super::cursor::CursorPetAction {
// No source slot: the word comes from the book, not the bar.
src_slot: 0,
packed,
passive: slot.passive,
texture: slot.texture.clone(),
})
})
} else {
Some(CursorPayload::Spell(CursorSpell {
book_slot: id,
book_type: book_type.to_string(),
spell_id: slot.spell_id,
texture: slot.texture.clone(),
passive: slot.passive,
}))
};
(payload, slot.spell_id, held)
};
verb(lua, clear_cursor);
if target == held || held != 0 {
return false;
}
let Some(slot) = book_slot(model, id, book_type) else {
return false;
};
// The pet arm of `0x4b3260` puts the pet's raw word on the cursor (`0x494e20`, cursor mode 4),
// the player arm a spell id (`0x494d20`, mode 3); only the pet word drops on the pet bar.
let payload = if is_pet_book(book_type) {
// `0x494e20` refuses type 0 and types 8 and up.
let packed = slot.packed;
if !(1..=7).contains(&((packed >> 24) & 0x3F)) {
return false;
}
CursorPayload::PetAction(super::cursor::CursorPetAction {
// No source slot: the word comes from the book, not the bar.
src_slot: 0,
packed,
passive: slot.passive,
texture: slot.texture.clone(),
})
} else {
CursorPayload::Spell(CursorSpell {
book_slot: id,
book_type: book_type.to_string(),
spell_id: slot.spell_id,
texture: slot.texture.clone(),
passive: slot.passive,
})
};
model.cursor = Some(payload);
raise_cursor_update(model);
true
payload.is_some_and(|payload| set_cursor(lua, true, |_| Some(payload)))
}
/// Register the spellbook globals.
@@ -587,7 +599,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"PickupSpell",
lua.create_function(|lua, (id, book_type): (Value, Value)| {
let (id, book_type) = spell_slot_args(id, book_type, "PickupSpell")?;
super::tick::verb(lua, |model| pickup_spell(model, id, &book_type));
pickup_spell(lua, id, &book_type);
Ok(())
})?,
)?;
@@ -930,8 +942,7 @@ mod tests {
s.cursor_payload()
);
// Tick first to flush the first pickup's `CURSOR_UPDATE`, so the count below is the
// refused call's alone.
// The pickup's events fired inside it; the listener below counts the next call's alone.
s.tick(0.0);
s.run(
r#"
@@ -942,46 +953,52 @@ mod tests {
"#,
)
.unwrap();
s.run(r#"PickupSpell(3, BOOKTYPE_SPELL)"#).unwrap(); // already holding -> refused, no-op
// Another spell held: the binding's clear is all (`0x4b3293`, `0x4b32aa`).
s.run(r#"PickupSpell(3, BOOKTYPE_SPELL)"#).unwrap();
s.tick(0.01);
assert_eq!(
s.eval::<i64>("return cursorUpdates").unwrap(),
0,
"refused pickup fires no CURSOR_UPDATE"
);
// Still holding the first pickup: a refusal never clobbers it.
assert!(
matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::Spell(c))
if c.book_slot == 1 && c.book_type == "spell" && c.spell_id == 133
),
"{:?}",
s.cursor_payload()
);
assert_eq!(s.eval::<i64>("return cursorUpdates").unwrap(), 1);
assert!(s.cursor_payload().is_none(), "{:?}", s.cursor_payload());
}
#[test]
fn pickup_spell_refuses_while_already_holding_any_payload() {
fn pickup_spell_clears_a_held_payload_first() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE", "ACTIONBAR_SHOWGRID", "ACTIONBAR_HIDEGRID"],
Some("CursorHasSpell()"),
);
// Another payload: the binding's clear (`0x4b3293`) and the setter's (`0x494dab`) drop
// it, then the spell goes on with its grid and no `CURSOR_UPDATE` after (`0x494dd1`).
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 1,
kind: 0,
action: 111,
texture: None,
}));
assert!(!pickup_spell(&mut s.model_mut(), 1, "spell"));
// The original (action) payload survives untouched.
assert!(
matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::Action(_))
),
"{:?}",
s.cursor_payload()
assert!(pickup_spell(&s.lua, 1, "spell"));
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
assert_eq!(
crate::script::tick::heard::take(&s),
[
"CURSOR_UPDATE nil",
"CURSOR_UPDATE nil",
"ACTIONBAR_SHOWGRID 1"
]
);
// The held spell again: put down, nothing picked up (`0x4b329a`).
assert!(!pickup_spell(&s.lua, 1, "spell"));
assert!(s.cursor_payload().is_none());
assert_eq!(
crate::script::tick::heard::take(&s),
["ACTIONBAR_HIDEGRID nil", "CURSOR_UPDATE nil"]
);
// Another spell held: the clear is all (`0x4b32aa`).
assert!(pickup_spell(&s.lua, 1, "spell"));
crate::script::tick::heard::take(&s);
assert!(!pickup_spell(&s.lua, 2, "spell"));
assert!(s.cursor_payload().is_none());
}
#[test]
@@ -1096,7 +1113,7 @@ mod tests {
assert!(s.take_spell_casts().is_empty(), "pet cast is a no-op");
assert!(s.take_pet_spell_casts().is_empty());
assert!(!pickup_spell(&mut s.model_mut(), 1, "pet"));
assert!(!pickup_spell(&s.lua, 1, "pet"));
assert!(s.cursor_payload().is_none(), "pet pickup is a no-op");
}
@@ -1284,7 +1301,7 @@ mod tests {
s.set_spellbook(book());
s.set_pet_book(pet_book());
assert!(pickup_spell(&mut s.model_mut(), 1, "pet"));
assert!(pickup_spell(&s.lua, 1, "pet"));
let Some(CursorPayload::PetAction(p)) = s.cursor_payload() else {
panic!(
"expected a pet action payload, got {:?}",
@@ -1296,7 +1313,7 @@ mod tests {
// The player book still produces a spell payload.
s.run("ClearCursor()").unwrap();
assert!(pickup_spell(&mut s.model_mut(), 1, "spell"));
assert!(pickup_spell(&s.lua, 1, "spell"));
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
}
+49 -11
View File
@@ -354,25 +354,32 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// PickupStablePet(i): the mode-10 cursor grab, returning nothing. The gate is the family icon,
// not occupancy (`0x495010`): no icon, no grab.
// PickupStablePet(i) (`0x4cb7a0`): the mode-10 cursor grab, returning nothing. The binding
// takes an occupied slot only (`0x4cb7f9`); the grab (`0x495010`) clears with
// `ClearCursor(1,1)` (`0x495034`) before its own check, the family icon: no icon, no grab, the
// cursor left clear. The set fires nothing of its own.
g.set(
"PickupStablePet",
lua.create_function(|lua, i: i64| {
super::tick::verb(lua, |model| {
let texture = slot_index(i)
let occupied = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
slot_index(i)
.and_then(|i| model.stable.state.as_ref()?.slots[i].as_ref())
.and_then(|p| p.icon.clone());
if let Some(texture) = texture {
model.cursor = Some(super::cursor::CursorPayload::StablePet(
.is_some()
};
if occupied {
super::cursor::set_cursor(lua, true, |model| {
let texture = slot_index(i)
.and_then(|i| model.stable.state.as_ref()?.slots[i].as_ref())
.and_then(|p| p.icon.clone())?;
Some(super::cursor::CursorPayload::StablePet(
super::cursor::CursorStablePet {
slot: i as u8,
texture,
},
));
super::cursor::raise_cursor_update(model);
}
});
))
});
}
Ok(())
})?,
)?;
@@ -735,4 +742,35 @@ mod tests {
let s = UiScript::new().unwrap();
s.eval::<()>("SetPetStablePaperdoll(nil)").unwrap();
}
/// The grab clears first (`0x495034`) and only then checks the family icon: a slot without one
/// leaves the cursor clear. The set fires nothing of its own, so every `CURSOR_UPDATE` reads
/// the emptied cursor.
#[test]
fn a_stable_grab_clears_before_its_icon_check() {
let mut s = UiScript::new().unwrap();
let mut iconless = pet(8, "Bruiser", 38);
iconless.icon = None;
s.set_stable(Some(state([Some(pet(7, "Rex", 41)), Some(iconless), None])));
crate::script::cursor::install_kind_probe(&s);
crate::script::tick::heard::listen(&s, &["CURSOR_UPDATE"], Some("CursorKind()"));
s.set_cursor_for_test(crate::script::cursor::CursorPayload::Macro(
crate::script::cursor::CursorMacro {
index: 1,
texture: None,
},
));
s.run("PickupStablePet(1)").unwrap();
assert!(s.cursor_payload().is_none(), "cleared, and nothing grabbed");
assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE none"]);
s.run("PickupStablePet(0)").unwrap();
assert!(matches!(
s.cursor_payload(),
Some(crate::script::cursor::CursorPayload::StablePet(_))
));
assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE none"]);
// An empty slot is the binding's own refusal, ahead of the grab and its clear.
s.run("PickupStablePet(2)").unwrap();
assert!(crate::script::tick::heard::take(&s).is_empty());
}
}
+6 -3
View File
@@ -147,11 +147,14 @@ fn click_trade_button(model: &mut Model, id: u32) {
cursor::money::add_trade_money(model);
return;
}
match model.cursor.take() {
match model.cursor.clone() {
Some(CursorPayload::Item(item)) => {
let (bag, slot) = (item.bag, item.slot);
model.trade_set_items.push((id, bag, slot));
cursor::raise_cursor_update(model);
// The reference passes `SetCursorItem` the send's failure as its unlock (`0x4c005f`),
// so a sent item's clear is `ClearCursor(0,1)`. The lock event reports the engine's
// lock lifting with the cursor ([`cursor::client_locked`]), which holds no trade slot.
cursor::clear(model, cursor::Clear::KEEP_LOCK);
cursor::raise_lock_changed(model);
}
None => {
@@ -168,7 +171,7 @@ fn click_trade_button(model: &mut Model, id: u32) {
model.trade_clear_items.push(id);
}
}
Some(other) => model.cursor = Some(other),
Some(_) => {}
}
}