Skip to main content

forge/
simc.rs

1// #t(file: rust_alloc_in_loop) CLI binary, allocations are fine for readability.
2// #t(file: rust_forbidden_deps) forge is CLI-only, never targets WASM.
3
4#![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
10//! `SimcProvider`: shells out to the `SimC` binary and parses json2 output.
11
12use 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    // tidy-alphabetical-start
33    "augmentation",
34    "flask",
35    "food",
36    "potion",
37    "snapshot_stats",
38    "use_items",
39    // tidy-alphabetical-end
40];
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
172// #t(fn: rust_cyclomatic_complexity) 1:1 spec-to-filename match table
173fn slug_to_assisted(spec: SpecId) -> Option<&'static str> {
174    match spec {
175        // tidy-alphabetical-start
176        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        // tidy-alphabetical-end
204        _ => None,
205    }
206}
207
208// #t(fn: rust_cyclomatic_complexity) 1:1 spec-to-filename match table
209fn slug_to_simc_profile(spec: SpecId) -> Option<&'static str> {
210    match spec {
211        // tidy-alphabetical-start
212        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        // tidy-alphabetical-end
231        _ => 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
239/// Extracts precombat variable definitions so the assisted APL's `use_items` expansions don't dangle and zero the `SimC` side.
240fn 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/// Actions that only start the actor's auto-attack loop rather than making a rotation decision.
261#[rustfmt::skip]
262const AUTO_ATTACK_ACTIONS: &[&str] = &[
263    // tidy-alphabetical-start
264    "auto_attack",
265    "auto_shot",
266    // tidy-alphabetical-end
267];
268
269/// Returns every action string declared for `list` in a `.simc` file, in declaration order.
270fn 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
301/// The auto-attack action a profile declares that the assisted dump never does.
302fn 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
331/// Swaps the profile's action lists for the assisted dump, keeping its profile-only setup actions.
332fn 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    // Rebuild the default list with the auto-attack seeded first, exactly where every assisted
344    // dump that does declare one puts it.
345
346    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        // The engine simulates a lone actor; external raid buffs/debuffs would skew every dpc.
412        "optimal_raid=0".to_string(),
413        format!("json2={}", json_path.display()),
414        "threads=1".to_string(),
415        // Pin the RNG so SimC re-runs are reproducible, mirroring the seed-deterministic engine side.
416        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
432/// Writes `SimC`'s debug stdout beside the engine trace log so both sides can be grepped factor by factor.
433fn 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
446// #t(fn: rust_unchecked_indexing) serde_json::Value indexing returns Value::Null for missing keys, never panics
447fn 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
498// #t(fn: rust_unchecked_indexing) serde_json::Value indexing returns Value::Null for missing keys, never panics
499fn push_spell_result(
500    s: &serde_json::Value,
501    mean_dps: f64,
502    owner: StatsOwner,
503    spells: &mut Vec<(String, SpellResult)>,
504) {
505    // Heal/absorb rows report portion_amount of their own pool (absorbs report 1.0), not damage.
506    if s["type"].as_str().is_some_and(|t| t != "damage") {
507        return;
508    }
509    // portion_amount is each row's share of total damage; portion_aps is pet-active-time normalized and inflates temporary-pet rows.
510
511    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    // Pet, guardian, and proc rows report zero executes but still land results.
522    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
538// #t(fn: rust_unchecked_indexing) serde_json::Value indexing returns Value::Null for missing keys, never panics
539fn collect_stats<'a>(
540    stats: &'a serde_json::Value,
541    mean_dps: f64,
542    owner: StatsOwner,
543    spells: &mut Vec<(String, SpellResult)>,
544) {
545    // Child actions carry their own damage that never rolls up into the parent's portion figures, nesting arbitrarily deep.
546    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
560// #t(fn: rust_unchecked_indexing) serde_json::Value indexing returns Value::Null for missing keys, never panics
561fn 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    // Pet damage lives under stats_pets and never rolls up into the owner's stats array.
574
575    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;