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:
2026-07-29 20:27:35 +02:00
parent c067761e56
commit c1da907135
3 changed files with 403 additions and 0 deletions

View 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");
}
}