1use serde::Serialize;
2
3wowlab_engine_macros::define_error! {
4#[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 #[must_use]
48 pub const fn is_invalid_characters(&self) -> bool {
49 matches!(self.kind, TraitErrorKind::InvalidCharacters)
50 }
51
52 #[must_use]
54 pub const fn is_too_short(&self) -> bool {
55 matches!(self.kind, TraitErrorKind::TooShort)
56 }
57
58 #[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#[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#[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 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 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 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 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 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
231pub 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#[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#[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;