The nine files touched by the OPTIONS commits of 2026-09-03 (77f1d18,fda417a,3efe1cc,80042cb,4c24e06,a921c1e,41f1331,6b4b1df,edf8979), taken as of0148cb8, the tip of auto/port-p6-audio. The branch was deleted from the server on 2026-09-17 during the consolidation cleanup; issue #6 asks for this work as a reviewable PR, so it is recovered here before the commits are garbage collected. This is a review slice, not a self-consistent tree: the OPTIONS work and the F5/F6 work interleaved in the original history and cannot be separated by file, so each file carries whatever else had changed in it by 2026-09-04, and files it depends on are absent. The complete state is recover/port-f5-f6. Refs #6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
964 lines
45 KiB
Rust
964 lines
45 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`. The mapping is decoded for the kinds listed; anything else
|
||
/// exports as `unknown` with its raw kind visible.
|
||
fn role_of(kind: u32, has_sprite: bool) -> &'static str {
|
||
// 🔴 BIT 0 IS THE PARENT FLAG AND CARRIES NO ROLE INFORMATION. Decoded
|
||
// disc-wide: `kind & 1` agrees with "has a parent" on 15 493 elements with
|
||
// zero disagreements (`docs/re/ui-kind-bit0-is-has-parent.md`). So a role
|
||
// table keyed on the raw kind splits every class in two and calls the
|
||
// parented half `unknown` -- which is how the OPTIONS menu's rows came out
|
||
// roleless while the exporter had already accepted them as buttons.
|
||
//
|
||
// ⚠️ APPLIED TO EVERY PAIR, NOT JUST THE ONE THAT FAILED. Fixing only
|
||
// `0x3003` would have left `0x1` as `unknown` while `0x0` is `decoration`,
|
||
// i.e. the same inconsistency one kind along -- and half-applying this
|
||
// decode is exactly what produced the failure this is fixing.
|
||
//
|
||
// ⚠️ `0x73002`/`0x73003` are NOT folded in. Their `0x70000` bits are
|
||
// undecoded, so they stay `unknown` with their raw kind visible.
|
||
match kind {
|
||
0x3002 | 0x3003 => "button",
|
||
0x10 | 0x11 if !has_sprite => "primitive",
|
||
0x0 | 0x1 => "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>,
|
||
/// `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<bool>,
|
||
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,
|
||
/// 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<u32>,
|
||
/// 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.
|
||
/// `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<bool>,
|
||
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,
|
||
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.
|
||
//
|
||
// 🔴 `0x3003` IS `0x3002`. Bit 0 of `kind` is the PARENT FLAG and carries no
|
||
// role information: decoded disc-wide over every `.pak` in `dat/`, `kind & 1`
|
||
// agrees with "has a parent" on 15 493 elements with ZERO disagreements
|
||
// (`docs/re/ui-kind-bit0-is-has-parent.md`). Matching only `0x3002` meant the
|
||
// OPTIONS menu's five rows -- parented, hence `0x3003` -- were not buttons,
|
||
// so the screen opened and could not be navigated.
|
||
//
|
||
// ⚠️ TWO VALUES, LISTED, NOT A MASK. `kind & 0xFFFE == 0x3002` would also
|
||
// match `0x73002`/`0x73003` -- 160 elements whose `0x70000` bits nobody has
|
||
// decoded -- and it would do it silently, on screens neither agent has
|
||
// looked at. Those are excluded by construction until somebody decides about
|
||
// them deliberately.
|
||
let mut buttons: Vec<(i32, String)> = b
|
||
.elements
|
||
.iter()
|
||
.filter(|e| matches!(e.kind, 0x3002 | 0x3003) && !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<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;
|
||
}
|
||
// 🔴 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<usize>, elements: &[Element], design: [u32; 2]) -> Vec<usize> {
|
||
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<usize> = 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<i64> = 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<i64> = (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
|
||
}
|