//! 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, /// `true` when the game draws this sprite ADDITIVE — `T8aD +0x04` bit /// `0x02`, decoded. Absent when the sprite resolves to no `T8aD` header. /// /// A leaf's sprite may live in the leaf's own table or in the parent /// bundle's, so the bit is looked up in the same two places, in the same /// order, that the PNG is written from. #[serde(skip_serializing_if = "Option::is_none")] pub blend_additive: 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, /// The record header's `+0x08`: **where the cycle restarts**, in keyframe /// units — which is not the same thing as the last keyframe's time. /// /// `ptbtn00f`, the `PRESS Ⓐ` plate's glow, ramps 0→80→0 over **105** units /// inside a **120**-unit cycle and rests dark for the remaining 15. Deriving /// the period from the largest keyframe time — what the port did until now — /// runs it 14 % fast and deletes the dark rest entirely. /// /// Decoded by the Decoder (`07e93ce`, `docs/re/structures/ui-record-loop-length.md`, /// delivered in HANDOFF `27938aa`) and **re-run here before adoption**, with /// their falsifier and their non-triviality control (⚠️ the 92.3 % below is /// "of records where the question is meaningful" -- 1 643 of the 1 781 with a /// timed keyframe. 3 311 nested records exist; the other 1 530 have no /// keyframe time at all, so `+0x08 == max t` is not a question there. Quoted /// bare until 2026-09-01, which is a population-scoped statistic reported /// without its population): /// `cargo run -p sylpheed-export --example record_loop_control`. Disc-wide /// 1 781 timed records, 92.3 % exact, 7.7 % hold, **0 declaring less than /// their own last pose**; on the eight records this port animates, seven /// exact and `ptbtn00f` the one hold. /// /// ✅ **The port no longer owns this reading.** For one iteration `screen.rs` /// held its own guard and byte read, because the field was decoded in an /// example and a test and exposed in no public API on any ref. It is now /// `ui_layout::loop_length_units`, taken at `formats-pin-2026-08-30b`, and /// the local copy is deleted — the doc comment that promised that deletion /// is the only reason it did not quietly become permanent. pub loop_length_units: Option, /// 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, /// 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, /// 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, 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. /// `true` when the game draws this element ADDITIVE — `T8aD +0x04` bit /// `0x02`. /// /// 🔴 **DECODED, and it replaces an authored map.** The port carried an /// `additive_elements` table in `authored/rendering.json`, keyed by SCREEN /// NAME and transcribed from the Decoder's per-draw `RB_BLENDCONTROL0` log. /// A name-keyed map cannot answer for a screen nobody drove the game to, /// which is why the port was drawing the English menus additive and the /// Japanese ones alpha-over — asserting by omission that the JP build /// blends differently. The bit is on the disc for every screen at once. /// /// ⚠️ `kind_raw` is NOT this field. `kind` is `+40` of the RATC declaration /// entry; this is `+0x04` of the sprite's own `T8aD` header. Tested over /// four screens: `kind & 0x2` is *anti*-correlated with the measured map — /// 0 of 14 additive elements set it and 9 non-additive ones do. #[serde(skip_serializing_if = "Option::is_none")] pub blend_additive: Option, 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, /// 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, } /// 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. // 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, missing: &mut Vec| -> Result> { 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, blend_additive: ui_layout::blend_additive_by_name( &leaf, &bundle[off..off + size], sp) .or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)), 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(), loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]), 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, blend_additive: ui_layout::blend_additive_by_name( &leaf, &bundle[off..off + size], sp) .or_else(|| ui_layout::blend_additive_by_name(&b, bundle, sp)), 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, loop_length_units: ui_layout::loop_length_units(&bundle[off..off + size]), 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, blend_additive: ui_layout::sprite_blend_additive(&b, bundle, el), 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 order = forced_backdrop_first( ui_layout::derived_paint_order(&b, bundle), &elements, [b.design_w, b.design_h], ); 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: order, 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 = 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; } // 🔴 A GAP IN WHICH NOTHING IS VISIBLE IS NOT A SETTLE WINDOW. // // The widest keyframe-free interval is only a settled state if the screen is // actually PRESENTING something across it. `press_start` is the case that // proves it: its keyframes are 0, 214, 236, 238, 244, so the widest gap is // 0..214 -- the dead stretch BEFORE the plate appears, where `ptbtn00` is // alpha 0 throughout. Taking its midpoint gave a settle instant of t=107, // and the runtime then answered every question about that screen at t=107. // The result was that the PRESS (A) plate could not be drawn at any instant // at all, including the boot's own end state, whose entire purpose is to // show it. // // The fix is not a tuned threshold: it is that the heuristic was reading an // interval where the screen is BLANK as the interval where it has arrived. // Rejecting those leaves `press_start` with 214..236 (22 units), which is // under the runtime's 30-unit bar, so it falls back to each element's own // hold -- which is the plate, opaque, exactly as the disc declares it. // // ⚠️ This does not disturb the windows the settle instant was measured on. // `title` keeps [160, 236]: elements are visible across it, and the // Decoder's draw stream independently found the game's clock freezing in // that same interval. let visible_at = |t: i64| elements.iter().any(|e| alpha_at(e, t) > 0); let (a, b) = times .windows(2) .map(|w| (w[0], w[1])) .filter(|(a, b)| visible_at((a + b) / 2)) .max_by_key(|(a, b)| b - a)?; Some([a, b, (a + b) / 2]) } /// Alpha of one element at instant `t`, under the linear ramp the port uses. fn alpha_at(e: &Element, t: i64) -> u8 { let ks = &e.keyframes; let a = |k: &Keyframe| (u32::from_str_radix(k.fade_argb.trim_start_matches("0x"), 16) .unwrap_or(0) >> 24) as i64; let timed: Vec<&Keyframe> = ks.iter().filter(|k| k.t.is_some()).collect(); if timed.is_empty() { return 0; } if t <= timed[0].t.unwrap() as i64 { return a(timed[0]) as u8; } for w in timed.windows(2) { let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64); if t < t1 { if t1 <= t0 { return a(w[0]) as u8; } let f = (t - t0) as f64 / (t1 - t0) as f64; return (a(w[0]) as f64 + (a(w[1]) - a(w[0])) as f64 * f).round() as u8; } } a(timed[timed.len() - 1]) as u8 } /// Scale of one element at instant `t`, in percent per axis, under the same /// linear ramp as the fade. Interpolated rather than stepped, because a scale /// that animates passes through every value between its keyframes. fn scale_at(e: &Element, t: i64) -> [f64; 2] { let timed: Vec<&Keyframe> = e.keyframes.iter().filter(|k| k.t.is_some()).collect(); if timed.is_empty() { return [100.0, 100.0]; } let g = |k: &Keyframe, i: usize| k.scale[i] as f64; if t <= timed[0].t.unwrap() as i64 { return [g(timed[0], 0), g(timed[0], 1)]; } for w in timed.windows(2) { let (t0, t1) = (w[0].t.unwrap() as i64, w[1].t.unwrap() as i64); if t < t1 { if t1 <= t0 { return [g(w[0], 0), g(w[0], 1)]; } let f = (t - t0) as f64 / (t1 - t0) as f64; return [ g(w[0], 0) + (g(w[1], 0) - g(w[0], 0)) * f, g(w[0], 1) + (g(w[1], 1) - g(w[0], 1)) * f, ]; } } let l = timed[timed.len() - 1]; [g(l, 0), g(l, 1)] } /// Move a full-screen opaque primitive to the FRONT of the paint order when the /// file forces it there. /// /// 🔴 **The rule is a constraint, not a preference**, and it is the Decoder's: /// *an element that covers the screen and is fully opaque at some instant cannot /// paint above anything visible at that instant; where the elements visible /// during its opaque span are ALL of them, its position is forced to first.* /// /// It was found because `build_12`/`build_15` are **black at every instant** of /// their declared timeline under the old rule — `pgloading_eff00` is opaque for /// 39 instants while all 9 other elements live and die inside that span. A /// screen that is black for its whole life is impossible on its face, which is /// the only kind of check that survives two renderers sharing an assumption: /// `sylpheed-cli` agreed with the port here because it agreed about /// `implied_layer_key`. /// /// Two measured controls, both prior orders off the running game: /// /// | primitive | measured | opaque instants | forced below | | /// |---|---|---|---|---| /// | `palogo_eff0.prm` | **first** | 211 | 6 of 6 | ✅ forced | /// | `pteff00.prm` | **last** | 2 | 3 of 23 | ✅ permitted on top | /// /// ⚠️ **Do NOT reduce this to a name heuristic.** `*base*` first / `*eff*` last /// matches 77 of 80 and fails on exactly the three families that cross it — /// `palogo_eff0`, `pgloading_eff00`, `pzeff00`. `palogo_eff0.prm` is *named like /// an overlay* and is measured painting first. The name is not the rule. /// /// 🔴 **And it is restricted to elements with NO SPRITE**, which is the limit /// that the rule's own disc-wide test caught: applied to sprites it claimed 22 /// `.t32` textures must sort first *against their own layer keys*. **An /// element's alpha says nothing about whether its texture covers the screen** — /// most of a sprite may be transparent. /// /// ⚠️ Reach: assumes straight alpha-over. Blend mode is undecoded, and an /// additive quad at alpha 255 would not occlude. It is a lower bound, not an /// ordering — it says nothing about elements that are constrained but not /// forced. Delete this when a pinned `sylpheed-formats` does it. fn forced_backdrop_first(order: Vec, elements: &[Element], design: [u32; 2]) -> Vec { let screen_end: i64 = elements .iter() .flat_map(|e| e.keyframes.iter().filter_map(|k| k.t)) .map(i64::from) .max() .unwrap_or(0); let forced: Vec = elements .iter() .enumerate() .filter(|(_, e)| { // 🔴 UNTEXTURED SOLID QUAD, tested positively -- NOT merely "has no // sprite". Those coincide in GP_TITLE and the distinction is still // the whole point, because the negative test guards a SYMPTOM. // // The rule needs the element's alpha to BE its pixels' alpha. That // is true of a `.prm` solid quad and of nothing else. The Decoder // found this the expensive way twice: first `.t32` sprites (an // element's alpha says nothing about a texture that is mostly // transparent), guarded with "no sprite" -- and then `.tbm`, which // is 38 of their 80 forced-first verdicts and declares fade // `ffffffff`. A solid WHITE quad painted first at alpha 255 would // make the screen white; no screen is white, so a `.tbm`'s white is // a modulation ON a texture and its element alpha proves nothing // about coverage either. // // "No sprite" would keep admitting a `.tbm` that this exporter // happens not to emit a sprite for. `role == "primitive"` cannot. // GP_TITLE has no full-screen `.tbm` at all -- every layerless // full-screen element here is `.prm` and pure black, checked -- so // this changes no verdict today and is a guard against a corpus // that grows. // Cheap prefilter only -- the binding coverage test is per-instant, // in `covers` below. An element scaled ABOVE 100 could cover the // screen from a smaller declared size, so this deliberately does // not reject on size. e.role == "primitive" && e.sprite.is_none() && e.size.is_some() }) .filter(|(i, e)| { let span: Vec = e .keyframes .iter() .filter_map(|k| k.t) .map(i64::from) .collect(); let Some(&lo) = span.first() else { return false }; // 🔴 COVERAGE IS TESTED AT EACH INSTANT, NOT ONCE FROM `size`. // Declared size alone is not what the element draws: scale is a // percent per axis and it animates. `pbafc.prm` is the disc's own // counterexample -- declared 844x600, scaled 2 % x 3 %, so it draws // about 17x18 px, a moving glint rather than a wash. A rule that // read its declared size would call it screen-covering. // // Nothing in GP_TITLE needs this: every layerless full-screen // element here is at scale 100 on every keyframe, so no verdict // moves. It is in because the data that would break it exists on // this disc, which is a better reason than a failure would have been. let covers = |t: i64| { let sc = scale_at(e, t); e.size.is_some_and(|s| { s[0] as f64 * sc[0] / 100.0 >= design[0] as f64 && s[1] as f64 * sc[1] / 100.0 >= design[1] as f64 }) }; // An element HOLDS ITS FINAL POSE to the end of the screen -- it does // not vanish at its own last keyframe. `palogo_eff0.prm` is the case // that shows why: it declares ONE keyframe, opaque black full-screen // at t=0, and reading its span as `0..=0` makes the splash's backdrop // a single-instant event instead of the thing that is on screen for // the whole splash. So the span runs to the SCREEN's last keyframe. let hi = screen_end.max(*span.last().unwrap()); let opaque: Vec = (lo..=hi) .filter(|&t| alpha_at(e, t) == 255 && covers(t)) .collect(); if opaque.is_empty() { return false; } // Every OTHER element must be visible somewhere inside that span. elements.iter().enumerate().all(|(j, o)| { j == *i || opaque.iter().any(|&t| alpha_at(o, t) > 0) }) }) .map(|(i, _)| i) .collect(); if forced.is_empty() { return order; } let mut out = forced.clone(); out.extend(order.into_iter().filter(|i| !forced.contains(i))); out }