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