formats: ui_layout — reassemble UI screens from .rat records

Parse each RATC bundle's .rat layout records (sprite name @0x20, placement
block [scale,tint,X,Y]) and composite the .t32 sprites back into the screen
image. Validated: GP_PAUSE_MENU rebuilds pixel-accurately (btn X=226,
Y=268/337/407/478 — the documented 70px pitch); focus records land 42px
left/8px up. Compositor confirmed by rendering the pause menu from disc alone.

Format doc: docs/re/structures/ui-rat-layout.md (agent RE).
This commit is contained in:
2026-07-29 20:27:35 +02:00
parent c067761e56
commit c1da907135
3 changed files with 403 additions and 0 deletions

View File

@@ -0,0 +1,118 @@
//! Scratch: confirm the `.rat` layout record offsets against a real screen pak.
//! Run: cargo run -p sylpheed-formats --example rat_inspect -- <GP_SCREEN.pak>
use sylpheed_formats::{pak::PakArchive, ratc, t8ad};
fn be32(b: &[u8], o: usize) -> u32 {
u32::from_be_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]])
}
fn main() {
let path = std::env::args().nth(1).expect("pak path");
let ar = PakArchive::open(&path).expect("open pak");
println!("entries: {}", ar.len());
let mut decoded: Vec<Vec<u8>> = Vec::new();
for (i, e) in ar.entries().iter().enumerate() {
let d = ar.read(e).unwrap_or_default();
let magic = String::from_utf8_lossy(&d[..4.min(d.len())]).to_string();
let (nt, nr) = if ratc::is_ratc(&d) {
let k = ratc::parse(&d).unwrap_or_default();
(
k.iter().filter(|c| c.kind == "T8aD").count(),
k.iter()
.filter(|c| c.name.to_lowercase().ends_with(".rat"))
.count(),
)
} else {
(0, 0)
};
println!(
" entry[{:2}] {:>8} B magic={:4} t32={:2} rat={:2}",
i,
d.len(),
magic,
nt,
nr
);
decoded.push(d);
}
// Pick the entry with the most children as "build0".
let bi = (0..decoded.len())
.filter(|&i| ratc::is_ratc(&decoded[i]))
.max_by_key(|&i| ratc::parse(&decoded[i]).map(|k| k.len()).unwrap_or(0))
.unwrap();
println!("=> inspecting richest build: entry[{}]", bi);
let bundle = &decoded[bi];
let kids = ratc::parse(bundle).unwrap_or_default();
let t32: Vec<_> = kids.iter().filter(|c| c.kind == "T8aD").collect();
let rat: Vec<_> = kids
.iter()
.filter(|c| c.name.to_lowercase().ends_with(".rat"))
.collect();
println!(
"build0: {} children, {} t32 sprites, {} rat records",
kids.len(),
t32.len(),
rat.len()
);
for c in t32.iter().take(5) {
let b = &bundle[c.offset..(c.offset + c.size).min(bundle.len())];
if let Some(img) = t8ad::parse(b) {
println!(" T8aD {:28} {}x{}", c.name, img.width, img.height);
}
}
println!(" -- .rat records --");
for c in rat.iter().take(10) {
let r = &bundle[c.offset..(c.offset + c.size).min(bundle.len())];
if r.len() < 0x60 {
println!(" .rat {:20} (len {}, too short)", c.name, r.len());
continue;
}
let (dw, dh) = (be32(r, 0x18), be32(r, 0x1c));
let sprite = {
let s = &r[0x20..0x30.min(r.len())];
let end = s.iter().position(|&b| b == 0).unwrap_or(s.len());
String::from_utf8_lossy(&s[..end]).to_string()
};
let (pvx, pvy) = (be32(r, 0x50), be32(r, 0x54));
// scan for placement block [scaleX=100, scaleY=100, tint, X<dw, Y<dh]
let mut placement = None;
let mut o = 0x58;
while o + 20 <= r.len() {
if be32(r, o) == 100 && be32(r, o + 4) == 100 {
let (tint, x, y) = (be32(r, o + 8), be32(r, o + 12), be32(r, o + 16));
if x < dw && y < dh {
placement = Some((o, tint, x, y));
break;
}
}
o += 4;
}
println!(
" .rat {:22} {}x{} sprite={:16} pivot=({},{}) place={:x?}",
c.name, dw, dh, sprite, pvx, pvy, placement
);
}
// End-to-end: composite the build and write it out as a PNG.
if let Some(screen) = sylpheed_formats::ui_layout::compose_build(bundle, false) {
println!(
"compose: {}x{}, drew {} records: {:?}",
screen.width,
screen.height,
screen.drawn.len(),
screen.drawn
);
if let Some(out) = std::env::args().nth(2) {
// Dependency-free PPM (P6, RGB — alpha already composited over the backdrop).
let mut buf = format!("P6\n{} {}\n255\n", screen.width, screen.height).into_bytes();
for px in screen.rgba.chunks_exact(4) {
buf.extend_from_slice(&px[..3]);
}
std::fs::write(&out, buf).unwrap();
println!("wrote {out}");
}
} else {
println!("compose: build produced no image");
}
}

