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 = "SimC reports non-negative bounded mean cast counts as 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    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
169// #t(fn: rust_cyclomatic_complexity) 1:1 spec-to-filename match table
170fn slug_to_assisted(spec: SpecId) -> Option<&'static str> {
171    match spec {
172        // tidy-alphabetical-start
173        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        // tidy-alphabetical-end
201        _ => None,
202    }
203}
204
205// #t(fn: rust_cyclomatic_complexity) 1:1 spec-to-filename match table
206fn slug_to_simc_profile(spec: SpecId) -> Option<&'static str> {
207    match spec {
208        // tidy-alphabetical-start
209        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        // tidy-alphabetical-end
237        _ => 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
245/// Extracts precombat variable definitions so the assisted APL's `use_items` expansions don't dangle and zero the `SimC` side.
246fn 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/// Actions that only start the actor's auto-attack loop rather than making a rotation decision.
267#[rustfmt::skip]
268const AUTO_ATTACK_ACTIONS: &[&str] = &[
269    // tidy-alphabetical-start
270    "auto_attack",
271    "auto_shot",
272    // tidy-alphabetical-end
273];
274
275/// Returns every action string declared for `list` in a `.simc` file, in declaration order.
276fn 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
307/// The auto-attack action a profile declares that the assisted dump never does.
308fn 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
337/// Swaps the profile's action lists for the assisted dump, keeping its profile-only setup actions.
338fn 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    // Rebuild the default list with the auto-attack seeded first, exactly where every assisted
350    // dump that does declare one puts it.
351
352    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        // The engine simulates a lone actor; external raid buffs/debuffs would skew every dpc.
418        "optimal_raid=0".to_string(),
419        format!("json2={}", json_path.display()),
420        "threads=1".to_string(),
421        // Pin the RNG so SimC re-runs are reproducible, mirroring the seed-deterministic engine side.
422        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
438/// Writes `SimC`'s debug stdout beside the engine trace log so both sides can be grepped factor by factor.
439fn 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
452// #t(fn: rust_unchecked_indexing) serde_json::Value indexing returns Value::Null for missing keys, never panics
453fn 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
504// #t(fn: rust_unchecked_indexing) serde_json::Value indexing returns Value::Null for missing keys, never panics
505fn push_spell_result(
506    s: &serde_json::Value,
507    mean_dps: f64,
508    owner: StatsOwner,
509    spells: &mut Vec<(String, SpellResult)>,
510) {
511    // Heal/absorb rows report portion_amount of their own pool (absorbs report 1.0), not damage.
512    if s["type"].as_str().is_some_and(|t| t != "damage") {
513        return;
514    }
515    // portion_amount is each row's share of total damage; portion_aps is pet-active-time normalized and inflates temporary-pet rows.
516
517    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    // Pet, guardian, and proc rows report zero executes but still land results.
528    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
544// #t(fn: rust_unchecked_indexing) serde_json::Value indexing returns Value::Null for missing keys, never panics
545fn collect_stats<'a>(
546    stats: &'a serde_json::Value,
547    mean_dps: f64,
548    owner: StatsOwner,
549    spells: &mut Vec<(String, SpellResult)>,
550) {
551    // Child actions carry their own damage that never rolls up into the parent's portion figures, nesting arbitrarily deep.
552    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
566// #t(fn: rust_unchecked_indexing) serde_json::Value indexing returns Value::Null for missing keys, never panics
567fn 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    // Pet damage lives under stats_pets and never rolls up into the owner's stats array.
580
581    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;