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