This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Sylpheed-Godot/crates/sylpheed-export/src/screen.rs
Sylpheed port agent 8dd0577e01 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

352 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
})
}