- Casting a targeted spell ends repair mode, as 1.12.1's targeting arm writes its cursor over Repair - A right mouse-down on the world ends repair mode, as 1.12.1's WorldFrame hook resets the cursor to Point Fixes #420 Fixes #422 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1367 lines
53 KiB
Rust
1367 lines
53 KiB
Rust
//! The spellbook bindings, over a book the app pushes (tabs and a flat slot list, already resolved
|
|
//! to name, rank, icon and passive); `CastSpell` and `PickupSpell` queue intents the app drains.
|
|
//!
|
|
//! FrameXML passes every binding a 1-based book id (`SpellBook_GetSpellID`), and the reference's
|
|
//! marshaller (`0x4b3ec0`) subtracts 1, as [`slot_index`] does. `BOOKTYPE_PET` selects the pet's
|
|
//! slot list, fed from `SMSG_PET_SPELLS`, where `PickupSpell` and `CastSpell` produce a pet action
|
|
//! word and a `CMSG_PET_ACTION` instead.
|
|
//!
|
|
//! Deviation: `BOOKTYPE_SPELL`/`BOOKTYPE_PET` are engine globals here, where the reference defines
|
|
//! them in `SpellBookFrame.lua:5-6`, so this module's tests run without the stock file.
|
|
|
|
use mlua::{Lua, MultiValue, Value};
|
|
|
|
use super::binding_abi::flag;
|
|
use super::cursor::{queue_cursor_update, CursorPayload, CursorSpell};
|
|
use super::Model;
|
|
|
|
const BOOKTYPE_SPELL: &str = "spell";
|
|
const BOOKTYPE_PET: &str = "pet";
|
|
|
|
/// `HasPetSpells`' second return when no token is resolved: the reference's literal (`0x846a40`),
|
|
/// pushed at `0x4b44a6` when the player object does not resolve. FrameXML concatenates it, so
|
|
/// never nil.
|
|
const PET_TOKEN_FALLBACK: &str = "PET";
|
|
|
|
/// One skill-line tab as `GetSpellTabInfo` returns it; `offset` is the tab's 0-based start in
|
|
/// [`SpellBookState::slots`], computed by the app.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct SpellTabView {
|
|
pub name: String,
|
|
pub texture: Option<String>,
|
|
pub offset: u32,
|
|
pub num_spells: u32,
|
|
}
|
|
|
|
/// One spell in the flat book, every field resolved by the app.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct SpellSlotView {
|
|
pub spell_id: u32,
|
|
pub name: String,
|
|
/// The rank line, `Spell.dbc`'s `NameSubtext`; `None` shows no second line.
|
|
pub rank: Option<String>,
|
|
pub texture: Option<String>,
|
|
/// `SPELL_ATTR_PASSIVE`: grays the name and refuses `CastSpell` and `CastSpellByName`, never
|
|
/// a pickup (the reference does not block one).
|
|
pub passive: bool,
|
|
/// `IsCurrentCast`, the checked ring: the reference (`0x4b3600`) answers it only for a
|
|
/// shapeshift into the player's current form or the open trade-skill window's spell, never an
|
|
/// ordinary cast.
|
|
pub current: bool,
|
|
/// `(start_ms on the GetTime clock, duration_ms, enabled)`, the triple the action bar carries;
|
|
/// `None` is cold. The start is absolute, so a running cooldown never churns the book diff.
|
|
pub cooldown: Option<(i64, u32, bool)>,
|
|
/// `GetSpellAutocast`'s `(allowed, enabled)`, pet book only, read off the pet's raw word
|
|
/// (`0x4bd160`, bits 31 and 30).
|
|
pub autocast: Option<(bool, bool)>,
|
|
/// The pet slot's packed word as the server sent it, which `PickupSpell(id, "pet")` puts on the
|
|
/// cursor (`0x4b3260` hands `0x494e20` a pointer to it); 0 in the player book.
|
|
pub packed: u32,
|
|
}
|
|
|
|
/// The pet's book, the reference's second flat array (`0xb6f098`, count `0xb71174`):
|
|
///
|
|
/// - no tabs, so `GetNumSpellTabs` and `GetSpellTabInfo` take no book type: `SpellBookFrame_Update`
|
|
/// hides the skill-line tabs for it (`SpellBookFrame.lua:124`) and `SpellBook_GetSpellID` adds
|
|
/// no tab offset (`SpellBookFrame.lua:460-462`);
|
|
/// - its own add gate (`0x4b2f90`): in `Spell.dbc` with `Attributes & 0x80` (DO_NOT_DISPLAY)
|
|
/// clear, without the player book's tradeskill and `castUI` tests;
|
|
/// - the player book's order: `0x4b2fd0` sorts it with `0x4b30c0` (spell-line group, name, rank).
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct PetBookState {
|
|
/// `HasPetSpells`' second return, `ChrClasses.dbc` field 4 (`"PET"` or `"DEMON"`), a key
|
|
/// FrameXML looks up as `PET_TYPE_<token>`; `None` only while there is no book.
|
|
pub token: Option<String>,
|
|
pub slots: Vec<SpellSlotView>,
|
|
}
|
|
|
|
/// The player's book: the skill-line tabs and the flat slot list they index into.
|
|
#[derive(Clone, Debug, Default, PartialEq)]
|
|
pub struct SpellBookState {
|
|
pub tabs: Vec<SpellTabView>,
|
|
pub slots: Vec<SpellSlotView>,
|
|
}
|
|
|
|
impl super::UiScript {
|
|
/// Replace the book; the app fires `SPELLS_CHANGED` on its own diff.
|
|
pub fn set_spellbook(&mut self, state: SpellBookState) {
|
|
self.model_mut().spellbook = state;
|
|
}
|
|
|
|
/// Replace the pet's book; the app fires `SPELLS_CHANGED`, which the reference fires for both
|
|
/// books off one re-sort (`0x4b2fd0`).
|
|
pub fn set_pet_book(&mut self, state: PetBookState) {
|
|
self.model_mut().pet_book = state;
|
|
}
|
|
|
|
/// Drain the pet spell ids `CastSpell(id, "pet")` queued: 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> {
|
|
std::mem::take(&mut self.model_mut().pet_spell_casts)
|
|
}
|
|
|
|
/// Drain the spell ids `ToggleSpellAutocast` queued: `CMSG_PET_SPELL_AUTOCAST` (0x2F3, sent by
|
|
/// `0x4bccb0`) names a spell id, where the pet bar's toggle sends `CMSG_PET_SET_ACTION`.
|
|
pub fn take_pet_spell_autocasts(&mut self) -> Vec<u32> {
|
|
std::mem::take(&mut self.model_mut().pet_spell_autocasts)
|
|
}
|
|
|
|
/// The pushed book, for resolving a macro's spell by the rule `CastSpellByName` uses, so the
|
|
/// bar and the cast agree on which rank a bare `/cast` means.
|
|
pub fn spellbook(&self) -> SpellBookState {
|
|
self.model_mut().spellbook.clone()
|
|
}
|
|
|
|
/// Drain the spell ids `CastSpell` and `CastSpellByName` queued.
|
|
pub fn take_spell_casts(&mut self) -> Vec<u32> {
|
|
std::mem::take(&mut self.model_mut().spell_casts)
|
|
}
|
|
|
|
/// Whether `SpellStopCasting()` has something to stop: an auto-repeat or an in-flight cast,
|
|
/// never a channel (`0x6e6e80` reads only the auto-repeat key `0xceac30` and the in-flight
|
|
/// id `0xceca88`, which is 0 mid-channel). Pushed each frame before the ESC chain runs.
|
|
pub fn set_casting(&mut self, casting: bool) {
|
|
self.model_mut().casting = casting;
|
|
}
|
|
|
|
/// Drain the `SpellStopCasting()` trigger; the app stops the auto-repeat first, else the
|
|
/// in-flight cast, the reference's order.
|
|
pub fn take_spell_stop(&mut self) -> bool {
|
|
std::mem::take(&mut self.model_mut().spell_stop)
|
|
}
|
|
|
|
/// Whether the spell-targeting cursor is up, for `SpellIsTargeting()` and
|
|
/// `SpellStopTargeting()`; pushed each frame before the input pass runs the ESC chain. Arming
|
|
/// ends repair mode, as `HideRepairCursor` does: the targeting arm writes the Cast base mode
|
|
/// over Repair (`0x6e50b0`), and its end restores Point (`0x6e49f5`, `0x6e554c`), never Repair.
|
|
pub fn set_spell_targeting(&mut self, targeting: bool) {
|
|
let mut model = self.model_mut();
|
|
model.spell_targeting = targeting;
|
|
if targeting {
|
|
model.repair_mode = false;
|
|
}
|
|
}
|
|
|
|
/// Drain the `SpellStopTargeting()` trigger, the ESC chain's rung (`UIParent.lua:1490`); the
|
|
/// app clears its targeting mode.
|
|
pub fn take_stop_targeting(&mut self) -> bool {
|
|
std::mem::take(&mut self.model_mut().spell_stop_targeting)
|
|
}
|
|
}
|
|
|
|
/// Whether `bookType` names the pet book: a case-insensitive compare with `"pet"` alone
|
|
/// (`0x4b3f27`), so any other string, `"spell"` included, is the player's book.
|
|
fn is_pet_book(book_type: &str) -> bool {
|
|
book_type.eq_ignore_ascii_case(BOOKTYPE_PET)
|
|
}
|
|
|
|
/// The reference's shared spell-slot marshaller (`0x4b3ec0`): arg 1 a number, arg 2 a number or
|
|
/// string (`0x4b3ee8`), and the index `trunc(arg1 - 1)` in `[0, 0x400)`, else the binding raises
|
|
/// `Invalid spell slot in <Verb>`. Answers the 1-based id and the normalised book type.
|
|
fn spell_slot_args(id: Value, book_type: Value, verb: &str) -> mlua::Result<(u32, String)> {
|
|
let invalid = || mlua::Error::runtime(format!("Invalid spell slot in {verb}"));
|
|
// `lua_isnumber` + `lua_tonumber` (`0x6f34d0`/`0x6f3620`): a numeric string is a number.
|
|
let index = match id {
|
|
Value::Integer(i) => i as f64,
|
|
Value::Number(n) => n,
|
|
Value::String(s) => s
|
|
.to_str()
|
|
.ok()
|
|
.and_then(|s| s.trim().parse::<f64>().ok())
|
|
.ok_or_else(invalid)?,
|
|
_ => return Err(invalid()),
|
|
};
|
|
let book = match book_type {
|
|
Value::String(s) => s.to_str().map(|s| s.to_string()).map_err(|_| invalid())?,
|
|
Value::Integer(_) | Value::Number(_) => String::new(),
|
|
_ => return Err(invalid()),
|
|
};
|
|
let index = (index - 1.0).trunc();
|
|
if !(0.0..1024.0).contains(&index) {
|
|
return Err(invalid());
|
|
}
|
|
let book = if is_pet_book(&book) {
|
|
BOOKTYPE_PET
|
|
} else {
|
|
BOOKTYPE_SPELL
|
|
};
|
|
Ok((index as u32 + 1, book.to_string()))
|
|
}
|
|
|
|
/// The 1-based book id as a 0-based slot index.
|
|
fn slot_index(id: u32) -> Option<usize> {
|
|
usize::try_from(id.checked_sub(1)?).ok()
|
|
}
|
|
|
|
/// The slot a `bookType` binding reads. The reference forks `isPet ? [0xb6f098 + 4*i] :
|
|
/// [0xb700f0 + 4*i]` in each binding (`0x4b3f5d`, `0x4b40e6`, `0x4b3735`, `0x4b3339`) and in
|
|
/// `GameTooltip:SetSpell` (`0x532e1c`/`0x532e2a`), which shares this.
|
|
pub(super) fn book_slot<'a>(
|
|
model: &'a Model,
|
|
id: u32,
|
|
book_type: &str,
|
|
) -> Option<&'a SpellSlotView> {
|
|
let slots = if is_pet_book(book_type) {
|
|
&model.pet_book.slots
|
|
} else {
|
|
&model.spellbook.slots
|
|
};
|
|
slots.get(slot_index(id)?)
|
|
}
|
|
|
|
/// Resolve a spell by name against the player's book, for `CastSpellByName` and so `/cast`, whose
|
|
/// grammar the client's help text gives as `/cast <name> (<subtext>)` (`MACRO_HELP_TEXT_LINE4`):
|
|
///
|
|
/// - `Fireball`: the highest known rank, by the subtext's leading number (unranked is 0, and a tie
|
|
/// goes to the later slot, the order the book lists ranks in);
|
|
/// - `Fireball(Rank 1)` or `Fireball (Rank 1)`: that subtext, case-insensitively.
|
|
///
|
|
/// Names match whole and case-insensitively; passives never match. An unknown spell casts nothing
|
|
/// and prints no error, as in the reference (`SlashCmdList["CAST"]` discards the result). The
|
|
/// reference cuts the name at `(` untrimmed (`0x4b3950`), so its spaced form keeps a trailing space
|
|
/// and misses, and it matches name and rank alone, across the pet's book too (`0x4b3a10`).
|
|
pub fn resolve_spell_by_name<'a>(
|
|
book: &'a SpellBookState,
|
|
query: &str,
|
|
) -> Option<&'a SpellSlotView> {
|
|
let (name, subtext) = split_subtext(query);
|
|
if name.is_empty() {
|
|
return None;
|
|
}
|
|
book.slots
|
|
.iter()
|
|
.filter(|s| !s.passive && s.name.eq_ignore_ascii_case(name))
|
|
.filter(|s| match subtext {
|
|
Some(want) => s
|
|
.rank
|
|
.as_deref()
|
|
.is_some_and(|r| r.eq_ignore_ascii_case(want)),
|
|
None => true,
|
|
})
|
|
.enumerate()
|
|
.max_by_key(|(i, s)| (rank_number(s.rank.as_deref()), *i))
|
|
.map(|(_, s)| s)
|
|
}
|
|
|
|
/// `Name(Subtext)` or `Name (Subtext)` to `("Name", Some("Subtext"))`; without a closing
|
|
/// parenthesis after the opening one, the whole string is the name.
|
|
fn split_subtext(query: &str) -> (&str, Option<&str>) {
|
|
let q = query.trim();
|
|
let Some(open) = q.find('(') else {
|
|
return (q, None);
|
|
};
|
|
let Some(close) = q.rfind(')') else {
|
|
return (q, None);
|
|
};
|
|
if close < open {
|
|
return (q, None);
|
|
}
|
|
(q[..open].trim(), Some(q[open + 1..close].trim()))
|
|
}
|
|
|
|
/// The rank in a `NameSubtext` (`"Rank 8"` is 8) by the reference's parse, the first digit run;
|
|
/// a subtext without digits, or none, reads 0.
|
|
fn rank_number(subtext: Option<&str>) -> u32 {
|
|
subtext
|
|
.unwrap_or("")
|
|
.chars()
|
|
.skip_while(|c| !c.is_ascii_digit())
|
|
.take_while(|c| c.is_ascii_digit())
|
|
.fold(0u32, |acc, c| acc * 10 + c.to_digit(10).unwrap_or(0))
|
|
}
|
|
|
|
/// `PickupSpell(id, bookType)`, the spell button's drag and shift-click
|
|
/// (`SpellBookFrame.lua:266-290`). It refuses while the cursor holds anything, where the
|
|
/// reference's setters clear the cursor first (`0x495190`, called at `0x494dab` and `0x494f00`).
|
|
fn pickup_spell(model: &mut Model, id: u32, book_type: &str) -> bool {
|
|
if model.cursor.is_some() {
|
|
return false;
|
|
}
|
|
let Some(slot) = book_slot(model, id, book_type) else {
|
|
return false;
|
|
};
|
|
// The pet arm of `0x4b3260` puts the pet's raw word on the cursor (`0x494e20`, cursor mode 4),
|
|
// the player arm a spell id (`0x494d20`, mode 3); only the pet word drops on the pet bar.
|
|
let payload = if is_pet_book(book_type) {
|
|
// `0x494e20` refuses type 0 and types 8 and up.
|
|
let packed = slot.packed;
|
|
if !(1..=7).contains(&((packed >> 24) & 0x3F)) {
|
|
return false;
|
|
}
|
|
CursorPayload::PetAction(super::cursor::CursorPetAction {
|
|
// No source slot: the word comes from the book, not the bar.
|
|
src_slot: 0,
|
|
packed,
|
|
passive: slot.passive,
|
|
texture: slot.texture.clone(),
|
|
})
|
|
} else {
|
|
CursorPayload::Spell(CursorSpell {
|
|
book_slot: id,
|
|
book_type: book_type.to_string(),
|
|
spell_id: slot.spell_id,
|
|
texture: slot.texture.clone(),
|
|
passive: slot.passive,
|
|
})
|
|
};
|
|
model.cursor = Some(payload);
|
|
queue_cursor_update(model);
|
|
true
|
|
}
|
|
|
|
/// Register the spellbook globals.
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
g.set("BOOKTYPE_SPELL", BOOKTYPE_SPELL)?;
|
|
g.set("BOOKTYPE_PET", BOOKTYPE_PET)?;
|
|
|
|
// `UpdateSpells()` (`[0x4b43e0,0x4b43ec)`) only signals `SPELLS_CHANGED` (event 260, whose one
|
|
// fire site is `0x4b302f`): no arguments read, no returns, no state change, and no re-sort,
|
|
// which the other `SPELLS_CHANGED` sites do. The repaint is FrameXML's, through the event.
|
|
// `SignalEvent` is synchronous, so this fires now, not on the next tick.
|
|
g.set(
|
|
"UpdateSpells",
|
|
lua.create_function(|lua, ()| {
|
|
super::tick::fire_event_into(lua, "SPELLS_CHANGED", Vec::new());
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// `PlayerHasSpells()` is a constant 1 in the reference (it pushes 1.0 with no branch);
|
|
// `MainMenuBarMicroButtons.xml:80` picks the spellbook micro button's tooltip by it.
|
|
g.set("PlayerHasSpells", lua.create_function(|_, ()| Ok(1_i64))?)?;
|
|
|
|
g.set(
|
|
"GetNumSpellTabs",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(model.spellbook.tabs.len() as i64)
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellTabInfo(i) -> name, texture, offset, numSpells, four values on every path
|
|
// (`0x4b3ce0`). Out of range, 0 and negatives included (`0x4b3d26`), is `nil, nil, 0, 0`
|
|
// (`0x4b3e12`): `SpellBookFrame.lua:295` and `:328` compare `id > (offset + numSpells)`
|
|
// unguarded, so the zeros hide every button where a nil would raise. In range, the reference
|
|
// answers a nil name and texture for a skill line with no DBC row (`0x4b3dbd`) and a nil
|
|
// texture for one with no icon (SkillLine 733, 753, 754); here the name is always a string.
|
|
// The index is truncated before the decrement, unlike in `0x4b3ec0`, so 0.5 is out of range.
|
|
g.set(
|
|
"GetSpellTabInfo",
|
|
lua.create_function(|lua, i: Value| {
|
|
let i = super::binding_abi::number_arg(lua, i, "Usage: GetSpellTabInfo(index)")?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
// Zero or a negative fails `usize::try_from`, as the unsigned compare fails it.
|
|
let tab = usize::try_from(i64::from(i) - 1)
|
|
.ok()
|
|
.and_then(|n| model.spellbook.tabs.get(n));
|
|
let Some(tab) = tab else {
|
|
return Ok(MultiValue::from_vec(vec![
|
|
Value::Nil,
|
|
Value::Nil,
|
|
Value::Integer(0),
|
|
Value::Integer(0),
|
|
]));
|
|
};
|
|
let texture = match &tab.texture {
|
|
Some(t) => Value::String(lua.create_string(t)?),
|
|
None => Value::Nil,
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
Value::String(lua.create_string(&tab.name)?),
|
|
texture,
|
|
Value::Integer(i64::from(tab.offset)),
|
|
Value::Integer(i64::from(tab.num_spells)),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellName(id, bookType) -> name, rank, always two values; a rankless spell's rank is "",
|
|
// never nil: `0x4b4076` pushes `NameSubtext` through `0x6f3890`, which tests only pointer
|
|
// nullity, and `SpellRec::Read` (`0x583750`) makes every string offset a pointer. 14,403 of
|
|
// the 22,357 `Spell.dbc` rows have an empty one, `Attack` (spell 6603) among them.
|
|
g.set(
|
|
"GetSpellName",
|
|
lua.create_function(|lua, (id, book_type): (Value, Value)| {
|
|
let (id, book_type) = spell_slot_args(id, book_type, "GetSpellName")?;
|
|
// Out of `[0, 0x400)` already raised in `spell_slot_args`; the binding's own bound
|
|
// check (`0x4b4018`) is never taken.
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(slot) = book_slot(&model, id, &book_type) else {
|
|
// An empty slot inside the range answers two nils (`0x4b4086`).
|
|
return Ok(MultiValue::from_vec(vec![Value::Nil, Value::Nil]));
|
|
};
|
|
Ok(MultiValue::from_vec(vec![
|
|
Value::String(lua.create_string(&slot.name)?),
|
|
Value::String(lua.create_string(slot.rank.as_deref().unwrap_or(""))?),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"GetSpellTexture",
|
|
lua.create_function(|lua, (id, book_type): (Value, Value)| {
|
|
let (id, book_type) = spell_slot_args(id, book_type, "GetSpellTexture")?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let tex = book_slot(&model, id, &book_type).and_then(|s| s.texture.clone());
|
|
match tex {
|
|
Some(t) => Ok(Value::String(lua.create_string(&t)?)),
|
|
None => Ok(Value::Nil),
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// IsCurrentCast(id, bookType) (`0x4b4370`) reads back the app's per-slot verdict,
|
|
// `SpellSlotView::current`.
|
|
g.set(
|
|
"IsCurrentCast",
|
|
lua.create_function(|lua, (id, book_type): (Value, Value)| {
|
|
let (id, book_type) = spell_slot_args(id, book_type, "IsCurrentCast")?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let current = book_slot(&model, id, &book_type).is_some_and(|s| s.current);
|
|
// 1 or nil, never false, as the reference's bindings answer.
|
|
match current {
|
|
true => Ok(Value::Integer(1)),
|
|
false => Ok(Value::Nil),
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellCooldown(id, bookType) -> start, duration, enable in `GetTime` seconds, as
|
|
// `GetActionCooldown`: enable 0 for an on-hold cooldown; an expired or empty one answers the
|
|
// reference's no-cooldown `(0, 0, 1)`, so a re-feed cannot replay the finish flash.
|
|
g.set(
|
|
"GetSpellCooldown",
|
|
lua.create_function(|lua, (id, book_type): (Value, Value)| {
|
|
let (id, book_type) = spell_slot_args(id, book_type, "GetSpellCooldown")?;
|
|
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let cooldown = book_slot(&model, id, &book_type).and_then(|s| s.cooldown);
|
|
Ok(match cooldown {
|
|
Some((start_ms, duration_ms, enabled)) => {
|
|
let (start, duration) =
|
|
(start_ms as f64 / 1000.0, f64::from(duration_ms) / 1000.0);
|
|
if start + duration > now || !enabled {
|
|
(start, duration, i32::from(enabled))
|
|
} else {
|
|
(0.0, 0.0, 1)
|
|
}
|
|
}
|
|
None => (0.0, 0.0, 1),
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"IsSpellPassive",
|
|
lua.create_function(|lua, (id, book_type): (Value, Value)| {
|
|
let (id, book_type) = spell_slot_args(id, book_type, "IsSpellPassive")?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(flag(
|
|
book_slot(&model, id, &book_type).is_some_and(|s| s.passive),
|
|
))
|
|
})?,
|
|
)?;
|
|
|
|
// 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.
|
|
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;
|
|
if is_pet_book(&book_type) {
|
|
model.pet_spell_casts.push(spell_id);
|
|
} else {
|
|
model.spell_casts.push(spell_id);
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// HasPetSpells() -> numPetSpells, petToken: always two returns (`0x4b4410`), `(nil, nil)` with
|
|
// no pet spells (`0x4b4420`). The count is a number: `SpellBook_GetCurrentPage` divides by it.
|
|
g.set(
|
|
"HasPetSpells",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let n = model.pet_book.slots.len();
|
|
if n == 0 {
|
|
return Ok(MultiValue::from_vec(vec![Value::Nil, Value::Nil]));
|
|
}
|
|
let token = match &model.pet_book.token {
|
|
Some(t) => Value::String(lua.create_string(t)?),
|
|
None => Value::String(lua.create_string(PET_TOKEN_FALLBACK)?),
|
|
};
|
|
Ok(MultiValue::from_vec(vec![Value::Integer(n as i64), token]))
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellAutocast(id, bookType) -> autoCastAllowed, autoCastEnabled, 1 or nil each: always
|
|
// two returns, `(nil, nil)` for the player book (`0x4b4180` tests the book at `0x4b41cb` and
|
|
// `0x4b41d6`) and for an empty slot.
|
|
g.set(
|
|
"GetSpellAutocast",
|
|
lua.create_function(move |lua, (id, book_type): (Value, Value)| {
|
|
let (id, book_type) = spell_slot_args(id, book_type, "GetSpellAutocast")?;
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let (allowed, enabled) = book_slot(&model, id, &book_type)
|
|
.filter(|_| is_pet_book(&book_type))
|
|
.and_then(|s| s.autocast)
|
|
.unwrap_or((false, false));
|
|
Ok((flag(allowed), flag(enabled)))
|
|
})?,
|
|
)?;
|
|
|
|
// ToggleSpellAutocast(id, bookType), the pet book's right click (`0x4b4240`): queued only for
|
|
// an autocast-allowed word, the gate `0x4bccb0` applies before sending (`0x4bccf5`). The app
|
|
// sends `CMSG_PET_SPELL_AUTOCAST` and flips bit 30 on the book and the bar at the drain.
|
|
g.set(
|
|
"ToggleSpellAutocast",
|
|
lua.create_function(|lua, (id, book_type): (Value, Value)| {
|
|
let (id, book_type) = spell_slot_args(id, book_type, "ToggleSpellAutocast")?;
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let spell_id = book_slot(&model, id, &book_type)
|
|
.filter(|_| is_pet_book(&book_type))
|
|
.filter(|s| s.autocast.is_some_and(|(allowed, _)| allowed))
|
|
.map(|s| s.spell_id);
|
|
if let Some(spell_id) = spell_id {
|
|
model.pet_spell_autocasts.push(spell_id);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// CastSpellByName(name [, onSelf]) (`0x4b4ab0`) shares the dispatcher `0x4b3300` with
|
|
// `CastSpell`, so it queues on the same list; `SlashCmdList["CAST"]` calls it. `onSelf` is
|
|
// accepted and ignored: self-cast is not built.
|
|
g.set(
|
|
"CastSpellByName",
|
|
lua.create_function(|lua, (name, _on_self): (String, MultiValue)| {
|
|
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.spell_casts.push(spell_id);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
g.set(
|
|
"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");
|
|
Ok(pickup_spell(&mut model, id, &book_type))
|
|
})?,
|
|
)?;
|
|
|
|
// SpellStopCasting() (`0x6e6e80`) stops the first of a running auto-repeat (`0x6ea080`,
|
|
// `CMSG_CANCEL_AUTO_REPEAT_SPELL`) or an in-flight cast (`CMSG_CANCEL_CAST`) and answers 1,
|
|
// else nil. A channel answers nil: the channel canceler `0x6e9b70` is never reached, and the
|
|
// in-flight id `0xceca88` is 0 mid-channel (cleared at `0x6e7408`). The nil matters: the ESC
|
|
// chain (`UIParent.lua:1489`) reaches the game menu only through it.
|
|
g.set(
|
|
"SpellStopCasting",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
if model.casting {
|
|
model.spell_stop = true;
|
|
Ok(Value::Integer(1))
|
|
} else {
|
|
Ok(Value::Nil)
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// SpellIsTargeting() (`0x6e6cd0`): true while the targeting cursor is up, else nil.
|
|
g.set(
|
|
"SpellIsTargeting",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
if model.spell_targeting {
|
|
Ok(Value::Boolean(true))
|
|
} else {
|
|
Ok(Value::Nil)
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// SpellCanTargetUnit("unit") (`0x6e6d00`) asks `0x6e6460`'s unit leg whether the targeting
|
|
// word can take the unit. The token is not read: no word benilla can arm (location, item,
|
|
// gameobject) takes a unit, and unit words are not built.
|
|
g.set(
|
|
"SpellCanTargetUnit",
|
|
lua.create_function(|lua, _unit: Option<String>| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
if model.spell_can_target_unit {
|
|
Ok(Value::Boolean(true))
|
|
} else {
|
|
Ok(Value::Nil)
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
// SpellStopTargeting() (`0x6e6e30`): while targeting, `0x6e4900` clears the word with no
|
|
// packet and the binding answers 1, else nil, so the ESC chain (`UIParent.lua:1490`) falls
|
|
// through to the game menu.
|
|
g.set(
|
|
"SpellStopTargeting",
|
|
lua.create_function(|lua, ()| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
if model.spell_targeting {
|
|
model.spell_stop_targeting = true;
|
|
Ok(Value::Integer(1))
|
|
} else {
|
|
Ok(Value::Nil)
|
|
}
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{PetBookState, SpellBookState, SpellSlotView, SpellTabView};
|
|
use crate::script::cursor::{CursorAction, CursorPayload};
|
|
use crate::script::UiScript;
|
|
|
|
/// Two tabs: Fire (Fireball, and Fire Blast marked passive) and Frost (Frost Armor).
|
|
fn book() -> SpellBookState {
|
|
SpellBookState {
|
|
tabs: vec![
|
|
SpellTabView {
|
|
name: "Fire".into(),
|
|
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
|
|
offset: 0,
|
|
num_spells: 2,
|
|
},
|
|
SpellTabView {
|
|
name: "Frost".into(),
|
|
texture: Some("Interface\\Icons\\Spell_Frost_FrostBolt02".into()),
|
|
offset: 2,
|
|
num_spells: 1,
|
|
},
|
|
],
|
|
slots: vec![
|
|
SpellSlotView {
|
|
spell_id: 133,
|
|
name: "Fireball".into(),
|
|
rank: Some("Rank 1".into()),
|
|
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
|
|
passive: false,
|
|
current: false,
|
|
cooldown: None,
|
|
..Default::default()
|
|
},
|
|
SpellSlotView {
|
|
spell_id: 2136,
|
|
name: "Fire Blast".into(),
|
|
rank: Some("Rank 1".into()),
|
|
texture: Some("Interface\\Icons\\Spell_Fire_FireBolt02".into()),
|
|
passive: true, // not really passive: exercises the refusal
|
|
current: false,
|
|
cooldown: None,
|
|
..Default::default()
|
|
},
|
|
SpellSlotView {
|
|
spell_id: 168,
|
|
name: "Frost Armor".into(),
|
|
rank: Some("Rank 1".into()),
|
|
texture: Some("Interface\\Icons\\Spell_Frost_FrostArmor02".into()),
|
|
passive: false,
|
|
current: false,
|
|
cooldown: None,
|
|
..Default::default()
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn is_current_cast_reads_the_slot_verdict() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert!(s
|
|
.eval::<bool>("return IsCurrentCast(1, BOOKTYPE_SPELL) == nil")
|
|
.unwrap());
|
|
let mut b = book();
|
|
b.slots[0].current = true;
|
|
s.set_spellbook(b);
|
|
assert_eq!(
|
|
s.eval::<i64>("return IsCurrentCast(1, BOOKTYPE_SPELL)")
|
|
.unwrap(),
|
|
1
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("return IsCurrentCast(2, BOOKTYPE_SPELL) == nil")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("return IsCurrentCast(1, BOOKTYPE_PET) == nil")
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>("return IsCurrentCast(99, BOOKTYPE_SPELL) == nil")
|
|
.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn get_spell_cooldown_reads_the_slot_triple() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
s.tick(20.0); // GetTime = 20
|
|
|
|
// Cold slot: the reference's no-cooldown shape.
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(1, BOOKTYPE_SPELL)")
|
|
.unwrap(),
|
|
(0.0, 0.0, 1)
|
|
);
|
|
|
|
let mut b = book();
|
|
b.slots[0].cooldown = Some((14_000, 10_000, true)); // running: 4 s elapsed of 10
|
|
b.slots[1].cooldown = Some((2_000, 8_000, false)); // on hold: parked since t=2
|
|
b.slots[2].cooldown = Some((5_000, 10_000, true)); // elapsed at t=15: cold
|
|
s.set_spellbook(b);
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(1, BOOKTYPE_SPELL)")
|
|
.unwrap(),
|
|
(14.0, 10.0, 1)
|
|
);
|
|
// On hold survives the expiry guard.
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(2, BOOKTYPE_SPELL)")
|
|
.unwrap(),
|
|
(2.0, 8.0, 0)
|
|
);
|
|
// Elapsed goes cold.
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(3, BOOKTYPE_SPELL)")
|
|
.unwrap(),
|
|
(0.0, 0.0, 1)
|
|
);
|
|
// No pet book, and past the book, answer cold too.
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(1, BOOKTYPE_PET)")
|
|
.unwrap(),
|
|
(0.0, 0.0, 1)
|
|
);
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(99, BOOKTYPE_SPELL)")
|
|
.unwrap(),
|
|
(0.0, 0.0, 1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tab_info_shapes_and_book_id_offsets() {
|
|
let mut s = UiScript::new().unwrap();
|
|
assert_eq!(s.eval::<i64>("return GetNumSpellTabs()").unwrap(), 0);
|
|
// Lua compares only the first value; `out_of_range_answers_four_values` checks the arity.
|
|
assert!(s
|
|
.eval::<bool>("return (GetSpellTabInfo(1)) == nil")
|
|
.unwrap());
|
|
|
|
s.set_spellbook(book());
|
|
assert_eq!(s.eval::<i64>("return GetNumSpellTabs()").unwrap(), 2);
|
|
|
|
let (name, texture, offset, num) = s
|
|
.eval::<(String, String, i64, i64)>("return GetSpellTabInfo(1)")
|
|
.unwrap();
|
|
assert_eq!(
|
|
(name.as_str(), texture.as_str(), offset, num),
|
|
("Fire", "Interface\\Icons\\Spell_Fire_FlameBolt", 0, 2)
|
|
);
|
|
let (name2, _tex2, offset2, num2) = s
|
|
.eval::<(String, String, i64, i64)>("return GetSpellTabInfo(2)")
|
|
.unwrap();
|
|
assert_eq!((name2.as_str(), offset2, num2), ("Frost", 2, 1));
|
|
|
|
// Out of range: nil first.
|
|
assert!(s.eval::<bool>("return GetSpellTabInfo(3) == nil").unwrap());
|
|
}
|
|
|
|
/// The reference's marshaller, `0x4b3ec0`.
|
|
#[test]
|
|
fn the_slot_marshaller_gates_the_index_and_names_the_book() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
s.set_pet_book(pet_book());
|
|
for bad in ["0", "-11", "1025", "nil", "\"x\"", "{}"] {
|
|
let err = s
|
|
.eval::<mlua::Value>(&format!("return GetSpellTexture({bad}, BOOKTYPE_SPELL)"))
|
|
.expect_err(bad)
|
|
.to_string();
|
|
assert!(
|
|
err.contains("Invalid spell slot in GetSpellTexture"),
|
|
"{bad}: {err}"
|
|
);
|
|
}
|
|
assert!(
|
|
s.eval::<mlua::Value>("return GetSpellTexture(1)")
|
|
.unwrap_err()
|
|
.to_string()
|
|
.contains("Invalid spell slot in GetSpellTexture"),
|
|
"the book type is not optional"
|
|
);
|
|
// Inside the range but past the book: nil, not an error.
|
|
assert!(s
|
|
.eval::<bool>("return GetSpellTexture(1024, BOOKTYPE_SPELL) == nil")
|
|
.unwrap());
|
|
// Truncation toward zero: 1.9 is slot 1, and so is 0.99 (`trunc(-0.01)` is 0). A numeric
|
|
// string is a number (`lua_isnumber`).
|
|
for one in ["1.9", "0.99", "\"1\""] {
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return GetSpellName({one}, BOOKTYPE_SPELL)"))
|
|
.unwrap(),
|
|
"Fireball",
|
|
"{one}"
|
|
);
|
|
}
|
|
let (name, _) = s
|
|
.eval::<(String, String)>("return GetSpellName(1, \"PET\")")
|
|
.unwrap();
|
|
assert_eq!(
|
|
name,
|
|
pet_book().slots[0].name,
|
|
"the pet list, case-insensitively"
|
|
);
|
|
let (name, _) = s
|
|
.eval::<(String, String)>("return GetSpellName(1, 7)")
|
|
.unwrap();
|
|
assert_eq!(
|
|
name, "Fireball",
|
|
"a number is an accepted, player-book type"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn name_and_rank_read_through_the_book_id_seam() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
|
|
// Book id 1 (tab 1 offset 0 + button 1) -> slot 0 -> Fireball.
|
|
let (name, rank) = s
|
|
.eval::<(String, String)>(r#"return GetSpellName(1, BOOKTYPE_SPELL)"#)
|
|
.unwrap();
|
|
assert_eq!((name.as_str(), rank.as_str()), ("Fireball", "Rank 1"));
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetSpellTexture(1, BOOKTYPE_SPELL)"#)
|
|
.unwrap(),
|
|
"Interface\\Icons\\Spell_Fire_FlameBolt"
|
|
);
|
|
|
|
// Book id 3 (tab 2 offset 2 + button 1) -> slot 2 -> Frost Armor.
|
|
let (name3, _rank3) = s
|
|
.eval::<(String, String)>(r#"return GetSpellName(3, BOOKTYPE_SPELL)"#)
|
|
.unwrap();
|
|
assert_eq!(name3, "Frost Armor");
|
|
|
|
// Past the book, and an absent pet book, answer nil.
|
|
assert!(s
|
|
.eval::<bool>(r#"return GetSpellName(99, BOOKTYPE_SPELL) == nil"#)
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>(r#"return GetSpellName(1, BOOKTYPE_PET) == nil"#)
|
|
.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn pickup_spell_payload_and_cursor_update() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
|
|
s.run(r#"picked = PickupSpell(1, BOOKTYPE_SPELL)"#).unwrap();
|
|
assert!(s.eval::<bool>("return picked").unwrap());
|
|
assert!(s.cursor_payload().is_some());
|
|
let (kind, book_id, book, spell_id) = s
|
|
.eval::<(String, i64, String, i64)>(
|
|
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
(kind.as_str(), book_id, book.as_str(), spell_id),
|
|
("spell", 1, "spell", 133)
|
|
);
|
|
|
|
// Tick first to flush the first pickup's `CURSOR_UPDATE`, so the count below is the
|
|
// refused call's alone.
|
|
s.tick(0.0);
|
|
s.run(
|
|
r#"
|
|
cursorUpdates = 0
|
|
local f = CreateFrame("Frame", "CursorListener")
|
|
f:RegisterEvent("CURSOR_UPDATE")
|
|
f:SetScript("OnEvent", function() cursorUpdates = cursorUpdates + 1 end)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
s.run(r#"PickupSpell(3, BOOKTYPE_SPELL)"#).unwrap(); // already holding -> refused, no-op
|
|
s.tick(0.01);
|
|
assert_eq!(
|
|
s.eval::<i64>("return cursorUpdates").unwrap(),
|
|
0,
|
|
"refused pickup fires no CURSOR_UPDATE"
|
|
);
|
|
// Still holding the first pickup: a refusal never clobbers it.
|
|
assert_eq!(
|
|
s.eval::<(String, i64, String, i64)>(
|
|
"local k, slot, book, id = GetCursorInfo() return k, slot, book, id"
|
|
)
|
|
.unwrap(),
|
|
("spell".to_string(), 1, "spell".to_string(), 133)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn pickup_spell_refuses_while_already_holding_any_payload() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
s.set_cursor_for_test(CursorPayload::Action(CursorAction {
|
|
src_slot: 1,
|
|
kind: 0,
|
|
action: 111,
|
|
texture: None,
|
|
}));
|
|
|
|
assert!(!s
|
|
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#)
|
|
.unwrap());
|
|
// The original (action) payload survives untouched.
|
|
assert_eq!(
|
|
s.eval::<String>("local k = GetCursorInfo() return k")
|
|
.unwrap(),
|
|
"action"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn passive_refuses_the_cast_but_active_queues_it() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
|
|
s.run(r#"CastSpell(1, BOOKTYPE_SPELL)"#).unwrap(); // Fireball: active
|
|
assert_eq!(s.take_spell_casts(), vec![133]);
|
|
|
|
s.run(r#"CastSpell(2, BOOKTYPE_SPELL)"#).unwrap(); // Fire Blast: passive, refused
|
|
assert!(s.take_spell_casts().is_empty());
|
|
|
|
assert!(s
|
|
.eval::<bool>(r#"return IsSpellPassive(2, BOOKTYPE_SPELL)"#)
|
|
.unwrap());
|
|
assert!(!s
|
|
.eval::<bool>(r#"return IsSpellPassive(1, BOOKTYPE_SPELL)"#)
|
|
.unwrap());
|
|
}
|
|
|
|
/// With no pet book, as when the reference's count `[0xb71174]` is 0, every pet arm answers
|
|
/// nothing.
|
|
#[test]
|
|
fn an_absent_pet_book_answers_empty_everywhere() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
|
|
assert!(s
|
|
.eval::<bool>(r#"return GetSpellName(1, BOOKTYPE_PET) == nil"#)
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>(r#"return GetSpellTexture(1, BOOKTYPE_PET) == nil"#)
|
|
.unwrap());
|
|
assert!(!s
|
|
.eval::<bool>(r#"return IsSpellPassive(1, BOOKTYPE_PET)"#)
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>(r#"local n, t = HasPetSpells() return n == nil and t == nil"#)
|
|
.unwrap());
|
|
|
|
s.run(r#"CastSpell(1, BOOKTYPE_PET)"#).unwrap();
|
|
assert!(s.take_spell_casts().is_empty(), "pet cast is a no-op");
|
|
assert!(s.take_pet_spell_casts().is_empty());
|
|
|
|
assert!(!s
|
|
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_PET)"#)
|
|
.unwrap());
|
|
assert!(s.cursor_payload().is_none(), "pet pickup is a no-op");
|
|
}
|
|
|
|
/// A hunter's pet book: Growl (autocastable, on, cooling down), Claw (autocastable, off) and
|
|
/// Avoidance (a passive, no autocast, `ACT_PASSIVE` 0x01).
|
|
fn pet_book() -> PetBookState {
|
|
PetBookState {
|
|
token: Some("PET".into()),
|
|
slots: vec![
|
|
SpellSlotView {
|
|
spell_id: 2649,
|
|
name: "Growl".into(),
|
|
rank: Some("Rank 1".into()),
|
|
texture: Some("Interface\\Icons\\Ability_Physical_Taunt".into()),
|
|
cooldown: Some((9400, 5000, true)),
|
|
autocast: Some((true, true)),
|
|
packed: 0xC100_0000 | 2649,
|
|
..Default::default()
|
|
},
|
|
SpellSlotView {
|
|
spell_id: 16827,
|
|
name: "Claw".into(),
|
|
rank: Some("Rank 1".into()),
|
|
texture: Some("Interface\\Icons\\Ability_Druid_Rake".into()),
|
|
autocast: Some((true, false)),
|
|
packed: 0x8100_0000 | 16827,
|
|
..Default::default()
|
|
},
|
|
SpellSlotView {
|
|
spell_id: 3025,
|
|
name: "Avoidance".into(),
|
|
texture: Some("Interface\\Icons\\Spell_Nature_SpiritArmor".into()),
|
|
passive: true,
|
|
autocast: Some((false, false)),
|
|
packed: 0x0100_0000 | 3025,
|
|
..Default::default()
|
|
},
|
|
],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn has_pet_spells_answers_a_count_and_a_class_token() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_pet_book(pet_book());
|
|
assert!(s
|
|
.eval::<bool>(r#"local n, t = HasPetSpells() return n == 3 and t == "PET""#)
|
|
.unwrap());
|
|
|
|
// A warlock's token, which FrameXML reads as `PET_TYPE_DEMON`.
|
|
let mut demon = pet_book();
|
|
demon.token = Some("DEMON".into());
|
|
s.set_pet_book(demon);
|
|
assert_eq!(
|
|
s.eval::<String>("local _, t = HasPetSpells() return t")
|
|
.unwrap(),
|
|
"DEMON"
|
|
);
|
|
|
|
// No token resolved (no `ChrClasses.dbc`) still answers a string.
|
|
let mut untokened = pet_book();
|
|
untokened.token = None;
|
|
s.set_pet_book(untokened);
|
|
assert_eq!(
|
|
s.eval::<String>("local _, t = HasPetSpells() return t")
|
|
.unwrap(),
|
|
"PET"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn one_id_reads_two_different_books() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
s.set_pet_book(pet_book());
|
|
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetSpellName(1, BOOKTYPE_SPELL)"#)
|
|
.unwrap(),
|
|
"Fireball"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetSpellName(1, BOOKTYPE_PET)"#)
|
|
.unwrap(),
|
|
"Growl"
|
|
);
|
|
// `"pet"` alone, without case (`0x4b3f27`); any other string, a typo too, is the player's.
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetSpellName(1, "PeT")"#)
|
|
.unwrap(),
|
|
"Growl"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>(r#"return GetSpellName(1, "spel")"#)
|
|
.unwrap(),
|
|
"Fireball"
|
|
);
|
|
// Past the pet book's end: nil.
|
|
assert!(s
|
|
.eval::<bool>(r#"return GetSpellName(4, BOOKTYPE_PET) == nil"#)
|
|
.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn autocast_is_a_pet_only_pair() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
s.set_pet_book(pet_book());
|
|
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local a, e = GetSpellAutocast(1, BOOKTYPE_PET) return a == 1 and e == 1"#
|
|
)
|
|
.unwrap());
|
|
assert!(s
|
|
.eval::<bool>(
|
|
r#"local a, e = GetSpellAutocast(2, BOOKTYPE_PET) return a == 1 and e == nil"#
|
|
)
|
|
.unwrap());
|
|
assert!(
|
|
s.eval::<bool>(
|
|
r#"local a, e = GetSpellAutocast(3, BOOKTYPE_PET) return a == nil and e == nil"#
|
|
)
|
|
.unwrap(),
|
|
"a passive is not autocastable"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>(
|
|
r#"local a, e = GetSpellAutocast(1, BOOKTYPE_SPELL) return a == nil and e == nil"#
|
|
)
|
|
.unwrap(),
|
|
"the PLAYER book never answers a pair"
|
|
);
|
|
assert!(
|
|
s.eval::<bool>(
|
|
r#"local a, e = GetSpellAutocast(9, BOOKTYPE_PET) return a == nil and e == nil"#
|
|
)
|
|
.unwrap(),
|
|
"still two returns out of range"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_an_autocastable_pet_slot_queues_a_toggle() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
s.set_pet_book(pet_book());
|
|
|
|
s.run(
|
|
r#"ToggleSpellAutocast(1, BOOKTYPE_PET)
|
|
ToggleSpellAutocast(3, BOOKTYPE_PET)
|
|
ToggleSpellAutocast(9, BOOKTYPE_PET)
|
|
ToggleSpellAutocast(1, BOOKTYPE_SPELL)"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(s.take_pet_spell_autocasts(), vec![2649]);
|
|
assert!(s.take_pet_spell_autocasts().is_empty(), "drain empties");
|
|
assert!(s.take_spell_casts().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn a_pet_cast_queues_apart_from_a_player_cast() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
s.set_pet_book(pet_book());
|
|
|
|
s.run(
|
|
r#"CastSpell(1, BOOKTYPE_PET)
|
|
CastSpell(3, BOOKTYPE_PET)
|
|
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!(s.take_pet_spell_casts().is_empty(), "drain empties");
|
|
}
|
|
|
|
#[test]
|
|
fn a_pet_book_pickup_carries_the_packed_word() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
s.set_pet_book(pet_book());
|
|
|
|
assert!(s
|
|
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_PET)"#)
|
|
.unwrap());
|
|
let Some(CursorPayload::PetAction(p)) = s.cursor_payload() else {
|
|
panic!(
|
|
"expected a pet action payload, got {:?}",
|
|
s.cursor_payload()
|
|
);
|
|
};
|
|
assert_eq!(p.packed, 0xC100_0000 | 2649);
|
|
assert_eq!(p.src_slot, 0, "it came out of the book, not off the bar");
|
|
|
|
// The player book still produces a spell payload.
|
|
s.run("ClearCursor()").unwrap();
|
|
assert!(s
|
|
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#)
|
|
.unwrap());
|
|
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
|
|
}
|
|
|
|
/// The reference reads a pet book cooldown through `0x6e2ea0` (`edx = isPet`), as
|
|
/// `GetPetActionCooldown` does, so the book and the bar agree.
|
|
#[test]
|
|
fn the_pet_books_cooldown_is_the_pets_own() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.tick(10.0); // GetTime == 10
|
|
s.set_spellbook(book());
|
|
s.set_pet_book(pet_book());
|
|
|
|
let (start, duration, enable) = s
|
|
.eval::<(f64, f64, i32)>(r#"return GetSpellCooldown(1, BOOKTYPE_PET)"#)
|
|
.unwrap();
|
|
assert!((start - 9.4).abs() < 1e-9, "start {start}");
|
|
assert!((duration - 5.0).abs() < 1e-9);
|
|
assert_eq!(enable, 1);
|
|
// The player book's slot 1 has none.
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i32)>(r#"return GetSpellCooldown(1, BOOKTYPE_SPELL)"#)
|
|
.unwrap(),
|
|
(0.0, 0.0, 1)
|
|
);
|
|
|
|
s.tick(5.0); // now == 15 > 9.4 + 5.0
|
|
assert_eq!(
|
|
s.eval::<(f64, f64, i32)>(r#"return GetSpellCooldown(1, BOOKTYPE_PET)"#)
|
|
.unwrap(),
|
|
(0.0, 0.0, 1)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_rankless_spell_answers_an_empty_rank_and_still_two_values() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(SpellBookState {
|
|
tabs: Vec::new(),
|
|
slots: vec![
|
|
SpellSlotView {
|
|
spell_id: 6603,
|
|
name: "Attack".into(),
|
|
rank: None,
|
|
..Default::default()
|
|
},
|
|
SpellSlotView {
|
|
spell_id: 78,
|
|
name: "Heroic Strike".into(),
|
|
rank: Some("Rank 1".into()),
|
|
..Default::default()
|
|
},
|
|
],
|
|
});
|
|
|
|
assert_eq!(s.arity(r#"GetSpellName(1, "spell")"#).unwrap(), 2);
|
|
let (name, rank) = s
|
|
.eval::<(String, String)>(r#"return GetSpellName(1, "spell")"#)
|
|
.unwrap();
|
|
assert_eq!((name.as_str(), rank.as_str()), ("Attack", ""));
|
|
assert!(
|
|
s.eval::<bool>(r#"local _, r = GetSpellName(1, "spell") return r ~= nil"#)
|
|
.unwrap(),
|
|
"the rankless rank must be a STRING, not nil"
|
|
);
|
|
// Two addon idioms that a nil rank breaks: a table key and a `string.find` argument.
|
|
s.run(r#"local _, r = GetSpellName(1, "spell") local t = {} t[r] = 1"#)
|
|
.expect("a rankless rank must be a legal table key");
|
|
s.run(r#"local _, r = GetSpellName(1, "spell") string.find(r, "(%d+)")"#)
|
|
.expect("a rankless rank must be a legal string.find argument");
|
|
// A ranked spell is unchanged.
|
|
assert_eq!(
|
|
s.eval::<(String, String)>(r#"return GetSpellName(2, "spell")"#)
|
|
.unwrap(),
|
|
("Heroic Strike".to_string(), "Rank 1".to_string())
|
|
);
|
|
|
|
// An empty slot inside the range is two nils.
|
|
assert_eq!(s.arity(r#"GetSpellName(9, "spell")"#).unwrap(), 2);
|
|
assert!(s
|
|
.eval::<bool>(r#"local a, b = GetSpellName(9, "spell") return a == nil and b == nil"#)
|
|
.unwrap());
|
|
|
|
// Id 1025 is index 1024, past the `[0, 0x400)` gate: it raises.
|
|
let err = s
|
|
.run(r#"GetSpellName(1025, "spell")"#)
|
|
.expect_err("an out-of-range slot must raise");
|
|
assert!(
|
|
format!("{err}").contains("Invalid spell slot in GetSpellName"),
|
|
"got {err}"
|
|
);
|
|
// Id 1023 is inside it and answers.
|
|
assert_eq!(s.arity(r#"GetSpellName(1023, "spell")"#).unwrap(), 2);
|
|
}
|
|
|
|
/// The assertion is that a handler ran, not that a frame repainted: the repaint is FrameXML's.
|
|
#[test]
|
|
fn update_spells_fires_spells_changed_and_touches_nothing() {
|
|
let s = UiScript::new().unwrap();
|
|
s.run(
|
|
r#"
|
|
fired = 0
|
|
local f = CreateFrame("Frame", "SpellsWatcher")
|
|
f:RegisterEvent("SPELLS_CHANGED")
|
|
f:SetScript("OnEvent", function() fired = fired + 1 end)
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
assert_eq!(s.eval::<i64>("return fired").unwrap(), 0);
|
|
s.run("UpdateSpells()").unwrap();
|
|
assert_eq!(
|
|
s.eval::<i64>("return fired").unwrap(),
|
|
1,
|
|
"UpdateSpells must fire SPELLS_CHANGED synchronously, as SignalEvent does"
|
|
);
|
|
// Zero returns, `reference/1.12-shapes.tsv`'s `arity = 0 (exact)`.
|
|
assert_eq!(s.arity("UpdateSpells()").unwrap(), 0);
|
|
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// On an empty book, where a "has any spells" reading would answer 0.
|
|
#[test]
|
|
fn player_has_spells_is_one_even_with_an_empty_book() {
|
|
let s = UiScript::new().unwrap();
|
|
assert_eq!(s.eval::<i64>("return GetNumSpellTabs()").unwrap(), 0);
|
|
assert_eq!(s.eval::<i64>("return PlayerHasSpells()").unwrap(), 1);
|
|
assert_eq!(s.arity("PlayerHasSpells()").unwrap(), 1);
|
|
}
|
|
|
|
/// The count is the assertion: `(GetSpellTabInfo(0)) == nil` holds for a single nil too.
|
|
#[test]
|
|
fn out_of_range_answers_four_values() {
|
|
let s = UiScript::new().unwrap();
|
|
for idx in ["0", "1", "99", "-1", "0.5"] {
|
|
assert_eq!(
|
|
s.arity(&format!("GetSpellTabInfo({idx})")).unwrap(),
|
|
4,
|
|
"GetSpellTabInfo({idx}) must answer four values"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<i64>(&format!(
|
|
"local _,_,o,n = GetSpellTabInfo({idx}) return o + n"
|
|
))
|
|
.unwrap(),
|
|
0,
|
|
"GetSpellTabInfo({idx}) slots 3+4 must be NUMBERS summing to 0"
|
|
);
|
|
assert!(s
|
|
.eval::<bool>(&format!("return (GetSpellTabInfo({idx})) == nil"))
|
|
.unwrap());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn tab_index_raises_when_absent_and_coerces_a_numeric_string() {
|
|
let mut s = UiScript::new().unwrap();
|
|
for bad in [
|
|
"GetSpellTabInfo()",
|
|
"GetSpellTabInfo(nil)",
|
|
"GetSpellTabInfo({})",
|
|
"GetSpellTabInfo('abc')",
|
|
] {
|
|
let err = s.run(bad).expect_err(bad);
|
|
assert!(
|
|
format!("{err}").contains("Usage: GetSpellTabInfo(index)"),
|
|
"{bad}: got {err}"
|
|
);
|
|
}
|
|
s.set_spellbook(book());
|
|
// "1" coerces as 1 does.
|
|
assert_eq!(
|
|
s.eval::<String>("return (GetSpellTabInfo('1'))").unwrap(),
|
|
s.eval::<String>("return (GetSpellTabInfo(1))").unwrap()
|
|
);
|
|
}
|
|
}
|