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
+5 -2
View File
@@ -1692,9 +1692,12 @@ mod bind_confirm_tests {
},
);
}
// Re-run the place now that the item is out of reach.
// Re-run the place now that the item is out of reach. The fixture's own place asked
// first, unjudged; the re-run's pickup retires that question with its `CURSOR_UPDATE`,
// which fires inside the verb.
{
let s = app.world_mut().non_send_resource_mut::<UiScript>();
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
s.tick(0.01);
s.run("PickupContainerItem(0, 1)").unwrap();
s.run("PickupInventoryItem(16)").unwrap();
}
@@ -148,6 +148,7 @@ fn fire_sites() -> Vec<(String, Vec<char>, String)> {
for call in [
concat!("fire_event", "("),
concat!("fire_event_into", "("),
concat!("model.raise", "("),
concat!("pending_events.push", "(("),
] {
let mut from = 0;
@@ -2103,10 +2103,12 @@ mod tests {
// rustfmt splits a long chain at its dots, so the three shapes are matched
// with the whitespace before each `.` removed.
let text = glue_chains(&text);
// The engine's deferred lane, `pending_events.push((name, args))`, fires too.
const CALLS: [&str; 3] = [
// A verb's `model.raise(name, args)` and the host queue,
// `pending_events.push((name, args))`, fire too.
const CALLS: [&str; 4] = [
concat!("fire_event", "("),
concat!("fire_event_into", "("),
concat!("model.raise", "("),
concat!("pending_events.push", "(("),
];
let mut fires_indirectly = false;
+3 -2
View File
@@ -241,12 +241,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"UseAction",
lua.create_function(|lua, (action, rest): (u32, MultiValue)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// Both flags go through `GetBoolOrDefault` (`0x4e717e`, `0x4e716f`), default 0.
let check_cursor = bool_or_default(rest.front(), false);
let on_self = bool_or_default(rest.get(1), false);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if check_cursor && model.cursor.is_some() {
super::cursor::place_action(&mut model, action);
drop(model);
super::cursor::place_action(lua, action);
} else {
model
.script_calls
+6 -9
View File
@@ -743,12 +743,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// ClickAuctionSellItemButton(): attach the cursor's item, or pick the attached one back up;
// every cursor write queues both CURSOR_UPDATE and ITEM_LOCK_CHANGED.
// every cursor write raises both CURSOR_UPDATE and ITEM_LOCK_CHANGED.
g.set(
"ClickAuctionSellItemButton",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
click_auction_sell_item(&mut model);
super::tick::verb(lua, click_auction_sell_item);
Ok(())
})?,
)?;
@@ -790,17 +789,15 @@ const AUCTION_INV_TYPES: [(u32, &str); 14] = [
fn click_auction_sell_item(model: &mut Model) {
match model.cursor.take() {
Some(CursorPayload::Item(item)) => {
let (bag, slot) = (item.bag, item.slot);
model.auction_sell_item = Some(item);
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
}
None => {
if let Some(item) = model.auction_sell_item.take() {
let (bag, slot) = (item.bag, item.slot);
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
}
}
Some(other) => {
@@ -204,8 +204,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// Never raises; a stored filter fires `UPDATE_BATTLEFIELD_SCORE` (`0x4abc90`), here on the
// next dispatch.
// Never raises; a stored filter rebuilds the board and fires `UPDATE_BATTLEFIELD_SCORE` in
// place (`0x4abc90` → `0x4aa200`, the signal at `0x4aa308`).
g.set(
"SetBattlefieldScoreFaction",
lua.create_function(|lua, faction: Option<Value>| {
@@ -213,12 +213,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
if !(-1..=1).contains(&f) {
return Ok(());
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.battlefield_board.filter = f;
model.battlefield_board.rebuild();
model
.pending_events
.push(("UPDATE_BATTLEFIELD_SCORE".to_string(), Vec::new()));
super::tick::verb(lua, |model| {
model.battlefield_board.filter = f;
model.battlefield_board.rebuild();
model.raise("UPDATE_BATTLEFIELD_SCORE", Vec::new());
});
Ok(())
})?,
)?;
+13 -14
View File
@@ -209,20 +209,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"SetGuildRecruitmentMode: invalid mode",
));
}
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let mode = n as u8;
if model.guild_recruitment_mode != mode {
model.guild_recruitment_mode = mode;
model.guild_recruitment_changed = true;
}
// `0x49ea79 jne` / `0x49ea7b jmp 0x49ea90`: the cascade, on the new value alone.
if mode == 1 {
model.guild_recruitment_cascade = true;
}
// `0x4a00a4`/`0x4a00a9`: `UPDATE_CHAT_WINDOWS`, on every successful call.
model
.pending_events
.push(("UPDATE_CHAT_WINDOWS".to_string(), Vec::new()));
super::tick::verb(lua, |model| {
let mode = n as u8;
if model.guild_recruitment_mode != mode {
model.guild_recruitment_mode = mode;
model.guild_recruitment_changed = true;
}
// `0x49ea79 jne` / `0x49ea7b jmp 0x49ea90`: the cascade, on the new value alone.
if mode == 1 {
model.guild_recruitment_cascade = true;
}
// `0x4a00a4`/`0x4a00a9`: `UPDATE_CHAT_WINDOWS`, on every successful call, in place.
model.raise("UPDATE_CHAT_WINDOWS", Vec::new());
});
Ok(())
})?,
)?;
+31 -18
View File
@@ -200,7 +200,7 @@ impl super::UiScript {
model.pending_wrap = Some(PendingWrap { bag, slot });
model.ui_cursor = Some(UiCursorMode::Cast);
model.ui_cursor_dirty = true;
cursor::queue_lock_changed(&mut model, bag, slot);
cursor::raise_lock_changed(&mut model);
}
/// The armed gift wrap, if any; the app reads it to hold the cursor's base at Cast.
@@ -293,7 +293,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
model.container_wraps.push((wrap.bag, wrap.slot, bag, slot));
// The paper unlocks and the cursor resets right after the send (`0x5eded0` at
// `0x5ee0aa`), not on the answer; the target never locks.
cursor::queue_lock_changed(model, wrap.bag, wrap.slot);
cursor::raise_lock_changed(model);
model.ui_cursor = None;
model.ui_cursor_dirty = true;
return;
@@ -339,13 +339,14 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
});
if let Some(item) = picked {
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
}
}
// The cancel is `ClearCursor(1,1)` (`0x4f9e0e`): the unlock, then `CURSOR_UPDATE`.
Some(CursorPayload::Item(held)) if held.bag == bag && held.slot == slot => {
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, held.bag, held.slot);
cursor::raise_lock_changed(model);
cursor::raise_cursor_update(model);
}
Some(CursorPayload::Item(held)) => {
// Cloned so the moves below can borrow `model`; an unresolved slot reads as empty.
@@ -360,12 +361,24 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
(Some(_), Some(d)) if d.item_id != held.item_id => {
model.cursor = Some(CursorPayload::Item(held));
}
// Anything else queues the move and clears: a split, a merge the server tops up,
// or a swap that lands the displaced item in the source slot.
(count, _) => {
queue_move(model, &held, bag, slot, count);
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, held.bag, held.slot);
// 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);
}
// A whole stack locks the destination's item first (`0x4f9e85`; an empty slot's
// zero guid signals nothing, `0x4953ed`), then the swap send clears
// (`ClearCursor(0,1)` at `0x5e0fe7`: the grid hide, `CURSOR_UPDATE`) and the
// binding clears again (`0x4f9eb8`), a second `CURSOR_UPDATE` on the empty cursor.
// The source keeps its lock in both: the clears pass no unlock.
(None, dest) => {
queue_move(model, &held, bag, slot, None);
if dest.is_some() {
cursor::raise_lock_changed(model);
}
cursor::raise_cursor_update(model);
model.raise("CURSOR_UPDATE", Vec::new());
}
}
}
@@ -383,7 +396,7 @@ 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::queue_cursor_update(model);
cursor::raise_cursor_update(model);
}
Some(
other @ (CursorPayload::Spell(_)
@@ -588,10 +601,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"UseContainerItem",
lua.create_function(|lua, (bag, slot, _rest): (i64, u32, mlua::MultiValue)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// Every right-click clears the cursor before it reads the slot (`0x4fa198`): a held
// payload goes back, never placed, and an armed gift wrap is cancelled.
cursor::clear_cursor(&mut model);
// payload goes back, never placed, and an armed gift wrap is cancelled. The clear's
// events run first, as the reference's do before it reads the slot.
super::tick::verb(lua, cursor::clear_cursor);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.repair_mode {
// Then the item in the slot is repaired (`0x4fa1a6`, `0x4fa1da`).
let occupied = model
@@ -616,8 +630,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"PickupContainerItem",
lua.create_function(|lua, (bag, slot): (i64, u32)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_container_item(&mut model, bag, slot);
super::tick::verb(lua, |model| pickup_container_item(model, bag, slot));
Ok(())
})?,
)?;
+154 -62
View File
@@ -1,6 +1,6 @@
//! 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 [`queue_cursor_update`] and [`queue_lock_changed`],
//! 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.
use mlua::Lua;
@@ -161,13 +161,13 @@ pub struct CursorMacro {
// The transition events
// ─────────────────────────────────────────────────────────────────────────────────────────────
/// Queue `CURSOR_UPDATE`, once per payload transition, plus a grid event when what the bars can
/// 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.
pub(crate) fn queue_cursor_update(model: &mut Model) {
model
.pending_events
.push(("CURSOR_UPDATE".to_string(), Vec::new()));
/// 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
@@ -177,43 +177,46 @@ pub(crate) fn queue_cursor_update(model: &mut Model) {
let bar_held = model.cursor.is_some()
&& !pet_held
&& !matches!(model.cursor, Some(CursorPayload::Merchant(_)));
if bar_held != model.cursor_grid_shown {
model.cursor_grid_shown = bar_held;
let event = if bar_held {
"ACTIONBAR_SHOWGRID"
} else {
"ACTIONBAR_HIDEGRID"
};
model.pending_events.push((event.to_string(), Vec::new()));
}
if pet_held != model.pet_grid_shown {
model.pet_grid_shown = pet_held;
let event = if pet_held {
"PET_BAR_SHOWGRID"
} else {
"PET_BAR_HIDEGRID"
};
model.pending_events.push((event.to_string(), Vec::new()));
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)
};
for event in [first, second].into_iter().flatten() {
model.raise(event, Vec::new());
}
model.raise("CURSOR_UPDATE", Vec::new());
}
/// Queue `ITEM_LOCK_CHANGED(bag, slot)` for a source slot that a pickup locks or a put-down frees.
pub(crate) fn queue_lock_changed(model: &mut Model, bag: i64, slot: u32) {
model.pending_events.push((
"ITEM_LOCK_CHANGED".to_string(),
vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))],
));
/// 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.
pub(crate) fn raise_lock_changed(model: &mut Model) {
model.raise("ITEM_LOCK_CHANGED", Vec::new());
}
/// Queue `DELETE_ITEM_CONFIRM(name, quality)` for an item dropped on the world. The payload stays
/// 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 queue_delete_item_confirm(model: &mut Model, item: &CursorItem) {
fn raise_delete_item_confirm(model: &mut Model, item: &CursorItem) {
let name = item_link_name(item.link.as_deref());
let quality = item.quality.unwrap_or(0);
model.pending_events.push((
"DELETE_ITEM_CONFIRM".to_string(),
model.raise(
"DELETE_ITEM_CONFIRM",
vec![ScriptValue::Str(name), ScriptValue::Int(i64::from(quality))],
));
);
}
/// An item link's name, from its `|h[Name]|h` segment; empty for no link or another shape.
@@ -229,18 +232,18 @@ pub(super) fn item_link_name(link: Option<&str>) -> String {
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 let Some(wrap) = model.pending_wrap.take() {
queue_lock_changed(model, wrap.bag, wrap.slot);
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 taken = model.cursor.take();
queue_cursor_update(model);
if let Some(CursorPayload::Item(item)) = taken {
queue_lock_changed(model, item.bag, item.slot);
// 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`
@@ -255,7 +258,7 @@ pub(crate) fn take_cursor_item_for_sale(model: &mut Model) -> Option<CursorItem>
return None;
}
};
queue_cursor_update(model);
raise_cursor_update(model);
Some(item)
}
@@ -286,7 +289,7 @@ pub(crate) fn world_drop_click(model: &mut Model) -> bool {
match (&model.cursor, model.world_pick) {
(Some(CursorPayload::Item(item)), WorldPick::Terrain | WorldPick::Nothing) => {
let item = item.clone();
queue_delete_item_confirm(model, &item);
raise_delete_item_confirm(model, &item);
true
}
(
@@ -332,8 +335,8 @@ pub(crate) fn split_container_item(model: &mut Model, bag: i64, slot: u32, count
equip_slots: s.equip_slots.clone(),
};
model.cursor = Some(CursorPayload::Item(item));
queue_cursor_update(model);
queue_lock_changed(model, bag, slot);
raise_cursor_update(model);
raise_lock_changed(model);
true
}
@@ -348,8 +351,8 @@ pub(crate) fn delete_cursor_item(model: &mut Model) {
model
.container_destroys
.push((item.bag, item.slot, item.count.unwrap_or(0)));
queue_cursor_update(model);
queue_lock_changed(model, item.bag, item.slot);
raise_cursor_update(model);
raise_lock_changed(model);
}
// ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -474,8 +477,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"ClearCursor",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
clear_cursor(&mut model);
super::tick::verb(lua, clear_cursor);
Ok(())
})?,
)?;
@@ -483,16 +485,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"SplitContainerItem",
lua.create_function(|lua, (bag, slot, count): (i64, u32, i64)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
split_container_item(&mut model, bag, slot, count);
super::tick::verb(lua, |model| split_container_item(model, bag, slot, count));
Ok(())
})?,
)?;
g.set(
"DeleteCursorItem",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
delete_cursor_item(&mut model);
super::tick::verb(lua, delete_cursor_item);
Ok(())
})?,
)?;
@@ -509,6 +509,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
#[cfg(test)]
mod tests {
use super::{split_container_item, CursorAction, CursorItem, CursorPayload, CursorSpell};
use crate::script::tick::heard;
use crate::script::UiScript;
/// A backpack holding a five-stack in slot 1.
@@ -550,10 +551,10 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_item_backpack()));
// Drain the pickup's events so only the sell clear's remain.
// The pickup's events fire inside it, so the queue holds only the host's sell clear.
s.eval::<()>("PickupContainerItem(0, 1)").unwrap();
assert!(s.cursor_item().is_some(), "the pickup did not take");
s.model_mut().pending_events.clear();
assert!(s.model_ref().pending_events.is_empty());
let sold = s.take_cursor_item_for_sale().expect("mode 1 is a sale");
assert_eq!((sold.bag, sold.slot), (0, 1));
@@ -664,18 +665,109 @@ 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:
/// 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`).
#[test]
fn a_pickup_fires_its_events_before_it_returns() {
let mut s = UiScript::new().unwrap();
s.set_container(0, Some(one_item_backpack()));
heard::listen(
&s,
&[
"ACTIONBAR_SHOWGRID",
"ACTIONBAR_HIDEGRID",
"CURSOR_UPDATE",
"ITEM_LOCK_CHANGED",
],
Some("CursorHasItem()"),
);
let seen: Vec<String> = s
.eval("PickupContainerItem(0, 1) local h = HEARD HEARD = {} return h")
.unwrap();
assert_eq!(
seen,
[
"ACTIONBAR_SHOWGRID 1",
"CURSOR_UPDATE 1",
"ITEM_LOCK_CHANGED 1"
]
);
let seen: Vec<String> = s
.eval("PickupContainerItem(0, 1) local h = HEARD HEARD = {} return h")
.unwrap();
assert_eq!(
seen,
[
"ITEM_LOCK_CHANGED nil",
"ACTIONBAR_HIDEGRID nil",
"CURSOR_UPDATE nil"
]
);
assert!(s.model_ref().pending_events.is_empty());
}
/// The bag place arm (`0x4f9e1c`-`0x4f9eb8`): a whole stack locks an occupied destination
/// first (`0x4f9e85`), then the swap's clear (`0x5e0fe7`) and the binding's own (`0x4f9eb8`)
/// each fire `CURSOR_UPDATE`; an empty destination signals no lock (`0x4953ed`); a split's
/// one clear (`0x4f9e4f`) signals no lock either.
#[test]
fn the_bag_place_arm_locks_the_destination_before_its_two_clears() {
let mut s = UiScript::new().unwrap();
let mut bag = one_item_backpack();
let mut other = bag.slots[&1].clone();
other.item_id = 118;
bag.slots.insert(2, other);
s.set_container(0, Some(bag));
heard::listen(
&s,
&["ITEM_LOCK_CHANGED", "ACTIONBAR_HIDEGRID", "CURSOR_UPDATE"],
None,
);
let place = |s: &UiScript, from: u32, to: u32| {
s.run(&format!("PickupContainerItem(0, {from})")).unwrap();
heard::take(s);
s.run(&format!("PickupContainerItem(0, {to})")).unwrap();
heard::take(s)
};
assert_eq!(
place(&s, 1, 2),
[
"ITEM_LOCK_CHANGED",
"ACTIONBAR_HIDEGRID",
"CURSOR_UPDATE",
"CURSOR_UPDATE"
],
"a swap onto an occupied slot"
);
assert_eq!(
place(&s, 1, 3),
["ACTIONBAR_HIDEGRID", "CURSOR_UPDATE", "CURSOR_UPDATE"],
"a move onto an empty slot"
);
s.run("SplitContainerItem(0, 1, 2)").unwrap();
heard::take(&s);
s.run("PickupContainerItem(0, 4)").unwrap();
assert_eq!(
heard::take(&s),
["ACTIONBAR_HIDEGRID", "CURSOR_UPDATE"],
"a split"
);
}
/// The clear's shared tail (`0x49529b`) signals on every call: with nothing held, one
/// `CURSOR_UPDATE` and no grid event.
#[test]
fn clear_cursor_with_nothing_held_still_signals() {
let s = UiScript::new().unwrap();
let before = s.model_ref().pending_events.len();
heard::listen(
&s,
&["CURSOR_UPDATE", "ACTIONBAR_SHOWGRID", "ACTIONBAR_HIDEGRID"],
None,
);
s.run("ClearCursor()").unwrap();
let fired: Vec<String> = s.model_ref().pending_events[before..]
.iter()
.map(|(e, _)| e.clone())
.collect();
assert_eq!(fired, vec!["CURSOR_UPDATE"]);
assert_eq!(heard::take(&s), ["CURSOR_UPDATE"]);
}
/// Clearing a spell on terrain is the deviation documented on `world_drop_click`.
@@ -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(_))
+8 -9
View File
@@ -80,18 +80,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
Ok(())
})?,
)?;
// `0x48df30` runs the generic cancel-aura (`0x6e7040`): the event, then `0x136` with
// `u32 2584`, no guid. Its refusal gate (`AttributesEx` bit 13 set, bit 2 clear, then
// `0x5ee290`) never trips for 2584 in the shipped `Spell.dbc` (the app's
// `spell_2584_never_trips_the_cancel_gate`), so the send is unconditional.
// `0x48df30` runs the generic cancel-aura (`0x4923a0` → `0x6e7040`): the event in place
// (`0x6e70c3`), then `0x136` with `u32 2584`, no guid. Its refusal gate (`AttributesEx` bit 13
// set, bit 2 clear, then `0x5ee290`) never trips for 2584 in the shipped `Spell.dbc` (the
// app's `spell_2584_never_trips_the_cancel_gate`), so the send is unconditional.
g.set(
"CancelAreaSpiritHeal",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model
.pending_events
.push(("AREA_SPIRIT_HEALER_OUT_OF_RANGE".to_string(), Vec::new()));
model.cancel_aura_requests.push(AREA_SPIRIT_HEALER_SPELL);
super::tick::verb(lua, |model| {
model.raise("AREA_SPIRIT_HEALER_OUT_OF_RANGE", Vec::new());
model.cancel_aura_requests.push(AREA_SPIRIT_HEALER_SPELL);
});
Ok(())
})?,
)?;
@@ -102,16 +102,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
)?;
// UpdateInventoryAlertStatus() (`0x4c9560`): the alert recompute (`0x4c7ee0`), which fires
// UPDATE_INVENTORY_ALERTS unconditionally; zero returns. Stock calls it when the durability
// UPDATE_INVENTORY_ALERTS (`0x4c8085`) in place, after every write; zero returns. Stock calls it when the durability
// figure's enchant timer runs out (`DurabilityFrame.lua:81`).
g.set(
"UpdateInventoryAlertStatus",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
recompute_inventory_alerts(&mut model);
model
.pending_events
.push(("UPDATE_INVENTORY_ALERTS".to_string(), Vec::new()));
super::tick::verb(lua, |model| {
recompute_inventory_alerts(model);
model.raise("UPDATE_INVENTORY_ALERTS", Vec::new());
});
Ok(MultiValue::new())
})?,
)?;
+3 -4
View File
@@ -9,7 +9,7 @@
use mlua::{Lua, MultiValue, Value};
use super::cursor::{queue_cursor_update, CursorMacro, CursorPayload};
use super::cursor::{raise_cursor_update, CursorMacro, CursorPayload};
use super::Model;
/// Macros per tab (`Blizzard_MacroUI.lua:1`), and so the character tab's base.
@@ -268,7 +268,7 @@ fn pickup_macro(model: &mut Model, index: usize) -> bool {
texture: entry.texture.clone(),
};
model.cursor = Some(CursorPayload::Macro(payload));
queue_cursor_update(model);
raise_cursor_update(model);
true
}
@@ -414,8 +414,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PickupMacro",
lua.create_function(|lua, index: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_macro(&mut model, index);
super::tick::verb(lua, |model| pickup_macro(model, index));
Ok(())
})?,
)?;
+78 -61
View File
@@ -128,18 +128,11 @@ pub struct MailSendRequest {
pub package: u32,
}
/// `0x4acdc0`'s clears: the attachment, whose lock it lifts (`0x495420`, firing
/// `ITEM_LOCK_CHANGED`), then money, COD, stationery and package. Returns the lock event to fire.
fn clear_compose(model: &mut Model) -> Option<(String, Vec<super::ScriptValue>)> {
let lock = model.mail_send_item.take().map(|item| {
(
"ITEM_LOCK_CHANGED".to_string(),
vec![
super::ScriptValue::Int(item.bag),
super::ScriptValue::Int(i64::from(item.slot)),
],
)
});
/// `0x4acdc0`'s clears: the attachment, whose lock it lifts (`0x495420`, firing an argless
/// `ITEM_LOCK_CHANGED` at `0x495455`), then money, COD, stationery and package. Returns whether
/// an attachment was lifted, so the caller fires that event.
fn clear_compose(model: &mut Model) -> bool {
let lock = model.mail_send_item.take().is_some();
model.mail_send_money = 0;
model.mail_send_cod = 0;
model.mail_stationery = 0;
@@ -220,8 +213,8 @@ impl super::UiScript {
pub fn reset_compose_tab(&mut self) {
let lock = clear_compose(&mut self.model_mut());
// Fired now, not queued: the order against the caller's `MAIL_SHOW`/`MAIL_FAILED` matters.
if let Some((event, args)) = lock {
self.fire_event(&event, args);
if lock {
self.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
// One literal call each, as the event census (`ui_script::reference_ui`) reads them.
self.fire_event("SEND_MAIL_MONEY_CHANGED", Vec::new()); // 0x4ace14
@@ -232,9 +225,8 @@ impl super::UiScript {
/// The close core's silent reset (`0x4acdc0(0)`, from `0x4acd50`): the same clears, and only
/// the attachment's `ITEM_LOCK_CHANGED`, which `0x495420` fires on either leg.
pub fn reset_compose_tab_silently(&mut self) {
let lock = clear_compose(&mut self.model_mut());
if let Some((event, args)) = lock {
self.fire_event(&event, args);
if clear_compose(&mut self.model_mut()) {
self.fire_event("ITEM_LOCK_CHANGED", Vec::new());
}
}
@@ -543,16 +535,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
copper,
"Usage: SetSendMailMoney(amount)",
)? as u32;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if u64::from(n) > model.money {
model.ui_errors.push("ERR_NOT_ENOUGH_MONEY");
return Ok(Value::Nil);
}
model.mail_send_money = n;
model
.pending_events
.push(("SEND_MAIL_MONEY_CHANGED".to_string(), Vec::new()));
Ok(Value::Integer(1))
Ok(super::tick::verb(lua, |model| {
if u64::from(n) > model.money {
model.ui_errors.push("ERR_NOT_ENOUGH_MONEY");
return Value::Nil;
}
// The write (`0x4adc39`), then the signal (`0x4adc44`).
model.mail_send_money = n;
model.raise("SEND_MAIL_MONEY_CHANGED", Vec::new());
Value::Integer(1)
}))
})?,
)?;
@@ -588,14 +580,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
copper,
"Usage: SetSendMailCOD(amount)",
)? as u32;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if model.mail_send_item.is_none() {
return Ok(());
}
model.mail_send_cod = n;
model
.pending_events
.push(("SEND_MAIL_COD_CHANGED".to_string(), Vec::new()));
super::tick::verb(lua, |model| {
if model.mail_send_item.is_some() {
// The write (`0x4adc7d`), then the signal (`0x4adc88`).
model.mail_send_cod = n;
model.raise("SEND_MAIL_COD_CHANGED", Vec::new());
}
});
Ok(())
})?,
)?;
@@ -655,8 +646,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"ClickSendMailItemButton",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
click_send_mail_item(&mut model);
super::tick::verb(lua, click_send_mail_item);
Ok(())
})?,
)?;
@@ -739,21 +729,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ClearSendMail() (`0x4adee0`): the compose reset with its events (`0x4acdc0(1)`), queued in
// its order; zero returns.
// ClearSendMail() (`0x4adee0`): the compose reset with its events (`0x4acdc0(1)`), fired in
// place in its order: the attachment's unlock (`0x495455`), then `0x4ace19`, `0x4ace23` and
// `0x4ace2d`; zero returns.
g.set(
"ClearSendMail",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let lock = clear_compose(&mut model);
model.pending_events.extend(lock);
for event in [
"SEND_MAIL_MONEY_CHANGED",
"SEND_MAIL_COD_CHANGED",
"MAIL_SEND_SUCCESS",
] {
model.pending_events.push((event.to_string(), Vec::new()));
}
super::tick::verb(lua, |model| {
if clear_compose(model) {
model.raise("ITEM_LOCK_CHANGED", Vec::new());
}
model.raise("SEND_MAIL_MONEY_CHANGED", Vec::new());
model.raise("SEND_MAIL_COD_CHANGED", Vec::new());
model.raise("MAIL_SEND_SUCCESS", Vec::new());
});
Ok(MultiValue::new())
})?,
)?;
@@ -814,25 +803,19 @@ 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)) => {
let (bag, slot) = (item.bag, item.slot);
// An earlier attachment is overwritten, as in the reference; its slot has no lock.
model.mail_send_item = Some(item);
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
// `MAIL_SEND_INFO_UPDATE` (`0x4ae0de`): the stock tab re-reads the item and postage.
model
.pending_events
.push(("MAIL_SEND_INFO_UPDATE".to_string(), Vec::new()));
model.raise("MAIL_SEND_INFO_UPDATE", Vec::new());
}
None => {
if let Some(item) = model.mail_send_item.take() {
let (bag, slot) = (item.bag, item.slot);
model.cursor = Some(CursorPayload::Item(item));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
model
.pending_events
.push(("MAIL_SEND_INFO_UPDATE".to_string(), Vec::new()));
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
model.raise("MAIL_SEND_INFO_UPDATE", Vec::new());
}
}
Some(other) => {
@@ -846,6 +829,40 @@ mod tests {
use super::*;
use crate::script::UiScript;
/// The compose verbs fire in place, each after its write (`0x4adc39` then `0x4adc44`), and
/// `ClearSendMail` after all its clears (`0x4acddf`-`0x4ace0c`, then `0x4ace19`, `0x4ace23`,
/// `0x4ace2d`): every handler reads the stored amount before the next Lua line runs.
#[test]
fn the_compose_verbs_fire_their_events_in_place() {
let mut s = UiScript::new().unwrap();
s.set_money(1_000);
crate::script::tick::heard::listen(
&s,
&[
"SEND_MAIL_MONEY_CHANGED",
"SEND_MAIL_COD_CHANGED",
"MAIL_SEND_SUCCESS",
],
Some("GetSendMailMoney()"),
);
let seen: Vec<String> = s
.eval("SetSendMailMoney(250) local h = HEARD HEARD = {} return h")
.unwrap();
assert_eq!(seen, ["SEND_MAIL_MONEY_CHANGED 250"]);
let seen: Vec<String> = s
.eval("ClearSendMail() local h = HEARD HEARD = {} return h")
.unwrap();
assert_eq!(
seen,
[
"SEND_MAIL_MONEY_CHANGED 0",
"SEND_MAIL_COD_CHANGED 0",
"MAIL_SEND_SUCCESS 0"
]
);
assert!(s.model_ref().pending_events.is_empty());
}
/// A send needs a stationery: select the default, as the stock `SendMailFrame_Reset` does.
fn select_default_stationery(s: &mut UiScript) {
s.set_mail_stationeries(vec![super::StationeryView {
+65 -54
View File
@@ -173,6 +173,37 @@ fn invalid_tuple() -> Vec<Value> {
]
}
/// The row `PickupMerchantItem(index)` grabs, read before its clear: `None` for each silent
/// refusal, a non-number (`0x4fb7cb`; a numeric string passes), `index < 1` (`0x4fb7e1`),
/// `index > count` (`0x4fb7e9`), no merchant (`0x4fb7f7`), a dead row (`0x4fb80b`), or the held
/// row again (`0x4fb818`, the toggle-off). Neither stock nor the item cache is tested: a sold-out
/// row grabs, and the server refuses the buy.
fn merchant_grab(model: &Model, index: Option<f64>) -> Option<CursorPayload> {
let held = match &model.cursor {
Some(CursorPayload::Merchant(m)) => Some(m.row),
_ => None,
};
// `_ftol` truncates toward zero, then the bound applies signed. The upper bound is
// load-bearing: `2^32 + 1` would narrow to row 0, where the reference bounds against the
// vendor count before any narrowing.
let row = index?.trunc();
if row.is_nan() || row < 1.0 || row > f64::from(u32::MAX) {
return None;
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let row0 = row as u32 - 1;
let item = model.merchant.as_ref()?.items.get(row0 as usize)?;
// The toggle-off: naming the held row puts nothing back.
if held == Some(row0) {
return None;
}
Some(CursorPayload::Merchant(super::cursor::CursorMerchantItem {
item_id: item.item_id,
row: row0,
texture: item.texture.clone(),
}))
}
/// Register the merchant globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -277,62 +308,35 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
// `CursorHasItem()` first (`MerchantFrame.lua:329`). A bag item on the cursor is sold, index
// ignored and no merchant-open gate, which stock `MerchantFrame.xml:743`'s
// `PickupMerchantItem(0)` relies on; otherwise row `index - 1` is grabbed as cursor mode 5.
//
// Every refusal is silent and clears the cursor: a non-number (`0x4fb7cb`; a numeric string
// passes), `index < 1` (`0x4fb7e1`), `index > count` (`0x4fb7e9`), no merchant (`0x4fb7f7`), a
// dead row (`0x4fb80b`), or the held row again (`0x4fb818`, the toggle-off). Neither stock nor
// the item cache is tested: a sold-out row grabs, and the server refuses the buy.
// Every refusal ([`merchant_grab`]) is silent and clears the cursor.
g.set(
"PickupMerchantItem",
lua.create_function(|lua, index: Option<f64>| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// The sell fork comes first, before the argument is read. Its clear is the sale's, not
// `ClearCursor`: `CURSOR_UPDATE` fires and the source slot stays greyed until the
// server's inventory update (`0x494b60`).
if let Some(item) = crate::script::cursor::take_cursor_item_for_sale(&mut model) {
model.merchant_cursor_sells.push((item.bag, item.slot));
return Ok(());
}
// server's inventory update (`0x494b60`). Every other leg reads the row, then clears.
let grab = super::tick::verb(lua, |model| {
if let Some(item) = crate::script::cursor::take_cursor_item_for_sale(model) {
model.merchant_cursor_sells.push((item.bag, item.slot));
return None;
}
Some(merchant_grab(model, index))
});
let Some(grab) = grab else { return Ok(()) };
// Every other leg clears with the real `ClearCursor(1,1)` (`0x4fb82d`/`0x4fb83f` on a
// refusal, `0x49510b` 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`).
let held = match &model.cursor {
Some(CursorPayload::Merchant(m)) => Some(m.row),
_ => None,
};
crate::script::cursor::clear_cursor(&mut model);
let Some(index) = index else { return Ok(()) };
// `_ftol` truncates toward zero, then the bound applies signed. The upper bound is
// load-bearing: `2^32 + 1` would narrow to row 0, where the reference bounds against
// the vendor count before any narrowing.
let row = index.trunc();
if row.is_nan() || row < 1.0 || row > f64::from(u32::MAX) {
return Ok(());
// 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);
});
}
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let row0 = row as u32 - 1;
let Some(merchant) = model.merchant.as_ref() else {
return Ok(());
};
let Some(item) = merchant.items.get(row0 as usize) else {
return Ok(());
};
// The toggle-off: naming the held row puts nothing back.
if held == Some(row0) {
return Ok(());
}
let payload = CursorPayload::Merchant(super::cursor::CursorMerchantItem {
item_id: item.item_id,
row: row0,
texture: item.texture.clone(),
});
// The grab's `SignalEvent(CURSOR_UPDATE)` (`0x495159`); mode 5 skips the mode-7
// `ACTIONBAR_SHOWGRID` branch.
model.cursor = Some(payload);
crate::script::cursor::queue_cursor_update(&mut model);
Ok(())
})?,
)?;
@@ -447,13 +451,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"ShowRepairCursor",
lua.create_function(|lua, ()| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
// `0x4fbcc0`: a targeting spell or a vendor without repair service leaves the
// cursor untouched. `ClearCursor(1,1)` returns any held item before mode 0x11 arms.
if model.spell_targeting || !model.merchant.as_ref().is_some_and(|m| m.can_repair) {
return Ok(());
// cursor untouched. `ClearCursor(1,1)` returns any held item before mode 0x11 arms,
// its events running first.
{
let model = lua.app_data_ref::<Model>().expect("model app_data");
if model.spell_targeting || !model.merchant.as_ref().is_some_and(|m| m.can_repair) {
return Ok(());
}
}
crate::script::cursor::clear_cursor(&mut model);
super::tick::verb(lua, crate::script::cursor::clear_cursor);
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
model.repair_mode = true;
model.ui_cursor = None;
model.ui_cursor_dirty = true;
@@ -892,7 +900,10 @@ mod tests {
assert!(s.cursor_item().is_some());
s.set_spell_targeting(false);
// The clear's `CURSOR_UPDATE` (`0x4fbcd9`) runs before repair mode arms.
crate::script::tick::heard::listen(&s, &["CURSOR_UPDATE"], Some("InRepairMode()"));
s.run("ShowRepairCursor()").unwrap();
assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE nil"]);
assert!(s.repair_mode());
assert!(
s.cursor_item().is_none(),
@@ -1157,7 +1168,7 @@ mod tests {
texture: None,
passive: false,
}));
cursor::queue_cursor_update(&mut s.model_mut()); // the pickup's own transition
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");
s.run("PickupMerchantItem(0)").unwrap();
+11 -15
View File
@@ -1019,22 +1019,17 @@ pub(crate) struct Model {
pub(crate) chat_bubbles: super::chat_bubble::ChatBubbles,
/// The world-state readout's rows, gated and resolved by the app.
pub(crate) worldstate: super::worldstate::WorldStateUiState,
/// Events raised outside a dispatch, fired at the start of the next tick ahead of its `OnUpdate`
/// pass (`UiScript::tick`). The reference queues nothing here: a fire site calls
/// `SignalEvent` (`0x703e50`, `0x703f50`), which runs every handler before it returns, so a
/// verb's event reaches its listeners inside the call.
///
/// A binding can do the same: `tick::fire_event_into` re-enters dispatch from inside Lua, as
/// `SetCVar`'s `CVAR_UPDATE` does, once the binding holds no model borrow. So the queue is no
/// limit, and a Lua verb that pushes here instead is a gap against the reference: the cursor
/// verbs' `CURSOR_UPDATE`, grid, `ITEM_LOCK_CHANGED`, `DELETE_ITEM_CONFIRM` and
/// `PLAYER_MONEY`, the send-mail verbs' events, `SetGuildRecruitmentMode`'s
/// `UPDATE_CHAT_WINDOWS`, `SetBattlefieldScoreFaction`'s `UPDATE_BATTLEFIELD_SCORE`,
/// `UpdateInventoryAlertStatus`'s `UPDATE_INVENTORY_ALERTS`, `CancelAreaSpiritHeal`'s
/// `AREA_SPIRIT_HEALER_OUT_OF_RANGE` and the trainer verbs' `TRAINER_UPDATE`. The host's own
/// setters (the world map's, the world-state rows, a dropped mail attachment) and
/// `UiScript::queue_event` have no verb to fire inside and queue by design.
/// The host's event queue, fired at the start of the next tick ahead of its `OnUpdate` pass
/// (`UiScript::tick`). Host producers only: the app's setters (the world map's, the
/// world-state rows, a dropped mail attachment), `UiScript::queue_event`, and an engine path
/// such as a world click that clears the cursor, which has no verb to fire inside and queues
/// so it stays behind the input already queued. A Lua verb never lands here: the reference's
/// `SignalEvent` (`0x703e50`, `0x703f50`) runs every handler before it returns, so a verb's
/// events fire inside the call, through [`super::tick::verb`].
pub(crate) pending_events: Vec<(String, Vec<ScriptValue>)>,
/// The events the running [`super::tick::verb`] segment has raised, fired in order once its
/// model borrow drops; `None` outside a segment, where [`Model::raise`] queues instead.
pub(crate) verb_events: Option<Vec<(String, Vec<ScriptValue>)>>,
/// The last cursor position in UI space (logical px, y-up), behind `GetCursorPosition()`.
pub(crate) cursor_pos: (f32, f32),
@@ -1543,6 +1538,7 @@ impl Model {
chat_bubbles: super::chat_bubble::ChatBubbles::default(),
worldstate: super::worldstate::WorldStateUiState::default(),
pending_events: Vec::new(),
verb_events: None,
cursor_pos: (0.0, 0.0),
minimap_ping_request: None,
minimap_ping: (0.0, 0.0),
+3 -4
View File
@@ -12,7 +12,7 @@
use mlua::{Lua, MultiValue, Value};
use super::binding_abi::{bool_or_default, flag};
use super::cursor::{queue_cursor_update, CursorPayload, CursorSpell};
use super::cursor::{raise_cursor_update, CursorPayload, CursorSpell};
use super::{Model, ScriptCall};
const BOOKTYPE_SPELL: &str = "spell";
@@ -316,7 +316,7 @@ fn pickup_spell(model: &mut Model, id: u32, book_type: &str) -> bool {
})
};
model.cursor = Some(payload);
queue_cursor_update(model);
raise_cursor_update(model);
true
}
@@ -581,8 +581,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")?;
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_spell(&mut model, id, &book_type);
super::tick::verb(lua, |model| pickup_spell(model, id, &book_type));
Ok(())
})?,
)?;
+66 -61
View File
@@ -186,6 +186,56 @@ fn drag_verb(from: u8, to: u8, state: &StableState) -> Option<StableIntent> {
}
}
/// `ClickStablePet(i)`'s one value (`0x4cb420`), keyed only on whether the cursor held a pet: a
/// plain click selects and answers 1, so the stock window repaints; a drop answers nil, packet or
/// not, and the repaint comes with the server's next list.
fn click_stable_pet(model: &mut Model, i: i64) -> Value {
let Some(to) = slot_index(i) else {
// Out of range: a click selects nothing, a drop clears the cursor.
if !matches!(
model.cursor,
Some(super::cursor::CursorPayload::StablePet(_))
) {
model.stable.selected = 0;
return Value::Number(1.0);
}
super::cursor::clear_cursor(model);
return Value::Nil;
};
let to = to as u8;
// A drop.
if let Some(super::cursor::CursorPayload::StablePet(held)) = model.cursor.clone() {
let from = held.slot;
let intent = model
.stable
.state
.as_ref()
.and_then(|state| drag_verb(from, to, state));
super::cursor::clear_cursor(model);
if let Some(intent) = intent {
model.stable.intents.push(intent);
}
return Value::Nil;
}
// A plain click: select only.
let pet_number = model
.stable
.state
.as_ref()
.and_then(|s| s.slots[usize::from(to)].as_ref())
.map(|p| p.pet_number);
model.stable.selected = match (to, pet_number) {
// Slot 0 selects the summoned pet by its sentinel, not a pet number.
(0, _) => -1,
(_, Some(n)) => n as i32,
// An empty slot selects nothing.
(_, None) => 0,
};
Value::Number(1.0)
}
/// Register the stable globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
@@ -296,57 +346,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
})?,
)?;
// ClickStablePet(i) → one value (`0x4cb420`), keyed only on whether the cursor held a pet: a
// plain click selects and answers 1, so the stock window repaints; a drop answers nil, packet
// or not, and the repaint comes with the server's next list.
// ClickStablePet(i) → one value ([`click_stable_pet`]).
g.set(
"ClickStablePet",
lua.create_function(|lua, i: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let Some(to) = slot_index(i) else {
// Out of range: a click selects nothing, a drop clears the cursor.
if !matches!(
model.cursor,
Some(super::cursor::CursorPayload::StablePet(_))
) {
model.stable.selected = 0;
return Ok(Value::Number(1.0));
}
super::cursor::clear_cursor(&mut model);
return Ok(Value::Nil);
};
let to = to as u8;
// A drop.
if let Some(super::cursor::CursorPayload::StablePet(held)) = model.cursor.clone() {
let from = held.slot;
let intent = model
.stable
.state
.as_ref()
.and_then(|state| drag_verb(from, to, state));
super::cursor::clear_cursor(&mut model);
if let Some(intent) = intent {
model.stable.intents.push(intent);
}
return Ok(Value::Nil);
}
// A plain click: select only.
let pet_number = model
.stable
.state
.as_ref()
.and_then(|s| s.slots[usize::from(to)].as_ref())
.map(|p| p.pet_number);
model.stable.selected = match (to, pet_number) {
// Slot 0 selects the summoned pet by its sentinel, not a pet number.
(0, _) => -1,
(_, Some(n)) => n as i32,
// An empty slot selects nothing.
(_, None) => 0,
};
Ok(Value::Number(1.0))
Ok(super::tick::verb(lua, |model| click_stable_pet(model, i)))
})?,
)?;
@@ -355,19 +359,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"PickupStablePet",
lua.create_function(|lua, i: i64| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
let texture = 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(
super::cursor::CursorStablePet {
slot: i as u8,
texture,
},
));
super::cursor::queue_cursor_update(&mut model);
}
super::tick::verb(lua, |model| {
let texture = 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(
super::cursor::CursorStablePet {
slot: i as u8,
texture,
},
));
super::cursor::raise_cursor_update(model);
}
});
Ok(())
})?,
)?;
+117 -4
View File
@@ -9,10 +9,11 @@ use super::{event, Model, ScriptValue, UiScript};
impl UiScript {
/// Queue an event for the start of the next tick, behind everything queued earlier and before
/// that tick's `OnUpdate` pass. A drain runs a step behind its input, so firing at once would
/// overtake events that input already queued: the soulbind confirm for an item placed into a
/// worn slot must follow that placement's `CURSOR_UPDATE`, whose handler hides it
/// (`UIParent.lua:357-361`).
/// that tick's `OnUpdate` pass: the host's lane ([`Model::pending_events`]), for a producer
/// outside any Lua verb. A drain runs a step behind its input, so firing at once would
/// overtake events the host already queued. A Lua verb's own events never wait here: they
/// fire inside it ([`verb`]), so the soulbind confirm for an item placed into a worn slot
/// follows that placement's `CURSOR_UPDATE`, whose handler hides it (`UIParent.lua:357-361`).
pub fn queue_event(&mut self, event: &str, args: Vec<ScriptValue>) {
self.model_mut()
.pending_events
@@ -33,6 +34,40 @@ impl UiScript {
}
}
impl Model {
/// Raise `event` from engine code. Inside a [`verb`] segment it fires before the verb
/// returns, once the segment's borrow drops; anywhere else it joins the host queue
/// ([`Model::pending_events`]) for the next tick. So a helper shared by a Lua verb and a host
/// path, such as the cursor's clear, fires in place for the one and queues for the other.
pub(crate) fn raise(&mut self, event: &str, args: Vec<ScriptValue>) {
let raised = (event.to_string(), args);
match &mut self.verb_events {
Some(segment) => segment.push(raised),
None => self.pending_events.push(raised),
}
}
}
/// Run one segment of a Lua verb with the model borrowed, then fire every event it raised, in
/// order, after the borrow drops and before the binding returns, as `SignalEvent` (`0x703e50`)
/// and `SignalEvent2` (`0x703f50`) walk the listeners in place. A verb whose reference signals
/// and then changes state a getter reads runs one segment per such step, so each handler sees
/// the state the reference's sees. A handler that calls another verb runs that verb's segments
/// inside this fan-out, the reference's synchronous nesting.
pub(crate) fn verb<R>(lua: &Lua, body: impl FnOnce(&mut Model) -> R) -> R {
let (out, raised) = {
let mut model = lua.app_data_mut::<Model>().expect("model app_data set");
let outer = model.verb_events.replace(Vec::new());
debug_assert!(outer.is_none(), "a verb segment opened inside another");
let out = body(&mut model);
(out, model.verb_events.take().unwrap_or_default())
};
for (event, args) in raised {
fire_event_into(lua, &event, args);
}
out
}
/// [`UiScript::fire_event`] for a caller holding `&Lua`, such as a Lua binding.
pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec<ScriptValue>) {
let model_mut = || lua.app_data_mut::<Model>().expect("model app_data set");
@@ -241,3 +276,81 @@ fn paints(model: &super::Model, h: FrameHandle, f: &crate::widget::Frame) -> boo
let eps = crate::layout::SIZE_EPS;
f64::from(wide.abs()) >= eps && f64::from(tall.abs()) >= eps
}
/// A Lua listener that logs each delivery in order, for the tests that pin when a verb's events
/// fire and what their handlers read.
#[cfg(test)]
pub(crate) mod heard {
use super::UiScript;
/// Register one frame for `events`; each delivery appends `EVENT` or, with a `probe`, `EVENT
/// probe-value`, the probe being a Lua expression read inside the handler.
pub(crate) fn listen(s: &UiScript, events: &[&str], probe: Option<&str>) {
let register: String = events
.iter()
.map(|e| format!("HEARD_FRAME:RegisterEvent(\"{e}\")\n"))
.collect();
let line = probe.map_or_else(
|| "event".to_string(),
|p| format!("event .. \" \" .. tostring({p})"),
);
s.run(&format!(
"HEARD = {{}}\n\
HEARD_FRAME = HEARD_FRAME or CreateFrame(\"Frame\")\n\
{register}\
HEARD_FRAME:SetScript(\"OnEvent\", function() table.insert(HEARD, {line}) end)"
))
.unwrap();
}
/// The deliveries since the last call, with no tick run: what fired inside the verbs.
pub(crate) fn take(s: &UiScript) -> Vec<String> {
s.eval::<Vec<String>>("local h = HEARD HEARD = {} return h")
.unwrap()
}
}
#[cfg(test)]
mod tests {
use super::{heard, verb};
use crate::script::UiScript;
/// A verb's events have run their handlers by the verb's next Lua line, in the order raised,
/// and none is left for the tick.
#[test]
fn a_verb_segment_fires_its_events_before_the_binding_returns() {
let s = UiScript::new().unwrap();
s.lua
.globals()
.set(
"RaiseTwo",
s.lua
.create_function(|lua, ()| {
verb(lua, |model| {
model.raise("CURSOR_UPDATE", Vec::new());
model.raise("PLAYER_MONEY", Vec::new());
});
Ok(())
})
.unwrap(),
)
.unwrap();
heard::listen(&s, &["CURSOR_UPDATE", "PLAYER_MONEY"], None);
let seen: Vec<String> = s
.eval("RaiseTwo() local h = HEARD HEARD = {} return h")
.unwrap();
assert_eq!(seen, ["CURSOR_UPDATE", "PLAYER_MONEY"]);
assert!(s.model_ref().pending_events.is_empty());
}
/// Outside a segment, a raise is the host's and waits for the next tick.
#[test]
fn a_raise_outside_a_verb_queues_for_the_tick() {
let mut s = UiScript::new().unwrap();
heard::listen(&s, &["CURSOR_UPDATE"], None);
s.model_mut().raise("CURSOR_UPDATE", Vec::new());
assert!(heard::take(&s).is_empty());
s.tick(0.0);
assert_eq!(heard::take(&s), ["CURSOR_UPDATE"]);
}
}
+4 -6
View File
@@ -139,8 +139,8 @@ fn click_trade_button(model: &mut Model, id: u32) {
Some(CursorPayload::Item(item)) => {
let (bag, slot) = (item.bag, item.slot);
model.trade_set_items.push((id, bag, slot));
cursor::queue_cursor_update(model);
cursor::queue_lock_changed(model, bag, slot);
cursor::raise_cursor_update(model);
cursor::raise_lock_changed(model);
}
None => {
let filled = matches!(
@@ -355,8 +355,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"ClickTradeButton",
lua.create_function(|lua, id: u32| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
click_trade_button(&mut model, id);
super::tick::verb(lua, |model| click_trade_button(model, id));
Ok(())
})?,
)?;
@@ -366,8 +365,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
"ClickTargetTradeButton",
lua.create_function(|lua, _id: u32| {
// Coins on the cursor still go into our offer, the index unread (`0x4c00a3`).
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
cursor::money::add_trade_money(&mut model);
super::tick::verb(lua, cursor::money::add_trade_money);
Ok(())
})?,
)?;
+20 -18
View File
@@ -377,14 +377,13 @@ fn selected_row(model: &Model) -> Option<usize> {
.checked_add(1)
}
/// Queue `TRAINER_UPDATE`, which the reference fires from its mask commits, not from Lua:
/// `0x4d8c90` writes the filter mask, re-runs the finalizer and fires event `0x136`, and the
/// skill-line and expand-mask commits (`0x4d8cb0`, `0x4d8cd0`, the collapse verbs') do the same.
/// The stock window's filter click (`Blizzard_TrainerUI.lua:524`) does not repaint.
fn queue_trainer_update(model: &mut Model) {
model
.pending_events
.push(("TRAINER_UPDATE".to_string(), Vec::new()));
/// Raise `TRAINER_UPDATE`, as the reference's mask commits do after the re-filter: `0x4d8c90`
/// writes the type mask, re-runs the finalizer `0x4d8410` and signals at `0x4d8ca0`; the
/// skill-line and expand-mask commits (`0x4d8cb0`, `0x4d8cd0`, the collapse verbs') do the same
/// (`0x4d8cc0`, `0x4d8ce0`). The stock window's filter click (`Blizzard_TrainerUI.lua:524`) does
/// not repaint.
fn raise_trainer_update(model: &mut Model) {
model.raise("TRAINER_UPDATE", Vec::new());
}
fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
@@ -660,13 +659,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"SetTrainerServiceTypeFilter",
lua.create_function(|lua, (kind, on): (String, Value)| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(c) = TrainerServiceCategory::from_filter_str(&kind) {
// The stock window passes 1 or 0; nil, false and 0 disable.
let enable = !matches!(on, Value::Nil | Value::Integer(0) | Value::Boolean(false));
model.trainer_filter[c.filter_slot()] = enable;
// Fires even when the bit did not move, as the reference's thunk does.
queue_trainer_update(&mut model);
super::tick::verb(lua, |model| {
model.trainer_filter[c.filter_slot()] = enable;
// Fires even when the bit did not move, as the reference's setter does.
raise_trainer_update(model);
});
}
Ok(())
})?,
@@ -677,18 +677,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
g.set(
"CollapseTrainerSkillLine",
lua.create_function(|lua, id: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_collapsed(&mut model, id, true);
queue_trainer_update(&mut model);
super::tick::verb(lua, |model| {
set_collapsed(model, id, true);
raise_trainer_update(model);
});
Ok(())
})?,
)?;
g.set(
"ExpandTrainerSkillLine",
lua.create_function(|lua, id: usize| {
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
set_collapsed(&mut model, id, false);
queue_trainer_update(&mut model);
super::tick::verb(lua, |model| {
set_collapsed(model, id, false);
raise_trainer_update(model);
});
Ok(())
})?,
)?;
@@ -685,3 +685,19 @@ fn talent_order_sorts_on_state_within_a_group() {
let got: Vec<String> = visible(&mut s).into_iter().map(|(n, _)| n).collect();
assert_eq!(got, ["Riding", "Aye", "Bee", "Cee"]);
}
/// The mask commits fire `TRAINER_UPDATE` inside the verb, after the re-filter (`0x4d8410`, then
/// `0x4d8ca0`/`0x4d8ce0`): a handler reads the filtered tree before the next Lua line runs.
#[test]
fn the_filter_and_collapse_verbs_fire_trainer_update_in_place() {
let mut s = UiScript::new().unwrap();
s.set_trainer(Some(trainer()));
crate::script::tick::heard::listen(&s, &["TRAINER_UPDATE"], Some("GetNumTrainerServices()"));
let seen: Vec<String> = s
.eval(
"SetTrainerServiceTypeFilter('used', 0) local h = HEARD HEARD = {} \
CollapseTrainerSkillLine(1) table.insert(h, HEARD[1]) return h",
)
.unwrap();
assert_eq!(seen, ["TRAINER_UPDATE 4", "TRAINER_UPDATE 3"]);
}