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.
This commit is contained in:
Sylpheed port agent
2026-08-28 18:43:52 +00:00
parent b07381d443
commit 8dd0577e01
5 changed files with 873 additions and 15 deletions

View File

@@ -0,0 +1,45 @@
{
"format": "sylpheed.screen_names/1",
"_": [
"Which GP_TITLE build is which screen. AUTHORED: the disc does not name its",
"builds, so every name here is a decision. The identifications come from",
"HANDOFF Q2 (ui-title-build-map.md), which measured four of them against",
"framebuffer captures of the running game; the exporter stamps the name into",
"the screen file with name_source: \"authored\" so a reader can tell a",
"recovered name from an invented one.",
"",
"`build` is the index into the pak's list of screen builds -- what",
"`sylpheed-cli screen --build N` takes -- and is stable as long as the",
"enumeration rule is. The screen file also records the pak entry index,",
"which is the stronger locator.",
"",
"Delete an entry here the day the RE agent decodes a name field."
],
"archives": {
"dat/GP_TITLE.pak": {
"2": {
"name": "press_start",
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture."
},
"3": { "name": "press_start_jp", "why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need." },
"4": {
"name": "title",
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
},
"5": {
"name": "main_menu",
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)"
},
"6": {
"name": "extras",
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture."
},
"7": { "name": "title_jp", "why": "HANDOFF Q2: the Japanese twin of build 4." },
"8": { "name": "main_menu_jp", "why": "HANDOFF Q2: the Japanese twin of build 5." },
"9": { "name": "extras_jp", "why": "HANDOFF Q2: the Japanese twin of build 6." }
}
},
"unnamed": {
"dat/GP_TITLE.pak": "Builds 0/1 and 10/11 are a DELTASABER / SYLPHEED A.I. plate that was never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their build index rather than a name we would be inventing."
}
}

View File

