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

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

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

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

Fixes #999
Fixes #582

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-10-12 16:14:10 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 24c7cab983
commit aba120cdcb
26 changed files with 1042 additions and 593 deletions
+113 -56
View File
@@ -9,9 +9,10 @@
use mlua::Lua;
use crate::script::tick::verb;
use crate::script::Model;
use super::{queue_cursor_update, CursorPayload, CursorPetAction};
use super::{raise_cursor_update, CursorPayload, CursorPetAction};
/// Pet bar slots (`NUM_PET_ACTION_SLOTS`, vmangos `MAX_UNIT_ACTION_BAR_INDEX`). Deviation: slot
/// 11 is refused, because the reference's gate (`cmp esi,0xa; jbe`) admits it and neither
@@ -96,22 +97,64 @@ fn assign(slots: &mut [u32], target: usize, source: u32, passive: bool) -> Optio
/// (`0x4be1f7`) or is `UNIT_FLAG_POSSESSED` (`0x4be20a`) blocks both ends, the gate sitting above
/// the cursor fork, so the cursor is left as it was. Another payload stays held here, where the
/// reference clears it (`0x4be220`) and picks the slot up. A pickup blanks only a spell slot
/// (`0x4be268`), so a dragged token is copied, not moved.
pub(super) fn pickup_pet_action(model: &mut Model, slot: u32) -> bool {
/// (`0x4be268`), so a dragged token is copied, not moved; the blank comes after the setter's
/// events (`0x494e20`), so they read the slot as it was. Returns whether to repaint.
pub(super) fn pickup_pet_action(lua: &Lua, slot: u32) -> bool {
let pick = verb(lua, |model| {
let pick = pick_or_place(model, slot);
// Both the pickup and a drop's displaced occupant can put a token on the cursor.
resolve_token_icon(lua, model);
pick
});
match pick {
PetPick::Refused => false,
PetPick::Dropping(held) => verb(lua, |model| {
let wrote = place_pet_action(model, slot, &held);
resolve_token_icon(lua, model);
wrote
}),
PetPick::Picked(blank) => {
if let Some((index, word)) = blank {
verb(lua, |model| {
write_slot(model, index, word, false);
});
}
true
}
}
}
/// What [`pickup_pet_action`]'s cursor half did.
enum PetPick {
Refused,
/// A held pet action left the cursor, its clear signalled; the drop still owes its write.
Dropping(CursorPetAction),
/// The slot's word went on the cursor; a spell slot still owes its blank, `(index, word)`.
Picked(Option<(usize, u32)>),
}
fn pick_or_place(model: &mut Model, slot: u32) -> PetPick {
if !model.pet_bar.pickup_allowed {
return false;
return PetPick::Refused;
}
match &model.cursor {
Some(CursorPayload::PetAction(_)) => return place_pet_action(model, slot),
Some(_) => return false,
Some(CursorPayload::PetAction(held)) => {
// `ClearCursor(1,1)` (`0x4be220`), unconditionally, before anything is decided: its
// `PET_BAR_HIDEGRID` and `CURSOR_UPDATE` run before the bar write.
let held = held.clone();
model.cursor = None;
raise_cursor_update(model);
return PetPick::Dropping(held);
}
Some(_) => return PetPick::Refused,
None => {}
}
let Some(index) = slot_index(model, slot) else {
return false;
return PetPick::Refused;
};
let view = model.pet_bar.slots[index].view.clone();
let Some(packed) = payload_word(view.packed) else {
return false;
return PetPick::Refused;
};
model.cursor = Some(CursorPayload::PetAction(CursorPetAction {
@@ -120,43 +163,32 @@ pub(super) fn pickup_pet_action(model: &mut Model, slot: u32) -> bool {
passive: view.passive,
texture: view.texture.clone(),
}));
raise_cursor_update(model);
// A spell slot keeps its type and autocast bits with id 0, and the server is told. `false` is
// right: the passive gate reads the source's spell record, and id 0 has none.
if kind(packed) == 1 {
write_slot(model, index, packed & 0xFFFF_0000, false);
}
queue_cursor_update(model);
true
PetPick::Picked((kind(packed) == 1).then_some((index, packed & 0xFFFF_0000)))
}
/// The drop: the cursor always empties, and only a homeless occupant refills it (`0x4bce3d`).
/// Returns whether anything was written; the cursor empties on `false` too.
fn place_pet_action(model: &mut Model, slot: u32) -> bool {
let Some(CursorPayload::PetAction(held)) = model.cursor.clone() else {
return false;
};
// `ClearCursor(1,1)`, unconditionally, before anything is decided.
model.cursor = None;
/// The drop past its clear: the bar write (`0x4bce00`), and only a homeless occupant refills the
/// cursor (`0x4bce3d`), signalling as a pickup does. Returns whether anything was written.
fn place_pet_action(model: &mut Model, slot: u32, held: &CursorPetAction) -> bool {
let Some(index) = slot_index(model, slot) else {
queue_cursor_update(model);
return false;
};
// Read before the write: a displaced word's passive bit and icon are its slot's.
let occupant_view = model.pet_bar.slots[index].view.clone();
let Some(assigned) = write_slot(model, index, held.packed, held.passive) else {
queue_cursor_update(model);
return false;
};
model.cursor = assigned.displaced.map(|packed| {
CursorPayload::PetAction(CursorPetAction {
if let Some(packed) = assigned.displaced {
model.cursor = Some(CursorPayload::PetAction(CursorPetAction {
src_slot: slot,
packed,
passive: occupant_view.passive,
texture: occupant_view.texture.clone(),
})
});
queue_cursor_update(model);
}));
raise_cursor_update(model);
}
true
}
@@ -197,23 +229,19 @@ fn write_slot(model: &mut Model, target: usize, source: u32, passive: bool) -> O
}
/// A token's texture is a global's name (`GetPetActionInfo`, `0x4bdc50`), which the bar resolves
/// with `getglobal` (`PetActionBarFrame.lua:102`); the cursor needs the path, resolved here.
fn resolve_token_icon(lua: &Lua) {
let name = {
let model = lua.app_data_ref::<Model>().expect("model app_data");
match &model.cursor {
Some(CursorPayload::PetAction(p)) if is_token(p.packed) => p.texture.clone(),
_ => None,
}
};
let Some(name) = name else {
/// with `getglobal` (`PetActionBarFrame.lua:102`); the cursor needs the path, resolved here. A raw
/// global read touches no model state, so it runs inside the verb's segment, before its events.
fn resolve_token_icon(lua: &Lua, model: &mut Model) {
let Some(CursorPayload::PetAction(p)) = &mut model.cursor else {
return;
};
let path: Option<String> = lua.globals().get(name.as_str()).unwrap_or_default();
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
if let Some(CursorPayload::PetAction(p)) = &mut model.cursor {
p.texture = path;
if !is_token(p.packed) {
return;
}
let Some(name) = p.texture.clone() else {
return;
};
p.texture = lua.globals().raw_get(name.as_str()).unwrap_or_default();
}
/// Register the pet bar's one cursor global.
@@ -221,12 +249,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
lua.globals().set(
"PickupPetAction",
lua.create_function(|lua, slot: u32| {
{
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
pickup_pet_action(&mut model, slot);
}
// Both the pickup and a drop's displaced occupant can put a token on the cursor.
resolve_token_icon(lua);
pickup_pet_action(lua, slot);
Ok(())
})?,
)?;
@@ -338,10 +361,44 @@ mod tests {
s
}
/// A drop clears first (`0x4be220`): its grid hide and `CURSOR_UPDATE` read the bar before the
/// write (`0x4bce00`), and the displaced occupant's events after it. The written word is
/// engine state, read here through a test global.
#[test]
fn a_drop_signals_its_clear_before_the_bar_write() {
let s = bar_script();
let word = s
.lua
.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.pet_bar.slots[4].view.packed == CLAW)
})
.unwrap();
s.lua.globals().set("SlotFiveIsClaw", word).unwrap();
s.run("PickupPetAction(4)").unwrap();
crate::script::tick::heard::listen(
&s,
&["PET_BAR_HIDEGRID", "CURSOR_UPDATE", "PET_BAR_SHOWGRID"],
Some("SlotFiveIsClaw()"),
);
// Growl is displaced and rides the cursor out, signalling after the write.
s.run("PickupPetAction(5)").unwrap();
assert_eq!(
crate::script::tick::heard::take(&s),
[
"PET_BAR_HIDEGRID false",
"CURSOR_UPDATE false",
"PET_BAR_SHOWGRID true",
"CURSOR_UPDATE true"
]
);
assert!(s.eval::<bool>("return SlotFiveIsClaw()").unwrap());
}
#[test]
fn picking_up_a_spell_blanks_its_slot_and_sends() {
let mut s = bar_script();
assert!(pickup_pet_action(&mut s.model_mut(), 4));
assert!(pickup_pet_action(&s.lua, 4));
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c)) if c.src_slot == 4),
"{:?}",
@@ -389,7 +446,7 @@ mod tests {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, false, true, hunter_bar());
assert!(pickup_pet_action(&mut s.model_mut(), 4));
assert!(pickup_pet_action(&s.lua, 4));
assert!(
matches!(s.cursor_payload(), Some(crate::script::CursorPayload::PetAction(c))
if c.src_slot == 4 && c.packed == CLAW),
@@ -441,7 +498,7 @@ mod tests {
#[test]
fn picking_up_a_token_leaves_the_slot_alone() {
let mut s = bar_script();
assert!(pickup_pet_action(&mut s.model_mut(), 1));
assert!(pickup_pet_action(&s.lua, 1));
assert!(
s.take_pet_set_actions().is_empty(),
"nothing was written, so nothing is sent"
@@ -507,8 +564,8 @@ mod tests {
#[test]
fn slot_eleven_is_refused_rather_than_written_past_the_array() {
let mut s = bar_script();
assert!(!pickup_pet_action(&mut s.model_mut(), 11));
assert!(!pickup_pet_action(&mut s.model_mut(), 0));
assert!(!pickup_pet_action(&s.lua, 11));
assert!(!pickup_pet_action(&s.lua, 0));
assert!(s.take_pet_set_actions().is_empty());
assert!(s.cursor_payload().is_none());
}
@@ -518,7 +575,7 @@ mod tests {
fn a_possessed_bar_takes_no_drag_at_all() {
let mut s = UiScript::new().unwrap();
s.set_pet_actions(true, true, false, hunter_bar());
assert!(!pickup_pet_action(&mut s.model_mut(), 4));
assert!(!pickup_pet_action(&s.lua, 4));
assert!(s.take_pet_set_actions().is_empty());
}
@@ -559,7 +616,7 @@ mod tests {
"the action bar's grid stays down — nothing can land there"
);
// PlaceAction refuses it outright and leaves it held.
assert!(!place_action(&mut s.model_mut(), 1));
assert!(!place_action(&s.lua, 1));
assert!(matches!(
s.cursor_payload(),
Some(crate::script::CursorPayload::PetAction(_))