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>
This commit is contained in:
MechaCat02
2026-08-17 18:59:27 +02:00
parent 6d5b13e395
commit dc96bace6f
6 changed files with 1717 additions and 160 deletions

View File

@@ -1,59 +1,177 @@
//! `.rat` UI-screen layout — reassemble a UI screen from its pak.
//! UI screen layout — reassemble a whole UI screen from one RATC bundle.
//!
//! A UI screen ships as one pak (`GP_TITLE`, `GP_PAUSE_MENU`, …). Inside it,
//! each large [RATC](crate::ratc) bundle is one *(context × language)* **build**
//! of the screen, holding its `<name>.t32` sprites and `<name>.rat` layout
//! records side by side. Each `.rat` record is itself a RATC-tagged blob that
//! places one sprite; this module parses those records and composites the
//! sprites back into the screen image.
//! 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.
//!
//! Format reverse-engineered in `docs/re/structures/ui-rat-layout.md` and
//! validated here against `GP_PAUSE_MENU.pak` / `GP_TITLE.pak`: the pause menu's
//! `pgpbtn00/01/04/15.rat` read X=226, Y=268/337/407/478 (the documented 70 px
//! pitch), and focus records land 42 px left / 8 px up of their base.
//! # 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]])
}
/// One sprite placement parsed from a `.rat` record.
#[derive(Debug, Clone)]
pub struct Placement {
/// The `.rat` record's own name (e.g. `pgpbtn00.rat`).
pub record: String,
/// The `.t32` sprite this record places (from record offset 0x20).
pub sprite: String,
/// Top-left position in the design space (X/Y from the placement block).
pub x: u32,
pub y: u32,
/// 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 (`0xffffffff` = untinted).
/// RGBA tint (`0xffff_ffff` = untinted).
pub tint: u32,
/// A `*f.rat` focus-state record (draws the selection art).
/// 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.rat` / keyframed record — its first frame is taken.
/// 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 `.rat` placement in draw order.
pub placements: Vec<Placement>,
/// 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 build (has at least one `.rat` layout child).
/// 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| {
@@ -62,96 +180,244 @@ pub fn is_build(bundle: &[u8]) -> bool {
})
}
/// Parse one `.rat` record (a RATC-tagged placement blob) → a [`Placement`].
fn parse_record(name: &str, rec: &[u8]) -> Option<Placement> {
if rec.len() < 0x58 || rec[0..4] != *b"RATC" {
/// 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 dw = be32(rec, 0x18);
let dh = be32(rec, 0x1c);
if dw == 0 || dh == 0 || dw > 8192 || dh > 8192 {
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;
}
// Sprite name: NUL-terminated ASCII at 0x20 (up to 16 bytes).
let sname = {
let s = &rec[0x20..0x30.min(rec.len())];
let end = s.iter().position(|&b| b == 0).unwrap_or(s.len());
String::from_utf8_lossy(&s[..end]).trim().to_string()
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),
};
if sname.is_empty() {
// 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;
}
// Placement block: [scaleX=100, scaleY=100, tint, X, Y] — the first such run
// whose X/Y fall inside the design space (records are tag-driven/variable, so
// this anchor is more robust than a fixed offset). See the format doc.
let mut placement = 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), be32(rec, o + 16));
if x < dw && y < dh {
placement = Some((tint, x, y));
break;
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;
}
let (tint, x, y) = placement?;
let lname = name.to_ascii_lowercase();
Some(Placement {
record: name.to_string(),
sprite: sname,
x,
y,
scale_x: 100,
scale_y: 100,
tint,
focused: lname.ends_with("f.rat"),
animated: lname.contains("loop"),
})
}
/// Parse a build bundle into its placements and sprite table.
pub fn parse_build(bundle: &[u8]) -> Option<UiBuild> {
let kids = ratc::parse(bundle)?;
let mut sprites = HashMap::new();
let mut placements = Vec::new();
for c in &kids {
let lname = c.name.to_ascii_lowercase();
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 lname.ends_with(".rat") {
if let Some(p) = parse_record(&c.name, &bundle[c.offset..end]) {
placements.push(p);
}
}
}
if placements.is_empty() {
return None;
}
let (design_w, design_h) = placements
.iter()
.find_map(|_| {
// design dims are constant across records; re-read the first record
kids.iter()
.find(|c| c.name.to_ascii_lowercase().ends_with(".rat"))
.map(|c| {
let r = &bundle[c.offset..(c.offset + c.size).min(bundle.len())];
(be32(r, 0x18), be32(r, 0x1c))
})
})
.unwrap_or((1280, 720));
let context_hint = sprites
.keys()
.find_map(|n| n.contains("ttrl").then(|| "tutorial".to_string()));
Some(UiBuild {
design_w,
design_h,
placements,
sprites,
context_hint,
})
None
}
/// A composited screen image ready to display.
@@ -160,72 +426,136 @@ pub struct ComposedScreen {
pub height: u32,
/// Row-major RGBA8.
pub rgba: Vec<u8>,
/// Names of the records actually drawn.
pub drawn: Vec<String>,
/// 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>,
}
/// Composite a build into its screen image.
///
/// Draws every base placement (title + menu items). Animated `loop*` records are
/// skipped (they're decorations without a static position); `*f` focus records
/// are skipped unless `include_focus`. Sprites are alpha-blended at their
/// top-left with their tint applied.
/// 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 + live 3D scene.
let mut canvas = vec![0u8; (w * h * 4) as usize];
// 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();
for p in &build.placements {
if p.animated || (p.focused && !include_focus) {
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(&(off, size)) = build.sprites.get(&p.sprite) else {
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, p);
drawn.push(p.record.clone());
blit(&mut canvas, w, h, &img, kf);
drawn.push(el.index);
}
Some(ComposedScreen {
ComposedScreen {
width: w,
height: h,
rgba: canvas,
drawn,
})
missing,
}
}
/// Alpha-blend one sprite onto the canvas at its placement, with tint + scale.
fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, p: &Placement) {
/// 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 (dw, dh) = (sw * p.scale_x / 100, sh * p.scale_y / 100);
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) = (
(p.tint >> 24) & 0xff,
(p.tint >> 16) & 0xff,
(p.tint >> 8) & 0xff,
p.tint & 0xff,
(kf.tint >> 24) & 0xff,
(kf.tint >> 16) & 0xff,
(kf.tint >> 8) & 0xff,
kf.tint & 0xff,
);
for oy in 0..dh {
let ty = p.y + oy;
if ty >= ch {
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 = p.x + ox;
if tx >= cw {
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;
@@ -233,7 +563,7 @@ fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, p: &Placemen
if sa == 0 {
continue;
}
let di = ((ty * cw + tx) * 4) as usize;
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;
@@ -247,36 +577,160 @@ fn blit(canvas: &mut [u8], cw: u32, ch: u32, img: &t8ad::T8adImage, p: &Placemen
mod tests {
use super::*;
/// A synthetic `.rat` record: RATC header, sprite name at 0x20, a
/// `[100,100,tint,X,Y]` placement block.
fn synth_record(sprite: &str, x: u32, y: u32) -> Vec<u8> {
/// 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());
let nb = sprite.as_bytes();
r[0x20..0x20 + nb.len()].copy_from_slice(nb);
for v in [100u32, 100, 0xffff_ffff, x, y] {
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());
}
r
}
#[test]
fn parses_placement_block() {
let r = synth_record("pgpbtn00.t32", 226, 268);
let p = parse_record("pgpbtn00.rat", &r).unwrap();
assert_eq!(p.sprite, "pgpbtn00.t32");
assert_eq!((p.x, p.y), (226, 268));
assert_eq!(p.tint, 0xffff_ffff);
assert!(!p.focused);
}
#[test]
fn flags_focus_and_rejects_out_of_range() {
let f = parse_record("pgpbtn00f.rat", &synth_record("ring.t32", 184, 260)).unwrap();
assert!(f.focused);
// X beyond design space → no placement found.
assert!(parse_record("bad.rat", &synth_record("x.t32", 9000, 10)).is_none());
assert_eq!(scan_placement_block(&r), Some((0xffff_ffff, 226, 268)));
}
}