wowlab_engine_application/audit/
generic_folds.rs1use wowlab_engine_domain::dbc::{
28 AuraSubtypeKind, SpellEffectKind, SpellEffectSemanticExt, live_damage_modifier,
29};
30use wowlab_engine_ports::{DataResolver, DynDataResolver, SpellId};
31use wowlab_manifest_schema::{Manifest, ManifestAuraDef, ManifestDamageDef, ScalarRef};
32use wowlab_types::sim::FastSet;
33
34use super::{AuditCtx, AuditSink};
35
36const SCOPE_NOTE: &str = "[duplicate-fold] scope: static over manifest + DBC only. Findings are CANDIDATES (confirm on combined_mult), and a clean spec is NOT proof of absence — imperative mask_aura_dbc_effect calls, disjoint pet/guardian axes, spell-modifier effect bindings, and duplications living only in hook Rust are all invisible to it.";
38
39fn declared_damage_fields(aura: &ManifestAuraDef) -> impl Iterator<Item = &'static str> {
43 [
44 aura.damage_mult.is_some().then_some("damage_mult"),
45 aura.damage_mult_stacking
46 .is_some()
47 .then_some("damage_mult_stacking"),
48 aura.damage_percent_spells
49 .is_some()
50 .then_some("damage_percent_spells"),
51 aura.damage_percent_spells_linear
52 .is_some()
53 .then_some("damage_percent_spells_linear"),
54 aura.crit_chance_spells
55 .is_some()
56 .then_some("crit_chance_spells"),
57 aura.crit_damage_spells
58 .is_some()
59 .then_some("crit_damage_spells"),
60 ]
61 .into_iter()
62 .flatten()
63}
64
65pub(super) async fn check_duplicate_generic_folds(ctx: &mut AuditCtx<'_>) {
66 let registered: FastSet<u32> = ctx.manifest.auras.values().map(|aura| aura.id).collect();
67
68 let before = ctx.sink.warning_count();
69
70 check_aura_self_folds(ctx).await;
71 check_auto_attack_aura_self_folds(ctx).await;
72 check_effect_bindings(ctx, ®istered).await;
73 check_periodic_energize_hooks(ctx).await;
74 check_trigger_program_damage_rows(ctx).await;
75
76 if ctx.sink.warning_count() > before {
77 ctx.sink.warning(SCOPE_NOTE.to_string());
78 }
79}
80
81async fn check_auto_attack_aura_self_folds(ctx: &mut AuditCtx<'_>) {
85 let manifest = ctx.manifest;
86 let resolver = ctx.resolver;
87 let sink = &mut *ctx.sink;
88 let auras = &manifest.auras;
89
90 for (name, aura) in auras {
91 let has_authored_field = has_authored_auto_attack_field(aura);
92
93 if aura.id == 0 || !has_authored_field {
94 continue;
95 }
96
97 let Ok(effects) = resolver.get_spell_effects(spell_id_of(aura.id)).await else {
98 continue;
99 };
100 let talent_forwards_value = talent_forwards_value_to(manifest, resolver, aura.id).await;
101
102 if let Some(field) = aura.attack_speed_per_stack.as_ref() {
103 let forwarded = talent_forwards_value
104 || scalar_ref_forwards_value_to(Some(field), resolver, aura.id).await;
105
106 warn_auto_attack_field_folds(
107 sink,
108 &effects,
109 AutoAttackFieldFold {
110 name,
111 aura_id: aura.id,
112 field: "attack_speed_per_stack",
113 subtypes: &[
114 AuraSubtypeKind::ModMeleeAutoAttackSpeedPercent,
115 AuraSubtypeKind::ModRangedAndMeleeAutoAttackSpeedPercent,
116 ],
117 forwarded,
118 },
119 );
120 }
121
122 if let Some(field) = aura.auto_attack_damage_per_stack.as_ref() {
123 let forwarded = talent_forwards_value
124 || scalar_ref_forwards_value_to(Some(field), resolver, aura.id).await;
125
126 warn_auto_attack_field_folds(
127 sink,
128 &effects,
129 AutoAttackFieldFold {
130 name,
131 aura_id: aura.id,
132 field: "auto_attack_damage_per_stack",
133 subtypes: &[AuraSubtypeKind::ModAutoAttackDamage],
134 forwarded,
135 },
136 );
137 }
138 }
139}
140
141fn has_authored_auto_attack_field(aura: &ManifestAuraDef) -> bool {
142 aura.attack_speed_per_stack.is_some() || aura.auto_attack_damage_per_stack.is_some()
143}
144
145#[derive(Clone, Copy)]
146struct AutoAttackFieldFold<'a> {
147 name: &'a str,
148 aura_id: u32,
149 field: &'static str,
150 subtypes: &'a [AuraSubtypeKind],
151 forwarded: bool,
152}
153
154fn warn_auto_attack_field_folds(
155 sink: &mut AuditSink,
156 effects: &[wowlab_types::data::SpellEffect],
157 fold: AutoAttackFieldFold<'_>,
158) {
159 for (position, effect) in effects.iter().enumerate() {
160 if (effect.base_points.abs() > f64::EPSILON || fold.forwarded)
161 && effect.aura_is_any(fold.subtypes)
162 {
163 warn_auto_attack_self_fold(
164 sink,
165 fold.name,
166 fold.aura_id,
167 fold.field,
168 one_based_index(position),
169 fold.forwarded && effect.base_points.abs() <= f64::EPSILON,
170 );
171 }
172 }
173}
174
175fn warn_auto_attack_self_fold(
176 sink: &mut AuditSink,
177 name: &str,
178 aura_id: u32,
179 field: &str,
180 effect_index: u8,
181 forwarded: bool,
182) {
183 let ownership = if forwarded {
184 "a TriggerSpellWithValue driver populates"
185 } else {
186 "DBC already provides"
187 };
188
189 sink.warning(format!(
190 "[duplicate-fold] aura {name} ({aura_id}): {field} re-declares an auto-attack modifier {ownership} for the generic live-aura path at effect {effect_index}; delete the manifest row, or mask the DBC effect if content must own it",
191 ));
192}
193
194async fn scalar_ref_forwards_value_to(
195 scalar: Option<&ScalarRef>,
196 resolver: &DynDataResolver<'_>,
197 aura_id: u32,
198) -> bool {
199 let Some(ScalarRef::EffectRef {
200 spell_id, effect, ..
201 }) = scalar
202 else {
203 return false;
204 };
205 let Ok(effects) = resolver.get_spell_effects(spell_id_of(*spell_id)).await else {
206 return false;
207 };
208 let driver_index = i32::from(*effect).saturating_sub(1);
209 let Some(driver) = effects
210 .iter()
211 .find(|candidate| candidate.index == driver_index)
212 else {
213 return false;
214 };
215
216 driver.aura_is(AuraSubtypeKind::TriggerSpellWithValue)
217 && u32::try_from(driver.trigger_spell).is_ok_and(|id| id == aura_id)
218}
219
220async fn talent_forwards_value_to(
221 manifest: &Manifest,
222 resolver: &DynDataResolver<'_>,
223 aura_id: u32,
224) -> bool {
225 for &talent_id in manifest.talents.values() {
226 let Ok(effects) = resolver.get_spell_effects(spell_id_of(talent_id)).await else {
227 continue;
228 };
229
230 if effects.iter().any(|effect| {
231 effect.aura_is(AuraSubtypeKind::TriggerSpellWithValue)
232 && u32::try_from(effect.trigger_spell).is_ok_and(|id| id == aura_id)
233 }) {
234 return true;
235 }
236 }
237
238 false
239}
240
241async fn check_trigger_program_damage_rows(ctx: &mut AuditCtx<'_>) {
246 let manifest = ctx.manifest;
247 let resolver = ctx.resolver;
248 let sink = &mut *ctx.sink;
249
250 for (name, spell) in &manifest.spells {
251 let Some(ManifestDamageDef::EffectRef {
252 spell_id: payload, ..
253 }) = spell.damage
254 else {
255 continue;
256 };
257
258 if spell.id == 0 || payload == spell.id {
259 continue;
260 }
261
262 for (index, triggered) in trigger_program_children(resolver, spell.id).await {
263 if triggered != payload {
264 continue;
265 }
266
267 sink.warning(format!(
268 "[duplicate-fold] spell {name} ({}): damage names {payload}, which effect {index} already triggers; the generic trigger program dispatches it too, so the payload lands twice",
269 spell.id,
270 ));
271 }
272 }
273}
274
275async fn trigger_program_children(resolver: &DynDataResolver<'_>, spell_id: u32) -> Vec<(u8, u32)> {
277 let Ok(effects) = resolver.get_spell_effects(spell_id_of(spell_id)).await else {
278 return Vec::new();
279 };
280
281 effects
282 .iter()
283 .enumerate()
284 .filter(|(_, effect)| {
285 effect.effect_is_any(&[
286 SpellEffectKind::TriggerMissile,
287 SpellEffectKind::TriggerSpell,
288 SpellEffectKind::TriggerSpellWithValue,
289 SpellEffectKind::TriggerSpell2,
290 ])
291 })
292 .filter_map(|(position, effect)| {
293 u32::try_from(effect.trigger_spell)
294 .ok()
295 .filter(|triggered| *triggered != 0)
296 .map(|triggered| (one_based_index(position), triggered))
297 })
298 .collect()
299}
300
301async fn check_periodic_energize_hooks(ctx: &mut AuditCtx<'_>) {
306 let manifest = ctx.manifest;
307 let resolver = ctx.resolver;
308 let sink = &mut *ctx.sink;
309
310 for (name, aura) in &manifest.auras {
311 if aura.id == 0 || aura.tick_hook.is_none() {
312 continue;
313 }
314
315 for (index, triggered) in periodic_trigger_children(resolver, aura.id).await {
316 if !spell_energizes(resolver, triggered).await {
317 continue;
318 }
319
320 sink.warning(format!(
321 "[duplicate-fold] aura {name} ({}): tick_hook duplicates the resource the generic periodic-trigger path already grants through effect {index} -> spell {triggered}; delete one half",
322 aura.id,
323 ));
324 }
325 }
326}
327
328async fn periodic_trigger_children(
330 resolver: &DynDataResolver<'_>,
331 spell_id: u32,
332) -> Vec<(u8, u32)> {
333 let Ok(effects) = resolver.get_spell_effects(spell_id_of(spell_id)).await else {
334 return Vec::new();
335 };
336
337 effects
338 .iter()
339 .enumerate()
340 .filter(|(_, effect)| effect.aura_is(AuraSubtypeKind::PeriodicTriggerSpell))
341 .filter_map(|(position, effect)| {
342 u32::try_from(effect.trigger_spell)
343 .ok()
344 .filter(|triggered| *triggered != 0)
345 .map(|triggered| (one_based_index(position), triggered))
346 })
347 .collect()
348}
349
350async fn spell_energizes(resolver: &DynDataResolver<'_>, spell_id: u32) -> bool {
351 let Ok(effects) = resolver.get_spell_effects(spell_id_of(spell_id)).await else {
352 return false;
353 };
354
355 effects.iter().any(|effect| {
356 effect.effect_is_any(&[
357 SpellEffectKind::Energize,
358 SpellEffectKind::EnergizePowerPercent,
359 ])
360 })
361}
362
363async fn check_aura_self_folds(ctx: &mut AuditCtx<'_>) {
365 let manifest = ctx.manifest;
366 let resolver = ctx.resolver;
367 let sink = &mut *ctx.sink;
368
369 for (name, aura) in &manifest.auras {
370 let mut fields = declared_damage_fields(aura).peekable();
371
372 if aura.id == 0 || fields.peek().is_none() {
373 continue;
374 }
375
376 for index in folded_damage_effects(resolver, aura.id).await {
377 for field in declared_damage_fields(aura) {
378 sink.warning(format!(
379 "[duplicate-fold] aura {name} ({}): {field} re-declares a damage modifier the generic live-aura path already folds from effect {index}; delete the manifest row, or mask the DBC effect if content must own it",
380 aura.id,
381 ));
382 }
383 }
384 }
385}
386
387async fn check_effect_bindings(ctx: &mut AuditCtx<'_>, registered: &FastSet<u32>) {
388 let manifest = ctx.manifest;
389 let resolver = ctx.resolver;
390 let sink = &mut *ctx.sink;
391
392 for (name, binding) in &manifest.effects {
393 if !registered.contains(&binding.spell_id) {
394 continue;
395 }
396
397 if !folded_damage_effects(resolver, binding.spell_id)
398 .await
399 .contains(&binding.effect)
400 {
401 continue;
402 }
403
404 sink.warning(format!(
405 "[duplicate-fold] effect binding {name} ({}, e{}) names a registered aura effect the generic live-aura path already folds as a damage modifier; a hook reading it applies the percentage a second time",
406 binding.spell_id, binding.effect,
407 ));
408 }
409}
410
411async fn folded_damage_effects(resolver: &DynDataResolver<'_>, spell_id: u32) -> Vec<u8> {
413 let Ok(effects) = resolver.get_spell_effects(spell_id_of(spell_id)).await else {
414 return Vec::new();
415 };
416
417 effects
418 .iter()
419 .enumerate()
420 .filter(|(_, effect)| live_damage_modifier(effect.aura, effect.misc_value_0).is_some())
421 .map(|(position, _)| one_based_index(position))
422 .collect()
423}
424
425fn spell_id_of(spell_id: u32) -> SpellId {
426 SpellId::new(wowlab_types::numeric::u32_to_i32_saturating(spell_id))
427}
428
429fn one_based_index(position: usize) -> u8 {
430 u8::try_from(position.saturating_add(1)).unwrap_or(u8::MAX)
431}