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
|
// 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;
|
||||||
|
|
||||||
|
|||||||
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