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"
);
}
}