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    // Type-format from header is authoritative. The save function wrote the
176    // format flags based on entity counts at save time; reading them back must
177    // use the same flags regardless of whether counts changed.
178    let tf = TypeFormat::from_string(&header.type_format);
179    let (objects, units, items, infos, states, extensions, trailer) = parse_footer(&mut c, tf)?;
180
181    // Detect password from encoded form
182    let decoded_pw = decode_password(&encoded_pw, pw_key);
183
184    Ok(S2Map {
185        header,
186        minimap,
187        password: S2Password {
188            key: pw_key,
189            encoded: encoded_pw,
190            decoded: decoded_pw,
191        },
192        env_vars,
193        quickslots,
194        colormap_dim,
195        colormap,
196        terrain_size,
197        heights,
198        grass,
199        objects,
200        units,
201        items,
202        infos,
203        states,
204        extensions,
205        trailer,
206    })
207}
208
209/// Simple XOR-based password decoder from the Blitz3D source's `code()` function.
210fn decode_password(encoded: &str, key: u8) -> String {
211    let bytes = encoded.as_bytes();
212    let decoded: Vec<u8> = bytes.iter().map(|&b| b ^ key).collect();
213    String::from_utf8_lossy(&decoded).into_owned()
214}
215
216// ---------------------------------------------------------------------------
217// Header
218// ---------------------------------------------------------------------------
219
220fn parse_header(c: &mut Cursor<&[u8]>) -> S2Result<S2Header> {
221    let mut lines: Vec<String> = Vec::new();
222    loop {
223        let line = read_s2_line(c)?;
224        if line == "###" {
225            break;
226        }
227        lines.push(line);
228    }
229
230    if lines.len() < 11 {
231        return Err(S2Error::InvalidHeader(format!(
232            "expected 11+ header lines before '###', got {}",
233            lines.len()
234        )));
235    }
236
237    // Line 0: "### Stranded II Mapfile ..."
238    if !lines[0].starts_with("### Stranded II Mapfile") {
239        return Err(S2Error::InvalidHeader(format!(
240            "not a Stranded II map file: {:?}",
241            lines[0]
242        )));
243    }
244
245    // Lines 1-5 (indices 1-5)
246    let version = lines.get(1).cloned().unwrap_or_default();
247    let date = lines.get(2).cloned().unwrap_or_default();
248    let time = lines.get(3).cloned().unwrap_or_default();
249    let author = lines.get(4).cloned().unwrap_or_default();
250    let map_type = lines.get(5).cloned().unwrap_or_default();
251
252    // Line 6 (index 6): type format string (e.g. "001", "100") or empty for all u8
253    let type_format = lines.get(6).cloned().unwrap_or_default();
254
255    // Lines 7-10: blank lines, ignored
256
257    Ok(S2Header {
258        version,
259        date,
260        time,
261        author,
262        map_type,
263        type_format,
264    })
265}
266
267// ---------------------------------------------------------------------------
268// Environment variables
269// ---------------------------------------------------------------------------
270// Source order (e_save_map.bb lines 70-84):
271//   WriteInt(day) → 4
272//   WriteByte(hour) → 1
273//   WriteByte(minute) → 1
274//   WriteByte(freezetime) → 1
275//   WriteString(skybox) → 4 + len
276//   WriteByte(multiplayer) → 1
277//   WriteByte(climate) → 1
278//   WriteString(music) → 4 + len
279//   WriteString(briefing) → 4 + len
280//   WriteByte(fog_r) → 1
281//   WriteByte(fog_g) → 1
282//   WriteByte(fog_b) → 1
283//   WriteByte(fog_mode) → 1
284//   WriteByte(extra) → 1
285
286fn parse_env_vars(c: &mut Cursor<&[u8]>) -> S2Result<S2EnvVars> {
287    let day = read_u32_le(c)?;
288    let hour = read_u8(c)?;
289    let minute = read_u8(c)?;
290    let freezetime = read_u8(c)?;
291    let skybox = read_s2_string(c)?;
292    let multiplayer = read_u8(c)?;
293    let climate = read_u8(c)?;
294    let music = read_s2_string(c)?;
295    let briefing = read_s2_string(c)?;
296    let f_r = read_u8(c)?;
297    let f_g = read_u8(c)?;
298    let f_b = read_u8(c)?;
299    let f_mode = read_u8(c)?;
300    let extra = read_u8(c)?;
301
302    Ok(S2EnvVars {
303        day,
304        hour,
305        minute,
306        freezetime,
307        skybox,
308        multiplayer,
309        climate,
310        music,
311        briefing,
312        fog: [f_r, f_g, f_b, f_mode],
313        extra,
314    })
315}
316
317// ---------------------------------------------------------------------------
318// Quickslots
319// ---------------------------------------------------------------------------
320
321fn parse_quickslots(c: &mut Cursor<&[u8]>) -> S2Result<Vec<String>> {
322    let mut slots = Vec::with_capacity(10);
323    for _ in 0..10 {
324        slots.push(read_s2_string(c)?);
325    }
326    Ok(slots)
327}
328
329// ---------------------------------------------------------------------------
330// Type format flags
331// ---------------------------------------------------------------------------
332
333/// Type-format flags parsed from the header's type_format string.
334/// "001" → tf_item=true, rest false. Empty → all false.
335#[derive(Debug, Clone, Copy)]
336struct TypeFormat {
337    tf_object: bool,
338    tf_unit: bool,
339    tf_item: bool,
340}
341
342impl TypeFormat {
343    /// Parse from the 3-character type_format header line (or empty).
344    /// Characters are '0' (u8) or '1' (u16) for object/unit/item.
345    fn from_string(s: &str) -> Self {
346        let chars: Vec<char> = s.chars().collect();
347        TypeFormat {
348            tf_object: chars.first().copied() == Some('1'),
349            tf_unit: chars.get(1).copied() == Some('1'),
350            tf_item: chars.get(2).copied() == Some('1'),
351        }
352    }
353
354}
355
356// ---------------------------------------------------------------------------
357// Footer
358// ---------------------------------------------------------------------------
359//
360// IMPORTANT: The counts and their data sections are INTERLEAVED, not batched.
361//
362//   WriteInt(object_count)     → count
363//   For each object: record    → data
364//   WriteInt(unit_count)       → count
365//   For each unit: record      → data
366//   WriteInt(item_count)       → count
367//   For each item: record      → data
368//   WriteInt(info_count)       → count
369//   For each info: record      → data
370//   WriteInt(state_count)      → count
371//   For each state: record     → data
372//   WriteInt(ext_count)        → count
373//   For each ext: record       → data
374//   Trailer                    → 3 lines
375//
376// (Confirmed from e_save_map.bb lines 131-373)
377
378fn parse_footer(
379    c: &mut Cursor<&[u8]>,
380    tf: TypeFormat,
381) -> S2Result<(
382    Vec<S2Object>,
383    Vec<S2Unit>,
384    Vec<S2Item>,
385    Vec<S2Info>,
386    Vec<S2State>,
387    Vec<S2Extension>,
388    S2Trailer,
389)> {
390    // --- Objects: count then data ---
391    let obj_count = read_u32_le(c)?;
392    let mut objects = Vec::with_capacity(obj_count as usize);
393    for _ in 0..obj_count {
394        objects.push(parse_object(c, tf.tf_object)?);
395    }
396
397    // --- Units: count then data ---
398    let unit_count = read_u32_le(c)?;
399    let mut units = Vec::with_capacity(unit_count as usize);
400    for _ in 0..unit_count {
401        units.push(parse_unit(c, tf.tf_unit)?);
402    }
403
404    // --- Items: count then data ---
405    let item_count = read_u32_le(c)?;
406    let mut items = Vec::with_capacity(item_count as usize);
407    for _ in 0..item_count {
408        items.push(parse_item(c, tf.tf_item)?);
409    }
410
411    // --- Infos: count then data ---
412    let info_count = read_u32_le(c)?;
413    let mut infos = Vec::with_capacity(info_count as usize);
414    for _ in 0..info_count {
415        infos.push(parse_info(c)?);
416    }
417
418    // --- States: count then data ---
419    let state_count = read_u32_le(c)?;
420    let mut states = Vec::with_capacity(state_count as usize);
421    for _ in 0..state_count {
422        states.push(parse_state(c)?);
423    }
424
425    // --- Extensions: count then data ---
426    let ext_count = read_u32_le(c)?;
427    let mut extensions = Vec::with_capacity(ext_count as usize);
428    for _ in 0..ext_count {
429        extensions.push(parse_extension(c)?);
430    }
431
432    // --- Trailer ---
433    let blank = read_s2_line(c)?;
434    let eof_marker = read_s2_line(c)?;
435    let url = read_s2_line(c)?;
436
437    if !eof_marker.is_empty() && eof_marker != "### EOF Map File" {
438        return Err(S2Error::InvalidTrailer(format!(
439            "expected '### EOF Map File', got {:?}",
440            eof_marker
441        )));
442    }
443    if !url.is_empty() && url != "www.unrealsoftware.de" {
444        return Err(S2Error::InvalidTrailer(format!(
445            "expected 'www.unrealsoftware.de', got {:?}",
446            url
447        )));
448    }
449
450    let trailer = S2Trailer {
451        blank,
452        eof_marker,
453        url,
454    };
455
456    Ok((objects, units, items, infos, states, extensions, trailer))
457}
458
459// ---------------------------------------------------------------------------
460// Entity parsers (all from e_save_map.bb)
461// ---------------------------------------------------------------------------
462
463/// Object record (e_save_map.bb lines 137-148):
464///   WriteInt(id), If tf: WriteShort(typ)/WriteByte(typ),
465///   WriteFloat(x), WriteFloat(z), WriteFloat(yaw),
466///   WriteFloat(health), WriteFloat(health_max), WriteInt(daytimer)
467fn parse_object(c: &mut Cursor<&[u8]>, tf: bool) -> S2Result<S2Object> {
468    let id = read_u32_le(c)?;
469    let typ = if tf {
470        read_u16_le(c)? as u32
471    } else {
472        read_u8(c)? as u32
473    };
474    let x = read_f32_le(c)?;
475    let z = read_f32_le(c)?;
476    let yaw = read_f32_le(c)?;
477    let health = read_f32_le(c)?;
478    let health_max = read_f32_le(c)?;
479    let day_timer = read_u32_le(c)?;
480    Ok(S2Object {
481        id,
482        typ,
483        x,
484        z,
485        yaw,
486        health,
487        health_max,
488        day_timer,
489    })
490}
491
492/// Unit record (e_save_map.bb lines 166-182):
493///   WriteInt(id), If tf: WriteShort(typ)/WriteByte(typ),
494///   WriteFloat(x), WriteFloat(y), WriteFloat(z), WriteFloat(yaw),
495///   WriteFloat(health), WriteFloat(health_max),
496///   WriteFloat(hunger), WriteFloat(thirst), WriteFloat(exhaustion),
497///   WriteFloat(ai_cx), WriteFloat(ai_cz)
498fn parse_unit(c: &mut Cursor<&[u8]>, tf: bool) -> S2Result<S2Unit> {
499    let id = read_u32_le(c)?;
500    let typ = if tf {
501        read_u16_le(c)? as u32
502    } else {
503        read_u8(c)? as u32
504    };
505    let x = read_f32_le(c)?;
506    let y = read_f32_le(c)?;
507    let z = read_f32_le(c)?;
508    let yaw = read_f32_le(c)?;
509    let health = read_f32_le(c)?;
510    let health_max = read_f32_le(c)?;
511    let hunger = read_f32_le(c)?;
512    let thirst = read_f32_le(c)?;
513    let exhaustion = read_f32_le(c)?;
514    let ai_center_x = read_f32_le(c)?;
515    let ai_center_z = read_f32_le(c)?;
516
517    Ok(S2Unit {
518        id,
519        typ,
520        x,
521        y,
522        z,
523        yaw,
524        health,
525        health_max,
526        hunger,
527        thirst,
528        exhaustion,
529        ai_center_x,
530        ai_center_z,
531    })
532}
533
534/// Item record (e_save_map.bb lines 192-207):
535///   WriteInt(id), If tf: WriteShort(typ)/WriteByte(typ),
536///   WriteFloat(x), WriteFloat(y), WriteFloat(z), WriteFloat(yaw),
537///   WriteFloat(health), WriteInt(count),
538///   WriteByte(parent_class), WriteByte(parent_mode), WriteInt(parent_id)
539fn parse_item(c: &mut Cursor<&[u8]>, tf: bool) -> S2Result<S2Item> {
540    let id = read_u32_le(c)?;
541    let typ = if tf {
542        read_u16_le(c)? as u32
543    } else {
544        read_u8(c)? as u32
545    };
546    let x = read_f32_le(c)?;
547    let y = read_f32_le(c)?;
548    let z = read_f32_le(c)?;
549    let yaw = read_f32_le(c)?;
550    let health = read_f32_le(c)?;
551    let count = read_u32_le(c)?;
552    let parent_class = read_u8(c)?;
553    let parent_mode = read_u8(c)?;
554    let parent_id = read_u32_le(c)?;
555    Ok(S2Item {
556        id,
557        typ,
558        x,
559        y,
560        z,
561        yaw,
562        health,
563        count,
564        parent_class,
565        parent_mode,
566        parent_id,
567    })
568}
569
570/// Info record (e_save_map.bb lines 243-251):
571///   WriteInt(id), WriteByte(typ),
572///   WriteFloat(x), WriteFloat(y), WriteFloat(z),
573///   WriteFloat(pitch), WriteFloat(yaw),
574///   WriteString(vars)
575fn parse_info(c: &mut Cursor<&[u8]>) -> S2Result<S2Info> {
576    let id = read_u32_le(c)?;
577    let typ = read_u8(c)?;
578    let x = read_f32_le(c)?;
579    let y = read_f32_le(c)?;
580    let z = read_f32_le(c)?;
581    let pitch = read_f32_le(c)?;
582    let yaw = read_f32_le(c)?;
583    let vars = read_s2_string(c)?;
584    Ok(S2Info {
585        id,
586        typ,
587        x,
588        y,
589        z,
590        pitch,
591        yaw,
592        vars,
593    })
594}
595
596/// State record (e_save_map.bb lines 260-272):
597///   WriteByte(typ), WriteByte(parent_class), WriteInt(parent_id),
598///   WriteFloat(x), WriteFloat(y), WriteFloat(z),
599///   WriteFloat(fx), WriteFloat(fy), WriteFloat(fz),
600///   WriteInt(value), WriteFloat(value_f), WriteString(value_s)
601fn parse_state(c: &mut Cursor<&[u8]>) -> S2Result<S2State> {
602    let typ = read_u8(c)? as u32;
603    let parent_class = read_u8(c)? as u32;
604    let parent_id = read_u32_le(c)?;
605    let x = read_f32_le(c)?;
606    let y = read_f32_le(c)?;
607    let z = read_f32_le(c)?;
608    let fx = read_f32_le(c)?;
609    let fy = read_f32_le(c)?;
610    let fz = read_f32_le(c)?;
611    let value = read_u32_le(c)?;
612    let value_f = read_f32_le(c)?;
613    let value_s = read_s2_string(c)?;
614    Ok(S2State {
615        typ,
616        parent_class,
617        parent_id,
618        x,
619        y,
620        z,
621        fx,
622        fy,
623        fz,
624        value,
625        value_f,
626        value_s,
627    })
628}
629
630/// Extension record (e_save_map.bb lines 314-327):
631///   WriteByte(typ), WriteByte(parent_class), WriteInt(parent_id),
632///   WriteInt(mode),
633///   If mode=0: WriteString("") Else WriteString(key),
634///   WriteString(value), WriteString(stuff)
635fn parse_extension(c: &mut Cursor<&[u8]>) -> S2Result<S2Extension> {
636    let typ = read_u8(c)?;
637    let parent_class = read_u8(c)?;
638    let parent_id = read_u32_le(c)?;
639    let mode = read_u32_le(c)?;
640    let key = if mode == 0 {
641        // Script keys not saved (mode=0)
642        let _empty = read_s2_string(c)?;
643        String::new()
644    } else {
645        read_s2_string(c)?
646    };
647    let value = read_s2_string(c)?;
648    let stuff = read_s2_string(c)?;
649    Ok(S2Extension {
650        typ,
651        parent_class,
652        parent_id,
653        mode,
654        key,
655        value,
656        stuff,
657    })
658}
659
660// ---------------------------------------------------------------------------
661// Utility
662// ---------------------------------------------------------------------------
663
664/// Load a `.s2` file from disk and parse it.
665pub fn parse_s2_file(path: impl AsRef<std::path::Path>) -> S2Result<S2Map> {
666    let data = std::fs::read(path.as_ref()).map_err(S2Error::Io)?;
667    parse_s2(&data)
668}
669
670#[cfg(test)]
671mod tests {
672    use super::*;
673
674    #[test]
675    fn test_read_s2_string_empty() {
676        let data: &[u8] = &[0, 0, 0, 0];
677        let mut c = Cursor::new(data);
678        let s = read_s2_string(&mut c).unwrap();
679        assert_eq!(s, "");
680        assert_eq!(c.position(), 4);
681    }
682
683    #[test]
684    fn test_read_s2_string_hello() {
685        let data: &[u8] = &[5, 0, 0, 0, b'H', b'e', b'l', b'l', b'o'];
686        let mut c = Cursor::new(data);
687        let s = read_s2_string(&mut c).unwrap();
688        assert_eq!(s, "Hello");
689    }
690
691    #[test]
692    fn test_parse_quickslots() {
693        let mut data = Vec::new();
694        for _ in 0..10 {
695            data.extend_from_slice(&[1, 0, 0, 0, b'0']);
696        }
697        let mut c = Cursor::new(data.as_slice());
698        let slots = parse_quickslots(&mut c).unwrap();
699        assert_eq!(slots.len(), 10);
700        assert!(slots.iter().all(|s| s == "0"));
701    }
702
703    #[test]
704    fn test_decode_password_empty() {
705        let pw = decode_password("", 0);
706        assert_eq!(pw, "");
707    }
708
709    #[test]
710    fn test_decode_password_xor() {
711        // "123":
712        //   '1' (0x31) ^ 50 = 0x31 ^ 0x32 = 0x03
713        //   '2' (0x32) ^ 50 = 0x32 ^ 0x32 = 0x00
714        //   '3' (0x33) ^ 50 = 0x33 ^ 0x32 = 0x01
715        let encoded = "\x03\x00\x01";
716        let pw = decode_password(encoded, 50);
717        assert_eq!(pw, "123");
718    }
719
720    /// Build a minimal valid .s2 file for testing.
721    /// 16×16 terrain, black colormap, no entities, no password, lasttime extension.
722    fn build_minimal_s2() -> Vec<u8> {
723        let mut buf = Vec::new();
724
725        // 1. Header (variable-sized, terminated by "###\r\n")
726        // NOTE: © is single byte \xa9 (Windows-1252), not UTF-8 \xc2\xa9
727        buf.extend_from_slice(b"### Stranded II Mapfile \xa9by Unreal Software 2004-2007\r\n");
728        buf.extend_from_slice(b"1.0.0.1\r\n");
729        buf.extend_from_slice(b"10 Jul 2026\r\n");
730        buf.extend_from_slice(b"18:10:56\r\n");
731        buf.extend_from_slice(b"default\r\n");
732        buf.extend_from_slice(b"map\r\n");
733        buf.extend_from_slice(b"\r\n");
734        buf.extend_from_slice(b"\r\n");
735        buf.extend_from_slice(b"\r\n");
736        buf.extend_from_slice(b"\r\n");
737        buf.extend_from_slice(b"\r\n");
738        buf.extend_from_slice(b"###\r\n");
739
740        // 2. Minimap (20736 bytes of zeros)
741        buf.extend(std::iter::repeat(0u8).take(20736));
742
743        // 3. Password header: key=0, empty password → WriteByte(0) + WriteLine("")
744        buf.push(0); // pwkey
745        buf.extend_from_slice(b"\r\n"); // WriteLine("")
746
747        // 4. Env vars
748        buf.extend_from_slice(&1u32.to_le_bytes()); // day
749        buf.push(9); // hour
750        buf.push(0); // minute
751        buf.push(3); // freezetime
752        buf.extend_from_slice(&3u32.to_le_bytes()); // skybox "sky"
753        buf.extend_from_slice(b"sky");
754        buf.push(0); // multiplayer
755        buf.push(0); // climate
756        let music = b"amb_jungle.mp3";
757        buf.extend_from_slice(&(music.len() as u32).to_le_bytes());
758        buf.extend_from_slice(music);
759        buf.extend_from_slice(&0u32.to_le_bytes()); // briefing (empty)
760        buf.extend_from_slice(&[205, 210, 245, 0]); // fog
761        buf.push(0); // extra
762
763        // 5. Quickslots
764        for _ in 0..10 {
765            buf.extend_from_slice(&[1, 0, 0, 0, b'0']);
766        }
767
768        // 6. Colormap: dim=128, all zeros
769        buf.extend_from_slice(&128u32.to_le_bytes());
770        buf.extend(std::iter::repeat(0u8).take(128 * 128 * 3));
771
772        // 7. Heightmap: size=16, 289 f32 values (0.447)
773        buf.extend_from_slice(&16u32.to_le_bytes());
774        for _ in 0..289 {
775            buf.extend_from_slice(&0.447_f32.to_le_bytes());
776        }
777
778        // 8. Grass: (128+1)*(128+1) = 16641 bytes of zeros
779        buf.extend(std::iter::repeat(0u8).take(16641));
780
781        // 9. Footer: 0 entities, 1 extension (lasttime)
782        buf.extend_from_slice(&0u32.to_le_bytes()); // objects
783        buf.extend_from_slice(&0u32.to_le_bytes()); // units
784        buf.extend_from_slice(&0u32.to_le_bytes()); // items
785        buf.extend_from_slice(&0u32.to_le_bytes()); // infos
786        buf.extend_from_slice(&0u32.to_le_bytes()); // states
787        buf.extend_from_slice(&1u32.to_le_bytes()); // extensions
788
789        // Extension: lasttime (mode=6)
790        buf.push(0); // typ
791        buf.push(0); // parent_class
792        buf.extend_from_slice(&0u32.to_le_bytes()); // parent_id
793        buf.extend_from_slice(&6u32.to_le_bytes()); // mode
794        // mode=6, so key is saved
795        buf.extend_from_slice(&8u32.to_le_bytes()); // key len
796        buf.extend_from_slice(b"lasttime");
797        buf.extend_from_slice(&0u32.to_le_bytes()); // value len (empty)
798        buf.extend_from_slice(&5u32.to_le_bytes()); // stuff len
799        buf.extend_from_slice(b"1,9,2"); // stuff
800
801        // Trailer
802        buf.extend_from_slice(b"\r\n");
803        buf.extend_from_slice(b"### EOF Map File\r\n");
804        buf.extend_from_slice(b"www.unrealsoftware.de\r\n");
805
806        buf
807    }
808
809    #[test]
810    fn test_parse_minimal_s2() {
811        let data = build_minimal_s2();
812        let result = parse_s2(&data);
813        assert!(result.is_ok(), "parse failed: {:?}", result.err());
814        let map = result.unwrap();
815        assert_eq!(map.header.version, "1.0.0.1");
816        assert_eq!(map.terrain_size, 16);
817        assert!(map.objects.is_empty());
818        assert!(map.units.is_empty());
819        assert!(map.items.is_empty());
820        assert!(map.infos.is_empty());
821        assert!(map.states.is_empty());
822        assert_eq!(map.extensions.len(), 1);
823        assert_eq!(map.extensions[0].key, "lasttime");
824    }
825
826    #[test]
827    fn test_parse_minimal_s2_with_password() {
828        let mut buf = Vec::new();
829
830        // Same header
831        buf.extend_from_slice(b"### Stranded II Mapfile \xa9by Unreal Software 2004-2007\r\n");
832        buf.extend_from_slice(b"1.0.0.1\r\n");
833        buf.extend_from_slice(b"10 Jul 2026\r\n");
834        buf.extend_from_slice(b"18:10:56\r\n");
835        buf.extend_from_slice(b"default\r\n");
836        buf.extend_from_slice(b"map\r\n");
837        buf.extend_from_slice(b"\r\n");
838        buf.extend_from_slice(b"\r\n");
839        buf.extend_from_slice(b"\r\n");
840        buf.extend_from_slice(b"\r\n");
841        buf.extend_from_slice(b"\r\n");
842        buf.extend_from_slice(b"###\r\n");
843
844        // Minimap
845        buf.extend(std::iter::repeat(0u8).take(20736));
846
847        // Password: key=50, "123" XOR 50:
848        //   '1'(0x31)^50 = 0x03, '2'(0x32)^50 = 0x00, '3'(0x33)^50 = 0x01
849        buf.push(50); // key
850        buf.extend_from_slice(b"\x03\x00\x01\r\n"); // WriteLine with encoded pw + \r\n
851
852        // Rest same as minimal
853        buf.extend_from_slice(&1u32.to_le_bytes()); // day
854        buf.push(9);
855        buf.push(0);
856        buf.push(3);
857        buf.extend_from_slice(&3u32.to_le_bytes());
858        buf.extend_from_slice(b"sky");
859        buf.push(0);
860        buf.push(0);
861        let music = b"amb_jungle.mp3";
862        buf.extend_from_slice(&(music.len() as u32).to_le_bytes());
863        buf.extend_from_slice(music);
864        buf.extend_from_slice(&0u32.to_le_bytes());
865        buf.extend_from_slice(&[205, 210, 245, 0]);
866        buf.push(0);
867        for _ in 0..10 {
868            buf.extend_from_slice(&[1, 0, 0, 0, b'0']);
869        }
870        buf.extend_from_slice(&128u32.to_le_bytes());
871        buf.extend(std::iter::repeat(0u8).take(128 * 128 * 3));
872        buf.extend_from_slice(&16u32.to_le_bytes());
873        for _ in 0..289 {
874            buf.extend_from_slice(&0.447_f32.to_le_bytes());
875        }
876        buf.extend(std::iter::repeat(0u8).take(16641));
877        buf.extend_from_slice(&0u32.to_le_bytes());
878        buf.extend_from_slice(&0u32.to_le_bytes());
879        buf.extend_from_slice(&0u32.to_le_bytes());
880        buf.extend_from_slice(&0u32.to_le_bytes());
881        buf.extend_from_slice(&0u32.to_le_bytes());
882        buf.extend_from_slice(&1u32.to_le_bytes());
883        buf.push(0);
884        buf.push(0);
885        buf.extend_from_slice(&0u32.to_le_bytes());
886        buf.extend_from_slice(&6u32.to_le_bytes());
887        buf.extend_from_slice(&8u32.to_le_bytes());
888        buf.extend_from_slice(b"lasttime");
889        buf.extend_from_slice(&0u32.to_le_bytes());
890        buf.extend_from_slice(&5u32.to_le_bytes());
891        buf.extend_from_slice(b"1,9,2");
892        buf.extend_from_slice(b"\r\n");
893        buf.extend_from_slice(b"### EOF Map File\r\n");
894        buf.extend_from_slice(b"www.unrealsoftware.de\r\n");
895
896        let result = parse_s2(&buf);
897        assert!(result.is_ok(), "parse failed: {:?}", result.err());
898        let map = result.unwrap();
899        assert_eq!(map.password.key, 50);
900        assert_eq!(map.password.decoded, "123");
901    }
902}