openstranded-s2mod-tool 0.2.3

Convert original Stranded II mods to the .s2mod Content Pack format
Documentation
// openstranded-s2mod-tool — convert Stranded II mods to .s2mod format
// Copyright (C) 2025  openstranded-s2mod-tool contributors
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program.  If not, see <https://www.gnu.org/licenses/>.

//! Script handling for extracted `.inf` scripts, standalone `.s2s` transpilation,
//! dialogue parsing, and sectioned file conversion.

use std::fmt::Write as FmtWrite;
use std::fs;
use std::path::Path;

use anyhow::{anyhow, Context, Result};
use serde::Serialize;

use crate::scanner::S2sRefType;
use crate::util::relative_path;

/// Fallback when .inf parsing fails: preserve the raw source in .ron format
/// so the file still appears in the manifest registry.
#[derive(Serialize)]
pub struct InfRawSource {
    /// Human-readable parse error message
    #[serde(rename = "_parse_error")]
    pub parse_error: String,
    /// Original file contents (lossy UTF-8 / Latin-1 transcoded)
    pub raw_content: String,
}

/// Format a path to the .lua script for a given sequential index,
/// relative to the .ron file.
pub fn format_script_ref(ron_rel: &Path, seq_index: usize) -> String {
    format!("{}.{}.lua", ron_rel.to_string_lossy(), seq_index)
}

/// Extract an embedded .s2s script from an .inf entry, transpile it to Lua,
/// and write it alongside the .ron file as `<ron_stem>.<seq_index>.lua`.
///
/// If s2s2lua parsing fails, the raw script is still written as a Lua
/// comment block so no data is lost.
pub fn write_script_lua(
    inf_path: &Path,
    input_root: &Path,
    stage_root: &Path,
    seq_index: usize,
    script_s2s: &str,
    debug: bool,
) -> Result<()> {
    let ron_rel = relative_path(inf_path, input_root).with_extension("ron");

    // Lua script path: <ron_path>.<seq_index>.lua
    let lua_name = format!("{}.{}.lua", ron_rel.to_string_lossy(), seq_index);
    let lua_path = stage_root.join(&lua_name);

    if let Some(parent) = lua_path.parent() {
        fs::create_dir_all(parent)?;
    }

    let lua = match s2s2lua::S2sParser::parse(script_s2s) {
        Ok(script) => s2s2lua::LuaGenerator::generate(&script, s2s2lua::GenOptions::default())
            .unwrap_or_else(|_| {
                // If generation fails, wrap raw script as comment
                format!("--[[ s2s2lua generation failed\n{}\n]]", script_s2s)
            }),
        Err(e) => {
            // If parsing fails, wrap raw script as comment
            format!("--[[ s2s2lua parse error: {}\n{}\n]]", e, script_s2s)
        }
    };

    fs::write(&lua_path, &lua)
        .with_context(|| format!("writing {:?}", lua_path))?;

    if debug {
        eprintln!("    script {}{:?}", seq_index, lua_path);
    }

    Ok(())
}

/// Parse dialog data from a .s2s file and generate a Lua table as a string.
///
/// Dialog files have this structure:
/// ```s2s
/// page=<name>
/// title=<NPC Name>
/// text=start
/// <dialog text>
/// text=end
/// button=<target>,<button text>
/// trade=start
/// sell=<item_id>,<count>
/// buy=<item_id>,<count>
/// trade=end
/// ```
pub fn parse_dialogue_to_lua(content: &str) -> Result<String> {
    let mut lua = String::from("-- Generated by openstranded-s2mod-tool\nreturn {\n  pages = {\n");
    let mut current_page: Option<String> = None;
    let mut current_title = String::new();
    let mut current_text = String::new();
    let mut in_text = false;
    let mut buttons: Vec<(String, String)> = Vec::new(); // (target, label)
    let mut trade_buy: Vec<(String, String)> = Vec::new();
    let mut trade_sell: Vec<(String, String)> = Vec::new();
    let mut in_trade = false;

    let flush_page = |lua: &mut String,
                      page: &Option<String>,
                      title: &str,
                      text: &str,
                      buttons: &[(String, String)],
                      buy: &[(String, String)],
                      sell: &[(String, String)]|
     -> Result<()> {
        let page_name = page.as_ref().ok_or_else(|| anyhow!("page without name"))?;
        writeln!(lua, "    [\"{}\"] = {{", page_name)?;
        if !title.is_empty() {
            writeln!(lua, "      title = \"{}\",", title.replace('\\', "\\\\").replace('"', "\\\""))?;
        }
        if !text.is_empty() {
            writeln!(lua, "      text = [[{}]],", text)?;
        }
        if !buy.is_empty() || !sell.is_empty() {
            lua.push_str("      trade = {\n");
            for (id, count) in buy {
                writeln!(lua, "        buy = {{ id = {}, count = {} }},", id, count)?;
            }
            for (id, count) in sell {
                writeln!(lua, "        sell = {{ id = {}, count = {} }},", id, count)?;
            }
            lua.push_str("      },\n");
        }
        if !buttons.is_empty() {
            lua.push_str("      buttons = {\n");
            for (target, label) in buttons {
                writeln!(
                    lua,
                    "        {{ target = \"{}\", label = \"{}\" }},",
                    target.replace('\\', "\\\\").replace('"', "\\\""),
                    label.replace('\\', "\\\\").replace('"', "\\\""),
                )?;
            }
            lua.push_str("      },\n");
        }
        lua.push_str("    },\n");
        Ok(())
    };

    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with("//") {
            continue;
        }

        if let Some(page_name) = trimmed.strip_prefix("page=") {
            // Flush previous page
            if current_page.is_some() {
                flush_page(
                    &mut lua,
                    &current_page,
                    &current_title,
                    &current_text,
                    &buttons,
                    &trade_buy,
                    &trade_sell,
                )?;
            }
            current_page = Some(page_name.trim().to_string());
            current_title.clear();
            current_text.clear();
            buttons.clear();
            trade_buy.clear();
            trade_sell.clear();
            in_text = false;
            in_trade = false;
            continue;
        }

        if trimmed == "text=start" {
            in_text = true;
            continue;
        }
        if trimmed == "text=end" {
            in_text = false;
            continue;
        }

        if let Some(title_val) = trimmed.strip_prefix("title=") {
            current_title = title_val.trim().to_string();
            continue;
        }

        if trimmed == "trade=start" {
            in_trade = true;
            continue;
        }
        if trimmed == "trade=end" {
            in_trade = false;
            continue;
        }

        if in_text {
            if !current_text.is_empty() {
                current_text.push('\n');
            }
            current_text.push_str(line.trim_start());
            continue;
        }

        if in_trade {
            if let Some(sell_str) = trimmed.strip_prefix("sell=") {
                let parts: Vec<&str> = sell_str.splitn(2, ',').collect();
                let id = parts.first().unwrap_or(&"").trim().to_string();
                let count = parts.get(1).unwrap_or(&"1").trim().to_string();
                trade_sell.push((id, count));
            } else if let Some(buy_str) = trimmed.strip_prefix("buy=") {
                let parts: Vec<&str> = buy_str.splitn(2, ',').collect();
                let id = parts.first().unwrap_or(&"").trim().to_string();
                let count = parts.get(1).unwrap_or(&"1").trim().to_string();
                trade_buy.push((id, count));
            }
            continue;
        }

        if let Some(btn_str) = trimmed.strip_prefix("button=") {
            let parts: Vec<&str> = btn_str.splitn(2, ',').collect();
            let target = parts.first().unwrap_or(&"").trim().to_string();
            let label = parts.get(1).unwrap_or(&"").trim().to_string();
            buttons.push((target, label));
            continue;
        }
    }

    // Flush last page
    if current_page.is_some() {
        flush_page(
            &mut lua,
            &current_page,
            &current_title,
            &current_text,
            &buttons,
            &trade_buy,
            &trade_sell,
        )?;
    }

    lua.push_str("  },\n}\n");
    Ok(lua)
}

