ui+app+formats: the Spell.dbc row, and seven columns that answered nil

`GetSpellRecField` knew three names. Counting what the corpus actually
asks it for: `name` (4 sites), `school` (3), `stackAmount` (2),
`spellIconID` (2), then one each of `rank`, `rangeIndex`, `mechanic`,
`manaCost`, `effectMechanic`, `effectApplyAuraName`. Seven of those ten
answered nil, and each was a feature quietly off rather than an error —
DoiteAuras caps its stack display on `stackAmount`
(DoitePlayerAuras.lua:539) and SuperCleveRoidMacros classifies a
crowd-control effect off the two arrays (Utility.lua:7776-7794).

The eighty field names are the DLL's own, read off its pool in Spell.dbc
column order. The mapping to `SpellDisplay` lives in
`ui_script/spell_rec.rs`, beside the struct it reads — a copy on the VM
side would be free to drift — and `GetSpellRec(spellId)` is that same
field list walked through that same per-field reader, so the two verbs
cannot answer differently for one name. A test asserts exactly that,
key by key, over one row.

The array columns are a 1-based table of three, which is measured, not
guessed: SuperCleveRoidMacros walks `effectMechanic` as
`for i = 1, 3 do local em = effectMechanics[i]`. `totem`, `reagent` and
`reagentCount` take the same shape, two and eight long.

One new DBC column — `StackAmount`, 39, bracketed by two constants this
tree already had (38 ModalNextSpell, 40 Totem[0]). It is the only one
added: fifteen further names upstream answers are left nil rather than
putting fifteen unused u32s on a struct the spell catalog holds one of
per row. They are listed at `FIELDS` with that reason, so the gap is a
decision rather than an omission.

