From aba120cdcbb9912e3506a5ed23a7dc99fffa36e2 Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:14:10 +0700 Subject: [PATCH] Lua verbs fire their events before returning, in the reference's order (#1015) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/benilla-app/src/ui_items/drain.rs | 7 +- .../src/ui_script/event_shape_gate.rs | 1 + .../benilla-app/src/ui_script/reference_ui.rs | 6 +- crates/benilla-ui/src/script/action.rs | 5 +- crates/benilla-ui/src/script/auction.rs | 15 +- .../src/script/battlefield_score.rs | 15 +- crates/benilla-ui/src/script/channel.rs | 27 ++- crates/benilla-ui/src/script/container.rs | 49 ++-- crates/benilla-ui/src/script/cursor.rs | 216 +++++++++++++----- .../benilla-ui/src/script/cursor/bag_verbs.rs | 90 +++++--- crates/benilla-ui/src/script/cursor/bar.rs | 178 ++++++++++----- crates/benilla-ui/src/script/cursor/doll.rs | 50 ++-- crates/benilla-ui/src/script/cursor/money.rs | 169 +++++++++----- crates/benilla-ui/src/script/cursor/pet.rs | 169 +++++++++----- crates/benilla-ui/src/script/dialog_verbs.rs | 17 +- .../benilla-ui/src/script/inventory_verbs.rs | 11 +- crates/benilla-ui/src/script/macros.rs | 7 +- crates/benilla-ui/src/script/mail.rs | 139 ++++++----- crates/benilla-ui/src/script/merchant.rs | 119 +++++----- crates/benilla-ui/src/script/model.rs | 26 +-- crates/benilla-ui/src/script/spellbook.rs | 7 +- crates/benilla-ui/src/script/stable.rs | 127 +++++----- crates/benilla-ui/src/script/tick.rs | 121 +++++++++- crates/benilla-ui/src/script/trade.rs | 10 +- crates/benilla-ui/src/script/trainer/mod.rs | 38 +-- crates/benilla-ui/src/script/trainer/tests.rs | 16 ++ 26 files changed, 1042 insertions(+), 593 deletions(-) diff --git a/crates/benilla-app/src/ui_items/drain.rs b/crates/benilla-app/src/ui_items/drain.rs index 2e8d5028..adc01aad 100644 --- a/crates/benilla-app/src/ui_items/drain.rs +++ b/crates/benilla-app/src/ui_items/drain.rs @@ -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::(); + let mut s = app.world_mut().non_send_resource_mut::(); + s.tick(0.01); s.run("PickupContainerItem(0, 1)").unwrap(); s.run("PickupInventoryItem(16)").unwrap(); } diff --git a/crates/benilla-app/src/ui_script/event_shape_gate.rs b/crates/benilla-app/src/ui_script/event_shape_gate.rs index 1413dc5e..3ba9b0d2 100644 --- a/crates/benilla-app/src/ui_script/event_shape_gate.rs +++ b/crates/benilla-app/src/ui_script/event_shape_gate.rs @@ -148,6 +148,7 @@ fn fire_sites() -> Vec<(String, Vec, String)> { for call in [ concat!("fire_event", "("), concat!("fire_event_into", "("), + concat!("model.raise", "("), concat!("pending_events.push", "(("), ] { let mut from = 0; diff --git a/crates/benilla-app/src/ui_script/reference_ui.rs b/crates/benilla-app/src/ui_script/reference_ui.rs index 9ff8922c..1954e5bb 100644 --- a/crates/benilla-app/src/ui_script/reference_ui.rs +++ b/crates/benilla-app/src/ui_script/reference_ui.rs @@ -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; diff --git a/crates/benilla-ui/src/script/action.rs b/crates/benilla-ui/src/script/action.rs index f3facdfe..625a3af4 100644 --- a/crates/benilla-ui/src/script/action.rs +++ b/crates/benilla-ui/src/script/action.rs @@ -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::().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::().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 diff --git a/crates/benilla-ui/src/script/auction.rs b/crates/benilla-ui/src/script/auction.rs index 6ef25f1a..948a9077 100644 --- a/crates/benilla-ui/src/script/auction.rs +++ b/crates/benilla-ui/src/script/auction.rs @@ -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::().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) => { diff --git a/crates/benilla-ui/src/script/battlefield_score.rs b/crates/benilla-ui/src/script/battlefield_score.rs index c23a0c65..7ce4d65f 100644 --- a/crates/benilla-ui/src/script/battlefield_score.rs +++ b/crates/benilla-ui/src/script/battlefield_score.rs @@ -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| { @@ -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::().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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/channel.rs b/crates/benilla-ui/src/script/channel.rs index af5a3ce5..b6aa1326 100644 --- a/crates/benilla-ui/src/script/channel.rs +++ b/crates/benilla-ui/src/script/channel.rs @@ -209,20 +209,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { "SetGuildRecruitmentMode: invalid mode", )); } - let mut model = lua.app_data_mut::().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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/container.rs b/crates/benilla-ui/src/script/container.rs index 2b602b3e..d9d455e1 100644 --- a/crates/benilla-ui/src/script/container.rs +++ b/crates/benilla-ui/src/script/container.rs @@ -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::().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::().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::().expect("model app_data"); - pickup_container_item(&mut model, bag, slot); + super::tick::verb(lua, |model| pickup_container_item(model, bag, slot)); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/cursor.rs b/crates/benilla-ui/src/script/cursor.rs index 22fcbc4f..05f4d232 100644 --- a/crates/benilla-ui/src/script/cursor.rs +++ b/crates/benilla-ui/src/script/cursor.rs @@ -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 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::().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::().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::().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 = 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 = 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 = 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`. diff --git a/crates/benilla-ui/src/script/cursor/bag_verbs.rs b/crates/benilla-ui/src/script/cursor/bag_verbs.rs index a56b34e3..9dc0106c 100644 --- a/crates/benilla-ui/src/script/cursor/bag_verbs.rs +++ b/crates/benilla-ui/src/script/cursor/bag_verbs.rs @@ -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::().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::().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::().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(); diff --git a/crates/benilla-ui/src/script/cursor/bar.rs b/crates/benilla-ui/src/script/cursor/bar.rs index 48210a5a..4f2ca695 100644 --- a/crates/benilla-ui/src/script/cursor/bar.rs +++ b/crates/benilla-ui/src/script/cursor/bar.rs @@ -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::() + .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)> { // 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::().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::().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::("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::("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::("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::("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::("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::("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::("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::("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::("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::("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)]); } } diff --git a/crates/benilla-ui/src/script/cursor/doll.rs b/crates/benilla-ui/src/script/cursor/doll.rs index a73bf403..85b3279b 100644 --- a/crates/benilla-ui/src/script/cursor/doll.rs +++ b/crates/benilla-ui/src/script/cursor/doll.rs @@ -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::().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::().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::().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::().expect("model app_data"); - use_inventory_item(&mut model, id); + use_inventory_item(lua, id); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/cursor/money.rs b/crates/benilla-ui/src/script/cursor/money.rs index ea759745..0efca2bd 100644 --- a/crates/benilla-ui/src/script/cursor/money.rs +++ b/crates/benilla-ui/src/script/cursor/money.rs @@ -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::().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::().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::().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::().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::().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 { - 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::>("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 = 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::("return GetCursorMoney()").unwrap(), 20); + assert!(s.errors().is_empty(), "{:?}", s.errors()); + assert!(s.model_ref().pending_events.is_empty()); } #[test] diff --git a/crates/benilla-ui/src/script/cursor/pet.rs b/crates/benilla-ui/src/script/cursor/pet.rs index 658cd99f..c68561cd 100644 --- a/crates/benilla-ui/src/script/cursor/pet.rs +++ b/crates/benilla-ui/src/script/cursor/pet.rs @@ -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::().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 = lua.globals().get(name.as_str()).unwrap_or_default(); - let mut model = lua.app_data_mut::().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::().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::().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::("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(_)) diff --git a/crates/benilla-ui/src/script/dialog_verbs.rs b/crates/benilla-ui/src/script/dialog_verbs.rs index c5f5d40d..cec46c4e 100644 --- a/crates/benilla-ui/src/script/dialog_verbs.rs +++ b/crates/benilla-ui/src/script/dialog_verbs.rs @@ -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::().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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/inventory_verbs.rs b/crates/benilla-ui/src/script/inventory_verbs.rs index 1340ce0d..a5679d00 100644 --- a/crates/benilla-ui/src/script/inventory_verbs.rs +++ b/crates/benilla-ui/src/script/inventory_verbs.rs @@ -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::().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()) })?, )?; diff --git a/crates/benilla-ui/src/script/macros.rs b/crates/benilla-ui/src/script/macros.rs index 4d8fd354..90709936 100644 --- a/crates/benilla-ui/src/script/macros.rs +++ b/crates/benilla-ui/src/script/macros.rs @@ -9,7 +9,7 @@ use mlua::{Lua, MultiValue, Value}; -use super::cursor::{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::().expect("model app_data"); - pickup_macro(&mut model, index); + super::tick::verb(lua, |model| pickup_macro(model, index)); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/mail.rs b/crates/benilla-ui/src/script/mail.rs index c7d15e5e..a7793c81 100644 --- a/crates/benilla-ui/src/script/mail.rs +++ b/crates/benilla-ui/src/script/mail.rs @@ -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)> { - 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::().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::().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::().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::().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 = s + .eval("SetSendMailMoney(250) local h = HEARD HEARD = {} return h") + .unwrap(); + assert_eq!(seen, ["SEND_MAIL_MONEY_CHANGED 250"]); + let seen: Vec = 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 { diff --git a/crates/benilla-ui/src/script/merchant.rs b/crates/benilla-ui/src/script/merchant.rs index 257f3dd2..8a617e88 100644 --- a/crates/benilla-ui/src/script/merchant.rs +++ b/crates/benilla-ui/src/script/merchant.rs @@ -173,6 +173,37 @@ fn invalid_tuple() -> Vec { ] } +/// 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) -> Option { + 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| { - let mut model = lua.app_data_mut::().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::().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::().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::().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(); diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 0a9e0fea..a371d29a 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -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)>, + /// 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)>>, /// 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), diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index a868d3f1..f162cd77 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -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::().expect("model app_data"); - pickup_spell(&mut model, id, &book_type); + super::tick::verb(lua, |model| pickup_spell(model, id, &book_type)); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/stable.rs b/crates/benilla-ui/src/script/stable.rs index 72d07a57..d59be82c 100644 --- a/crates/benilla-ui/src/script/stable.rs +++ b/crates/benilla-ui/src/script/stable.rs @@ -186,6 +186,56 @@ fn drag_verb(from: u8, to: u8, state: &StableState) -> Option { } } +/// `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::().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::().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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/tick.rs b/crates/benilla-ui/src/script/tick.rs index ca767233..cd1daf68 100644 --- a/crates/benilla-ui/src/script/tick.rs +++ b/crates/benilla-ui/src/script/tick.rs @@ -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) { 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) { + 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(lua: &Lua, body: impl FnOnce(&mut Model) -> R) -> R { + let (out, raised) = { + let mut model = lua.app_data_mut::().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) { let model_mut = || lua.app_data_mut::().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 { + s.eval::>("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 = 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"]); + } +} diff --git a/crates/benilla-ui/src/script/trade.rs b/crates/benilla-ui/src/script/trade.rs index ba66e706..f8396754 100644 --- a/crates/benilla-ui/src/script/trade.rs +++ b/crates/benilla-ui/src/script/trade.rs @@ -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::().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::().expect("model app_data"); - cursor::money::add_trade_money(&mut model); + super::tick::verb(lua, cursor::money::add_trade_money); Ok(()) })?, )?; diff --git a/crates/benilla-ui/src/script/trainer/mod.rs b/crates/benilla-ui/src/script/trainer/mod.rs index 6f51e4bd..166db905 100644 --- a/crates/benilla-ui/src/script/trainer/mod.rs +++ b/crates/benilla-ui/src/script/trainer/mod.rs @@ -377,14 +377,13 @@ fn selected_row(model: &Model) -> Option { .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::().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::().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::().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(()) })?, )?; diff --git a/crates/benilla-ui/src/script/trainer/tests.rs b/crates/benilla-ui/src/script/trainer/tests.rs index 8f03aeae..730ad83c 100644 --- a/crates/benilla-ui/src/script/trainer/tests.rs +++ b/crates/benilla-ui/src/script/trainer/tests.rs @@ -685,3 +685,19 @@ fn talent_order_sorts_on_state_within_a_group() { let got: Vec = 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 = 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"]); +}