wowlab_engine_application/audit/data_values/
scalar.rs1use wowlab_engine_domain::dbc::cast_time_ms;
4use wowlab_engine_ports::{DataResolver, DynDataResolver, SpellId};
5use wowlab_manifest_schema::{ScalarField, ScalarRef, SpellScalarField};
6use wowlab_types::{
7 constants::MS_PER_SECOND,
8 data::{SpellDataFlat, SpellEffect},
9 numeric::round_to_decimals,
10};
11
12use super::{AuditSink, DATA_EPS};
13
14pub(super) fn warn_if_redundant(literal: f64, resolved: f64, label: &str, sink: &mut AuditSink) {
15 if (literal - resolved).abs() <= DATA_EPS {
16 sink.warning(format!(
17 "{label}={literal} is a redundant override; game data resolves {resolved}"
18 ));
19 }
20}
21
22pub(super) async fn check_redundant_resolved_override(
23 resolver: &DynDataResolver<'_>,
24 value: &ScalarRef,
25 owner_id: u32,
26 target_resolved: f64,
27 label: &str,
28 sink: &mut AuditSink,
29) {
30 let Some(resolved) = resolve_scalar_ref(resolver, Some(value)).await else {
31 return;
32 };
33
34 if !resolved.is_finite() {
35 sink.warning(format!("{label} resolves to a non-finite value"));
36
37 return;
38 }
39
40 if value.effect_spell_id() != Some(owner_id) {
41 return;
42 }
43
44 warn_if_redundant(resolved, target_resolved, label, sink);
45}
46
47pub(super) async fn resolve_scalar_ref(
48 resolver: &DynDataResolver<'_>,
49 value: Option<&ScalarRef>,
50) -> Option<f64> {
51 match value {
52 Some(
53 value @ ScalarRef::EffectRef {
54 spell_id, effect, ..
55 },
56 ) => {
57 let Ok(effect_data) = resolver
58 .get_spell_effect(
59 SpellId::new(wowlab_types::numeric::u32_to_i32_saturating(*spell_id)),
60 *effect,
61 )
62 .await
63 else {
64 return None;
65 };
66
67 resolved_scalar_value(value, &effect_data)
68 }
69 Some(value @ ScalarRef::SpellRef { spell_id, .. }) => {
70 let Ok(spell_data) = resolver
71 .get_spell(SpellId::new(wowlab_types::numeric::u32_to_i32_saturating(
72 *spell_id,
73 )))
74 .await
75 else {
76 return None;
77 };
78
79 resolved_spell_scalar_value(value, &spell_data)
80 }
81 _ => None,
82 }
83}
84
85pub(super) fn scalar_field_value(effect: &SpellEffect, field: ScalarField) -> f64 {
86 match field {
87 ScalarField::BasePoints => effect.base_points,
88 ScalarField::ApCoef => effect.bonus_coefficient_from_ap,
89 ScalarField::SpCoef => effect.bonus_coefficient,
90 ScalarField::Amplitude => f64::from(effect.amplitude),
91 ScalarField::Period => f64::from(effect.period),
92 ScalarField::Coefficient => f64::from(effect.coefficient),
93 _ => 0.0,
94 }
95}
96
97pub(super) fn resolved_scalar_value(value: &ScalarRef, effect: &SpellEffect) -> Option<f64> {
98 let ScalarRef::EffectRef {
99 field,
100 multiplier,
101 offset,
102 round_decimals,
103 ..
104 } = value
105 else {
106 return None;
107 };
108
109 Some(apply_scalar_transform(
110 scalar_field_value(effect, *field),
111 *multiplier,
112 *offset,
113 *round_decimals,
114 ))
115}
116
117fn resolved_spell_scalar_value(value: &ScalarRef, spell: &SpellDataFlat) -> Option<f64> {
118 let ScalarRef::SpellRef {
119 field,
120 multiplier,
121 offset,
122 round_decimals,
123 ..
124 } = value
125 else {
126 return None;
127 };
128 let source = match field {
129 SpellScalarField::DurationMs => f64::from(spell.duration.max(0)),
130 SpellScalarField::MaxStacks => f64::from(spell.max_stacks),
131 SpellScalarField::Cooldown => f64::from(spell.recovery_time.max(0)) / MS_PER_SECOND,
132 SpellScalarField::Cost => 0.0,
133 SpellScalarField::CastTimeMs => f64::from(cast_time_ms(spell)),
134 SpellScalarField::Charges => f64::from(spell.max_charges),
135 SpellScalarField::ChargeCd => f64::from(spell.charge_recovery_time) / MS_PER_SECOND,
136 SpellScalarField::GcdMs => f64::from(spell.start_recovery_time),
137 SpellScalarField::ProcCategoryRecoveryMs => {
138 f64::from(spell.proc_category_recovery_ms.max(0))
139 }
140 _ => return None,
141 };
142
143 Some(apply_scalar_transform(
144 source,
145 *multiplier,
146 *offset,
147 *round_decimals,
148 ))
149}
150
151fn apply_scalar_transform(
152 mut resolved: f64,
153 multiplier: Option<f64>,
154 offset: Option<f64>,
155 round_decimals: Option<u8>,
156) -> f64 {
157 resolved *= multiplier.unwrap_or(1.0);
158 resolved += offset.unwrap_or(0.0);
159
160 if let Some(decimals) = round_decimals {
161 resolved = round_to_decimals(resolved, decimals);
162 }
163
164 resolved
165}
166
167pub(super) async fn check_scalar_literal(
168 resolver: &DynDataResolver<'_>,
169 name: &str,
170 aura_id: u32,
171 field: &str,
172 value: Option<&ScalarRef>,
173 sink: &mut AuditSink,
174) {
175 let Some(ScalarRef::Literal(literal)) = value else {
176 return;
177 };
178 let effects = resolver
179 .get_spell_effects(SpellId::new(wowlab_types::numeric::u32_to_i32_saturating(
180 aura_id,
181 )))
182 .await
183 .unwrap_or_default();
184
185 if effects
186 .iter()
187 .any(|effect| (effect.base_points - *literal).abs() < DATA_EPS)
188 {
189 return;
190 }
191
192 let base_points = effects
193 .iter()
194 .map(|effect| effect.base_points.to_string())
195 .collect::<Vec<_>>()
196 .join(", ");
197
198 sink.warning(format!(
199 "aura {name} {field}={literal} not found in game data (effects base_points: [{base_points}])"
200 ));
201}