This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Sylpheed port agent 8dd0577e01 export: P0 — GP_TITLE's screens and sprites into the open tree
`sylpheed-export export` reads `dat/GP_TITLE.pak`, enumerates its twelve screen
builds, and writes each as one `sylpheed.screen/2` document with its sprite PNGs
beside it. `sylpheed-export check` validates that tree against docs/FORMAT.md
with no disc in hand — the P0 gate is "validates against FORMAT.md", which is
not something anyone can confirm by reading, so it is a program.

Two readings from FORMAT v1 turned out to be wrong and are corrected here rather
than carried:

* The focus sprite does NOT come from the element's `opt ` link. That was
  measured and refuted upstream, and this export shows why plainly: on the main
  menu `opt ` chains ptloop01 -> ptloop02 -> ptbtn01, two decorations and then a
  button. The highlight pairs by sprite NAME instead (ptbtn01.t32 <->
  ptbtn01f.t32), which is the convention HANDOFF blesses and which resolves all
  five main-menu buttons. The raw link is still exported, renamed `opt_link` so
  nothing downstream mistakes it for navigation.
* There are TWO modulate colours in different byte orders, and they multiply.
  v1's single `#rrggbbaa` could not carry both and silently dropped the alpha
  that every fade ramps. They are now `tint_rgba` and `fade_argb`, with the byte
  order in the key name, because getting it backwards is silent and reads as an
  art bug rather than a parse bug.

The exporter takes exactly one authored input: `authored/screen_names.json`,
because the disc does not name its builds and "build 5 is the main menu" is a
measurement (HANDOFF Q2), not a field. Every name it applies is stamped
`name_source: "authored"` with the evidence in `name_why`, and `check` rejects
an authored name that has no `why` — so the derived tree stays honest about
which of its fields is a decision.

Sprites are per screen, not a flat pool: `main_menu` and `extras` both ship a
`ptbase.t32` and they are different pictures.

Checked, not assumed:

* two exports of the same disc are byte-identical;
* five mutations of a valid main_menu.json — a broken paint_order permutation,
  a dangling focus_sprite, a reversed buttons list, a `#rrggbbaa` colour and an
  invented name_source — are each caught with a specific message.

`t` stays raw. Q1 is answered, but the seconds conversion is measured off the
running game and its own finding flags the frame-rate measurement as the part
worth re-testing; if the game presents at 60 Hz every duration halves. One
constant, at P2, in a file that says it is a decision.
2026-08-28 18:43:52 +00:00

219 lines
7.2 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 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 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>,
warnings: Vec<String>,
}
/// The authored `build index → name` map, keyed by archive path.
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,
}
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)
}
/// 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<u8>)> {
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(())
}