Skip to main content

openstranded_s2mod_tool/
script.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//! Script handling for extracted `.inf` scripts, standalone `.s2s` transpilation,
18//! dialogue parsing, and sectioned file conversion.
19
20use std::fmt::Write as FmtWrite;
21use std::fs;
22use std::path::Path;
23
24use anyhow::{anyhow, Context, Result};
25use serde::Serialize;
26
27use crate::scanner::S2sRefType;
28use crate::util::relative_path;
29
30/// Fallback when .inf parsing fails: preserve the raw source in .ron format
31/// so the file still appears in the manifest registry.
32#[derive(Serialize)]
33pub struct InfRawSource {
34    /// Human-readable parse error message
35    #[serde(rename = "_parse_error")]
36    pub parse_error: String,
37    /// Original file contents (lossy UTF-8 / Latin-1 transcoded)
38    pub raw_content: String,
39}
40
41/// Format a path to the .lua script for a given sequential index,
42/// relative to the .ron file.
43pub fn format_script_ref(ron_rel: &Path, seq_index: usize) -> String {
44    format!("{}.{}.lua", ron_rel.to_string_lossy(), seq_index)
45}
46
47/// Extract an embedded .s2s script from an .inf entry, transpile it to Lua,
48/// and write it alongside the .ron file as `<ron_stem>.<seq_index>.lua`.
49///
50/// If s2s2lua parsing fails, the raw script is still written as a Lua
51/// comment block so no data is lost.
52pub fn write_script_lua(
53    inf_path: &Path,
54    input_root: &Path,
55    stage_root: &Path,
56    seq_index: usize,
57    script_s2s: &str,
58    debug: bool,
59) -> Result<()> {
60    let ron_rel = relative_path(inf_path, input_root).with_extension("ron");
61
62    // Lua script path: <ron_path>.<seq_index>.lua
63    let lua_name = format!("{}.{}.lua", ron_rel.to_string_lossy(), seq_index);
64    let lua_path = stage_root.join(&lua_name);
65
66    if let Some(parent) = lua_path.parent() {
67        fs::create_dir_all(parent)?;
68    }
69
70    let lua = match s2s2lua::S2sParser::parse(script_s2s) {
71        Ok(script) => s2s2lua::LuaGenerator::generate(&script, s2s2lua::GenOptions::default())
72            .unwrap_or_else(|_| {
73                // If generation fails, wrap raw script as comment
74                format!("--[[ s2s2lua generation failed\n{}\n]]", script_s2s)
75            }),
76        Err(e) => {
77            // If parsing fails, wrap raw script as comment
78            format!("--[[ s2s2lua parse error: {}\n{}\n]]", e, script_s2s)
79        }
80    };
81
82    fs::write(&lua_path, &lua)
83        .with_context(|| format!("writing {:?}", lua_path))?;
84
85    if debug {
86        eprintln!("    script {} → {:?}", seq_index, lua_path);
87    }
88
89    Ok(())
90}
91
92/// Parse dialog data from a .s2s file and generate a Lua table as a string.
93///
94/// Dialog files have this structure:
95/// ```s2s
96/// page=<name>
97/// title=<NPC Name>
98/// text=start
99/// <dialog text>
100/// text=end
101/// button=<target>,<button text>
102/// trade=start
103/// sell=<item_id>,<count>
104/// buy=<item_id>,<count>
105/// trade=end
106/// ```
107pub fn parse_dialogue_to_lua(content: &str) -> Result<String> {
108    let mut lua = String::from("-- Generated by openstranded-s2mod-tool\nreturn {\n  pages = {\n");
109    let mut current_page: Option<String> = None;
110    let mut current_title = String::new();
111    let mut current_text = String::new();
112    let mut in_text = false;
113    let mut buttons: Vec<(String, String)> = Vec::new(); // (target, label)
114    let mut trade_buy: Vec<(String, String)> = Vec::new();
115    let mut trade_sell: Vec<(String, String)> = Vec::new();
116    let mut in_trade = false;
117
118    let flush_page = |lua: &mut String,
119                      page: &Option<String>,
120                      title: &str,
121                      text: &str,
122                      buttons: &[(String, String)],
123                      buy: &[(String, String)],
124                      sell: &[(String, String)]|
125     -> Result<()> {
126        let page_name = page.as_ref().ok_or_else(|| anyhow!("page without name"))?;
127        writeln!(lua, "    [\"{}\"] = {{", page_name)?;
128        if !title.is_empty() {
129            writeln!(lua, "      title = \"{}\",", title.replace('\\', "\\\\").replace('"', "\\\""))?;
130        }
131        if !text.is_empty() {
132            writeln!(lua, "      text = [[{}]],", text)?;
133        }
134        if !buy.is_empty() || !sell.is_empty() {
135            lua.push_str("      trade = {\n");
136            for (id, count) in buy {
137                writeln!(lua, "        buy = {{ id = {}, count = {} }},", id, count)?;
138            }
139            for (id, count) in sell {
140                writeln!(lua, "        sell = {{ id = {}, count = {} }},", id, count)?;
141            }
142            lua.push_str("      },\n");
143        }
144        if !buttons.is_empty() {
145            lua.push_str("      buttons = {\n");
146            for (target, label) in buttons {
147                writeln!(
148                    lua,
149                    "        {{ target = \"{}\", label = \"{}\" }},",
150                    target.replace('\\', "\\\\").replace('"', "\\\""),
151                    label.replace('\\', "\\\\").replace('"', "\\\""),
152                )?;
153            }
154            lua.push_str("      },\n");
155        }
156        lua.push_str("    },\n");
157        Ok(())
158    };
159
160    for line in content.lines() {
161        let trimmed = line.trim();
162        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
163            continue;
164        }
165
166        if let Some(page_name) = trimmed.strip_prefix("page=") {
167            // Flush previous page
168            if current_page.is_some() {
169                flush_page(
170                    &mut lua,
171                    &current_page,
172                    &current_title,
173                    &current_text,
174                    &buttons,
175                    &trade_buy,
176                    &trade_sell,
177                )?;
178            }
179            current_page = Some(page_name.trim().to_string());
180            current_title.clear();
181            current_text.clear();
182            buttons.clear();
183            trade_buy.clear();
184            trade_sell.clear();
185            in_text = false;
186            in_trade = false;
187            continue;
188        }
189
190        if trimmed == "text=start" {
191            in_text = true;
192            continue;
193        }
194        if trimmed == "text=end" {
195            in_text = false;
196            continue;
197        }
198
199        if let Some(title_val) = trimmed.strip_prefix("title=") {
200            current_title = title_val.trim().to_string();
201            continue;
202        }
203
204        if trimmed == "trade=start" {
205            in_trade = true;
206            continue;
207        }
208        if trimmed == "trade=end" {
209            in_trade = false;
210            continue;
211        }
212
213        if in_text {
214            if !current_text.is_empty() {
215                current_text.push('\n');
216            }
217            current_text.push_str(line.trim_start());
218            continue;
219        }
220
221        if in_trade {
222            if let Some(sell_str) = trimmed.strip_prefix("sell=") {
223                let parts: Vec<&str> = sell_str.splitn(2, ',').collect();
224                let id = parts.first().unwrap_or(&"").trim().to_string();
225                let count = parts.get(1).unwrap_or(&"1").trim().to_string();
226                trade_sell.push((id, count));
227            } else if let Some(buy_str) = trimmed.strip_prefix("buy=") {
228                let parts: Vec<&str> = buy_str.splitn(2, ',').collect();
229                let id = parts.first().unwrap_or(&"").trim().to_string();
230                let count = parts.get(1).unwrap_or(&"1").trim().to_string();
231                trade_buy.push((id, count));
232            }
233            continue;
234        }
235
236        if let Some(btn_str) = trimmed.strip_prefix("button=") {
237            let parts: Vec<&str> = btn_str.splitn(2, ',').collect();
238            let target = parts.first().unwrap_or(&"").trim().to_string();
239            let label = parts.get(1).unwrap_or(&"").trim().to_string();
240            buttons.push((target, label));
241            continue;
242        }
243    }
244
245    // Flush last page
246    if current_page.is_some() {
247        flush_page(
248            &mut lua,
249            &current_page,
250            &current_title,
251            &current_text,
252            &buttons,
253            &trade_buy,
254            &trade_sell,
255        )?;
256    }
257
258    lua.push_str("  },\n}\n");
259    Ok(lua)
260}
261
262/// Split .s2s file content into sections by `//~` markers.
263///
264/// In Stranded II, files can be partitioned using `//~SectionName` at the
265/// start of a line.  The `loadfile` command then loads only the named section.
266/// Returns `(section_name, content)` pairs. The content before the first `//~`
267/// marker is returned with an empty-string name.
268pub fn split_into_sections(content: &str) -> Vec<(String, String)> {
269    let mut sections: Vec<(String, String)> = Vec::new();
270    let mut current_name = String::new();
271    let mut current_lines: Vec<&str> = Vec::new();
272
273    for line in content.lines() {
274        if let Some(name) = line.strip_prefix("//~") {
275            // Flush previous section
276            if !current_lines.is_empty() || !current_name.is_empty() {
277                sections.push((current_name.clone(), current_lines.join("\n")));
278            }
279            // Name is everything after //~, trimmed
280            current_name = name.trim().to_string();
281            current_lines.clear();
282        } else {
283            current_lines.push(line);
284        }
285    }
286    // Flush last section
287    if !current_lines.is_empty() || !current_name.is_empty() {
288        sections.push((current_name, current_lines.join("\n")));
289    }
290
291    sections
292}
293
294/// Convert a sectioned .s2s file (with `//~` markers) to a Lua module.
295///
296/// The output is a Lua file that returns a table keyed by section name.
297/// Text sections (referenced by `msgbox`) become strings.
298/// Script sections (referenced by `button`, `addscript`, etc.) become
299/// transpiled Lua functions. Sections not referenced at all are transpiled
300/// as scripts.
301pub fn convert_sectioned_file(
302    content: &str,
303    sections_refs: &std::collections::HashMap<String, Vec<S2sRefType>>,
304) -> String {
305    let mut lua = String::from("-- Generated by openstranded-s2mod-tool\nreturn {\n");
306    let parts = split_into_sections(content);
307
308    // Preamble (unnamed section before the first //~)
309    for (name, body) in &parts {
310        let key = if name.is_empty() {
311            "[\"__preamble\"]".to_string()
312        } else {
313            format!("[\"{}\"]", name.replace('\\', "\\\\").replace('"', "\\\""))
314        };
315
316        // Determine the references for this section
317        let refs_for_section = if name.as_str().is_empty() {
318            vec![]
319        } else {
320            sections_refs
321                .get(name.as_str())
322                .cloned()
323                .unwrap_or_default()
324        };
325
326        let is_msgbox = refs_for_section
327            .iter()
328            .any(|r| matches!(r, S2sRefType::Msgbox { .. }));
329        let is_script = refs_for_section
330            .iter()
331            .any(|r| !matches!(r, S2sRefType::Msgbox { .. }));
332
333        if is_msgbox && !is_script {
334            // Pure text section → string
335            writeln!(&mut lua, "  {} = [[{}]],", key, body).unwrap();
336        } else {
337            // Script section (or unreferenced) → transpile to Lua function
338            match s2s2lua::S2sParser::parse(body) {
339                Ok(ast) => {
340                    let func_body = s2s2lua::LuaGenerator::generate(&ast, s2s2lua::GenOptions::default())
341                        .unwrap_or_else(|_| format!("--[[ generation failed ]]\n{}", body));
342                    writeln!(&mut lua, "  {} = function()\n{}  end,", key, func_body).unwrap();
343                }
344                Err(_) => {
345                    // Parse failed — store as raw string so data isn't lost
346                    writeln!(&mut lua, "  {} = [[{}]],", key, body).unwrap();
347                }
348            }
349        }
350    }
351
352    lua.push_str("}\n");
353    lua
354}