Files
benilla-octo/crates/benilla-ui/src/script/cursor/bar.rs
T
Sam SungandClaude Opus 5.5 aba120cdcb Lua verbs fire their events before returning, in the reference's order (#1015)
In 1.12.1 an event a Lua verb raises runs its handlers before the verb returns: `SignalEvent` (`0x703e50`) walks the listeners in place. About 35 benilla binding sites queued theirs for a later drain instead.

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

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

Fixes #999
Fixes #582

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 16:14:10 +07:00

461 lines
17 KiB
Rust

//! The action-bar cursor verbs. The bar is client-authoritative: placing onto an occupied slot
//! hops the displaced action onto the cursor (`PlaceAction`, `0x4e62e0`). `model.actions` is an
//! optimistic mirror of the app's table; each change queues one `CMSG_SET_ACTION_BUTTON`, so a
//! drag-swap is two sends.
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::{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`).
const PASSIVE_ON_BAR_ERROR: &str = "ERR_PASSIVE_ABILITY";
/// The `CMSG_SET_ACTION_BUTTON` slot word.
fn pack(kind: u8, action: u32) -> u32 {
(u32::from(kind) << 24) | (action & 0x00FF_FFFF)
}
/// `PickupAction(id)`, the slot's shift-click and drag start (`ActionBarFrame.xml:12-38`). With a
/// payload held it places instead; a pickup also clears the slot on the wire at once. The cursor
/// goes first and the slot after, as `0x4e6130` runs its setter before the slot clear `0x4e5db0`,
/// so the pickup's `CURSOR_UPDATE` handler still reads the action in its slot. Returns whether to
/// repaint.
pub(super) fn pickup_action(lua: &Lua, id: u32) -> bool {
let held = lua
.app_data_ref::<Model>()
.expect("model app_data")
.cursor
.is_some();
if held {
return place_action(lua, id);
}
let 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. The cursor goes first and the slot after, as `0x4e62e0`
/// puts the displaced action on the cursor (or clears it) before it writes the slot, so the
/// cursor's events read the slot as it was. Returns whether to repaint.
pub(crate) fn place_action(lua: &Lua, id: u32) -> bool {
let Some((kind, action, texture)) = verb(lua, |model| take_for_place(model, id)) else {
return false;
};
verb(lua, |model| {
model.actions.insert(
id,
ActionSlot {
texture,
kind,
action,
// Count and consumable wait for the app's next-frame re-feed, which knows the item.
count: 0,
consumable: false,
},
);
model.action_sets.push((id, pack(kind, action)));
});
true
}
/// `PlaceAction`'s cursor half: the accept filters, then the held payload swapped for the slot's
/// occupant. Returns what the slot takes, or `None` for a refusal that leaves the payload held.
fn take_for_place(model: &mut Model, id: u32) -> Option<(u8, u32, Option<String>)> {
// The two accept filters (`0x4e62e0`) refuse with a bare return: nothing stored or sent, and
// the payload stays held. An item needs an on-use spell or an equip slot (`0x4e6571`), so a
// bag is placeable; a passive spell (`Attributes & 0x40`) also raises errorId `0x9e` through
// `DisplayError` (`0x4e63ad`, `0x496720`), where the item refusal is mute.
match &model.cursor {
Some(CursorPayload::Item(i)) if !i.bar_placeable => return None,
Some(CursorPayload::Spell(s)) if s.passive => {
model.ui_errors.push(PASSIVE_ON_BAR_ERROR);
return None;
}
_ => {}
}
let held = model.cursor.take()?;
let placeable = match &held {
CursorPayload::Action(a) => Some((a.kind, a.action, a.texture.clone())),
CursorPayload::Item(i) => Some((ACTION_KIND_ITEM, i.item_id, i.texture.clone())),
CursorPayload::Spell(s) => Some((ACTION_KIND_SPELL, s.spell_id, s.texture.clone())),
// `PlaceAction` accepts modes 1, 3, 7 and 8 (`0x4e62e0`): a macro, mode 8, packs its id
// under the macro tag. Pet actions (4), coins (2), stabled pets (10) and vendor rows (5)
// are refused and stay held.
CursorPayload::Macro(m) => Some((ACTION_KIND_MACRO, m.index, m.texture.clone())),
CursorPayload::PetAction(_) => None,
CursorPayload::StablePet(_) => None,
CursorPayload::Money(_) => None,
CursorPayload::Merchant(_) => None,
};
let Some(placed) = placeable else {
model.cursor = Some(held);
return None;
};
model.cursor = model.actions.get(&id).map(|d| {
CursorPayload::Action(CursorAction {
src_slot: id,
kind: d.kind,
action: d.action,
texture: d.texture.clone(),
})
});
raise_cursor_update(model);
Some(placed)
}
/// Register `PickupAction` and `PlaceAction` as globals.
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
let g = lua.globals();
g.set(
"PickupAction",
lua.create_function(|lua, id: u32| {
pickup_action(lua, id);
Ok(())
})?,
)?;
g.set(
"PlaceAction",
lua.create_function(|lua, id: u32| {
place_action(lua, id);
Ok(())
})?,
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::{pickup_action, place_action};
use crate::script::cursor::{CursorAction, CursorItem, CursorPayload, CursorSpell};
use crate::script::{ActionSlot, UiScript};
fn action_slot(texture: &str, kind: u8, action: u32) -> ActionSlot {
ActionSlot {
texture: Some(texture.into()),
kind,
action,
count: 0,
consumable: false,
}
}
#[test]
fn pickup_action_empties_the_slot_and_queues_a_clear() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 133)));
assert!(pickup_action(&s.lua, 1));
assert!(
!s.eval::<bool>("return HasAction(1)").unwrap(),
"removed from the engine's optimistic mirror"
);
assert!(
matches!(s.cursor_payload(), Some(CursorPayload::Action(c)) if c.src_slot == 1),
"{:?}",
s.cursor_payload()
);
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
}
/// The cursor's events run before the slot write, as `0x4e6130` runs its setter before the
/// slot clear `0x4e5db0` and `0x4e62e0` hops the displaced action before it writes: a handler
/// reads each slot as it was.
#[test]
fn the_cursor_events_fire_before_the_slot_write() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
crate::script::tick::heard::listen(
&s,
&["CURSOR_UPDATE"],
Some("tostring(GetActionTexture(1)) .. ' ' .. tostring(GetActionTexture(2))"),
);
s.run("PickupAction(1)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE Interface\\Icons\\Spell_A Interface\\Icons\\Spell_B"]
);
assert!(!s.eval::<bool>("return HasAction(1)").unwrap());
s.run("PlaceAction(2)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
["CURSOR_UPDATE nil Interface\\Icons\\Spell_B"]
);
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
);
}
#[test]
fn pickup_action_on_an_empty_slot_is_a_no_op() {
let mut s = UiScript::new().unwrap();
assert!(!pickup_action(&s.lua, 5));
assert!(s.cursor_payload().is_none());
assert!(s.take_action_sets().is_empty());
}
#[test]
fn place_action_onto_empty_writes_the_slot_and_clears_cursor() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 3,
kind: 0x00,
action: 133,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(place_action(&s.lua, 7));
assert!(s.cursor_payload().is_none(), "empty destination clears");
assert!(s.eval::<bool>("return HasAction(7)").unwrap());
assert_eq!(
s.eval::<String>("return GetActionTexture(7)").unwrap(),
"Interface\\Icons\\Spell_A"
);
// 0x00<<24 | 133 == 133.
assert_eq!(s.take_action_sets(), vec![(7, 133)]);
}
#[test]
fn place_action_onto_occupied_hops_the_displaced_action() {
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)));
assert!(pickup_action(&s.lua, 1));
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
assert!(place_action(&s.lua, 2));
assert_eq!(
s.eval::<String>("return GetActionTexture(2)").unwrap(),
"Interface\\Icons\\Spell_A"
);
assert_eq!(
s.cursor_payload(),
Some(CursorPayload::Action(CursorAction {
src_slot: 2,
kind: 0x00,
action: 222,
texture: Some("Interface\\Icons\\Spell_B".into()),
}))
);
assert_eq!(s.take_action_sets(), vec![(2, 111)]);
}
#[test]
fn place_action_item_payload_packs_the_item_kind_and_leaves_the_bag_untouched() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true,
bag: 0,
slot: 1,
item_id: 117,
texture: Some("Interface\\Icons\\INV_Misc_Food_16".into()),
link: None,
count: None,
quality: Some(1),
equip_slots: Vec::new(),
}));
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)]);
assert!(
s.take_container_moves().is_empty(),
"a bar item action is a reference, not a move"
);
}
#[test]
fn place_action_spell_payload_packs_the_spell_kind() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
passive: false,
book_slot: 3,
book_type: "spell".into(),
spell_id: 133,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(place_action(&s.lua, 9));
assert_eq!(s.take_action_sets(), vec![(9, 133)]); // 0x00<<24 | 133
}
#[test]
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(&s.lua, 1));
assert!(s.eval::<bool>("return HasAction(1)").unwrap(), "untouched");
assert!(s.take_action_sets().is_empty());
}
#[test]
fn pickup_action_with_a_payload_held_falls_through_to_place() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
src_slot: 1,
kind: 0x00,
action: 111,
texture: Some("Interface\\Icons\\Spell_A".into()),
}));
assert!(pickup_action(&s.lua, 5));
assert!(s.cursor_payload().is_none(), "placed onto the empty slot 5");
assert!(s.eval::<bool>("return HasAction(5)").unwrap());
assert_eq!(s.take_action_sets(), vec![(5, 111)]);
}
#[test]
fn showgrid_hidegrid_fire_on_gain_and_loss_not_on_a_hop() {
let mut s = UiScript::new().unwrap();
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
s.run(
r#"
shows, hides = 0, 0
local f = CreateFrame("Frame", "GridListener")
f:RegisterEvent("ACTIONBAR_SHOWGRID")
f:RegisterEvent("ACTIONBAR_HIDEGRID")
f:SetScript("OnEvent", function()
if event == "ACTIONBAR_SHOWGRID" then shows = shows + 1 end
if event == "ACTIONBAR_HIDEGRID" then hides = hides + 1 end
end)
"#,
)
.unwrap();
s.run("PickupAction(1)").unwrap(); // None -> Some: SHOW
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
s.run("PlaceAction(2)").unwrap(); // Some -> Some (hop): neither
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 0);
s.run("ClearCursor()").unwrap(); // Some -> None: HIDE
s.tick(0.01);
assert_eq!(s.eval::<i64>("return shows").unwrap(), 1);
assert_eq!(s.eval::<i64>("return hides").unwrap(), 1);
}
#[test]
fn a_non_usable_non_equippable_item_is_refused_and_stays_held() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: false, // no on-use spell, InventoryType 0
bag: 0,
slot: 1,
item_id: 2589, // Linen Cloth
texture: Some("Interface\\Icons\\INV_Fabric_Linen_01".into()),
link: None,
count: None,
quality: Some(1),
equip_slots: Vec::new(),
}));
assert!(!place_action(&s.lua, 4));
assert!(!s.eval::<bool>("return HasAction(4)").unwrap(), "no store");
assert!(s.take_action_sets().is_empty(), "no packet");
assert!(
matches!(s.cursor_payload(), Some(CursorPayload::Item(_))),
"a refused payload stays on the cursor — the reference never clears it"
);
assert!(
s.take_ui_errors().is_empty(),
"the ITEM refusal is MUTE — only the SPELL arm carries a DisplayError (`4e63ad`); a \
toast here would be ours, not the reference's"
);
}
#[test]
fn a_passive_spell_is_refused_with_the_refs_error_and_stays_held() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 674, // Dual Wield, a passive
texture: Some("Interface\\Icons\\Ability_DualWield".into()),
passive: true,
}));
assert!(!place_action(&s.lua, 4));
assert!(!s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_action_sets().is_empty());
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
assert_eq!(
s.take_ui_errors(),
vec![super::PASSIVE_ON_BAR_ERROR],
"the ref's errorId 0x9e toast — 'You can't put a passive ability in the action bar.'"
);
assert!(
s.take_ui_errors().is_empty(),
"…and the queue drains, so the toast fires once per refusal"
);
}
#[test]
fn an_active_spell_places_without_an_error() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Spell(CursorSpell {
book_slot: 1,
book_type: "spell".into(),
spell_id: 133, // Fireball
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
passive: false,
}));
assert!(place_action(&s.lua, 4));
assert!(s.eval::<bool>("return HasAction(4)").unwrap());
assert!(s.take_ui_errors().is_empty());
}
#[test]
fn a_bag_is_placeable() {
let mut s = UiScript::new().unwrap();
s.set_cursor_for_test(CursorPayload::Item(CursorItem {
bar_placeable: true, // equippable (BAG), even with no on-use spell
bag: 0,
slot: 1,
item_id: 4496, // Small Brown Pouch
texture: Some("Interface\\Icons\\INV_Misc_Bag_08".into()),
link: None,
count: None,
quality: Some(1),
equip_slots: vec![20, 21, 22, 23],
}));
assert!(place_action(&s.lua, 4));
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000u32 | 4496)]);
}
}