use std::io::{Cursor, Read};
use crate::types::*;
#[derive(Debug)]
pub enum S2Error {
Io(std::io::Error),
InvalidHeader(String),
UnexpectedEof,
InvalidTrailer(String),
UnsupportedFormat(String),
}
impl std::fmt::Display for S2Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
S2Error::Io(e) => write!(f, "I/O error: {e}"),
S2Error::InvalidHeader(s) => write!(f, "invalid header: {s}"),
S2Error::UnexpectedEof => write!(f, "unexpected end of file"),
S2Error::InvalidTrailer(s) => write!(f, "invalid trailer: {s}"),
S2Error::UnsupportedFormat(s) => write!(f, "unsupported format: {s}"),
}
}
}
impl std::error::Error for S2Error {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
S2Error::Io(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for S2Error {
fn from(e: std::io::Error) -> Self {
S2Error::Io(e)
}
}
pub type S2Result<T> = Result<T, S2Error>;
fn read_u8(c: &mut Cursor<&[u8]>) -> S2Result<u8> {
let mut buf = [0u8; 1];
c.read_exact(&mut buf)?;
Ok(buf[0])
}
fn read_u16_le(c: &mut Cursor<&[u8]>) -> S2Result<u16> {
let mut buf = [0u8; 2];
c.read_exact(&mut buf)?;
Ok(u16::from_le_bytes(buf))
}
fn read_u32_le(c: &mut Cursor<&[u8]>) -> S2Result<u32> {
let mut buf = [0u8; 4];
c.read_exact(&mut buf)?;
Ok(u32::from_le_bytes(buf))
}
fn read_f32_le(c: &mut Cursor<&[u8]>) -> S2Result<f32> {
let mut buf = [0u8; 4];
c.read_exact(&mut buf)?;
Ok(f32::from_le_bytes(buf))
}
fn read_exact(c: &mut Cursor<&[u8]>, n: usize) -> S2Result<Vec<u8>> {
let mut buf = vec![0u8; n];
c.read_exact(&mut buf)?;
Ok(buf)
}
fn read_s2_string(c: &mut Cursor<&[u8]>) -> S2Result<String> {
let len = read_u32_le(c)? as usize;
if len == 0 {
return Ok(String::new());
}
let bytes = read_exact(c, len)?;
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
fn read_s2_line(c: &mut Cursor<&[u8]>) -> S2Result<String> {
let mut bytes = Vec::new();
loop {
let b = read_u8(c)?;
if b == b'\r' {
let next = read_u8(c)?;
if next == b'\n' {
break;
}
bytes.push(b);
bytes.push(next);
} else {
bytes.push(b);
}
}
Ok(String::from_utf8_lossy(&bytes).into_owned())
}
pub fn parse_s2(data: &[u8]) -> S2Result<S2Map> {
let mut c = Cursor::new(data);
let header = parse_header(&mut c)?;
let minimap = read_exact(&mut c, 20736)?;
let pw_key = read_u8(&mut c)?;
let encoded_pw = read_s2_line(&mut c)?;
let env_vars = parse_env_vars(&mut c)?;
let quickslots = parse_quickslots(&mut c)?;
let colormap_dim = read_u32_le(&mut c)?;
let colormap_size = (colormap_dim * colormap_dim * 3) as usize;
let colormap = read_exact(&mut c, colormap_size)?;
let terrain_size = read_u32_le(&mut c)?;
let height_count = ((terrain_size + 1) * (terrain_size + 1)) as usize;
let mut heights = Vec::with_capacity(height_count);
for _ in 0..height_count {
heights.push(read_f32_le(&mut c)?);
}
let grass_dim = colormap_dim + 1;
let grass_size = (grass_dim * grass_dim) as usize;
let grass = read_exact(&mut c, grass_size)?;
let tf = TypeFormat::from_string(&header.type_format);
let (objects, units, items, infos, states, extensions, trailer) = parse_footer(&mut c, tf)?;
let decoded_pw = decode_password(&encoded_pw, pw_key);
Ok(S2Map {
header,
minimap,
password: S2Password {
key: pw_key,
encoded: encoded_pw,
decoded: decoded_pw,
},
env_vars,
quickslots,
colormap_dim,
colormap,
terrain_size,
heights,
grass,
objects,
units,
items,
infos,
states,
extensions,
trailer,
})
}
fn decode_password(encoded: &str, key: u8) -> String {
let bytes = encoded.as_bytes();
let decoded: Vec<u8> = bytes.iter().map(|&b| b ^ key).collect();
String::from_utf8_lossy(&decoded).into_owned()
}
fn parse_header(c: &mut Cursor<&[u8]>) -> S2Result<S2Header> {
let mut lines: Vec<String> = Vec::new();
loop {
let line = read_s2_line(c)?;
if line == "###" {
break;
}
lines.push(line);
}
if lines.len() < 11 {
return Err(S2Error::InvalidHeader(format!(
"expected 11+ header lines before '###', got {}",
lines.len()
)));
}
if !lines[0].starts_with("### Stranded II Mapfile") {
return Err(S2Error::InvalidHeader(format!(
"not a Stranded II map file: {:?}",
lines[0]
)));
}
let version = lines.get(1).cloned().unwrap_or_default();
let date = lines.get(2).cloned().unwrap_or_default();
let time = lines.get(3).cloned().unwrap_or_default();
let author = lines.get(4).cloned().unwrap_or_default();
let map_type = lines.get(5).cloned().unwrap_or_default();
let type_format = lines.get(6).cloned().unwrap_or_default();
Ok(S2Header {
version,
date,
time,
author,
map_type,
type_format,
})
}
fn parse_env_vars(c: &mut Cursor<&[u8]>) -> S2Result<S2EnvVars> {
let day = read_u32_le(c)?;
let hour = read_u8(c)?;
let minute = read_u8(c)?;
let freezetime = read_u8(c)?;
let skybox = read_s2_string(c)?;
let multiplayer = read_u8(c)?;
let climate = read_u8(c)?;
let music = read_s2_string(c)?;
let briefing = read_s2_string(c)?;
let f_r = read_u8(c)?;
let f_g = read_u8(c)?;
let f_b = read_u8(c)?;
let f_mode = read_u8(c)?;
let extra = read_u8(c)?;
Ok(S2EnvVars {
day,
hour,
minute,
freezetime,
skybox,
multiplayer,
climate,
music,
briefing,
fog: [f_r, f_g, f_b, f_mode],
extra,
})
}
fn parse_quickslots(c: &mut Cursor<&[u8]>) -> S2Result<Vec<String>> {
let mut slots = Vec::with_capacity(10);
for _ in 0..10 {
slots.push(read_s2_string(c)?);
}
Ok(slots)
}
#[derive(Debug, Clone, Copy)]
struct TypeFormat {
tf_object: bool,
tf_unit: bool,
tf_item: bool,
}
impl TypeFormat {
fn from_string(s: &str) -> Self {
let chars: Vec<char> = s.chars().collect();
TypeFormat {
tf_object: chars.first().copied() == Some('1'),
tf_unit: chars.get(1).copied() == Some('1'),
tf_item: chars.get(2).copied() == Some('1'),
}
}
}
fn parse_footer(
c: &mut Cursor<&[u8]>,
tf: TypeFormat,
) -> S2Result<(
Vec<S2Object>,
Vec<S2Unit>,
Vec<S2Item>,
Vec<S2Info>,
Vec<S2State>,
Vec<S2Extension>,
S2Trailer,
)> {
let obj_count = read_u32_le(c)?;
let mut objects = Vec::with_capacity(obj_count as usize);
for _ in 0..obj_count {
objects.push(parse_object(c, tf.tf_object)?);
}
let unit_count = read_u32_le(c)?;
let mut units = Vec::with_capacity(unit_count as usize);
for _ in 0..unit_count {
units.push(parse_unit(c, tf.tf_unit)?);
}
let item_count = read_u32_le(c)?;
let mut items = Vec::with_capacity(item_count as usize);
for _ in 0..item_count {
items.push(parse_item(c, tf.tf_item)?);
}
let info_count = read_u32_le(c)?;
let mut infos = Vec::with_capacity(info_count as usize);
for _ in 0..info_count {
infos.push(parse_info(c)?);
}
let state_count = read_u32_le(c)?;
let mut states = Vec::with_capacity(state_count as usize);
for _ in 0..state_count {
states.push(parse_state(c)?);
}
let ext_count = read_u32_le(c)?;
let mut extensions = Vec::with_capacity(ext_count as usize);
for _ in 0..ext_count {
extensions.push(parse_extension(c)?);
}
let blank = read_s2_line(c)?;
let eof_marker = read_s2_line(c)?;
let url = read_s2_line(c)?;
if !eof_marker.is_empty() && eof_marker != "### EOF Map File" {
return Err(S2Error::InvalidTrailer(format!(
"expected '### EOF Map File', got {:?}",
eof_marker
)));
}
if !url.is_empty() && url != "www.unrealsoftware.de" {
return Err(S2Error::InvalidTrailer(format!(
"expected 'www.unrealsoftware.de', got {:?}",
url
)));
}
let trailer = S2Trailer {
blank,
eof_marker,
url,
};
Ok((objects, units, items, infos, states, extensions, trailer))
}
fn parse_object(c: &mut Cursor<&[u8]>, tf: bool) -> S2Result<S2Object> {
let id = read_u32_le(c)?;
let typ = if tf {
read_u16_le(c)? as u32
} else {
read_u8(c)? as u32
};
let x = read_f32_le(c)?;
let z = read_f32_le(c)?;
let yaw = read_f32_le(c)?;
let health = read_f32_le(c)?;
let health_max = read_f32_le(c)?;
let day_timer = read_u32_le(c)?;
Ok(S2Object {
id,
typ,
x,
z,
yaw,
health,
health_max,
day_timer,
})
}
fn parse_unit(c: &mut Cursor<&[u8]>, tf: bool) -> S2Result<S2Unit> {
let id = read_u32_le(c)?;
let typ = if tf {
read_u16_le(c)? as u32
} else {
read_u8(c)? as u32
};
let x = read_f32_le(c)?;
let y = read_f32_le(c)?;
let z = read_f32_le(c)?;
let yaw = read_f32_le(c)?;
let health = read_f32_le(c)?;
let health_max = read_f32_le(c)?;
let hunger = read_f32_le(c)?;
let thirst = read_f32_le(c)?;
let exhaustion = read_f32_le(c)?;
let ai_center_x = read_f32_le(c)?;
let ai_center_z = read_f32_le(c)?;
Ok(S2Unit {
id,
typ,
x,
y,
z,
yaw,
health,
health_max,
hunger,
thirst,
exhaustion,
ai_center_x,
ai_center_z,
})
}
fn parse_item(c: &mut Cursor<&[u8]>, tf: bool) -> S2Result<S2Item> {
let id = read_u32_le(c)?;
let typ = if tf {
read_u16_le(c)? as u32
} else {
read_u8(c)? as u32
};
let x = read_f32_le(c)?;
let y = read_f32_le(c)?;
let z = read_f32_le(c)?;
let yaw = read_f32_le(c)?;
let health = read_f32_le(c)?;
let count = read_u32_le(c)?;
let parent_class = read_u8(c)?;
let parent_mode = read_u8(c)?;
let parent_id = read_u32_le(c)?;
Ok(S2Item {
id,
typ,
x,
y,
z,
yaw,
health,
count,
parent_class,
parent_mode,
parent_id,
})
}
fn parse_info(c: &mut Cursor<&[u8]>) -> S2Result<S2Info> {
let id = read_u32_le(c)?;
let typ = read_u8(c)?;
let x = read_f32_le(c)?;
let y = read_f32_le(c)?;
let z = read_f32_le(c)?;
let pitch = read_f32_le(c)?;
let yaw = read_f32_le(c)?;
let vars = read_s2_string(c)?;
Ok(S2Info {
id,
typ,
x,
y,
z,
pitch,
yaw,
vars,
})
}
fn parse_state(c: &mut Cursor<&[u8]>) -> S2Result<S2State> {
let typ = read_u8(c)? as u32;
let parent_class = read_u8(c)? as u32;
let parent_id = read_u32_le(c)?;
let x = read_f32_le(c)?;
let y = read_f32_le(c)?;
let z = read_f32_le(c)?;
let fx = read_f32_le(c)?;
let fy = read_f32_le(c)?;
let fz = read_f32_le(c)?;
let value = read_u32_le(c)?;
let value_f = read_f32_le(c)?;
let value_s = read_s2_string(c)?;
Ok(S2State {
typ,
parent_class,
parent_id,
x,
y,
z,
fx,
fy,
fz,
value,
value_f,
value_s,
})
}
fn parse_extension(c: &mut Cursor<&[u8]>) -> S2Result<S2Extension> {
let typ = read_u8(c)?;
let parent_class = read_u8(c)?;
let parent_id = read_u32_le(c)?;
let mode = read_u32_le(c)?;
let key = if mode == 0 {
let _empty = read_s2_string(c)?;
String::new()
} else {
read_s2_string(c)?
};
let value = read_s2_string(c)?;
let stuff = read_s2_string(c)?;
Ok(S2Extension {
typ,
parent_class,
parent_id,
mode,
key,
value,
stuff,
})
}
pub fn parse_s2_file(path: impl AsRef<std::path::Path>) -> S2Result<S2Map> {
let data = std::fs::read(path.as_ref()).map_err(S2Error::Io)?;
parse_s2(&data)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_read_s2_string_empty() {
let data: &[u8] = &[0, 0, 0, 0];
let mut c = Cursor::new(data);
let s = read_s2_string(&mut c).unwrap();
assert_eq!(s, "");
assert_eq!(c.position(), 4);
}
#[test]
fn test_read_s2_string_hello() {
let data: &[u8] = &[5, 0, 0, 0, b'H', b'e', b'l', b'l', b'o'];
let mut c = Cursor::new(data);
let s = read_s2_string(&mut c).unwrap();
assert_eq!(s, "Hello");
}
#[test]
fn test_parse_quickslots() {
let mut data = Vec::new();
for _ in 0..10 {
data.extend_from_slice(&[1, 0, 0, 0, b'0']);
}
let mut c = Cursor::new(data.as_slice());
let slots = parse_quickslots(&mut c).unwrap();
assert_eq!(slots.len(), 10);
assert!(slots.iter().all(|s| s == "0"));
}
#[test]
fn test_decode_password_empty() {
let pw = decode_password("", 0);
assert_eq!(pw, "");
}
#[test]
fn test_decode_password_xor() {
let encoded = "\x03\x00\x01";
let pw = decode_password(encoded, 50);
assert_eq!(pw, "123");
}
fn build_minimal_s2() -> Vec<u8> {
let mut buf = Vec::new();
buf.extend_from_slice(b"### Stranded II Mapfile \xa9by Unreal Software 2004-2007\r\n");
buf.extend_from_slice(b"1.0.0.1\r\n");
buf.extend_from_slice(b"10 Jul 2026\r\n");
buf.extend_from_slice(b"18:10:56\r\n");
buf.extend_from_slice(b"default\r\n");
buf.extend_from_slice(b"map\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"###\r\n");
buf.extend(std::iter::repeat(0u8).take(20736));
buf.push(0); buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(&1u32.to_le_bytes()); buf.push(9); buf.push(0); buf.push(3); buf.extend_from_slice(&3u32.to_le_bytes()); buf.extend_from_slice(b"sky");
buf.push(0); buf.push(0); let music = b"amb_jungle.mp3";
buf.extend_from_slice(&(music.len() as u32).to_le_bytes());
buf.extend_from_slice(music);
buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&[205, 210, 245, 0]); buf.push(0);
for _ in 0..10 {
buf.extend_from_slice(&[1, 0, 0, 0, b'0']);
}
buf.extend_from_slice(&128u32.to_le_bytes());
buf.extend(std::iter::repeat(0u8).take(128 * 128 * 3));
buf.extend_from_slice(&16u32.to_le_bytes());
for _ in 0..289 {
buf.extend_from_slice(&0.447_f32.to_le_bytes());
}
buf.extend(std::iter::repeat(0u8).take(16641));
buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&1u32.to_le_bytes());
buf.push(0); buf.push(0); buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&6u32.to_le_bytes()); buf.extend_from_slice(&8u32.to_le_bytes()); buf.extend_from_slice(b"lasttime");
buf.extend_from_slice(&0u32.to_le_bytes()); buf.extend_from_slice(&5u32.to_le_bytes()); buf.extend_from_slice(b"1,9,2");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"### EOF Map File\r\n");
buf.extend_from_slice(b"www.unrealsoftware.de\r\n");
buf
}
#[test]
fn test_parse_minimal_s2() {
let data = build_minimal_s2();
let result = parse_s2(&data);
assert!(result.is_ok(), "parse failed: {:?}", result.err());
let map = result.unwrap();
assert_eq!(map.header.version, "1.0.0.1");
assert_eq!(map.terrain_size, 16);
assert!(map.objects.is_empty());
assert!(map.units.is_empty());
assert!(map.items.is_empty());
assert!(map.infos.is_empty());
assert!(map.states.is_empty());
assert_eq!(map.extensions.len(), 1);
assert_eq!(map.extensions[0].key, "lasttime");
}
#[test]
fn test_parse_minimal_s2_with_password() {
let mut buf = Vec::new();
buf.extend_from_slice(b"### Stranded II Mapfile \xa9by Unreal Software 2004-2007\r\n");
buf.extend_from_slice(b"1.0.0.1\r\n");
buf.extend_from_slice(b"10 Jul 2026\r\n");
buf.extend_from_slice(b"18:10:56\r\n");
buf.extend_from_slice(b"default\r\n");
buf.extend_from_slice(b"map\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"###\r\n");
buf.extend(std::iter::repeat(0u8).take(20736));
buf.push(50); buf.extend_from_slice(b"\x03\x00\x01\r\n");
buf.extend_from_slice(&1u32.to_le_bytes()); buf.push(9);
buf.push(0);
buf.push(3);
buf.extend_from_slice(&3u32.to_le_bytes());
buf.extend_from_slice(b"sky");
buf.push(0);
buf.push(0);
let music = b"amb_jungle.mp3";
buf.extend_from_slice(&(music.len() as u32).to_le_bytes());
buf.extend_from_slice(music);
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&[205, 210, 245, 0]);
buf.push(0);
for _ in 0..10 {
buf.extend_from_slice(&[1, 0, 0, 0, b'0']);
}
buf.extend_from_slice(&128u32.to_le_bytes());
buf.extend(std::iter::repeat(0u8).take(128 * 128 * 3));
buf.extend_from_slice(&16u32.to_le_bytes());
for _ in 0..289 {
buf.extend_from_slice(&0.447_f32.to_le_bytes());
}
buf.extend(std::iter::repeat(0u8).take(16641));
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&1u32.to_le_bytes());
buf.push(0);
buf.push(0);
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&6u32.to_le_bytes());
buf.extend_from_slice(&8u32.to_le_bytes());
buf.extend_from_slice(b"lasttime");
buf.extend_from_slice(&0u32.to_le_bytes());
buf.extend_from_slice(&5u32.to_le_bytes());
buf.extend_from_slice(b"1,9,2");
buf.extend_from_slice(b"\r\n");
buf.extend_from_slice(b"### EOF Map File\r\n");
buf.extend_from_slice(b"www.unrealsoftware.de\r\n");
let result = parse_s2(&buf);
assert!(result.is_ok(), "parse failed: {:?}", result.err());
let map = result.unwrap();
assert_eq!(map.password.key, 50);
assert_eq!(map.password.decoded, "123");
}
}