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:
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user