Skip to main content

forge/compare_matrix/
runner.rs

1use std::{cmp::Ordering, panic::AssertUnwindSafe};
2
3use anyhow::{Context, Result, ensure};
4#[cfg(test)]
5use googletest::{Result as GtestResult, prelude::*};
6use rayon::prelude::*;
7use wowlab_common::output;
8use wowlab_engine_ports::content_catalog;
9use wowlab_types::game::SpecId;
10
11use super::types::{ComparisonMatrix, ComparisonRow, ComparisonStatus};
12use crate::{
13    provider::{ComparisonConfig, SimProvider},
14    run::RunParameters,
15    simc::{self, SimcProvider},
16    wowlab::WowlabProvider,
17};
18
19#[expect(
20    clippy::needless_pass_by_value,
21    reason = "the optional race is cloned into independently owned parallel comparison configs"
22)]
23pub(super) fn run(
24    filter: &[SpecId],
25    parameters: RunParameters,
26    jobs: usize,
27    race: Option<String>,
28) -> Result<ComparisonMatrix> {
29    let iterations = parameters.iterations();
30    let duration_secs = parameters.fight_duration_secs();
31    let specs = discover_specs(filter)?;
32
33    output::header(&format!(
34        "WoW Lab / SimC comparison matrix: {} specs ({iterations} iter, {duration_secs}s, {jobs} jobs)",
35        specs.len(),
36    ));
37    output::blank();
38
39    let pool = rayon::ThreadPoolBuilder::new()
40        .num_threads(jobs.min(specs.len()))
41        .build()
42        .context("failed to build comparison worker pool")?;
43    let mut rows = pool.install(|| {
44        specs
45            .par_iter()
46            .map(|spec| run_spec(*spec, parameters, race.clone()))
47            .collect::<Vec<_>>()
48    });
49
50    rows.sort_by(compare_rows);
51
52    Ok(ComparisonMatrix {
53        duration_secs,
54        iterations,
55        rows,
56    })
57}
58
59fn discover_specs(filter: &[SpecId]) -> Result<Vec<SpecId>> {
60    let catalog = content_catalog().context("engine content catalog is unavailable")?;
61    let mut supported: Vec<_> = catalog
62        .descriptors()
63        .iter()
64        .map(|descriptor| descriptor.spec_id)
65        .filter(|spec| simc::supports_comparison(*spec))
66        .collect();
67
68    supported.sort_by_key(|spec| spec.slug());
69    supported.dedup();
70    ensure!(
71        !supported.is_empty(),
72        "no specs have both a SimC profile and assisted rotation"
73    );
74
75    if filter.is_empty() {
76        return Ok(supported);
77    }
78
79    for spec in filter {
80        ensure!(
81            supported.contains(spec),
82            "{} does not have both a SimC profile and assisted rotation",
83            spec.slug(),
84        );
85    }
86
87    let mut selected = filter.to_vec();
88
89    selected.sort_by_key(|spec| spec.slug());
90    selected.dedup();
91
92    Ok(selected)
93}
94
95fn run_spec(spec: SpecId, parameters: RunParameters, race: Option<String>) -> ComparisonRow {
96    let slug = spec.slug().to_string();
97    // PANIC-BOUNDARY: isolate one provider pair so the matrix preserves other spec rows.
98    let result = std::panic::catch_unwind(AssertUnwindSafe(|| {
99        let config = ComparisonConfig {
100            spec,
101            parameters,
102            race,
103            bugs: true,
104            simc_debug: false,
105        };
106        let wowlab = WowlabProvider::new(None)
107            .run(&config)
108            .context("WoW Lab simulation failed")?;
109        let simc = SimcProvider
110            .run(&config)
111            .context("SimulationCraft simulation failed")?;
112
113        Ok::<_, anyhow::Error>((wowlab.dps, simc.dps))
114    }));
115
116    match result {
117        Ok(Ok((wowlab_dps, simc_dps))) => {
118            output::success(&format!("{slug:<28} complete"));
119
120            ComparisonRow::success(slug, wowlab_dps, simc_dps)
121        }
122        Ok(Err(error)) => ComparisonRow::failed(slug, format!("{error:#}")),
123        Err(payload) => ComparisonRow::failed(slug, panic_message(&payload)),
124    }
125}
126
127fn panic_message(payload: &Box<dyn std::any::Any + Send>) -> String {
128    payload
129        .downcast_ref::<String>()
130        .map(String::as_str)
131        .or_else(|| payload.downcast_ref::<&str>().copied())
132        .unwrap_or("unknown panic")
133        .to_string()
134}
135
136fn compare_rows(left: &ComparisonRow, right: &ComparisonRow) -> Ordering {
137    match (left.status, right.status) {
138        (ComparisonStatus::Failed, ComparisonStatus::Ok) => Ordering::Less,
139        (ComparisonStatus::Ok, ComparisonStatus::Failed) => Ordering::Greater,
140        (ComparisonStatus::Failed, ComparisonStatus::Failed) => left.spec.cmp(&right.spec),
141        (ComparisonStatus::Ok, ComparisonStatus::Ok) => right
142            .absolute_delta_pct()
143            .partial_cmp(&left.absolute_delta_pct())
144            .unwrap_or(Ordering::Equal)
145            .then_with(|| left.spec.cmp(&right.spec)),
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152
153    #[gtest]
154    fn rows_sort_failures_first_then_by_largest_absolute_gap() -> GtestResult<()> {
155        let mut rows = [
156            ComparisonRow::success("close".to_string(), 95.0, 100.0),
157            ComparisonRow::success("high".to_string(), 140.0, 100.0),
158            ComparisonRow::success("low".to_string(), 40.0, 100.0),
159            ComparisonRow::failed("broken".to_string(), "failed".to_string()),
160        ];
161
162        rows.sort_by(compare_rows);
163
164        verify_that!(
165            rows.map(|row| row.spec),
166            elements_are!["broken", "low", "high", "close"]
167        )?;
168
169        Ok(())
170    }
171}