`AdvancedTradeSkillWindow2.lua:3878` is `if Item.ReplaceWords == 1then`, with no space, and it compiles on the reference. Its `read_numeral` (`0x6ffee0`) is 5.0's: digits, at most one `.` and its digits, then — given an `e`/`E` — the marker, an optional sign (the `0x2b`/`0x2d` test at `0x6fff04`, reached with the marker already saved) and its own digit loop. The only character-class test in the whole routine is the ctype digit mask at `0x6fff47`; past it the buffer is terminated and handed to `luaO_str2d` (`0x6f5900`). 5.1 instead ends with `while (isalnum(ls->current) || ls->current == '_') save_and_next(ls);`, which swallowed `1then` into the numeral and failed it — so we raised `malformed number` and lost a 4000-line file and every global it defines, and its own XML then raised four more times calling them. The addon was dead. The ninth hunk of the `lua-src` fork, which is where a grammar difference belongs: `BENILLA.md` is explicit that rewriting the chunk source means parsing Lua with a regex. A replacement, not a deletion — 5.1 had no exponent-digit loop of its own, so deleting the slurp alone would have cost `1e2`, which is what the new test caught. The same boundary is the deliberate half: 1.12 has **no hex literals**. The digit loop stops at the `x`, so the reference reads `0xFF` as `0` and the name `xFF` and cannot parse the statement around it. A comment- and string-stripping scan of the installed corpus finds hex in code in exactly two files, `pfQuest/toolbox/pngLua/deflate.lua` and `numberlua.lua`, which no `.toc` row reaches — zero loaded sites, the same standard the fork's three other deletions were held to. Our own shipped Lua and his saved variables have none either. Two of our own test chunks did, and are now decimal: no chunk of benilla's Lua may use a construct the client's parser rejects. Verified: the whole chain still loads with no failure, the `WOW_DATA` failure set is unchanged, and `AdvancedTradeSkillWindow2` now publishes `ATSW_OnLoad` and `ATSWTypeColor`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
1172 lines
50 KiB
Rust
1172 lines
50 KiB
Rust
//! ClassicAPI's `C_Spell`, spell-id-first over two sources.
|
|
//!
|
|
//! The three getters addons actually key tables on — name, rank subtext and icon — read the
|
|
//! player's own spellbook first (`FindSpellBookSlotByID`, this directory's `spellbook.rs`) and
|
|
//! fall back to the host's `Spell.dbc` reader, [`SpellProbe`](crate::script::SpellProbe), for
|
|
//! every other id. Book first because a book slot carries the live icon substitutions the player
|
|
//! is actually looking at (Attack shows the equipped weapon, `ui_spellbook.rs`); the probe
|
|
//! because the book holds the ~200 spells one character knows, and addons ask about arbitrary
|
|
//! ids at file scope — pfUI keys a predict table on `GetSpellTexture(774)` (Rejuvenation) and
|
|
//! `GetSpellName(139)` (Renew) in `api/unitframes.lua:42`, on every class, and `strlower(nil)`
|
|
//! would stop its load on anything but a druid-priest. A VM with no host attached (the UI-only
|
|
//! test harness) has no probe and answers from the book alone.
|
|
//!
|
|
//! The richer introspection (school, mechanic, dispel type, radius, reagents, power cost, "is
|
|
//! harmful/helpful") is still unbuilt: the probe carries only the three display strings, not the
|
|
//! whole `benilla_formats::SpellDisplay` — `benilla-ui` is deliberately DBC-free, so widening it
|
|
//! means widening [`SpellLookup`](crate::script::SpellLookup) field by field, with a reason each
|
|
//! time. Those rows stay `blocked` in `docs/ADDON_COMPAT.md`, not approximated.
|
|
//!
|
|
//! The rest is backed by the spellbook slot view and the two bare `casting`/`spell_targeting`
|
|
//! flags the stock `SpellStopCasting`/`SpellIsTargeting` bindings already read
|
|
//! (`script/spellbook.rs`) — there is no spell id attached to either, so the cancel functions
|
|
//! below can stop *a* cast, never verify *which* one, same limit as those stock bindings already
|
|
//! carry.
|
|
|
|
use mlua::{Lua, MultiValue, Table, Value};
|
|
|
|
use super::spellbook::find_slot;
|
|
use crate::script::spellbook::{book_slot, spell_display as display};
|
|
use crate::script::Model;
|
|
|
|
/// `UnitCastingInfo`/`UnitChannelInfo`'s returns for whatever `token` names, or a lone **nil** when
|
|
/// that unit has no live window of the asked-for kind.
|
|
///
|
|
/// One nil, not zero values: upstream answers nil (pfUI's `modules/turtle-wow.lua:242` says so in
|
|
/// as many words — "C_Spell.UnitCastingInfo returns nil"), and the difference is observable —
|
|
/// `type(f())` on a call that returned *nothing* raises "value expected" rather than reading
|
|
/// "nil".
|
|
///
|
|
/// The two shapes differ, and the difference is load-bearing — the one field between `isTradeskill`
|
|
/// and `spellID` that the cast form has and the channel form does not is `castID`, so `spellID`
|
|
/// lands at 9 for a cast and 8 for a channel. Both of this install's consumers destructure
|
|
/// positionally and both get it right, which is the cross-check:
|
|
///
|
|
/// ```text
|
|
/// nameplates.lua:83 name, _, texture, startMs, endMs, _, _, _, spellID = UnitCastingInfo(unit)
|
|
/// nameplates.lua:86 name, _, texture, startMs, endMs, _, _, spellID = UnitChannelInfo(unit)
|
|
/// castbar.lua:155 name, _, tex, startMs, endMs, isTradeskill, _, _, spellID, _, delayMs
|
|
/// ```
|
|
///
|
|
/// Positions this client cannot back, left nil rather than filled with a plausible value — a cast
|
|
/// bar is read as truth, so a wrong field is worse than an absent one:
|
|
///
|
|
/// - **2 `text`.** Answered as `name`, which is what upstream answers for all but a channel. 1.12
|
|
/// carries no second per-cast display string on the wire; the one place the reference's own text
|
|
/// *does* differ from the spell's name is the stock channel bar's `CHANNELING` word
|
|
/// (`benilla-app`'s `ui_cast.rs`, `AttributesEx` bit 29), and that is an argument of
|
|
/// `SPELLCAST_CHANNEL_START` for the local player only, not a per-unit field. No consumer in the
|
|
/// corpus reads this position — both destructure it as `_`.
|
|
/// - **6 `isTradeskill`.** nil. 1.12 has no tradeskill column in `Spell.dbc` and no wire bit for
|
|
/// it; a modern client answers it from recipe data this client's `Spell.dbc` does not carry.
|
|
/// pfUI reads it to merge a queued craft stack into one long bar (`castbar.lua:57`), and nil
|
|
/// simply leaves each craft its own bar — a feature not drawn, not a bar drawn wrong.
|
|
/// - **7 `castID`.** nil. 1.12 has no per-cast identifier anywhere on the wire; the modern one
|
|
/// arrived with the `UNIT_SPELLCAST_*` GUID payloads.
|
|
/// - **8 `notInterruptible`** (cast) / **7** (channel). nil, which reads as false —
|
|
/// "interruptible", and in 1.12 every cast is: there is no uninterruptible-cast flag in the
|
|
/// client, in `Spell.dbc` or on the wire for one to be read off.
|
|
/// - **10**, between `spellID` and `delayMs` in the cast form. nil: upstream puts *something*
|
|
/// there (pfUI skips exactly one slot to reach `delayMs` at 11) and nothing in this tree or the
|
|
/// addon corpus says what, so it is left empty rather than guessed at.
|
|
fn cast_info(
|
|
lua: &Lua,
|
|
model: &Model,
|
|
token: &str,
|
|
want_channel: bool,
|
|
) -> mlua::Result<MultiValue> {
|
|
let Some(cast) = model.unit_cast(token, want_channel) else {
|
|
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
|
};
|
|
// Name and icon off the same `spell_display` every other `C_Spell` getter reads, so the bar's
|
|
// label and a caller's own `GetSpellName(spellID)` are one string, not two that can drift. An
|
|
// id with no display anywhere (no book slot, no probe) answers nothing at all rather than a
|
|
// nameless window: every consumer's existence test is `if not name`, so a nil name has to mean
|
|
// "no cast to draw" or it draws an unlabelled bar off a real window.
|
|
let Some(display) = display(model, cast.spell_id) else {
|
|
return Ok(MultiValue::from_vec(vec![Value::Nil]));
|
|
};
|
|
let name = Value::String(lua.create_string(&display.name)?);
|
|
let icon = match display.icon {
|
|
Some(p) => Value::String(lua.create_string(&p)?),
|
|
None => Value::Nil,
|
|
};
|
|
// ms, not seconds: the corpus does `startMs / 1000` and compares `endMs > GetTime() * 1000`.
|
|
let ms = |secs: f64| Value::Number(secs * 1000.0);
|
|
let mut out = vec![
|
|
name.clone(),
|
|
// 2 `text`: the same string as `name` — see this function's note.
|
|
name,
|
|
icon,
|
|
ms(cast.start),
|
|
ms(cast.end),
|
|
// 6 isTradeskill, then 7 castID (cast form) / notInterruptible (channel form).
|
|
Value::Nil,
|
|
Value::Nil,
|
|
];
|
|
if !want_channel {
|
|
// 8 notInterruptible, the field the channel form does not have.
|
|
out.push(Value::Nil);
|
|
}
|
|
out.push(Value::Integer(i64::from(cast.spell_id)));
|
|
if !want_channel {
|
|
// 10 upstream's unidentified slot, then 11 delayMs.
|
|
out.push(Value::Nil);
|
|
out.push(Value::Integer(i64::from(cast.delay_ms)));
|
|
}
|
|
Ok(MultiValue::from_vec(out))
|
|
}
|
|
|
|
/// `GetSchoolString(schoolMask)`: the seven `Spell.dbc` school bits, a pure lookup with no client
|
|
/// state — `SpellDisplay::school` (`benilla-app` only) is the single-index form this takes a
|
|
/// mask of; `0x1` physical .. `0x40` arcane, the standard WoW bit order.
|
|
fn school_string(mask: i64) -> Option<&'static str> {
|
|
Some(match mask {
|
|
0x1 => "SCHOOL_MASK_PHYSICAL",
|
|
0x2 => "SCHOOL_MASK_HOLY",
|
|
0x4 => "SCHOOL_MASK_FIRE",
|
|
0x8 => "SCHOOL_MASK_NATURE",
|
|
0x10 => "SCHOOL_MASK_FROST",
|
|
0x20 => "SCHOOL_MASK_SHADOW",
|
|
0x40 => "SCHOOL_MASK_ARCANE",
|
|
_ => return None,
|
|
})
|
|
}
|
|
|
|
/// The classes 1.12 lets dual-wield (`CanDualWield`'s only input): warrior, rogue, hunter and
|
|
/// shaman; druid/paladin/priest/mage/warlock cannot slot an off-hand weapon at all.
|
|
fn class_can_dual_wield(file_token: &str) -> bool {
|
|
matches!(
|
|
file_token.to_ascii_uppercase().as_str(),
|
|
"WARRIOR" | "ROGUE" | "HUNTER" | "SHAMAN"
|
|
)
|
|
}
|
|
|
|
pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
|
|
let t = lua.create_table()?;
|
|
|
|
// "Exists in Spell.dbc", not "is in my book" — that is `IsSpellKnown` below. A host-less VM
|
|
// can only see the book, so there it answers the narrower question.
|
|
t.set(
|
|
"DoesSpellExist",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(display(&model, spell_id).is_some())
|
|
})?,
|
|
)?;
|
|
|
|
t.set(
|
|
"GetSpellName",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(match display(&model, spell_id) {
|
|
Some(d) => Value::String(lua.create_string(&d.name)?),
|
|
None => Value::Nil,
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
t.set(
|
|
"GetSpellTexture",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(match display(&model, spell_id).and_then(|d| d.icon) {
|
|
Some(t) => Value::String(lua.create_string(&t)?),
|
|
None => Value::Nil,
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellSubtext(spellID): the rank line, the one piece of "subtext" this book carries.
|
|
t.set(
|
|
"GetSpellSubtext",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(match display(&model, spell_id).and_then(|d| d.rank) {
|
|
Some(r) => Value::String(lua.create_string(&r)?),
|
|
None => Value::Nil,
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellDispelType(spellID): `Spell.dbc`'s `Dispel` id — the dispel class an aura belongs
|
|
// to — or nil for a spell that has none and for an id nothing can name.
|
|
//
|
|
// The **raw id**, not `SpellDispelType.dbc`'s localized name: the caller maps it itself, and
|
|
// pfUI's debuff library keys its own `dispelTypeMap` on the number (`libs/libdebuff.lua:379`),
|
|
// so a name would make a localized realm answer something that table cannot match.
|
|
//
|
|
// This is the first of the `C_Spell` predicates the probe can answer, and it is built because
|
|
// its call site is hot and unguarded: `libdebuff` calls it once per debuff while walking aura
|
|
// slots 33-48 for a unit, so the absent function raised for every debuff on every scanned
|
|
// unit — i.e. continuously, in combat, which is exactly when the debuff display matters.
|
|
t.set(
|
|
"GetSpellDispelType",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
// 0 is "no dispel class" in the DBC, which the call site already treats as absent
|
|
// (`if dispelId and dispelId > 0`); nil says the same thing without a magic number.
|
|
Ok(display(&model, spell_id)
|
|
.map(|d| d.dispel)
|
|
.filter(|&id| id != 0))
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellMechanicByID(spellID) → `(mechanicID, enUS name)`. The spell-level `Spell.dbc`
|
|
// `Mechanic` column plus that row's `SpellMechanic.dbc` name.
|
|
//
|
|
// The three-way answer is the consumer's own spec, written in its comment
|
|
// (`SuperCleveRoidMacros/ClassicAPI.lua:252-255`): **nothing at all** for an id no spell owns,
|
|
// **0** for a real spell with no mechanic, and the id otherwise. It replaces a hand-kept
|
|
// spellID → mechanic table, so a wrong 0 would quietly reclassify crowd control as ordinary
|
|
// damage — which is why "unknown" and "none" must not collapse into one answer here.
|
|
//
|
|
// The name is the second return and is `nil` when `SpellMechanic.dbc` has no row for the id;
|
|
// the id is what the consumer branches on.
|
|
t.set(
|
|
"GetSpellMechanicByID",
|
|
lua.create_function(|lua, spell_id: Option<u32>| {
|
|
let Some(spell_id) = spell_id else {
|
|
return Ok(mlua::MultiValue::new());
|
|
};
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(probe) = model.spell_mechanic_probe.as_ref() else {
|
|
return Ok(mlua::MultiValue::new());
|
|
};
|
|
let Some(x) = probe(spell_id) else {
|
|
return Ok(mlua::MultiValue::new());
|
|
};
|
|
Ok(mlua::MultiValue::from_vec(vec![
|
|
Value::Integer(i64::from(x.mechanic)),
|
|
x.mechanic_name
|
|
.and_then(|n| lua.create_string(&n).ok())
|
|
.map_or(Value::Nil, Value::String),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellEffectMechanics(spellID) → `{m1, m2, m3}`, the per-effect `EffectMechanic[3]`
|
|
// column, where `GetSpellMechanicByID` above reads only the spell-level `Mechanic` one.
|
|
//
|
|
// The two are not the same question and the consumer says why
|
|
// (`SuperCleveRoidMacros/ClassicAPI.lua:259-263`): "vanilla stores some mechanics on an
|
|
// effect instead — Rake's bleed is effect-level, so GetSpellMechanicByID returns 0 but this
|
|
// returns {0,15,0}". A macro conditional that asks whether its target is bleeding gets the
|
|
// wrong answer from the spell-level field alone.
|
|
//
|
|
// Same three-way answer as its sibling, for the same reason: **nothing** for an id no spell
|
|
// owns, and a table of three numbers otherwise, with `0` in a slot meaning that effect
|
|
// carries no mechanic. A table of zeros and no table at all must stay distinguishable.
|
|
t.set(
|
|
"GetSpellEffectMechanics",
|
|
lua.create_function(|lua, spell_id: Option<u32>| {
|
|
let Some(spell_id) = spell_id else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(probe) = model.spell_mechanic_probe.as_ref() else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let Some(x) = probe(spell_id) else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let out = lua.create_table()?;
|
|
for (i, m) in x.effect_mechanics.iter().enumerate() {
|
|
out.set(i + 1, *m)?;
|
|
}
|
|
Ok(Value::Table(out))
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellPowerCost(spellID) → an **array of cost rows**, not a number: the consumer reads
|
|
// `costs[1].cost` (`SuperCleveRoidMacros/Core.lua:670-672`), so the shape is a list even
|
|
// though 1.12 has exactly one cost per spell — there is no second power type to report.
|
|
//
|
|
// `manaCost` is a flat `Spell.dbc` column; the reference's own `GetPowerCost` (`0x6e31b0`)
|
|
// then adds a per-level term and a percentage term this does **not** apply, because both need
|
|
// the caster's level and max power and this is a static row lookup. So the number is the
|
|
// spell's base cost, which is what a macro-condition reader wants, and not what the cast bar
|
|
// would spend for a scaling spell.
|
|
//
|
|
// `type` rides along as the power type (0 mana, 1 rage, 3 energy) since the cost is
|
|
// meaningless without its unit. Nothing at all for an id no spell owns.
|
|
t.set(
|
|
"GetSpellPowerCost",
|
|
lua.create_function(|lua, spell_id: Option<u32>| {
|
|
let Some(spell_id) = spell_id else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(x) = model
|
|
.spell_mechanic_probe
|
|
.as_ref()
|
|
.and_then(|p| p(spell_id))
|
|
else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let row = lua.create_table()?;
|
|
row.set("cost", x.mana_cost)?;
|
|
row.set("type", x.power_type)?;
|
|
let out = lua.create_table()?;
|
|
out.set(1, row)?;
|
|
Ok(Value::Table(out))
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellInfo(spellID) -> a table. Modern clients carry castTime/minRange/maxRange/originalIconID
|
|
// too; this book has none of those (`Spell.dbc` depth, `benilla-app`-only), so this fills only
|
|
// name/iconID/subText/spellID and leaves the rest absent rather than inventing zeros.
|
|
t.set(
|
|
"GetSpellInfo",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(d) = display(&model, spell_id) else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let out = lua.create_table()?;
|
|
out.set("name", d.name)?;
|
|
out.set("iconID", d.icon)?;
|
|
out.set("subText", d.rank)?;
|
|
out.set("spellID", spell_id)?;
|
|
Ok(Value::Table(out))
|
|
})?,
|
|
)?;
|
|
|
|
t.set(
|
|
"IsSpellPassive",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(find_slot(&model, spell_id)
|
|
.and_then(|(slot, bank)| book_slot(&model, slot, bank))
|
|
.is_some_and(|s| s.passive))
|
|
})?,
|
|
)?;
|
|
|
|
// IsSpellKnown(spellID): "in my own book" is the closest honest equivalent benilla-ui can
|
|
// answer — modern clients also count spells overridden-but-still-known, which 1.12 has no
|
|
// concept of at all.
|
|
// GetSpellReagents(spellID) -> an array of { itemID, count } tables, the modern shape. Backed
|
|
// by `Spell.dbc`'s `Reagent[8]`/`ReagentCount[8]` columns, which `benilla-formats` already
|
|
// parses (`spells/display.rs`); `SpellLookup::reagents` drops the empty rows, so `[1]` is the
|
|
// first real reagent rather than whichever column happened to be filled.
|
|
//
|
|
// Answers an **empty table** for a spell that needs no reagents, and **nil** for a spell id
|
|
// nothing knows about — a caller's `if reagents and reagents[1]` (pfUI,
|
|
// `modules/actionbar.lua:1553`) reads both correctly, and the two cases are genuinely
|
|
// different: "this spell is free" is not "there is no such spell".
|
|
t.set(
|
|
"GetSpellReagents",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let Some(d) = display(&model, spell_id) else {
|
|
return Ok(Value::Nil);
|
|
};
|
|
let out = lua.create_table()?;
|
|
for (i, (item_id, count)) in d.reagents.iter().enumerate() {
|
|
let row = lua.create_table()?;
|
|
row.set("itemID", *item_id)?;
|
|
row.set("count", *count)?;
|
|
out.set(i + 1, row)?;
|
|
}
|
|
Ok(Value::Table(out))
|
|
})?,
|
|
)?;
|
|
|
|
t.set(
|
|
"IsSpellKnown",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(find_slot(&model, spell_id).is_some())
|
|
})?,
|
|
)?;
|
|
|
|
t.set(
|
|
"IsCurrentSpell",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(find_slot(&model, spell_id)
|
|
.and_then(|(slot, bank)| book_slot(&model, slot, bank))
|
|
.is_some_and(|s| s.current))
|
|
})?,
|
|
)?;
|
|
|
|
// GetSpellCooldown(spellID) -> startTime, duration, isEnabled, three values as the stock
|
|
// slot-indexed `GetSpellCooldown` already does.
|
|
t.set(
|
|
"GetSpellCooldown",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
|
let cooldown = find_slot(&model, spell_id)
|
|
.and_then(|(slot, bank)| book_slot(&model, slot, bank))
|
|
.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),
|
|
})
|
|
})?,
|
|
)?;
|
|
|
|
// IsSpellUsable(spellID): approximated as "known and not on cooldown" — modern usability also
|
|
// weighs resource cost (mana/rage/energy) and range, neither tracked per spell id here.
|
|
t.set(
|
|
"IsSpellUsable",
|
|
lua.create_function(|lua, spell_id: u32| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let now: f64 = lua.globals().get("__benilla_now").unwrap_or(0.0);
|
|
let Some(s) =
|
|
find_slot(&model, spell_id).and_then(|(slot, bank)| book_slot(&model, slot, bank))
|
|
else {
|
|
return Ok(false);
|
|
};
|
|
let off_cooldown = s.cooldown.is_none_or(|(start_ms, duration_ms, enabled)| {
|
|
!enabled || (start_ms as f64 / 1000.0) + f64::from(duration_ms) / 1000.0 <= now
|
|
});
|
|
Ok(off_cooldown)
|
|
})?,
|
|
)?;
|
|
|
|
// UnitCastingInfo(unit) / UnitChannelInfo(unit), and the no-argument pair upstream lists
|
|
// beside them (`docs/classic-api-functions.md`, the Spell section), which are the same two
|
|
// asked about `"player"` — Classic's own legacy spelling of them. pfUI's `libdebuff.lua:887`
|
|
// calls `C_Spell.ChannelInfo()` exactly that way, for the spell name alone.
|
|
//
|
|
// [`cast_info`] carries the whole contract: the two return shapes, the millisecond unit, and
|
|
// every position this client leaves nil with the reason.
|
|
for (name, channel) in [("UnitCastingInfo", false), ("UnitChannelInfo", true)] {
|
|
t.set(
|
|
name,
|
|
lua.create_function(move |lua, unit: String| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
cast_info(lua, &model, &unit, channel)
|
|
})?,
|
|
)?;
|
|
}
|
|
for (name, channel) in [("CastingInfo", false), ("ChannelInfo", true)] {
|
|
t.set(
|
|
name,
|
|
lua.create_function(move |lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
cast_info(lua, &model, "player", channel)
|
|
})?,
|
|
)?;
|
|
}
|
|
|
|
t.set(
|
|
"GetSchoolString",
|
|
lua.create_function(|_, mask: i64| Ok(school_string(mask)))?,
|
|
)?;
|
|
|
|
// CanDualWield(): a class fact, off `PlayerRecord.class`'s file token — not spell-specific at
|
|
// all, but ClassicAPI files it under `C_Spell`.
|
|
t.set(
|
|
"CanDualWield",
|
|
lua.create_function(|lua, ()| {
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
Ok(model
|
|
.player_record
|
|
.class
|
|
.as_ref()
|
|
.is_some_and(|(_, file)| class_can_dual_wield(file)))
|
|
})?,
|
|
)?;
|
|
|
|
// CastAtCursor(spellIDOrName): resolves a spell id to its book slot's spell id (a no-op, but
|
|
// confirms it's actually known before queueing) and casts exactly as `CastSpellByName` does.
|
|
// A bare name string is also accepted, matching `resolve_spell_by_name`'s own rules.
|
|
t.set(
|
|
"CastAtCursor",
|
|
lua.create_function(|lua, spell: Value| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let spell_id = match spell {
|
|
Value::Integer(i) => find_slot(&model, i as u32).map(|_| i as u32),
|
|
Value::Number(n) => find_slot(&model, n as u32).map(|_| n as u32),
|
|
Value::String(s) => s.to_str().ok().and_then(|name| {
|
|
crate::script::resolve_spell_by_name(&model.spellbook, &name)
|
|
.map(|slot| slot.spell_id)
|
|
}),
|
|
_ => None,
|
|
};
|
|
if let Some(id) = spell_id {
|
|
model.spell_casts.push(id);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// CastAtUnit(spellIDOrName, unit): only `unit == "mouseover"` is backed, the same SuperWoW
|
|
// `"CLICK"` queue `CastSpellByName` already drains at `"mouseover"` specifically — an
|
|
// arbitrary unit target has no queue of its own here (`docs/ADDON_COMPAT.md`).
|
|
t.set(
|
|
"CastAtUnit",
|
|
lua.create_function(|lua, (spell, unit): (Value, String)| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
let spell_id = match spell {
|
|
Value::Integer(i) => find_slot(&model, i as u32).map(|_| i as u32),
|
|
Value::Number(n) => find_slot(&model, n as u32).map(|_| n as u32),
|
|
Value::String(s) => s.to_str().ok().and_then(|name| {
|
|
crate::script::resolve_spell_by_name(&model.spellbook, &name)
|
|
.map(|slot| slot.spell_id)
|
|
}),
|
|
_ => None,
|
|
};
|
|
if let (Some(id), true) = (spell_id, unit.eq_ignore_ascii_case("mouseover")) {
|
|
model.mouseover_spell_casts.push(id);
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
|
|
// CancelSpellByID/CancelSpellByName: both fall through to the same unconditional
|
|
// `SpellStopCasting` path the stock binding uses — there is no spell id attached to
|
|
// `model.casting`, so neither verifies the id actually matches what's in flight.
|
|
for name in ["CancelSpellByID", "CancelSpellByName"] {
|
|
t.set(
|
|
name,
|
|
lua.create_function(|lua, _: Value| {
|
|
let mut model = lua.app_data_mut::<Model>().expect("model app_data");
|
|
if model.casting {
|
|
model.spell_stop = true;
|
|
}
|
|
Ok(())
|
|
})?,
|
|
)?;
|
|
}
|
|
|
|
Ok(t)
|
|
}
|
|
|
|
/// The bare globals upstream lists in the same section as `C_Spell` — its own heading in
|
|
/// `docs/classic-api-functions.md` reads "Spell (`C_Spell`, plus several bare globals)".
|
|
///
|
|
/// These hand out **the very same function values** the namespaced table holds, lifted out of it
|
|
/// rather than written twice. That is the point: a caller cannot get one answer from
|
|
/// `IsSpellKnown` and a different one from `C_Spell.IsSpellKnown`, and there is no second body to
|
|
/// drift. Nothing is retained Rust-side — the value goes straight into `_G` at install.
|
|
pub(super) fn install_globals(lua: &Lua, t: &Table) -> mlua::Result<()> {
|
|
let g = lua.globals();
|
|
|
|
// `IsSpellKnown(spellID)`: not a 1.12 name (`reference/1.12-globals.tsv` has no row for it —
|
|
// 1.12 asks "do I know this" only by walking the book with `GetSpellName(i, "spell")`).
|
|
// pfUI's `modules/bags.lua:703` calls it bare, keyed by spell id, to decide which extract /
|
|
// disenchant button its bag frame may show.
|
|
g.set("IsSpellKnown", t.get::<Value>("IsSpellKnown")?)?;
|
|
|
|
// `GetSpellInfo` as a **bare global**, in both of the forms upstream lists
|
|
// (`docs/classic-api-functions.md`'s Spell section says so outright: "GetSpellInfo (both
|
|
// forms)"). The namespaced `C_Spell.GetSpellInfo` above answers with a *table*; this one is
|
|
// positional, and the two are deliberately different shapes rather than one wrapping the
|
|
// other — a caller picks the spelling that matches the shape it destructures.
|
|
//
|
|
// **Ten returns**, which is the WotLK-era signature plus the `spellID` later clients appended:
|
|
// `name, rank, icon, powerCost, isFunnel, powerType, castingTime, minRange, maxRange,
|
|
// spellID`. The consumer states that shape: SuperCleveRoidMacros walks its book with
|
|
// `local spellName, spellRank, texture, _, _, _, _, _, _, spellId = GetSpellInfo(i, bookType)`
|
|
// and its own comment says "returns the spellID as the 10th value… replacing GetSpellName +
|
|
// GetSpellTexture" (`Extensions/Tooltip/Generic.lua:44-50`).
|
|
//
|
|
// Slots 4-9 are **nil, not zeros**: 1.12's `Spell.dbc` reach here carries no power cost, funnel
|
|
// flag, power type, cast time or range, and a fabricated 0 reads as "free, instant, melee" to
|
|
// anything that checks. The four this book does know are filled.
|
|
//
|
|
// The two forms are told apart by the **second argument**, exactly as 1.12's own
|
|
// `GetSpellName(id, bookType)` pair is: a string book type means the first argument is a book
|
|
// slot, anything else means it is a spell id.
|
|
g.set(
|
|
"GetSpellInfo",
|
|
lua.create_function(|lua, (arg1, book): (Value, Value)| {
|
|
let as_num = |v: &Value| -> Option<u32> {
|
|
match v {
|
|
Value::Integer(i) => u32::try_from(*i).ok(),
|
|
Value::Number(n) => (*n >= 0.0).then_some(*n as u32),
|
|
Value::String(t) => t.to_str().ok().and_then(|t| t.parse().ok()),
|
|
_ => None,
|
|
}
|
|
};
|
|
let Some(id) = as_num(&arg1) else {
|
|
return Ok(mlua::MultiValue::new());
|
|
};
|
|
let model = lua.app_data_ref::<Model>().expect("model app_data");
|
|
let found = match &book {
|
|
Value::String(b) => b.to_str().ok().and_then(|b| {
|
|
crate::script::spellbook::book_slot(&model, id, &b).map(|slot| {
|
|
(
|
|
slot.name.clone(),
|
|
slot.rank.clone(),
|
|
slot.texture.clone(),
|
|
slot.spell_id,
|
|
)
|
|
})
|
|
}),
|
|
_ => crate::script::spellbook::spell_display(&model, id)
|
|
.map(|d| (d.name, d.rank, d.icon, id)),
|
|
};
|
|
// An empty slot or an unknown id answers nothing at all, which is what the caller's
|
|
// `if not spellName then` walk ends on.
|
|
let Some((name, rank, icon, spell_id)) = found else {
|
|
return Ok(mlua::MultiValue::new());
|
|
};
|
|
let str_or_nil = |v: Option<String>| -> Value {
|
|
v.and_then(|t| lua.create_string(&t).ok())
|
|
.map_or(Value::Nil, Value::String)
|
|
};
|
|
Ok(mlua::MultiValue::from_vec(vec![
|
|
str_or_nil(Some(name)),
|
|
// Rankless is `""`, as 1.12's `GetSpellName` and SuperWoW's `SpellInfo` both answer.
|
|
str_or_nil(Some(rank.unwrap_or_default())),
|
|
str_or_nil(icon),
|
|
Value::Nil, // powerCost
|
|
Value::Nil, // isFunnel
|
|
Value::Nil, // powerType
|
|
Value::Nil, // castingTime
|
|
Value::Nil, // minRange
|
|
Value::Nil, // maxRange
|
|
Value::Integer(i64::from(spell_id)),
|
|
]))
|
|
})?,
|
|
)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
|
|
|
|
/// `GetSpellMechanicByID`'s three-way answer, which is the consumer's own spec: nothing for an
|
|
/// `GetSpellEffectMechanics` reads the **per-effect** column, which is a different question
|
|
/// from its spell-level sibling and the reason the consumer calls both: vanilla stores some
|
|
/// mechanics on an effect instead, so a spell whose `Mechanic` is 0 can still carry one
|
|
/// (`SuperCleveRoidMacros/ClassicAPI.lua:259-263` names Rake's bleed, `{0, 15, 0}`). An id no
|
|
/// spell owns stays distinguishable from a spell whose three effects carry none.
|
|
#[test]
|
|
fn the_per_effect_mechanics_are_a_table_of_three_and_absent_for_an_unknown_spell() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spell_mechanic_probe(Box::new(move |id| match id {
|
|
// Rake: nothing at the spell level, a bleed on the second effect.
|
|
1822 => Some(crate::script::SpellDbcExtras {
|
|
mechanic: 0,
|
|
mechanic_name: None,
|
|
effect_mechanics: [0, 15, 0],
|
|
mana_cost: 0,
|
|
power_type: 0,
|
|
duration_ms: 0,
|
|
icon_id: 0,
|
|
}),
|
|
// A real spell with no mechanic anywhere.
|
|
133 => Some(crate::script::SpellDbcExtras {
|
|
mechanic: 0,
|
|
mechanic_name: None,
|
|
effect_mechanics: [0; 3],
|
|
mana_cost: 0,
|
|
power_type: 0,
|
|
duration_ms: 0,
|
|
icon_id: 0,
|
|
}),
|
|
_ => None,
|
|
}));
|
|
assert_eq!(
|
|
s.eval::<Vec<u32>>("return C_Spell.GetSpellEffectMechanics(1822)")
|
|
.unwrap(),
|
|
vec![0, 15, 0],
|
|
"the effect-level bleed the spell-level column does not carry"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<Vec<u32>>("return C_Spell.GetSpellEffectMechanics(133)")
|
|
.unwrap(),
|
|
vec![0, 0, 0],
|
|
"a real spell with no mechanic is three zeros, not nothing"
|
|
);
|
|
assert!(
|
|
s.eval::<Option<mlua::Table>>("return C_Spell.GetSpellEffectMechanics(999999)")
|
|
.unwrap()
|
|
.is_none(),
|
|
"an id no spell owns answers nothing at all"
|
|
);
|
|
// And the spell-level getter still disagrees with it, which is the whole point.
|
|
assert_eq!(
|
|
s.eval::<i64>("local m = C_Spell.GetSpellMechanicByID(1822) return m")
|
|
.unwrap(),
|
|
0
|
|
);
|
|
}
|
|
|
|
/// id no spell owns, `0` for a real spell with no mechanic, the id otherwise. It replaces a
|
|
/// hand-kept spellID → mechanic table, so collapsing "unknown" into "none" would quietly
|
|
/// reclassify crowd control as ordinary damage.
|
|
#[test]
|
|
fn the_spell_mechanic_lookup_tells_no_mechanic_apart_from_no_such_spell() {
|
|
let mut s = UiScript::new().unwrap();
|
|
let extras = |mechanic: u32, name: Option<&str>, cost: u32| crate::script::SpellDbcExtras {
|
|
mechanic,
|
|
mechanic_name: name.map(str::to_string),
|
|
effect_mechanics: [0; 3],
|
|
mana_cost: cost,
|
|
power_type: 0,
|
|
duration_ms: 0,
|
|
icon_id: 0,
|
|
};
|
|
s.set_spell_mechanic_probe(Box::new(move |id| match id {
|
|
// Polymorph: a mechanic with a name.
|
|
118 => Some(extras(17, Some("polymorphed"), 60)),
|
|
// Fireball: a real spell with no mechanic at all.
|
|
133 => Some(extras(0, None, 30)),
|
|
// A mechanic whose SpellMechanic.dbc row is missing: id, no name.
|
|
999 => Some(extras(42, None, 0)),
|
|
_ => None,
|
|
}));
|
|
|
|
assert_eq!(
|
|
s.eval::<(i64, String)>("return C_Spell.GetSpellMechanicByID(118)")
|
|
.unwrap(),
|
|
(17, "polymorphed".to_string())
|
|
);
|
|
assert_eq!(
|
|
s.eval::<i64>("return C_Spell.GetSpellMechanicByID(133)")
|
|
.unwrap(),
|
|
0,
|
|
"a known spell with no mechanic is 0, which is a VALUE"
|
|
);
|
|
assert_eq!(
|
|
s.arity("C_Spell.GetSpellMechanicByID(4242424)").unwrap(),
|
|
0,
|
|
"an unknown spell answers nothing at all, not 0"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<i64>("return C_Spell.GetSpellMechanicByID(999)")
|
|
.unwrap(),
|
|
42,
|
|
"a nameless mechanic still reports its id"
|
|
);
|
|
// The consumer branches on the id, so the two must stay distinguishable in Lua.
|
|
assert_eq!(
|
|
s.eval::<String>(
|
|
"local m = C_Spell.GetSpellMechanicByID(4242424) if m == nil then return \"unknown\" else return \"m=\" .. m end"
|
|
)
|
|
.unwrap(),
|
|
"unknown"
|
|
);
|
|
// The cost rides the same probe and is a LIST, which is the shape its consumer indexes.
|
|
assert_eq!(
|
|
s.eval::<i64>(
|
|
"local c = C_Spell.GetSpellPowerCost(118) return c and c[1] and c[1].cost"
|
|
)
|
|
.unwrap(),
|
|
60
|
|
);
|
|
assert!(
|
|
s.eval::<Option<mlua::Table>>("return C_Spell.GetSpellPowerCost(4242424)")
|
|
.unwrap()
|
|
.is_none(),
|
|
"an unknown spell has no cost list at all"
|
|
);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
/// The bare global, in both forms. The book form is what SuperCleveRoidMacros walks
|
|
/// (`Extensions/Tooltip/Generic.lua:50`) and its destructuring is transcribed verbatim here,
|
|
/// because a positional shape is only correct relative to the code that reads it.
|
|
#[test]
|
|
fn the_bare_get_spell_info_answers_both_forms_in_the_consumers_shape() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
|
|
// Book form: the consumer's own line.
|
|
let (name, rank, tex, id) = s
|
|
.eval::<(String, String, String, i64)>(
|
|
"local n, r, t, _, _, _, _, _, _, i = GetSpellInfo(1, \"spell\") return n, r, t, i",
|
|
)
|
|
.unwrap();
|
|
assert_eq!(name, "Fireball");
|
|
assert_eq!(rank, "Rank 1");
|
|
assert!(
|
|
tex.contains("Spell_Fire_FlameBolt"),
|
|
"the icon travels too: {tex}"
|
|
);
|
|
assert_eq!(id, 133, "the spellID is the TENTH return");
|
|
|
|
// Id form: same four facts, no book type.
|
|
assert_eq!(
|
|
s.eval::<(String, String)>("local n, r = GetSpellInfo(133) return n, r")
|
|
.unwrap(),
|
|
("Fireball".to_string(), "Rank 1".to_string())
|
|
);
|
|
|
|
// Slots 4-9 are nil rather than zeros, which would read as free/instant/melee.
|
|
assert_eq!(
|
|
s.eval::<i64>(
|
|
"local t = { GetSpellInfo(1, \"spell\") } local n = 0 for k in pairs(t) do if k >= 4 and k <= 9 then n = n + 1 end end return n"
|
|
)
|
|
.unwrap(),
|
|
0,
|
|
"no fabricated numbers in the slots this client cannot fill"
|
|
);
|
|
|
|
// Past the end of the book: nothing at all, which ends the caller's walk.
|
|
assert_eq!(s.arity("GetSpellInfo(99, \"spell\")").unwrap(), 0);
|
|
assert_eq!(s.arity("GetSpellInfo(4242424)").unwrap(), 0);
|
|
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
|
|
}
|
|
|
|
fn book() -> SpellBookState {
|
|
SpellBookState {
|
|
tabs: vec![SpellTabView {
|
|
name: "Fire".into(),
|
|
texture: None,
|
|
offset: 0,
|
|
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,
|
|
..Default::default()
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn spell_id_wrappers_read_the_same_book_the_stock_verbs_do() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
|
|
assert!(s
|
|
.eval::<bool>("return C_Spell.DoesSpellExist(133)")
|
|
.unwrap());
|
|
assert!(!s
|
|
.eval::<bool>("return C_Spell.DoesSpellExist(9999)")
|
|
.unwrap());
|
|
assert_eq!(
|
|
s.eval::<String>("return C_Spell.GetSpellName(133)")
|
|
.unwrap(),
|
|
"Fireball"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return C_Spell.GetSpellInfo(133).name")
|
|
.unwrap(),
|
|
"Fireball"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return C_Spell.GetSpellSubtext(133)")
|
|
.unwrap(),
|
|
"Rank 1"
|
|
);
|
|
assert!(s.eval::<bool>("return C_Spell.IsSpellKnown(133)").unwrap());
|
|
assert!(!s.eval::<bool>("return C_Spell.IsSpellKnown(9999)").unwrap());
|
|
}
|
|
|
|
/// The bare spelling is the *same function value* as the namespaced one, not a second body —
|
|
/// that is what makes it impossible for the two to disagree, and it is the whole reason this
|
|
/// is lifted out of the table instead of written again.
|
|
#[test]
|
|
fn the_bare_is_spell_known_is_the_very_function_c_spell_holds() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
assert!(
|
|
s.eval::<bool>("return IsSpellKnown == C_Spell.IsSpellKnown")
|
|
.unwrap(),
|
|
"one function, two names: a copy could drift, this cannot"
|
|
);
|
|
assert!(s.eval::<bool>("return IsSpellKnown(133)").unwrap());
|
|
assert!(!s.eval::<bool>("return IsSpellKnown(9999)").unwrap());
|
|
}
|
|
|
|
/// The reason the probe exists: pfUI's `api/unitframes.lua:42` keys a predict table on
|
|
/// `strlower(C_Spell.GetSpellTexture(774))` with `name = strlower(C_Spell.GetSpellName(774))`,
|
|
/// pfUI's debuff library calls this once per debuff while walking aura slots 33-48
|
|
/// (`libs/libdebuff.lua:379`), so it runs for every debuff on every scanned unit — the
|
|
/// reason it is built rather than left to the "unbuilt predicates" row.
|
|
#[test]
|
|
fn dispel_type_answers_the_raw_dbc_id_and_nil_for_none() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spell_probe(Box::new(|id| {
|
|
let dispel = match id {
|
|
11408 => 1, // Magic
|
|
2818 => 4, // Poison
|
|
133 => 0, // Fireball: a direct-damage spell, no dispel class
|
|
_ => return None,
|
|
};
|
|
Some(crate::script::SpellLookup {
|
|
name: format!("Spell {id}"),
|
|
rank: None,
|
|
icon: None,
|
|
reagents: Vec::new(),
|
|
dispel,
|
|
})
|
|
}));
|
|
|
|
assert_eq!(
|
|
s.eval::<Option<i64>>("return C_Spell.GetSpellDispelType(11408)")
|
|
.unwrap(),
|
|
Some(1)
|
|
);
|
|
assert_eq!(
|
|
s.eval::<Option<i64>>("return C_Spell.GetSpellDispelType(2818)")
|
|
.unwrap(),
|
|
Some(4)
|
|
);
|
|
// 0 in the DBC means "no dispel class"; nil says that without a magic number, and the
|
|
// call site's own `if dispelId and dispelId > 0` reads either the same way.
|
|
assert_eq!(
|
|
s.eval::<Option<i64>>("return C_Spell.GetSpellDispelType(133)")
|
|
.unwrap(),
|
|
None,
|
|
"a spell with no dispel class is nil, not 0"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<Option<i64>>("return C_Spell.GetSpellDispelType(999999)")
|
|
.unwrap(),
|
|
None,
|
|
"an id nothing can name is nil, not an error"
|
|
);
|
|
}
|
|
|
|
/// at file scope, on every class. Rejuvenation is in no warrior's book, so without a
|
|
/// `Spell.dbc` source both returns are nil and `strlower(nil)` stops pfUI's load.
|
|
#[test]
|
|
fn the_host_probe_answers_for_a_spell_the_character_never_learned() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book()); // a mage's book: 133 only, no 774
|
|
s.set_spell_probe(Box::new(|id| {
|
|
(id == 774).then(|| crate::script::SpellLookup {
|
|
name: "Rejuvenation".into(),
|
|
rank: Some("Rank 1".into()),
|
|
icon: Some("Interface\\Icons\\Spell_Nature_Rejuvenation".into()),
|
|
reagents: Vec::new(),
|
|
dispel: 0,
|
|
})
|
|
}));
|
|
|
|
assert_eq!(
|
|
s.eval::<String>("return C_Spell.GetSpellName(774)")
|
|
.unwrap(),
|
|
"Rejuvenation"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return C_Spell.GetSpellTexture(774)")
|
|
.unwrap(),
|
|
"Interface\\Icons\\Spell_Nature_Rejuvenation"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return C_Spell.GetSpellSubtext(774)")
|
|
.unwrap(),
|
|
"Rank 1"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<(String, String, String)>(
|
|
"local i = C_Spell.GetSpellInfo(774) return i.name, i.iconID, i.subText"
|
|
)
|
|
.unwrap(),
|
|
(
|
|
"Rejuvenation".to_string(),
|
|
"Interface\\Icons\\Spell_Nature_Rejuvenation".to_string(),
|
|
"Rank 1".to_string()
|
|
)
|
|
);
|
|
assert!(s
|
|
.eval::<bool>("return C_Spell.DoesSpellExist(774)")
|
|
.unwrap());
|
|
|
|
// pfUI's own line, verbatim in shape: it must not raise.
|
|
assert_eq!(
|
|
s.eval::<String>(
|
|
r#"local t = {}
|
|
t[strlower(C_Spell.GetSpellTexture(774))] =
|
|
{ name = strlower(C_Spell.GetSpellName(774)), predict = "Reju" }
|
|
return t[strlower("Interface\\Icons\\Spell_Nature_Rejuvenation")].name"#
|
|
)
|
|
.unwrap(),
|
|
"rejuvenation"
|
|
);
|
|
|
|
// The probe widens; it does not override. A spell in the book keeps the book's answer,
|
|
// which carries the live icon substitutions the probe's raw DBC icon does not.
|
|
s.set_spell_probe(Box::new(|_| {
|
|
Some(crate::script::SpellLookup {
|
|
name: "WRONG".into(),
|
|
rank: None,
|
|
icon: Some("Interface\\Icons\\WRONG".into()),
|
|
reagents: Vec::new(),
|
|
dispel: 0,
|
|
})
|
|
}));
|
|
assert_eq!(
|
|
s.eval::<String>("return C_Spell.GetSpellName(133)")
|
|
.unwrap(),
|
|
"Fireball"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<String>("return C_Spell.GetSpellTexture(133)")
|
|
.unwrap(),
|
|
"Interface\\Icons\\Spell_Fire_FlameBolt"
|
|
);
|
|
}
|
|
|
|
/// With no host there is no probe, and the getters narrow to the book — the shape every other
|
|
/// test in this file and the UI-only harness rely on.
|
|
#[test]
|
|
fn with_no_probe_the_getters_see_only_the_book() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
for call in [
|
|
"GetSpellName",
|
|
"GetSpellTexture",
|
|
"GetSpellSubtext",
|
|
"GetSpellInfo",
|
|
] {
|
|
assert_eq!(
|
|
s.eval::<String>(&format!("return type(C_Spell.{call}(774))"))
|
|
.unwrap(),
|
|
"nil",
|
|
"C_Spell.{call} must stay nil for an unlearned spell without a host"
|
|
);
|
|
}
|
|
assert!(!s
|
|
.eval::<bool>("return C_Spell.DoesSpellExist(774)")
|
|
.unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn cast_at_cursor_resolves_by_id_or_name_and_queues_the_spell_cast() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
|
|
s.run("C_Spell.CastAtCursor(133)").unwrap();
|
|
assert_eq!(s.take_spell_casts(), vec![133]);
|
|
|
|
s.run(r#"C_Spell.CastAtCursor("Fireball")"#).unwrap();
|
|
assert_eq!(s.take_spell_casts(), vec![133]);
|
|
}
|
|
|
|
#[test]
|
|
fn cast_at_unit_only_backs_mouseover() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spellbook(book());
|
|
|
|
s.run(r#"C_Spell.CastAtUnit(133, "mouseover")"#).unwrap();
|
|
assert_eq!(s.take_mouseover_spell_casts(), vec![133]);
|
|
|
|
s.run(r#"C_Spell.CastAtUnit(133, "target")"#).unwrap();
|
|
assert!(s.take_spell_casts().is_empty());
|
|
assert!(s.take_mouseover_spell_casts().is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn school_string_covers_the_seven_schools_and_nothing_else() {
|
|
let s = UiScript::new().unwrap();
|
|
assert_eq!(
|
|
// 4, not `0x4`: 1.12's lexer has no hex literals, so a chunk of ours must not use one.
|
|
s.eval::<String>("return C_Spell.GetSchoolString(4)")
|
|
.unwrap(),
|
|
"SCHOOL_MASK_FIRE"
|
|
);
|
|
assert_eq!(
|
|
s.eval::<Option<String>>("return C_Spell.GetSchoolString(0)")
|
|
.unwrap(),
|
|
None
|
|
);
|
|
}
|
|
|
|
/// Three cases that must read differently, because "this spell is free" is not "there is no
|
|
/// such spell": a spell with reagents, a spell with none, and an id nothing knows.
|
|
#[test]
|
|
fn get_spell_reagents_distinguishes_having_none_from_not_existing() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spell_probe(Box::new(|id| match id {
|
|
// Slow Fall, whose real `Spell.dbc` row is (17056, 1) — the pair
|
|
// `benilla-formats`'s own `catalog_tests.rs` pins.
|
|
130 => Some(crate::script::SpellLookup {
|
|
name: "Slow Fall".into(),
|
|
rank: None,
|
|
icon: None,
|
|
reagents: vec![(17056, 1)],
|
|
dispel: 0,
|
|
}),
|
|
// A real spell that costs nothing.
|
|
133 => Some(crate::script::SpellLookup {
|
|
name: "Fireball".into(),
|
|
rank: Some("Rank 1".into()),
|
|
icon: None,
|
|
reagents: Vec::new(),
|
|
dispel: 0,
|
|
}),
|
|
_ => None,
|
|
}));
|
|
|
|
// pfUI's exact read (`modules/actionbar.lua:1553`).
|
|
assert_eq!(
|
|
s.eval::<(u32, u32)>(
|
|
"local r = C_Spell.GetSpellReagents(130) return r[1].itemID, r[1].count"
|
|
)
|
|
.unwrap(),
|
|
(17056, 1)
|
|
);
|
|
// No reagents: a table, and `r[1]` is nil, so `if r and r[1]` takes the empty branch.
|
|
assert_eq!(
|
|
s.eval::<String>(
|
|
"local r = C_Spell.GetSpellReagents(133) return type(r) .. \"/\" .. type(r[1])"
|
|
)
|
|
.unwrap(),
|
|
"table/nil"
|
|
);
|
|
// Unknown id: nil, which is a different answer from an empty table.
|
|
assert_eq!(
|
|
s.eval::<String>("return type(C_Spell.GetSpellReagents(999999))")
|
|
.unwrap(),
|
|
"nil"
|
|
);
|
|
}
|
|
|
|
/// The subtle one. A spellbook slot carries no reagent columns, so `spell_display`'s book arm
|
|
/// must still take reagents from the probe — otherwise the spells a caller is *most* likely to
|
|
/// ask about (the ones the player knows and has on a bar) are the ones that wrongly report
|
|
/// needing nothing.
|
|
#[test]
|
|
fn a_known_spell_still_reports_its_reagents_rather_than_the_books_silence() {
|
|
let mut s = UiScript::new().unwrap();
|
|
s.set_spell_probe(Box::new(|id| {
|
|
(id == 130).then(|| crate::script::SpellLookup {
|
|
name: "Slow Fall".into(),
|
|
rank: None,
|
|
icon: None,
|
|
reagents: vec![(17056, 1)],
|
|
dispel: 0,
|
|
})
|
|
}));
|
|
s.set_spellbook(crate::script::SpellBookState {
|
|
slots: vec![crate::script::SpellSlotView {
|
|
spell_id: 130,
|
|
name: "Slow Fall".into(),
|
|
rank: None,
|
|
texture: Some("Interface\\Icons\\Spell_Magic_FeatherFall".into()),
|
|
..Default::default()
|
|
}],
|
|
..Default::default()
|
|
});
|
|
|
|
// The book wins for display...
|
|
assert_eq!(
|
|
s.eval::<String>("return C_Spell.GetSpellName(130)")
|
|
.unwrap(),
|
|
"Slow Fall"
|
|
);
|
|
// ...but the reagents still come through.
|
|
assert_eq!(
|
|
s.eval::<u32>("return C_Spell.GetSpellReagents(130)[1].itemID")
|
|
.unwrap(),
|
|
17056,
|
|
"the book arm must not shadow the probe's reagent list"
|
|
);
|
|
}
|
|
}
|