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) <noreply@anthropic.com>
Co-Authored-By: Sam <samuv.eth@gmail.com>
This commit is contained in:
Juozas Polikevičius
2026-09-29 13:08:34 +07:00
committed by GitHub
co-authored by Claude Opus 5.5 Sam
parent 5757ab2334
commit 4f86560b4a
26 changed files with 2006 additions and 457 deletions
+4 -4
View File
@@ -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);
}
}
+9 -1
View File
@@ -151,12 +151,20 @@ impl RangeInputs {
&self,
def: &SpellDisplay,
row: Option<&benilla_formats::SpellRange>,
mods: &super::SpellModifiers,
) -> Option<u8> {
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,
)
}
}
+116 -31
View File
@@ -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,
+3 -1
View File
@@ -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,
+115 -3
View File
@@ -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<f32>,
) -> 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"
);
}
}
+213 -131
View File
@@ -283,6 +283,7 @@ fn on_spell_go(In(ev): In<SessionEvent>, 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::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.add_message::<crate::player::StandStateRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
.world_mut()
.spawn((Guid(10), SelfPlayer, ObjectStore::default()))
.id();
let mut app = App::new();
app.add_message::<CastEvent>()
.add_message::<SpellGoTargets>()
.add_message::<CombatTextSpawn>()
.add_message::<GoLidOpen>()
.add_message::<crate::creature_anim::SheathRequest>()
.add_message::<crate::player::StandStateRequest>()
.init_resource::<GuidIndex>()
.init_resource::<SelfGuid>()
.init_resource::<CastBarFeed>()
.init_resource::<PendingCast>()
.init_resource::<QueuedMeleeSpell>()
.init_resource::<Cooldowns>()
.init_resource::<crate::spell::SpellModifiers>()
.init_resource::<crate::ui_pet::PetBar>()
.init_resource::<crate::items::Items>();
let self_e = app
.world_mut()
.spawn((Guid(10), SelfPlayer, ObjectStore::default()))
.id();
app.world_mut()
.resource_mut::<GuidIndex>()
.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::<GuidIndex>();
index.0.insert(10, self_e);
index.0.insert(caster, caster_e);
}
app.world_mut().resource_mut::<SelfGuid>().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<GuidIndex>,
casting: Query<&Casting>,
mut cast_events: MessageWriter<CastEvent>,
mut go_targets: MessageWriter<SpellGoTargets>,
self_guid: Res<SelfGuid>,
stores: Query<&mut ObjectStore>,
mut cast_bar: ResMut<CastBarFeed>,
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>,
mut text: MessageWriter<CombatTextSpawn>,
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>| {
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::<Cooldowns>()),
armed(&world.resource::<crate::ui_pet::PetBar>().cooldowns),
world.resource::<crate::ui_pet::PetBar>().bar_signals,
.resource_mut::<GuidIndex>()
.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::<GuidIndex>()
.0
.insert(guid, e);
guid
});
app.world_mut().resource_mut::<SelfGuid>().0 = Some(10);
let (tx, _rx) = crossbeam_channel::unbounded();
app.world_mut()
.run_system_once(
move |mut commands: Commands,
index: Res<GuidIndex>,
casting: Query<&Casting>,
mut cast_events: MessageWriter<CastEvent>,
mut go_targets: MessageWriter<SpellGoTargets>,
self_guid: Res<SelfGuid>,
stores: Query<&mut ObjectStore>,
mut cast_bar: ResMut<CastBarFeed>,
mut pending: ResMut<PendingCast>,
mut queued_melee: ResMut<QueuedMeleeSpell>,
mut text: MessageWriter<CombatTextSpawn>,
mut go_lid: MessageWriter<GoLidOpen>,
mut cooldowns: ResMut<Cooldowns>,
mut pet_bar: ResMut<crate::ui_pet::PetBar>,
mut items: ResMut<crate::items::Items>,
mut sheath: MessageWriter<crate::creature_anim::SheathRequest>| {
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::<Cooldowns>()),
armed(&world.resource::<crate::ui_pet::PetBar>().cooldowns),
world.resource::<crate::ui_pet::PetBar>().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(),
@@ -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::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.init_resource::<crate::spell::SpellModifiers>();
world.insert_resource(crate::net::Reputations(Vec::new()));
world.insert_resource(PickOcclusion {
distance: 10.0,
@@ -374,6 +388,7 @@ mod tests {
world.init_resource::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.init_resource::<crate::spell::SpellModifiers>();
world.insert_resource(crate::net::Reputations(Vec::new()));
world.init_resource::<PickOcclusion>();
let display = |range_index| benilla_formats::SpellDisplay {
@@ -457,6 +472,7 @@ mod tests {
world.init_resource::<crate::go_templates::GameObjectTemplates>();
world.init_resource::<crate::items::Items>();
world.init_resource::<crate::net::GuidIndex>();
world.init_resource::<crate::spell::SpellModifiers>();
world.insert_resource(crate::net::Reputations(Vec::new()));
world.init_resource::<PickOcclusion>();
world.insert_resource(fx::spells());
@@ -65,6 +65,7 @@ pub(crate) struct BindChecks<'w, 's> {
/// `publish_camera_pose` rewrites mid-frame.
poses: Query<'w, 's, &'static GlobalTransform, Without<benilla_world::view::WorldCamera>>,
spells: Option<Res<'w, crate::ui_action::Spells>>,
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<u8> {
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
+21 -7
View File
@@ -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<f32>,
dist_sq: Option<f32>,
mods: &SpellModifiers,
) -> Option<u8> {
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
);
}
+4 -2
View File
@@ -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<PickOcclusion>,
cursor: Res<WorldCursor>,
spells: Option<Res<Spells>>,
spell_mods: Res<crate::spell::SpellModifiers>,
self_store: Query<&ObjectStore, With<SelfPlayer>>,
decals: WorldDecal,
mut state: ResMut<ReticleState>,
@@ -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::<ReticleState>();
world.init_resource::<WorldCursor>();
world.init_resource::<SpellTargeting>();
world.init_resource::<crate::spell::SpellModifiers>();
world.insert_resource(PickOcclusion {
distance: 10.0,
point: Some(Vec3::ZERO),
+22 -6
View File
@@ -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
}
}
+1 -1
View File
@@ -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) {
+7 -2
View File
@@ -97,6 +97,7 @@ fn feed_craft(
open: Res<CraftOpen>,
actions: Res<PlayerActions>,
spells: Option<Res<Spells>>,
spell_mods: Res<crate::spell::SpellModifiers>,
skill_lines: Option<Res<SkillLines>>,
focus: Option<Res<SpellFocus>>,
icons: Option<Res<ItemDisplays>>,
@@ -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`)
+105 -15
View File
@@ -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<String>,
global: &dyn Fn(&str) -> Option<String>,
) -> Option<String> {
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<String>) -> Option<Str
fn template_view(
t: &ItemInfo,
spells: Option<&crate::ui_action::Spells>,
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<String>,
// The same table, for the `$`-engine's keyed tokens.
text: &dyn Fn(&str, &[i64]) -> Option<String>,
) -> benilla_ui::script::ItemTemplateView {
let spell_name = |id: u32| -> Option<String> {
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<Items>,
commands: Res<NetCommands>,
spells: Option<Res<crate::ui_action::Spells>>,
spell_mods: Res<crate::spell::SpellModifiers>,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
mut pending: Local<
crate::ui_script::VmMemo<std::collections::HashMap<u32, benilla_ui::script::ItemSetView>>,
>,
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
) {
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<u32> = 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<String>)> = 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<Items>,
commands: Res<NetCommands>,
spells: Option<Res<crate::ui_action::Spells>>,
spell_mods: Res<crate::spell::SpellModifiers>,
skill_lines: Option<Res<crate::ui_spellbook::SkillLines>>,
// The `$z` token: the bind point's area (`SMSG_BINDPOINTUPDATE`), named through `AreaTable`.
home_bind: Option<Res<crate::net::HomeBind>>,
@@ -332,12 +374,14 @@ pub(super) fn feed_item_stats(
classes: Option<Res<super::ItemClasses>>,
icons: Option<Res<ItemDisplays>>,
mut pending: Local<crate::ui_script::VmMemo<std::collections::HashSet<u32>>>,
mut mod_sensitive: Local<crate::ui_script::VmMemo<HashSet<u32>>>,
mut last_home: Local<crate::ui_script::VmMemo<Option<String>>>,
) {
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"),
+1 -1
View File
@@ -818,7 +818,7 @@ fn a_cooldown_alone_fires_the_cooldown_event_and_not_the_bar_update() {
app.world_mut()
.resource_mut::<PetBar>()
.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()],
+10 -12
View File
@@ -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<String> + '_ {
|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`.
+28 -17
View File
@@ -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<String>,
text: &'a dyn Fn(&str, &[i64]) -> Option<String>,
}
/// 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) {
+103 -17
View File
@@ -8,37 +8,26 @@ struct TestCtx {
items: Items,
commands: NetCommands,
_rx: crossbeam_channel::Receiver<crate::net::ClientCommand>,
/// 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<Getter>,
text: Box<Filler>,
/// 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<String>;
type Filler = dyn Fn(&str, &[i64]) -> Option<String>;
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!();
+6 -6
View File
@@ -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,
+35 -21
View File
@@ -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<Item = (u32, SpellCastTime)>) -> 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<SpellCastTimeCatalog>
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]
@@ -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"
@@ -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],
+7 -1
View File
@@ -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<SpellCatalog> {
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)
}),
@@ -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);
}
}
File diff suppressed because it is too large Load Diff
+107 -14
View File
@@ -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<usize>, 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<u8>, i32) {
let sci = format!("{a:.16e}");
let (mantissa, exp) = sci.split_once('e').unwrap_or((sci.as_str(), "0"));
let mant: Vec<u8> = mantissa.bytes().filter(u8::is_ascii_digit).collect();
let mut decpt = exp.parse::<i32>().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<String> {