`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.
278 lines
11 KiB
Rust
278 lines
11 KiB
Rust
//! `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())
|
|
}
|