/// Split .s2s file content into sections by `//~` markers.
///
/// In Stranded II, files can be partitioned using `//~SectionName` at the
/// start of a line.  The `loadfile` command then loads only the named section.
/// Returns `(section_name, content)` pairs. The content before the first `//~`
/// marker is returned with an empty-string name.
pub fn split_into_sections(content: &str) -> Vec<(String, String)> {
    let mut sections: Vec<(String, String)> = Vec::new();
    let mut current_name = String::new();
    let mut current_lines: Vec<&str> = Vec::new();

    for line in content.lines() {
        if let Some(name) = line.strip_prefix("//~") {
            // Flush previous section
            if !current_lines.is_empty() || !current_name.is_empty() {
                sections.push((current_name.clone(), current_lines.join("\n")));
            }
            // Name is everything after //~, trimmed
            current_name = name.trim().to_string();
            current_lines.clear();
        } else {
            current_lines.push(line);
        }
    }
    // Flush last section
    if !current_lines.is_empty() || !current_name.is_empty() {
        sections.push((current_name, current_lines.join("\n")));
    }

    sections
}

/// Convert a sectioned .s2s file (with `//~` markers) to a Lua module.
///
/// The output is a Lua file that returns a table keyed by section name.
/// Text sections (referenced by `msgbox`) become strings.
/// Script sections (referenced by `button`, `addscript`, etc.) become
/// transpiled Lua functions. Sections not referenced at all are transpiled
/// as scripts.
pub fn convert_sectioned_file(
    content: &str,
    sections_refs: &std::collections::HashMap<String, Vec<S2sRefType>>,
) -> String {
    let mut lua = String::from("-- Generated by openstranded-s2mod-tool\nreturn {\n");
    let parts = split_into_sections(content);

    // Preamble (unnamed section before the first //~)
    for (name, body) in &parts {
        let key = if name.is_empty() {
            "[\"__preamble\"]".to_string()
        } else {
            format!("[\"{}\"]", name.replace('\\', "\\\\").replace('"', "\\\""))
        };

        // Determine the references for this section
        let refs_for_section = if name.as_str().is_empty() {
            vec![]
        } else {
            sections_refs
                .get(name.as_str())
                .cloned()
                .unwrap_or_default()
        };

        let is_msgbox = refs_for_section
            .iter()
            .any(|r| matches!(r, S2sRefType::Msgbox { .. }));
        let is_script = refs_for_section
            .iter()
            .any(|r| !matches!(r, S2sRefType::Msgbox { .. }));

        if is_msgbox && !is_script {
            // Pure text section → string
            writeln!(&mut lua, "  {} = [[{}]],", key, body).unwrap();
        } else {
            // Script section (or unreferenced) → transpile to Lua function
            match s2s2lua::S2sParser::parse(body) {
                Ok(ast) => {
                    let func_body = s2s2lua::LuaGenerator::generate(&ast, s2s2lua::GenOptions::default())
                        .unwrap_or_else(|_| format!("--[[ generation failed ]]\n{}", body));
                    writeln!(&mut lua, "  {} = function()\n{}  end,", key, func_body).unwrap();
                }
                Err(_) => {
                    // Parse failed — store as raw string so data isn't lost
                    writeln!(&mut lua, "  {} = [[{}]],", key, body).unwrap();
                }
            }
        }
    }

    lua.push_str("}\n");
    lua
}