merge P0: the exporter, FORMAT v2, and the container script fixes
The port agent's first published arc. Merged from auto/p0-exporter so the image build below carries its docker/bin fixes -- build-export follows the exporter's new subcommand shape, and build-reference-cli makes the pinned reference CLI reproducible rather than a hand-built artifact in a volume.
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 {
|
||||
/// 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<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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
45
docker/bin/build-reference-cli
Executable file
45
docker/bin/build-reference-cli
Executable file
@@ -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)"
|
||||
@@ -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.
|
||||
|
||||
116
docs/DECISIONS.md
Normal file
116
docs/DECISIONS.md
Normal file
@@ -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/<subdir>/<screen>/<name>.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.
|
||||
200
docs/FORMAT.md
200
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/<screen>/*.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/<subdir>/<screen>/*.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. |
|
||||
|
||||
Reference in New Issue
Block a user