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:
118
crates/sylpheed-formats/examples/rat_inspect.rs
Normal file
118
crates/sylpheed-formats/examples/rat_inspect.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,9 @@ pub mod t8ad;
|
||||
// RATC nested resource bundle
|
||||
pub mod ratc;
|
||||
|
||||
/// UI screen layout (`.rat`) — reassemble a screen from its pak.
|
||||
pub mod ui_layout;
|
||||
|
||||
// LSTA sprite list (inline T8aD frames)
|
||||
pub mod lsta;
|
||||
|
||||
|
||||
282
crates/sylpheed-formats/src/ui_layout.rs
Normal file
282
crates/sylpheed-formats/src/ui_layout.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user