diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index c9da76c2..3a006376 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -100,3 +100,92 @@ impl Plugin for SpellPlugin { net::register(app); } } + +/// The player's skill in `spell_id`'s own line, the input of every per-level term: `0x5ea690` +/// hops spell to SkillLineAbility line (`0x6de040`), then `0x5ea520` reads that line's +/// `PLAYER_SKILL_INFO` slot. Every missing input reads 0, like the reference's null paths. +pub(crate) fn spell_skill_value( + me: Option<&crate::net::ObjectStore>, + skill_lines: Option<&benilla_formats::SkillLineCatalog>, + spell_id: u32, +) -> u32 { + let Some(line) = skill_lines.and_then(|c| c.spell_to_line(spell_id)) else { + return 0; + }; + let Some(store) = me else { return 0 }; + line_skill_value( + (0..benilla_protocol::messages::PLAYER_SKILL_SLOTS) + .filter_map(|slot| store.0.player_skill(slot)), + line, + ) +} + +/// `0x5ea520`'s sum on the line's first slot (`0x5ea3f0`): the value, plus the bonus word's high +/// half read unsigned when the value is positive (`0x5ea571`-`0x5ea580`), plus its low half read +/// signed (`0x5ea5ac`-`0x5ea5ba`), floored at 0 (`0x5ea5bc`). +fn line_skill_value( + slots: impl Iterator, + line: u32, +) -> u32 { + for s in slots { + if u32::from(s.skill_id) == line { + let mut v = i32::from(s.value); + if v > 0 { + v += i32::from(s.perm_bonus as u16); + } + return (v + i32::from(s.temp_bonus)).max(0) as u32; + } + } + 0 +} + +/// Each skill slot's line and effective value, all [`spell_skill_value`] reads: a text built +/// against one snapshot is rebuilt when the next differs. +pub(crate) type SkillSnapshot = + [(u16, u32); benilla_protocol::messages::PLAYER_SKILL_SLOTS as usize]; + +/// The [`SkillSnapshot`] of the player; all zeros without one. +pub(crate) fn skill_snapshot(me: Option<&crate::net::ObjectStore>) -> SkillSnapshot { + std::array::from_fn(|slot| { + me.and_then(|s| s.0.player_skill(slot as u8)) + .map_or((0, 0), |s| { + ( + s.skill_id, + line_skill_value(std::iter::once(s), s.skill_id.into()), + ) + }) + }) +} + +#[cfg(test)] +mod tests { + use benilla_protocol::messages::PlayerSkillSlot; + + fn slot(value: u16, temp_bonus: i16, perm_bonus: i16) -> PlayerSkillSlot { + PlayerSkillSlot { + skill_id: 43, + step: 0, + value, + max: 300, + temp_bonus, + perm_bonus, + } + } + + /// The high bonus half counts only over a positive value, and unsigned; the low half is + /// signed and always counts; the sum floors at 0. + #[test] + fn the_skill_value_reads_its_bonus_halves_as_the_reference_does() { + let value = |s: PlayerSkillSlot| super::line_skill_value(std::iter::once(s), 43); + assert_eq!(value(slot(100, 0, 5)), 105); + assert_eq!(value(slot(0, 0, 5)), 0); + assert_eq!(value(slot(0, 7, 5)), 7); + assert_eq!(value(slot(100, -20, 0)), 80); + assert_eq!(value(slot(10, -20, 0)), 0); + assert_eq!(value(slot(1, 0, -1)), 65536); + assert_eq!( + super::line_skill_value(std::iter::once(slot(100, 0, 0)), 44), + 0 + ); + } +} diff --git a/crates/benilla-app/src/target/lock.rs b/crates/benilla-app/src/target/lock.rs index 70586e7c..85ceb91e 100644 --- a/crates/benilla-app/src/target/lock.rs +++ b/crates/benilla-app/src/target/lock.rs @@ -13,7 +13,7 @@ //! ([`benilla_formats::SpellDisplay::open_lock_skill`]) reaches the requirement (`0x5f850f`), //! `Skill[i]` or `GAMEOBJECT_LEVEL × 5` when that is zero (`0x5f84be`). The value's level term is //! the player's skill in the spell's own line (`0x6e384d → 0x6e3130 → [vtbl+0xa8] = 0x5ea690`, -//! [`spell_skill_value`]), not the character level. +//! [`crate::spell::spell_skill_value`]), not the character level. use std::collections::BTreeSet; @@ -32,7 +32,8 @@ pub(crate) struct GoLockInputs<'w, 's> { pub(crate) locks: Option>, pub(crate) lock_types: Option>, pub(crate) spells: Option>, - /// The spell to skill-line hop of [`spell_skill_value`]; absent, the skill reads 0. + /// The spell to skill-line hop of [`crate::spell::spell_skill_value`]; absent, the skill reads + /// 0. pub(crate) skill_lines: Option>, pub(crate) items: Res<'w, crate::items::Items>, /// The object index the key-item scan walks the bags through. @@ -140,7 +141,7 @@ pub(crate) fn resolve_lock( } // `0x5f84f8`: set on the LockType match, before the value test. matched_spell.get_or_insert(id); - let skill = spell_skill_value(me, skill_lines, id); + let skill = crate::spell::spell_skill_value(me, skill_lines, id); let provides = spell.open_lock_skill(skill).unwrap_or(0); if provides >= required_skill(slot, go.level) { return LockOutcome::OpenBySpell(id); @@ -166,40 +167,6 @@ pub(crate) fn resolve_lock( } } -/// The player's skill in `spell_id`'s own line, the opener value's level term: `0x5ea690` hops -/// spell to SkillLineAbility line (`0x6de040`), then `0x5ea520` reads that line's -/// `PLAYER_SKILL_INFO` slot. Every missing input reads 0, like the reference's null paths. -fn spell_skill_value( - me: Option<&ObjectStore>, - skill_lines: Option<&benilla_formats::SkillLineCatalog>, - spell_id: u32, -) -> u32 { - let Some(line) = skill_lines.and_then(|c| c.spell_to_line(spell_id)) else { - return 0; - }; - let Some(store) = me else { return 0 }; - line_skill_value( - (0..benilla_protocol::messages::PLAYER_SKILL_SLOTS) - .filter_map(|slot| store.0.player_skill(slot)), - line, - ) -} - -/// `0x5ea520`'s sum on the line's first slot: `value + temp_bonus + perm_bonus` -/// (`0x5ea56d`..`0x5ea580`), floored at 0 since the bonuses are signed. -fn line_skill_value( - slots: impl Iterator, - line: u32, -) -> u32 { - for s in slots { - if u32::from(s.skill_id) == line { - let v = i32::from(s.value) + i32::from(s.temp_bonus) + i32::from(s.perm_bonus); - return v.max(0) as u32; - } - } - 0 -} - /// `0x5f8260`, the targeting cursor's question for the pending spell (`[0xceac58]`) over a /// GameObject, from the object dispatcher `0x4828d0` through `0x6e6460`. Unlike [`resolve_lock`] /// it matches the spell's own `OPEN_LOCK` effects against each slot's `Index` and compares no skill diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index ab36c5ae..fb55bcf2 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -129,6 +129,9 @@ fn feed_craft( durations: &spells.durations, radii: &spells.radii, ranges: Some(&spells.ranges), + skill: &|id| { + crate::spell::spell_skill_value(Some(store), Some(&skill_lines.catalog), id) + }, lookup: &|id| spells.catalog.get(id), mods: Some(&*spell_mods), unmodified_points: false, diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index f072d675..e36d7777 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -57,6 +57,8 @@ pub(crate) struct SlotGuids { fn spell_desc_text( spells: Option<&crate::ui_action::Spells>, id: u32, + // The player's skill in a spell's line, the `$`-tokens' level (`TokenContext::skill`). + skill: &dyn Fn(u32) -> u32, home_area: Option<&str>, mods: Option<&crate::spell::SpellModifiers>, // The VM's strings for the keyed `$d`/`$s` tokens. @@ -71,6 +73,7 @@ fn spell_desc_text( durations: &sp.durations, radii: &sp.radii, ranges: Some(&sp.ranges), + skill, lookup: &|i| sp.catalog.get(i), mods: mods.map(|m| m as &dyn benilla_formats::SpellMods), unmodified_points: false, @@ -131,6 +134,7 @@ fn template_view( t: &ItemInfo, spells: Option<&crate::ui_action::Spells>, mods: Option<&crate::spell::SpellModifiers>, + skill: &dyn Fn(u32) -> u32, skill_lines: Option<&benilla_formats::SkillLineCatalog>, home_area: Option<&str>, factions: Option<&benilla_formats::FactionCatalog>, @@ -146,7 +150,7 @@ fn template_view( .and_then(|s| s.catalog.get(id)) .map(|sd| sd.name.clone()) }; - let spell_text = |id: u32| spell_desc_text(spells, id, home_area, mods, get); + let spell_text = |id: u32| spell_desc_text(spells, id, skill, home_area, mods, get); benilla_ui::script::ItemTemplateView { name: t.name.clone(), quality: t.quality, @@ -245,20 +249,27 @@ pub(super) fn feed_item_sets( spells: Option>, spell_mods: Res, skill_lines: Option>, + self_q: Query<&ObjectStore, With>, mut pending: Local< crate::ui_script::VmMemo>, >, mut mod_sensitive: Local>>, + mut last_skills: Local>>, ) { let Some(mut script) = script else { return; }; let pending = pending.get(&script); let mod_sensitive = mod_sensitive.get(&script); + let me = self_q.single().ok(); for id in script.take_item_set_asks() { pending.entry(id).or_default(); } - if spell_mods.is_changed() { + // The `$`-tokens' per-level terms follow the player's skills. + let skills = Some(crate::spell::skill_snapshot(me)); + let skills_changed = *last_skills.get(&script) != skills; + *last_skills.get(&script) = skills; + if spell_mods.is_changed() || skills_changed { for &id in mod_sensitive.iter() { pending.entry(id).or_default(); } @@ -271,6 +282,7 @@ pub(super) fn feed_item_sets( }; let spell_res = spells.as_deref(); let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog); + let skill = |id| crate::spell::spell_skill_value(me, skill_catalog, id); let mut done: Vec = Vec::new(); let mut push: Vec<(u32, benilla_ui::script::ItemSetView)> = Vec::new(); // Its borrow of the VM ends before the push, which needs the VM mutably. @@ -296,7 +308,7 @@ pub(super) fn feed_item_sets( .bonuses .iter() .filter_map(|&(n, spell)| { - spell_desc_text(spell_res, spell, None, Some(&spell_mods), &global) + spell_desc_text(spell_res, spell, &skill, None, Some(&spell_mods), &global) .map(|desc| (n, desc)) }) .collect(), @@ -363,7 +375,11 @@ pub(super) fn feed_item_stats( mut items: ResMut, commands: Res, spells: Option>, - spell_mods: Res, + // The caster the `$`-tokens read: its spell modifiers and its skills. + (spell_mods, self_q): ( + Res, + Query<&ObjectStore, With>, + ), skill_lines: Option>, // The `$z` token: the bind point's area (`SMSG_BINDPOINTUPDATE`), named through `AreaTable`. home_bind: Option>, @@ -376,6 +392,7 @@ pub(super) fn feed_item_stats( mut pending: Local>>, mut mod_sensitive: Local>>, mut last_home: Local>>, + mut last_skills: Local>>, ) { let Some(mut script) = script else { return; @@ -383,6 +400,11 @@ pub(super) fn feed_item_stats( let pending = pending.get(&script); let mod_sensitive = mod_sensitive.get(&script); let last_home = last_home.get(&script); + let me = self_q.single().ok(); + // The `$`-tokens' per-level terms follow the player's skills. + let skills = Some(crate::spell::skill_snapshot(me)); + let skills_changed = *last_skills.get(&script) != skills; + *last_skills.get(&script) = skills; // `GetBindLocation()`'s push, here so it and the `$z` token share one name, and ahead of the // pending gate below: the bind point can arrive while the feed idles. let home_area: Option<&str> = home_bind @@ -399,7 +421,7 @@ pub(super) fn feed_item_stats( pending.extend(items.take_fresh()); pending.extend(script.take_item_stat_asks()); - if spell_mods.is_changed() { + if spell_mods.is_changed() || skills_changed { pending.extend(mod_sensitive.iter().copied()); } if pending.is_empty() { @@ -407,6 +429,7 @@ pub(super) fn feed_item_stats( } let spell_res = spells.as_deref(); let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog); + let skill = |id| crate::spell::spell_skill_value(me, skill_catalog, id); let ready: Vec = pending .iter() .copied() @@ -438,6 +461,7 @@ pub(super) fn feed_item_stats( &t, spell_res, Some(&spell_mods), + &skill, skill_catalog, home_area, factions.as_deref().map(|f| f.catalog()), @@ -1912,7 +1936,7 @@ mod tests { "spell {id} has a name — which is exactly what must NOT leak into the tooltip" ); assert_eq!( - super::spell_desc_text(Some(&spells), id, None, None, &no_strings), + super::spell_desc_text(Some(&spells), id, &|_| 0, None, None, &no_strings), None, "spell {id} ({:?}) has no description, so the reference prints no trigger line", d.name @@ -1920,7 +1944,7 @@ mod tests { } // The control: Fireball (133), described, still yields its line. - let fireball = super::spell_desc_text(Some(&spells), 133, None, None, &no_strings) + let fireball = super::spell_desc_text(Some(&spells), 133, &|_| 0, None, None, &no_strings) .expect("a described spell still yields its line"); assert!( fireball.contains("damage"), diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index 865e8e92..9ab04b95 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -55,6 +55,8 @@ struct ViewCtx<'a, 'w, 's> { items: &'a mut Items, commands: &'a NetCommands, sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>, + /// The spell-to-line hop the `$`-tokens' skill level reads ([`crate::spell::spell_skill_value`]). + skill_lines: Option<&'a benilla_formats::SkillLineCatalog>, /// The talent spell-modifier tables: the cost cell shows the modified cost (`power_cost`). spell_mods: &'a crate::spell::SpellModifiers, /// The VM's `GlobalStrings.lua`, where the keyed cells and the `$` tokens' templates resolve. @@ -83,6 +85,7 @@ fn spell_tooltip_view( durations: &spells.durations, radii: &spells.radii, ranges: Some(&spells.ranges), + skill: &|id| crate::spell::spell_skill_value(vctx.store, vctx.skill_lines, id), lookup: &|id| spells.catalog.get(id), mods: Some(vctx.spell_mods), unmodified_points: false, @@ -327,6 +330,8 @@ struct SpellFeedMemory { home: Option, /// The form the required-form line's colour follows (`0x52f1e3`). form: Option, + /// The skills the descriptions' per-level terms scale by ([`crate::spell::skill_snapshot`]). + skills: Option, /// The 19 worn-slot guids the required-item line's colour follows (`0x5f0c50`). worn: Option<[u64; 19]>, /// The block, dodge, parry and crit percentages as bit patterns (`0x52f5b1`). @@ -364,11 +369,12 @@ fn feed_spell_tooltips( lookups: ( Option>, Res, + Option>, ), commands: Res, mut memory: Local>, ) { - let (sub_classes, spell_mods) = &lookups; + let (sub_classes, spell_mods, skill_lines) = &lookups; let SpellTooltipSources { spells, trainer_subjects, @@ -443,6 +449,12 @@ fn feed_spell_tooltips( wanted.extend(memory.pushed.drain()); } let self_store = self_q.single().ok(); + // So does a skill change: the `$`-tokens' per-level terms scale by the spell's line. + let skills = crate::spell::skill_snapshot(self_store); + if memory.skills != Some(skills) { + memory.skills = Some(skills); + wanted.extend(memory.pushed.drain()); + } // So do the worn set and, below, the reagents on show. let worn = self_store.map(|s| std::array::from_fn(|i| s.0.player_inv_slot(i as u8).unwrap_or(0))); @@ -512,6 +524,7 @@ fn feed_spell_tooltips( items: &mut items, commands: &commands, sub_classes: sub_classes.as_deref().map(|c| &c.0), + skill_lines: skill_lines.as_deref().map(|s| &s.catalog), spell_mods, get: &get, }; diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index e752061c..bed1986a 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -13,6 +13,8 @@ struct TestCtx { get: Box, /// Empty by default; modifier tests populate it explicitly. spell_mods: crate::spell::SpellModifiers, + /// Absent by default: every spell's skill level reads 0. + skill_lines: Option, } type Getter = dyn Fn(&str) -> Option; @@ -28,6 +30,7 @@ impl TestCtx { _rx: rx, get: Box::new(move |key| benilla_ui::strings::global(vm.lua(), key)), spell_mods: crate::spell::SpellModifiers::default(), + skill_lines: None, } } @@ -68,6 +71,7 @@ impl TestCtx { items: &mut self.items, commands: &self.commands, sub_classes, + skill_lines: self.skill_lines.as_ref(), spell_mods: &self.spell_mods, get: self.get.as_ref(), } @@ -85,6 +89,51 @@ fn empty_player() -> ObjectStore { )])) } +/// Battle Shout rank 1's `$s1` (14 + 1d1, 0.5 a level from 1, cap 11) scales by the player's +/// skill in its line over 5 (`0x6e3130`), not the character level: a class line sits at level +/// × 5 (vmangos `Player::UpdateSkillsForLevel`), so levels 1, 11 and 60 read 15, 20 and 20. +#[test] +fn battle_shout_description_scales_by_the_skill_level() { + use benilla_protocol::messages::FIELD_PLAYER_SKILL_INFO_1_1; + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let spells = Spells { + catalog: benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"), + forms: benilla_formats::load_shapeshift_forms(&mut chain).expect("forms"), + ranges: benilla_formats::load_spell_ranges(&mut chain).expect("ranges"), + cast_times: benilla_formats::load_spell_cast_times(&mut chain).expect("cast times"), + durations: benilla_formats::load_spell_durations(&mut chain).expect("durations"), + radii: benilla_formats::load_spell_radii(&mut chain).expect("radii"), + }; + let skill_lines = benilla_formats::load_skill_line_catalog(&mut chain).expect("skill lines"); + let line = skill_lines + .spell_to_line(6673) + .expect("Battle Shout's line"); + let mut t = TestCtx::new(); + t.skill_lines = Some(skill_lines); + let mut objs = no_objects(); + let objects = objs.get(); + // (character level, skill value, attack power); the last is a level 60 at skill 5. + for (level, skill, ap) in [(1, 5, 15), (11, 55, 20), (60, 300, 20), (60, 5, 15)] { + let store = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (34, level), + (FIELD_PLAYER_SKILL_INFO_1_1, line), + (FIELD_PLAYER_SKILL_INFO_1_1 + 1, skill | 300 << 16), + ])); + let view = spell_tooltip_view( + 6673, + &spells, + &mut t.ctx_for(&objects, 0, None, Some(&store)), + ) + .expect("Battle Shout view"); + assert!( + view.description.contains(&format!("by {ap}.")), + "level {level}, skill {skill}: {}", + view.description + ); + } +} + /// Fireball rank 1 (133) end to end: description 138, cast index 18 (1500 ms), duration 30. #[test] fn fireball_view_on_real_data() { diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index fefe3e27..2f60e817 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -13,6 +13,9 @@ pub struct OpenLock { /// One spell's `Spell.dbc` row, as the display, tooltip and cast gates read it. pub struct SpellDisplay { + /// `ID` (column 0): the row's own id, which `GetEffectPoints` hands the level lookup + /// `0x6e3130` (`0x6e384b`). + pub id: u32, pub name: String, /// `SpellNameSubtext` enUS (column 129): "Rank N", even "Rank 1", or a word such as "Racial". pub rank: Option, @@ -192,6 +195,7 @@ pub struct SpellDisplay { impl Default for SpellDisplay { fn default() -> Self { SpellDisplay { + id: 0, name: String::new(), rank: None, icon: None, @@ -274,6 +278,18 @@ impl Default for SpellDisplay { } impl SpellDisplay { + /// The level the effect values, cast time, cost and duration scale by for a caster whose + /// skill in this spell's line is `skill_value` (`0x6e3130`): the skill capped at + /// `maxLevel × 5` unless `maxLevel` is 0 (`0x5ea6dc`-`0x5ea6ea`), over 5 (`0x6e3195`). + pub fn skill_level(&self, skill_value: u32) -> u32 { + let capped = if self.max_level > 0 { + skill_value.min(self.max_level.saturating_mul(5)) + } else { + skill_value + }; + capped / 5 + } + /// The `LockType` this spell opens, if any. pub fn open_lock_type(&self) -> Option { self.open_lock.map(|o| o.lock_type) @@ -285,14 +301,10 @@ impl SpellDisplay { /// the flat terms. pub fn open_lock_skill(&self, skill_value: u32) -> Option { let e = self.open_lock?.effect; - // The skill capped at maxLevel·5, 0 uncapped (`0x5ea6e3`), integer /5 (`0x6e3195`), less - // baseLevel floored at 0 (read at `0x6e3854`, subtracted at `0x6e385b`-`0x6e385f`). - let capped = if self.max_level > 0 { - skill_value.min(self.max_level * 5) - } else { - skill_value - }; - let delta = (capped / 5).saturating_sub(self.base_level) as f32; + // Less baseLevel floored at 0 (read at `0x6e3854`, subtracted at `0x6e385b`-`0x6e385f`). + let delta = self + .skill_level(skill_value) + .saturating_sub(self.base_level) as f32; let v = self.effect_base_points[e] as f32 + self.effect_base_dice[e] as f32 + self.effect_dice_per_level[e] as f32 * delta diff --git a/crates/benilla-formats/src/spells/duration.rs b/crates/benilla-formats/src/spells/duration.rs index d0d78cbe..f83e8359 100644 --- a/crates/benilla-formats/src/spells/duration.rs +++ b/crates/benilla-formats/src/spells/duration.rs @@ -1,7 +1,7 @@ //! `SpellDuration.dbc`: the duration a spell's `DurationIndex` //! ([`crate::spells::SpellDisplay::duration_index`]) resolves to in `Spell_C::GetSpellDuration` //! (`0x6ea000`), before spell-mod op 1 (`SPELLMOD_DURATION`). All three columns are signed: a few -//! rows (427) pair a negative base with a per-level term this crate does not evaluate. +//! rows (427, Resurrection Sickness's) pair a negative base with a per-level term. use std::collections::HashMap; @@ -16,7 +16,8 @@ use crate::dbc::{i32_at, parse, u32_at}; pub struct SpellDuration { /// The duration a level-independent tooltip shows; -1 is permanent. pub base_ms: i32, - /// Added per caster level above the spell's `BaseLevel`; 0 for most rows. + /// Added per level of the spell's skill level (`0x6e3130`) above its `baseLevel`, below it + /// taken off (`0x6ea049`); 0 for most rows. pub per_level_ms: i32, /// The ceiling the level-scaled duration clamps to. pub max_ms: i32, @@ -50,6 +51,12 @@ impl SpellDurationCatalog { ); } + /// Test-only seeding of a whole row. + #[cfg(test)] + pub(crate) fn insert_row_for_tests(&mut self, index: u32, row: SpellDuration) { + self.durations.insert(index, row); + } + pub fn get(&self, index: u32) -> Option<&SpellDuration> { self.durations.get(&index) } diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index 1a558bf3..c1913b85 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -475,6 +475,7 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { spells.insert( id, SpellDisplay { + id, name, rank, icon, diff --git a/crates/benilla-formats/src/spells/soft_float.rs b/crates/benilla-formats/src/spells/soft_float.rs index 8619b1d4..c6640715 100644 --- a/crates/benilla-formats/src/spells/soft_float.rs +++ b/crates/benilla-formats/src/spells/soft_float.rs @@ -124,6 +124,16 @@ pub(crate) fn int_to_float(v: i32) -> f32 { f32::from_bits(from_int(v)) } +/// `0x760e20` over floats. +pub(crate) fn add_f32(a: f32, b: f32) -> f32 { + f32::from_bits(add(a.to_bits(), b.to_bits())) +} + +/// `0x760be0` over floats. +pub(crate) fn mul_f32(a: f32, b: f32) -> f32 { + f32::from_bits(mul(a.to_bits(), b.to_bits())) +} + /// `GetEffectPoints`' tail (`6e39ee`-`6e3a65`): to the nearest 1/128, as `floor(x·128 + 0.5) / /// 128` with the ×128 and ÷128 done on the exponent field and the add and floor in the soft /// arithmetic (`0x761160`, its 0.5 at `0xcf0b2c`). diff --git a/crates/benilla-formats/src/spells/tokens.rs b/crates/benilla-formats/src/spells/tokens.rs index baf51c38..04c77d01 100644 --- a/crates/benilla-formats/src/spells/tokens.rs +++ b/crates/benilla-formats/src/spells/tokens.rs @@ -1,9 +1,7 @@ //! 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 are -//! the flat term: the reference's per-level terms (`DicePerLevel·max(0, casterLevel − baseLevel)` -//! on the dice, and `RealPointsPerLevel`) are not applied. 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`. 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. use super::soft_float; use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog, SpellRangeCatalog}; @@ -29,6 +27,11 @@ pub struct TokenContext<'a> { pub durations: &'a SpellDurationCatalog, pub radii: &'a SpellRadiusCatalog, pub ranges: Option<&'a SpellRangeCatalog>, + /// `0x5ea520` behind `0x6de040`: the active player's skill in a spell's `SkillLineAbility` + /// line, bonuses included, by spell id; 0 without a player or a line. Every per-level term + /// scales by the level derived from it ([`SpellDisplay::skill_level`]), not the character + /// level. + pub skill: &'a dyn Fn(u32) -> u32, pub lookup: &'a dyn Fn(u32) -> Option<&'a SpellDisplay>, /// The caster's live spell-modifier tables; absent for contexts without a caster. pub mods: Option<&'a dyn SpellMods>, @@ -55,6 +58,11 @@ fn tenths(ctx: &TokenContext, v: f32) -> String { (ctx.printf)("%.1f", &[TokenNumber::Float(f64::from(v))]) } +/// `0x6e3130` for the active player (`0x6e318d`: `CGPlayer`'s `[vtbl+0xa8]`, `0x5ea690`). +fn skill_level(ctx: &TokenContext, d: &SpellDisplay) -> u32 { + d.skill_level((ctx.skill)(d.id)) +} + /// `0x6e3b80`'s verdict on an effect: whether its points round to whole numbers (its out-byte) /// and whether it takes a damage op (its return). fn classify(effect: u32, aura: u32) -> (bool, bool) { @@ -70,18 +78,41 @@ fn classify(effect: u32, aura: u32) -> (bool, bool) { } } -/// `GetEffectPoints 0x6e3800`: the effect's `(min, max)` as the soft floats it returns. The -/// bounds start as the flat term, `BasePoints + BaseDice` and `BasePoints + DieSides·BaseDice` -/// (the per-level terms at `6e3863`-`6e391a` are not applied). Unless the context is the aura -/// tooltip's, op 8, then a damage effect's op (22 for aura 3, else 0), then the aura's -/// ([`aura_op`]) apply to each bound through `0x6e6c30`. The tail quantizes both to 1/128, and a -/// rounding effect floors the minimum and ceils the maximum (`6e3a67`). -fn effect_points(d: &SpellDisplay, slot: usize, ctx: &TokenContext) -> (f32, f32) { +/// `GetEffectPoints 0x6e3800`: the effect's `(min, max)` as the soft floats it returns. A `level` +/// of 0 takes the spell's own ([`skill_level`], `6e3841`-`6e3852`); `Δ` is it less `baseLevel` +/// when that is positive, floored at 0 (`6e3854`-`6e3861`). With `n` the dice, the integer +/// `BaseDice + DicePerLevel·Δ`, the bounds start as `BasePoints + n` and `BasePoints + +/// DieSides·n` (`6e3863`, `6e38bb`), each made a soft float plus `RealPointsPerLevel × Δ` +/// through `0x760be0` and `0x760e20`. Unless the context is the aura tooltip's, op 8, then a +/// damage effect's op (22 for aura 3, else 0), then the aura's ([`aura_op`]) apply to each bound +/// through `0x6e6c30`. The tail quantizes both to 1/128, and a rounding effect floors the minimum +/// and ceils the maximum (`6e3a67`). +fn effect_points(d: &SpellDisplay, slot: usize, ctx: &TokenContext, level: u32) -> (f32, f32) { + let level = if level == 0 { + skill_level(ctx, d) + } else { + level + } as i32; + let base_level = d.base_level as i32; + let delta = if base_level > 0 { + level.wrapping_sub(base_level) + } else { + level + } + .max(0); let base = d.effect_base_points[slot]; - let dice = d.effect_base_dice[slot]; + let dice = + d.effect_base_dice[slot].wrapping_add(d.effect_dice_per_level[slot].wrapping_mul(delta)); let sides = d.effect_die_sides[slot]; - let mut min = soft_float::int_to_float(base.wrapping_add(dice)); - let mut max = soft_float::int_to_float(base.wrapping_add(sides.wrapping_mul(dice))); + let real = soft_float::mul_f32( + d.effect_real_points_per_level[slot], + soft_float::int_to_float(delta), + ); + let mut min = soft_float::add_f32(soft_float::int_to_float(base.wrapping_add(dice)), real); + let mut max = soft_float::add_f32( + soft_float::int_to_float(base.wrapping_add(sides.wrapping_mul(dice))), + real, + ); let aura = d.effect_apply_aura[slot]; let (rounds, damage) = classify(d.effects[slot], aura); if let Some(mods) = ctx.mods.filter(|_| !ctx.unmodified_points) { @@ -121,23 +152,31 @@ fn modify_float(ctx: &TokenContext, d: &SpellDisplay, op: u8, value: f32) -> f32 ctx.mods.map_or(value, |m| m.apply_float(d, op, value)) } -/// A spell's duration in ms, flat term only. `GetSpellDuration 0x6ea000` applies op 1 after -/// resolving and capping the DBC row, unless its flag says not to (`6ea064`); `$d` and `$o` both -/// call it. -fn duration_ms(d: &SpellDisplay, ctx: &TokenContext) -> Option { - let row = ctx.durations.get(d.duration_index)?; - let resolved = row.base_ms.min(row.max_ms); - Some(i64::from(if ctx.unmodified_points { +/// A spell's duration in ms, `GetSpellDuration 0x6ea000`, which `$d` and `$o` both call: 0 with +/// no row (`6ea032`), else the row's base plus its per-level term times the spell's own level ([`skill_level`], `6ea041`) +/// less `baseLevel`, subtracted and multiplied unfloored (`6ea046`-`6ea053`), capped at the row's +/// maximum (`6ea055`), then op 1 unless its flag says not to (`6ea064`). +fn duration_ms(d: &SpellDisplay, ctx: &TokenContext) -> i64 { + let Some(row) = ctx.durations.get(d.duration_index) else { + return 0; + }; + let levels = (skill_level(ctx, d) as i32).wrapping_sub(d.base_level as i32); + let resolved = row + .base_ms + .wrapping_add(levels.wrapping_mul(row.per_level_ms)) + .min(row.max_ms); + i64::from(if ctx.unmodified_points { resolved } else { modify_int(ctx, d, 1, resolved) - })) + }) } -/// The duration formatter at `0x52f980` selects the integer ladder (`0x52fa50`) for whole -/// units and the floating ladder (`0x52fbd0`) for fractional units. Both use the largest unit. +/// The `$d` text: no positive duration is `SPELL_DURATION_UNTIL_CANCELLED` (`507cda`); else the +/// formatter at `0x52f980` selects the integer ladder (`0x52fa50`) for whole units and the +/// floating ladder (`0x52fbd0`) for fractional units. Both use the largest unit. fn duration_text(ms: i64, ctx: &TokenContext) -> Option { - if ms < 0 { + if ms <= 0 { return keyed(ctx, "SPELL_DURATION_UNTIL_CANCELLED", &[]); } let (unit, unit_ms) = if ms < 60_000 { @@ -191,18 +230,15 @@ fn points_text( d: &SpellDisplay, ctx: &TokenContext, scale: f32, + level: u32, ) -> Option<(String, f64)> { // The expander multiplies both by its integer argument here (`5078d1`), which is 1 but in // the aura tooltip's stack count; no caller passes a count. - let (mut min, mut max) = effect_points(d, slot, ctx); + let (mut min, mut max) = effect_points(d, slot, ctx, level); if letter.eq_ignore_ascii_case(&'o') { let amplitude = d.effect_amplitude[slot] as i32; let period = if amplitude == 0 { 5000 } else { amplitude }; - let duration = if period > 0 { - duration_ms(d, ctx).unwrap_or(0) - } else { - 0 - }; + let duration = if period > 0 { duration_ms(d, ctx) } else { 0 }; (min, max) = if duration > 0 { let over = |v: f32| (duration as f64 * f64::from(v) / f64::from(period)) as f32; (over(min), over(max)) @@ -262,11 +298,12 @@ fn token_value( d: &SpellDisplay, ctx: &TokenContext, scale: f32, + level: u32, ) -> Option<(String, f64)> { match letter.to_ascii_lowercase() { - 's' | 'm' | 'o' => points_text(letter, slot, d, ctx, scale), + 's' | 'm' | 'o' => points_text(letter, slot, d, ctx, scale, level), 'd' => { - let ms = duration_ms(d, ctx)?; + let ms = duration_ms(d, ctx); let v = if ms < 0 { 0.0 } else { ms as f64 / 1000.0 }; Some((duration_text(ms, ctx)?, v)) } @@ -372,6 +409,9 @@ fn atoi(bytes: &[u8]) -> i32 { /// Substitute every `$`-token in `text` against `spell`. pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> String { + // `0x5075f0`'s level: its caller's, which only the player-buff tooltip passes (the aura's + // `AURALEVELS` byte, `0x532bc3`, not built), else the expanded spell's own (`50764a`). + let level = skill_level(ctx, spell); let bytes = text.as_bytes(); let mut out = String::with_capacity(text.len()); let mut i = 0; @@ -446,17 +486,19 @@ pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> Strin } else { 0 }; - let target: &SpellDisplay = match ref_spell { + // A cross-spell token caps the level at its row's positive `maxLevel` (`507805`-`507817`). + let (target, level): (&SpellDisplay, u32) = match ref_spell { Some(id) => match (ctx.lookup)(id) { - Some(s) => s, + Some(s) if s.max_level > 0 => (s, level.min(s.max_level)), + Some(s) => (s, level), None => { out.push_str(&text[start..i]); continue; } }, - None => spell, + None => (spell, level), }; - match token_value(letter, slot, target, ctx, scale) { + match token_value(letter, slot, target, ctx, scale, level) { Some((sub, val)) => { // Deviation: every token keys the `$l` plural. The reference's `$a`, `$d`, `$t`, // `$e`, `$c`, `$p`, `$f`, `$F` and `$z` arms never write `[0xbe0b84]`, so its `$l` @@ -573,6 +615,7 @@ mod tests { durations, radii, ranges: None, + skill: &|_| 0, lookup, mods: None, unmodified_points: false, @@ -616,6 +659,7 @@ mod tests { (4, 7_200_000), (5, 172_800_000), (6, -1), + (7, 0), ] { durations.insert_for_tests(idx, ms); } @@ -636,6 +680,9 @@ mod tests { assert_eq!(d(4), "<2hrs>", "but two take the _P1 twin"); assert_eq!(d(5), "<2days>", "the days arm the ladder used to lack"); assert_eq!(d(6), ""); + // No positive duration, and no row, which `0x6ea000` reads as 0 (`507cda`). + assert_eq!(d(7), ""); + assert_eq!(d(8), ""); } #[test] @@ -973,6 +1020,181 @@ mod tests { } } + /// The per-level terms scale by the skill level (`0x6e3130`): the skill capped at + /// `maxLevel × 5`, over 5 in integers, less `baseLevel` floored at 0. Battle Shout rank 1 + /// (14 + 1d1, 0.5 a level from 1, cap 11), Power Word: Shield rank 1 (43 + 1d1, 0.8 from 6, + /// cap 11), Fireball rank 1 (13 + 1d9, 0.6 from 1, cap 5, a rounding effect). + #[test] + fn per_level_terms_scale_by_the_skill_level_from_real_spells() { + 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 = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let lookup = |id| spells.get(id); + let skill = std::cell::Cell::new(0); + let skill_of = |_| skill.get(); + let c = TokenContext { + skill: &skill_of, + ..ctx(&durations, &radii, &lookup) + }; + let at = |value: u32, id: u32| { + skill.set(value); + substitute("$s1", spells.get(id).unwrap(), &c) + }; + // Levels 1, 11 and 60 at a class line's level × 5, and the cap at 11. + for (skill, expected) in [(0, "15"), (5, "15"), (55, "20"), (300, "20")] { + assert_eq!(at(skill, 6673), expected, "Battle Shout at skill {skill}"); + } + assert_eq!(at(300, 17), "48"); + // Level 3 is under baseLevel 6: no negative term. + assert_eq!(at(15, 17), "44"); + assert_eq!(at(0, 133), "<14..22>"); + // Skill 24 is level 4, not 4.8: 14 + 1.8 floored, 22 + 1.8 ceiled. + assert_eq!(at(24, 133), "<15..24>"); + // 14 + 2.4 floored, 22 + 2.4 ceiled (`6e3a67`). + assert_eq!(at(300, 133), "<16..25>"); + } + + /// A `baseLevel` at or below 0 is not subtracted (`6e3859`): the level itself is Δ. + #[test] + fn a_base_level_at_or_below_zero_is_not_subtracted() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let d = SpellDisplay { + base_level: -1i32 as u32, + effect_real_points_per_level: [1.0, 0.0, 0.0], + ..points(10) + }; + let c = TokenContext { + skill: &|_| 50, + ..ctx(&durations, &radii, &none_lookup) + }; + assert_eq!(substitute("$s1", &d, &c), "20"); + } + + /// A cross-spell token takes the expanded spell's level capped at its own row's `maxLevel` + /// (`507805`); only a level of 0 falls through to the referenced spell's own (`6e3841`). + #[test] + fn a_cross_spell_token_caps_the_outer_level() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let per_level = |id: u32, max_level: u32| SpellDisplay { + id, + base_level: 1, + max_level, + effect_real_points_per_level: [1.0, 0.0, 0.0], + ..points(10) + }; + let (capped, uncapped) = (per_level(2, 10), per_level(3, 0)); + let lookup = |id: u32| match id { + 2 => Some(&capped), + 3 => Some(&uncapped), + _ => None, + }; + let outer = SpellDisplay { + id: 1, + ..Default::default() + }; + // The outer spell at level 30; the referenced ones in no line of the player's. + let c = TokenContext { + skill: &|id| if id == 1 { 150 } else { 0 }, + ..ctx(&durations, &radii, &lookup) + }; + assert_eq!(substitute("$2s1 $3s1", &outer, &c), "19 39"); + // The outer spell at level 0: each takes its own, 100 capped at 10 × 5. + let c = TokenContext { + skill: &|id| if id == 1 { 0 } else { 100 }, + ..ctx(&durations, &radii, &lookup) + }; + assert_eq!(substitute("$2s1 $3s1", &outer, &c), "19 29"); + } + + /// Resurrection Sickness (15007) is the one shipped spell on a per-level duration row: 427, + /// -600000 ms plus 60000 a level, capped at 600000. In no line of the player's its level is 0, + /// and a duration at or below 0 reads as until cancelled. + #[test] + fn the_duration_takes_its_per_level_term_from_real_data() { + 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 = SpellRadiusCatalog::default(); + let lookup = |id| spells.get(id); + let sickness = spells.get(15007).expect("Resurrection Sickness"); + assert_eq!(sickness.duration_index, 427); + let skill = std::cell::Cell::new(0); + let skill_of = |_| skill.get(); + let c = TokenContext { + skill: &skill_of, + ..ctx(&durations, &radii, &lookup) + }; + for (value, expected) in [ + (0, ""), + (50, ""), + (55, "<1min>"), + (150, "<10min>"), + ] { + skill.set(value); + assert_eq!(substitute("$d", sickness, &c), expected, "skill {value}"); + } + } + + /// Below `baseLevel` the duration's per-level term goes negative: no floor (`6ea046`). + #[test] + fn the_duration_level_term_is_not_floored() { + let mut durations = SpellDurationCatalog::default(); + durations.insert_row_for_tests( + 1, + crate::SpellDuration { + base_ms: 20_000, + per_level_ms: 1_000, + max_ms: 30_000, + }, + ); + let radii = SpellRadiusCatalog::default(); + let d = SpellDisplay { + base_level: 10, + duration_index: 1, + ..Default::default() + }; + let skill = std::cell::Cell::new(0); + let skill_of = |_| skill.get(); + let c = TokenContext { + skill: &skill_of, + ..ctx(&durations, &radii, &none_lookup) + }; + // Level 5, five under: 20 s less 5 s. Level 20: 30 s, the cap. + skill.set(25); + assert_eq!(substitute("$d", &d, &c), "<15sec>"); + skill.set(100); + assert_eq!(substitute("$d", &d, &c), "<30sec>"); + } + + #[test] + fn dice_per_level_reaches_spread_and_overtime_tokens() { + let mut durations = SpellDurationCatalog::default(); + durations.insert_for_tests(1, 9_000); + let radii = SpellRadiusCatalog::default(); + let spell = SpellDisplay { + base_level: 1, + max_level: 3, + duration_index: 1, + effect_base_points: [10, 0, 0], + effect_base_dice: [1, 0, 0], + effect_die_sides: [3, 0, 0], + effect_dice_per_level: [1, 0, 0], + effect_amplitude: [3_000, 0, 0], + ..Default::default() + }; + // Level 20, capped at 3: two more dice. + let c = TokenContext { + skill: &|_| 100, + ..ctx(&durations, &radii, &none_lookup) + }; + assert_eq!(substitute("$s1; $o1", &spell, &c), "<13..19>; <39..57>"); + } + #[test] fn overtime_duration_period_scale_plural() { let mut durations = SpellDurationCatalog::default();