Files
Syplheed-Reborn/crates/sylpheed-formats/src/ui_layout.rs
Claude (auto-RE) c1da907135 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).
2026-07-29 20:27:35 +02:00

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