From 2b16219357a8f81a753d7af509454b516bb2d590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juozas=20Polikevi=C4=8Dius?= Date: Wed, 30 Sep 2026 08:36:29 +0300 Subject: [PATCH] Spell descriptions resolve $b, $f, $F, $i, $q, $u and $v (#622) Spell descriptions printed `$b`, `$f`, `$F`, `$i`, `$q`, `$u` and `$v` raw. The reference's expander resolves each from Spell.dbc: | token | column | |---|---| | `$b` | EffectPointsPerComboPoint | | `$f`/`$F` | DmgMultiplier | | `$i` | MaxAffectedTargets | | `$q` | EffectMiscValue | | `$u` | StackAmount | | `$v` | MaxTargetLevel | `$b` chops toward zero. `$f` prints one decimal, and `$F` rounds half away from zero, both after the scale and in double precision. The integer arms print a signed `%d`. This change reads the new columns and adds the arms. `$f` and `$F` keep keying the `$l` plural, under the recorded deviation. Tests pin each arm, the schema's float columns and every shipped use of the six letters. Chain Heal now reads "Each jump is 50% as effective". Fixes #593 Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- .../src/spells/catalog_tests.rs | 25 ++ crates/benilla-formats/src/spells/display.rs | 15 + crates/benilla-formats/src/spells/mod.rs | 33 ++- crates/benilla-formats/src/spells/tokens.rs | 271 +++++++++++++++++- 4 files changed, 336 insertions(+), 8 deletions(-) diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 58777411..dd6ae59c 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -622,6 +622,31 @@ fn real_spell_catalog_reads_tooltip_columns() { ); } +/// The WDBC schema must decode the new float arrays before `f32_at` can pass them to token +/// expansion. An integer schema silently turns Chain Heal's 0.5 into the loader's 0.0 fallback. +#[test] +fn spell_schema_decodes_token_float_columns() { + let mut raw = Vec::new(); + raw.extend_from_slice(b"WDBC"); + for n in [1u32, SPELL_FIELDS as u32, (SPELL_FIELDS * 4) as u32, 1] { + raw.extend_from_slice(&n.to_le_bytes()); + } + let mut row = vec![0u8; SPELL_FIELDS * 4]; + row[COL_DAMAGE_MULTIPLIER_1 * 4..COL_DAMAGE_MULTIPLIER_1 * 4 + 4] + .copy_from_slice(&0.5f32.to_le_bytes()); + row[COL_EFFECT_POINTS_PER_COMBO_POINT_1 * 4..COL_EFFECT_POINTS_PER_COMBO_POINT_1 * 4 + 4] + .copy_from_slice(&20.0f32.to_le_bytes()); + raw.extend_from_slice(&row); + raw.push(0); + let set = parse(&raw, spell_schema(), "Spell.dbc").expect("synthetic Spell.dbc"); + let spell = set.records().first().unwrap(); + assert_eq!(f32_at(spell, COL_DAMAGE_MULTIPLIER_1), Some(0.5)); + assert_eq!( + f32_at(spell, COL_EFFECT_POINTS_PER_COMBO_POINT_1), + Some(20.0) + ); +} + /// The attack-start masks, rows from vmangos: [`SpellDisplay::on_next_swing`] (`0x404`), /// [`SpellDisplay::initiates_auto_attack`] (adding `AttributesEx & 0x200`) and /// [`SpellDisplay::initiates_auto_attack_at_go`] (`AttributesEx2 & 0x100000`), whose union is diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index 1dae9ed9..3d911c74 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -162,6 +162,12 @@ pub struct SpellDisplay { pub proc_chance: u32, /// `ProcCharges` (column 26): the description expander's `$n` value before op 4. pub proc_charges: u32, + /// `StackAmount` (column 39): the description expander's `$u` (`507ef1`). + pub stack_amount: u32, + /// `MaxTargetLevel` (column 159): the description expander's `$v` (`507f4f`). + pub max_target_level: u32, + /// `MaxAffectedTargets` (column 163): the description expander's `$i` (`507fc3`). + pub max_affected_targets: u32, /// `EffectBasePoints[3]` (columns 76-78, signed): each roll's floor; -1 on weapon damage. pub effect_base_points: [i32; 3], /// `EffectDieSides[3]` (columns 64-66, signed): with n dice, the value runs base + n to @@ -182,6 +188,10 @@ pub struct SpellDisplay { pub effect_chain_targets: [u32; 3], /// `EffectMultipleValue[3]` (columns 97-99, float): the chain or multi-target falloff. pub effect_multiple_value: [f32; 3], + /// `DmgMultiplier[3]` (columns 167-169, float): `$f`/`$F` print it scaled (`507ff7`, `508025`). + pub damage_multiplier: [f32; 3], + /// `EffectPointsPerComboPoint[3]` (columns 112-114, float): `$b` prints it chopped (`507ed4`). + pub effect_points_per_combo_point: [f32; 3], /// `EffectTriggerSpell[3]` (columns 109-111): each effect's triggered spell, 0 for none. pub effect_trigger_spell: [u32; 3], /// `EffectItemType[3]` (columns 103-105): the item a `SPELL_EFFECT_CREATE_ITEM` effect makes. @@ -264,6 +274,9 @@ impl Default for SpellDisplay { proc_flags: 0, proc_chance: 0, proc_charges: 0, + stack_amount: 0, + max_target_level: 0, + max_affected_targets: 0, effect_base_points: [0; 3], effect_die_sides: [0; 3], effect_base_dice: [0; 3], @@ -277,6 +290,8 @@ impl Default for SpellDisplay { effect_radius_index: [0; 3], effect_chain_targets: [0; 3], effect_multiple_value: [0.0; 3], + damage_multiplier: [0.0; 3], + effect_points_per_combo_point: [0.0; 3], effect_trigger_spell: [0; 3], effect_item_type: [0; 3], effect_misc_value: [0; 3], diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index c3a49cb4..21a84945 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -168,6 +168,11 @@ const COL_CASTING_TIME_INDEX: usize = 18; const COL_PROC_FLAGS: usize = 24; const COL_PROC_CHANCE: usize = 25; const COL_PROC_CHARGES: usize = 26; +const COL_STACK_AMOUNT: usize = 39; +const COL_MAX_TARGET_LEVEL: usize = 159; +const COL_MAX_AFFECTED_TARGETS: usize = 163; +const COL_DAMAGE_MULTIPLIER_1: usize = 167; +const COL_EFFECT_POINTS_PER_COMBO_POINT_1: usize = 112; /// The per-effect `[3]` arrays, each constant slot 0; die sides and base points are signed. const COL_EFFECT_DIE_SIDES_1: usize = 64; @@ -362,8 +367,8 @@ impl SpellCatalog { } } -/// 173 fields, `u32` but for the floats (`Speed`, the two float effect arrays) and the four enUS -/// string heads; the signed columns stay `u32`, as [`i32_at`] reads the same bits. +/// 173 fields, `u32` but for `Speed`, the four float effect arrays and the four enUS string heads; +/// the signed columns stay `u32`, as [`i32_at`] reads the same bits. fn spell_schema() -> Schema { let mut s = Schema::new("Spell"); for i in 0..SPELL_FIELDS { @@ -392,6 +397,21 @@ fn spell_schema() -> Schema { format!("EffectMultipleValue{}", i - COL_EFFECT_MULTIPLE_VALUE_1), FieldType::Float32, )); + } else if (COL_EFFECT_POINTS_PER_COMBO_POINT_1..COL_EFFECT_POINTS_PER_COMBO_POINT_1 + 3) + .contains(&i) + { + s.add_field(SchemaField::new( + format!( + "EffectPointsPerComboPoint{}", + i - COL_EFFECT_POINTS_PER_COMBO_POINT_1 + ), + FieldType::Float32, + )); + } else if (COL_DAMAGE_MULTIPLIER_1..COL_DAMAGE_MULTIPLIER_1 + 3).contains(&i) { + s.add_field(SchemaField::new( + format!("DmgMultiplier{}", i - COL_DAMAGE_MULTIPLIER_1), + FieldType::Float32, + )); } else { s.add_field(SchemaField::new(format!("F{i}"), FieldType::UInt32)); } @@ -585,6 +605,9 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { proc_flags: u32_at(r, COL_PROC_FLAGS).unwrap_or(0), proc_chance: u32_at(r, COL_PROC_CHANCE).unwrap_or(0), proc_charges: u32_at(r, COL_PROC_CHARGES).unwrap_or(0), + stack_amount: u32_at(r, COL_STACK_AMOUNT).unwrap_or(0), + max_target_level: u32_at(r, COL_MAX_TARGET_LEVEL).unwrap_or(0), + max_affected_targets: u32_at(r, COL_MAX_AFFECTED_TARGETS).unwrap_or(0), effect_base_points: std::array::from_fn(|i| { i32_at(r, COL_EFFECT_BASE_POINTS_1 + i).unwrap_or(0) }), @@ -613,6 +636,12 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { effect_multiple_value: std::array::from_fn(|i| { f32_at(r, COL_EFFECT_MULTIPLE_VALUE_1 + i).unwrap_or(0.0) }), + damage_multiplier: std::array::from_fn(|i| { + f32_at(r, COL_DAMAGE_MULTIPLIER_1 + i).unwrap_or(0.0) + }), + effect_points_per_combo_point: std::array::from_fn(|i| { + f32_at(r, COL_EFFECT_POINTS_PER_COMBO_POINT_1 + i).unwrap_or(0.0) + }), effect_trigger_spell, effect_item_type: std::array::from_fn(|i| { u32_at(r, COL_EFFECT_ITEM_TYPE_1 + i).unwrap_or(0) diff --git a/crates/benilla-formats/src/spells/tokens.rs b/crates/benilla-formats/src/spells/tokens.rs index 73808b5b..a86b4945 100644 --- a/crates/benilla-formats/src/spells/tokens.rs +++ b/crates/benilla-formats/src/spells/tokens.rs @@ -1,7 +1,8 @@ //! The spell-description `$`-token engine the 1.12 client runs over `Spell.dbc` description and -//! aura text (`0x5075f0` → `0x507710`), effect values from `GetEffectPoints 0x6e3800`. Values -//! print unsigned, as the client's do. `$g` always takes the first form, as there is no gender -//! input, and `$u` and any unknown or unresolved token stay raw. +//! aura text (`0x5075f0` → `0x507710`), effect values from `GetEffectPoints 0x6e3800`. Effect +//! point values print unsigned, as the client's do. `$g` takes the first form without gender +//! input. `$c` and `$p` (`507dde`, `507ded`), which no shipped text uses, and unknown or +//! unresolved tokens stay raw. use super::soft_float; use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog, SpellRangeCatalog}; @@ -55,8 +56,8 @@ fn keyed(ctx: &TokenContext, key: &str, args: &[TokenNumber]) -> Option } /// `"%.1f"` (`0x84f398`). -fn tenths(ctx: &TokenContext, v: f32) -> String { - (ctx.printf)("%.1f", &[TokenNumber::Float(f64::from(v))]) +fn tenths(ctx: &TokenContext, v: impl Into) -> String { + (ctx.printf)("%.1f", &[TokenNumber::Float(v.into())]) } /// `0x6e3130` for the active player (`0x6e318d`: `CGPlayer`'s `[vtbl+0xa8]`, `0x5ea690`). @@ -303,6 +304,42 @@ fn token_value( ) -> Option<(String, f64)> { match letter.to_ascii_lowercase() { 's' | 'm' | 'o' => points_text(letter, slot, d, ctx, scale, level), + 'b' => { + // `507ed4`: `EffectPointsPerComboPoint`, truncated toward zero by `0x40a2b0`. + let v = d.effect_points_per_combo_point[slot].trunc() as i32; + Some((v.to_string(), f64::from(v))) + } + 'f' => { + // `507ff7` / `508025`: the scale times `DmgMultiplier`, exact at the x87's PC_53. + let v = f64::from(scale) * f64::from(d.damage_multiplier[slot]); + if letter == 'F' { + // ±0.5 by the sign, then chopped by `0x40a2b0` (`50802f`-`50804a`). + let n = v.round() as i32; + Some((n.to_string(), f64::from(n))) + } else { + Some((tenths(ctx, v), v)) + } + } + 'i' => { + // `507fc3`: `MaxAffectedTargets` through `"%d"`. + let v = d.max_affected_targets as i32; + Some((v.to_string(), f64::from(v))) + } + 'q' => { + // `50806e`: the slot's `EffectMiscValue` through `"%d"`. + let v = d.effect_misc_value[slot]; + Some((v.to_string(), f64::from(v))) + } + 'u' => { + // `507ef1`: `StackAmount` through `"%d"`. + let v = d.stack_amount as i32; + Some((v.to_string(), f64::from(v))) + } + 'v' => { + // `507f4f`: `MaxTargetLevel` through `"%d"`. + let v = d.max_target_level as i32; + Some((v.to_string(), f64::from(v))) + } 'd' => { let ms = duration_ms(d, ctx); let v = if ms < 0 { 0.0 } else { ms as f64 / 1000.0 }; @@ -548,6 +585,20 @@ mod tests { ) } + /// Every spell-data formatter key needed by a corpus walk; individual formatter tests above + /// pin the reference's unit and spread choices separately. + fn corpus_global(key: &str) -> Option { + global(key).or_else(|| { + if key.starts_with("INT_SPELL_DURATION_") { + Some("%d".into()) + } else if key.starts_with("SPELL_DURATION_") { + Some("%.1f".into()) + } else { + None + } + }) + } + /// `%d`, `%.Nf` and `%%` in order; the tests keep clear of the CRT's rounding ties. fn printf(template: &str, args: &[TokenNumber]) -> String { let mut out = String::new(); @@ -1233,7 +1284,8 @@ mod tests { substitute("as strong as $1234s1 hits", &d, &c), "as strong as 100 hits" ); - assert_eq!(substitute("stacks $u times", &d, &c), "stacks $u times"); + assert_eq!(substitute("stacks $u times", &d, &c), "stacks 0 times"); + assert_eq!(substitute("unknown $j", &d, &c), "unknown $j"); assert_eq!( substitute("Returns you to $z.", &d, &c), "Returns you to Goldshire." @@ -1334,6 +1386,213 @@ mod tests { ), "25 Frost damage every 1 second" ); + // `$f` and `$F` key it too, though their arms (`507ff7`, `508025`) leave it alone. + let multiplier = SpellDisplay { + effect_base_points: [24, 0, 0], + effect_base_dice: [1, 1, 0], + effect_die_sides: [1, 1, 0], + damage_multiplier: [1.0, 1.5, 0.0], + ..Default::default() + }; + assert_eq!( + substitute("$s1 then $F1 $lpoint:points;", &multiplier, &c), + "25 then 1 point" + ); + assert_eq!( + substitute("$s2 then $f2 $lpoint:points;", &multiplier, &c), + "1 then 1.5 points" + ); + } + + /// The field arms read the referenced row, `$b`, `$q`, `$f` and `$F` at the slot digit; `$b`, + /// `$u`, `$v`, `$i` and `$q` print `"%d"` and key the `$l` plural (`507ee0`, `507ef7`, + /// `507f55`, `507fc9`, `508075`). + #[test] + fn field_tokens_read_their_columns_and_plural_counts() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let rows = [ + SpellDisplay { + id: 42, + effect_points_per_combo_point: [0.0, -2.9, 0.0], + damage_multiplier: [0.5, 1.3, -0.25], + effect_misc_value: [135, -1, 0], + max_affected_targets: 4, + stack_amount: 5, + max_target_level: 40, + ..Default::default() + }, + SpellDisplay { + id: 43, + effect_points_per_combo_point: [1.9, 0.0, 0.0], + effect_misc_value: [0, 0, 1], + max_affected_targets: 1, + stack_amount: 1, + max_target_level: 1, + ..Default::default() + }, + SpellDisplay { + id: 44, + max_affected_targets: 0x8000_0000, + stack_amount: u32::MAX, + max_target_level: u32::MAX - 1, + ..Default::default() + }, + ]; + let lookup = |id| rows.iter().find(|r| r.id == id); + let c = ctx(&durations, &radii, &lookup); + let empty = SpellDisplay::default(); + assert_eq!(substitute("$42b2", &empty, &c), "-2"); + assert_eq!(substitute("$42q1 $42Q2", &empty, &c), "135 -1"); + assert_eq!(substitute("$42i $42u $42v", &empty, &c), "4 5 40"); + assert_eq!(substitute("$42I $42U $42V", &empty, &c), "4 5 40"); + assert_eq!(substitute("$*100;42F1", &empty, &c), "50"); + assert_eq!(substitute("$42f2", &empty, &c), "1.3"); + assert_eq!(substitute("$*10;42F3", &empty, &c), "-3"); + assert_eq!( + substitute("$44i $44u $44v", &empty, &c), + "-2147483648 -1 -2" + ); + for token in ["$43b1", "$43q3", "$43i", "$43u", "$43v"] { + assert_eq!( + substitute(&format!("{token} $lone:many;"), &empty, &c), + "1 one", + "{token}" + ); + } + } + + /// `$f` and `$F` (`507ff7`, `508025`) multiply on the x87 at PC_53, so the product of the two + /// floats is exact: rounded to a float first, these would read 1.1 and 3. + #[test] + fn the_multiplier_tokens_multiply_in_double() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let c = ctx(&durations, &radii, &none_lookup); + let d = SpellDisplay { + damage_multiplier: [0.23, 0.833_333_3, 0.0], + ..Default::default() + }; + assert_eq!(substitute("$*5;f1", &d, &c), "1.2"); + assert_eq!(substitute("$*3;F2", &d, &c), "2"); + } + + #[test] + fn shipped_spell_data_tokens_resolve() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let spells = crate::load_spell_catalog(&mut chain).expect("Spell.dbc"); + let durations = crate::load_spell_durations(&mut chain).expect("SpellDuration.dbc"); + let radii = crate::load_spell_radii(&mut chain).expect("SpellRadius.dbc"); + let ranges = crate::load_spell_ranges(&mut chain).expect("SpellRange.dbc"); + let lookup = |id| spells.get(id); + let c = TokenContext { + ranges: Some(&ranges), + global: &corpus_global, + ..ctx(&durations, &radii, &lookup) + }; + for (id, text, want) in [ + (1064, "$*100;F1", "50"), + (10622, "$*100;F1", "50"), + (10623, "$*100;F1", "50"), + (22568, "$f1", "1.0"), + (2006, "$q1", "135"), + (1680, "$i", "4"), + (22959, "$u", "5"), + (453, "$v", "40"), + (14179, "$b1", "20"), + ] { + assert_eq!( + substitute(text, spells.get(id).unwrap(), &c), + want, + "spell {id}" + ); + } + for id in [1064, 10622, 10623] { + let spell = spells.get(id).unwrap(); + assert_eq!(spell.damage_multiplier[0], 0.5, "Chain Heal {id}"); + let description = spell.description.as_deref().unwrap(); + assert!( + description.contains("$*100;F1"), + "Chain Heal {id}: {description}" + ); + assert!( + substitute(description, spell, &c).contains("50%"), + "Chain Heal {id} must show 50%" + ); + } + + // Walk every shipped spell-data token, including other ranks, effect slots, scale + // prefixes and cross-spell references. `$z` needs the player's home area, not Spell.dbc. + let mut seen = [0usize; 26]; + for (id, spell) in spells.iter() { + for description in [ + spell.description.as_deref(), + spell.aura_description.as_deref(), + ] + .into_iter() + .flatten() + { + for (arm, token) in spell_data_tokens_in(description) { + let expanded = substitute(token, spell, &c); + assert_ne!(expanded, token, "spell {id} leaves {token} raw"); + seen[arm] += 1; + } + } + } + for &arm in b"bfiquv" { + assert!( + seen[usize::from(arm - b'a')] > 0, + "missing ${} in {seen:?}", + arm as char + ); + } + assert!( + seen.iter().sum::() >= 50, + "too few shipped spell-data tokens: {seen:?}" + ); + } + + /// Isolate spell-data tokens, including a scale prefix, cross-spell id and one slot digit, + /// bounded as `substitute` bounds them. `$l`, `$g` and `$z` need other context. + fn spell_data_tokens_in(text: &str) -> Vec<(usize, &str)> { + let bytes = text.as_bytes(); + let mut found = Vec::new(); + for start in 0..bytes.len() { + if bytes[start] != b'$' { + continue; + } + let mut end = start + 1; + if matches!(bytes.get(end), Some(b'*' | b'/')) { + let Some(semi) = bytes[end..].iter().position(|&b| b == b';') else { + continue; + }; + end += semi + 1; + } + while bytes.get(end).is_some_and(u8::is_ascii_digit) { + end += 1; + } + let Some(letter) = bytes.get(end).copied().map(|b| b.to_ascii_lowercase()) else { + continue; + }; + if !b"abdefhimnoqrstuvx".contains(&letter) { + continue; + } + end += 1; + if bytes.get(end).is_some_and(u8::is_ascii_digit) { + end += 1; + } + found.push((usize::from(letter - b'a'), &text[start..end])); + } + found + } + + #[test] + fn spell_data_token_scan_keeps_scale_reference_and_slot() { + assert_eq!( + spell_data_tokens_in("Each jump is $*100;F1%, stacks $22959u times; $s1 is separate."), + vec![(5, "$*100;F1"), (20, "$22959u"), (18, "$s1")] + ); } /// `$r` (`507d5b`) reads row 1 for an index at or below 1 and prints one decimal through