This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/crates/sylpheed-formats/src/ui_layout.rs
Sylpheed RE agent ba47bdebe8 re(ui): measure the paint-order hedge -- exact on 4 of 5, and bound the rest
`compose` claimed the derived paint order "reproduces both measured
orders up to ties". That sentence was never measured and was stale by
one: there are three measured orders, not two. examples/paint_order_audit.rs
checks it.

  main menu (entries 5, 8)      derived == measured   0 inverted pairs
  developer splash (11, 14)     derived == measured   0 inverted pairs
  title (entry 4)               DIFFERS               8, all same-key ties

So the claim holds and the exception is entirely ties -- but two of those
ties are total occlusions, not near-misses. The tied family is the five
ptlogo_back2eff glows (key 32899); back2eff5 is 1133x280 and FULLY
CONTAINS back2eff3 (82,824 px^2 = 100% of the smaller) and back2eff4
(152,047 px^2 = 100%). Derived paints it on top of two glows it entirely
covers; the game paints it underneath. A tie-break by declaration index
can therefore be wrong by a whole layer. The title itself is unaffected --
it has a measured order.

The port's actual exposure, per screen: title, main menu and developer
splash all use MEASURED orders; the publisher splash is derived but has
ZERO ties, so it is fully determined; EXTRAS is derived with 15 tied
pairs of which only 2 OVERLAP. Two element pairs on one screen is the
whole risk, and that is what HANDOFF now says -- not the raw 15, which
would have overstated it 7x.

Reach stated: this compares the derived order against orders measured
from the game, not an independent derivation, so where no measured order
exists only the tie exposure can be checked. Overlap uses pivot*2 as the
element size at its resting placement.

Stale comment in compose corrected. METHOD: a hedge in a code comment is
an unmeasured claim; and count the cases that can bite, not the ones that
match the pattern.
2026-08-29 02:22:01 +00:00

1356 lines
56 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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,
/// Keyframe time, or `None` for the group's **last** frame.
///
/// A group's data stops 4 bytes short of its final block's time slot — that
/// word is already the next group's element index. Reading it anyway is
/// where a stray `time = 1869640736` comes from, and it silently corrupts
/// the max-dwell pick in [`Element::rest`].
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.
pub fn rest(&self) -> Option<&Keyframe> {
if let Some(k) = self.rest_plateau() {
return Some(k);
}
match self.keyframes.len() {
0 => None,
1 => self.keyframes.first(),
n => {
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 {
continue; // the last frame carries no time
};
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)>,
/// 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)
}
/// 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> {
// Experiment gate, default off; see the `time` field below.
let shift_times = std::env::var("SYLPHEED_KF_TIME_SHIFT").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;
}
// Group header is (index, count) then one lead-in word; blocks follow.
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,
// Only a block wholly inside the group carries a time.
//
// ⚠️ Which block a time word BELONGS TO is under test — see
// `docs/re/ui-keyframe-time-unit.md`. Set `SYLPHEED_KF_TIME_SHIFT=1`
// to read `W[k-1]` as block `k`'s time ("the word is the time the
// NEXT pose is reached") instead of `W[k]`. Default is unchanged.
time: if shift_times {
(k >= 1).then(|| be32(bundle, blk - KEYFRAME + 36))
} else {
(blk + 40 <= group_end).then(|| be32(bundle, blk + 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,
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,
}
impl Default for ComposeOptions {
fn default() -> Self {
Self {
include_focus: false,
include_animated: false,
backdrop: [14, 14, 20, 255],
include_primitives: false,
}
}
}
/// 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 15), 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.
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))
.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
}
pub fn compose(
build: &UiBuild,
bundle: &[u8],
opts: ComposeOptions,
visible: Option<&[bool]>,
) -> 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> =
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;
}
let Some(kf) = el.rest() else { continue };
// 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 {
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;
};
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)·(21) = (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;
// 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,
);
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 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)));
}
}