`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.
352 lines
14 KiB
Rust
352 lines
14 KiB
Rust
//! 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,
|
||
})
|
||
}
|