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