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(
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#[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 const GAME_FORMAT_LYT: &[u8] = include_bytes!(concat!(
217 env!("CARGO_MANIFEST_DIR"),
218 "/../../fixtures/test_lyt_vanilla.lyt"
219 ));
220 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}