openstranded-map-tool 0.2.1

Convert Stranded II .s2 map files to .osmap (OpenStranded MAP) format
Documentation
// openstranded-map-tool — integration tests
// Copyright (C) 2026  OpenStranded contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

use openstranded_map_tool::{parse_s2, s2_to_osmap, save_osmap_ron};

/// Build a minimal valid .s2 file (16×16, no entities, no password).
fn build_minimal_s2() -> Vec<u8> {
    let mut buf = Vec::new();

    // Header
    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");

    // Minimap
    buf.extend(std::iter::repeat(0u8).take(20736));

    // Password header: empty password
    buf.push(0);
    buf.extend_from_slice(b"\r\n");

    // Env vars
    buf.extend_from_slice(&1u32.to_le_bytes()); // day
    buf.push(9); // hour
    buf.push(0); // minute
    buf.push(3); // freezetime
    buf.extend_from_slice(&3u32.to_le_bytes()); // skybox "sky"
    buf.extend_from_slice(b"sky");
    buf.push(0); // multiplayer
    buf.push(0); // climate
    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()); // briefing
    buf.extend_from_slice(&[205, 210, 245, 0]); // fog
    buf.push(0); // extra

    // Quickslots
    for _ in 0..10 {
        buf.extend_from_slice(&[1, 0, 0, 0, b'0']);
    }

    // Colormap: 128×128 black
    buf.extend_from_slice(&128u32.to_le_bytes());
    buf.extend(std::iter::repeat(0u8).take(128 * 128 * 3));

    // Heightmap: 17×17 = 289 f32 values
    buf.extend_from_slice(&16u32.to_le_bytes());
    for _ in 0..289 {
        buf.extend_from_slice(&0.447_f32.to_le_bytes());
    }

    // Grass: 129×129 zeros
    buf.extend(std::iter::repeat(0u8).take(16641));

    // Footer: 0 entities, 1 extension (lasttime)
    buf.extend_from_slice(&0u32.to_le_bytes()); // objects count
    buf.extend_from_slice(&0u32.to_le_bytes()); // units count
    buf.extend_from_slice(&0u32.to_le_bytes()); // items count
    buf.extend_from_slice(&0u32.to_le_bytes()); // infos count
    buf.extend_from_slice(&0u32.to_le_bytes()); // states count
    buf.extend_from_slice(&1u32.to_le_bytes()); // extensions count

    // Extension: lasttime (mode=6)
    buf.push(0); // typ
    buf.push(0); // parent_class
    buf.extend_from_slice(&0u32.to_le_bytes()); // parent_id
    buf.extend_from_slice(&6u32.to_le_bytes()); // mode
    buf.extend_from_slice(&8u32.to_le_bytes()); // key len
    buf.extend_from_slice(b"lasttime");
    buf.extend_from_slice(&0u32.to_le_bytes()); // value len
    buf.extend_from_slice(&5u32.to_le_bytes()); // stuff len
    buf.extend_from_slice(b"1,9,2");

    // Trailer
    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_s2_minimal() {
    let data = build_minimal_s2();
    let s2 = parse_s2(&data).expect("should parse minimal .s2");
    assert_eq!(s2.header.version, "1.0.0.1");
    assert_eq!(s2.terrain_size, 16);
    assert_eq!(s2.colormap_dim, 128);
    assert!(s2.objects.is_empty());
    assert!(s2.extensions.iter().any(|e| e.key == "lasttime"));
}

#[test]
fn test_convert_minimal_roundtrip() {
    let data = build_minimal_s2();
    let s2 = parse_s2(&data).expect("parse");
    let osmap = s2_to_osmap(s2, "test.s2");

    assert_eq!(osmap.meta.name, "test");
    assert_eq!(osmap.terrain.size, 16);
    assert!(osmap.entities.is_empty());

    // Serialize to RON and back
    let ron = ron::to_string(&osmap).expect("serialize");
    let deserialized: openstranded_map_tool::OpenStrandedMap =
        ron::from_str(&ron).expect("deserialize");
    assert_eq!(deserialized.terrain.size, 16);
    assert_eq!(deserialized.meta.name, "test");
}

#[test]
fn test_convert_minimal_to_file() {
    let tmp = tempfile::NamedTempFile::new().expect("tempfile");
    let out_path = tmp.path().with_extension("osmap");

    let data = build_minimal_s2();
    let s2 = parse_s2(&data).expect("parse");
    let osmap = s2_to_osmap(s2, "test.s2");
    save_osmap_ron(&osmap, &out_path).expect("save");

    // Read back
    let content = std::fs::read_to_string(&out_path).expect("read");
    assert!(content.contains("terrain"));
    assert!(content.contains("test"));

    // Parse as RON
    let parsed: openstranded_map_tool::OpenStrandedMap =
        ron::from_str(&content).expect("parse osmap");
    assert_eq!(parsed.terrain.size, 16);
}

