From c1da90713557bb9da8c53d65784c0766d85117a9 Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Wed, 29 Jul 2026 20:27:35 +0200 Subject: [PATCH] =?UTF-8?q?formats:=20ui=5Flayout=20=E2=80=94=20reassemble?= =?UTF-8?q?=20UI=20screens=20from=20.rat=20records?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- .../sylpheed-formats/examples/rat_inspect.rs | 118 ++++++++ crates/sylpheed-formats/src/lib.rs | 3 + crates/sylpheed-formats/src/ui_layout.rs | 282 ++++++++++++++++++ 3 files changed, 403 insertions(+) create mode 100644 crates/sylpheed-formats/examples/rat_inspect.rs create mode 100644 crates/sylpheed-formats/src/ui_layout.rs diff --git a/crates/sylpheed-formats/examples/rat_inspect.rs b/crates/sylpheed-formats/examples/rat_inspect.rs new file mode 100644 index 0000000..389d531 --- /dev/null +++ b/crates/sylpheed-formats/examples/rat_inspect.rs @@ -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 -- +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::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.t32` sprites and `.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, + /// Sprite name → (offset, size) of its `T8aD` child within the bundle. + pub sprites: HashMap, + /// A guessed context from the sprite naming (e.g. `"tutorial"`), if any. + pub context_hint: Option, +} + +/// 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 { + 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 { + 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, + /// Names of the records actually drawn. + pub drawn: Vec, +} + +/// 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 { + 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 { + 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()); + } +}