Skip to main content

rakata_formats/txi/
reader.rs

1//! TXI ASCII reader.
2
3use std::io::Read;
4
5use rakata_core::{decode_text_strict, TextEncoding};
6
7use super::{
8    Txi, TxiCoordinate, TxiCoordinateBlock, TxiDirective, TxiEntry, TxiError, TxiReadOptions,
9    DECAL1_ALIAS, DECAL_COMMAND, LOWER_RIGHT_COORDS_COMMAND, UPPER_LEFT_COORDS_COMMAND,
10};
11
12/// Reads TXI data from a reader.
13///
14/// # Errors
15///
16/// The same as [`read_txi_with_options`] under the default options.
17#[cfg_attr(
18    feature = "tracing",
19    tracing::instrument(level = "debug", skip(reader))
20)]
21pub fn read_txi<R: Read>(reader: &mut R) -> Result<Txi, TxiError> {
22    read_txi_with_options(reader, TxiReadOptions::default())
23}
24
25/// Reads TXI data from a reader with explicit parse options.
26///
27/// # Errors
28///
29/// [`TxiError::Io`] when the stream will not read to end, and whatever
30/// [`read_txi_from_bytes_with_options`] reports for the bytes it collected.
31#[cfg_attr(
32    feature = "tracing",
33    tracing::instrument(level = "debug", skip(reader, options))
34)]
35pub fn read_txi_with_options<R: Read>(
36    reader: &mut R,
37    options: TxiReadOptions,
38) -> Result<Txi, TxiError> {
39    let mut bytes = Vec::new();
40    reader.read_to_end(&mut bytes)?;
41    read_txi_from_bytes_with_options(&bytes, options)
42}
43
44/// Reads TXI data from bytes.
45///
46/// # Errors
47///
48/// The same as [`read_txi_from_bytes_with_options`] under the default options.
49#[cfg_attr(
50    feature = "tracing",
51    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
52)]
53pub fn read_txi_from_bytes(bytes: &[u8]) -> Result<Txi, TxiError> {
54    read_txi_from_bytes_with_options(bytes, TxiReadOptions::default())
55}
56
57/// Reads TXI data from bytes with explicit parse options.
58///
59/// # Errors
60///
61/// [`TxiError::TextDecoding`] when `bytes` are not valid text in the parse
62/// encoding, and [`TxiError::InvalidData`] when a coordinate block declares
63/// more rows than follow it.
64///
65/// An unrecognised command is not an error. The engine ignores one silently
66/// and this stores it verbatim so the writer can re-emit it, so a file full of
67/// commands nothing here knows still round-trips.
68#[cfg_attr(
69    feature = "tracing",
70    tracing::instrument(level = "debug", skip(bytes, options), fields(bytes_len = bytes.len()))
71)]
72pub fn read_txi_from_bytes_with_options(
73    bytes: &[u8],
74    options: TxiReadOptions,
75) -> Result<Txi, TxiError> {
76    let text = decode_text_strict(bytes, TextEncoding::Windows1252).map_err(|source| {
77        TxiError::TextDecoding {
78            context: "TXI payload".into(),
79            source,
80        }
81    })?;
82    let lines: Vec<&str> = text.lines().collect();
83    let mut entries = Vec::new();
84
85    let mut line_index = 0usize;
86    while line_index < lines.len() {
87        let raw_line = lines[line_index];
88        let parsed_line = raw_line.trim();
89        if parsed_line.is_empty() {
90            line_index += 1;
91            continue;
92        }
93
94        let (raw_command, raw_args) = split_command_arguments(parsed_line);
95        let mut lowered = raw_command.to_ascii_lowercase();
96        let mut arguments = raw_args.to_string();
97        if options.compatibility_decal1_alias && lowered == DECAL1_ALIAS {
98            lowered = DECAL_COMMAND.to_string();
99            if arguments.is_empty() {
100                arguments = "1".into();
101            }
102        }
103        // Always normalize to lowercase.
104        let command = lowered.clone();
105
106        if is_coordinate_block_command(&lowered) {
107            let declared_count = match arguments.parse::<usize>() {
108                Ok(value) => value,
109                Err(_) => {
110                    return Err(TxiError::InvalidData(format!(
111                        "line {} has invalid coordinate count `{arguments}` for `{command}`",
112                        line_index + 1
113                    )));
114                }
115            };
116            line_index += 1;
117
118            let mut coordinates = Vec::new();
119            let mut consumed_coords = 0usize;
120            while line_index < lines.len() && consumed_coords < declared_count {
121                let coordinate_line = lines[line_index].trim();
122                if coordinate_line.is_empty() {
123                    line_index += 1;
124                    continue;
125                }
126
127                let (candidate_command, _) = split_command_arguments(coordinate_line);
128                if is_known_command(&candidate_command.to_ascii_lowercase()) {
129                    break;
130                }
131
132                if let Some(coordinate) = parse_coordinate_line(coordinate_line) {
133                    coordinates.push(coordinate);
134                    consumed_coords += 1;
135                    line_index += 1;
136                    continue;
137                }
138
139                break;
140            }
141
142            entries.push(TxiEntry::CoordinateBlock(TxiCoordinateBlock {
143                command,
144                declared_count,
145                coordinates,
146            }));
147            continue;
148        }
149
150        if !is_known_command(&lowered) {
151            crate::trace_warn!(
152                command = lowered.as_str(),
153                line = line_index + 1,
154                "TXI: unrecognized command `{lowered}` (line {}); storing verbatim",
155                line_index + 1
156            );
157        }
158        entries.push(TxiEntry::Directive(TxiDirective { command, arguments }));
159        line_index += 1;
160    }
161
162    Ok(Txi { entries })
163}
164
165fn split_command_arguments(line: &str) -> (&str, &str) {
166    if let Some((command, arguments)) = line.split_once(char::is_whitespace) {
167        (command.trim(), arguments.trim())
168    } else {
169        (line.trim(), "")
170    }
171}
172
173/// Checks whether a lowercase command token is a coordinate block command.
174fn is_coordinate_block_command(lowered: &str) -> bool {
175    lowered == UPPER_LEFT_COORDS_COMMAND || lowered == LOWER_RIGHT_COORDS_COMMAND
176}
177
178/// Checks whether a lowercase command token is a recognized TXI command.
179fn is_known_command(lowered: &str) -> bool {
180    matches!(
181        lowered,
182        "alphamean"
183            | "arturoheight"
184            | "arturowidth"
185            | "baselineheight"
186            | "blending"
187            | "bumpmapscaling"
188            | "bumpmaptexture"
189            | "bumpyshinytexture"
190            | "candownsample"
191            | "caretindent"
192            | "channelscale"
193            | "channeltranslate"
194            | "clamp"
195            | "codepage"
196            | "cols"
197            | "compresstexture"
198            | "controllerscript"
199            | "cube"
200            | "decal"
201            | "defaultbpp"
202            | "defaultheight"
203            | "defaultwidth"
204            | "distort"
205            | "distortangle"
206            | "distortionamplitude"
207            | "downsamplefactor"
208            | "downsamplemax"
209            | "downsamplemin"
210            | "envmaptexture"
211            | "filerange"
212            | "filter"
213            | "fontheight"
214            | "fontwidth"
215            | "fps"
216            | "isbumpmap"
217            | "isdiffusebumpmap"
218            | "islightmap"
219            | "isspecularbumpmap"
220            | "lowerrightcoords"
221            | "maxsizehq"
222            | "maxsizelq"
223            | "minsizehq"
224            | "minsizelq"
225            | "mipmap"
226            | "numchars"
227            | "numcharspersheet"
228            | "numx"
229            | "numy"
230            | "ondemand"
231            | "priority"
232            | "proceduretype"
233            | "rows"
234            | "spacingb"
235            | "spacingr"
236            | "speed"
237            | "temporary"
238            | "texturewidth"
239            | "unique"
240            | "upperleftcoords"
241            | "wateralpha"
242            | "waterheight"
243            | "waterwidth"
244            | "xbox_downsample"
245            | "isdoublebyte"
246            | "dbmapping"
247    )
248}
249
250fn parse_coordinate_line(line: &str) -> Option<TxiCoordinate> {
251    let mut parts = line.split_whitespace();
252    let u = parts.next()?.parse::<f32>().ok()?;
253    let v = parts.next()?.parse::<f32>().ok()?;
254    let w = parts.next()?.parse::<i32>().ok()?;
255    Some(TxiCoordinate { u, v, w })
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261    use crate::txi::write_txi_to_vec;
262
263    const MENU_FONT_TXI: &[u8] = include_bytes!(concat!(
264        env!("CARGO_MANIFEST_DIR"),
265        "/../../fixtures/textures/lbl_menudarr.txi"
266    ));
267    const CUBE_TXI: &[u8] = include_bytes!(concat!(
268        env!("CARGO_MANIFEST_DIR"),
269        "/../../fixtures/textures/cube/cm_506ond.txi"
270    ));
271
272    #[test]
273    fn parses_menu_font_txi_fixture() {
274        let txi = read_txi_from_bytes(MENU_FONT_TXI).expect("fixture should parse");
275        assert_eq!(txi.entries.len(), 2);
276        assert!(matches!(
277            &txi.entries[0],
278            TxiEntry::Directive(TxiDirective { command, arguments }) if command == "mipmap" && arguments == "0"
279        ));
280    }
281
282    #[test]
283    fn parses_coordinate_blocks_and_preserves_declared_count() {
284        let bytes = b"
285            upperleftcoords 3
286            0.0 0.0 0
287            0.5 0.0 0
288            lowerrightcoords 2
289            1.0 1.0 0
290        ";
291
292        let txi = read_txi_from_bytes(bytes).expect("must parse");
293        assert_eq!(txi.entries.len(), 2);
294
295        match &txi.entries[0] {
296            TxiEntry::CoordinateBlock(block) => {
297                assert_eq!(block.command, UPPER_LEFT_COORDS_COMMAND);
298                assert_eq!(block.declared_count, 3);
299                assert_eq!(block.coordinates.len(), 2);
300            }
301            _ => panic!("expected coordinate block"),
302        }
303
304        match &txi.entries[1] {
305            TxiEntry::CoordinateBlock(block) => {
306                assert_eq!(block.command, LOWER_RIGHT_COORDS_COMMAND);
307                assert_eq!(block.declared_count, 2);
308                assert_eq!(block.coordinates.len(), 1);
309            }
310            _ => panic!("expected coordinate block"),
311        }
312    }
313
314    #[test]
315    fn parse_aliases_decal1_to_decal() {
316        let txi = read_txi_from_bytes_with_options(
317            b"decal1",
318            TxiReadOptions {
319                compatibility_decal1_alias: true,
320            },
321        )
322        .expect("must parse");
323        assert_eq!(txi.entries.len(), 1);
324        assert!(matches!(
325            &txi.entries[0],
326            TxiEntry::Directive(TxiDirective { command, arguments }) if command == "decal" && arguments == "1"
327        ));
328    }
329
330    #[test]
331    fn default_mode_preserves_decal1_token() {
332        let txi = read_txi_from_bytes(b"decal1").expect("must parse");
333        assert_eq!(txi.entries.len(), 1);
334        assert!(matches!(
335            &txi.entries[0],
336            TxiEntry::Directive(TxiDirective { command, arguments }) if command == "decal1" && arguments.is_empty()
337        ));
338    }
339
340    #[test]
341    fn parser_accepts_windows_1252_non_utf8_bytes() {
342        let txi = read_txi_from_bytes(b"controllerscript caf\xe9").expect("must parse");
343        assert!(matches!(
344            &txi.entries[0],
345            TxiEntry::Directive(TxiDirective { command, arguments })
346                if command == "controllerscript" && arguments == "caf\u{e9}"
347        ));
348    }
349
350    #[test]
351    fn roundtrip_synthetic_txi() {
352        let mut txi = Txi::new();
353        txi.push_directive("mipmap", "0");
354        txi.push_directive("cube", "1");
355        txi.push_coordinate_block(
356            "upperleftcoords",
357            2,
358            vec![
359                TxiCoordinate {
360                    u: 0.0,
361                    v: 0.0,
362                    w: 0,
363                },
364                TxiCoordinate {
365                    u: 0.5,
366                    v: 0.5,
367                    w: 0,
368                },
369            ],
370        );
371
372        let bytes = write_txi_to_vec(&txi).expect("write should succeed");
373        let parsed = read_txi_from_bytes(&bytes).expect("read should succeed");
374        assert_eq!(parsed, txi);
375    }
376
377    #[test]
378    fn writer_is_deterministic_for_synthetic_txi() {
379        let mut txi = Txi::new();
380        txi.push_directive("mipmap", "0");
381        txi.push_directive("decal1", "");
382        txi.push_coordinate_block(
383            "upperleftcoords",
384            1,
385            vec![TxiCoordinate {
386                u: 0.0,
387                v: 0.0,
388                w: 0,
389            }],
390        );
391
392        let first = write_txi_to_vec(&txi).expect("first write should succeed");
393        let second = write_txi_to_vec(&txi).expect("second write should succeed");
394        assert_eq!(first, second);
395    }
396
397    #[test]
398    fn read_write_roundtrip_preserves_fixture_semantics() {
399        let parsed = read_txi_from_bytes(CUBE_TXI).expect("fixture should parse");
400        let bytes = write_txi_to_vec(&parsed).expect("write should succeed");
401        let reparsed = read_txi_from_bytes(&bytes).expect("re-read should succeed");
402        assert_eq!(reparsed, parsed);
403    }
404
405    #[test]
406    fn writer_rejects_unencodable_text() {
407        let mut txi = Txi::new();
408        txi.push_directive("controllerscript", "emoji_\u{1f600}");
409        let err = write_txi_to_vec(&txi).expect_err("must fail");
410        assert!(matches!(err, TxiError::TextEncoding { .. }));
411    }
412
413    #[test]
414    fn unknown_command_is_preserved_verbatim() {
415        // The engine silently ignores unknown commands (Ghidra: `CAurTextureBasic::ParseField`
416        // `0x00422390`, no match -> silent return). This parser stores them as `TxiDirective`
417        // and the writer re-emits them unchanged. A `trace_warn!` is emitted when the
418        // `tracing` feature is enabled, which does not affect parsing or roundtrip.
419        let bytes = b"mipmap 0\nunknown_cmd foo bar\n";
420        let txi = read_txi_from_bytes(bytes).expect("must parse");
421        assert_eq!(txi.entries.len(), 2);
422        assert!(matches!(
423            &txi.entries[1],
424            TxiEntry::Directive(TxiDirective { command, arguments })
425                if command == "unknown_cmd" && arguments == "foo bar"
426        ));
427        let out = write_txi_to_vec(&txi).expect("must write");
428        assert_eq!(out, bytes);
429    }
430
431    #[test]
432    fn rejects_malformed_coordinate_count() {
433        let err = read_txi_from_bytes(b"upperleftcoords nope\n").expect_err("must fail");
434        assert!(matches!(err, TxiError::InvalidData(_)));
435    }
436}