From f6fc2694781769238f31c8a6c80691572bed549d Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 18:43:52 +0000 Subject: [PATCH] =?UTF-8?q?export:=20P0=20=E2=80=94=20GP=5FTITLE's=20scree?= =?UTF-8?q?ns=20and=20sprites=20into=20the=20open=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- authored/screen_names.json | 45 ++++ crates/sylpheed-export/Cargo.toml | 2 +- crates/sylpheed-export/src/check.rs | 277 +++++++++++++++++++++ crates/sylpheed-export/src/main.rs | 213 ++++++++++++++-- crates/sylpheed-export/src/screen.rs | 351 +++++++++++++++++++++++++++ 5 files changed, 873 insertions(+), 15 deletions(-) create mode 100644 authored/screen_names.json create mode 100644 crates/sylpheed-export/src/check.rs create mode 100644 crates/sylpheed-export/src/screen.rs diff --git a/authored/screen_names.json b/authored/screen_names.json new file mode 100644 index 00000000..8ce84420 --- /dev/null +++ b/authored/screen_names.json @@ -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." + } +} diff --git a/crates/sylpheed-export/Cargo.toml b/crates/sylpheed-export/Cargo.toml index fa21ccd8..e1ea00b6 100644 --- a/crates/sylpheed-export/Cargo.toml +++ b/crates/sylpheed-export/Cargo.toml @@ -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"] } diff --git a/crates/sylpheed-export/src/check.rs b/crates/sylpheed-export/src/check.rs new file mode 100644 index 00000000..abbeb528 --- /dev/null +++ b/crates/sylpheed-export/src/check.rs @@ -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, +} + +impl Ctx { + fn err(&mut self, msg: impl Into) { + 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) -> 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("").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 = 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 { + 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()) +} diff --git a/crates/sylpheed-export/src/main.rs b/crates/sylpheed-export/src/main.rs index 7af3d3a0..b1810836 100644 --- a/crates/sylpheed-export/src/main.rs +++ b/crates/sylpheed-export/src/main.rs @@ -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 { - /// Extracted disc root (the directory holding `dat/` and `hidden/`). - #[arg(long)] - disc: PathBuf, - /// Output tree. Rewritten wholesale — never hand-edit it. - #[arg(long, default_value = "export")] - out: PathBuf, + #[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<()> { - 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(()) } diff --git a/crates/sylpheed-export/src/screen.rs b/crates/sylpheed-export/src/screen.rs new file mode 100644 index 00000000..6f24ac95 --- /dev/null +++ b/crates/sylpheed-export/src/screen.rs @@ -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, + /// 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, +} + +#[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, + /// 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, + /// 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, + 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, + /// 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, + /// 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, + pub keyframes: Vec, +} + +#[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, + pub design: [u32; 2], + pub elements: Vec, + /// 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, + /// 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, + /// 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, +} + +/// 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, + subdir: &str, + exporter: &str, + formats_rev: &'static str, +) -> Result { + 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 = BTreeMap::new(); + let mut missing = Vec::new(); + let mut write_sprite = |sprite: &str| -> Result { + 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 { + 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, + }) +}