From 4f86560b4a055b79eb149f7e58fd6413b589a958 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Juozas=20Polikevi=C4=8Dius?= Date: Tue, 29 Sep 2026 09:08:34 +0300 Subject: [PATCH] fix: spell modifiers (#554) - fix: spell modifiers - fix: round scaled spell tooltip values to client tenths - fix: refresh only modifier-dependent item tooltips - The pet leg of the SPELL_GO cooldown insert takes op 11, and the item leg takes none - The tooltip's cast cell is GetCastTime's unclamped result - The Improved Fire Blast tooltip test reads Fire Blast's real recovery columns - The effect-points aura ops follow the reference's jump table - The template filler rounds %f and %g ties half up, as the client's CRT does - The $s, $S, $m, $M and $o tokens compute and print their values as the reference does - The $t, $r, $e and $a tokens follow the expander's arms - The unclamped cast-cell test skips without an install, as its GlobalStrings come from one - start_gcd's doc names the reference's gate: category or time nonzero, then op 21, and nothing armed for a zero result Co-Authored-By: Claude Opus 5.5 (1M context) Co-Authored-By: Sam --- crates/benilla-app/src/spell/cast_send.rs | 8 +- crates/benilla-app/src/spell/cast_target.rs | 10 +- crates/benilla-app/src/spell/cooldowns.rs | 147 ++- crates/benilla-app/src/spell/mod.rs | 4 +- crates/benilla-app/src/spell/mods.rs | 118 ++- crates/benilla-app/src/spell/net.rs | 344 +++--- .../benilla-app/src/spell/targeting/cursor.rs | 38 +- crates/benilla-app/src/spell/targeting/mod.rs | 3 +- crates/benilla-app/src/spell/validator.rs | 28 +- crates/benilla-app/src/target/reticle.rs | 6 +- crates/benilla-app/src/ui_action/mod.rs | 28 +- crates/benilla-app/src/ui_action/state.rs | 2 +- crates/benilla-app/src/ui_craft.rs | 9 +- crates/benilla-app/src/ui_items/feed.rs | 120 ++- crates/benilla-app/src/ui_pet/tests.rs | 2 +- crates/benilla-app/src/ui_script/mod.rs | 22 +- crates/benilla-app/src/ui_tooltip/mod.rs | 45 +- crates/benilla-app/src/ui_tooltip/tests.rs | 120 ++- crates/benilla-formats/src/lib.rs | 12 +- .../benilla-formats/src/spells/cast_times.rs | 56 +- .../src/spells/catalog_tests.rs | 5 + crates/benilla-formats/src/spells/display.rs | 7 + crates/benilla-formats/src/spells/mod.rs | 8 +- .../benilla-formats/src/spells/soft_float.rs | 203 ++++ crates/benilla-formats/src/spells/tokens.rs | 997 +++++++++++++++--- crates/benilla-ui/src/strings.rs | 121 ++- 26 files changed, 2006 insertions(+), 457 deletions(-) create mode 100644 crates/benilla-formats/src/spells/soft_float.rs diff --git a/crates/benilla-app/src/spell/cast_send.rs b/crates/benilla-app/src/spell/cast_send.rs index 7b88b395..e93bdf11 100644 --- a/crates/benilla-app/src/spell/cast_send.rs +++ b/crates/benilla-app/src/spell/cast_send.rs @@ -190,7 +190,7 @@ impl CastLadder<'_, '_> { self.pending.arm(spell_id, now, guards); } if let Some(d) = self.spells.as_ref().and_then(|s| s.catalog.get(spell_id)) { - self.cooldowns.start_gcd(spell_id, d, now); + self.cooldowns.start_gcd(spell_id, d, now, &self.spell_mods); } self.ground.clear(); } @@ -494,7 +494,7 @@ fn send_spell_cast( if let Some(d) = def { if target.is_some() && target == ctx.selection_guid && target != ctx.self_guid { let row = spells.and_then(|s| s.ranges.get(d.range_index)); - if let Some(reason) = ctx.range.refusal(d, row) { + if let Some(reason) = ctx.range.refusal(d, row, spell_mods) { debug!("ui_action: cast {spell_id} refused locally — range ({reason:#x})"); cast_errors.push_local(spell_id, reason); return; @@ -621,7 +621,7 @@ fn send_spell_cast( .self_store .and_then(|s| s.0.unit_level()) .unwrap_or(0); - let cast_time_ms = spells.map_or(0, |s| s.cast_time_ms(d, caster_level)); + let cast_time_ms = spells.map_or(0, |s| s.cast_time_ms(d, caster_level, spell_mods)); if validator::cast_moving_refusal(ctx.self_move_flags, cast_time_ms, def) { debug!("ui_action: cast {spell_id} refused locally — moving (0x2e)"); cast_errors.push_local(spell_id, 0x2e); @@ -769,7 +769,7 @@ fn send_spell_cast( // The GCD arms at send (`0x6e2de0` from `0x6e58fb`); a failed cast result clears it // (`0x6e1630`). if let Some(d) = def { - cooldowns.start_gcd(spell_id, d, now); + cooldowns.start_gcd(spell_id, d, now, spell_mods); } } diff --git a/crates/benilla-app/src/spell/cast_target.rs b/crates/benilla-app/src/spell/cast_target.rs index fd842c0a..e24ce212 100644 --- a/crates/benilla-app/src/spell/cast_target.rs +++ b/crates/benilla-app/src/spell/cast_target.rs @@ -151,12 +151,20 @@ impl RangeInputs { &self, def: &SpellDisplay, row: Option<&benilla_formats::SpellRange>, + mods: &super::SpellModifiers, ) -> Option { let dist_sq = self .self_pos .zip(self.target_pos) .map(|(a, b)| a.distance_squared(b)); - super::validator::cast_range_refusal(def, row, self.self_reach, self.target_reach, dist_sq) + super::validator::cast_range_refusal( + def, + row, + self.self_reach, + self.target_reach, + dist_sq, + mods, + ) } } diff --git a/crates/benilla-app/src/spell/cooldowns.rs b/crates/benilla-app/src/spell/cooldowns.rs index 709dd174..3d319290 100644 --- a/crates/benilla-app/src/spell/cooldowns.rs +++ b/crates/benilla-app/src/spell/cooldowns.rs @@ -17,6 +17,7 @@ use std::time::{Duration, Instant}; use bevy::prelude::*; +use super::{SpellModifiers, OP_GCD}; use benilla_formats::SpellDisplay; use benilla_protocol::messages::ItemUseSpell; @@ -180,28 +181,39 @@ impl Cooldowns { /// /// `ranged_attack_time_ms` is `UNIT_FIELD_RANGEDATTACKTIME` when /// [`SpellDisplay::ranged_speed_cooldown`], else 0, added to the category timer (`0x6e2b60`): - /// the Throw and wand Shoot sweep. Category 0 (Auto Shot) never surfaces it. + /// the Throw and wand Shoot sweep. Category 0 (Auto Shot) never surfaces it. `mods` applies + /// op 11 to both timers, as the self and pet legs do; `None` inserts the DBC values. pub(crate) fn start_spell( &mut self, spell_id: u32, spell: &SpellDisplay, ranged_attack_time_ms: u32, now: Instant, + mods: Option<&SpellModifiers>, ) { + let (recovery_ms, category_ms) = mods.map_or_else( + || { + ( + spell.recovery_ms, + spell + .category_recovery_ms + .saturating_add(ranged_attack_time_ms), + ) + }, + |mods| mods.spell_cooldowns(spell, ranged_attack_time_ms), + ); self.add( spell_id, 0, Timer { start: now, - duration: Duration::from_millis(u64::from(spell.recovery_ms)), + duration: Duration::from_millis(u64::from(recovery_ms)), }, spell.category, spell.category_wildcard, Timer { start: now, - duration: Duration::from_millis(u64::from( - spell.category_recovery_ms + ranged_attack_time_ms, - )), + duration: Duration::from_millis(u64::from(category_ms)), }, spell.cooldown_on_event(), 0, @@ -249,10 +261,23 @@ impl Cooldowns { } /// Arm the GCD at cast send (`StartGlobalCooldown 0x6e2de0`, from `0x6e58fb`) for any cast, - /// item use or pet cast, only when `startRecoveryTime != 0` (`0x6e2e0f`). `on_hold` is - /// Attributes bit 25. - pub(crate) fn start_gcd(&mut self, spell_id: u32, spell: &SpellDisplay, now: Instant) { - if spell.start_recovery_ms == 0 { + /// item use or pet cast, unless `startRecoveryCategory` and `startRecoveryTime` are both 0 + /// (`0x6e2e0f`-`0x6e2e21`), then with op 21 applied (`0x6e2e2c`) and nothing armed for a zero + /// result (`0x6e2e3d`). A negative one arms nothing either, where the reference inserts a + /// record no reader shows. `on_hold` is Attributes bit 25. + pub(crate) fn start_gcd( + &mut self, + spell_id: u32, + spell: &SpellDisplay, + now: Instant, + mods: &SpellModifiers, + ) { + if spell.start_recovery_category == 0 && spell.start_recovery_ms == 0 { + return; + } + // `StartGlobalCooldown 0x6e2de0`: op 21 before the zero-duration exit (`0x6e2e2c`). + let duration_ms = mods.apply(spell, OP_GCD, spell.start_recovery_ms as i32); + if duration_ms <= 0 { return; } if benilla_assets::trace::enabled() { @@ -260,7 +285,7 @@ impl Cooldowns { "cd", &format!( "arm-gcd spell={spell_id} gcdcat={} dur={}ms (cast-send)", - spell.start_recovery_category, spell.start_recovery_ms + spell.start_recovery_category, duration_ms ), ); } @@ -275,7 +300,7 @@ impl Cooldowns { spell.start_recovery_category, Timer { start: now, - duration: Duration::from_millis(u64::from(spell.start_recovery_ms)), + duration: Duration::from_millis(duration_ms as u64), }, ); } @@ -594,6 +619,56 @@ mod tests { spell(0, 0, 0, (133, 1500), 0x10000) } + #[test] + fn gcd_uses_op_21_before_arming() { + let mut d = fireball(); + d.spell_family = 3; + d.spell_family_flags = 1 << 5; + let mut mods = SpellModifiers::default(); + mods.set_class_family(3); + mods.set(false, 5, OP_GCD, -50); + let now = Instant::now(); + let mut cds = Cooldowns::default(); + cds.start_gcd(133, &d, now, &mods); + assert_eq!(cds.info(133, 0, Some(&d), now).duration_ms, 750); + + d.start_recovery_ms = 0; + mods.set(true, 5, OP_GCD, 1000); + let mut cds = Cooldowns::default(); + cds.start_gcd(133, &d, now, &mods); + assert_eq!(cds.info(133, 0, Some(&d), now).duration_ms, 500); + } + + #[test] + fn spell_cooldown_applies_op_11_to_both_timers_after_the_ranged_pad() { + let mut d = spell(44, 8_000, 5_000, (0, 0), 0); + d.spell_family = 3; + d.spell_family_flags = 1 << 5; + let mut mods = SpellModifiers::default(); + mods.set_class_family(3); + mods.set(true, 5, super::super::OP_COOLDOWN, -1_500); + assert_eq!(mods.spell_cooldowns(&d, 0), (6_500, 3_500)); + assert_eq!(mods.spell_cooldowns(&d, 2_000), (6_500, 5_500)); + + let now = Instant::now(); + let mut cds = Cooldowns::default(); + cds.start_spell(2136, &d, 2_000, now, Some(&mods)); + assert_eq!(cds.info(2136, 0, Some(&d), now).duration_ms, 6_500); + let sibling = spell(44, 0, 0, (0, 0), 0); + assert_eq!(cds.info(999, 0, Some(&sibling), now).duration_ms, 5_500); + let mut cds = Cooldowns::default(); + cds.start_spell(2136, &d, 2_000, now, None); + assert_eq!(cds.info(2136, 0, Some(&d), now).duration_ms, 8_000); + assert_eq!(cds.info(999, 0, Some(&sibling), now).duration_ms, 7_000); + + // Endurance 2/2: Sprint's five-minute recovery loses 30%. + d.recovery_ms = 300_000; + d.category_recovery_ms = 0; + mods.set(true, 5, super::super::OP_COOLDOWN, 0); + mods.set(false, 5, super::super::OP_COOLDOWN, -30); + assert_eq!(mods.spell_cooldowns(&d, 0), (210_000, 0)); + } + /// Charge-shaped: category 44, 15 s category cooldown, NO GCD pair. fn charge() -> SpellDisplay { spell(44, 0, 15_000, (0, 0), 0) @@ -603,7 +678,7 @@ mod tests { fn the_gcd_spreads_to_every_spell_sharing_the_start_recovery_category() { let t0 = Instant::now(); let mut cds = Cooldowns::default(); - cds.start_gcd(133, &fireball(), t0); + cds.start_gcd(133, &fireball(), t0, &super::SpellModifiers::default()); // Any spell with the same startRecoveryCategory reads the GCD; Charge has none. let mid = t0 + Duration::from_millis(500); @@ -630,8 +705,8 @@ mod tests { let mut cds = Cooldowns::default(); // Frost-Nova-shaped: own 25 s recovery, category 35, the ordinary 133/1500 GCD pair. let frost_nova = spell(35, 25_000, 0, (133, 1500), 0); - cds.start_gcd(122, &frost_nova, t0); // the cast-send arm - cds.start_spell(122, &frost_nova, 0, t0 + Duration::from_millis(100)); // the GO insert + cds.start_gcd(122, &frost_nova, t0, &super::SpellModifiers::default()); // the cast-send arm + cds.start_spell(122, &frost_nova, 0, t0 + Duration::from_millis(100), None); // the GO insert let mid = t0 + Duration::from_millis(200); // Every GCD sibling still reads the running GCD. @@ -655,8 +730,8 @@ mod tests { let t0 = Instant::now(); let mut cds = Cooldowns::default(); let fd = spell(0, 30_000, 0, (133, 1500), 0); // Feign-Death-shaped minus on-event - cds.start_gcd(5384, &fd, t0); - cds.start_spell(5384, &fd, 0, t0); + cds.start_gcd(5384, &fd, t0, &super::SpellModifiers::default()); + cds.start_spell(5384, &fd, 0, t0, None); let mid = t0 + Duration::from_millis(100); assert!(cds.not_ready(5384, 0, Some(&fd), mid)); @@ -671,7 +746,7 @@ mod tests { fn category_cooldowns_reach_category_siblings_but_not_others() { let t0 = Instant::now(); let mut cds = Cooldowns::default(); - cds.start_spell(100, &charge(), 0, t0); // Charge: category 44, 15 s + cds.start_spell(100, &charge(), 0, t0, None); // Charge: category 44, 15 s let mid = t0 + Duration::from_secs(5); // Another spell in category 44 reads the shared remainder. @@ -692,7 +767,7 @@ mod tests { let mut cds = Cooldowns::default(); // Feign Death: 30 s recovery, SPELL_ATTR_COOLDOWN_ON_EVENT (bit 25). let fd = spell(0, 30_000, 0, (0, 0), 0x0200_0000); - cds.start_spell(5384, &fd, 0, t0); + cds.start_spell(5384, &fd, 0, t0, None); // Parked: full duration, disabled, and still not ready. let parked = cds.info(5384, 0, Some(&fd), t0 + Duration::from_secs(60)); @@ -821,8 +896,8 @@ mod tests { fn prune_drops_only_fully_elapsed_records() { let t0 = Instant::now(); let mut cds = Cooldowns::default(); - cds.start_gcd(133, &fireball(), t0); - cds.start_spell(100, &charge(), 0, t0); + cds.start_gcd(133, &fireball(), t0, &super::SpellModifiers::default()); + cds.start_spell(100, &charge(), 0, t0, None); assert_eq!(cds.records.len(), 2); // Past the 1.5 s GCD, inside Charge's 15 s. @@ -841,7 +916,7 @@ mod tests { let t0 = Instant::now(); let mut cds = Cooldowns::default(); let e0 = cds.feed_epoch(); - cds.start_gcd(133, &fireball(), t0); + cds.start_gcd(133, &fireball(), t0, &super::SpellModifiers::default()); let armed = cds.feed_epoch(); assert_ne!(e0, armed, "a mutation moves the epoch (generation)"); @@ -862,7 +937,7 @@ mod tests { cds.generation, { let mut probe = Cooldowns::default(); - probe.start_gcd(133, &fireball(), t0); + probe.start_gcd(133, &fireball(), t0, &super::SpellModifiers::default()); probe.generation }, "…but the event-edge generation never saw it (the reference is silent on expiry)" @@ -877,7 +952,7 @@ mod tests { fn the_ui_triple_is_stable_per_arm_and_distinct_across_arms() { let t0 = Instant::now(); let mut cds = Cooldowns::default(); - cds.start_gcd(772, &fireball(), t0); + cds.start_gcd(772, &fireball(), t0, &super::SpellModifiers::default()); // Two reads of one arm, frames apart, with both clocks in lockstep. let read1 = cds @@ -891,7 +966,12 @@ mod tests { // The fail clears the GCD and a re-press re-arms 200 ms later, unseen by the feed. cds.clear_gcd(772, t0 + Duration::from_millis(200)); - cds.start_gcd(772, &fireball(), t0 + Duration::from_millis(200)); + cds.start_gcd( + 772, + &fireball(), + t0 + Duration::from_millis(200), + &super::SpellModifiers::default(), + ); let rearmed = cds .info(772, 0, Some(&fireball()), t0 + Duration::from_millis(216)) .ui_triple(t0 + Duration::from_millis(216), 10.216); @@ -907,7 +987,12 @@ mod tests { let anchor0 = Instant::now(); let mut cds = Cooldowns::default(); // Armed 4 ms after this frame's anchor sample. - cds.start_gcd(133, &fireball(), anchor0 + Duration::from_millis(4)); + cds.start_gcd( + 133, + &fireball(), + anchor0 + Duration::from_millis(4), + &super::SpellModifiers::default(), + ); // Frame 1 converts through the pre-arm pair, frame 2 through the next, 16 ms on. let read1 = cds @@ -941,7 +1026,7 @@ mod tests { "no GCD running — nothing locks" ); - cds.start_gcd(772, &fireball(), t0); + cds.start_gcd(772, &fireball(), t0, &super::SpellModifiers::default()); let mid = t0 + Duration::from_millis(200); assert!( cds.not_ready(133, 0, Some(&fireball()), mid), @@ -962,7 +1047,7 @@ mod tests { fn attack_and_tradeskill_reads_are_always_cold() { let t0 = Instant::now(); let mut cds = Cooldowns::default(); - cds.start_gcd(772, &fireball(), t0); + cds.start_gcd(772, &fireball(), t0, &super::SpellModifiers::default()); let mid = t0 + Duration::from_millis(200); let attack = SpellDisplay { effects: [78, 0, 0], // SPELL_EFFECT_ATTACK @@ -985,7 +1070,7 @@ mod tests { attributes: 0x50012, ..Default::default() }; - cds.start_spell(5019, &shoot, 1500, t0); + cds.start_spell(5019, &shoot, 1500, t0, None); let mid = t0 + Duration::from_millis(500); // Unrelated Fireball reads the wand swing and is not ready for its duration. let fb = cds.info(133, 0, Some(&fireball()), mid); @@ -1003,7 +1088,7 @@ mod tests { cds.wipe(); assert_eq!(cds.generation, g0); - cds.start_gcd(133, &fireball(), t0); + cds.start_gcd(133, &fireball(), t0, &super::SpellModifiers::default()); assert_ne!(cds.generation, g0); let g1 = cds.generation; cds.wipe(); @@ -1017,7 +1102,7 @@ mod tests { let t0 = Instant::now(); let mut cds = Cooldowns::default(); let throw = spell(76, 0, 0, (0, 0), 0x410012); - cds.start_spell(2764, &throw, 2200, t0); + cds.start_spell(2764, &throw, 2200, t0, None); let mid = t0 + Duration::from_millis(1000); let info = cds.info(2764, 0, Some(&throw), mid); assert_eq!(info.duration_ms, 2200, "the sweep is the weapon speed"); @@ -1036,7 +1121,7 @@ mod tests { let t0 = Instant::now(); let mut cds = Cooldowns::default(); let auto_shot = spell(0, 0, 0, (0, 0), 0x50012); - cds.start_spell(75, &auto_shot, 3200, t0); + cds.start_spell(75, &auto_shot, 3200, t0, None); let mid = t0 + Duration::from_millis(100); assert_eq!( cds.info(75, 0, Some(&auto_shot), mid).remaining_ms, diff --git a/crates/benilla-app/src/spell/mod.rs b/crates/benilla-app/src/spell/mod.rs index 48afa439..c9da76c2 100644 --- a/crates/benilla-app/src/spell/mod.rs +++ b/crates/benilla-app/src/spell/mod.rs @@ -30,7 +30,9 @@ pub(crate) use inflight::{ inflight, ActiveChannel, AutoRepeatActive, LocalMoveStart, PendingCast, QueuedMeleeSpell, SelfCancel, SPELL_INTERRUPT_MOVEMENT, }; -pub(crate) use mods::{SpellModifiers, OP_COST}; +#[cfg(test)] +pub(crate) use mods::OP_COOLDOWN; +pub(crate) use mods::{SpellModifiers, OP_CAST_TIME, OP_COST, OP_GCD, OP_RADIUS}; // `TargetingWants` is exported for the ground reticle, which draws for the location word alone. pub(crate) use targeting::{ ground_cast_radius, CorpsePick, PicksSelf, ScriptCursor, SpellTargeting, TargetingWants, diff --git a/crates/benilla-app/src/spell/mods.rs b/crates/benilla-app/src/spell/mods.rs index ab2f298a..15b0b6f1 100644 --- a/crates/benilla-app/src/spell/mods.rs +++ b/crates/benilla-app/src/spell/mods.rs @@ -15,12 +15,12 @@ //! boolean, so a bare `(flat, pct)` pair would zero every unmodified spell; hence //! [`SpellModifiers::modifiers`] returns `Option` and [`SpellMod`] is private. //! -//! All 29 ops are stored; only op 14 (`COST`) is read here. Op 24 is the one the client routes -//! aura 65 (casting speed) to, whatever vmangos names it. +//! All 29 ops are stored. Op 24 is the one the client routes aura 65 (casting speed) to, +//! whatever vmangos names it. use bevy::prelude::*; -use benilla_formats::SpellDisplay; +use benilla_formats::{SpellDisplay, SpellRange}; use crate::chr_classes::ChrClassTable; use crate::net::{ObjectStore, SelfPlayer}; @@ -35,6 +35,16 @@ const CELLS: usize = BITS * OPS; /// `SPELLMOD_COST`, read by [`crate::spell::usable::power_cost`] (`GetPowerCost 0x6e31b0` at /// `6e32e3`). pub(crate) const OP_COST: u8 = 14; +/// `SPELLMOD_CASTING_TIME`, applied by `GetCastTime 0x6e3340` at `0x6e33b7`. +pub(crate) const OP_CAST_TIME: u8 = 10; +/// `GetMinMaxRange 0x6e3480` applies op 5 to its maximum at `0x6e3744`. +pub(crate) const OP_RANGE: u8 = 5; +/// `GetCurrentCastRadius 0x6e6350` applies op 6 before the reticle's 20-yard cap. +pub(crate) const OP_RADIUS: u8 = 6; +/// `StartGlobalCooldown 0x6e2de0` applies op 21 to `startRecoveryTime`. +pub(crate) const OP_GCD: u8 = 21; +/// `StartCooldown 0x6e2c60` and the tooltip apply op 11 to both recovery columns. +pub(crate) const OP_COOLDOWN: u8 = 11; /// `SPELL_ATTR_EX3_IGNORE_CASTER_MODIFIERS` (vmangos `SpellDefines.h:940`): a spell carrying it /// takes no modifier (`0x6e6b52`). @@ -161,6 +171,64 @@ impl SpellModifiers { pub(crate) fn apply(&self, d: &SpellDisplay, op: u8, value: i32) -> i32 { self.modifiers(d, op).map_or(value, |m| m.apply(value)) } + + /// The reference applies op 11 separately to RecoveryTime and to CategoryRecoveryTime + /// after `0x6e2b60` adds the ranged attack time to the latter. + pub(crate) fn spell_cooldowns(&self, d: &SpellDisplay, ranged_ms: u32) -> (u32, u32) { + let recovery = self.apply(d, OP_COOLDOWN, d.recovery_ms as i32).max(0) as u32; + let category_base = d.category_recovery_ms.saturating_add(ranged_ms); + let category = self.apply(d, OP_COOLDOWN, category_base as i32).max(0) as u32; + (recovery, category) + } + + /// The reference's float applier `0x6e6bf0`: add the flat integer, then multiply by the + /// summed percentage and its stored single-precision 0.01 constant. + pub(crate) fn apply_float(&self, d: &SpellDisplay, op: u8, value: f32) -> f32 { + self.modifiers(d, op).map_or(value, |m| { + ((f64::from(value) + f64::from(m.flat)) * f64::from(m.pct) * f64::from(0.01f32)) as f32 + }) + } + + /// The software-float applier `0x6e6c30` that `GetEffectPoints` calls + /// ([`benilla_formats::soft_modify`]). + pub(crate) fn apply_soft(&self, d: &SpellDisplay, op: u8, value: f32) -> f32 { + self.modifiers(d, op).map_or(value, |m| { + benilla_formats::soft_modify(value, m.flat, m.pct) + }) + } + + /// `GetMinMaxRange` modifies only the maximum at its common tail. The on-next-swing + /// `Attributes & 0x404` arm returns before that tail (`0x6e350f`). + pub(crate) fn min_max_range( + &self, + d: &SpellDisplay, + row: Option<&SpellRange>, + caster_reach: f32, + target_reach: Option, + ) -> Option<(f32, f32)> { + let (min, max) = benilla_formats::min_max_range(d, row, caster_reach, target_reach)?; + Some(( + min, + if d.on_next_swing() { + max + } else { + self.apply_float(d, OP_RANGE, max) + }, + )) + } +} + +/// The description expander's view of the tables. +impl benilla_formats::SpellMods for SpellModifiers { + fn apply_int(&self, d: &SpellDisplay, op: u8, value: i32) -> i32 { + self.apply(d, op, value) + } + fn apply_float(&self, d: &SpellDisplay, op: u8, value: f32) -> f32 { + SpellModifiers::apply_float(self, d, op, value) + } + fn apply_soft(&self, d: &SpellDisplay, op: u8, value: f32) -> f32 { + SpellModifiers::apply_soft(self, d, op, value) + } } /// Entering the world clears the tables and class family: `Spell_C::SystemInitialize 0x6e7150`, @@ -386,4 +454,48 @@ mod tests { "four set bits, four cells" ); } + + /// `GetCastTime` applies op 10 after resolving the DBC row; an instant modifier lifts the + /// moving-cast refusal for Frostbolt, while another class's spell keeps its cast time. + #[test] + fn cast_time_uses_the_live_modifier() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let catalog = benilla_formats::load_spell_catalog(&mut chain).expect("load Spell.dbc"); + let mut spells = crate::ui_action::Spells::empty_for_tests(); + spells.cast_times = + benilla_formats::load_spell_cast_times(&mut chain).expect("load SpellCastTimes.dbc"); + let frostbolt = catalog.get(116).expect("Frostbolt"); + let mut mods = mage_tables(); + let base = spells.cast_time_ms(frostbolt, 60, &mods); + assert!(base > 0); + mods.set(false, 5, OP_CAST_TIME, -100); + assert_eq!(spells.cast_time_ms(frostbolt, 60, &mods), 0); + } + + #[test] + fn range_modifier_changes_only_the_maximum_after_reach() { + let mut mods = mage_tables(); + let d = mage(&[5]); + let row = SpellRange { + min: 8.0, + max: 35.0, + flags: 0, + }; + mods.set(false, 5, OP_RANGE, 20); + let (min, max) = mods.min_max_range(&d, Some(&row), 1.5, Some(1.5)).unwrap(); + assert_eq!(min, 11.0); + assert!((max - 45.6).abs() < 0.001, "38 yards with +20% range"); + assert_eq!(mods.min_max_range(&d, None, 1.5, Some(1.5)), None); + + let on_next_swing = SpellDisplay { + attributes: 0x404, + ..d + }; + assert_eq!( + mods.min_max_range(&on_next_swing, Some(&row), 1.5, Some(1.5)), + benilla_formats::min_max_range(&on_next_swing, Some(&row), 1.5, Some(1.5)), + "the reference's early return bypasses op 5" + ); + } } diff --git a/crates/benilla-app/src/spell/net.rs b/crates/benilla-app/src/spell/net.rs index 098a0b7e..84c4cf56 100644 --- a/crates/benilla-app/src/spell/net.rs +++ b/crates/benilla-app/src/spell/net.rs @@ -283,6 +283,7 @@ fn on_spell_go(In(ev): In, mut l: Lifecycle, mut sc: Scene) { &mut sc.items, &l.net, &mut l.pet_bar, + &l.spell_mods, ), (&mut l.auto_repeat, &mut sc.sheaths, engaged), l.play_seq.next(), @@ -827,6 +828,7 @@ fn spell_go( &mut crate::items::Items, &crate::net::NetCommands, &mut crate::ui_pet::PetBar, + &crate::spell::SpellModifiers, ), // The GO-deferred auto-attack start's writes (`0x6e83c0`) and the attack lock it gates on // (`Engaged`, the reference's `[player+0xc48]`). @@ -863,7 +865,7 @@ fn spell_go( if let Some(go_guid) = go_target { go_lid.write(crate::go_anim::GoLidOpen { go_guid, spell_id }); } - let (cooldowns, spells, items, net_commands, pet_bar) = cooldown_ctx; + let (cooldowns, spells, items, net_commands, pet_bar, spell_mods) = cooldown_ctx; let (auto_repeat, sheath, engaged) = attack_ctx; let now = Instant::now(); let display = spells.and_then(|s| s.catalog.get(spell_id)); @@ -955,27 +957,29 @@ fn spell_go( .filter(|u| u.spell_id == spell_id); match use_spell { Some(u) => cooldowns.start_item(entry, &u, display, now), - // Template not streamed, or naming another spell: the spell-keyed record. + // Template not streamed, or naming another spell: the spell-keyed record, + // unmodified, as the item leg never applies op 11 (`0x6e8566`, `0x6e8830`). None => { if let Some(d) = display { - cooldowns.start_spell(spell_id, d, ranged_ms, now); + cooldowns.start_spell(spell_id, d, ranged_ms, now, None); } } } } None => { if let Some(d) = display { - cooldowns.start_spell(spell_id, d, ranged_ms, now); + cooldowns.start_spell(spell_id, d, ranged_ms, now, Some(spell_mods)); } } } if benilla_assets::trace::enabled() { if let Some(d) = display { + let (recovery_ms, category_ms) = spell_mods.spell_cooldowns(d, ranged_ms); benilla_assets::trace::line( "cd", &format!( "arm spell={spell_id} rec={}ms cat={}:{}ms (GO self-insert)", - d.recovery_ms, d.category, d.category_recovery_ms + recovery_ms, d.category, category_ms ), ); } @@ -983,18 +987,23 @@ fn spell_go( } // The pet leg of the same insert: an independent `if`, not an else. if let Some(d) = display.filter(|_| pet_go_cooldown(caster, self_guid, index, stores)) { - // No ranged pad: `0x6e2b60` is called on the self leg only (`0x6e845d`). - pet_bar.cooldowns.start_spell(spell_id, d, 0, now); + // No ranged pad: `0x6e2b60` is called on the self leg only (`0x6e845d`). Op 11 still + // applies to both columns (`0x6e85c5`, `0x6e85d1`) off the player's tables, which gate + // on the spell's family and never the caster (`0x6e6b30`). + pet_bar + .cooldowns + .start_spell(spell_id, d, 0, now, Some(spell_mods)); // `0x6e85fc`/`0x6e8601` fire SPELL_UPDATE_COOLDOWN and PET_BAR_UPDATE_COOLDOWN; the pet // bar's one repaint fires off its diff, and the signal bump covers a re-arm to an // identical triple. pet_bar.bar_signals = pet_bar.bar_signals.wrapping_add(1); if benilla_assets::trace::enabled() { + let (recovery_ms, category_ms) = spell_mods.spell_cooldowns(d, 0); benilla_assets::trace::line( "cd", &format!( "arm spell={spell_id} rec={}ms cat={}:{}ms (GO pet-insert)", - d.recovery_ms, d.category, d.category_recovery_ms + recovery_ms, d.category, category_ms ), ); } @@ -1673,6 +1682,7 @@ mod tests { &mut items, &net_commands, &mut pet_bar, + &crate::spell::SpellModifiers::default(), ), ( &mut crate::spell::AutoRepeatActive::default(), @@ -1928,6 +1938,7 @@ mod tests { &mut items, &net_commands, &mut pet_bar, + &crate::spell::SpellModifiers::default(), ), ( &mut crate::spell::AutoRepeatActive::default(), @@ -1961,30 +1972,39 @@ mod tests { ); } - /// The GO's pet leg arms the pet's bank (`0xcecb04`), never the player's; the owner read falls - /// back to SUMMONEDBY, not CREATEDBY (`0x6e859a`); a stranger's cast arms neither. - #[test] - fn a_pets_own_go_arms_the_pet_bank_and_only_the_pet_bank() { + /// `UNIT_FIELD_CHARMEDBY` / `SUMMONEDBY` / `CREATEDBY`, two dwords each. + const CHARMEDBY: u16 = 10; + const SUMMONEDBY: u16 = 12; + const CREATEDBY: u16 = 14; + + /// A unit store whose `field` pair names `owner`. + fn owned(field: u16, owner: u64) -> ObjectStore { + ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ + (field, owner as u32), + (field + 1, (owner >> 32) as u32), + ])) + } + + /// One SPELL_GO of `display` (as spell 2649) by `caster` in a world holding us (guid 10) and, + /// when given, the casting item; `mods` is the player's table. Returns the player bank's and + /// the pet bank's armed durations and the pet bar's repaint count. + fn go_cooldowns( + caster: u64, + store: ObjectStore, + item: Option<(u64, ObjectStore)>, + display: fn() -> benilla_formats::SpellDisplay, + mods: crate::spell::SpellModifiers, + ) -> (u32, u32, u32) { use crate::combat_text::CombatTextSpawn; use crate::creature_anim::Casting; use crate::go_anim::GoLidOpen; use crate::net::{Guid, SelfPlayer}; use bevy::ecs::system::RunSystemOnce; - /// `UNIT_FIELD_CHARMEDBY` / `SUMMONEDBY` / `CREATEDBY`, two dwords each. - const CHARMEDBY: u16 = 10; - const SUMMONEDBY: u16 = 12; - const CREATEDBY: u16 = 14; - - const GROWL: u32 = 2649; - let growl = || benilla_formats::SpellDisplay { - name: "Growl".into(), - recovery_ms: 5000, - ..Default::default() - }; - let make_spells = || crate::ui_action::Spells { + const SPELL: u32 = 2649; + let spells = crate::ui_action::Spells { catalog: benilla_formats::SpellCatalog::from_displays( - [(GROWL, growl())].into_iter().collect(), + [(SPELL, display())].into_iter().collect(), ), forms: Default::default(), ranges: Default::default(), @@ -1992,115 +2012,142 @@ mod tests { durations: Default::default(), radii: Default::default(), }; - - // A world with us (guid 10), our pet (20, SUMMONEDBY us), a totem (30, CREATEDBY us only) - // and a stranger's pet (40, SUMMONEDBY somebody else). - let owned = |field: u16, owner: u64| { - ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[ - (field, owner as u32), - (field + 1, (owner >> 32) as u32), - ])) - }; - let fire = |caster: u64, store: ObjectStore| { - let mut app = App::new(); - app.add_message::() - .add_message::() - .add_message::() - .add_message::() - .add_message::() - .add_message::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::() - .init_resource::(); - let self_e = app - .world_mut() - .spawn((Guid(10), SelfPlayer, ObjectStore::default())) - .id(); + let mut app = App::new(); + app.add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .add_message::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::() + .init_resource::(); + let self_e = app + .world_mut() + .spawn((Guid(10), SelfPlayer, ObjectStore::default())) + .id(); + app.world_mut() + .resource_mut::() + .0 + .insert(10, self_e); + if caster != 10 { let caster_e = app.world_mut().spawn((Guid(caster), store)).id(); - { - let mut index = app.world_mut().resource_mut::(); - index.0.insert(10, self_e); - index.0.insert(caster, caster_e); - } - app.world_mut().resource_mut::().0 = Some(10); - - let (tx, _rx) = crossbeam_channel::unbounded(); - let spells = make_spells(); app.world_mut() - .run_system_once( - move |mut commands: Commands, - index: Res, - casting: Query<&Casting>, - mut cast_events: MessageWriter, - mut go_targets: MessageWriter, - self_guid: Res, - stores: Query<&mut ObjectStore>, - mut cast_bar: ResMut, - mut pending: ResMut, - mut queued_melee: ResMut, - mut text: MessageWriter, - mut go_lid: MessageWriter, - mut cooldowns: ResMut, - mut pet_bar: ResMut, - mut items: ResMut, - mut sheath: MessageWriter| { - let net_commands = crate::net::NetCommands(tx.clone()); - spell_go( - caster, - GROWL, - 0, - vec![], - vec![], - None, - None, - None, - None, - Some(caster), - &mut commands, - &index, - &casting, - &mut cast_events, - &mut go_targets, - &self_guid, - &stores, - &mut cast_bar, - &mut pending, - &mut queued_melee, - &mut text, - crate::combat_text::DamageTextGates::default(), - &mut go_lid, - &mut crate::ui_loot::LootLatch::default(), - ( - &mut cooldowns, - Some(&spells), - &mut items, - &net_commands, - &mut pet_bar, - ), - ( - &mut crate::spell::AutoRepeatActive::default(), - &mut sheath, - false, - ), - 1, - ); - }, - ) - .unwrap(); - let now = Instant::now(); - let armed = |c: &Cooldowns| c.info(GROWL, 0, Some(&growl()), now).remaining_ms > 0; - let world = app.world(); - ( - armed(world.resource::()), - armed(&world.resource::().cooldowns), - world.resource::().bar_signals, + .resource_mut::() + .0 + .insert(caster, caster_e); + } + let item_caster = item.map(|(guid, item_store)| { + let e = app.world_mut().spawn((Guid(guid), item_store)).id(); + app.world_mut() + .resource_mut::() + .0 + .insert(guid, e); + guid + }); + app.world_mut().resource_mut::().0 = Some(10); + + let (tx, _rx) = crossbeam_channel::unbounded(); + app.world_mut() + .run_system_once( + move |mut commands: Commands, + index: Res, + casting: Query<&Casting>, + mut cast_events: MessageWriter, + mut go_targets: MessageWriter, + self_guid: Res, + stores: Query<&mut ObjectStore>, + mut cast_bar: ResMut, + mut pending: ResMut, + mut queued_melee: ResMut, + mut text: MessageWriter, + mut go_lid: MessageWriter, + mut cooldowns: ResMut, + mut pet_bar: ResMut, + mut items: ResMut, + mut sheath: MessageWriter| { + let net_commands = crate::net::NetCommands(tx.clone()); + spell_go( + caster, + SPELL, + 0, + vec![], + vec![], + None, + None, + None, + None, + item_caster.or(Some(caster)), + &mut commands, + &index, + &casting, + &mut cast_events, + &mut go_targets, + &self_guid, + &stores, + &mut cast_bar, + &mut pending, + &mut queued_melee, + &mut text, + crate::combat_text::DamageTextGates::default(), + &mut go_lid, + &mut crate::ui_loot::LootLatch::default(), + ( + &mut cooldowns, + Some(&spells), + &mut items, + &net_commands, + &mut pet_bar, + &mods, + ), + ( + &mut crate::spell::AutoRepeatActive::default(), + &mut sheath, + false, + ), + 1, + ); + }, ) + .unwrap(); + let now = Instant::now(); + let armed = |c: &Cooldowns| c.info(SPELL, 0, Some(&display()), now).duration_ms; + let world = app.world(); + ( + armed(world.resource::()), + armed(&world.resource::().cooldowns), + world.resource::().bar_signals, + ) + } + + /// Growl's shape: a 5 s recovery, no category. + fn growl() -> benilla_formats::SpellDisplay { + benilla_formats::SpellDisplay { + name: "Growl".into(), + recovery_ms: 5000, + ..Default::default() + } + } + + /// The GO's pet leg arms the pet's bank (`0xcecb04`), never the player's; the owner read falls + /// back to SUMMONEDBY, not CREATEDBY (`0x6e859a`); a stranger's cast arms neither. + #[test] + fn a_pets_own_go_arms_the_pet_bank_and_only_the_pet_bank() { + let fire = |caster: u64, store: ObjectStore| { + let (player, pet, signals) = go_cooldowns( + caster, + store, + None, + growl, + crate::spell::SpellModifiers::default(), + ); + (player > 0, pet > 0, signals) }; // Our pet: the PET bank only, and a forced repaint with it. @@ -2123,6 +2170,39 @@ mod tests { assert!(!p2 && !pet2, "a stranger's pet arms nothing"); } + /// Op 11 on the GO insert: the self leg (`0x6e846f`, and `0x6e2b60`'s tail) and the pet leg + /// (`0x6e85c5`) apply the player's table; the item leg inserts the DBC values (`0x6e8566`), + /// here through its not-streamed fallback. + #[test] + fn op_11_shortens_the_self_and_pet_inserts_and_never_the_item_one() { + fn display() -> benilla_formats::SpellDisplay { + benilla_formats::SpellDisplay { + spell_family: 9, + spell_family_flags: 1, + ..growl() + } + } + let mods = || { + let mut m = crate::spell::SpellModifiers::default(); + m.set_class_family(9); + m.set(true, 0, crate::spell::OP_COOLDOWN, -2000); + m + }; + let own = ObjectStore::default(); + assert_eq!(go_cooldowns(10, own, None, display, mods()).0, 3000); + let pet = go_cooldowns(20, owned(SUMMONEDBY, 10), None, display, mods()); + assert_eq!(pet.1, 3000, "the pet leg takes the player's op 11"); + let item = ObjectStore(benilla_protocol::ObjectFields::from_pairs(&[(3, 6948)])); + let used = go_cooldowns( + 10, + ObjectStore::default(), + Some((50, item)), + display, + mods(), + ); + assert_eq!(used.0, 5000, "the item leg applies no modifier"); + } + /// Both producers of the chain-hop array, the GO's hit list and the 816 packet, drop the /// caster's own guid and clear before they fill. #[test] @@ -2225,6 +2305,7 @@ mod tests { &mut items, &net_commands, &mut pet_bar, + &crate::spell::SpellModifiers::default(), ), ( &mut crate::spell::AutoRepeatActive::default(), @@ -2611,6 +2692,7 @@ mod tests { &mut items, &net_commands, &mut pet_bar, + &crate::spell::SpellModifiers::default(), ), ( &mut crate::spell::AutoRepeatActive::default(), diff --git a/crates/benilla-app/src/spell/targeting/cursor.rs b/crates/benilla-app/src/spell/targeting/cursor.rs index 044bd0f9..789ee832 100644 --- a/crates/benilla-app/src/spell/targeting/cursor.rs +++ b/crates/benilla-app/src/spell/targeting/cursor.rs @@ -35,9 +35,14 @@ fn range_row(spells: Option<&Spells>, spell_id: u32) -> Option<&SpellRange> { /// `GetCurrentCastRadius 0x6e6350`: per effect `radius + casterLevel × perLevel` over /// `EffectRadiusIndex[0]` and `[1]` only, the larger with slot 1 winning ties and NaN, clamped to /// 20.0 (`0x4820f0`'s `[0x804478]`). 0.0 means no radius rows and the reticle's default size. -/// The reference then applies spell-mod op 6 (SPELLMOD_RADIUS, `0x6e6bf0`); this does not yet, -/// though `crate::spell::mods` has it. -pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: u32) -> f32 { +/// The reference then applies spell-mod op 6 (SPELLMOD_RADIUS, `0x6e6bf0`) before the reticle's +/// 20-yard clamp (`0x4820f0`). +pub(crate) fn ground_cast_radius( + spells: Option<&Spells>, + spell_id: u32, + level: u32, + mods: &super::super::SpellModifiers, +) -> f32 { let Some(spells) = spells else { return 0.0 }; let Some(d) = spells.catalog.get(spell_id) else { return 0.0; @@ -55,7 +60,7 @@ pub(crate) fn ground_cast_radius(spells: Option<&Spells>, spell_id: u32, level: let (c0, c1) = (candidate(0), candidate(1)); // Strict > for slot 0: a tie or a NaN falls to slot 1, as the reference compares. let r = if c0 > c1 { c0 } else { c1 }; - r.min(20.0) + mods.apply_float(d, super::super::OP_RADIUS, r).min(20.0) } /// While targeting, runs after the world classifier and overwrites its verdict (`0x4820f0`'s @@ -223,6 +228,8 @@ mod tests { let mut spells = crate::ui_action::Spells::empty_for_tests(); let display = |idx: [u32; 3]| SpellDisplay { effect_radius_index: idx, + spell_family: 3, + spell_family_flags: 1 << 5, ..SpellDisplay::default() }; spells.catalog = benilla_formats::SpellCatalog::from_displays(HashMap::from([ @@ -275,17 +282,23 @@ mod tests { ), ])); let s = Some(&spells); - assert_eq!(ground_cast_radius(s, 10, 60), 8.0); - assert_eq!(ground_cast_radius(s, 2120, 60), 5.0); + let mods = crate::spell::SpellModifiers::default(); + assert_eq!(ground_cast_radius(s, 10, 60, &mods), 8.0); + assert_eq!(ground_cast_radius(s, 2120, 60, &mods), 5.0); // Slot 2 is never read: no rows in slots 0 and 1 reads 0, the default size. - assert_eq!(ground_cast_radius(s, 777, 60), 0.0); + assert_eq!(ground_cast_radius(s, 777, 60, &mods), 0.0); // Per-level: 2.0 + 60 × 0.1 = 8.0 beats slot 1's 5.0. - assert_eq!(ground_cast_radius(s, 778, 60), 8.0); + assert_eq!(ground_cast_radius(s, 778, 60, &mods), 8.0); // The 20.0 clamp (`[0x804478]`). - assert_eq!(ground_cast_radius(s, 779, 60), 20.0); + assert_eq!(ground_cast_radius(s, 779, 60, &mods), 20.0); // Unknown spell or no data: 0, the default size. - assert_eq!(ground_cast_radius(s, 9999, 60), 0.0); - assert_eq!(ground_cast_radius(None, 10, 60), 0.0); + assert_eq!(ground_cast_radius(s, 9999, 60, &mods), 0.0); + assert_eq!(ground_cast_radius(None, 10, 60, &mods), 0.0); + + let mut mods = crate::spell::SpellModifiers::default(); + mods.set_class_family(3); + mods.set(false, 5, crate::spell::OP_RADIUS, -50); + assert_eq!(ground_cast_radius(s, 779, 60, &mods), 15.0); } /// Only two of the three pick states are handlers; state 0 is UnableCast. @@ -302,6 +315,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.insert_resource(crate::net::Reputations(Vec::new())); world.insert_resource(PickOcclusion { distance: 10.0, @@ -374,6 +388,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.insert_resource(crate::net::Reputations(Vec::new())); world.init_resource::(); let display = |range_index| benilla_formats::SpellDisplay { @@ -457,6 +472,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.insert_resource(crate::net::Reputations(Vec::new())); world.init_resource::(); world.insert_resource(fx::spells()); diff --git a/crates/benilla-app/src/spell/targeting/mod.rs b/crates/benilla-app/src/spell/targeting/mod.rs index d2df19db..cd737379 100644 --- a/crates/benilla-app/src/spell/targeting/mod.rs +++ b/crates/benilla-app/src/spell/targeting/mod.rs @@ -65,6 +65,7 @@ pub(crate) struct BindChecks<'w, 's> { /// `publish_camera_pose` rewrites mid-frame. poses: Query<'w, 's, &'static GlobalTransform, Without>, spells: Option>, + spell_mods: Res<'w, super::SpellModifiers>, } impl BindChecks<'_, '_> { @@ -142,7 +143,7 @@ impl BindChecks<'_, '_> { fn refusal(&self, spell_id: u32, range: super::cast_target::RangeInputs) -> Option { let spells = self.spells.as_deref()?; let def = spells.catalog.get(spell_id)?; - range.refusal(def, spells.ranges.get(def.range_index)) + range.refusal(def, spells.ranges.get(def.range_index), &self.spell_mods) } /// What the corpse legs read of this corpse: `CORPSE_FLAG_BONES` and the reaction gate diff --git a/crates/benilla-app/src/spell/validator.rs b/crates/benilla-app/src/spell/validator.rs index bab56f06..426160c2 100644 --- a/crates/benilla-app/src/spell/validator.rs +++ b/crates/benilla-app/src/spell/validator.rs @@ -4,6 +4,7 @@ //! called by [`super::cast_send`]'s ladder in its order. Only `TryCast` reaches `0x6094f0`, never //! the greying walk `0x6e3d60`. +use super::SpellModifiers; use benilla_formats::{SpellDisplay, SpellRange}; /// The range refusals `CanTargetUnit 0x6e4440` emits: "Out of range." and "Target too close". @@ -20,8 +21,9 @@ pub(super) fn cast_range_refusal( self_reach: f32, target_reach: Option, dist_sq: Option, + mods: &SpellModifiers, ) -> Option { - let (min, max) = benilla_formats::min_max_range(spell, row, self_reach, target_reach)?; + let (min, max) = mods.min_max_range(spell, row, self_reach, target_reach)?; let d2 = dist_sq?; if d2 > max * max { return Some(ERR_OUT_OF_RANGE); @@ -235,6 +237,7 @@ mod tests { /// `IsTargetInRange 0x6e47b0`'s two compares, on Auto Shot's {8, 35} row plus the reach pad. #[test] fn cast_range_refusal_follows_the_two_compares() { + let mods = SpellModifiers::default(); let d = spell_with_range(114, 0); let auto_shot = SpellRange { min: 8.0, @@ -243,7 +246,8 @@ mod tests { }; let reach = Some(1.5); // Both bounds carry the bare reach pad (self 1.5 + target 1.5): min = 11, max = 38. - let refuse = |d2: f32| cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, Some(d2)); + let refuse = + |d2: f32| cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, Some(d2), &mods); assert_eq!(refuse(3.0 * 3.0), Some(ERR_TOO_CLOSE)); assert_eq!(refuse(20.0 * 20.0), None); assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE)); @@ -254,7 +258,7 @@ mod tests { max: 35.0, flags: 0, }; - let refuse = |d2: f32| cast_range_refusal(&d, Some(&fireball), 1.5, reach, Some(d2)); + let refuse = |d2: f32| cast_range_refusal(&d, Some(&fireball), 1.5, reach, Some(d2), &mods); assert_eq!(refuse(0.1), None); assert_eq!(refuse(60.0 * 60.0), Some(ERR_OUT_OF_RANGE)); @@ -266,18 +270,28 @@ mod tests { }; let melee_spell = spell_with_range(2, 0); assert_eq!( - cast_range_refusal(&melee_spell, Some(&melee), 1.5, reach, Some(0.1)), + cast_range_refusal(&melee_spell, Some(&melee), 1.5, reach, Some(0.1), &mods), None ); assert_eq!( - cast_range_refusal(&melee_spell, Some(&melee), 1.5, reach, Some(15.0 * 15.0)), + cast_range_refusal( + &melee_spell, + Some(&melee), + 1.5, + reach, + Some(15.0 * 15.0), + &mods + ), Some(ERR_OUT_OF_RANGE) ); // No row or no distance passes. - assert_eq!(cast_range_refusal(&d, None, 1.5, reach, Some(1.0)), None); assert_eq!( - cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, None), + cast_range_refusal(&d, None, 1.5, reach, Some(1.0), &mods), + None + ); + assert_eq!( + cast_range_refusal(&d, Some(&auto_shot), 1.5, reach, None, &mods), None ); } diff --git a/crates/benilla-app/src/target/reticle.rs b/crates/benilla-app/src/target/reticle.rs index 3c2e4cbd..a16d5c9b 100644 --- a/crates/benilla-app/src/target/reticle.rs +++ b/crates/benilla-app/src/target/reticle.rs @@ -10,7 +10,7 @@ //! object-placement spell (effect `0x51`); that model is not built. //! //! The radius is `ground_cast_radius` (`GetCurrentCastRadius 0x6e6350`, clamped to 20.0 in -//! `0x4820f0`), with no spell mod applied. Out of range forces it to 0.0, and 0.0 draws at the +//! `0x4820f0`), after spell-mod op 6. Out of range forces it to 0.0, and 0.0 draws at the //! 1.3888889 default. With no world hit nothing is drawn: the //! reference resets its draw state every hover pass. Over a unit the decal lands on the ground //! behind it, since a dest-only word's pick skips the object trace (`0x480e7b`). @@ -82,6 +82,7 @@ pub(super) fn update_reticle( occlusion: Res, cursor: Res, spells: Option>, + spell_mods: Res, self_store: Query<&ObjectStore, With>, decals: WorldDecal, mut state: ResMut, @@ -103,7 +104,7 @@ pub(super) fn update_reticle( .ok() .and_then(|s| s.0.unit_level()) .unwrap_or(1); - ground_cast_radius(spells.as_deref(), spell_id, level) + ground_cast_radius(spells.as_deref(), spell_id, level, &spell_mods) } else { 0.0 }; @@ -197,6 +198,7 @@ mod tests { world.init_resource::(); world.init_resource::(); world.init_resource::(); + world.init_resource::(); world.insert_resource(PickOcclusion { distance: 10.0, point: Some(Vec3::ZERO), diff --git a/crates/benilla-app/src/ui_action/mod.rs b/crates/benilla-app/src/ui_action/mod.rs index a9c64f60..62341dfc 100644 --- a/crates/benilla-app/src/ui_action/mod.rs +++ b/crates/benilla-app/src/ui_action/mod.rs @@ -99,17 +99,33 @@ pub(crate) struct Spells { } impl Spells { - /// The cast time in ms (`GetCastTime 0x6e3340`), level-scaled from the `CastingTimeIndex` - /// row; a missing row reads 0. Spell-mod op `0xa` is not applied, so a talent-shortened cast - /// shows its base length. + /// The cast time in ms as `GetCastTime 0x6e3340` returns it for argument 1, the tooltip's + /// (`52eb4b`): the level-scaled `CastingTimeIndex` row, then spell-mod op 10 (`0x6e33b7`), + /// with no clamp, so row 18's negative time survives. A missing row returns 0 before the + /// modifier (`0x6e338a`). The cast-speed multiply (`0x6e3422`) is not applied. + pub(crate) fn cast_time_unclamped_ms( + &self, + def: &benilla_formats::SpellDisplay, + caster_level: u32, + mods: &crate::spell::SpellModifiers, + ) -> i32 { + self.cast_times + .get(def.casting_time_index) + .map_or(0, |row| { + let resolved = row.resolved_ms(caster_level, def.base_level); + mods.apply(def, crate::spell::OP_CAST_TIME, resolved) + }) + } + + /// [`Self::cast_time_unclamped_ms`] for argument 0, the cast validator's (`0x609e16`): a + /// result at or below zero reads 0 (`0x6e3461`-`0x6e3470`). pub(crate) fn cast_time_ms( &self, def: &benilla_formats::SpellDisplay, caster_level: u32, + mods: &crate::spell::SpellModifiers, ) -> u32 { - self.cast_times - .get(def.casting_time_index) - .map_or(0, |row| row.resolved_ms(caster_level, def.base_level)) + self.cast_time_unclamped_ms(def, caster_level, mods).max(0) as u32 } } diff --git a/crates/benilla-app/src/ui_action/state.rs b/crates/benilla-app/src/ui_action/state.rs index c21265f9..f99ee43b 100644 --- a/crates/benilla-app/src/ui_action/state.rs +++ b/crates/benilla-app/src/ui_action/state.rs @@ -215,7 +215,7 @@ pub(super) fn feed_action_state( } // The range verdict against the target (`0x4e56f0`); nil without one. let row = spells.as_ref().and_then(|s| s.ranges.get(d.range_index)); - let resolved = benilla_formats::min_max_range(d, row, self_reach, target_reach); + let resolved = spell_mods.min_max_range(d, row, self_reach, target_reach); st.has_range = resolved .is_some_and(|(min, max)| min.abs() > f32::EPSILON || max.abs() > f32::EPSILON); st.in_range = match (resolved, dist_sq) { diff --git a/crates/benilla-app/src/ui_craft.rs b/crates/benilla-app/src/ui_craft.rs index a668746f..ab36c5ae 100644 --- a/crates/benilla-app/src/ui_craft.rs +++ b/crates/benilla-app/src/ui_craft.rs @@ -97,6 +97,7 @@ fn feed_craft( open: Res, actions: Res, spells: Option>, + spell_mods: Res, skill_lines: Option>, focus: Option>, icons: Option>, @@ -123,13 +124,17 @@ fn feed_craft( .line(line) .map(|l| l.name.clone()) .unwrap_or_else(|| format!("Skill {line}")); - let text = crate::ui_script::token_text(&script); + let global = |key: &str| benilla_ui::strings::global(script.lua(), key); let ctx = benilla_formats::TokenContext { durations: &spells.durations, radii: &spells.radii, + ranges: Some(&spells.ranges), lookup: &|id| spells.catalog.get(id), + mods: Some(&*spell_mods), + unmodified_points: false, home_area: None, - text: &text, + global: &global, + printf: &crate::ui_script::token_printf, }; // Admission (`0x5e9c20`): known, not hidden (`Attributes & 0x20`), and `castUI` equal to // the craft type. The client walks its per-type list (`CGPlayer_C + 0x1cd0 + 0x10*type`) diff --git a/crates/benilla-app/src/ui_items/feed.rs b/crates/benilla-app/src/ui_items/feed.rs index ec0913e1..f072d675 100644 --- a/crates/benilla-app/src/ui_items/feed.rs +++ b/crates/benilla-app/src/ui_items/feed.rs @@ -1,7 +1,7 @@ //! The per-frame feeds into the VM: the container snapshots built from the player's descriptor, //! and the item-tooltip views every window reads. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use bevy::prelude::*; @@ -58,8 +58,9 @@ fn spell_desc_text( spells: Option<&crate::ui_action::Spells>, id: u32, home_area: Option<&str>, + mods: Option<&crate::spell::SpellModifiers>, // The VM's strings for the keyed `$d`/`$s` tokens. - text: &dyn Fn(&str, &[i64]) -> Option, + global: &dyn Fn(&str) -> Option, ) -> Option { let sp = spells?; let d = sp.catalog.get(id)?; @@ -69,9 +70,13 @@ fn spell_desc_text( let ctx = benilla_formats::TokenContext { durations: &sp.durations, radii: &sp.radii, + ranges: Some(&sp.ranges), lookup: &|i| sp.catalog.get(i), + mods: mods.map(|m| m as &dyn benilla_formats::SpellMods), + unmodified_points: false, home_area, - text, + global, + printf: &crate::ui_script::token_printf, }; Some(benilla_formats::substitute(desc, d, &ctx)).filter(|t| !t.is_empty()) } @@ -79,6 +84,29 @@ fn spell_desc_text( } } +/// Only descriptions with substitution tokens can change when the caster's spell mods change. +fn spell_has_mod_tokens(spells: Option<&crate::ui_action::Spells>, id: u32) -> bool { + spells + .and_then(|s| s.catalog.get(id)) + .and_then(|d| d.description.as_deref()) + .is_some_and(|desc| desc.contains('$')) +} + +fn item_has_mod_tokens(t: &ItemInfo, spells: Option<&crate::ui_action::Spells>) -> bool { + t.spells + .iter() + .any(|slot| slot.spell_id != 0 && spell_has_mod_tokens(spells, slot.spell_id)) +} + +fn bonuses_have_mod_tokens( + bonuses: &[(u32, u32)], + spells: Option<&crate::ui_action::Spells>, +) -> bool { + bonuses + .iter() + .any(|&(_, spell)| spell_has_mod_tokens(spells, spell)) +} + /// The tooltip's "N Charges" count, 0 for no line. The builder (`0x52d8a0`) reads a template 0 as /// `-1` (`0x52da01`), prints nothing for `-1` (`0x52db51`) and otherwise the absolute value /// (`0x52db56`): food's `-1` shows no line, a `-5` pool "5 Charges". It prints per spell slot; one @@ -102,23 +130,23 @@ fn standing_label(rank: u32, get: &dyn Fn(&str) -> Option) -> Option, + mods: Option<&crate::spell::SpellModifiers>, skill_lines: Option<&benilla_formats::SkillLineCatalog>, home_area: Option<&str>, factions: Option<&benilla_formats::FactionCatalog>, sub_classes: Option<&benilla_formats::ItemSubClassCatalog>, classes: Option<&benilla_formats::ItemClassCatalog>, icons: Option<&ItemDisplays>, - // The VM's own `GlobalStrings.lua`, for the reputation-requirement line. + // The VM's own `GlobalStrings.lua`, for the reputation-requirement line and the `$`-engine's + // keyed tokens. get: &dyn Fn(&str) -> Option, - // The same table, for the `$`-engine's keyed tokens. - text: &dyn Fn(&str, &[i64]) -> Option, ) -> benilla_ui::script::ItemTemplateView { let spell_name = |id: u32| -> Option { spells .and_then(|s| s.catalog.get(id)) .map(|sd| sd.name.clone()) }; - let spell_text = |id: u32| spell_desc_text(spells, id, home_area, text); + let spell_text = |id: u32| spell_desc_text(spells, id, home_area, mods, get); benilla_ui::script::ItemTemplateView { name: t.name.clone(), quality: t.quality, @@ -215,18 +243,26 @@ pub(super) fn feed_item_sets( items: Res, commands: Res, spells: Option>, + spell_mods: Res, skill_lines: Option>, mut pending: Local< crate::ui_script::VmMemo>, >, + mut mod_sensitive: Local>>, ) { let Some(mut script) = script else { return; }; let pending = pending.get(&script); + let mod_sensitive = mod_sensitive.get(&script); for id in script.take_item_set_asks() { pending.entry(id).or_default(); } + if spell_mods.is_changed() { + for &id in mod_sensitive.iter() { + pending.entry(id).or_default(); + } + } if pending.is_empty() { return; } @@ -237,13 +273,18 @@ pub(super) fn feed_item_sets( let skill_catalog = skill_lines.as_deref().map(|s| &s.catalog); let mut done: Vec = Vec::new(); let mut push: Vec<(u32, benilla_ui::script::ItemSetView)> = Vec::new(); - // Dropped before the push, which needs the VM mutably. - let token_text = crate::ui_script::token_text(&script); + // Its borrow of the VM ends before the push, which needs the VM mutably. + let global = |key: &str| benilla_ui::strings::global(script.lua(), key); for (&set_id, last) in pending.iter_mut() { let Some(row) = sets.0.set(set_id) else { done.push(set_id); // no such row: drop the ask continue; }; + if bonuses_have_mod_tokens(&row.bonuses, spell_res) { + mod_sensitive.insert(set_id); + } else { + mod_sensitive.remove(&set_id); + } let members: Vec<(u32, Option)> = row .items .iter() @@ -255,7 +296,8 @@ pub(super) fn feed_item_sets( .bonuses .iter() .filter_map(|&(n, spell)| { - spell_desc_text(spell_res, spell, None, &token_text).map(|desc| (n, desc)) + spell_desc_text(spell_res, spell, None, Some(&spell_mods), &global) + .map(|desc| (n, desc)) }) .collect(), required_skill: row.required_skill, @@ -277,7 +319,6 @@ pub(super) fn feed_item_sets( push.push((set_id, view)); } } - drop(token_text); for (id, view) in push { script.set_item_set(id, view); } @@ -322,6 +363,7 @@ pub(super) fn feed_item_stats( mut items: ResMut, commands: Res, spells: Option>, + spell_mods: Res, skill_lines: Option>, // The `$z` token: the bind point's area (`SMSG_BINDPOINTUPDATE`), named through `AreaTable`. home_bind: Option>, @@ -332,12 +374,14 @@ pub(super) fn feed_item_stats( classes: Option>, icons: Option>, mut pending: Local>>, + mut mod_sensitive: Local>>, mut last_home: Local>>, ) { let Some(mut script) = script else { return; }; let pending = pending.get(&script); + let mod_sensitive = mod_sensitive.get(&script); let last_home = last_home.get(&script); // `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. @@ -355,6 +399,9 @@ pub(super) fn feed_item_stats( pending.extend(items.take_fresh()); pending.extend(script.take_item_stat_asks()); + if spell_mods.is_changed() { + pending.extend(mod_sensitive.iter().copied()); + } if pending.is_empty() { return; } @@ -380,11 +427,17 @@ pub(super) fn feed_item_stats( .filter_map(|id| { pending.remove(&id); let t = items.template(id, 0, &commands)?.clone(); + if item_has_mod_tokens(&t, spell_res) { + mod_sensitive.insert(id); + } else { + mod_sensitive.remove(&id); + } Some(( id, template_view( &t, spell_res, + Some(&spell_mods), skill_catalog, home_area, factions.as_deref().map(|f| f.catalog()), @@ -392,7 +445,6 @@ pub(super) fn feed_item_stats( classes.as_deref().map(|c| &c.0), icons.as_deref(), &get, - &crate::ui_script::token_text(&script), ), )) }) @@ -1778,6 +1830,44 @@ mod tests { } } + #[test] + fn modifier_refresh_tracks_only_item_and_set_descriptions_with_tokens() { + let displays = std::collections::HashMap::from([ + ( + 1, + benilla_formats::SpellDisplay { + description: Some("Restores 5 health.".into()), + ..Default::default() + }, + ), + ( + 2, + benilla_formats::SpellDisplay { + description: Some("Restores $s1 health.".into()), + ..Default::default() + }, + ), + ]); + let spells = crate::ui_action::Spells { + catalog: benilla_formats::SpellCatalog::from_displays(displays), + forms: Default::default(), + ranges: Default::default(), + cast_times: Default::default(), + durations: Default::default(), + radii: Default::default(), + }; + let mut item = crate::items::test_template("Token item"); + item.spells = vec![slot(1, 0)]; + assert!(!super::item_has_mod_tokens(&item, Some(&spells))); + item.spells.push(slot(2, 0)); + assert!(super::item_has_mod_tokens(&item, Some(&spells))); + assert!(!super::bonuses_have_mod_tokens(&[(2, 1)], Some(&spells))); + assert!(super::bonuses_have_mod_tokens( + &[(2, 1), (4, 2)], + Some(&spells) + )); + } + /// The builder's charge gate (`0x52da01`, `0x52db51`). #[test] fn charge_gate_matches_the_real_builder() { @@ -1812,7 +1902,7 @@ mod tests { }; // No string table: Fireball's description reaches no keyed token. - let no_strings = |_: &str, _: &[i64]| None; + let no_strings = |_: &str| None; // The lock chain's Opening and Closing spells, none with a description. for id in [3365u32, 3366, 6246, 6247, 6477, 21651] { @@ -1822,7 +1912,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, &no_strings), + super::spell_desc_text(Some(&spells), id, None, None, &no_strings), None, "spell {id} ({:?}) has no description, so the reference prints no trigger line", d.name @@ -1830,7 +1920,7 @@ mod tests { } // The control: Fireball (133), described, still yields its line. - let fireball = super::spell_desc_text(Some(&spells), 133, None, &no_strings) + let fireball = super::spell_desc_text(Some(&spells), 133, None, None, &no_strings) .expect("a described spell still yields its line"); assert!( fireball.contains("damage"), diff --git a/crates/benilla-app/src/ui_pet/tests.rs b/crates/benilla-app/src/ui_pet/tests.rs index fe797681..8701d1da 100644 --- a/crates/benilla-app/src/ui_pet/tests.rs +++ b/crates/benilla-app/src/ui_pet/tests.rs @@ -818,7 +818,7 @@ fn a_cooldown_alone_fires_the_cooldown_event_and_not_the_bar_update() { app.world_mut() .resource_mut::() .cooldowns - .start_spell(CLAW, &claw(), 0, std::time::Instant::now()); + .start_spell(CLAW, &claw(), 0, std::time::Instant::now(), None); assert_eq!( seen(&mut app), vec!["PET_BAR_UPDATE_COOLDOWN".to_string()], diff --git a/crates/benilla-app/src/ui_script/mod.rs b/crates/benilla-app/src/ui_script/mod.rs index 4d5c5bdb..79338c8d 100644 --- a/crates/benilla-app/src/ui_script/mod.rs +++ b/crates/benilla-app/src/ui_script/mod.rs @@ -660,18 +660,16 @@ pub(crate) fn fire_chat_login(s: &mut benilla_ui::script::UiScript) { } } -/// The `benilla_formats::TokenContext::text` seam: a `GlobalStrings` key, its `%d` holes filled. -pub(crate) fn token_text( - script: &benilla_ui::script::UiScript, -) -> impl Fn(&str, &[i64]) -> Option + '_ { - |key: &str, args: &[i64]| { - let template = benilla_ui::strings::global(script.lua(), key)?; - let args: Vec<_> = args - .iter() - .map(|n| benilla_ui::strings::Arg::D(*n)) - .collect(); - Some(benilla_ui::strings::fill(&template, &args)) - } +/// The `benilla_formats::TokenContext::printf` seam: `SStrPrintf` over the template filler. +pub(crate) fn token_printf(template: &str, args: &[benilla_formats::TokenNumber]) -> String { + let args: Vec<_> = args + .iter() + .map(|n| match n { + benilla_formats::TokenNumber::Int(v) => benilla_ui::strings::Arg::D(*v), + benilla_formats::TokenNumber::Float(v) => benilla_ui::strings::Arg::F(*v), + }) + .collect(); + benilla_ui::strings::fill(template, &args) } /// [`test_ui::load_ui`] for a test module outside `ui_script`, such as `ui_action::feed_tests`. diff --git a/crates/benilla-app/src/ui_tooltip/mod.rs b/crates/benilla-app/src/ui_tooltip/mod.rs index f343fc05..865e8e92 100644 --- a/crates/benilla-app/src/ui_tooltip/mod.rs +++ b/crates/benilla-app/src/ui_tooltip/mod.rs @@ -57,10 +57,8 @@ struct ViewCtx<'a, 'w, 's> { sub_classes: Option<&'a benilla_formats::ItemSubClassCatalog>, /// 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 resolve; `text` is the `%d`-filling form - /// the `$` tokens take. + /// The VM's `GlobalStrings.lua`, where the keyed cells and the `$` tokens' templates resolve. get: &'a dyn Fn(&str) -> Option, - text: &'a dyn Fn(&str, &[i64]) -> Option, } /// Fill a key's template from the VM's `GlobalStrings.lua`; no string shows nothing. @@ -84,9 +82,18 @@ fn spell_tooltip_view( let ctx = benilla_formats::TokenContext { durations: &spells.durations, radii: &spells.radii, + ranges: Some(&spells.ranges), lookup: &|id| spells.catalog.get(id), + mods: Some(vctx.spell_mods), + unmodified_points: false, home_area, - text: vctx.text, + global: vctx.get, + printf: &crate::ui_script::token_printf, + }; + // The aura tooltip `0x52f880` expands the aura text with the points' modifiers off (`52f940`). + let aura_ctx = benilla_formats::TokenContext { + unmodified_points: true, + ..ctx }; // The cost cell (`0x52e8ad`): the resolved `power_cost`, named by power type (keys at // `0x85416c`) and Health for a type outside 0..=4 (`0x52e8fc`: Bloodrage's -2), never a @@ -123,7 +130,7 @@ fn spell_tooltip_view( // `max(reach + casterReach + 1.3333334, 5.0)` and prints like any other, usually "5 yd range". let range = (!d.tooltip_omits_range_line()) .then(|| { - let (min, max) = benilla_formats::min_max_range( + let (min, max) = vctx.spell_mods.min_max_range( d, spells.ranges.get(d.range_index), vctx.combat_reach, @@ -144,16 +151,14 @@ fn spell_tooltip_view( }) .flatten(); // The cast line: gate `0x52eb15` also omits it for a TRADE_SKILL or ATTACK `Effect[0]`; then - // the ladder `0x52eb45`-`0x52ec90`, where a negative base is "Instant cast" (`0x52ebce`) and a - // zero one is "Instant cast" only for a mana spell with a cost (`0x52ec4b`). + // the ladder `0x52eb45`-`0x52ec90` over `GetCastTime(1)` (`52eb4d`), unclamped, where a + // negative time is "Instant cast" (`0x52ebce`) and a zero one is "Instant cast" only for a + // mana spell with a cost (`0x52ec4b`). let cast_time = if d.tooltip_omits_cast_line() { None } else { - let base = spells - .cast_times - .get(d.casting_time_index) - .map(|c| c.base_ms as i32) - .unwrap_or(0); + let level = vctx.store.and_then(|s| s.0.unit_level()).unwrap_or(0); + let base = spells.cast_time_unclamped_ms(d, level, vctx.spell_mods); // `%.3g` templates: the seconds go over as a real. if base > 0 { let (key, v) = if base >= 60_000 { @@ -179,8 +184,16 @@ fn spell_tooltip_view( keyed(vctx.get, key, &[]) } }; - // The larger recovery column (`0x52eada`): Charge's 15 s is its category recovery. - let recovery_ms = d.recovery_ms.max(d.category_recovery_ms); + // The larger recovery column (`0x52eada`): op 11 follows the ranged-speed category pad. + let ranged_ms = if d.ranged_speed_cooldown() { + vctx.store + .and_then(|s| s.0.unit_ranged_attack_time()) + .unwrap_or(0) + } else { + 0 + }; + let (own_recovery, category_recovery) = vctx.spell_mods.spell_cooldowns(d, ranged_ms); + let recovery_ms = own_recovery.max(category_recovery); let cooldown = (recovery_ms > 0) .then(|| { let secs = f64::from(recovery_ms) / 1000.0; @@ -277,7 +290,7 @@ fn spell_tooltip_view( aura_description: d .aura_description .as_deref() - .map(|t| benilla_formats::substitute(t, d, &ctx)) + .map(|t| benilla_formats::substitute(t, d, &aura_ctx)) .unwrap_or_default(), }) } @@ -489,7 +502,6 @@ fn feed_spell_tooltips( let mut built: Vec<(u32, benilla_ui::script::SpellTooltipView)> = Vec::new(); { let get = |key: &str| benilla_ui::strings::global(script.lua(), key); - let text = crate::ui_script::token_text(&script); let mut vctx = ViewCtx { home_area: home_area.as_deref(), form, @@ -502,7 +514,6 @@ fn feed_spell_tooltips( sub_classes: sub_classes.as_deref().map(|c| &c.0), spell_mods, get: &get, - text: &text, }; for id in wanted { if let Some(view) = spell_tooltip_view(id, spells, &mut vctx) { diff --git a/crates/benilla-app/src/ui_tooltip/tests.rs b/crates/benilla-app/src/ui_tooltip/tests.rs index 6cbe1265..e752061c 100644 --- a/crates/benilla-app/src/ui_tooltip/tests.rs +++ b/crates/benilla-app/src/ui_tooltip/tests.rs @@ -8,37 +8,26 @@ struct TestCtx { items: Items, commands: NetCommands, _rx: crossbeam_channel::Receiver, - /// The builder's two lookups, over the shipped `GlobalStrings.lua`: a stub would pass on - /// wording the client never shows. + /// The builder's lookup over the shipped `GlobalStrings.lua`: a stub would pass on wording + /// the client never shows. get: Box, - text: Box, - /// Empty: every cell here is graded as an untalented character. + /// Empty by default; modifier tests populate it explicitly. spell_mods: crate::spell::SpellModifiers, } type Getter = dyn Fn(&str) -> Option; -type Filler = dyn Fn(&str, &[i64]) -> Option; impl TestCtx { fn new() -> Self { let (tx, rx) = crossbeam_channel::unbounded(); - let vm = std::rc::Rc::new(benilla_ui::script::UiScript::new().expect("VM")); + let vm = benilla_ui::script::UiScript::new().expect("VM"); crate::ui_script::load_ui_for_test(&vm, "Interface\\FrameXML\\GlobalStrings.lua"); - let (for_get, for_text) = (vm.clone(), vm); Self { items: Items::default(), commands: NetCommands(tx), _rx: rx, - get: Box::new(move |key| benilla_ui::strings::global(for_get.lua(), key)), + get: Box::new(move |key| benilla_ui::strings::global(vm.lua(), key)), spell_mods: crate::spell::SpellModifiers::default(), - text: Box::new(move |key, args: &[i64]| { - let template = benilla_ui::strings::global(for_text.lua(), key)?; - let args: Vec<_> = args - .iter() - .map(|n| benilla_ui::strings::Arg::D(*n)) - .collect(); - Some(benilla_ui::strings::fill(&template, &args)) - }), } } @@ -81,7 +70,6 @@ impl TestCtx { sub_classes, spell_mods: &self.spell_mods, get: self.get.as_ref(), - text: self.text.as_ref(), } } } @@ -177,6 +165,104 @@ fn fireball_view_on_real_data() { } } +/// Improved Devotion Aura's +25% on op 8 reaches the spell's description, 55 armor to 68; the +/// aura text expands as the aura tooltip `0x52f880` expands it, with the points' modifiers off +/// (`52f940`), so it keeps 55. +#[test] +fn improved_devotion_aura_updates_the_description_but_not_the_aura_text() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let mut spells = Spells::empty_for_tests(); + spells.catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"); + spells.ranges = benilla_formats::load_spell_ranges(&mut chain).expect("SpellRange.dbc"); + spells.durations = + benilla_formats::load_spell_durations(&mut chain).expect("SpellDuration.dbc"); + spells.radii = benilla_formats::load_spell_radii(&mut chain).expect("SpellRadius.dbc"); + let devotion = spells.catalog.get(465).expect("Devotion Aura rank 1"); + let bit = devotion.spell_family_flags.trailing_zeros() as u8; + let mut t = TestCtx::new(); + let mut objs = no_objects(); + let objects = objs.get(); + let base = spell_tooltip_view(465, &spells, &mut t.ctx(&objects, 0, None)).unwrap(); + assert!(base.description.contains("55 additional armor")); + assert!(base.aura_description.contains("55")); + + t.spell_mods.set_class_family(devotion.spell_family); + t.spell_mods.set(false, bit, 8, 25); + let improved = spell_tooltip_view(465, &spells, &mut t.ctx(&objects, 0, None)).unwrap(); + assert!(improved.description.contains("68 additional armor")); + assert_eq!(improved.aura_description, base.aura_description); +} + +/// Fire Blast rank 1's 8 s is its category recovery, its own recovery 0; Improved Fire Blast's +/// flat op 11 shortens the category value, and the cell shows the larger column (`0x52eada`). +#[test] +fn improved_fire_blast_shortens_the_cooldown_cell() { + let data = benilla_formats::wow_data_or_skip!(); + let mut chain = benilla_formats::open_chain(&data).expect("open chain"); + let mut spells = Spells::empty_for_tests(); + spells.catalog = benilla_formats::load_spell_catalog(&mut chain).expect("Spell.dbc"); + let fire_blast = spells.catalog.get(2136).expect("Fire Blast rank 1"); + assert_eq!( + (fire_blast.recovery_ms, fire_blast.category_recovery_ms), + (0, 8_000) + ); + let bit = fire_blast.spell_family_flags.trailing_zeros() as u8; + let mut t = TestCtx::new(); + let mut objs = no_objects(); + let objects = objs.get(); + let base = spell_tooltip_view(2136, &spells, &mut t.ctx(&objects, 0, None)).unwrap(); + assert_eq!(base.cooldown.as_deref(), Some("8 sec cooldown")); + + t.spell_mods.set_class_family(fire_blast.spell_family); + t.spell_mods + .set(true, bit, crate::spell::OP_COOLDOWN, -1_500); + let improved = spell_tooltip_view(2136, &spells, &mut t.ctx(&objects, 0, None)).unwrap(); + assert_eq!(improved.cooldown.as_deref(), Some("6.5 sec cooldown")); +} + +/// The cast cell reads `GetCastTime(1)` (`52eb4b`): op 10 applies and nothing clamps, so a +/// modifier past the whole cast time reaches the negative "Instant cast" arm (`0x52ebce`), where +/// a clamped zero would take the no-mana "Instant" (`0x52ec4b`). +#[test] +fn the_cast_cell_takes_op_10_unclamped() { + // The cells fill the install's `GlobalStrings.lua`. + let _data = benilla_formats::wow_data_or_skip!(); + let mut spells = Spells::empty_for_tests(); + let rage_cast = benilla_formats::SpellDisplay { + name: "Rage Cast".into(), + casting_time_index: 5, + power_type: 1, + spell_family: 4, + spell_family_flags: 1, + ..Default::default() + }; + spells.catalog = + benilla_formats::SpellCatalog::from_displays([(900_001, rage_cast)].into_iter().collect()); + spells.cast_times = benilla_formats::SpellCastTimeCatalog::from_rows([( + 5, + benilla_formats::SpellCastTime { + base_ms: 1500, + per_level_ms: 0, + minimum_ms: 1500, + }, + )]); + let mut objs = no_objects(); + let objects = objs.get(); + let cell = |flat: i32| { + let mut t = TestCtx::new(); + t.spell_mods.set_class_family(4); + t.spell_mods.set(true, 0, crate::spell::OP_CAST_TIME, flat); + spell_tooltip_view(900_001, &spells, &mut t.ctx(&objects, 0, None)) + .unwrap() + .cast_time + }; + assert_eq!(cell(0).as_deref(), Some("1.5 sec cast")); + assert_eq!(cell(-500).as_deref(), Some("1 sec cast")); + assert_eq!(cell(-1500).as_deref(), Some("Instant"), "exactly zero"); + assert_eq!(cell(-2000).as_deref(), Some("Instant cast"), "below zero"); +} + #[test] fn cost_and_cast_cells_on_real_data() { let data = benilla_formats::wow_data_or_skip!(); diff --git a/crates/benilla-formats/src/lib.rs b/crates/benilla-formats/src/lib.rs index f8bde36e..f9112aec 100644 --- a/crates/benilla-formats/src/lib.rs +++ b/crates/benilla-formats/src/lib.rs @@ -197,12 +197,12 @@ mod spells; pub use spells::{ cc_exemption, grants_immunity, load_shapeshift_forms, load_spell_cast_times, load_spell_catalog, load_spell_dispel_types, load_spell_durations, load_spell_radii, - load_spell_ranges, min_max_range, substitute, CcExemption, FormRefusal, LearnAnnouncement, - LearnEffect, OpenLock, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, SpellCatalog, - SpellDispelTypes, SpellDisplay, SpellDuration, SpellDurationCatalog, SpellRadius, - SpellRadiusCatalog, SpellRange, SpellRangeCatalog, TokenContext, ATTR_CASTABLE_WHILE_DEAD, - ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, - ON_NEXT_SWING_RANGE, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, + load_spell_ranges, min_max_range, soft_modify, substitute, CcExemption, FormRefusal, + LearnAnnouncement, LearnEffect, OpenLock, ShapeshiftForm, SpellCastTime, SpellCastTimeCatalog, + SpellCatalog, SpellDispelTypes, SpellDisplay, SpellDuration, SpellDurationCatalog, SpellMods, + SpellRadius, SpellRadiusCatalog, SpellRange, SpellRangeCatalog, TokenContext, TokenNumber, + ATTR_CASTABLE_WHILE_DEAD, ATTR_NOT_IN_COMBAT, ATTR_ONLY_STEALTHED, COMBAT_REACH_ADD, + MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE, SPELL_ATTR_IS_TRADESKILL, SPELL_EFFECT_CREATE_ITEM, SPELL_EFFECT_ENCHANT_ITEM, SPELL_EFFECT_ENCHANT_ITEM_TEMPORARY, SPELL_EFFECT_LEARN_PET_SPELL, SPELL_EFFECT_LEARN_SPELL, SPELL_EFFECT_PROSPECTING, SPELL_EFFECT_SKILL_STEP, SPELL_EFFECT_SKINNING, SPELL_EFFECT_TRADE_SKILL, diff --git a/crates/benilla-formats/src/spells/cast_times.rs b/crates/benilla-formats/src/spells/cast_times.rs index ccbc15da..598ae550 100644 --- a/crates/benilla-formats/src/spells/cast_times.rs +++ b/crates/benilla-formats/src/spells/cast_times.rs @@ -11,26 +11,30 @@ use benilla_dbc::{FieldType, Schema, SchemaField}; use crate::dbc::{i32_at, parse, u32_at}; -/// One `SpellCastTimes.dbc` row, in ms. +/// One `SpellCastTimes.dbc` row, in ms. All three columns are signed: row 18, the hunter shots', +/// is `{-1000000, 0, -1000000}`. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct SpellCastTime { - /// The cast time a level-independent tooltip shows; 0 is instant. - pub base_ms: u32, + /// The cast time at the spell's `BaseLevel`; 0 is instant. + pub base_ms: i32, /// Added per caster level above the spell's `BaseLevel`; negative on a few rows (row 10). pub per_level_ms: i32, - /// The floor the level-scaled cast time clamps to. - pub minimum_ms: u32, + /// The floor the level-scaled cast time is raised to. + pub minimum_ms: i32, } impl SpellCastTime { - /// The level-scaled cast time (`0x6e3340`): `base + perLevel·(casterLevel − baseLevel)`, - /// floored to the row minimum and to zero. `base_level` is the DBC's `baseLevel` - /// ([`crate::spells::SpellDisplay::base_level`]), not `spellLevel`. Spell-mod op `0xa` is not - /// applied. - pub fn resolved_ms(&self, caster_level: u32, base_level: u32) -> u32 { - let delta = i64::from(caster_level.saturating_sub(base_level)); - let scaled = i64::from(self.base_ms) + i64::from(self.per_level_ms) * delta; - scaled.max(i64::from(self.minimum_ms)).max(0) as u32 + /// The level-scaled cast time (`0x6e3395`-`0x6e33b4`): `base + perLevel·(casterLevel − + /// baseLevel)`, the level delta signed, raised to the row minimum and nothing else, so row 18 + /// stays negative. `base_level` is the DBC's `baseLevel` + /// ([`crate::spells::SpellDisplay::base_level`]), not `spellLevel`. Spell-mod op `0xa` and the + /// clamp at zero are the caller's. + pub fn resolved_ms(&self, caster_level: u32, base_level: u32) -> i32 { + let delta = (caster_level as i32).wrapping_sub(base_level as i32); + let scaled = self + .base_ms + .wrapping_add(self.per_level_ms.wrapping_mul(delta)); + scaled.max(self.minimum_ms) } } @@ -41,6 +45,13 @@ pub struct SpellCastTimeCatalog { } impl SpellCastTimeCatalog { + /// A catalog of the given rows, for callers' tests. + pub fn from_rows(rows: impl IntoIterator) -> Self { + SpellCastTimeCatalog { + times: rows.into_iter().collect(), + } + } + pub fn get(&self, index: u32) -> Option<&SpellCastTime> { self.times.get(&index) } @@ -76,9 +87,9 @@ pub fn load_spell_cast_times(chain: &mut Chain) -> Result times.insert( id, SpellCastTime { - base_ms: u32_at(r, 1).unwrap_or(0), + base_ms: i32_at(r, 1).unwrap_or(0), per_level_ms: i32_at(r, 2).unwrap_or(0), - minimum_ms: u32_at(r, 3).unwrap_or(0), + minimum_ms: i32_at(r, 3).unwrap_or(0), }, ); } @@ -115,13 +126,16 @@ mod tests { assert_eq!(scaling.resolved_ms(13, 10), 700); assert_eq!(scaling.resolved_ms(60, 10), 500, "the minimum floor"); - // A hypothetical floor-less shrink clamps at zero rather than going negative. - let floorless = SpellCastTime { - base_ms: 100, - per_level_ms: -100, - minimum_ms: 0, + // Below the spell's level the delta goes negative and the cast lengthens. + assert_eq!(scaling.resolved_ms(8, 10), 1200); + + // Row 18: a negative base under a negative minimum stays negative. + let shots = SpellCastTime { + base_ms: -1_000_000, + per_level_ms: 0, + minimum_ms: -1_000_000, }; - assert_eq!(floorless.resolved_ms(60, 1), 0); + assert_eq!(shots.resolved_ms(60, 1), -1_000_000); } #[test] diff --git a/crates/benilla-formats/src/spells/catalog_tests.rs b/crates/benilla-formats/src/spells/catalog_tests.rs index 7b56245a..efb744c8 100644 --- a/crates/benilla-formats/src/spells/catalog_tests.rs +++ b/crates/benilla-formats/src/spells/catalog_tests.rs @@ -593,6 +593,11 @@ fn real_spell_catalog_reads_tooltip_columns() { assert_eq!(durations.get(30).unwrap().base_ms, 1_800_000, "30 minutes"); assert_eq!(frost_armor.casting_time_index, 1); assert_eq!(cast_times.get(1).unwrap().base_ms, 0, "instant"); + assert_eq!( + cast_times.get(18).unwrap().resolved_ms(60, 1), + -1_000_000, + "the hunter shots' row reads signed and stays negative" + ); assert_eq!( frost_armor.effect_apply_aura[0], 22, "SPELL_AURA_MOD_RESISTANCE" diff --git a/crates/benilla-formats/src/spells/display.rs b/crates/benilla-formats/src/spells/display.rs index 7ca16db6..fefe3e27 100644 --- a/crates/benilla-formats/src/spells/display.rs +++ b/crates/benilla-formats/src/spells/display.rs @@ -147,8 +147,13 @@ pub struct SpellDisplay { pub duration_index: u32, /// `CastingTimeIndex` (column 18): the `SpellCastTimes.dbc` row; row 1 is instant. pub casting_time_index: u32, + /// `ProcFlags` (column 24, `SpellRec+0x60`): bit 0 makes the description's `$t` a fixed five + /// seconds. + pub proc_flags: u32, /// `ProcChance` (column 25): percent; vmangos reads 101 as always, with no roll. pub proc_chance: u32, + /// `ProcCharges` (column 26): the description expander's `$n` value before op 4. + pub proc_charges: 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 @@ -245,7 +250,9 @@ impl Default for SpellDisplay { aura_description: None, duration_index: 0, casting_time_index: 0, + proc_flags: 0, proc_chance: 0, + proc_charges: 0, effect_base_points: [0; 3], effect_die_sides: [0; 3], effect_base_dice: [0; 3], diff --git a/crates/benilla-formats/src/spells/mod.rs b/crates/benilla-formats/src/spells/mod.rs index b127fd33..1a558bf3 100644 --- a/crates/benilla-formats/src/spells/mod.rs +++ b/crates/benilla-formats/src/spells/mod.rs @@ -20,6 +20,7 @@ mod duration; mod forms; mod radius; mod ranges; +mod soft_float; mod tokens; pub use cast_times::{load_spell_cast_times, SpellCastTime, SpellCastTimeCatalog}; @@ -34,7 +35,8 @@ pub use ranges::{ load_spell_ranges, min_max_range, SpellRange, SpellRangeCatalog, COMBAT_REACH_ADD, MELEE_RANGE_FLOOR, ON_NEXT_SWING_RANGE, }; -pub use tokens::{substitute, TokenContext}; +pub use soft_float::modify as soft_modify; +pub use tokens::{substitute, SpellMods, TokenContext, TokenNumber}; use std::collections::HashMap; @@ -156,7 +158,9 @@ const COL_AURA_DESCRIPTION_ENUS: usize = 147; const COL_DURATION_INDEX: usize = 30; /// `CastingTimeIndex` (`+0x48`): `Spell_C::GetCastTime` (`0x6e3340`) looks it up at `0xc0d878`. 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; /// The per-effect `[3]` arrays, each constant slot 0; die sides and base points are signed. const COL_EFFECT_DIE_SIDES_1: usize = 64; @@ -561,7 +565,9 @@ pub fn load_spell_catalog(chain: &mut Chain) -> Result { aura_description: str_at(&spells_set, r, COL_AURA_DESCRIPTION_ENUS), duration_index: u32_at(r, COL_DURATION_INDEX).unwrap_or(0), casting_time_index: u32_at(r, COL_CASTING_TIME_INDEX).unwrap_or(0), + 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), effect_base_points: std::array::from_fn(|i| { i32_at(r, COL_EFFECT_BASE_POINTS_1 + i).unwrap_or(0) }), diff --git a/crates/benilla-formats/src/spells/soft_float.rs b/crates/benilla-formats/src/spells/soft_float.rs new file mode 100644 index 00000000..8619b1d4 --- /dev/null +++ b/crates/benilla-formats/src/spells/soft_float.rs @@ -0,0 +1,203 @@ +//! The reference's software single-precision arithmetic, which `GetEffectPoints 0x6e3800` and its +//! modifier applier `0x6e6c30` run on instead of the FPU: an int-to-float (`0x761260`), an add +//! (`0x760e20`) and a multiply (`0x760be0`) that truncate where IEEE rounds, and a floor +//! (`0x760fb0`) and ceil (`0x761040`). Values are `f32` bit patterns throughout. + +/// `0x761260`: an integer's float; exact below 2^24, the low bits shifted out above it. +fn from_int(v: i32) -> u32 { + if v == 0 { + return 0; + } + let sign = if v < 0 { 0x8000_0000 } else { 0 }; + let a = v.unsigned_abs(); + let lz = a.leading_zeros() as i32; + let shift = lz - 8; + let mant = if shift >= 0 { a << shift } else { a >> -shift }; + let exp = (31 - lz + 0x7f) as u32; + sign | exp << 23 | mant & 0x7f_ffff +} + +/// `0x760be0`: multiply the 24-bit significands and keep the high word, truncating. A power of +/// two on either side (a zero fraction) adds exponents and keeps the other fraction exactly; a +/// zero exponent on either side, or an exponent sum at or below zero, is 0. +fn mul(a: u32, b: u32) -> u32 { + let sign = (a ^ b) & 0x8000_0000; + let (ae, be) = (a & 0x7f80_0000, b & 0x7f80_0000); + let (af, bf) = (a & 0x7f_ffff, b & 0x7f_ffff); + let exp = be.wrapping_add(ae).wrapping_sub(0x3f80_0000); + let underflow = (exp.wrapping_add(0xff80_0000) as i32) < 0; + if af == 0 || bf == 0 { + if ae == 0 || be == 0 || underflow { + return 0; + } + return exp | bf | af | sign; + } + let product = u64::from((af | 0x80_0000) << 8) * u64::from((bf | 0x80_0000) << 8); + let mut hi = (product >> 32) as u32; + let carry = hi >> 31; + hi >>= carry; + if underflow { + return 0; + } + (hi >> 7) & 0x7f_ffff | (carry << 23).wrapping_add(exp) | sign +} + +/// `0x760e20`: align the two-guard-bit significands by an arithmetic shift, add, renormalise by +/// shifting out, truncating. A zero exponent returns the other operand; an exponent gap of 23 or +/// more returns the larger. +fn add(a: u32, b: u32) -> u32 { + let ae = a & 0x7f80_0000; + if ae == 0 { + return b; + } + let be = b & 0x7f80_0000; + if be == 0 { + return a; + } + let significand = |x: u32| { + let m = ((x & 0x7f_ffff | 0x80_0000) << 1) as i32; + if x & 0x8000_0000 != 0 { + -m + } else { + m + } + }; + let (mut sa, mut sb) = (significand(a), significand(b)); + let gap = be.wrapping_sub(ae) as i32; + let exp = if gap > 0 { + if gap >= 0xb80_0000 { + return b; + } + sa >>= gap >> 23; + be + } else { + if gap <= -0xb80_0000 { + return a; + } + sb >>= (ae - be) >> 23; + ae + }; + let sum = sa.wrapping_add(sb); + if sum == 0 { + return 0; + } + let sign = sum as u32 & 0x8000_0000; + let mag = sum.unsigned_abs(); + let shift = 31 - mag.leading_zeros() as i32 - 23; + let mant = if shift >= 0 { + mag >> shift + } else { + mag << -shift + }; + (((shift - 1) << 23) as u32).wrapping_add(exp) | sign | mant & 0x7f_ffff +} + +/// `0x760fb0`: floor; under one in magnitude a negative nonzero value is -1.0 and anything else +/// +0.0. +fn floor(x: u32) -> u32 { + let v = f32::from_bits(x); + if v.abs() < 1.0 { + return if v < 0.0 { (-1.0f32).to_bits() } else { 0 }; + } + v.floor().to_bits() +} + +/// `0x761040`: ceil; under one in magnitude a negative nonzero value is +0.0 and anything else +/// 1.0, zero included (`76105d`: the sign mask of a zero tests clear). +fn ceil(x: u32) -> u32 { + let v = f32::from_bits(x); + if v.abs() < 1.0 { + return if v < 0.0 { 0 } else { 1.0f32.to_bits() }; + } + v.ceil().to_bits() +} + +/// The applier `0x6e6c30`: `((value + flat) × pct) × 0.01`, each step in the soft arithmetic, +/// the 0.01 the single-precision constant `0xcf0b0c` (`0x3c23d70a`). +pub fn modify(value: f32, flat: i32, pct: i32) -> f32 { + let sum = add(value.to_bits(), from_int(flat)); + f32::from_bits(mul(mul(sum, from_int(pct)), 0x3c23_d70a)) +} + +/// An integer's float as `0x761260` builds it. +pub(crate) fn int_to_float(v: i32) -> f32 { + f32::from_bits(from_int(v)) +} + +/// `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`). +pub(crate) fn quantize(x: f32) -> f32 { + let bits = x.to_bits(); + let scaled = if bits & 0x7f80_0000 != 0 { + bits.wrapping_add(0x380_0000) + } else { + bits + }; + let rounded = floor(add(scaled, 0.5f32.to_bits())); + // A zero, either sign, flushes to +0.0; otherwise the exponent drops by 7. + let shrunk = rounded.wrapping_add(0xfc80_0000); + let flips = ((rounded.wrapping_sub(0x400_0000) ^ rounded) as i32) < 0; + f32::from_bits(if flips { 0 } else { shrunk }) +} + +/// The integral bounds `GetEffectPoints` hands a rounding effect (`6e3a71`-`6e3a89`). +pub(crate) fn floor_ceil(min: f32, max: f32) -> (f32, f32) { + ( + f32::from_bits(floor(min.to_bits())), + f32::from_bits(ceil(max.to_bits())), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Vectors from a bit-level transcription of the routines: the soft results sit one unit in + /// the last place under the FPU's where the FPU rounds up. + #[test] + fn the_applier_truncates_where_the_fpu_rounds() { + for (value, flat, pct, bits) in [ + (55.0, 0, 125, 0x4289_7fff), + (93.0, 0, 102, 0x42bd_b851), + (1000.0, -5, 90, 0x445f_dfff), + (3.0, 0, 100, 0x403f_ffff), + (0.0, 5, 110, 0x40af_ffff), + (-31.0, 0, 125, 0xc21a_ffff), + (325.0, 0, 115, 0x43ba_dfff), + ] { + assert_eq!( + modify(value, flat, pct).to_bits(), + bits, + "({value} + {flat}) × {pct}%" + ); + } + let chained = modify(modify(93.0, 0, 102), 0, 101); + assert_eq!(chained.to_bits(), 0x42bf_9dfe); + assert_eq!(quantize(chained), 12_263.0 / 128.0); + } + + #[test] + fn the_quantizer_rounds_to_128ths_and_flushes_zero() { + assert_eq!(quantize(f32::from_bits(0x4289_7fff)), 68.75); + assert_eq!(quantize(0.3), 0.296_875); + assert_eq!(quantize(-2.3), -2.296_875); + assert_eq!(quantize(0.0).to_bits(), 0); + assert_eq!(quantize(-0.0).to_bits(), 0); + assert_eq!(quantize(0.001), 0.0); + assert_eq!(quantize(55.0), 55.0); + assert_eq!( + f32::from_bits(add((-31.0f32).to_bits(), 0.3f32.to_bits())), + -30.7 + ); + } + + #[test] + fn floor_and_ceil_keep_the_reference_quirks() { + assert_eq!(floor_ceil(4.2, 4.2), (4.0, 5.0)); + assert_eq!(floor_ceil(-0.5, -0.5), (-1.0, 0.0)); + assert_eq!(floor_ceil(0.0, 0.0), (0.0, 1.0), "ceil(+0.0) is 1.0"); + assert_eq!(floor_ceil(7.0, 7.0), (7.0, 7.0)); + assert_eq!(int_to_float(-1_000_000), -1_000_000.0); + } +} diff --git a/crates/benilla-formats/src/spells/tokens.rs b/crates/benilla-formats/src/spells/tokens.rs index 86f620e8..d20999fa 100644 --- a/crates/benilla-formats/src/spells/tokens.rs +++ b/crates/benilla-formats/src/spells/tokens.rs @@ -1,153 +1,309 @@ //! The spell-description `$`-token engine the 1.12 client runs over `Spell.dbc` description and -//! aura text (`0x5075f0` → `0x507710`), effect values from `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, and an -//! `EffectAmplitude` of 0 is the reference's 5000 ms period (`$t`, `$o`). `$g` always takes the -//! first form, as there is no gender input, and `$r`, `$u` and any unknown or unresolved token -//! stay raw. +//! 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. -use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog}; +use super::soft_float; +use super::{SpellDisplay, SpellDurationCatalog, SpellRadiusCatalog, SpellRangeCatalog}; + +/// The caster's spell-modifier appliers over its tables (`GetSpellModifiers 0x6e6b30`): the +/// integer `0x6e6af0`, the FPU float `0x6e6bf0` and the software float `0x6e6c30` +/// ([`super::soft_modify`]), each returning `value` unchanged when no modifier matches. +pub trait SpellMods { + fn apply_int(&self, d: &SpellDisplay, op: u8, value: i32) -> i32; + fn apply_float(&self, d: &SpellDisplay, op: u8, value: f32) -> f32; + fn apply_soft(&self, d: &SpellDisplay, op: u8, value: f32) -> f32; +} + +/// A value supplied to a `%` hole. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum TokenNumber { + Int(i64), + Float(f64), +} /// The inputs one substitution runs over. `lookup` resolves cross-spell references (`$1234s1`). pub struct TokenContext<'a> { pub durations: &'a SpellDurationCatalog, pub radii: &'a SpellRadiusCatalog, + pub ranges: Option<&'a SpellRangeCatalog>, 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>, + /// `0x5075f0`'s last argument, set only by the aura tooltip `0x52f880` (`52f940`): the effect + /// points skip their modifiers (`6e3925`), and so does the duration of `$d` and `$o` + /// (`0x6ea000`'s flag, pushed at `507ccd` and `507917`). + pub unmodified_points: bool, /// The `$z` token: the home-bind area's name, from `SMSG_BINDPOINTUPDATE`'s area id through /// `AreaTable.dbc`; `None` leaves the token raw. pub home_area: Option<&'a str>, - /// Resolves a `GlobalStrings` key and fills its `%d` holes; the caller owns the string table. - /// Integer holes only: the keys that take numbers are `INT_SPELL_*` ones, whose float twins - /// would print "14.0 to 22.0" for "14 to 22". `None` leaves the token raw. - pub text: &'a dyn Fn(&str, &[i64]) -> Option, + /// `GetText 0x703bf0`: a `GlobalStrings` template by key. `None` leaves the token raw. + pub global: &'a dyn Fn(&str) -> Option, + /// `SStrPrintf 0x64a7f0`: a template's `%` holes filled, as the CRT formats them. + pub printf: &'a dyn Fn(&str, &[TokenNumber]) -> String, } -/// The effect's `(min, max)`, flat term only (`0x6e3800`). -fn effect_bounds(d: &SpellDisplay, slot: usize) -> (i64, i64) { - let base = i64::from(*d.effect_base_points.get(slot).unwrap_or(&0)); - let dice = i64::from(*d.effect_base_dice.get(slot).unwrap_or(&0)); - let sides = i64::from(*d.effect_die_sides.get(slot).unwrap_or(&0)); - (base + dice, base + sides * dice) +/// A `GlobalStrings` template, filled. +fn keyed(ctx: &TokenContext, key: &str, args: &[TokenNumber]) -> Option { + Some((ctx.printf)(&(ctx.global)(key)?, args)) } -/// A spell's duration in ms, flat term only; -1 is permanent. +/// `"%.1f"` (`0x84f398`). +fn tenths(ctx: &TokenContext, v: f32) -> String { + (ctx.printf)("%.1f", &[TokenNumber::Float(f64::from(v))]) +} + +/// `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) { + match effect { + 2 | 9 | 17 | 31 | 58 | 121 => (true, true), + 6 | 27 | 35 | 119 | 128 | 129 => match aura { + 3 | 15 | 43 | 53 | 89 => (true, true), + 8 | 20 | 21 | 24 | 62 | 63 | 64 | 162 => (true, false), + _ => (false, false), + }, + 8 | 10 | 30 | 62 | 67 | 75 => (true, false), + _ => (false, false), + } +} + +/// `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) { + let base = d.effect_base_points[slot]; + let dice = d.effect_base_dice[slot]; + 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 aura = d.effect_apply_aura[slot]; + let (rounds, damage) = classify(d.effects[slot], aura); + if let Some(mods) = ctx.mods.filter(|_| !ctx.unmodified_points) { + let damage_op = damage.then_some(if aura == 3 { 22 } else { 0 }); + for op in [Some(8), damage_op, aura_op(d, slot)].into_iter().flatten() { + min = mods.apply_soft(d, op, min); + max = mods.apply_soft(d, op, max); + } + } + let (min, max) = (soft_float::quantize(min), soft_float::quantize(max)); + if rounds { + soft_float::floor_ceil(min, max) + } else { + (min, max) + } +} + +/// The aura's own op (`0x6e397e`, jump table `0x6e3ab8` over the byte table `0x6e3ad0`, indexed +/// by `aura − 10`): 10/103/183 op 2 (`6e3996`), the speed auras op 12 (`6e39a6`), 138 op 23 +/// (`6e39b6`), 65 op 24 (`6e39c6`), 99 op 3 (`6e39d6`). +fn aura_op(d: &SpellDisplay, slot: usize) -> Option { + match d.effect_apply_aura[slot] { + 10 | 103 | 183 => Some(2), + 31 | 32 | 33 | 58 | 129 | 130 | 171 | 172 => Some(12), + 138 => Some(23), + 65 => Some(24), + 99 => Some(3), + _ => None, + } +} + +fn modify_int(ctx: &TokenContext, d: &SpellDisplay, op: u8, value: i32) -> i32 { + ctx.mods.map_or(value, |m| m.apply_int(d, op, value)) +} + +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)?; - Some(i64::from(row.base_ms)) + let resolved = row.base_ms.min(row.max_ms); + Some(i64::from(if ctx.unmodified_points { + resolved + } else { + modify_int(ctx, d, 1, resolved) + })) } -/// The duration in the largest whole `INT_SPELL_DURATION_*` unit, the ladder `0x52fa50` walks, -/// or `SPELL_DURATION_UNTIL_CANCELLED` when permanent. The plural pick is `GetText`'s, the `_P1` -/// twin unless exactly one; only HOURS ships one, so the others fall back to the bare key. +/// 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. fn duration_text(ms: i64, ctx: &TokenContext) -> Option { if ms < 0 { - return (ctx.text)("SPELL_DURATION_UNTIL_CANCELLED", &[]); + return keyed(ctx, "SPELL_DURATION_UNTIL_CANCELLED", &[]); } - let secs = ms / 1000; - let (unit, n) = if secs < 60 { - ("SEC", secs) - } else if secs < 3_600 { - ("MIN", secs / 60) - } else if secs < 86_400 { - ("HOURS", secs / 3_600) + let (unit, unit_ms) = if ms < 60_000 { + ("SEC", 1_000) + } else if ms < 3_600_000 { + ("MIN", 60_000) + } else if ms < 86_400_000 { + ("HOURS", 3_600_000) } else { - ("DAYS", secs / 86_400) + ("DAYS", 86_400_000) }; + let amount = ms as f64 / unit_ms as f64; + if (amount - amount.trunc()).abs() >= 0.01 { + return keyed( + ctx, + &format!("SPELL_DURATION_{unit}"), + &[TokenNumber::Float(amount)], + ); + } + let n = ms / unit_ms; let key = format!("INT_SPELL_DURATION_{unit}"); (n != 1) - .then(|| (ctx.text)(&format!("{key}_P1"), &[n])) + .then(|| keyed(ctx, &format!("{key}_P1"), &[TokenNumber::Int(n)])) .flatten() - .or_else(|| (ctx.text)(&key, &[n])) + .or_else(|| keyed(ctx, &key, &[TokenNumber::Int(n)])) } -/// `INT_SPELL_POINTS_SPREAD_TEMPLATE` ("%d to %d"), not the float `SPELL_POINTS_SPREAD_TEMPLATE`, -/// which would print "14.0 to 22.0" where Fireball rank 1 says "14 to 22". -fn spread_text(min: i64, max: i64, ctx: &TokenContext) -> Option { - (ctx.text)("INT_SPELL_POINTS_SPREAD_TEMPLATE", &[min, max]) -} - -/// Trim a float to the client's terse style (no trailing zeros: 2.5 → "2.5", 3.0 → "3"). -fn trim_float(v: f64) -> String { - if (v - v.round()).abs() < 1e-9 { - format!("{}", v.round() as i64) +/// The expander's integer read of a scaled bound (`5079cc`-`507b04`): the bound truncated, and +/// whether it counts as whole, within 0.001 (`[0x801360]`) of its floor or its ceiling. A bound +/// just under its ceiling reads as the ceiling. +fn whole(v: f32) -> (i32, bool) { + let x = f64::from(v); + let eps = f64::from(0.001f32); + let truncated = x.floor() as i32; + let near_ceil = x.ceil() - x < eps; + if x - x.floor() < eps || near_ceil { + let up = near_ceil && truncated != x.ceil() as i32; + (truncated + i32::from(up), true) } else { - format!("{v:.1}") + (truncated, false) } } +/// The `$s $S $m $M $o $O` arm (`0x5078b4`): the effect points, spread over the duration for `$o` +/// (`507926`: duration × points / period, 0 without a positive period or duration, a period of +/// 0 read as 5000), then made positive and scaled (`507952`) and printed by the letter's rule +/// (`507b1a`-`507c4a`). The text and the integer the `$l` plural picker keys on (`[0xbe0b84]`). +fn points_text( + letter: char, + slot: usize, + d: &SpellDisplay, + ctx: &TokenContext, + scale: f32, +) -> 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); + 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 + }; + (min, max) = if duration > 0 { + let over = |v: f32| (duration as f64 * f64::from(v) / f64::from(period)) as f32; + (over(min), over(max)) + } else { + (0.0, 0.0) + }; + } + let scaled = |v: f32| (f64::from(v.abs()) * f64::from(scale)) as f32; + let (lo, hi) = (scaled(min), scaled(max)); + let ((lo_int, lo_whole), (hi_int, hi_whole)) = (whole(lo), whole(hi)); + let plural = match letter { + 'm' if lo_whole => lo_int, + 'm' => 2, + _ if hi_whole => hi_int, + _ => 2, + }; + let int = |n: i32| TokenNumber::Int(i64::from(n)); + let text = match letter { + 'm' if lo_whole => lo_int.to_string(), + 'm' => tenths(ctx, lo), + 'M' if hi_whole => hi_int.to_string(), + 'M' => tenths(ctx, hi), + // Equal bounds print one number, `%d` unless `$S` holds a fraction (`507ba3`). + _ if min == max && (lo_whole || letter != 'S') => lo_int.to_string(), + _ if min == max => tenths(ctx, lo), + _ if lo_whole && hi_whole => keyed( + ctx, + "INT_SPELL_POINTS_SPREAD_TEMPLATE", + &[int(lo_int), int(hi_int)], + )?, + 'S' => keyed( + ctx, + "SPELL_POINTS_SPREAD_TEMPLATE", + &[ + TokenNumber::Float(f64::from(lo)), + TokenNumber::Float(f64::from(hi)), + ], + )?, + // A fractional spread under `s`/`o`/`O`: the minimum truncated, a fractional maximum + // truncated plus one (`507c2a`-`507c4a`). + _ => { + let top = if hi_whole { hi_int } else { hi as i32 + 1 }; + keyed( + ctx, + "INT_SPELL_POINTS_SPREAD_TEMPLATE", + &[int(lo as i32), int(top)], + )? + } + }; + Some((text, f64::from(plural))) +} + /// One token's text and the numeric value the `$l` plural picker keys on. fn token_value( letter: char, slot: usize, d: &SpellDisplay, ctx: &TokenContext, - scale: f64, + scale: f32, ) -> Option<(String, f64)> { - let scaled = |v: i64| -> i64 { - if scale == 1.0 { - v - } else { - (v as f64 * scale).round() as i64 - } - }; match letter.to_ascii_lowercase() { - 's' => { - let (min, max) = effect_bounds(d, slot); - let (min, max) = (scaled(min.abs()), scaled(max.abs())); - Some(if min == max { - (min.to_string(), min as f64) - } else { - (spread_text(min, max, ctx)?, max as f64) - }) - } - 'm' if letter == 'm' => { - let (min, _) = effect_bounds(d, slot); - let v = scaled(min.abs()); - Some((v.to_string(), v as f64)) - } - 'm' => { - // 'M' - let (_, max) = effect_bounds(d, slot); - let v = scaled(max.abs()); - Some((v.to_string(), v as f64)) - } - 'o' => { - let (min, max) = effect_bounds(d, slot); - let period = i64::from(*d.effect_amplitude.get(slot).unwrap_or(&0)).max(0); - let period = if period == 0 { 5000 } else { period }; - let dur = duration_ms(d, ctx).unwrap_or(0).max(0); - let total = |v: i64| scaled((v.abs() * dur / period).max(0)); - let (tmin, tmax) = (total(min), total(max)); - Some(if tmin == tmax { - (tmin.to_string(), tmin as f64) - } else { - (spread_text(tmin, tmax, ctx)?, tmax as f64) - }) - } + 's' | 'm' | 'o' => points_text(letter, slot, d, ctx, scale), 'd' => { 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)) } 't' => { - let period = i64::from(*d.effect_amplitude.get(slot).unwrap_or(&0)); - let period = if period == 0 { 5000 } else { period }; - let v = period as f64 / 1000.0; - Some((trim_float(v), v)) + // `507e3c`: ProcFlags bit 0 is five seconds unmodified, else the amplitude through + // op 19, zero included (`507e5c`); whole seconds, truncated (`507e61`). + let ms = if d.proc_flags & 1 != 0 { + 5000 + } else { + modify_int(ctx, d, 19, d.effect_amplitude[slot] as i32) + }; + let secs = ms / 1000; + Some((secs.to_string(), f64::from(secs))) } 'a' => { - let idx = *d.effect_radius_index.get(slot).unwrap_or(&0); - let r = ctx.radii.get(idx)?; - Some((trim_float(f64::from(r.radius)), f64::from(r.radius))) + // `507c74`: the SpellRadius row's radius truncated, then op 6; no row is 0. + let r = ctx.radii.get(d.effect_radius_index[slot]); + let v = r.map_or(0, |r| modify_int(ctx, d, 6, r.radius as i32)); + Some((v.to_string(), f64::from(v))) + } + 'h' => { + let v = modify_int(ctx, d, 18, d.proc_chance as i32); + Some((v.to_string(), f64::from(v))) } - 'h' => Some((d.proc_chance.to_string(), f64::from(d.proc_chance))), 'x' => { let v = *d.effect_chain_targets.get(slot).unwrap_or(&0); + let v = modify_int(ctx, d, 17, v as i32); Some((v.to_string(), f64::from(v))) } 'e' => { - let v = f64::from(*d.effect_multiple_value.get(slot).unwrap_or(&0.0)); - Some((trim_float(v), v)) + // `507f83`: EffectMultipleValue through op 27, always one decimal. + let v = modify_float(ctx, d, 27, d.effect_multiple_value[slot]); + Some((tenths(ctx, v), f64::from(v))) + } + 'n' => { + let v = modify_int(ctx, d, 4, d.proc_charges as i32); + Some((v.to_string(), f64::from(v))) } 'z' => { // Player state, not spell data: the home-bind area name. @@ -155,13 +311,65 @@ fn token_value( Some((name.to_string(), 0.0)) } 'r' => { - // Left raw: the display carries only the range index. - None + // `507d5b`: RangeIndex at or below 1 reads row 1, its maximum through op 5; no row is + // 0.0. One decimal, and the plural keys on the ceiling (`507dbd`). + let row = ctx.ranges?.get(d.range_index.max(1)); + let v = row.map_or(0.0, |r| modify_float(ctx, d, 5, r.max)); + Some((tenths(ctx, v), f64::from(v).ceil())) } _ => None, } } +/// `$/N;` or `$*N;` just after the `$` (`507738`-`507790`): the text up to the `;` (at most seven +/// characters) read by `atoi` (`0x64ac60`), the scale its value or, for `/`, its reciprocal in +/// single precision (`507784`); a zero leaves 1.0 (`507774`). Returns the scale and the index past +/// the `;`, or `None` without a `;`, where the reference consumes nothing. +fn scale_prefix(text: &str, at: usize) -> Option<(f32, usize)> { + let op = *text.as_bytes().get(at)?; + if op != b'/' && op != b'*' { + return None; + } + let semi = at + text[at..].find(';')?; + let digits = &text.as_bytes()[at + 1..semi.min(at + 8)]; + let n = atoi(digits); + let scale = match (n, op) { + (0, _) => 1.0, + (n, b'/') => (1.0 / f64::from(n)) as f32, + (n, _) => n as f32, + }; + Some((scale, semi + 1)) +} + +/// The CRT's `atoi`: leading whitespace, an optional sign, then decimal digits up to the first +/// other character; nothing parsed is 0. +fn atoi(bytes: &[u8]) -> i32 { + let mut rest = bytes + .iter() + .copied() + .skip_while(u8::is_ascii_whitespace) + .peekable(); + let negative = match rest.peek() { + Some(b'-') => { + rest.next(); + true + } + Some(b'+') => { + rest.next(); + false + } + _ => false, + }; + let n = rest.take_while(u8::is_ascii_digit).fold(0i32, |n, b| { + n.wrapping_mul(10).wrapping_add(i32::from(b - b'0')) + }); + if negative { + n.wrapping_neg() + } else { + n + } +} + /// Substitute every `$`-token in `text` against `spell`. pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> String { let bytes = text.as_bytes(); @@ -177,22 +385,10 @@ pub fn substitute(text: &str, spell: &SpellDisplay, ctx: &TokenContext) -> Strin } let start = i; i += 1; - // `$/N;` or `$*N;` scales the next token. - let mut scale = 1.0f64; - if i < bytes.len() && (bytes[i] == b'/' || bytes[i] == b'*') { - let op = bytes[i]; - let mut j = i + 1; - let num_start = j; - while j < bytes.len() && (bytes[j].is_ascii_digit() || bytes[j] == b'.') { - j += 1; - } - if let Ok(n) = text[num_start..j].parse::() { - if j < bytes.len() && bytes[j] == b';' { - j += 1; - } - scale = if op == b'/' { 1.0 / n } else { n }; - i = j; - } + let mut scale = 1.0f32; + if let Some((s, next)) = scale_prefix(text, i) { + scale = s; + i = next; } // Optional cross-spell id digits. let id_start = i; @@ -286,18 +482,80 @@ mod tests { use crate::spells::SpellDisplay; /// Deliberately unlike the shipped wording, so a wrong key cannot pass by reading the same. - fn text(key: &str, args: &[i64]) -> Option { - let n = |i: usize| args.get(i).copied().unwrap_or_default(); - Some(match key { - "SPELL_DURATION_UNTIL_CANCELLED" => "".into(), - "INT_SPELL_DURATION_SEC" => format!("<{}sec>", n(0)), - "INT_SPELL_DURATION_MIN" => format!("<{}min>", n(0)), - "INT_SPELL_DURATION_HOURS" => format!("<{}hour>", n(0)), - "INT_SPELL_DURATION_HOURS_P1" => format!("<{}hrs>", n(0)), - "INT_SPELL_DURATION_DAYS" => format!("<{}days>", n(0)), - "INT_SPELL_POINTS_SPREAD_TEMPLATE" => format!("<{}..{}>", n(0), n(1)), - _ => return None, - }) + fn global(key: &str) -> Option { + Some( + match key { + "SPELL_DURATION_UNTIL_CANCELLED" => "", + "INT_SPELL_DURATION_SEC" => "<%dsec>", + "INT_SPELL_DURATION_MIN" => "<%dmin>", + "INT_SPELL_DURATION_HOURS" => "<%dhour>", + "INT_SPELL_DURATION_HOURS_P1" => "<%dhrs>", + "INT_SPELL_DURATION_DAYS" => "<%ddays>", + "SPELL_DURATION_MIN" => "<%.2fmin>", + "INT_SPELL_POINTS_SPREAD_TEMPLATE" => "<%d..%d>", + "SPELL_POINTS_SPREAD_TEMPLATE" => "<%.1f to %.1f>", + _ => return None, + } + .into(), + ) + } + + /// `%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(); + let mut args = args.iter(); + let mut chars = template.chars().peekable(); + while let Some(c) = chars.next() { + if c != '%' { + out.push(c); + continue; + } + let mut spec = String::new(); + while let Some(&n) = chars.peek() { + chars.next(); + spec.push(n); + if n.is_ascii_alphabetic() || n == '%' { + break; + } + } + match (spec.as_str(), args.next()) { + ("d", Some(TokenNumber::Int(n))) => out.push_str(&n.to_string()), + (f, Some(TokenNumber::Float(x))) if f.starts_with('.') => { + let p: usize = f[1..f.len() - 1].parse().unwrap(); + out.push_str(&format!("{x:.p$}")); + } + (s, a) => panic!("printf: %{s} with {a:?}"), + } + } + out + } + + /// A caster's table: `(op, flat, pct)` rows through the three appliers' arithmetic. + struct Mods(Vec<(u8, i32, i32)>); + + impl Mods { + fn find(&self, op: u8) -> Option<(i32, i32)> { + self.0 + .iter() + .find(|m| m.0 == op) + .map(|&(_, flat, pct)| (flat, pct)) + } + } + + impl SpellMods for Mods { + fn apply_int(&self, _: &SpellDisplay, op: u8, value: i32) -> i32 { + self.find(op) + .map_or(value, |(flat, pct)| (value + flat) * pct / 100) + } + fn apply_float(&self, _: &SpellDisplay, op: u8, value: f32) -> f32 { + self.find(op).map_or(value, |(flat, pct)| { + ((f64::from(value) + f64::from(flat)) * f64::from(pct) * f64::from(0.01f32)) as f32 + }) + } + fn apply_soft(&self, _: &SpellDisplay, op: u8, value: f32) -> f32 { + self.find(op) + .map_or(value, |(flat, pct)| crate::soft_modify(value, flat, pct)) + } } fn ctx<'a>( @@ -309,8 +567,36 @@ mod tests { home_area: None, durations, radii, + ranges: None, lookup, - text: &text, + mods: None, + unmodified_points: false, + global: &global, + printf: &printf, + } + } + + fn none_lookup<'a>(_: u32) -> Option<&'a SpellDisplay> { + None + } + + /// A flat effect of `points` in slot 0 (base `points − 1`, one one-sided die). + fn points(points: i32) -> SpellDisplay { + SpellDisplay { + effect_base_points: [points - 1, 0, 0], + effect_base_dice: [1, 0, 0], + effect_die_sides: [1, 0, 0], + ..Default::default() + } + } + + /// An effect rolling `min..=max` in slot 0. + fn spread(min: i32, max: i32) -> SpellDisplay { + SpellDisplay { + effect_base_points: [min - 1, 0, 0], + effect_base_dice: [1, 0, 0], + effect_die_sides: [max - min + 1, 0, 0], + ..Default::default() } } @@ -347,8 +633,71 @@ mod tests { assert_eq!(d(6), ""); } - fn none_lookup<'a>(_: u32) -> Option<&'a SpellDisplay> { - None + #[test] + fn duration_modifier_updates_description_and_overtime_total() { + let mut durations = SpellDurationCatalog::default(); + durations.insert_for_tests(1, 120_000); + let radii = SpellRadiusCatalog::default(); + let mods = Mods(vec![(1, 0, 150)]); + let c = TokenContext { + mods: Some(&mods), + ..ctx(&durations, &radii, &none_lookup) + }; + let d = SpellDisplay { + duration_index: 1, + effect_amplitude: [3_000, 0, 0], + ..points(3) + }; + assert_eq!( + substitute("Lasts $d; deals $o1 total.", &d, &c), + "Lasts <3min>; deals 180 total." + ); + } + + #[test] + fn battle_shout_description_uses_modified_duration_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 d = spells.get(6673).expect("Battle Shout rank 1"); + let description = d.description.as_deref().expect("description"); + let lookup = |id| spells.get(id); + let base = ctx(&durations, &radii, &lookup); + assert!(substitute(description, d, &base).contains("<2min>")); + let mods = Mods(vec![(1, 0, 150)]); + let modified = TokenContext { + mods: Some(&mods), + ..base + }; + assert!(substitute(description, d, &modified).contains("<3min>")); + } + + #[test] + fn booming_voice_ranks_preserve_fractional_minutes() { + let mut durations = SpellDurationCatalog::default(); + durations.insert_for_tests(1, 120_000); + let radii = SpellRadiusCatalog::default(); + let spell = SpellDisplay { + duration_index: 1, + ..Default::default() + }; + for (rank, expected) in [ + (0, "<2min>"), + (1, "<2.20min>"), + (2, "<2.40min>"), + (3, "<2.60min>"), + (4, "<2.80min>"), + (5, "<3min>"), + ] { + let mods = Mods(vec![(1, 0, 100 + rank * 10)]); + let ctx = TokenContext { + mods: Some(&mods), + ..ctx(&durations, &radii, &none_lookup) + }; + assert_eq!(substitute("$d", &spell, &ctx), expected, "rank {rank}"); + } } /// Base 13 and one 9-sided die give "14 to 22", Fireball rank 1's shape; a diceless effect @@ -378,6 +727,247 @@ mod tests { ); } + #[test] + fn scaled_effect_points_keep_fractional_seconds() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let c = ctx(&durations, &radii, &none_lookup); + for (n, expected) in [(100, "0.1"), (200, "0.2"), (500, "0.5")] { + let d = points(n); + assert_eq!( + substitute("$/1000;S1 sec.", &d, &c), + format!("{expected} sec.") + ); + assert_eq!( + substitute("$/1000;m1 sec.", &d, &c), + format!("{expected} sec.") + ); + } + // A whole scaled value prints `%d` under any letter. + assert_eq!(substitute("$/1000;S1", &points(1000), &c), "1"); + } + + /// Flametongue Weapon's shape (`$/77;8026m1 to $/25;8026M1` over 326): `m`/`M` print a + /// fraction at one decimal. The scale is `atoi`'s, so a decimal `$*` reads its integer part + /// and `$*0.04;` is no scale at all. + #[test] + fn scaled_m_tokens_print_tenths_and_the_scale_is_an_integer() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let c = ctx(&durations, &radii, &none_lookup); + let d = points(326); + assert_eq!( + substitute("$/77;m1 to $/25;M1 additional Fire damage", &d, &c), + "4.2 to 13.0 additional Fire damage" + ); + assert_eq!(substitute("$*0.04;M1", &d, &c), "326"); + assert_eq!(substitute("$*2;M1", &d, &c), "652"); + assert_eq!(substitute("$/0;M1", &d, &c), "326", "atoi 0 leaves 1.0"); + assert_eq!( + substitute("$/2 x;M1", &d, &c), + "163", + "atoi stops at the space" + ); + assert_eq!(substitute("$/2 M1", &d, &c), "$/2 M1", "no `;`, no token"); + } + + /// `$/2;s1` of 3 truncates to 1 (`507b28`), where `$/2;S1` keeps its fraction. + #[test] + fn a_fractional_single_value_truncates_under_s_and_keeps_tenths_under_capital_s() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let c = ctx(&durations, &radii, &none_lookup); + let d = points(3); + assert_eq!(substitute("$/2;s1", &d, &c), "1"); + assert_eq!( + substitute("$/2;o1", &d, &c), + "0", + "no duration spreads nothing" + ); + assert_eq!(substitute("$/2;S1", &d, &c), "1.5"); + assert_eq!(substitute("$/2;m1 $/2;M1", &d, &c), "1.5 1.5"); + } + + /// The spread arms (`507bc2`-`507c4a`): both whole takes the integer template; else `$S` + /// takes the float one, and `s` the integer one over the truncated minimum and a fractional + /// maximum truncated plus one. + #[test] + fn a_spread_picks_its_template_by_letter_and_wholeness() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let c = ctx(&durations, &radii, &none_lookup); + assert_eq!(substitute("$/2;s1", &spread(4, 6), &c), "<2..3>"); + assert_eq!(substitute("$/2;s1", &spread(3, 5), &c), "<1..3>"); + assert_eq!(substitute("$/2;S1", &spread(3, 5), &c), "<1.5 to 2.5>"); + assert_eq!(substitute("$/2;s1", &spread(4, 5), &c), "<2..3>"); + assert_eq!(substitute("$/2;S1", &spread(4, 5), &c), "<2.0 to 2.5>"); + assert_eq!(substitute("$/2;O1", &spread(4, 5), &c), "0"); + } + + /// The plural value `[0xbe0b84]`: `m` keys on its whole minimum, the others on the whole + /// maximum, and a fraction keys as 2 (`507aa2`-`507b10`). + #[test] + fn the_plural_keys_on_the_whole_bound_or_two() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let c = ctx(&durations, &radii, &none_lookup); + let plural = |text: &str, d: &SpellDisplay| substitute(text, d, &c); + assert_eq!(plural("$m1 $lpoint:points;", &spread(1, 3)), "1 point"); + assert_eq!( + plural("$s1 $lpoint:points;", &spread(1, 3)), + "<1..3> points" + ); + assert_eq!(plural("$M1 $lpoint:points;", &points(1)), "1 point"); + assert_eq!(plural("$/2;m1 $lpoint:points;", &points(2)), "1 point"); + assert_eq!(plural("$/4;S1 $lpoint:points;", &points(2)), "0.5 points"); + } + + /// `0x6e3b80`'s rounding set: School Damage floors the minimum and ceils the maximum after + /// its modifiers, where an unrounded effect keeps the quantized fraction; a zero maximum + /// ceils to 1 (`0x761040`). + #[test] + fn a_rounding_effect_floors_and_ceils_its_modified_bounds() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let mods = Mods(vec![(0, 0, 110)]); + let c = TokenContext { + mods: Some(&mods), + ..ctx(&durations, &radii, &none_lookup) + }; + let school = SpellDisplay { + effects: [2, 0, 0], + ..spread(14, 22) + }; + assert_eq!(substitute("$s1", &school, &c), "<15..25>"); + assert_eq!(substitute("$m1 $M1", &school, &c), "15 25"); + let zero = SpellDisplay { + effects: [2, 0, 0], + ..points(0) + }; + assert_eq!( + substitute("$s1", &zero, &ctx(&durations, &radii, &none_lookup)), + "<0..1>" + ); + // A dummy effect is not in the set: op 0 does not reach it, and nothing rounds. + let dummy = SpellDisplay { + effects: [3, 0, 0], + ..spread(14, 22) + }; + assert_eq!(substitute("$s1", &dummy, &c), "<14..22>"); + } + + /// The damage op is 22 for aura 3 and 0 for the other damage auras (`6e394a`). + #[test] + fn a_periodic_damage_aura_takes_op_22() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let mods = Mods(vec![(22, 0, 200)]); + let c = TokenContext { + mods: Some(&mods), + ..ctx(&durations, &radii, &none_lookup) + }; + let aura = |aura| SpellDisplay { + effects: [6, 0, 0], + effect_apply_aura: [aura, 0, 0], + ..points(10) + }; + assert_eq!(substitute("$s1", &aura(3), &c), "20"); + assert_eq!(substitute("$s1", &aura(15), &c), "10"); + } + + /// `GetEffectPoints` modifies through the software-float applier `0x6e6c30`, whose truncating + /// steps land 269 × 1.01 × 1.33 on 361.34375 after the 1/128 quantizer, where the FPU applier + /// would reach 361.3515625 and print "361.4". + #[test] + fn effect_points_take_the_software_float_applier() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let mods = Mods(vec![(8, 0, 101), (2, 0, 133)]); + let c = TokenContext { + mods: Some(&mods), + ..ctx(&durations, &radii, &none_lookup) + }; + let d = SpellDisplay { + effects: [6, 0, 0], + effect_apply_aura: [10, 0, 0], + ..points(269) + }; + assert_eq!(substitute("$M1", &d, &c), "361.3"); + } + + /// The aura tooltip's context skips the effect points' modifiers and `$d`'s op 1, and keeps + /// the other tokens' ops. + #[test] + fn the_aura_tooltip_context_leaves_points_and_duration_unmodified() { + let mut durations = SpellDurationCatalog::default(); + durations.insert_for_tests(1, 60_000); + let radii = SpellRadiusCatalog::default(); + let mods = Mods(vec![(8, 0, 125), (1, 0, 200), (18, 10, 100)]); + let modified = TokenContext { + mods: Some(&mods), + ..ctx(&durations, &radii, &none_lookup) + }; + let aura = TokenContext { + unmodified_points: true, + ..modified + }; + let d = SpellDisplay { + duration_index: 1, + proc_chance: 5, + ..points(40) + }; + assert_eq!(substitute("$s1 $d $h", &d, &modified), "50 <2min> 15"); + assert_eq!(substitute("$s1 $d $h", &d, &aura), "40 <1min> 15"); + } + + #[test] + fn scaled_damage_tokens_from_real_spell_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 c = ctx(&durations, &radii, &lookup); + for (id, expected) in [ + (8024, "4.2 to 13.0 additional Fire damage"), + (20154, "an additional 1 to 4 Holy damage"), + // `$/1000;s1` of 1500 and of 600: lowercase `s` truncates a fraction (`507b28`). + (21854, "Enslave Demon spell by 1 sec."), + (24271, "Raptor Strike by 0 sec."), + ] { + let d = spells.get(id).expect("spell"); + let description = substitute(d.description.as_deref().unwrap(), d, &c); + assert!(description.contains(expected), "{id}: {description}"); + } + } + + #[test] + fn improved_frostbolt_ranks_display_tenths_from_real_spell_data() { + let data = crate::wow_data_or_skip!(); + let mut chain = crate::open_chain(&data).expect("open chain"); + let catalog = crate::load_spell_catalog(&mut chain).expect("Spell.dbc"); + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let lookup = |id| catalog.get(id); + let c = ctx(&durations, &radii, &lookup); + for (id, expected) in [ + (11070, "0.1"), + (12473, "0.2"), + (16763, "0.3"), + (16765, "0.4"), + (16766, "0.5"), + ] { + let d = catalog.get(id).expect("Improved Frostbolt rank"); + assert!(d.name == "Improved Frostbolt"); + let description = substitute(d.description.as_deref().unwrap(), d, &c); + assert!( + description.contains(&format!("by {expected} sec.")), + "{description}" + ); + } + } + #[test] fn overtime_duration_period_scale_plural() { let mut durations = SpellDurationCatalog::default(); @@ -385,11 +975,8 @@ mod tests { let radii = SpellRadiusCatalog::default(); let d = SpellDisplay { duration_index: 1, - effect_base_points: [2, 0, 0], - effect_base_dice: [1, 0, 0], - effect_die_sides: [1, 0, 0], effect_amplitude: [3000, 0, 0], - ..Default::default() + ..points(3) }; let c = ctx(&durations, &radii, &none_lookup); assert_eq!( @@ -397,9 +984,9 @@ mod tests { "Deals 18 damage over <18sec>, every 3 sec." ); assert_eq!( - substitute("Restores $/2;s1 health: $l point:points;.", &d, &c), - // 3 halved is 1.5, which rounds to 2; the plural picks "points" - "Restores 2 health: points.".to_string() + substitute("Restores $/2;S1 health: $l point:points;.", &d, &c), + // Uppercase S keeps 3/2 fractional; the plural picks "points". + "Restores 1.5 health: points.".to_string() ); } @@ -407,20 +994,12 @@ mod tests { fn cross_spell_and_unknown_tokens() { let durations = SpellDurationCatalog::default(); let radii = SpellRadiusCatalog::default(); - let other = SpellDisplay { - effect_base_points: [99, 0, 0], - effect_base_dice: [1, 0, 0], - effect_die_sides: [1, 0, 0], - ..Default::default() - }; + let other = points(100); let lookup = |id: u32| -> Option<&SpellDisplay> { (id == 1234).then_some(&other) }; let d = SpellDisplay::default(); let c = TokenContext { home_area: Some("Goldshire"), - durations: &durations, - radii: &radii, - lookup: &lookup, - text: &text, + ..ctx(&durations, &radii, &lookup) }; assert_eq!( substitute("as strong as $1234s1 hits", &d, &c), @@ -431,19 +1010,133 @@ mod tests { substitute("Returns you to $z.", &d, &c), "Returns you to Goldshire." ); - let unbound = TokenContext { - home_area: None, - durations: &durations, - radii: &radii, - lookup: &lookup, - text: &text, - }; + let unbound = ctx(&durations, &radii, &lookup); assert_eq!( substitute("Returns you to $z.", &d, &unbound), "Returns you to $z." ); } + /// Devotion Aura rank 1's shape: 55 armor, and Improved Devotion Aura's +25% on op 8 through + /// the soft applier and the 1/128 quantizer is 68.75, which `$s1` truncates and `$M1` prints + /// at one decimal. + #[test] + fn effect_value_tokens_take_the_all_effects_modifier() { + let d = SpellDisplay { + effects: [35, 0, 0], + effect_apply_aura: [22, 0, 0], + ..points(55) + }; + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let mods = Mods(vec![(8, 0, 125)]); + let ctx = TokenContext { + mods: Some(&mods), + ..ctx(&durations, &radii, &none_lookup) + }; + assert_eq!(substitute("Gives $s1 armor.", &d, &ctx), "Gives 68 armor."); + assert_eq!( + substitute("Increases armor by $M1.", &d, &ctx), + "Increases armor by 68.8." + ); + } + + /// Every aura `0x6e3ab8`'s table covers (10..=183), and none outside it. + #[test] + fn each_aura_takes_the_op_of_its_jump_table_arm() { + for aura in 0..=255u32 { + let expected = match aura { + 10 | 103 | 183 => Some(2), + 31 | 32 | 33 | 58 | 129 | 130 | 171 | 172 => Some(12), + 138 => Some(23), + 65 => Some(24), + 99 => Some(3), + _ => None, + }; + let d = SpellDisplay { + effect_apply_aura: [aura, 0, 0], + ..Default::default() + }; + assert_eq!(aura_op(&d, 0), expected, "aura {aura}"); + } + } + + /// `$t` (`507e3c`): ProcFlags bit 0 is five seconds whatever the amplitude; else the + /// amplitude through op 19, zero included, in whole seconds. + #[test] + fn t_is_whole_seconds_of_the_modified_amplitude() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::default(); + let mods = Mods(vec![(19, -1000, 100)]); + let c = TokenContext { + mods: Some(&mods), + ..ctx(&durations, &radii, &none_lookup) + }; + let tick = |amplitude, proc_flags| SpellDisplay { + effect_amplitude: [amplitude, 0, 0], + proc_flags, + ..Default::default() + }; + let bare = ctx(&durations, &radii, &none_lookup); + assert_eq!(substitute("$t1", &tick(1500, 0), &bare), "1"); + assert_eq!(substitute("$t1", &tick(0, 0), &bare), "0"); + assert_eq!(substitute("$t1", &tick(3000, 0), &c), "2"); + assert_eq!(substitute("$t1", &tick(3000, 1), &c), "5"); + } + + /// `$r` (`507d5b`) reads row 1 for an index at or below 1 and prints one decimal through + /// op 5; `$e` (`507f83`) prints one decimal through op 27; `$a` (`507c74`) truncates the + /// radius, applies op 6 and prints an integer, 0 without a row. + #[test] + fn r_e_and_a_follow_their_arms() { + let durations = SpellDurationCatalog::default(); + let radii = SpellRadiusCatalog::from_rows( + [( + 7, + crate::SpellRadius { + radius: 8.5, + per_level: 0.0, + max: 8.5, + }, + )] + .into(), + ); + let range = |max| crate::SpellRange { + min: 0.0, + max, + flags: 0, + }; + let ranges = SpellRangeCatalog::from_rows([(1, range(5.0)), (4, range(30.0))].into()); + let mods = Mods(vec![(5, 0, 110), (27, 0, 150), (6, 2, 100)]); + let c = TokenContext { + ranges: Some(&ranges), + mods: Some(&mods), + ..ctx(&durations, &radii, &none_lookup) + }; + let d = |range_index, radius| SpellDisplay { + range_index, + effect_radius_index: [radius, 0, 0], + effect_multiple_value: [2.0, 0.0, 0.0], + ..Default::default() + }; + assert_eq!(substitute("$r", &d(4, 0), &c), "33.0"); + assert_eq!(substitute("$r", &d(0, 0), &c), "5.5", "index 0 reads row 1"); + assert_eq!(substitute("$r", &d(9, 0), &c), "0.0", "no row"); + assert_eq!(substitute("$e1", &d(4, 0), &c), "3.0"); + assert_eq!(substitute("$a1", &d(4, 7), &c), "10"); + assert_eq!(substitute("$a1", &d(4, 3), &c), "0", "no row"); + } + + /// Whole within 0.001 of either neighbour, a near-ceiling value reading as the ceiling. + #[test] + fn a_bound_is_whole_within_a_thousandth() { + assert_eq!(whole(3.0), (3, true)); + assert_eq!(whole(2.9995), (3, true)); + assert_eq!(whole(3.0005), (3, true)); + assert_eq!(whole(2.5), (2, false)); + assert_eq!(whole(0.0), (0, true)); + } + #[test] fn a_dollar_before_a_multibyte_char_passes_through_on_the_char_boundary() { let durations = SpellDurationCatalog::default(); diff --git a/crates/benilla-ui/src/strings.rs b/crates/benilla-ui/src/strings.rs index 212f9267..9a9bf889 100644 --- a/crates/benilla-ui/src/strings.rs +++ b/crates/benilla-ui/src/strings.rs @@ -2,7 +2,8 @@ //! the client's `SStrPrintf` does: `%s`, `%d`, `%c`, `%f` and `%g` take the next argument left to //! right, `%N$s` takes argument N without moving that cursor (`DUEL_WINNER_RETREAT` reorders its //! two names), `%%` is `%`, and the precision is the template's. A hole with no argument, or one -//! this does not know, is copied through literally so a mis-modelled template shows. +//! this does not know, is copied through literally so a mis-modelled template shows. Reals round +//! as the MSVC CRT behind `SStrPrintf` does: half up on the decimal digits ([`crt_digits`]). use std::fmt::Write as _; @@ -84,12 +85,8 @@ impl Arg<'_> { fn as_f(&self, prec: Option, out: &mut String) { let p = prec.unwrap_or(6); match self { - Arg::F(x) => { - let _ = write!(out, "{x:.p$}"); - } - Arg::D(n) => { - let _ = write!(out, "{:.p$}", *n as f64); - } + Arg::F(x) => crt_f(*x, p, out), + Arg::D(n) => crt_f(*n as f64, p, out), Arg::S(s) => out.push_str(s), } } @@ -107,21 +104,95 @@ impl Arg<'_> { out.push('0'); return; } - // Round first, then read the exponent, so 9.999 at P=3 is "10", not "10.0". - let sci = format!("{:.*e}", p - 1, x); - let (mantissa, exp) = sci.split_once('e').unwrap_or((sci.as_str(), "0")); - let exp: i32 = exp.parse().unwrap_or(0); + if !x.is_finite() { + let _ = write!(out, "{x}"); + return; + } + if x.is_sign_negative() { + out.push('-'); + } + // Round to P significant digits first, then read the exponent, so 9.999 at P=3 is "10", + // not "10.0"; the style's digits are the rounded ones, never rounded twice. + let (digits, decpt) = crt_digits(x.abs(), |_| p as i32); + let exp = decpt - 1; if exp < -4 || exp >= p as i32 { // `%e`: C writes at least two exponent digits and always a sign; Rust writes neither. - out.push_str(trim_zeros(mantissa)); + let mantissa = fixed(&digits, 1, p - 1); + out.push_str(trim_zeros(&mantissa)); let _ = write!(out, "e{}{:02}", if exp < 0 { '-' } else { '+' }, exp.abs()); } else { let decimals = (p as i32 - 1 - exp).max(0) as usize; - out.push_str(trim_zeros(&format!("{x:.decimals$}"))); + out.push_str(trim_zeros(&fixed(&digits, decpt, decimals))); } } } +/// `%f` by the CRT: the sign, then [`crt_digits`] rounded at `p` decimals. +fn crt_f(x: f64, p: usize, out: &mut String) { + if !x.is_finite() { + let _ = write!(out, "{x:.p$}"); + return; + } + if x.is_sign_negative() { + out.push('-'); + } + let (digits, decpt) = crt_digits(x.abs(), |decpt| decpt + p as i32); + out.push_str(&fixed(&digits, decpt, p)); +} + +/// The CRT's decimal digits of a finite `a >= 0` and its point position `decpt` (the value is +/// `0.d1d2… × 10^decpt`): `_fltout2` takes 17 significant digits, then `_fptostr 0x40fef0` keeps +/// `keep(decpt)` of them and rounds on the next digit, half up (`40ff3d: cmp byte [ecx],0x35`), +/// a carry out of the first digit moving the point (`40ff4f`). A negative count keeps nothing +/// and rounds nothing (`40ff3b`). So 41.25 at one decimal is 41.3, where Rust's `{:.1}` rounds +/// the tie to even. +fn crt_digits(a: f64, keep: impl FnOnce(i32) -> i32) -> (Vec, i32) { + let sci = format!("{a:.16e}"); + let (mantissa, exp) = sci.split_once('e').unwrap_or((sci.as_str(), "0")); + let mant: Vec = mantissa.bytes().filter(u8::is_ascii_digit).collect(); + let mut decpt = exp.parse::().unwrap_or(0) + 1; + let Ok(n) = usize::try_from(keep(decpt)) else { + return (Vec::new(), decpt); + }; + // A leading slot takes the carry. + let mut buf = vec![b'0']; + buf.extend((0..n).map(|i| mant.get(i).copied().unwrap_or(b'0'))); + if mant.get(n).is_some_and(|&d| d >= b'5') { + let mut i = buf.len() - 1; + while buf[i] == b'9' { + buf[i] = b'0'; + i -= 1; + } + buf[i] += 1; + } + if buf[0] == b'1' { + decpt += 1; + } else { + buf.remove(0); + } + (buf, decpt) +} + +/// Lay rounded digits out as `int.frac` with `p` decimals, zero-padded both sides (`_cftof`). +fn fixed(digits: &[u8], decpt: i32, p: usize) -> String { + let digit = |i: i32| -> char { + usize::try_from(i) + .ok() + .and_then(|i| digits.get(i)) + .map_or('0', |&d| d as char) + }; + let mut s: String = if decpt <= 0 { + "0".into() + } else { + (0..decpt).map(digit).collect() + }; + if p > 0 { + s.push('.'); + s.extend((0..p as i32).map(|k| digit(decpt + k))); + } + s +} + /// `%g`'s last step: drop a fraction's trailing zeros, and the point if nothing is left. fn trim_zeros(s: &str) -> &str { match s.contains('.') { @@ -287,11 +358,33 @@ mod tests { fn f_takes_the_templates_precision() { assert_eq!( fill("(%.1f damage per second)", &[Arg::F(41.25)]), - "(41.2 damage per second)" + "(41.3 damage per second)" ); assert_eq!(fill("%.2f", &[Arg::F(41.25)]), "41.25"); } + /// The CRT rounds a tie half up on its decimal digits (`_fptostr`'s `>= '5'`), where Rust + /// rounds it to even; a value just short of the tie still rounds down. + #[test] + fn reals_round_ties_up_as_the_crt_does() { + let f = |t: &str, v: f64| fill(t, &[Arg::F(v)]); + assert_eq!(f("%.1f", 68.25), "68.3"); + assert_eq!(f("%.1f", 0.25), "0.3"); + assert_eq!(f("%.1f", 0.05), "0.1", "a carry past the point"); + assert_eq!(f("%.1f", 99.96), "100.0", "a carry out of the first digit"); + assert_eq!(f("%.1f", 0.004), "0.0"); + assert_eq!( + f("%.1f", f64::from(0.35f32)), + "0.3", + "0.3499999940… is not a tie" + ); + assert_eq!(f("%.1f", -2.25), "-2.3"); + assert_eq!(f("%.2f", 0.125), "0.13"); + // `%.3g` rounds at its third significant digit the same way: 67.5 s is 1.125 minutes. + assert_eq!(f("%.3g min", 1.125), "1.13 min"); + assert_eq!(f("%.3g", 2.675), "2.67", "2.67499999… is not a tie"); + } + #[test] fn plural_takes_the_twin_for_zero_and_the_bare_token_for_none() { let table = |key: &str| -> Option {