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:
co-authored by
Claude Opus 5.5
parent
24c7cab983
commit
aba120cdcb
@@ -1692,9 +1692,12 @@ mod bind_confirm_tests {
|
||||
},
|
||||
);
|
||||
}
|
||||
// Re-run the place now that the item is out of reach.
|
||||
// Re-run the place now that the item is out of reach. The fixture's own place asked
|
||||
// first, unjudged; the re-run's pickup retires that question with its `CURSOR_UPDATE`,
|
||||
// which fires inside the verb.
|
||||
{
|
||||
let s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
let mut s = app.world_mut().non_send_resource_mut::<UiScript>();
|
||||
s.tick(0.01);
|
||||
s.run("PickupContainerItem(0, 1)").unwrap();
|
||||
s.run("PickupInventoryItem(16)").unwrap();
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ fn fire_sites() -> Vec<(String, Vec<char>, String)> {
|
||||
for call in [
|
||||
concat!("fire_event", "("),
|
||||
concat!("fire_event_into", "("),
|
||||
concat!("model.raise", "("),
|
||||
concat!("pending_events.push", "(("),
|
||||
] {
|
||||
let mut from = 0;
|
||||
|
||||
@@ -2103,10 +2103,12 @@ mod tests {
|
||||
// rustfmt splits a long chain at its dots, so the three shapes are matched
|
||||
// with the whitespace before each `.` removed.
|
||||
let text = glue_chains(&text);
|
||||
// The engine's deferred lane, `pending_events.push((name, args))`, fires too.
|
||||
const CALLS: [&str; 3] = [
|
||||
// A verb's `model.raise(name, args)` and the host queue,
|
||||
// `pending_events.push((name, args))`, fire too.
|
||||
const CALLS: [&str; 4] = [
|
||||
concat!("fire_event", "("),
|
||||
concat!("fire_event_into", "("),
|
||||
concat!("model.raise", "("),
|
||||
concat!("pending_events.push", "(("),
|
||||
];
|
||||
let mut fires_indirectly = false;
|
||||
|
||||
@@ -241,12 +241,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"UseAction",
|
||||
lua.create_function(|lua, (action, rest): (u32, MultiValue)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
// Both flags go through `GetBoolOrDefault` (`0x4e717e`, `0x4e716f`), default 0.
|
||||
let check_cursor = bool_or_default(rest.front(), false);
|
||||
let on_self = bool_or_default(rest.get(1), false);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if check_cursor && model.cursor.is_some() {
|
||||
super::cursor::place_action(&mut model, action);
|
||||
drop(model);
|
||||
super::cursor::place_action(lua, action);
|
||||
} else {
|
||||
model
|
||||
.script_calls
|
||||
|
||||
@@ -743,12 +743,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// ClickAuctionSellItemButton(): attach the cursor's item, or pick the attached one back up;
|
||||
// every cursor write queues both CURSOR_UPDATE and ITEM_LOCK_CHANGED.
|
||||
// every cursor write raises both CURSOR_UPDATE and ITEM_LOCK_CHANGED.
|
||||
g.set(
|
||||
"ClickAuctionSellItemButton",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
click_auction_sell_item(&mut model);
|
||||
super::tick::verb(lua, click_auction_sell_item);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -790,17 +789,15 @@ const AUCTION_INV_TYPES: [(u32, &str); 14] = [
|
||||
fn click_auction_sell_item(model: &mut Model) {
|
||||
match model.cursor.take() {
|
||||
Some(CursorPayload::Item(item)) => {
|
||||
let (bag, slot) = (item.bag, item.slot);
|
||||
model.auction_sell_item = Some(item);
|
||||
cursor::queue_cursor_update(model);
|
||||
cursor::queue_lock_changed(model, bag, slot);
|
||||
cursor::raise_cursor_update(model);
|
||||
cursor::raise_lock_changed(model);
|
||||
}
|
||||
None => {
|
||||
if let Some(item) = model.auction_sell_item.take() {
|
||||
let (bag, slot) = (item.bag, item.slot);
|
||||
model.cursor = Some(CursorPayload::Item(item));
|
||||
cursor::queue_cursor_update(model);
|
||||
cursor::queue_lock_changed(model, bag, slot);
|
||||
cursor::raise_cursor_update(model);
|
||||
cursor::raise_lock_changed(model);
|
||||
}
|
||||
}
|
||||
Some(other) => {
|
||||
|
||||
@@ -204,8 +204,8 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// Never raises; a stored filter fires `UPDATE_BATTLEFIELD_SCORE` (`0x4abc90`), here on the
|
||||
// next dispatch.
|
||||
// Never raises; a stored filter rebuilds the board and fires `UPDATE_BATTLEFIELD_SCORE` in
|
||||
// place (`0x4abc90` → `0x4aa200`, the signal at `0x4aa308`).
|
||||
g.set(
|
||||
"SetBattlefieldScoreFaction",
|
||||
lua.create_function(|lua, faction: Option<Value>| {
|
||||
@@ -213,12 +213,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
if !(-1..=1).contains(&f) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.battlefield_board.filter = f;
|
||||
model.battlefield_board.rebuild();
|
||||
model
|
||||
.pending_events
|
||||
.push(("UPDATE_BATTLEFIELD_SCORE".to_string(), Vec::new()));
|
||||
super::tick::verb(lua, |model| {
|
||||
model.battlefield_board.filter = f;
|
||||
model.battlefield_board.rebuild();
|
||||
model.raise("UPDATE_BATTLEFIELD_SCORE", Vec::new());
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -209,20 +209,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"SetGuildRecruitmentMode: invalid mode",
|
||||
));
|
||||
}
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let mode = n as u8;
|
||||
if model.guild_recruitment_mode != mode {
|
||||
model.guild_recruitment_mode = mode;
|
||||
model.guild_recruitment_changed = true;
|
||||
}
|
||||
// `0x49ea79 jne` / `0x49ea7b jmp 0x49ea90`: the cascade, on the new value alone.
|
||||
if mode == 1 {
|
||||
model.guild_recruitment_cascade = true;
|
||||
}
|
||||
// `0x4a00a4`/`0x4a00a9`: `UPDATE_CHAT_WINDOWS`, on every successful call.
|
||||
model
|
||||
.pending_events
|
||||
.push(("UPDATE_CHAT_WINDOWS".to_string(), Vec::new()));
|
||||
super::tick::verb(lua, |model| {
|
||||
let mode = n as u8;
|
||||
if model.guild_recruitment_mode != mode {
|
||||
model.guild_recruitment_mode = mode;
|
||||
model.guild_recruitment_changed = true;
|
||||
}
|
||||
// `0x49ea79 jne` / `0x49ea7b jmp 0x49ea90`: the cascade, on the new value alone.
|
||||
if mode == 1 {
|
||||
model.guild_recruitment_cascade = true;
|
||||
}
|
||||
// `0x4a00a4`/`0x4a00a9`: `UPDATE_CHAT_WINDOWS`, on every successful call, in place.
|
||||
model.raise("UPDATE_CHAT_WINDOWS", Vec::new());
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -200,7 +200,7 @@ impl super::UiScript {
|
||||
model.pending_wrap = Some(PendingWrap { bag, slot });
|
||||
model.ui_cursor = Some(UiCursorMode::Cast);
|
||||
model.ui_cursor_dirty = true;
|
||||
cursor::queue_lock_changed(&mut model, bag, slot);
|
||||
cursor::raise_lock_changed(&mut model);
|
||||
}
|
||||
|
||||
/// The armed gift wrap, if any; the app reads it to hold the cursor's base at Cast.
|
||||
@@ -293,7 +293,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
|
||||
model.container_wraps.push((wrap.bag, wrap.slot, bag, slot));
|
||||
// The paper unlocks and the cursor resets right after the send (`0x5eded0` at
|
||||
// `0x5ee0aa`), not on the answer; the target never locks.
|
||||
cursor::queue_lock_changed(model, wrap.bag, wrap.slot);
|
||||
cursor::raise_lock_changed(model);
|
||||
model.ui_cursor = None;
|
||||
model.ui_cursor_dirty = true;
|
||||
return;
|
||||
@@ -339,13 +339,14 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
|
||||
});
|
||||
if let Some(item) = picked {
|
||||
model.cursor = Some(CursorPayload::Item(item));
|
||||
cursor::queue_cursor_update(model);
|
||||
cursor::queue_lock_changed(model, bag, slot);
|
||||
cursor::raise_cursor_update(model);
|
||||
cursor::raise_lock_changed(model);
|
||||
}
|
||||
}
|
||||
// The cancel is `ClearCursor(1,1)` (`0x4f9e0e`): the unlock, then `CURSOR_UPDATE`.
|
||||
Some(CursorPayload::Item(held)) if held.bag == bag && held.slot == slot => {
|
||||
cursor::queue_cursor_update(model);
|
||||
cursor::queue_lock_changed(model, held.bag, held.slot);
|
||||
cursor::raise_lock_changed(model);
|
||||
cursor::raise_cursor_update(model);
|
||||
}
|
||||
Some(CursorPayload::Item(held)) => {
|
||||
// Cloned so the moves below can borrow `model`; an unresolved slot reads as empty.
|
||||
@@ -360,12 +361,24 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
|
||||
(Some(_), Some(d)) if d.item_id != held.item_id => {
|
||||
model.cursor = Some(CursorPayload::Item(held));
|
||||
}
|
||||
// Anything else queues the move and clears: a split, a merge the server tops up,
|
||||
// or a swap that lands the displaced item in the source slot.
|
||||
(count, _) => {
|
||||
queue_move(model, &held, bag, slot, count);
|
||||
cursor::queue_cursor_update(model);
|
||||
cursor::queue_lock_changed(model, held.bag, held.slot);
|
||||
// A split places and clears (`0x5e1210`, then `ClearCursor(0,1)` at `0x4f9e4f`):
|
||||
// the grid hide and `CURSOR_UPDATE`, and no lock event.
|
||||
(Some(n), _) => {
|
||||
queue_move(model, &held, bag, slot, Some(n));
|
||||
cursor::raise_cursor_update(model);
|
||||
}
|
||||
// A whole stack locks the destination's item first (`0x4f9e85`; an empty slot's
|
||||
// zero guid signals nothing, `0x4953ed`), then the swap send clears
|
||||
// (`ClearCursor(0,1)` at `0x5e0fe7`: the grid hide, `CURSOR_UPDATE`) and the
|
||||
// binding clears again (`0x4f9eb8`), a second `CURSOR_UPDATE` on the empty cursor.
|
||||
// The source keeps its lock in both: the clears pass no unlock.
|
||||
(None, dest) => {
|
||||
queue_move(model, &held, bag, slot, None);
|
||||
if dest.is_some() {
|
||||
cursor::raise_lock_changed(model);
|
||||
}
|
||||
cursor::raise_cursor_update(model);
|
||||
model.raise("CURSOR_UPDATE", Vec::new());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -383,7 +396,7 @@ fn pickup_container_item(model: &mut super::Model, bag: i64, slot: u32) {
|
||||
if let Some(entry) = entry {
|
||||
model.merchant_slot_buys.push((bag, slot, entry));
|
||||
}
|
||||
cursor::queue_cursor_update(model);
|
||||
cursor::raise_cursor_update(model);
|
||||
}
|
||||
Some(
|
||||
other @ (CursorPayload::Spell(_)
|
||||
@@ -588,10 +601,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.globals().set(
|
||||
"UseContainerItem",
|
||||
lua.create_function(|lua, (bag, slot, _rest): (i64, u32, mlua::MultiValue)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
// Every right-click clears the cursor before it reads the slot (`0x4fa198`): a held
|
||||
// payload goes back, never placed, and an armed gift wrap is cancelled.
|
||||
cursor::clear_cursor(&mut model);
|
||||
// payload goes back, never placed, and an armed gift wrap is cancelled. The clear's
|
||||
// events run first, as the reference's do before it reads the slot.
|
||||
super::tick::verb(lua, cursor::clear_cursor);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if model.repair_mode {
|
||||
// Then the item in the slot is repaired (`0x4fa1a6`, `0x4fa1da`).
|
||||
let occupied = model
|
||||
@@ -616,8 +630,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
lua.globals().set(
|
||||
"PickupContainerItem",
|
||||
lua.create_function(|lua, (bag, slot): (i64, u32)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
pickup_container_item(&mut model, bag, slot);
|
||||
super::tick::verb(lua, |model| pickup_container_item(model, bag, slot));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The cursor payload: the client's payload-mode global `[0xb4d900]` as a typed enum, and the drag
|
||||
//! gestures ([`drag`]) that change it. Bags, the paper doll ([`doll`]), the action bars ([`bar`])
|
||||
//! and the spellbook all transition it through [`queue_cursor_update`] and [`queue_lock_changed`],
|
||||
//! and the spellbook all transition it through [`raise_cursor_update`] and [`raise_lock_changed`],
|
||||
//! so sounds, `CURSOR_UPDATE`, grid events and lock display fire from one place.
|
||||
|
||||
use mlua::Lua;
|
||||
@@ -161,13 +161,13 @@ pub struct CursorMacro {
|
||||
// The transition events
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Queue `CURSOR_UPDATE`, once per payload transition, plus a grid event when what the bars can
|
||||
/// Raise `CURSOR_UPDATE`, once per payload transition, after a grid event when what the bars can
|
||||
/// take changes. Call it after `model.cursor` holds the new payload; an action hop keeps the grid
|
||||
/// up rather than hiding and reshowing it.
|
||||
pub(crate) fn queue_cursor_update(model: &mut Model) {
|
||||
model
|
||||
.pending_events
|
||||
.push(("CURSOR_UPDATE".to_string(), Vec::new()));
|
||||
/// up rather than hiding and reshowing it. The grid event comes first, hides before shows, as
|
||||
/// `ClearCursor`'s tail fires its grid hide (`0x4952ac`/`0x4952b3`) before its `CURSOR_UPDATE`
|
||||
/// (`0x4952cc`) and `SetCursorItem` its `ACTIONBAR_SHOWGRID` (`0x494c3b`) before its own
|
||||
/// (`0x494c54`).
|
||||
pub(crate) fn raise_cursor_update(model: &mut Model) {
|
||||
// The action bar's grid shows for any payload but a pet action, which `PlaceAction` refuses,
|
||||
// or a vendor row, whose grab `0x4950f0` fires `ACTIONBAR_SHOWGRID` for mode 7 only
|
||||
// (`0x495106`, `0x49513a`). The reference fires it (`0x4e58c0`) only from the item, spell and
|
||||
@@ -177,43 +177,46 @@ pub(crate) fn queue_cursor_update(model: &mut Model) {
|
||||
let bar_held = model.cursor.is_some()
|
||||
&& !pet_held
|
||||
&& !matches!(model.cursor, Some(CursorPayload::Merchant(_)));
|
||||
if bar_held != model.cursor_grid_shown {
|
||||
model.cursor_grid_shown = bar_held;
|
||||
let event = if bar_held {
|
||||
"ACTIONBAR_SHOWGRID"
|
||||
} else {
|
||||
"ACTIONBAR_HIDEGRID"
|
||||
};
|
||||
model.pending_events.push((event.to_string(), Vec::new()));
|
||||
}
|
||||
if pet_held != model.pet_grid_shown {
|
||||
model.pet_grid_shown = pet_held;
|
||||
let event = if pet_held {
|
||||
"PET_BAR_SHOWGRID"
|
||||
} else {
|
||||
"PET_BAR_HIDEGRID"
|
||||
};
|
||||
model.pending_events.push((event.to_string(), Vec::new()));
|
||||
let bar_flip = (bar_held != model.cursor_grid_shown).then_some(if bar_held {
|
||||
"ACTIONBAR_SHOWGRID"
|
||||
} else {
|
||||
"ACTIONBAR_HIDEGRID"
|
||||
});
|
||||
let pet_flip = (pet_held != model.pet_grid_shown).then_some(if pet_held {
|
||||
"PET_BAR_SHOWGRID"
|
||||
} else {
|
||||
"PET_BAR_HIDEGRID"
|
||||
});
|
||||
model.cursor_grid_shown = bar_held;
|
||||
model.pet_grid_shown = pet_held;
|
||||
// A hop between the bars hides the old grid before it shows the new one.
|
||||
let (first, second) = if pet_held {
|
||||
(bar_flip, pet_flip)
|
||||
} else {
|
||||
(pet_flip, bar_flip)
|
||||
};
|
||||
for event in [first, second].into_iter().flatten() {
|
||||
model.raise(event, Vec::new());
|
||||
}
|
||||
model.raise("CURSOR_UPDATE", Vec::new());
|
||||
}
|
||||
|
||||
/// Queue `ITEM_LOCK_CHANGED(bag, slot)` for a source slot that a pickup locks or a put-down frees.
|
||||
pub(crate) fn queue_lock_changed(model: &mut Model, bag: i64, slot: u32) {
|
||||
model.pending_events.push((
|
||||
"ITEM_LOCK_CHANGED".to_string(),
|
||||
vec![ScriptValue::Int(bag), ScriptValue::Int(i64::from(slot))],
|
||||
));
|
||||
/// Raise `ITEM_LOCK_CHANGED` for a slot that a pickup locks or a put-down frees. It carries no
|
||||
/// arguments: the lock (`0x495415`) and the unlock (`0x495455`) are plain `SignalEvent`s, and the
|
||||
/// stock buttons re-read every slot's lock.
|
||||
pub(crate) fn raise_lock_changed(model: &mut Model) {
|
||||
model.raise("ITEM_LOCK_CHANGED", Vec::new());
|
||||
}
|
||||
|
||||
/// Queue `DELETE_ITEM_CONFIRM(name, quality)` for an item dropped on the world. The payload stays
|
||||
/// Raise `DELETE_ITEM_CONFIRM(name, quality)` for an item dropped on the world. The payload stays
|
||||
/// held: the popup's `DeleteCursorItem` or `ClearCursor` clears it.
|
||||
fn queue_delete_item_confirm(model: &mut Model, item: &CursorItem) {
|
||||
fn raise_delete_item_confirm(model: &mut Model, item: &CursorItem) {
|
||||
let name = item_link_name(item.link.as_deref());
|
||||
let quality = item.quality.unwrap_or(0);
|
||||
model.pending_events.push((
|
||||
"DELETE_ITEM_CONFIRM".to_string(),
|
||||
model.raise(
|
||||
"DELETE_ITEM_CONFIRM",
|
||||
vec![ScriptValue::Str(name), ScriptValue::Int(i64::from(quality))],
|
||||
));
|
||||
);
|
||||
}
|
||||
|
||||
/// An item link's name, from its `|h[Name]|h` segment; empty for no link or another shape.
|
||||
@@ -229,18 +232,18 @@ pub(super) fn item_link_name(link: Option<&str>) -> String {
|
||||
pub(crate) fn clear_cursor(model: &mut Model) {
|
||||
// Any clear first cancels an armed gift wrap, whatever its parameters (`0x495190` opens with
|
||||
// `0x5edf10`): the wrapping paper unlocks and the cursor mode returns to the base.
|
||||
if let Some(wrap) = model.pending_wrap.take() {
|
||||
queue_lock_changed(model, wrap.bag, wrap.slot);
|
||||
if model.pending_wrap.take().is_some() {
|
||||
raise_lock_changed(model);
|
||||
model.ui_cursor = None;
|
||||
model.ui_cursor_dirty = true;
|
||||
}
|
||||
// Coins go back to nothing, as the purse was never debited; a vendor row clears with no
|
||||
// packet (`0x49525f`).
|
||||
let taken = model.cursor.take();
|
||||
queue_cursor_update(model);
|
||||
if let Some(CursorPayload::Item(item)) = taken {
|
||||
queue_lock_changed(model, item.bag, item.slot);
|
||||
// packet (`0x49525f`). An item's unlock signals in the mode-1 arm (`0x495455`), before the
|
||||
// tail's grid hide and `CURSOR_UPDATE`.
|
||||
if let Some(CursorPayload::Item(_)) = model.cursor.take() {
|
||||
raise_lock_changed(model);
|
||||
}
|
||||
raise_cursor_update(model);
|
||||
}
|
||||
|
||||
/// The sell fork's clear, `SetCursorItem(0, …)` (`0x494b60`), shared by `PickupMerchantItem`
|
||||
@@ -255,7 +258,7 @@ pub(crate) fn take_cursor_item_for_sale(model: &mut Model) -> Option<CursorItem>
|
||||
return None;
|
||||
}
|
||||
};
|
||||
queue_cursor_update(model);
|
||||
raise_cursor_update(model);
|
||||
Some(item)
|
||||
}
|
||||
|
||||
@@ -286,7 +289,7 @@ pub(crate) fn world_drop_click(model: &mut Model) -> bool {
|
||||
match (&model.cursor, model.world_pick) {
|
||||
(Some(CursorPayload::Item(item)), WorldPick::Terrain | WorldPick::Nothing) => {
|
||||
let item = item.clone();
|
||||
queue_delete_item_confirm(model, &item);
|
||||
raise_delete_item_confirm(model, &item);
|
||||
true
|
||||
}
|
||||
(
|
||||
@@ -332,8 +335,8 @@ pub(crate) fn split_container_item(model: &mut Model, bag: i64, slot: u32, count
|
||||
equip_slots: s.equip_slots.clone(),
|
||||
};
|
||||
model.cursor = Some(CursorPayload::Item(item));
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, bag, slot);
|
||||
raise_cursor_update(model);
|
||||
raise_lock_changed(model);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -348,8 +351,8 @@ pub(crate) fn delete_cursor_item(model: &mut Model) {
|
||||
model
|
||||
.container_destroys
|
||||
.push((item.bag, item.slot, item.count.unwrap_or(0)));
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, item.bag, item.slot);
|
||||
raise_cursor_update(model);
|
||||
raise_lock_changed(model);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
||||
@@ -474,8 +477,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"ClearCursor",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
clear_cursor(&mut model);
|
||||
super::tick::verb(lua, clear_cursor);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -483,16 +485,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"SplitContainerItem",
|
||||
lua.create_function(|lua, (bag, slot, count): (i64, u32, i64)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
split_container_item(&mut model, bag, slot, count);
|
||||
super::tick::verb(lua, |model| split_container_item(model, bag, slot, count));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"DeleteCursorItem",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
delete_cursor_item(&mut model);
|
||||
super::tick::verb(lua, delete_cursor_item);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -509,6 +509,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{split_container_item, CursorAction, CursorItem, CursorPayload, CursorSpell};
|
||||
use crate::script::tick::heard;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// A backpack holding a five-stack in slot 1.
|
||||
@@ -550,10 +551,10 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_container(0, Some(one_item_backpack()));
|
||||
|
||||
// Drain the pickup's events so only the sell clear's remain.
|
||||
// The pickup's events fire inside it, so the queue holds only the host's sell clear.
|
||||
s.eval::<()>("PickupContainerItem(0, 1)").unwrap();
|
||||
assert!(s.cursor_item().is_some(), "the pickup did not take");
|
||||
s.model_mut().pending_events.clear();
|
||||
assert!(s.model_ref().pending_events.is_empty());
|
||||
|
||||
let sold = s.take_cursor_item_for_sale().expect("mode 1 is a sale");
|
||||
assert_eq!((sold.bag, sold.slot), (0, 1));
|
||||
@@ -664,18 +665,109 @@ mod tests {
|
||||
assert!(s.cursor_payload().is_none());
|
||||
}
|
||||
|
||||
/// A pickup's events have run by the verb's next Lua line, each handler reading the held item:
|
||||
/// the grid before `CURSOR_UPDATE` (`0x494c3b`, `0x494c54`), then the lock (`0x495415`). The
|
||||
/// same-slot click that puts it back is `ClearCursor(1,1)` (`0x4f9e0e`): the unlock
|
||||
/// (`0x495455`), the grid hide (`0x4952b3`), then `CURSOR_UPDATE` (`0x4952cc`).
|
||||
#[test]
|
||||
fn a_pickup_fires_its_events_before_it_returns() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_container(0, Some(one_item_backpack()));
|
||||
heard::listen(
|
||||
&s,
|
||||
&[
|
||||
"ACTIONBAR_SHOWGRID",
|
||||
"ACTIONBAR_HIDEGRID",
|
||||
"CURSOR_UPDATE",
|
||||
"ITEM_LOCK_CHANGED",
|
||||
],
|
||||
Some("CursorHasItem()"),
|
||||
);
|
||||
let seen: Vec<String> = s
|
||||
.eval("PickupContainerItem(0, 1) local h = HEARD HEARD = {} return h")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
"ACTIONBAR_SHOWGRID 1",
|
||||
"CURSOR_UPDATE 1",
|
||||
"ITEM_LOCK_CHANGED 1"
|
||||
]
|
||||
);
|
||||
let seen: Vec<String> = s
|
||||
.eval("PickupContainerItem(0, 1) local h = HEARD HEARD = {} return h")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
"ITEM_LOCK_CHANGED nil",
|
||||
"ACTIONBAR_HIDEGRID nil",
|
||||
"CURSOR_UPDATE nil"
|
||||
]
|
||||
);
|
||||
assert!(s.model_ref().pending_events.is_empty());
|
||||
}
|
||||
|
||||
/// The bag place arm (`0x4f9e1c`-`0x4f9eb8`): a whole stack locks an occupied destination
|
||||
/// first (`0x4f9e85`), then the swap's clear (`0x5e0fe7`) and the binding's own (`0x4f9eb8`)
|
||||
/// each fire `CURSOR_UPDATE`; an empty destination signals no lock (`0x4953ed`); a split's
|
||||
/// one clear (`0x4f9e4f`) signals no lock either.
|
||||
#[test]
|
||||
fn the_bag_place_arm_locks_the_destination_before_its_two_clears() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
let mut bag = one_item_backpack();
|
||||
let mut other = bag.slots[&1].clone();
|
||||
other.item_id = 118;
|
||||
bag.slots.insert(2, other);
|
||||
s.set_container(0, Some(bag));
|
||||
heard::listen(
|
||||
&s,
|
||||
&["ITEM_LOCK_CHANGED", "ACTIONBAR_HIDEGRID", "CURSOR_UPDATE"],
|
||||
None,
|
||||
);
|
||||
let place = |s: &UiScript, from: u32, to: u32| {
|
||||
s.run(&format!("PickupContainerItem(0, {from})")).unwrap();
|
||||
heard::take(s);
|
||||
s.run(&format!("PickupContainerItem(0, {to})")).unwrap();
|
||||
heard::take(s)
|
||||
};
|
||||
assert_eq!(
|
||||
place(&s, 1, 2),
|
||||
[
|
||||
"ITEM_LOCK_CHANGED",
|
||||
"ACTIONBAR_HIDEGRID",
|
||||
"CURSOR_UPDATE",
|
||||
"CURSOR_UPDATE"
|
||||
],
|
||||
"a swap onto an occupied slot"
|
||||
);
|
||||
assert_eq!(
|
||||
place(&s, 1, 3),
|
||||
["ACTIONBAR_HIDEGRID", "CURSOR_UPDATE", "CURSOR_UPDATE"],
|
||||
"a move onto an empty slot"
|
||||
);
|
||||
s.run("SplitContainerItem(0, 1, 2)").unwrap();
|
||||
heard::take(&s);
|
||||
s.run("PickupContainerItem(0, 4)").unwrap();
|
||||
assert_eq!(
|
||||
heard::take(&s),
|
||||
["ACTIONBAR_HIDEGRID", "CURSOR_UPDATE"],
|
||||
"a split"
|
||||
);
|
||||
}
|
||||
|
||||
/// The clear's shared tail (`0x49529b`) signals on every call: with nothing held, one
|
||||
/// `CURSOR_UPDATE` and no grid event.
|
||||
#[test]
|
||||
fn clear_cursor_with_nothing_held_still_signals() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let before = s.model_ref().pending_events.len();
|
||||
heard::listen(
|
||||
&s,
|
||||
&["CURSOR_UPDATE", "ACTIONBAR_SHOWGRID", "ACTIONBAR_HIDEGRID"],
|
||||
None,
|
||||
);
|
||||
s.run("ClearCursor()").unwrap();
|
||||
let fired: Vec<String> = s.model_ref().pending_events[before..]
|
||||
.iter()
|
||||
.map(|(e, _)| e.clone())
|
||||
.collect();
|
||||
assert_eq!(fired, vec!["CURSOR_UPDATE"]);
|
||||
assert_eq!(heard::take(&s), ["CURSOR_UPDATE"]);
|
||||
}
|
||||
|
||||
/// Clearing a spell on terrain is the deviation documented on `world_drop_click`.
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use crate::script::container::ContainerMove;
|
||||
use crate::script::tick::verb;
|
||||
use crate::script::Model;
|
||||
|
||||
use super::{clear_cursor, queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload};
|
||||
use super::{clear_cursor, raise_cursor_update, raise_lock_changed, CursorItem, CursorPayload};
|
||||
use super::{doll::pickup_inventory_item, EQUIPMENT_BAG};
|
||||
|
||||
/// The ids `PutItemInBag` and `PickupBagFromSlot` take: what their argument reader (`0x4c8520`)
|
||||
@@ -80,9 +81,10 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
|
||||
|
||||
// The held bag is this slot's own: a cancel that sends nothing and answers 1.
|
||||
if held.bag == EQUIPMENT_BAG && held.slot == live {
|
||||
// `ClearCursor(1,1)`: the unlock, then `CURSOR_UPDATE`.
|
||||
model.cursor = None;
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, held.bag, held.slot);
|
||||
raise_lock_changed(model);
|
||||
raise_cursor_update(model);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -98,8 +100,8 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
|
||||
dst_slot: live,
|
||||
count: None,
|
||||
});
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, held.bag, held.slot);
|
||||
raise_cursor_update(model);
|
||||
raise_lock_changed(model);
|
||||
return true;
|
||||
}
|
||||
model.cursor = Some(CursorPayload::Item(held));
|
||||
@@ -118,8 +120,8 @@ pub(super) fn put_item_in_bag(model: &mut Model, live: u32) -> bool {
|
||||
dst_bag,
|
||||
count: held.count,
|
||||
});
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, held.bag, held.slot);
|
||||
raise_cursor_update(model);
|
||||
raise_lock_changed(model);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -137,23 +139,31 @@ pub(super) fn put_item_in_backpack(model: &mut Model) -> bool {
|
||||
dst_bag: 0,
|
||||
count: held.count,
|
||||
});
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, held.bag, held.slot);
|
||||
raise_cursor_update(model);
|
||||
raise_lock_changed(model);
|
||||
true
|
||||
}
|
||||
|
||||
/// `PickupBagFromSlot(inventorySlot)` (`0x4c7b00`): clears a held payload before it looks at the
|
||||
/// slot, where the reference clears only a held item (`0x4c7b70`, `0x4c7b79`); with nothing held it
|
||||
/// skips the clear and its `CURSOR_UPDATE`, as the reference does. Takes only a container
|
||||
/// skips the clear and its `CURSOR_UPDATE`, as the reference does. The clear's events run before
|
||||
/// the slot is read, so its `CURSOR_UPDATE` handler sees the empty cursor. Takes only a container
|
||||
/// (`0x4c7bb3`), and never places or swaps. The client has no empty-bag rule: the server refuses
|
||||
/// with `EQUIP_ERR_CAN_ONLY_DO_WITH_EMPTY_BAGS`.
|
||||
pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
|
||||
pub(super) fn pickup_bag_from_slot(lua: &Lua, live: u32) {
|
||||
if !accepts(live) {
|
||||
return;
|
||||
}
|
||||
if model.cursor.is_some() {
|
||||
clear_cursor(model);
|
||||
}
|
||||
verb(lua, |model| {
|
||||
if model.cursor.is_some() {
|
||||
clear_cursor(model);
|
||||
}
|
||||
});
|
||||
verb(lua, |model| grab_bag(model, live));
|
||||
}
|
||||
|
||||
/// `PickupBagFromSlot`'s grab of an unlocked container in `live`.
|
||||
fn grab_bag(model: &mut Model, live: u32) {
|
||||
let picked = usize::try_from(live)
|
||||
.ok()
|
||||
.and_then(|slot| model.inv_slot("player", slot))
|
||||
@@ -172,8 +182,8 @@ pub(super) fn pickup_bag_from_slot(model: &mut Model, live: u32) {
|
||||
.filter(is_container);
|
||||
if let Some(item) = picked {
|
||||
model.cursor = Some(CursorPayload::Item(item));
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, EQUIPMENT_BAG, live);
|
||||
raise_cursor_update(model);
|
||||
raise_lock_changed(model);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,28 +203,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"PutItemInBag",
|
||||
lua.create_function(|lua, live: u32| {
|
||||
let yes = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
put_item_in_bag(&mut model, live)
|
||||
};
|
||||
consumed(yes)
|
||||
consumed(verb(lua, |model| put_item_in_bag(model, live)))
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"PutItemInBackpack",
|
||||
lua.create_function(|lua, ()| {
|
||||
let yes = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
put_item_in_backpack(&mut model)
|
||||
};
|
||||
consumed(yes)
|
||||
})?,
|
||||
lua.create_function(|lua, ()| consumed(verb(lua, put_item_in_backpack)))?,
|
||||
)?;
|
||||
g.set(
|
||||
"PickupBagFromSlot",
|
||||
lua.create_function(|lua, live: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
pickup_bag_from_slot(&mut model, live);
|
||||
pickup_bag_from_slot(lua, live);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -225,6 +224,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::script::cursor::EQUIPMENT_BAG;
|
||||
use crate::script::tick::heard;
|
||||
use crate::script::{
|
||||
BagAutoStore, BankBagSlots, ContainerMove, ContainerSlot, ContainerState, InvSlotView,
|
||||
InventorySlots, UiScript,
|
||||
@@ -235,10 +235,10 @@ mod tests {
|
||||
#[test]
|
||||
fn an_empty_cursor_skips_the_clear_and_its_signal() {
|
||||
let s = UiScript::new().unwrap();
|
||||
let before = s.model_ref().pending_events.len();
|
||||
heard::listen(&s, &["CURSOR_UPDATE"], None);
|
||||
s.run("PickupBagFromSlot(20)").unwrap();
|
||||
assert!(s.cursor_payload().is_none());
|
||||
assert_eq!(s.model_ref().pending_events.len(), before);
|
||||
assert!(heard::take(&s).is_empty());
|
||||
}
|
||||
|
||||
/// An item that is a bag: `equip_slots` is `find_equip_slot(INVTYPE_BAG)`.
|
||||
@@ -454,6 +454,32 @@ mod tests {
|
||||
assert!(s.take_container_moves().is_empty(), "no place path exists");
|
||||
}
|
||||
|
||||
/// The clear's events (the unlock `0x495455`, then `0x4952cc`) run before the grab reads the
|
||||
/// slot (`0x4c7be2`), so its handlers see the bag still in place and only the grab's
|
||||
/// (`0x494c54`, then `0x495415`) see it held and locked.
|
||||
#[test]
|
||||
fn pickup_bag_from_slot_fires_the_clear_before_the_grab() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_container(0, Some(backpack()));
|
||||
s.set_bank_bag_slots(one_bank_bag());
|
||||
s.run("PickupContainerItem(0, 2)").unwrap();
|
||||
heard::listen(
|
||||
&s,
|
||||
&["CURSOR_UPDATE", "ITEM_LOCK_CHANGED"],
|
||||
Some("IsInventoryItemLocked(64)"),
|
||||
);
|
||||
s.run("PickupBagFromSlot(64)").unwrap();
|
||||
assert_eq!(
|
||||
heard::take(&s),
|
||||
[
|
||||
"ITEM_LOCK_CHANGED nil",
|
||||
"CURSOR_UPDATE nil",
|
||||
"CURSOR_UPDATE 1",
|
||||
"ITEM_LOCK_CHANGED 1"
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pickup_bag_from_slot_only_takes_containers_and_never_a_locked_one() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
use mlua::Lua;
|
||||
|
||||
use crate::script::action::{ACTION_KIND_ITEM, ACTION_KIND_MACRO, ACTION_KIND_SPELL};
|
||||
use crate::script::tick::verb;
|
||||
use crate::script::{ActionSlot, Model};
|
||||
|
||||
use super::{queue_cursor_update, CursorAction, CursorPayload};
|
||||
use super::{raise_cursor_update, CursorAction, CursorPayload};
|
||||
|
||||
/// The passive-spell refusal's error, errorId `0x9e`: entry `0xb4c0f0` of the table at `0xb4b498`
|
||||
/// (stride `0x14`) names this key (`0x84133c`).
|
||||
@@ -20,47 +21,84 @@ fn pack(kind: u8, action: u32) -> u32 {
|
||||
}
|
||||
|
||||
/// `PickupAction(id)`, the slot's shift-click and drag start (`ActionBarFrame.xml:12-38`). With a
|
||||
/// payload held it places instead; a pickup also clears the slot on the wire at once. Returns
|
||||
/// whether to repaint.
|
||||
pub(super) fn pickup_action(model: &mut Model, id: u32) -> bool {
|
||||
if model.cursor.is_some() {
|
||||
return place_action(model, id);
|
||||
/// payload held it places instead; a pickup also clears the slot on the wire at once. The cursor
|
||||
/// goes first and the slot after, as `0x4e6130` runs its setter before the slot clear `0x4e5db0`,
|
||||
/// so the pickup's `CURSOR_UPDATE` handler still reads the action in its slot. Returns whether to
|
||||
/// repaint.
|
||||
pub(super) fn pickup_action(lua: &Lua, id: u32) -> bool {
|
||||
let held = lua
|
||||
.app_data_ref::<Model>()
|
||||
.expect("model app_data")
|
||||
.cursor
|
||||
.is_some();
|
||||
if held {
|
||||
return place_action(lua, id);
|
||||
}
|
||||
let Some(slot) = model.actions.get(&id) else {
|
||||
return false;
|
||||
};
|
||||
let payload = CursorAction {
|
||||
src_slot: id,
|
||||
kind: slot.kind,
|
||||
action: slot.action,
|
||||
texture: slot.texture.clone(),
|
||||
};
|
||||
model.actions.remove(&id);
|
||||
model.cursor = Some(CursorPayload::Action(payload));
|
||||
model.action_sets.push((id, 0));
|
||||
queue_cursor_update(model);
|
||||
true
|
||||
let picked = verb(lua, |model| {
|
||||
let Some(slot) = model.actions.get(&id) else {
|
||||
return false;
|
||||
};
|
||||
let payload = CursorAction {
|
||||
src_slot: id,
|
||||
kind: slot.kind,
|
||||
action: slot.action,
|
||||
texture: slot.texture.clone(),
|
||||
};
|
||||
model.cursor = Some(CursorPayload::Action(payload));
|
||||
raise_cursor_update(model);
|
||||
true
|
||||
});
|
||||
if picked {
|
||||
verb(lua, |model| {
|
||||
model.actions.remove(&id);
|
||||
model.action_sets.push((id, 0));
|
||||
});
|
||||
}
|
||||
picked
|
||||
}
|
||||
|
||||
/// `PlaceAction(id)`, the slot's `OnReceiveDrag` and `UseAction(id, 1)`'s place fork. An occupied
|
||||
/// slot's action hops onto the cursor with `id` as its source; an item stays in its bag, since a
|
||||
/// bar item is a reference, not a move. Returns whether to repaint.
|
||||
pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
|
||||
/// bar item is a reference, not a move. The cursor goes first and the slot after, as `0x4e62e0`
|
||||
/// puts the displaced action on the cursor (or clears it) before it writes the slot, so the
|
||||
/// cursor's events read the slot as it was. Returns whether to repaint.
|
||||
pub(crate) fn place_action(lua: &Lua, id: u32) -> bool {
|
||||
let Some((kind, action, texture)) = verb(lua, |model| take_for_place(model, id)) else {
|
||||
return false;
|
||||
};
|
||||
verb(lua, |model| {
|
||||
model.actions.insert(
|
||||
id,
|
||||
ActionSlot {
|
||||
texture,
|
||||
kind,
|
||||
action,
|
||||
// Count and consumable wait for the app's next-frame re-feed, which knows the item.
|
||||
count: 0,
|
||||
consumable: false,
|
||||
},
|
||||
);
|
||||
model.action_sets.push((id, pack(kind, action)));
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
/// `PlaceAction`'s cursor half: the accept filters, then the held payload swapped for the slot's
|
||||
/// occupant. Returns what the slot takes, or `None` for a refusal that leaves the payload held.
|
||||
fn take_for_place(model: &mut Model, id: u32) -> Option<(u8, u32, Option<String>)> {
|
||||
// The two accept filters (`0x4e62e0`) refuse with a bare return: nothing stored or sent, and
|
||||
// the payload stays held. An item needs an on-use spell or an equip slot (`0x4e6571`), so a
|
||||
// bag is placeable; a passive spell (`Attributes & 0x40`) also raises errorId `0x9e` through
|
||||
// `DisplayError` (`0x4e63ad`, `0x496720`), where the item refusal is mute.
|
||||
match &model.cursor {
|
||||
Some(CursorPayload::Item(i)) if !i.bar_placeable => return false,
|
||||
Some(CursorPayload::Item(i)) if !i.bar_placeable => return None,
|
||||
Some(CursorPayload::Spell(s)) if s.passive => {
|
||||
model.ui_errors.push(PASSIVE_ON_BAR_ERROR);
|
||||
return false;
|
||||
return None;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let Some(held) = model.cursor.take() else {
|
||||
return false;
|
||||
};
|
||||
let held = model.cursor.take()?;
|
||||
let placeable = match &held {
|
||||
CursorPayload::Action(a) => Some((a.kind, a.action, a.texture.clone())),
|
||||
CursorPayload::Item(i) => Some((ACTION_KIND_ITEM, i.item_id, i.texture.clone())),
|
||||
@@ -74,32 +112,20 @@ pub(crate) fn place_action(model: &mut Model, id: u32) -> bool {
|
||||
CursorPayload::Money(_) => None,
|
||||
CursorPayload::Merchant(_) => None,
|
||||
};
|
||||
let Some((kind, action, texture)) = placeable else {
|
||||
let Some(placed) = placeable else {
|
||||
model.cursor = Some(held);
|
||||
return false;
|
||||
return None;
|
||||
};
|
||||
let displaced = model.actions.insert(
|
||||
id,
|
||||
ActionSlot {
|
||||
texture,
|
||||
kind,
|
||||
action,
|
||||
// Count and consumable wait for the app's next-frame re-feed, which knows the item.
|
||||
count: 0,
|
||||
consumable: false,
|
||||
},
|
||||
);
|
||||
model.action_sets.push((id, pack(kind, action)));
|
||||
model.cursor = displaced.map(|d| {
|
||||
model.cursor = model.actions.get(&id).map(|d| {
|
||||
CursorPayload::Action(CursorAction {
|
||||
src_slot: id,
|
||||
kind: d.kind,
|
||||
action: d.action,
|
||||
texture: d.texture,
|
||||
texture: d.texture.clone(),
|
||||
})
|
||||
});
|
||||
queue_cursor_update(model);
|
||||
true
|
||||
raise_cursor_update(model);
|
||||
Some(placed)
|
||||
}
|
||||
|
||||
/// Register `PickupAction` and `PlaceAction` as globals.
|
||||
@@ -109,16 +135,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"PickupAction",
|
||||
lua.create_function(|lua, id: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
pickup_action(&mut model, id);
|
||||
pickup_action(lua, id);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"PlaceAction",
|
||||
lua.create_function(|lua, id: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
place_action(&mut model, id);
|
||||
place_action(lua, id);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -147,7 +171,7 @@ mod tests {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 133)));
|
||||
|
||||
assert!(pickup_action(&mut s.model_mut(), 1));
|
||||
assert!(pickup_action(&s.lua, 1));
|
||||
assert!(
|
||||
!s.eval::<bool>("return HasAction(1)").unwrap(),
|
||||
"removed from the engine's optimistic mirror"
|
||||
@@ -160,10 +184,40 @@ mod tests {
|
||||
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
|
||||
}
|
||||
|
||||
/// The cursor's events run before the slot write, as `0x4e6130` runs its setter before the
|
||||
/// slot clear `0x4e5db0` and `0x4e62e0` hops the displaced action before it writes: a handler
|
||||
/// reads each slot as it was.
|
||||
#[test]
|
||||
fn the_cursor_events_fire_before_the_slot_write() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
|
||||
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
|
||||
crate::script::tick::heard::listen(
|
||||
&s,
|
||||
&["CURSOR_UPDATE"],
|
||||
Some("tostring(GetActionTexture(1)) .. ' ' .. tostring(GetActionTexture(2))"),
|
||||
);
|
||||
s.run("PickupAction(1)").unwrap();
|
||||
assert_eq!(
|
||||
crate::script::tick::heard::take(&s),
|
||||
["CURSOR_UPDATE Interface\\Icons\\Spell_A Interface\\Icons\\Spell_B"]
|
||||
);
|
||||
assert!(!s.eval::<bool>("return HasAction(1)").unwrap());
|
||||
s.run("PlaceAction(2)").unwrap();
|
||||
assert_eq!(
|
||||
crate::script::tick::heard::take(&s),
|
||||
["CURSOR_UPDATE nil Interface\\Icons\\Spell_B"]
|
||||
);
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetActionTexture(2)").unwrap(),
|
||||
"Interface\\Icons\\Spell_A"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pickup_action_on_an_empty_slot_is_a_no_op() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
assert!(!pickup_action(&mut s.model_mut(), 5));
|
||||
assert!(!pickup_action(&s.lua, 5));
|
||||
assert!(s.cursor_payload().is_none());
|
||||
assert!(s.take_action_sets().is_empty());
|
||||
}
|
||||
@@ -178,7 +232,7 @@ mod tests {
|
||||
texture: Some("Interface\\Icons\\Spell_A".into()),
|
||||
}));
|
||||
|
||||
assert!(place_action(&mut s.model_mut(), 7));
|
||||
assert!(place_action(&s.lua, 7));
|
||||
assert!(s.cursor_payload().is_none(), "empty destination clears");
|
||||
assert!(s.eval::<bool>("return HasAction(7)").unwrap());
|
||||
assert_eq!(
|
||||
@@ -195,10 +249,10 @@ mod tests {
|
||||
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
|
||||
s.set_action(2, Some(action_slot("Interface\\Icons\\Spell_B", 0x00, 222)));
|
||||
|
||||
assert!(pickup_action(&mut s.model_mut(), 1));
|
||||
assert!(pickup_action(&s.lua, 1));
|
||||
assert_eq!(s.take_action_sets(), vec![(1, 0)]);
|
||||
|
||||
assert!(place_action(&mut s.model_mut(), 2));
|
||||
assert!(place_action(&s.lua, 2));
|
||||
assert_eq!(
|
||||
s.eval::<String>("return GetActionTexture(2)").unwrap(),
|
||||
"Interface\\Icons\\Spell_A"
|
||||
@@ -230,7 +284,7 @@ mod tests {
|
||||
equip_slots: Vec::new(),
|
||||
}));
|
||||
|
||||
assert!(place_action(&mut s.model_mut(), 4));
|
||||
assert!(place_action(&s.lua, 4));
|
||||
assert!(s.cursor_payload().is_none());
|
||||
// 0x80<<24 | 117.
|
||||
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000 | 117)]);
|
||||
@@ -251,7 +305,7 @@ mod tests {
|
||||
texture: Some("Interface\\Icons\\Spell_A".into()),
|
||||
}));
|
||||
|
||||
assert!(place_action(&mut s.model_mut(), 9));
|
||||
assert!(place_action(&s.lua, 9));
|
||||
assert_eq!(s.take_action_sets(), vec![(9, 133)]); // 0x00<<24 | 133
|
||||
}
|
||||
|
||||
@@ -259,7 +313,7 @@ mod tests {
|
||||
fn place_action_with_an_empty_cursor_is_a_no_op() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_action(1, Some(action_slot("Interface\\Icons\\Spell_A", 0x00, 111)));
|
||||
assert!(!place_action(&mut s.model_mut(), 1));
|
||||
assert!(!place_action(&s.lua, 1));
|
||||
assert!(s.eval::<bool>("return HasAction(1)").unwrap(), "untouched");
|
||||
assert!(s.take_action_sets().is_empty());
|
||||
}
|
||||
@@ -273,7 +327,7 @@ mod tests {
|
||||
action: 111,
|
||||
texture: Some("Interface\\Icons\\Spell_A".into()),
|
||||
}));
|
||||
assert!(pickup_action(&mut s.model_mut(), 5));
|
||||
assert!(pickup_action(&s.lua, 5));
|
||||
assert!(s.cursor_payload().is_none(), "placed onto the empty slot 5");
|
||||
assert!(s.eval::<bool>("return HasAction(5)").unwrap());
|
||||
assert_eq!(s.take_action_sets(), vec![(5, 111)]);
|
||||
@@ -329,7 +383,7 @@ mod tests {
|
||||
equip_slots: Vec::new(),
|
||||
}));
|
||||
|
||||
assert!(!place_action(&mut s.model_mut(), 4));
|
||||
assert!(!place_action(&s.lua, 4));
|
||||
assert!(!s.eval::<bool>("return HasAction(4)").unwrap(), "no store");
|
||||
assert!(s.take_action_sets().is_empty(), "no packet");
|
||||
assert!(
|
||||
@@ -354,7 +408,7 @@ mod tests {
|
||||
passive: true,
|
||||
}));
|
||||
|
||||
assert!(!place_action(&mut s.model_mut(), 4));
|
||||
assert!(!place_action(&s.lua, 4));
|
||||
assert!(!s.eval::<bool>("return HasAction(4)").unwrap());
|
||||
assert!(s.take_action_sets().is_empty());
|
||||
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
|
||||
@@ -380,7 +434,7 @@ mod tests {
|
||||
passive: false,
|
||||
}));
|
||||
|
||||
assert!(place_action(&mut s.model_mut(), 4));
|
||||
assert!(place_action(&s.lua, 4));
|
||||
assert!(s.eval::<bool>("return HasAction(4)").unwrap());
|
||||
assert!(s.take_ui_errors().is_empty());
|
||||
}
|
||||
@@ -400,7 +454,7 @@ mod tests {
|
||||
equip_slots: vec![20, 21, 22, 23],
|
||||
}));
|
||||
|
||||
assert!(place_action(&mut s.model_mut(), 4));
|
||||
assert!(place_action(&s.lua, 4));
|
||||
assert_eq!(s.take_action_sets(), vec![(4, 0x8000_0000u32 | 4496)]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ use mlua::Lua;
|
||||
|
||||
use crate::script::binding_abi::flag;
|
||||
use crate::script::container::ContainerMove;
|
||||
use crate::script::tick::verb;
|
||||
use crate::script::Model;
|
||||
|
||||
use super::{queue_cursor_update, queue_lock_changed, CursorItem, CursorPayload, EQUIPMENT_BAG};
|
||||
use super::{raise_cursor_update, raise_lock_changed, CursorItem, CursorPayload, EQUIPMENT_BAG};
|
||||
|
||||
/// `PickupInventoryItem(id)` (`0x4c7300`), the doll slot's click, on the bag click's model: a
|
||||
/// fitting whole item moves in and the cursor clears, with no hop; a split or a misfit stays
|
||||
@@ -57,16 +58,17 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
|
||||
match picked {
|
||||
Some(item) => {
|
||||
model.cursor = Some(CursorPayload::Item(item));
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, EQUIPMENT_BAG, id);
|
||||
raise_cursor_update(model);
|
||||
raise_lock_changed(model);
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
// The cancel is `ClearCursor(1,1)` (`0x4c794d`): the unlock, then `CURSOR_UPDATE`.
|
||||
Some(CursorPayload::Item(held)) if held.bag == EQUIPMENT_BAG && held.slot == id => {
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, held.bag, held.slot);
|
||||
raise_lock_changed(model);
|
||||
raise_cursor_update(model);
|
||||
true
|
||||
}
|
||||
Some(CursorPayload::Item(held)) => {
|
||||
@@ -82,8 +84,8 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
|
||||
dst_slot: id,
|
||||
count: None,
|
||||
});
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, held.bag, held.slot);
|
||||
raise_cursor_update(model);
|
||||
raise_lock_changed(model);
|
||||
true
|
||||
}
|
||||
// Mode 5, a buy into this slot: `0x4c78fd` sends `CMSG_BUY_ITEM_IN_SLOT` (`0x1a3`) and the
|
||||
@@ -99,7 +101,7 @@ pub(super) fn pickup_inventory_item(model: &mut Model, id: u32) -> bool {
|
||||
if let Some(entry) = entry {
|
||||
model.merchant_slot_buys.push((EQUIPMENT_BAG, id, entry));
|
||||
}
|
||||
queue_cursor_update(model);
|
||||
raise_cursor_update(model);
|
||||
true
|
||||
}
|
||||
Some(
|
||||
@@ -144,8 +146,8 @@ pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool {
|
||||
match model.cursor.take() {
|
||||
Some(CursorPayload::Item(item)) if item.bag >= 0 && item.count.is_none() => {
|
||||
model.container_autoequips.push((item.bag, item.slot));
|
||||
queue_cursor_update(model);
|
||||
queue_lock_changed(model, item.bag, item.slot);
|
||||
raise_cursor_update(model);
|
||||
raise_lock_changed(model);
|
||||
true
|
||||
}
|
||||
other => {
|
||||
@@ -156,11 +158,17 @@ pub(super) fn auto_equip_cursor_item(model: &mut Model) -> bool {
|
||||
}
|
||||
|
||||
/// `UseInventoryItem(id)`, the doll slot's right-click (`PaperDollFrame.lua:658-659`): the cursor
|
||||
/// clears before the slot is read (`0x4c79a9`), so a held payload goes back, never placed; then
|
||||
/// the app sends `CMSG_USE_ITEM` with bag 255 and the 0-based slot, dropping an empty slot unsent,
|
||||
/// or in repair mode a worn item is repaired, locked or not (`0x4c79c4`).
|
||||
pub(super) fn use_inventory_item(model: &mut Model, id: u32) {
|
||||
super::clear_cursor(model);
|
||||
/// clears before the slot is read (`0x4c79a9`), so a held payload goes back, never placed, its
|
||||
/// events running first; then the app sends `CMSG_USE_ITEM` with bag 255 and the 0-based slot,
|
||||
/// dropping an empty slot unsent, or in repair mode a worn item is repaired, locked or not
|
||||
/// (`0x4c79c4`).
|
||||
pub(super) fn use_inventory_item(lua: &Lua, id: u32) {
|
||||
verb(lua, super::clear_cursor);
|
||||
verb(lua, |model| use_cleared(model, id));
|
||||
}
|
||||
|
||||
/// `UseInventoryItem` past its clear.
|
||||
fn use_cleared(model: &mut Model, id: u32) {
|
||||
if !model.repair_mode {
|
||||
model
|
||||
.script_calls
|
||||
@@ -195,16 +203,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"PickupInventoryItem",
|
||||
lua.create_function(|lua, id: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
pickup_inventory_item(&mut model, id);
|
||||
verb(lua, |model| pickup_inventory_item(model, id));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"EquipCursorItem",
|
||||
lua.create_function(|lua, id: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
equip_cursor_item(&mut model, id);
|
||||
verb(lua, |model| equip_cursor_item(model, id));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -218,16 +224,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"AutoEquipCursorItem",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
auto_equip_cursor_item(&mut model);
|
||||
verb(lua, auto_equip_cursor_item);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"UseInventoryItem",
|
||||
lua.create_function(|lua, id: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
use_inventory_item(&mut model, id);
|
||||
use_inventory_item(lua, id);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
//! The money cursor, payload mode 2: coins taken off a money frame; the purse is never debited.
|
||||
//! Deviation: a `CURSOR_UPDATE` handler already sees the coins, because events are queued; the
|
||||
//! reference runs it inside the clear, before they land.
|
||||
|
||||
use mlua::{Lua, Value};
|
||||
|
||||
use super::{queue_cursor_update, CursorMoney, CursorPayload};
|
||||
use super::{raise_cursor_update, CursorMoney, CursorPayload};
|
||||
use crate::script::binding_abi::{flag, number_arg};
|
||||
use crate::script::tick::verb;
|
||||
use crate::script::Model;
|
||||
|
||||
/// The coin icon for an amount of copper: `GetCoinIcon`'s table (`0x48d4e0`), thresholds signed.
|
||||
@@ -33,32 +32,38 @@ pub(crate) fn cursor_money(model: &Model) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Queue `PLAYER_MONEY`, which repaints the money frames less the held coins.
|
||||
fn queue_player_money(model: &mut Model) {
|
||||
model
|
||||
.pending_events
|
||||
.push(("PLAYER_MONEY".to_string(), Vec::new()));
|
||||
/// Raise `PLAYER_MONEY`, which repaints the money frames less the held coins.
|
||||
fn raise_player_money(model: &mut Model) {
|
||||
model.raise("PLAYER_MONEY", Vec::new());
|
||||
}
|
||||
|
||||
/// Put coins on the cursor as `0x494cc0` does: the inner clear's `CURSOR_UPDATE`, after its own
|
||||
/// `PLAYER_MONEY` if coins were already held, then the payload and `PLAYER_MONEY`.
|
||||
fn install_money(model: &mut Model, copper: u32) {
|
||||
if matches!(model.cursor, Some(CursorPayload::Money(_))) {
|
||||
queue_player_money(model);
|
||||
}
|
||||
model.cursor = None;
|
||||
queue_cursor_update(model);
|
||||
model.cursor = Some(CursorPayload::Money(CursorMoney { copper }));
|
||||
queue_player_money(model);
|
||||
/// Put coins on the cursor as `0x494cc0` does, in two steps. Its inner `ClearCursor(1,1)`
|
||||
/// (`0x494cd9`) drops coins already held with their `PLAYER_MONEY` (`0x495243`) and fires
|
||||
/// `CURSOR_UPDATE` (`0x4952cc`) on the empty cursor, so that handler sees no coins; then the coins
|
||||
/// land and `PLAYER_MONEY` fires (`0x494d0a`), with no second `CURSOR_UPDATE`.
|
||||
fn install_money(lua: &Lua, copper: u32) {
|
||||
verb(lua, |model| {
|
||||
if matches!(model.cursor.take(), Some(CursorPayload::Money(_))) {
|
||||
raise_player_money(model);
|
||||
}
|
||||
raise_cursor_update(model);
|
||||
});
|
||||
verb(lua, |model| {
|
||||
model.cursor = Some(CursorPayload::Money(CursorMoney { copper }));
|
||||
raise_player_money(model);
|
||||
});
|
||||
}
|
||||
|
||||
/// `PickupPlayerMoney` after the argument gate; each refusal is silent.
|
||||
pub(crate) fn pickup_player_money(model: &mut Model, copper: u32) -> bool {
|
||||
if copper == 0 || !model.player_control || u64::from(copper) > model.money {
|
||||
return false;
|
||||
pub(crate) fn pickup_player_money(lua: &Lua, copper: u32) -> bool {
|
||||
let allowed = {
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
copper != 0 && model.player_control && u64::from(copper) <= model.money
|
||||
};
|
||||
if allowed {
|
||||
install_money(lua, copper);
|
||||
}
|
||||
install_money(model, copper);
|
||||
true
|
||||
allowed
|
||||
}
|
||||
|
||||
/// `DropCursorMoney`: `PLAYER_MONEY`, then the clear's `CURSOR_UPDATE`, and no packet.
|
||||
@@ -67,8 +72,8 @@ pub(crate) fn drop_cursor_money(model: &mut Model) -> bool {
|
||||
return false;
|
||||
}
|
||||
model.cursor = None;
|
||||
queue_player_money(model);
|
||||
queue_cursor_update(model);
|
||||
raise_player_money(model);
|
||||
raise_cursor_update(model);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -91,24 +96,29 @@ pub(crate) fn add_trade_money(model: &mut Model) -> bool {
|
||||
}
|
||||
// `ClearCursor(1, 0)`: no PLAYER_MONEY, the clear's own CURSOR_UPDATE.
|
||||
model.cursor = None;
|
||||
queue_cursor_update(model);
|
||||
raise_cursor_update(model);
|
||||
true
|
||||
}
|
||||
|
||||
/// `PickupTradeMoney` after the argument gate: signed gates, then `CMSG_SET_TRADE_GOLD` (`0x11F`)
|
||||
/// with the trimmed offer, then the coins onto the cursor.
|
||||
pub(crate) fn pickup_trade_money(model: &mut Model, amount: i32) -> bool {
|
||||
let offer = model.trade.as_ref().map_or(0, |t| t.player.gold) as i32;
|
||||
if amount <= 0 || amount > offer {
|
||||
return false;
|
||||
pub(crate) fn pickup_trade_money(lua: &Lua, amount: i32) -> bool {
|
||||
let trimmed = verb(lua, |model| {
|
||||
let offer = model.trade.as_ref().map_or(0, |t| t.player.gold) as i32;
|
||||
if amount <= 0 || amount > offer {
|
||||
return false;
|
||||
}
|
||||
let left = (offer - amount) as u32;
|
||||
model.trade_set_money = Some(left);
|
||||
if let Some(t) = model.trade.as_mut() {
|
||||
t.player.gold = left;
|
||||
}
|
||||
true
|
||||
});
|
||||
if trimmed {
|
||||
install_money(lua, amount as u32);
|
||||
}
|
||||
let left = (offer - amount) as u32;
|
||||
model.trade_set_money = Some(left);
|
||||
if let Some(t) = model.trade.as_mut() {
|
||||
t.player.gold = left;
|
||||
}
|
||||
install_money(model, amount as u32);
|
||||
true
|
||||
trimmed
|
||||
}
|
||||
|
||||
impl crate::script::UiScript {
|
||||
@@ -139,24 +149,21 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"PickupPlayerMoney",
|
||||
lua.create_function(|lua, amount: Value| {
|
||||
let n = number_arg(lua, amount, "Usage: PickupPlayerMoney(amount)")? as u32;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
pickup_player_money(&mut model, n);
|
||||
pickup_player_money(lua, n);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"DropCursorMoney",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
drop_cursor_money(&mut model);
|
||||
verb(lua, drop_cursor_money);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"AddTradeMoney",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
add_trade_money(&mut model);
|
||||
verb(lua, add_trade_money);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -164,8 +171,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"PickupTradeMoney",
|
||||
lua.create_function(|lua, amount: Value| {
|
||||
let n = number_arg(lua, amount, "Usage: PickupTradeMoney(amount)")?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
pickup_trade_money(&mut model, n);
|
||||
pickup_trade_money(lua, n);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -183,18 +189,9 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
mod tests {
|
||||
use super::coin_icon;
|
||||
use crate::script::cursor::CursorPayload;
|
||||
use crate::script::tick::heard;
|
||||
use crate::script::UiScript;
|
||||
|
||||
fn events(s: &mut UiScript) -> Vec<String> {
|
||||
s.run(
|
||||
r#"EV = {} F = F or CreateFrame("Frame") F:RegisterEvent("PLAYER_MONEY")
|
||||
F:RegisterEvent("CURSOR_UPDATE") F:SetScript("OnEvent", function() table.insert(EV, event) end)"#,
|
||||
)
|
||||
.unwrap();
|
||||
s.tick(0.0);
|
||||
s.eval::<Vec<String>>("return EV").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coins_ride_the_cursor_without_leaving_the_purse() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
@@ -251,25 +248,75 @@ mod tests {
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
}
|
||||
|
||||
/// The events fire inside each verb, in `0x494cc0`'s order: the inner clear's `CURSOR_UPDATE`
|
||||
/// reads an empty cursor, and only the install's `PLAYER_MONEY` reads the new coins.
|
||||
#[test]
|
||||
fn the_pickup_and_drop_fire_player_money_around_the_clear() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_money(1_000);
|
||||
let _ = events(&mut s);
|
||||
heard::listen(
|
||||
&s,
|
||||
&["PLAYER_MONEY", "CURSOR_UPDATE"],
|
||||
Some("GetCursorMoney()"),
|
||||
);
|
||||
s.run("PickupPlayerMoney(10)").unwrap();
|
||||
assert_eq!(
|
||||
events(&mut s),
|
||||
vec!["CURSOR_UPDATE", "PLAYER_MONEY"],
|
||||
heard::take(&s),
|
||||
["CURSOR_UPDATE 0", "PLAYER_MONEY 10"],
|
||||
"the inner clear's CURSOR_UPDATE, then the money's PLAYER_MONEY"
|
||||
);
|
||||
s.run("PickupPlayerMoney(20)").unwrap();
|
||||
assert_eq!(
|
||||
events(&mut s),
|
||||
vec!["PLAYER_MONEY", "CURSOR_UPDATE", "PLAYER_MONEY"],
|
||||
heard::take(&s),
|
||||
["PLAYER_MONEY 0", "CURSOR_UPDATE 0", "PLAYER_MONEY 20"],
|
||||
"coins already held: the mode-2 arm's PLAYER_MONEY first"
|
||||
);
|
||||
s.run("DropCursorMoney()").unwrap();
|
||||
assert_eq!(events(&mut s), vec!["PLAYER_MONEY", "CURSOR_UPDATE"]);
|
||||
assert_eq!(heard::take(&s), ["PLAYER_MONEY 0", "CURSOR_UPDATE 0"]);
|
||||
assert!(s.model_ref().pending_events.is_empty());
|
||||
}
|
||||
|
||||
/// A handler that calls another verb runs it to completion inside the dispatch, as the
|
||||
/// reference's synchronous `SignalEvent` nests: the mail verb's event lands between the
|
||||
/// pickup's two steps, and a pickup called from the install's `PLAYER_MONEY` runs both of its
|
||||
/// own steps before the outer verb returns. No step waits on a held model borrow.
|
||||
#[test]
|
||||
fn a_verb_called_from_a_handler_nests_inside_the_dispatch() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_money(1_000);
|
||||
let log: Vec<String> = s
|
||||
.eval(
|
||||
r#"LOG = {} NEST = 0
|
||||
local f = CreateFrame("Frame")
|
||||
f:RegisterEvent("CURSOR_UPDATE")
|
||||
f:RegisterEvent("PLAYER_MONEY")
|
||||
f:RegisterEvent("SEND_MAIL_MONEY_CHANGED")
|
||||
f:SetScript("OnEvent", function()
|
||||
table.insert(LOG, event .. " " .. GetCursorMoney() .. " " .. GetSendMailMoney())
|
||||
if event == "CURSOR_UPDATE" and NEST == 0 then
|
||||
NEST = 1 SetSendMailMoney(5)
|
||||
elseif event == "PLAYER_MONEY" and NEST == 1 then
|
||||
NEST = 2 PickupPlayerMoney(20)
|
||||
end
|
||||
end)
|
||||
PickupPlayerMoney(10)
|
||||
return LOG"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
log,
|
||||
[
|
||||
"CURSOR_UPDATE 0 0",
|
||||
"SEND_MAIL_MONEY_CHANGED 0 5",
|
||||
"PLAYER_MONEY 10 5",
|
||||
"PLAYER_MONEY 0 5",
|
||||
"CURSOR_UPDATE 0 5",
|
||||
"PLAYER_MONEY 20 5",
|
||||
]
|
||||
);
|
||||
assert_eq!(s.eval::<i64>("return GetCursorMoney()").unwrap(), 20);
|
||||
assert!(s.errors().is_empty(), "{:?}", s.errors());
|
||||
assert!(s.model_ref().pending_events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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(_))
|
||||
|
||||
@@ -80,18 +80,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
// `0x48df30` runs the generic cancel-aura (`0x6e7040`): the event, then `0x136` with
|
||||
// `u32 2584`, no guid. Its refusal gate (`AttributesEx` bit 13 set, bit 2 clear, then
|
||||
// `0x5ee290`) never trips for 2584 in the shipped `Spell.dbc` (the app's
|
||||
// `spell_2584_never_trips_the_cancel_gate`), so the send is unconditional.
|
||||
// `0x48df30` runs the generic cancel-aura (`0x4923a0` → `0x6e7040`): the event in place
|
||||
// (`0x6e70c3`), then `0x136` with `u32 2584`, no guid. Its refusal gate (`AttributesEx` bit 13
|
||||
// set, bit 2 clear, then `0x5ee290`) never trips for 2584 in the shipped `Spell.dbc` (the
|
||||
// app's `spell_2584_never_trips_the_cancel_gate`), so the send is unconditional.
|
||||
g.set(
|
||||
"CancelAreaSpiritHeal",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model
|
||||
.pending_events
|
||||
.push(("AREA_SPIRIT_HEALER_OUT_OF_RANGE".to_string(), Vec::new()));
|
||||
model.cancel_aura_requests.push(AREA_SPIRIT_HEALER_SPELL);
|
||||
super::tick::verb(lua, |model| {
|
||||
model.raise("AREA_SPIRIT_HEALER_OUT_OF_RANGE", Vec::new());
|
||||
model.cancel_aura_requests.push(AREA_SPIRIT_HEALER_SPELL);
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -102,16 +102,15 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
)?;
|
||||
|
||||
// UpdateInventoryAlertStatus() (`0x4c9560`): the alert recompute (`0x4c7ee0`), which fires
|
||||
// UPDATE_INVENTORY_ALERTS unconditionally; zero returns. Stock calls it when the durability
|
||||
// UPDATE_INVENTORY_ALERTS (`0x4c8085`) in place, after every write; zero returns. Stock calls it when the durability
|
||||
// figure's enchant timer runs out (`DurabilityFrame.lua:81`).
|
||||
g.set(
|
||||
"UpdateInventoryAlertStatus",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
recompute_inventory_alerts(&mut model);
|
||||
model
|
||||
.pending_events
|
||||
.push(("UPDATE_INVENTORY_ALERTS".to_string(), Vec::new()));
|
||||
super::tick::verb(lua, |model| {
|
||||
recompute_inventory_alerts(model);
|
||||
model.raise("UPDATE_INVENTORY_ALERTS", Vec::new());
|
||||
});
|
||||
Ok(MultiValue::new())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::cursor::{queue_cursor_update, CursorMacro, CursorPayload};
|
||||
use super::cursor::{raise_cursor_update, CursorMacro, CursorPayload};
|
||||
use super::Model;
|
||||
|
||||
/// Macros per tab (`Blizzard_MacroUI.lua:1`), and so the character tab's base.
|
||||
@@ -268,7 +268,7 @@ fn pickup_macro(model: &mut Model, index: usize) -> bool {
|
||||
texture: entry.texture.clone(),
|
||||
};
|
||||
model.cursor = Some(CursorPayload::Macro(payload));
|
||||
queue_cursor_update(model);
|
||||
raise_cursor_update(model);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -414,8 +414,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"PickupMacro",
|
||||
lua.create_function(|lua, index: usize| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
pickup_macro(&mut model, index);
|
||||
super::tick::verb(lua, |model| pickup_macro(model, index));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -128,18 +128,11 @@ pub struct MailSendRequest {
|
||||
pub package: u32,
|
||||
}
|
||||
|
||||
/// `0x4acdc0`'s clears: the attachment, whose lock it lifts (`0x495420`, firing
|
||||
/// `ITEM_LOCK_CHANGED`), then money, COD, stationery and package. Returns the lock event to fire.
|
||||
fn clear_compose(model: &mut Model) -> Option<(String, Vec<super::ScriptValue>)> {
|
||||
let lock = model.mail_send_item.take().map(|item| {
|
||||
(
|
||||
"ITEM_LOCK_CHANGED".to_string(),
|
||||
vec![
|
||||
super::ScriptValue::Int(item.bag),
|
||||
super::ScriptValue::Int(i64::from(item.slot)),
|
||||
],
|
||||
)
|
||||
});
|
||||
/// `0x4acdc0`'s clears: the attachment, whose lock it lifts (`0x495420`, firing an argless
|
||||
/// `ITEM_LOCK_CHANGED` at `0x495455`), then money, COD, stationery and package. Returns whether
|
||||
/// an attachment was lifted, so the caller fires that event.
|
||||
fn clear_compose(model: &mut Model) -> bool {
|
||||
let lock = model.mail_send_item.take().is_some();
|
||||
model.mail_send_money = 0;
|
||||
model.mail_send_cod = 0;
|
||||
model.mail_stationery = 0;
|
||||
@@ -220,8 +213,8 @@ impl super::UiScript {
|
||||
pub fn reset_compose_tab(&mut self) {
|
||||
let lock = clear_compose(&mut self.model_mut());
|
||||
// Fired now, not queued: the order against the caller's `MAIL_SHOW`/`MAIL_FAILED` matters.
|
||||
if let Some((event, args)) = lock {
|
||||
self.fire_event(&event, args);
|
||||
if lock {
|
||||
self.fire_event("ITEM_LOCK_CHANGED", Vec::new());
|
||||
}
|
||||
// One literal call each, as the event census (`ui_script::reference_ui`) reads them.
|
||||
self.fire_event("SEND_MAIL_MONEY_CHANGED", Vec::new()); // 0x4ace14
|
||||
@@ -232,9 +225,8 @@ impl super::UiScript {
|
||||
/// The close core's silent reset (`0x4acdc0(0)`, from `0x4acd50`): the same clears, and only
|
||||
/// the attachment's `ITEM_LOCK_CHANGED`, which `0x495420` fires on either leg.
|
||||
pub fn reset_compose_tab_silently(&mut self) {
|
||||
let lock = clear_compose(&mut self.model_mut());
|
||||
if let Some((event, args)) = lock {
|
||||
self.fire_event(&event, args);
|
||||
if clear_compose(&mut self.model_mut()) {
|
||||
self.fire_event("ITEM_LOCK_CHANGED", Vec::new());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -543,16 +535,16 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
copper,
|
||||
"Usage: SetSendMailMoney(amount)",
|
||||
)? as u32;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if u64::from(n) > model.money {
|
||||
model.ui_errors.push("ERR_NOT_ENOUGH_MONEY");
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
model.mail_send_money = n;
|
||||
model
|
||||
.pending_events
|
||||
.push(("SEND_MAIL_MONEY_CHANGED".to_string(), Vec::new()));
|
||||
Ok(Value::Integer(1))
|
||||
Ok(super::tick::verb(lua, |model| {
|
||||
if u64::from(n) > model.money {
|
||||
model.ui_errors.push("ERR_NOT_ENOUGH_MONEY");
|
||||
return Value::Nil;
|
||||
}
|
||||
// The write (`0x4adc39`), then the signal (`0x4adc44`).
|
||||
model.mail_send_money = n;
|
||||
model.raise("SEND_MAIL_MONEY_CHANGED", Vec::new());
|
||||
Value::Integer(1)
|
||||
}))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -588,14 +580,13 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
copper,
|
||||
"Usage: SetSendMailCOD(amount)",
|
||||
)? as u32;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if model.mail_send_item.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
model.mail_send_cod = n;
|
||||
model
|
||||
.pending_events
|
||||
.push(("SEND_MAIL_COD_CHANGED".to_string(), Vec::new()));
|
||||
super::tick::verb(lua, |model| {
|
||||
if model.mail_send_item.is_some() {
|
||||
// The write (`0x4adc7d`), then the signal (`0x4adc88`).
|
||||
model.mail_send_cod = n;
|
||||
model.raise("SEND_MAIL_COD_CHANGED", Vec::new());
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -655,8 +646,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"ClickSendMailItemButton",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
click_send_mail_item(&mut model);
|
||||
super::tick::verb(lua, click_send_mail_item);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -739,21 +729,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ClearSendMail() (`0x4adee0`): the compose reset with its events (`0x4acdc0(1)`), queued in
|
||||
// its order; zero returns.
|
||||
// ClearSendMail() (`0x4adee0`): the compose reset with its events (`0x4acdc0(1)`), fired in
|
||||
// place in its order: the attachment's unlock (`0x495455`), then `0x4ace19`, `0x4ace23` and
|
||||
// `0x4ace2d`; zero returns.
|
||||
g.set(
|
||||
"ClearSendMail",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let lock = clear_compose(&mut model);
|
||||
model.pending_events.extend(lock);
|
||||
for event in [
|
||||
"SEND_MAIL_MONEY_CHANGED",
|
||||
"SEND_MAIL_COD_CHANGED",
|
||||
"MAIL_SEND_SUCCESS",
|
||||
] {
|
||||
model.pending_events.push((event.to_string(), Vec::new()));
|
||||
}
|
||||
super::tick::verb(lua, |model| {
|
||||
if clear_compose(model) {
|
||||
model.raise("ITEM_LOCK_CHANGED", Vec::new());
|
||||
}
|
||||
model.raise("SEND_MAIL_MONEY_CHANGED", Vec::new());
|
||||
model.raise("SEND_MAIL_COD_CHANGED", Vec::new());
|
||||
model.raise("MAIL_SEND_SUCCESS", Vec::new());
|
||||
});
|
||||
Ok(MultiValue::new())
|
||||
})?,
|
||||
)?;
|
||||
@@ -814,25 +803,19 @@ fn click_send_mail_item(model: &mut Model) {
|
||||
match model.cursor.take() {
|
||||
// A held bag item attaches; it stays in its bag slot until the send.
|
||||
Some(CursorPayload::Item(item)) => {
|
||||
let (bag, slot) = (item.bag, item.slot);
|
||||
// An earlier attachment is overwritten, as in the reference; its slot has no lock.
|
||||
model.mail_send_item = Some(item);
|
||||
cursor::queue_cursor_update(model);
|
||||
cursor::queue_lock_changed(model, bag, slot);
|
||||
cursor::raise_cursor_update(model);
|
||||
cursor::raise_lock_changed(model);
|
||||
// `MAIL_SEND_INFO_UPDATE` (`0x4ae0de`): the stock tab re-reads the item and postage.
|
||||
model
|
||||
.pending_events
|
||||
.push(("MAIL_SEND_INFO_UPDATE".to_string(), Vec::new()));
|
||||
model.raise("MAIL_SEND_INFO_UPDATE", Vec::new());
|
||||
}
|
||||
None => {
|
||||
if let Some(item) = model.mail_send_item.take() {
|
||||
let (bag, slot) = (item.bag, item.slot);
|
||||
model.cursor = Some(CursorPayload::Item(item));
|
||||
cursor::queue_cursor_update(model);
|
||||
cursor::queue_lock_changed(model, bag, slot);
|
||||
model
|
||||
.pending_events
|
||||
.push(("MAIL_SEND_INFO_UPDATE".to_string(), Vec::new()));
|
||||
cursor::raise_cursor_update(model);
|
||||
cursor::raise_lock_changed(model);
|
||||
model.raise("MAIL_SEND_INFO_UPDATE", Vec::new());
|
||||
}
|
||||
}
|
||||
Some(other) => {
|
||||
@@ -846,6 +829,40 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// The compose verbs fire in place, each after its write (`0x4adc39` then `0x4adc44`), and
|
||||
/// `ClearSendMail` after all its clears (`0x4acddf`-`0x4ace0c`, then `0x4ace19`, `0x4ace23`,
|
||||
/// `0x4ace2d`): every handler reads the stored amount before the next Lua line runs.
|
||||
#[test]
|
||||
fn the_compose_verbs_fire_their_events_in_place() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_money(1_000);
|
||||
crate::script::tick::heard::listen(
|
||||
&s,
|
||||
&[
|
||||
"SEND_MAIL_MONEY_CHANGED",
|
||||
"SEND_MAIL_COD_CHANGED",
|
||||
"MAIL_SEND_SUCCESS",
|
||||
],
|
||||
Some("GetSendMailMoney()"),
|
||||
);
|
||||
let seen: Vec<String> = s
|
||||
.eval("SetSendMailMoney(250) local h = HEARD HEARD = {} return h")
|
||||
.unwrap();
|
||||
assert_eq!(seen, ["SEND_MAIL_MONEY_CHANGED 250"]);
|
||||
let seen: Vec<String> = s
|
||||
.eval("ClearSendMail() local h = HEARD HEARD = {} return h")
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
seen,
|
||||
[
|
||||
"SEND_MAIL_MONEY_CHANGED 0",
|
||||
"SEND_MAIL_COD_CHANGED 0",
|
||||
"MAIL_SEND_SUCCESS 0"
|
||||
]
|
||||
);
|
||||
assert!(s.model_ref().pending_events.is_empty());
|
||||
}
|
||||
|
||||
/// A send needs a stationery: select the default, as the stock `SendMailFrame_Reset` does.
|
||||
fn select_default_stationery(s: &mut UiScript) {
|
||||
s.set_mail_stationeries(vec![super::StationeryView {
|
||||
|
||||
@@ -173,6 +173,37 @@ fn invalid_tuple() -> Vec<Value> {
|
||||
]
|
||||
}
|
||||
|
||||
/// The row `PickupMerchantItem(index)` grabs, read before its clear: `None` for each silent
|
||||
/// refusal, a non-number (`0x4fb7cb`; a numeric string passes), `index < 1` (`0x4fb7e1`),
|
||||
/// `index > count` (`0x4fb7e9`), no merchant (`0x4fb7f7`), a dead row (`0x4fb80b`), or the held
|
||||
/// row again (`0x4fb818`, the toggle-off). Neither stock nor the item cache is tested: a sold-out
|
||||
/// row grabs, and the server refuses the buy.
|
||||
fn merchant_grab(model: &Model, index: Option<f64>) -> Option<CursorPayload> {
|
||||
let held = match &model.cursor {
|
||||
Some(CursorPayload::Merchant(m)) => Some(m.row),
|
||||
_ => None,
|
||||
};
|
||||
// `_ftol` truncates toward zero, then the bound applies signed. The upper bound is
|
||||
// load-bearing: `2^32 + 1` would narrow to row 0, where the reference bounds against the
|
||||
// vendor count before any narrowing.
|
||||
let row = index?.trunc();
|
||||
if row.is_nan() || row < 1.0 || row > f64::from(u32::MAX) {
|
||||
return None;
|
||||
}
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let row0 = row as u32 - 1;
|
||||
let item = model.merchant.as_ref()?.items.get(row0 as usize)?;
|
||||
// The toggle-off: naming the held row puts nothing back.
|
||||
if held == Some(row0) {
|
||||
return None;
|
||||
}
|
||||
Some(CursorPayload::Merchant(super::cursor::CursorMerchantItem {
|
||||
item_id: item.item_id,
|
||||
row: row0,
|
||||
texture: item.texture.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Register the merchant globals.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
@@ -277,62 +308,35 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
// `CursorHasItem()` first (`MerchantFrame.lua:329`). A bag item on the cursor is sold, index
|
||||
// ignored and no merchant-open gate, which stock `MerchantFrame.xml:743`'s
|
||||
// `PickupMerchantItem(0)` relies on; otherwise row `index - 1` is grabbed as cursor mode 5.
|
||||
//
|
||||
// Every refusal is silent and clears the cursor: a non-number (`0x4fb7cb`; a numeric string
|
||||
// passes), `index < 1` (`0x4fb7e1`), `index > count` (`0x4fb7e9`), no merchant (`0x4fb7f7`), a
|
||||
// dead row (`0x4fb80b`), or the held row again (`0x4fb818`, the toggle-off). Neither stock nor
|
||||
// the item cache is tested: a sold-out row grabs, and the server refuses the buy.
|
||||
// Every refusal ([`merchant_grab`]) is silent and clears the cursor.
|
||||
g.set(
|
||||
"PickupMerchantItem",
|
||||
lua.create_function(|lua, index: Option<f64>| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
|
||||
// The sell fork comes first, before the argument is read. Its clear is the sale's, not
|
||||
// `ClearCursor`: `CURSOR_UPDATE` fires and the source slot stays greyed until the
|
||||
// server's inventory update (`0x494b60`).
|
||||
if let Some(item) = crate::script::cursor::take_cursor_item_for_sale(&mut model) {
|
||||
model.merchant_cursor_sells.push((item.bag, item.slot));
|
||||
return Ok(());
|
||||
}
|
||||
// server's inventory update (`0x494b60`). Every other leg reads the row, then clears.
|
||||
let grab = super::tick::verb(lua, |model| {
|
||||
if let Some(item) = crate::script::cursor::take_cursor_item_for_sale(model) {
|
||||
model.merchant_cursor_sells.push((item.bag, item.slot));
|
||||
return None;
|
||||
}
|
||||
Some(merchant_grab(model, index))
|
||||
});
|
||||
let Some(grab) = grab else { return Ok(()) };
|
||||
|
||||
// Every other leg clears with the real `ClearCursor(1,1)` (`0x4fb82d`/`0x4fb83f` on a
|
||||
// refusal, `0x49510b` in the grab), not a bare drop: a held spell fires `CURSOR_UPDATE`
|
||||
// and hides the bar grid, and an armed gift wrap is cancelled (`0x5edf10`).
|
||||
let held = match &model.cursor {
|
||||
Some(CursorPayload::Merchant(m)) => Some(m.row),
|
||||
_ => None,
|
||||
};
|
||||
crate::script::cursor::clear_cursor(&mut model);
|
||||
|
||||
let Some(index) = index else { return Ok(()) };
|
||||
// `_ftol` truncates toward zero, then the bound applies signed. The upper bound is
|
||||
// load-bearing: `2^32 + 1` would narrow to row 0, where the reference bounds against
|
||||
// the vendor count before any narrowing.
|
||||
let row = index.trunc();
|
||||
if row.is_nan() || row < 1.0 || row > f64::from(u32::MAX) {
|
||||
return Ok(());
|
||||
// refusal, `0x495112` in the grab), not a bare drop: a held spell fires `CURSOR_UPDATE`
|
||||
// and hides the bar grid, and an armed gift wrap is cancelled (`0x5edf10`). Its events
|
||||
// run before the grab sets the row, so that `CURSOR_UPDATE` reads an empty cursor.
|
||||
super::tick::verb(lua, crate::script::cursor::clear_cursor);
|
||||
if let Some(payload) = grab {
|
||||
// The grab's `SignalEvent(CURSOR_UPDATE)` (`0x49515e`); mode 5 skips the mode-7
|
||||
// `ACTIONBAR_SHOWGRID` branch.
|
||||
super::tick::verb(lua, |model| {
|
||||
model.cursor = Some(payload);
|
||||
crate::script::cursor::raise_cursor_update(model);
|
||||
});
|
||||
}
|
||||
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
|
||||
let row0 = row as u32 - 1;
|
||||
let Some(merchant) = model.merchant.as_ref() else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(item) = merchant.items.get(row0 as usize) else {
|
||||
return Ok(());
|
||||
};
|
||||
// The toggle-off: naming the held row puts nothing back.
|
||||
if held == Some(row0) {
|
||||
return Ok(());
|
||||
}
|
||||
let payload = CursorPayload::Merchant(super::cursor::CursorMerchantItem {
|
||||
item_id: item.item_id,
|
||||
row: row0,
|
||||
texture: item.texture.clone(),
|
||||
});
|
||||
// The grab's `SignalEvent(CURSOR_UPDATE)` (`0x495159`); mode 5 skips the mode-7
|
||||
// `ACTIONBAR_SHOWGRID` branch.
|
||||
model.cursor = Some(payload);
|
||||
crate::script::cursor::queue_cursor_update(&mut model);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -447,13 +451,17 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"ShowRepairCursor",
|
||||
lua.create_function(|lua, ()| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
// `0x4fbcc0`: a targeting spell or a vendor without repair service leaves the
|
||||
// cursor untouched. `ClearCursor(1,1)` returns any held item before mode 0x11 arms.
|
||||
if model.spell_targeting || !model.merchant.as_ref().is_some_and(|m| m.can_repair) {
|
||||
return Ok(());
|
||||
// cursor untouched. `ClearCursor(1,1)` returns any held item before mode 0x11 arms,
|
||||
// its events running first.
|
||||
{
|
||||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||||
if model.spell_targeting || !model.merchant.as_ref().is_some_and(|m| m.can_repair) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
crate::script::cursor::clear_cursor(&mut model);
|
||||
super::tick::verb(lua, crate::script::cursor::clear_cursor);
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
model.repair_mode = true;
|
||||
model.ui_cursor = None;
|
||||
model.ui_cursor_dirty = true;
|
||||
@@ -892,7 +900,10 @@ mod tests {
|
||||
assert!(s.cursor_item().is_some());
|
||||
|
||||
s.set_spell_targeting(false);
|
||||
// The clear's `CURSOR_UPDATE` (`0x4fbcd9`) runs before repair mode arms.
|
||||
crate::script::tick::heard::listen(&s, &["CURSOR_UPDATE"], Some("InRepairMode()"));
|
||||
s.run("ShowRepairCursor()").unwrap();
|
||||
assert_eq!(crate::script::tick::heard::take(&s), ["CURSOR_UPDATE nil"]);
|
||||
assert!(s.repair_mode());
|
||||
assert!(
|
||||
s.cursor_item().is_none(),
|
||||
@@ -1157,7 +1168,7 @@ mod tests {
|
||||
texture: None,
|
||||
passive: false,
|
||||
}));
|
||||
cursor::queue_cursor_update(&mut s.model_mut()); // the pickup's own transition
|
||||
cursor::raise_cursor_update(&mut s.model_mut()); // the pickup's own transition
|
||||
assert_eq!(cursor_event_counts(&mut s), (1, 1, 0), "the spell pickup");
|
||||
|
||||
s.run("PickupMerchantItem(0)").unwrap();
|
||||
|
||||
@@ -1019,22 +1019,17 @@ pub(crate) struct Model {
|
||||
pub(crate) chat_bubbles: super::chat_bubble::ChatBubbles,
|
||||
/// The world-state readout's rows, gated and resolved by the app.
|
||||
pub(crate) worldstate: super::worldstate::WorldStateUiState,
|
||||
/// Events raised outside a dispatch, fired at the start of the next tick ahead of its `OnUpdate`
|
||||
/// pass (`UiScript::tick`). The reference queues nothing here: a fire site calls
|
||||
/// `SignalEvent` (`0x703e50`, `0x703f50`), which runs every handler before it returns, so a
|
||||
/// verb's event reaches its listeners inside the call.
|
||||
///
|
||||
/// A binding can do the same: `tick::fire_event_into` re-enters dispatch from inside Lua, as
|
||||
/// `SetCVar`'s `CVAR_UPDATE` does, once the binding holds no model borrow. So the queue is no
|
||||
/// limit, and a Lua verb that pushes here instead is a gap against the reference: the cursor
|
||||
/// verbs' `CURSOR_UPDATE`, grid, `ITEM_LOCK_CHANGED`, `DELETE_ITEM_CONFIRM` and
|
||||
/// `PLAYER_MONEY`, the send-mail verbs' events, `SetGuildRecruitmentMode`'s
|
||||
/// `UPDATE_CHAT_WINDOWS`, `SetBattlefieldScoreFaction`'s `UPDATE_BATTLEFIELD_SCORE`,
|
||||
/// `UpdateInventoryAlertStatus`'s `UPDATE_INVENTORY_ALERTS`, `CancelAreaSpiritHeal`'s
|
||||
/// `AREA_SPIRIT_HEALER_OUT_OF_RANGE` and the trainer verbs' `TRAINER_UPDATE`. The host's own
|
||||
/// setters (the world map's, the world-state rows, a dropped mail attachment) and
|
||||
/// `UiScript::queue_event` have no verb to fire inside and queue by design.
|
||||
/// The host's event queue, fired at the start of the next tick ahead of its `OnUpdate` pass
|
||||
/// (`UiScript::tick`). Host producers only: the app's setters (the world map's, the
|
||||
/// world-state rows, a dropped mail attachment), `UiScript::queue_event`, and an engine path
|
||||
/// such as a world click that clears the cursor, which has no verb to fire inside and queues
|
||||
/// so it stays behind the input already queued. A Lua verb never lands here: the reference's
|
||||
/// `SignalEvent` (`0x703e50`, `0x703f50`) runs every handler before it returns, so a verb's
|
||||
/// events fire inside the call, through [`super::tick::verb`].
|
||||
pub(crate) pending_events: Vec<(String, Vec<ScriptValue>)>,
|
||||
/// The events the running [`super::tick::verb`] segment has raised, fired in order once its
|
||||
/// model borrow drops; `None` outside a segment, where [`Model::raise`] queues instead.
|
||||
pub(crate) verb_events: Option<Vec<(String, Vec<ScriptValue>)>>,
|
||||
/// The last cursor position in UI space (logical px, y-up), behind `GetCursorPosition()`.
|
||||
pub(crate) cursor_pos: (f32, f32),
|
||||
|
||||
@@ -1543,6 +1538,7 @@ impl Model {
|
||||
chat_bubbles: super::chat_bubble::ChatBubbles::default(),
|
||||
worldstate: super::worldstate::WorldStateUiState::default(),
|
||||
pending_events: Vec::new(),
|
||||
verb_events: None,
|
||||
cursor_pos: (0.0, 0.0),
|
||||
minimap_ping_request: None,
|
||||
minimap_ping: (0.0, 0.0),
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
use mlua::{Lua, MultiValue, Value};
|
||||
|
||||
use super::binding_abi::{bool_or_default, flag};
|
||||
use super::cursor::{queue_cursor_update, CursorPayload, CursorSpell};
|
||||
use super::cursor::{raise_cursor_update, CursorPayload, CursorSpell};
|
||||
use super::{Model, ScriptCall};
|
||||
|
||||
const BOOKTYPE_SPELL: &str = "spell";
|
||||
@@ -316,7 +316,7 @@ fn pickup_spell(model: &mut Model, id: u32, book_type: &str) -> bool {
|
||||
})
|
||||
};
|
||||
model.cursor = Some(payload);
|
||||
queue_cursor_update(model);
|
||||
raise_cursor_update(model);
|
||||
true
|
||||
}
|
||||
|
||||
@@ -581,8 +581,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"PickupSpell",
|
||||
lua.create_function(|lua, (id, book_type): (Value, Value)| {
|
||||
let (id, book_type) = spell_slot_args(id, book_type, "PickupSpell")?;
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
pickup_spell(&mut model, id, &book_type);
|
||||
super::tick::verb(lua, |model| pickup_spell(model, id, &book_type));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -186,6 +186,56 @@ fn drag_verb(from: u8, to: u8, state: &StableState) -> Option<StableIntent> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `ClickStablePet(i)`'s one value (`0x4cb420`), keyed only on whether the cursor held a pet: a
|
||||
/// plain click selects and answers 1, so the stock window repaints; a drop answers nil, packet or
|
||||
/// not, and the repaint comes with the server's next list.
|
||||
fn click_stable_pet(model: &mut Model, i: i64) -> Value {
|
||||
let Some(to) = slot_index(i) else {
|
||||
// Out of range: a click selects nothing, a drop clears the cursor.
|
||||
if !matches!(
|
||||
model.cursor,
|
||||
Some(super::cursor::CursorPayload::StablePet(_))
|
||||
) {
|
||||
model.stable.selected = 0;
|
||||
return Value::Number(1.0);
|
||||
}
|
||||
super::cursor::clear_cursor(model);
|
||||
return Value::Nil;
|
||||
};
|
||||
let to = to as u8;
|
||||
|
||||
// A drop.
|
||||
if let Some(super::cursor::CursorPayload::StablePet(held)) = model.cursor.clone() {
|
||||
let from = held.slot;
|
||||
let intent = model
|
||||
.stable
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| drag_verb(from, to, state));
|
||||
super::cursor::clear_cursor(model);
|
||||
if let Some(intent) = intent {
|
||||
model.stable.intents.push(intent);
|
||||
}
|
||||
return Value::Nil;
|
||||
}
|
||||
|
||||
// A plain click: select only.
|
||||
let pet_number = model
|
||||
.stable
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|s| s.slots[usize::from(to)].as_ref())
|
||||
.map(|p| p.pet_number);
|
||||
model.stable.selected = match (to, pet_number) {
|
||||
// Slot 0 selects the summoned pet by its sentinel, not a pet number.
|
||||
(0, _) => -1,
|
||||
(_, Some(n)) => n as i32,
|
||||
// An empty slot selects nothing.
|
||||
(_, None) => 0,
|
||||
};
|
||||
Value::Number(1.0)
|
||||
}
|
||||
|
||||
/// Register the stable globals.
|
||||
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
let g = lua.globals();
|
||||
@@ -296,57 +346,11 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
})?,
|
||||
)?;
|
||||
|
||||
// ClickStablePet(i) → one value (`0x4cb420`), keyed only on whether the cursor held a pet: a
|
||||
// plain click selects and answers 1, so the stock window repaints; a drop answers nil, packet
|
||||
// or not, and the repaint comes with the server's next list.
|
||||
// ClickStablePet(i) → one value ([`click_stable_pet`]).
|
||||
g.set(
|
||||
"ClickStablePet",
|
||||
lua.create_function(|lua, i: i64| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let Some(to) = slot_index(i) else {
|
||||
// Out of range: a click selects nothing, a drop clears the cursor.
|
||||
if !matches!(
|
||||
model.cursor,
|
||||
Some(super::cursor::CursorPayload::StablePet(_))
|
||||
) {
|
||||
model.stable.selected = 0;
|
||||
return Ok(Value::Number(1.0));
|
||||
}
|
||||
super::cursor::clear_cursor(&mut model);
|
||||
return Ok(Value::Nil);
|
||||
};
|
||||
let to = to as u8;
|
||||
|
||||
// A drop.
|
||||
if let Some(super::cursor::CursorPayload::StablePet(held)) = model.cursor.clone() {
|
||||
let from = held.slot;
|
||||
let intent = model
|
||||
.stable
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|state| drag_verb(from, to, state));
|
||||
super::cursor::clear_cursor(&mut model);
|
||||
if let Some(intent) = intent {
|
||||
model.stable.intents.push(intent);
|
||||
}
|
||||
return Ok(Value::Nil);
|
||||
}
|
||||
|
||||
// A plain click: select only.
|
||||
let pet_number = model
|
||||
.stable
|
||||
.state
|
||||
.as_ref()
|
||||
.and_then(|s| s.slots[usize::from(to)].as_ref())
|
||||
.map(|p| p.pet_number);
|
||||
model.stable.selected = match (to, pet_number) {
|
||||
// Slot 0 selects the summoned pet by its sentinel, not a pet number.
|
||||
(0, _) => -1,
|
||||
(_, Some(n)) => n as i32,
|
||||
// An empty slot selects nothing.
|
||||
(_, None) => 0,
|
||||
};
|
||||
Ok(Value::Number(1.0))
|
||||
Ok(super::tick::verb(lua, |model| click_stable_pet(model, i)))
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -355,19 +359,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"PickupStablePet",
|
||||
lua.create_function(|lua, i: i64| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
let texture = slot_index(i)
|
||||
.and_then(|i| model.stable.state.as_ref()?.slots[i].as_ref())
|
||||
.and_then(|p| p.icon.clone());
|
||||
if let Some(texture) = texture {
|
||||
model.cursor = Some(super::cursor::CursorPayload::StablePet(
|
||||
super::cursor::CursorStablePet {
|
||||
slot: i as u8,
|
||||
texture,
|
||||
},
|
||||
));
|
||||
super::cursor::queue_cursor_update(&mut model);
|
||||
}
|
||||
super::tick::verb(lua, |model| {
|
||||
let texture = slot_index(i)
|
||||
.and_then(|i| model.stable.state.as_ref()?.slots[i].as_ref())
|
||||
.and_then(|p| p.icon.clone());
|
||||
if let Some(texture) = texture {
|
||||
model.cursor = Some(super::cursor::CursorPayload::StablePet(
|
||||
super::cursor::CursorStablePet {
|
||||
slot: i as u8,
|
||||
texture,
|
||||
},
|
||||
));
|
||||
super::cursor::raise_cursor_update(model);
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -9,10 +9,11 @@ use super::{event, Model, ScriptValue, UiScript};
|
||||
|
||||
impl UiScript {
|
||||
/// Queue an event for the start of the next tick, behind everything queued earlier and before
|
||||
/// that tick's `OnUpdate` pass. A drain runs a step behind its input, so firing at once would
|
||||
/// overtake events that input already queued: the soulbind confirm for an item placed into a
|
||||
/// worn slot must follow that placement's `CURSOR_UPDATE`, whose handler hides it
|
||||
/// (`UIParent.lua:357-361`).
|
||||
/// that tick's `OnUpdate` pass: the host's lane ([`Model::pending_events`]), for a producer
|
||||
/// outside any Lua verb. A drain runs a step behind its input, so firing at once would
|
||||
/// overtake events the host already queued. A Lua verb's own events never wait here: they
|
||||
/// fire inside it ([`verb`]), so the soulbind confirm for an item placed into a worn slot
|
||||
/// follows that placement's `CURSOR_UPDATE`, whose handler hides it (`UIParent.lua:357-361`).
|
||||
pub fn queue_event(&mut self, event: &str, args: Vec<ScriptValue>) {
|
||||
self.model_mut()
|
||||
.pending_events
|
||||
@@ -33,6 +34,40 @@ impl UiScript {
|
||||
}
|
||||
}
|
||||
|
||||
impl Model {
|
||||
/// Raise `event` from engine code. Inside a [`verb`] segment it fires before the verb
|
||||
/// returns, once the segment's borrow drops; anywhere else it joins the host queue
|
||||
/// ([`Model::pending_events`]) for the next tick. So a helper shared by a Lua verb and a host
|
||||
/// path, such as the cursor's clear, fires in place for the one and queues for the other.
|
||||
pub(crate) fn raise(&mut self, event: &str, args: Vec<ScriptValue>) {
|
||||
let raised = (event.to_string(), args);
|
||||
match &mut self.verb_events {
|
||||
Some(segment) => segment.push(raised),
|
||||
None => self.pending_events.push(raised),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Run one segment of a Lua verb with the model borrowed, then fire every event it raised, in
|
||||
/// order, after the borrow drops and before the binding returns, as `SignalEvent` (`0x703e50`)
|
||||
/// and `SignalEvent2` (`0x703f50`) walk the listeners in place. A verb whose reference signals
|
||||
/// and then changes state a getter reads runs one segment per such step, so each handler sees
|
||||
/// the state the reference's sees. A handler that calls another verb runs that verb's segments
|
||||
/// inside this fan-out, the reference's synchronous nesting.
|
||||
pub(crate) fn verb<R>(lua: &Lua, body: impl FnOnce(&mut Model) -> R) -> R {
|
||||
let (out, raised) = {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data set");
|
||||
let outer = model.verb_events.replace(Vec::new());
|
||||
debug_assert!(outer.is_none(), "a verb segment opened inside another");
|
||||
let out = body(&mut model);
|
||||
(out, model.verb_events.take().unwrap_or_default())
|
||||
};
|
||||
for (event, args) in raised {
|
||||
fire_event_into(lua, &event, args);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// [`UiScript::fire_event`] for a caller holding `&Lua`, such as a Lua binding.
|
||||
pub(crate) fn fire_event_into(lua: &Lua, event: &str, args: Vec<ScriptValue>) {
|
||||
let model_mut = || lua.app_data_mut::<Model>().expect("model app_data set");
|
||||
@@ -241,3 +276,81 @@ fn paints(model: &super::Model, h: FrameHandle, f: &crate::widget::Frame) -> boo
|
||||
let eps = crate::layout::SIZE_EPS;
|
||||
f64::from(wide.abs()) >= eps && f64::from(tall.abs()) >= eps
|
||||
}
|
||||
|
||||
/// A Lua listener that logs each delivery in order, for the tests that pin when a verb's events
|
||||
/// fire and what their handlers read.
|
||||
#[cfg(test)]
|
||||
pub(crate) mod heard {
|
||||
use super::UiScript;
|
||||
|
||||
/// Register one frame for `events`; each delivery appends `EVENT` or, with a `probe`, `EVENT
|
||||
/// probe-value`, the probe being a Lua expression read inside the handler.
|
||||
pub(crate) fn listen(s: &UiScript, events: &[&str], probe: Option<&str>) {
|
||||
let register: String = events
|
||||
.iter()
|
||||
.map(|e| format!("HEARD_FRAME:RegisterEvent(\"{e}\")\n"))
|
||||
.collect();
|
||||
let line = probe.map_or_else(
|
||||
|| "event".to_string(),
|
||||
|p| format!("event .. \" \" .. tostring({p})"),
|
||||
);
|
||||
s.run(&format!(
|
||||
"HEARD = {{}}\n\
|
||||
HEARD_FRAME = HEARD_FRAME or CreateFrame(\"Frame\")\n\
|
||||
{register}\
|
||||
HEARD_FRAME:SetScript(\"OnEvent\", function() table.insert(HEARD, {line}) end)"
|
||||
))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The deliveries since the last call, with no tick run: what fired inside the verbs.
|
||||
pub(crate) fn take(s: &UiScript) -> Vec<String> {
|
||||
s.eval::<Vec<String>>("local h = HEARD HEARD = {} return h")
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{heard, verb};
|
||||
use crate::script::UiScript;
|
||||
|
||||
/// A verb's events have run their handlers by the verb's next Lua line, in the order raised,
|
||||
/// and none is left for the tick.
|
||||
#[test]
|
||||
fn a_verb_segment_fires_its_events_before_the_binding_returns() {
|
||||
let s = UiScript::new().unwrap();
|
||||
s.lua
|
||||
.globals()
|
||||
.set(
|
||||
"RaiseTwo",
|
||||
s.lua
|
||||
.create_function(|lua, ()| {
|
||||
verb(lua, |model| {
|
||||
model.raise("CURSOR_UPDATE", Vec::new());
|
||||
model.raise("PLAYER_MONEY", Vec::new());
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
heard::listen(&s, &["CURSOR_UPDATE", "PLAYER_MONEY"], None);
|
||||
let seen: Vec<String> = s
|
||||
.eval("RaiseTwo() local h = HEARD HEARD = {} return h")
|
||||
.unwrap();
|
||||
assert_eq!(seen, ["CURSOR_UPDATE", "PLAYER_MONEY"]);
|
||||
assert!(s.model_ref().pending_events.is_empty());
|
||||
}
|
||||
|
||||
/// Outside a segment, a raise is the host's and waits for the next tick.
|
||||
#[test]
|
||||
fn a_raise_outside_a_verb_queues_for_the_tick() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
heard::listen(&s, &["CURSOR_UPDATE"], None);
|
||||
s.model_mut().raise("CURSOR_UPDATE", Vec::new());
|
||||
assert!(heard::take(&s).is_empty());
|
||||
s.tick(0.0);
|
||||
assert_eq!(heard::take(&s), ["CURSOR_UPDATE"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,8 +139,8 @@ fn click_trade_button(model: &mut Model, id: u32) {
|
||||
Some(CursorPayload::Item(item)) => {
|
||||
let (bag, slot) = (item.bag, item.slot);
|
||||
model.trade_set_items.push((id, bag, slot));
|
||||
cursor::queue_cursor_update(model);
|
||||
cursor::queue_lock_changed(model, bag, slot);
|
||||
cursor::raise_cursor_update(model);
|
||||
cursor::raise_lock_changed(model);
|
||||
}
|
||||
None => {
|
||||
let filled = matches!(
|
||||
@@ -355,8 +355,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"ClickTradeButton",
|
||||
lua.create_function(|lua, id: u32| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
click_trade_button(&mut model, id);
|
||||
super::tick::verb(lua, |model| click_trade_button(model, id));
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
@@ -366,8 +365,7 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
"ClickTargetTradeButton",
|
||||
lua.create_function(|lua, _id: u32| {
|
||||
// Coins on the cursor still go into our offer, the index unread (`0x4c00a3`).
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
cursor::money::add_trade_money(&mut model);
|
||||
super::tick::verb(lua, cursor::money::add_trade_money);
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -377,14 +377,13 @@ fn selected_row(model: &Model) -> Option<usize> {
|
||||
.checked_add(1)
|
||||
}
|
||||
|
||||
/// Queue `TRAINER_UPDATE`, which the reference fires from its mask commits, not from Lua:
|
||||
/// `0x4d8c90` writes the filter mask, re-runs the finalizer and fires event `0x136`, and the
|
||||
/// skill-line and expand-mask commits (`0x4d8cb0`, `0x4d8cd0`, the collapse verbs') do the same.
|
||||
/// The stock window's filter click (`Blizzard_TrainerUI.lua:524`) does not repaint.
|
||||
fn queue_trainer_update(model: &mut Model) {
|
||||
model
|
||||
.pending_events
|
||||
.push(("TRAINER_UPDATE".to_string(), Vec::new()));
|
||||
/// Raise `TRAINER_UPDATE`, as the reference's mask commits do after the re-filter: `0x4d8c90`
|
||||
/// writes the type mask, re-runs the finalizer `0x4d8410` and signals at `0x4d8ca0`; the
|
||||
/// skill-line and expand-mask commits (`0x4d8cb0`, `0x4d8cd0`, the collapse verbs') do the same
|
||||
/// (`0x4d8cc0`, `0x4d8ce0`). The stock window's filter click (`Blizzard_TrainerUI.lua:524`) does
|
||||
/// not repaint.
|
||||
fn raise_trainer_update(model: &mut Model) {
|
||||
model.raise("TRAINER_UPDATE", Vec::new());
|
||||
}
|
||||
|
||||
fn set_collapsed(model: &mut Model, id: usize, collapse: bool) {
|
||||
@@ -660,13 +659,14 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"SetTrainerServiceTypeFilter",
|
||||
lua.create_function(|lua, (kind, on): (String, Value)| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
if let Some(c) = TrainerServiceCategory::from_filter_str(&kind) {
|
||||
// The stock window passes 1 or 0; nil, false and 0 disable.
|
||||
let enable = !matches!(on, Value::Nil | Value::Integer(0) | Value::Boolean(false));
|
||||
model.trainer_filter[c.filter_slot()] = enable;
|
||||
// Fires even when the bit did not move, as the reference's thunk does.
|
||||
queue_trainer_update(&mut model);
|
||||
super::tick::verb(lua, |model| {
|
||||
model.trainer_filter[c.filter_slot()] = enable;
|
||||
// Fires even when the bit did not move, as the reference's setter does.
|
||||
raise_trainer_update(model);
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
@@ -677,18 +677,20 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
||||
g.set(
|
||||
"CollapseTrainerSkillLine",
|
||||
lua.create_function(|lua, id: usize| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
set_collapsed(&mut model, id, true);
|
||||
queue_trainer_update(&mut model);
|
||||
super::tick::verb(lua, |model| {
|
||||
set_collapsed(model, id, true);
|
||||
raise_trainer_update(model);
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
g.set(
|
||||
"ExpandTrainerSkillLine",
|
||||
lua.create_function(|lua, id: usize| {
|
||||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||||
set_collapsed(&mut model, id, false);
|
||||
queue_trainer_update(&mut model);
|
||||
super::tick::verb(lua, |model| {
|
||||
set_collapsed(model, id, false);
|
||||
raise_trainer_update(model);
|
||||
});
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
|
||||
@@ -685,3 +685,19 @@ fn talent_order_sorts_on_state_within_a_group() {
|
||||
let got: Vec<String> = visible(&mut s).into_iter().map(|(n, _)| n).collect();
|
||||
assert_eq!(got, ["Riding", "Aye", "Bee", "Cee"]);
|
||||
}
|
||||
|
||||
/// The mask commits fire `TRAINER_UPDATE` inside the verb, after the re-filter (`0x4d8410`, then
|
||||
/// `0x4d8ca0`/`0x4d8ce0`): a handler reads the filtered tree before the next Lua line runs.
|
||||
#[test]
|
||||
fn the_filter_and_collapse_verbs_fire_trainer_update_in_place() {
|
||||
let mut s = UiScript::new().unwrap();
|
||||
s.set_trainer(Some(trainer()));
|
||||
crate::script::tick::heard::listen(&s, &["TRAINER_UPDATE"], Some("GetNumTrainerServices()"));
|
||||
let seen: Vec<String> = s
|
||||
.eval(
|
||||
"SetTrainerServiceTypeFilter('used', 0) local h = HEARD HEARD = {} \
|
||||
CollapseTrainerSkillLine(1) table.insert(h, HEARD[1]) return h",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(seen, ["TRAINER_UPDATE 4", "TRAINER_UPDATE 3"]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user