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 `.osmap` (Bevy convention).
24///
25/// # Coordinate conversion
26///
27/// `.s2` stores positions in **Blitz3D convention**:
28///   - origin at terrain corner (0..terrain_size in both X and Z)
29///   - +Z forward
30///
31/// `.osmap` stores positions in **Bevy convention**:
32///   - origin centered at the terrain centre
33///   - −Z forward (Bevy standard)
34///   - units = grid cells (same as .s2, i.e. one unit per terrain segment)
35///
36/// The engine scales grid-cell units to world units by multiplying with the
37/// map's step factor (`step = 64.0 / terrain_size`).
38pub fn s2_to_osmap(s2: S2Map, source_filename: &str) -> OpenStrandedMap {
39    let terrain_size = s2.terrain_size;
40    let half = terrain_size as f32 / 2.0;
41
42    // ── Flip heightmap Z: row z → row (terrain_size - z) ────────────
43    // This keeps entity height-sampling consistent after Z negation.
44    let heights = flip_heightmap_z(&s2.heights, terrain_size);
45
46    let terrain = TerrainData {
47        size: terrain_size,
48        heights,
49        seaground_level: -2.0,
50    };
51
52    let colormap = if s2.colormap_dim > 0 {
53        Some(ColormapData {
54            dim: s2.colormap_dim,
55            pixels: s2.colormap.clone(),
56        })
57    } else {
58        None
59    };
60
61    let grass = if !s2.grass.is_empty() {
62        Some(GrassData {
63            dim: s2.colormap_dim + 1,
64            values: s2.grass.clone(),
65        })
66    } else {
67        None
68    };
69
70    // ── Convert entities ────────────────────────────────────────────
71    // Position conversion from .s2 (Blitz3D) to .osmap (Bevy convention):
72    //   osmap_x = s2_x - half
73    //   osmap_z = half - s2_z     (center + negate Z for Bevy −Z-forward)
74    //
75    // For objects: Y is sampled from the FLIPPED heightmap (which we just
76    // built) using ORIGINAL grid coordinates (s2_x, s2_z).  The original
77    // heightmap index `[z * stride + x]` in the flipped array now stores
78    // the height for grid position (x, terrain_size - z).  Since the
79    // original s2_z value directly indexes the flipped array at the same
80    // index (we only re-ordered rows, not the indexing convention),
81    // sampling with original (s2_x, s2_z) into `heights[]` is correct:
82    //   flipped[z * stride + x] = original[(terrain_size - z) * stride + x]
83    //   sample(s2_x, s2_z) → heights[s2_z * stride + s2_x]
84    //     = original[(terrain_size - s2_z) * stride + s2_x]
85    //   Which is the height at grid (s2_x, terrain_size - s2_z).
86    // The terrain mesh places vertex at grid_z in world-z, and the entity
87    // at negated world-z sees the same height.  Internally consistent.
88    let mut entities: Vec<MapEntity> = Vec::new();
89
90    for obj in &s2.objects {
91        // Sample using ORIGINAL .s2 coords (before centering/Z-negation)
92        let y = sample_height(&terrain.heights, terrain_size, obj.x, obj.z);
93        entities.push(MapEntity {
94            class: "object".into(),
95            type_id: obj.typ,
96            id: obj.id,
97            position: (obj.x - half, y, half - obj.z),
98            rotation_yaw: deg_to_rad(obj.yaw),
99            health: obj.health,
100            health_max: obj.health_max,
101            amount: 0,
102            parent_id: 0,
103            parent_class: 0,
104            links: vec![],
105            build_progress: 0,
106            states: vec![],
107            script: None,
108            unit_data: None,
109            extensions: extract_extensions_for_parent(&s2.extensions, 1, obj.id),
110        });
111    }
112
113    for unit in &s2.units {
114        entities.push(MapEntity {
115            class: "unit".into(),
116            type_id: unit.typ,
117            id: unit.id,
118            position: (unit.x - half, unit.y, half - unit.z),
119            rotation_yaw: deg_to_rad(unit.yaw),
120            health: unit.health,
121            health_max: unit.health_max,
122            amount: 0,
123            parent_id: 0,
124            parent_class: 0,
125            links: vec![],
126            build_progress: 0,
127            states: vec![],
128            script: None,
129            unit_data: Some(UnitData {
130                hunger: unit.hunger,
131                thirst: unit.thirst,
132                exhaustion: unit.exhaustion,
133                ai_center_x: unit.ai_center_x - half,
134                ai_center_z: half - unit.ai_center_z,
135                day_timer: 0.0,
136            }),
137            extensions: extract_extensions_for_parent(&s2.extensions, 2, unit.id),
138        });
139    }
140
141    for item in &s2.items {
142        entities.push(MapEntity {
143            class: "item".into(),
144            type_id: item.typ,
145            id: item.id,
146            position: (item.x - half, item.y, half - item.z),
147            rotation_yaw: deg_to_rad(item.yaw),
148            health: item.health,
149            health_max: 0.0,
150            amount: item.count,
151            parent_id: item.parent_id,
152            parent_class: item.parent_class,
153            links: vec![],
154            build_progress: 0,
155            states: vec![],
156            script: None,
157            unit_data: None,
158            extensions: extract_extensions_for_parent(&s2.extensions, 3, item.id),
159        });
160    }
161
162    for info in &s2.infos {
163        entities.push(MapEntity {
164            class: "info".into(),
165            type_id: info.typ as u32,
166            id: info.id,
167            position: (info.x - half, info.y, half - info.z),
168            rotation_yaw: deg_to_rad(info.yaw),
169            health: 0.0,
170            health_max: 0.0,
171            amount: 0,
172            parent_id: 0,
173            parent_class: 0,
174            links: vec![],
175            build_progress: 0,
176            states: vec![],
177            script: Some(info.vars.clone()),
178            unit_data: None,
179            extensions: extract_extensions_for_parent(&s2.extensions, 4, info.id),
180        });
181    }
182
183    // Build environment map
184    let mut environment = HashMap::new();
185    environment.insert("day".into(), s2.env_vars.day.to_string());
186    environment.insert("hour".into(), s2.env_vars.hour.to_string());
187    environment.insert("minute".into(), s2.env_vars.minute.to_string());
188    environment.insert("freezetime".into(), s2.env_vars.freezetime.to_string());
189    environment.insert("skybox".into(), s2.env_vars.skybox);
190    environment.insert("multiplayer".into(), s2.env_vars.multiplayer.to_string());
191    environment.insert("climate".into(), s2.env_vars.climate.to_string());
192    environment.insert("music".into(), s2.env_vars.music);
193    let briefing = s2.env_vars.briefing.clone();
194    let decoded_pw = s2.password.decoded.clone();
195    environment.insert("briefing".into(), s2.env_vars.briefing);
196    environment.insert("fog_r".into(), s2.env_vars.fog[0].to_string());
197    environment.insert("fog_g".into(), s2.env_vars.fog[1].to_string());
198    environment.insert("fog_b".into(), s2.env_vars.fog[2].to_string());
199    environment.insert("fog_mode".into(), s2.env_vars.fog[3].to_string());
200    if !decoded_pw.is_empty() {
201        environment.insert("password".into(), decoded_pw);
202    }
203
204    // Collect scripts from infos and briefing
205    let mut scripts: Vec<ScriptData> = Vec::new();
206    for info in &s2.infos {
207        if !info.vars.is_empty() {
208            scripts.push(ScriptData {
209                text: info.vars.clone(),
210                entity_id: info.id,
211            });
212        }
213    }
214    if !briefing.is_empty() {
215        scripts.push(ScriptData {
216            text: briefing,
217            entity_id: 0,
218        });
219    }
220
221    OpenStrandedMap {
222        meta: MapMeta {
223            name: source_filename.trim_end_matches(".s2").to_string(),
224            engine_version: env!("CARGO_PKG_VERSION").to_string(),
225            map_version: "0.2.0".to_string(),
226            source_file: source_filename.to_string(),
227            s2_header: Some(s2.header),
228            created_at: String::new(),
229        },
230        terrain,
231        entities,
232        // Player spawn at terrain centre, Y=2.0 (placeholder — heightmap
233        // sampling in the engine will override Y).  Already in Bevy
234        // convention (centered, −Z forward).
235        player_spawn: Some(PlayerSpawn {
236            position: (0.0, 2.0, 0.0),
237            rotation_yaw: 0.0,
238        }),
239        environment,
240        colormap,
241        grass,
242        password: s2.password.decoded.clone(),
243        scripts,
244    }
245}
246
247/// Reverse the Z-axis order of a heightmap (row‑major `[z * stride + x]`).
248///
249/// Row `z` is moved to row `(terrain_size - z)`.  This is needed so that
250/// the terrain mesh stays consistent when entity Z is negated (Bevy uses
251/// −Z forward whereas the original Blitz3D uses +Z forward).
252fn flip_heightmap_z(heights: &[f32], terrain_size: u32) -> Vec<f32> {
253    let stride = (terrain_size + 1) as usize;
254    let mut out = vec![0.0; heights.len()];
255    for z in 0..=terrain_size as usize {
256        for x in 0..stride {
257            let src = z * stride + x;
258            let dst = (terrain_size as usize - z) * stride + x;
259            out[dst] = heights[src];
260        }
261    }
262    out
263}
264
265/// Sample height from the heightmap at a given (x, z) world position.
266fn sample_height(heights: &[f32], terrain_size: u32, x: f32, z: f32) -> f32 {
267    if heights.is_empty() {
268        return 0.0;
269    }
270    let size = terrain_size as f32;
271    let ix = (x.clamp(0.0, size)).round() as usize;
272    let iz = (z.clamp(0.0, size)).round() as usize;
273    let idx = iz * (terrain_size as usize + 1) + ix;
274    if idx < heights.len() {
275        heights[idx]
276    } else {
277        0.0
278    }
279}
280
281/// Convert degrees to radians.
282fn deg_to_rad(deg: f32) -> f32 {
283    deg * std::f32::consts::PI / 180.0
284}
285
286/// Extract extensions for a given parent class+id as key-value map.
287fn extract_extensions_for_parent(
288    extensions: &[S2Extension],
289    parent_class: u8,
290    parent_id: u32,
291) -> HashMap<String, String> {
292    let mut map = HashMap::new();
293    for ext in extensions {
294        if ext.parent_class == parent_class && ext.parent_id == parent_id {
295            let key = if ext.key.is_empty() {
296                format!("mode_{}", ext.mode)
297            } else {
298                ext.key.clone()
299            };
300            map.insert(key.clone(), ext.value.clone());
301            if !ext.stuff.is_empty() {
302                map.insert(format!("{}_stuff", key), ext.stuff.clone());
303            }
304        }
305    }
306    map
307}