forge/compare_matrix/
runner.rs1use 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 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}