sylpheed-port reports that a record's loop length is on no public ref at all (example, test and docs/re/ only), so their screen.rs parses the four bytes with its own RATC guard. That is my field to publish. One function serves both levels, since a nested .rat leaf is itself a RATC bundle with the same header shape. Returns None for a non-RATC or short slice so callers need no guard of their own. Verified against the disc, controls first: rejects a non-RATC slice, rejects one too short for the field, reads big-endian at +0x08 -- then reproduces every published value (ptbtn00f 120, ptloop01 600, ptloop02 720) over 65 GP_TITLE records with 0 violations of +0x08 >= largest keyframe time. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Wuu56cE8vJGTBtn1ppsk8v
1914 lines
84 KiB
Rust
1914 lines
84 KiB
Rust
//! UI screen layout — reassemble a whole UI screen from one RATC bundle.
|
||
//!
|
||
//! A UI screen ships as one pak (`GP_TITLE`, `GP_PAUSE_MENU`, `GP_HANGAR_ARSENAL`,
|
||
//! …). Inside it, each top-level [RATC](crate::ratc) bundle is one
|
||
//! *(context × language)* **build** of that screen, holding its `<name>.t32`
|
||
//! sprites and `<name>.rat` layout records side by side.
|
||
//!
|
||
//! # The model
|
||
//!
|
||
//! The screen is **not** the set of `.rat` records — that was the first reading,
|
||
//! and it misses every element that has no `.rat` (the `eff*` glow frames, the
|
||
//! `deli*` dividers, `msg`). The screen is the bundle's own header:
|
||
//!
|
||
//! * the **element declaration table** at `0x20` (`0x14` = entry count, 60 bytes
|
||
//! per entry) lists every element **in back-to-front draw order**, with its
|
||
//! parent element index and its pivot;
|
||
//! * the **placement region** that follows gives each element a keyframe group —
|
||
//! a header of `(element index, keyframe count)` and then 40-byte keyframes of
|
||
//! scale / tint / X / Y / time.
|
||
//!
|
||
//! Three traps, each of which cost a wrong answer before it was measured (see
|
||
//! `docs/re/structures/ui-rat-layout.md`):
|
||
//!
|
||
//! * **X and Y are signed.** An Arsenal window animates in from X = −516; a
|
||
//! parser reading them as `u32` throws that element away as out of range.
|
||
//! * The trailing word of a keyframe is a **time**, not a fourth coordinate.
|
||
//! * **Neither the first nor the last keyframe is where the element sits.** A
|
||
//! group is an in → hold → out animation, so the resting position is the
|
||
//! **max-dwell** keyframe — the one with the longest gap to the next keyframe's
|
||
//! time. See [`Element::rest`].
|
||
//!
|
||
//! Validated against the running game: the tutorial PAUSE menu and the title main
|
||
//! menu both rebuild pixel-accurately, and the Arsenal's eight category chips
|
||
//! land within ±2 px (the only free parameter being emulator window chrome).
|
||
|
||
use crate::{ratc, t8ad};
|
||
use std::collections::HashMap;
|
||
|
||
fn be32(b: &[u8], o: usize) -> u32 {
|
||
if o + 4 > b.len() {
|
||
return 0;
|
||
}
|
||
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
|
||
}
|
||
|
||
/// Offset of the element declaration table within a build bundle.
|
||
const DECL_TABLE_AT: usize = 0x20;
|
||
/// Bytes per declaration entry.
|
||
const DECL_ENTRY: usize = 60;
|
||
/// Bytes per placement keyframe.
|
||
const KEYFRAME: usize = 40;
|
||
/// The design space every screen is authored in.
|
||
const DESIGN_W: u32 = 1280;
|
||
const DESIGN_H: u32 = 720;
|
||
|
||
/// One keyframe of an element's placement animation.
|
||
///
|
||
/// The block is 40 bytes:
|
||
///
|
||
/// ```text
|
||
/// +0 u32 ARGB fade colour — alpha ramps 0x00 → 0x80 → 0xd5 … over the group
|
||
/// +4 i32 ⚠️ NOT always 0 — see below
|
||
/// +8 i32 ⚠️ NOT always 0 — see below
|
||
/// +12 i32 ⚠️ NOT always 0 — see below
|
||
/// +16 u32 scale X, percent
|
||
/// +20 u32 scale Y, percent
|
||
/// +24 u32 tint (0xffff_ffff on every frame seen)
|
||
/// +28 i32 X ← signed
|
||
/// +32 i32 Y ← signed
|
||
/// +36 u32 time
|
||
/// ```
|
||
///
|
||
/// ## `+12` is the screen-plane ROTATION, in degrees (2026-08-28)
|
||
///
|
||
/// ✅ **Measured against the framebuffer, not against our own renderer.** The
|
||
/// title's two light sweeps are the nested leaf records `ptloop01.rat` /
|
||
/// `ptloop02.rat`, and their keyframe blocks read `+12` = `30` and `-45`. A
|
||
/// `log_ui_draws` capture of the live title submits those two quads rotated by
|
||
/// **+30.26°** and **-45.28°** — magnitude *and* sign, on two different values.
|
||
/// Positive is clockwise in screen space (Y down).
|
||
///
|
||
/// `+4` and `+8` are 🟡 still unexplained: signed, non-zero in ~4.7 % / 4.6 %
|
||
/// of blocks disc-wide, dominated by `±180` and `±90`. Plausibly rotation about
|
||
/// the other two axes, but nothing observed turns on them.
|
||
///
|
||
/// ⚠️ **`rotation_deg` is decoded but NOT rendered.** [`crate::ui_layout`]'s
|
||
/// blitter draws axis-aligned quads only, so `screen render` still paints a
|
||
/// rotated element upright. See `docs/re/ui-title-build-map.md`.
|
||
///
|
||
/// ⚠️ The earlier note here — *"every element of `GP_TITLE` build 4 has all
|
||
/// three at zero"* — was **wrong about reach, not about the bytes**: build 4's
|
||
/// top-level elements do read zero, but the rotated quads come from its two
|
||
/// **nested** `.rat` leaf records, which the census never opened.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct Keyframe {
|
||
/// The fade colour, ARGB. Its alpha is what ramps an element in.
|
||
pub fade: u32,
|
||
/// Screen-plane rotation in **degrees**, clockwise-positive (`+12`).
|
||
/// Confirmed against a GPU capture; see the type's docs. Not rendered.
|
||
pub rotation_deg: i32,
|
||
/// `+4` / `+8` — signed, meaning unexplained. Carried rather than dropped
|
||
/// so a consumer can see them instead of assuming they are zero.
|
||
pub unknown_4: i32,
|
||
pub unknown_8: i32,
|
||
/// Scale in percent (100 = 1:1).
|
||
pub scale_x: u32,
|
||
pub scale_y: u32,
|
||
/// RGBA tint (`0xffff_ffff` = untinted).
|
||
pub tint: u32,
|
||
/// Top-left position in the design space. **Signed** — off-screen animation
|
||
/// starts are negative.
|
||
pub x: i32,
|
||
pub y: i32,
|
||
/// The time at which this pose is reached.
|
||
///
|
||
/// ✅ Always `Some` since 2026-08-29. A placement group is an 8-byte header
|
||
/// followed by `frames` records of `{ u32 time; 36-byte pose }`, so the time
|
||
/// word **precedes** the pose it belongs to. Our block window starts at the
|
||
/// pose, so pose `k`'s time is the previous stride's `+36` word, and pose
|
||
/// 0's is the group's lead-in word at `header + 8`.
|
||
///
|
||
/// ⚠️ The old reading took `+36` as *this* pose's time. That left the final
|
||
/// pose — the end of every fade-out — untimed, and it is where the "a
|
||
/// group's data stops 4 bytes short of its final block's time slot" note and
|
||
/// the stray `time = 1869640736` both came from. There is no short group and
|
||
/// no missing word; the association was off by one.
|
||
/// See `docs/re/ui-keyframe-record-layout.md`.
|
||
///
|
||
/// `Option` is retained for the `SYLPHEED_KF_TIME_LEGACY=1` escape hatch.
|
||
pub time: Option<u32>,
|
||
}
|
||
|
||
/// One element of a screen, in draw order.
|
||
#[derive(Debug, Clone)]
|
||
pub struct Element {
|
||
/// Index into [`UiBuild::elements`] — also the key the placement region uses.
|
||
pub index: usize,
|
||
/// Declared name, e.g. `pgp_ttrl_eff10.t32` or `pgp_ttrl_btn10.rat`.
|
||
pub name: String,
|
||
/// The `.t32` sprite this element draws, if one resolves.
|
||
pub sprite: Option<String>,
|
||
/// Parent element index (`+32`), or `None` for `0xffff_ffff`.
|
||
pub parent: Option<usize>,
|
||
/// Kind flags (`+40`): `0` plain sprite, `1` has a parent, `0x4` a repeated
|
||
/// instance of a template, `0x3002` a button record.
|
||
pub kind: u32,
|
||
/// Declared pivot — for a `.t32` element this is exactly half the decoded
|
||
/// texture's dimensions (verified 7/7 on the tutorial bundle).
|
||
pub pivot_x: u32,
|
||
pub pivot_y: u32,
|
||
/// The element's placement keyframes, empty if the region declares none.
|
||
pub keyframes: Vec<Keyframe>,
|
||
/// `opt ` link to another record — the focused state of a button.
|
||
pub focus_link: Option<String>,
|
||
/// This element is itself a focused-state record: its name is another
|
||
/// element's name plus a trailing `f`, and that other element is present.
|
||
/// The pairing is required — see `mark_focused_states`.
|
||
pub focused: bool,
|
||
/// A `loopN` sprite animation rather than a placed element.
|
||
pub animated: bool,
|
||
}
|
||
|
||
impl Element {
|
||
/// Where the element actually rests on screen.
|
||
///
|
||
/// A keyframe group is an in → hold → out animation, and the resting pose is
|
||
/// **the hold**: the longest run of consecutive keyframes whose pose is
|
||
/// identical. A single keyframe is its own rest position.
|
||
///
|
||
/// This replaced a longest-*dwell* rule — pick the keyframe with the largest
|
||
/// gap to the next keyframe's time — which was wrong for a reason worth
|
||
/// stating, because it is the whole shape of this format. A keyframe is the
|
||
/// **start of a ramp toward the next one**, not a value held until it. So a
|
||
/// long gap after keyframe *k* means the screen spends that time *arriving
|
||
/// at* `k+1`; the settled pose is at the far end of the gap, not the near
|
||
/// one. The title wordmark zooms in over 5 frames and then holds at
|
||
/// `(184,193)` at 100 % from t=251 to t=264; the old rule picked the frame
|
||
/// before the long gap — `(179,186)` at 101 %, still mid-zoom.
|
||
///
|
||
/// Measured, not argued. Edge-correlating the composite against the
|
||
/// framebuffer capture of the running title screen
|
||
/// (`docs/re/captures/title-screen-oracle.png`, which is a 1:1 crop, so
|
||
/// coordinates map directly):
|
||
///
|
||
/// | rule | best correlation | at shift |
|
||
/// |---|---|---|
|
||
/// | plateau (this one) | **0.4597** | **(0, 0)** |
|
||
/// | longest dwell (old) | 0.1511 | (+3, +8) |
|
||
///
|
||
/// The old composite had to be *moved* to line up with the game. See
|
||
/// `docs/re/structures/ui-resting-pose.md`.
|
||
///
|
||
/// Falls back to the longest-dwell rule when no two adjacent keyframes
|
||
/// agree — a group that ramps through every frame and never holds.
|
||
/// The element's pose at keyframe time `t`, linearly interpolated.
|
||
///
|
||
/// `rest()` returns the last HOLD keyframe **of one element, chosen
|
||
/// independently of every other element**. That is the wrong pose twice over:
|
||
///
|
||
/// * for anything still moving — the title's light sweeps hold at `x = 1521`,
|
||
/// off the right edge, so a resting composite deletes them rather than
|
||
/// settling them;
|
||
/// * 🔴 and for anything **transient**. `ptlogo_back2eff1` is a two-frame
|
||
/// flash (`a=0` until t52, `255` at t54–56, `0` again by t58); its last
|
||
/// hold *is* the flash peak, so `rest()` leaves it burning forever. Five
|
||
/// such flashes stack on the title and blow the light arc out to pure
|
||
/// white — see `docs/re/structures/ui-settle-time.md`.
|
||
///
|
||
/// A settled screen is one INSTANT that every element is posed at, which is
|
||
/// what [`UiBuild::settle_time`] computes and `ComposeOptions::at` applies.
|
||
///
|
||
/// The ramp is linear (`docs/re/ui-keyframe-time-unit.md`), and a group
|
||
/// **holds** at its last keyframe rather than looping, so `t` past the end
|
||
/// clamps.
|
||
pub fn pose_at(&self, t: u32) -> Option<Keyframe> {
|
||
let ks = &self.keyframes;
|
||
if ks.is_empty() {
|
||
return None;
|
||
}
|
||
let timed: Vec<(u32, &Keyframe)> =
|
||
ks.iter().filter_map(|k| k.time.map(|tt| (tt, k))).collect();
|
||
if timed.is_empty() {
|
||
return Some(ks[ks.len() - 1].clone());
|
||
}
|
||
if t <= timed[0].0 {
|
||
return Some(timed[0].1.clone());
|
||
}
|
||
if t >= timed[timed.len() - 1].0 {
|
||
return Some(timed[timed.len() - 1].1.clone());
|
||
}
|
||
for w in timed.windows(2) {
|
||
let ((t0, a), (t1, b)) = (w[0], w[1]);
|
||
if t >= t0 && t <= t1 {
|
||
if t1 == t0 {
|
||
return Some(b.clone());
|
||
}
|
||
let f = (t - t0) as f64 / (t1 - t0) as f64;
|
||
let li = |x: i32, y: i32| x + ((y - x) as f64 * f).round() as i32;
|
||
let lu = |x: u32, y: u32| (x as f64 + (y as f64 - x as f64) * f).round() as u32;
|
||
// ARGB / RGBA words interpolate per BYTE, not as integers.
|
||
let lc = |x: u32, y: u32| {
|
||
let mut o = 0u32;
|
||
for sh in [24, 16, 8, 0] {
|
||
let (cx, cy) = ((x >> sh) & 0xff, (y >> sh) & 0xff);
|
||
o |= (lu(cx, cy) & 0xff) << sh;
|
||
}
|
||
o
|
||
};
|
||
return Some(Keyframe {
|
||
fade: lc(a.fade, b.fade),
|
||
rotation_deg: li(a.rotation_deg, b.rotation_deg),
|
||
unknown_4: li(a.unknown_4, b.unknown_4),
|
||
unknown_8: li(a.unknown_8, b.unknown_8),
|
||
scale_x: lu(a.scale_x, b.scale_x),
|
||
scale_y: lu(a.scale_y, b.scale_y),
|
||
tint: lc(a.tint, b.tint),
|
||
x: li(a.x, b.x),
|
||
y: li(a.y, b.y),
|
||
time: Some(t),
|
||
});
|
||
}
|
||
}
|
||
Some(timed[timed.len() - 1].1.clone())
|
||
}
|
||
|
||
pub fn rest(&self) -> Option<&Keyframe> {
|
||
// `lastall`: the LAST keyframe for every element, bypassing the plateau
|
||
// rule entirely.
|
||
//
|
||
// ⚠️ ITS STATED PURPOSE IS RETIRED (corrected 2026-08-30). This comment
|
||
// read: "This is what the shifted time reading predicts — under it the
|
||
// final pose is reached at a definite time and nothing follows, so
|
||
// 'rest' needs no heuristic. Testing it against the captures is an
|
||
// independent check on that reading." The shifted reading was **refuted**
|
||
// by the record-layout fix above, so this override no longer checks
|
||
// anything about it. It survives only as a plain "take the last
|
||
// keyframe" diagnostic, alongside the documented `last` and `maxalpha`
|
||
// (see `docs/re/structures/ui-resting-pose.md`).
|
||
if std::env::var("SYLPHEED_REST_RULE").as_deref() == Ok("lastall") {
|
||
return self.keyframes.last();
|
||
}
|
||
if let Some(k) = self.rest_plateau() {
|
||
return Some(k);
|
||
}
|
||
match self.keyframes.len() {
|
||
0 => None,
|
||
1 => self.keyframes.first(),
|
||
n => {
|
||
// ⚠️ EXPERIMENT GATE, default off. Both published alternatives to
|
||
// the longest-dwell fallback died by argument rather than by
|
||
// measurement, and `compose` can score a rule against the live
|
||
// captures — so they are reachable here to be tested.
|
||
// SYLPHEED_REST_RULE=last -> the final keyframe
|
||
// SYLPHEED_REST_RULE=maxalpha -> the most opaque keyframe
|
||
match std::env::var("SYLPHEED_REST_RULE").as_deref() {
|
||
Ok("last") => return self.keyframes.last(),
|
||
Ok("maxalpha") => {
|
||
let mut best = (0usize, 0u32);
|
||
for (k, f) in self.keyframes.iter().enumerate() {
|
||
let a = (f.fade >> 24) & 0xff;
|
||
if a >= best.1 {
|
||
best = (k, a);
|
||
}
|
||
}
|
||
return self.keyframes.get(best.0);
|
||
}
|
||
_ => {}
|
||
}
|
||
let mut best = (0usize, 0u32);
|
||
for k in 0..n - 1 {
|
||
let (Some(t0), Some(t1)) =
|
||
(self.keyframes[k].time, self.keyframes[k + 1].time)
|
||
else {
|
||
// ⚠️ PRE-FIX COMMENT, corrected 2026-08-30. This read
|
||
// "the last frame carries no time", which was the rule
|
||
// BEFORE the record-layout fix directly above. Post-fix
|
||
// every pose is timed — measured at **0 untimed of
|
||
// 24 811 keyframes** across 965 builds — so this branch
|
||
// is unreachable on this disc. Kept as a guard because
|
||
// `time` is still `Option<u32>` and a malformed group
|
||
// could produce `None`; it is no longer a description of
|
||
// the format.
|
||
continue;
|
||
};
|
||
let dwell = t1.saturating_sub(t0);
|
||
// `>=`, not `>`: on a tie take the LATER frame. A group is
|
||
// in → hold → out, so when two gaps are equal the second is
|
||
// the settled pose — `pgpmsg` holds 5 ticks at y=645 on the
|
||
// way in and 5 more at y=605 where it stays, and `>` picks
|
||
// the fly-through.
|
||
if dwell >= best.1 {
|
||
best = (k, dwell);
|
||
}
|
||
}
|
||
self.keyframes.get(best.0)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The hold: the longest run of consecutive keyframes whose pose is
|
||
/// identical. `None` when no two adjacent frames agree.
|
||
///
|
||
/// **A run that ends on the last keyframe is the exit, not the hold.** A
|
||
/// group carries the screen's entry animation *and* its exit, so the shape
|
||
/// is pre-roll → ramp in → **hold** → ramp out → post-roll, and the last two
|
||
/// of those are often plateaus themselves. The pause menu's `pgptitle.rat`
|
||
/// has three runs of two — invisible, visible, invisible — and taking the
|
||
/// last one erased the word PAUSE, which the capture of the running game
|
||
/// plainly shows (`captures/ui-layout/pause-tutorial-real-vs-rebuilt.png`).
|
||
/// So trailing runs are excluded, and only fall back to if there is nothing
|
||
/// else.
|
||
///
|
||
/// Among the runs that remain, longest wins and a **later** run breaks a tie:
|
||
/// the pre-roll comes first and the hold after it.
|
||
fn rest_plateau(&self) -> Option<&Keyframe> {
|
||
let n = self.keyframes.len();
|
||
if n < 2 {
|
||
return None;
|
||
}
|
||
let same = |a: &Keyframe, b: &Keyframe| {
|
||
a.fade == b.fade
|
||
&& a.scale_x == b.scale_x
|
||
&& a.scale_y == b.scale_y
|
||
&& a.tint == b.tint
|
||
&& a.x == b.x
|
||
&& a.y == b.y
|
||
};
|
||
// (start, len) of the best run that does not end on the last keyframe,
|
||
// and separately of the best run overall.
|
||
let (mut best, mut best_len) = (None, 0usize);
|
||
let (mut any, mut any_len) = (None, 0usize);
|
||
let mut i = 0usize;
|
||
while i < n {
|
||
let mut j = i;
|
||
while j + 1 < n && same(&self.keyframes[j], &self.keyframes[j + 1]) {
|
||
j += 1;
|
||
}
|
||
let len = j - i + 1;
|
||
if len >= 2 {
|
||
if len >= any_len {
|
||
any = Some(i);
|
||
any_len = len;
|
||
}
|
||
// A trailing run is normally the EXIT and is excluded (see the
|
||
// doc comment) — but not always, and the tell is its ALPHA.
|
||
//
|
||
// An exit fades the element out, so its final keyframe is
|
||
// transparent: `pgptitle.rat`'s trailing run is `0x00ffffff`, and
|
||
// taking it erases the word PAUSE. An element with **no exit**
|
||
// ends on its hold, which is whatever it looks like on screen —
|
||
// opaque: `ptframe1`/`ptframe2` on the title main menu end on a
|
||
// run of three at `0xffffffff`, and excluding it dropped the
|
||
// bright circuit bracket the capture plainly shows.
|
||
//
|
||
// So a trailing run is the hold exactly when it is **visible**.
|
||
// (The port agent's report proposed "the final untimed keyframe
|
||
// has the same pose as the last timed one"; that is true of
|
||
// `pgptitle` too and would erase PAUSE, so it is the alpha and
|
||
// not the pose-equality that separates the two.)
|
||
let trailing_is_the_hold = j == n - 1 && (self.keyframes[i].fade >> 24) != 0;
|
||
if (j != n - 1 || trailing_is_the_hold) && len >= best_len {
|
||
best = Some(i);
|
||
best_len = len;
|
||
}
|
||
}
|
||
i = j + 1;
|
||
}
|
||
best.or(any).map(|k| &self.keyframes[k])
|
||
}
|
||
}
|
||
|
||
/// A parsed UI build: one screen layout (one context × language).
|
||
pub struct UiBuild {
|
||
/// Design-space dimensions, normally 1280×720.
|
||
pub design_w: u32,
|
||
pub design_h: u32,
|
||
/// Every element, in the declaration table's back-to-front draw order.
|
||
pub elements: Vec<Element>,
|
||
/// Sprite name → (offset, size) of its `T8aD` child within the bundle.
|
||
pub sprites: HashMap<String, (usize, usize)>,
|
||
/// Record name → (offset, size) of its nested `.rat` **leaf** within the
|
||
/// bundle, e.g. `ptbtn01f.rat`.
|
||
///
|
||
/// Exposed because a leaf is where a focused button's extra elements live —
|
||
/// `ptbtn0Nf.rat` declares the focus ring `ptbtneff01.t32` **and** the bright
|
||
/// label, and the parent bundle declares no element for the `f` record at
|
||
/// all. A consumer that walks only top-level elements cannot see either.
|
||
///
|
||
/// A leaf is itself readable by [`parse_build`]: its first 32 bytes have the
|
||
/// same shape as a bundle header, so
|
||
/// `parse_build(&bundle[off..off + size])` returns its elements with
|
||
/// absolute design-space placements. See
|
||
/// `docs/re/structures/ui-button-focus-record.md`.
|
||
pub records: HashMap<String, (usize, usize)>,
|
||
/// A guessed context from the sprite naming (e.g. `"tutorial"`), if any.
|
||
pub context_hint: Option<String>,
|
||
/// True when the declaration table was unusable and the build was recovered
|
||
/// by scanning `.rat` records instead — placements are then per-record and
|
||
/// `.rat`-less elements are missing.
|
||
pub from_fallback: bool,
|
||
/// Element indices in the order the placement region stores their keyframe
|
||
/// groups. Each group names its element explicitly, so this *could* be a
|
||
/// second, independent ordering — and therefore a candidate for the paint
|
||
/// order the title screen needs. It is not: it equals the declaration order
|
||
/// on every build on the disc. Kept, with
|
||
/// `placement_region_order_is_never_a_second_ordering` guarding it, so the
|
||
/// refutation stays checkable rather than remembered.
|
||
pub placement_order: Vec<usize>,
|
||
}
|
||
|
||
/// Whether `bundle` is a RATC screen build (has at least one `.rat` layout child).
|
||
pub fn is_build(bundle: &[u8]) -> bool {
|
||
ratc::is_ratc(bundle)
|
||
&& ratc::parse(bundle).is_some_and(|kids| {
|
||
kids.iter()
|
||
.any(|c| c.name.to_ascii_lowercase().ends_with(".rat"))
|
||
})
|
||
}
|
||
|
||
/// Whether `compose` can draw this bundle: it parses a real declaration table
|
||
/// and at least one element resolves to a `T8aD` sprite the bundle carries.
|
||
///
|
||
/// Strictly wider than [`is_build`], which additionally requires a `.rat` layout
|
||
/// child. Measured on the disc: 2 859 RATC bundles, 965 pass `is_build`, 2 751
|
||
/// pass this — and no bundle passes `is_build` without passing this, so it is a
|
||
/// superset and not a different rule.
|
||
///
|
||
/// The extra 1 786 are **not** all screens. Most are two-element fragments — a
|
||
/// button beside its glow (`pvbtnnew.t32` + `pvbtnneweff.t32`) — which is why
|
||
/// `is_build` stays the default enumeration and this is opt-in. What it does
|
||
/// unlock is the developer-logo splash, which declares its sprites directly,
|
||
/// has no `.rat` child, and was therefore impossible to render at all despite
|
||
/// being one of only two screens whose paint order has been measured off the
|
||
/// running game.
|
||
pub fn is_composable(bundle: &[u8]) -> bool {
|
||
parse_build(bundle).is_some_and(|b| {
|
||
!b.from_fallback
|
||
&& b.elements
|
||
.iter()
|
||
.any(|e| e.sprite.as_ref().is_some_and(|s| b.sprites.contains_key(s)))
|
||
})
|
||
}
|
||
|
||
/// Trim a NUL-padded fixed-width name field.
|
||
fn fixed_name(b: &[u8]) -> String {
|
||
let end = b.iter().position(|&c| c == 0).unwrap_or(b.len());
|
||
String::from_utf8_lossy(&b[..end]).trim().to_string()
|
||
}
|
||
|
||
/// The `opt ` link inside a `.rat` record: a tag, a length, then the name.
|
||
fn opt_link(rec: &[u8]) -> Option<String> {
|
||
let pos = rec.windows(4).position(|w| w == b"opt ")?;
|
||
let len = be32(rec, pos + 4) as usize;
|
||
if len == 0 || len > 64 || pos + 8 + len > rec.len() {
|
||
return None;
|
||
}
|
||
let s = fixed_name(&rec[pos + 8..pos + 8 + len]);
|
||
(!s.is_empty()).then_some(s)
|
||
}
|
||
|
||
/// A RATC record's animation **loop length** in keyframe units — its `+0x08`.
|
||
///
|
||
/// Works at either level: a nested `.rat` leaf is itself a RATC bundle with the
|
||
/// same header shape as the one containing it, so this reads a whole screen
|
||
/// build's length and a single record's length through one path.
|
||
///
|
||
/// **Why it is public.** The loop length is not the largest keyframe time —
|
||
/// `ptbtn00f.rat`, the `PRESS Ⓐ` plate glow, declares **120** while its last
|
||
/// keyframe is at **105**, and that 15-unit slack is the plate holding dark
|
||
/// between cycles. A consumer that infers the period from the keyframes gets
|
||
/// 105 (1.750 s) against a real pulse measured four times at 2.12–2.34 s.
|
||
/// Decoded disc-wide: 1 781 records, **0** violations of
|
||
/// `+0x08 >= largest keyframe time` — see
|
||
/// `docs/re/structures/ui-record-loop-length.md`.
|
||
///
|
||
/// Returns `None` for anything that is not a RATC record, so it is safe to call
|
||
/// on an arbitrary slice; callers do not need their own magic guard.
|
||
pub fn loop_length_units(rec: &[u8]) -> Option<u32> {
|
||
if rec.len() < 0x0c || rec[0..4] != *b"RATC" {
|
||
return None;
|
||
}
|
||
Some(be32(rec, 0x08))
|
||
}
|
||
|
||
/// The sprite a `.rat` record places: a NUL-terminated name at `0x20`.
|
||
///
|
||
/// The field is **not** 16 bytes. Capping it there truncates every longer name —
|
||
/// `pgp_ttrl_title.t32` becomes `pgp_ttrl_title.t`, which then resolves against
|
||
/// nothing and silently drops the element from the composite. It runs up to the
|
||
/// pivot words at `0x50`.
|
||
fn record_sprite(rec: &[u8]) -> Option<String> {
|
||
if rec.len() < 0x30 || rec[0..4] != *b"RATC" {
|
||
return None;
|
||
}
|
||
let end = 0x50.min(rec.len());
|
||
let s = fixed_name(&rec[0x20..end]);
|
||
(!s.is_empty()).then_some(s)
|
||
}
|
||
|
||
/// Read the declaration table. `None` when it does not look like one.
|
||
fn parse_decls(bundle: &[u8]) -> Option<Vec<Element>> {
|
||
let count = be32(bundle, 0x14) as usize;
|
||
// Guard: this parser runs over ~2 900 bundles, most of which are not screen
|
||
// builds. A count that cannot fit is a mis-read, not a short table.
|
||
if count == 0 || count > 4096 || DECL_TABLE_AT + count * DECL_ENTRY > bundle.len() {
|
||
return None;
|
||
}
|
||
let mut elements = Vec::with_capacity(count);
|
||
for i in 0..count {
|
||
let e = &bundle[DECL_TABLE_AT + i * DECL_ENTRY..DECL_TABLE_AT + (i + 1) * DECL_ENTRY];
|
||
let name = fixed_name(&e[..28]);
|
||
// Every real declaration names something; a table of blanks means we are
|
||
// reading past the header of a bundle that has no declaration table.
|
||
if name.is_empty() {
|
||
return None;
|
||
}
|
||
let parent = match be32(e, 32) {
|
||
u32::MAX => None,
|
||
p if (p as usize) < count => Some(p as usize),
|
||
_ => None,
|
||
};
|
||
let lname = name.to_ascii_lowercase();
|
||
elements.push(Element {
|
||
index: i,
|
||
name,
|
||
sprite: None,
|
||
parent,
|
||
kind: be32(e, 40),
|
||
pivot_x: be32(e, 48),
|
||
pivot_y: be32(e, 52),
|
||
keyframes: Vec::new(),
|
||
focus_link: None,
|
||
focused: false, // needs the whole table — see below
|
||
animated: lname.contains("loop"),
|
||
});
|
||
}
|
||
mark_focused_states(&mut elements);
|
||
Some(elements)
|
||
}
|
||
|
||
/// Flag the elements that are a **focused variant of another element present in
|
||
/// the same build** — `pgmenu_btn00f.t32` next to `pgmenu_btn00.t32`.
|
||
///
|
||
/// The pairing is not decoration, it is the whole rule. A trailing `f` alone
|
||
/// flags **2 458** elements on the disc and only **54** of them have a base to
|
||
/// be the focused version of; all 54 are `pgmenu_btnNNf.t32`. The other 2 404,
|
||
/// spread over 864 bundles, are `_eff` glow layers whose names merely end in the
|
||
/// same letter — `pb_name_eff.t32` (1 122 elements), `pbmwindow_eff.t32`,
|
||
/// `pghud_range_eff.t32`, `palogo_gamearts_eff.t32`. `compose` drops focused
|
||
/// records by default, so the unpaired rule was deleting a glow layer from
|
||
/// nearly every screen that has one.
|
||
///
|
||
/// The draw capture settles it independently: on the developer-logo splash the
|
||
/// three `_eff` glows carry layer key `0xa100` and are **painted**, before their
|
||
/// logos (`docs/re/structures/ui-paint-order-key.md`). They are not focus states.
|
||
fn mark_focused_states(elements: &mut [Element]) {
|
||
let names: std::collections::HashSet<String> = elements
|
||
.iter()
|
||
.map(|e| e.name.to_ascii_lowercase())
|
||
.collect();
|
||
for el in elements.iter_mut() {
|
||
let l = el.name.to_ascii_lowercase();
|
||
if !(l.ends_with("f.rat") || l.ends_with("f.t32")) {
|
||
continue;
|
||
}
|
||
let Some((stem, ext)) = l.rsplit_once('.') else {
|
||
continue;
|
||
};
|
||
el.focused = names.contains(&format!("{}.{}", &stem[..stem.len() - 1], ext));
|
||
}
|
||
}
|
||
|
||
/// Read the placement region that follows the declaration table, filling in each
|
||
/// element's keyframe group.
|
||
fn parse_placements(bundle: &[u8], elements: &mut [Element]) -> Vec<usize> {
|
||
// Escape hatch for the pre-2026-08-29 reading, which mis-associated every
|
||
// keyframe time by one slot and could not time a group's final pose at all.
|
||
// See `docs/re/ui-keyframe-record-layout.md`.
|
||
let legacy_times = std::env::var("SYLPHEED_KF_TIME_LEGACY").as_deref() == Ok("1");
|
||
let count = elements.len();
|
||
let mut order = Vec::with_capacity(count);
|
||
let mut pos = DECL_TABLE_AT + count * DECL_ENTRY;
|
||
for _ in 0..count {
|
||
if pos + 8 > bundle.len() {
|
||
break;
|
||
}
|
||
let idx = be32(bundle, pos) as usize;
|
||
let frames = be32(bundle, pos + 4) as usize;
|
||
if idx >= count || frames == 0 || frames > 4096 {
|
||
break;
|
||
}
|
||
// The region is `frames` records of 40 bytes, each `{ u32 time; 36-byte
|
||
// pose }`, after an 8-byte header — so the word at `pos + 8` is the
|
||
// FIRST pose's time, and each 40-byte stride's `+36` word is the time of
|
||
// the pose that follows it. Our block window is offset 4 bytes into the
|
||
// record (it starts at the pose), which is why the pose field offsets
|
||
// below are right while the times were off by one.
|
||
let first_time = be32(bundle, pos + 8);
|
||
let first = pos + 12;
|
||
// The region is packed so that the next group's header sits 4 bytes
|
||
// inside the last block — i.e. the group owns `frames * 40 - 4` bytes of
|
||
// block data, and the final block's time field is not its own.
|
||
let group_end = first + frames * KEYFRAME - 4;
|
||
let mut group = Vec::with_capacity(frames);
|
||
for k in 0..frames {
|
||
let blk = first + k * KEYFRAME;
|
||
if blk + 36 > bundle.len() || blk + 36 > group_end {
|
||
break;
|
||
}
|
||
group.push(Keyframe {
|
||
fade: be32(bundle, blk),
|
||
rotation_deg: be32(bundle, blk + 12) as i32,
|
||
unknown_4: be32(bundle, blk + 4) as i32,
|
||
unknown_8: be32(bundle, blk + 8) as i32,
|
||
scale_x: be32(bundle, blk + 16),
|
||
scale_y: be32(bundle, blk + 20),
|
||
tint: be32(bundle, blk + 24),
|
||
x: be32(bundle, blk + 28) as i32,
|
||
y: be32(bundle, blk + 32) as i32,
|
||
// Pose `k`'s time is the word that PRECEDES it: the group's
|
||
// lead-in word for `k == 0`, and the previous stride's `+36`
|
||
// otherwise. Every pose is timed; nothing is missing and nothing
|
||
// is special-cased. Checked disc-wide — see
|
||
// `docs/re/ui-keyframe-record-layout.md`.
|
||
time: if legacy_times {
|
||
(blk + 40 <= group_end).then(|| be32(bundle, blk + 36))
|
||
} else if k == 0 {
|
||
Some(first_time)
|
||
} else {
|
||
Some(be32(bundle, blk - KEYFRAME + 36))
|
||
},
|
||
});
|
||
}
|
||
elements[idx].keyframes = group;
|
||
order.push(idx);
|
||
pos = group_end;
|
||
}
|
||
order
|
||
}
|
||
|
||
/// Parse a build bundle into its elements and sprite table.
|
||
pub fn parse_build(bundle: &[u8]) -> Option<UiBuild> {
|
||
let kids = ratc::parse(bundle)?;
|
||
let mut sprites = HashMap::new();
|
||
let mut records: HashMap<String, (usize, usize)> = HashMap::new();
|
||
for c in &kids {
|
||
let end = (c.offset + c.size).min(bundle.len());
|
||
if c.kind == "T8aD" {
|
||
sprites.insert(c.name.clone(), (c.offset, end - c.offset));
|
||
} else if c.name.to_ascii_lowercase().ends_with(".rat") {
|
||
records.insert(c.name.clone(), (c.offset, end - c.offset));
|
||
}
|
||
}
|
||
|
||
let (mut elements, from_fallback, placement_order) = match parse_decls(bundle) {
|
||
Some(mut els) => {
|
||
let order = parse_placements(bundle, &mut els);
|
||
(els, false, order)
|
||
}
|
||
// No usable declaration table: recover what the `.rat` records alone can
|
||
// say. Elements without a record (eff*/deli*/msg) are then missing, so
|
||
// callers are told via `from_fallback`.
|
||
None => {
|
||
let mut els = fallback_elements(bundle, &records);
|
||
mark_focused_states(&mut els);
|
||
let order = (0..els.len()).collect();
|
||
(els, true, order)
|
||
}
|
||
};
|
||
|
||
// Resolve each element to the sprite it draws, and pick up its focus link.
|
||
for el in &mut elements {
|
||
if let Some(&(off, size)) = records.get(&el.name) {
|
||
let rec = &bundle[off..off + size];
|
||
el.sprite = record_sprite(rec);
|
||
el.focus_link = opt_link(rec);
|
||
} else if sprites.contains_key(&el.name) {
|
||
el.sprite = Some(el.name.clone());
|
||
}
|
||
}
|
||
|
||
if elements.is_empty() {
|
||
return None;
|
||
}
|
||
// The design space is stated by any `.rat` record; every screen seen is
|
||
// 1280×720, which is also the fallback.
|
||
let (design_w, design_h) = records
|
||
.values()
|
||
.find_map(|&(off, size)| {
|
||
let r = &bundle[off..off + size];
|
||
let (w, h) = (be32(r, 0x18), be32(r, 0x1c));
|
||
(w > 0 && h > 0 && w <= 8192 && h <= 8192).then_some((w, h))
|
||
})
|
||
.unwrap_or((DESIGN_W, DESIGN_H));
|
||
|
||
let context_hint = sprites
|
||
.keys()
|
||
.find_map(|n| n.contains("ttrl").then(|| "tutorial".to_string()));
|
||
|
||
Some(UiBuild {
|
||
design_w,
|
||
design_h,
|
||
elements,
|
||
sprites,
|
||
records,
|
||
context_hint,
|
||
from_fallback,
|
||
placement_order,
|
||
})
|
||
}
|
||
|
||
/// Recover elements from the `.rat` records when the declaration table is
|
||
/// unreadable: each record becomes an element with a single keyframe.
|
||
fn fallback_elements(bundle: &[u8], records: &HashMap<String, (usize, usize)>) -> Vec<Element> {
|
||
let mut names: Vec<&String> = records.keys().collect();
|
||
names.sort_by_key(|n| records[*n].0); // file order stands in for draw order
|
||
let mut out = Vec::new();
|
||
for name in names {
|
||
let (off, size) = records[name];
|
||
let rec = &bundle[off..off + size];
|
||
let Some((tint, x, y)) = scan_placement_block(rec) else {
|
||
continue;
|
||
};
|
||
let lname = name.to_ascii_lowercase();
|
||
out.push(Element {
|
||
index: out.len(),
|
||
name: name.clone(),
|
||
sprite: record_sprite(rec),
|
||
parent: None,
|
||
kind: 0,
|
||
pivot_x: be32(rec, 0x50),
|
||
pivot_y: be32(rec, 0x54),
|
||
keyframes: vec![Keyframe {
|
||
fade: 0xffff_ffff,
|
||
rotation_deg: 0,
|
||
unknown_4: 0,
|
||
unknown_8: 0,
|
||
scale_x: 100,
|
||
scale_y: 100,
|
||
tint,
|
||
x,
|
||
y,
|
||
time: Some(0),
|
||
}],
|
||
focus_link: opt_link(rec),
|
||
focused: false, // set by `mark_focused_states` once the set is known
|
||
animated: lname.contains("loop"),
|
||
});
|
||
}
|
||
for (i, el) in out.iter_mut().enumerate() {
|
||
el.index = i;
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Find a `[scaleX=100, scaleY=100, tint, X, Y]` run inside a `.rat` record.
|
||
/// Records are tag-driven and variable-length, so this anchor beats a fixed
|
||
/// offset — it is only used on the fallback path.
|
||
fn scan_placement_block(rec: &[u8]) -> Option<(u32, i32, i32)> {
|
||
let mut o = 0x58;
|
||
while o + 20 <= rec.len() {
|
||
if be32(rec, o) == 100 && be32(rec, o + 4) == 100 {
|
||
let (tint, x, y) = (
|
||
be32(rec, o + 8),
|
||
be32(rec, o + 12) as i32,
|
||
be32(rec, o + 16) as i32,
|
||
);
|
||
if x > -4096 && x < 8192 && y > -4096 && y < 8192 {
|
||
return Some((tint, x, y));
|
||
}
|
||
}
|
||
o += 4;
|
||
}
|
||
None
|
||
}
|
||
|
||
/// A composited screen image ready to display.
|
||
pub struct ComposedScreen {
|
||
pub width: u32,
|
||
pub height: u32,
|
||
/// Row-major RGBA8.
|
||
pub rgba: Vec<u8>,
|
||
/// Indices of the elements actually drawn, in draw order.
|
||
pub drawn: Vec<usize>,
|
||
/// Elements skipped because their sprite could not be resolved or decoded.
|
||
pub missing: Vec<String>,
|
||
}
|
||
|
||
/// What to include when compositing.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct ComposeOptions {
|
||
/// Draw `*f` focused-state records over their base elements.
|
||
pub include_focus: bool,
|
||
/// Draw `loop*` sprite animations.
|
||
pub include_animated: bool,
|
||
/// Colour the canvas starts at. The default dim slate stands in for the
|
||
/// PRMD dim-quad plus the live 3D scene behind an in-mission screen; a
|
||
/// screen that carries its own full-screen background wants **black**,
|
||
/// which is what the game composites over — comparing a composite against a
|
||
/// framebuffer capture needs the backdrop to match, or every partially
|
||
/// transparent pixel is off by the backdrop.
|
||
pub backdrop: [u8; 4],
|
||
/// Draw the untextured `.prm` primitives — the fade / dim / flash quads.
|
||
///
|
||
/// **Off by default, and not because they are undecoded.** They are decoded
|
||
/// (`docs/re/structures/ui-prm-primitives.md`) and drawing them is right on the
|
||
/// title screen, whose paint order was read off the running game. What is
|
||
/// unsolved is *where they paint on every other screen*: a primitive has no
|
||
/// `T8aD` header, so it has no layer key, and the derived order forces the
|
||
/// keyless to the end. That is wrong — the two measured screens show a
|
||
/// primitive painting **first** (the splash's black backdrop) and another
|
||
/// painting **last** (the title's fade-out), so no single default is right.
|
||
/// Left on with the derived order, an opaque black quad sorts last and wipes
|
||
/// 32 of `GP_DIALOG`'s builds.
|
||
pub include_primitives: bool,
|
||
/// Pose every element at this keyframe time instead of at its resting pose.
|
||
///
|
||
/// `None` keeps the settled composite, which is what every existing caller
|
||
/// wants. A capture taken mid-animation needs the render posed at the same
|
||
/// instant — see [`Element::pose_at`].
|
||
pub at: Option<u32>,
|
||
}
|
||
|
||
impl Default for ComposeOptions {
|
||
fn default() -> Self {
|
||
Self {
|
||
include_focus: false,
|
||
include_animated: false,
|
||
backdrop: [14, 14, 20, 255],
|
||
include_primitives: false,
|
||
at: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Composite a build into its screen image, using the default options.
|
||
pub fn compose_build(bundle: &[u8], include_focus: bool) -> Option<ComposedScreen> {
|
||
let build = parse_build(bundle)?;
|
||
Some(compose(
|
||
&build,
|
||
bundle,
|
||
ComposeOptions {
|
||
include_focus,
|
||
..Default::default()
|
||
},
|
||
None,
|
||
))
|
||
}
|
||
|
||
/// Composite a parsed build.
|
||
///
|
||
/// `visible`, when given, selects elements by index — the viewer uses it for
|
||
/// per-element toggles. Elements are drawn in declaration order, which is the
|
||
/// screen's own back-to-front order.
|
||
/// The layer key a sprite carries, from the word at `+0x08` of its `T8aD`
|
||
/// header — the field the paint order sorts by.
|
||
///
|
||
/// Measured, not guessed: read in the order the game actually paints them, this
|
||
/// word is non-decreasing on both screens whose order has been captured, with no
|
||
/// inversion (`docs/re/structures/ui-paint-order-key.md`). On the developer-logo
|
||
/// splash it explains the whole permutation — the `_eff` glows carry `0xa100`
|
||
/// and their base logos `0xa110`, so the glows paint first even though the
|
||
/// declaration table interleaves them.
|
||
pub fn sprite_layer_key(build: &UiBuild, bundle: &[u8], el: &Element) -> Option<u32> {
|
||
let sprite = el.sprite.as_ref()?;
|
||
let &(off, size) = build.sprites.get(sprite)?;
|
||
if size < 0x0c {
|
||
return None;
|
||
}
|
||
Some(be32(bundle, off + 8))
|
||
}
|
||
|
||
/// Layer keys for elements the bundle gives no key for, **measured** from the
|
||
/// running game rather than read from a file.
|
||
///
|
||
/// A `.prm` primitive has no `T8aD` header and therefore no layer key, and the
|
||
/// bundle carries no data for it at all — the menu build declares
|
||
/// `pteff00.prm`, `pteff02.prm` and `pteff05.t32` and has **zero** RATC children
|
||
/// for any of them. The four unread words of the 60-byte declaration entry are
|
||
/// constant across every element (`+28`=0, `+36`=0xffffffff, `+56`=0, and `+44`
|
||
/// is a button ordinal 1–5), so the key is not there either. It comes from the
|
||
/// game's own code.
|
||
///
|
||
/// But it is *consistent*, which is what makes a table honest rather than a
|
||
/// fudge. Bracketing each unkeyed element by its measured neighbours' keys:
|
||
///
|
||
/// | element | title screen | main menu | splash |
|
||
/// |---|---|---|---|
|
||
/// | `pteff05.t32` | — | (0x8010, 0x8040) | — |
|
||
/// | `pteff04.t32` | (0x8010, 0x8040) | — | — |
|
||
/// | `pteff02.prm` | (0x8010, 0x8040) | (0x8010, 0x8040) | — |
|
||
/// | `pteff00.prm` | (0x8100, end) | (0x8110, end) | — |
|
||
/// | `palogo_eff0.prm` | — | — | (start, 0xa100) |
|
||
///
|
||
/// `pteff02.prm` lands in the **same interval on both** screens it appears on;
|
||
/// the fade quad is past the maximum on both; the splash backdrop is below the
|
||
/// minimum. So these behave exactly like fixed per-name layers.
|
||
///
|
||
/// Only names whose position has actually been measured are listed. An unlisted
|
||
/// primitive keeps `u32::MAX` and sorts last, which is where `compose` leaves it
|
||
/// — and why `ComposeOptions::include_primitives` is still off by default.
|
||
pub fn implied_layer_key(name: &str) -> Option<u32> {
|
||
Some(match name {
|
||
// Full-screen backdrops, measured painting FIRST on their screens.
|
||
// `pfbase.tbm` is not a `.prm` at all — a `.tbm` with no sprite and no
|
||
// key — but it behaves identically: the save/load screen paints it and
|
||
// its `kind = 0x3004` instance before everything else.
|
||
"palogo_eff0.prm" | "pfbase.tbm" => 0x0000,
|
||
"pteff04.t32" | "pteff05.t32" => 0x8020,
|
||
"pteff02.prm" => 0x8030,
|
||
"pteff00.prm" => 0xffff_fffe,
|
||
_ => return None,
|
||
})
|
||
}
|
||
|
||
/// The paint order derived from the bundle: a stable sort of the elements by
|
||
/// their sprite's layer key.
|
||
///
|
||
/// Elements with no sprite (the `.prm` primitives) have no key and keep their
|
||
/// declaration position among themselves; `compose` skips them anyway, so where
|
||
/// they land does not affect a composite. Ties keep declaration order — the game
|
||
/// breaks them some other way, which is unexplained and looks harmless because
|
||
/// tied elements are same-layer.
|
||
/// Is this element an opaque full-screen quad that **must** sort below everything?
|
||
///
|
||
/// A keyless primitive has no layer key and the game's own code decides where it
|
||
/// paints ([`implied_layer_key`] records the names measured in the running game).
|
||
/// For one whole class of them the file settles it without a measurement: an
|
||
/// element that covers the screen and is **fully opaque** at some instant cannot
|
||
/// paint above anything visible at that instant, or the screen would be blank.
|
||
/// Where the elements visible during its opaque span are *all* of them, its
|
||
/// position is forced to first.
|
||
///
|
||
/// Two controls, both measured in the running game and both reproduced by this
|
||
/// rule rather than assumed by it:
|
||
///
|
||
/// * `palogo_eff0.prm` is measured painting **first** — and comes out forced
|
||
/// first (opaque for 211 instants, below 6 of 6). A rule keyed on the *name*
|
||
/// would get this wrong: it is named like an overlay.
|
||
/// * `pteff00.prm` is measured painting **last** — and is forced below only 3 of
|
||
/// 23 elements on the title, because it is opaque for 2 instants at the screen's
|
||
/// entry and exit, so the rule permits it on top.
|
||
///
|
||
/// Disc-wide: 80 instances forced first, 50 constrained but not forced, 0
|
||
/// unconstrained. It also explains the 36 dialog builds that composite to one
|
||
/// colour — `pzeff00.prm` is forced first in 32 of 32 instances.
|
||
///
|
||
/// ⚠️ Assumes straight alpha-over blending. Blend mode is an open question in
|
||
/// `docs/re/structures/ui-prm-primitives.md`; an *additive* quad at alpha 255
|
||
/// would not occlude, and this rule would then be placing it wrongly.
|
||
pub fn forced_backdrop(build: &UiBuild, el: &Element) -> bool {
|
||
// 🔴 Untextured primitives only. A `.t32` sprite's ELEMENT alpha being 255
|
||
// says nothing about whether its texture covers the screen — most of it may
|
||
// be transparent, so it occludes nothing. Applied without this guard the
|
||
// rule claims 22 textured sprites must sort first, against their own layer
|
||
// keys: `pneff01.t32` (key 0xd850, paints #8 of 13) and `pbfriendly.t32`
|
||
// (key 0x9230, #17 of 49). Those disagreements are the rule being wrong,
|
||
// not the keys.
|
||
if el.sprite.is_some() {
|
||
return false;
|
||
}
|
||
// The element must have a declared size at all; coverage itself is tested
|
||
// per instant below, against the SCALED size.
|
||
if el.pivot_x == 0 || el.pivot_y == 0 {
|
||
return false;
|
||
}
|
||
let tmax = build
|
||
.elements
|
||
.iter()
|
||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
|
||
.max()
|
||
.unwrap_or(0);
|
||
if tmax == 0 {
|
||
return false;
|
||
}
|
||
// An instant counts only where the element is BOTH fully opaque AND actually
|
||
// covering — tested together, because both animate on the same ramp.
|
||
//
|
||
// ⚠️ Coverage is the SCALED size, not the declared one, and the test must be
|
||
// two-sided. A quad scaled down does not cover what its pivot suggests —
|
||
// `pbafc.prm` declares 844x600 and draws ~17x18 at 2 %/3 %. And a quad scaled
|
||
// *up* can cover from a smaller declared size, so rejecting on the declared
|
||
// size would replace one error with its mirror. Checked before adopting:
|
||
// across 921 keyless elements, **0** cover the screen only via scale, so the
|
||
// mirror case does not occur on this disc — the per-instant test is in
|
||
// because it does not need that to stay true.
|
||
let (dw, dh) = ((el.pivot_x * 2) as u64, (el.pivot_y * 2) as u64);
|
||
let opaque: Vec<u32> = (0..=tmax)
|
||
.filter(|&t| {
|
||
el.pose_at(t).map_or(false, |k| {
|
||
k.fade >> 24 == 255
|
||
&& dw * k.scale_x as u64 / 100 >= build.design_w as u64
|
||
&& dh * k.scale_y as u64 / 100 >= build.design_h as u64
|
||
})
|
||
})
|
||
.collect();
|
||
if opaque.is_empty() {
|
||
return false;
|
||
}
|
||
let mut others = 0usize;
|
||
let mut occluded = 0usize;
|
||
for o in &build.elements {
|
||
if o.index == el.index {
|
||
continue;
|
||
}
|
||
others += 1;
|
||
if opaque
|
||
.iter()
|
||
.any(|&t| o.pose_at(t).map(|k| k.fade >> 24).unwrap_or(0) > 0)
|
||
{
|
||
occluded += 1;
|
||
}
|
||
}
|
||
others > 0 && occluded == others
|
||
}
|
||
|
||
pub fn derived_paint_order(build: &UiBuild, bundle: &[u8]) -> Vec<usize> {
|
||
let mut idx: Vec<usize> = (0..build.elements.len()).collect();
|
||
idx.sort_by_key(|&i| {
|
||
let el = &build.elements[i];
|
||
(
|
||
sprite_layer_key(build, bundle, el)
|
||
.or_else(|| implied_layer_key(&el.name))
|
||
// An opaque full-screen quad that covers every other element
|
||
// while it is opaque cannot be on top — see `forced_backdrop`.
|
||
.or_else(|| forced_backdrop(build, el).then_some(0))
|
||
.unwrap_or(u32::MAX),
|
||
i,
|
||
)
|
||
});
|
||
idx
|
||
}
|
||
|
||
/// Paint orders **measured from the running game**, not derived from the file.
|
||
///
|
||
/// The bundle does not say what order its elements paint in — the game builds a
|
||
/// second, reordered child list at load time and paints that, and no decoded
|
||
/// field reproduces it (`docs/re/structures/ui-screen-runtime.md` records the
|
||
/// search, including everything refuted). Until the ordering is derived, the
|
||
/// honest thing is to use the orders that HAVE been read off the running game
|
||
/// and to fall back to declaration order everywhere else — which is what this
|
||
/// table does. Keying is by element names, because that identifies a build
|
||
/// across paks and language variants without a pak hash.
|
||
///
|
||
/// Each entry is the paint order as declaration indices, first painted first.
|
||
fn measured_paint_order(build: &UiBuild) -> Option<Vec<usize>> {
|
||
let names: Vec<&str> = build.elements.iter().map(|e| e.name.as_str()).collect();
|
||
// GP_TITLE.pak entry 4 (a60fcb85) — the title screen the game actually runs.
|
||
const TITLE: [&str; 24] = [
|
||
"ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32", "ptlogo2.t32", "ptlogo1.t32",
|
||
"ptlogo2.t32", "pteff01.t32", "ptlogo_tm.t32", "pteff00.prm", "ptbase2.t32",
|
||
"pteff04.t32", "ptloop01.rat", "ptloop02.rat", "pteff02.prm",
|
||
"ptlogo_back2eff1.t32", "ptlogo_back2eff2.t32", "ptlogo_back2eff3.t32",
|
||
"ptlogo_back2eff4.t32", "ptlogo_back2eff5.t32", "ptlogo_back2.t32",
|
||
"ptlogo_back2eff.t32", "ptcopyright.t32", "ptlogoall_eff.t32",
|
||
"ptlogoall_eff2.t32",
|
||
];
|
||
// GP_TITLE.pak entries 11/14 — the GAME ARTS / SETA / studio anima splash.
|
||
const SPLASH: [&str; 7] = [
|
||
"palogo_eff0.prm", "palogo_gamearts.t32", "palogo_gamearts_eff.t32",
|
||
"palogo_seta.t32", "palogo_seta_eff.t32", "palogo_anima.t32",
|
||
"palogo_anima_eff.t32",
|
||
];
|
||
if names == TITLE {
|
||
// background, the rotating pair, the other full-screen layers, the
|
||
// back2 glow group, the wordmarks, the copyright, the fade.
|
||
return Some(vec![
|
||
9, 11, 12, 10, 13, 6, 20, 19, 14, 15, 18, 16, 17, 0, 2, 4, 7, 1, 3, 5,
|
||
22, 23, 21, 8,
|
||
]);
|
||
}
|
||
if names == SPLASH {
|
||
// the full-screen .prm, then all three glows, then the three logos.
|
||
return Some(vec![0, 2, 4, 6, 1, 3, 5]);
|
||
}
|
||
// GP_TITLE.pak ratc-index 8 — the MAIN MENU (NEW GAME / LOAD GAME /
|
||
// TUTORIAL / OPTIONS / EXTRAS). Read off the running game 2026-08-19; it is
|
||
// the third measured permutation and the first that contains TWO
|
||
// primitives, which is why it matters — see `ui-prm-primitives.md`.
|
||
const MENU: [&str; 16] = [
|
||
"pteff00.prm", "ptbase.t32", "pteff05.t32", "ptloop01.rat",
|
||
"ptloop02.rat", "pteff02.prm", "ptframe1.t32", "ptframe2.t32",
|
||
"pteff10.t32", "pteff12.t32", "ptbtn01.rat", "ptbtn02.rat",
|
||
"ptbtn03.rat", "ptbtn04.rat", "ptbtn05.rat", "ptmsg.t32",
|
||
];
|
||
if names == MENU {
|
||
// background, the two loops, the full-screen effect, the DIM quad,
|
||
// the glows, the frames, the message, the five buttons, and the
|
||
// screen-transition FADE quad last.
|
||
return Some(vec![1, 3, 4, 2, 5, 8, 9, 6, 7, 15, 10, 11, 12, 13, 14, 0]);
|
||
}
|
||
None
|
||
}
|
||
|
||
impl UiBuild {
|
||
/// The instant at which this screen is *settled*, in keyframe time units.
|
||
///
|
||
/// A screen is not settled when each element sits at its own last hold —
|
||
/// that is what [`Element::rest`] gives, and it is wrong for a transient
|
||
/// (see the note there). It is settled at one shared instant, and the disc
|
||
/// says which: gather every keyframe time in the build, and take the
|
||
/// **longest interval containing none of them**. Inside that gap nothing has
|
||
/// an inflection, so every element is either holding or on a long linear
|
||
/// ramp — which is exactly what "the screen has stopped changing" means.
|
||
///
|
||
/// Returns the midpoint of that gap, or `None` when the build has fewer than
|
||
/// two distinct keyframe times.
|
||
///
|
||
/// ⚠️ **Not every bundle has a settled instant.** Disc-wide over the 1 758
|
||
/// composable bundles carrying two or more keyframe times, 30 % have a gap of
|
||
/// at least half a second and 42 % have one under 10 units — the latter are
|
||
/// mostly `loop*` animation fragments, which are *meant* to be in motion and
|
||
/// have no settled pose to find. Check the gap width before trusting the
|
||
/// midpoint; `settle_window` returns it.
|
||
pub fn settle_time(&self) -> Option<u32> {
|
||
self.settle_window().map(|(lo, hi)| lo + (hi - lo) / 2)
|
||
}
|
||
|
||
/// The `[start, end]` of the longest keyframe-free interval — see
|
||
/// [`UiBuild::settle_time`]. The width `end - start` is how much confidence
|
||
/// the midpoint deserves.
|
||
pub fn settle_window(&self) -> Option<(u32, u32)> {
|
||
let mut ts: Vec<u32> = self
|
||
.elements
|
||
.iter()
|
||
.flat_map(|e| e.keyframes.iter().filter_map(|k| k.time))
|
||
.collect();
|
||
ts.sort_unstable();
|
||
ts.dedup();
|
||
if ts.len() < 2 {
|
||
return None;
|
||
}
|
||
ts.windows(2)
|
||
.map(|w| (w[1] - w[0], w[0], w[1]))
|
||
.max_by_key(|&(d, _, _)| d)
|
||
.map(|(_, lo, hi)| (lo, hi))
|
||
}
|
||
}
|
||
|
||
pub fn compose(
|
||
build: &UiBuild,
|
||
bundle: &[u8],
|
||
opts: ComposeOptions,
|
||
visible: Option<&[bool]>,
|
||
) -> ComposedScreen {
|
||
compose_with_order(build, bundle, opts, visible, None)
|
||
}
|
||
|
||
/// `compose`, with the paint order supplied by the caller.
|
||
///
|
||
/// The only reason this exists is to *measure* what a paint order costs: render
|
||
/// a screen twice, once with the order `compose` would pick and once with two
|
||
/// elements swapped, and diff the pixels. `order` is a permutation of element
|
||
/// indices, first painted first; `None` means "whatever `compose` would use".
|
||
/// Nothing in the normal render path passes anything but `None`.
|
||
pub fn compose_with_order(
|
||
build: &UiBuild,
|
||
bundle: &[u8],
|
||
opts: ComposeOptions,
|
||
visible: Option<&[bool]>,
|
||
order_override: Option<&[usize]>,
|
||
) -> ComposedScreen {
|
||
let (w, h) = (build.design_w, build.design_h);
|
||
// A dim backdrop stands in for the PRMD dim-quad + the live 3D scene behind
|
||
// an in-mission screen.
|
||
let mut canvas = vec![0u8; (w as usize) * (h as usize) * 4];
|
||
for px in canvas.chunks_exact_mut(4) {
|
||
px.copy_from_slice(&opts.backdrop);
|
||
}
|
||
let mut drawn = Vec::new();
|
||
let mut missing = Vec::new();
|
||
// Measured paint order when one exists for this build, declaration order
|
||
// otherwise — see `measured_paint_order`.
|
||
// Measured order when this build is one of the two read off the running
|
||
// game; otherwise the order DERIVED from the sprites' layer keys.
|
||
//
|
||
// ✅ Checked 2026-08-29 (`examples/paint_order_audit.rs`), because the
|
||
// previous wording here — "reproduces both measured orders up to ties" —
|
||
// was unmeasured and stale by one: there are THREE measured orders. The
|
||
// derived order reproduces the main menu and the developer splash EXACTLY
|
||
// (0 inverted pairs each) and differs on the title by 8 pairs, all of them
|
||
// same-layer-key ties, two being total occlusions. Of the port's five
|
||
// screens only `EXTRAS` rests on a derived order with ties: 15 tied pairs,
|
||
// 2 overlapping. See docs/re/structures/ui-paint-order-derived-check.md.
|
||
let order: Vec<usize> = match order_override {
|
||
Some(o) => o.to_vec(),
|
||
None => measured_paint_order(build).unwrap_or_else(|| derived_paint_order(build, bundle)),
|
||
};
|
||
for &ei in &order {
|
||
let Some(el) = build.elements.get(ei) else {
|
||
continue;
|
||
};
|
||
if let Some(v) = visible {
|
||
if !v.get(el.index).copied().unwrap_or(true) {
|
||
continue;
|
||
}
|
||
}
|
||
if (el.animated && !opts.include_animated) || (el.focused && !opts.include_focus) {
|
||
continue;
|
||
}
|
||
// A `kind = 0x4` element is a repeated instance of a template. On the
|
||
// title screen those are motion-trail ghosts and are NOT on screen at
|
||
// rest — the draw capture shows exactly ONE quad at each wordmark's
|
||
// position though the bundle declares three instances of each, and
|
||
// drawing them put three oversized PROJECT SYLPHEED copies across the
|
||
// composite.
|
||
//
|
||
// But `0x4` alone does not mean "ghost". 174 elements on the disc are
|
||
// `0x4` with **no** non-`0x4` element of the same sprite — e.g.
|
||
// `GP_READY_ROOM` pak entry 75, 56 elements and every one of them `0x4`
|
||
// (`pbb_destroyer` ×5, `pb_w_line` ×5, …), a list of real icons that a
|
||
// blanket skip would erase. Measured afterwards, and worth stating
|
||
// plainly: **none** of those 174 is in a bundle `is_build` accepts, so
|
||
// today they never reach this function and a blanket skip would have
|
||
// been harmless in practice. The condition below is therefore a
|
||
// precaution, not a bug fix — it keeps the rule to the case the capture
|
||
// actually covers, an instance whose template is also present.
|
||
if el.kind & 0x4 != 0
|
||
&& build
|
||
.elements
|
||
.iter()
|
||
.any(|o| o.kind & 0x4 == 0 && o.name == el.name)
|
||
{
|
||
continue;
|
||
}
|
||
// 🔴 `at` poses LEAVES ONLY, never the top-level elements.
|
||
//
|
||
// Posing everything at one global time was tried and is wrong: a
|
||
// top-level group's final keyframes are its **exit ramp** — the fade-out
|
||
// played when the screen leaves — and `rest()` deliberately stops at the
|
||
// last *hold* keyframe before it. Posing the title at t=358 walked every
|
||
// parent into its exit and drove the render's disagreement with the
|
||
// capture from 10.92 to 61.74. The leaf is the thing still animating at
|
||
// that instant, and it runs on its own timeline
|
||
// (`docs/re/structures/ui-leaf-vs-parent-alpha.md`).
|
||
let Some(kf) = (match opts.at {
|
||
Some(t) => el.pose_at(t),
|
||
None => el.rest().cloned(),
|
||
}) else {
|
||
continue;
|
||
};
|
||
let kf = &kf;
|
||
// An untextured primitive: a solid quad of the keyframe's `fade` colour,
|
||
// sized by the declared pivot. `kind & 0x10` marks these exactly — see
|
||
// `docs/re/structures/ui-prm-primitives.md`. They are the screen's
|
||
// fade-to-black, dim-behind-a-menu and flash layers.
|
||
if el.kind & 0x10 != 0 && el.sprite.is_none() {
|
||
if !opts.include_primitives {
|
||
continue;
|
||
}
|
||
if fill_quad(&mut canvas, w, h, kf, el.pivot_x, el.pivot_y) {
|
||
drawn.push(el.index);
|
||
}
|
||
continue;
|
||
}
|
||
let Some(sprite) = el.sprite.as_ref() else {
|
||
// Report it, do not just skip it. This arm used to `continue`
|
||
// silently while the two arms below recorded into `missing`, so when
|
||
// a name-decoding defect left every menu screen's background
|
||
// unresolved, `screen render` still said "sprites that did not
|
||
// resolve: none". A diagnostic with a hole in it is worse than none.
|
||
// See docs/re/structures/ratc-child-names.md.
|
||
missing.push(format!("{} (element declares no resolvable sprite)", el.name));
|
||
continue;
|
||
};
|
||
let Some(&(off, size)) = build.sprites.get(sprite) else {
|
||
missing.push(sprite.clone());
|
||
continue;
|
||
};
|
||
let Some(img) = t8ad::parse(&bundle[off..off + size]) else {
|
||
missing.push(sprite.clone());
|
||
continue;
|
||
};
|
||
// A nested `.rat` leaf sometimes carries the geometry while the parent
|
||
// carries none — the title's light sweeps are the case: the parent sits
|
||
// fixed at (441,270) scale 100 %, and the leaf holds the 600 %/800 %
|
||
// scale, the +30°/−45° rotation and the whole sweep. Drawing the parent
|
||
// put the sprite upright in the middle of the screen.
|
||
//
|
||
// ⚠️ NOT a blanket rule. A button's base record has a leaf that
|
||
// DUPLICATES it, and there the parent wins
|
||
// (`docs/re/structures/ui-button-focus-record.md`). The discriminator is
|
||
// which record actually carries geometry, so the leaf is used only when
|
||
// its pose genuinely differs — see `ui-leaf-vs-parent-alpha.md`.
|
||
let leaf = build.records.get(&el.name).and_then(|&(lo, ls)| {
|
||
if lo + ls > bundle.len() {
|
||
return None;
|
||
}
|
||
let lb = parse_build(&bundle[lo..lo + ls])?;
|
||
let pose = |e: &Element| match opts.at {
|
||
Some(t) => e.pose_at(t),
|
||
None => e.rest().cloned(),
|
||
};
|
||
let differs = lb.elements.iter().any(|le| {
|
||
pose(le).map_or(false, |lk| {
|
||
lk.rotation_deg != 0 || lk.scale_x != kf.scale_x || lk.scale_y != kf.scale_y
|
||
})
|
||
});
|
||
if differs { Some(lb) } else { None }
|
||
});
|
||
if let Some(lb) = leaf {
|
||
let mut any = false;
|
||
for le in &lb.elements {
|
||
let lk = match opts.at {
|
||
Some(t) => le.pose_at(t),
|
||
None => le.rest().cloned(),
|
||
};
|
||
let Some(lk) = lk else { continue };
|
||
// A leaf element resolves no sprite of its own: sprite names are
|
||
// resolved against the bundle a build was parsed from, and a leaf
|
||
// is parsed from its own slice. Its NAME is the sprite name, and
|
||
// the sprite itself lives in the PARENT bundle's table.
|
||
let lsp = le.sprite.clone().unwrap_or_else(|| le.name.clone());
|
||
let Some(&(so, ss)) = build.sprites.get(&lsp) else { continue };
|
||
let Some(limg) = t8ad::parse(&bundle[so..so + ss]) else { continue };
|
||
// 🔴 Only count it as drawn if it CAN draw. `blit` returns early
|
||
// on a zero scale — "collapsed to nothing", not "unset" — so
|
||
// setting the flag unconditionally would let a scale-0 leaf
|
||
// suppress its parent and blank the element outright.
|
||
// `pgloading_loop5`'s leaf is scale (0,0), and scale-0 is one of
|
||
// the failures this corpus is already named for.
|
||
if lk.scale_x == 0 || lk.scale_y == 0 {
|
||
continue;
|
||
}
|
||
blit(&mut canvas, w, h, &limg, &lk, le.pivot_x, le.pivot_y);
|
||
any = true;
|
||
}
|
||
if any {
|
||
drawn.push(el.index);
|
||
continue;
|
||
}
|
||
}
|
||
blit(&mut canvas, w, h, &img, kf, el.pivot_x, el.pivot_y);
|
||
drawn.push(el.index);
|
||
}
|
||
ComposedScreen {
|
||
width: w,
|
||
height: h,
|
||
rgba: canvas,
|
||
drawn,
|
||
missing,
|
||
}
|
||
}
|
||
|
||
/// Alpha-blend an untextured primitive: a solid rectangle of the keyframe's
|
||
/// `fade` colour, `pivot × 2` in size, placed and scaled exactly as a sprite is.
|
||
///
|
||
/// Returns whether anything was drawn — a primitive resting at alpha 0 (189 of
|
||
/// the 369 on the disc) contributes nothing and should not be counted as drawn.
|
||
///
|
||
/// The size comes from the pivot because there is no texture to take it from,
|
||
/// and a `.t32` element's pivot is exactly half its decoded sprite. 361 of the
|
||
/// 369 primitives are `pivot × 2 == 1280×720`, the design space.
|
||
fn fill_quad(
|
||
canvas: &mut [u8],
|
||
cw: u32,
|
||
ch: u32,
|
||
kf: &Keyframe,
|
||
pivot_x: u32,
|
||
pivot_y: u32,
|
||
) -> bool {
|
||
let (a, r, g, b) = (
|
||
(kf.fade >> 24) & 0xff,
|
||
(kf.fade >> 16) & 0xff,
|
||
(kf.fade >> 8) & 0xff,
|
||
kf.fade & 0xff,
|
||
);
|
||
if a == 0 {
|
||
return false;
|
||
}
|
||
let (sw, sh) = (pivot_x * 2, pivot_y * 2);
|
||
if sw == 0 || sh == 0 {
|
||
return false;
|
||
}
|
||
// `scale = 0` is COLLAPSED TO NOTHING, not "unset" — see `blit`.
|
||
let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y);
|
||
if sx_pct == 0 || sy_pct == 0 {
|
||
return false;
|
||
}
|
||
let dw = (sw * sx_pct / 100).max(1);
|
||
let dh = (sh * sy_pct / 100).max(1);
|
||
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
|
||
let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100;
|
||
for row in 0..dh {
|
||
let ty = oy + row as i32;
|
||
if ty < 0 {
|
||
continue;
|
||
}
|
||
if ty >= ch as i32 {
|
||
break;
|
||
}
|
||
for col in 0..dw {
|
||
let tx = ox + col as i32;
|
||
if tx < 0 {
|
||
continue;
|
||
}
|
||
if tx >= cw as i32 {
|
||
break;
|
||
}
|
||
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
|
||
for (k, sc) in [r, g, b].into_iter().enumerate() {
|
||
let dc = canvas[di + k] as u32;
|
||
canvas[di + k] = ((sc * a + dc * (255 - a)) / 255) as u8;
|
||
}
|
||
canvas[di + 3] = 255;
|
||
}
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Alpha-blend one sprite onto the canvas at a keyframe's placement, with tint
|
||
/// and scale. Placements may be negative or run off the edge, so both axes clip.
|
||
///
|
||
/// **A keyframe's X/Y is the element's top-left at 1:1, and scale grows it about
|
||
/// the declared pivot, not about that corner.** Measured against a framebuffer
|
||
/// capture of the running title screen: `ptbase2.t32` is a 640×360 background
|
||
/// placed at (320,180) with `scale = 200%` and pivot (320,180). Growing from the
|
||
/// corner puts it at 320..1600 × 180..900 — a quarter-screen slab. Anchoring the
|
||
/// pivot gives top-left `(320,180) − (320,180)·(2−1) = (0,0)` and a 1280×720
|
||
/// rect, which is what the game draws. At 100 % the pivot cancels, which is why
|
||
/// every unscaled element — and so every ruler this format was checked against —
|
||
/// was unaffected. See `docs/re/structures/ui-rat-layout.md`.
|
||
fn blit(
|
||
canvas: &mut [u8],
|
||
cw: u32,
|
||
ch: u32,
|
||
img: &t8ad::T8adImage,
|
||
kf: &Keyframe,
|
||
pivot_x: u32,
|
||
pivot_y: u32,
|
||
) {
|
||
let (sw, sh) = (img.width, img.height);
|
||
if sw == 0 || sh == 0 {
|
||
return;
|
||
}
|
||
// `scale = 0` means COLLAPSED TO NOTHING, not "unset". This used to coerce
|
||
// 0 → 100 %, which drew a fully-collapsed element at full size. The disc
|
||
// settles it: of 15 493 elements with a keyframe group, **2 166 have at
|
||
// least one zero-scale keyframe and not one has zero on every keyframe** —
|
||
// and 1 762 of them grow back out of it (`ptlogo_eff3.t32` runs 0 % → 200 %).
|
||
// An "unset" marker that no element ever uses throughout is not a marker.
|
||
let (sx_pct, sy_pct) = (kf.scale_x, kf.scale_y);
|
||
if sx_pct == 0 || sy_pct == 0 {
|
||
return;
|
||
}
|
||
let dw = (sw * sx_pct / 100).max(1);
|
||
let dh = (sh * sy_pct / 100).max(1);
|
||
// Keep the pivot point fixed as the element scales.
|
||
let ox = kf.x - (pivot_x as i32 * (sx_pct as i32 - 100)) / 100;
|
||
let oy = kf.y - (pivot_y as i32 * (sy_pct as i32 - 100)) / 100;
|
||
// The pivot's ABSOLUTE position is invariant under scale, which is the whole
|
||
// point of the two lines above: at 100 % `ox = kf.x` so the pivot sits at
|
||
// `kf.x + pivot_x`; at 200 % `ox = kf.x - pivot_x` and the pivot sits at
|
||
// `ox + 2·pivot_x`, the same place. So rotation turns about it.
|
||
let (pax, pay) = (kf.x + pivot_x as i32, kf.y + pivot_y as i32);
|
||
// Two modulate colours multiply into one: `tint` (RGBA, and `0xffffffff` on
|
||
// essentially every keyframe seen) and `fade` (**ARGB** — the high byte is
|
||
// the alpha that ramps, the low 24 bits a colour multiply that is `0xffffff`
|
||
// on 5 276 of the disc's 5 453 resting keyframes). The byte order is not a
|
||
// guess: it is the high byte that walks 0x00 → 0x80 → 0xc0 → 0xe0 → 0xff
|
||
// across a fade-in while the low three stay `ffffff`.
|
||
let (fa, fr, fg, fb) = (
|
||
(kf.fade >> 24) & 0xff,
|
||
(kf.fade >> 16) & 0xff,
|
||
(kf.fade >> 8) & 0xff,
|
||
kf.fade & 0xff,
|
||
);
|
||
let (tr, tg, tb, ta) = (
|
||
((kf.tint >> 24) & 0xff) * fr / 255,
|
||
((kf.tint >> 16) & 0xff) * fg / 255,
|
||
((kf.tint >> 8) & 0xff) * fb / 255,
|
||
(kf.tint & 0xff) * fa / 255,
|
||
);
|
||
// ---- rotated path -------------------------------------------------------
|
||
// `+12` is a screen-plane rotation in DEGREES, clockwise-positive with Y
|
||
// down (`docs/re/structures/ui-keyframe-rotation.md`). The game submits
|
||
// rotated quads for it; this used to draw them axis-aligned, which put the
|
||
// title's two light sweeps upright instead of at +30° / −45° and left at
|
||
// least two thirds of that screen's disagreement with the capture
|
||
// (`title-residual-tone-vs-geometry.md`).
|
||
//
|
||
// Zero rotation keeps the original forward-mapped path byte for byte, so
|
||
// the screens that do not rotate cannot regress. A rotated element is drawn
|
||
// by INVERSE mapping instead: forward-mapping a rotation leaves gaps.
|
||
let rot = ((kf.rotation_deg % 360) + 360) % 360;
|
||
if rot != 0 {
|
||
let th = (rot as f64).to_radians();
|
||
let (cs, sn) = (th.cos(), th.sin());
|
||
// Axis-aligned bounds of the rotated destination rect.
|
||
let corners = [
|
||
(ox, oy),
|
||
(ox + dw as i32, oy),
|
||
(ox + dw as i32, oy + dh as i32),
|
||
(ox, oy + dh as i32),
|
||
];
|
||
let (mut x0, mut y0, mut x1, mut y1) = (i32::MAX, i32::MAX, i32::MIN, i32::MIN);
|
||
for (cx, cy) in corners {
|
||
let (rx, ry) = ((cx - pax) as f64, (cy - pay) as f64);
|
||
let px = pax as f64 + rx * cs - ry * sn;
|
||
let py = pay as f64 + rx * sn + ry * cs;
|
||
x0 = x0.min(px.floor() as i32);
|
||
y0 = y0.min(py.floor() as i32);
|
||
x1 = x1.max(px.ceil() as i32);
|
||
y1 = y1.max(py.ceil() as i32);
|
||
}
|
||
for ty in y0.max(0)..=y1.min(ch as i32 - 1) {
|
||
for tx in x0.max(0)..=x1.min(cw as i32 - 1) {
|
||
// Rotate the destination pixel BACK to find its source pixel.
|
||
let (rx, ry) = ((tx - pax) as f64 + 0.5, (ty - pay) as f64 + 0.5);
|
||
let ux = rx * cs + ry * sn;
|
||
let uy = -rx * sn + ry * cs;
|
||
let dx = ux + (pax - ox) as f64;
|
||
let dy = uy + (pay - oy) as f64;
|
||
if dx < 0.0 || dy < 0.0 || dx >= dw as f64 || dy >= dh as f64 {
|
||
continue;
|
||
}
|
||
let sxi = ((dx as u32) * sw / dw).min(sw - 1);
|
||
let syi = ((dy as u32) * sh / dh).min(sh - 1);
|
||
let si = ((syi * sw + sxi) * 4) as usize;
|
||
if si + 3 >= img.rgba.len() {
|
||
continue;
|
||
}
|
||
let sr = img.rgba[si] as u32 * tr / 255;
|
||
let sg = img.rgba[si + 1] as u32 * tg / 255;
|
||
let sb = img.rgba[si + 2] as u32 * tb / 255;
|
||
let sa = img.rgba[si + 3] as u32 * ta / 255;
|
||
if sa == 0 {
|
||
continue;
|
||
}
|
||
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
|
||
for (k, sc) in [sr, sg, sb].into_iter().enumerate() {
|
||
let dc = canvas[di + k] as u32;
|
||
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
|
||
}
|
||
canvas[di + 3] = 255;
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
// ---- unrotated path (unchanged) -----------------------------------------
|
||
for row in 0..dh {
|
||
let ty = oy + row as i32;
|
||
if ty < 0 {
|
||
continue;
|
||
}
|
||
if ty >= ch as i32 {
|
||
break;
|
||
}
|
||
let syi = (row * sh / dh).min(sh - 1);
|
||
for col in 0..dw {
|
||
let tx = ox + col as i32;
|
||
if tx < 0 {
|
||
continue;
|
||
}
|
||
if tx >= cw as i32 {
|
||
break;
|
||
}
|
||
let sxi = (col * sw / dw).min(sw - 1);
|
||
let si = ((syi * sw + sxi) * 4) as usize;
|
||
if si + 3 >= img.rgba.len() {
|
||
continue;
|
||
}
|
||
let sr = img.rgba[si] as u32 * tr / 255;
|
||
let sg = img.rgba[si + 1] as u32 * tg / 255;
|
||
let sb = img.rgba[si + 2] as u32 * tb / 255;
|
||
let sa = img.rgba[si + 3] as u32 * ta / 255;
|
||
if sa == 0 {
|
||
continue;
|
||
}
|
||
let di = ((ty as u32 * cw + tx as u32) * 4) as usize;
|
||
// Straight alpha-over. `T8aD +0x04` bit 0x02 was tested as an
|
||
// ADDITIVE selector and REFUTED — it moved every metric against the
|
||
// title capture the wrong way (see the doc comment on
|
||
// `T8adImage::flags`), so the bit is carried but not acted on.
|
||
for (k, sc) in [sr, sg, sb].into_iter().enumerate() {
|
||
let dc = canvas[di + k] as u32;
|
||
canvas[di + k] = ((sc * sa + dc * (255 - sa)) / 255) as u8;
|
||
}
|
||
canvas[di + 3] = 255;
|
||
}
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// A solid opaque rectangle sprite, for exercising `blit` geometry.
|
||
fn solid(w: u32, h: u32) -> t8ad::T8adImage {
|
||
t8ad::T8adImage { width: w, height: h, rgba: vec![255u8; (w * h * 4) as usize],
|
||
flags: 0 }
|
||
}
|
||
fn kf_at(x: i32, y: i32, rot: i32) -> Keyframe {
|
||
Keyframe { fade: 0xff_ff_ff_ff, rotation_deg: rot, unknown_4: 0, unknown_8: 0,
|
||
scale_x: 100, scale_y: 100, tint: 0xffff_ffff, x, y, time: Some(0) }
|
||
}
|
||
fn draw(img: &t8ad::T8adImage, kf: &Keyframe, px: u32, py: u32) -> Vec<u8> {
|
||
let mut c = vec![0u8; 64 * 64 * 4];
|
||
blit(&mut c, 64, 64, img, kf, px, py);
|
||
c
|
||
}
|
||
fn covered(c: &[u8]) -> Vec<(i32, i32)> {
|
||
let mut v = Vec::new();
|
||
for y in 0..64 { for x in 0..64 {
|
||
if c[((y * 64 + x) * 4 + 3) as usize] != 0 { v.push((x as i32, y as i32)); }
|
||
}}
|
||
v
|
||
}
|
||
|
||
/// 🔴 CONTROL for the rotated path. An estimator that is wrong on a known
|
||
/// angle cannot be trusted on an unknown one, so the rotated blit is pinned
|
||
/// against angles whose answer is arithmetic rather than measured.
|
||
#[test]
|
||
fn rotation_control_known_angles() {
|
||
let img = solid(10, 4);
|
||
// pivot at the sprite's centre, so rotation turns in place
|
||
let (px, py) = (5u32, 2u32);
|
||
let base = draw(&img, &kf_at(20, 30, 0), px, py);
|
||
|
||
// 0° and 360° must be identical to the unrotated path, byte for byte:
|
||
// the fast path must be exactly the old behaviour.
|
||
assert_eq!(base, draw(&img, &kf_at(20, 30, 360), px, py),
|
||
"360 degrees must equal the unrotated path exactly");
|
||
|
||
// 90° must turn a 10x4 into a 4x10 about the same centre.
|
||
let r90 = covered(&draw(&img, &kf_at(20, 30, 90), px, py));
|
||
let b = covered(&base);
|
||
let bw = b.iter().map(|p| p.0).max().unwrap() - b.iter().map(|p| p.0).min().unwrap();
|
||
let bh = b.iter().map(|p| p.1).max().unwrap() - b.iter().map(|p| p.1).min().unwrap();
|
||
let rw = r90.iter().map(|p| p.0).max().unwrap() - r90.iter().map(|p| p.0).min().unwrap();
|
||
let rh = r90.iter().map(|p| p.1).max().unwrap() - r90.iter().map(|p| p.1).min().unwrap();
|
||
assert_eq!((bw, bh), (9, 3), "unrotated extent");
|
||
assert_eq!((rw, rh), (3, 9), "90 degrees must swap the extents");
|
||
|
||
// The covered area must be conserved to a few percent -- a rotation that
|
||
// loses or invents pixels is the forward-mapping bug this path avoids.
|
||
let (a0, a90) = (b.len() as f64, r90.len() as f64);
|
||
assert!((a0 - a90).abs() / a0 < 0.15,
|
||
"area changed too much under rotation: {a0} -> {a90}");
|
||
|
||
// And the centroid must stay on the pivot.
|
||
let cen = |v: &Vec<(i32, i32)>| {
|
||
let n = v.len() as f64;
|
||
(v.iter().map(|p| p.0 as f64).sum::<f64>() / n,
|
||
v.iter().map(|p| p.1 as f64).sum::<f64>() / n)
|
||
};
|
||
let (c0, c9) = (cen(&b), cen(&r90));
|
||
assert!((c0.0 - c9.0).abs() < 1.0 && (c0.1 - c9.1).abs() < 1.0,
|
||
"rotation moved the centroid: {c0:?} -> {c9:?}");
|
||
}
|
||
|
||
/// A synthetic build bundle: RATC magic, entry count at 0x14, a declaration
|
||
/// table at 0x20, then a placement region.
|
||
fn synth_build(decls: &[(&str, u32, u32, u32, u32)], groups: &[(usize, Vec<Keyframe>)]) -> Vec<u8> {
|
||
let count = decls.len();
|
||
let mut b = vec![0u8; DECL_TABLE_AT + count * DECL_ENTRY];
|
||
b[0..4].copy_from_slice(b"RATC");
|
||
b[0x14..0x18].copy_from_slice(&(count as u32).to_be_bytes());
|
||
for (i, &(name, parent, kind, px, py)) in decls.iter().enumerate() {
|
||
let at = DECL_TABLE_AT + i * DECL_ENTRY;
|
||
b[at..at + name.len()].copy_from_slice(name.as_bytes());
|
||
b[at + 32..at + 36].copy_from_slice(&parent.to_be_bytes());
|
||
b[at + 40..at + 44].copy_from_slice(&kind.to_be_bytes());
|
||
b[at + 48..at + 52].copy_from_slice(&px.to_be_bytes());
|
||
b[at + 52..at + 56].copy_from_slice(&py.to_be_bytes());
|
||
}
|
||
for (idx, frames) in groups {
|
||
let start = b.len();
|
||
b.extend_from_slice(&(*idx as u32).to_be_bytes());
|
||
b.extend_from_slice(&(frames.len() as u32).to_be_bytes());
|
||
b.resize(start + 12, 0); // header + one lead-in word
|
||
for kf in frames {
|
||
b.extend_from_slice(&kf.fade.to_be_bytes());
|
||
b.extend_from_slice(&[0u8; 12]);
|
||
b.extend_from_slice(&kf.scale_x.to_be_bytes());
|
||
b.extend_from_slice(&kf.scale_y.to_be_bytes());
|
||
b.extend_from_slice(&kf.tint.to_be_bytes());
|
||
b.extend_from_slice(&kf.x.to_be_bytes());
|
||
b.extend_from_slice(&kf.y.to_be_bytes());
|
||
b.extend_from_slice(&kf.time.unwrap_or(0).to_be_bytes());
|
||
}
|
||
// The next group's header overlaps the last block's time slot.
|
||
b.truncate(b.len() - 4);
|
||
}
|
||
b
|
||
}
|
||
|
||
fn kf(x: i32, y: i32, time: u32) -> Keyframe {
|
||
Keyframe {
|
||
fade: 0xffff_ffff,
|
||
rotation_deg: 0,
|
||
unknown_4: 0,
|
||
unknown_8: 0,
|
||
scale_x: 100,
|
||
scale_y: 100,
|
||
tint: 0xffff_ffff,
|
||
x,
|
||
y,
|
||
time: Some(time),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn reads_declaration_table_in_draw_order() {
|
||
let b = synth_build(
|
||
&[
|
||
("pgpeff02.t32", u32::MAX, 0, 204, 60),
|
||
("pgpeff02a.t32", 0, 1, 100, 40),
|
||
("pgpbtn00.rat", u32::MAX, 0x3002, 64, 16),
|
||
],
|
||
&[],
|
||
);
|
||
let els = parse_decls(&b).unwrap();
|
||
assert_eq!(els.len(), 3);
|
||
assert_eq!(els[0].name, "pgpeff02.t32");
|
||
assert_eq!(els[0].parent, None);
|
||
// `+32` is a parent element index: the `…a` variant names its base.
|
||
assert_eq!(els[1].parent, Some(0));
|
||
assert_eq!(els[1].kind, 1);
|
||
assert_eq!(els[2].kind, 0x3002);
|
||
assert_eq!((els[0].pivot_x, els[0].pivot_y), (204, 60));
|
||
}
|
||
|
||
#[test]
|
||
fn placement_x_and_y_are_signed() {
|
||
// The Arsenal window animates in from X = -516; read as u32 it would be
|
||
// ~4.29 billion and the element would be discarded.
|
||
let b = synth_build(
|
||
&[("aswindow.t32", u32::MAX, 0, 0, 0)],
|
||
&[(0, vec![kf(-516, 40, 0), kf(120, 40, 30)])],
|
||
);
|
||
let mut els = parse_decls(&b).unwrap();
|
||
parse_placements(&b, &mut els);
|
||
assert_eq!(els[0].keyframes.len(), 2);
|
||
assert_eq!(els[0].keyframes[0].x, -516);
|
||
assert_eq!(els[0].keyframes[1].x, 120);
|
||
}
|
||
|
||
#[test]
|
||
fn rest_is_the_max_dwell_keyframe_not_the_first_or_last() {
|
||
// in (t=0) → hold (t=10..90) → out (t=100): the resting pose is the
|
||
// middle frame, which both "first" and "last" would get wrong.
|
||
let el = Element {
|
||
index: 0,
|
||
name: "x.t32".into(),
|
||
sprite: None,
|
||
parent: None,
|
||
kind: 0,
|
||
pivot_x: 0,
|
||
pivot_y: 0,
|
||
keyframes: vec![kf(-500, 10, 0), kf(226, 268, 10), kf(900, 268, 90)],
|
||
focus_link: None,
|
||
focused: false,
|
||
animated: false,
|
||
};
|
||
let r = el.rest().unwrap();
|
||
assert_eq!((r.x, r.y), (226, 268));
|
||
}
|
||
|
||
#[test]
|
||
fn single_keyframe_is_its_own_rest() {
|
||
let el = Element {
|
||
index: 0,
|
||
name: "x.t32".into(),
|
||
sprite: None,
|
||
parent: None,
|
||
kind: 0,
|
||
pivot_x: 0,
|
||
pivot_y: 0,
|
||
keyframes: vec![kf(546, 288, 0)],
|
||
focus_link: None,
|
||
focused: false,
|
||
animated: false,
|
||
};
|
||
assert_eq!(el.rest().map(|k| (k.x, k.y)), Some((546, 288)));
|
||
}
|
||
|
||
#[test]
|
||
fn rejects_a_bundle_with_no_declaration_table() {
|
||
let mut b = vec![0u8; 0x400];
|
||
b[0..4].copy_from_slice(b"RATC");
|
||
b[0x14..0x18].copy_from_slice(&999_999u32.to_be_bytes()); // cannot fit
|
||
assert!(parse_decls(&b).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn opt_link_reads_the_focus_record() {
|
||
let mut r = vec![0u8; 0x60];
|
||
r[0..4].copy_from_slice(b"RATC");
|
||
r[0x20..0x20 + 12].copy_from_slice(b"pgpbtn00.t32");
|
||
r.extend_from_slice(b"opt ");
|
||
r.extend_from_slice(&13u32.to_be_bytes());
|
||
r.extend_from_slice(b"pgpbtn00f.rat");
|
||
assert_eq!(record_sprite(&r).as_deref(), Some("pgpbtn00.t32"));
|
||
assert_eq!(opt_link(&r).as_deref(), Some("pgpbtn00f.rat"));
|
||
}
|
||
|
||
#[test]
|
||
fn scaling_grows_about_the_pivot_not_the_corner() {
|
||
// Measured against the real title screen: `ptbase2.t32` is 640x360 with
|
||
// pivot (320,180), placed at (320,180) with scale 200%. The game draws
|
||
// it as the full-screen background — top-left (0,0), 1280x720. Growing
|
||
// from the keyframe corner instead paints a quarter-screen slab and
|
||
// leaves the top-left quadrant bare. See
|
||
// `docs/re/structures/ui-rat-layout.md`.
|
||
let img = t8ad::T8adImage {
|
||
flags: 0,
|
||
width: 640,
|
||
height: 360,
|
||
rgba: vec![255u8; 640 * 360 * 4],
|
||
};
|
||
let k = Keyframe {
|
||
fade: 0xffff_ffff,
|
||
rotation_deg: 0,
|
||
unknown_4: 0,
|
||
unknown_8: 0,
|
||
scale_x: 200,
|
||
scale_y: 200,
|
||
tint: 0xffff_ffff,
|
||
x: 320,
|
||
y: 180,
|
||
time: None,
|
||
};
|
||
let (w, h) = (1280u32, 720u32);
|
||
let mut canvas = vec![0u8; (w * h * 4) as usize];
|
||
blit(&mut canvas, w, h, &img, &k, 320, 180);
|
||
// Every pixel is covered; the four corners are the cheap witnesses.
|
||
for (x, y) in [(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1)] {
|
||
let i = ((y * w + x) * 4) as usize;
|
||
assert_eq!(
|
||
canvas[i], 255,
|
||
"pixel ({x},{y}) not covered — the background is not full-screen"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_hundred_percent_element_lands_on_its_keyframe_corner() {
|
||
// The other half of the rule, and the reason the corner reading survived
|
||
// this long: at 100% the pivot cancels, so no unscaled element moves.
|
||
// `ptcopyright.t32` is 694x20 at (293,655), and the capture's glyph run
|
||
// starts at x = 295 — inside that rect, not offset by a pivot.
|
||
let img = t8ad::T8adImage {
|
||
flags: 0,
|
||
width: 694,
|
||
height: 20,
|
||
rgba: vec![255u8; 694 * 20 * 4],
|
||
};
|
||
let k = kf(293, 655, 0);
|
||
let (w, h) = (1280u32, 720u32);
|
||
let mut canvas = vec![0u8; (w * h * 4) as usize];
|
||
blit(&mut canvas, w, h, &img, &k, 309, 10);
|
||
let at = |x: u32, y: u32| canvas[((y * w + x) * 4) as usize];
|
||
assert_eq!(at(293, 655), 255, "top-left corner is the keyframe");
|
||
assert_eq!(at(986, 674), 255, "bottom-right corner is corner + size");
|
||
assert_eq!(at(292, 655), 0, "nothing left of the keyframe X");
|
||
assert_eq!(at(293, 654), 0, "nothing above the keyframe Y");
|
||
}
|
||
|
||
#[test]
|
||
fn fallback_scans_records_when_the_table_is_unusable() {
|
||
// The old `.rat`-only reading, kept as a recovery path.
|
||
let mut r = vec![0u8; 0x58];
|
||
r[0..4].copy_from_slice(b"RATC");
|
||
r[0x18..0x1c].copy_from_slice(&1280u32.to_be_bytes());
|
||
r[0x1c..0x20].copy_from_slice(&720u32.to_be_bytes());
|
||
r[0x20..0x20 + 12].copy_from_slice(b"pgpbtn00.t32");
|
||
for v in [100u32, 100, 0xffff_ffff, 226, 268] {
|
||
r.extend_from_slice(&v.to_be_bytes());
|
||
}
|
||
assert_eq!(scan_placement_block(&r), Some((0xffff_ffff, 226, 268)));
|
||
}
|
||
}
|