From 8dd0577e0142bc88429924212b074a34f203db43 Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 18:43:52 +0000 Subject: [PATCH 1/3] =?UTF-8?q?export:=20P0=20=E2=80=94=20GP=5FTITLE's=20s?= =?UTF-8?q?creens=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, + }) +} From 8d2c092788a497db7538cfdca9b62b755654097d Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 18:43:52 +0000 Subject: [PATCH 2/3] docs: FORMAT v2, what P0 decided, and BLOCKED reconciled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FORMAT is bumped to v2 with a "Changes from v1" table giving a reason per row. v1 was written before HANDOFF answered Q1 and Q3 and before the two-colour modulate was known; each change is a thing v1 could not have said. The paint order moves from `unresolved` into the export, because Q3 decoded it — a u16 layer key at +0x0A, stable-sorted — and a decoded answer is read in the exporter. `paint_order_ties` replaces it, because the tie-break is still unknown. Where a layer key is not in the file it 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". BLOCKED is reconciled against HANDOFF at /reborn e81dcad. Seven of its ten rows are answered and are moved out; what remains is Q8 (no cue-to-event binding), the two Q10 unknowns (which BGM, and where a loop restarts), Q9's unsettled skippability, Q4's untested NEW GAME, and Q6's undecoded boot driver. It also raises one question back, found by counting the export rather than by reverse engineering anything: the decoders document a .t32 element's pivot as "exactly half the decoded texture's dimensions (verified 7/7 on the tutorial bundle)", and on GP_TITLE that holds for 55 of 93 sprite-bearing .t32 elements. 38 do not, some grossly — ptlogo_back2 is 1118x262 with pivot (500,117) where half is (559,131). It changes nothing today, because the exporter emits the declared pivot and the pivot only matters when scale != 100%. But scale IS animated here — 177 keyframes across GP_TITLE are not 100%, including on the title screen P1 has to draw — so the question of what the running game anchors a scale to is worth an answer before P2. Noted there that the port and `sylpheed-cli screen render` make the same choice, so a P1 diff cannot distinguish them and their agreement is not evidence. --- docs/BLOCKED.md | 77 ++++++++++++++---- docs/DECISIONS.md | 116 +++++++++++++++++++++++++++ docs/FORMAT.md | 200 +++++++++++++++++++++++++++++++++++++--------- 3 files changed, 340 insertions(+), 53 deletions(-) create mode 100644 docs/DECISIONS.md diff --git a/docs/BLOCKED.md b/docs/BLOCKED.md index ec94dc81..2812e542 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 00000000..d4279002 --- /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 b5189206..91ce0115 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. | From 5d04ef16fe68080415485382ba4504c77ab052cc Mon Sep 17 00:00:00 2001 From: Sylpheed port agent Date: Fri, 28 Aug 2026 19:00:26 +0000 Subject: [PATCH 3/3] docker: fix build-export for the subcommand, and build the reference CLI at the pin Two tooling fixes, both needed before P1 can diff anything. `build-export` was calling `sylpheed-export --disc ... --out export`, which P0 broke when it moved the exporter behind `export` / `check` subcommands. It is a regression from 8dd0577 and this is the fix. It now also runs `check` after exporting, because `export` writes a tree and `check` is the only thing that says the tree is readable by something other than the program that wrote it. `build-reference-cli` is new. `sylpheed-cli screen render` is the reference the Godot port gets diffed against, and the only copy of it in the container is /reborn/target/release, which has two problems: the release binary predates the `screen` subcommand entirely, and /reborn is a LIVE mount of the other agent's working tree -- it moved from e81dcad to f5e7426 during a single iteration. A reference renderer that runs different decoders than the exporter puts a free variable in every pixel diff, so this builds the CLI from the same pinned revision the exporter uses and drops it on the persistent target volume. Both are COPY'd into the image at Dockerfile:76, so neither takes effect in a running container until the image is rebuilt. The reference CLI is already built into the target volume by hand, so P1 is not blocked in the meantime. --- docker/bin/build-export | 10 ++++++-- docker/bin/build-reference-cli | 45 ++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) create mode 100755 docker/bin/build-reference-cli diff --git a/docker/bin/build-export b/docker/bin/build-export index 3e4ebfe7..6102bde3 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 00000000..c7356f27 --- /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)"