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