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)

`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) <noreply@anthropic.com>
This commit is contained in:
Sam Sung
2026-09-29 21:11:47 +07:00
committed by GitHub
co-authored by Claude Opus 5.5
parent 666300c6a5
commit f768cf3e60
10 changed files with 314 additions and 76 deletions
+3 -2
View File
@@ -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,
+142 -5
View File
@@ -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::<UiScript>()
.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::<crate::ui_pet::PetBar>()
.spells
.pet_guid = PET;
f.app
.world_mut()
.non_send_resource_mut::<UiScript>()
.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]
@@ -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.
+1 -16
View File
@@ -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,
);
}
+8
View File
@@ -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.
+6 -5
View File
@@ -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) {
@@ -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 ──────────────────────────────
+8 -6
View File
@@ -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)]
+6 -4
View File
@@ -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.
+118 -34
View File
@@ -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<u32> {
/// 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<u32> {
/// 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::<Model>().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<Value>)| {
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::<Model>().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<Value>)| {
let on_self = bool_or_default(on_self.as_ref(), false);
let mut model = lua.app_data_mut::<Model>().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");
}