Skip to main content

forge/talent_conformance/
audit.rs

1// #t(file: rust_alloc_in_loop) conformance diagnostics own stable per-spell evidence.
2
3use std::collections::{BTreeMap, BTreeSet, VecDeque};
4
5use anyhow::{Context, Result, bail};
6use wowlab_common::output;
7use wowlab_manifest_schema::{ContentEffectCoverage, ContentEffectStatus, Manifest};
8use wowlab_parsers::{DbcData, transform_spell};
9use wowlab_types::game::SpecId;
10
11use super::TalentSnapshot;
12use crate::{
13    talent_attribute_summary::AttributeSummaries,
14    talent_aura_summary::AuraSummaries,
15    talent_effect_summary::EffectSummaries,
16    talent_modifier_summary::ModifierSummaries,
17    talent_target_flag_summary::TargetFlagSummaries,
18    talent_target_plan_summary::{TargetPlanAxisFilter, TargetPlanSummaries},
19};
20
21mod target;
22
23use target::{
24    TargetPlanAuditContext, audit_effect_target_plan, audit_spell_attributes,
25    audit_spell_target_flags, effect_audit_row,
26};
27
28#[derive(Default)]
29struct EffectAuditCounts {
30    spells: usize,
31    unresolved_spells: usize,
32    effects: usize,
33    generic: usize,
34    partial: usize,
35    content_required: usize,
36    content_modeled: usize,
37    content_ignored: usize,
38    content_undeclared: usize,
39    ignored: usize,
40    unsupported: usize,
41    attributes: usize,
42    attribute_generic: usize,
43    attribute_partial: usize,
44    attribute_ignored: usize,
45    attribute_unsupported: usize,
46    target_plans: usize,
47    target_plan_generic: usize,
48    target_plan_overlaid: usize,
49    target_plan_unsupported: usize,
50    target_flags: usize,
51    target_flag_generic: usize,
52    target_flag_partial: usize,
53    target_flag_ignored: usize,
54    target_flag_unsupported: usize,
55}
56
57#[derive(Clone, Copy)]
58struct ContentEffectDeclaration<'a> {
59    name: &'a str,
60    status: ContentEffectStatus,
61    reason: &'a str,
62}
63
64struct ContentEffectLedger<'a> {
65    declarations: BTreeMap<(i32, i32), ContentEffectDeclaration<'a>>,
66}
67
68impl<'a> ContentEffectLedger<'a> {
69    fn from_manifest(manifest: &'a Manifest) -> Result<Self> {
70        let mut declarations = BTreeMap::new();
71
72        for (name, declaration) in &manifest.content_effects {
73            validate_content_effect_declaration(name, declaration)?;
74            let spell_id = i32::try_from(declaration.spell_id)
75                .with_context(|| format!("content effect {name} spell ID exceeds i32"))?;
76            let coordinate = (spell_id, i32::from(declaration.effect));
77            let entry = ContentEffectDeclaration {
78                name,
79                status: declaration.status,
80                reason: declaration.reason.trim(),
81            };
82
83            if let Some(previous) = declarations.insert(coordinate, entry) {
84                bail!(
85                    "content effects {} and {name} both declare spell {} effect {}",
86                    previous.name,
87                    declaration.spell_id,
88                    declaration.effect
89                );
90            }
91        }
92
93        Ok(Self { declarations })
94    }
95
96    fn get(&self, spell_id: i32, effect_index: i32) -> Option<ContentEffectDeclaration<'a>> {
97        self.declarations.get(&(spell_id, effect_index)).copied()
98    }
99}
100
101fn validate_content_effect_declaration(
102    name: &str,
103    declaration: &ContentEffectCoverage,
104) -> Result<()> {
105    if declaration.spell_id == 0 {
106        bail!("content effect {name} has spell_id 0");
107    }
108
109    if declaration.effect == 0 {
110        bail!("content effect {name} has effect 0; effect indices are one-based");
111    }
112
113    if declaration.reason.trim().is_empty() {
114        bail!("content effect {name} must explain how the effect is accounted for");
115    }
116
117    Ok(())
118}
119
120#[derive(tabled::Tabled)]
121struct EffectAuditRow {
122    #[tabled(rename = "Spell")]
123    spell: String,
124    #[tabled(rename = "Effect")]
125    effect_index: i32,
126    #[tabled(rename = "Type")]
127    effect_type: i32,
128    #[tabled(rename = "Aura")]
129    aura_subtype: i32,
130    #[tabled(rename = "Property")]
131    modifier_property: i32,
132    #[tabled(rename = "Trigger")]
133    trigger_spell: i32,
134    #[tabled(rename = "Coverage")]
135    coverage: &'static str,
136    #[tabled(rename = "Handler / gap")]
137    handler: String,
138}
139
140#[derive(tabled::Tabled)]
141struct AttributeAuditRow {
142    #[tabled(rename = "Spell")]
143    spell: String,
144    #[tabled(rename = "Attribute")]
145    attribute: u16,
146    #[tabled(rename = "Name")]
147    name: &'static str,
148    #[tabled(rename = "Coverage")]
149    coverage: &'static str,
150    #[tabled(rename = "Handler / gap")]
151    handler: &'static str,
152}
153
154#[derive(tabled::Tabled)]
155struct TargetPlanAuditRow {
156    #[tabled(rename = "Spell")]
157    spell: String,
158    #[tabled(rename = "Effect")]
159    effect_index: i32,
160    #[tabled(rename = "TargetA / TargetB")]
161    selectors: String,
162    #[tabled(rename = "Program")]
163    program: String,
164    #[tabled(rename = "Provenance")]
165    provenance: String,
166    #[tabled(rename = "Coverage")]
167    coverage: &'static str,
168    #[tabled(rename = "Handler / gap")]
169    handler: String,
170}
171
172#[derive(tabled::Tabled)]
173struct TargetFlagAuditRow {
174    #[tabled(rename = "Spell")]
175    spell: String,
176    #[tabled(rename = "Bit")]
177    bit: u32,
178    #[tabled(rename = "Name")]
179    name: &'static str,
180    #[tabled(rename = "Required")]
181    required: bool,
182    #[tabled(rename = "Coverage")]
183    coverage: &'static str,
184    #[tabled(rename = "Handler / gap")]
185    handler: &'static str,
186}
187
188#[derive(tabled::Tabled)]
189struct UnresolvedSpellAuditRow {
190    #[tabled(rename = "Spell ID")]
191    spell_id: i32,
192    #[tabled(rename = "Gap")]
193    gap: String,
194}
195
196// #t(fn: rust_large_fn_params) paired optional summaries share the semantic traversal context
197// #t(fn: rust_cyclomatic_complexity) one traversal reconciles optional summaries with the semantic gate.
198#[expect(
199    clippy::too_many_arguments,
200    reason = "the audit pipeline passes independent summary accumulators explicitly"
201)]
202pub(super) fn audit_effect_semantics(
203    dbc: &DbcData,
204    spec: SpecId,
205    snapshot: &TalentSnapshot,
206    manifest: &Manifest,
207    attribute_filter: &[u16],
208    attribute_summaries: &mut AttributeSummaries,
209    aura_filter: &[i32],
210    aura_summaries: &mut AuraSummaries,
211    effect_filter: &[i32],
212    effect_summaries: &mut EffectSummaries,
213    modifier_filter: &[i32],
214    modifier_aura_filter: &[i32],
215    modifier_summaries: &mut ModifierSummaries,
216    target_plan_filter: &[TargetPlanAxisFilter],
217    target_plan_summaries: &mut TargetPlanSummaries,
218    target_flag_filter: &[u8],
219    target_flag_summaries: &mut TargetFlagSummaries,
220) -> Result<()> {
221    let content_effects = ContentEffectLedger::from_manifest(manifest)?;
222    let mut pending: VecDeque<i32> = snapshot
223        .effective_records
224        .iter()
225        .map(|record| record.spell_id)
226        .filter(|spell_id| *spell_id > 0)
227        .collect();
228
229    pending.extend(manifest_semantic_roots(manifest));
230    let mut visited = BTreeSet::new();
231    let mut counts = EffectAuditCounts::default();
232    let mut rows = Vec::new();
233    let mut attribute_rows = Vec::new();
234    let mut target_plan_rows = Vec::new();
235    let mut target_flag_rows = Vec::new();
236    let mut unresolved_spell_rows = Vec::new();
237
238    while let Some(spell_id) = pending.pop_front() {
239        if !visited.insert(spell_id) {
240            continue;
241        }
242
243        let spell = match transform_spell(dbc, spell_id, None) {
244            Ok(spell) => spell,
245            Err(error) => {
246                counts.unresolved_spells += 1;
247                unresolved_spell_rows.push(UnresolvedSpellAuditRow {
248                    spell_id,
249                    gap: error.to_string(),
250                });
251                continue;
252            }
253        };
254
255        counts.spells += 1;
256        attribute_summaries.observe(
257            spec,
258            spell_id,
259            &spell.name,
260            spell
261                .attributes
262                .iter()
263                .copied()
264                .enumerate()
265                .flat_map(|(block, value)| {
266                    (0..i32::BITS).filter_map(move |bit| {
267                        (value & 1_i32.wrapping_shl(bit) != 0)
268                            .then_some(block * i32::BITS as usize + bit as usize)
269                            .and_then(|raw| u16::try_from(raw).ok())
270                    })
271                }),
272            attribute_filter,
273        );
274        aura_summaries.observe(spec, &spell, aura_filter);
275        effect_summaries.observe(spec, &spell, effect_filter);
276        modifier_summaries.observe(spec, &spell, modifier_filter, modifier_aura_filter);
277        target_flag_summaries.observe(spec, &spell, target_flag_filter);
278        audit_spell_attributes(
279            spell_id,
280            &spell.name,
281            &spell.attributes,
282            &mut attribute_rows,
283            &mut counts,
284        );
285        audit_spell_target_flags(
286            spell_id,
287            &spell.name,
288            &spell,
289            &mut target_flag_rows,
290            &mut counts,
291        );
292
293        for effect in &spell.effects {
294            counts.effects += 1;
295            audit_effect_target_plan(
296                spell_id,
297                &spell.name,
298                effect,
299                &mut TargetPlanAuditContext {
300                    spec,
301                    rows: &mut target_plan_rows,
302                    counts: &mut counts,
303                    filter: target_plan_filter,
304                    summaries: target_plan_summaries,
305                },
306            );
307            rows.push(effect_audit_row(
308                spell_id,
309                &spell.name,
310                effect,
311                &content_effects,
312                &mut counts,
313            ));
314
315            if effect.trigger_spell > 0 {
316                pending.push_back(effect.trigger_spell);
317            }
318        }
319
320        pending.extend(
321            spell
322                .learn_spells
323                .iter()
324                .map(|learned| learned.learn_spell_id)
325                .filter(|spell_id| *spell_id > 0),
326        );
327    }
328
329    if attribute_filter.is_empty()
330        && aura_filter.is_empty()
331        && effect_filter.is_empty()
332        && modifier_filter.is_empty()
333        && target_plan_filter.is_empty()
334        && target_flag_filter.is_empty()
335    {
336        print_effect_audit(
337            spec,
338            rows,
339            attribute_rows,
340            target_plan_rows,
341            target_flag_rows,
342            unresolved_spell_rows,
343            &counts,
344        );
345    }
346
347    if counts.unsupported > 0
348        || counts.attribute_unsupported > 0
349        || counts.content_undeclared > 0
350        || counts.target_plan_unsupported > 0
351        || counts.target_flag_unsupported > 0
352        || counts.unresolved_spells > 0
353    {
354        bail!(
355            "{} effects, {} spell attributes, {} target plans, and {} target flags have unsupported semantics; {} content-required effects are undeclared; {} registered spells are unresolved",
356            counts.unsupported,
357            counts.attribute_unsupported,
358            counts.target_plan_unsupported,
359            counts.target_flag_unsupported,
360            counts.content_undeclared,
361            counts.unresolved_spells,
362        );
363    }
364
365    Ok(())
366}
367
368// #t(fn: rust_large_fn_params) typed audit tables and their reconciled counters are rendered together.
369fn print_effect_audit(
370    spec: SpecId,
371    rows: Vec<EffectAuditRow>,
372    attribute_rows: Vec<AttributeAuditRow>,
373    target_plan_rows: Vec<TargetPlanAuditRow>,
374    target_flag_rows: Vec<TargetFlagAuditRow>,
375    unresolved_spell_rows: Vec<UnresolvedSpellAuditRow>,
376    counts: &EffectAuditCounts,
377) {
378    output::blank();
379    output::header("Spec effect semantics");
380    output::table(rows);
381    output::blank();
382    output::header("Spec spell attributes");
383    output::table(attribute_rows);
384    output::blank();
385    output::header("Spec compiled target plans");
386    output::table(target_plan_rows);
387    output::blank();
388    output::header("Spec cast target flags");
389    output::table(target_flag_rows);
390    output::blank();
391    output::header("Unresolved registered spells");
392    output::table(unresolved_spell_rows);
393    output::kv("Spec", spec.slug());
394    output::kv_fmt("Spells", counts.spells);
395    output::kv_fmt("Unresolved spells", counts.unresolved_spells);
396    output::kv_fmt("Effects", counts.effects);
397    output::kv_fmt("Generic", counts.generic);
398    output::kv_fmt("Partial", counts.partial);
399    output::kv_fmt("Content required", counts.content_required);
400    output::kv_fmt("Content modeled", counts.content_modeled);
401    output::kv_fmt("Content ignored", counts.content_ignored);
402    output::kv_fmt("Content undeclared", counts.content_undeclared);
403    output::kv_fmt("Ignored", counts.ignored);
404    output::kv_fmt("Unsupported", counts.unsupported);
405    output::kv_fmt("Spell attributes", counts.attributes);
406    output::kv_fmt("Attribute generic", counts.attribute_generic);
407    output::kv_fmt("Attribute partial", counts.attribute_partial);
408    output::kv_fmt("Attribute ignored", counts.attribute_ignored);
409    output::kv_fmt("Attribute unsupported", counts.attribute_unsupported);
410    output::kv_fmt("Target plans", counts.target_plans);
411    output::kv_fmt("Target plan generic", counts.target_plan_generic);
412    output::kv_fmt("Target plan overlaid", counts.target_plan_overlaid);
413    output::kv_fmt("Target plan unsupported", counts.target_plan_unsupported);
414    output::kv_fmt("Target flags", counts.target_flags);
415    output::kv_fmt("Target flag generic", counts.target_flag_generic);
416    output::kv_fmt("Target flag partial", counts.target_flag_partial);
417    output::kv_fmt("Target flag ignored", counts.target_flag_ignored);
418    output::kv_fmt("Target flag unsupported", counts.target_flag_unsupported);
419}
420
421fn manifest_semantic_roots(manifest: &Manifest) -> impl Iterator<Item = i32> + '_ {
422    std::iter::once(manifest.mastery.spell_id)
423        .chain(manifest.spells.values().map(|spell| spell.id))
424        .chain(manifest.auras.values().map(|aura| aura.id))
425        .chain(manifest.talents.values().copied())
426        .chain(manifest.set_bonuses.values().copied())
427        .chain(manifest.auto_attacks.values().map(|attack| attack.spell_id))
428        .chain(manifest.reported_spells.values().copied())
429        .filter_map(|spell_id| i32::try_from(spell_id).ok())
430        .filter(|spell_id| *spell_id > 0)
431}