Skip to main content

wowlab_engine_application/audit/
generic_folds.rs

1// #t(file: rust_alloc_in_loop) the audit renders one owned diagnostic per finding, bounded by manifest size
2
3//! Duplicate-fold check for content that re-declares a folded DBC damage modifier.
4//!
5//! Five static shapes are detected.
6//!
7//! Shape one is an aura declaring a damage-multiplier field over its own folded DBC effect.
8//! Shape two is an `[effects]` entry naming a registered aura's folded damage effect.
9//! Only shape two catches a hook folding the percentage into a spell coefficient.
10//! Shape three is an aura tick hook over a DBC periodic trigger that already energizes.
11//! Shape four is a `[spells.X] damage` row re-dealing a child of `X`'s own DBC trigger program.
12//! Shape five is an aura re-declaring an auto-attack modifier already provided by its own DBC effects or a `TriggerSpellWithValue` driver.
13//!
14//! # Scope
15//!
16//! Every finding is a CANDIDATE and the absence of findings proves nothing.
17//! The check is static over the manifest plus DBC, so several duplication shapes stay invisible.
18//!
19//! - Imperative `mask_aura_dbc_effect` builder calls legitimately hand an effect to content.
20//! - A hook applying the same percentage to a disjoint pet or guardian axis reads as a duplicate.
21//! - A hook may consume the aura before dealing damage, so the live modifier is already inactive.
22//! - An `[effects]` binding may name a spell-modifier effect the generic path never folds.
23//! - A duplication living only in hook Rust produces no manifest evidence at all.
24//!
25//! `SCOPE_NOTE` renders that contract beside the findings, so no reader reads clean as absent.
26
27use 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
36/// What the duplicate-fold check does and does not see; emitted with every non-empty result.
37const 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
39/// Aura fields that declare a damage multiplier on the caster's own spells.
40///
41/// `pet_damage_mult` is deliberately absent: a whole-pet multiplier is a different axis.
42fn 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, &registered).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
81/// Flags authored swing-speed and auto-attack damage fields already owned by the live-aura path.
82///
83/// A non-zero carrier is data-owned; a zero carrier requires a `TriggerSpellWithValue` driver.
84async 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
241/// Flags a `[spells.X] damage` row that re-deals a child of `X`'s own DBC trigger program.
242///
243/// The generic trigger program already resolves that child's geometry and dispatches its damage.
244// #t(fn: rust_async_loop_no_yield) an offline manifest audit over a bounded spell list, not a runtime task
245async 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
275/// `(1-based effect index, triggered spell id)` for every trigger-program effect.
276async 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
301/// Flags an aura tick hook whose resource grant the generic periodic-trigger path already makes.
302///
303/// A `Periodic Trigger Spell (23)` whose child energizes already runs generically.
304///   A `tick_hook` on the same aura therefore grants that resource a second time.
305async 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
328/// `(1-based effect index, triggered spell id)` for every `Periodic Trigger Spell` effect.
329async 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
363// #t(fn: rust_async_loop_no_yield) an offline manifest audit over a bounded aura list, not a runtime task
364async 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
411/// 1-based indexes of the spell's effects the generic live-aura path folds as damage modifiers.
412async 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}