From f768cf3e60e9d1251ba99e3d5a594b7e8456939e Mon Sep 17 00:00:00 2001 From: Sam Sung <51686767+samwhosung@users.noreply.github.com> Date: Tue, 29 Sep 2026 21:11:47 +0700 Subject: [PATCH] CastSpellByName's onSelf and CastSpell's third argument cast the spell at the player, the pet book's at the player too, read through GetBoolOrDefault as the reference does (#634) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `CastSpellByName(name, onSelf)` dropped its second argument, and `CastSpell(slot, book, onSelf)` read only two. So a self-cast macro line such as `CastSpellByName("Power Word: Fortitude", 1)` buffed the selected friend instead of you. With no friendly target and `autoSelfCast` off, it did not cast at all. In the reference, `CastSpellByName` (`0x4b4ab0`) reads its second argument through `GetBoolOrDefault(L, 2, 0)` (`0x4b4aea`-`0x4b4af1`). `CastSpell` (`0x4b42f0`) reads its third the same way (`0x4b4333`-`0x4b433c`). When the value is nonzero, the active player's guid replaces the selection as the guid handed to the dispatcher `0x4b3300` (`0x4b4afa`, `0x4b4345`). The player's book then binds it through `ArmCast`'s explicit-guid leg (`0x6e5393`). The pet book sends it as `CMSG_PET_ACTION`'s target (`0x4b34ce`). - `ScriptCall::CastSpell` and `CastPetSpell` now carry `on_self`, read with `binding_abi::bool_or_default`. That is not Lua truthiness: `"0"`, `"false"` and `0.5` are false; `"yes"`, `"on"` and `1` are true. - The action bar's self-bind swap is now `CastContext::self_bound`, which `UseAction`'s `onSelf` and the two script casts share. `ui_spellbook::cast_spell` applies it and hands `on_self` to the attack pick. - The pet book's `CMSG_PET_ACTION` aims at the player under `onSelf`. Tests: - `cast_spell_by_name_reads_on_self_as_the_reference_coerces_it` and `cast_spell_reads_on_self_from_its_third_argument` cover the coercion. - `on_self_casts_at_the_player_and_leaves_the_selection`, `on_self_binds_the_player_where_the_selection_is_not_assistable` and `a_pet_book_cast_on_self_aims_at_the_player` check the wire target through `apply_script_calls`. Fixes #446 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/benilla-app/src/net.rs | 5 +- crates/benilla-app/src/script_calls.rs | 147 ++++++++++++++++- crates/benilla-app/src/spell/cast_target.rs | 14 ++ crates/benilla-app/src/ui_action/drain.rs | 17 +- crates/benilla-app/src/ui_pet/drain.rs | 8 + crates/benilla-app/src/ui_pet_book.rs | 11 +- .../src/ui_script/spellbook_tests.rs | 12 +- crates/benilla-app/src/ui_spellbook.rs | 14 +- crates/benilla-ui/src/script/calls.rs | 10 +- crates/benilla-ui/src/script/spellbook.rs | 152 ++++++++++++++---- 10 files changed, 314 insertions(+), 76 deletions(-) diff --git a/crates/benilla-app/src/net.rs b/crates/benilla-app/src/net.rs index d4d63e79..9975565f 100644 --- a/crates/benilla-app/src/net.rs +++ b/crates/benilla-app/src/net.rs @@ -960,8 +960,9 @@ pub(crate) enum ClientCommand { toggles: u8, }, /// `CMSG_PET_ACTION`: `packed` is the slot's word as the server sent it, dispatched on its type - /// byte; `target_guid` is our selection (`0x4bd212`). The server does not reply, so the - /// caller applies the change locally first. + /// byte; `target_guid` is our selection (`0x4bd212`), or the player under the pet book's + /// `onSelf` (`0x4b4345`). The server does not reply, so the caller applies the change locally + /// first. PetAction { pet_guid: u64, packed: u32, diff --git a/crates/benilla-app/src/script_calls.rs b/crates/benilla-app/src/script_calls.rs index 6afe032e..7231a433 100644 --- a/crates/benilla-app/src/script_calls.rs +++ b/crates/benilla-app/src/script_calls.rs @@ -49,8 +49,8 @@ pub(crate) fn apply_script_calls( ScriptCall::SpellTargetUnit(token) => appliers.p1().spell_target_unit(&token), ScriptCall::SpellStopTargeting => appliers.p1().stop_targeting(), ScriptCall::SpellStopCasting => appliers.p2().stop_casting(), - ScriptCall::CastSpell(spell_id) => { - crate::ui_spellbook::cast_spell(&mut appliers.p3(), spell_id); + ScriptCall::CastSpell { spell_id, on_self } => { + crate::ui_spellbook::cast_spell(&mut appliers.p3(), spell_id, on_self); } ScriptCall::CastShapeshiftForm(spell_id) => { crate::ui_shapeshift::cast_form(&mut appliers.p3(), spell_id); @@ -65,8 +65,8 @@ pub(crate) fn apply_script_calls( appliers.p5().use_container_item(script, bag, slot); } ScriptCall::UseInventoryItem(id) => appliers.p5().use_inventory_item(script, id), - ScriptCall::CastPetSpell(spell_id) => { - crate::ui_pet_book::cast_pet_spell(&mut appliers.p6(), spell_id); + ScriptCall::CastPetSpell { spell_id, on_self } => { + crate::ui_pet_book::cast_pet_spell(&mut appliers.p6(), spell_id, on_self); } ScriptCall::PetAction(slot) => appliers.p6().press_slot(slot), ScriptCall::PetOrder(packed) => appliers.p6().order(packed), @@ -123,7 +123,7 @@ mod tests { use benilla_protocol::messages::{ ActionButton, GroupMemberEntry, ACTION_KIND_MACRO, ACTION_KIND_SPELL, }; - use benilla_ui::script::{SpellBookState, SpellSlotView, SpellTabView}; + use benilla_ui::script::{PetBookState, SpellBookState, SpellSlotView, SpellTabView}; use bevy::ecs::system::RunSystemOnce; use crossbeam_channel::Receiver; @@ -352,6 +352,143 @@ mod tests { .collect() } + /// `CastSpellByName(name, onSelf)` (`0x4b4ab0`) and `CastSpell(slot, book, onSelf)` (`0x4b42f0`) + /// swap the target guid for the active player's (`0x4b4afa`, `0x4b4345`) when + /// `GetBoolOrDefault` reads the flag true, so the cast goes to us whoever is selected, and the + /// selection stays where it was. Without the flag it goes to the selection. + #[test] + fn on_self_casts_at_the_player_and_leaves_the_selection() { + for (lua, target, selection) in [ + (r#"CastSpellByName("Heal", 1)"#, ME, OLD), + (r#"CastSpellByName("Heal", "yes")"#, ME, OLD), + (r#"CastSpell(1, "spell", 1)"#, ME, OLD), + ( + r#"TargetUnit("party1") CastSpellByName("Heal", 1)"#, + ME, + NEW, + ), + (r#"CastSpellByName("Heal")"#, OLD, OLD), + (r#"CastSpellByName("Heal", 0.5)"#, OLD, OLD), + (r#"CastSpellByName("Heal", "false")"#, OLD, OLD), + (r#"CastSpell(1, "spell")"#, OLD, OLD), + (r#"CastSpell(1, "spell", "0")"#, OLD, OLD), + ] { + let mut f = frame(true); + run(&mut f, lua); + assert_eq!(casts(&f), vec![(HEAL, Some(target))], "{lua}"); + assert_eq!(selected(&f), Some(selection), "{lua}"); + } + } + + /// Power Word: Fortitude's word, an assist unit (implicit target 21), in the catalog and + /// the book. + const FORTITUDE: u32 = 1243; + + fn with_fortitude(f: &mut Frame) { + let world = f.app.world_mut(); + let display = benilla_formats::SpellDisplay { + implicit_target_a1: 21, + ..Default::default() + }; + world.insert_resource(crate::ui_action::Spells { + catalog: benilla_formats::SpellCatalog::from_displays([(FORTITUDE, display)].into()), + ..crate::ui_action::Spells::empty_for_tests() + }); + world + .non_send_resource_mut::() + .set_spellbook(SpellBookState { + tabs: vec![SpellTabView { + name: "Priest".into(), + texture: None, + offset: 0, + num_spells: 1, + }], + slots: vec![SpellSlotView { + spell_id: FORTITUDE, + name: "Fortitude".into(), + ..Default::default() + }], + }); + } + + /// The bind runs the ordinary relation chain over the player: with a unit we cannot assist + /// selected and `autoSelfCast` off, the plain call binds nothing (the cursor comes up), while + /// `onSelf` binds us; with a friendly player selected the plain call binds that player. + #[test] + fn on_self_binds_the_player_where_the_selection_is_not_assistable() { + for (selected_template, lua, target) in [ + (FOE, r#"CastSpellByName("Fortitude")"#, None), + (FOE, r#"CastSpellByName("Fortitude", 1)"#, Some(ME)), + (FOE, r#"CastSpell(1, "spell", 1)"#, Some(ME)), + (FRIEND, r#"CastSpellByName("Fortitude")"#, Some(OLD)), + (FRIEND, r#"CastSpellByName("Fortitude", 1)"#, Some(ME)), + ] { + let mut f = frame(true); + // `CanAssist` passes a friendly player-controlled unit (`UNIT_FLAG_PLAYER_CONTROLLED`). + with_factions(&mut f, selected_template, &[(FLAGS, 0x8)]); + with_fortitude(&mut f); + run(&mut f, lua); + let sent: Vec<_> = target.into_iter().map(|t| (FORTITUDE, Some(t))).collect(); + assert_eq!(casts(&f), sent, "{lua} at template {selected_template}"); + assert_eq!(selected(&f), Some(OLD), "{lua}"); + } + } + + /// The pet's guid, and the pet book's Growl (`CMSG_PET_ACTION`'s type-1 word, `0x4b34ce`). + const PET: u64 = 0x50; + const GROWL: u32 = 2649; + + /// The `CMSG_PET_ACTION`s sent, as `(pet, word, target)`. + fn pet_actions(f: &Frame) -> Vec<(u64, u32, u64)> { + f.rx.try_iter() + .filter_map(|c| match c { + ClientCommand::PetAction { + pet_guid, + packed, + target_guid, + } => Some((pet_guid, packed, target_guid)), + _ => None, + }) + .collect() + } + + /// `0x4b3300`'s pet fork sends the guid it was handed as the order's target (`0x4b34ce`), so + /// `CastSpell(slot, "pet", 1)` aims the pet's spell at the player, not the selection. + #[test] + fn a_pet_book_cast_on_self_aims_at_the_player() { + for (lua, target) in [ + (r#"CastSpell(1, "pet", 1)"#, ME), + (r#"CastSpell(1, "pet", "on")"#, ME), + (r#"CastSpell(1, "pet")"#, OLD), + (r#"CastSpell(1, "pet", "0")"#, OLD), + ] { + let mut f = frame(true); + f.app + .world_mut() + .resource_mut::() + .spells + .pet_guid = PET; + f.app + .world_mut() + .non_send_resource_mut::() + .set_pet_book(PetBookState { + token: Some("PET".into()), + slots: vec![SpellSlotView { + spell_id: GROWL, + name: "Growl".into(), + ..Default::default() + }], + }); + run(&mut f, lua); + assert_eq!( + pet_actions(&f), + vec![(PET, 0x0100_0000 | GROWL, target)], + "{lua}" + ); + assert_eq!(selected(&f), Some(OLD), "{lua}"); + } + } + /// `/target` then `/cast`: `TargetUnit` commits through `SetSelection 0x493540` before it /// returns, and `CastSpellByName` (`0x4b4ab0`) casts at the selection as it stands then. #[test] diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index e24ce212..58c37f43 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -131,6 +131,20 @@ pub(crate) struct CastContext<'a> { pub(crate) self_move_flags: u32, } +impl CastContext<'_> { + /// Applies the self-cast modifier, `onSelf`: the caster replaces the selection as the bind + /// candidate, guid and store both, the pair the autoSelfCast fallback swaps (`0x6e53d7`). It + /// is the explicit guid `CastSpell` and `CastSpellByName` hand `ArmCast 0x6e5250` (`0x4b4345`, + /// `0x4b4afa`, bound at `0x6e5393`); where `UseAction 0x4e5ee0` applies its own is untraced. + pub(crate) fn self_bound(mut self, on_self: bool) -> Self { + if on_self { + self.selection_guid = self.self_guid; + self.rel.target_store = self.rel.self_store; + } + self + } +} + /// Positions and combat reaches for the pre-send range refusal: the client's cast runs /// `CanTargetUnit 0x6e4440` and `IsTargetInRange 0x6e47b0` before the commit, so an out-of-range /// press refuses locally and none of the commit tail runs. diff --git a/crates/benilla-app/src/ui_action/drain.rs b/crates/benilla-app/src/ui_action/drain.rs index 8ff79201..a734e009 100644 --- a/crates/benilla-app/src/ui_action/drain.rs +++ b/crates/benilla-app/src/ui_action/drain.rs @@ -177,21 +177,6 @@ pub(super) fn drain_go_openers( } } -/// Applies the self-cast modifier, `UseAction`'s `onSelf` (`SELFACTIONBUTTON1`-`12`): the caster -/// replaces the selection as the bind candidate, guid and store both, the pair the autoSelfCast -/// fallback swaps (`0x6e53d7`). Where `UseAction 0x4e5ee0` applies `onSelf` is untraced; this -/// fills `ArmCast 0x6e5250`'s explicit guid (`0x6e5393`); a forced-self leg would look the same. -fn self_bound<'a>( - mut ctx: cast_target::CastContext<'a>, - press: benilla_ui::script::ActionUse, -) -> cast_target::CastContext<'a> { - if press.on_self { - ctx.selection_guid = ctx.self_guid; - ctx.rel.target_store = ctx.rel.self_store; - } - ctx -} - /// What a `UseAction` press reads and writes. #[derive(bevy::ecs::system::SystemParam)] pub(crate) struct ActionPress<'w, 's> { @@ -279,7 +264,7 @@ pub(crate) fn use_action( ); ladder.send_spell( b.action, - &self_bound(targeting.context(), press), + &targeting.context().self_bound(press.on_self), press.on_self, ); } diff --git a/crates/benilla-app/src/ui_pet/drain.rs b/crates/benilla-app/src/ui_pet/drain.rs index aff04730..4caa30c5 100644 --- a/crates/benilla-app/src/ui_pet/drain.rs +++ b/crates/benilla-app/src/ui_pet/drain.rs @@ -43,6 +43,14 @@ impl PetPress<'_, '_> { } } + /// The guid the pet book's `CastSpell(id, "pet", onSelf)` aims at: the selection, or with + /// `onSelf` the active player (`0x4b4345`). A zero guid falls back to the selection + /// (`0x4b34af`), so a missing player leaves it. + pub(crate) fn spell_target(&self, on_self: bool) -> u64 { + let caster = if on_self { self.pet.self_guid.0 } else { None }; + caster.or(self.selection.guid).unwrap_or(0) + } + /// `PetAttack` and the other one-shot orders, as their slot's packed word. A slot press and /// an order are one press: both reach the reference's one dispatcher (`0x4bd1d0`). The slot /// number only labels logs. diff --git a/crates/benilla-app/src/ui_pet_book.rs b/crates/benilla-app/src/ui_pet_book.rs index ef93575b..6b2124e2 100644 --- a/crates/benilla-app/src/ui_pet_book.rs +++ b/crates/benilla-app/src/ui_pet_book.rs @@ -168,16 +168,17 @@ fn slot_view( } } -/// `CastSpell(id, "pet")`: cancel first, as on the bar (`0x4b33af`-`0x4b3461`), a spell whose -/// aura is on the pet sending `CMSG_PET_CANCEL_AURA` (0x26B) instead of the order; else -/// `CMSG_PET_ACTION` at the selection as the calls before it left it. -pub(crate) fn cast_pet_spell(p: &mut crate::ui_pet::PetPress, spell_id: u32) { +/// `CastSpell(id, "pet", onSelf)`: cancel first, as on the bar (`0x4b33af`-`0x4b3461`), a spell +/// whose aura is on the pet sending `CMSG_PET_CANCEL_AURA` (0x26B) instead of the order; else +/// `CMSG_PET_ACTION` at the selection as the calls before it left it, or with `onSelf` at the +/// player. +pub(crate) fn cast_pet_spell(p: &mut crate::ui_pet::PetPress, spell_id: u32, on_self: bool) { let pet_guid = p.bar.spells.pet_guid; if pet_guid == 0 { debug!("ui_pet_book: dropping a queued pet book cast — the bar is gone"); return; } - let target_guid = p.selection.guid.unwrap_or(0); + let target_guid = p.spell_target(on_self); let display = p.spells.as_ref().and_then(|s| s.catalog.get(spell_id)); if let (Some(d), Some(store)) = (display, p.pet.store(pet_guid)) { if crate::ui_action::toggle::active_action_toggle(spell_id, d, store) { diff --git a/crates/benilla-app/src/ui_script/spellbook_tests.rs b/crates/benilla-app/src/ui_script/spellbook_tests.rs index 66ed8a6d..29d7808a 100644 --- a/crates/benilla-app/src/ui_script/spellbook_tests.rs +++ b/crates/benilla-app/src/ui_script/spellbook_tests.rs @@ -195,7 +195,7 @@ fn shipped_spellbook_drives_end_to_end() { s.mouse_button(x1, y1, "LeftButton", true); s.mouse_button(x1, y1, "LeftButton", false); assert!(s.errors().is_empty(), "click errors: {:?}", s.errors()); - assert_eq!(s.take_spell_casts(), vec![133]); + assert_eq!(s.take_spell_casts(), vec![(133, false)]); assert!(s.cursor_payload().is_none(), "a cast never picks up"); s.set_modifiers(true, false, false); @@ -536,7 +536,7 @@ fn the_pet_tab_switches_books_and_renders_the_pets_spells() { // ── The clicks ──────────────────────────────────────────────────────────────────────────── click(&mut s, "SpellButton1", "LeftButton"); assert!(s.errors().is_empty(), "click errors: {:?}", s.errors()); - assert_eq!(s.take_pet_spell_casts(), vec![2649]); + assert_eq!(s.take_pet_spell_casts(), vec![(2649, false)]); assert!(s.take_spell_casts().is_empty()); assert!(s.take_pet_spell_autocasts().is_empty()); @@ -710,7 +710,11 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() { "right-click errors: {:?}", s.errors() ); - assert_eq!(s.take_spell_casts(), vec![133], "a right-click casts"); + assert_eq!( + s.take_spell_casts(), + vec![(133, false)], + "a right-click casts" + ); assert_eq!( body(&s), "", @@ -719,7 +723,7 @@ fn the_macro_editor_takes_a_shift_click_and_only_a_shift_click() { assert!(s.cursor_payload().is_none()); click(&mut s, "SpellButton1", "LeftButton"); - assert_eq!(s.take_spell_casts(), vec![133]); + assert_eq!(s.take_spell_casts(), vec![(133, false)]); assert_eq!(body(&s), ""); // ── Shift-click appends a whole `/cast` line and never casts ────────────────────────────── diff --git a/crates/benilla-app/src/ui_spellbook.rs b/crates/benilla-app/src/ui_spellbook.rs index e79c0923..767a95cd 100644 --- a/crates/benilla-app/src/ui_spellbook.rs +++ b/crates/benilla-app/src/ui_spellbook.rs @@ -420,9 +420,10 @@ fn leading_number(s: &str) -> u32 { .unwrap_or(0) } -/// `CastSpell(id, "spell")` and `CastSpellByName` (`0x4b3300`'s player leg), through the cast -/// tail the action bar uses, at the selection as the calls before it left it. -pub(crate) fn cast_spell(cast: &mut crate::spell::ScriptCast, spell_id: u32) { +/// `CastSpell(id, "spell", onSelf)` and `CastSpellByName(name, onSelf)` (`0x4b3300`'s player leg), +/// through the cast tail the action bar uses, at the selection as the calls before it left it, or +/// with `onSelf` at the caster. +pub(crate) fn cast_spell(cast: &mut crate::spell::ScriptCast, spell_id: u32, on_self: bool) { let crate::spell::ScriptCast { targeting, ladder } = cast; // `CastSpell`'s two cancel forks (`0x4b3300`), in order: the active-action toggle // (`0x4b36f0`), then the form match (`0x4b348b`), which `UseAction` lacks, with its silent @@ -457,10 +458,11 @@ pub(crate) fn cast_spell(cast: &mut crate::spell::ScriptCast, spell_id: u32) { } } debug!( - "ui_spellbook: cast {spell_id} (target {:?})", - targeting.selection.guid + "ui_spellbook: cast {spell_id} (target {:?}{})", + targeting.selection.guid, + if on_self { ", on self" } else { "" } ); - ladder.send_spell(spell_id, &targeting.context(), false); + ladder.send_spell(spell_id, &targeting.context().self_bound(on_self), on_self); } #[cfg(test)] diff --git a/crates/benilla-ui/src/script/calls.rs b/crates/benilla-ui/src/script/calls.rs index 9dc326a8..c08af75f 100644 --- a/crates/benilla-ui/src/script/calls.rs +++ b/crates/benilla-ui/src/script/calls.rs @@ -33,10 +33,12 @@ pub enum ScriptCall { SpellStopTargeting, /// `SpellStopCasting()` (`0x6e6e80`), made with something to stop. SpellStopCasting, - /// `CastSpell(id, "spell")` and `CastSpellByName`, through `0x4b3300`'s player leg. - CastSpell(u32), - /// `CastSpell(id, "pet")`, `0x4b3300`'s `CMSG_PET_ACTION` fork (`0x4b34ce`). - CastPetSpell(u32), + /// `CastSpell(id, "spell", onSelf)` and `CastSpellByName(name, onSelf)`, through `0x4b3300`'s + /// player leg; `onSelf` swaps the target guid for the active player's (`0x4b4afa`, `0x4b4345`). + CastSpell { spell_id: u32, on_self: bool }, + /// `CastSpell(id, "pet", onSelf)`, `0x4b3300`'s `CMSG_PET_ACTION` fork (`0x4b34ce`), with the + /// same guid swap. + CastPetSpell { spell_id: u32, on_self: bool }, /// `CastShapeshiftForm(index)` (`0x4b4810`), as the form's spell. CastShapeshiftForm(u32), /// `UseAction(slot, …)` (`0x4e7140` → `0x4e5ee0`); a macro slot runs its lines inside it. diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index d28f2ee5..e78a832a 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -11,7 +11,7 @@ use mlua::{Lua, MultiValue, Value}; -use super::binding_abi::flag; +use super::binding_abi::{bool_or_default, flag}; use super::cursor::{queue_cursor_update, CursorPayload, CursorSpell}; use super::{Model, ScriptCall}; @@ -94,11 +94,11 @@ impl super::UiScript { self.model_mut().pet_book = state; } - /// Take the `CastSpell(id, "pet")` calls out of the call stream: each is a `CMSG_PET_ACTION` - /// with a type-1 word (`0x4b34ce`), not a player cast. - pub fn take_pet_spell_casts(&mut self) -> Vec { + /// Take the `CastSpell(id, "pet")` calls out of the call stream as `(spell id, onSelf)`: each + /// is a `CMSG_PET_ACTION` with a type-1 word (`0x4b34ce`), not a player cast. + pub fn take_pet_spell_casts(&mut self) -> Vec<(u32, bool)> { self.take_calls_where(|c| match c { - ScriptCall::CastPetSpell(id) => Some(*id), + ScriptCall::CastPetSpell { spell_id, on_self } => Some((*spell_id, *on_self)), _ => None, }) } @@ -115,10 +115,11 @@ impl super::UiScript { self.model_mut().spellbook.clone() } - /// Take the `CastSpell` and `CastSpellByName` calls out of the call stream. - pub fn take_spell_casts(&mut self) -> Vec { + /// Take the `CastSpell` and `CastSpellByName` calls out of the call stream as + /// `(spell id, onSelf)`. + pub fn take_spell_casts(&mut self) -> Vec<(u32, bool)> { self.take_calls_where(|c| match c { - ScriptCall::CastSpell(id) => Some(*id), + ScriptCall::CastSpell { spell_id, on_self } => Some((*spell_id, *on_self)), _ => None, }) } @@ -474,28 +475,34 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { })?, )?; - // CastSpell(id, bookType), the plain click, queues the slot's spell unless it is passive. On - // the pet book `0x4b3300` forks at `0x4b34c8` (the player's cast is `0x6e5a90`) to send - // `CMSG_PET_ACTION` (0x175), `{ u64 [0xb714a0], u32 (spellId & 0xFFFF) | 0x01000000, u64 - // target }` (`0x4b34ce`), so the pet book casts a spell not on the bar; the target falls back - // to the selection (`0x4b34af`), as in `CastPetAction`. The app sends both at the drain. + // CastSpell(id, bookType [, onSelf]), the plain click, queues the slot's spell unless it is + // passive. The third argument goes through `GetBoolOrDefault(L, 3, 0)` (`0x4b4333`-`0x4b433c`), + // and a nonzero one swaps the target guid for the active player's (`0x4b4345`) before the + // dispatcher `0x4b3300`. On the pet book that forks at `0x4b34c8` (the player's cast is + // `0x6e5a90`) to send `CMSG_PET_ACTION` (0x175), `{ u64 [0xb714a0], u32 (spellId & 0xFFFF) | + // 0x01000000, u64 target }` (`0x4b34ce`), so the pet book casts a spell not on the bar; the + // target falls back to the selection (`0x4b34af`), as in `CastPetAction`. The app sends both at + // the drain. g.set( "CastSpell", - lua.create_function(|lua, (id, book_type): (Value, Value)| { - let (id, book_type) = spell_slot_args(id, book_type, "CastSpell")?; - let mut model = lua.app_data_mut::().expect("model app_data"); - if let Some(slot) = book_slot(&model, id, &book_type) { - if !slot.passive { - let spell_id = slot.spell_id; - model.script_calls.push(if is_pet_book(&book_type) { - ScriptCall::CastPetSpell(spell_id) - } else { - ScriptCall::CastSpell(spell_id) - }); + lua.create_function( + |lua, (id, book_type, on_self): (Value, Value, Option)| { + let (id, book_type) = spell_slot_args(id, book_type, "CastSpell")?; + let on_self = bool_or_default(on_self.as_ref(), false); + let mut model = lua.app_data_mut::().expect("model app_data"); + if let Some(slot) = book_slot(&model, id, &book_type) { + if !slot.passive { + let spell_id = slot.spell_id; + model.script_calls.push(if is_pet_book(&book_type) { + ScriptCall::CastPetSpell { spell_id, on_self } + } else { + ScriptCall::CastSpell { spell_id, on_self } + }); + } } - } - Ok(()) - })?, + Ok(()) + }, + )?, )?; // HasPetSpells() -> numPetSpells, petToken: always two returns (`0x4b4410`), `(nil, nil)` with @@ -552,15 +559,19 @@ pub(super) fn install(lua: &Lua) -> mlua::Result<()> { )?; // CastSpellByName(name [, onSelf]) (`0x4b4ab0`) shares the dispatcher `0x4b3300` with - // `CastSpell`, so it queues the same call; `SlashCmdList["CAST"]` calls it. `onSelf` is - // accepted and ignored: self-cast is not built. + // `CastSpell`, so it queues the same call; `SlashCmdList["CAST"]` calls it. The target guid + // defaults to the selection (`0x4b4adb`), and a nonzero `GetBoolOrDefault(L, 2, 0)` + // (`0x4b4aea`-`0x4b4af1`) swaps it for the active player's (`0x4b4afa`). g.set( "CastSpellByName", - lua.create_function(|lua, (name, _on_self): (String, MultiValue)| { + lua.create_function(|lua, (name, on_self): (String, Option)| { + let on_self = bool_or_default(on_self.as_ref(), false); let mut model = lua.app_data_mut::().expect("model app_data"); if let Some(slot) = resolve_spell_by_name(&model.spellbook, &name) { let spell_id = slot.spell_id; - model.script_calls.push(ScriptCall::CastSpell(spell_id)); + model + .script_calls + .push(ScriptCall::CastSpell { spell_id, on_self }); } Ok(()) })?, @@ -968,7 +979,7 @@ mod tests { s.set_spellbook(book()); s.run(r#"CastSpell(1, BOOKTYPE_SPELL)"#).unwrap(); // Fireball: active - assert_eq!(s.take_spell_casts(), vec![133]); + assert_eq!(s.take_spell_casts(), vec![(133, false)]); s.run(r#"CastSpell(2, BOOKTYPE_SPELL)"#).unwrap(); // Fire Blast: passive, refused assert!(s.take_spell_casts().is_empty()); @@ -981,6 +992,75 @@ mod tests { .unwrap()); } + /// `CastSpellByName`'s second argument goes through `GetBoolOrDefault(L, 2, 0)` + /// (`0x4b4aea`-`0x4b4af1`), which is not Lua truthiness: `"0"`, `"false"` and `0.5` are false. + #[test] + fn cast_spell_by_name_reads_on_self_as_the_reference_coerces_it() { + let mut s = UiScript::new().unwrap(); + s.set_spellbook(book()); + for (arg, on_self) in [ + ("", false), + (", nil", false), + (", false", false), + (", 0", false), + (", 0.5", false), + (r#", "0""#, false), + (r#", "false""#, false), + (r#", "off""#, false), + (", 1", true), + (", true", true), + (", 1, 0", true), + (r#", "1""#, true), + (r#", "yes""#, true), + (r#", "on""#, true), + (r#", "enabled""#, true), + ] { + s.run(&format!(r#"CastSpellByName("Fireball"{arg})"#)) + .unwrap(); + assert_eq!( + s.take_spell_casts(), + vec![(133, on_self)], + "CastSpellByName(\"Fireball\"{arg})" + ); + } + } + + /// `CastSpell`'s third argument is `GetBoolOrDefault(L, 3, 0)` (`0x4b4333`-`0x4b433c`), on the + /// pet book as well, and a passive is refused whatever it says. + #[test] + fn cast_spell_reads_on_self_from_its_third_argument() { + let mut s = UiScript::new().unwrap(); + s.set_spellbook(book()); + s.set_pet_book(pet_book()); + for (arg, on_self) in [ + ("", false), + (", 0.5", false), + (r#", "false""#, false), + (", 1", true), + (", 1, 0", true), + (r#", "yes""#, true), + (r#", "on""#, true), + ] { + s.run(&format!("CastSpell(1, BOOKTYPE_SPELL{arg})")) + .unwrap(); + assert_eq!( + s.take_spell_casts(), + vec![(133, on_self)], + "CastSpell(1, BOOKTYPE_SPELL{arg})" + ); + s.run(&format!("CastSpell(1, BOOKTYPE_PET{arg})")).unwrap(); + assert_eq!( + s.take_pet_spell_casts(), + vec![(2649, on_self)], + "CastSpell(1, BOOKTYPE_PET{arg})" + ); + } + s.run("CastSpell(2, BOOKTYPE_SPELL, 1) CastSpell(3, BOOKTYPE_PET, 1)") + .unwrap(); + assert!(s.take_spell_casts().is_empty(), "a passive is refused"); + assert!(s.take_pet_spell_casts().is_empty(), "a passive is refused"); + } + /// With no pet book, as when the reference's count `[0xb71174]` is 0, every pet arm answers /// nothing. #[test] @@ -1180,8 +1260,12 @@ mod tests { CastSpell(1, BOOKTYPE_SPELL)"#, ) .unwrap(); - assert_eq!(s.take_pet_spell_casts(), vec![2649], "the passive refused"); - assert_eq!(s.take_spell_casts(), vec![133]); + assert_eq!( + s.take_pet_spell_casts(), + vec![(2649, false)], + "the passive refused" + ); + assert_eq!(s.take_spell_casts(), vec![(133, false)]); assert!(s.take_pet_spell_casts().is_empty(), "drain empties"); }