- every build off macOS has been silent since the 09-02 snapshot (B356): the non-macOS device layer was a stub that opened nothing, and nothing had ever compiled the client for another target; that layer is now the same output stream over cpal — one seam, not two stacks: the ring, the render thread and the rebuild-on-device-change loop are one code on every platform, only the device file differs; the audio thread never allocates, a lost or changed default device is noticed at kira's own cadence, and realtime is `SCHED_FIFO` on unix and MMCSS "Pro Audio" on Windows, either refusable, the ring turning a refusal into a degradation; and the two instruments this bug says were missing exist — a Linux and Windows cross-compile under `clippy -D warnings`, and a live output test that opens the real device and asserts the ring never ran dry, because compiling is not sounding (1920) - an emote that requires standing still now refuses on the move: `CheckEmoteEligible`'s `0x4000` arm writes an out-param that `DoEmote` turns into a red "You can't do that while moving!" and aborts; 20 emotes of 78 can raise the line, read off the shipped `Emotes.dbc`; SWIMMING is not in the mask, so a swimmer holding still emotes fine, and `IsSelfControlled` is TRUE for an ordinary player, so a feared or confused player emotes away happily (1904); and the cast path gains its equipped-item rung — the button greyed and pressing it still sent: the reference short-circuits to "fits" on four gates, not one, the miss being a zero `EquippedItemSubClassMask`, which means no item required at all, and the rung sits above the crowd-control validator, so a player both stunned and missing the weapon is told about the weapon; that ladder has six arms, not three — CHARMED, FLEEING and CONFUSED join (1925) - the quest reward's money is the reference's own widget: the tail of the shared `QuestFrameItems_Update` drives `questState.."MoneyFrame"` through `MoneyFrame_Update`, and the two hand-built coin trios become one `MoneyFrameTemplate` instance each at the reference's anchors, which our coins already used; the progress panel stays on coins, being the required-money flow, not the reward one (1928); and the flight master's `!` joins the questgiver sweep, because the green `!` and the gold `!` are the SAME slot, `unit+0xb2c`, torn down once before both queries re-issue; the mouseover re-ask is refuted — the only taxi-status sender has one live caller — and the two queries are not symmetric: the questgiver's goes out on every sweep, the taxi one only when the unit's key moves or on a full sweep, as the wire shows at Dungar Longdrink: 68 questgiver status queries against 1 taxi-node one (1918) - `GetSpellTabInfo` out of range answers `nil, nil, 0, 0` — the reference returns four values on every path that returns (`0x4b3ce0`), and ours answered a single nil copied from another binding's shape; it matters because stock `SpellBookFrame_OnLoad` calls it unconditionally and then does `id > (offset + numSpells)`, which a nil raises on and (0+0) makes true, hiding every button on the page — the behaviour to match; the argument raises the reference's usage string when absent, and the index truncates toward zero THEN decrements (1931); the perf probe gains a `WOW_PROBE_UNCAP=vsync` arm, because the uncapped present mode is a request, not a schedule — measured, it taxes the number it was meant to pin (11.80 `cpu_ms` uncapped against 14.65 under it), so the default stays uncapped; and the last three test temp paths are keyed on the pid, after concurrent runs raced on one fixed shared file Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1586 lines
74 KiB
Rust
1586 lines
74 KiB
Rust
//! The spellbook (decision 0216 §8, slice 5) — the spell **source** for the cursor payload
|
||
//! system: a read-only book model the app builds from `PlayerActions.spells`
|
||
//! (`SMSG_INITIAL_SPELLS`), the same two-way seam shape as [`super::merchant`]/[`super::action`]:
|
||
//! the app pushes a **book snapshot** ([`UiScript::set_spellbook`] — tabs + the flat slot list,
|
||
//! already resolved to name/rank/icon/passive by the app's `benilla_formats::SpellCatalog` ×
|
||
//! skill-line join), and `CastSpell`/`PickupSpell` queue outbound intents the app drains
|
||
//! ([`UiScript::take_spell_casts`] / the cursor seam's own `CursorPayload::Spell` arm — decision
|
||
//! 0216 §1). The engine holds no spell KNOWLEDGE (icons/ranks/skill lines are the app's job) — a
|
||
//! slot is "a spell id, a name, a rank, a texture, and a passive bit".
|
||
//!
|
||
//! ## The book-id seam (decision 0218 §4's byte-verified 0-based book slot)
|
||
//!
|
||
//! The ref's own FrameXML computes a **1-based, per-tab-cumulative "book id"**
|
||
//! (`SpellBookFrame.lua`'s `SpellBook_GetSpellID`: `buttonId + tabOffset + 12*(page-1)`, where
|
||
//! `buttonId` is a spell button's own 1..12 `id=` attribute and `tabOffset` is
|
||
//! `GetSpellTabInfo`'s own `offset` return) and passes that SAME id, unmodified, to every one of
|
||
//! `GetSpellName`/`GetSpellTexture`/`IsSpellPassive`/`CastSpell`/`PickupSpell`. 0218 §4 byte-read
|
||
//! `PickupSpell`'s own argument as a **0-based book slot** — so the real client's Lua↔C++ glue
|
||
//! does the `-1` itself, invisibly to FrameXML. This engine keeps the ref's exact Lua-facing
|
||
//! convention (every binding below takes the SAME 1-based-cumulative `id` a transcribed
|
||
//! `SpellBookFrame.xml` computes and passes verbatim, so the transcription needs no special
|
||
//! casing) and does the byte-verified `-1` at THIS one seam ([`slot_index`]) before indexing
|
||
//! [`SpellBookState::slots`] (0-based, flat, tab order). `GetSpellTabInfo`'s pushed `offset` is
|
||
//! therefore exactly each tab's 0-based START index into `slots` — the app computes it as the
|
||
//! running sum of every earlier tab's `num_spells` (tab 1's is `0`, so its first spell's book id
|
||
//! is `1`, matching the ref's own "first tab's first spell is id 1").
|
||
//!
|
||
//! ## The pet book (decision 1032 — live; 0216 §8's deferral is retired)
|
||
//!
|
||
//! `BOOKTYPE_PET` selects a **second slot list** ([`PetBookState`]), fed from `SMSG_PET_SPELLS`'
|
||
//! own spell tail. Every `bookType`-taking binding is a two-way fork ([`book_slot`]) exactly as the
|
||
//! reference's are — `isPet ? [0xb6f098 + 4*i] : [0xb700f0 + 4*i]`, written out once per binding —
|
||
//! and the three pet-only bindings (`HasPetSpells`, `GetSpellAutocast`, `ToggleSpellAutocast`) live
|
||
//! here with them.
|
||
//!
|
||
//! Two asymmetries are the API and not tidiable away:
|
||
//!
|
||
//! - `GetNumSpellTabs`/`GetSpellTabInfo` take **no** `bookType` and only ever answer the player's
|
||
//! skill lines. That is the reference's own signature: the pet book has no tabs, and
|
||
//! `SpellBookFrame_Update` hides the whole skill-line strip while it is up.
|
||
//! - `PickupSpell` and `CastSpell` produce a **different kind of thing** on the pet side — a pet
|
||
//! action word on the cursor (`0x494e20`, cursor modes 1-7) and a `CMSG_PET_ACTION` on the wire
|
||
//! (`0x4b34ce`) — rather than a spell payload and a player cast. See each binding.
|
||
//!
|
||
//! `BOOKTYPE_SPELL`/`BOOKTYPE_PET` are installed as plain Lua globals here rather than left to the
|
||
//! transcribed XML's own `<Script>` block (the ref's actual home for them, `SpellBookFrame.lua:
|
||
//! 5-6`, and this crate's usual house rule for Era top-level constants) — the one deliberate
|
||
//! exception, so this module's OWN engine-level tests can drive the pet-deferral path without
|
||
//! loading a real `SpellBookFrame.xml`.
|
||
|
||
use mlua::{Lua, MultiValue, Value};
|
||
|
||
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 the app has not resolved a token — the reference's own
|
||
/// literal at `0x846a40`, pushed by `0x4b44a6` whenever the player object fails to resolve. Never
|
||
/// nil: FrameXML concatenates it (`"PET_TYPE_"..token`), which would error on one.
|
||
const PET_TOKEN_FALLBACK: &str = "PET";
|
||
|
||
/// One skill-line tab (`GetSpellTabInfo`'s own Era tuple shape). `offset` is the tab's 0-based
|
||
/// START index into [`SpellBookState::slots`] (module docs' book-id seam) — pushed by the app,
|
||
/// trusted here (the engine holds no spell knowledge to derive it from itself).
|
||
#[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 (0-based [`SpellBookState::slots`] index; module docs' book-id
|
||
/// seam). Every field is pre-resolved by the app — the engine draws whatever it's given.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct SpellSlotView {
|
||
pub spell_id: u32,
|
||
pub name: String,
|
||
/// The rank/subtext line (`Spell.dbc`'s `NameSubtext`, `benilla-formats`' own pin); `None`
|
||
/// shows no second line.
|
||
pub rank: Option<String>,
|
||
pub texture: Option<String>,
|
||
/// `SPELL_ATTR_PASSIVE` (`benilla-formats`' `SpellDisplay::passive`) — grays the name in the
|
||
/// transcribed XML and refuses both [`CastSpell`]-family casts (this module) and, faithfully,
|
||
/// nothing else: a passive can still be picked up/placed on a bar (the ref never blocks that).
|
||
pub passive: bool,
|
||
/// The `IsCurrentCast` verdict for this slot — the checked ring (`SpellButton_UpdateSelection`'s
|
||
/// gold glow). The delegate `0x4b3600` has exactly two arms (wow-re
|
||
/// `spellbook-checked-predicate.md`): a shapeshift spell whose form is the player's current
|
||
/// form byte, or the open trade-skill window's own spell — never an ordinary in-flight cast.
|
||
/// App-resolved (`benilla::ui_spellbook`), pushed with the book; the app fires
|
||
/// `CURRENT_SPELL_CAST_CHANGED` on its edges.
|
||
pub current: bool,
|
||
/// The spell's running cooldown as `(start_ms on the GetTime clock, duration_ms, enabled)` —
|
||
/// the same app-computed triple [`super::ActionState::cooldown`] and the container slots
|
||
/// carry, resolved by the ONE cooldown store (`benilla::cooldowns::Cooldowns::info` — id,
|
||
/// category and GCD reads alike); `GetSpellCooldown` answers the reference's
|
||
/// `(start, duration, enable)`. `None` = cold. Frame-stable per arm (the absolute start), so
|
||
/// a running cooldown never churns the book diff.
|
||
pub cooldown: Option<(i64, u32, bool)>,
|
||
/// `GetSpellAutocast`'s `(allowed, enabled)` pair — **pet-book only**, and `None` is not
|
||
/// "neither": it is the reference's own player-book answer, `(nil, nil)`, because `0x4b4180`
|
||
/// short-circuits on the book flag (`0x4b41cb`/`0x4b41d6`) before it looks a spell up at all.
|
||
/// Read off the pet's **raw** word (`0x4bd160` → bits 31/30), never off the filtered book.
|
||
pub autocast: Option<(bool, bool)>,
|
||
/// The pet slot's packed word **verbatim** — what `PickupSpell(id, "pet")` puts on the cursor.
|
||
/// `0x4b3260`'s pet arm hands `0x494e20` a *pointer* to this very word, so the payload is the
|
||
/// server's own dword, type byte and autocast bits included, not a synthesized one. `0` for a
|
||
/// player-book slot, which has no word.
|
||
pub packed: u32,
|
||
}
|
||
|
||
/// The **pet's** book — the reference's second flat array (`0xb6f098`, count `0xb71174`), which is
|
||
/// a genuinely different object from the player's rather than a variant of it:
|
||
///
|
||
/// - **no tabs.** `GetNumSpellTabs`/`GetSpellTabInfo` take no `bookType` and only ever answer the
|
||
/// player's skill lines; `SpellBookFrame_Update` hides every skill-line tab while the pet book is
|
||
/// up (`SpellBookFrame.lua:124`), and `SpellBook_GetSpellID` returns the button's own 1..12 id
|
||
/// with no tab offset at all (`l.460-462`).
|
||
/// - **a different add-gate.** `0x4b2f90` admits a spell iff it resolves in `Spell.dbc` **and**
|
||
/// `Attributes & 0x80` (DO_NOT_DISPLAY) is clear — `0x4b2fa8 mov dl,[rec+0x18]; test dl,dl; js`.
|
||
/// That is *one* of the three tests the player book's own gate makes: no `IS_TRADESKILL` leg and
|
||
/// no `castUI == 0` leg. Reusing the player book's gate here would be the same class of mistake
|
||
/// as reusing its tab routing.
|
||
/// - **the same order.** `0x4b2fd0(ecx = 0, edx = 1)` sorts it with `0x4b30c0`, the player book's
|
||
/// own comparator (name, then parsed rank), and tail-jumps `SPELLS_CHANGED`.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct PetBookState {
|
||
/// `HasPetSpells`'s second return: `ChrClasses.dbc` field 4 for the player's class — `"PET"`
|
||
/// or `"DEMON"` (`benilla_formats::PetNameTokens`). A **key**, not display text: FrameXML does
|
||
/// `getglobal("PET_TYPE_"..token)`. `None` only while there is no book at all.
|
||
pub token: Option<String>,
|
||
pub slots: Vec<SpellSlotView>,
|
||
}
|
||
|
||
/// The player's known-spell book: tabs (skill lines) + the flat slot list every tab indexes into
|
||
/// (module docs). Durable player state, not a session window (like [`super::action`]'s
|
||
/// `actions` map) — never `Option`; "no known spells yet" is simply empty vectors.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct SpellBookState {
|
||
pub tabs: Vec<SpellTabView>,
|
||
pub slots: Vec<SpellSlotView>,
|
||
}
|
||
|
||
impl super::UiScript {
|
||
/// Push the whole book snapshot (tabs + flat slots), replacing whatever was there. A bare
|
||
/// setter — firing `SPELLS_CHANGED` is the app's own diff-and-fire job (mirroring
|
||
/// `set_action`/`set_container`; never auto-fired here).
|
||
pub fn set_spellbook(&mut self, state: SpellBookState) {
|
||
self.model_mut().spellbook = state;
|
||
}
|
||
|
||
/// Push the pet's book ([`PetBookState`]), replacing whatever was there. A bare setter for the
|
||
/// same reason as its sibling: the reference fires `SPELLS_CHANGED` for **both** books off the
|
||
/// one re-sort (`0x4b2fd0` → `SignalEvent(0x104)`), and whose diff moved is the app's to know.
|
||
pub fn set_pet_book(&mut self, state: PetBookState) {
|
||
self.model_mut().pet_book = state;
|
||
}
|
||
|
||
/// Drain the pet spell ids `CastSpell(id, "pet")` queued. Separate from
|
||
/// [`Self::take_spell_casts`] because the wire verb is different in kind: the dispatcher's pet
|
||
/// arm sends **`CMSG_PET_ACTION`** with a synthesized type-1 word (`0x4b34ce`), not a player
|
||
/// cast, so folding them into one queue would lose which end of the leash the cast came from.
|
||
pub fn take_pet_spell_casts(&mut self) -> Vec<u32> {
|
||
std::mem::take(&mut self.model_mut().pet_spell_casts)
|
||
}
|
||
|
||
/// Drain the pet spell ids `ToggleSpellAutocast` queued — the pet **book**'s autocast verb,
|
||
/// which is a different opcode from the pet **bar**'s ([`super::pet::UiScript::…`]'s
|
||
/// `take_pet_autocast_toggles` → `CMSG_PET_SET_ACTION`): this one is
|
||
/// `CMSG_PET_SPELL_AUTOCAST 0x2F3` and names a spell id rather than a bar slot
|
||
/// (`0x4b4291` → `0x4bccb0`).
|
||
pub fn take_pet_spell_autocasts(&mut self) -> Vec<u32> {
|
||
std::mem::take(&mut self.model_mut().pet_spell_autocasts)
|
||
}
|
||
|
||
/// Read the book back — for the ONE app-side consumer that must resolve a spell name by the
|
||
/// same law `CastSpellByName` does: a macro's bound spell (`benilla::ui_macro`, decision
|
||
/// 0983). Going through the pushed book rather than re-deriving from the catalog is what
|
||
/// stops the bar's cooldown swirl and the macro's own cast disagreeing about which rank a
|
||
/// bare `/cast Fireball` means.
|
||
pub fn spellbook(&self) -> SpellBookState {
|
||
self.model_mut().spellbook.clone()
|
||
}
|
||
|
||
/// Drain the spell ids `CastSpell` queued since the last call.
|
||
pub fn take_spell_casts(&mut self) -> Vec<u32> {
|
||
std::mem::take(&mut self.model_mut().spell_casts)
|
||
}
|
||
|
||
/// Push whether the app's cast lifecycle holds something `SpellStopCasting()` can stop — a
|
||
/// running auto-repeat or an in-flight cast, but NOT a channel (the ref's `0x6e6e80` reads
|
||
/// only the auto-repeat key `0xceac30` and the inflight id `0xceca88`, and the inflight id
|
||
/// is already 0 during a channel — wow-re `esc-stopcasting.md`). Pushed each frame by the
|
||
/// app's cast feed (`benilla::ui_cast`), before the input pass runs the ESC chain.
|
||
pub fn set_casting(&mut self, casting: bool) {
|
||
self.model_mut().casting = casting;
|
||
}
|
||
|
||
/// Drain the `SpellStopCasting()` trigger: `true` if it fired on a stoppable state since
|
||
/// the last call — the ESC leg of the local self-cancel (`benilla::ui_cast` resolves WHICH
|
||
/// thing dies: auto-repeat first, else the in-flight cast — the ref's branch order).
|
||
pub fn take_spell_stop(&mut self) -> bool {
|
||
std::mem::take(&mut self.model_mut().spell_stop)
|
||
}
|
||
|
||
/// Push whether the app's spell-targeting cursor mode is active (decision 0792) — what
|
||
/// `SpellIsTargeting()` reads and `SpellStopTargeting()` gates on. Pushed each frame by the
|
||
/// app's targeting feed (`benilla::ui_action`), before the input pass runs the ESC chain.
|
||
pub fn set_spell_targeting(&mut self, targeting: bool) {
|
||
self.model_mut().spell_targeting = targeting;
|
||
}
|
||
|
||
/// Drain the `SpellStopTargeting()` trigger: `true` if it fired while targeting since the
|
||
/// last call — the ESC-chain 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)
|
||
}
|
||
}
|
||
|
||
/// Which book a `bookType` argument names — the reference's own one-line test, and it is a
|
||
/// **case-insensitive compare against `"pet"` alone** (`0x4b3f27` → `SStrCmpI(arg2, "pet")`), so
|
||
/// every other string, `"spell"` included, is the player's book. Reproduced rather than tightened:
|
||
/// the shared parser also *requires* a string second argument (`0x4b3ee8 lua_isstring(2)`), which
|
||
/// is why these bindings take `String` and not `Option<String>`.
|
||
fn is_pet_book(book_type: &str) -> bool {
|
||
book_type.eq_ignore_ascii_case(BOOKTYPE_PET)
|
||
}
|
||
|
||
/// The book-id → 0-based slot-list index seam (module docs). `None` for an id of `0` (the ref's
|
||
/// ids start at 1, so `id - 1` would otherwise underflow) — the reference's own `arg1 - 1` with its
|
||
/// `[0, 0x400)` bound, which our slot lists enforce by being shorter than that anyway.
|
||
fn slot_index(id: u32) -> Option<usize> {
|
||
usize::try_from(id.checked_sub(1)?).ok()
|
||
}
|
||
|
||
/// The one lookup every `bookType`-taking binding shares: pick the book, then the slot. This is
|
||
/// literally the reference's shape — `isPet ? [0xb6f098 + 4*i] : [0xb700f0 + 4*i]`, one fork
|
||
/// repeated verbatim inside each binding (`0x4b3f5d`, `0x4b40e6`, `0x4b3735`, `0x4b3339`, …).
|
||
///
|
||
/// Shared with the tooltip channel (`super::tooltip_spell`'s `SetSpell`), which is a `GameTooltip`
|
||
/// method rather than a global but repeats the identical fork at `0x532e1c`/`0x532e2a` — and which
|
||
/// read the player's book for a pet hover until 1050, because it took the argument and dropped it.
|
||
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 — the law behind `CastSpellByName` and,
|
||
/// through it, `/cast` and every macro's `/cast` line (decision 0983).
|
||
///
|
||
/// The grammar is the one the client documents in its own help text:
|
||
/// `MACRO_HELP_TEXT_LINE4 = "- To cast a spell from a macro use the following syntax:
|
||
/// /cast <name> (<subtext>)"`. So:
|
||
///
|
||
/// - **`Fireball`** — the highest-ranked *known* Fireball. Rank order is the book's own
|
||
/// `NameSubtext` number (`"Rank 8"` → 8) through the reference's own leading-number parse
|
||
/// ([`super::super::…`]'s twin lives in `benilla::ui_spellbook`); an unranked subtext sorts as 0,
|
||
/// and ties fall to the later book slot, which is the order the book itself lists ranks in.
|
||
/// - **`Fireball(Rank 1)`** / **`Fireball (Rank 1)`** — that exact subtext, case-insensitively.
|
||
/// Both spacings, because vanilla macros in the wild are written both ways and the reference's
|
||
/// own help text prints the spaced form while its FrameXML never re-spaces the argument.
|
||
///
|
||
/// Name matching is case-insensitive and whole-name — a *prefix* rule would silently cast
|
||
/// "Frostbolt" for "Frost" and there is nothing in the reference suggesting one. A spell the
|
||
/// player does not know resolves to `None` and the cast simply does not happen: the reference has
|
||
/// no error line for it either (`SlashCmdList["CAST"]` discards the binding's result).
|
||
///
|
||
/// Passives are skipped, matching [`pickup_spell`]'s sibling rule in `CastSpell`: a passive is
|
||
/// permanent player state, never something a macro casts.
|
||
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,
|
||
})
|
||
// Highest rank wins when no subtext pinned one; the book's own later slot breaks a tie.
|
||
.enumerate()
|
||
.max_by_key(|(i, s)| (rank_number(s.rank.as_deref()), *i))
|
||
.map(|(_, s)| s)
|
||
}
|
||
|
||
/// `Name(Subtext)` / `Name (Subtext)` → `("Name", Some("Subtext"))`; a bare name → `(name, None)`.
|
||
/// An unclosed parenthesis is not a subtext — the whole string stays the name, so a spell whose
|
||
/// own name holds a `(` cannot be silently truncated.
|
||
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 number inside a `NameSubtext` (`"Rank 8"` → 8), by the reference's own leading-number
|
||
/// parse: skip to the first digit, fold the digit run. A subtext with no digits (`"Racial"`,
|
||
/// `"Passive"`) and an absent one both read 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 drag/shift-click entry point (ref `SpellButton_OnClick`'s
|
||
/// other two forks, `SpellBookFrame.lua:263-290`). The book is a SOURCE, never a placement
|
||
/// target — the ref's plain click always casts unconditionally, never checking `GetCursorInfo`
|
||
/// first (unlike `UseAction`'s `checkCursor` fork) — so this refuses outright when the cursor
|
||
/// already holds ANYTHING rather than silently discarding it: the doll's own refusal precedent
|
||
/// (`cursor::doll::pickup_inventory_item`) for a payload with nowhere faithful to go, since a
|
||
/// spell button is not a fit-checked drop target the way a doll slot or bar button is.
|
||
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 book's payload is a different KIND**, not a Spell payload with a flag on it: the
|
||
// pet arm of `0x4b3260` calls `0x494e20` with a pointer to the pet's raw word (cursor modes
|
||
// 1-7), while the player arm calls `0x494d20` with a spell id (mode 9). That is why a pet
|
||
// spell can be dropped onto the pet bar and a player spell cannot — the bar's drop accepts
|
||
// exactly one payload kind, and the book is the *second* place that kind is produced.
|
||
let payload = if is_pet_book(book_type) {
|
||
// `0x494e20`'s own jump table refuses type 0 and type >= 8, so a word that could not sit
|
||
// on the bar cannot ride the cursor either (`cursor::pet::payload_word`, same rule).
|
||
let packed = slot.packed;
|
||
if !(1..=7).contains(&((packed >> 24) & 0x3F)) {
|
||
return false;
|
||
}
|
||
CursorPayload::PetAction(super::cursor::CursorPetAction {
|
||
// No source slot: this word came out of the BOOK, not off the bar, so there is
|
||
// nothing to blank behind it and nothing to swap back to. The reference is the same
|
||
// shape — its cursor holds a pointer into the raw spell array, not into 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)?;
|
||
|
||
/// The 1/nil boolean every Era binding in this file answers with.
|
||
fn flag(b: bool) -> Value {
|
||
if b {
|
||
Value::Integer(1)
|
||
} else {
|
||
Value::Nil
|
||
}
|
||
}
|
||
|
||
// `UpdateSpells()` — twelve bytes in the reference (`[0x4b43e0,0x4b43ec)`), and its entire
|
||
// content is a bare `SignalEvent(SPELLS_CHANGED)`: event 260, **no arguments**, and NO state
|
||
// mutation whatsoever. Byte-carved by a wow-re cross-check (decision 1924, their
|
||
// `system/ui/scratch/updatespells-verb.md`). Entered with both sort flags zero the worker
|
||
// performs exactly one memory write — a `push esi` undone nine instructions later — and
|
||
// `0x4b302f` is the sole fire site for event 260 image-wide.
|
||
//
|
||
// **`argc = 0` is PROVEN, not observed**: the binding overwrites `ecx` at its second
|
||
// instruction, so an extra argument is structurally unobservable. Zero return values.
|
||
//
|
||
// **It is NOT a no-op, and it is not a repaint.** Two readings were tried and both are wrong.
|
||
// A no-op breaks the five stock call sites (`ToggleSpellBook`, `SpellBookFrame_Update`'s
|
||
// showing leg, both page-turn OnClicks, `SpellBookSkillLineTab_OnClick`). And our old
|
||
// `BenillaUpdateSpells` stand-in — a loop repainting `SpellButton1..N` — was wrong in BOTH
|
||
// directions: narrower (it missed the skill-line tab strip, the pet/spell tab buttons,
|
||
// `SpellBook_UpdatePageArrows` and every other registrant) and wider (the reference gates the
|
||
// frame update on `IsVisible()`). The repaint is FrameXML's, reached through the event; there
|
||
// is no native listener mechanism at all.
|
||
//
|
||
// Fired SYNCHRONOUSLY, through [`super::tick::fire_event_into`] — the reference's
|
||
// `SignalEvent` is synchronous, so queueing it for the next tick would be a real divergence.
|
||
//
|
||
// Firing `SPELLS_CHANGED` from the other six reference sites (spell learned/removed/
|
||
// superseded, `SMSG_PET_SPELLS`, the bring-up rebuild, two UpdateFields reflexes) is a
|
||
// SEPARATE obligation and is where the spell-list re-sort lives; `UpdateSpells` is the one
|
||
// caller of the seven that never performed that recompute.
|
||
g.set(
|
||
"UpdateSpells",
|
||
lua.create_function(|lua, ()| {
|
||
super::tick::fire_event_into(lua, "SPELLS_CHANGED", Vec::new());
|
||
Ok(())
|
||
})?,
|
||
)?;
|
||
|
||
// `PlayerHasSpells()` — a hard-coded `1` in the reference: `push 0x3ff00000; push 0;
|
||
// lua_pushnumber; mov eax,1; ret`. No branch, no store read, so it cannot answer anything else
|
||
// (1924, found by the same dispatch). `MainMenuBarMicroButtons.lua` gates the spellbook micro
|
||
// button on it, which is the only reason it exists here.
|
||
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 that
|
||
// returns** — `0x4b3ce0` has two live `ret`s and both `mov eax,4`; the `eax=0` one at `0x4b3d0d`
|
||
// is dead code after `luaL_error` longjmps. Byte-carved by a wow-re cross-check (decision 1931,
|
||
// `system/ui/scratch/spelltabinfo-return-contract.md`).
|
||
//
|
||
// **OUT OF RANGE IS `nil, nil, 0, 0` — LITERAL zeros, not a single nil.** This comment used to say
|
||
// "out of range -> a single nil (GetMerchantItemInfo's own out-of-range shape)", i.e. one
|
||
// binding's shape copied onto another on the assumption that they generalise. They do not
|
||
// (1919 proved it for GetSkillLineInfo; this is the same bug one verb over). Leg `0x4b3e12`
|
||
// pushes nil, nil, then `push 0; push 0; lua_pushnumber` twice — `0x6f3810` copies its two
|
||
// stack dwords VERBATIM, so that is exactly +0.0, not a stale offset and nothing off the array
|
||
// base. Index 0, a negative, past-the-count and the empty/no-player registry all reach it via
|
||
// ONE unsigned branch (`0x4b3d26 dec ebx / cmp ebx,eax / jae`).
|
||
//
|
||
// Why the zeros are load-bearing rather than cosmetic: `SpellBookFrame_OnLoad` runs
|
||
// `SpellBookSkillLineTab_OnClick(1)` -> `GetSpellTabInfo(1)` UNCONDITIONALLY at load, and
|
||
// `SpellBookFrame_Update` can pass `SpellBookFrame:GetID()`, which is 0. The stock file then
|
||
// does `id > (offset + numSpells)` unguarded (l.295) — a nil there raises, and `(0+0)` makes it
|
||
// true, so the reference HIDES EVERY BUTTON ON THE PAGE. That is the behaviour to match.
|
||
//
|
||
// **The argument RAISES when absent or not number-coercible** — `luaL_error(L, "Usage:
|
||
// GetSpellTabInfo(index)")`, a numeric string accepted. [`number_arg`] is that exact shape
|
||
// (coerce, truncate toward zero, raise the usage string), and the decrement happens AFTER it.
|
||
// Do NOT share `GetSpellName`'s marshaller: `0x4b3ec0` does `fsub 1.0` BEFORE truncating, and
|
||
// the two disagree below 1 (`0.5` is out-of-range here and index 0 there).
|
||
//
|
||
// Slot 1 is also nil on an IN-RANGE tab whose skillLineId has no DBC row (`0x4b3dbd`), and
|
||
// `(string?, nil, num, num)` is live in shipped data (SkillLine.dbc 733/753/754 have
|
||
// spellIconID 0) — both already the shape the in-range arm below produces.
|
||
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");
|
||
// Truncate (done), THEN decrement, then the unsigned compare: a negative or zero
|
||
// index lands below 0 and `usize::try_from` refuses it — the `jae` leg.
|
||
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, never one, and the rank of a
|
||
// rankless spell is the EMPTY STRING, never nil** (wow-re
|
||
// `scratch/getspellname-return-contract.md`, §5-derived from the bytes alone).
|
||
//
|
||
// Both returns go through the same push helper with **no rank-specific branch anywhere in the
|
||
// binding** — a ranked and a rankless spell run byte-identical code: `0x4b4063 call 0x6f3890`
|
||
// pushes `SpellRec.Name[locale]`, `0x4b4076` pushes `NameSubtext[locale]`, and the single
|
||
// DBC-resolved exit is `mov eax,0x2` at `0x4b407c`. `0x6f3890` decides on **pointer nullity
|
||
// alone** (`test edx,edx`), and the DBC pointer is never NULL: `SpellRec::Read 0x583750` fixes
|
||
// up every string column with an unconditional `add offset, stringBlockBase`, so an on-disk
|
||
// offset of 0 materializes as a pointer to the string block's own byte 0 — a NUL. `0x6f3840`
|
||
// then writes tag 4 (`LUA_TSTRING`) unconditionally; `len == 0` is not a special case. So the
|
||
// rankless rank is a real, interned, zero-length Lua string.
|
||
//
|
||
// This is not an edge case: **14,403 of the shipped Spell.dbc's 22,357 rows carry NameSubtext
|
||
// offset 0** — 64%, `Attack` (6603) among them. Our `Option<String>::None` pushed nil there,
|
||
// and two 1.12 corpus addons walking the whole book die on it in two different idioms:
|
||
// `Roid-Macros/Generic.lua:35` uses the rank as a TABLE KEY (nil is illegal), and
|
||
// `CT_MasterMod/CT_Master.lua:16` passes it straight to `string.find` (nil raises). Both were
|
||
// invisible until the survey seated a spellbook.
|
||
//
|
||
// The `Option` is still the right shape for the MODEL — it records "this spell has no
|
||
// NameSubtext", which is a fact about the DBC row. What was wrong was rendering that absence
|
||
// as nil at the Lua boundary; the reference renders it as the empty string the pointer targets.
|
||
g.set(
|
||
"GetSpellName",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
// An out-of-range index RAISES rather than answering nil. `0x4b3ec0` gates the index to
|
||
// `[0, 0x400)` (`0x4b3f0a`/`0x4b3f11`) before the binding proper runs, and the miss arm
|
||
// is `luaL_error("Invalid spell slot in GetSpellName")`, which longjmps and abandons
|
||
// the caller's statement (`super::binding_abi`). The in-binding bound check at
|
||
// `0x4b4018` is unreachable-as-taken. This file previously assumed the bound was
|
||
// "enforced by our slot lists being shorter than that anyway" — a short list answers
|
||
// nil, which is a different thing entirely.
|
||
if id >= 0x400 {
|
||
return Err(mlua::Error::runtime("Invalid spell slot in GetSpellName"));
|
||
}
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
let Some(slot) = book_slot(&model, id, &book_type) else {
|
||
// An unfilled slot inside the range answers **two** nils (`0x4b4086` → `mov eax,0x2`
|
||
// at `0x4b4095`), not one — which `select('#', …)` and a two-name assignment can
|
||
// both tell apart.
|
||
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): (u32, String)| {
|
||
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) — the spellbook button's checked ring (binding `0x4b4370` →
|
||
// delegate `0x4b3600`; ref
|
||
// `SpellButton_UpdateSelection` SetChecks on it). The verdict itself is app-resolved per slot
|
||
// ([`SpellSlotView::current`]); this only reads it back.
|
||
g.set(
|
||
"IsCurrentCast",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
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);
|
||
// The ref's binding convention: 1 or nil, never false.
|
||
match current {
|
||
true => Ok(Value::Integer(1)),
|
||
false => Ok(Value::Nil),
|
||
}
|
||
})?,
|
||
)?;
|
||
|
||
// GetSpellCooldown(id, bookType) → start, duration, enable — the book twin of
|
||
// `GetActionCooldown`/`GetContainerItemCooldown`, identical conventions: `GetTime`-clock
|
||
// `(seconds, seconds, 0/1)`, enable 0 = an on-hold record (parked, full duration), and the
|
||
// cold-at-expiry guard so an event-driven re-feed can never replay the finish flash. Pet or
|
||
// out-of-range answers the cold `(0, 0, 1)` — the ref's own no-cooldown shape.
|
||
g.set(
|
||
"GetSpellCooldown",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
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): (u32, String)| {
|
||
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
||
Ok(book_slot(&model, id, &book_type).is_some_and(|s| s.passive))
|
||
})?,
|
||
)?;
|
||
|
||
// CastSpell(id, bookType) — the plain click (ref SpellButton_OnClick's `else` branch): queues
|
||
// the resolved spell id UNLESS the slot is passive (module doc: a passive is permanent player
|
||
// state, never something the player casts) or bookType/id resolve to nothing.
|
||
//
|
||
// **The pet arm is a different verb, not a flag.** `0x4b3300`'s tail forks on the book byte
|
||
// one instruction before the send (`0x4b34c8 cmp ecx, 0; je` → the player's own cast
|
||
// `0x6e5a90`), and the pet side builds `CMSG_PET_ACTION 0x175` by hand:
|
||
// `{ u64 [0xb714a0], u32 (spellId & 0xFFFF) | 0x01000000, u64 target }` (`0x4b34ce`-`0x4b3524`)
|
||
// — a **synthesized type-1 word**, which is why the pet book can cast a spell that is not on
|
||
// the bar at all. The target is the passed one, falling back to the current selection
|
||
// (`0x4b34af`-`0x4b34bb`), exactly as `CastPetAction` does; the app supplies it at the drain.
|
||
g.set(
|
||
"CastSpell",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
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 — no arguments, and **always exactly two returns**
|
||
// (`0x4b4410`, `EAX = 2` on every path). Zero spells answers `(nil, nil)` (`0x4b4420`), which
|
||
// is the gate `ToggleSpellBook` and `SpellBookFrame_Update` both read: no pet book, no tab row.
|
||
//
|
||
// Return 1 is the **count as a number**, not a boolean — `SpellBook_GetCurrentPage` divides by
|
||
// it (`ceil(numPetSpells/12)`), so answering 1/nil would silently pin the pet book to one page.
|
||
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)?),
|
||
// The reference's own unresolved-player arm pushes the literal "PET"
|
||
// (`0x4b44a6`), never nil — a nil here would make `"PET_TYPE_"..token` error.
|
||
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/nil each) — the
|
||
// AutoCastable overlay and the sparkle model on a pet book button.
|
||
//
|
||
// **Pet-only, and it fails to (nil, nil) rather than (nil) for the player book**: `0x4b4180`
|
||
// tests the book flag twice (`0x4b41cb`, `0x4b41d6`) and falls into the same two nil pushes the
|
||
// no-record path uses, so the arity is 2 on every path including a bad index.
|
||
g.set(
|
||
"GetSpellAutocast",
|
||
lua.create_function(move |lua, (id, book_type): (u32, String)| {
|
||
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 (ref `SpellButton_OnClick`'s
|
||
// `arg1 ~= "LeftButton"` fork). **A different binding and a different opcode from the pet
|
||
// BAR's `TogglePetAutocast`**: `0x4b4240` indexes the pet spellbook and calls `0x4bccb0`, which
|
||
// sends `CMSG_PET_SPELL_AUTOCAST 0x2F3` naming a **spell id**, where the bar's verb sends
|
||
// `CMSG_PET_SET_ACTION` naming a slot. Confusing them is a wire bug that looks like a UI one.
|
||
//
|
||
// The gate here is the same one `0x4bccb0` applies before it sends: the word must be
|
||
// autocast-ALLOWED (`0x4bccf5 test cl,1`). Everything else the sender does — flipping bit 30
|
||
// in place and mirroring it onto every bar slot carrying the same action — is state the app
|
||
// owns, so it happens at the drain.
|
||
g.set(
|
||
"ToggleSpellAutocast",
|
||
lua.create_function(|lua, (id, book_type): (u32, String)| {
|
||
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]) — the reference binding `0x4b4ab0`, whose only two callers
|
||
// share the `0x4b3300` dispatcher with `CastSpell` above (wow-re ledger), so this queues onto
|
||
// the same `spell_casts` list and the app's one cast tail handles both. `SlashCmdList["CAST"]`
|
||
// is literally `CastSpellByName(msg)`, which is why `/cast` needs nothing else, and it is the
|
||
// command the whole macro system is built to run.
|
||
//
|
||
// `onSelf` is accepted and carried no further (benilla has no self-cast modifier yet — the
|
||
// same named gap `UseAction`'s third argument already has).
|
||
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): (u32, String)| {
|
||
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
||
Ok(pickup_spell(&mut model, id, &book_type))
|
||
})?,
|
||
)?;
|
||
|
||
// SpellStopCasting() — the ref's Script::SpellStopCasting (`0x6e6e80`, §5-verified whole,
|
||
// wow-re `esc-stopcasting.md`): stop the FIRST of {running auto-repeat (`0x6ea080`,
|
||
// CMSG_CANCEL_AUTO_REPEAT_SPELL), in-flight cast (`AbortCast` → CMSG_CANCEL_CAST)} and
|
||
// return 1; nil when neither runs. A CHANNEL is nil — the body's whole callee closure
|
||
// never reaches the channel canceler `0x6e9b70`, and the inflight id `0xceca88` it gates
|
||
// on is already 0 mid-channel (the launch CAST_RESULT(OKAY) clears it at `0x6e7408`) —
|
||
// the vanilla "/stopcasting can't stop a channel" quirk, kept faithfully. The falsy leg is
|
||
// load-bearing ground truth from the artifact: `ToggleGameMenu`'s ESC chain (extracted
|
||
// `UIParent.lua:1489`, `elseif ( SpellStopCasting() ) then`) only reaches
|
||
// `CloseAllWindows()`/the game menu through nil, so an unconditional true would eat every
|
||
// ESC press forever. The host feeds the stoppable mirror (`set_casting`) and resolves the
|
||
// branch order at the drain (`benilla::ui_cast::local_self_cancel`).
|
||
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() — the ref's Script::SpellIsTargeting (`0x6e6cd0`, wow-re
|
||
// `wave-cast.md`): true while the targeting cursor is up (`flag_word != 0`), nil otherwise.
|
||
// Read by FrameXML (PetFrame's right-click bind fork) and by the ESC chain's callers.
|
||
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") — the ref's `0x6e6d00`: resolve the token, then ask `0x6e6460`'s
|
||
// UNIT leg whether the standing word can bind it. Its one shipped caller is
|
||
// `UnitFrame_OnEnter`, and it is what picks CAST_CURSOR over CAST_ERROR_CURSOR — the only
|
||
// lit/grey cursor split over a UI element in 1.12.
|
||
//
|
||
// The token is not consulted yet, and that is honest rather than lazy: the answer is `false`
|
||
// for **every** unit while any word benilla can arm is standing (location / item / gameobject —
|
||
// no unit satisfies those), so no token can change it. The app derives the flag from the word
|
||
// itself, so this starts discriminating by unit the moment the residual unit-word machine
|
||
// lands rather than silently staying wrong.
|
||
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() — the ref's Script::SpellStopTargeting (`0x6e6e30`: if IsTargeting →
|
||
// StopTargeting `0x6e4900` → AbortCast(0x1c), which in targeting mode just clears the word,
|
||
// no packet). The 1/nil return is load-bearing exactly like SpellStopCasting's above: the
|
||
// ESC chain's rung (`UIParent.lua:1490`, `elseif ( SpellStopTargeting() ) then`) must fall
|
||
// through to the game menu when nothing was targeting. The host drains the trigger
|
||
// (`benilla::ui_action::targeting`) and clears its mode.
|
||
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" (2 spells: Fireball rank1 active, Fire Blast PASSIVE — an artificial
|
||
/// fixture just to exercise the gray/refuse gate) and "Frost" (1 spell).
|
||
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, // artificial: exercises the refusal gate
|
||
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()
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
/// `IsCurrentCast` reads the app-resolved per-slot verdict back as the ref's 1-or-nil.
|
||
#[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());
|
||
}
|
||
|
||
/// `GetSpellCooldown` reads the app-pushed per-slot triple back as the ref's GetTime-clock
|
||
/// `(start, duration, enable)` — cold `(0, 0, 1)` for absent/pet/out-of-range, enable 0 for
|
||
/// an on-hold record regardless of expiry, and the cold-at-expiry guard once
|
||
/// `start + duration` passes (`GetActionCooldown`'s own conventions, the book twin).
|
||
#[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 ref'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 (enable 0 = the parked "hasn't begun").
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(2, BOOKTYPE_SPELL)")
|
||
.unwrap(),
|
||
(2.0, 8.0, 0)
|
||
);
|
||
// Elapsed goes cold — an event-driven re-feed can never replay the finish flash.
|
||
assert_eq!(
|
||
s.eval::<(f64, f64, i64)>("return GetSpellCooldown(3, BOOKTYPE_SPELL)")
|
||
.unwrap(),
|
||
(0.0, 0.0, 1)
|
||
);
|
||
// The pet deferral and out-of-range 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);
|
||
// `== nil` reads true either way — Lua compares only the first value. The ARITY is the
|
||
// load-bearing half and lives in `out_of_range_answers_four_values` below (1931).
|
||
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.
|
||
assert!(s.eval::<bool>("return GetSpellTabInfo(3) == nil").unwrap());
|
||
}
|
||
|
||
#[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");
|
||
|
||
// Out of range and the pet deferral both 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)
|
||
);
|
||
|
||
// CURSOR_UPDATE fired (the shared cursor seam, not duplicated here) — a listener sees it.
|
||
// Tick first to flush the FIRST pickup's already-queued CURSOR_UPDATE before the listener
|
||
// registers, so the count below is purely about the second (refused) call.
|
||
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 spell 133 (book slot 1) from the first pickup — a refusal never clobbers
|
||
// it (GetCursorInfo's Spell arm: kind, book_slot, book_type, spell_id).
|
||
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 fed, every pet arm answers nothing** — the old deferral's behaviour,
|
||
/// which is also the reference's whenever `[0xb71174] == 0`. Kept as its own case so the
|
||
/// pet-book tests below can never pass by the player book leaking into them.
|
||
#[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, on cooldown), 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()
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
/// `HasPetSpells` is **the count and a token**, always two returns, and the count is a NUMBER
|
||
/// — `SpellBook_GetCurrentPage` divides by it, so a 1/nil boolean would pin the page count.
|
||
#[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 book carries the other token, which is the whole of what makes the tab read
|
||
// "Demon" — FrameXML does `getglobal("PET_TYPE_"..token)`.
|
||
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, never nil — a nil would
|
||
// make the reference's own concatenation error.
|
||
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"
|
||
);
|
||
}
|
||
|
||
/// The two books are separate lists reached by the SAME id — the reference's `isPet ? petArray
|
||
/// : playerArray` fork. Book id 1 means Fireball in one and Growl in the other.
|
||
#[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"
|
||
);
|
||
// The book type is a case-insensitive compare against "pet" ALONE (`0x4b3f27`), so every
|
||
// other string is the player's book — including a typo'd one.
|
||
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: one nil, the out-of-range shape.
|
||
assert!(s
|
||
.eval::<bool>(r#"return GetSpellName(4, BOOKTYPE_PET) == nil"#)
|
||
.unwrap());
|
||
}
|
||
|
||
/// `GetSpellAutocast` is **pet-only and always two returns**: the player book short-circuits
|
||
/// to `(nil, nil)` before it looks anything up (`0x4b41cb`/`0x4b41d6`), and so does an
|
||
/// out-of-range pet index.
|
||
#[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"
|
||
);
|
||
}
|
||
|
||
/// `ToggleSpellAutocast` queues only what `0x4bccb0` would actually send: a pet-book slot whose
|
||
/// word is autocast-ALLOWED. A passive, a player-book id and an out-of-range id all queue
|
||
/// nothing — and none of them may leak into the player's cast queue.
|
||
#[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());
|
||
}
|
||
|
||
/// A pet cast is a **different queue** from a player cast, because it is a different opcode at
|
||
/// the far end (`CMSG_PET_ACTION`, not a player cast). A passive still refuses on both.
|
||
#[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");
|
||
}
|
||
|
||
/// A pet-book pickup puts a **pet action word** on the cursor, not a spell payload — which is
|
||
/// exactly what makes it droppable on the pet bar (`cursor::pet`'s payload). The word is the
|
||
/// server's own, autocast bits and all.
|
||
#[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 — the two are not interchangeable.
|
||
s.run("ClearCursor()").unwrap();
|
||
assert!(s
|
||
.eval::<bool>(r#"return PickupSpell(1, BOOKTYPE_SPELL)"#)
|
||
.unwrap());
|
||
assert!(matches!(s.cursor_payload(), Some(CursorPayload::Spell(_))));
|
||
}
|
||
|
||
/// `GetSpellCooldown(id, "pet")` reads the PET's slot — the reference reaches bank 1 with the
|
||
/// same `0x6e2ea0(edx = isPet)` `GetPetActionCooldown` uses, so a spell on the bar and the same
|
||
/// spell in the book must never disagree. The elapsed-goes-cold rule is the player book's.
|
||
#[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 — proof the fork reached the right list.
|
||
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)
|
||
);
|
||
}
|
||
|
||
/// **A rankless spell's rank is the empty string, not nil** — and the arity is always two.
|
||
///
|
||
/// Both returns go through one push helper with no rank-specific branch (`0x4b4063`/`0x4b4076`,
|
||
/// single exit `mov eax,0x2` at `0x4b407c`), and the DBC pointer is never NULL, so an on-disk
|
||
/// NameSubtext offset of 0 becomes a pointer to the string block's byte 0 — a real zero-length
|
||
/// Lua string. 64% of the shipped Spell.dbc's rows are in that state, `Attack` among them.
|
||
///
|
||
/// The two corpus idioms this was breaking are both asserted here, because "falsey either way"
|
||
/// is exactly the reasoning that made nil look acceptable: a nil is an ILLEGAL TABLE KEY
|
||
/// (Roid-Macros) and an ILLEGAL `string.find` argument (CT_MasterMod), while `""` is neither.
|
||
#[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.eval::<i64>(r#"return select('#', 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"
|
||
);
|
||
// Roid-Macros/Generic.lua:35 and CT_MasterMod/CT_Master.lua:16, in miniature.
|
||
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 unfilled slot INSIDE the range is two nils, not one — distinguishable by select('#').
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return select('#', GetSpellName(9, "spell"))"#)
|
||
.unwrap(),
|
||
2
|
||
);
|
||
assert!(s
|
||
.eval::<bool>(r#"local a, b = GetSpellName(9, "spell") return a == nil and b == nil"#)
|
||
.unwrap());
|
||
|
||
// Past the reference's [0, 0x400) gate it RAISES rather than answering nil.
|
||
let err = s
|
||
.run(r#"GetSpellName(1024, "spell")"#)
|
||
.expect_err("an out-of-range slot must raise");
|
||
assert!(
|
||
format!("{err}").contains("Invalid spell slot in GetSpellName"),
|
||
"got {err}"
|
||
);
|
||
// ...and 1023 is inside it, so it answers rather than raising.
|
||
assert_eq!(
|
||
s.eval::<i64>(r#"return select('#', GetSpellName(1023, "spell"))"#)
|
||
.unwrap(),
|
||
2
|
||
);
|
||
}
|
||
|
||
/// **`UpdateSpells()` fires `SPELLS_CHANGED` and does nothing else** — decision 1924, from a
|
||
/// wow-re byte read of `[0x4b43e0,0x4b43ec)`.
|
||
///
|
||
/// The assertion is that a registered handler RAN, not that any frame repainted: the reference
|
||
/// verb mutates no state at all, and the repaint is FrameXML's, reached only through the event.
|
||
/// Asserting a repaint here would re-encode the `BenillaUpdateSpells` guess this replaces —
|
||
/// which was both narrower than the reference (it repainted `SpellButton1..N` and nothing else)
|
||
/// and wider (the reference gates its frame update on `IsVisible()`).
|
||
#[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 return values — `arity = 0 (exact)` in `reference/1.12-shapes.tsv`.
|
||
assert_eq!(
|
||
s.eval::<i64>("return select('#', UpdateSpells())").unwrap(),
|
||
0
|
||
);
|
||
assert!(s.errors().is_empty(), "errors: {:?}", s.errors());
|
||
}
|
||
|
||
/// **`PlayerHasSpells()` is a hard-coded `1`** (1924): `push 0x3ff00000; push 0;
|
||
/// lua_pushnumber; mov eax,1; ret` — no branch and no store read, so it cannot answer anything
|
||
/// else. Asserted on an EMPTY spellbook precisely because that is the state where a
|
||
/// plausible-looking "does the player have any spells?" implementation would answer 0 and
|
||
/// diverge silently.
|
||
#[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.eval::<i64>("return select('#', PlayerHasSpells())")
|
||
.unwrap(),
|
||
1
|
||
);
|
||
}
|
||
|
||
/// **Out of range answers `nil, nil, 0, 0` — four values, the last two NUMBERS** (1931). The
|
||
/// count is the assertion: `GetSpellTabInfo(0) == nil` is true either way, which is exactly how
|
||
/// the old one-nil shape passed. Stock `SpellBookFrame.lua:295` does `id > (offset + numSpells)`
|
||
/// unguarded straight off `SpellBookFrame_OnLoad`, so slots 3+4 nil is a raise on load, and
|
||
/// `(0+0)` is what makes the reference hide every button — the behaviour this pins.
|
||
#[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.eval::<i64>(&format!("return select('#', 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());
|
||
}
|
||
}
|
||
|
||
/// The argument is shape A (1931): absent or non-numeric RAISES the reference's own usage
|
||
/// string; a numeric STRING is coerced; a negative is truncated and falls to the fallback
|
||
/// rather than raising. `-1` and `0.5` are covered by the test above.
|
||
#[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 exactly as 1 does — same name comes back.
|
||
assert_eq!(
|
||
s.eval::<String>("return (GetSpellTabInfo('1'))").unwrap(),
|
||
s.eval::<String>("return (GetSpellTabInfo(1))").unwrap()
|
||
);
|
||
}
|
||
}
|