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(
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#[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#[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#[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 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
173fn is_coordinate_block_command(lowered: &str) -> bool {
175 lowered == UPPER_LEFT_COORDS_COMMAND || lowered == LOWER_RIGHT_COORDS_COMMAND
176}
177
178fn 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 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}