1#![expect(
5 clippy::cast_possible_truncation,
6 clippy::cast_sign_loss,
7 reason = "mean cast counts are non-negative bounded JSON floating-point values"
8)]
9
10use std::{collections::BTreeSet, process::Command};
13
14use anyhow::{Context, Result, bail};
15use wowlab_common::output;
16use wowlab_fs::{
17 directory::{self, EntryKind},
18 file,
19 path::{Path, PathBuf},
20 temporary,
21};
22use wowlab_types::game::SpecId;
23
24use crate::provider::{CastEntry, ComparisonConfig, SimOutput, SimProvider, SpellResult};
25
26const SIMC_STDERR_TRUNCATE: usize = 500;
27
28const DEFAULT_BUILD_JOBS: usize = 4;
29
30#[rustfmt::skip]
31const SIMC_SKIP: &[&str] = &[
32 "augmentation",
34 "flask",
35 "food",
36 "potion",
37 "snapshot_stats",
38 "use_items",
39 ];
41
42#[derive(Debug)]
43pub(crate) struct SimcProvider;
44
45impl SimProvider for SimcProvider {
46 fn name(&self) -> &'static str {
47 "SimC"
48 }
49
50 fn run(&self, config: &ComparisonConfig) -> Result<SimOutput> {
51 run_simc(config)
52 }
53}
54
55fn simc_dir() -> PathBuf {
56 std::env::var_os("WOWLAB_SIMC_DIR").map_or_else(
57 || crate::constants::workspace_parent().join("simc"),
58 PathBuf::from,
59 )
60}
61
62fn simc_binary() -> PathBuf {
63 simc_dir().join("build/simc")
64}
65
66fn ensure_simc_binary() -> Result<PathBuf> {
67 let binary = simc_binary();
68
69 match directory::inspect(&binary)
70 .with_context(|| format!("failed to inspect SimC binary {}", binary.display()))?
71 {
72 Some(entry) if entry.kind() == EntryKind::File => return Ok(binary),
73 Some(_) => bail!(
74 "SimC binary path is not a regular file: {}",
75 binary.display()
76 ),
77 None => {}
78 }
79
80 let dir = simc_dir();
81
82 match directory::inspect(&dir)
83 .with_context(|| format!("failed to inspect SimC repository {}", dir.display()))?
84 {
85 Some(entry) if entry.kind() == EntryKind::Directory => {}
86 Some(_) => bail!("SimC repository path is not a directory: {}", dir.display()),
87 None => {
88 bail!(
89 "SimC repo not found at {0}. Clone it next to this workspace: \
90 git clone git@github.com:simulationcraft/simc.git {0}",
91 dir.display(),
92 );
93 }
94 }
95
96 let build_dir = dir.join("build");
97
98 directory::ensure(&build_dir)
99 .with_context(|| format!("failed to create {}", build_dir.display()))?;
100
101 let jobs = std::thread::available_parallelism()
102 .map_or(DEFAULT_BUILD_JOBS, std::num::NonZeroUsize::get);
103
104 output::detail(&format!(
105 "SimC binary missing; building in {} (-j{jobs})...",
106 build_dir.display(),
107 ));
108
109 run_build_step(
110 &build_dir,
111 "cmake",
112 &["..".to_string(), "-DBUILD_GUI=OFF".to_string()],
113 )?;
114 run_build_step(&build_dir, "make", &[format!("-j{jobs}")])?;
115
116 match directory::inspect(&binary)
117 .with_context(|| format!("failed to inspect SimC binary {}", binary.display()))?
118 {
119 Some(entry) if entry.kind() == EntryKind::File => {}
120 Some(_) => bail!(
121 "SimC build finished but binary path is not a regular file: {}",
122 binary.display(),
123 ),
124 None => bail!(
125 "SimC build finished but binary still missing at {}",
126 binary.display(),
127 ),
128 }
129
130 Ok(binary)
131}
132
133fn run_build_step(cwd: &Path, program: &str, args: &[String]) -> Result<()> {
134 let status = Command::new(program)
135 .current_dir(cwd)
136 .args(args)
137 .status()
138 .with_context(|| format!("failed to spawn {program}"))?;
139
140 if !status.success() {
141 bail!("{program} failed while building SimC ({status})");
142 }
143
144 Ok(())
145}
146
147pub(crate) fn simc_profile_path(spec: SpecId) -> Option<PathBuf> {
148 let name = slug_to_simc_profile(spec)?;
149 let path = simc_dir()
150 .join("profiles/MID2")
151 .join(format!("{name}.simc"));
152
153 is_regular_file(&path).then_some(path)
154}
155
156fn simc_assisted_path(spec: SpecId) -> Option<PathBuf> {
157 let name = slug_to_assisted(spec)?;
158 let path = simc_dir()
159 .join("ActionPriorityLists/assisted_combat")
160 .join(format!("{name}.simc"));
161
162 is_regular_file(&path).then_some(path)
163}
164
165fn is_regular_file(path: &Path) -> bool {
166 directory::inspect(path)
167 .ok()
168 .flatten()
169 .is_some_and(|entry| entry.kind() == EntryKind::File)
170}
171
172fn slug_to_assisted(spec: SpecId) -> Option<&'static str> {
174 match spec {
175 SpecId::Affliction => Some("warlock_affliction"),
177 SpecId::Arcane => Some("mage_arcane"),
178 SpecId::Arms => Some("warrior_arms"),
179 SpecId::Assassination => Some("rogue_assassination"),
180 SpecId::Balance => Some("druid_balance"),
181 SpecId::BeastMastery => Some("hunter_beast_mastery"),
182 SpecId::Demonology => Some("warlock_demonology"),
183 SpecId::Destruction => Some("warlock_destruction"),
184 SpecId::Devastation => Some("evoker_devastation"),
185 SpecId::Devourer => Some("demonhunter_devourer"),
186 SpecId::Elemental => Some("shaman_elemental"),
187 SpecId::Enhancement => Some("shaman_enhancement"),
188 SpecId::Feral => Some("druid_feral"),
189 SpecId::Fire => Some("mage_fire"),
190 SpecId::FrostDK => Some("deathknight_frost"),
191 SpecId::FrostMage => Some("mage_frost"),
192 SpecId::Fury => Some("warrior_fury"),
193 SpecId::Havoc => Some("demonhunter_havoc"),
194 SpecId::Marksmanship => Some("hunter_marksmanship"),
195 SpecId::Outlaw => Some("rogue_outlaw"),
196 SpecId::Retribution => Some("paladin_retribution"),
197 SpecId::Shadow => Some("priest_shadow"),
198 SpecId::Subtlety => Some("rogue_subtlety"),
199 SpecId::Survival => Some("hunter_survival"),
200 SpecId::Unholy => Some("deathknight_unholy"),
201 SpecId::Vengeance => Some("demonhunter_vengeance"),
202 SpecId::Windwalker => Some("monk_windwalker"),
203 _ => None,
205 }
206}
207
208fn slug_to_simc_profile(spec: SpecId) -> Option<&'static str> {
210 match spec {
211 SpecId::Affliction => Some("MID2_Warlock_Affliction"),
213 SpecId::Arcane => Some("MID2_Mage_Arcane"),
214 SpecId::Assassination => Some("MID2_Rogue_Assassination"),
215 SpecId::BeastMastery => Some("MID2_Hunter_Beast_Mastery"),
216 SpecId::Demonology => Some("MID2_Warlock_Demonology"),
217 SpecId::Destruction => Some("MID2_Warlock_Destruction"),
218 SpecId::Elemental => Some("MID2_Shaman_Elemental"),
219 SpecId::Enhancement => Some("MID2_Shaman_Enhancement"),
220 SpecId::Fire => Some("MID2_Mage_Fire"),
221 SpecId::FrostDK => Some("MID2_Death_Knight_Frost"),
222 SpecId::FrostMage => Some("MID2_Mage_Frost"),
223 SpecId::Marksmanship => Some("MID2_Hunter_Marksmanship"),
224 SpecId::Outlaw => Some("MID2_Rogue_Outlaw"),
225 SpecId::Shadow => Some("MID2_Priest_Shadow"),
226 SpecId::Subtlety => Some("MID2_Rogue_Subtlety"),
227 SpecId::Survival => Some("MID2_Hunter_Survival"),
228 SpecId::Unholy => Some("MID2_Death_Knight_Unholy"),
229 SpecId::Vengeance => Some("MID2_Demon_Hunter_Vengeance"),
230 _ => None,
232 }
233}
234
235pub(crate) fn supports_comparison(spec: SpecId) -> bool {
236 slug_to_assisted(spec).is_some() && slug_to_simc_profile(spec).is_some()
237}
238
239fn extract_precombat_variables(profile: &Path) -> Vec<String> {
241 let text = file::read_text(profile).unwrap_or_default();
242 let mut vars = Vec::new();
243
244 for line in text.lines() {
245 if let Some(rest) = line.strip_prefix("actions.precombat") {
246 let action = rest
247 .strip_prefix("+=/")
248 .or_else(|| rest.strip_prefix('='))
249 .unwrap_or("");
250
251 if action.starts_with("variable,") {
252 vars.push(action.to_string());
253 }
254 }
255 }
256
257 vars
258}
259
260#[rustfmt::skip]
262const AUTO_ATTACK_ACTIONS: &[&str] = &[
263 "auto_attack",
265 "auto_shot",
266 ];
268
269fn extract_list_actions(source: &Path, list: &str) -> Vec<String> {
271 let text = file::read_text(source).unwrap_or_default();
272 let prefix = if list == "default" {
273 "actions".to_string()
274 } else {
275 format!("actions.{list}")
276 };
277 let mut actions = Vec::new();
278
279 for line in text.lines() {
280 let Some(rest) = line.strip_prefix(&prefix) else {
281 continue;
282 };
283 let action = if let Some(appended) = rest.strip_prefix("+=") {
284 appended.strip_prefix('/').unwrap_or(appended)
285 } else {
286 let Some(assigned) = rest.strip_prefix('=') else {
287 continue;
288 };
289
290 assigned.strip_prefix('/').unwrap_or(assigned)
291 };
292
293 if !action.is_empty() {
294 actions.push(action.to_string());
295 }
296 }
297
298 actions
299}
300
301fn dropped_auto_attack(profile: &Path, assisted: &Path) -> Option<String> {
303 let assisted_text = file::read_text(assisted).unwrap_or_default();
304
305 extract_action_lists(profile)
306 .iter()
307 .flat_map(|list| extract_list_actions(profile, list))
308 .find(|action| {
309 AUTO_ATTACK_ACTIONS.contains(&action.as_str())
310 && !assisted_text.contains(action.as_str())
311 })
312}
313
314pub(crate) fn extract_action_lists(profile: &Path) -> Vec<String> {
315 let text = file::read_text(profile).unwrap_or_default();
316 let mut names = BTreeSet::new();
317
318 for line in text.lines() {
319 if let Some(rest) = line.strip_prefix("actions.") {
320 if let Some(name) = rest.split(['+', '=']).next() {
321 names.insert(name.to_string());
322 }
323 } else if line.starts_with("actions+") || line.starts_with("actions=") {
324 names.insert("default".to_string());
325 }
326 }
327
328 names.into_iter().collect()
329}
330
331fn append_assisted_arguments(cmd_args: &mut Vec<String>, profile: &Path, assisted: &Path) {
333 for name in extract_action_lists(profile) {
334 if name == "default" {
335 cmd_args.push("actions=/".to_string());
336 } else {
337 cmd_args.push(format!("actions.{name}=/"));
338 }
339 }
340
341 cmd_args.push(assisted.display().to_string());
342
343 if let Some(action) = dropped_auto_attack(profile, assisted) {
347 cmd_args.push(format!("actions=/{action}"));
348
349 for line in extract_list_actions(assisted, "default") {
350 cmd_args.push(format!("actions+=/{line}"));
351 }
352 }
353
354 for var in extract_precombat_variables(profile) {
355 cmd_args.push(format!("actions.precombat+=/{var}"));
356 }
357}
358
359fn run_simc(config: &ComparisonConfig) -> Result<SimOutput> {
360 let binary = ensure_simc_binary()?;
361
362 let profile = simc_profile_path(config.spec)
363 .ok_or_else(|| anyhow::anyhow!("no SimC profile for {}", config.spec.slug()))?;
364 let output = temporary::Directory::with_prefix("wowlab-forge-simc-")
365 .context("failed to create temporary SimC output directory")?;
366 let json_path = output.path().join("result.json");
367
368 let mut cmd_args: Vec<String> = vec![profile.display().to_string()];
369
370 if let Some(assisted) = simc_assisted_path(config.spec) {
371 let apl_name = assisted.file_name().unwrap_or_default().to_string_lossy();
372
373 output::detail(&format!("Using assisted APL: {apl_name}"));
374 append_assisted_arguments(&mut cmd_args, &profile, &assisted);
375 }
376
377 append_run_arguments(&mut cmd_args, config, &json_path);
378
379 output::detail(&format!(
380 "Running SimC ({} iter, {}s)...",
381 config.parameters.iterations(),
382 config.parameters.fight_duration_secs(),
383 ));
384
385 let cmd_output = Command::new(&binary)
386 .args(&cmd_args)
387 .output()
388 .context("failed to spawn SimC")?;
389
390 if !cmd_output.status.success() {
391 let stderr = String::from_utf8_lossy(&cmd_output.stderr);
392 let truncated: String = stderr.chars().take(SIMC_STDERR_TRUNCATE).collect();
393
394 bail!("SimC failed:\n{truncated}");
395 }
396
397 if config.simc_debug {
398 retain_debug_log(config.spec, &cmd_output.stdout)?;
399 }
400
401 let json_text = file::read_text(&json_path).context("failed to read SimC json2 output")?;
402
403 parse_json2(&json_text, config)
404}
405
406fn append_run_arguments(arguments: &mut Vec<String>, config: &ComparisonConfig, json_path: &Path) {
407 arguments.extend([
408 format!("max_time={}", config.parameters.fight_duration_secs()),
409 format!("iterations={}", config.parameters.iterations()),
410 "fight_style=Patchwerk".to_string(),
411 "optimal_raid=0".to_string(),
413 format!("json2={}", json_path.display()),
414 "threads=1".to_string(),
415 format!("seed={}", crate::constants::DEFAULT_SEED),
417 ]);
418
419 if let Some(ref race) = config.race {
420 arguments.push(format!("race={race}"));
421 }
422
423 if !config.bugs {
424 arguments.push("bugs=0".to_string());
425 }
426
427 if config.simc_debug {
428 arguments.push("debug=1".to_string());
429 }
430}
431
432fn retain_debug_log(spec: SpecId, stdout: &[u8]) -> Result<()> {
434 let log = temporary::File::with_affixes(&format!("forge-simc-debug-{}-", spec.slug()), ".log")
435 .context("failed to create SimC debug log")?
436 .retain()
437 .context("failed to create SimC debug log")?;
438 let path = log.path().to_path_buf();
439
440 file::write_bytes(&path, stdout).context("failed to write SimC debug log")?;
441 output::detail(&format!("SimC debug log written to: {}", path.display()));
442
443 Ok(())
444}
445
446fn parse_resource_gains(
448 player: &serde_json::Value,
449) -> std::collections::BTreeMap<String, crate::provider::ResourceGain> {
450 let mut resource_gains = std::collections::BTreeMap::new();
451 let Some(gains) = player["gains"].as_array() else {
452 return resource_gains;
453 };
454
455 for gain in gains {
456 let (Some(name), Some(obj)) = (gain["name"].as_str(), gain.as_object()) else {
457 continue;
458 };
459
460 for (key, value) in obj {
461 if key == "name" {
462 continue;
463 }
464
465 let gained = value["actual"].as_f64().unwrap_or(0.0);
466 let wasted = value["overflow"].as_f64().unwrap_or(0.0);
467
468 if gained > 0.0 || wasted > 0.0 {
469 let entry = resource_gains
470 .entry(name.to_string())
471 .or_insert(crate::provider::ResourceGain::default());
472
473 entry.gained += gained;
474 entry.wasted += wasted;
475 }
476 }
477 }
478
479 resource_gains
480}
481
482#[derive(Clone, Copy, Debug, Eq, PartialEq)]
483enum StatsOwner {
484 Player,
485 Pet,
486}
487
488impl StatsOwner {
489 fn report_name(self, name: &str) -> String {
490 if self == Self::Pet && matches!(name, "auto_attack_mh" | "auto_attack_oh") {
491 format!("pet_{name}")
492 } else {
493 name.to_string()
494 }
495 }
496}
497
498fn push_spell_result(
500 s: &serde_json::Value,
501 mean_dps: f64,
502 owner: StatsOwner,
503 spells: &mut Vec<(String, SpellResult)>,
504) {
505 if s["type"].as_str().is_some_and(|t| t != "damage") {
507 return;
508 }
509 let dps = s["portion_amount"].as_f64().map_or_else(
512 || s["portion_aps"]["mean"].as_f64().unwrap_or(0.0),
513 |p| p * mean_dps,
514 );
515
516 if dps <= 0.0 {
517 return;
518 }
519
520 let pct = if mean_dps > 0.0 { dps / mean_dps } else { 0.0 };
521 let hits = s["num_direct_results"]["mean"].as_f64().unwrap_or(0.0)
523 + s["num_tick_results"]["mean"].as_f64().unwrap_or(0.0);
524
525 let name = owner.report_name(s["name"].as_str().unwrap_or("?"));
526
527 spells.push((
528 name,
529 SpellResult {
530 casts: s["num_executes"]["mean"].as_f64().unwrap_or(0.0) as u32,
531 hits: hits as u32,
532 dps,
533 pct,
534 },
535 ));
536}
537
538fn collect_stats<'a>(
540 stats: &'a serde_json::Value,
541 mean_dps: f64,
542 owner: StatsOwner,
543 spells: &mut Vec<(String, SpellResult)>,
544) {
545 let mut queue: Vec<&'a serde_json::Value> = vec![stats];
547
548 while let Some(node) = queue.pop() {
549 let Some(arr) = node.as_array() else {
550 continue;
551 };
552
553 for s in arr {
554 push_spell_result(s, mean_dps, owner, spells);
555 queue.push(&s["children"]);
556 }
557 }
558}
559
560fn parse_json2(json_text: &str, config: &ComparisonConfig) -> Result<SimOutput> {
562 let root: serde_json::Value = serde_json::from_str(json_text).context("invalid SimC json2")?;
563 let player = &root["sim"]["players"][0];
564
565 let mean_dps = player["collected_data"]["dps"]["mean"]
566 .as_f64()
567 .unwrap_or(0.0);
568
569 let mut spells = Vec::new();
570
571 collect_stats(&player["stats"], mean_dps, StatsOwner::Player, &mut spells);
572 let resource_gains = parse_resource_gains(player);
573 if let Some(pets) = player["stats_pets"].as_object() {
576 for pet_stats in pets.values() {
577 collect_stats(pet_stats, mean_dps, StatsOwner::Pet, &mut spells);
578 }
579 }
580
581 let mut timeline = Vec::new();
582
583 if config.parameters.iterations() == 1
584 && let Some(seq) = player["collected_data"]["action_sequence"].as_array()
585 {
586 for event in seq {
587 let name = event["name"].as_str().unwrap_or("").to_string();
588
589 if SIMC_SKIP.contains(&name.as_str()) {
590 continue;
591 }
592
593 let time_secs = event["time"].as_f64().unwrap_or(0.0);
594
595 timeline.push(CastEntry {
596 time_secs,
597 spell_name: name,
598 });
599 }
600 }
601
602 let mut output = SimOutput::new(mean_dps, spells, timeline);
603
604 output.resource_gains = resource_gains;
605
606 Ok(output)
607}
608
609#[cfg(test)]
610mod tests;