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]
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 #[arg(long)]
81 spec: Option<String>,
82
83 #[arg(long)]
85 refresh: bool,
86
87 #[arg(long)]
89 data_dir: Option<PathBuf>,
90
91 #[arg(long)]
93 simc_binary: Option<PathBuf>,
94
95 #[arg(long)]
97 effects: bool,
98
99 #[arg(long, value_delimiter = ',', requires = "effects")]
101 attribute_summary: Vec<u16>,
102
103 #[arg(long, value_delimiter = ',', requires = "effects")]
105 aura_summary: Vec<i32>,
106
107 #[arg(long, value_delimiter = ',', requires = "effects")]
109 effect_summary: Vec<i32>,
110
111 #[arg(long, value_delimiter = ',', requires = "effects")]
113 modifier_summary: Vec<i32>,
114
115 #[arg(long, value_delimiter = ',', requires = "modifier_summary")]
117 modifier_aura: Vec<i32>,
118
119 #[arg(long, value_delimiter = ',', requires = "effects")]
121 target_plan_summary: Vec<TargetPlanAxisFilter>,
122
123 #[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
190pub(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}