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 c3c79ad028 formats: composite in the MEASURED paint order, and drop the ghost instances
The compositor painted in declaration order, which the draw capture proved wrong
— the title's background is declared ninth and paints first. The order the game
uses is its runtime child list, and no decoded field reproduces it, so rather
than leave the screen composited wrongly this lands the orders that HAVE been
read off the running game and falls back to declaration order everywhere else.
Two builds are covered: GP_TITLE's title build and the GAME ARTS / SETA / studio
anima splash. Keyed by element names, which identify a build across paks and
language variants.

Rendering it exposed a second defect, and the same capture settles it: the
kind = 0x4 elements are motion-trail ghosts, not resting content. The bundle
declares three instances of each wordmark; the capture shows exactly ONE quad at
each wordmark's position. Drawing them at their resting keyframe put three
oversized PROJECT SYLPHEED copies across the composite. They are now skipped.

Verified with an artifact, not a green build: the composite is committed
(captures/title-composited-measured-order.png) and now reads as the title screen
— background, planet, ship, wordmark, TM, copyright, correctly layered.

The test is disc-gated and was checked BOTH ways: it passes as landed, and
disabling the order table makes it fail. It reads one pak rather than every
build on the disc — the first version used the all-builds helper and got the
test process OOM-killed running alongside the other three.
2026-08-19 02:32:14 +00:00

901 lines
34 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.
/// 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]);
}
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`.
let order: Vec<usize> =
measured_paint_order(build).unwrap_or_else(|| (0..build.elements.len()).collect());
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;
}
// `kind = 0x4` elements are the repeated instances of a template — the
// motion-trail ghosts a wordmark leaves while it flies in. They are NOT
// on screen at rest: the title's draw capture shows exactly ONE quad at
// each wordmark's position, though the bundle declares three instances
// of each, and their keyframe groups end at alpha 0. Drawing them at
// their resting keyframe is what put three oversized copies of
// PROJECT SYLPHEED across the composite.
if el.kind & 0x4 != 0 {
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)·(2−1) = (0,0)` and a 1280×720
/// rect, which is what the game draws. At 100 % the pivot cancels, which is why
/// every unscaled element — and so every ruler this format was checked against —
/// was unaffected. See `docs/re/structures/ui-rat-layout.md`.
fn blit(
canvas: &mut [u8],
cw: u32,
ch: u32,
img: &t8ad::T8adImage,
kf: &Keyframe,
pivot_x: u32,
pivot_y: u32,
) {
let (sw, sh) = (img.width, img.height);
if sw == 0 || sh == 0 {
return;
}
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)));
}
}