From eb63c8861b7a5ee8246fdd93334b7dbd93f229f6 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:47:20 +0700 Subject: [PATCH] Cursor runs on ClearCursor and the setters' own event tails (#1038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/benilla-ui/src/script/auction.rs | 106 +++- crates/benilla-ui/src/script/container.rs | 109 ++-- crates/benilla-ui/src/script/cursor.rs | 520 ++++++++++++++---- .../benilla-ui/src/script/cursor/bag_verbs.rs | 62 ++- crates/benilla-ui/src/script/cursor/bar.rs | 179 +++--- crates/benilla-ui/src/script/cursor/doll.rs | 133 +++-- crates/benilla-ui/src/script/cursor/money.rs | 40 +- crates/benilla-ui/src/script/cursor/pet.rs | 196 ++++--- crates/benilla-ui/src/script/macros.rs | 91 +-- crates/benilla-ui/src/script/mail.rs | 133 ++++- crates/benilla-ui/src/script/merchant.rs | 35 +- crates/benilla-ui/src/script/model.rs | 12 +- crates/benilla-ui/src/script/spellbook.rs | 155 +++--- crates/benilla-ui/src/script/stable.rs | 60 +- crates/benilla-ui/src/script/trade.rs | 9 +- 15 files changed, 1220 insertions(+), 620 deletions(-) diff --git a/crates/benilla-ui/src/script/auction.rs b/crates/benilla-ui/src/script/auction.rs index a6286a05..5d867419 100644 --- a/crates/benilla-ui/src/script/auction.rs +++ b/crates/benilla-ui/src/script/auction.rs @@ -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::(&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"]); + } } diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 45d94a7e..d56f2949 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -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 { // 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::().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); } diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 52d54d74..4b3fcede 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -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, +) -> 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 { - 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::().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, +) -> bool { + let (held, old) = { + let mut model = lua.app_data_mut::().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| 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::().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)); + } } diff --git a/crates/benilla-ui/src/script/cursor/bag_verbs.rs b/crates/benilla-ui/src/script/cursor/bag_verbs.rs index 9dc0106c..8d34de4c 100644 --- a/crates/benilla-ui/src/script/cursor/bag_verbs.rs +++ b/crates/benilla-ui/src/script/cursor/bag_verbs.rs @@ -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::().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 { + 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" ] diff --git a/crates/benilla-ui/src/script/cursor/bar.rs b/crates/benilla-ui/src/script/cursor/bar.rs index 4f2ca695..01ef8d4d 100644 --- a/crates/benilla-ui/src/script/cursor/bar.rs +++ b/crates/benilla-ui/src/script/cursor/bar.rs @@ -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::() - .expect("model app_data") - .cursor - .is_some(); - if held { + let picked = { + let model = lua.app_data_ref::().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)> { +/// What the slot takes and the action it displaces. +type PlacePlan = ((u8, u32, Option), Option); + +/// `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 { // 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 } _ => {} } - 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::("return shows").unwrap(), 1); assert_eq!(s.eval::("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::("return shows").unwrap(), 1); - assert_eq!(s.eval::("return hides").unwrap(), 0); + assert_eq!(s.eval::("return shows").unwrap(), 2); + assert_eq!(s.eval::("return hides").unwrap(), 1); s.run("ClearCursor()").unwrap(); // Some -> None: HIDE s.tick(0.01); - assert_eq!(s.eval::("return shows").unwrap(), 1); - assert_eq!(s.eval::("return hides").unwrap(), 1); + assert_eq!(s.eval::("return shows").unwrap(), 2); + assert_eq!(s.eval::("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"] + ); + } } diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index 8a3ce339..481a912b 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -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::("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::("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::("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(), diff --git a/crates/benilla-ui/src/script/cursor/money.rs b/crates/benilla-ui/src/script/cursor/money.rs index 0efca2bd..d9459a58 100644 --- a/crates/benilla-ui/src/script/cursor/money.rs +++ b/crates/benilla-ui/src/script/cursor/money.rs @@ -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 } diff --git a/crates/benilla-ui/src/script/cursor/pet.rs b/crates/benilla-ui/src/script/cursor/pet.rs index c68561cd..043c3c32 100644 --- a/crates/benilla-ui/src/script/cursor/pet.rs +++ b/crates/benilla-ui/src/script/cursor/pet.rs @@ -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::().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::().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) { 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::("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" + ] + ); + } } diff --git a/crates/benilla-ui/src/script/macros.rs b/crates/benilla-ui/src/script/macros.rs index c6d4f3bc..c2df9f8a 100644 --- a/crates/benilla-ui/src/script/macros.rs +++ b/crates/benilla-ui/src/script/macros.rs @@ -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::().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 { @@ -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::("return GetMacroIconInfo(4) == nil").unwrap()); assert!(s.eval::("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" + ] + ); + } } diff --git a/crates/benilla-ui/src/script/mail.rs b/crates/benilla-ui/src/script/mail.rs index ab523d1c..9771437d 100644 --- a/crates/benilla-ui/src/script/mail.rs +++ b/crates/benilla-ui/src/script/mail.rs @@ -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::("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::(&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)] diff --git a/crates/benilla-ui/src/script/merchant.rs b/crates/benilla-ui/src/script/merchant.rs index c835a2d4..52b4d3af 100644 --- a/crates/benilla-ui/src/script/merchant.rs +++ b/crates/benilla-ui/src/script/merchant.rs @@ -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" ); } diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 6b80207e..8b2690de 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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, - /// 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(), diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 39c86d9c..c20e2621 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -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::().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::("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::("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(_)))); } diff --git a/crates/benilla-ui/src/script/stable.rs b/crates/benilla-ui/src/script/stable.rs index d59be82c..5ed4a1d4 100644 --- a/crates/benilla-ui/src/script/stable.rs +++ b/crates/benilla-ui/src/script/stable.rs @@ -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::().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()); + } } diff --git a/crates/benilla-ui/src/script/trade.rs b/crates/benilla-ui/src/script/trade.rs index e3e29b73..b1e16e88 100644 --- a/crates/benilla-ui/src/script/trade.rs +++ b/crates/benilla-ui/src/script/trade.rs @@ -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(_) => {} } }