@@ -18,5 +18,5 @@ sylpheed-formats = { git = "https://git.mc02.dev/fabi/Syplheed-Reborn.git", rev
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"
clap = { version = "4", features = ["derive"] }
clap = { version = "4", features = ["derive", "env"] }
image = { version = "0.25", default-features = false, features = ["png"] }

View File

@@ -0,0 +1,277 @@
//! `sylpheed-export check` — validate an export tree against `docs/FORMAT.md`.
//!
//! This is the executable form of FORMAT.md, and the reason it exists is that
//! "the export is correct" is otherwise an assertion. It reads `export/` the way
//! the Godot project will — as a stranger, with no access to the disc, the
//! decoders or this exporter's internals — and fails on anything a consumer
//! could not act on:
//!
//! * a document whose `format` is not the version this build writes;
//! * a required field missing, or a colour that is not `0x` + 8 hex digits;
//! * a `paint_order` that is not a permutation of the element indices;
//! * a `buttons` entry naming an element that is not a button, or out of
//! resting-Y order;
//! * a sprite path that does not exist, or a PNG that does not decode;
//! * a name presented as recovered when it was authored.
//!
//! It deliberately does **not** check that the export matches the disc. That is
//! what `sylpheed-cli screen render` is for.
use anyhow::{bail, Result};
use serde_json::Value;
use std::path::Path;
const SCREEN_FORMAT: &str = "sylpheed.screen/2";
const MANIFEST_FORMAT: &str = "sylpheed.manifest/1";
struct Ctx {
file: String,
errors: Vec<String>,
}
impl Ctx {
fn err(&mut self, msg: impl Into<String>) {
self.errors.push(format!("{}: {}", self.file, msg.into()));
}
fn require<'a>(&mut self, v: &'a Value, key: &str) -> Option<&'a Value> {
match v.get(key) {
Some(Value::Null) | None => {
self.err(format!("missing required field `{key}`"));
None
}
Some(x) => Some(x),
}
}
}
/// A colour is exported as `0x` + 8 hex digits, with its byte order in the key
/// name. Anything else means a consumer has to guess, which is the whole thing
/// the format exists to prevent.
fn is_hex32(v: Option<&Value>) -> bool {
v.and_then(Value::as_str)
.is_some_and(|s| s.len() == 10 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit()))
}
fn check_pose(c: &mut Ctx, where_: &str, p: &Value) {
for (key, want_len) in [("pos", 2usize), ("scale", 2)] {
match p.get(key).and_then(Value::as_array) {
Some(a) if a.len() == want_len && a.iter().all(Value::is_i64) => {}
_ => c.err(format!("{where_}: `{key}` must be {want_len} integers")),
}
}
for key in ["tint_rgba", "fade_argb"] {
if !is_hex32(p.get(key)) {
c.err(format!("{where_}: `{key}` must be 0x + 8 hex digits"));
}
}
}
fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()> {
let raw = std::fs::read_to_string(root.join(rel))?;
let v: Value = serde_json::from_str(&raw)?;
let mut c = Ctx {
file: rel.to_string(),
errors: Vec::new(),
};
if v.get("format").and_then(Value::as_str) != Some(SCREEN_FORMAT) {
c.err(format!(
"format is {:?}, expected {SCREEN_FORMAT:?}",
v.get("format")
));
}
for key in ["exporter", "formats_rev", "name", "name_source"] {
c.require(&v, key);
}
// Rule 2 of the format: a modder must be able to tell a recovered name from
// an invented one, so the provenance is mandatory and closed.
match v.get("name_source").and_then(Value::as_str) {
Some("authored") => {
if v.get("name_why").and_then(Value::as_str).is_none_or(str::is_empty) {
c.err("name_source is `authored` but there is no `name_why`");
}
}
Some("index") => {}
other => c.err(format!("name_source must be `authored` or `index`, got {other:?}")),
}
if let Some(s) = v.get("source") {
for key in ["archive", "entry", "build"] {
if s.get(key).is_none() {
c.err(format!("source is missing `{key}`"));
}
}
} else {
c.err("missing required field `source`");
}
match v.get("design").and_then(Value::as_array) {
Some(d) if d.len() == 2 && d.iter().all(Value::is_u64) => {}
_ => c.err("`design` must be two positive integers"),
}
let Some(elements) = v.get("elements").and_then(Value::as_array) else {
c.err("missing required field `elements`");
errors.append(&mut c.errors);
return Ok(());
};
let mut indices = Vec::new();
let mut buttons_by_y: Vec<(i64, String)> = Vec::new();
for (i, el) in elements.iter().enumerate() {
let id = el.get("id").and_then(Value::as_str).unwrap_or("<no id>").to_string();
let at = format!("element {i} ({id})");
for key in ["index", "id", "declared", "role", "kind_raw", "pivot", "layer_source", "keyframes"] {
if el.get(key).is_none() {
c.err(format!("{at}: missing `{key}`"));
}
}
let Some(idx) = el.get("index").and_then(Value::as_u64) else {
c.err(format!("{at}: `index` is not an integer"));
continue;
};
if idx as usize != i {
c.err(format!("{at}: `index` {idx} does not match its position {i}"));
}
indices.push(idx as usize);
let role = el.get("role").and_then(Value::as_str).unwrap_or("");
if !matches!(role, "button" | "decoration" | "primitive" | "unknown") {
c.err(format!("{at}: role {role:?} is not one FORMAT.md defines"));
}
// A role of `unknown` must still carry the raw kind, or the information
// is simply lost.
if role == "unknown" && el.get("kind_raw").is_none() {
c.err(format!("{at}: role `unknown` without `kind_raw`"));
}
// A primitive has no texture, so its quad size has to come from the file.
if role == "primitive" && el.get("size").is_none() {
c.err(format!("{at}: primitive without a `size`"));
}
match el.get("layer_source").and_then(Value::as_str) {
Some("sprite") | Some("implied") => {
if !is_hex32(el.get("layer")) {
c.err(format!("{at}: layer_source claims a key but `layer` is not one"));
}
}
Some("none") => {
if el.get("layer").is_some() {
c.err(format!("{at}: layer_source `none` but a `layer` is present"));
}
}
other => c.err(format!("{at}: layer_source must be sprite/implied/none, got {other:?}")),
}
for key in ["sprite", "focus_sprite"] {
if let Some(p) = el.get(key).and_then(Value::as_str) {
let path = root.join(p);
if !path.exists() {
c.err(format!("{at}: `{key}` points at {p}, which does not exist"));
} else if let Err(e) = image::open(&path) {
c.err(format!("{at}: `{key}` {p} does not decode as an image: {e}"));
}
}
}
if let Some(r) = el.get("rest") {
check_pose(&mut c, &at, r);
if role == "button" {
if let Some(y) = r.get("pos").and_then(Value::as_array).and_then(|a| a[1].as_i64()) {
buttons_by_y.push((y, id.clone()));
}
}
}
if let Some(kfs) = el.get("keyframes").and_then(Value::as_array) {
for (k, kf) in kfs.iter().enumerate() {
check_pose(&mut c, &format!("{at} keyframe {k}"), kf);
}
// The last keyframe of a group carries no time slot on the disc, and
// an invented one is exactly the kind of value this format refuses.
if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) {
c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there"));
}
}
}
// Paint order must be a permutation of the element indices, or the runtime
// either drops an element or draws one twice.
match v.get("paint_order").and_then(Value::as_array) {
Some(po) => {
let mut got: Vec<usize> = po.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect();
if got.len() != po.len() {
c.err("`paint_order` holds a non-integer");
}
let mut want = indices.clone();
got.sort_unstable();
want.sort_unstable();
if got != want {
c.err("`paint_order` is not a permutation of the element indices");
}
}
None => c.err("missing required field `paint_order`"),
}
// `buttons` is navigation order and is defined as resting Y, ascending. If
// it is not sorted, it is not the thing FORMAT.md says it is.
match v.get("buttons").and_then(Value::as_array) {
Some(b) => {
let listed: Vec<&str> = b.iter().filter_map(Value::as_str).collect();
buttons_by_y.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let want: Vec<&str> = buttons_by_y.iter().map(|(_, n)| n.as_str()).collect();
if listed != want {
c.err(format!(
"`buttons` is {listed:?} but resting-Y order is {want:?}"
));
}
}
None => c.err("missing required field `buttons`"),
}
if v.get("unresolved").and_then(Value::as_array).is_none() {
c.err("missing required field `unresolved` (an empty list is a claim; absence is a gap)");
}
errors.append(&mut c.errors);
Ok(())
}
/// Validate a whole export tree. Returns the number of screens checked.
pub fn run(root: &Path) -> Result<usize> {
let manifest_path = root.join("manifest.json");
if !manifest_path.exists() {
bail!("{} has no manifest.json — is that an export tree?", root.display());
}
let m: Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?;
let mut errors = Vec::new();
if m.get("format").and_then(Value::as_str) != Some(MANIFEST_FORMAT) {
errors.push(format!("manifest.json: format is not {MANIFEST_FORMAT:?}"));
}
for key in ["exporter", "formats_rev", "screens", "warnings"] {
if m.get(key).is_none() {
errors.push(format!("manifest.json: missing `{key}`"));
}
}
let screens = m
.get("screens")
.and_then(Value::as_array)
.map(|s| s.to_vec())
.unwrap_or_default();
for s in &screens {
let Some(file) = s.get("file").and_then(Value::as_str) else {
errors.push("manifest.json: a screen entry has no `file`".into());
continue;
};
if !root.join(file).exists() {
errors.push(format!("manifest.json: lists {file}, which does not exist"));
continue;
}
check_screen(root, file, &mut errors)?;
}
if !errors.is_empty() {
for e in &errors {
eprintln!("{e}");
}
bail!("{} problem(s) in {}", errors.len(), root.display());
}
Ok(screens.len())
}

