Files
Sylpheed/crates/sylpheed-export/src/check.rs
Sylpheed port agent f6fc269478 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.
2026-08-28 18:43:52 +00:00

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())
}