diff --git a/authored/screen_names.json b/authored/screen_names.json new file mode 100644 index 0000000..8ce8442 --- /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 fa21ccd..e1ea00b 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 0000000..abbeb52 --- /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 7af3d3a..b181083 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 0000000..6f24ac9 --- /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, + }) +} diff --git a/docker/bin/build-export b/docker/bin/build-export index 3e4ebfe..6102bde 100755 --- a/docker/bin/build-export +++ b/docker/bin/build-export @@ -2,7 +2,12 @@ # Build and run the exporter against the disc. # # build-export build only -# build-export --run build, then export to ./export +# build-export --run build, export to ./export, then validate it +# +# The validate step is not optional politeness: `export` writes a tree and +# `check` is the only thing that says the tree is readable by anything other +# than the program that wrote it. A build that exports and does not check has +# not shown anything. # # Jobs are capped: this box runs two agent containers and a desktop, and an # unbounded parallel build has crashed it. Do not raise this to "use all cores". @@ -13,5 +18,6 @@ cargo build --release -p sylpheed-export if [ "${1:-}" = "--run" ]; then shift disc="${SYLPHEED_DISC:?set SYLPHEED_DISC to the extracted disc root}" - exec "$CARGO_TARGET_DIR/release/sylpheed-export" --disc "$disc" --out export "$@" + "$CARGO_TARGET_DIR/release/sylpheed-export" export --disc "$disc" --out export "$@" + exec "$CARGO_TARGET_DIR/release/sylpheed-export" check --out export fi diff --git a/docker/bin/build-reference-cli b/docker/bin/build-reference-cli new file mode 100755 index 0000000..c7356f2 --- /dev/null +++ b/docker/bin/build-reference-cli @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Build `sylpheed-cli` from the SAME revision of sylpheed-formats the exporter +# is pinned to, and put it on the persistent target volume. +# +# build-reference-cli -> $CARGO_TARGET_DIR/release/sylpheed-cli +# +# Why not just use /reborn/target/release/sylpheed-cli: that binary is built +# from whatever /reborn's working tree is at, which is a LIVE mount of the other +# agent's checkout and moves under you mid-iteration. `sylpheed-cli screen +# render` is the reference the Godot port is diffed against, so if it runs +# different decoders than the exporter, a pixel disagreement has a free variable +# in it and proves nothing about the port. +# +# The pinned source lives in CARGO_HOME, which is on the container overlay and +# does not survive a fresh container -- cargo re-fetches it. The BINARY goes to +# CARGO_TARGET_DIR, which is a volume, so this is a one-off per image. +# +# Jobs are capped for the same reason as build-export. +set -euo pipefail +cd "${PROJECT_DIR:-/work}" +export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-3}" + +rev=$(sed -n 's/.*Syplheed-Reborn\.git", rev = "\([0-9a-f]*\)".*/\1/p' \ + crates/sylpheed-export/Cargo.toml | head -1) +[ -n "$rev" ] || { echo "build-reference-cli: no rev pin found in Cargo.toml" >&2; exit 1; } + +# The checkout only exists once cargo has fetched it; a fresh container has not. +find_checkout() { + find "${CARGO_HOME:?}/git/checkouts" -maxdepth 2 -type d -name "${rev}*" 2>/dev/null | head -1 +} +src=$(find_checkout) +if [ -z "$src" ]; then + echo "build-reference-cli: fetching the pinned decoders ($rev)" + cargo fetch + src=$(find_checkout) +fi +[ -n "$src" ] || { echo "build-reference-cli: no checkout for rev $rev" >&2; exit 1; } + +echo "build-reference-cli: building sylpheed-cli from $rev" +cargo build --release --manifest-path "$src/Cargo.toml" -p sylpheed-cli + +out="$CARGO_TARGET_DIR/release/sylpheed-cli" +"$out" screen list "${SYLPHEED_DISC:-/disc}/dat/GP_TITLE.pak" >/dev/null \ + || { echo "build-reference-cli: built, but 'screen list' failed" >&2; exit 1; } +echo "build-reference-cli: $out (rev $rev, 'screen' subcommand present)" diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index ec94dc8..2812e54 100644 --- a/docs/BLOCKED.md +++ b/docs/BLOCKED.md @@ -4,20 +4,67 @@ What this port cannot do until an answer lands in [`/reborn/docs/port/HANDOFF.md`](https://git.mc02.dev/fabi/Syplheed-Reborn). Recorded so it is not re-discovered every iteration. -| Milestone | Needs | HANDOFF question | -|---|---|---| -| P2 keyframe animation | the unit of a keyframe time, and the ramp shape | Q1 | -| P3 splash → title | which build is which screen state | Q2 | -| P1/P3 correct layering | paint order for these six screens | Q3 | -| P5 button actions | which button opens which GamePart | Q4 | -| P5 navigation | initial focus, wrap-around, what B does | Q5 | -| P3 sequencing | the boot order and what drives it | Q6 | -| P3 transitions | what happens visually between screens, and its timing | Q7 | -| P6 audio | which BGM per screen; which cue on move/confirm/back | Q8 | -| P4/P7 video | which movie is the boot intro vs the new-game intro | Q9 | -| P6 looping | whether a music bank's sub-waves are intro+loop or variations | Q10 | - **None of these may be guessed.** A value invented here is indistinguishable from a decoded one a month from now. Where a milestone can proceed with a placeholder, -put the placeholder in `authored/` with a `why` naming the question it is standing -in for, so it is deleted rather than forgotten when the answer arrives. +the placeholder goes in `authored/` with a `why` naming the question it stands in +for, so it is deleted rather than forgotten when the answer arrives. + +Last reconciled against HANDOFF.md on **2026-08-28**, at `/reborn` HEAD `e81dcad`. + +## Still open — these block work + +| Milestone | Needs | HANDOFF | State | +|---|---|---|---| +| P6 audio | which cue fires on move / confirm / back | Q8 | ❔ open. The cue table is complete; the **event binding is not**. P6 cannot bind a sound to a keypress without inventing it. | +| P6 audio | which BGM the menu plays | Q10 | ❔ **not on the disc.** All 32 banks are named `BGM_001`…`BGM_109` with no semantic name anywhere. The port is choosing a track, and that choice is authored. | +| P6 looping | where a menu loop restarts | Q10 | ❔ `BGM_001` fades out at 167.663 s into 6.15 s of silence, and no loop-point field has been identified. A menu loop is authored. | +| P4/P7 video | whether Ⓐ skips a movie | Q9 | 🟡 unsettled — the corpus says Ⓐ skips every time, the boot harness never taps during a movie because it breaks the title. P4 can play the movie; it cannot yet say what a button press does during one. | +| P5 `NEW GAME` | what Ⓐ on `NEW GAME` opens | Q4 | ❔ untested: Ⓐ on it **hangs the emulator**. The other four destinations are measured. | +| P3 sequencing | what code decides to advance the boot sequence | Q6 | 🟡 the order is observed and the attract cycle timed (~8–10 s idle → fade → `ADV.wmv` in full → title). The *driver* is not decoded. P3 can reproduce the observed behaviour and must say it is reproducing an observation. | + +## Answered since this file was last written — no longer blocking + +Q1 (keyframe time unit — linear ramp, 2 units per rendered frame, 1 unit = 1/60 s +*measured*), Q2 (which build is which screen), Q3 (paint order — a `u16` layer key +at `+0x0A`, **decoded**), Q5 (navigation: ⬆⬇ wrap, ⬅➡ nothing, Ⓑ up with focus +restored), Q7 (transitions: a fade through black, fade-in decoded, ~0.4 s fade-out +measured), Q9 (`ADVERTISE_MOVIE` → `ADV.wmv` is boot intro *and* attract; `MS00A` → +`S00A.wmv` is the new-game intro), Q10 (a bank is two stems played **together** — +do not concatenate), S1 (Ready Room: no-go). + +Three of those are **measured**, not decoded, and so are authored here rather +than exported: + +| Authored because it is not on the disc | HANDOFF | Where it lives | +|---|---|---| +| `1 keyframe unit = 1/60 s` | Q1 | not yet written — P2 | +| initial menu focus (not stable across boots; pick one and say so) | Q5 | not yet written — P5 | +| the ~0.4 s fade-out and the 0.17–0.23 s black hold | Q7 | not yet written — P3 | + +## Questions this port has raised + +Not blocking anything today; raised because the port found them and a guess here +would be believed later. + +### The pivot is not half the texture on `GP_TITLE` + +`sylpheed-formats`'s `ui_layout::Element::pivot_x` is documented as "for a `.t32` +element this is exactly half the decoded texture's dimensions (verified 7/7 on +the tutorial bundle)". Counting it over the whole of `GP_TITLE` as exported: + +* **55 of 93** sprite-bearing `.t32` elements match within ±1 px. +* **38 do not**, and several are not close: `ptlogo_back2` is 1118×262 with pivot + (500, 117) where half is (559, 131); `ptmsg` is 223×38 with pivot (123, 19) + where half is (111.5, 19) — the Y matches and the X does not. + +This changes nothing today: the exporter emits the **declared** pivot and never +derives one, and the pivot only affects drawing when scale ≠ 100 %. But it does +matter, because scale is genuinely animated here — **177 keyframes** across +`GP_TITLE` are not 100 %, including on the title screen the port must draw at P1. + +The question for the RE agent, when it is cheap to answer: **does the running +game anchor a scale to the declared pivot, or to half the texture?** The two +differ by up to 59 px on `ptlogo_back2`, which is visible. Until then the port +follows the decoders and uses the declared pivot, which is also what +`sylpheed-cli screen render` does — so a P1 diff cannot distinguish them, and +agreement between the two is not evidence. diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md new file mode 100644 index 0000000..d427900 --- /dev/null +++ b/docs/DECISIONS.md @@ -0,0 +1,116 @@ +# Decisions + +One entry per decision that outlives the container it was made in. Newest last. +A decision that lives only in an agent's context is lost when that container +dies, which is what this file is for. + +--- + +## P0 — the exporter, 2026-08-28 + +### The exporter reads one authored file, and stamps its provenance into the output + +`export/` is derived and `authored/` is hand-written, and the natural reading of +that is that the exporter never touches `authored/`. But a screen has to be +*called* something, and the disc does not name its builds — the identification of +build 5 as the main menu is HANDOFF Q2, **measured against a live capture**, not +a field. + +Two ways to handle that: + +1. the exporter emits `build_05.json` and the runtime renames it from + `authored/screen_names.json`; +2. the exporter reads that map and writes `main_menu.json` directly. + +Chose **2**, with a condition: every name it applies carries `name_source: +"authored"` and a `name_why` quoting the evidence, and `check` **rejects** an +authored name with no `why`. The file that lands in `export/` is therefore still +honest about which of its fields is a measurement — which is the property the +derived/authored split exists to protect — while a human opening the tree sees +`main_menu.json` rather than having to resolve a rename in their head. A build +nobody has identified exports as `build_NN` with `name_source: "index"`, which is +a locator and not a claim. + +This is the **only** authored input the exporter takes. Everything else in +`authored/` is applied by the runtime over `export/`. + +### Sprites are per screen, not a flat pool + +`main_menu` and `extras` both ship a `ptbase.t32` and they are different +pictures. A flat `sprites/` directory would have silently collided; whichever +screen exported second would have won, and the loser would have drawn the wrong +background with no error anywhere. `sprites///.png`. + +### The format is executable + +`sylpheed-export check --out export` validates a tree against `docs/FORMAT.md` +with no disc in hand. It exists because "the export is correct" is otherwise an +assertion, and because the P0 gate is *"validates against FORMAT.md"* — which is +not a thing anyone can confirm by reading. + +It reads the tree the way Godot will: as a stranger, with no access to the disc, +the decoders, or the exporter's internals. It deliberately does **not** check the +export against the disc — that is what `sylpheed-cli screen render` is for, at P1. + +Checked that it bites, rather than assuming: five mutations of a valid +`main_menu.json` — a broken `paint_order` permutation, a dangling +`focus_sprite`, a reversed `buttons` list, a `#rrggbbaa` colour, an invented +`name_source` — are each caught with a specific message. + +### The highlight sprite pairs by name; `opt ` is exported but not believed + +FORMAT v1 said `focus_sprite` came from the element's `opt ` link. That reading +was **measured and refuted** by the RE agent, and this export shows why plainly: +on the main menu, `opt ` chains `ptloop01 → ptloop02 → ptbtn01` — two decorations +and then a button. It is a linked list of something, and it is not focus. + +The highlight is paired by **sprite name** instead (`ptbtn01.t32` ↔ +`ptbtn01f.t32`), which is HANDOFF's convention and holds for all 54 real pairs on +the disc. It resolves all five main-menu buttons. The raw link is still exported +as `opt_link`, renamed so that nothing downstream mistakes it for navigation, and +so that whoever eventually decodes it has the data. + +Note this is 🟡 a naming convention, not a decoded field. It is authored in +effect, and lives in the exporter only because it is a rule over disc data rather +than a value we chose. + +### The paint order is exported, not authored + +Q3 decoded it — a `u16` layer key at `+0x0A` of each `T8aD` sprite header, +stable-sorted with declaration index. So it is read in the exporter, per the +contract's own rule for a decoded answer, and `paint_order` in `export/` is a +derived field. `"paint_order"` is gone from `unresolved`; **`paint_order_ties` +replaces it**, because the tie-break is still unknown and costs one element's +blend on one screen. + +Where an element has no `T8aD` header the key comes from the decoders' table of +keys **measured off the running game**. That is a different kind of fact, so it +is labelled: `layer_source` is `"sprite"`, `"implied"` or `"none"`, and a +consumer that needs to know whether a layer is read or measured can tell. + +### Colours are exported as two fields with the byte order in the name + +There are two modulate colours and they multiply: `tint` is RGBA, `fade` is +**ARGB** and its high byte is the alpha that ramps. v1's single `"#ffffffff"` +could not carry both and silently discarded the ramping alpha. They are exported +as `tint_rgba` and `fade_argb`, raw hex, byte order in the key — because getting +it backwards is silent and looks like an art bug rather than a parse bug. + +### `t` stays raw + +HANDOFF Q1 is answered — linear ramp, 2 units per rendered frame, working +conversion 1 unit = 1/60 s — but that conversion is **measured off the running +game, not read from the file**, and the finding itself flags the 27.6 present- +frames/second measurement as the part worth re-testing. If the game turns out to +present at 60 Hz, every duration halves. + +So `t` is exported exactly as the disc spells it, `keyframe_time_unit` stays in +`unresolved`, and the conversion will live in one authored place at P2. One +constant to change, in a file that says it is a decision. + +### The final keyframe has no `t`, and `check` enforces that + +The disc has no time slot on the last keyframe of a group. A file that carries +one there has invented it. `check` rejects it — this is the one place where the +temptation to emit a plausible number is strongest and the resulting error is +completely invisible. diff --git a/docs/FORMAT.md b/docs/FORMAT.md index b518920..91ce011 100644 --- a/docs/FORMAT.md +++ b/docs/FORMAT.md @@ -1,9 +1,9 @@ -# The open export format — v1 +# The open export format — v2 The format the disc is converted *into*, and the one the Godot project and any -modding tool read. **This is a starting point, and it is yours to revise** — but -it is versioned, so a change is a deliberate act with a version bump, not a -silent edit. +modding tool read. **It is versioned, so a change is a deliberate act with a +version bump**, not a silent edit. [Changes from v1](#changes-from-v1) is at the +bottom, with a reason for each. Design rules, in priority order: @@ -14,92 +14,196 @@ Design rules, in priority order: in the file that the real name is unknown**. A modder must be able to tell a recovered name from an invented one. 3. **Provenance travels with the data.** Source archive, entry index, exporter - version. This is what keeps the export auditable against the disc instead of - drifting into an unverifiable fork. + version, decoder revision. This is what keeps the export auditable against the + disc instead of drifting into an unverifiable fork. 4. **Say what is unknown.** A field we could not decode is absent and listed in `unresolved` — never guessed, never silently defaulted. **JSON, not XML.** Godot parses JSON natively with `JSON.parse_string`; its `XMLParser` is a SAX-style API that would need a hand-written binding per schema. +**The format is executable.** `sylpheed-export check --out export` validates a +tree against this document with no disc in hand, reading it the way Godot will — +as a stranger. Where the prose here and `crates/sylpheed-export/src/check.rs` +disagree, that is a bug in one of them and worth saying which. + ## Layout ``` export/ # DERIVED. Regenerable. Gitignored. Never hand-edited. manifest.json screens/title/*.json - sprites/*.png + sprites/title//*.png audio/music/*.ogg audio/sfx/*.ogg audio/cues.json video/*.ogv authored/ # AUTHORED. Hand-written. Committed. Survives re-export. + screen_names.json # which build is which screen flow.json # boot sequence + what each button does - paint_order.json # per-screen z-order cue_bindings.json # which cue fires on move / confirm / back ``` -Godot loads `export/` first, then applies `authored/` over it. +Sprites are **per screen**, not a flat pool: a sprite name is unique within a +bundle and not across them, and `main_menu`'s `ptbase.t32` and `extras`' +`ptbase.t32` are different pictures. + +`authored/screen_names.json` is the one authored file the *exporter* reads; the +rest are applied by the runtime over `export/`. ## Common header ```json { - "format": "sylpheed.screen/1", + "format": "sylpheed.screen/2", "exporter": "sylpheed-export 0.1.0", - "source": { "archive": "dat/GP_TITLE.pak", "entry": 5 } + "formats_rev": "8b6dbcf", + "source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 } } ``` -`source.entry` is the pak **entry index** — the stable locator. Not the display -ordinal, which renumbers whenever the enumeration rule changes. +`source.entry` is the pak **entry index** — the stable locator. `source.build` is +the index into that pak's list of screen builds (what `sylpheed-cli screen +--build N` takes), which is stable only as long as the enumeration rule is. +`formats_rev` pins which decoders produced the file. ## `screens/*.json` ```json { - "format": "sylpheed.screen/1", + "format": "sylpheed.screen/2", "exporter": "sylpheed-export 0.1.0", - "source": { "archive": "dat/GP_TITLE.pak", "entry": 5 }, + "formats_rev": "8b6dbcf", + "source": { "archive": "dat/GP_TITLE.pak", "entry": 5, "build": 5 }, "name": "main_menu", "name_source": "authored", + "name_why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English…", "design": [1280, 720], "elements": [ { + "index": 10, "id": "ptbtn01", - "sprite": "sprites/ptbtn01.png", - "focus_sprite": "sprites/ptbtn01f.png", + "declared": "ptbtn01.rat", "role": "button", + "kind_raw": "0x3002", + "sprite": "sprites/title/main_menu/ptbtn01.png", + "focus_sprite": "sprites/title/main_menu/ptbtn01f.png", + "opt_link": "ptbtn01f.rat", "pivot": [42, 22], - "rest": { "pos": [542, 162], "scale": [1.0, 1.0], "tint": "#ffffffff" }, + "layer_source": "sprite", + "layer": "0x00008110", + "rest": { "pos": [542, 162], "scale": [100, 100], + "tint_rgba": "0xffffffff", "fade_argb": "0xffffffff", "t": 64 }, "keyframes": [ - { "t": 28, "pos": [542, 142] }, - { "t": 34, "pos": [542, 157] }, - { "t": 64, "pos": [542, 162] } + { "t": 28, "pos": [542, 142], "scale": [100, 100], + "tint_rgba": "0xffffffff", "fade_argb": "0x00ffffff" } ] } ], + "paint_order": [1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0], "buttons": ["ptbtn01", "ptbtn02", "ptbtn03", "ptbtn04", "ptbtn05"], - "unresolved": ["paint_order", "keyframe_time_unit"] + "unresolved": ["keyframe_time_unit", "paint_order_ties", "fade_out_duration"] } ``` -**`role`** comes from the decoded element kind: `0x3002` → `button`, `0x10` → -`primitive`, `0x0` → `decoration`. Anything else exports as `"unknown"` with the -raw value in `kind_raw`. Do not invent a name for a kind nobody has decoded. +### `name` / `name_source` / `name_why` + +`name_source` is `"authored"` or `"index"` and nothing else. `"authored"` means +the name came from `authored/screen_names.json` and **requires** a `name_why` +saying who decided it and on what evidence. `"index"` means nobody has +identified this build and the name is `build_NN` — a locator, not a claim. + +### `elements[]` + +`index` is the declaration index and is also the key `paint_order` uses; it +always equals the element's position in the array. `id` is `declared` with its +extension stripped. + +**`role`** comes from the decoded element kind: `0x3002` → `button`, `0x10` +without a sprite → `primitive`, `0x0` → `decoration`. Anything else is +`"unknown"` with the raw value in `kind_raw`. Do not invent a name for a kind +nobody has decoded. + +> ⚠️ `0x3002` is **not** a general button test. It is one member of a `0x3000` +> family with sub-bits, and `GP_READY_ROOM` uses `0x3000` / `0x3004` / `0x300c` / +> `0x3008` with zero `0x3002`. Every screen in this milestone is `GP_TITLE`, +> where the mapping is decoded. A consumer meeting `role: "unknown"` should read +> `kind_raw`, not assume. + +> ⚠️ **`kind & 0x4` is a repeated instance of a template.** On the title screen +> those are motion-trail ghosts and are *not* on screen at rest — the draw +> capture shows one quad where the bundle declares three. A runtime should skip a +> `kind & 0x4` element **when another element in the same screen has the same +> `id` and does not have that bit**, and only then: 174 elements on the disc are +> `0x4` with no such template, and a blanket skip erases them. Both are visible +> in this format from `kind_raw` and `id`. + +**`pivot`** is the declared pivot, and it is the **anchor scale grows about** — +`pos` is the element's top-left at 1:1, and at scale `s` the drawn top-left is +`pos − pivot·(s−1)`. At 100 % the pivot cancels, which is why it went unnoticed +for a long time. + +> 🟡 The decoders document the pivot as "exactly half the decoded texture's +> dimensions (verified 7/7 on the tutorial bundle)". **That does not hold on +> `GP_TITLE`**: 38 of its 93 sprite-bearing `.t32` elements disagree, some +> grossly (`ptlogo_back2`, 1118×262, pivot 500,117 where half is 559,131). It is +> not a problem for this port — the exporter emits the declared pivot and never +> derives one — but it is a claim a consumer should not lean on. Raised in +> `docs/BLOCKED.md`. + +**`sprite`** / **`focus_sprite`** are paths relative to `export/`. The highlight +pairs **by name** on the sprite — `ptbtn01.t32` ↔ `ptbtn01f.t32` — which is 🟡 a +naming convention that holds for all 54 real pairs on the disc, not a decoded +field. + +**`opt_link`** is the raw `opt ` link inside the element's `.rat` record, carried +through unresolved. ⚠️ **It is not a focus link.** That reading was measured and +refuted: on the main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two +decorations and into a button. It is exported so whoever decodes it has it, and +named so nothing downstream mistakes it for navigation. + +**`layer` / `layer_source`** are the paint-order key. `"sprite"` means it was +read from the `u16` at `+0x0A` of the element's `T8aD` header — a decoded disc +field. `"implied"` means the element carries no header and the key came from the +decoders' table of keys **measured off the running game**. `"none"` means neither +is known, and the element sorts last. A consumer that needs to know whether a +layer is a fact or a measurement reads `layer_source`. + +**`size`** appears only on a `primitive`, which has no texture to take a size +from: the quad is `pivot × 2`, and its colour is the keyframe's `fade_argb`. + +**`keyframes`** carry the on-disc time verbatim in `t`. A keyframe is the +**start of a ramp toward the next**, not a pose that is held, and the ramp is +linear. The **last keyframe of a group has no `t`** — the disc has no time slot +there — and a file that puts one on it is wrong, not merely odd. The unit of `t` +is measured, not on the disc, and so lives in `authored/` and is applied in +exactly one place. + +**Two colours multiply.** `tint_rgba` is RGBA and is `0xffffffff` on essentially +every keyframe; `fade_argb` is **ARGB**, and its high byte is the alpha that ramps +during a fade. The byte order is in the key name because getting it backwards is +silent and looks like an art bug. The drawn modulate is their per-channel product. + +**`rest`** is 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, and not the longest-dwell frame either: a long gap after +keyframe *k* means the screen spends that time *arriving at* `k+1`. + +**`paint_order`** is back-to-front, as declaration indices, and is a permutation +of them. It is the stable sort by `layer`. See `unresolved: paint_order_ties`. **`buttons`** is navigation order: `button`-role elements sorted by resting Y. This is **geometric, not a decoded neighbour graph** — the disc's real navigation -structure is unknown and `opt ` is *not* a focus link (measured and refuted). It -is right for a vertical menu and should not be trusted for anything else. - -**`keyframes`** carry the on-disc time verbatim in `t`. A keyframe is the **start -of a ramp toward the next**, not a pose that is held. The unit of `t` is HANDOFF -Q1 and is unanswered — keep `t` raw so the conversion lives in exactly one place. - -**`rest`** is the resting pose: the longest run of consecutive keyframes with an -unchanged value, falling back to longest-dwell. Neither the first nor the last. +structure is unknown. It is right for a vertical menu and should not be trusted +for anything else. **`unresolved`** lists what this file does not answer; a consumer needing one of -those must get it from `authored/`. +those must get it from `authored/`. An empty list is a claim that nothing is +missing; an absent list is a gap, and `check` rejects it. + +## `authored/screen_names.json` + +Which build is which screen, keyed by archive and build index, each with a +`why`. The exporter reads this and stamps `name` / `name_source` / `name_why` +into the screen file. A build with no entry exports as `build_NN`. ## `authored/flow.json` @@ -129,11 +233,31 @@ reaches which entry is Q4 and is not). "format": "sylpheed.manifest/1", "exporter": "sylpheed-export 0.1.0", "formats_rev": "8b6dbcf", + "disc": "/disc", + "screens": [{ "name": "main_menu", "file": "screens/title/main_menu.json", + "sprites": 18, "missing_sprites": [] }], "video_transcode": "ffmpeg -i ADV.wmv -c:v libtheora -q:v 8 -c:a libvorbis -q:a 5 ADV.ogv", "warnings": ["GP_READY_ROOM not exported -- out of scope"] } ``` -`formats_rev` pins which decoders produced this export, and `video_transcode` -records the exact command so a modder can re-run it rather than reverse-engineer -what was done. +`video_transcode` will record the exact command so a modder can re-run it rather +than reverse-engineer what was done. It is absent until P4 writes a video. + +## Changes from v1 + +v1 was written before HANDOFF answered Q1 and Q3, and before the two-colour +modulate was known. Each change below is a thing v1 could not have said. + +| Change | Why | +|---|---| +| `rest.tint` (one `#rrggbbaa`) → `tint_rgba` **and** `fade_argb` | There are two modulate colours on the disc, in *different byte orders*, and they multiply. One field could not carry both, and a single `#rrggbbaa` silently discarded the alpha that every fade ramps. | +| `scale` is percent integers, not floats | It is a percent integer on the disc. Emitting `1.0` invents a precision the file does not have. | +| `paint_order` added, `"paint_order"` dropped from `unresolved` | Q3 decoded it: a `u16` layer key at `+0x0A`, stable-sorted. It is now derived, so it belongs in `export/` rather than `authored/`. `paint_order_ties` remains unresolved. | +| `layer` / `layer_source` added | Some keys are read from the file and some are measured off the running game. A consumer must be able to tell which. | +| `focus_sprite` now pairs by sprite **name**; `opt_link` exported raw | v1 implied `opt ` was the focus link. That was refuted. Pairing by name is the convention that survives. | +| `kind_raw` on every element, not only on `unknown` | The `0x3002` button test is not general and `kind & 0x4` changes whether an element draws at all. Both need the raw value present unconditionally. | +| `index`, `declared`, `parent`, `size`, `layer` added | Needed to reconstruct the screen: `paint_order` keys on `index`, primitives have no texture to take a size from, and `declared` keeps the disc's own spelling next to the derived `id`. | +| `name_why` required whenever `name_source` is `authored` | Rule 2. A name presented without its evidence is indistinguishable from a recovered one. | +| sprites moved from `sprites/*.png` to `sprites///*.png` | Sprite names collide across builds. `main_menu` and `extras` both ship a `ptbase.t32`, and they are different pictures. | +| `unresolved` is required, and may be empty | An empty list is a claim; an absent one is a gap. |