Skip to main content

openstranded_map_tool/
parser.rs

1// openstranded-map-tool — convert Stranded II .s2 maps to .osmap format
2// Copyright (C) 2026  OpenStranded contributors
3//
4// This program is free software: you can redistribute it and/or modify
5// it under the terms of the GNU General Public License as published by
6// the Free Software Foundation, either version 3 of the License, or
7// (at your option) any later version.
8//
9// This program is distributed in the hope that it will be useful,
10// but WITHOUT ANY WARRANTY; without even the implied warranty of
11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12// GNU General Public License for more details.
13//
14// You should have received a copy of the GNU General Public License
15// along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17//! Binary parser for Stranded II `.s2` map files.
18//!
19//! **Authoritative reference:** `original-source/source/includes/e_save_map.bb`
20//! — the actual Blitz3D save routine. The `.s2-map-format.md` doc has several
21//! errors; the source is the source of truth.
22
23use std::io::{Cursor, Read};
24
25use crate::types::*;
26
27/// Errors that can occur during `.s2` parsing.
28#[derive(Debug)]
29pub enum S2Error {
30    Io(std::io::Error),
31    InvalidHeader(String),
32    UnexpectedEof,
33    InvalidTrailer(String),
34    UnsupportedFormat(String),
35}
36
37impl std::fmt::Display for S2Error {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            S2Error::Io(e) => write!(f, "I/O error: {e}"),
41            S2Error::InvalidHeader(s) => write!(f, "invalid header: {s}"),
42            S2Error::UnexpectedEof => write!(f, "unexpected end of file"),
43            S2Error::InvalidTrailer(s) => write!(f, "invalid trailer: {s}"),
44            S2Error::UnsupportedFormat(s) => write!(f, "unsupported format: {s}"),
45        }
46    }
47}
48
49impl std::error::Error for S2Error {
50    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
51        match self {
52            S2Error::Io(e) => Some(e),
53            _ => None,
54        }
55    }
56}
57
58impl From<std::io::Error> for S2Error {
59    fn from(e: std::io::Error) -> Self {
60        S2Error::Io(e)
61    }
62}
63
64/// Specialized result type for `.s2` parsing.
65pub type S2Result<T> = Result<T, S2Error>;
66
67// ---------------------------------------------------------------------------
68// Helper: reading primitives from a Cursor
69// ---------------------------------------------------------------------------
70
71fn read_u8(c: &mut Cursor<&[u8]>) -> S2Result<u8> {
72    let mut buf = [0u8; 1];
73    c.read_exact(&mut buf)?;
74    Ok(buf[0])
75}
76
77fn read_u16_le(c: &mut Cursor<&[u8]>) -> S2Result<u16> {
78    let mut buf = [0u8; 2];
79    c.read_exact(&mut buf)?;
80    Ok(u16::from_le_bytes(buf))
81}
82
83fn read_u32_le(c: &mut Cursor<&[u8]>) -> S2Result<u32> {
84    let mut buf = [0u8; 4];
85    c.read_exact(&mut buf)?;
86    Ok(u32::from_le_bytes(buf))
87}
88
89fn read_f32_le(c: &mut Cursor<&[u8]>) -> S2Result<f32> {
90    let mut buf = [0u8; 4];
91    c.read_exact(&mut buf)?;
92    Ok(f32::from_le_bytes(buf))
93}
94
95fn read_exact(c: &mut Cursor<&[u8]>, n: usize) -> S2Result<Vec<u8>> {
96    let mut buf = vec![0u8; n];
97    c.read_exact(&mut buf)?;
98    Ok(buf)
99}
100
101/// Read a Blitz3D `WriteString`: u32 LE length prefix + data (Windows-1252).
102/// Uses lossy UTF-8 conversion for non-UTF-8 bytes.
103fn read_s2_string(c: &mut Cursor<&[u8]>) -> S2Result<String> {
104    let len = read_u32_le(c)? as usize;
105    if len == 0 {
106        return Ok(String::new());
107    }
108    let bytes = read_exact(c, len)?;
109    Ok(String::from_utf8_lossy(&bytes).into_owned())
110}
111
112/// Read a Blitz3D `WriteLine`: read until `\r\n`. Uses lossy UTF-8 conversion.
113fn read_s2_line(c: &mut Cursor<&[u8]>) -> S2Result<String> {
114    let mut bytes = Vec::new();
115    loop {
116        let b = read_u8(c)?;
117        if b == b'\r' {
118            let next = read_u8(c)?;
119            if next == b'\n' {
120                break;
121            }
122            bytes.push(b);
123            bytes.push(next);
124        } else {
125            bytes.push(b);
126        }
127    }
128    Ok(String::from_utf8_lossy(&bytes).into_owned())
129}
130
131// ---------------------------------------------------------------------------
132// Main parser
133// ---------------------------------------------------------------------------
134
135/// Parse a complete `.s2` file from a byte slice.
136pub fn parse_s2(data: &[u8]) -> S2Result<S2Map> {
137    let mut c = Cursor::new(data);
138
139    // ---- 1. Header — dynamic lines until "###\r\n" ----
140    let header = parse_header(&mut c)?;
141
142    // ---- 2. Minimap (96×72×3 = 20736 bytes, 24-bit RGB) ----
143    let minimap = read_exact(&mut c, 20736)?;
144
145    // ---- 3. Password header ----
146    // From source: WriteByte(stream, pwkey) + WriteLine(stream, encodedpw$)
147    let pw_key = read_u8(&mut c)?;
148    let encoded_pw = read_s2_line(&mut c)?;
149
150    // ---- 4. Environment variables ----
151    let env_vars = parse_env_vars(&mut c)?;
152
153    // ---- 5. Quickslots (10 × WriteString) ----
154    let quickslots = parse_quickslots(&mut c)?;
155
156    // ---- 6. Colormap ----
157    let colormap_dim = read_u32_le(&mut c)?;
158    let colormap_size = (colormap_dim * colormap_dim * 3) as usize;
159    let colormap = read_exact(&mut c, colormap_size)?;
160
161    // ---- 7. Heightmap ----
162    let terrain_size = read_u32_le(&mut c)?;
163    let height_count = ((terrain_size + 1) * (terrain_size + 1)) as usize;
164    let mut heights = Vec::with_capacity(height_count);
165    for _ in 0..height_count {
166        heights.push(read_f32_le(&mut c)?);
167    }
168
169    // ---- 8. Grass layer (colormap_dim+1)² bytes, column-major ----
170    let grass_dim = colormap_dim + 1;
171    let grass_size = (grass_dim * grass_dim) as usize;
172    let grass = read_exact(&mut c, grass_size)?;
173
174    // ---- 9. Footer: entity sections + trailer ----
175    let (objects, units, items, infos, states, extensions, trailer) = parse_footer(&mut c)?;
176
177    // Detect password from encoded form
178    let decoded_pw = decode_password(&encoded_pw, pw_key);
179
180    Ok(S2Map {
181        header,
182        minimap,
183        password: S2Password {
184            key: pw_key,
185            encoded: encoded_pw,
186            decoded: decoded_pw,
187        },
188        env_vars,
189        quickslots,
190        colormap_dim,
191        colormap,
192        terrain_size,
193        heights,
194        grass,
195        objects,
196        units,
197        items,
198        infos,
199        states,
200        extensions,
201        trailer,
202    })
203}
204
205/// Simple XOR-based password decoder from the Blitz3D source's `code()` function.
206fn decode_password(encoded: &str, key: u8) -> String {
207    let bytes = encoded.as_bytes();
208    let decoded: Vec<u8> = bytes.iter().map(|&b| b ^ key).collect();
209    String::from_utf8_lossy(&decoded).into_owned()
210}
211
212// ---------------------------------------------------------------------------
213// Header
214// ---------------------------------------------------------------------------
215
216fn parse_header(c: &mut Cursor<&[u8]>) -> S2Result<S2Header> {
217    let mut lines: Vec<String> = Vec::new();
218    loop {
219        let line = read_s2_line(c)?;
220        if line == "###" {
221            break;
222        }
223        lines.push(line);
224    }
225
226    if lines.len() < 11 {
227        return Err(S2Error::InvalidHeader(format!(
228            "expected 11+ header lines before '###', got {}",
229            lines.len()
230        )));
231    }
232
233    // Line 0: "### Stranded II Mapfile ..."
234    if !lines[0].starts_with("### Stranded II Mapfile") {
235        return Err(S2Error::InvalidHeader(format!(
236            "not a Stranded II map file: {:?}",
237            lines[0]
238        )));
239    }
240
241    // Lines 1-5 (indices 1-5)
242    let version = lines.get(1).cloned().unwrap_or_default();
243    let date = lines.get(2).cloned().unwrap_or_default();
244    let time = lines.get(3).cloned().unwrap_or_default();
245    let author = lines.get(4).cloned().unwrap_or_default();
246    let map_type = lines.get(5).cloned().unwrap_or_default();
247
248    // Lines 6-10: blank lines (or type_format for .map export)
249    // Lines 6..10 may contain "001"-style format spec in .map files, but
250    // in .s2 they should be blank. We ignore them.
251
252    Ok(S2Header {
253        version,
254        date,
255        time,
256        author,
257        map_type,
258    })
259}
260
261// ---------------------------------------------------------------------------
262// Environment variables
263// ---------------------------------------------------------------------------
264// Source order (e_save_map.bb lines 70-84):
265//   WriteInt(day) → 4
266//   WriteByte(hour) → 1
267//   WriteByte(minute) → 1
268//   WriteByte(freezetime) → 1
269//   WriteString(skybox) → 4 + len
270//   WriteByte(multiplayer) → 1
271//   WriteByte(climate) → 1
272//   WriteString(music) → 4 + len
273//   WriteString(briefing) → 4 + len
274//   WriteByte(fog_r) → 1
275//   WriteByte(fog_g) → 1
276//   WriteByte(fog_b) → 1
277//   WriteByte(fog_mode) → 1
278//   WriteByte(extra) → 1
279
280fn parse_env_vars(c: &mut Cursor<&[u8]>) -> S2Result<S2EnvVars> {
281    let day = read_u32_le(c)?;
282    let hour = read_u8(c)?;
283    let minute = read_u8(c)?;
284    let freezetime = read_u8(c)?;
285    let skybox = read_s2_string(c)?;
286    let multiplayer = read_u8(c)?;
287    let climate = read_u8(c)?;
288    let music = read_s2_string(c)?;
289    let briefing = read_s2_string(c)?;
290    let f_r = read_u8(c)?;
291    let f_g = read_u8(c)?;
292    let f_b = read_u8(c)?;
293    let f_mode = read_u8(c)?;
294    let extra = read_u8(c)?;
295
296    Ok(S2EnvVars {
297        day,
298        hour,
299        minute,
300        freezetime,
301        skybox,
302        multiplayer,
303        climate,
304        music,
305        briefing,
306        fog: [f_r, f_g, f_b, f_mode],
307        extra,
308    })
309}
310
311// ---------------------------------------------------------------------------
312// Quickslots
313// ---------------------------------------------------------------------------
314
315fn parse_quickslots(c: &mut Cursor<&[u8]>) -> S2Result<Vec<String>> {
316    let mut slots = Vec::with_capacity(10);
317    for _ in 0..10 {
318        slots.push(read_s2_string(c)?);
319    }
320    Ok(slots)
321}
322
323// ---------------------------------------------------------------------------
324// Footer
325// ---------------------------------------------------------------------------
326
327fn parse_footer(
328    c: &mut Cursor<&[u8]>,
329) -> S2Result<(
330    Vec<S2Object>,
331    Vec<S2Unit>,
332    Vec<S2Item>,
333    Vec<S2Info>,
334    Vec<S2State>,
335    Vec<S2Extension>,
336    S2Trailer,
337)> {
338    // 6 section counts (u32 each)
339    let obj_count = read_u32_le(c)?;
340    let unit_count = read_u32_le(c)?;
341    let item_count = read_u32_le(c)?;
342    let info_count = read_u32_le(c)?;
343    let state_count = read_u32_le(c)?;
344    let ext_count = read_u32_le(c)?;
345
346    // Determine type format flags
347    // tfX = 1 when count > 255, else 0
348    let tf_object = obj_count > 255;
349    let tf_unit = unit_count > 255;
350    let tf_item = item_count > 255;
351
352    let mut objects = Vec::with_capacity(obj_count as usize);
353    for _ in 0..obj_count {
354        objects.push(parse_object(c, tf_object)?);
355    }
356
357    let mut units = Vec::with_capacity(unit_count as usize);
358    for _ in 0..unit_count {
359        units.push(parse_unit(c, tf_unit)?);
360    }
361
362    let mut items = Vec::with_capacity(item_count as usize);
363    for _ in 0..item_count {
364        items.push(parse_item(c, tf_item)?);
365    }
366
367    let mut infos = Vec::with_capacity(info_count as usize);
368    for _ in 0..info_count {
369        infos.push(parse_info(c)?);
370    }
371
372    let mut states = Vec::with_capacity(state_count as usize);
373    for _ in 0..state_count {
374        states.push(parse_state(c)?);
375    }
376
377    let mut extensions = Vec::with_capacity(ext_count as usize);
378    for _ in 0..ext_count {
379        extensions.push(parse_extension(c)?);
380    }
381
382    // Trailer: WriteLine("") + WriteLine("### EOF Map File") + WriteLine("www.unrealsoftware.de")
383    let blank = read_s2_line(c)?;
384    let eof_marker = read_s2_line(c)?;
385    let url = read_s2_line(c)?;
386
387    if !eof_marker.is_empty() && eof_marker != "### EOF Map File" {
388        return Err(S2Error::InvalidTrailer(format!(
389            "expected '### EOF Map File', got {:?}",
390            eof_marker
391        )));
392    }
393    if !url.is_empty() && url != "www.unrealsoftware.de" {
394        return Err(S2Error::InvalidTrailer(format!(
395            "expected 'www.unrealsoftware.de', got {:?}",
396            url
397        )));
398    }
399
400    let trailer = S2Trailer {
401        blank,
402        eof_marker,
403        url,
404    };
405
406    Ok((objects, units, items, infos, states, extensions, trailer))
407}
408
409// ---------------------------------------------------------------------------
410// Entity parsers (all from e_save_map.bb)
411// ---------------------------------------------------------------------------
412
413/// Object record (e_save_map.bb lines 137-148):
414///   WriteInt(id), If tf: WriteShort(typ)/WriteByte(typ),
415///   WriteFloat(x), WriteFloat(z), WriteFloat(yaw),
416///   WriteFloat(health), WriteFloat(health_max), WriteInt(daytimer)
417fn parse_object(c: &mut Cursor<&[u8]>, tf: bool) -> S2Result<S2Object> {
418    let id = read_u32_le(c)?;
419    let typ = if tf {
420        read_u16_le(c)? as u32
421    } else {
422        read_u8(c)? as u32
423    };
424    let x = read_f32_le(c)?;
425    let z = read_f32_le(c)?;
426    let yaw = read_f32_le(c)?;
427    let health = read_f32_le(c)?;
428    let health_max = read_f32_le(c)?;
429    let day_timer = read_u32_le(c)?;
430    Ok(S2Object {
431        id,
432        typ,
433        x,
434        z,
435        yaw,
436        health,
437        health_max,
438        day_timer,
439    })
440}
441
442/// Unit record (e_save_map.bb lines 166-182):
443///   WriteInt(id), If tf: WriteShort(typ)/WriteByte(typ),
444///   WriteFloat(x), WriteFloat(y), WriteFloat(z), WriteFloat(yaw),
445///   WriteFloat(health), WriteFloat(health_max),
446///   WriteFloat(hunger), WriteFloat(thirst), WriteFloat(exhaustion),
447///   WriteFloat(ai_cx), WriteFloat(ai_cz)
448fn parse_unit(c: &mut Cursor<&[u8]>, tf: bool) -> S2Result<S2Unit> {
449    let id = read_u32_le(c)?;
450    let typ = if tf {
451        read_u16_le(c)? as u32
452    } else {
453        read_u8(c)? as u32
454    };
455    let x = read_f32_le(c)?;
456    let y = read_f32_le(c)?;
457    let z = read_f32_le(c)?;
458    let yaw = read_f32_le(c)?;
459    let health = read_f32_le(c)?;
460    let health_max = read_f32_le(c)?;
461    let hunger = read_f32_le(c)?;
462    let thirst = read_f32_le(c)?;
463    let exhaustion = read_f32_le(c)?;
464    let ai_center_x = read_f32_le(c)?;
465    let ai_center_z = read_f32_le(c)?;
466
467    Ok(S2Unit {
468        id,
469        typ,
470        x,
471        y,
472        z,
473        yaw,
474        health,
475        health_max,
476        hunger,
477        thirst,
478        exhaustion,
479        ai_center_x,
480        ai_center_z,
481    })
482}
483
484/// Item record (e_save_map.bb lines 192-207):
485///   WriteInt(id), If tf: WriteShort(typ)/WriteByte(typ),
486///   WriteFloat(x), WriteFloat(y), WriteFloat(z), WriteFloat(yaw),
487///   WriteFloat(health), WriteInt(count),
488///   WriteByte(parent_class), WriteByte(parent_mode), WriteInt(parent_id)
489fn parse_item(c: &mut Cursor<&[u8]>, tf: bool) -> S2Result<S2Item> {
490    let id = read_u32_le(c)?;
491    let typ = if tf {
492        read_u16_le(c)? as u32
493    } else {
494        read_u8(c)? as u32
495    };
496    let x = read_f32_le(c)?;
497    let y = read_f32_le(c)?;
498    let z = read_f32_le(c)?;
499    let yaw = read_f32_le(c)?;
500    let health = read_f32_le(c)?;
501    let count = read_u32_le(c)?;
502    let parent_class = read_u8(c)?;
503    let parent_mode = read_u8(c)?;
504    let parent_id = read_u32_le(c)?;
505    Ok(S2Item {
506        id,
507        typ,
508        x,
509        y,
510        z,
511        yaw,
512        health,
513        count,
514        parent_class,
515        parent_mode,
516        parent_id,
517    })
518}
519
520/// Info record (e_save_map.bb lines 243-251):
521///   WriteInt(id), WriteByte(typ),
522///   WriteFloat(x), WriteFloat(y), WriteFloat(z),
523///   WriteFloat(pitch), WriteFloat(yaw),
524///   WriteString(vars)
525fn parse_info(c: &mut Cursor<&[u8]>) -> S2Result<S2Info> {
526    let id = read_u32_le(c)?;
527    let typ = read_u8(c)?;
528    let x = read_f32_le(c)?;
529    let y = read_f32_le(c)?;
530    let z = read_f32_le(c)?;
531    let pitch = read_f32_le(c)?;
532    let yaw = read_f32_le(c)?;
533    let vars = read_s2_string(c)?;
534    Ok(S2Info {
535        id,
536        typ,
537        x,
538        y,
539        z,
540        pitch,
541        yaw,
542        vars,
543    })
544}
545
546/// State record (e_save_map.bb lines 260-272):
547///   WriteByte(typ), WriteByte(parent_class), WriteInt(parent_id),
548///   WriteFloat(x), WriteFloat(y), WriteFloat(z),
549///   WriteFloat(fx), WriteFloat(fy), WriteFloat(fz),
550///   WriteInt(value), WriteFloat(value_f), WriteString(value_s)
551fn parse_state(c: &mut Cursor<&[u8]>) -> S2Result<S2State> {
552    let typ = read_u8(c)? as u32;
553    let parent_class = read_u8(c)? as u32;
554    let parent_id = read_u32_le(c)?;
555    let x = read_f32_le(c)?;
556    let y = read_f32_le(c)?;
557    let z = read_f32_le(c)?;
558    let fx = read_f32_le(c)?;
559    let fy = read_f32_le(c)?;
560    let fz = read_f32_le(c)?;
561    let value = read_u32_le(c)?;
562    let value_f = read_f32_le(c)?;
563    let value_s = read_s2_string(c)?;
564    Ok(S2State {
565        typ,
566        parent_class,
567        parent_id,
568        x,
569        y,
570        z,
571        fx,
572        fy,
573        fz,
574        value,
575        value_f,
576        value_s,
577    })
578}
579
580/// Extension record (e_save_map.bb lines 314-327):
581///   WriteByte(typ), WriteByte(parent_class), WriteInt(parent_id),
582///   WriteInt(mode),
583///   If mode=0: WriteString("") Else WriteString(key),
584///   WriteString(value), WriteString(stuff)
585fn parse_extension(c: &mut Cursor<&[u8]>) -> S2Result<S2Extension> {
586    let typ = read_u8(c)?;
587    let parent_class = read_u8(c)?;
588    let parent_id = read_u32_le(c)?;
589    let mode = read_u32_le(c)?;
590    let key = if mode == 0 {
591        // Script keys not saved (mode=0)
592        let _empty = read_s2_string(c)?;
593        String::new()
594    } else {
595        read_s2_string(c)?
596    };
597    let value = read_s2_string(c)?;
598    let stuff = read_s2_string(c)?;
599    Ok(S2Extension {
600        typ,
601        parent_class,
602        parent_id,
603        mode,
604        key,
605        value,
606        stuff,
607    })
608}
609
610// ---------------------------------------------------------------------------
611// Utility
612// ---------------------------------------------------------------------------
613
614/// Load a `.s2` file from disk and parse it.
615pub fn parse_s2_file(path: impl AsRef<std::path::Path>) -> S2Result<S2Map> {
616    let data = std::fs::read(path.as_ref()).map_err(S2Error::Io)?;
617    parse_s2(&data)
618}
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn test_read_s2_string_empty() {
626        let data: &[u8] = &[0, 0, 0, 0];
627        let mut c = Cursor::new(data);
628        let s = read_s2_string(&mut c).unwrap();
629        assert_eq!(s, "");
630        assert_eq!(c.position(), 4);
631    }
632
633    #[test]
634    fn test_read_s2_string_hello() {
635        let data: &[u8] = &[5, 0, 0, 0, b'H', b'e', b'l', b'l', b'o'];
636        let mut c = Cursor::new(data);
637        let s = read_s2_string(&mut c).unwrap();
638        assert_eq!(s, "Hello");
639    }
640
641    #[test]
642    fn test_parse_quickslots() {
643        let mut data = Vec::new();
644        for _ in 0..10 {
645            data.extend_from_slice(&[1, 0, 0, 0, b'0']);
646        }
647        let mut c = Cursor::new(data.as_slice());
648        let slots = parse_quickslots(&mut c).unwrap();
649        assert_eq!(slots.len(), 10);
650        assert!(slots.iter().all(|s| s == "0"));
651    }
652
653    #[test]
654    fn test_decode_password_empty() {
655        let pw = decode_password("", 0);
656        assert_eq!(pw, "");
657    }
658
659    #[test]
660    fn test_decode_password_xor() {
661        // "123":
662        //   '1' (0x31) ^ 50 = 0x31 ^ 0x32 = 0x03
663        //   '2' (0x32) ^ 50 = 0x32 ^ 0x32 = 0x00
664        //   '3' (0x33) ^ 50 = 0x33 ^ 0x32 = 0x01
665        let encoded = "\x03\x00\x01";
666        let pw = decode_password(encoded, 50);
667        assert_eq!(pw, "123");
668    }
669
670    /// Build a minimal valid .s2 file for testing.
671    /// 16×16 terrain, black colormap, no entities, no password, lasttime extension.
672    fn build_minimal_s2() -> Vec<u8> {
673        let mut buf = Vec::new();
674
675        // 1. Header (variable-sized, terminated by "###\r\n")
676        // NOTE: © is single byte \xa9 (Windows-1252), not UTF-8 \xc2\xa9
677        buf.extend_from_slice(b"### Stranded II Mapfile \xa9by Unreal Software 2004-2007\r\n");
678        buf.extend_from_slice(b"1.0.0.1\r\n");
679        buf.extend_from_slice(b"10 Jul 2026\r\n");
680        buf.extend_from_slice(b"18:10:56\r\n");
681        buf.extend_from_slice(b"default\r\n");
682        buf.extend_from_slice(b"map\r\n");
683        buf.extend_from_slice(b"\r\n");
684        buf.extend_from_slice(b"\r\n");
685        buf.extend_from_slice(b"\r\n");
686        buf.extend_from_slice(b"\r\n");
687        buf.extend_from_slice(b"\r\n");
688        buf.extend_from_slice(b"###\r\n");
689
690        // 2. Minimap (20736 bytes of zeros)
691        buf.extend(std::iter::repeat(0u8).take(20736));
692
693        // 3. Password header: key=0, empty password → WriteByte(0) + WriteLine("")
694        buf.push(0); // pwkey
695        buf.extend_from_slice(b"\r\n"); // WriteLine("")
696
697        // 4. Env vars
698        buf.extend_from_slice(&1u32.to_le_bytes()); // day
699        buf.push(9); // hour
700        buf.push(0); // minute
701        buf.push(3); // freezetime
702        buf.extend_from_slice(&3u32.to_le_bytes()); // skybox "sky"
703        buf.extend_from_slice(b"sky");
704        buf.push(0); // multiplayer
705        buf.push(0); // climate
706        let music = b"amb_jungle.mp3";
707        buf.extend_from_slice(&(music.len() as u32).to_le_bytes());
708        buf.extend_from_slice(music);
709        buf.extend_from_slice(&0u32.to_le_bytes()); // briefing (empty)
710        buf.extend_from_slice(&[205, 210, 245, 0]); // fog
711        buf.push(0); // extra
712
713        // 5. Quickslots
714        for _ in 0..10 {
715            buf.extend_from_slice(&[1, 0, 0, 0, b'0']);
716        }
717
718        // 6. Colormap: dim=128, all zeros
719        buf.extend_from_slice(&128u32.to_le_bytes());
720        buf.extend(std::iter::repeat(0u8).take(128 * 128 * 3));
721
722        // 7. Heightmap: size=16, 289 f32 values (0.447)
723        buf.extend_from_slice(&16u32.to_le_bytes());
724        for _ in 0..289 {
725            buf.extend_from_slice(&0.447_f32.to_le_bytes());
726        }
727
728        // 8. Grass: (128+1)*(128+1) = 16641 bytes of zeros
729        buf.extend(std::iter::repeat(0u8).take(16641));
730
731        // 9. Footer: 0 entities, 1 extension (lasttime)
732        buf.extend_from_slice(&0u32.to_le_bytes()); // objects
733        buf.extend_from_slice(&0u32.to_le_bytes()); // units
734        buf.extend_from_slice(&0u32.to_le_bytes()); // items
735        buf.extend_from_slice(&0u32.to_le_bytes()); // infos
736        buf.extend_from_slice(&0u32.to_le_bytes()); // states
737        buf.extend_from_slice(&1u32.to_le_bytes()); // extensions
738
739        // Extension: lasttime (mode=6)
740        buf.push(0); // typ
741        buf.push(0); // parent_class
742        buf.extend_from_slice(&0u32.to_le_bytes()); // parent_id
743        buf.extend_from_slice(&6u32.to_le_bytes()); // mode
744        // mode=6, so key is saved
745        buf.extend_from_slice(&8u32.to_le_bytes()); // key len
746        buf.extend_from_slice(b"lasttime");
747        buf.extend_from_slice(&0u32.to_le_bytes()); // value len (empty)
748        buf.extend_from_slice(&5u32.to_le_bytes()); // stuff len
749        buf.extend_from_slice(b"1,9,2"); // stuff
750
751        // Trailer
752        buf.extend_from_slice(b"\r\n");
753        buf.extend_from_slice(b"### EOF Map File\r\n");
754        buf.extend_from_slice(b"www.unrealsoftware.de\r\n");
755
756        buf
757    }
758
759    #[test]
760    fn test_parse_minimal_s2() {
761        let data = build_minimal_s2();
762        let result = parse_s2(&data);
763        assert!(result.is_ok(), "parse failed: {:?}", result.err());
764        let map = result.unwrap();
765        assert_eq!(map.header.version, "1.0.0.1");
766        assert_eq!(map.terrain_size, 16);
767        assert!(map.objects.is_empty());
768        assert!(map.units.is_empty());
769        assert!(map.items.is_empty());
770        assert!(map.infos.is_empty());
771        assert!(map.states.is_empty());
772        assert_eq!(map.extensions.len(), 1);
773        assert_eq!(map.extensions[0].key, "lasttime");
774    }
775
776    #[test]
777    fn test_parse_minimal_s2_with_password() {
778        let mut buf = Vec::new();
779
780        // Same header
781        buf.extend_from_slice(b"### Stranded II Mapfile \xa9by Unreal Software 2004-2007\r\n");
782        buf.extend_from_slice(b"1.0.0.1\r\n");
783        buf.extend_from_slice(b"10 Jul 2026\r\n");
784        buf.extend_from_slice(b"18:10:56\r\n");
785        buf.extend_from_slice(b"default\r\n");
786        buf.extend_from_slice(b"map\r\n");
787        buf.extend_from_slice(b"\r\n");
788        buf.extend_from_slice(b"\r\n");
789        buf.extend_from_slice(b"\r\n");
790        buf.extend_from_slice(b"\r\n");
791        buf.extend_from_slice(b"\r\n");
792        buf.extend_from_slice(b"###\r\n");
793
794        // Minimap
795        buf.extend(std::iter::repeat(0u8).take(20736));
796
797        // Password: key=50, "123" XOR 50:
798        //   '1'(0x31)^50 = 0x03, '2'(0x32)^50 = 0x00, '3'(0x33)^50 = 0x01
799        buf.push(50); // key
800        buf.extend_from_slice(b"\x03\x00\x01\r\n"); // WriteLine with encoded pw + \r\n
801
802        // Rest same as minimal
803        buf.extend_from_slice(&1u32.to_le_bytes()); // day
804        buf.push(9);
805        buf.push(0);
806        buf.push(3);
807        buf.extend_from_slice(&3u32.to_le_bytes());
808        buf.extend_from_slice(b"sky");
809        buf.push(0);
810        buf.push(0);
811        let music = b"amb_jungle.mp3";
812        buf.extend_from_slice(&(music.len() as u32).to_le_bytes());
813        buf.extend_from_slice(music);
814        buf.extend_from_slice(&0u32.to_le_bytes());
815        buf.extend_from_slice(&[205, 210, 245, 0]);
816        buf.push(0);
817        for _ in 0..10 {
818            buf.extend_from_slice(&[1, 0, 0, 0, b'0']);
819        }
820        buf.extend_from_slice(&128u32.to_le_bytes());
821        buf.extend(std::iter::repeat(0u8).take(128 * 128 * 3));
822        buf.extend_from_slice(&16u32.to_le_bytes());
823        for _ in 0..289 {
824            buf.extend_from_slice(&0.447_f32.to_le_bytes());
825        }
826        buf.extend(std::iter::repeat(0u8).take(16641));
827        buf.extend_from_slice(&0u32.to_le_bytes());
828        buf.extend_from_slice(&0u32.to_le_bytes());
829        buf.extend_from_slice(&0u32.to_le_bytes());
830        buf.extend_from_slice(&0u32.to_le_bytes());
831        buf.extend_from_slice(&0u32.to_le_bytes());
832        buf.extend_from_slice(&1u32.to_le_bytes());
833        buf.push(0);
834        buf.push(0);
835        buf.extend_from_slice(&0u32.to_le_bytes());
836        buf.extend_from_slice(&6u32.to_le_bytes());
837        buf.extend_from_slice(&8u32.to_le_bytes());
838        buf.extend_from_slice(b"lasttime");
839        buf.extend_from_slice(&0u32.to_le_bytes());
840        buf.extend_from_slice(&5u32.to_le_bytes());
841        buf.extend_from_slice(b"1,9,2");
842        buf.extend_from_slice(b"\r\n");
843        buf.extend_from_slice(b"### EOF Map File\r\n");
844        buf.extend_from_slice(b"www.unrealsoftware.de\r\n");
845
846        let result = parse_s2(&buf);
847        assert!(result.is_ok(), "parse failed: {:?}", result.err());
848        let map = result.unwrap();
849        assert_eq!(map.password.key, 50);
850        assert_eq!(map.password.decoded, "123");
851    }
852}