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/// Blitz3D world-unit constant used to scale terrain vertices.
24/// `ScaleEntity ter, Cworld_size, Cworld_height, Cworld_size`
25const CWORLD_SIZE: f32 = 64.0;
26/// Blitz3D terrain height constant.
27const CWORLD_HEIGHT: f32 = 3200.0;
28
29/// Parse a FRAP (Free Rotation And Position) extension value.
30///
31/// The original game stores `"f"` extensions with value `"y,pitch,roll"` where:
32/// - `y`     — absolute Y position in Blitz3D world units
33/// - `pitch` — pitch rotation in degrees
34/// - `roll`  — roll rotation in degrees
35///
36/// Returns `(y_blitz3d, pitch_deg, roll_deg)` or `None` if parsing fails.
37fn parse_frap_value(value: &str) -> Option<(f32, f32, f32)> {
38    let parts: Vec<&str> = value.split(',').collect();
39    if parts.len() >= 3 {
40        let y = parts[0].trim().parse().ok()?;
41        let pitch = parts[1].trim().parse().unwrap_or(0.0);
42        let roll = parts[2].trim().parse().unwrap_or(0.0);
43        Some((y, pitch, roll))
44    } else {
45        None
46    }
47}
48
49/// Convert a parsed `.s2` map into an `.osmap` (engine convention).
50///
51/// # Coordinate conversion
52///
53/// Blitz3D terrain setup:
54///   `CreateTerrain(ter_size)` — grid `0..ter_size` in both X and Z
55///   `ScaleEntity ter, Cworld_size, Cworld_height, Cworld_size` — vertex spacing = 64 world units
56///   `PositionEntity ter, -(64*ter_size/2), -(Cworld_height/2), -(64*ter_size/2)` — centre at origin
57///
58/// So `EntityX(h)` returns a **centred world coordinate** in the range `−ter_size×32 .. +ter_size×32`.
59///
60/// `.osmap` stores positions in **grid-cell units**:
61///   - same orientation as the engine terrain mesh: +X right, +Z = north (= forward)
62///   - X and Z range = `−ter_size/2 .. +ter_size/2` (grid cells, identical to engine's grid)
63///   - Y = engine world Y (from `norm_to_world`)
64///
65/// Because the Blitz3D terrain is already centred, we simply divide by Cworld_size
66/// to get grid-cell units.  No additional centering subtraction is needed,
67/// and Z is NOT negated (both the original and the engine use +Z forward).
68///
69/// The engine scales grid-cell XZ to world units by multiplying with the
70/// map's step factor (`step = 64.0 / terrain_size`).
71/// For a 33×33 grid (terrain_size=32): step=2.0, world spans −32 .. +32.
72pub fn s2_to_osmap(s2: S2Map, source_filename: &str) -> OpenStrandedMap {
73    let terrain_size = s2.terrain_size;
74
75    // Heightmap stored in natural row-major order: `[z * stride + x]`
76    // = height at grid (x, z).  No Z flip — the terrain mesh and entities
77    // both use the same +Z = north convention.
78    let terrain = TerrainData {
79        size: terrain_size,
80        heights: s2.heights.clone(),
81        seaground_level: -2.0,
82    };
83
84    let colormap = if s2.colormap_dim > 0 {
85        Some(ColormapData {
86            dim: s2.colormap_dim,
87            pixels: s2.colormap.clone(),
88        })
89    } else {
90        None
91    };
92
93    let grass = if !s2.grass.is_empty() {
94        Some(GrassData {
95            dim: s2.colormap_dim + 1,
96            values: s2.grass.clone(),
97        })
98    } else {
99        None
100    };
101
102    // ── Convert entities ────────────────────────────────────────────
103    // Position conversion from .s2 (Blitz3D world units) to .osmap (grid‑cell units):
104    //
105    //   s2 EntityX()  returns centred world X  ∈ [−ter_size×32, +ter_size×32]
106    //   → grid cell   osmap_x = s2_x / CWORLD_SIZE  ∈ [−ter_size/2, +ter_size/2]
107    //
108    //   Z is in the same orientation (both systems use +Z = north):
109    //   osmap_z = s2_z / CWORLD_SIZE   (NO negation — both +Z = north)
110    //
111    //   Y is converted from Blitz3D world Y to engine world Y:
112    //   engine_y = (s2_y / CWORLD_HEIGHT + 0.5 − 0.35) × 60.0
113    //
114    // For objects: the .s2 does not store Y; height is sampled from the
115    // unflipped heightmap using original s2_x/s2_z, then converted.
116    let mut entities: Vec<MapEntity> = Vec::new();
117    let engine_y_from_s2 = |s2_y: f32| -> f32 {
118        (s2_y / CWORLD_HEIGHT + 0.5 - 0.35) * 60.0
119    };
120
121    for obj in &s2.objects {
122        // Default Y from terrain height (objects don't store Y in .s2)
123        let h_raw = sample_height(&terrain.heights, terrain_size, obj.x, obj.z);
124        let mut y = (h_raw - 0.35) * 60.0;
125
126        // Collect extensions for this object
127        let mut extensions = extract_extensions_for_parent(&s2.extensions, 1, obj.id);
128
129        // Apply FRAP (Free Rotation And Position) if present.
130        // The "f" extension overrides the object's Y position and rotation
131        // (pitch, roll) — see load_map_extensions in e_load_map.bb.
132        // After applying, the raw "f" extension is removed so the data is
133        // not duplicated (position + rotation fields are the source of truth).
134        let mut rotation_pitch = 0.0;
135        let mut rotation_roll = 0.0;
136        if let Some(frap_val) = extensions.get("f") {
137            if let Some((frap_y, pitch_deg, roll_deg)) = parse_frap_value(frap_val) {
138                // Convert Y from Blitz3D world units to engine coordinates
139                y = engine_y_from_s2(frap_y);
140                rotation_pitch = deg_to_rad(pitch_deg);
141                rotation_roll = deg_to_rad(roll_deg);
142            }
143        }
144        extensions.remove("f"); // applied above — no longer needed as raw extension
145
146        entities.push(MapEntity {
147            class: "object".into(),
148            type_id: obj.typ,
149            id: obj.id,
150            position: (obj.x / CWORLD_SIZE, y, obj.z / CWORLD_SIZE),
151            rotation_yaw: deg_to_rad(obj.yaw),
152            rotation_pitch,
153            rotation_roll,
154            health: obj.health,
155            health_max: obj.health_max,
156            amount: 0,
157            parent_id: 0,
158            parent_class: 0,
159            links: vec![],
160            build_progress: 0,
161            states: vec![],
162            script: None,
163            unit_data: None,
164            extensions,
165        });
166    }
167
168    for unit in &s2.units {
169        let y = engine_y_from_s2(unit.y);
170        entities.push(MapEntity {
171            class: "unit".into(),
172            type_id: unit.typ,
173            id: unit.id,
174            position: (unit.x / CWORLD_SIZE, y, unit.z / CWORLD_SIZE),
175            rotation_yaw: deg_to_rad(unit.yaw),
176            rotation_pitch: 0.0,
177            rotation_roll: 0.0,
178            health: unit.health,
179            health_max: unit.health_max,
180            amount: 0,
181            parent_id: 0,
182            parent_class: 0,
183            links: vec![],
184            build_progress: 0,
185            states: vec![],
186            script: None,
187            unit_data: Some(UnitData {
188                hunger: unit.hunger,
189                thirst: unit.thirst,
190                exhaustion: unit.exhaustion,
191                ai_center_x: unit.ai_center_x / CWORLD_SIZE,
192                ai_center_z: unit.ai_center_z / CWORLD_SIZE,
193                day_timer: 0.0,
194            }),
195            extensions: extract_extensions_for_parent(&s2.extensions, 2, unit.id),
196        });
197    }
198
199    for item in &s2.items {
200        let y = engine_y_from_s2(item.y);
201        entities.push(MapEntity {
202            class: "item".into(),
203            type_id: item.typ,
204            id: item.id,
205            position: (item.x / CWORLD_SIZE, y, item.z / CWORLD_SIZE),
206            rotation_yaw: deg_to_rad(item.yaw),
207            rotation_pitch: 0.0,
208            rotation_roll: 0.0,
209            health: item.health,
210            health_max: 0.0,
211            amount: item.count,
212            parent_id: item.parent_id,
213            parent_class: item.parent_class,
214            links: vec![],
215            build_progress: 0,
216            states: vec![],
217            script: None,
218            unit_data: None,
219            extensions: extract_extensions_for_parent(&s2.extensions, 3, item.id),
220        });
221    }
222
223    for info in &s2.infos {
224        let y = engine_y_from_s2(info.y);
225        entities.push(MapEntity {
226            class: "info".into(),
227            type_id: info.typ as u32,
228            id: info.id,
229            position: (info.x / CWORLD_SIZE, y, info.z / CWORLD_SIZE),
230            rotation_yaw: deg_to_rad(info.yaw),
231            rotation_pitch: 0.0,
232            rotation_roll: 0.0,
233            health: 0.0,
234            health_max: 0.0,
235            amount: 0,
236            parent_id: 0,
237            parent_class: 0,
238            links: vec![],
239            build_progress: 0,
240            states: vec![],
241            script: Some(info.vars.clone()),
242            unit_data: None,
243            extensions: extract_extensions_for_parent(&s2.extensions, 4, info.id),
244        });
245    }
246
247    // Build environment map
248    let mut environment = HashMap::new();
249    environment.insert("day".into(), s2.env_vars.day.to_string());
250    environment.insert("hour".into(), s2.env_vars.hour.to_string());
251    environment.insert("minute".into(), s2.env_vars.minute.to_string());
252    environment.insert("freezetime".into(), s2.env_vars.freezetime.to_string());
253    environment.insert("skybox".into(), s2.env_vars.skybox);
254    environment.insert("multiplayer".into(), s2.env_vars.multiplayer.to_string());
255    environment.insert("climate".into(), s2.env_vars.climate.to_string());
256    environment.insert("music".into(), s2.env_vars.music);
257    let briefing = s2.env_vars.briefing.clone();
258    let decoded_pw = s2.password.decoded.clone();
259    environment.insert("briefing".into(), s2.env_vars.briefing);
260    environment.insert("fog_r".into(), s2.env_vars.fog[0].to_string());
261    environment.insert("fog_g".into(), s2.env_vars.fog[1].to_string());
262    environment.insert("fog_b".into(), s2.env_vars.fog[2].to_string());
263    environment.insert("fog_mode".into(), s2.env_vars.fog[3].to_string());
264    if !decoded_pw.is_empty() {
265        environment.insert("password".into(), decoded_pw);
266    }
267
268    // Player spawn Y at terrain centre (computed before `terrain` is moved)
269    let spawn_pos = spawn_center_engine_y(terrain_size, &terrain.heights);
270
271    // Collect scripts from infos and briefing
272    let mut scripts: Vec<ScriptData> = Vec::new();
273    for info in &s2.infos {
274        if !info.vars.is_empty() {
275            scripts.push(ScriptData {
276                text: info.vars.clone(),
277                entity_id: info.id,
278            });
279        }
280    }
281    if !briefing.is_empty() {
282        scripts.push(ScriptData {
283            text: briefing,
284            entity_id: 0,
285        });
286    }
287
288    OpenStrandedMap {
289        meta: MapMeta {
290            name: source_filename.trim_end_matches(".s2").to_string(),
291            engine_version: env!("CARGO_PKG_VERSION").to_string(),
292            map_version: "0.2.0".to_string(),
293            source_file: source_filename.to_string(),
294            s2_header: Some(s2.header),
295            created_at: String::new(),
296        },
297        terrain,
298        entities,
299        player_spawn: Some(PlayerSpawn {
300            position: spawn_pos,
301            rotation_yaw: 0.0,
302        }),
303        environment,
304        colormap,
305        grass,
306        password: s2.password.decoded.clone(),
307        scripts,
308    }
309}
310
311/// Compute player-spawn position at terrain centre.
312/// Returns `(0, ground_y, 0)` in grid-cell coords — engine adds eye height.
313fn spawn_center_engine_y(terrain_size: u32, heights: &[f32]) -> (f32, f32, f32) {
314    let mid = terrain_size as usize / 2;
315    let stride = (terrain_size + 1) as usize;
316    let centre_h = heights.get(mid * stride + mid).copied().unwrap_or(0.35);
317    let y = (centre_h - 0.35) * 60.0; // norm_to_world
318    (0.0, y, 0.0) // ground Y — engine adds EYE_HEIGHT
319}
320
321/// Sample height from the heightmap at Blitz3D world position (x, z).
322///
323/// `x` and `z` are Blitz3D centred world coordinates (range `±ter_size×32`).
324/// We convert to grid indices by dividing by `CWORLD_SIZE = 64` and adding
325/// half_grid (shifting from centred to 0‑based grid coordinates).
326fn sample_height(heights: &[f32], terrain_size: u32, x: f32, z: f32) -> f32 {
327    if heights.is_empty() {
328        return 0.0;
329    }
330    let half = terrain_size as f32 / 2.0;
331    let gx = (x / CWORLD_SIZE + half).clamp(0.0, terrain_size as f32);
332    let gz = (z / CWORLD_SIZE + half).clamp(0.0, terrain_size as f32);
333    let ix = gx.round() as usize;
334    let iz = gz.round() as usize;
335    let idx = iz * (terrain_size as usize + 1) + ix;
336    if idx < heights.len() {
337        heights[idx]
338    } else {
339        0.0
340    }
341}
342
343/// Convert degrees to radians.
344fn deg_to_rad(deg: f32) -> f32 {
345    deg * std::f32::consts::PI / 180.0
346}
347
348/// Extract extensions for a given parent class+id as key-value map.
349fn extract_extensions_for_parent(
350    extensions: &[S2Extension],
351    parent_class: u8,
352    parent_id: u32,
353) -> HashMap<String, String> {
354    let mut map = HashMap::new();
355    for ext in extensions {
356        if ext.parent_class == parent_class && ext.parent_id == parent_id {
357            let key = if ext.key.is_empty() {
358                format!("mode_{}", ext.mode)
359            } else {
360                ext.key.clone()
361            };
362            map.insert(key.clone(), ext.value.clone());
363            if !ext.stuff.is_empty() {
364                map.insert(format!("{}_stuff", key), ext.stuff.clone());
365            }
366        }
367    }
368    map
369}