The exporter transcodes ADV.wmv and S00A.wmv to Ogg Theora and records the exact ffmpeg command in the manifest, per MISSION §6, so a modder who dislikes the quality re-runs one line rather than reverse-engineering what was done. Quality was MEASURED, not judged: SSIM against the decoded source over a 10 s sample is 0.9863 / 0.9896 / 0.9924 at -q:v 6 / 8 / 10, and at 200 % zoom on the reel's hardest case -- fine serif text and soft gradients over near-black, where Theora breaks first -- q8 is indistinguishable. So MISSION §6's permitted FFmpeg-GDExtension fallback is NOT needed and is NOT being proposed. No new runtime dependency. -ac 2 because the source is 6-channel WMA Pro; that downmix is a decision, so it lives in the recorded command rather than in prose. Encoding is cached on a .cmd sidecar holding the command and the source size -- any change to either re-encodes. export/ is still regenerated wholesale; this is derived state validating derived state, not a hand-edit, and without it every re-export pays ~4 minutes to produce a byte-identical file. The player renders INTO the design SubViewport. Parenting it to the Boot node played the movie to the window instead, and every captured frame came out black -- which is worth more than a capture-bug note: a movie outside the 1280x720 design space is outside the coordinate system every screen is expressed in. (A) skips a movie, because Q9 measured that (title at 57 s vs a 193 s baseline). NOT VERIFIED, and stated as such: audible playback. This container has no audio device and Godot falls back to the dummy driver. The Vorbis stream exists, is 2-channel and decodes; whether Godot emits it is unconfirmed.
304 lines
11 KiB
Rust
304 lines
11 KiB
Rust
//! 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<String>,
|
|
}
|
|
|
|
#[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<ManifestScreen>,
|
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
|
videos: Vec<ManifestVideo>,
|
|
warnings: Vec<String>,
|
|
}
|
|
|
|
/// 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<String, std::collections::BTreeMap<String, NameEntry>>;
|
|
|
|
#[derive(serde::Deserialize)]
|
|
struct NameEntry {
|
|
name: String,
|
|
#[serde(default)]
|
|
why: Option<String>,
|
|
}
|
|
|
|
fn load_names(authored: &Path) -> Result<NameMap> {
|
|
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::<File>(&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<String, std::collections::BTreeMap<String, NameEntry>>;
|
|
|
|
fn load_also_export(authored: &Path) -> Result<AlsoExport> {
|
|
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::<File>(&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<String, NameEntry>>)
|
|
-> Vec<(usize, Vec<u8>)>
|
|
{
|
|
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/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 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(())
|
|
}
|