Skip to main content

wowlab_cli/commands/snapshot/
decode_loadout.rs

1use std::fmt::Write;
2
3use anyhow::{Context, Result};
4use serde::Serialize;
5use wowlab_common::output;
6use wowlab_loadout::{DecodedTraitLoadout, EnrichedLoadout, decode_trait_loadout};
7use wowlab_types::data::TraitTreeFlat;
8
9use super::DecodeLoadoutArgs;
10
11#[derive(Serialize)]
12struct RawTalent {
13    node_index: i32,
14    ranks: i32,
15    #[serde(skip_serializing_if = "Option::is_none")]
16    choice_index: Option<u8>,
17}
18
19#[derive(Serialize)]
20struct RawOutput {
21    spec_id: u16,
22    version: u8,
23    tree_hash: String,
24    talents: Vec<RawTalent>,
25    total_points: i32,
26}
27
28#[derive(Serialize)]
29struct EnrichedOutput {
30    #[serde(flatten)]
31    enriched: EnrichedLoadout,
32    version: u8,
33    tree_hash: String,
34    bitstream_position_count: usize,
35    tree_position_count: usize,
36    mapped_position_count: usize,
37    selected_node_count: usize,
38    selected_entry_count: usize,
39    trait_entries: Vec<DecodedTalentEntry>,
40}
41
42#[derive(Serialize)]
43#[expect(
44    clippy::struct_excessive_bools,
45    reason = "these flags mirror the decoded loadout wire representation"
46)]
47struct DecodedTalentEntry {
48    position: usize,
49    node_id: i32,
50    trait_node_entry_id: i32,
51    spell_id: i32,
52    name: String,
53    tree_index: i32,
54    tree_kind: &'static str,
55    sub_tree_id: i32,
56    rank: i32,
57    selected: bool,
58    granted: bool,
59    purchased: bool,
60    partially_ranked: bool,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    choice_index: Option<u8>,
63}
64
65pub(super) fn run_decode_loadout(args: &DecodeLoadoutArgs) -> Result<()> {
66    let decoded = decode_trait_loadout(&args.loadout).context("Failed to decode loadout")?;
67
68    let mut tree_hash = String::with_capacity(decoded.tree_hash.len() * 2);
69
70    for byte in &decoded.tree_hash {
71        write!(&mut tree_hash, "{byte:02x}").context("failed to format trait-tree hash")?;
72    }
73
74    if let Some(data_dir) = &args.data_dir {
75        use wowlab_parsers::{DbcData, transform_trait_tree};
76
77        let dbc = DbcData::load_all(data_dir)?;
78        let tree = transform_trait_tree(&dbc, i32::from(decoded.spec_id))?;
79        let output = enriched_output(tree, &decoded, tree_hash);
80
81        output::json(&output);
82    } else {
83        let mut talents = Vec::new();
84        let mut total = 0i32;
85
86        for (i, node) in decoded.nodes.iter().enumerate() {
87            if !node.selected || !node.purchased {
88                continue;
89            }
90
91            let ranks = node.ranks_purchased.map_or(1, i32::from);
92
93            total += ranks;
94
95            talents.push(RawTalent {
96                node_index: i32::try_from(i).context("loadout contains too many talent nodes")?,
97                ranks,
98                choice_index: node.choice_index,
99            });
100        }
101
102        let output = RawOutput {
103            spec_id: decoded.spec_id,
104            version: decoded.version,
105            tree_hash,
106            talents,
107            total_points: total,
108        };
109
110        output::json(&output);
111    }
112
113    Ok(())
114}
115
116fn enriched_output(
117    tree: TraitTreeFlat,
118    decoded: &DecodedTraitLoadout,
119    tree_hash: String,
120) -> EnrichedOutput {
121    use wowlab_loadout::{apply_decoded_traits, enrich_loadout};
122
123    let tree_position_count = tree.all_node_ids.len();
124    let mapped_position_count = decoded.nodes.len().min(tree_position_count);
125    let positions: wowlab_types::sim::IntMap<i32, (usize, i32)> = tree
126        .nodes
127        .iter()
128        .filter_map(|node| {
129            tree.all_node_ids
130                .iter()
131                .position(|&node_id| node_id == node.id)
132                .map(|position| (node.id, (position, node.granted_ranks)))
133        })
134        .collect();
135    let with_selections = apply_decoded_traits(tree, decoded);
136    let enriched = enrich_loadout(&with_selections);
137
138    let trait_entries = enriched
139        .talents
140        .iter()
141        .filter_map(|talent| {
142            let &(position, granted_ranks) = positions.get(&talent.node_id)?;
143            let decoded_node = decoded.nodes.get(position);
144            let purchased = decoded_node.is_some_and(|node| node.purchased);
145            let selected = decoded_node.is_some_and(|node| node.selected);
146
147            Some(DecodedTalentEntry {
148                position,
149                node_id: talent.node_id,
150                trait_node_entry_id: talent.trait_node_entry_id,
151                spell_id: talent.spell_id,
152                name: talent.name.clone(),
153                tree_index: talent.tree_index,
154                tree_kind: tree_kind(talent.tree_index),
155                sub_tree_id: talent.sub_tree_id,
156                rank: talent.ranks,
157                selected,
158                granted: !purchased && (selected || granted_ranks > 0),
159                purchased,
160                partially_ranked: decoded_node.is_some_and(|node| node.partially_ranked),
161                choice_index: decoded_node.and_then(|node| node.choice_index),
162            })
163        })
164        .collect::<Vec<_>>();
165    let selected_node_count = decoded
166        .nodes
167        .iter()
168        .take(mapped_position_count)
169        .filter(|node| node.selected)
170        .count();
171    let selected_entry_count = trait_entries.len();
172
173    EnrichedOutput {
174        enriched,
175        version: decoded.version,
176        tree_hash,
177        bitstream_position_count: decoded.nodes.len(),
178        tree_position_count,
179        mapped_position_count,
180        selected_node_count,
181        selected_entry_count,
182        trait_entries,
183    }
184}
185
186const fn tree_kind(tree_index: i32) -> &'static str {
187    match wowlab_types::data::TraitTreeIndex::from_dbc(tree_index) {
188        Some(wowlab_types::data::TraitTreeIndex::Class) => "class",
189        Some(wowlab_types::data::TraitTreeIndex::Specialization) => "specialization",
190        Some(wowlab_types::data::TraitTreeIndex::Hero) => "hero",
191        Some(wowlab_types::data::TraitTreeIndex::Selection) => "selection",
192        _ => "unknown",
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use googletest::{Result as GtestResult, prelude::*};
199    use serde_json::Value;
200    use wowlab_loadout::DecodedTraitNode;
201    use wowlab_types::data::{TraitNode, TraitNodeEntry, TraitSubTree};
202
203    use super::*;
204
205    fn entry(id: i32, spell_id: i32, sub_tree_id: i32, max_ranks: i32) -> TraitNodeEntry {
206        TraitNodeEntry {
207            id,
208            spell_id,
209            sub_tree_id,
210            max_ranks,
211            name: format!("Entry {id}"),
212            ..Default::default()
213        }
214    }
215
216    fn decoded_node(
217        purchased: bool,
218        ranks: Option<u8>,
219        choice_index: Option<u8>,
220    ) -> DecodedTraitNode {
221        DecodedTraitNode {
222            selected: true,
223            purchased,
224            partially_ranked: ranks.is_some(),
225            ranks_purchased: ranks,
226            choice_node: choice_index.is_some(),
227            choice_index,
228        }
229    }
230
231    fn comparison_fixture() -> (TraitTreeFlat, DecodedTraitLoadout) {
232        let tree = TraitTreeFlat {
233            spec_id: 255,
234            all_node_ids: vec![10, 20, 30, 40],
235            nodes: vec![
236                TraitNode {
237                    id: 10,
238                    max_ranks: 1,
239                    tree_index: 1,
240                    entries: vec![entry(100, 1_000, 0, 1)],
241                    ..Default::default()
242                },
243                TraitNode {
244                    id: 20,
245                    max_ranks: 1,
246                    node_type: 3,
247                    tree_index: 4,
248                    entries: vec![entry(200, 0, 60, 1)],
249                    ..Default::default()
250                },
251                TraitNode {
252                    id: 30,
253                    max_ranks: 1,
254                    tree_index: 3,
255                    sub_tree_id: 60,
256                    entries: vec![entry(300, 3_000, 0, 1)],
257                    ..Default::default()
258                },
259                TraitNode {
260                    id: 40,
261                    max_ranks: 3,
262                    node_type: 1,
263                    tree_index: 2,
264                    entries: vec![entry(400, 4_000, 0, 1), entry(401, 4_001, 0, 2)],
265                    ..Default::default()
266                },
267            ],
268            sub_trees: vec![TraitSubTree {
269                id: 60,
270                name: "Pack Leader".to_string(),
271                ..Default::default()
272            }],
273            ..Default::default()
274        };
275        let decoded = DecodedTraitLoadout {
276            version: 2,
277            spec_id: 255,
278            tree_hash: [7; 16],
279            nodes: vec![
280                decoded_node(false, None, None),
281                decoded_node(true, None, Some(0)),
282                decoded_node(true, None, None),
283                decoded_node(true, Some(2), None),
284            ],
285        };
286
287        (tree, decoded)
288    }
289
290    #[gtest]
291    fn enriched_output_emits_stable_entry_comparison_rows() -> GtestResult<()> {
292        let (tree, decoded) = comparison_fixture();
293        let output = enriched_output(tree, &decoded, "07".repeat(16));
294        let json = serde_json::to_value(output).expect("serialize comparison output");
295        let entries = json["trait_entries"]
296            .as_array()
297            .expect("trait_entries array");
298
299        verify_that!(json["version"].as_u64(), some(eq(2)))?;
300        verify_that!(json["spec_id"].as_i64(), some(eq(255)))?;
301        verify_that!(json["bitstream_position_count"].as_u64(), some(eq(4)))?;
302        verify_that!(json["tree_position_count"].as_u64(), some(eq(4)))?;
303        verify_that!(json["mapped_position_count"].as_u64(), some(eq(4)))?;
304        verify_that!(json["selected_node_count"].as_u64(), some(eq(4)))?;
305        verify_that!(json["selected_entry_count"].as_u64(), some(eq(5)))?;
306        verify_that!(entries.len(), eq(5))?;
307
308        verify_that!(entries[0]["position"].as_u64(), some(eq(0)))?;
309        verify_that!(entries[0]["node_id"].as_i64(), some(eq(10)))?;
310        verify_that!(entries[0]["trait_node_entry_id"].as_i64(), some(eq(100)))?;
311        verify_that!(entries[0]["tree_kind"].as_str(), some(eq("class")))?;
312        verify_that!(entries[0]["selected"].as_bool(), some(eq(true)))?;
313        verify_that!(entries[0]["granted"].as_bool(), some(eq(true)))?;
314        verify_that!(entries[0]["purchased"].as_bool(), some(eq(false)))?;
315
316        verify_that!(entries[1]["tree_kind"].as_str(), some(eq("selection")))?;
317        verify_that!(entries[1]["sub_tree_id"].as_i64(), some(eq(60)))?;
318        verify_that!(entries[1]["choice_index"].as_u64(), some(eq(0)))?;
319        verify_that!(entries[2]["tree_kind"].as_str(), some(eq("hero")))?;
320        verify_that!(entries[2]["sub_tree_id"].as_i64(), some(eq(60)))?;
321
322        verify_that!(entries[3]["rank"].as_i64(), some(eq(1)))?;
323        verify_that!(entries[4]["rank"].as_i64(), some(eq(1)))?;
324
325        verify_that!(entries[4]["partially_ranked"].as_bool(), some(eq(true)))
326    }
327
328    #[gtest]
329    fn tree_kind_has_stable_unknown_fallback() -> GtestResult<()> {
330        verify_that!(tree_kind(1), eq("class"))?;
331        verify_that!(tree_kind(2), eq("specialization"))?;
332        verify_that!(tree_kind(3), eq("hero"))?;
333        verify_that!(tree_kind(4), eq("selection"))?;
334
335        verify_that!(tree_kind(99), eq("unknown"))
336    }
337
338    #[gtest]
339    fn output_schema_keeps_choice_index_optional() -> GtestResult<()> {
340        let (tree, decoded) = comparison_fixture();
341        let output = enriched_output(tree, &decoded, "07".repeat(16));
342        let json = serde_json::to_value(output).or_fail()?;
343        let entries = json["trait_entries"].as_array().or_fail()?;
344        let first = entries.first().or_fail()?;
345        let second = entries.get(1).or_fail()?;
346
347        verify_that!(first.get("choice_index"), none())?;
348
349        verify_that!(second.get("choice_index"), some(eq(&Value::from(0))))
350    }
351}