1use 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#[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#[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#[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#[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 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
150fn is_coordinate_block_command(lowered: &str) -> bool {
152 lowered == UPPER_LEFT_COORDS_COMMAND || lowered == LOWER_RIGHT_COORDS_COMMAND
153}
154
155fn 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 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}