forge/talent_conformance/
snapshot.rs1use std::{collections::BTreeMap, process::Command};
4
5use anyhow::{Context, Result, bail};
6#[cfg(test)]
7use googletest::{Result as GtestResult, prelude::*};
8use wowlab_fs::{file, path::Path, temporary};
9use wowlab_loadout::{
10 DecodedTraitNode, apply_decoded_traits, decode_trait_loadout, enrich_loadout,
11};
12use wowlab_types::{
13 data::{
14 TraitNode, TraitNodeEntry, TraitNodeType, TraitTreeFlat, TraitTreeIndex,
15 TraitTreeWithSelections,
16 },
17 game::SpecId,
18};
19
20use super::{
21 SIMC_TALENT_OUTPUT_OPTION, SimcJsonReport, TalentRecord, TalentSnapshot,
22 golden::normalize_snapshot,
23};
24use crate::simc::extract_action_lists;
25
26pub(super) fn simc_snapshot(binary: &Path, profile: &Path, spec: SpecId) -> Result<TalentSnapshot> {
27 let output = temporary::Directory::with_prefix("wowlab-talent-conformance-")
28 .context("failed to create temporary SimC talent output directory")?;
29 let output_path = output.path().join("result.json");
30 let action_lists = extract_action_lists(profile);
31 let arguments = simc_arguments(profile, &output_path, &action_lists);
32 let result = Command::new(binary)
33 .args(&arguments)
34 .output()
35 .with_context(|| format!("failed to run patched SimC at {}", binary.display()))?;
36
37 if !result.status.success() {
38 bail!(
39 "SimC talent export failed for {}: {}",
40 spec.slug(),
41 String::from_utf8_lossy(&result.stderr)
42 );
43 }
44
45 let json = file::read_text(&output_path).with_context(|| {
46 format!(
47 "patched SimC did not write {SIMC_TALENT_OUTPUT_OPTION} output {}",
48 output_path.display()
49 )
50 })?;
51 let report: SimcJsonReport = serde_json::from_str(&json)
52 .with_context(|| format!("invalid SimC talent JSON for {}", spec.slug()))?;
53 let mut snapshot = report
54 .sim
55 .players
56 .into_iter()
57 .next()
58 .with_context(|| format!("SimC JSON has no player for {}", spec.slug()))?
59 .talent_decode;
60
61 normalize_snapshot(&mut snapshot);
62
63 Ok(snapshot)
64}
65
66fn simc_arguments(profile: &Path, output_path: &Path, action_lists: &[String]) -> Vec<String> {
67 let mut arguments = vec![profile.display().to_string()];
68
69 for name in action_lists {
70 if name == "default" {
71 arguments.push("actions=/".to_string());
72 } else {
73 arguments.push(format!("actions.{name}=/"));
74 }
75 }
76
77 arguments.extend([
78 "actions=wait,sec=1".to_string(),
79 "iterations=1".to_string(),
80 "max_time=1".to_string(),
81 "report_details=0".to_string(),
82 format!("{SIMC_TALENT_OUTPUT_OPTION}={}", output_path.display()),
83 ]);
84
85 arguments
86}
87
88pub(super) fn wowlab_snapshot(loadout: &str, tree: &TraitTreeFlat) -> Result<TalentSnapshot> {
89 let decoded = decode_trait_loadout(loadout).context("failed to decode MID1 loadout")?;
90
91 if i32::from(decoded.spec_id) != tree.spec_id {
92 bail!(
93 "loadout spec {} does not match transformed tree {}",
94 decoded.spec_id,
95 tree.spec_id
96 );
97 }
98
99 let raw_by_node: BTreeMap<i32, _> = decoded
100 .nodes
101 .iter()
102 .zip(&tree.all_node_ids)
103 .map(|(node, &node_id)| (node_id, node))
104 .collect();
105 let applied = apply_decoded_traits(tree.clone(), &decoded);
106 let records = raw_records(&applied, &raw_by_node)?;
107 let mut active_subtree_ids: Vec<i32> = records
108 .iter()
109 .filter(|record| record.tree == "selection")
110 .map(|record| record.subtree_id)
111 .collect();
112
113 active_subtree_ids.sort_unstable();
114 active_subtree_ids.dedup();
115 let enriched = enrich_loadout(&applied);
116 let effective_records = enriched
117 .talents
118 .into_iter()
119 .map(|talent| {
120 let raw = raw_by_node.get(&talent.node_id).copied();
121 let node = tree
122 .nodes
123 .iter()
124 .find(|node| node.id == talent.node_id)
125 .with_context(|| format!("enriched node {} is absent from tree", talent.node_id))?;
126
127 Ok(TalentRecord {
128 node_id: talent.node_id,
129 entry_id: talent.trait_node_entry_id,
130 spell_id: talent.spell_id,
131 tree: tree_name(talent.tree_index)?.to_string(),
132 subtree_id: talent.sub_tree_id,
133 rank: talent.ranks,
134 selected: raw.is_some_and(|decoded| decoded.selected),
135 granted: raw.is_none_or(|decoded| !decoded.purchased),
136 purchased: raw.is_some_and(|decoded| decoded.purchased),
137 choice_index: normalized_choice_index(node, raw),
138 choice_encoded: raw.is_some_and(|decoded| decoded.choice_node),
139 encoded_choice_index: raw.and_then(|decoded| decoded.choice_index),
140 })
141 })
142 .collect::<Result<Vec<_>>>()?;
143
144 let mut snapshot = TalentSnapshot {
145 version: u32::from(decoded.version),
146 spec_id: tree.spec_id,
147 node_count: tree.all_node_ids.len(),
148 node_ids: tree.all_node_ids.clone(),
149 records,
150 active_subtree_ids,
151 effective_records,
152 };
153
154 normalize_snapshot(&mut snapshot);
155
156 Ok(snapshot)
157}
158
159fn raw_records(
160 applied: &TraitTreeWithSelections,
161 raw_by_node: &BTreeMap<i32, &DecodedTraitNode>,
162) -> Result<Vec<TalentRecord>> {
163 let mut records = Vec::with_capacity(applied.selections.len());
164
165 for selection in &applied.selections {
166 if !selection.selected || selection.ranks_purchased <= 0 {
167 continue;
168 }
169
170 let Some(node) = applied
171 .tree
172 .nodes
173 .iter()
174 .find(|node| node.id == selection.node_id)
175 else {
176 continue;
177 };
178 let decoded = raw_by_node.get(&node.id).copied();
179 let rank = selection.ranks_purchased;
180
181 if TraitNodeType::from_dbc(node.node_type) == Some(TraitNodeType::Tiered) {
182 let mut remaining = rank;
183
184 for entry in &node.entries {
185 let allocated = remaining.min(entry.max_ranks);
186
187 if allocated > 0 {
188 records.push(raw_record(node, entry, decoded, allocated)?);
189 remaining -= allocated;
190 }
191 }
192
193 if remaining != 0 {
194 bail!("tiered node {} left {remaining} ranks unallocated", node.id);
195 }
196 } else {
197 let choice = usize::from(selection.choice_index.unwrap_or(0));
198 let entry = node
199 .entries
200 .get(choice)
201 .with_context(|| format!("node {} choice {choice} is out of bounds", node.id))?;
202
203 records.push(raw_record(node, entry, decoded, rank)?);
204 }
205 }
206
207 Ok(records)
208}
209
210fn raw_record(
211 node: &TraitNode,
212 entry: &TraitNodeEntry,
213 decoded: Option<&DecodedTraitNode>,
214 rank: i32,
215) -> Result<TalentRecord> {
216 Ok(TalentRecord {
217 node_id: node.id,
218 entry_id: entry.id,
219 spell_id: entry.spell_id,
220 tree: tree_name(node.tree_index)?.to_string(),
221 subtree_id: if TraitTreeIndex::from_dbc(node.tree_index) == Some(TraitTreeIndex::Selection)
222 {
223 entry.sub_tree_id
224 } else {
225 node.sub_tree_id
226 },
227 rank,
228 selected: decoded.is_some_and(|node| node.selected),
229 granted: decoded.is_none_or(|node| !node.purchased),
230 purchased: decoded.is_some_and(|node| node.purchased),
231 choice_index: normalized_choice_index(node, decoded),
232 choice_encoded: decoded.is_some_and(|node| node.choice_node),
233 encoded_choice_index: decoded.and_then(|node| node.choice_index),
234 })
235}
236
237fn normalized_choice_index(node: &TraitNode, decoded: Option<&DecodedTraitNode>) -> Option<u8> {
238 matches!(
239 TraitNodeType::from_dbc(node.node_type),
240 Some(TraitNodeType::Selection | TraitNodeType::SubtreeSelection)
241 )
242 .then(|| decoded.and_then(|raw| raw.choice_index).unwrap_or(0))
243}
244
245fn tree_name(tree_index: i32) -> Result<&'static str> {
246 match TraitTreeIndex::from_dbc(tree_index) {
247 Some(TraitTreeIndex::Class) => Ok("class"),
248 Some(TraitTreeIndex::Specialization) => Ok("spec"),
249 Some(TraitTreeIndex::Hero) => Ok("hero"),
250 Some(TraitTreeIndex::Selection) => Ok("selection"),
251 _ => bail!("unknown trait tree index {tree_index}"),
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 #[gtest]
260 fn simc_arguments_clear_profiles_and_pin_export_options() -> GtestResult<()> {
261 let arguments = simc_arguments(
262 Path::new("profiles/MID1.simc"),
263 Path::new("/tmp/talents.json"),
264 &["cooldowns".to_string(), "default".to_string()],
265 );
266
267 verify_that!(
268 arguments,
269 elements_are![
270 "profiles/MID1.simc",
271 "actions.cooldowns=/",
272 "actions=/",
273 "actions=wait,sec=1",
274 "iterations=1",
275 "max_time=1",
276 "report_details=0",
277 "json2=/tmp/talents.json",
278 ]
279 )?;
280
281 Ok(())
282 }
283}