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
355 lines
14 KiB
Rust
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"
|
|
);
|
|
}
|
|
}
|