//! Convert a Project Sylpheed disc into the open asset tree the Godot port reads. //! //! The one rule this binary exists to enforce: **Godot never sees a disc format.** //! Everything proprietary is decoded here and written out as JSON, PNG, Ogg //! Vorbis and Ogg Theora, so the runtime — and anyone modding it — reads formats //! a person can open. //! //! The output tree is **derived**: regenerated wholesale, never hand-edited. The //! only thing this program takes from `authored/` is the screen-name map, and //! every name it applies is stamped `name_source: "authored"` in the file it //! lands in, so the export stays auditable against the disc. //! //! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope. mod check; mod screen; use anyhow::{Context, Result}; use clap::Parser; use serde::Serialize; use std::path::{Path, PathBuf}; use sylpheed_formats::{pak::PakArchive, ui_layout}; /// The revision of `sylpheed-formats` this exporter is pinned to, recorded in /// every file it writes. Keep in step with `Cargo.toml` — it is what makes an /// export auditable a month later. const FORMATS_REV: &str = "8b6dbcf"; const EXPORTER: &str = concat!("sylpheed-export ", env!("CARGO_PKG_VERSION")); #[derive(Parser)] #[command(about, version)] struct Args { #[command(subcommand)] cmd: Cmd, } #[derive(clap::Subcommand)] enum Cmd { /// Convert the disc into `export/`. Rewrites the tree wholesale. Export { /// Extracted disc root (the directory holding `dat/` and `hidden/`). #[arg(long, env = "SYLPHEED_DISC")] disc: PathBuf, /// Output tree. Rewritten wholesale — never hand-edit it. #[arg(long, default_value = "export")] out: PathBuf, /// Authored decisions applied during export (currently the screen names). #[arg(long, default_value = "authored")] authored: PathBuf, }, /// Validate an export tree against `docs/FORMAT.md`, with no disc in hand. /// /// Reads the tree the way the Godot project will: as a stranger, with no /// access to the disc, the decoders or this exporter's internals. Check { #[arg(long, default_value = "export")] out: PathBuf, }, } #[derive(Serialize)] struct ManifestScreen { name: String, file: String, sprites: usize, #[serde(skip_serializing_if = "Vec::is_empty")] missing_sprites: Vec, } #[derive(Serialize)] struct Manifest { format: &'static str, exporter: &'static str, /// Which decoders produced this export. Pinned by revision, not floated. formats_rev: &'static str, disc: String, screens: Vec, warnings: Vec, } /// The authored `build index → name` map, keyed by archive path. type NameMap = std::collections::BTreeMap>; #[derive(serde::Deserialize)] struct NameEntry { name: String, #[serde(default)] why: Option, } fn load_names(authored: &Path) -> Result { let path = authored.join("screen_names.json"); if !path.exists() { return Ok(NameMap::new()); } #[derive(serde::Deserialize)] struct File { archives: NameMap, } let raw = std::fs::read_to_string(&path) .with_context(|| format!("read {}", path.display()))?; Ok(serde_json::from_str::(&raw) .with_context(|| format!("parse {}", path.display()))? .archives) } /// Every RATC entry of a UI pak that parses as a screen build. /// /// The filter is `is_build` — a bundle with a `.rat` layout child. The developer /// splash declares its sprites directly and has none, so it is invisible here; /// that is P3's problem and is recorded as a manifest warning rather than /// silently widened. fn screen_builds(ar: &PakArchive) -> Vec<(usize, Vec)> { let mut out = Vec::new(); for (i, e) in ar.entries().iter().enumerate() { let Ok(bytes) = ar.read(e) else { continue }; if ui_layout::is_build(&bytes) { out.push((i, bytes)); } } out } fn main() -> Result<()> { match Args::parse().cmd { Cmd::Export { disc, out, authored, } => run_export(&disc, &out, &authored), Cmd::Check { out } => { let n = check::run(&out)?; println!("{} screen(s) in {} validate against sylpheed.screen/2", n, out.display()); Ok(()) } } } fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> { let names = load_names(authored_dir)?; // Derived output is regenerated wholesale: clear it, so a screen that stops // being exported stops existing rather than lingering as a stale file that // still validates. if out.exists() { std::fs::remove_dir_all(&out).context("clear the output tree")?; } std::fs::create_dir_all(&out)?; let archive = "dat/GP_TITLE.pak"; let pak = disc.join(archive); let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?; let builds = screen_builds(&ar); println!("{archive}: {} screen build(s)", builds.len()); let archive_names = names.get(archive); let mut screens = Vec::new(); for (build_idx, (entry, bytes)) in builds.iter().enumerate() { let authored = archive_names.and_then(|m| m.get(&build_idx.to_string())); let (name, name_source, why) = match authored { Some(e) => (e.name.clone(), "authored", e.why.clone()), // Nobody has identified this build. Emit a stable synthetic id and // say in the file that the name is not a recovered one. None => (format!("build_{build_idx:02}"), "index", None), }; let ex = screen::export_build( &out, archive, *entry, build_idx, bytes, &name, name_source, why, "title", EXPORTER, FORMATS_REV, ) .with_context(|| format!("export build {build_idx} of {archive}"))?; println!( " [{build_idx}] entry {entry:<3} -> {} ({} sprites{})", ex.json_path, ex.sprites, if ex.missing.is_empty() { String::new() } else { format!(", {} missing", ex.missing.len()) } ); screens.push(ManifestScreen { name: ex.name, file: ex.json_path, sprites: ex.sprites, missing_sprites: ex.missing, }); } let manifest = Manifest { format: "sylpheed.manifest/1", exporter: EXPORTER, formats_rev: FORMATS_REV, disc: disc.display().to_string(), screens, warnings: vec![ "P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive." .into(), "The developer-logo splash is not here: it declares its sprites directly and has \ no .rat layout child, so `is_build` does not see it. P3." .into(), ], }; std::fs::write( out.join("manifest.json"), format!("{}\n", serde_json::to_string_pretty(&manifest)?), )?; println!("wrote {}/manifest.json", out.display()); Ok(()) }