//! 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 video; 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 ManifestVideo { name: String, file: String, /// The exact command that produced this file. MISSION §6: a modder who /// dislikes the quality re-runs one line rather than reverse-engineering it. command: String, why: &'static str, } #[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, #[serde(skip_serializing_if = "Vec::is_empty")] videos: Vec, warnings: Vec, } /// The authored `pak entry index → name` map, keyed by archive path. /// /// Keyed by **entry**, not by the enumeration ordinal. The file itself always /// called the entry "the stronger locator"; it is now also the only stable one, /// because widening the enumeration to reach the splash renumbers the ordinals. 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, #[serde(default)] also_export: AlsoExport, } 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) } /// Extra pak entries to export that `is_build` does not accept, keyed by /// archive. AUTHORED, and each carries its own `why`. type AlsoExport = std::collections::BTreeMap>; fn load_also_export(authored: &Path) -> Result { let path = authored.join("screen_names.json"); if !path.exists() { return Ok(AlsoExport::new()); } #[derive(serde::Deserialize)] struct File { #[serde(default)] also_export: AlsoExport, } 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()))? .also_export) } /// Every RATC entry of a UI pak this exporter treats as a screen. /// /// The rule is `is_build` — a bundle with a `.rat` layout child — **plus an /// authored allow-list of entry indices**. /// /// The allow-list exists because the splash screens declare their sprites /// directly and have no `.rat` child, so `is_build` cannot see them, and **there /// is no content rule that would**. The RE agent looked: design size fails /// (every extra composable bundle sampled is 1280x720, the same as every /// screen) and element count fails (fragments run 2..15 elements in /// `GP_OPTIONS`/`GP_SAVE_LOAD` while the splash halves are 3 and 7 — the ranges /// overlap). So the splashes are located **by entry index**, which is a locator /// and not a claim, and each one says so in its own `why`. /// /// This is safe here rather than in general: in `GP_TITLE` the widened set adds /// exactly four bundles and all four are real screens, with zero fragments. In /// another archive it would not be, which is why this is an allow-list and not /// a widened predicate. fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap>) -> Vec<(usize, Vec)> { let mut out = Vec::new(); for (i, e) in ar.entries().iter().enumerate() { let Ok(bytes) = ar.read(e) else { continue }; let allowed = also.is_some_and(|m| m.contains_key(&i.to_string())); if ui_layout::is_build(&bytes) || allowed { 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/3", 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 also = load_also_export(authored_dir)?; let archive_also = also.get(archive); let builds = screen_builds(&ar, archive_also); 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() { // Keyed by ENTRY, not by the ordinal: widening the enumeration to reach // the splash renumbers ordinals, and a name that moves when the rule // changes is not a name. let key = entry.to_string(); let named = archive_names .and_then(|m| m.get(&key)) .or_else(|| archive_also.and_then(|m| m.get(&key))); let (name, name_source, why) = match named { 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_{entry: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, }); } // MISSION §6: the boot intro and the one new-game intro only. let mut videos = Vec::new(); for m in video::MOVIES { match video::transcode(disc, out, m)? { Some(t) => { println!(" video {} -> {}", m.src, t.file); videos.push(ManifestVideo { name: t.name, file: t.file, command: t.command, why: t.why, }); } None => println!(" video {} not on this disc -- skipped", m.src), } } let manifest = Manifest { format: "sylpheed.manifest/1", exporter: EXPORTER, formats_rev: FORMATS_REV, disc: disc.display().to_string(), screens, videos, warnings: vec![ "P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive." .into(), "The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \ layout child, so `is_build` cannot see them and no content rule can: element \ count and design size both overlap with two-element fragments in other archives. \ They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \ which is a locator and not a claim -- see each one's name_why." .into(), ], }; std::fs::write( out.join("manifest.json"), format!("{}\n", serde_json::to_string_pretty(&manifest)?), )?; println!("wrote {}/manifest.json", out.display()); Ok(()) }