Skip to main content

rakata_formats/twoda/
mod.rs

1//! 2DA `V2.b` binary reader and writer.
2//!
3//! 2DA is a compact table format used for gameplay metadata. The binary form
4//! stores column headers, row labels, a cell-offset matrix, and a shared string
5//! table.
6//!
7//! ## Format Layout
8//! ```text
9//! +------------------------------+
10//! | "2DA " + "V2.b" + '\n'       |
11//! +------------------------------+
12//! | Header row text              |
13//! | tab-separated, NUL-terminated|
14//! +------------------------------+
15//! | Row count (u32)              |
16//! +------------------------------+
17//! | Row labels                   |
18//! | each terminated by '\t'      |
19//! +------------------------------+
20//! | Cell offset table            |
21//! | u16 * (rows * columns)       |
22//! +------------------------------+
23//! | String table blob            |
24//! | NUL-terminated cell strings  |
25//! +------------------------------+
26//! ```
27//!
28//! Writer output is deterministic: repeated cell strings are deduplicated in
29//! the string table while preserving row and column order.
30
31mod reader;
32mod source;
33mod writer;
34
35pub use reader::{
36    read_twoda, read_twoda_from_bytes, read_twoda_from_bytes_with_options, read_twoda_with_options,
37};
38pub use source::TwoDaSource;
39pub use writer::{
40    write_twoda, write_twoda_to_vec, write_twoda_to_vec_with_options, write_twoda_with_options,
41};
42
43use std::io::Read;
44use thiserror::Error;
45
46#[cfg(feature = "serde")]
47use serde::{Deserialize, Serialize};
48
49use rakata_core::{DecodeTextError, EncodeTextError, TextEncoding};
50
51use crate::binary::{self, DecodeBinary, EncodeBinary};
52
53/// 2DA file type marker.
54const TWODA_MAGIC: [u8; 4] = *b"2DA ";
55/// Binary 2DA format version used by KotOR.
56const TWODA_VERSION_V2B: [u8; 4] = *b"V2.b";
57
58/// Encoding options for binary 2DA reader/writer operations.
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub struct TwoDaBinaryOptions {
61    /// Text encoding used for headers, row labels, and cell values.
62    pub text_encoding: TextEncoding,
63}
64
65impl Default for TwoDaBinaryOptions {
66    fn default() -> Self {
67        Self {
68            text_encoding: TextEncoding::Windows1252,
69        }
70    }
71}
72
73/// In-memory representation of a binary 2DA table.
74///
75/// The structure keeps header order and row order stable so a write->read
76/// roundtrip remains deterministic.
77#[derive(Debug, Clone, PartialEq, Eq)]
78#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
79pub struct TwoDa {
80    /// Ordered column headers.
81    pub headers: Vec<String>,
82    /// Ordered table rows.
83    pub rows: Vec<TwoDaRow>,
84}
85
86impl TwoDa {
87    /// Creates an empty table with no headers and no rows.
88    pub fn empty() -> Self {
89        Self {
90            headers: Vec::new(),
91            rows: Vec::new(),
92        }
93    }
94
95    /// Creates an empty table with the provided headers.
96    pub fn new(headers: Vec<String>) -> Self {
97        Self {
98            headers,
99            rows: Vec::new(),
100        }
101    }
102
103    /// Appends a row, validating that cell count matches header count.
104    pub fn push_row(
105        &mut self,
106        label: impl Into<String>,
107        cells: Vec<String>,
108    ) -> Result<(), TwoDaBinaryError> {
109        if cells.len() != self.headers.len() {
110            return Err(TwoDaBinaryError::InvalidTable(format!(
111                "row width {} does not match header width {}",
112                cells.len(),
113                self.headers.len()
114            )));
115        }
116        self.rows.push(TwoDaRow {
117            label: label.into(),
118            cells,
119        });
120        Ok(())
121    }
122
123    /// Returns the number of rows.
124    pub fn row_count(&self) -> usize {
125        self.rows.len()
126    }
127
128    /// Returns the number of columns.
129    pub fn column_count(&self) -> usize {
130        self.headers.len()
131    }
132
133    /// Returns a cell by row index and column header name.
134    pub fn cell(&self, row_index: usize, column_name: &str) -> Option<&str> {
135        let column_index = self
136            .headers
137            .iter()
138            .position(|header| header == column_name)?;
139        self.rows
140            .get(row_index)
141            .and_then(|row| row.cells.get(column_index))
142            .map(String::as_str)
143    }
144}
145
146impl DecodeBinary for TwoDa {
147    type Error = TwoDaBinaryError;
148
149    fn decode_binary(bytes: &[u8]) -> Result<Self, Self::Error> {
150        read_twoda_from_bytes(bytes)
151    }
152}
153
154impl EncodeBinary for TwoDa {
155    type Error = TwoDaBinaryError;
156
157    fn encode_binary(&self) -> Result<Vec<u8>, Self::Error> {
158        write_twoda_to_vec(self)
159    }
160}
161
162/// One row in a 2DA table.
163#[derive(Debug, Clone, PartialEq, Eq)]
164#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
165pub struct TwoDaRow {
166    /// Row label written in the row-label table.
167    pub label: String,
168    /// Ordered cell values aligned with [`TwoDa::headers`].
169    pub cells: Vec<String>,
170}
171
172/// Errors produced while parsing or serializing binary 2DA data.
173#[derive(Debug, Error)]
174pub enum TwoDaBinaryError {
175    /// I/O read/write failure.
176    #[error(transparent)]
177    Io(#[from] std::io::Error),
178    /// Header magic does not match `2DA `.
179    #[error("invalid 2DA magic: {0:?}")]
180    InvalidMagic([u8; 4]),
181    /// Header version is unsupported.
182    #[error("invalid 2DA version: {0:?}")]
183    InvalidVersion([u8; 4]),
184    /// Header/body layout is invalid or truncated.
185    #[error("invalid 2DA header: {0}")]
186    InvalidHeader(String),
187    /// In-memory table content is structurally invalid for writing.
188    #[error("invalid 2DA table: {0}")]
189    InvalidTable(String),
190    /// Value cannot fit the target on-disk integer width.
191    #[error("value overflow while writing field `{0}`")]
192    ValueOverflow(&'static str),
193    /// Text cannot be represented losslessly as the configured target encoding.
194    #[error("2DA text encoding failed for {context}: {source}")]
195    TextEncoding {
196        /// Context of the value being encoded.
197        context: String,
198        /// Source encoding error with exact character location.
199        #[source]
200        source: EncodeTextError,
201    },
202    /// Text bytes cannot be decoded losslessly using the configured encoding.
203    #[error("2DA text decoding failed for {context}: {source}")]
204    TextDecoding {
205        /// Context of the value being decoded.
206        context: String,
207        /// Source decoding error with byte position details.
208        #[source]
209        source: DecodeTextError,
210    },
211}
212
213impl From<binary::BinaryLayoutError> for TwoDaBinaryError {
214    fn from(error: binary::BinaryLayoutError) -> Self {
215        Self::InvalidHeader(error.to_string())
216    }
217}
218
219fn validate_twoda(twoda: &TwoDa) -> Result<(), TwoDaBinaryError> {
220    for (index, header) in twoda.headers.iter().enumerate() {
221        if header.contains('\0') || header.contains('\t') {
222            return Err(TwoDaBinaryError::InvalidTable(format!(
223                "header[{index}] contains reserved delimiter"
224            )));
225        }
226    }
227    for (row_index, row) in twoda.rows.iter().enumerate() {
228        if row.cells.len() != twoda.headers.len() {
229            return Err(TwoDaBinaryError::InvalidTable(format!(
230                "row[{row_index}] width {} does not match header width {}",
231                row.cells.len(),
232                twoda.headers.len()
233            )));
234        }
235        if row.label.contains('\0') || row.label.contains('\t') {
236            return Err(TwoDaBinaryError::InvalidTable(format!(
237                "row label[{row_index}] contains reserved delimiter"
238            )));
239        }
240        for (column_index, value) in row.cells.iter().enumerate() {
241            if value.contains('\0') {
242                return Err(TwoDaBinaryError::InvalidTable(format!(
243                    "cell[{row_index}][{column_index}] contains NUL byte"
244                )));
245            }
246        }
247    }
248    Ok(())
249}
250
251//
252// Serde Support (CSV/JSON)
253//
254
255#[cfg(feature = "serde")]
256mod serde_impl {
257    use super::*;
258
259    pub mod csv_impl {
260        use super::*;
261        use csv::{ReaderBuilder, StringRecord, WriterBuilder};
262        use std::io::{Cursor, Write};
263
264        /// Reads a 2DA from CSV format.
265        pub fn read_twoda_from_csv<R: Read>(reader: &mut R) -> Result<TwoDa, TwoDaBinaryError> {
266            let mut csv_reader = ReaderBuilder::new()
267                .has_headers(true)
268                .flexible(true)
269                .trim(::csv::Trim::All)
270                .from_reader(reader);
271
272            let headers: Vec<String> = csv_reader
273                .headers()
274                .map_err(|e| TwoDaBinaryError::InvalidTable(e.to_string()))?
275                .iter()
276                .map(|s| s.to_string())
277                .collect();
278
279            // Interoperability Convention:
280            // The first column in 2DA CSVs is reserved for the row label.
281            // Some tools explicitly name it "label", while others leave it implicit.
282            // We always strip the first column from the header list to treat it as metadata.
283            let actual_headers = if headers.is_empty() {
284                Vec::new()
285            } else {
286                headers[1..].to_vec()
287            };
288
289            let mut twoda = TwoDa::new(actual_headers);
290
291            for result in csv_reader.records() {
292                let record = result.map_err(|e| TwoDaBinaryError::InvalidTable(e.to_string()))?;
293                if record.is_empty() {
294                    continue;
295                }
296
297                // First field is the row label
298                let label = record.get(0).unwrap_or_default().to_string();
299                // Remaining fields are cells
300                let cells: Vec<String> = record.iter().skip(1).map(|s| s.to_string()).collect();
301
302                // Ensure row width matches the header count.
303                if cells.len() != twoda.headers.len() {
304                    return Err(TwoDaBinaryError::InvalidTable(format!(
305                        "row width mismatch: expected {}, got {}",
306                        twoda.headers.len(),
307                        cells.len()
308                    )));
309                }
310
311                twoda.push_row(label, cells)?;
312            }
313
314            Ok(twoda)
315        }
316
317        /// Writes a 2DA to CSV format.
318        pub fn write_twoda_to_csv<W: Write>(
319            writer: &mut W,
320            twoda: &TwoDa,
321        ) -> Result<(), TwoDaBinaryError> {
322            validate_twoda(twoda)?;
323
324            let mut csv_writer = WriterBuilder::new().has_headers(true).from_writer(writer);
325
326            // Write header row: label column + actual column headers
327            let mut header_record = StringRecord::new();
328            header_record.push_field("label");
329            for header in &twoda.headers {
330                header_record.push_field(header);
331            }
332            csv_writer
333                .write_record(&header_record)
334                .map_err(|e| TwoDaBinaryError::InvalidTable(e.to_string()))?;
335
336            // Write data rows
337            for row in &twoda.rows {
338                let mut record = StringRecord::new();
339                record.push_field(&row.label);
340                for cell in &row.cells {
341                    record.push_field(cell);
342                }
343                csv_writer
344                    .write_record(&record)
345                    .map_err(|e| TwoDaBinaryError::InvalidTable(e.to_string()))?;
346            }
347
348            csv_writer
349                .flush()
350                .map_err(|e| TwoDaBinaryError::InvalidTable(e.to_string()))?;
351
352            Ok(())
353        }
354
355        /// Serializes a 2DA to a CSV byte vector.
356        pub fn write_twoda_to_csv_vec(twoda: &TwoDa) -> Result<Vec<u8>, TwoDaBinaryError> {
357            let mut cursor = Cursor::new(Vec::new());
358            write_twoda_to_csv(&mut cursor, twoda)?;
359            Ok(cursor.into_inner())
360        }
361
362        /// Parses a 2DA from CSV bytes.
363        pub fn read_twoda_from_csv_bytes(bytes: &[u8]) -> Result<TwoDa, TwoDaBinaryError> {
364            let mut cursor = Cursor::new(bytes);
365            read_twoda_from_csv(&mut cursor)
366        }
367    }
368
369    pub mod json_impl {
370        use super::*;
371        use serde_json::{from_slice, from_str, to_string_pretty, to_vec};
372
373        /// Serializes a 2DA to JSON.
374        pub fn write_twoda_to_json(twoda: &TwoDa) -> Result<String, TwoDaBinaryError> {
375            to_string_pretty(twoda).map_err(|e| TwoDaBinaryError::InvalidTable(e.to_string()))
376        }
377
378        /// Serializes a 2DA to JSON bytes.
379        pub fn write_twoda_to_json_vec(twoda: &TwoDa) -> Result<Vec<u8>, TwoDaBinaryError> {
380            to_vec(twoda).map_err(|e| TwoDaBinaryError::InvalidTable(e.to_string()))
381        }
382
383        /// Deserializes a 2DA from JSON.
384        pub fn read_twoda_from_json(json: &str) -> Result<TwoDa, TwoDaBinaryError> {
385            from_str(json).map_err(|e| TwoDaBinaryError::InvalidTable(e.to_string()))
386        }
387
388        /// Deserializes a 2DA from JSON bytes.
389        pub fn read_twoda_from_json_bytes(bytes: &[u8]) -> Result<TwoDa, TwoDaBinaryError> {
390            from_slice(bytes).map_err(|e| TwoDaBinaryError::InvalidTable(e.to_string()))
391        }
392    }
393}
394
395#[cfg(feature = "serde")]
396pub use serde_impl::csv_impl::{
397    read_twoda_from_csv, read_twoda_from_csv_bytes, write_twoda_to_csv, write_twoda_to_csv_vec,
398};
399
400#[cfg(feature = "serde")]
401pub use serde_impl::json_impl::{
402    read_twoda_from_json, read_twoda_from_json_bytes, write_twoda_to_json, write_twoda_to_json_vec,
403};
404
405/// Auto-detects the format from file extension and reads accordingly.
406///
407/// Supported extensions (case-insensitive):
408/// - `.2da`, `.bif`, `.mod`, `.erf`, `.rim` (or unknown) -> binary 2DA
409/// - `.csv` -> CSV format
410/// - `.json` -> JSON format
411#[cfg_attr(
412    feature = "tracing",
413    tracing::instrument(level = "debug", skip(reader))
414)]
415pub fn read_twoda_auto<R: Read>(
416    reader: &mut R,
417    name_or_extension: &str,
418) -> Result<TwoDa, TwoDaBinaryError> {
419    let ext = if let Some(idx) = name_or_extension.rfind('.') {
420        &name_or_extension[idx + 1..]
421    } else {
422        name_or_extension
423    };
424
425    match ext.to_lowercase().as_str() {
426        "csv" => {
427            #[cfg(feature = "serde")]
428            {
429                read_twoda_from_csv(reader)
430            }
431            #[cfg(not(feature = "serde"))]
432            Err(TwoDaBinaryError::InvalidTable(
433                "CSV format requires serde feature".into(),
434            ))
435        }
436        "json" => {
437            #[cfg(feature = "serde")]
438            {
439                // JSON requires reading to end to parse
440                let mut bytes = Vec::new();
441                reader.read_to_end(&mut bytes)?;
442                read_twoda_from_json_bytes(&bytes)
443            }
444            #[cfg(not(feature = "serde"))]
445            Err(TwoDaBinaryError::InvalidTable(
446                "JSON format requires serde feature".into(),
447            ))
448        }
449        _ => read_twoda(reader),
450    }
451}