The Decoder's finding, applied. `rest()` returns each element's last hold keyframe chosen independently of every other element: right for anything that ends the screen settled, exactly wrong for a transient. The title's ptlogo_back2eff1 is a two-frame flash (0 until t52, 255 at t54-56, 0 by t58), so its last hold IS the flash peak and rest() left it burning -- five of them, drawn at once. The settled instant is the longest interval containing no keyframe time, over a bundle's TOP-LEVEL elements. Reproduced here before adopting: title [160, 236], midpoint 198, the Decoder's number to the unit. The top-level restriction is what makes it match -- including the ptloop leaves gives [269, 540]. AGAINST THE ORACLE: title 20.92 RMSE 1.81% -> 14.61 RMSE 0.26% publisher_logo 9.05 0.75% -> 2.17 0.01% developer_logos 8.86 0.33% -> 3.05 0.01% main_menu 0.08% -> 0.08% window too narrow extras 0.19% -> 0.19% window too narrow Seven times fewer differing pixels on the title, seventy-five times fewer on the publisher splash, whose differing region is now a 13x18 box. The largest correctness gain this port has had, and none of it is mine -- it is a decode computed from the keyframe table with no reference to any capture. APPLIED ONLY WHERE THE WINDOW IS WIDE, and the bar is not invented. This export's widths split with nothing in between: 214, 190, 145, 76, then 12, 12, 8, 4. The bar is 30 units -- the Decoder's disc-wide census puts the knee there (30% of bundles >= 30, 42% under 10, the latter mostly loop* fragments meant to be in motion) and this export's screens sit 4x either side with nothing between 12 and 46. Two independent populations agreeing on where to cut. Checked unbroken: boot pacing unmoved, scripted walk runs end to end with focus restored. Also recorded: my "34 focus-record elements, only 2 varying" is right for GP_TITLE and reads as a fact about the format -- disc-wide it is 210 varying, 202 with rest() at the peak, concentrated in the paks a wider port reaches next. And their sharper framing, which I have adopted: a pulsing element has no resting pose at all, so rest() is MALFORMED rather than mis-answered on one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01N7FiFFFwbvG2uxdcEh8HyF
655 lines
28 KiB
Rust
655 lines
28 KiB
Rust
//! 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>,
|
||
|
||
/// This element's own `.rat` leaf, when its declared name is itself a
|
||
/// record in the bundle.
|
||
///
|
||
/// 🔴 **DECODED DATA THE EXPORTER USED TO DROP.** `ptloop01`/`ptloop02` on
|
||
/// the title declare scale 100 % and rotation 0 at the parent, and their
|
||
/// leaves declare **(100, 600) at +30°** and **(100, 800) at −45°** — and
|
||
/// the leaves *move*, x from −639 → 1521 and 1721 → −839. `ui_layout`'s own
|
||
/// note says so: *"the rotated quads come from its two nested `.rat` leaf
|
||
/// records, which the census never opened."* Neither did this exporter: it
|
||
/// opened a leaf only for a FOCUS record, via `highlight_name`.
|
||
///
|
||
/// That omission is measurable. It is the whole of the title's 1.82 %
|
||
/// disagreement with the oracle — the port draws two 400 px sprites upright
|
||
/// and static at (441, 270) where the game sweeps two ~1080 and ~1440 px
|
||
/// quads across the frame at opposite leans.
|
||
///
|
||
/// ⚠️ **Emitted, not yet drawn.** Parent and leaf each carry their own alpha
|
||
/// ramp on a different span — parent 0→255 over t=70…238, leaf
|
||
/// 255→0x80→255 over t=150…600 — so how the two compose is a *decoding*
|
||
/// question and not the port's to answer. The data is exported so it stops
|
||
/// being invisible; `ScreenView` ignores it until the composition rule is
|
||
/// known.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub leaf: Option<Focus>,
|
||
|
||
/// True when the leaf's geometry DIFFERS from the parent's, so the leaf is
|
||
/// what the game draws.
|
||
///
|
||
/// Decided here rather than in the runtime because it is disc knowledge.
|
||
/// The Decoder's rule: *"the discriminator is which record carries the
|
||
/// geometry, not a fixed order"* — and the census over this export splits
|
||
/// cleanly, with no ambiguous middle:
|
||
///
|
||
/// * **30 of 46** leaf elements duplicate the parent's scale and rotation
|
||
/// exactly. That is the BASE-record case `screen.rs` already handled: the
|
||
/// leaf may differ by a unit of position (`ptbtn04`: parent y=401, leaf
|
||
/// y=402) and the parent wins. Flag is false; nothing changes.
|
||
/// * **16 differ**, and all of them differ in scale or rotation, not by a
|
||
/// rounding unit: the ten `ptloop01`/`ptloop02` sweeps ((100,600) at +30°
|
||
/// and (100,800) at −45° against an identity parent), two
|
||
/// `pgloading_ring` (leaf scale **(0,0)**), and `title_jp`'s
|
||
/// `ptlogo_eff2` (**parent 125 %, leaf 100 %**).
|
||
///
|
||
/// ⚠️ **Only the `ptloop` case is decoded.** The Decoder fitted the game's
|
||
/// own composed alpha — vertex colours `C3FFFFFF`/`B6FFFFFF`, i.e. 195 and
|
||
/// 182 — against the two leaf ramps and got one consistent time, t=355, then
|
||
/// *predicted* the quad centres at 981 and 478 against 992.0 and 467.2
|
||
/// measured. The other two are the same shape and are **not** separately
|
||
/// confirmed; they are flagged so the harness can adjudicate them rather
|
||
/// than being asserted.
|
||
#[serde(skip_serializing_if = "std::ops::Not::not")]
|
||
pub leaf_carries_geometry: bool,
|
||
/// 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>,
|
||
|
||
/// The instant every element of this screen is settled at, and the width of
|
||
/// the interval it was taken from — `[start, end, midpoint]` in keyframe
|
||
/// units, absent when the screen has fewer than two keyframe times.
|
||
///
|
||
/// 🔴 **A SETTLED SCREEN IS ONE INSTANT, AND THE DISC SAYS WHICH.** Posing
|
||
/// each element at its own `rest()` is right for anything that ends the
|
||
/// screen settled and **exactly wrong for a transient**: the title's
|
||
/// `ptlogo_back2eff1` is a two-frame flash — 0 until t52, 255 at t54–56, 0
|
||
/// again by t58 — so its last *hold* is the flash peak and `rest()` leaves
|
||
/// it burning forever. There are five of these, and `rest()` draws all five
|
||
/// at once, saturating the light arc.
|
||
///
|
||
/// The window is the **longest interval containing no keyframe time**, over
|
||
/// this bundle's TOP-LEVEL elements only. Nested leaves are excluded, and
|
||
/// that exclusion is what reproduces the Decoder's independently computed
|
||
/// `[160, 236]` for the title: including the `ptloop` leaves gives
|
||
/// `[269, 540]` instead.
|
||
///
|
||
/// ⚠️ **Emitted for every screen; USABLE only where it is wide.** Across this
|
||
/// export the widths split with nothing in between — `press_start` 214,
|
||
/// `publisher_logo` 190, `developer_logos` 145, `title` 76, then
|
||
/// `main_menu` 12, `extras` 12, the loading screens 8 and 4. A 12-unit
|
||
/// "settle" on a menu that builds in until t=70 is not a settled pose, it is
|
||
/// a gap between staggered ramps. The Decoder's disc-wide census agrees on
|
||
/// the shape: only 30 % of bundles have a window ≥ 30 units and 42 % have
|
||
/// one under 10, the latter mostly `loop*` fragments meant to be in motion.
|
||
#[serde(skip_serializing_if = "Option::is_none")]
|
||
pub settle_window: Option<[i64; 3]>,
|
||
/// 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.
|
||
// Reads one record in the bundle as a nested build and returns its
|
||
// elements. Used twice: for a FOCUS record (`ptbtn0Nf.rat`) and for an
|
||
// element whose OWN declared name is a record (`ptloop01.rat`). One
|
||
// implementation, because the second case was missing for eight
|
||
// milestones and a second copy is how it would go missing again.
|
||
let read_leaf = |rec: &str,
|
||
written: &mut std::collections::BTreeMap<String, ()>,
|
||
missing: &mut Vec<String>|
|
||
-> Result<Option<Focus>> {
|
||
let Some(&(off, size)) = b.records.get(rec) else { return Ok(None) };
|
||
let Some(leaf) = ui_layout::parse_build(&bundle[off..off + size]) else {
|
||
return Ok(None);
|
||
};
|
||
let mut fes = Vec::new();
|
||
for fe in &leaf.elements {
|
||
let sp: &str = fe.sprite.as_deref().unwrap_or(&fe.name);
|
||
let mut fsprite = None;
|
||
if write_from(&sprite_dir, written, sp, &bundle[off..off + size], &leaf.sprites)?
|
||
|| write_from(&sprite_dir, 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(),
|
||
});
|
||
}
|
||
Ok(if fes.is_empty() {
|
||
None
|
||
} else {
|
||
Some(Focus { record: rec.to_string(), elements: fes })
|
||
})
|
||
};
|
||
|
||
// An element whose own declared name is a record in this bundle carries
|
||
// its geometry THERE, not in its parent entry. See `Element::leaf`.
|
||
let leaf = read_leaf(&el.name, &mut written, &mut missing)?;
|
||
|
||
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,
|
||
leaf_carries_geometry: leaf.as_ref().is_some_and(|l| {
|
||
let p = el.rest();
|
||
l.elements.iter().any(|le| {
|
||
p.is_none_or(|p| {
|
||
le.rest.scale != [p.scale_x, p.scale_y]
|
||
|| le.rest.rotation_deg != p.rotation_deg
|
||
})
|
||
})
|
||
}),
|
||
leaf,
|
||
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 window = settle_window(&elements);
|
||
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(),
|
||
settle_window: window,
|
||
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,
|
||
})
|
||
}
|
||
|
||
|
||
/// The longest interval containing no keyframe time, over TOP-LEVEL elements.
|
||
///
|
||
/// See [`Screen::settle_window`] for why this is the settled instant and why
|
||
/// nested leaves are excluded. Returns `[start, end, midpoint]`.
|
||
fn settle_window(elements: &[Element]) -> Option<[i64; 3]> {
|
||
let mut times: Vec<i64> = elements
|
||
.iter()
|
||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.t.map(i64::from)))
|
||
.collect();
|
||
times.sort_unstable();
|
||
times.dedup();
|
||
if times.len() < 2 {
|
||
return None;
|
||
}
|
||
let (a, b) = times
|
||
.windows(2)
|
||
.map(|w| (w[0], w[1]))
|
||
.max_by_key(|(a, b)| b - a)?;
|
||
Some([a, b, (a + b) / 2])
|
||
}
|