Skip to main content

openstranded_map_tool/
convert.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//! Convert parsed `.s2` data into the `.osmap` format.
18
19use std::collections::HashMap;
20
21use crate::types::*;
22
23/// Convert a parsed `.s2` map into an `OpenStrandedMap`.
24pub fn s2_to_osmap(s2: S2Map, source_filename: &str) -> OpenStrandedMap {
25    let terrain_size = s2.terrain_size;
26
27    let terrain = TerrainData {
28        size: terrain_size,
29        heights: s2.heights.clone(),
30        seaground_level: -2.0,
31    };
32
33    let colormap = if s2.colormap_dim > 0 {
34        Some(ColormapData {
35            dim: s2.colormap_dim,
36            pixels: s2.colormap.clone(),
37        })
38    } else {
39        None
40    };
41
42    let grass = if !s2.grass.is_empty() {
43        Some(GrassData {
44            dim: s2.colormap_dim + 1,
45            values: s2.grass.clone(),
46        })
47    } else {
48        None
49    };
50
51    // Convert entities
52    let mut entities: Vec<MapEntity> = Vec::new();
53
54    for obj in &s2.objects {
55        let y = sample_height(&s2.heights, terrain_size, obj.x, obj.z);
56        entities.push(MapEntity {
57            class: "object".into(),
58            type_id: obj.typ,
59            id: obj.id,
60            position: (obj.x, y, obj.z),
61            rotation_yaw: deg_to_rad(obj.yaw),
62            health: obj.health,
63            health_max: obj.health_max,
64            amount: 0,
65            parent_id: 0,
66            parent_class: 0,
67            links: vec![],
68            build_progress: 0,
69            states: vec![],
70            script: None,
71            unit_data: None,
72            extensions: extract_extensions_for_parent(&s2.extensions, 1, obj.id),
73        });
74    }
75
76    for unit in &s2.units {
77        entities.push(MapEntity {
78            class: "unit".into(),
79            type_id: unit.typ,
80            id: unit.id,
81            position: (unit.x, unit.y, unit.z),
82            rotation_yaw: deg_to_rad(unit.yaw),
83            health: unit.health,
84            health_max: unit.health_max,
85            amount: 0,
86            parent_id: 0,
87            parent_class: 0,
88            links: vec![],
89            build_progress: 0,
90            states: vec![],
91            script: None,
92            unit_data: Some(UnitData {
93                hunger: unit.hunger,
94                thirst: unit.thirst,
95                exhaustion: unit.exhaustion,
96                ai_center_x: unit.ai_center_x,
97                ai_center_z: unit.ai_center_z,
98                day_timer: 0.0,
99            }),
100            extensions: extract_extensions_for_parent(&s2.extensions, 2, unit.id),
101        });
102    }
103
104    for item in &s2.items {
105        entities.push(MapEntity {
106            class: "item".into(),
107            type_id: item.typ,
108            id: item.id,
109            position: (item.x, item.y, item.z),
110            rotation_yaw: deg_to_rad(item.yaw),
111            health: item.health,
112            health_max: 0.0,
113            amount: item.count,
114            parent_id: item.parent_id,
115            parent_class: item.parent_class,
116            links: vec![],
117            build_progress: 0,
118            states: vec![],
119            script: None,
120            unit_data: None,
121            extensions: extract_extensions_for_parent(&s2.extensions, 3, item.id),
122        });
123    }
124
125    for info in &s2.infos {
126        entities.push(MapEntity {
127            class: "info".into(),
128            type_id: info.typ as u32,
129            id: info.id,
130            position: (info.x, info.y, info.z),
131            rotation_yaw: deg_to_rad(info.yaw),
132            health: 0.0,
133            health_max: 0.0,
134            amount: 0,
135            parent_id: 0,
136            parent_class: 0,
137            links: vec![],
138            build_progress: 0,
139            states: vec![],
140            script: Some(info.vars.clone()),
141            unit_data: None,
142            extensions: extract_extensions_for_parent(&s2.extensions, 4, info.id),
143        });
144    }
145
146    // Build environment map
147    let mut environment = HashMap::new();
148    environment.insert("day".into(), s2.env_vars.day.to_string());
149    environment.insert("hour".into(), s2.env_vars.hour.to_string());
150    environment.insert("minute".into(), s2.env_vars.minute.to_string());
151    environment.insert("freezetime".into(), s2.env_vars.freezetime.to_string());
152    environment.insert("skybox".into(), s2.env_vars.skybox);
153    environment.insert("multiplayer".into(), s2.env_vars.multiplayer.to_string());
154    environment.insert("climate".into(), s2.env_vars.climate.to_string());
155    environment.insert("music".into(), s2.env_vars.music);
156    let briefing = s2.env_vars.briefing.clone();
157    let decoded_pw = s2.password.decoded.clone();
158    environment.insert("briefing".into(), s2.env_vars.briefing);
159    environment.insert("fog_r".into(), s2.env_vars.fog[0].to_string());
160    environment.insert("fog_g".into(), s2.env_vars.fog[1].to_string());
161    environment.insert("fog_b".into(), s2.env_vars.fog[2].to_string());
162    environment.insert("fog_mode".into(), s2.env_vars.fog[3].to_string());
163    if !decoded_pw.is_empty() {
164        environment.insert("password".into(), decoded_pw);
165    }
166
167    // Collect scripts from infos and briefing
168    let mut scripts: Vec<ScriptData> = Vec::new();
169    for info in &s2.infos {
170        if !info.vars.is_empty() {
171            scripts.push(ScriptData {
172                text: info.vars.clone(),
173                entity_id: info.id,
174            });
175        }
176    }
177    if !briefing.is_empty() {
178        scripts.push(ScriptData {
179            text: briefing,
180            entity_id: 0,
181        });
182    }
183
184    OpenStrandedMap {
185        meta: MapMeta {
186            name: source_filename.trim_end_matches(".s2").to_string(),
187            engine_version: env!("CARGO_PKG_VERSION").to_string(),
188            map_version: "0.1.0".to_string(),
189            source_file: source_filename.to_string(),
190            s2_header: Some(s2.header),
191            created_at: String::new(),
192        },
193        terrain,
194        entities,
195        player_spawn: Some(PlayerSpawn {
196            position: (0.0, 2.0, 0.0),
197            rotation_yaw: 0.0,
198        }),
199        environment,
200        colormap,
201        grass,
202        password: s2.password.decoded.clone(),
203        scripts,
204    }
205}
206
207/// Sample height from the heightmap at a given (x, z) world position.
208fn sample_height(heights: &[f32], terrain_size: u32, x: f32, z: f32) -> f32 {
209    if heights.is_empty() {
210        return 0.0;
211    }
212    let size = terrain_size as f32;
213    let ix = (x.clamp(0.0, size)).round() as usize;
214    let iz = (z.clamp(0.0, size)).round() as usize;
215    let idx = iz * (terrain_size as usize + 1) + ix;
216    if idx < heights.len() {
217        heights[idx]
218    } else {
219        0.0
220    }
221}
222
223/// Convert degrees to radians.
224fn deg_to_rad(deg: f32) -> f32 {
225    deg * std::f32::consts::PI / 180.0
226}
227
228/// Extract extensions for a given parent class+id as key-value map.
229fn extract_extensions_for_parent(
230    extensions: &[S2Extension],
231    parent_class: u8,
232    parent_id: u32,
233) -> HashMap<String, String> {
234    let mut map = HashMap::new();
235    for ext in extensions {
236        if ext.parent_class == parent_class && ext.parent_id == parent_id {
237            let key = if ext.key.is_empty() {
238                format!("mode_{}", ext.mode)
239            } else {
240                ext.key.clone()
241            };
242            map.insert(key.clone(), ext.value.clone());
243            if !ext.stuff.is_empty() {
244                map.insert(format!("{}_stuff", key), ext.stuff.clone());
245            }
246        }
247    }
248    map
249}