diff --git a/crates/benilla-app/src/ui_script/lifecycle.rs b/crates/benilla-app/src/ui_script/lifecycle.rs index faf81d02..ceafa9b3 100644 --- a/crates/benilla-app/src/ui_script/lifecycle.rs +++ b/crates/benilla-app/src/ui_script/lifecycle.rs @@ -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(); diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index c465541e..a1e0276d 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -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, diff --git a/crates/benilla-app/src/ui_script/spell_rec.rs b/crates/benilla-app/src/ui_script/spell_rec.rs new file mode 100644 index 00000000..afa16405 --- /dev/null +++ b/crates/benilla-app/src/ui_script/spell_rec.rs @@ -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>(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) -> Option { + 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 { + 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" + ); + } +} diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index e9c67486..d37e11ae 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -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, diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index e2154efc..1766fae6 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -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 { 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| { diff --git a/crates/benilla-ui/src/script/compat/nampower.rs b/crates/benilla-ui/src/script/compat/nampower.rs index 01fe3662..5ff178a8 100644 --- a/crates/benilla-ui/src/script/compat/nampower.rs +++ b/crates/benilla-ui/src/script/compat/nampower.rs @@ -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 { + 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, Option)| { + let rows = { + let model = lua + .app_data_ref::() + .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, Option)| { - let (Some(spell_id), Some(field)) = (spell_id, field) else { - return Ok(mlua::Value::Nil); - }; - let model = lua - .app_data_ref::() - .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| -> mlua::Result { - 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, Option, Option)| { + let asked = field; + let found = { + let model = lua + .app_data_ref::() + .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::() + .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::( + 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::(r#"return GetSpellRecField(133, "effectApplyAuraName")[1]"#) + .unwrap(), + 3 + ); + assert_eq!( + s.eval::(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::( + 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::>(&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::(r#"return GetSpellRecField(133, "name")"#) - .unwrap(), - "Fireball" - ); - assert_eq!( - s.eval::(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::( r#"local m = GetSpellRecField(133, "manaCost") @@ -2686,10 +2909,6 @@ mod tests { .unwrap(), "guarded" ); - assert!(s - .eval::>(r#"return GetSpellRecField(999999, "name")"#) - .unwrap() - .is_none()); assert!(s.errors().is_empty(), "script errors: {:?}", s.errors()); } diff --git a/crates/benilla-ui/src/script/mod.rs b/crates/benilla-ui/src/script/mod.rs index 7609dfa1..85be4c74 100644 --- a/crates/benilla-ui/src/script/mod.rs +++ b/crates/benilla-ui/src/script/mod.rs @@ -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}; diff --git a/crates/benilla-ui/src/script/model.rs b/crates/benilla-ui/src/script/model.rs index 765886de..bfcfb451 100644 --- a/crates/benilla-ui/src/script/model.rs +++ b/crates/benilla-ui/src/script/model.rs @@ -71,6 +71,34 @@ pub type SpellProbe = Box Option>; /// through (`benilla-app`'s `cursor.rs`). `None` where the row or its icon is absent. pub type ItemIconProbe = Box Option>; +/// 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), + Nums(Vec), +} + +/// 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 Option>; + +/// 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 Option>>; + /// 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, pub(crate) item_icon_probe: Option, pub(crate) spell_modifier_probe: Option, + pub(crate) spell_rec_field_probe: Option, + pub(crate) spell_record_probe: Option, /// Creature display id to model path ([`CreatureModelProbe`]). pub(crate) creature_model_probe: Option, /// 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], diff --git a/crates/benilla-ui/src/script/spellbook.rs b/crates/benilla-ui/src/script/spellbook.rs index 4b45085a..aebc4577 100644 --- a/crates/benilla-ui/src/script/spellbook.rs +++ b/crates/benilla-ui/src/script/spellbook.rs @@ -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. diff --git a/crates/benilla-ui/src/script/tests/reference_surface.rs b/crates/benilla-ui/src/script/tests/reference_surface.rs index 3825f592..0f8e7112 100644 --- a/crates/benilla-ui/src/script/tests/reference_surface.rs +++ b/crates/benilla-ui/src/script/tests/reference_surface.rs @@ -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. diff --git a/docs/ADDON_COMPAT.md b/docs/ADDON_COMPAT.md index 74f3c025..f62c6a72 100644 --- a/docs/ADDON_COMPAT.md +++ b/docs/ADDON_COMPAT.md @@ -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.