//! 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, /// 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, } /// 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, pub pivot: [u32; 2], pub rest: Rest, pub keyframes: Vec, } #[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, } #[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, /// 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, /// 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, /// 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, 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, /// 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, /// 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, pub keyframes: Vec, } #[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, pub design: [u32; 2], pub elements: Vec, /// 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, /// 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, /// 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, } /// 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, subdir: &str, exporter: &str, formats_rev: &'static str, ) -> Result { 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 = 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, sprite: &str, bytes: &[u8], map: &std::collections::HashMap, ) -> Result { 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 { 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, }) }