Skip to main content

rakata_formats/twoda/
reader.rs

1//! 2DA binary reader.
2
3use std::io::Read;
4
5use rakata_core::decode_text_strict;
6
7use super::{
8    binary, TwoDa, TwoDaBinaryError, TwoDaBinaryOptions, TwoDaRow, TWODA_MAGIC, TWODA_VERSION_V2B,
9};
10
11/// Reads a binary 2DA from a reader.
12///
13/// The stream is consumed from its current position.
14///
15/// # Errors
16///
17/// The same as [`read_twoda_with_options`] under the default options, which
18/// decode text as Windows-1252.
19#[cfg_attr(
20    feature = "tracing",
21    tracing::instrument(level = "debug", skip(reader))
22)]
23pub fn read_twoda<R: Read>(reader: &mut R) -> Result<TwoDa, TwoDaBinaryError> {
24    read_twoda_with_options(reader, TwoDaBinaryOptions::default())
25}
26
27/// Reads a binary 2DA from a reader with explicit text options.
28///
29/// The stream is consumed from its current position.
30///
31/// # Errors
32///
33/// [`TwoDaBinaryError::Io`] when the stream will not read to end, and whatever
34/// [`read_twoda_from_bytes_with_options`] reports for the bytes it collected.
35#[cfg_attr(
36    feature = "tracing",
37    tracing::instrument(level = "debug", skip(reader, options))
38)]
39pub fn read_twoda_with_options<R: Read>(
40    reader: &mut R,
41    options: TwoDaBinaryOptions,
42) -> Result<TwoDa, TwoDaBinaryError> {
43    let mut bytes = Vec::new();
44    reader.read_to_end(&mut bytes)?;
45    read_twoda_from_bytes_with_options(&bytes, options)
46}
47
48/// Reads a binary 2DA (`2DA V2.b`) from in-memory bytes.
49///
50/// # Errors
51///
52/// The same as [`read_twoda_from_bytes_with_options`] under the default
53/// options, which decode text as Windows-1252.
54#[cfg_attr(
55    feature = "tracing",
56    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
57)]
58pub fn read_twoda_from_bytes(bytes: &[u8]) -> Result<TwoDa, TwoDaBinaryError> {
59    read_twoda_from_bytes_with_options(bytes, TwoDaBinaryOptions::default())
60}
61
62/// Reads a binary 2DA (`2DA V2.b`) from in-memory bytes with explicit text options.
63///
64/// # Errors
65///
66/// [`TwoDaBinaryError::InvalidMagic`] when the signature is not `2DA `, and
67/// [`TwoDaBinaryError::InvalidVersion`] for a version other than `V2.b`.
68///
69/// [`TwoDaBinaryError::InvalidHeader`] covers every structural failure, and
70/// it is the arm most malformed files land on: a truncated header, a missing
71/// terminator for the column list or a row label, an offset table or cell
72/// region running past the end of `bytes`, a cell offset outside the cell
73/// region, a cell with no NUL terminator, and any cursor arithmetic that
74/// overflows. The message names which one.
75///
76/// [`TwoDaBinaryError::TextDecoding`] when a column name, row label or cell
77/// is not valid text in `options.text_encoding`, naming which one.
78#[cfg_attr(
79    feature = "tracing",
80    tracing::instrument(level = "debug", skip(bytes, options), fields(bytes_len = bytes.len()))
81)]
82pub fn read_twoda_from_bytes_with_options(
83    bytes: &[u8],
84    options: TwoDaBinaryOptions,
85) -> Result<TwoDa, TwoDaBinaryError> {
86    if bytes.len() < 9 {
87        return Err(TwoDaBinaryError::InvalidHeader(
88            "file smaller than 2DA header".into(),
89        ));
90    }
91
92    let magic = binary::read_fourcc(bytes, 0)?;
93    if magic != TWODA_MAGIC {
94        return Err(TwoDaBinaryError::InvalidMagic(magic));
95    }
96
97    let version = binary::read_fourcc(bytes, 4)?;
98    if version != TWODA_VERSION_V2B {
99        return Err(TwoDaBinaryError::InvalidVersion(version));
100    }
101
102    if bytes[8] != b'\n' {
103        return Err(TwoDaBinaryError::InvalidHeader(
104            "missing newline after 2DA version".into(),
105        ));
106    }
107
108    let mut cursor = 9;
109    let headers_end = find_byte(bytes, cursor, b'\0')
110        .ok_or_else(|| TwoDaBinaryError::InvalidHeader("missing header terminator".into()))?;
111    let mut headers = split_tabbed(&bytes[cursor..headers_end])
112        .into_iter()
113        .enumerate()
114        .map(|(index, token)| {
115            decode_text_strict(token, options.text_encoding).map_err(|source| {
116                TwoDaBinaryError::TextDecoding {
117                    context: format!("header[{index}]"),
118                    source,
119                }
120            })
121        })
122        .collect::<Result<Vec<_>, _>>()?;
123    if headers.last().is_some_and(String::is_empty) {
124        headers.pop();
125    }
126    cursor = headers_end + 1;
127
128    let row_count = usize::try_from(binary::read_u32(bytes, cursor)?)
129        .map_err(|_| TwoDaBinaryError::InvalidHeader("row count does not fit usize".into()))?;
130    cursor = cursor
131        .checked_add(4)
132        .ok_or_else(|| TwoDaBinaryError::InvalidHeader("row count cursor overflow".into()))?;
133
134    let mut rows = Vec::with_capacity(row_count);
135    for row_index in 0..row_count {
136        let label_end = find_byte(bytes, cursor, b'\t').ok_or_else(|| {
137            TwoDaBinaryError::InvalidHeader(format!(
138                "missing tab terminator for row label {row_index}"
139            ))
140        })?;
141        let label = decode_text_strict(&bytes[cursor..label_end], options.text_encoding).map_err(
142            |source| TwoDaBinaryError::TextDecoding {
143                context: format!("row label[{row_index}]"),
144                source,
145            },
146        )?;
147        rows.push(TwoDaRow {
148            label,
149            cells: vec![String::new(); headers.len()],
150        });
151        cursor = label_end
152            .checked_add(1)
153            .ok_or_else(|| TwoDaBinaryError::InvalidHeader("row label cursor overflow".into()))?;
154    }
155
156    let cell_count = row_count
157        .checked_mul(headers.len())
158        .ok_or_else(|| TwoDaBinaryError::InvalidHeader("cell count overflow".into()))?;
159    let offsets_bytes = cell_count
160        .checked_mul(2)
161        .ok_or_else(|| TwoDaBinaryError::InvalidHeader("offset table size overflow".into()))?;
162    if cursor
163        .checked_add(offsets_bytes + 2)
164        .is_none_or(|end| end > bytes.len())
165    {
166        return Err(TwoDaBinaryError::InvalidHeader(
167            "offset table exceeds file length".into(),
168        ));
169    }
170
171    let mut cell_offsets = Vec::with_capacity(cell_count);
172    for _ in 0..cell_count {
173        cell_offsets.push(binary::read_u16(bytes, cursor)?);
174        cursor = cursor
175            .checked_add(2)
176            .ok_or_else(|| TwoDaBinaryError::InvalidHeader("offset cursor overflow".into()))?;
177    }
178
179    let cell_data_size = usize::from(binary::read_u16(bytes, cursor)?);
180    cursor = cursor
181        .checked_add(2)
182        .ok_or_else(|| TwoDaBinaryError::InvalidHeader("cell data cursor overflow".into()))?;
183    let cell_data_end = cursor
184        .checked_add(cell_data_size)
185        .ok_or_else(|| TwoDaBinaryError::InvalidHeader("cell data end overflow".into()))?;
186    if cell_data_end > bytes.len() {
187        return Err(TwoDaBinaryError::InvalidHeader(
188            "cell string table exceeds file length".into(),
189        ));
190    }
191    let cell_data = &bytes[cursor..cell_data_end];
192
193    if !headers.is_empty() {
194        for (cell_index, offset) in cell_offsets.iter().enumerate() {
195            let offset = usize::from(*offset);
196            if offset >= cell_data_size {
197                return Err(TwoDaBinaryError::InvalidHeader(format!(
198                    "cell offset {offset} out of range for cell index {cell_index}"
199                )));
200            }
201
202            let value_end = find_byte(cell_data, offset, b'\0').ok_or_else(|| {
203                TwoDaBinaryError::InvalidHeader(format!(
204                    "missing NUL terminator for cell index {cell_index}"
205                ))
206            })?;
207            let row_index = cell_index / headers.len();
208            let column_index = cell_index % headers.len();
209            let value = decode_text_strict(&cell_data[offset..value_end], options.text_encoding)
210                .map_err(|source| TwoDaBinaryError::TextDecoding {
211                    context: format!("cell[{row_index}][{column_index}]"),
212                    source,
213                })?;
214            rows[row_index].cells[column_index] = value;
215        }
216    }
217
218    Ok(TwoDa { headers, rows })
219}
220
221fn find_byte(bytes: &[u8], start: usize, needle: u8) -> Option<usize> {
222    bytes
223        .get(start..)?
224        .iter()
225        .position(|byte| *byte == needle)
226        .map(|relative| start + relative)
227}
228
229fn split_tabbed(bytes: &[u8]) -> Vec<&[u8]> {
230    if bytes.is_empty() {
231        return Vec::new();
232    }
233    let mut out = Vec::new();
234    let mut start = 0;
235    for (index, byte) in bytes.iter().enumerate() {
236        if *byte == b'\t' {
237            out.push(&bytes[start..index]);
238            start = index + 1;
239        }
240    }
241    out.push(&bytes[start..]);
242    out
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248    use crate::twoda::{
249        write_twoda_to_vec, write_twoda_to_vec_with_options, TwoDaBinaryOptions, TWODA_MAGIC,
250        TWODA_VERSION_V2B,
251    };
252    use rakata_core::TextEncoding;
253
254    #[test]
255    fn roundtrip_twoda_binary() {
256        let mut table = TwoDa::new(vec!["col3".into(), "col2".into(), "col1".into()]);
257        table
258            .push_row("10", vec!["ghi".into(), "def".into(), "abc".into()])
259            .expect("valid row");
260        table
261            .push_row("1", vec!["123".into(), "ghi".into(), "def".into()])
262            .expect("valid row");
263        table
264            .push_row("2", vec!["abc".into(), "".into(), "123".into()])
265            .expect("valid row");
266
267        let bytes = write_twoda_to_vec(&table).expect("write should succeed");
268        let parsed = read_twoda_from_bytes(&bytes).expect("read should succeed");
269
270        assert_eq!(parsed, table);
271        assert_eq!(parsed.cell(0, "col1"), Some("abc"));
272        assert_eq!(parsed.cell(2, "col2"), Some(""));
273    }
274
275    #[test]
276    fn writer_is_deterministic_for_synthetic_twoda() {
277        let mut table = TwoDa::new(vec!["col3".into(), "col2".into(), "col1".into()]);
278        table
279            .push_row("10", vec!["ghi".into(), "def".into(), "abc".into()])
280            .expect("valid row");
281        table
282            .push_row("1", vec!["123".into(), "ghi".into(), "def".into()])
283            .expect("valid row");
284        table
285            .push_row("2", vec!["abc".into(), "".into(), "123".into()])
286            .expect("valid row");
287
288        let first = write_twoda_to_vec(&table).expect("first write should succeed");
289        let second = write_twoda_to_vec(&table).expect("second write should succeed");
290        assert_eq!(first, second, "canonical 2DA writer output drifted");
291    }
292
293    #[test]
294    fn rejects_truncated_header() {
295        let bytes = vec![0_u8; 8];
296        let err = read_twoda_from_bytes(&bytes).expect_err("must fail");
297        assert!(matches!(err, TwoDaBinaryError::InvalidHeader(_)));
298    }
299
300    #[test]
301    fn rejects_invalid_magic() {
302        let mut bytes = vec![0_u8; 9];
303        bytes[0..4].copy_from_slice(b"BAD!");
304        bytes[4..8].copy_from_slice(&TWODA_VERSION_V2B);
305        bytes[8] = b'\n';
306
307        let err = read_twoda_from_bytes(&bytes).expect_err("must fail");
308        assert!(matches!(err, TwoDaBinaryError::InvalidMagic(_)));
309    }
310
311    #[test]
312    fn rejects_invalid_version() {
313        let mut bytes = vec![0_u8; 9];
314        bytes[0..4].copy_from_slice(&TWODA_MAGIC);
315        bytes[4..8].copy_from_slice(b"V2.0");
316        bytes[8] = b'\n';
317
318        let err = read_twoda_from_bytes(&bytes).expect_err("must fail");
319        assert!(matches!(err, TwoDaBinaryError::InvalidVersion(_)));
320    }
321
322    #[test]
323    fn writer_rejects_unencodable_text() {
324        let mut table = TwoDa::new(vec!["col".into()]);
325        table
326            .push_row("0", vec!["emoji \u{1f600}".into()])
327            .expect("valid table shape");
328
329        let err = write_twoda_to_vec(&table).expect_err("must fail");
330        match err {
331            TwoDaBinaryError::TextEncoding { context, source } => {
332                assert!(context.contains("cell[0][0]"));
333                assert_eq!(source.character, '\u{1f600}');
334            }
335            other => panic!("unexpected error variant: {other}"),
336        }
337    }
338
339    #[test]
340    fn writer_rejects_row_width_mismatch() {
341        let mut table = TwoDa::new(vec!["col1".into(), "col2".into()]);
342        table.rows.push(TwoDaRow {
343            label: "0".into(),
344            cells: vec!["only_one".into()],
345        });
346
347        let err = write_twoda_to_vec(&table).expect_err("must fail");
348        assert!(matches!(err, TwoDaBinaryError::InvalidTable(_)));
349    }
350
351    #[test]
352    fn writer_deduplicates_identical_cell_strings() {
353        let mut table = TwoDa::new(vec!["a".into(), "b".into()]);
354        table
355            .push_row("0", vec!["same".into(), "x".into()])
356            .expect("valid row");
357        table
358            .push_row("1", vec!["same".into(), "y".into()])
359            .expect("valid row");
360
361        let bytes = write_twoda_to_vec(&table).expect("write should succeed");
362        let offsets = extract_cell_offsets(&bytes).expect("must parse offsets");
363        assert_eq!(offsets.len(), 4);
364        assert_eq!(offsets[0], offsets[2]);
365    }
366
367    #[test]
368    fn configurable_encoding_supports_cp1250_data() {
369        let mut table = TwoDa::new(vec!["text".into()]);
370        table
371            .push_row("0", vec!["Za\u{017c}\u{00f3}\u{0142}\u{0107} g\u{0119}\u{015b}l\u{0105} ja\u{017a}\u{0144}".into()])
372            .expect("valid row");
373        let options = TwoDaBinaryOptions {
374            text_encoding: TextEncoding::Windows1250,
375        };
376
377        let bytes = write_twoda_to_vec_with_options(&table, options).expect("write should succeed");
378        let parsed =
379            read_twoda_from_bytes_with_options(&bytes, options).expect("read should succeed");
380        assert_eq!(
381            parsed.cell(0, "text"),
382            Some(
383                "Za\u{017c}\u{00f3}\u{0142}\u{0107} g\u{0119}\u{015b}l\u{0105} ja\u{017a}\u{0144}"
384            )
385        );
386    }
387
388    #[test]
389    fn default_encoding_rejects_cp1250_specific_characters() {
390        let mut table = TwoDa::new(vec!["text".into()]);
391        table
392            .push_row("0", vec!["Za\u{017c}\u{00f3}\u{0142}\u{0107} g\u{0119}\u{015b}l\u{0105} ja\u{017a}\u{0144}".into()])
393            .expect("valid row");
394
395        let err = write_twoda_to_vec(&table).expect_err("must fail");
396        assert!(matches!(err, TwoDaBinaryError::TextEncoding { .. }));
397    }
398
399    fn extract_cell_offsets(bytes: &[u8]) -> Result<Vec<u16>, TwoDaBinaryError> {
400        let mut cursor = 9;
401        let headers_end = find_byte(bytes, cursor, b'\0')
402            .ok_or_else(|| TwoDaBinaryError::InvalidHeader("missing header terminator".into()))?;
403        cursor = headers_end + 1;
404
405        let row_count = usize::try_from(binary::read_u32(bytes, cursor)?)
406            .map_err(|_| TwoDaBinaryError::InvalidHeader("row count conversion failed".into()))?;
407        cursor += 4;
408        for _ in 0..row_count {
409            let label_end = find_byte(bytes, cursor, b'\t')
410                .ok_or_else(|| TwoDaBinaryError::InvalidHeader("missing row label tab".into()))?;
411            cursor = label_end + 1;
412        }
413
414        // Test fixture uses exactly 2 headers.
415        let cell_count = row_count * 2;
416        let mut offsets = Vec::with_capacity(cell_count);
417        for _ in 0..cell_count {
418            offsets.push(binary::read_u16(bytes, cursor)?);
419            cursor += 2;
420        }
421        Ok(offsets)
422    }
423}