Skip to main content

openstranded_s2mod_tool/
convert.rs

1// openstranded-s2mod-tool — convert Stranded II mods to .s2mod format
2// Copyright (C) 2025  openstranded-s2mod-tool 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//! File format converters: `.inf → .ron`, `.b3d → .glb`, `.bmp → .png`,
18//! `.bmpf → .fnt + .png`, `.s2 → .osmap`.
19
20use std::collections::HashMap;
21use std::fs;
22use std::path::{Path, PathBuf};
23
24use anyhow::{Context, Result};
25use bmpf2fnt::{build_font_atlas, generate_bmfont, BmpfFont};
26
27use crate::registry::register_ron;
28use crate::script::{format_script_ref, write_script_lua};
29use crate::util::{relative_path, write_ron};
30
31/// Unified .inf → .ron conversion: extract scripts, write .ron, register.
32///
33/// Every .inf file is parsed the same way:
34/// 1. Extract embedded scripts → `<ron_stem>.<N>.lua` (sequential numbering)
35/// 2. Replace each script block's content with the reference path (now external)
36/// 3. Write raw entries as `Vec<InfEntry>` to `.ron`
37/// 4. Auto-register in manifest based on filename
38///
39/// Script blocks are identified by block name `"script"`. Each `script=start…end`
40/// block is extracted, written as `<ron_stem>.<seq>.lua`, and the block's text
41/// content is replaced with the reference path.
42pub fn convert_inf_to_ron(
43    inf_path: &Path,
44    input_root: &Path,
45    stage_root: &Path,
46    entries: &mut [inf2ron::InfEntry],
47    registry: &mut HashMap<String, Vec<String>>,
48    debug: bool,
49) -> Result<()> {
50    let ron_rel = relative_path(inf_path, input_root).with_extension("ron");
51    let ron_path = stage_root.join(&ron_rel);
52
53    if let Some(parent) = ron_path.parent() {
54        fs::create_dir_all(parent)?;
55    }
56
57    // Extract embedded scripts with sequential numbering
58    // and replace each script block's content with the reference path.
59    let mut seq = 0usize;
60    for entry in entries.iter_mut() {
61        if let Some(script_blocks) = entry.blocks.get_mut("script") {
62            for block in script_blocks.iter_mut() {
63                if let inf2ron::BlockContent::Text(script_s2s) = &mut block.content {
64                    seq += 1;
65                    let s2s = std::mem::take(script_s2s);
66                    // Write the extracted script
67                    write_script_lua(inf_path, input_root, stage_root, seq, &s2s, debug)?;
68                    // Replace inline script with reference path
69                    block.content = inf2ron::BlockContent::Text(format_script_ref(&ron_rel, seq));
70                }
71            }
72        }
73    }
74
75    // Write .ron — cast to shared slice for Serialize
76    let entries_slice: &[inf2ron::InfEntry] = &*entries;
77    write_ron(&ron_path, entries_slice)?;
78
79    // Auto-register in manifest based on filename
80    register_ron(&ron_rel, registry);
81
82    if debug {
83        eprintln!("    {:?} → {:?} ({} entries, {} scripts)", inf_path, ron_path, entries.len(), seq);
84    }
85
86    Ok(())
87}
88
89/// Convert a `.b3d` file to `.glb` using the b3d2glb library.
90pub fn convert_b3d_to_glb(
91    b3d_path: &Path,
92    glb_path: &Path,
93    game_root: &Path,
94    _stem: &str,
95) -> Result<()> {
96    let data = fs::read(b3d_path).with_context(|| format!("reading {:?}", b3d_path))?;
97
98    let b3d = b3d2glb::b3d_parser::B3D::read(&data)
99        .map_err(|e| anyhow::anyhow!("B3D parse error in {:?}: {}", b3d_path, e))?;
100
101    let mesh = b3d2glb::b3d::collect_mesh(&b3d);
102    let mut joints = Vec::new();
103    let mut vertex_joint = vec![None; mesh.positions.len()];
104    b3d2glb::b3d::collect_joints(&b3d.node, None, &mut joints, &mut vertex_joint, mesh.positions.len(), true);
105    let clips = b3d2glb::b3d::collect_anims(&b3d.node);
106
107    // Create a texture cache directory alongside the .glb
108    let tex_cache = glb_path.parent().unwrap_or(Path::new(".")).join(".tex_cache");
109    fs::create_dir_all(&tex_cache)?;
110
111    b3d2glb::writer::write_glb(
112        &mesh,
113        &joints,
114        &clips,
115        &b3d.textures,
116        &b3d.brushes,
117        _stem,
118        game_root,
119        &tex_cache,
120        glb_path,
121        None, // material_params
122        None, // color_override
123    )
124    .map_err(|e| anyhow::anyhow!("GLB conversion error in {:?}: {}", b3d_path, e))?;
125
126    Ok(())
127}
128
129/// Convert a `.bmp` texture to `.png`, converting the magenta colour key
130/// (255, 0, 255 ± tolerance) to fully transparent.
131///
132/// Blitz3D/Stranded II uses 24-bit BMP without alpha; magenta pixels serve
133/// as the transparency colour key.  We convert them to `(0,0,0,0)` so the
134/// output PNG has a proper alpha channel.
135pub fn convert_bmp_to_png(bmp_path: &Path, png_path: &Path) -> Result<()> {
136    let img = image::ImageReader::open(bmp_path)
137        .with_context(|| format!("opening {:?}", bmp_path))?
138        .decode()
139        .with_context(|| format!("decoding {:?}", bmp_path))?;
140
141    // Convert to RGBA so we can set alpha on colour-key pixels
142    let mut rgba = img.to_rgba8();
143    let tolerance = 10u8;
144
145    for pixel in rgba.pixels_mut() {
146        // magenta colour key: R=255, G=0, B=255
147        let dr = pixel[0].abs_diff(255);
148        let dg = pixel[1].abs_diff(0);
149        let db = pixel[2].abs_diff(255);
150        if dr <= tolerance && dg <= tolerance && db <= tolerance {
151            pixel[3] = 0; // fully transparent
152        }
153    }
154
155    if let Some(parent) = png_path.parent() {
156        fs::create_dir_all(parent)?;
157    }
158    rgba.save(png_path)
159        .with_context(|| format!("saving {:?}", png_path))?;
160
161    Ok(())
162}
163
164/// Convert a `.bmpf` bitmap font + its `.bmp` texture into `.fnt` + `.png`.
165pub fn convert_bmpf_to_fnt(
166    bmpf_path: &Path,
167    bmp_path: &Path,
168    fnt_path: &Path,
169    png_path: &Path,
170    stem: &str,
171    _input_root: &Path,
172) -> Result<()> {
173    // Read and parse .bmpf
174    let bmpf_data = fs::read(bmpf_path)
175        .with_context(|| format!("reading {:?}", bmpf_path))?;
176    let bmpf = BmpfFont::parse(&bmpf_data)
177        .map_err(|e| anyhow::anyhow!("invalid .bmpf {:?}: {e}", bmpf_path))?;
178
179    // Load the matching .bmp as RGBA
180    let img = image::ImageReader::open(bmp_path)
181        .with_context(|| format!("opening {:?}", bmp_path))?
182        .decode()
183        .with_context(|| format!("decoding {:?}", bmp_path))?;
184    let rgba = img.to_rgba8();
185    let (img_w, img_h) = rgba.dimensions();
186    let pixels = rgba.into_raw();
187
188    // Build font atlas (scan glyphs, match to bmpf chars)
189    let atlas = build_font_atlas(&pixels, img_w, img_h, &bmpf)?;
190
191    // Always convert the BMP to PNG (with magenta→transparent)
192    convert_bmp_to_png(bmp_path, png_path)?;
193
194    // Compute relative path from .fnt's directory to .png for the .fnt reference
195    let png_rel = if let Some(fnt_parent) = fnt_path.parent() {
196        pathdiff::diff_paths(png_path, fnt_parent)
197            .unwrap_or_else(|| PathBuf::from(stem).with_extension("png"))
198    } else {
199        PathBuf::from(stem).with_extension("png")
200    };
201
202    let fnt_content = generate_bmfont(&atlas, stem, png_rel.to_string_lossy().as_ref());
203    fs::write(fnt_path, &fnt_content)
204        .with_context(|| format!("writing {:?}", fnt_path))?;
205
206    Ok(())
207}
208
209/// Convert a `.s2` map file to `.osmap` (OpenStranded MAP) format.
210///
211/// Reads the binary `.s2` file, parses it with `openstranded_map_tool`,
212/// converts to the `.osmap` intermediate format, and writes as RON.
213pub fn convert_s2_to_osmap(
214    s2_path: &Path,
215    osmap_path: &Path,
216    input_root: &Path,
217) -> Result<String> {
218    let source_name = s2_path
219        .file_stem()
220        .and_then(|s| s.to_str())
221        .unwrap_or("map");
222
223    let data = fs::read(s2_path)
224        .with_context(|| format!("reading {:?}", s2_path))?;
225
226    let s2 = openstranded_map_tool::parse_s2(&data)
227        .map_err(|e| anyhow::anyhow!("failed to parse {:?}: {}", s2_path, e))?;
228
229    let source_filename = source_name.to_string() + ".s2";
230    let osmap = openstranded_map_tool::s2_to_osmap(s2, &source_filename);
231
232    if let Some(parent) = osmap_path.parent() {
233        fs::create_dir_all(parent)?;
234    }
235    openstranded_map_tool::save_osmap_ron(&osmap, osmap_path)?;
236
237    let orig_rel = super::util::relative_path(s2_path, input_root)
238        .to_string_lossy()
239        .to_string();
240    Ok(orig_rel)
241}