//! `.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 `.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()); } }