Skip to main content

rakata_formats/lyt/
reader.rs

1//! LYT ASCII reader.
2
3use std::io::Read;
4
5use rakata_core::{decode_text_strict, TextEncoding};
6
7use super::{Lyt, LytDoorHook, LytError, LytObstacle, LytRoom, LytTrack, Quaternion, Vec3};
8
9/// Reads a LYT layout from a reader.
10///
11/// # Errors
12///
13/// [`LytError::Io`] when the stream will not read to end, and whatever
14/// [`read_lyt_from_bytes`] reports for the bytes it collected.
15#[cfg_attr(
16    feature = "tracing",
17    tracing::instrument(level = "debug", skip(reader))
18)]
19pub fn read_lyt<R: Read>(reader: &mut R) -> Result<Lyt, LytError> {
20    let mut bytes = Vec::new();
21    reader.read_to_end(&mut bytes)?;
22    read_lyt_from_bytes(&bytes)
23}
24
25/// Reads a LYT layout from bytes.
26///
27/// # Errors
28///
29/// [`LytError::TextDecoding`] when `bytes` are not Windows-1252 text, and
30/// [`LytError::InvalidData`] when a count section's rows do not parse. An
31/// unrecognised line is skipped rather than reported, so a file that is not a
32/// LYT at all can read as an empty layout.
33#[cfg_attr(
34    feature = "tracing",
35    tracing::instrument(level = "debug", skip(bytes), fields(bytes_len = bytes.len()))
36)]
37pub fn read_lyt_from_bytes(bytes: &[u8]) -> Result<Lyt, LytError> {
38    let text = decode_text_strict(bytes, TextEncoding::Windows1252).map_err(|source| {
39        LytError::TextDecoding {
40            context: "LYT payload".into(),
41            source,
42        }
43    })?;
44    let lines: Vec<&str> = text.lines().collect();
45
46    let mut lyt = Lyt::new();
47    let mut line_index = 0usize;
48    while line_index < lines.len() {
49        let line = lines[line_index];
50        let tokens: Vec<&str> = line.split_whitespace().collect();
51        if tokens.is_empty() {
52            line_index += 1;
53            continue;
54        }
55
56        match tokens[0] {
57            "roomcount" => {
58                let count = parse_count(&tokens, line_index, "roomcount")?;
59                line_index += 1;
60                for item_index in 0..count {
61                    let entry_line = next_required_line(&lines, line_index, "room", item_index)?;
62                    lyt.rooms.push(parse_room_line(entry_line, line_index)?);
63                    line_index += 1;
64                }
65            }
66            "trackcount" => {
67                let count = parse_count(&tokens, line_index, "trackcount")?;
68                line_index += 1;
69                for item_index in 0..count {
70                    let entry_line = next_required_line(&lines, line_index, "track", item_index)?;
71                    lyt.tracks.push(parse_track_line(entry_line, line_index)?);
72                    line_index += 1;
73                }
74            }
75            "obstaclecount" => {
76                let count = parse_count(&tokens, line_index, "obstaclecount")?;
77                line_index += 1;
78                for item_index in 0..count {
79                    let entry_line =
80                        next_required_line(&lines, line_index, "obstacle", item_index)?;
81                    lyt.obstacles
82                        .push(parse_obstacle_line(entry_line, line_index)?);
83                    line_index += 1;
84                }
85            }
86            "doorhookcount" => {
87                let count = parse_count(&tokens, line_index, "doorhookcount")?;
88                line_index += 1;
89                for item_index in 0..count {
90                    let entry_line =
91                        next_required_line(&lines, line_index, "doorhook", item_index)?;
92                    lyt.doorhooks
93                        .push(parse_doorhook_line(entry_line, line_index)?);
94                    line_index += 1;
95                }
96            }
97            _ => {
98                line_index += 1;
99            }
100        }
101    }
102
103    Ok(lyt)
104}
105
106fn parse_count(tokens: &[&str], line_index: usize, key: &'static str) -> Result<usize, LytError> {
107    let count_token = tokens.get(1).ok_or_else(|| {
108        LytError::InvalidData(format!("line {} missing {key} value", line_index + 1))
109    })?;
110    count_token.parse::<usize>().map_err(|_| {
111        LytError::InvalidData(format!(
112            "line {} has invalid {key} value `{count_token}`",
113            line_index + 1
114        ))
115    })
116}
117
118fn next_required_line<'a>(
119    lines: &'a [&str],
120    line_index: usize,
121    section: &'static str,
122    item_index: usize,
123) -> Result<&'a str, LytError> {
124    lines.get(line_index).copied().ok_or_else(|| {
125        LytError::InvalidData(format!(
126            "missing {section} line {} declared by count",
127            item_index + 1
128        ))
129    })
130}
131
132fn parse_room_line(line: &str, line_index: usize) -> Result<LytRoom, LytError> {
133    let tokens: Vec<&str> = line.split_whitespace().collect();
134    if tokens.len() < 4 {
135        return Err(LytError::InvalidData(format!(
136            "line {} has invalid room entry",
137            line_index + 1
138        )));
139    }
140    Ok(LytRoom {
141        model: tokens[0].to_string(),
142        position: Vec3::new(
143            parse_f32(tokens[1], line_index, "room.x")?,
144            parse_f32(tokens[2], line_index, "room.y")?,
145            parse_f32(tokens[3], line_index, "room.z")?,
146        ),
147    })
148}
149
150fn parse_track_line(line: &str, line_index: usize) -> Result<LytTrack, LytError> {
151    let tokens: Vec<&str> = line.split_whitespace().collect();
152    if tokens.len() < 4 {
153        return Err(LytError::InvalidData(format!(
154            "line {} has invalid track entry",
155            line_index + 1
156        )));
157    }
158    Ok(LytTrack {
159        model: tokens[0].to_string(),
160        position: Vec3::new(
161            parse_f32(tokens[1], line_index, "track.x")?,
162            parse_f32(tokens[2], line_index, "track.y")?,
163            parse_f32(tokens[3], line_index, "track.z")?,
164        ),
165    })
166}
167
168fn parse_obstacle_line(line: &str, line_index: usize) -> Result<LytObstacle, LytError> {
169    let tokens: Vec<&str> = line.split_whitespace().collect();
170    if tokens.len() < 4 {
171        return Err(LytError::InvalidData(format!(
172            "line {} has invalid obstacle entry",
173            line_index + 1
174        )));
175    }
176    Ok(LytObstacle {
177        model: tokens[0].to_string(),
178        position: Vec3::new(
179            parse_f32(tokens[1], line_index, "obstacle.x")?,
180            parse_f32(tokens[2], line_index, "obstacle.y")?,
181            parse_f32(tokens[3], line_index, "obstacle.z")?,
182        ),
183    })
184}
185
186fn parse_doorhook_line(line: &str, line_index: usize) -> Result<LytDoorHook, LytError> {
187    let tokens: Vec<&str> = line.split_whitespace().collect();
188    if tokens.len() < 10 {
189        return Err(LytError::InvalidData(format!(
190            "line {} has invalid doorhook entry",
191            line_index + 1
192        )));
193    }
194
195    Ok(LytDoorHook {
196        room: tokens[0].to_string(),
197        door: tokens[1].to_string(),
198        position: Vec3::new(
199            parse_f32(tokens[3], line_index, "doorhook.x")?,
200            parse_f32(tokens[4], line_index, "doorhook.y")?,
201            parse_f32(tokens[5], line_index, "doorhook.z")?,
202        ),
203        orientation: Quaternion::new(
204            parse_f32(tokens[6], line_index, "doorhook.qx")?,
205            parse_f32(tokens[7], line_index, "doorhook.qy")?,
206            parse_f32(tokens[8], line_index, "doorhook.qz")?,
207            parse_f32(tokens[9], line_index, "doorhook.qw")?,
208        ),
209    })
210}
211
212fn parse_f32(token: &str, line_index: usize, field: &'static str) -> Result<f32, LytError> {
213    token.parse::<f32>().map_err(|_| {
214        LytError::InvalidData(format!(
215            "line {} has invalid {field} value `{token}`",
216            line_index + 1
217        ))
218    })
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::lyt::write_lyt_to_vec;
225
226    // Synthetic game-format fixture: mimics NWNmax exporter output (preamble lines,
227    // irregular whitespace) but uses invented resrefs -- no real game data.
228    const GAME_FORMAT_LYT: &[u8] = include_bytes!(concat!(
229        env!("CARGO_MANIFEST_DIR"),
230        "/../../fixtures/test_lyt_vanilla.lyt"
231    ));
232    // Synthetic corrupted fixture: roomcount declares 5 rooms but only 2 are present.
233    const CORRUPTED_LYT: &[u8] = include_bytes!(concat!(
234        env!("CARGO_MANIFEST_DIR"),
235        "/../../fixtures/test_corrupted.lyt"
236    ));
237
238    #[test]
239    fn parses_game_format_lyt_fixture() {
240        let lyt = read_lyt_from_bytes(GAME_FORMAT_LYT).expect("fixture should parse");
241        assert_eq!(lyt.rooms.len(), 2);
242        assert_eq!(lyt.rooms[0].model, "syn_room_01a");
243        assert_eq!(lyt.rooms[0].position, Vec3::new(100.0, 100.0, 0.0));
244        assert_eq!(lyt.tracks.len(), 2);
245        assert_eq!(lyt.tracks[1].model, "syn_mgt02");
246        assert_eq!(lyt.obstacles.len(), 2);
247        assert_eq!(lyt.obstacles[0].position, Vec3::new(103.309, 3691.61, 0.0));
248        assert_eq!(lyt.doorhooks.len(), 2);
249        assert_eq!(lyt.doorhooks[0].room, "syn_room_01a");
250        assert_eq!(lyt.doorhooks[0].door, "door_01");
251        let approx_sqrt_half: f32 = "0.707107".parse().expect("valid float");
252        assert_eq!(
253            lyt.doorhooks[0].orientation,
254            Quaternion::new(approx_sqrt_half, 0.0, 0.0, -approx_sqrt_half)
255        );
256    }
257
258    #[test]
259    fn roundtrip_synthetic_lyt() {
260        let mut lyt = Lyt::new();
261        lyt.rooms.push(LytRoom {
262            model: "room_a".into(),
263            position: Vec3::new(1.0, 2.0, 3.0),
264        });
265        lyt.tracks.push(LytTrack {
266            model: "track_a".into(),
267            position: Vec3::new(4.0, 5.0, 6.0),
268        });
269        lyt.obstacles.push(LytObstacle {
270            model: "obs_a".into(),
271            position: Vec3::new(7.0, 8.0, 9.0),
272        });
273        lyt.doorhooks.push(LytDoorHook {
274            room: "room_a".into(),
275            door: "door_00".into(),
276            position: Vec3::new(10.0, 11.0, 12.0),
277            orientation: Quaternion::new(0.0, 0.0, 0.0, 1.0),
278        });
279
280        let bytes = write_lyt_to_vec(&lyt).expect("write should succeed");
281        let parsed = read_lyt_from_bytes(&bytes).expect("read should succeed");
282        assert_eq!(parsed, lyt);
283    }
284
285    #[test]
286    fn writer_is_deterministic_for_synthetic_lyt() {
287        let mut lyt = Lyt::new();
288        lyt.rooms.push(LytRoom {
289            model: "room_a".into(),
290            position: Vec3::new(1.0, 2.0, 3.0),
291        });
292        lyt.doorhooks.push(LytDoorHook {
293            room: "room_a".into(),
294            door: "door_00".into(),
295            position: Vec3::new(4.0, 5.0, 6.0),
296            orientation: Quaternion::new(0.0, 0.0, 0.0, 1.0),
297        });
298
299        let first = write_lyt_to_vec(&lyt).expect("first write should succeed");
300        let second = write_lyt_to_vec(&lyt).expect("second write should succeed");
301        assert_eq!(first, second);
302    }
303
304    #[test]
305    fn read_write_roundtrip_preserves_fixture_semantics() {
306        let parsed = read_lyt_from_bytes(GAME_FORMAT_LYT).expect("read should succeed");
307        let bytes = write_lyt_to_vec(&parsed).expect("write should succeed");
308        let reparsed = read_lyt_from_bytes(&bytes).expect("re-read should succeed");
309        assert_eq!(reparsed, parsed);
310    }
311
312    #[test]
313    fn rejects_corrupted_lyt_fixture() {
314        let err = read_lyt_from_bytes(CORRUPTED_LYT).expect_err("must fail");
315        assert!(matches!(err, LytError::InvalidData(_)));
316    }
317
318    #[test]
319    fn rejects_truncated_layout_section() {
320        let bytes = b"beginlayout\r\nroomcount 1\r\n";
321        let err = read_lyt_from_bytes(bytes).expect_err("must fail");
322        assert!(matches!(err, LytError::InvalidData(_)));
323    }
324
325    #[test]
326    fn writer_rejects_whitespace_in_name_tokens() {
327        let mut lyt = Lyt::new();
328        lyt.rooms.push(LytRoom {
329            model: "bad name".into(),
330            position: Vec3::new(0.0, 0.0, 0.0),
331        });
332        let err = write_lyt_to_vec(&lyt).expect_err("must fail");
333        assert!(matches!(err, LytError::InvalidName { .. }));
334    }
335
336    #[test]
337    fn parser_accepts_windows_1252_non_utf8_bytes() {
338        let bytes = b"beginlayout\nroomcount 1\nmod\xe9l 1 2 3\ntrackcount 0\nobstaclecount 0\ndoorhookcount 0\ndonelayout\n";
339        let lyt = read_lyt_from_bytes(bytes).expect("must parse");
340        assert_eq!(lyt.rooms.len(), 1);
341        assert_eq!(lyt.rooms[0].model, "mod\u{e9}l");
342    }
343
344    #[test]
345    fn writer_rejects_unencodable_text() {
346        let mut lyt = Lyt::new();
347        lyt.rooms.push(LytRoom {
348            model: "emoji_\u{1f600}".into(),
349            position: Vec3::new(0.0, 0.0, 0.0),
350        });
351        let err = write_lyt_to_vec(&lyt).expect_err("must fail");
352        assert!(matches!(err, LytError::TextEncoding { .. }));
353    }
354}