1#![expect(
4 clippy::cast_possible_wrap,
5 reason = "specialization IDs are constrained to the signed DBC identifier domain"
6)]
7
8use 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 #[arg(long)]
104 spec: Option<String>,
105
106 #[arg(long)]
108 refresh: bool,
109
110 #[arg(long)]
112 data_dir: Option<PathBuf>,
113
114 #[arg(long)]
116 simc_binary: Option<PathBuf>,
117
118 #[arg(long)]
120 effects: bool,
121
122 #[arg(long, value_delimiter = ',', requires = "effects")]
124 attribute_summary: Vec<u16>,
125
126 #[arg(long, value_delimiter = ',', requires = "effects")]
128 aura_summary: Vec<i32>,
129
130 #[arg(long, value_delimiter = ',', requires = "effects")]
132 effect_summary: Vec<i32>,
133
134 #[arg(long, value_delimiter = ',', requires = "effects")]
136 modifier_summary: Vec<i32>,
137
138 #[arg(long, value_delimiter = ',', requires = "modifier_summary")]
140 modifier_aura: Vec<i32>,
141
142 #[arg(long, value_delimiter = ',', requires = "effects")]
144 target_plan_summary: Vec<TargetPlanAxisFilter>,
145
146 #[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
213pub(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}