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>
461 lines
17 KiB
Rust
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)]);
|
|
}
|
|
}
|