monorepo: one repository for the decoders, the port and the corpus
Some checks failed
Some checks failed
Merges the Godot port into the reverse-engineering repository, preserving both
histories -- 1019 commits of corpus plus the port's 31, brought in by subtree
merge and then moved into place so git can follow each file across the rename.
The reason is not tidiness. The two-repo split forced the exporter to depend on
the decoders by pinned revision, and that created a whole class of failure that
now disappears: a sha reachable only from a topic branch, orphaned by a
squash-merge, breaking a fresh checkout silently at build time. It also forced a
live read-only mount of one agent's working tree into another's container, which
is why a contract file could move mid-iteration. With a path dependency, a
decoder change and the exporter change it requires land in the same commit or
not at all.
Canary stays separate: it is a fork tracking upstream.
New structure for the long term:
docs/game/ how the game is NAVIGATED -- menus, modals, prompts, alerts,
and in-game flight. Written so nobody rediscovers it. Mostly
open questions on purpose; the in-game tutorials are the
resource for the flight half.
docs/port/MODDING.md
modding as a constraint on the exporter TODAY, not a later
feature: one logical asset in one file (the disc splits nearly
everything, and resolving that is the exporter's job), names a
person recognises, PNG/OGG/OGV/JSON only, base-and-overrides so
re-exporting is always safe, provenance in every file.
data/base + data/mods
generated tree and drop-in overrides, both gitignored
exchange/ transient inter-agent files, deliberately outside history
docs/agents/ the team protocol
Both the README and the navigation doc lead with the correction that cost the
most: the oracle is the real game under Xenia Canary. Reborn's renderer is a
hypothesis under test, it has been wrong, and treating it as ground truth
propagated into three documents and both agents before a human caught it.
Scripted modding stays possible without being built: no screen name is hardcoded
in GDScript and there is no native code in port/, which is what Godot Mod Loader
needs to be able to substitute behaviour later.
This commit is contained in:
64
crates/sylpheed-export/Cargo.toml
Normal file
64
crates/sylpheed-export/Cargo.toml
Normal file
@@ -0,0 +1,64 @@
|
||||
[package]
|
||||
name = "sylpheed-export"
|
||||
description = "Convert a Project Sylpheed disc into the open asset tree the Godot port reads"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
|
||||
[dependencies]
|
||||
# The decoders, PINNED BY REVISION. Not vendored and not reimplemented: they are
|
||||
# disc-wide verified in their own repository, and floating the pin would let a
|
||||
# decoder change land mid-milestone -- exactly the confusion this prevents.
|
||||
#
|
||||
# `sylpheed_formats::media` in particular owns the cases where one playable thing
|
||||
# is not one archive entry (segment-spanning reads, multi-sub-wave banks, and the
|
||||
# continuous cutscene-voice stream). Do not re-derive those here.
|
||||
#
|
||||
# Pin moved f817dd5 -> 7eeae30 on 2026-08-29. WHAT I WANTED FROM IT: `UiBuild`
|
||||
# gained a public `records` map (record name -> the nested `.rat` leaf's byte
|
||||
# range). Without it a consumer could not locate a leaf at all: `parse_build`
|
||||
# sorted T8aD children into `sprites` and `.rat` children into a PRIVATE map, so
|
||||
# the focus ring -- which lives inside `ptbtn0Nf.rat`, a record the parent
|
||||
# bundle declares no element for -- was unreachable through the public API.
|
||||
# Also brings `Keyframe::rotation_deg`, which the game does render.
|
||||
#
|
||||
# PINNED BY TAG, not by sha, and MISSION §2 now requires it. The reachability
|
||||
# risk this comment used to warn about is closed: a sha reachable only from an
|
||||
# `auto/*` branch is orphaned when that branch is deleted or -- worse --
|
||||
# SQUASH-MERGED, because squash creates new commits, so `main` would look like
|
||||
# it contained the work while this pin became unreachable. A tag is a permanent
|
||||
# ref, it says what it is here in the file, and it fails loudly at FETCH rather
|
||||
# than silently at build. `formats-pin-2026-08-29` is 7eeae30.
|
||||
#
|
||||
# Previous pin note, kept because the reasoning still holds:
|
||||
# Pin moved 5414db3 -> f817dd5 on 2026-08-29. WHAT I WANTED FROM IT: `56cc7ac`,
|
||||
# "a RATC child's name is stated, not inferred". A child was named by scanning
|
||||
# backwards for the last printable run before its magic; for `pteff05.t32` the
|
||||
# three trailing payload bytes are `38 41 58` = `8AX` and beat the real name, so
|
||||
# the FULL-RESOLUTION BACKGROUND OF ALL FIVE MENU SCREENS registered under a
|
||||
# name no element declares and resolved to no sprite. This port exported those
|
||||
# screens without their background and said so in every render as "pteff05 (no
|
||||
# sprite in the export)" -- which docs/DECISIONS.md then wrote up as correct.
|
||||
# It was not. f817dd5 is the last commit touching `crates/` on that branch.
|
||||
#
|
||||
# Previous pin note, kept because the reasoning still holds:
|
||||
# Pin moved 8b6dbcf -> 5414db3 on 2026-08-28. WHAT I WANTED FROM IT: the fix to
|
||||
# `ui_layout::rest()`. At 8b6dbcf a trailing run of identical keyframes was
|
||||
# always treated as the exit, so an element with no exit animation rested at its
|
||||
# invisible pre-roll -- `ptframe1`/`ptframe2`, the main menu's circuit bracket,
|
||||
# which a capture of the running game plainly shows. 5414db3 is the revision at
|
||||
# which that fix carries its disc-wide check (30 of 13 991 elements move, 4
|
||||
# become visible, 0 become invisible), not merely the one where it was written.
|
||||
# A PATH dependency now that the decoders and the exporter live in one
|
||||
# repository. This deletes a whole class of failure that the two-repo split
|
||||
# created: no pinned revision to go stale, no tag to keep alive, no commit that
|
||||
# a squash-merge can orphan, and no way for the exporter to be built against a
|
||||
# decoder it was never tested with. A decoder change and the exporter change it
|
||||
# requires now land in the same commit or not at all.
|
||||
sylpheed-formats = { path = "../sylpheed-formats" }
|
||||
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
anyhow = "1"
|
||||
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/3";
|
||||
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())
|
||||
}
|
||||
303
crates/sylpheed-export/src/main.rs
Normal file
303
crates/sylpheed-export/src/main.rs
Normal file
@@ -0,0 +1,303 @@
|
||||
//! Convert a Project Sylpheed disc into the open asset tree the Godot port reads.
|
||||
//!
|
||||
//! The one rule this binary exists to enforce: **Godot never sees a disc format.**
|
||||
//! Everything proprietary is decoded here and written out as JSON, PNG, Ogg
|
||||
//! 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.
|
||||
|
||||
mod check;
|
||||
mod video;
|
||||
mod screen;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::Parser;
|
||||
use serde::Serialize;
|
||||
use std::path::{Path, PathBuf};
|
||||
use sylpheed_formats::{pak::PakArchive, ui_layout};
|
||||
|
||||
/// The revision of `sylpheed-formats` this exporter is pinned to, recorded in
|
||||
/// every file it writes. Keep in step with `Cargo.toml` — it is what makes an
|
||||
/// export auditable a month later.
|
||||
const FORMATS_REV: &str = "8b6dbcf";
|
||||
const EXPORTER: &str = concat!("sylpheed-export ", env!("CARGO_PKG_VERSION"));
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(about, version)]
|
||||
struct Args {
|
||||
#[command(subcommand)]
|
||||
cmd: Cmd,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
enum Cmd {
|
||||
/// Convert the disc into `export/`. Rewrites the tree wholesale.
|
||||
Export {
|
||||
/// Extracted disc root (the directory holding `dat/` and `hidden/`).
|
||||
#[arg(long, 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 ManifestVideo {
|
||||
name: String,
|
||||
file: String,
|
||||
/// The exact command that produced this file. MISSION §6: a modder who
|
||||
/// dislikes the quality re-runs one line rather than reverse-engineering it.
|
||||
command: String,
|
||||
why: &'static str,
|
||||
}
|
||||
|
||||
#[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>,
|
||||
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||
videos: Vec<ManifestVideo>,
|
||||
warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// The authored `pak entry index → name` map, keyed by archive path.
|
||||
///
|
||||
/// Keyed by **entry**, not by the enumeration ordinal. The file itself always
|
||||
/// called the entry "the stronger locator"; it is now also the only stable one,
|
||||
/// because widening the enumeration to reach the splash renumbers the ordinals.
|
||||
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,
|
||||
#[serde(default)]
|
||||
also_export: AlsoExport,
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
/// Extra pak entries to export that `is_build` does not accept, keyed by
|
||||
/// archive. AUTHORED, and each carries its own `why`.
|
||||
type AlsoExport =
|
||||
std::collections::BTreeMap<String, std::collections::BTreeMap<String, NameEntry>>;
|
||||
|
||||
fn load_also_export(authored: &Path) -> Result<AlsoExport> {
|
||||
let path = authored.join("screen_names.json");
|
||||
if !path.exists() {
|
||||
return Ok(AlsoExport::new());
|
||||
}
|
||||
#[derive(serde::Deserialize)]
|
||||
struct File {
|
||||
#[serde(default)]
|
||||
also_export: AlsoExport,
|
||||
}
|
||||
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()))?
|
||||
.also_export)
|
||||
}
|
||||
|
||||
/// Every RATC entry of a UI pak this exporter treats as a screen.
|
||||
///
|
||||
/// The rule is `is_build` — a bundle with a `.rat` layout child — **plus an
|
||||
/// authored allow-list of entry indices**.
|
||||
///
|
||||
/// The allow-list exists because the splash screens declare their sprites
|
||||
/// directly and have no `.rat` child, so `is_build` cannot see them, and **there
|
||||
/// is no content rule that would**. The RE agent looked: design size fails
|
||||
/// (every extra composable bundle sampled is 1280x720, the same as every
|
||||
/// screen) and element count fails (fragments run 2..15 elements in
|
||||
/// `GP_OPTIONS`/`GP_SAVE_LOAD` while the splash halves are 3 and 7 — the ranges
|
||||
/// overlap). So the splashes are located **by entry index**, which is a locator
|
||||
/// and not a claim, and each one says so in its own `why`.
|
||||
///
|
||||
/// This is safe here rather than in general: in `GP_TITLE` the widened set adds
|
||||
/// exactly four bundles and all four are real screens, with zero fragments. In
|
||||
/// another archive it would not be, which is why this is an allow-list and not
|
||||
/// a widened predicate.
|
||||
fn screen_builds(ar: &PakArchive, also: Option<&std::collections::BTreeMap<String, NameEntry>>)
|
||||
-> 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 };
|
||||
let allowed = also.is_some_and(|m| m.contains_key(&i.to_string()));
|
||||
if ui_layout::is_build(&bytes) || allowed {
|
||||
out.push((i, bytes));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
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/3", 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 also = load_also_export(authored_dir)?;
|
||||
let archive_also = also.get(archive);
|
||||
let builds = screen_builds(&ar, archive_also);
|
||||
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() {
|
||||
// Keyed by ENTRY, not by the ordinal: widening the enumeration to reach
|
||||
// the splash renumbers ordinals, and a name that moves when the rule
|
||||
// changes is not a name.
|
||||
let key = entry.to_string();
|
||||
let named = archive_names
|
||||
.and_then(|m| m.get(&key))
|
||||
.or_else(|| archive_also.and_then(|m| m.get(&key)));
|
||||
let (name, name_source, why) = match named {
|
||||
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_{entry: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,
|
||||
});
|
||||
}
|
||||
|
||||
// MISSION §6: the boot intro and the one new-game intro only.
|
||||
let mut videos = Vec::new();
|
||||
for m in video::MOVIES {
|
||||
match video::transcode(disc, out, m)? {
|
||||
Some(t) => {
|
||||
println!(" video {} -> {}", m.src, t.file);
|
||||
videos.push(ManifestVideo {
|
||||
name: t.name,
|
||||
file: t.file,
|
||||
command: t.command,
|
||||
why: t.why,
|
||||
});
|
||||
}
|
||||
None => println!(" video {} not on this disc -- skipped", m.src),
|
||||
}
|
||||
}
|
||||
|
||||
let manifest = Manifest {
|
||||
format: "sylpheed.manifest/1",
|
||||
exporter: EXPORTER,
|
||||
formats_rev: FORMATS_REV,
|
||||
disc: disc.display().to_string(),
|
||||
screens,
|
||||
videos,
|
||||
warnings: vec![
|
||||
"P0 scope: GP_TITLE screen builds only. No audio, no video, no other archive."
|
||||
.into(),
|
||||
"The four splash bundles (entries 10/13 publisher, 11/14 developer) have no .rat \
|
||||
layout child, so `is_build` cannot see them and no content rule can: element \
|
||||
count and design size both overlap with two-element fragments in other archives. \
|
||||
They are located by ENTRY INDEX from authored/screen_names.json `also_export`, \
|
||||
which is a locator and not a claim -- see each one's name_why."
|
||||
.into(),
|
||||
],
|
||||
};
|
||||
std::fs::write(
|
||||
out.join("manifest.json"),
|
||||
format!("{}\n", serde_json::to_string_pretty(&manifest)?),
|
||||
)?;
|
||||
println!("wrote {}/manifest.json", out.display());
|
||||
Ok(())
|
||||
}
|
||||
475
crates/sylpheed-export/src/screen.rs
Normal file
475
crates/sylpheed-export/src/screen.rs
Normal file
@@ -0,0 +1,475 @@
|
||||
//! One UI build → one `sylpheed.screen/3` 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,
|
||||
/// Screen-plane rotation in **degrees**, clockwise-positive, decoded from
|
||||
/// the keyframe's `+12`. **The game renders this** — confirmed twice, on
|
||||
/// different screens and different elements: the title's `ptloop` sweeps
|
||||
/// declare +30 / −45 and a GPU capture submits their quads at +30.26 /
|
||||
/// −45.28, and the focus ring ramps 0 → 360 with everything else constant,
|
||||
/// which a capture caught mid-spin. Rotation is about the **declared
|
||||
/// pivot**, also measured.
|
||||
pub rotation_deg: i32,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Rest {
|
||||
pub pos: [i32; 2],
|
||||
pub scale: [u32; 2],
|
||||
pub tint_rgba: String,
|
||||
pub fade_argb: String,
|
||||
/// Screen-plane rotation in **degrees**, clockwise-positive, decoded from
|
||||
/// the keyframe's `+12`. **The game renders this** — confirmed twice, on
|
||||
/// different screens and different elements: the title's `ptloop` sweeps
|
||||
/// declare +30 / −45 and a GPU capture submits their quads at +30.26 /
|
||||
/// −45.28, and the focus ring ramps 0 → 360 with everything else constant,
|
||||
/// which a capture caught mid-spin. Rotation is about the **declared
|
||||
/// pivot**, also measured.
|
||||
pub rotation_deg: i32,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub t: Option<u32>,
|
||||
}
|
||||
|
||||
/// One element of a button's focused-state record.
|
||||
///
|
||||
/// A focus record is **not** a single sprite. `ptbtn0Nf.rat` declares the
|
||||
/// spinning ring `ptbtneff01.t32` *and* the bright label, and the parent bundle
|
||||
/// declares **no element for the record at all** — so the leaf is the only
|
||||
/// source of placement for both, and the parent has nothing to inherit from.
|
||||
/// That is why these carry their own `pos`, and why they are not simply a
|
||||
/// second sprite path on the base element.
|
||||
#[derive(Serialize)]
|
||||
pub struct FocusElement {
|
||||
pub id: String,
|
||||
pub declared: String,
|
||||
pub sprite: Option<String>,
|
||||
pub pivot: [u32; 2],
|
||||
pub rest: Rest,
|
||||
pub keyframes: Vec<Keyframe>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Focus {
|
||||
/// The `.rat` leaf this came from, e.g. `ptbtn01f.rat`.
|
||||
pub record: String,
|
||||
/// Back-to-front, in the leaf's own declaration order.
|
||||
pub elements: Vec<FocusElement>,
|
||||
}
|
||||
|
||||
#[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 focused state, read from the element's `.rat` leaf. Supersedes
|
||||
/// `focus_sprite`, which is kept because it is the 54-pair naming
|
||||
/// convention and a consumer may still want the bare highlight texture.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub focus: Option<Focus>,
|
||||
/// 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();
|
||||
// Decode one T8aD and write it, from whichever bundle slice and sprite map
|
||||
// owns it. A leaf's sprites may be indexed in the leaf's own map (offsets
|
||||
// relative to the leaf slice) or in the parent's; the caller says which.
|
||||
fn write_from(
|
||||
dir: &Path,
|
||||
written: &mut BTreeMap<String, ()>,
|
||||
sprite: &str,
|
||||
bytes: &[u8],
|
||||
map: &std::collections::HashMap<String, (usize, usize)>,
|
||||
) -> Result<bool> {
|
||||
if written.contains_key(sprite) {
|
||||
return Ok(true);
|
||||
}
|
||||
let Some(&(off, size)) = map.get(sprite) else {
|
||||
return Ok(false);
|
||||
};
|
||||
let Some(img) = t8ad::parse(&bytes[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(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_from(&sprite_dir, &mut written, s, bundle, &b.sprites)? {
|
||||
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 write_from(&sprite_dir, &mut written, &h, bundle, &b.sprites)? {
|
||||
focus_sprite = Some(sprite_rel(&h));
|
||||
}
|
||||
}
|
||||
|
||||
// The focused state is a RECORD, not a sprite. `ptbtn0Nf.rat` declares
|
||||
// the spinning ring AND the bright label, and the parent bundle
|
||||
// declares no element for it at all -- so the leaf is the only source
|
||||
// of placement for both, and there is nothing for it to inherit.
|
||||
//
|
||||
// Contrast with a BASE record, where the leaf duplicates the parent's
|
||||
// placement and the two can differ by a unit (ptbtn04: parent y=401,
|
||||
// leaf y=402). There the parent wins. Here there is no parent.
|
||||
let mut focus = None;
|
||||
if let Some(rec) = highlight_name(&el.name) {
|
||||
if let Some(&(off, size)) = b.records.get(&rec) {
|
||||
if let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) {
|
||||
let mut fes = Vec::new();
|
||||
for fe in &leaf.elements {
|
||||
// A leaf element's own NAME is its sprite -- `el.sprite`
|
||||
// is only populated for a T8aD child of the same bundle,
|
||||
// and these are indexed either in the leaf's map (offsets
|
||||
// into the leaf slice) or in the parent's.
|
||||
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
|
||||
let mut fsprite = None;
|
||||
if write_from(&sprite_dir, &mut written, sp,
|
||||
&bundle[off..off + size], &leaf.sprites)?
|
||||
|| write_from(&sprite_dir, &mut written, sp, bundle, &b.sprites)?
|
||||
{
|
||||
fsprite = Some(sprite_rel(sp));
|
||||
} else if sp.ends_with(".t32") {
|
||||
missing.push(sp.to_string());
|
||||
}
|
||||
let Some(r) = fe.rest() else { continue };
|
||||
fes.push(FocusElement {
|
||||
id: id_of(&fe.name),
|
||||
declared: fe.name.clone(),
|
||||
sprite: fsprite,
|
||||
pivot: [fe.pivot_x, fe.pivot_y],
|
||||
rest: Rest {
|
||||
pos: [r.x, r.y],
|
||||
scale: [r.scale_x, r.scale_y],
|
||||
tint_rgba: hex32(r.tint),
|
||||
fade_argb: hex32(r.fade),
|
||||
rotation_deg: r.rotation_deg,
|
||||
t: r.time,
|
||||
},
|
||||
keyframes: fe
|
||||
.keyframes
|
||||
.iter()
|
||||
.map(|k| 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),
|
||||
rotation_deg: k.rotation_deg,
|
||||
})
|
||||
.collect(),
|
||||
});
|
||||
}
|
||||
if !fes.is_empty() {
|
||||
focus = Some(Focus { record: rec, elements: fes });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
rotation_deg: k.rotation_deg,
|
||||
};
|
||||
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,
|
||||
focus,
|
||||
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),
|
||||
rotation_deg: k.rotation_deg,
|
||||
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/3",
|
||||
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,
|
||||
})
|
||||
}
|
||||
166
crates/sylpheed-export/src/video.rs
Normal file
166
crates/sylpheed-export/src/video.rs
Normal file
@@ -0,0 +1,166 @@
|
||||
//! Movies: disc WMV → Ogg Theora, because Godot 4 plays Theora natively and
|
||||
//! will never be taught to read WMV.
|
||||
//!
|
||||
//! The transcode command is **recorded in the manifest verbatim**. A modder who
|
||||
//! dislikes the quality re-runs one line rather than reverse-engineering what
|
||||
//! was done to their video, which is the whole reason this project converts the
|
||||
//! disc instead of reading it at runtime.
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
/// A movie in scope for this port.
|
||||
pub struct Movie {
|
||||
/// Path under the disc root.
|
||||
pub src: &'static str,
|
||||
/// Output stem under `export/video/`.
|
||||
pub stem: &'static str,
|
||||
pub why: &'static str,
|
||||
}
|
||||
|
||||
/// MISSION §6: the boot intro and the one new-game intro. The disc holds 3.3 GB
|
||||
/// of video and transcoding all of it is not this milestone.
|
||||
pub const MOVIES: &[Movie] = &[
|
||||
Movie {
|
||||
src: "dat/movie/ADV.wmv",
|
||||
stem: "ADV",
|
||||
why: "HANDOFF Q9: ADVERTISE_MOVIE -> ADV.wmv, and the boot intro and the \
|
||||
attract movie are the SAME asset -- there is no separate boot slot.",
|
||||
},
|
||||
Movie {
|
||||
src: "dat/movie/S00A.wmv",
|
||||
stem: "S00A",
|
||||
why: "HANDOFF Q9: MS00A -> S00A.wmv is the new-game intro. P7.",
|
||||
},
|
||||
];
|
||||
|
||||
/// The encode.
|
||||
///
|
||||
/// `-q:v 8` was chosen by measurement, not taste: against the decoded source,
|
||||
/// SSIM over a 10 s sample is 0.9863 at q6, **0.9896 at q8** and 0.9924 at q10,
|
||||
/// and q8 is visually indistinguishable at 200 % zoom on the reel's hardest
|
||||
/// case — fine serif text and soft gradients over near-black, which is where
|
||||
/// Theora usually breaks first. MISSION §6 anticipated that 720p Theora might
|
||||
/// be too poor and asked for the FFmpeg-GDExtension fallback to be *proposed*
|
||||
/// if so. It is not: **no runtime dependency is needed, and none is requested.**
|
||||
///
|
||||
/// The stereo downmix, **stated explicitly rather than inherited**.
|
||||
///
|
||||
/// The disc ships movies in two audio profiles: 28 files are 5.1 WMA Pro (every
|
||||
/// cutscene, including both movies this port needs) and 69 are already stereo.
|
||||
/// A bare `-ac 2` therefore does two different things and records neither — the
|
||||
/// stereo files pass through, and the 5.1 files are folded by **ffmpeg's default
|
||||
/// matrix**. How loudly centre-channel dialogue sits against the music is a
|
||||
/// CONTENT decision, and leaving it to a default means it is made by accident
|
||||
/// and can move under an ffmpeg upgrade.
|
||||
///
|
||||
/// So the matrix is written out: **ITU-R BS.775, LFE dropped**, normalised by
|
||||
/// `1/(1 + √½ + √½) = 0.4142` so the sum of coefficients cannot clip.
|
||||
///
|
||||
/// This does not change the audio. Measured against the inherited default over a
|
||||
/// 25 s stretch, the residual is **−91 dB** — roughly one LSB at 16-bit, i.e.
|
||||
/// coefficient rounding — and peak and mean levels agree to 0.1 dB. ffmpeg's
|
||||
/// default *is* this matrix; the point is that the manifest now says so.
|
||||
///
|
||||
/// The unnormalised form was measured too and **clips**: peak 0.0 dBFS. That is
|
||||
/// why the normalisation is here rather than the textbook coefficients.
|
||||
const DOWNMIX_51: &str = "pan=stereo|FL=0.4142*FL+0.2929*FC+0.2929*BL |FR=0.4142*FR+0.2929*FC+0.2929*BR";
|
||||
|
||||
/// How many audio channels the source declares.
|
||||
fn channels(src: &Path) -> Result<u32> {
|
||||
let out = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v", "error", "-select_streams", "a:0",
|
||||
"-show_entries", "stream=channels", "-of", "csv=p=0",
|
||||
])
|
||||
.arg(src)
|
||||
.output()
|
||||
.context("run ffprobe -- is it on PATH?")?;
|
||||
Ok(String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(2))
|
||||
}
|
||||
|
||||
fn args(src: &Path, out: &Path, channels: u32) -> Vec<String> {
|
||||
let mut v: Vec<String> = [
|
||||
"-hide_banner", "-loglevel", "error", "-y",
|
||||
"-i", &src.display().to_string(),
|
||||
"-c:v", "libtheora", "-q:v", "8",
|
||||
"-c:a", "libvorbis", "-q:a", "5",
|
||||
]
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect();
|
||||
// Only 5.1 sources are folded. A source that is already stereo is passed
|
||||
// through untouched rather than run through a matrix that would silently
|
||||
// reference channels it does not have.
|
||||
if channels == 6 {
|
||||
v.push("-af".into());
|
||||
v.push(DOWNMIX_51.into());
|
||||
}
|
||||
v.push("-ac".into());
|
||||
v.push("2".into());
|
||||
v.push(out.display().to_string());
|
||||
v
|
||||
}
|
||||
|
||||
pub struct Transcoded {
|
||||
pub name: String,
|
||||
pub file: String,
|
||||
pub command: String,
|
||||
pub why: &'static str,
|
||||
}
|
||||
|
||||
/// Transcode one movie, skipping the encode when the output already exists and
|
||||
/// was produced by exactly this command against exactly this source.
|
||||
///
|
||||
/// `export/` is still regenerated wholesale — this is a cache, not a hand-edit.
|
||||
/// The sidecar records the command and the source size, so any change to either
|
||||
/// re-encodes. Without it every re-export pays ~4 minutes to produce a
|
||||
/// byte-identical file, and an exporter nobody re-runs is worse than a cache.
|
||||
pub fn transcode(disc: &Path, out: &Path, m: &Movie) -> Result<Option<Transcoded>> {
|
||||
let src = disc.join(m.src);
|
||||
if !src.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let dir = out.join("video");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let ogv = dir.join(format!("{}.ogv", m.stem));
|
||||
let stamp = dir.join(format!("{}.cmd", m.stem));
|
||||
|
||||
let ch = channels(&src)?;
|
||||
let argv = args(&src, &ogv, ch);
|
||||
let command = format!("ffmpeg {}", argv.join(" "));
|
||||
let size = std::fs::metadata(&src)?.len();
|
||||
let want = format!("{command}\nsource-bytes: {size}\nsource-channels: {ch}\n");
|
||||
|
||||
let fresh = ogv.exists()
|
||||
&& std::fs::read_to_string(&stamp).map(|s| s == want).unwrap_or(false);
|
||||
if !fresh {
|
||||
// Encode to a temp name and rename on success. A reader that catches
|
||||
// this mid-write sees no file at all rather than a valid-looking one
|
||||
// with a wrong duration -- ffprobe reported 33 s against a 137 s source
|
||||
// during one such race, with no error, and it looked exactly like
|
||||
// catastrophic truncation. The filesystem is shared with another agent,
|
||||
// so this is a race and not an edge case.
|
||||
let partial = dir.join(format!(".{}.partial.ogv", m.stem));
|
||||
let mut argv = argv.clone();
|
||||
let last = argv.len() - 1;
|
||||
argv[last] = partial.display().to_string();
|
||||
let status = Command::new("ffmpeg")
|
||||
.args(&argv)
|
||||
.status()
|
||||
.context("run ffmpeg -- is it on PATH?")?;
|
||||
if !status.success() {
|
||||
let _ = std::fs::remove_file(&partial);
|
||||
bail!("ffmpeg failed on {}", m.src);
|
||||
}
|
||||
std::fs::rename(&partial, &ogv)?;
|
||||
std::fs::write(&stamp, &want)?;
|
||||
}
|
||||
Ok(Some(Transcoded {
|
||||
name: m.stem.to_string(),
|
||||
file: format!("video/{}.ogv", m.stem),
|
||||
command,
|
||||
why: m.why,
|
||||
}))
|
||||
}
|
||||
Reference in New Issue
Block a user