View File

@@ -37,6 +37,9 @@ pub mod t8ad;
// RATC nested resource bundle // RATC nested resource bundle
pub mod ratc; pub mod ratc;
/// UI screen layout (`.rat`) — reassemble a screen from its pak.
pub mod ui_layout;
// LSTA sprite list (inline T8aD frames) // LSTA sprite list (inline T8aD frames)
pub mod lsta; pub mod lsta;

View File

@@ -0,0 +1,282 @@
//! `.rat` UI-screen layout — reassemble a UI screen from its pak.
//!
//! 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.
//!
//! 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.
use crate::{ratc, t8ad};
use std::collections::HashMap;
fn be32(b: &[u8], o: usize) -> u32 {
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,
/// Scale in percent (100 = 1:1).
pub scale_x: u32,
pub scale_y: u32,
/// RGBA tint (`0xffffffff` = untinted).
pub tint: u32,
/// A `*f.rat` focus-state record (draws the selection art).
pub focused: bool,
/// A `loopN.rat` / keyframed record — its first frame is taken.
pub animated: bool,
}
/// 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>,
/// 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>,
}
/// Whether `bundle` is a RATC 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"))
})
}
/// 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" {
return None;
}
let dw = be32(rec, 0x18);
let dh = be32(rec, 0x1c);
if dw == 0 || dh == 0 || dw > 8192 || dh > 8192 {
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()
};
if sname.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;
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;
}
}
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,
})
}
/// A composited screen image ready to display.
pub struct ComposedScreen {
pub width: u32,
pub height: u32,
/// Row-major RGBA8.
pub rgba: Vec<u8>,
/// Names of the records actually drawn.
pub drawn: 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.
pub fn compose_build(bundle: &[u8], include_focus: bool) -> Option<ComposedScreen> {
let build = parse_build(bundle)?;
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];
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) {
continue;
}
let Some(&(off, size)) = build.sprites.get(&p.sprite) else {
continue;
};
let Some(img) = t8ad::parse(&bundle[off..off + size]) else {
continue;
};
blit(&mut canvas, w, h, &img, p);
drawn.push(p.record.clone());
}
Some(ComposedScreen {
width: w,
height: h,
rgba: canvas,
drawn,
})
}
/// 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) {
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 (tr, tg, tb, ta) = (
(p.tint >> 24) & 0xff,
(p.tint >> 16) & 0xff,
(p.tint >> 8) & 0xff,
p.tint & 0xff,
);
for oy in 0..dh {
let ty = p.y + oy;
if ty >= ch {
break;
}
let syi = (oy * sh / dh).min(sh - 1);
for ox in 0..dw {
let tx = p.x + ox;
if tx >= cw {
break;
}
let sxi = (ox * sw / dw).min(sw - 1);
let si = ((syi * sw + sxi) * 4) as usize;
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 * cw + tx) * 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 `.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> {
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.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());
}
}