Files
benilla-octo/crates/benilla-ui/src/script/compat/classic_api/spellbook.rs
T
gromandClaude Opus 5 546e60765d classicapi: fourteen members over four namespaces — the measured tail down to six
Of the 153 `C_<Namespace>.<Member>` names grom's addons reference, the
fresh VM was missing 56; this takes it to 6. Fourteen members here, each
over data or a stock verb that already existed:

`C_Spell` gains the four melee-timing predicates and `IsSpellInRange`.
1.12 answers all of those only about an ACTION BAR SLOT — `IsAttackAction`
and `IsAutoRepeatAction` — so a spell the player knows but has not placed
on a bar cannot be asked at all, which is the gap a condition engine
keeps running into. The three attribute answers ride named booleans on the
spell lookup rather than a raw attribute word, so the bits and their
reference citations stay in `benilla-formats` where they can be checked;
`do_not_reset_combat_timers` is named there for the first time, beside the
`ranged_speed_cooldown` that already tested the same bit. The melee
auto-attack is an ID (6603) rather than an attribute, because no bit marks
it — the ranged half has the auto-repeat flag and the melee half is that
spell.

`C_SpellBook.GetSkillLineName` is the most-read missing member of the
whole family (nine call sites) and is one delegation to `GetSpellTabInfo`.
`IsAutoAttackSpellBookItem` resolves by id against the same 6603, so the
two cannot disagree about what auto-attack is.

`C_Item.GetCurrentItemLevel` answers a number 1.12 prints in a tooltip
and returns from nothing, so no addon can compare two pieces of gear.
`RequestLoadItemDataByID` is the modern async load over the ask-once queue
`ItemMixin:ContinueOnItemLoad` already drives. `GetWeaponEnchantInfo` is a
pass-through.

`C_QuestLog`'s abandon pair delegates to the stock two-step, so the mark,
the still-in-the-log search and the send keep one owner — a namespaced
abandon cannot drop a quest the stock path would have refused.