View File

@@ -5,29 +5,214 @@
//! 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.
use anyhow::Result;
mod check;
mod screen;
use anyhow::{Context, Result};
use clap::Parser;
use std::path::PathBuf;
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)]
#[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<()> {
let args = Args::parse();
let source = sylpheed_formats::media::DirectorySource::new(&args.disc);
// P0 starts here: enumerate GP_TITLE's screen builds and write one out.
// Nothing is implemented yet -- this proves the pinned decoders resolve.
let _ = (&source, &args.out);
println!("sylpheed-export: scaffold only; see docs/MISSION.md milestone P0");
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(())
}

View File

@@ -0,0 +1,351 @@
//! One UI build → one `sylpheed.screen/2` JSON document plus its sprite PNGs.
//!
//! Everything here is **derived**: it is what the bundle says, restated in a
//! format Godot can read. The two places a value is not read off the disc are
//! marked in the output itself — `name_source` when a screen's name came from
//! `authored/`, and `layer_source: "implied"` when the paint-order key came from
//! the decoders' measured table rather than from a `T8aD` header. A consumer can
//! tell the difference without reading this file.
use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::BTreeMap;
use std::path::Path;
use sylpheed_formats::{t8ad, ui_layout};
/// `elements[].role`, from the decoded element kind.
///
/// ⚠️ `0x3002` is one member of a `0x3000` family and is **not** a general
/// button test — `GP_READY_ROOM` uses `0x3000`/`0x3004`/`0x300c`/`0x3008` and
/// has zero `0x3002`. Every screen in this milestone is `GP_TITLE`, where the
/// mapping is decoded; anything else exports as `unknown` with its raw kind.
fn role_of(kind: u32, has_sprite: bool) -> &'static str {
match kind {
0x3002 => "button",
0x10 if !has_sprite => "primitive",
0x0 => "decoration",
_ => "unknown",
}
}
#[derive(Serialize)]
pub struct Source {
/// Path of the archive within the disc root.
pub archive: String,
/// Pak **entry index** — the stable locator, not the display ordinal.
pub entry: usize,
/// Index into this pak's list of screen builds (what `screen --build` takes).
pub build: usize,
}
/// A placement keyframe, carrying the on-disc time verbatim.
///
/// `t` is in the disc's own units and is deliberately **not** converted here:
/// the seconds conversion is measured off the running game, not read from the
/// file, so it lives in `authored/timing.json` and is applied in exactly one
/// place. See HANDOFF Q1.
#[derive(Serialize)]
pub struct Keyframe {
/// On-disc time, absent on the final keyframe of a group — which carries no
/// time slot at all. Absent, never invented.
#[serde(skip_serializing_if = "Option::is_none")]
pub t: Option<u32>,
/// Top-left of the element at 1:1. Signed: elements animate in from off-screen.
pub pos: [i32; 2],
/// Percent, per axis. Scale grows the element **about its pivot**, not about
/// `pos` — at 100 % the two are identical, which is why it went unnoticed.
pub scale: [u32; 2],
/// Modulate colour, **RGBA** byte order. `0xffffffff` on essentially every
/// keyframe on the disc.
pub tint_rgba: String,
/// The second modulate colour, **ARGB** byte order — the high byte is the
/// alpha that ramps during a fade. Multiplies with `tint_rgba`.
pub fade_argb: String,
}
#[derive(Serialize)]
pub struct Rest {
pub pos: [i32; 2],
pub scale: [u32; 2],
pub tint_rgba: String,
pub fade_argb: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub t: Option<u32>,
}
#[derive(Serialize)]
pub struct Element {
/// Declaration index — the key the placement region and `paint_order` use.
pub index: usize,
/// The declared name with its extension stripped; stable within a screen.
pub id: String,
/// The name exactly as the declaration table spells it.
pub declared: String,
pub role: &'static str,
pub kind_raw: String,
/// Sprite PNG, relative to `export/`. Absent for an untextured primitive.
#[serde(skip_serializing_if = "Option::is_none")]
pub sprite: Option<String>,
/// The highlighted-state sprite: this element's sprite with an `f` before
/// the extension, when the bundle carries one — `ptbtn01.t32` ↔
/// `ptbtn01f.t32`. 🟡 **A naming convention, not a decoded field.** It holds
/// for all 54 real pairs on the disc (HANDOFF), and it is the only link
/// between a button and its highlight that has survived checking.
#[serde(skip_serializing_if = "Option::is_none")]
pub focus_sprite: Option<String>,
/// The raw `opt ` link inside this element's `.rat` record.
///
/// ⚠️ **This is not a focus link.** It was read as one, and that was
/// measured and refuted (HANDOFF, `ui-focus-and-effect-elements.md`) — on the
/// main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two
/// decorations and into a button. It is carried through unresolved and
/// unnamed so that whoever decodes it has it, and so that nothing downstream
/// mistakes it for navigation.
#[serde(skip_serializing_if = "Option::is_none")]
pub opt_link: Option<String>,
pub pivot: [u32; 2],
/// Untextured primitives have no texture to take a size from; the quad is
/// `pivot × 2`, which is 1280×720 for 361 of the disc's 369 primitives.
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<[u32; 2]>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<usize>,
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
/// `"implied"` = **measured off the running game**, for elements that carry
/// no header. `"none"` = neither; sorts last.
pub layer_source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub layer: Option<String>,
/// This element is another element's focused state, not a screen element.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub focused: bool,
/// A `loopN` sprite animation rather than a placed element.
#[serde(skip_serializing_if = "std::ops::Not::not")]
pub animated: bool,
/// The resting pose: the **hold**, the longest run of consecutive keyframes
/// with an identical pose that does not end the group. Neither the first nor
/// the last keyframe.
#[serde(skip_serializing_if = "Option::is_none")]
pub rest: Option<Rest>,
pub keyframes: Vec<Keyframe>,
}
#[derive(Serialize)]
pub struct Screen {
pub format: &'static str,
pub exporter: String,
/// Revision of `sylpheed-formats` whose decoders produced this file.
pub formats_rev: &'static str,
pub source: Source,
pub name: String,
/// `"authored"` when the name came from `authored/screen_names.json`,
/// `"index"` when nobody has named this build yet.
pub name_source: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub name_why: Option<String>,
pub design: [u32; 2],
pub elements: Vec<Element>,
/// Back-to-front paint order as declaration indices, from the decoded `u16`
/// layer key at `+0x0A` of each sprite header, stable-sorted so equal keys
/// keep declaration order. See `unresolved: paint_order_ties`.
pub paint_order: Vec<usize>,
/// Navigation order: `button`-role elements sorted by resting Y.
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
/// and not to be trusted for anything else.
pub buttons: Vec<String>,
/// What this file does not answer. A consumer needing one of these must get
/// it from `authored/`.
pub unresolved: Vec<&'static str>,
}
fn hex32(v: u32) -> String {
format!("0x{v:08x}")
}
/// Strip the extension the declaration table spells, giving a stable id.
pub fn id_of(declared: &str) -> String {
declared
.rsplit_once('.')
.map(|(stem, _)| stem)
.unwrap_or(declared)
.to_string()
}
/// What one screen's export produced, for the manifest.
pub struct Exported {
pub name: String,
pub json_path: String,
pub sprites: usize,
/// Sprites an element named that did not resolve or decode.
pub missing: Vec<String>,
}
/// Convert one build to JSON on disk, writing its sprite PNGs beside it.
///
/// `sprite_dir` is per-screen: a sprite name is unique within a bundle but not
/// across builds, and two screens' `ptbase.t32` are different pictures.
#[allow(clippy::too_many_arguments)]
pub fn export_build(
out: &Path,
archive: &str,
entry: usize,
build_idx: usize,
bundle: &[u8],
name: &str,
name_source: &'static str,
name_why: Option<String>,
subdir: &str,
exporter: &str,
formats_rev: &'static str,
) -> Result<Exported> {
let b = ui_layout::parse_build(bundle).context("build did not parse")?;
// Every sprite an element actually references, decoded once and written as a
// PNG under this screen's own directory.
let sprite_rel = |sprite: &str| format!("sprites/{subdir}/{name}/{}.png", id_of(sprite));
let sprite_dir = out.join("sprites").join(subdir).join(name);
std::fs::create_dir_all(&sprite_dir)?;
let mut written: BTreeMap<String, ()> = BTreeMap::new();
let mut missing = Vec::new();
let mut write_sprite = |sprite: &str| -> Result<bool> {
if written.contains_key(sprite) {
return Ok(true);
}
let Some(&(off, size)) = b.sprites.get(sprite) else {
return Ok(false);
};
let Some(img) = t8ad::parse(&bundle[off..off + size]) else {
return Ok(false);
};
let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba)
.context("T8aD dimensions disagree with its pixel count")?;
buf.save(sprite_dir.join(format!("{}.png", id_of(sprite))))?;
written.insert(sprite.to_string(), ());
Ok(true)
};
/// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`.
fn highlight_name(sprite: &str) -> Option<String> {
let (stem, ext) = sprite.rsplit_once('.')?;
Some(format!("{stem}f.{ext}"))
}
let mut elements = Vec::new();
for el in &b.elements {
let mut sprite_out = None;
if let Some(s) = &el.sprite {
if write_sprite(s)? {
sprite_out = Some(sprite_rel(s));
} else {
missing.push(s.clone());
}
}
// The highlight pairs by NAME on the sprite, not through the `opt `
// link: `opt ` is refuted as a focus link and points somewhere else
// entirely on half these elements.
let mut focus_sprite = None;
if let Some(h) = el.sprite.as_deref().and_then(highlight_name) {
if b.sprites.contains_key(&h) && write_sprite(&h)? {
focus_sprite = Some(sprite_rel(&h));
}
}
let (layer, layer_source) = match ui_layout::sprite_layer_key(&b, bundle, el) {
Some(k) => (Some(hex32(k)), "sprite"),
None => match ui_layout::implied_layer_key(&el.name) {
Some(k) => (Some(hex32(k)), "implied"),
None => (None, "none"),
},
};
let kf = |k: &ui_layout::Keyframe| Keyframe {
t: k.time,
pos: [k.x, k.y],
scale: [k.scale_x, k.scale_y],
tint_rgba: hex32(k.tint),
fade_argb: hex32(k.fade),
};
let role = role_of(el.kind, el.sprite.is_some());
elements.push(Element {
index: el.index,
id: id_of(&el.name),
declared: el.name.clone(),
role,
kind_raw: format!("{:#x}", el.kind),
sprite: sprite_out,
focus_sprite,
opt_link: el.focus_link.clone(),
pivot: [el.pivot_x, el.pivot_y],
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
parent: el.parent,
layer_source,
layer,
focused: el.focused,
animated: el.animated,
rest: el.rest().map(|k| Rest {
pos: [k.x, k.y],
scale: [k.scale_x, k.scale_y],
tint_rgba: hex32(k.tint),
fade_argb: hex32(k.fade),
t: k.time,
}),
keyframes: el.keyframes.iter().map(kf).collect(),
});
}
// Navigation order is geometric: buttons top-to-bottom by resting Y. A
// focused-state record is not itself a menu item.
let mut buttons: Vec<(i32, String)> = b
.elements
.iter()
.filter(|e| e.kind == 0x3002 && !e.focused)
.filter_map(|e| e.rest().map(|k| (k.y, id_of(&e.name))))
.collect();
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
let screen = Screen {
format: "sylpheed.screen/2",
exporter: exporter.to_string(),
formats_rev,
source: Source {
archive: archive.to_string(),
entry,
build: build_idx,
},
name: name.to_string(),
name_source,
name_why,
design: [b.design_w, b.design_h],
elements,
paint_order: ui_layout::derived_paint_order(&b, bundle),
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
unresolved: vec![
// The time unit is measured off the running game, not on the disc.
"keyframe_time_unit",
// Where two elements share a layer key the game's order is
// unexplained; eight candidates refuted. Costs one element's blend
// on one screen.
"paint_order_ties",
// The last keyframe of a group carries no time slot, so the
// fade-OUT length is not in the file.
"fade_out_duration",
],
};
let dir = out.join("screens").join(subdir);
std::fs::create_dir_all(&dir)?;
let json_path = format!("screens/{subdir}/{name}.json");
std::fs::write(
out.join(&json_path),
format!("{}\n", serde_json::to_string_pretty(&screen)?),
)?;
missing.sort();
missing.dedup();
Ok(Exported {
name: name.to_string(),
json_path,
sprites: written.len(),
missing,
})
}