A snapshot of the non-game files as of0148cb8("port: F5/F6 hand-off -- one-minute human checks, and a refutation attempt that survived", 2026-09-04), the tip of auto/port-p6-audio. The branch was deleted from the server on 2026-09-17 during the consolidation cleanup; issue #7 asks for this work as a reviewable PR, so it is recovered here before the commits are garbage collected. Contents: the 84 files the branch changed relative to its fork pointb305aa4, which is this commit's parent. The tree is therefore 0148cb8's tree with the 854 exported game assets left out -- export-probe/, export-probe2/, three .wav renders of game audio and adv-v2-screenlog.tsv. Game data stays out of git; the exporter regenerates those from the disc. docs/port/DECISIONS.md still refers to them by name. Not recovered: the branch's own 366 commits. Keeping them would make those assets reachable again, so this is one snapshot instead. The original commits stay unreferenced in the server's object store, and in this clone under the local branch archive/port-p6-audio, until either is garbage collected. Refs #7. The OPTIONS work that issue #6 asks for is a subset of this branch, also recovered as recover/options-menu. 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
|
||
}
|