#[test]
fn test_convert_with_entities() {
    // Build a minimal .s2 with a single object
    let mut buf2 = Vec::new();
    buf2.extend_from_slice(b"### Stranded II Mapfile \xa9by Unreal Software 2004-2007\r\n");
    buf2.extend_from_slice(b"1.0.0.1\r\n");
    buf2.extend_from_slice(b"10 Jul 2026\r\n");
    buf2.extend_from_slice(b"18:10:56\r\n");
    buf2.extend_from_slice(b"default\r\n");
    buf2.extend_from_slice(b"map\r\n");
    buf2.extend_from_slice(b"\r\n");
    buf2.extend_from_slice(b"\r\n");
    buf2.extend_from_slice(b"\r\n");
    buf2.extend_from_slice(b"\r\n");
    buf2.extend_from_slice(b"\r\n");
    buf2.extend_from_slice(b"###\r\n");
    buf2.extend(std::iter::repeat(0u8).take(20736));
    buf2.push(0);
    buf2.extend_from_slice(b"\r\n");
    buf2.extend_from_slice(&1u32.to_le_bytes());
    buf2.push(9);
    buf2.push(0);
    buf2.push(3);
    buf2.extend_from_slice(&3u32.to_le_bytes());
    buf2.extend_from_slice(b"sky");
    buf2.push(0);
    buf2.push(0);
    let music = b"amb_jungle.mp3";
    buf2.extend_from_slice(&(music.len() as u32).to_le_bytes());
    buf2.extend_from_slice(music);
    buf2.extend_from_slice(&0u32.to_le_bytes());
    buf2.extend_from_slice(&[205, 210, 245, 0]);
    buf2.push(0);
    for _ in 0..10 {
        buf2.extend_from_slice(&[1, 0, 0, 0, b'0']);
    }
    buf2.extend_from_slice(&128u32.to_le_bytes());
    buf2.extend(std::iter::repeat(0u8).take(128 * 128 * 3));
    buf2.extend_from_slice(&16u32.to_le_bytes());
    for _ in 0..289 {
        buf2.extend_from_slice(&0.447_f32.to_le_bytes());
    }
    buf2.extend(std::iter::repeat(0u8).take(16641));

    // Footer (interleaved: count → data for each section)
    // Object section: count + 1 record
    buf2.extend_from_slice(&1u32.to_le_bytes()); // object count
    // Object record: WriteInt(id=1), WriteByte(typ=5) since tf_object=false,
    //   WriteFloat(x=10), WriteFloat(z=20), WriteFloat(yaw=90),
    //   WriteFloat(health=100), WriteFloat(health_max=100), WriteInt(daytimer=0)
    buf2.extend_from_slice(&1u32.to_le_bytes()); // id
    buf2.push(5); // typ (u8)
    buf2.extend_from_slice(&10.0_f32.to_le_bytes()); // x
    buf2.extend_from_slice(&20.0_f32.to_le_bytes()); // z
    buf2.extend_from_slice(&90.0_f32.to_le_bytes()); // yaw
    buf2.extend_from_slice(&100.0_f32.to_le_bytes()); // health
    buf2.extend_from_slice(&100.0_f32.to_le_bytes()); // health_max
    buf2.extend_from_slice(&0u32.to_le_bytes()); // daytimer

    // Unit section: 0 units
    buf2.extend_from_slice(&0u32.to_le_bytes());

    // Item section: 0 items
    buf2.extend_from_slice(&0u32.to_le_bytes());

    // Info section: 0 infos
    buf2.extend_from_slice(&0u32.to_le_bytes());

    // State section: 0 states
    buf2.extend_from_slice(&0u32.to_le_bytes());

    // Extension section: count + 1 record
    buf2.extend_from_slice(&1u32.to_le_bytes()); // extension count

    // Extension: lasttime
    buf2.push(0);
    buf2.push(0);
    buf2.extend_from_slice(&0u32.to_le_bytes());
    buf2.extend_from_slice(&6u32.to_le_bytes());
    buf2.extend_from_slice(&8u32.to_le_bytes());
    buf2.extend_from_slice(b"lasttime");
    buf2.extend_from_slice(&0u32.to_le_bytes());
    buf2.extend_from_slice(&5u32.to_le_bytes());
    buf2.extend_from_slice(b"1,9,2");

    buf2.extend_from_slice(b"\r\n");
    buf2.extend_from_slice(b"### EOF Map File\r\n");
    buf2.extend_from_slice(b"www.unrealsoftware.de\r\n");

    let s2 = parse_s2(&buf2).expect("parse with object");
    assert_eq!(s2.objects.len(), 1);
    assert_eq!(s2.objects[0].id, 1);
    assert_eq!(s2.objects[0].typ, 5);
    assert_eq!(s2.objects[0].x, 10.0);

    let osmap = s2_to_osmap(s2, "test.s2");
    assert_eq!(osmap.entities.len(), 1);
    assert_eq!(osmap.entities[0].class, "object");
    assert_eq!(osmap.entities[0].type_id, 5);
    assert_eq!(osmap.entities[0].id, 1);

    // Position: s2 (x=10, z=20) → grid (10/64=0.15625, 20/64=0.3125)
    // No centering, no Z negation — both systems use +Z = north.
    // Y: height sampled at grid (8,8) from unflipped heights = 0.447
    // → engine_y = (0.447−0.35)*60 = 5.82
    let (px, py, pz) = osmap.entities[0].position;
    assert!((px - 0.15625).abs() < 1e-4, "expected 0.15625, got {px}");
    assert!((py - 5.82).abs() < 1e-4, "expected 5.82, got {py}");
    assert!((pz - 0.3125).abs() < 1e-4, "expected 0.3125, got {pz}");

    // Verify RON roundtrip
    let ron = ron::to_string(&osmap).expect("serialize");
    let back: openstranded_map_tool::OpenStrandedMap =
        ron::from_str(&ron).expect("deserialize");
    assert_eq!(back.entities.len(), 1);
}