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