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 d67c1da467 formats/cli: the bundle's other orderings, and a guard that neither is a paint order
The title screen needs an order that puts element 13 (`ptbase2.t32`, the
full-screen background) behind elements 0-5 (the wordmarks) — the capture shows
the wordmarks on top, so the declaration table is not it. Two other orderings
the bundle carries were the cheap candidates, and both are now dead:

* the **placement region** stores a keyframe group per element with an explicit
  element index, so it could be a second ordering. It is not — it equals the
  declaration order on every build on the disc. `UiBuild::placement_order`
  exposes it and `placement_region_order_is_never_a_second_ordering` pins it, so
  the refutation stays checkable instead of remembered.
* the **RATC child order** is the declaration order with the `.prm` elements
  absent — strictly less information, and no place to put the background other
  than where the table already puts it.

`screen info --geometry` prints both, plus each element's decoded sprite size
beside `pivot*2` and every keyframe's scale/position/time — the numbers a
placement hypothesis has to be tested against, and how the pivot/scale rule in
the previous commit was found.

`title_background_is_full_screen` pins that rule against the disc rather than a
synthetic sprite. `scaled_elements_are_a_small_and_mostly_undiscriminating_minority`
reports the scope honestly: 865 of 5 130 resting placements are scaled at all,
and only 213 of those could tell "about the pivot" from "about the sprite
centre" — which the capture did *not* settle, because `ptbase2`'s pivot is its
centre. It also counts how far `pivot*2` is from the decoded size disc-wide
(2 521 agree, 1 884 are off by more than 16 px), which demotes the "pivot is
exactly half the texture" result to a property of the tutorial bundle.
2026-08-18 16:32:40 +00:00

839 lines
31 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 u32 0
/// +8 u32 0
/// +12 u32 0
/// +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
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Keyframe {
/// The fade colour, ARGB. Its alpha is what ramps an element in.
pub fade: u32,
/// 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.
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, so the resting pose is
/// neither the first nor the last frame: it is the one that **dwells
/// longest** — the largest gap to the next keyframe's time. A single
/// keyframe is its own rest position.
pub fn rest(&self) -> Option<&Keyframe> {
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)
}
}
}
}
/// 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"))
})
}
/// 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: lname.ends_with("f.rat") || lname.ends_with("f.t32"),
animated: lname.contains("loop"),
});
}
Some(elements)
}
/// 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> {
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),
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.
time: (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 els = fallback_elements(bundle, &records);
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,
scale_x: 100,
scale_y: 100,
tint,
x,
y,
time: Some(0),
}],
focus_link: opt_link(rec),
focused: lname.ends_with("f.rat"),
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],
}
impl Default for ComposeOptions {
fn default() -> Self {
Self {
include_focus: false,
include_animated: false,
backdrop: [14, 14, 20, 255],
}
}
}
/// 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.
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();
for el in &build.elements {
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;
}
let Some(kf) = el.rest() else { 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 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;
}
let sx_pct = if kf.scale_x == 0 { 100 } else { kf.scale_x };
let sy_pct = if kf.scale_y == 0 { 100 } else { kf.scale_y };
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;
let (tr, tg, tb, ta) = (
(kf.tint >> 24) & 0xff,
(kf.tint >> 16) & 0xff,
(kf.tint >> 8) & 0xff,
kf.tint & 0xff,
);
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;
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,
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 {
width: 640,
height: 360,
rgba: vec![255u8; 640 * 360 * 4],
};
let k = Keyframe {
fade: 0xffff_ffff,
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 {
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)));
}
}