The six left are each written down with what they need. Two are a host
probe over a DBC `benilla-formats` already parses (`AreaTrigger` for
pfQuest's trigger geometry, `ItemSet`), two have no caller in the corpus,
`SetFriendNotes` has no note field on the 1.12 wire at all — a note typed
and lost at logout is worse than a button that does nothing — and
`C_BattleNet.SendGameData` stays deliberately absent, because every caller
guards it and registering it would claim a BattleNet we do not have.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
2026-10-25 18:00:13 -06:00

355 lines
14 KiB
Rust

//! ClassicAPI's `C_SpellBook`: a modern-shaped wrapper over the stock spellbook the reference
//! already builds (`script/spellbook.rs`'s `SpellBookState`/`PetBookState`, fed by the app). Most
//! of this namespace needs `Spell.dbc` depth (a skill-line id per spell, "cast count" tracking,
//! talent/disenchant classification) that data model genuinely doesn't carry — `benilla-ui` is
//! deliberately engine-free, so that lives only in `benilla-app`'s own `benilla_formats::Spells`
//! catalog, not reachable from here. Blocked rows are in `docs/ADDON_COMPAT.md`, not guessed at.
use mlua::{Lua, MultiValue, Table, Value};
use crate::script::spellbook::{book_slot, SpellBookState};
use crate::script::Model;
/// `FindSpellBookSlotByID`'s own direction (id → slot): the reference's own bindings only ever go
/// slot → id (`book_slot`), so this scans both books once. `pub(super)` for `spell.rs`, which
/// needs the same lookup for every `C_Spell.*(spellID)` wrapper.
pub(super) fn find_slot(model: &Model, spell_id: u32) -> Option<(u32, &'static str)> {
model
.spellbook
.slots
.iter()
.position(|s| s.spell_id == spell_id)
.map(|i| (i as u32 + 1, "spell"))
.or_else(|| {
model
.pet_book
.slots
.iter()
.position(|s| s.spell_id == spell_id)
.map(|i| (i as u32 + 1, "pet"))
})
}
fn tab_index_of(book: &SpellBookState, slot_0: usize) -> Option<usize> {
book.tabs
.iter()
.position(|t| slot_0 >= t.offset as usize && slot_0 < (t.offset + t.num_spells) as usize)
}
pub(super) fn install(lua: &Lua) -> mlua::Result<Table> {
let t = lua.create_table()?;
// `C_SpellBook.GetSkillLineRank(skillLineID)` -> `rank, maxRank`, the modern client's pair.
// 1.12 reaches a skill only by its **index into the skills window** (`GetSkillLineInfo`),
// which moves as lines are learnt and as categories collapse; a `SkillLine.dbc` id is stable,
// which is why pfQuest asks by one.
//
// The two numbers are the same ones `GetSkillLineInfo` reports for that line, bonuses folded
// the way the reference folds them (`script/skills.rs` carries that arithmetic) — so the
// stable-id getter and the index getter cannot disagree. A line the player does not have is
// `nil, nil`, not `0, 0`: untrained and unknown are different answers, and the modern API
// returns nothing for an id the character has no row for.
t.set(
"GetSkillLineRank",
lua.create_function(|lua, skill_id: Option<u32>| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(entry) = skill_id
.filter(|&id| id != 0)
.and_then(|id| model.skills.entries.iter().find(|e| e.skill_id == id))
else {
return Ok((None, None));
};
let (rank, max) = crate::script::skills::displayed_ranks(entry);
Ok((Some(rank), Some(max)))
})?,
)?;
// FindSpellBookSlotByID(spellID) -> slotIndex, spellBank ("spell"/"pet"), or nil. The id→slot
// direction every other function in this namespace needs.
t.set(
"FindSpellBookSlotByID",
lua.create_function(|lua, spell_id: u32| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(match find_slot(&model, spell_id) {
Some((slot, bank)) => MultiValue::from_vec(vec![
Value::Integer(i64::from(slot)),
Value::String(lua.create_string(bank)?),
]),
None => MultiValue::new(),
})
})?,
)?;
// GetSpellBookItemInfo(slotIndex, spellBank) -> a table. Modern clients carry far more here
// (itemType, actionID, subSpellID, …) than this book tracks; this fills what it has
// (name/iconID/isPassive/spellID) and nothing else, honestly, rather than inventing fields.
t.set(
"GetSpellBookItemInfo",
lua.create_function(|lua, (slot, bank): (u32, String)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(s) = book_slot(&model, slot, &bank) else {
return Ok(Value::Nil);
};
let out = lua.create_table()?;
out.set("name", s.name.clone())?;
out.set("iconID", s.texture.clone())?;
out.set("isPassive", s.passive)?;
out.set("spellID", s.spell_id)?;
Ok(Value::Table(out))
})?,
)?;
// GetNumSpellBookSkillLines(): the player book's tab count; the reference's own
// `GetNumSpellTabs` under the modern name (pet book has none, as `GetNumSpellTabs` already
// documents).
t.set(
"GetNumSpellBookSkillLines",
lua.create_function(|lua, ()| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
Ok(model.spellbook.tabs.len() as i64)
})?,
)?;
// GetSpellBookSkillLineInfo(skillLineIndex) -> a table (name/iconID/itemIndexOffset/numSpellBookItems).
// No skill-line *id* is tracked (only index/name/offset/count), so a caller that needs the id
// itself (`GetSkillLineIndexByID`, `GetSkillLineName`/`GetSkillLineRank` by id) isn't backed —
// see `docs/ADDON_COMPAT.md`.
t.set(
"GetSpellBookSkillLineInfo",
lua.create_function(|lua, index: usize| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
let Some(tab) = index
.checked_sub(1)
.and_then(|i| model.spellbook.tabs.get(i))
else {
return Ok(Value::Nil);
};
let out = lua.create_table()?;
out.set("name", tab.name.clone())?;
out.set("iconID", tab.texture.clone())?;
out.set("itemIndexOffset", tab.offset)?;
out.set("numSpellBookItems", tab.num_spells)?;
Ok(Value::Table(out))
})?,
)?;
// GetSpellBookItemSkillLineIndex(slotIndex, spellBank) -> the 1-based tab index the slot falls
// in, from the tab's own offset/count (pet book has no tabs, so always nil there).
t.set(
"GetSpellBookItemSkillLineIndex",
lua.create_function(|lua, (slot, bank): (u32, String)| {
let model = lua.app_data_ref::<Model>().expect("model app_data");
if bank.eq_ignore_ascii_case("pet") {
return Ok(Value::Nil);
}
let slot_0 = match slot.checked_sub(1) {
Some(n) => n as usize,
None => return Ok(Value::Nil),
};
Ok(tab_index_of(&model.spellbook, slot_0)
.map(|i| Value::Integer(i as i64 + 1))
.unwrap_or(Value::Nil))
})?,
)?;
// IsAutoAttackSpellBookItem / IsRangedAutoAttackSpellBookItem: the attribute bit that marks
// these (`SpellDisplay::attributes`) isn't in `SpellSlotView` at all — `benilla-ui` has no
// `Spell.dbc` access, only what the app already resolved into the book. Not backed.
// `GetSkillLineName(index)`: the spellbook tab's own name, which 1.12 answers as
// `GetSpellTabInfo(index)`'s first return. Nine call sites in grom's corpus — the most-read
// missing member of this whole namespace family — and a pure delegation, so the tab order and
// the "past the end" answer keep one owner.
t.set(
"GetSkillLineName",
lua.create_function(|lua, index: mlua::Value| {
let f: mlua::Function = lua.globals().get("GetSpellTabInfo")?;
let vals = f.call::<mlua::MultiValue>(index)?;
Ok(vals.into_iter().next().unwrap_or(mlua::Value::Nil))
})?,
)?;
// `IsAutoAttackSpellBookItem(slot, bookType)`: whether a book slot holds the melee
// auto-attack. 1.12's own `IsAttackAction` asks it of an **action bar slot**, so a book slot
// has no stock test at all. Resolved through the slot's spell id against the same id
// `C_Spell.IsAutoAttackSpell` tests, so the two cannot disagree about what auto-attack is.
t.set(
"IsAutoAttackSpellBookItem",
lua.create_function(|lua, (slot, book): (mlua::Value, mlua::Value)| {
const ATTACK: u32 = 6603;
// By **id**, out of the model's own slot list: the slot's *name* is not enough, since
// two spells can share one, and `GetSpellName` would answer the display text rather
// than what the slot holds.
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
let is_pet =
matches!(&book, mlua::Value::String(s) if s.to_str().is_ok_and(|b| &*b == "pet"));
let idx = match &slot {
mlua::Value::Integer(i) => usize::try_from(*i).ok(),
mlua::Value::Number(n) if n.fract() == 0.0 => usize::try_from(*n as i64).ok(),
_ => None,
};
let slots = if is_pet {
&model.pet_book.slots
} else {
&model.spellbook.slots
};
Ok(idx
.and_then(|i| i.checked_sub(1))
.and_then(|i| slots.get(i))
.is_some_and(|s| s.spell_id == ATTACK))
})?,
)?;
Ok(t)
}
#[cfg(test)]
mod tests {
use crate::script::{SpellBookState, SpellSlotView, SpellTabView, UiScript};
/// `C_SpellBook.GetSkillLineRank(skillLineID)`: the stable-id way into a skill, where 1.12
/// has only an index into the skills window that moves as lines are learnt and categories
/// collapse — which is why pfQuest asks by id. Must agree with `GetSkillLineInfo` for the
/// same line, bonuses and the mono override included.
#[test]
fn get_skill_line_rank_agrees_with_the_index_getter() {
// `SkillEntry` has no `Default` on purpose — every column is server data — so the test
// names all of them once here.
let skill =
|skill_id: u32, name: &str, value: u32, max: u32, perm_bonus: i32, mono: bool| {
crate::script::SkillEntry {
skill_id,
name: name.to_string(),
value,
max,
mono,
temp_bonus: 0,
perm_bonus,
min_level: 0,
cost_index: 0,
category_id: 6,
category_name: String::new(),
category_order: 0,
description: String::new(),
abandonable: false,
}
};
let mut s = UiScript::new().unwrap();
assert_eq!(
s.eval::<(Option<i64>, Option<i64>)>("return C_SpellBook.GetSkillLineRank(164)")
.unwrap(),
(None, None),
"no skills fed yet"
);
s.set_skills(crate::script::SkillsState {
entries: vec![
skill(164, "Blacksmithing", 210, 300, 5, false),
skill(5, "Beast Mastery", 300, 300, 0, true),
],
});
// The permanent bonus folds into both ranks, as the index getter folds it.
assert_eq!(
s.eval::<(Option<i64>, Option<i64>)>("return C_SpellBook.GetSkillLineRank(164)")
.unwrap(),
(Some(215), Some(305))
);
// A mono line reports max 1 whatever the server said — the same override.
assert_eq!(
s.eval::<(Option<i64>, Option<i64>)>("return C_SpellBook.GetSkillLineRank(5)")
.unwrap(),
(Some(1), Some(1))
);
// A line the character has no row for is nil, not 0: untrained and unknown differ.
for call in [
"C_SpellBook.GetSkillLineRank(999)",
"C_SpellBook.GetSkillLineRank(0)",
"C_SpellBook.GetSkillLineRank()",
] {
assert_eq!(
s.eval::<(Option<i64>, Option<i64>)>(&format!("return {call}"))
.unwrap(),
(None, None),
"{call}"
);
}
}
fn book() -> SpellBookState {
SpellBookState {
tabs: vec![
SpellTabView {
name: "Fire".into(),
texture: None,
offset: 0,
num_spells: 1,
},
SpellTabView {
name: "Frost".into(),
texture: None,
offset: 1,
num_spells: 1,
},
],
slots: vec![
SpellSlotView {
spell_id: 133,
name: "Fireball".into(),
texture: Some("Interface\\Icons\\Spell_Fire_FlameBolt".into()),
passive: false,
..Default::default()
},
SpellSlotView {
spell_id: 168,
name: "Frost Armor".into(),
passive: false,
..Default::default()
},
],
}
}
#[test]
fn find_slot_by_id_then_item_info_and_skill_line_round_trip() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(book());
assert_eq!(
s.eval::<(i64, String)>("return C_SpellBook.FindSpellBookSlotByID(133)")
.unwrap(),
(1, "spell".to_string())
);
assert_eq!(
s.eval::<Option<i64>>("local s = C_SpellBook.FindSpellBookSlotByID(9999) return s")
.unwrap(),
None
);
assert_eq!(
s.eval::<String>(r#"return C_SpellBook.GetSpellBookItemInfo(1, "spell").name"#)
.unwrap(),
"Fireball"
);
assert_eq!(
s.eval::<i64>("return C_SpellBook.GetNumSpellBookSkillLines()")
.unwrap(),
2
);
assert_eq!(
s.eval::<String>("return C_SpellBook.GetSpellBookSkillLineInfo(2).name")
.unwrap(),
"Frost"
);
assert_eq!(
s.eval::<i64>(r#"return C_SpellBook.GetSpellBookItemSkillLineIndex(2, "spell")"#)
.unwrap(),
2,
"slot 2 (0-based 1) falls in Frost's offset=1..2 range"
);
}
}