export: P0 — GP_TITLE's screens and sprites into the open tree
`sylpheed-export export` reads `dat/GP_TITLE.pak`, enumerates its twelve screen builds, and writes each as one `sylpheed.screen/2` document with its sprite PNGs beside it. `sylpheed-export check` validates that tree against docs/FORMAT.md with no disc in hand — the P0 gate is "validates against FORMAT.md", which is not something anyone can confirm by reading, so it is a program. Two readings from FORMAT v1 turned out to be wrong and are corrected here rather than carried: * The focus sprite does NOT come from the element's `opt ` link. That was measured and refuted upstream, and this export shows why plainly: on the main menu `opt ` chains ptloop01 -> ptloop02 -> ptbtn01, two decorations and then a button. The highlight pairs by sprite NAME instead (ptbtn01.t32 <-> ptbtn01f.t32), which is the convention HANDOFF blesses and which resolves all five main-menu buttons. The raw link is still exported, renamed `opt_link` so nothing downstream mistakes it for navigation. * There are TWO modulate colours in different byte orders, and they multiply. v1's single `#rrggbbaa` could not carry both and silently dropped the alpha that every fade ramps. They are now `tint_rgba` and `fade_argb`, with the byte order in the key name, because getting it backwards is silent and reads as an art bug rather than a parse bug. The exporter takes exactly one authored input: `authored/screen_names.json`, because the disc does not name its builds and "build 5 is the main menu" is a measurement (HANDOFF Q2), not a field. Every name it applies is stamped `name_source: "authored"` with the evidence in `name_why`, and `check` rejects an authored name that has no `why` — so the derived tree stays honest about which of its fields is a decision. Sprites are per screen, not a flat pool: `main_menu` and `extras` both ship a `ptbase.t32` and they are different pictures. Checked, not assumed: * two exports of the same disc are byte-identical; * five mutations of a valid main_menu.json — a broken paint_order permutation, a dangling focus_sprite, a reversed buttons list, a `#rrggbbaa` colour and an invented name_source — are each caught with a specific message. `t` stays raw. Q1 is answered, but the seconds conversion is measured off the running game and its own finding flags the frame-rate measurement as the part worth re-testing; if the game presents at 60 Hz every duration halves. One constant, at P2, in a file that says it is a decision.
This commit is contained in:
45
authored/screen_names.json
Normal file
45
authored/screen_names.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"format": "sylpheed.screen_names/1",
|
||||
"_": [
|
||||
"Which GP_TITLE build is which screen. AUTHORED: the disc does not name its",
|
||||
"builds, so every name here is a decision. The identifications come from",
|
||||
"HANDOFF Q2 (ui-title-build-map.md), which measured four of them against",
|
||||
"framebuffer captures of the running game; the exporter stamps the name into",
|
||||
"the screen file with name_source: \"authored\" so a reader can tell a",
|
||||
"recovered name from an invented one.",
|
||||
"",
|
||||
"`build` is the index into the pak's list of screen builds -- what",
|
||||
"`sylpheed-cli screen --build N` takes -- and is stable as long as the",
|
||||
"enumeration rule is. The screen file also records the pak entry index,",
|
||||
"which is the stronger locator.",
|
||||
"",
|
||||
"Delete an entry here the day the RE agent decodes a name field."
|
||||
],
|
||||
"archives": {
|
||||
"dat/GP_TITLE.pak": {
|
||||
"2": {
|
||||
"name": "press_start",
|
||||
"why": "HANDOFF Q2: builds 2/3 are the PRESS (A) BUTTON plate -- a build of its own, composited over the title and faded in a beat later. English of the EN/JP pair. Measured against a live capture."
|
||||
},
|
||||
"3": { "name": "press_start_jp", "why": "HANDOFF Q2: the Japanese twin of build 2. Out of scope for this milestone; named so it is not mistaken for a screen we need." },
|
||||
"4": {
|
||||
"name": "title",
|
||||
"why": "HANDOFF Q2: build 4 is the English title art. Measured against a live capture."
|
||||
},
|
||||
"5": {
|
||||
"name": "main_menu",
|
||||
"why": "HANDOFF Q2: builds 5/8 are the five-button main menu; 5 is English. Measured against a live capture. (An earlier reading called 8 a submenu and was withdrawn -- 8 is the Japanese main menu.)"
|
||||
},
|
||||
"6": {
|
||||
"name": "extras",
|
||||
"why": "HANDOFF Q2: builds 6/9 are the EXTRAS submenu, the only submenu inside this archive. Measured against a fresh EXTRAS capture."
|
||||
},
|
||||
"7": { "name": "title_jp", "why": "HANDOFF Q2: the Japanese twin of build 4." },
|
||||
"8": { "name": "main_menu_jp", "why": "HANDOFF Q2: the Japanese twin of build 5." },
|
||||
"9": { "name": "extras_jp", "why": "HANDOFF Q2: the Japanese twin of build 6." }
|
||||
}
|
||||
},
|
||||
"unnamed": {
|
||||
"dat/GP_TITLE.pak": "Builds 0/1 and 10/11 are a DELTASABER / SYLPHEED A.I. plate that was never seen running -- not in the boot path, not on any title-side screen, not in the attract loop (HANDOFF Q2). They export under their build index rather than a name we would be inventing."
|
||||
}
|
||||
}
|
||||
@@ -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"] }
|
||||
|
||||
277
crates/sylpheed-export/src/check.rs
Normal file
277
crates/sylpheed-export/src/check.rs
Normal file
@@ -0,0 +1,277 @@
|
||||
//! `sylpheed-export check` — validate an export tree against `docs/FORMAT.md`.
|
||||
//!
|
||||
//! This is the executable form of FORMAT.md, and the reason it exists is that
|
||||
//! "the export is correct" is otherwise an assertion. It reads `export/` the way
|
||||
//! the Godot project will — as a stranger, with no access to the disc, the
|
||||
//! decoders or this exporter's internals — and fails on anything a consumer
|
||||
//! could not act on:
|
||||
//!
|
||||
//! * a document whose `format` is not the version this build writes;
|
||||
//! * a required field missing, or a colour that is not `0x` + 8 hex digits;
|
||||
//! * a `paint_order` that is not a permutation of the element indices;
|
||||
//! * a `buttons` entry naming an element that is not a button, or out of
|
||||
//! resting-Y order;
|
||||
//! * a sprite path that does not exist, or a PNG that does not decode;
|
||||
//! * a name presented as recovered when it was authored.
|
||||
//!
|
||||
//! It deliberately does **not** check that the export matches the disc. That is
|
||||
//! what `sylpheed-cli screen render` is for.
|
||||
|
||||
use anyhow::{bail, Result};
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
|
||||
const SCREEN_FORMAT: &str = "sylpheed.screen/2";
|
||||
const MANIFEST_FORMAT: &str = "sylpheed.manifest/1";
|
||||
|
||||
struct Ctx {
|
||||
file: String,
|
||||
errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
fn err(&mut self, msg: impl Into<String>) {
|
||||
self.errors.push(format!("{}: {}", self.file, msg.into()));
|
||||
}
|
||||
fn require<'a>(&mut self, v: &'a Value, key: &str) -> Option<&'a Value> {
|
||||
match v.get(key) {
|
||||
Some(Value::Null) | None => {
|
||||
self.err(format!("missing required field `{key}`"));
|
||||
None
|
||||
}
|
||||
Some(x) => Some(x),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A colour is exported as `0x` + 8 hex digits, with its byte order in the key
|
||||
/// name. Anything else means a consumer has to guess, which is the whole thing
|
||||
/// the format exists to prevent.
|
||||
fn is_hex32(v: Option<&Value>) -> bool {
|
||||
v.and_then(Value::as_str)
|
||||
.is_some_and(|s| s.len() == 10 && s.starts_with("0x") && s[2..].chars().all(|c| c.is_ascii_hexdigit()))
|
||||
}
|
||||
|
||||
fn check_pose(c: &mut Ctx, where_: &str, p: &Value) {
|
||||
for (key, want_len) in [("pos", 2usize), ("scale", 2)] {
|
||||
match p.get(key).and_then(Value::as_array) {
|
||||
Some(a) if a.len() == want_len && a.iter().all(Value::is_i64) => {}
|
||||
_ => c.err(format!("{where_}: `{key}` must be {want_len} integers")),
|
||||
}
|
||||
}
|
||||
for key in ["tint_rgba", "fade_argb"] {
|
||||
if !is_hex32(p.get(key)) {
|
||||
c.err(format!("{where_}: `{key}` must be 0x + 8 hex digits"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_screen(root: &Path, rel: &str, errors: &mut Vec<String>) -> Result<()> {
|
||||
let raw = std::fs::read_to_string(root.join(rel))?;
|
||||
let v: Value = serde_json::from_str(&raw)?;
|
||||
let mut c = Ctx {
|
||||
file: rel.to_string(),
|
||||
errors: Vec::new(),
|
||||
};
|
||||
|
||||
if v.get("format").and_then(Value::as_str) != Some(SCREEN_FORMAT) {
|
||||
c.err(format!(
|
||||
"format is {:?}, expected {SCREEN_FORMAT:?}",
|
||||
v.get("format")
|
||||
));
|
||||
}
|
||||
for key in ["exporter", "formats_rev", "name", "name_source"] {
|
||||
c.require(&v, key);
|
||||
}
|
||||
// Rule 2 of the format: a modder must be able to tell a recovered name from
|
||||
// an invented one, so the provenance is mandatory and closed.
|
||||
match v.get("name_source").and_then(Value::as_str) {
|
||||
Some("authored") => {
|
||||
if v.get("name_why").and_then(Value::as_str).is_none_or(str::is_empty) {
|
||||
c.err("name_source is `authored` but there is no `name_why`");
|
||||
}
|
||||
}
|
||||
Some("index") => {}
|
||||
other => c.err(format!("name_source must be `authored` or `index`, got {other:?}")),
|
||||
}
|
||||
if let Some(s) = v.get("source") {
|
||||
for key in ["archive", "entry", "build"] {
|
||||
if s.get(key).is_none() {
|
||||
c.err(format!("source is missing `{key}`"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
c.err("missing required field `source`");
|
||||
}
|
||||
match v.get("design").and_then(Value::as_array) {
|
||||
Some(d) if d.len() == 2 && d.iter().all(Value::is_u64) => {}
|
||||
_ => c.err("`design` must be two positive integers"),
|
||||
}
|
||||
|
||||
let Some(elements) = v.get("elements").and_then(Value::as_array) else {
|
||||
c.err("missing required field `elements`");
|
||||
errors.append(&mut c.errors);
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut indices = Vec::new();
|
||||
let mut buttons_by_y: Vec<(i64, String)> = Vec::new();
|
||||
for (i, el) in elements.iter().enumerate() {
|
||||
let id = el.get("id").and_then(Value::as_str).unwrap_or("<no id>").to_string();
|
||||
let at = format!("element {i} ({id})");
|
||||
for key in ["index", "id", "declared", "role", "kind_raw", "pivot", "layer_source", "keyframes"] {
|
||||
if el.get(key).is_none() {
|
||||
c.err(format!("{at}: missing `{key}`"));
|
||||
}
|
||||
}
|
||||
let Some(idx) = el.get("index").and_then(Value::as_u64) else {
|
||||
c.err(format!("{at}: `index` is not an integer"));
|
||||
continue;
|
||||
};
|
||||
if idx as usize != i {
|
||||
c.err(format!("{at}: `index` {idx} does not match its position {i}"));
|
||||
}
|
||||
indices.push(idx as usize);
|
||||
|
||||
let role = el.get("role").and_then(Value::as_str).unwrap_or("");
|
||||
if !matches!(role, "button" | "decoration" | "primitive" | "unknown") {
|
||||
c.err(format!("{at}: role {role:?} is not one FORMAT.md defines"));
|
||||
}
|
||||
// A role of `unknown` must still carry the raw kind, or the information
|
||||
// is simply lost.
|
||||
if role == "unknown" && el.get("kind_raw").is_none() {
|
||||
c.err(format!("{at}: role `unknown` without `kind_raw`"));
|
||||
}
|
||||
// A primitive has no texture, so its quad size has to come from the file.
|
||||
if role == "primitive" && el.get("size").is_none() {
|
||||
c.err(format!("{at}: primitive without a `size`"));
|
||||
}
|
||||
|
||||
match el.get("layer_source").and_then(Value::as_str) {
|
||||
Some("sprite") | Some("implied") => {
|
||||
if !is_hex32(el.get("layer")) {
|
||||
c.err(format!("{at}: layer_source claims a key but `layer` is not one"));
|
||||
}
|
||||
}
|
||||
Some("none") => {
|
||||
if el.get("layer").is_some() {
|
||||
c.err(format!("{at}: layer_source `none` but a `layer` is present"));
|
||||
}
|
||||
}
|
||||
other => c.err(format!("{at}: layer_source must be sprite/implied/none, got {other:?}")),
|
||||
}
|
||||
|
||||
for key in ["sprite", "focus_sprite"] {
|
||||
if let Some(p) = el.get(key).and_then(Value::as_str) {
|
||||
let path = root.join(p);
|
||||
if !path.exists() {
|
||||
c.err(format!("{at}: `{key}` points at {p}, which does not exist"));
|
||||
} else if let Err(e) = image::open(&path) {
|
||||
c.err(format!("{at}: `{key}` {p} does not decode as an image: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(r) = el.get("rest") {
|
||||
check_pose(&mut c, &at, r);
|
||||
if role == "button" {
|
||||
if let Some(y) = r.get("pos").and_then(Value::as_array).and_then(|a| a[1].as_i64()) {
|
||||
buttons_by_y.push((y, id.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(kfs) = el.get("keyframes").and_then(Value::as_array) {
|
||||
for (k, kf) in kfs.iter().enumerate() {
|
||||
check_pose(&mut c, &format!("{at} keyframe {k}"), kf);
|
||||
}
|
||||
// The last keyframe of a group carries no time slot on the disc, and
|
||||
// an invented one is exactly the kind of value this format refuses.
|
||||
if kfs.len() > 1 && kfs.last().is_some_and(|k| k.get("t").is_some()) {
|
||||
c.err(format!("{at}: the final keyframe has a `t`; the disc has no time slot there"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Paint order must be a permutation of the element indices, or the runtime
|
||||
// either drops an element or draws one twice.
|
||||
match v.get("paint_order").and_then(Value::as_array) {
|
||||
Some(po) => {
|
||||
let mut got: Vec<usize> = po.iter().filter_map(|x| x.as_u64().map(|v| v as usize)).collect();
|
||||
if got.len() != po.len() {
|
||||
c.err("`paint_order` holds a non-integer");
|
||||
}
|
||||
let mut want = indices.clone();
|
||||
got.sort_unstable();
|
||||
want.sort_unstable();
|
||||
if got != want {
|
||||
c.err("`paint_order` is not a permutation of the element indices");
|
||||
}
|
||||
}
|
||||
None => c.err("missing required field `paint_order`"),
|
||||
}
|
||||
|
||||
// `buttons` is navigation order and is defined as resting Y, ascending. If
|
||||
// it is not sorted, it is not the thing FORMAT.md says it is.
|
||||
match v.get("buttons").and_then(Value::as_array) {
|
||||
Some(b) => {
|
||||
let listed: Vec<&str> = b.iter().filter_map(Value::as_str).collect();
|
||||
buttons_by_y.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
let want: Vec<&str> = buttons_by_y.iter().map(|(_, n)| n.as_str()).collect();
|
||||
if listed != want {
|
||||
c.err(format!(
|
||||
"`buttons` is {listed:?} but resting-Y order is {want:?}"
|
||||
));
|
||||
}
|
||||
}
|
||||
None => c.err("missing required field `buttons`"),
|
||||
}
|
||||
|
||||
if v.get("unresolved").and_then(Value::as_array).is_none() {
|
||||
c.err("missing required field `unresolved` (an empty list is a claim; absence is a gap)");
|
||||
}
|
||||
|
||||
errors.append(&mut c.errors);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate a whole export tree. Returns the number of screens checked.
|
||||
pub fn run(root: &Path) -> Result<usize> {
|
||||
let manifest_path = root.join("manifest.json");
|
||||
if !manifest_path.exists() {
|
||||
bail!("{} has no manifest.json — is that an export tree?", root.display());
|
||||
}
|
||||
let m: Value = serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?;
|
||||
let mut errors = Vec::new();
|
||||
if m.get("format").and_then(Value::as_str) != Some(MANIFEST_FORMAT) {
|
||||
errors.push(format!("manifest.json: format is not {MANIFEST_FORMAT:?}"));
|
||||
}
|
||||
for key in ["exporter", "formats_rev", "screens", "warnings"] {
|
||||
if m.get(key).is_none() {
|
||||
errors.push(format!("manifest.json: missing `{key}`"));
|
||||
}
|
||||
}
|
||||
let screens = m
|
||||
.get("screens")
|
||||
.and_then(Value::as_array)
|
||||
.map(|s| s.to_vec())
|
||||
.unwrap_or_default();
|
||||
for s in &screens {
|
||||
let Some(file) = s.get("file").and_then(Value::as_str) else {
|
||||
errors.push("manifest.json: a screen entry has no `file`".into());
|
||||
continue;
|
||||
};
|
||||
if !root.join(file).exists() {
|
||||
errors.push(format!("manifest.json: lists {file}, which does not exist"));
|
||||
continue;
|
||||
}
|
||||
check_screen(root, file, &mut errors)?;
|
||||
}
|
||||
|
||||
if !errors.is_empty() {
|
||||
for e in &errors {
|
||||
eprintln!(" ✗ {e}");
|
||||
}
|
||||
bail!("{} problem(s) in {}", errors.len(), root.display());
|
||||
}
|
||||
Ok(screens.len())
|
||||
}
|
||||
@@ -5,29 +5,214 @@
|
||||
//! Vorbis and Ogg Theora, so the runtime — and anyone modding it — reads formats
|
||||
//! a person can open.
|
||||
//!
|
||||
//! The output tree is **derived**: regenerated wholesale, never hand-edited. The
|
||||
//! only thing this program takes from `authored/` is the screen-name map, and
|
||||
//! every name it applies is stamped `name_source: "authored"` in the file it
|
||||
//! lands in, so the export stays auditable against the disc.
|
||||
//!
|
||||
//! See `docs/FORMAT.md` for the schema and `docs/MISSION.md` for scope.
|
||||
|
||||
use anyhow::Result;
|
||||
mod check;
|
||||
mod screen;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
|
||||
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
|
||||
/// export auditable a month later.
|
||||
const FORMATS_REV: &str = "8b6dbcf";
|
||||
const EXPORTER: &str = concat!("sylpheed-export ", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about, version)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
enum Cmd {
|
||||
/// Convert the disc into `export/`. Rewrites the tree wholesale.
|
||||
Export {
|
||||
/// Extracted disc root (the directory holding `dat/` and `hidden/`).
|
||||
#[arg(long)]
|
||||
#[arg(long, env = "SYLPHEED_DISC")]
|
||||
disc: PathBuf,
|
||||
/// Output tree. Rewritten wholesale — never hand-edit it.
|
||||
#[arg(long, default_value = "export")]
|
||||
out: PathBuf,
|
||||
/// Authored decisions applied during export (currently the screen names).
|
||||
#[arg(long, default_value = "authored")]
|
||||
authored: PathBuf,
|
||||
},
|
||||
/// Validate an export tree against `docs/FORMAT.md`, with no disc in hand.
|
||||
///
|
||||
/// Reads the tree the way the Godot project will: as a stranger, with no
|
||||
/// access to the disc, the decoders or this exporter's internals.
|
||||
Check {
|
||||
#[arg(long, default_value = "export")]
|
||||
out: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ManifestScreen {
|
||||
name: String,
|
||||
file: String,
|
||||
sprites: usize,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
missing_sprites: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Manifest {
|
||||
format: &'static str,
|
||||
exporter: &'static str,
|
||||
/// Which decoders produced this export. Pinned by revision, not floated.
|
||||
formats_rev: &'static str,
|
||||
disc: String,
|
||||
screens: Vec<ManifestScreen>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The authored `build index → name` map, keyed by archive path.
|
||||
type NameMap = std::collections::BTreeMap<String, std::collections::BTreeMap<String, NameEntry>>;
|
||||
|
||||
#[derive(serde::Deserialize)]
|
||||
struct NameEntry {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
why: Option<String>,
|
||||
}
|
||||
|
||||
fn load_names(authored: &Path) -> Result<NameMap> {
|
||||
let path = authored.join("screen_names.json");
|
||||
if !path.exists() {
|
||||
return Ok(NameMap::new());
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct File {
|
||||
archives: NameMap,
|
||||
}
|
||||
let raw = std::fs::read_to_string(&path)
|
||||
.with_context(|| format!("read {}", path.display()))?;
|
||||
Ok(serde_json::from_str::<File>(&raw)
|
||||
.with_context(|| format!("parse {}", path.display()))?
|
||||
.archives)
|
||||
}
|
||||
|
||||
/// Every RATC entry of a UI pak that parses as a screen build.
|
||||
///
|
||||
/// The filter is `is_build` — a bundle with a `.rat` layout child. The developer
|
||||
/// splash declares its sprites directly and has none, so it is invisible here;
|
||||
/// that is P3's problem and is recorded as a manifest warning rather than
|
||||
/// silently widened.
|
||||
fn screen_builds(ar: &PakArchive) -> Vec<(usize, Vec<u8>)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, e) in ar.entries().iter().enumerate() {
|
||||
let Ok(bytes) = ar.read(e) else { continue };
|
||||
if ui_layout::is_build(&bytes) {
|
||||
out.push((i, bytes));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args = Args::parse();
|
||||
let source = sylpheed_formats::media::DirectorySource::new(&args.disc);
|
||||
// P0 starts here: enumerate GP_TITLE's screen builds and write one out.
|
||||
// Nothing is implemented yet -- this proves the pinned decoders resolve.
|
||||
let _ = (&source, &args.out);
|
||||
println!("sylpheed-export: scaffold only; see docs/MISSION.md milestone P0");
|
||||
match Args::parse().cmd {
|
||||
Cmd::Export {
|
||||
disc,
|
||||
out,
|
||||
authored,
|
||||
} => run_export(&disc, &out, &authored),
|
||||
Cmd::Check { out } => {
|
||||
let n = check::run(&out)?;
|
||||
println!("{} screen(s) in {} validate against sylpheed.screen/2", n, out.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn run_export(disc: &Path, out: &Path, authored_dir: &Path) -> Result<()> {
|
||||
let names = load_names(authored_dir)?;
|
||||
|
||||
// Derived output is regenerated wholesale: clear it, so a screen that stops
|
||||
// being exported stops existing rather than lingering as a stale file that
|
||||
// still validates.
|
||||
if out.exists() {
|
||||
std::fs::remove_dir_all(&out).context("clear the output tree")?;
|
||||
}
|
||||
std::fs::create_dir_all(&out)?;
|
||||
|
||||
let archive = "dat/GP_TITLE.pak";
|
||||
let pak = disc.join(archive);
|
||||
let ar = PakArchive::open(&pak).with_context(|| format!("open {}", pak.display()))?;
|
||||
let builds = screen_builds(&ar);
|
||||
println!("{archive}: {} screen build(s)", builds.len());
|
||||
|
||||
let archive_names = names.get(archive);
|
||||
let mut screens = Vec::new();
|
||||
for (build_idx, (entry, bytes)) in builds.iter().enumerate() {
|
||||
let authored = archive_names.and_then(|m| m.get(&build_idx.to_string()));
|
||||
let (name, name_source, why) = match authored {
|
||||
Some(e) => (e.name.clone(), "authored", e.why.clone()),
|
||||
// Nobody has identified this build. Emit a stable synthetic id and
|
||||
// say in the file that the name is not a recovered one.
|
||||
None => (format!("build_{build_idx:02}"), "index", None),
|
||||
};
|
||||
let ex = screen::export_build(
|
||||
&out,
|
||||
archive,
|
||||
*entry,
|
||||
build_idx,
|
||||
bytes,
|
||||
&name,
|
||||
name_source,
|
||||
why,
|
||||
"title",
|
||||
EXPORTER,
|
||||
FORMATS_REV,
|
||||
)
|
||||
.with_context(|| format!("export build {build_idx} of {archive}"))?;
|
||||
println!(
|
||||
" [{build_idx}] entry {entry:<3} -> {} ({} sprites{})",
|
||||
ex.json_path,
|
||||
ex.sprites,
|
||||
if ex.missing.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!(", {} missing", ex.missing.len())
|
||||
}
|
||||
);
|
||||
screens.push(ManifestScreen {
|
||||
name: ex.name,
|
||||
file: ex.json_path,
|
||||
sprites: ex.sprites,
|
||||
missing_sprites: ex.missing,
|
||||
});
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
format: "sylpheed.manifest/1",
|
||||
exporter: EXPORTER,
|
||||
formats_rev: FORMATS_REV,
|
||||
disc: disc.display().to_string(),
|
||||
screens,
|
||||
warnings: vec![
|
||||
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
||||
.into(),
|
||||
"The developer-logo splash is not here: it declares its sprites directly and has \
|
||||
no .rat layout child, so `is_build` does not see it. P3."
|
||||
.into(),
|
||||
],
|
||||
};
|
||||
std::fs::write(
|
||||
out.join("manifest.json"),
|
||||
format!("{}\n", serde_json::to_string_pretty(&manifest)?),
|
||||
)?;
|
||||
println!("wrote {}/manifest.json", out.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
351
crates/sylpheed-export/src/screen.rs
Normal file
351
crates/sylpheed-export/src/screen.rs
Normal file
@@ -0,0 +1,351 @@
|
||||
//! One UI build → one `sylpheed.screen/2` JSON document plus its sprite PNGs.
|
||||
//!
|
||||
//! Everything here is **derived**: it is what the bundle says, restated in a
|
||||
//! format Godot can read. The two places a value is not read off the disc are
|
||||
//! marked in the output itself — `name_source` when a screen's name came from
|
||||
//! `authored/`, and `layer_source: "implied"` when the paint-order key came from
|
||||
//! the decoders' measured table rather than from a `T8aD` header. A consumer can
|
||||
//! tell the difference without reading this file.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
use sylpheed_formats::{t8ad, ui_layout};
|
||||
|
||||
/// `elements[].role`, from the decoded element kind.
|
||||
///
|
||||
/// ⚠️ `0x3002` is one member of a `0x3000` family and is **not** a general
|
||||
/// button test — `GP_READY_ROOM` uses `0x3000`/`0x3004`/`0x300c`/`0x3008` and
|
||||
/// has zero `0x3002`. Every screen in this milestone is `GP_TITLE`, where the
|
||||
/// mapping is decoded; anything else exports as `unknown` with its raw kind.
|
||||
fn role_of(kind: u32, has_sprite: bool) -> &'static str {
|
||||
match kind {
|
||||
0x3002 => "button",
|
||||
0x10 if !has_sprite => "primitive",
|
||||
0x0 => "decoration",
|
||||
_ => "unknown",
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Source {
|
||||
/// Path of the archive within the disc root.
|
||||
pub archive: String,
|
||||
/// Pak **entry index** — the stable locator, not the display ordinal.
|
||||
pub entry: usize,
|
||||
/// Index into this pak's list of screen builds (what `screen --build` takes).
|
||||
pub build: usize,
|
||||
}
|
||||
|
||||
/// A placement keyframe, carrying the on-disc time verbatim.
|
||||
///
|
||||
/// `t` is in the disc's own units and is deliberately **not** converted here:
|
||||
/// the seconds conversion is measured off the running game, not read from the
|
||||
/// file, so it lives in `authored/timing.json` and is applied in exactly one
|
||||
/// place. See HANDOFF Q1.
|
||||
#[derive(Serialize)]
|
||||
pub struct Keyframe {
|
||||
/// On-disc time, absent on the final keyframe of a group — which carries no
|
||||
/// time slot at all. Absent, never invented.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub t: Option<u32>,
|
||||
/// Top-left of the element at 1:1. Signed: elements animate in from off-screen.
|
||||
pub pos: [i32; 2],
|
||||
/// Percent, per axis. Scale grows the element **about its pivot**, not about
|
||||
/// `pos` — at 100 % the two are identical, which is why it went unnoticed.
|
||||
pub scale: [u32; 2],
|
||||
/// Modulate colour, **RGBA** byte order. `0xffffffff` on essentially every
|
||||
/// keyframe on the disc.
|
||||
pub tint_rgba: String,
|
||||
/// The second modulate colour, **ARGB** byte order — the high byte is the
|
||||
/// alpha that ramps during a fade. Multiplies with `tint_rgba`.
|
||||
pub fade_argb: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Rest {
|
||||
pub pos: [i32; 2],
|
||||
pub scale: [u32; 2],
|
||||
pub tint_rgba: String,
|
||||
pub fade_argb: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub t: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Element {
|
||||
/// Declaration index — the key the placement region and `paint_order` use.
|
||||
pub index: usize,
|
||||
/// The declared name with its extension stripped; stable within a screen.
|
||||
pub id: String,
|
||||
/// The name exactly as the declaration table spells it.
|
||||
pub declared: String,
|
||||
pub role: &'static str,
|
||||
pub kind_raw: String,
|
||||
/// Sprite PNG, relative to `export/`. Absent for an untextured primitive.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub sprite: Option<String>,
|
||||
/// The highlighted-state sprite: this element's sprite with an `f` before
|
||||
/// the extension, when the bundle carries one — `ptbtn01.t32` ↔
|
||||
/// `ptbtn01f.t32`. 🟡 **A naming convention, not a decoded field.** It holds
|
||||
/// for all 54 real pairs on the disc (HANDOFF), and it is the only link
|
||||
/// between a button and its highlight that has survived checking.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub focus_sprite: Option<String>,
|
||||
/// The raw `opt ` link inside this element's `.rat` record.
|
||||
///
|
||||
/// ⚠️ **This is not a focus link.** It was read as one, and that was
|
||||
/// measured and refuted (HANDOFF, `ui-focus-and-effect-elements.md`) — on the
|
||||
/// main menu it chains `ptloop01 → ptloop02 → ptbtn01`, across two
|
||||
/// decorations and into a button. It is carried through unresolved and
|
||||
/// unnamed so that whoever decodes it has it, and so that nothing downstream
|
||||
/// mistakes it for navigation.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub opt_link: Option<String>,
|
||||
pub pivot: [u32; 2],
|
||||
/// Untextured primitives have no texture to take a size from; the quad is
|
||||
/// `pivot × 2`, which is 1280×720 for 361 of the disc's 369 primitives.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub size: Option<[u32; 2]>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub parent: Option<usize>,
|
||||
/// Paint-order key. `"sprite"` = read from the `T8aD` header at `+0x0A`.
|
||||
/// `"implied"` = **measured off the running game**, for elements that carry
|
||||
/// no header. `"none"` = neither; sorts last.
|
||||
pub layer_source: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub layer: Option<String>,
|
||||
/// This element is another element's focused state, not a screen element.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub focused: bool,
|
||||
/// A `loopN` sprite animation rather than a placed element.
|
||||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||||
pub animated: bool,
|
||||
/// The resting pose: the **hold**, the longest run of consecutive keyframes
|
||||
/// with an identical pose that does not end the group. Neither the first nor
|
||||
/// the last keyframe.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub rest: Option<Rest>,
|
||||
pub keyframes: Vec<Keyframe>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Screen {
|
||||
pub format: &'static str,
|
||||
pub exporter: String,
|
||||
/// Revision of `sylpheed-formats` whose decoders produced this file.
|
||||
pub formats_rev: &'static str,
|
||||
pub source: Source,
|
||||
pub name: String,
|
||||
/// `"authored"` when the name came from `authored/screen_names.json`,
|
||||
/// `"index"` when nobody has named this build yet.
|
||||
pub name_source: &'static str,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name_why: Option<String>,
|
||||
pub design: [u32; 2],
|
||||
pub elements: Vec<Element>,
|
||||
/// Back-to-front paint order as declaration indices, from the decoded `u16`
|
||||
/// layer key at `+0x0A` of each sprite header, stable-sorted so equal keys
|
||||
/// keep declaration order. See `unresolved: paint_order_ties`.
|
||||
pub paint_order: Vec<usize>,
|
||||
/// Navigation order: `button`-role elements sorted by resting Y.
|
||||
/// **Geometric, not a decoded neighbour graph** — right for a vertical menu
|
||||
/// and not to be trusted for anything else.
|
||||
pub buttons: Vec<String>,
|
||||
/// What this file does not answer. A consumer needing one of these must get
|
||||
/// it from `authored/`.
|
||||
pub unresolved: Vec<&'static str>,
|
||||
}
|
||||
|
||||
fn hex32(v: u32) -> String {
|
||||
format!("0x{v:08x}")
|
||||
}
|
||||
|
||||
/// Strip the extension the declaration table spells, giving a stable id.
|
||||
pub fn id_of(declared: &str) -> String {
|
||||
declared
|
||||
.rsplit_once('.')
|
||||
.map(|(stem, _)| stem)
|
||||
.unwrap_or(declared)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// What one screen's export produced, for the manifest.
|
||||
pub struct Exported {
|
||||
pub name: String,
|
||||
pub json_path: String,
|
||||
pub sprites: usize,
|
||||
/// Sprites an element named that did not resolve or decode.
|
||||
pub missing: Vec<String>,
|
||||
}
|
||||
|
||||
/// Convert one build to JSON on disk, writing its sprite PNGs beside it.
|
||||
///
|
||||
/// `sprite_dir` is per-screen: a sprite name is unique within a bundle but not
|
||||
/// across builds, and two screens' `ptbase.t32` are different pictures.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn export_build(
|
||||
out: &Path,
|
||||
archive: &str,
|
||||
entry: usize,
|
||||
build_idx: usize,
|
||||
bundle: &[u8],
|
||||
name: &str,
|
||||
name_source: &'static str,
|
||||
name_why: Option<String>,
|
||||
subdir: &str,
|
||||
exporter: &str,
|
||||
formats_rev: &'static str,
|
||||
) -> Result<Exported> {
|
||||
let b = ui_layout::parse_build(bundle).context("build did not parse")?;
|
||||
|
||||
// Every sprite an element actually references, decoded once and written as a
|
||||
// PNG under this screen's own directory.
|
||||
let sprite_rel = |sprite: &str| format!("sprites/{subdir}/{name}/{}.png", id_of(sprite));
|
||||
let sprite_dir = out.join("sprites").join(subdir).join(name);
|
||||
std::fs::create_dir_all(&sprite_dir)?;
|
||||
let mut written: BTreeMap<String, ()> = BTreeMap::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut write_sprite = |sprite: &str| -> Result<bool> {
|
||||
if written.contains_key(sprite) {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(&(off, size)) = b.sprites.get(sprite) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(img) = t8ad::parse(&bundle[off..off + size]) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let buf = image::RgbaImage::from_raw(img.width, img.height, img.rgba)
|
||||
.context("T8aD dimensions disagree with its pixel count")?;
|
||||
buf.save(sprite_dir.join(format!("{}.png", id_of(sprite))))?;
|
||||
written.insert(sprite.to_string(), ());
|
||||
Ok(true)
|
||||
};
|
||||
|
||||
/// The highlighted twin of a sprite name: `ptbtn01.t32` → `ptbtn01f.t32`.
|
||||
fn highlight_name(sprite: &str) -> Option<String> {
|
||||
let (stem, ext) = sprite.rsplit_once('.')?;
|
||||
Some(format!("{stem}f.{ext}"))
|
||||
}
|
||||
|
||||
let mut elements = Vec::new();
|
||||
for el in &b.elements {
|
||||
let mut sprite_out = None;
|
||||
if let Some(s) = &el.sprite {
|
||||
if write_sprite(s)? {
|
||||
sprite_out = Some(sprite_rel(s));
|
||||
} else {
|
||||
missing.push(s.clone());
|
||||
}
|
||||
}
|
||||
// The highlight pairs by NAME on the sprite, not through the `opt `
|
||||
// link: `opt ` is refuted as a focus link and points somewhere else
|
||||
// entirely on half these elements.
|
||||
let mut focus_sprite = None;
|
||||
if let Some(h) = el.sprite.as_deref().and_then(highlight_name) {
|
||||
if b.sprites.contains_key(&h) && write_sprite(&h)? {
|
||||
focus_sprite = Some(sprite_rel(&h));
|
||||
}
|
||||
}
|
||||
|
||||
let (layer, layer_source) = match ui_layout::sprite_layer_key(&b, bundle, el) {
|
||||
Some(k) => (Some(hex32(k)), "sprite"),
|
||||
None => match ui_layout::implied_layer_key(&el.name) {
|
||||
Some(k) => (Some(hex32(k)), "implied"),
|
||||
None => (None, "none"),
|
||||
},
|
||||
};
|
||||
|
||||
let kf = |k: &ui_layout::Keyframe| Keyframe {
|
||||
t: k.time,
|
||||
pos: [k.x, k.y],
|
||||
scale: [k.scale_x, k.scale_y],
|
||||
tint_rgba: hex32(k.tint),
|
||||
fade_argb: hex32(k.fade),
|
||||
};
|
||||
let role = role_of(el.kind, el.sprite.is_some());
|
||||
elements.push(Element {
|
||||
index: el.index,
|
||||
id: id_of(&el.name),
|
||||
declared: el.name.clone(),
|
||||
role,
|
||||
kind_raw: format!("{:#x}", el.kind),
|
||||
sprite: sprite_out,
|
||||
focus_sprite,
|
||||
opt_link: el.focus_link.clone(),
|
||||
pivot: [el.pivot_x, el.pivot_y],
|
||||
size: (role == "primitive").then(|| [el.pivot_x * 2, el.pivot_y * 2]),
|
||||
parent: el.parent,
|
||||
layer_source,
|
||||
layer,
|
||||
focused: el.focused,
|
||||
animated: el.animated,
|
||||
rest: el.rest().map(|k| Rest {
|
||||
pos: [k.x, k.y],
|
||||
scale: [k.scale_x, k.scale_y],
|
||||
tint_rgba: hex32(k.tint),
|
||||
fade_argb: hex32(k.fade),
|
||||
t: k.time,
|
||||
}),
|
||||
keyframes: el.keyframes.iter().map(kf).collect(),
|
||||
});
|
||||
}
|
||||
|
||||
// Navigation order is geometric: buttons top-to-bottom by resting Y. A
|
||||
// focused-state record is not itself a menu item.
|
||||
let mut buttons: Vec<(i32, String)> = b
|
||||
.elements
|
||||
.iter()
|
||||
.filter(|e| e.kind == 0x3002 && !e.focused)
|
||||
.filter_map(|e| e.rest().map(|k| (k.y, id_of(&e.name))))
|
||||
.collect();
|
||||
buttons.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));
|
||||
|
||||
let screen = Screen {
|
||||
format: "sylpheed.screen/2",
|
||||
exporter: exporter.to_string(),
|
||||
formats_rev,
|
||||
source: Source {
|
||||
archive: archive.to_string(),
|
||||
entry,
|
||||
build: build_idx,
|
||||
},
|
||||
name: name.to_string(),
|
||||
name_source,
|
||||
name_why,
|
||||
design: [b.design_w, b.design_h],
|
||||
elements,
|
||||
paint_order: ui_layout::derived_paint_order(&b, bundle),
|
||||
buttons: buttons.into_iter().map(|(_, n)| n).collect(),
|
||||
unresolved: vec![
|
||||
// The time unit is measured off the running game, not on the disc.
|
||||
"keyframe_time_unit",
|
||||
// Where two elements share a layer key the game's order is
|
||||
// unexplained; eight candidates refuted. Costs one element's blend
|
||||
// on one screen.
|
||||
"paint_order_ties",
|
||||
// The last keyframe of a group carries no time slot, so the
|
||||
// fade-OUT length is not in the file.
|
||||
"fade_out_duration",
|
||||
],
|
||||
};
|
||||
|
||||
let dir = out.join("screens").join(subdir);
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let json_path = format!("screens/{subdir}/{name}.json");
|
||||
std::fs::write(
|
||||
out.join(&json_path),
|
||||
format!("{}\n", serde_json::to_string_pretty(&screen)?),
|
||||
)?;
|
||||
|
||||
missing.sort();
|
||||
missing.dedup();
|
||||
Ok(Exported {
|
||||
name: name.to_string(),
|
||||
json_path,
|
||||
sprites: written.len(),
|
||||
missing,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user