Lua verbs fire their events before returning, in the reference's order (#1015)

In 1.12.1 an event a Lua verb raises runs its handlers before the verb returns: `SignalEvent` (`0x703e50`) walks the listeners in place. About 35 benilla binding sites queued theirs for a later drain instead.

- Verbs now raise inside `tick::verb` steps, and the events fire as each step's model borrow drops. A step follows the reference's split wherever it signals before changing state a getter reads: the money pickup, bag pickup and place, merchant grab, repair cursor, item use, action and pet-bar pickup and drop.
- The grid events fire before `CURSOR_UPDATE`, and a clear's unlock before both, as at `0x4952b3`/`0x495455`. `ITEM_LOCK_CHANGED` carries no arguments.
- `pending_events` is host-only now.

Tests pin handler order per family, nesting from a handler, and the order changes.

Fixes #999
Fixes #582

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 16:14:10 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 24c7cab983
commit aba120cdcb
26 changed files with 1042 additions and 593 deletions
@@ -3,9 +3,10 @@
use mlua::{Lua, MultiValue, Value};
use crate::script::container::ContainerMove;
use crate::script::tick::verb;
use crate::script::Model;
use super::{clear_cursor, queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload};
use super::{clear_cursor, raise_cursor_update, raise_lock_changed, CursorItem, CursorPayload};
use super::{doll::pickup_inventory_item, EQUIPMENT_BAG};
/// The ids `PutItemInBag` and `PickupBagFromSlot` take: what their argument reader (`0x4c8520`)
@@ -80,9 +81,10 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
// 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;
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
raise_lock_changed(model);
raise_cursor_update(model);
return true;
}
@@ -98,8 +100,8 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
dst_slot: live,
count: None,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
raise_cursor_update(model);
raise_lock_changed(model);
return true;
}
model.cursor = Some(CursorPayload::Item(held));
@@ -118,8 +120,8 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
dst_bag,
count: held.count,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
raise_cursor_update(model);
raise_lock_changed(model);
true
}
@@ -137,23 +139,31 @@ pub(super) fn put_item_in_backpack(model: &mut Model) -> bool {
dst_bag: 0,
count: held.count,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
raise_cursor_update(model);
raise_lock_changed(model);
true
}
/// `PickupBagFromSlot(inventorySlot)` (`0x4c7b00`): clears a held payload before it looks at the
/// slot, where the reference clears only a held item (`0x4c7b70`, `0x4c7b79`); with nothing held it
/// skips the clear and its `CURSOR_UPDATE`, as the reference does. Takes only a container
/// skips the clear and its `CURSOR_UPDATE`, as the reference does. The clear's events run before
/// the slot is read, so its `CURSOR_UPDATE` handler sees the empty cursor. Takes only a container
/// (`0x4c7bb3`), and never places or swaps. The client has no empty-bag rule: the server refuses
/// with `EQUIP_ERR_CAN_ONLY_DO_WITH_EMPTY_BAGS`.
pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
pub(super) fn pickup_bag_from_slot(lua: &Lua, live: u32) {
if !accepts(live) {
return;
}
if model.cursor.is_some() {
clear_cursor(model);
}
verb(lua, |model| {
if model.cursor.is_some() {
clear_cursor(model);
}
});
verb(lua, |model| grab_bag(model, live));
}
/// `PickupBagFromSlot`'s grab of an unlocked container in `live`.
fn grab_bag(model: &mut Model, live: u32) {
let picked = usize::try_from(live)
.ok()
.and_then(|slot| model.inv_slot("player", slot))
@@ -172,8 +182,8 @@ pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
.filter(is_container);
if let Some(item) = picked {
model.cursor = Some(CursorPayload::Item(item));
queue_cursor_update(model);
queue_lock_changed(model, EQUIPMENT_BAG, live);
raise_cursor_update(model);
raise_lock_changed(model);
}
}
@@ -193,28 +203,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PutItemInBag",
lua.create_function(|lua, live: u32| {
let yes = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
put_item_in_bag(&mut model, live)
};
consumed(yes)
consumed(verb(lua, |model| put_item_in_bag(model, live)))
})?,
)?;
g.set(
"PutItemInBackpack",
lua.create_function(|lua, ()| {
let yes = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
put_item_in_backpack(&mut model)
};
consumed(yes)
})?,
lua.create_function(|lua, ()| consumed(verb(lua, put_item_in_backpack)))?,
)?;
g.set(
"PickupBagFromSlot",
lua.create_function(|lua, live: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_bag_from_slot(&mut model, live);
pickup_bag_from_slot(lua, live);
Ok(())
})?,
)?;
@@ -225,6 +224,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use crate::script::cursor::EQUIPMENT_BAG;
use crate::script::tick::heard;
use crate::script::{
BagAutoStore, BankBagSlots, ContainerMove, ContainerSlot, ContainerState, InvSlotView,
InventorySlots, UiScript,
@@ -235,10 +235,10 @@ mod tests {
#[test]
fn an_empty_cursor_skips_the_clear_and_its_signal() {
let s = UiScript::new().unwrap();
let before = s.model_ref().pending_events.len();
heard::listen(&s, &["CURSOR_UPDATE"], None);
s.run("PickupBagFromSlot(20)").unwrap();
assert!(s.cursor_payload().is_none());
assert_eq!(s.model_ref().pending_events.len(), before);
assert!(heard::take(&s).is_empty());
}
/// An item that is a bag: `equip_slots` is `find_equip_slot(INVTYPE_BAG)`.
@@ -454,6 +454,32 @@ mod tests {
assert!(s.take_container_moves().is_empty(), "no place path exists");
}
/// 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.
#[test]
fn pickup_bag_from_slot_fires_the_clear_before_the_grab() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(backpack()));
s.set_bank_bag_slots(one_bank_bag());
s.run("PickupContainerItem(0, 2)").unwrap();
heard::listen(
&s,
&["CURSOR_UPDATE", "ITEM_LOCK_CHANGED"],
Some("IsInventoryItemLocked(64)"),
);
s.run("PickupBagFromSlot(64)").unwrap();
assert_eq!(
heard::take(&s),
[
"ITEM_LOCK_CHANGED nil",
"CURSOR_UPDATE nil",
"CURSOR_UPDATE 1",
"ITEM_LOCK_CHANGED 1"
]
);
}
#[test]
fn pickup_bag_from_slot_only_takes_containers_and_never_a_locked_one() {
let mut s = UiScript::new().unwrap();
+116 -62
View File
@@ -6,9 +6,10 @@
use mlua::Lua;
use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
use crate::script::tick::verb;
use crate::script::{ActionSlot, Model};
use super::{queue_cursor_update, CursorAction, CursorPayload};
use super::{raise_cursor_update, CursorAction, CursorPayload};
/// The passive-spell refusal's error, errorId `0x9e`: entry `0xb4c0f0` of the table at `0xb4b498`
/// (stride `0x14`) names this key (`0x84133c`).
@@ -20,47 +21,84 @@ 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. Returns
/// whether to repaint.
pub(super) fn pickup_action(model: &mut Model, id: u32) -> bool {
if model.cursor.is_some() {
return place_action(model, id);
/// 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.
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 {
return place_action(lua, id);
}
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.actions.remove(&id);
model.cursor = Some(CursorPayload::Action(payload));
model.action_sets.push((id, 0));
queue_cursor_update(model);
true
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
});
if picked {
verb(lua, |model| {
model.actions.remove(&id);
model.action_sets.push((id, 0));
});
}
picked
}
/// `PlaceAction(id)`, the slot's `OnReceiveDrag` and `UseAction(id, 1)`'s place fork. An occupied
/// slot's action hops onto the cursor with `id` as its source; an item stays in its bag, since a
/// bar item is a reference, not a move. Returns whether to repaint.
pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
/// 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.
pub(crate) fn place_action(lua: &Lua, id: u32) -> bool {
let Some((kind, action, texture)) = verb(lua, |model| take_for_place(model, id)) else {
return false;
};
verb(lua, |model| {
model.actions.insert(
id,
ActionSlot {
texture,
kind,
action,
// Count and consumable wait for the app's next-frame re-feed, which knows the item.
count: 0,
consumable: false,
},
);
model.action_sets.push((id, pack(kind, action)));
});
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>)> {
// The two accept filters (`0x4e62e0`) refuse with a bare return: nothing stored or sent, and
// the payload stays held. An item needs an on-use spell or an equip slot (`0x4e6571`), so a
// bag is placeable; a passive spell (`Attributes & 0x40`) also raises errorId `0x9e` through
// `DisplayError` (`0x4e63ad`, `0x496720`), where the item refusal is mute.
match &model.cursor {
Some(CursorPayload::Item(i)) if !i.bar_placeable => return false,
Some(CursorPayload::Item(i)) if !i.bar_placeable => return None,
Some(CursorPayload::Spell(s)) if s.passive => {
model.ui_errors.push(PASSIVE_ON_BAR_ERROR);
return false;
return None;
}
_ => {}
}
let Some(held) = model.cursor.take() else {
return false;
};
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())),
@@ -74,32 +112,20 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
CursorPayload::Money(_) => None,
CursorPayload::Merchant(_) => None,
};
let Some((kind, action, texture)) = placeable else {
let Some(placed) = placeable else {
model.cursor = Some(held);
return false;
return None;
};
let displaced = model.actions.insert(
id,
ActionSlot {
texture,
kind,
action,
// Count and consumable wait for the app's next-frame re-feed, which knows the item.
count: 0,
consumable: false,
},
);
model.action_sets.push((id, pack(kind, action)));
model.cursor = displaced.map(|d| {
model.cursor = model.actions.get(&id).map(|d| {
CursorPayload::Action(CursorAction {
src_slot: id,
kind: d.kind,
action: d.action,
texture: d.texture,
texture: d.texture.clone(),
})
});
queue_cursor_update(model);
true
raise_cursor_update(model);
Some(placed)
}
/// Register `PickupAction` and `PlaceAction` as globals.
@@ -109,16 +135,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PickupAction",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_action(&mut model, id);
pickup_action(lua, id);
Ok(())
})?,
)?;
g.set(
"PlaceAction",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
place_action(&mut model, id);
place_action(lua, id);
Ok(())
})?,
)?;
@@ -147,7 +171,7 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 133)));
assert!(pickup_action(&mut s.model_mut(), 1));
assert!(pickup_action(&s.lua, 1));
assert!(
!s.eval::<bool>("return HasAction(1)").unwrap(),
"removed from the engine's optimistic mirror"
@@ -160,10 +184,40 @@ mod tests {
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
}
/// The cursor's events run before the slot write, as `0x4e6130` runs its setter before the
/// slot clear `0x4e5db0` and `0x4e62e0` hops the displaced action before it writes: a handler
/// reads each slot as it was.
#[test]
fn the_cursor_events_fire_before_the_slot_write() {
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)));
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE"],
Some("tostring(GetActionTexture(1)) .. ' ' .. tostring(GetActionTexture(2))"),
);
s.run("PickupAction(1)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE Interface\\Icons\\Spell_A Interface\\Icons\\Spell_B"]
);
assert!(!s.eval::<bool>("return HasAction(1)").unwrap());
s.run("PlaceAction(2)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE nil Interface\\Icons\\Spell_B"]
);
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
);
}
#[test]
fn pickup_action_on_an_empty_slot_is_a_no_op() {
let mut s = UiScript::new().unwrap();
assert!(!pickup_action(&mut s.model_mut(), 5));
assert!(!pickup_action(&s.lua, 5));
assert!(s.cursor_payload().is_none());
assert!(s.take_action_sets().is_empty());
}
@@ -178,7 +232,7 @@ mod tests {
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(place_action(&mut s.model_mut(), 7));
assert!(place_action(&s.lua, 7));
assert!(s.cursor_payload().is_none(), "empty destination clears");
assert!(s.eval::<bool>("return HasAction(7)").unwrap());
assert_eq!(
@@ -195,10 +249,10 @@ mod tests {
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)));
assert!(pickup_action(&mut s.model_mut(), 1));
assert!(pickup_action(&s.lua, 1));
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
assert!(place_action(&mut s.model_mut(), 2));
assert!(place_action(&s.lua, 2));
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
@@ -230,7 +284,7 @@ mod tests {
equip_slots: Vec::new(),
}));
assert!(place_action(&mut s.model_mut(), 4));
assert!(place_action(&s.lua, 4));
assert!(s.cursor_payload().is_none());
// 0x80<<24 | 117.
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000 | 117)]);
@@ -251,7 +305,7 @@ mod tests {
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(place_action(&mut s.model_mut(), 9));
assert!(place_action(&s.lua, 9));
assert_eq!(s.take_action_sets(), vec![(9, 133)]); // 0x00<<24 | 133
}
@@ -259,7 +313,7 @@ mod tests {
fn place_action_with_an_empty_cursor_is_a_no_op() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
assert!(!place_action(&mut s.model_mut(), 1));
assert!(!place_action(&s.lua, 1));
assert!(s.eval::<bool>("return HasAction(1)").unwrap(), "untouched");
assert!(s.take_action_sets().is_empty());
}
@@ -273,7 +327,7 @@ mod tests {
action: 111,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(pickup_action(&mut s.model_mut(), 5));
assert!(pickup_action(&s.lua, 5));
assert!(s.cursor_payload().is_none(), "placed onto the empty slot 5");
assert!(s.eval::<bool>("return HasAction(5)").unwrap());
assert_eq!(s.take_action_sets(), vec![(5, 111)]);
@@ -329,7 +383,7 @@ mod tests {
equip_slots: Vec::new(),
}));
assert!(!place_action(&mut s.model_mut(), 4));
assert!(!place_action(&s.lua, 4));
assert!(!s.eval::<bool>("return HasAction(4)").unwrap(), "no store");
assert!(s.take_action_sets().is_empty(), "no packet");
assert!(
@@ -354,7 +408,7 @@ mod tests {
passive: true,
}));
assert!(!place_action(&mut s.model_mut(), 4));
assert!(!place_action(&s.lua, 4));
assert!(!s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_action_sets().is_empty());
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
@@ -380,7 +434,7 @@ mod tests {
passive: false,
}));
assert!(place_action(&mut s.model_mut(), 4));
assert!(place_action(&s.lua, 4));
assert!(s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_ui_errors().is_empty());
}
@@ -400,7 +454,7 @@ mod tests {
equip_slots: vec![20, 21, 22, 23],
}));
assert!(place_action(&mut s.model_mut(), 4));
assert!(place_action(&s.lua, 4));
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000u32 | 4496)]);
}
}
+27 -23
View File
@@ -5,9 +5,10 @@ use mlua::Lua;
use crate::script::binding_abi::flag;
use crate::script::container::ContainerMove;
use crate::script::tick::verb;
use crate::script::Model;
use super::{queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload, EQUIPMENT_BAG};
use super::{raise_cursor_update, raise_lock_changed, 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
@@ -57,16 +58,17 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
match picked {
Some(item) => {
model.cursor = Some(CursorPayload::Item(item));
queue_cursor_update(model);
queue_lock_changed(model, EQUIPMENT_BAG, id);
raise_cursor_update(model);
raise_lock_changed(model);
true
}
None => 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 => {
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
raise_lock_changed(model);
raise_cursor_update(model);
true
}
Some(CursorPayload::Item(held)) => {
@@ -82,8 +84,8 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
dst_slot: id,
count: None,
});
queue_cursor_update(model);
queue_lock_changed(model, held.bag, held.slot);
raise_cursor_update(model);
raise_lock_changed(model);
true
}
// Mode 5, a buy into this slot: `0x4c78fd` sends `CMSG_BUY_ITEM_IN_SLOT` (`0x1a3`) and the
@@ -99,7 +101,7 @@ 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));
}
queue_cursor_update(model);
raise_cursor_update(model);
true
}
Some(
@@ -144,8 +146,8 @@ pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool {
match model.cursor.take() {
Some(CursorPayload::Item(item)) if item.bag >= 0 && item.count.is_none() => {
model.container_autoequips.push((item.bag, item.slot));
queue_cursor_update(model);
queue_lock_changed(model, item.bag, item.slot);
raise_cursor_update(model);
raise_lock_changed(model);
true
}
other => {
@@ -156,11 +158,17 @@ pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool {
}
/// `UseInventoryItem(id)`, the doll slot's right-click (`PaperDollFrame.lua:658-659`): the cursor
/// clears before the slot is read (`0x4c79a9`), so a held payload goes back, never placed; then
/// the app sends `CMSG_USE_ITEM` with bag 255 and the 0-based slot, dropping an empty slot unsent,
/// or in repair mode a worn item is repaired, locked or not (`0x4c79c4`).
pub(super) fn use_inventory_item(model: &mut Model, id: u32) {
super::clear_cursor(model);
/// clears before the slot is read (`0x4c79a9`), so a held payload goes back, never placed, its
/// events running first; then the app sends `CMSG_USE_ITEM` with bag 255 and the 0-based slot,
/// 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, |model| use_cleared(model, id));
}
/// `UseInventoryItem` past its clear.
fn use_cleared(model: &mut Model, id: u32) {
if !model.repair_mode {
model
.script_calls
@@ -195,16 +203,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PickupInventoryItem",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_inventory_item(&mut model, id);
verb(lua, |model| pickup_inventory_item(model, id));
Ok(())
})?,
)?;
g.set(
"EquipCursorItem",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
equip_cursor_item(&mut model, id);
verb(lua, |model| equip_cursor_item(model, id));
Ok(())
})?,
)?;
@@ -218,16 +224,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"AutoEquipCursorItem",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
auto_equip_cursor_item(&mut model);
verb(lua, auto_equip_cursor_item);
Ok(())
})?,
)?;
g.set(
"UseInventoryItem",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
use_inventory_item(&mut model, id);
use_inventory_item(lua, id);
Ok(())
})?,
)?;
+108 -61
View File
@@ -1,11 +1,10 @@
//! The money cursor, payload mode 2: coins taken off a money frame; the purse is never debited.
//! Deviation: a `CURSOR_UPDATE` handler already sees the coins, because events are queued; the
//! reference runs it inside the clear, before they land.
use mlua::{Lua, Value};
use super::{queue_cursor_update, CursorMoney, CursorPayload};
use super::{raise_cursor_update, CursorMoney, CursorPayload};
use crate::script::binding_abi::{flag, number_arg};
use crate::script::tick::verb;
use crate::script::Model;
/// The coin icon for an amount of copper: `GetCoinIcon`'s table (`0x48d4e0`), thresholds signed.
@@ -33,32 +32,38 @@ pub(crate) fn cursor_money(model: &Model) -> u32 {
}
}
/// Queue `PLAYER_MONEY`, which repaints the money frames less the held coins.
fn queue_player_money(model: &mut Model) {
model
.pending_events
.push(("PLAYER_MONEY".to_string(), Vec::new()));
/// 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: the inner clear's `CURSOR_UPDATE`, after its own
/// `PLAYER_MONEY` if coins were already held, then the payload and `PLAYER_MONEY`.
fn install_money(model: &mut Model, copper: u32) {
if matches!(model.cursor, Some(CursorPayload::Money(_))) {
queue_player_money(model);
}
model.cursor = None;
queue_cursor_update(model);
model.cursor = Some(CursorPayload::Money(CursorMoney { copper }));
queue_player_money(model);
/// 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
/// `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);
});
}
/// `PickupPlayerMoney` after the argument gate; each refusal is silent.
pub(crate) fn pickup_player_money(model: &mut Model, copper: u32) -> bool {
if copper == 0 || !model.player_control || u64::from(copper) > model.money {
return false;
pub(crate) fn pickup_player_money(lua: &Lua, copper: u32) -> bool {
let allowed = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
copper != 0 && model.player_control && u64::from(copper) <= model.money
};
if allowed {
install_money(lua, copper);
}
install_money(model, copper);
true
allowed
}
/// `DropCursorMoney`: `PLAYER_MONEY`, then the clear's `CURSOR_UPDATE`, and no packet.
@@ -67,8 +72,8 @@ pub(crate) fn drop_cursor_money(model: &mut Model) -> bool {
return false;
}
model.cursor = None;
queue_player_money(model);
queue_cursor_update(model);
raise_player_money(model);
raise_cursor_update(model);
true
}
@@ -91,24 +96,29 @@ pub(crate) fn add_trade_money(model: &mut Model) -> bool {
}
// `ClearCursor(1, 0)`: no PLAYER_MONEY, the clear's own CURSOR_UPDATE.
model.cursor = None;
queue_cursor_update(model);
raise_cursor_update(model);
true
}
/// `PickupTradeMoney` after the argument gate: signed gates, then `CMSG_SET_TRADE_GOLD` (`0x11F`)
/// with the trimmed offer, then the coins onto the cursor.
pub(crate) fn pickup_trade_money(model: &mut Model, amount: i32) -> bool {
let offer = model.trade.as_ref().map_or(0, |t| t.player.gold) as i32;
if amount <= 0 || amount > offer {
return false;
pub(crate) fn pickup_trade_money(lua: &Lua, amount: i32) -> bool {
let trimmed = verb(lua, |model| {
let offer = model.trade.as_ref().map_or(0, |t| t.player.gold) as i32;
if amount <= 0 || amount > offer {
return false;
}
let left = (offer - amount) as u32;
model.trade_set_money = Some(left);
if let Some(t) = model.trade.as_mut() {
t.player.gold = left;
}
true
});
if trimmed {
install_money(lua, amount as u32);
}
let left = (offer - amount) as u32;
model.trade_set_money = Some(left);
if let Some(t) = model.trade.as_mut() {
t.player.gold = left;
}
install_money(model, amount as u32);
true
trimmed
}
impl crate::script::UiScript {
@@ -139,24 +149,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"PickupPlayerMoney",
lua.create_function(|lua, amount: Value| {
let n = number_arg(lua, amount, "Usage: PickupPlayerMoney(amount)")? as u32;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_player_money(&mut model, n);
pickup_player_money(lua, n);
Ok(())
})?,
)?;
g.set(
"DropCursorMoney",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
drop_cursor_money(&mut model);
verb(lua, drop_cursor_money);
Ok(())
})?,
)?;
g.set(
"AddTradeMoney",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
add_trade_money(&mut model);
verb(lua, add_trade_money);
Ok(())
})?,
)?;
@@ -164,8 +171,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"PickupTradeMoney",
lua.create_function(|lua, amount: Value| {
let n = number_arg(lua, amount, "Usage: PickupTradeMoney(amount)")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_trade_money(&mut model, n);
pickup_trade_money(lua, n);
Ok(())
})?,
)?;
@@ -183,18 +189,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
mod tests {
use super::coin_icon;
use crate::script::cursor::CursorPayload;
use crate::script::tick::heard;
use crate::script::UiScript;
fn events(s: &mut UiScript) -> Vec<String> {
s.run(
r#"EV = {} F = F or CreateFrame("Frame") F:RegisterEvent("PLAYER_MONEY")
F:RegisterEvent("CURSOR_UPDATE") F:SetScript("OnEvent", function() table.insert(EV, event) end)"#,
)
.unwrap();
s.tick(0.0);
s.eval::<Vec<String>>("return EV").unwrap()
}
#[test]
fn coins_ride_the_cursor_without_leaving_the_purse() {
let mut s = UiScript::new().unwrap();
@@ -251,25 +248,75 @@ mod tests {
assert!(s.errors().is_empty(), "{:?}", s.errors());
}
/// The events fire inside each verb, in `0x494cc0`'s order: the inner clear's `CURSOR_UPDATE`
/// reads an empty cursor, and only the install's `PLAYER_MONEY` reads the new coins.
#[test]
fn the_pickup_and_drop_fire_player_money_around_the_clear() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
let _ = events(&mut s);
heard::listen(
&s,
&["PLAYER_MONEY", "CURSOR_UPDATE"],
Some("GetCursorMoney()"),
);
s.run("PickupPlayerMoney(10)").unwrap();
assert_eq!(
events(&mut s),
vec!["CURSOR_UPDATE", "PLAYER_MONEY"],
heard::take(&s),
["CURSOR_UPDATE 0", "PLAYER_MONEY 10"],
"the inner clear's CURSOR_UPDATE, then the money's PLAYER_MONEY"
);
s.run("PickupPlayerMoney(20)").unwrap();
assert_eq!(
events(&mut s),
vec!["PLAYER_MONEY", "CURSOR_UPDATE", "PLAYER_MONEY"],
heard::take(&s),
["PLAYER_MONEY 0", "CURSOR_UPDATE 0", "PLAYER_MONEY 20"],
"coins already held: the mode-2 arm's PLAYER_MONEY first"
);
s.run("DropCursorMoney()").unwrap();
assert_eq!(events(&mut s), vec!["PLAYER_MONEY", "CURSOR_UPDATE"]);
assert_eq!(heard::take(&s), ["PLAYER_MONEY 0", "CURSOR_UPDATE 0"]);
assert!(s.model_ref().pending_events.is_empty());
}
/// A handler that calls another verb runs it to completion inside the dispatch, as the
/// reference's synchronous `SignalEvent` nests: the mail verb's event lands between the
/// pickup's two steps, and a pickup called from the install's `PLAYER_MONEY` runs both of its
/// own steps before the outer verb returns. No step waits on a held model borrow.
#[test]
fn a_verb_called_from_a_handler_nests_inside_the_dispatch() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
let log: Vec<String> = s
.eval(
r#"LOG = {} NEST = 0
local f = CreateFrame("Frame")
f:RegisterEvent("CURSOR_UPDATE")
f:RegisterEvent("PLAYER_MONEY")
f:RegisterEvent("SEND_MAIL_MONEY_CHANGED")
f:SetScript("OnEvent", function()
table.insert(LOG, event .. " " .. GetCursorMoney() .. " " .. GetSendMailMoney())
if event == "CURSOR_UPDATE" and NEST == 0 then
NEST = 1 SetSendMailMoney(5)
elseif event == "PLAYER_MONEY" and NEST == 1 then
NEST = 2 PickupPlayerMoney(20)
end
end)
PickupPlayerMoney(10)
return LOG"#,
)
.unwrap();
assert_eq!(
log,
[
"CURSOR_UPDATE 0 0",
"SEND_MAIL_MONEY_CHANGED 0 5",
"PLAYER_MONEY 10 5",
"PLAYER_MONEY 0 5",
"CURSOR_UPDATE 0 5",
"PLAYER_MONEY 20 5",
]
);
assert_eq!(s.eval::<i64>("return GetCursorMoney()").unwrap(), 20);
assert!(s.errors().is_empty(), "{:?}", s.errors());
assert!(s.model_ref().pending_events.is_empty());
}
#[test]
+113 -56
View File
@@ -9,9 +9,10 @@
use mlua::Lua;
use crate::script::tick::verb;
use crate::script::Model;
use super::{queue_cursor_update, CursorPayload, CursorPetAction};
use super::{raise_cursor_update, 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
@@ -96,22 +97,64 @@ fn assign(slots: &mut [u32], target: usize, source: u32, passive: bool) -> Optio
/// (`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.
pub(super) fn pickup_pet_action(model: &mut Model, slot: u32) -> bool {
/// (`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.
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);
wrote
}),
PetPick::Picked(blank) => {
if let Some((index, word)) = blank {
verb(lua, |model| {
write_slot(model, index, word, false);
});
}
true
}
}
}
/// 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)>),
}
fn pick_or_place(model: &mut Model, slot: u32) -> PetPick {
if !model.pet_bar.pickup_allowed {
return false;
return PetPick::Refused;
}
match &model.cursor {
Some(CursorPayload::PetAction(_)) => return place_pet_action(model, slot),
Some(_) => return false,
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 false;
return PetPick::Refused;
};
let view = model.pet_bar.slots[index].view.clone();
let Some(packed) = payload_word(view.packed) else {
return false;
return PetPick::Refused;
};
model.cursor = Some(CursorPayload::PetAction(CursorPetAction {
@@ -120,43 +163,32 @@ pub(super) fn pickup_pet_action(model: &mut Model, slot: u32) -> bool {
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.
if kind(packed) == 1 {
write_slot(model, index, packed & 0xFFFF_0000, false);
}
queue_cursor_update(model);
true
PetPick::Picked((kind(packed) == 1).then_some((index, packed & 0xFFFF_0000)))
}
/// The drop: the cursor always empties, and only a homeless occupant refills it (`0x4bce3d`).
/// Returns whether anything was written; the cursor empties on `false` too.
fn place_pet_action(model: &mut Model, slot: u32) -> bool {
let Some(CursorPayload::PetAction(held)) = model.cursor.clone() else {
return false;
};
// `ClearCursor(1,1)`, unconditionally, before anything is decided.
model.cursor = None;
/// 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 {
let Some(index) = slot_index(model, slot) else {
queue_cursor_update(model);
return false;
};
// Read before the write: a displaced word's passive bit and icon are its slot's.
let occupant_view = model.pet_bar.slots[index].view.clone();
let Some(assigned) = write_slot(model, index, held.packed, held.passive) else {
queue_cursor_update(model);
return false;
};
model.cursor = assigned.displaced.map(|packed| {
CursorPayload::PetAction(CursorPetAction {
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(),
})
});
queue_cursor_update(model);
}));
raise_cursor_update(model);
}
true
}
@@ -197,23 +229,19 @@ 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.
fn resolve_token_icon(lua: &Lua) {
let name = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match &model.cursor {
Some(CursorPayload::PetAction(p)) if is_token(p.packed) => p.texture.clone(),
_ => None,
}
};
let Some(name) = name else {
/// 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;
};
let path: Option<String> = lua.globals().get(name.as_str()).unwrap_or_default();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(CursorPayload::PetAction(p)) = &mut model.cursor {
p.texture = path;
if !is_token(p.packed) {
return;
}
let Some(name) = p.texture.clone() else {
return;
};
p.texture = lua.globals().raw_get(name.as_str()).unwrap_or_default();
}
/// Register the pet bar's one cursor global.
@@ -221,12 +249,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"PickupPetAction",
lua.create_function(|lua, slot: u32| {
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_pet_action(&mut model, slot);
}
// Both the pickup and a drop's displaced occupant can put a token on the cursor.
resolve_token_icon(lua);
pickup_pet_action(lua, slot);
Ok(())
})?,
)?;
@@ -338,10 +361,44 @@ mod tests {
s
}
/// 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.
#[test]
fn a_drop_signals_its_clear_before_the_bar_write() {
let s = bar_script();
let word = s
.lua
.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.pet_bar.slots[4].view.packed == CLAW)
})
.unwrap();
s.lua.globals().set("SlotFiveIsClaw", word).unwrap();
s.run("PickupPetAction(4)").unwrap();
crate::script::tick::heard::listen(
&s,
&["PET_BAR_HIDEGRID", "CURSOR_UPDATE", "PET_BAR_SHOWGRID"],
Some("SlotFiveIsClaw()"),
);
// Growl is displaced and rides the cursor out, signalling after the write.
s.run("PickupPetAction(5)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
[
"PET_BAR_HIDEGRID false",
"CURSOR_UPDATE false",
"PET_BAR_SHOWGRID true",
"CURSOR_UPDATE true"
]
);
assert!(s.eval::<bool>("return SlotFiveIsClaw()").unwrap());
}
#[test]
fn picking_up_a_spell_blanks_its_slot_and_sends() {
let mut s = bar_script();
assert!(pickup_pet_action(&mut s.model_mut(), 4));
assert!(pickup_pet_action(&s.lua, 4));
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c)) if c.src_slot == 4),
"{:?}",
@@ -389,7 +446,7 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, false, true, hunter_bar());
assert!(pickup_pet_action(&mut s.model_mut(), 4));
assert!(pickup_pet_action(&s.lua, 4));
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c))
if c.src_slot == 4 && c.packed == CLAW),
@@ -441,7 +498,7 @@ mod tests {
#[test]
fn picking_up_a_token_leaves_the_slot_alone() {
let mut s = bar_script();
assert!(pickup_pet_action(&mut s.model_mut(), 1));
assert!(pickup_pet_action(&s.lua, 1));
assert!(
s.take_pet_set_actions().is_empty(),
"nothing was written, so nothing is sent"
@@ -507,8 +564,8 @@ mod tests {
#[test]
fn slot_eleven_is_refused_rather_than_written_past_the_array() {
let mut s = bar_script();
assert!(!pickup_pet_action(&mut s.model_mut(), 11));
assert!(!pickup_pet_action(&mut s.model_mut(), 0));
assert!(!pickup_pet_action(&s.lua, 11));
assert!(!pickup_pet_action(&s.lua, 0));
assert!(s.take_pet_set_actions().is_empty());
assert!(s.cursor_payload().is_none());
}
@@ -518,7 +575,7 @@ mod tests {
fn a_possessed_bar_takes_no_drag_at_all() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, false, hunter_bar());
assert!(!pickup_pet_action(&mut s.model_mut(), 4));
assert!(!pickup_pet_action(&s.lua, 4));
assert!(s.take_pet_set_actions().is_empty());
}
@@ -559,7 +616,7 @@ mod tests {
"the action bar's grid stays down — nothing can land there"
);
// PlaceAction refuses it outright and leaves it held.
assert!(!place_action(&mut s.model_mut(), 1));
assert!(!place_action(&s.lua, 1));
assert!(matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::PetAction(_))