1use std::io::Read;
4
5use rakata_core::{decode_text_strict, TextEncoding};
6
7use super::{Lyt, LytDoorHook, LytError, LytObstacle, LytRoom, LytTrack, Quaternion, Vec3};
8
9#[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#[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 const GAME_FORMAT_LYT: &[u8] = include_bytes!(concat!(
229 env!("CARGO_MANIFEST_DIR"),
230 "/../../fixtures/test_lyt_vanilla.lyt"
231 ));
232 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}