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:
co-authored by
Claude Opus 5.5
Sam
parent
5757ab2334
commit
4f86560b4a
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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!();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user