Skip to main content

forge/
talent_conformance.rs

1// #t(file: rust_alloc_in_loop) CLI conformance owns per-spec diagnostics and fixture records.
2
3#![expect(
4    clippy::cast_possible_wrap,
5    reason = "specialization IDs are constrained to the signed DBC identifier domain"
6)]
7
8//! Live `SimulationCraft` talent conformance and hermetic committed golden checks.
9
10use anyhow::{Context, Result, bail};
11use serde::{Deserialize, Serialize};
12use wowlab_common::output;
13use wowlab_fs::{
14    file,
15    path::{Path, PathBuf},
16};
17use wowlab_manifest_schema::{Manifest, ManifestRepository};
18use wowlab_parsers::{DbcData, parse_simc, transform_trait_tree};
19use wowlab_types::{data::TraitTreeFlat, game::SpecId};
20
21use crate::{
22    constants::{default_data_dir, engine_dir},
23    simc::simc_profile_path,
24    talent_attribute_summary::AttributeSummaries,
25    talent_aura_summary::AuraSummaries,
26    talent_effect_summary::EffectSummaries,
27    talent_modifier_summary::ModifierSummaries,
28    talent_target_flag_summary::TargetFlagSummaries,
29    talent_target_plan_summary::{TargetPlanAxisFilter, TargetPlanSummaries},
30};
31
32mod audit;
33mod golden;
34mod snapshot;
35
36#[cfg(test)]
37mod tests;
38
39use audit::audit_effect_semantics;
40use golden::{compact_tree, compare_snapshots, write_goldens};
41use snapshot::{simc_snapshot, wowlab_snapshot};
42
43const GOLDEN_VERSION: u32 = 1;
44const SIMC_TALENT_OUTPUT_OPTION: &str = "json2";
45const SIMC_ROOT_ANCESTOR_DEPTH: usize = 3;
46#[rustfmt::skip]
47#[cfg(test)]
48const GOLDEN_SPECS: &[SpecId] = &[
49    SpecId::Affliction,
50    SpecId::Arcane,
51    SpecId::Arms,
52    SpecId::Assassination,
53    SpecId::Balance,
54    SpecId::BeastMastery,
55    SpecId::Demonology,
56    SpecId::Destruction,
57    SpecId::Devastation,
58    SpecId::Devourer,
59    SpecId::Elemental,
60    SpecId::Enhancement,
61    SpecId::Feral,
62    SpecId::Fire,
63    SpecId::FrostDK,
64    SpecId::FrostMage,
65    SpecId::Fury,
66    SpecId::Havoc,
67    SpecId::Marksmanship,
68    SpecId::Outlaw,
69    SpecId::Retribution,
70    SpecId::Shadow,
71    SpecId::Subtlety,
72    SpecId::Survival,
73    SpecId::Unholy,
74    SpecId::Vengeance,
75    SpecId::Windwalker,
76];
77
78#[rustfmt::skip]
79const MID2_PROFILE_SPECS: &[SpecId] = &[
80    SpecId::Affliction,
81    SpecId::Arcane,
82    SpecId::Assassination,
83    SpecId::BeastMastery,
84    SpecId::Demonology,
85    SpecId::Destruction,
86    SpecId::Elemental,
87    SpecId::Enhancement,
88    SpecId::Fire,
89    SpecId::FrostDK,
90    SpecId::FrostMage,
91    SpecId::Marksmanship,
92    SpecId::Outlaw,
93    SpecId::Shadow,
94    SpecId::Subtlety,
95    SpecId::Survival,
96    SpecId::Unholy,
97    SpecId::Vengeance,
98];
99
100#[derive(Debug, clap::Args)]
101pub(crate) struct TalentConformanceArgs {
102    /// Optional manifest slug. Omit to check every supported MID2 profile.
103    #[arg(long)]
104    spec: Option<String>,
105
106    /// Replace the committed hermetic golden after every live comparison passes.
107    #[arg(long)]
108    refresh: bool,
109
110    /// Override the `WoW` CSV data checkout.
111    #[arg(long)]
112    data_dir: Option<PathBuf>,
113
114    /// Override the patched `SimulationCraft` executable.
115    #[arg(long)]
116    simc_binary: Option<PathBuf>,
117
118    /// Audit registered spec content, selected talents, and recursively triggered spells against the engine semantic registry.
119    #[arg(long)]
120    effects: bool,
121
122    /// Print only cross-spec totals for these spell-attribute IDs (comma-delimited).
123    #[arg(long, value_delimiter = ',', requires = "effects")]
124    attribute_summary: Vec<u16>,
125
126    /// Print retained DBC proc metadata for these aura subtype IDs (comma-delimited).
127    #[arg(long, value_delimiter = ',', requires = "effects")]
128    aura_summary: Vec<i32>,
129
130    /// Print retained DBC rows and child operation shapes for these spell-effect IDs.
131    #[arg(long, value_delimiter = ',', requires = "effects")]
132    effect_summary: Vec<i32>,
133
134    /// Print retained DBC rows for these spell-modifier property IDs.
135    #[arg(long, value_delimiter = ',', requires = "effects")]
136    modifier_summary: Vec<i32>,
137
138    /// Restrict modifier-property summaries to these aura subtype IDs.
139    #[arg(long, value_delimiter = ',', requires = "modifier_summary")]
140    modifier_aura: Vec<i32>,
141
142    /// Print unsupported target plans for these semantic axes.
143    #[arg(long, value_delimiter = ',', requires = "effects")]
144    target_plan_summary: Vec<TargetPlanAxisFilter>,
145
146    /// Print cross-spec totals for these zero-based cast-target flag bits.
147    #[arg(long, value_delimiter = ',', requires = "effects")]
148    target_flag_summary: Vec<u8>,
149}
150
151#[derive(Clone, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
152#[expect(
153    clippy::struct_excessive_bools,
154    reason = "the record mirrors independent flags in the loadout snapshot contract"
155)]
156struct TalentRecord {
157    node_id: i32,
158    #[serde(alias = "trait_node_entry_id")]
159    entry_id: i32,
160    spell_id: i32,
161    tree: String,
162    #[serde(alias = "sub_tree_id")]
163    subtree_id: i32,
164    rank: i32,
165    selected: bool,
166    granted: bool,
167    purchased: bool,
168    choice_index: Option<u8>,
169    choice_encoded: bool,
170    encoded_choice_index: Option<u8>,
171}
172
173#[derive(Debug, Deserialize, Serialize)]
174struct TalentSnapshot {
175    version: u32,
176    spec_id: i32,
177    node_count: usize,
178    node_ids: Vec<i32>,
179    records: Vec<TalentRecord>,
180    active_subtree_ids: Vec<i32>,
181    effective_records: Vec<TalentRecord>,
182}
183
184#[derive(Debug, Deserialize)]
185struct SimcJsonReport {
186    sim: SimcJsonSim,
187}
188
189#[derive(Debug, Deserialize)]
190struct SimcJsonSim {
191    players: Vec<SimcJsonPlayer>,
192}
193
194#[derive(Debug, Deserialize)]
195struct SimcJsonPlayer {
196    talent_decode: TalentSnapshot,
197}
198
199#[derive(Debug, Deserialize, Serialize)]
200struct SpecGolden {
201    slug: String,
202    loadout: String,
203    tree: TraitTreeFlat,
204    expected: TalentSnapshot,
205}
206
207#[derive(Debug, Deserialize, Serialize)]
208struct AllSpecGoldens {
209    version: u32,
210    specs: Vec<SpecGolden>,
211}
212
213// #t(fn: rust_cyclomatic_complexity) live, filtered, and refresh modes share one all-spec traversal.
214pub(crate) fn run(args: &TalentConformanceArgs) -> Result<()> {
215    let data_dir = args
216        .data_dir
217        .clone()
218        .unwrap_or_else(|| PathBuf::from(default_data_dir()));
219    let simc_binary = args.simc_binary.clone().unwrap_or_else(default_simc_binary);
220    let dbc = DbcData::load_all(&data_dir)
221        .with_context(|| format!("failed to load WoW data from {}", data_dir.display()))?;
222    let specs = selected_specs(args.spec.as_deref())?;
223    let mut goldens = Vec::with_capacity(specs.len());
224    let mut failures = Vec::new();
225    let mut attribute_summaries = AttributeSummaries::default();
226    let mut aura_summaries = AuraSummaries::default();
227    let mut effect_summaries = EffectSummaries::default();
228    let mut modifier_summaries = ModifierSummaries::default();
229    let mut target_plan_summaries = TargetPlanSummaries::default();
230    let mut target_flag_summaries = TargetFlagSummaries::default();
231
232    for spec in specs {
233        match check_spec(&dbc, &simc_binary, spec) {
234            Ok(golden) => {
235                if args.attribute_summary.is_empty()
236                    && args.aura_summary.is_empty()
237                    && args.effect_summary.is_empty()
238                    && args.modifier_summary.is_empty()
239                    && args.target_plan_summary.is_empty()
240                    && args.target_flag_summary.is_empty()
241                {
242                    output::subheader(spec.slug());
243                    output::success("Talent records match SimC");
244                    output::kv_fmt("Nodes", golden.expected.node_count);
245                    output::kv_fmt("Exact talent records", golden.expected.records.len());
246                }
247
248                if args.effects
249                    && let Err(error) = audit_effect_semantics(
250                        &dbc,
251                        spec,
252                        &golden.expected,
253                        &load_spec_manifest(spec)?,
254                        &args.attribute_summary,
255                        &mut attribute_summaries,
256                        &args.aura_summary,
257                        &mut aura_summaries,
258                        &args.effect_summary,
259                        &mut effect_summaries,
260                        &args.modifier_summary,
261                        &args.modifier_aura,
262                        &mut modifier_summaries,
263                        &args.target_plan_summary,
264                        &mut target_plan_summaries,
265                        &args.target_flag_summary,
266                        &mut target_flag_summaries,
267                    )
268                {
269                    if args.spec.is_some()
270                        && args.attribute_summary.is_empty()
271                        && args.aura_summary.is_empty()
272                        && args.effect_summary.is_empty()
273                        && args.modifier_summary.is_empty()
274                        && args.target_plan_summary.is_empty()
275                        && args.target_flag_summary.is_empty()
276                    {
277                        return Err(error);
278                    }
279
280                    output::error(&error.to_string());
281                    failures.push(format!("{}: {error:#}", spec.slug()));
282                    continue;
283                }
284
285                goldens.push(golden);
286            }
287            Err(error) if args.spec.is_some() => return Err(error),
288            Err(error) => {
289                output::subheader(spec.slug());
290                output::error(&error.to_string());
291                failures.push(format!("{}: {error:#}", spec.slug()));
292            }
293        }
294    }
295
296    if !args.attribute_summary.is_empty() {
297        attribute_summaries.print(&args.attribute_summary);
298    }
299
300    if !args.aura_summary.is_empty() {
301        aura_summaries.print(&args.aura_summary);
302    }
303
304    if !args.effect_summary.is_empty() {
305        effect_summaries.print(&args.effect_summary);
306    }
307
308    if !args.modifier_summary.is_empty() {
309        modifier_summaries.print(&args.modifier_summary);
310    }
311
312    if !args.target_plan_summary.is_empty() {
313        target_plan_summaries.print();
314    }
315
316    if !args.target_flag_summary.is_empty() {
317        target_flag_summaries.print(&args.target_flag_summary);
318    }
319
320    if !failures.is_empty() {
321        bail!(
322            "{} of {} talent conformance checks failed:\n{}",
323            failures.len(),
324            MID2_PROFILE_SPECS.len(),
325            failures.join("\n\n")
326        );
327    }
328
329    if args.refresh {
330        if args.spec.is_some() {
331            bail!("--refresh requires the complete all-spec run; omit --spec");
332        }
333
334        write_goldens(AllSpecGoldens {
335            version: GOLDEN_VERSION,
336            specs: goldens,
337        })?;
338    }
339
340    Ok(())
341}
342
343fn check_spec(dbc: &DbcData, simc_binary: &Path, spec: SpecId) -> Result<SpecGolden> {
344    let profile_path = simc_profile_path(spec)
345        .with_context(|| format!("missing MID2 profile for {}", spec.slug()))?;
346    let profile_text = file::read_text(&profile_path)
347        .with_context(|| format!("failed to read {}", profile_path.display()))?;
348    let profile = parse_simc(&profile_text)
349        .map_err(|error| anyhow::anyhow!("failed to parse {}: {error}", profile_path.display()))?;
350    let loadout = profile.talents.encoded;
351    let tree = transform_trait_tree(dbc, spec.wow_spec_id() as i32)
352        .with_context(|| format!("failed to transform trait tree for {}", spec.slug()))?;
353    let actual = wowlab_snapshot(&loadout, &tree)?;
354    let expected = simc_snapshot(simc_binary, &profile_path, spec)?;
355
356    compare_snapshots(spec, &actual, &expected)?;
357
358    Ok(SpecGolden {
359        slug: spec.slug().to_string(),
360        loadout,
361        tree: compact_tree(tree, &expected.records),
362        expected,
363    })
364}
365
366fn load_spec_manifest(spec: SpecId) -> Result<Manifest> {
367    let repository = ManifestRepository::new(engine_dir().join("manifests"));
368    let path = repository
369        .spec_paths()?
370        .into_iter()
371        .find(|path| {
372            repository
373                .spec_slug(path)
374                .is_ok_and(|slug| slug == spec.slug())
375        })
376        .with_context(|| format!("missing engine manifest for {}", spec.slug()))?;
377
378    repository
379        .load_spec(&path)
380        .with_context(|| format!("failed to load {}", path.display()))
381}
382
383fn selected_specs(slug: Option<&str>) -> Result<Vec<SpecId>> {
384    match slug {
385        Some(slug) => {
386            let spec = SpecId::from_manifest_slug(slug)
387                .with_context(|| format!("unknown spec slug: {slug}"))?;
388
389            if !MID2_PROFILE_SPECS.contains(&spec) {
390                bail!("{slug} has no Forge/SimC MID2 conformance profile");
391            }
392
393            Ok(vec![spec])
394        }
395        None => Ok(MID2_PROFILE_SPECS.to_vec()),
396    }
397}
398
399fn default_simc_binary() -> PathBuf {
400    simc_profile_path(SpecId::Survival)
401        .and_then(|path| {
402            path.ancestors()
403                .nth(SIMC_ROOT_ANCESTOR_DEPTH)
404                .map(PathBuf::from)
405        })
406        .unwrap_or_else(|| PathBuf::from("../simc"))
407        .join("build/simc")
408}