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
MechaCat02 dc96bace6f formats: consolidate ui_layout onto the declaration table; add a savegame parser
Two independent lines landed a `.rat` reading and neither was the whole
picture, so this merges them into one module and fixes what the merge exposed.

ui_layout — the screen is the BUNDLE, not the set of .rat records
------------------------------------------------------------------
`feat/ui-layout-preview` parsed `.rat` records; the autopilot stack documented
the RATC header and probed it in `examples/screen_layout.rs` but never landed a
library module. The `.rat`-only reading structurally cannot see an element that
has no record -- the `eff*` frame corners, the `deli*` dividers, `msg` -- which
is exactly what the committed real-vs-rebuilt capture shows missing. Rebuilt
around the header:

  * element declaration table at 0x20 (60-byte entries: name, parent index at
    +32, kind flags, pivot) = the back-to-front draw list;
  * the placement region after it = per-element keyframe groups.

Verified against the disc, each against a fact the docs state independently:
`pgpeff02a` -> parent 3 = `pgpeff02`; `pgp_ttrl_btn10` rests at (546,288); the
pause buttons sit at 268/337/407/478, the documented 70 px pitch; the Arsenal
carries X = -516. The tutorial PAUSE menu now composites 11/11 elements and
matches the real screen more closely than the earlier rebuild did.

Three defects found while validating, none of which any test would have caught:

  * the keyframe block is 40 bytes with X/Y/time at +28/+32/+36 and an
    alpha-ramping ARGB at +0 -- the fade, previously unread;
  * a group's data stops 4 bytes short of its last block's time slot, so that
    word is the NEXT group's element index. Reading it produced times like
    1869640736 and silently corrupted the max-dwell pick. Last-frame time is
    now `None`;
  * the `.rat` sprite-name field is not 16 bytes. Capping it there truncated
    `pgp_ttrl_title.t32` to `pgp_ttrl_title.t`, which resolved against nothing
    and dropped 4 of 11 tutorial elements from the composite.

Max-dwell also needed a tie-break: on equal gaps take the LATER frame, or
`pgpmsg` reports the y=645 fly-through instead of the y=605 it settles at.

savegame -- a Rust port of tools/re-capture/savegame.py
------------------------------------------------------
GDHA container, zlib payload, chunk stream (GDAA / phase / GHAD 122 B / 16x20 B
SHAB / trailer). Every GHAD word carries its own confidence rather than the
block being presented as solved: 6 named, 2 recorded as REFUTED (+36, +56 were
tested as difficulty and as stage and are neither), 7 still unknown.

Tested against the three real saves committed under docs/re/captures -- no disc
and no emulator needed. The load-bearing assertion is the byte-identical
round-trip; the develop differential is asserted as a property (spending 4000 P
moves +24 and not its twin +28, steps the clear ratio, and moves exactly two
blob entries), and the header summary is checked to agree with the payload it
mirrors -- the trap that makes the Details panel a bad oracle.

CLI: `screen list|info|render` and `save info`, so both are checkable headlessly
in the same spirit as `mesh render`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 18:59:27 +02:00

737 lines
26 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,
}
/// 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]) {
let count = elements.len();
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;
pos = group_end;
}
}
/// 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) = match parse_decls(bundle) {
Some(mut els) => {
parse_placements(bundle, &mut els);
(els, false)
}
// 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 => (fallback_elements(bundle, &records), true),
};
// 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,
})
}
/// 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,
}
impl Default for ComposeOptions {
fn default() -> Self {
Self {
include_focus: false,
include_animated: 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.
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(&[14, 14, 20, 255]);
}
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);
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.
fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, kf: &Keyframe) {
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);
let (tr, tg, tb, ta) = (
(kf.tint >> 24) & 0xff,
(kf.tint >> 16) & 0xff,
(kf.tint >> 8) & 0xff,
kf.tint & 0xff,
);
for oy in 0..dh {
let ty = kf.y + oy as i32;
if ty < 0 {
continue;
}
if ty >= ch as i32 {
break;
}
let syi = (oy * sh / dh).min(sh - 1);
for ox in 0..dw {
let tx = kf.x + ox as i32;
if tx < 0 {
continue;
}
if tx >= cw as i32 {
break;
}
let sxi = (ox * 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 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)));
}
}