`GetSpellRecField`'s three string columns keep the spellbook fallback
the verb had before the probe, so a VM with a book and no catalog — a
capture, or the boot VM before the catalog loads — still answers a
learnt spell's name and rank. Every other column is a DBC row and has
nowhere else to come from, which is why the fallback is exactly three
names wide and says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SgstFWtn1RxEJ5kVrpgYwn
This commit is contained in:
2026-10-24 09:28:05 -06:00
co-authored by Claude Opus 5
parent cf0e157f34
commit f06caa9968
11 changed files with 692 additions and 50 deletions
@@ -333,6 +333,27 @@ pub(crate) fn install_spell_extras_probes(
})
}));
}
// nampower's `Spell.dbc` row readers, both off the one mapping in `ui_script/spell_rec.rs`.
// Static DBC data, so a copy at VM build is right — unlike the modifier probe next door,
// nothing moves these.
{
let for_field = catalog.displays_shared();
let for_record = catalog.displays_shared();
script.set_spell_rec_probes(
Box::new(move |spell_id, name| {
super::spell_rec::field(for_field.get(&spell_id)?, name)
}),
Box::new(move |spell_id| {
let d = for_record.get(&spell_id)?;
Some(
super::spell_rec::FIELDS
.iter()
.filter_map(|&name| super::spell_rec::field(d, name).map(|v| (name, v)))
.collect(),
)
}),
);
}
// `GetSpellIconTexture`: `SpellIcon.dbc` by row id, the other half of
// `GetSpellRecField(id, "spellIconID")`. Shared by refcount like the display table.
let icons = catalog.icons_shared();
+1
View File
@@ -441,6 +441,7 @@ fn arbitrate_pointer_over_ui(
/// The session lifecycle: the VM's birth, identity, death and reload.
mod lifecycle;
mod spell_rec;
pub(crate) use lifecycle::{
end_ui_session, ingame_ui_up, install_spell_extras_probes, install_spell_probe,
run_pending_reload, setup_script, AddOnIdentity, LeavingWorldArmed, PendingEntryUiLoad,
@@ -0,0 +1,304 @@
//! nampower's `Spell.dbc` row readers: the name-to-column mapping behind `GetSpellRec(spellId)`
//! and `GetSpellRecField(spellId, fieldName)`.
//!
//! **The names are the DLL's own**, read off its string pool in `Spell.dbc` column order
//! (`0x91d68`–`0x92460`, bracketed on one side by the item-row names and on the other by format
//! strings — `docs/ADDON_COMPAT.md` has the technique). Eighty of them; this answers the sixty-odd
//! that `benilla_formats::SpellDisplay` parses and `None` for the rest, which [`FIELDS`] lists
//! with the reason.
//!
//! **Why this lives here and not in `benilla-ui`.** It is a mapping from upstream's spelling to
//! `SpellDisplay`'s fields, so it belongs beside that struct's reader; a copy on the VM side would
//! be free to drift from it. Both verbs go through one [`field`] call, so they cannot answer
//! differently for one name — `GetSpellRec` is [`FIELDS`] walked through [`field`], nothing more.
use benilla_formats::SpellDisplay;
use benilla_ui::script::SpellRecValue;
/// A three-element array as the 1-based Lua table every consumer walks:
/// `for i = 1, 3 do local em = effectMechanics[i]` (`SuperCleveRoidMacros/Utility.lua:7778`).
fn ints<T: Copy + Into<i64>>(a: &[T; 3]) -> SpellRecValue {
SpellRecValue::Ints(a.iter().map(|&v| v.into()).collect())
}
fn nums(a: &[f32; 3]) -> SpellRecValue {
SpellRecValue::Nums(a.iter().map(|&v| f64::from(v)).collect())
}
fn int(v: impl Into<i64>) -> Option<SpellRecValue> {
Some(SpellRecValue::Int(v.into()))
}
/// Every name this mapping answers, in `Spell.dbc` column order — which is also the key order
/// `GetSpellRec`'s table is built in.
///
/// **What is deliberately absent**, each a column `SpellDisplay` does not parse and no consumer
/// asks for: `attributesEx4`, `targetCreatureType`, `procFlags`, `procCharges`,
/// `manaPerSecondPerLevel`, `stackAmount`'s neighbours `maxTargetLevel` and `maxAffectedTargets`,
/// `dmgClass`, `spellPriority`, `spellVisual2`, `minFactionId`, `minReputation`,
/// `requiredAuraVision`, `effectPointsPerComboPoint` and `dmgMultiplier`. Adding them would mean
/// fifteen more `u32`s on a struct the spell catalog holds one of per row, for a name nothing
/// reads — `GetSpellRecField` answering nil for a name upstream answers is the honest cost, and
/// it is written here rather than left to be discovered.
pub(crate) const FIELDS: &[&str] = &[
"name",
"nameSubtext",
"rank",
"school",
"category",
"castUI",
"dispel",
"mechanic",
"attributes",
"attributesEx",
"attributesEx2",
"attributesEx3",
"stances",
"stancesNot",
"targets",
"requiresSpellFocus",
"casterAuraState",
"targetAuraState",
"castingTimeIndex",
"recoveryTime",
"categoryRecoveryTime",
"interruptFlags",
"auraInterruptFlags",
"channelInterruptFlags",
"procChance",
"maxLevel",
"baseLevel",
"spellLevel",
"durationIndex",
"powerType",
"manaCost",
"manaCostPerlevel",
"manaPerSecond",
"rangeIndex",
"speed",
"modalNextSpell",
"stackAmount",
"totem",
"reagent",
"reagentCount",
"equippedItemClass",
"equippedItemSubClassMask",
"equippedItemInventoryTypeMask",
"spellVisual",
"spellIconID",
"activeIconID",
"manaCostPercentage",
"startRecoveryCategory",
"startRecoveryTime",
"spellFamilyName",
"spellFamilyFlags",
"preventionType",
"stanceBarOrder",
"effect",
"effectDieSides",
"effectBaseDice",
"effectDicePerLevel",
"effectRealPointsPerLevel",
"effectBasePoints",
"effectMechanic",
"effectImplicitTargetA",
"effectImplicitTargetB",
"effectRadiusIndex",
"effectApplyAuraName",
"effectAmplitude",
"effectMultipleValue",
"effectChainTarget",
"effectItemType",
"effectMiscValue",
"effectTriggerSpell",
];
/// One field of `d` by nampower's name for it.
///
/// `rank` is upstream's `nameSubtext` under the name this client's own earlier binding used, kept
/// because `SuperCleveRoidMacros` asks by it (`GetSpellRecField(spellId, "rank")`); both answer
/// the same string. A spell with no rank line answers the empty string rather than nothing, as
/// the reference's own `GetSpellName` second return does.
pub(crate) fn field(d: &SpellDisplay, name: &str) -> Option<SpellRecValue> {
Some(match name {
"name" => SpellRecValue::Str(d.name.clone()),
"nameSubtext" | "rank" => SpellRecValue::Str(d.rank.clone().unwrap_or_default()),
"school" => return int(d.school),
"category" => return int(d.category),
"castUI" => return int(d.cast_ui),
"dispel" => return int(d.dispel),
"mechanic" => return int(d.mechanic),
"attributes" => return int(d.attributes),
"attributesEx" => return int(d.attributes_ex),
"attributesEx2" => return int(d.attributes_ex2),
"attributesEx3" => return int(d.attributes_ex3),
"stances" => return int(d.stances),
"stancesNot" => return int(d.stances_not),
"targets" => return int(d.targets),
"requiresSpellFocus" => return int(d.requires_spell_focus),
"casterAuraState" => return int(d.caster_aura_state),
"targetAuraState" => return int(d.target_aura_state),
"castingTimeIndex" => return int(d.casting_time_index),
"recoveryTime" => return int(d.recovery_ms),
"categoryRecoveryTime" => return int(d.category_recovery_ms),
"interruptFlags" => return int(d.interrupt_flags),
"auraInterruptFlags" => return int(d.aura_interrupt_flags),
"channelInterruptFlags" => return int(d.channel_interrupt_flags),
"procChance" => return int(d.proc_chance),
"maxLevel" => return int(d.max_level),
"baseLevel" => return int(d.base_level),
"spellLevel" => return int(d.spell_level),
"durationIndex" => return int(d.duration_index),
"powerType" => return int(d.power_type),
"manaCost" => return int(d.mana_cost),
"manaCostPerlevel" => return int(d.mana_cost_per_level),
"manaPerSecond" => return int(d.mana_per_second),
"rangeIndex" => return int(d.range_index),
"speed" => SpellRecValue::Num(f64::from(d.speed)),
"modalNextSpell" => return int(d.modal_next_spell),
"stackAmount" => return int(d.stack_amount),
// The two totem slots and the eight reagent pairs, as their own 1-based tables — the
// same shape the effect arrays take, since upstream's record holds all of them as C
// arrays and Lua has one way to spell an array.
"totem" => SpellRecValue::Ints(d.totems.iter().map(|&v| i64::from(v)).collect()),
"reagent" => SpellRecValue::Ints(d.reagents.iter().map(|&(id, _)| i64::from(id)).collect()),
"reagentCount" => {
SpellRecValue::Ints(d.reagents.iter().map(|&(_, n)| i64::from(n)).collect())
}
"equippedItemClass" => return int(d.equipped_item_class),
"equippedItemSubClassMask" => return int(d.equipped_item_subclass_mask),
"equippedItemInventoryTypeMask" => return int(d.equipped_item_inventory_type_mask),
"spellVisual" => return int(d.visual),
"spellIconID" => return int(d.icon_id),
"activeIconID" => return int(d.active_icon_id),
"manaCostPercentage" => return int(d.mana_cost_pct),
"startRecoveryCategory" => return int(d.start_recovery_category),
"startRecoveryTime" => return int(d.start_recovery_ms),
"spellFamilyName" => return int(d.spell_family),
// The 64-bit mask, which Lua 5.1 numbers hold exactly only to 2^53. Every real 1.12 row
// fits: the highest bit any `Spell.dbc` family mask sets is 42. Cast rather than
// truncated so a hypothetical high bit would be visible as a wrong number rather than
// silently dropped.
#[allow(clippy::cast_possible_wrap)]
"spellFamilyFlags" => SpellRecValue::Int(d.spell_family_flags as i64),
"preventionType" => return int(d.prevention_type),
"stanceBarOrder" => return int(d.stance_bar_order),
"effect" => ints(&d.effects.map(|v| v as i64)),
"effectDieSides" => ints(&d.effect_die_sides),
"effectBaseDice" => ints(&d.effect_base_dice),
"effectDicePerLevel" => ints(&d.effect_dice_per_level),
"effectRealPointsPerLevel" => nums(&d.effect_real_points_per_level),
"effectBasePoints" => ints(&d.effect_base_points),
"effectMechanic" => ints(&d.effect_mechanic),
"effectImplicitTargetA" => ints(&d.effect_implicit_target_a),
"effectImplicitTargetB" => ints(&d.effect_implicit_target_b),
"effectRadiusIndex" => ints(&d.effect_radius_index),
"effectApplyAuraName" => ints(&d.effect_apply_aura),
"effectAmplitude" => ints(&d.effect_amplitude),
"effectMultipleValue" => nums(&d.effect_multiple_value),
"effectChainTarget" => ints(&d.effect_chain_targets),
"effectItemType" => ints(&d.effect_item_type),
"effectMiscValue" => ints(&d.effect_misc_value),
"effectTriggerSpell" => ints(&d.effect_trigger_spell),
_ => return None,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// Every name [`FIELDS`] lists is one [`field`] answers, and vice versa for the sample row —
/// the gate that keeps `GetSpellRec`'s key list and the per-field reader in step. A name in
/// the list the reader does not know would be a nil-valued key, which in Lua is an absent one.
#[test]
fn every_listed_field_is_one_the_reader_answers() {
let d = SpellDisplay {
name: "Fireball".into(),
..Default::default()
};
let missing: Vec<&str> = FIELDS
.iter()
.copied()
.filter(|n| field(&d, n).is_none())
.collect();
assert!(
missing.is_empty(),
"FIELDS names the reader does not answer: {missing:?}"
);
assert!(
field(&d, "notAColumn").is_none(),
"and an unknown name is still None"
);
// No duplicates: a repeated key would be written twice into one table.
let mut sorted = FIELDS.to_vec();
sorted.sort_unstable();
let before = sorted.len();
sorted.dedup();
assert_eq!(sorted.len(), before, "FIELDS has a duplicate");
}
/// The array fields are three long and in effect order, which is the shape every consumer
/// walks (`for i = 1, 3`). Asserted on real values so a transposed or truncated array shows.
#[test]
fn the_array_fields_are_three_long_in_effect_order() {
let d = SpellDisplay {
effect_mechanic: [0, 12, 0],
effect_apply_aura: [3, 0, 0],
effect_misc_value: [-1, 7, 0],
effect_real_points_per_level: [0.0, 1.5, 0.0],
..Default::default()
};
assert_eq!(
field(&d, "effectMechanic"),
Some(SpellRecValue::Ints(vec![0, 12, 0]))
);
assert_eq!(
field(&d, "effectApplyAuraName"),
Some(SpellRecValue::Ints(vec![3, 0, 0]))
);
assert_eq!(
field(&d, "effectMiscValue"),
Some(SpellRecValue::Ints(vec![-1, 7, 0])),
"signed, and the sign survives"
);
assert_eq!(
field(&d, "effectRealPointsPerLevel"),
Some(SpellRecValue::Nums(vec![0.0, 1.5, 0.0]))
);
// The reagent pair splits into two arrays of eight, ids and counts in slot order.
let d = SpellDisplay {
reagents: [
(17_030, 1),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
(0, 0),
],
..Default::default()
};
assert_eq!(
field(&d, "reagent"),
Some(SpellRecValue::Ints(vec![17_030, 0, 0, 0, 0, 0, 0, 0]))
);
assert_eq!(
field(&d, "reagentCount"),
Some(SpellRecValue::Ints(vec![1, 0, 0, 0, 0, 0, 0, 0]))
);
}
/// A rank-less spell answers the empty string, not nothing: the caller concatenates it.
#[test]
fn a_rankless_spell_answers_an_empty_rank() {
let d = SpellDisplay::default();
assert_eq!(field(&d, "rank"), Some(SpellRecValue::Str(String::new())));
assert_eq!(
field(&d, "nameSubtext"),
field(&d, "rank"),
"upstream's name and this client's own earlier one answer the same string"
);
}
}
@@ -53,6 +53,10 @@ pub struct SpellDisplay {
/// it is the running auto-repeat, that is only re-armed (`0x6e745d`). Every hunter shot
/// names 75, Auto Shot: that is how shooting starts.
pub modal_next_spell: u32,
/// `StackAmount`: an aura's maximum stacks, `0` for one that does not stack. Nothing in the
/// reference's own Lua reaches it — the client applies the cap internally — which is why
/// nampower publishes it as `GetSpellRecField(id, "stackAmount")`.
pub stack_amount: u32,
/// `Attributes & 0x40`, `SPELL_ATTR_PASSIVE`: the spellbook grays it.
pub passive: bool,
/// `castUI` (column 3): nonzero keeps the spell out of the spellbook; 0 on ordinary spells.
@@ -202,6 +206,7 @@ impl Default for SpellDisplay {
attributes_ex: 0,
attributes_ex2: 0,
modal_next_spell: 0,
stack_amount: 0,
attributes_ex3: 0,
spell_family: 0,
spell_family_flags: 0,
+7
View File
@@ -69,6 +69,12 @@ const COL_MANA_PER_SECOND: usize = 34;
const COL_RANGE_INDEX: usize = 36;
/// `modalNextSpell` (`+0x98`): 52 of its 57 nonzero rows are hunter shots naming Auto Shot.
const COL_MODAL_NEXT_SPELL: usize = 38;
/// `StackAmount` (`+0x9c`): an aura's maximum stacks, 0 for one that does not stack. Read by
/// nampower's `GetSpellRecField(id, "stackAmount")` — DoiteAuras caps its stack display on it
/// (`Modules/DoitePlayerAuras.lua:539`, where `0` is normalised to 1) and
/// `SuperCleveRoidMacros` predicts a stack increment against it (`Conditionals.lua:1087`).
/// Bracketed by two constants here: 38 is `ModalNextSpell` and 40 is `Totem[0]`.
const COL_STACK_AMOUNT: usize = 39;
/// `StartRecoveryCategory` (`+0x274`) and `StartRecoveryTime` (`+0x278`): the global cooldown
/// `StartGlobalCooldown` (`0x6e2de0`) starts at the cast send (`0x6e58fb`).
const COL_START_RECOVERY_CATEGORY: usize = 157;
@@ -545,6 +551,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result<SpellCatalog> {
mana_per_second: u32_at(r, COL_MANA_PER_SECOND).unwrap_or(0),
range_index: u32_at(r, COL_RANGE_INDEX).unwrap_or(0),
modal_next_spell: u32_at(r, COL_MODAL_NEXT_SPELL).unwrap_or(0),
stack_amount: u32_at(r, COL_STACK_AMOUNT).unwrap_or(0),
targets: u32_at(r, COL_TARGETS).unwrap_or(0),
implicit_target_a1: u32_at(r, COL_IMPLICIT_TARGET_A1).unwrap_or(0),
effect_implicit_target_a: std::array::from_fn(|i| {
+266 -47
View File
@@ -169,6 +169,31 @@ fn equipped_table(lua: &Lua, view: &crate::script::InvSlotView) -> mlua::Result<
Ok(t)
}
/// One [`crate::script::SpellRecValue`] as Lua sees it: a number or a string, or a 1-based table
/// for an array column.
fn spell_rec_value(lua: &Lua, value: crate::script::SpellRecValue) -> mlua::Result<Value> {
use crate::script::SpellRecValue as V;
Ok(match value {
V::Int(n) => Value::Integer(n),
V::Num(n) => Value::Number(n),
V::Str(s) => Value::String(lua.create_string(&s)?),
V::Ints(v) => {
let t = lua.create_table()?;
for (i, n) in v.into_iter().enumerate() {
t.set(i as i64 + 1, n)?;
}
Value::Table(t)
}
V::Nums(v) => {
let t = lua.create_table()?;
for (i, n) in v.into_iter().enumerate() {
t.set(i as i64 + 1, n)?;
}
Value::Table(t)
}
})
}
/// nampower's cooldown table: twenty keys, read off the DLL's own string pool in table order
/// (`0xa88d4`), shared by `GetSpellIdCooldown` and `GetItemIdCooldown` because upstream's addon
/// layer says so in as many words — `API.GetItemCooldownInfo` "returns same table structure as
@@ -518,40 +543,103 @@ pub(in crate::script) fn install(lua: &Lua) -> mlua::Result<()> {
// 0 reads as "free", and its Clearcasting rule turns on exactly that test.
//
// `spellIconID` is answered: `SpellDisplay::icon_id` carries the raw column beside the path
// it resolves to, and `GetSpellIconTexture` below resolves it back.
// `GetSpellRec(spellId, [copy])` -> the whole `Spell.dbc` row as a table, and
// `GetSpellRecField(spellId, fieldName, [copy])` -> one column of it.
//
// The key names are the DLL's own, read off its string pool in column order and mapped to
// `SpellDisplay`'s fields in `benilla-app`'s `ui_script/spell_rec.rs` — which is also where
// the fifteen columns this client does not parse are listed with the reason. `GetSpellRec` is
// that same field list walked through that same per-field reader, so the two verbs cannot
// answer differently for one name.
//
// **The array columns are a 1-based table of three**, which is measured rather than guessed:
// `SuperCleveRoidMacros` walks `GetSpellRecField(spellID, "effectMechanic")` as
// `for i = 1, 3 do local em = effectMechanics[i]` (`Utility.lua:7776-7794`) and does the same
// with `effectApplyAuraName`. `totem`, `reagent` and `reagentCount` take the same shape, two
// and eight long.
//
// What the corpus actually asks `GetSpellRecField` for, counted: `name` (4 sites), `school`
// (3), `stackAmount` (2), `spellIconID` (2), then one each of `rank`, `rangeIndex`,
// `mechanic`, `manaCost`, `effectMechanic`, `effectApplyAuraName`. Seven of those ten
// answered **nil** before this — DoiteAuras caps its stack display on `stackAmount`
// (`Modules/DoitePlayerAuras.lua:539`) and `SuperCleveRoidMacros` classifies a crowd-control
// effect off the two arrays, so each nil was a feature quietly off rather than an error.
//
// `copy` is accepted and ignored: upstream's flag asks for a copy rather than a reference
// into its own cache, and every table built here is already a fresh one.
lua.globals().set(
"GetSpellRec",
lua.create_function(|lua, (spell_id, _copy): (Option<u32>, Option<Value>)| {
let rows = {
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
spell_id.filter(|&id| id != 0).and_then(|id| {
model
.spell_record_probe
.as_ref()
.and_then(|probe| probe(id))
})
};
let Some(rows) = rows else {
return Ok(Value::Nil);
};
let t = lua.create_table()?;
for (name, value) in rows {
t.set(name, spell_rec_value(lua, value)?)?;
}
Ok(Value::Table(t))
})?,
)?;
lua.globals().set(
"GetSpellRecField",
lua.create_function(|lua, (spell_id, field): (Option<u32>, Option<String>)| {
let (Some(spell_id), Some(field)) = (spell_id, field) else {
return Ok(mlua::Value::Nil);
};
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
let Some(d) = crate::script::spellbook::spell_display(&model, spell_id) else {
return Ok(mlua::Value::Nil);
};
let text = |v: Option<String>| -> mlua::Result<mlua::Value> {
Ok(v.and_then(|t| lua.create_string(&t).ok())
.map_or(mlua::Value::Nil, mlua::Value::String))
};
match field.as_str() {
"name" => text(Some(d.name)),
// The rank subtext, under both spellings upstream uses for it.
"rank" | "nameSubtext" => text(Some(d.rank.unwrap_or_default())),
// The `SpellIcon.dbc` row id, which the caller hands straight to
// `GetSpellIconTexture`. 0 means the spell has no icon row, and the caller's own
// `iconId > 0` gate reads that correctly, so it is answered rather than withheld.
"spellIconID" => Ok(model
.spell_mechanic_probe
.as_ref()
.and_then(|probe| probe(spell_id))
.map_or(mlua::Value::Nil, |e| {
mlua::Value::Integer(i64::from(e.icon_id))
})),
_ => Ok(mlua::Value::Nil),
}
})?,
lua.create_function(
|lua, (spell_id, field, _copy): (Option<u32>, Option<String>, Option<Value>)| {
let asked = field;
let found = {
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
spell_id
.filter(|&id| id != 0)
.zip(asked.as_deref())
.and_then(|(id, field)| {
model
.spell_rec_field_probe
.as_ref()
.and_then(|probe| probe(id, field))
})
};
if let Some(value) = found {
return spell_rec_value(lua, value);
}
// The three string columns keep the book fallback this verb had before the
// `Spell.dbc` probe existed: a VM with a spellbook and no catalog (a capture, an
// early boot) still answers a learnt spell's name and rank, which is what
// `GetSpellNameAndRankForId` beside it does from the same `spell_display`. Every
// other column is a DBC row and has nowhere else to come from.
let Some(field) =
asked.filter(|f| matches!(f.as_str(), "name" | "nameSubtext" | "rank"))
else {
return Ok(Value::Nil);
};
let model = lua
.app_data_ref::<crate::script::Model>()
.expect("model app_data");
let Some(d) = spell_id
.filter(|&id| id != 0)
.and_then(|id| crate::script::spellbook::spell_display(&model, id))
else {
return Ok(Value::Nil);
};
let text = if field == "name" {
d.name
} else {
d.rank.unwrap_or_default()
};
Ok(Value::String(lua.create_string(&text)?))
},
)?,
)?;
// `GetSpellNameAndRankForId(spellID)` -> name, rank. DoiteAuras' hardest-leaned-on nampower
@@ -2646,9 +2734,142 @@ mod tests {
/// `GetSpellRecField`: the columns this client has, and **nil for the ones it does not** —
/// asserted, because a plausible zero is worse than nothing here (DoiteAuras' Clearcasting
/// rule turns on `manaCost > 0`, so a fabricated 0 would silently disable it).
/// `GetSpellRec` and `GetSpellRecField` over the `Spell.dbc` probe, including the seven
/// names the corpus asks for that answered nil before it existed.
///
/// Both verbs are asserted on the **same row** so they cannot be seen to disagree, and the
/// array columns are read the way their one consumer reads them: `for i = 1, 3`.
#[test]
fn get_spell_rec_field_answers_what_it_has_and_nil_for_what_it_does_not() {
fn the_spell_record_verbs_answer_every_column_the_corpus_asks_for() {
use crate::script::SpellRecValue as V;
let mut s = UiScript::new().unwrap();
let row: Vec<(&'static str, V)> = vec![
("name", V::Str("Fireball".into())),
("rank", V::Str("Rank 1".into())),
("nameSubtext", V::Str("Rank 1".into())),
("school", V::Int(2)),
("mechanic", V::Int(0)),
("manaCost", V::Int(30)),
("rangeIndex", V::Int(4)),
("spellIconID", V::Int(135_812)),
("stackAmount", V::Int(0)),
("speed", V::Num(24.0)),
("effectMechanic", V::Ints(vec![0, 12, 0])),
("effectApplyAuraName", V::Ints(vec![3, 0, 0])),
("effectRealPointsPerLevel", V::Nums(vec![0.0, 1.5, 0.0])),
];
let field_rows = row.clone();
s.set_spell_rec_probes(
Box::new(move |id, name| {
(id == 133)
.then(|| {
field_rows
.iter()
.find(|(n, _)| *n == name)
.map(|(_, v)| v.clone())
})
.flatten()
}),
Box::new(move |id| (id == 133).then(|| row.clone())),
);
// The three names this verb already answered.
assert_eq!(
s.eval::<(String, String, i64)>(
r#"return GetSpellRecField(133, "name"), GetSpellRecField(133, "rank"),
GetSpellRecField(133, "spellIconID")"#
)
.unwrap(),
("Fireball".to_string(), "Rank 1".to_string(), 135_812)
);
// And the seven that did not. `stackAmount` is 0 for a non-stacking spell, which is a
// real answer the consumer normalises to 1 itself — nil would make it skip that step.
assert_eq!(
s.eval::<(i64, i64, i64, i64, f64)>(
r#"return GetSpellRecField(133, "school"), GetSpellRecField(133, "manaCost"),
GetSpellRecField(133, "rangeIndex"), GetSpellRecField(133, "stackAmount"),
GetSpellRecField(133, "speed")"#
)
.unwrap(),
(2, 30, 4, 0, 24.0)
);
// `SuperCleveRoidMacros/Utility.lua:7776-7794`, transcribed: the crowd-control classifier
// that walks both arrays. It is the only reader of either, and it reads them 1-based.
assert_eq!(
s.eval::<i64>(
r#"
local effectMechanics = GetSpellRecField(133, "effectMechanic")
if effectMechanics then
for i = 1, 3 do
local em = effectMechanics[i]
if em and em > 0 then return em end
end
end
return -1
"#
)
.unwrap(),
12,
"effect 2's mechanic, found by walking the 1-based table"
);
assert_eq!(
s.eval::<i64>(r#"return GetSpellRecField(133, "effectApplyAuraName")[1]"#)
.unwrap(),
3
);
assert_eq!(
s.eval::<f64>(r#"return GetSpellRecField(133, "effectRealPointsPerLevel")[2]"#)
.unwrap(),
1.5,
"a float column stays a float"
);
// `GetSpellRec` is the same row in one table, and the two verbs agree key by key.
assert_eq!(
s.eval::<(String, i64, i64, f64)>(
r#"local r = GetSpellRec(133)
return r.name, r.school, r.effectMechanic[2], r.speed"#
)
.unwrap(),
("Fireball".to_string(), 2, 12, 24.0)
);
assert!(
s.eval::<bool>(
r#"local r = GetSpellRec(133)
for k, v in pairs(r) do
if type(v) ~= "table" and v ~= GetSpellRecField(133, k) then return false end
end
return true"#
)
.unwrap(),
"every scalar key of GetSpellRec is what GetSpellRecField answers for that name"
);
// A name the mapping has no column for, and an id no spell owns, are both nil — never a
// fabricated zero, which is what the consumer's `if m and m > 0` guard is written for.
for call in [
r#"GetSpellRecField(133, "notAColumn")"#,
r#"GetSpellRecField(999999, "name")"#,
r#"GetSpellRecField(133)"#,
r#"GetSpellRecField()"#,
"GetSpellRec(999999)",
"GetSpellRec()",
] {
assert!(
s.eval::<Option<mlua::Value>>(&format!("return {call}"))
.unwrap()
.is_none_or(|v| v.is_nil()),
"{call}"
);
}
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
/// With no `Spell.dbc` probe at all — a capture, or the boot VM before the catalog loads —
/// the three string columns still answer off the spellbook, which is where this verb read
/// before the probe existed. Every other column has nowhere else to come from.
#[test]
fn the_string_columns_keep_their_spellbook_fallback() {
let mut s = UiScript::new().unwrap();
s.set_spellbook(crate::script::SpellBookState {
slots: vec![crate::script::SpellSlotView {
@@ -2660,14 +2881,16 @@ mod tests {
..Default::default()
});
assert_eq!(
s.eval::<String>(r#"return GetSpellRecField(133, "name")"#)
.unwrap(),
"Fireball"
);
assert_eq!(
s.eval::<String>(r#"return GetSpellRecField(133, "rank")"#)
.unwrap(),
"Rank 1"
s.eval::<(String, String, String)>(
r#"return GetSpellRecField(133, "name"), GetSpellRecField(133, "rank"),
GetSpellRecField(133, "nameSubtext")"#
)
.unwrap(),
(
"Fireball".to_string(),
"Rank 1".to_string(),
"Rank 1".to_string()
)
);
for absent in ["stackAmount", "manaCost", "spellIconID", "whatever"] {
assert!(
@@ -2677,7 +2900,7 @@ mod tests {
"{absent} must be nil, not a fabricated number"
);
}
// DoiteAuras' own guards, run against the verb: both must survive the nil.
// DoiteAuras' own guard, run against the verb: it must survive the nil.
assert_eq!(
s.eval::<String>(
r#"local m = GetSpellRecField(133, "manaCost")
@@ -2686,10 +2909,6 @@ mod tests {
.unwrap(),
"guarded"
);
assert!(s
.eval::<Option<i64>>(r#"return GetSpellRecField(999999, "name")"#)
.unwrap()
.is_none());
assert!(s.errors().is_empty(), "script errors: {:?}", s.errors());
}
+4 -1
View File
@@ -246,7 +246,10 @@ pub use model::{
SpellProbe, SpellUsable, TextureProbe, TextureSizeProbe,
};
pub(crate) use model::{Model, PendingTimer};
pub use model::{PlayerCacheEntry, SelfCast, SelfMovement, ServerClock, SpellModifierProbe};
pub use model::{
PlayerCacheEntry, SelfCast, SelfMovement, ServerClock, SpellModifierProbe, SpellRecFieldProbe,
SpellRecValue, SpellRecordProbe,
};
pub use party::{PartyMemberInfo, PartyRequest, PartyState, RaidMemberInfo, SavedInstanceInfo};
pub use pet::{PetActionView, PetStats};
pub use pvp::{HonorState, InspectHonorData};
+32
View File
@@ -71,6 +71,34 @@ pub type SpellProbe = Box<dyn Fn(u32) -> Option<SpellLookup>>;
/// through (`benilla-app`'s `cursor.rs`). `None` where the row or its icon is absent.
pub type ItemIconProbe = Box<dyn Fn(u32) -> Option<String>>;
/// One `Spell.dbc` column as nampower's `GetSpellRec`/`GetSpellRecField` hands it to Lua.
///
/// The array variants are a **1-based Lua table of three**, which is not a guess:
/// `SuperCleveRoidMacros` walks `GetSpellRecField(spellID, "effectMechanic")` as
/// `for i = 1, 3 do local em = effectMechanics[i]` (`Utility.lua:7776-7794`), and does the same
/// with `effectApplyAuraName`.
#[derive(Clone, Debug, PartialEq)]
pub enum SpellRecValue {
Int(i64),
Num(f64),
Str(String),
Ints(Vec<i64>),
Nums(Vec<f64>),
}
/// One field of a spell's `Spell.dbc` row by nampower's own name for it, or `None` for a name this
/// client does not parse (and for an id no spell owns).
///
/// **The name-to-column mapping lives in `benilla-app`,** beside the `SpellDisplay` it reads: this
/// is a mapping from upstream's spelling to that struct's fields, so it belongs where that struct
/// is, and a copy here would be free to drift from it.
pub type SpellRecFieldProbe = Box<dyn Fn(u32, &str) -> Option<SpellRecValue>>;
/// The whole row as `(name, value)` pairs in `Spell.dbc` column order, for `GetSpellRec`. Built
/// by walking the same field list through the same per-field function the probe above uses, so
/// the two verbs cannot answer differently for one name.
pub type SpellRecordProbe = Box<dyn Fn(u32) -> Option<Vec<(&'static str, SpellRecValue)>>>;
/// nampower's `GetSpellModifiers(spellId, modifierType)`: the `(flat, percent)` pair the client's
/// own `GetSpellModifiers 0x6e6b30` folds out of its two talent-modifier tables, or `None` for
/// any of that reader's four false exits (the spell has no family, it is not the player's class
@@ -799,6 +827,8 @@ pub(crate) struct Model {
pub(crate) spell_icon_probe: Option<SpellIconProbe>,
pub(crate) item_icon_probe: Option<ItemIconProbe>,
pub(crate) spell_modifier_probe: Option<SpellModifierProbe>,
pub(crate) spell_rec_field_probe: Option<SpellRecFieldProbe>,
pub(crate) spell_record_probe: Option<SpellRecordProbe>,
/// Creature display id to model path ([`CreatureModelProbe`]).
pub(crate) creature_model_probe: Option<CreatureModelProbe>,
/// The pet's spellbook, a second array as in the reference (`0xb700f0`, `0xb6f098`).
@@ -1701,6 +1731,8 @@ impl Model {
spell_probe: None,
item_icon_probe: None,
spell_modifier_probe: None,
spell_rec_field_probe: None,
spell_record_probe: None,
spell_mechanic_probe: None,
player_cache: HashMap::new(),
player_cache_enabled: [true, true],
+12
View File
@@ -131,6 +131,18 @@ impl super::UiScript {
self.model_mut().spell_modifier_probe = Some(probe);
}
/// Install nampower's `Spell.dbc` row readers, both built from the one name-to-column mapping
/// in `benilla-app` (see [`super::SpellRecFieldProbe`]).
pub fn set_spell_rec_probes(
&mut self,
field: super::SpellRecFieldProbe,
record: super::SpellRecordProbe,
) {
let mut m = self.model_mut();
m.spell_rec_field_probe = Some(field);
m.spell_record_probe = Some(record);
}
/// Replace the live per-spell usability map ([`super::SpellUsable`]), nampower's
/// `IsSpellUsable`/`IsSpellInRange` source. Pushed by the action feed, which already holds
/// every input the reference's own usable walk reads.
@@ -109,6 +109,7 @@ fn allowed_beyond_1_12() -> HashSet<&'static str> {
// client reaches a spell's name, rank and icon path and no other column of that row, so
// the rest answer nil rather than a plausible zero. DoiteAuras guards each non-name read.
"GetSpellRecField",
"GetSpellRec",
// nampower v4's spellbook index helpers (script/compat/nampower.rs), all four of them the
// fast paths DoiteAuras takes before falling back to a 200-slot `GetSpellName` scan per
// lookup — it does one of those per tracked aura per condition evaluation.
+39 -2
View File
@@ -726,6 +726,43 @@ in the corpus calls the global at all — the only mention is a comment in
`SuperCleveRoidMacros/ClassicAPI.lua:272` saying it reads the same client field. A bare name match
is not a call site.
### The `Spell.dbc` row — `GetSpellRec` and seven columns `GetSpellRecField` answered nil for
**Built**, and this one is a measured gap rather than a completeness exercise. Counting what the
corpus actually asks `GetSpellRecField` for: `name` (4 sites), `school` (3), `stackAmount` (2),
`spellIconID` (2), then one each of `rank`, `rangeIndex`, `mechanic`, `manaCost`,
`effectMechanic`, `effectApplyAuraName`. **Seven of those ten answered nil** — the binding knew
three names. DoiteAuras caps its stack display on `stackAmount` (`DoitePlayerAuras.lua:539`) and
`SuperCleveRoidMacros` classifies a crowd-control effect off the two arrays
(`Utility.lua:7776-7794`), so each nil was a feature quietly off.
The names are the DLL's own, read off its pool in `Spell.dbc` column order (`0x91d68`–`0x92460`):
eighty of them. The mapping to `benilla_formats::SpellDisplay` lives in
`benilla-app`'s `ui_script/spell_rec.rs` — beside the struct it reads, because a copy on the VM side
would be free to drift — and `GetSpellRec(spellId)` is that same field list walked through that
same per-field reader, so the two verbs cannot answer differently for one name. A test asserts
exactly that, key by key, over one row.
**The array columns are a 1-based table of three, which is measured and not guessed.**
`SuperCleveRoidMacros` walks `GetSpellRecField(spellID, "effectMechanic")` as
`for i = 1, 3 do local em = effectMechanics[i]`. `totem`, `reagent` and `reagentCount` take the
same shape, two and eight long.
One new DBC column: `StackAmount`, column **39**, bracketed by two constants this tree already had
(38 `ModalNextSpell`, 40 `Totem[0]`). It is the only one added — fifteen further names upstream
answers are left nil rather than putting fifteen unused `u32`s on a struct the spell catalog holds
one of per row. They are listed at `FIELDS` with that reason, so the gap is a decision and not an
omission: `attributesEx4`, `targetCreatureType`, `procFlags`, `procCharges`,
`manaPerSecondPerLevel`, `maxTargetLevel`, `maxAffectedTargets`, `dmgClass`, `spellPriority`,
`spellVisual2`, `minFactionId`, `minReputation`, `requiredAuraVision`,
`effectPointsPerComboPoint`, `dmgMultiplier`.
`GetSpellRecField`'s three string columns keep the spellbook fallback the verb had before the
probe: a VM with a book and no catalog — a capture, or the boot VM before `ui_action`'s loader
runs — still answers a learnt spell's name and rank, which is what `GetSpellNameAndRankForId`
beside it does from the same `spell_display`. Every other column is a DBC row and has nowhere else
to come from.
### The whole registered list, accounted for
With the registration table read (the section above), the surface is no longer a count of what a
@@ -742,10 +779,10 @@ comm -23 /tmp/np /tmp/ours
| Bucket | Count | Names |
|---|---|---|
| **Built** | 37 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetSpellRecField`, `GetSpellSlotTypeIdForName`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetMouseoverUnit`, `UseItemIdOrName` |
| **Built** | 38 | `CancelPlayerAuraSlot`, `CancelPlayerAuraSpellId`, `CastSpellByName`, `CastSpellByNameNoQueue`, `CastSpellNoQueue`, `FindPlayerItemSlot`, `GetBagItem`, `GetBagItems`, `GetEquippedItem`, `GetEquippedItems`, `GetItemIconTexture`, `GetItemIdCooldown`, `GetItemLevel`, `GetItemStats`, `GetItemStatsField`, `GetNampowerVersion`, `GetPlayerAuraDuration`, `GetSpellDuration`, `GetSpellIconTexture`, `GetSpellIdCooldown`, `GetSpellIdForName`, `GetSpellModifiers`, `GetSpellNameAndRankForId`, `GetSpellRec`, `GetSpellRecField`, `GetSpellSlotTypeIdForName`, `GetTrinkets`, `GetUnitField`, `GetUnitGUID`, `IsAuraHidden`, `IsSpellInRange`, `IsSpellUsable`, `PlayerIsMoving`, `PlayerIsRooted`, `PlayerIsSwimming`, `SetMouseoverUnit`, `UseItemIdOrName` |
| **Withheld pending grom's say-so** | 8 | `WriteCustomFile`, `ReadCustomFile`, `CustomFileExists`, `ImportFile`, `ExportFile`, `ExecuteCustomLuaFile`, `StartItemExport` (writes a file of its own), `EncryptPassword` + `EncryptedServerLogin` |
| **Not ours to add** | 4 | `QueueSpellByName`, `QueueScript`, `ChannelStopCastingNextTick`, `LearnTalentRank` |
| **To build, in corpus-call order** | 13 | `DisenchantAll` (5 call sites outside the API layer), then `GetSpellPower`, `GetSpellRec`, `GetTrinketCooldown`, `UseTrinket`, `GetUnitData`, `GetSpellRangeData`, `CombatLogFlush`, `GetRaidTargets`, `SetLocalRaidTargetIndex`, `GetQuestLogQuestIds`, `GetQuestDialogQuestId`, `StartItemExport` (0 each — present for completeness, not need) |
| **To build, in corpus-call order** | 12 | `DisenchantAll` (5 call sites outside the API layer), then `GetSpellPower`, `GetTrinketCooldown`, `UseTrinket`, `GetUnitData`, `GetSpellRangeData`, `CombatLogFlush`, `GetRaidTargets`, `SetLocalRaidTargetIndex`, `GetQuestLogQuestIds`, `GetQuestDialogQuestId`, `StartItemExport` (0 each — present for completeness, not need) |
The two that were at the head of that last bucket are now built — see "The cast feed" above for
why they came first and what each decision cost.