Skip to main content

wowlab_loadout/
codec.rs

1use serde::Serialize;
2
3wowlab_engine_macros::define_error! {
4/// Errors produced while decoding a trait loadout string.
5#[derive(Debug)]
6pub struct TraitError {
7    #[source]
8    kind: TraitErrorKind,
9}
10
11#[derive(Debug, thiserror::Error)]
12enum TraitErrorKind {
13    #[error("Invalid characters in trait string")]
14    InvalidCharacters,
15
16    #[error("Trait string too short")]
17    TooShort,
18
19    #[error("Invalid bit read: not enough data")]
20    NotEnoughData {
21        #[source]
22        source: Option<std::num::TryFromIntError>,
23    },
24}
25}
26
27impl TraitError {
28    const fn invalid_characters() -> Self {
29        Self {
30            kind: TraitErrorKind::InvalidCharacters,
31        }
32    }
33
34    const fn too_short() -> Self {
35        Self {
36            kind: TraitErrorKind::TooShort,
37        }
38    }
39
40    const fn not_enough_data() -> Self {
41        Self {
42            kind: TraitErrorKind::NotEnoughData { source: None },
43        }
44    }
45
46    /// Returns whether the input contains an unsupported character.
47    #[must_use]
48    pub const fn is_invalid_characters(&self) -> bool {
49        matches!(self.kind, TraitErrorKind::InvalidCharacters)
50    }
51
52    /// Returns whether the input was empty.
53    #[must_use]
54    pub const fn is_too_short(&self) -> bool {
55        matches!(self.kind, TraitErrorKind::TooShort)
56    }
57
58    /// Returns whether the encoded input ended in the middle of its header or a node.
59    #[must_use]
60    pub const fn is_not_enough_data(&self) -> bool {
61        matches!(self.kind, TraitErrorKind::NotEnoughData { .. })
62    }
63}
64
65impl From<std::num::TryFromIntError> for TraitError {
66    fn from(source: std::num::TryFromIntError) -> Self {
67        Self {
68            kind: TraitErrorKind::NotEnoughData {
69                source: Some(source),
70            },
71        }
72    }
73}
74
75const BITS_PER_BASE64_CHAR: usize = 6;
76const VERSION_BITS: usize = 8;
77const SPEC_ID_BITS: usize = 16;
78const TREE_HASH_LEN: usize = 16;
79const RANK_BITS: usize = 6;
80const CHOICE_INDEX_BITS: usize = 2;
81const MIN_NODE_BITS: usize = 2;
82const INVALID_BASE64_CHAR: u8 = 255;
83
84/// A decoded trait-loadout header and its position-ordered node selections.
85#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
86pub struct DecodedTraitLoadout {
87    pub version: u8,
88    pub spec_id: u16,
89    pub tree_hash: [u8; TREE_HASH_LEN],
90    pub nodes: Vec<DecodedTraitNode>,
91}
92
93/// Selection state for one positional talent node.
94#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
95#[expect(
96    clippy::struct_excessive_bools,
97    reason = "flags map one-to-one to the stable client bitstream and serialized API shape"
98)]
99pub struct DecodedTraitNode {
100    pub selected: bool,
101    pub purchased: bool,
102    pub partially_ranked: bool,
103    pub ranks_purchased: Option<u8>,
104    pub choice_node: bool,
105    pub choice_index: Option<u8>,
106}
107
108const BASE64_URL_SAFE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
109
110const BASE64_LOOKUP_SIZE: usize = 256;
111
112const BASE64_CHAR_MAP: [u8; BASE64_LOOKUP_SIZE] = build_char_map();
113
114const fn build_char_map() -> [u8; BASE64_LOOKUP_SIZE] {
115    let standard = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
116    let url_safe = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
117    let mut map = [INVALID_BASE64_CHAR; BASE64_LOOKUP_SIZE];
118    let mut i = 0u8;
119
120    while (i as usize) < standard.len() {
121        // #t(block: rust_unchecked_indexing) base64 index 0-63, fits in u8
122        map[standard[i as usize] as usize] = i;
123        i += 1;
124    }
125
126    i = 0;
127
128    while (i as usize) < url_safe.len() {
129        // #t(block: rust_unchecked_indexing) base64 index 0-63, fits in u8
130        map[url_safe[i as usize] as usize] = i;
131        i += 1;
132    }
133
134    map
135}
136
137struct BitReader<'a> {
138    data: &'a str,
139    position: usize,
140    total_bits: usize,
141}
142
143impl<'a> BitReader<'a> {
144    fn new(data: &'a str) -> Self {
145        Self {
146            data,
147            position: 0,
148            total_bits: data.len() * BITS_PER_BASE64_CHAR,
149        }
150    }
151
152    fn read(&mut self, bit_count: usize) -> Result<u32, TraitError> {
153        if self.position + bit_count > self.total_bits {
154            return Err(TraitError::not_enough_data());
155        }
156
157        let mut value = 0u32;
158
159        for i in 0..bit_count {
160            let char_idx = self.position / BITS_PER_BASE64_CHAR;
161            let bit_offset = self.position % BITS_PER_BASE64_CHAR;
162
163            let byte = self.data.as_bytes().get(char_idx).copied().unwrap_or(0);
164            // #t(rust_unchecked_indexing) byte is u8 and table has 256 entries, always in range
165            let char_value = BASE64_CHAR_MAP[byte as usize];
166
167            if char_value == INVALID_BASE64_CHAR {
168                return Err(TraitError::invalid_characters());
169            }
170
171            let bit = (char_value >> bit_offset) & 1;
172
173            value |= u32::from(bit) << i;
174            self.position += 1;
175        }
176
177        Ok(value)
178    }
179
180    fn has_remaining(&self, bit_count: usize) -> bool {
181        self.position + bit_count <= self.total_bits
182    }
183}
184
185struct BitWriter {
186    output: String,
187    bit_position: usize,
188    current_char: u8,
189}
190
191impl BitWriter {
192    fn new() -> Self {
193        Self {
194            output: String::new(),
195            bit_position: 0,
196            current_char: 0,
197        }
198    }
199
200    fn write(&mut self, value: u32, bit_count: usize) {
201        for i in 0..bit_count {
202            // #t(rust_lossy_cast) masked to single bit, value is 0 or 1
203            let bit = ((value >> i) & 1) as u8;
204
205            self.current_char |= bit << self.bit_position;
206            self.bit_position += 1;
207
208            if self.bit_position == BITS_PER_BASE64_CHAR {
209                self.flush_char();
210            }
211        }
212    }
213
214    fn flush_char(&mut self) {
215        // #t(block: rust_unchecked_indexing) current_char is max 63 from 6-bit accumulation, table has 64 entries
216        self.output
217            .push(BASE64_URL_SAFE[self.current_char as usize] as char);
218        self.current_char = 0;
219        self.bit_position = 0;
220    }
221
222    fn finish(mut self) -> String {
223        if self.bit_position > 0 {
224            self.flush_char();
225        }
226
227        self.output
228    }
229}
230
231/// Decode a trait loadout string into structured data.
232///
233/// # Errors
234///
235/// Returns an error for invalid base64 characters, incomplete headers, or truncated node data.
236pub fn decode_trait_loadout(talent_string: &str) -> Result<DecodedTraitLoadout, TraitError> {
237    if talent_string.is_empty() {
238        return Err(TraitError::too_short());
239    }
240
241    validate_base64_chars(talent_string)?;
242
243    let mut reader = BitReader::new(talent_string);
244
245    let version = read_u8(&mut reader, VERSION_BITS)?;
246    let spec_id = read_u16(&mut reader, SPEC_ID_BITS)?;
247
248    let mut tree_hash = [0u8; TREE_HASH_LEN];
249
250    for byte in &mut tree_hash {
251        *byte = read_u8(&mut reader, VERSION_BITS)?;
252    }
253
254    let remaining_bits = reader.total_bits.saturating_sub(reader.position);
255    let mut nodes = Vec::with_capacity(remaining_bits / MIN_NODE_BITS);
256
257    while reader.has_remaining(1) {
258        nodes.push(decode_node(&mut reader)?);
259    }
260
261    Ok(DecodedTraitLoadout {
262        version,
263        spec_id,
264        tree_hash,
265        nodes,
266    })
267}
268
269fn read_u8(reader: &mut BitReader<'_>, bit_count: usize) -> Result<u8, TraitError> {
270    Ok(u8::try_from(reader.read(bit_count)?)?)
271}
272
273fn read_u16(reader: &mut BitReader<'_>, bit_count: usize) -> Result<u16, TraitError> {
274    Ok(u16::try_from(reader.read(bit_count)?)?)
275}
276
277fn validate_base64_chars(s: &str) -> Result<(), TraitError> {
278    for c in s.chars() {
279        let valid = c.is_ascii_alphanumeric() || matches!(c, '+' | '/' | '-' | '_');
280
281        if !valid {
282            return Err(TraitError::invalid_characters());
283        }
284    }
285
286    Ok(())
287}
288
289fn decode_node(reader: &mut BitReader) -> Result<DecodedTraitNode, TraitError> {
290    let selected = reader.read(1)? == 1;
291
292    if !selected {
293        return Ok(DecodedTraitNode {
294            selected: false,
295            purchased: false,
296            partially_ranked: false,
297            ranks_purchased: None,
298            choice_node: false,
299            choice_index: None,
300        });
301    }
302
303    let purchased = reader.read(1)? == 1;
304
305    if !purchased {
306        return Ok(DecodedTraitNode {
307            selected: true,
308            purchased: false,
309            partially_ranked: false,
310            ranks_purchased: None,
311            choice_node: false,
312            choice_index: None,
313        });
314    }
315
316    let partially_ranked = reader.read(1)? == 1;
317    let ranks_purchased = if partially_ranked {
318        Some(read_u8(reader, RANK_BITS)?)
319    } else {
320        None
321    };
322
323    let choice_node = reader.read(1)? == 1;
324    let choice_index = if choice_node {
325        Some(read_u8(reader, CHOICE_INDEX_BITS)?)
326    } else {
327        None
328    };
329
330    Ok(DecodedTraitNode {
331        selected: true,
332        purchased: true,
333        partially_ranked,
334        ranks_purchased,
335        choice_node,
336        choice_index,
337    })
338}
339
340/// Creates the version-1 loadout header for a spec with a zero tree hash and no explicit nodes.
341#[must_use]
342pub fn encode_minimal_loadout(spec_id: u16) -> String {
343    let loadout = DecodedTraitLoadout {
344        version: 1,
345        spec_id,
346        tree_hash: [0u8; TREE_HASH_LEN],
347        nodes: Vec::new(),
348    };
349
350    encode_trait_loadout(&loadout)
351}
352
353/// Encodes a trait loadout with the URL-safe Base64 alphabet.
354#[must_use]
355pub fn encode_trait_loadout(loadout: &DecodedTraitLoadout) -> String {
356    let mut writer = BitWriter::new();
357
358    writer.write(u32::from(loadout.version), VERSION_BITS);
359    writer.write(u32::from(loadout.spec_id), SPEC_ID_BITS);
360
361    for &byte in &loadout.tree_hash {
362        writer.write(u32::from(byte), VERSION_BITS);
363    }
364
365    for node in &loadout.nodes {
366        encode_node(&mut writer, node);
367    }
368
369    writer.finish()
370}
371
372fn encode_node(writer: &mut BitWriter, node: &DecodedTraitNode) {
373    writer.write(u32::from(node.selected), 1);
374
375    if !node.selected {
376        return;
377    }
378
379    writer.write(u32::from(node.purchased), 1);
380
381    if !node.purchased {
382        return;
383    }
384
385    writer.write(u32::from(node.partially_ranked), 1);
386
387    if node.partially_ranked {
388        writer.write(u32::from(node.ranks_purchased.unwrap_or(0)), RANK_BITS);
389    }
390
391    writer.write(u32::from(node.choice_node), 1);
392
393    if node.choice_node {
394        writer.write(u32::from(node.choice_index.unwrap_or(0)), CHOICE_INDEX_BITS);
395    }
396}
397
398#[cfg(test)]
399#[path = "codec_tests.rs"]
400mod tests;