2 Commits

Author SHA1 Message Date
9bc8c2d694 viewer: preview reassembled UI screens in the PAK browser
When an opened GP_*.pak RATC entry is a .rat build, compose the screen
(ui_layout::compose_build) and show it atop the RATC detail, labelled
'UI screen', with the sprite children below. Reuses the existing pak-open +
T8aD-decode path — no new browser/threading. Entry summaries mark UI builds.

cargo check -p sylpheed-viewer: clean.
2026-07-29 20:33:29 +02:00
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
40 changed files with 452 additions and 941 deletions

View File

@@ -1,50 +0,0 @@
//! Scratch analysis: for one IDXD key, list every object that DECLARES it and
//! whether it carries a value on disc or is left at the title-code default.
//!
//! Run: cargo run -p sylpheed-formats --example default_owners -- <KEY> [KEY...]
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn is_number(s: &str) -> bool {
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
}
fn is_key(s: &str) -> bool {
!s.is_empty()
&& s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s)
}
fn is_value(s: &str) -> bool {
is_number(s) || !is_key(s)
}
fn main() {
let root = std::env::var("SYLPHEED_DISC")
.unwrap_or_else(|_| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
let wanted: Vec<String> = std::env::args().skip(1).collect();
let arc = PakArchive::open(std::path::Path::new(&root).join("dat/GP_MAIN_GAME_E.pak")).unwrap();
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else { continue };
let toks = obj.tokens().to_vec();
let mut hits: Vec<String> = vec![];
for (i, t) in toks.iter().enumerate() {
if !wanted.iter().any(|w| w == t) {
continue;
}
let valued = i > 0 && is_value(&toks[i - 1]);
hits.push(if valued {
format!("{t}={}", toks[i - 1])
} else {
format!("{t}=<DEFAULT>")
});
}
if !hits.is_empty() {
println!("0x{:08x} {:<44} {}", obj.schema_hash, obj.identity(), hits.join(" "));
}
}
}

View File

@@ -1,135 +0,0 @@
//! Scratch analysis: which IDXD fields are DECLARED but left at their default on
//! disc, per schema. Those defaults live in title code, so they can only be read
//! from the running game — this prints the shopping list for that dynamic capture.
//!
//! Run: cargo run -p sylpheed-formats --example defaulted_fields -- <disc-root>
use std::collections::{BTreeMap, BTreeSet};
use sylpheed_formats::idxd::IdxdObject;
use sylpheed_formats::pak::PakArchive;
fn is_number(s: &str) -> bool {
let s = s.strip_suffix(['f', 'F']).unwrap_or(s);
let t = s.strip_prefix(['-', '+']).unwrap_or(s);
!t.is_empty() && t.chars().all(|c| c.is_ascii_digit() || c == '.')
}
/// Same shape-test the parser uses: an identifier-looking token that is not a
/// value literal is a field-name key.
fn is_key(s: &str) -> bool {
!s.is_empty()
&& s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
&& s.chars().next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& !is_number(s)
}
fn is_value(s: &str) -> bool {
is_number(s) || !is_key(s)
}
fn main() {
let root = std::env::args()
.nth(1)
.unwrap_or_else(|| "/home/fabi/RE - Project Sylpheed/sylph_extract".into());
let paks: Vec<String> = std::env::args().skip(2).collect();
let paks = if paks.is_empty() {
vec![
"dat/GP_MAIN_GAME_E.pak".to_string(),
"dat/GP_HANGAR_ARSENAL.pak".to_string(),
]
} else {
paks
};
for pak_rel in &paks {
let path = std::path::Path::new(&root).join(pak_rel);
let Ok(arc) = PakArchive::open(&path) else {
eprintln!("-- skip {pak_rel} (open failed)");
continue;
};
println!("\n================ {pak_rel} ================");
// schema -> key -> (n_set, n_defaulted, distinct values, example owners)
type Stat = (usize, usize, BTreeSet<String>, Vec<String>);
let mut per_schema: BTreeMap<u32, (usize, BTreeMap<String, Stat>)> = BTreeMap::new();
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else { continue };
let Ok(obj) = IdxdObject::parse(&bytes) else {
continue;
};
let ident = obj.identity();
let toks = obj.tokens().to_vec();
let entry = per_schema.entry(obj.schema_hash).or_default();
entry.0 += 1;
// Track which keys this object declares, and whether each is valued.
let mut seen_here: BTreeMap<String, Option<String>> = BTreeMap::new();
for (i, t) in toks.iter().enumerate() {
if !is_key(t) {
continue;
}
let prev = if i == 0 { None } else { Some(&toks[i - 1]) };
let valued = prev.map(|p| is_value(p)).unwrap_or(false);
let v = if valued { Some(toks[i - 1].clone()) } else { None };
seen_here.entry(t.clone()).or_insert(v);
}
for (k, v) in seen_here {
let s = entry.1.entry(k).or_default();
match v {
Some(val) => {
s.0 += 1;
if s.2.len() < 12 {
s.2.insert(val);
}
}
None => {
s.1 += 1;
if s.3.len() < 6 {
s.3.push(ident.clone());
}
}
}
}
}
for (schema, (n_obj, keys)) in per_schema {
if n_obj < 2 {
continue;
}
let name = match schema {
0x0426_e81d => "PLAYER",
0x6ab4_825a => "WEAPON",
0x43fa_a517 => "UNIT",
0x3c5b_0549 => "VESSEL",
0xbd86_d41c => "CHARACTER",
0x3c9a_e32e => "STAGE",
0xb412_e6d8 => "MESSAGE",
_ => "?",
};
let defaulted: Vec<_> = keys
.iter()
.filter(|(_, s)| s.1 > 0)
.collect();
if defaulted.is_empty() {
continue;
}
println!("\n--- schema 0x{schema:08x} {name} ({n_obj} objects) ---");
println!(
"{:<30} {:>5} {:>5} {}",
"KEY", "set", "dflt", "values seen (≤12) | owners defaulting"
);
for (k, (n_set, n_def, vals, owners)) in defaulted {
let vv: Vec<&str> = vals.iter().map(|s| s.as_str()).collect();
println!(
"{:<30} {:>5} {:>5} {} | {}",
k,
n_set,
n_def,
vv.join(","),
owners.join(",")
);
}
}
}
}

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

View File

@@ -1,55 +0,0 @@
//! Scratch analysis: inventory one UI screen's pak — every entry, and for RATC
//! entries the children they bundle. Optionally write an entry's decompressed
//! bytes out for hex inspection.
//!
//! Run: cargo run -p sylpheed-formats --example ui_screen -- <PAK> [--dump 0xHASH out.bin]
use sylpheed_formats::pak::{self, PakArchive};
use sylpheed_formats::ratc;
fn main() {
let mut args = std::env::args().skip(1);
let pak = args.next().expect("usage: ui_screen <pak> [--dump 0xHASH out.bin]");
let arc = PakArchive::open(&pak).expect("open pak");
let rest: Vec<String> = args.collect();
if rest.first().map(|s| s == "--dump").unwrap_or(false) {
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
std::fs::write(&rest[2], &bytes).unwrap();
println!("wrote {} bytes to {}", bytes.len(), rest[2]);
return;
}
if rest.first().map(|s| s == "--child").unwrap_or(false) {
// --child 0xHASH <child-name> <out>: carve one RATC child out by its
// listed offset/size, so a 165-byte layout record can be hexdumped alone.
let h = u32::from_str_radix(rest[1].trim_start_matches("0x"), 16).unwrap();
let bytes = arc.read_by_hash(h).expect("entry present").expect("decompress");
let kids = ratc::parse(&bytes).expect("ratc");
let k = kids.iter().find(|k| k.name == rest[2]).expect("child not found");
std::fs::write(&rest[3], &bytes[k.offset..k.offset + k.size]).unwrap();
println!("wrote {} B ({} @ {:#x}) to {}", k.size, k.name, k.offset, rest[3]);
return;
}
println!("{pak}: {} entries", arc.len());
for e in arc.entries() {
let Ok(bytes) = arc.read(e) else {
println!(" {:08x} <decompress failed>", e.name_hash);
continue;
};
let label = pak::inner_format_label(&bytes);
println!(" {:08x} {:>9} B {}", e.name_hash, bytes.len(), label);
if ratc::is_ratc(&bytes) {
if let Some(kids) = ratc::parse(&bytes) {
for k in &kids {
println!(" - {:<40} {:>9} B {}", k.name, k.size, k.kind);
}
if kids.is_empty() {
println!(" (no children found)");
}
}
}
}
}

View File

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

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

View File

@@ -160,8 +160,9 @@ pub enum PakContent {
T8ad(ImageRgba),
/// An LSTA sprite list — inline T8aD frames.
Lsta(Vec<ImageRgba>),
/// A RATC bundle — its listed children (T8aD children carry a decoded image).
Ratc(Vec<RatcEntry>),
/// A RATC bundle — its listed children (T8aD children carry a decoded
/// image), plus the reassembled UI screen when the bundle is a `.rat` build.
Ratc(Vec<RatcEntry>, Option<ImageRgba>),
/// Plain-text / XML payload, with its encoding label.
Text { text: String, encoding: String },
}
@@ -192,6 +193,13 @@ impl ImageRgba {
rgba: img.rgba,
}
}
fn from_composed(s: sylpheed_formats::ui_layout::ComposedScreen) -> Self {
Self {
width: s.width,
height: s.height,
rgba: s.rgba,
}
}
fn pixels(&self) -> usize {
(self.width as usize) * (self.height as usize)
}
@@ -1469,7 +1477,10 @@ fn classify_content(payload: &[u8]) -> PakContent {
})
.collect();
if !entries.is_empty() {
return PakContent::Ratc(entries);
// If this bundle is a `.rat` UI build, reassemble the screen.
let ui_screen = sylpheed_formats::ui_layout::compose_build(payload, false)
.map(ImageRgba::from_composed);
return PakContent::Ratc(entries, ui_screen);
}
}
}

View File

@@ -672,7 +672,9 @@ fn draw_pak_browser(ui: &mut egui::Ui, pak: &mut PakView) {
PakContent::Png(img) => draw_png_detail(ui, row, img, img_tex),
PakContent::T8ad(img) => draw_t8ad_detail(ui, row, img, img_tex),
PakContent::Lsta(frames) => draw_lsta_detail(ui, row, frames, img_tex),
PakContent::Ratc(children) => draw_ratc_detail(ui, row, children, img_tex),
PakContent::Ratc(children, ui_screen) => {
draw_ratc_detail(ui, row, children, ui_screen.as_ref(), img_tex)
}
PakContent::Text { text, encoding } => {
draw_text_detail(ui, row, text, encoding)
}
@@ -699,7 +701,10 @@ fn row_kind(row: &crate::iso_loader::PakRow) -> String {
PakContent::Png(img) => format!("PNG {}×{}", img.width, img.height),
PakContent::T8ad(img) => format!("T8aD {}×{}", img.width, img.height),
PakContent::Lsta(f) => format!("LSTA · {} sprite(s)", f.len()),
PakContent::Ratc(c) => format!("RATC · {} item(s)", c.len()),
PakContent::Ratc(c, screen) => {
let s = if screen.is_some() { " · UI screen" } else { "" };
format!("RATC · {} item(s){s}", c.len())
}
PakContent::Text { .. } => "text".into(),
PakContent::None => row.identity.clone(),
}
@@ -939,21 +944,47 @@ fn draw_ratc_detail(
ui: &mut egui::Ui,
row: &crate::iso_loader::PakRow,
children: &[crate::iso_loader::RatcEntry],
ui_screen: Option<&ImageRgba>,
img_tex: &mut ImgCache,
) {
ui.horizontal(|ui| {
ui.heading("RATC bundle");
ui.separator();
ui.label(format!("{} item(s)", children.len()));
if ui_screen.is_some() {
ui.separator();
ui.strong("🖼 UI screen");
}
ui.separator();
ui.weak("colours unverified");
});
ui.separator();
let refs: Vec<&ImageRgba> = children.iter().filter_map(|c| c.image.as_ref()).collect();
// Cache the reassembled screen (if any) as texture 0, then child thumbnails.
let mut refs: Vec<&ImageRgba> = Vec::new();
if let Some(s) = ui_screen {
refs.push(s);
}
let child_base = refs.len();
refs.extend(children.iter().filter_map(|c| c.image.as_ref()));
ensure_textures(ui, row.hash, &refs, img_tex);
let mut img_i = 0;
// The reassembled screen, scaled to fit the panel width.
if ui_screen.is_some() {
if let Some(tex) = img_tex.as_ref().and_then(|(_, t)| t.first()) {
ui.label("Reassembled from this screen's .rat layout records:");
let sz = tex.size_vec2();
let scale = (ui.available_width() / sz.x.max(1.0)).min(1.0);
ui.add(egui::Image::new(egui::load::SizedTexture::new(
tex.id(),
[sz.x * scale, sz.y * scale],
)));
ui.weak("Frame/glow decorations (no .rat) and the live 3D background are omitted.");
ui.separator();
}
}
let mut img_i = child_base;
for c in children {
ui.horizontal(|ui| {
if c.image.is_some() {

View File

@@ -21,8 +21,6 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
| Fonts (ttf/otf/ttc) | ✅ | `sylpheed-formats/src/font.rs` | standard OpenType, parsed via ttf-parser |
| XBG7 mesh | 🟡/❔ | `sylpheed-formats/src/mesh.rs` + `tests/mesh_disc.rs` ([xbg7](structures/xbg7-mesh.md)) | weapons/props: declaration-driven variable stride (36 models), GPU-confirmed. **Stage containers: 5662 sub-models across 22 stages** via content-anchored grouped pools (`stage_models`). Quantized hero bodies (DeltaSaber `f004`) still declined |
| Capital-ship part placement | 🟡 | `sylpheed-formats/src/ship.rs` (static) + [runtime capture](ship-placement-runtime-capture.md) | hull placement static-exact; external parts approximate statically. **Runtime capture** (Canary F10 → VS-constant WorldView) gives ground truth — validated on `e106` destroyer; not yet baked into the viewer |
| Weapon fields defaulted on disc | ✅/🟡 | [runtime DATA SHEET](weapon-datasheet-runtime.md) | The Arsenal Gallery-Mode panel reads the live record: `Ammo Capacity` = `LoadingCount` ✅, `Max. Lock Ons` = `TriggerShotCount` ✅. Recovers `TriggerShotCount` for `wep_05`/`wep_60` (both **4**); brackets defaulted `Power`/`MaximumRange` via the letter classes. 9 of ~61 weapons reachable at 5 % save progress |
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded |
## Functions / code paths

Binary file not shown.

Before

Width:  |  Height:  |  Size: 195 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 233 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -1,182 +0,0 @@
# `.rat` — the UI element layout / animation record
**Status:**`CONFIRMED` for placement (2026-07-28). The retail UI can be
**reassembled from the disc**: the tutorial PAUSE menu rebuilds pixel-accurately from its
sprites placed at the coordinates in their `.rat` records — no fitting, no manual nudging.
![real vs rebuilt](captures/ui-layout/pause-tutorial-real-vs-rebuilt.png)
*Left: the running game (Canary screenshot). Right: rebuilt from `GP_PAUSE_MENU.pak` alone.
The remaining differences are the animated frame/glow sprites (`*eff*`) that were not
placed, and the live 3D background.*
## Screen composition
The UI is **one pak per screen**`GP_TITLE`, `GP_PAUSE_MENU`, `GP_READY_ROOM`,
`GP_SAVE_LOAD`, `GP_MISSION_SELECT`, `GP_OPTIONS`, `GP_SYSTEM`, `GP_TUTORIAL`,
`GP_STAGE_CLEAR`, `GP_MOVIE_THEATER`, `GP_DEBRIEFING_PILOTLOG`, `GP_LEADERBOARD`,
`GP_MISSION_LOG`, `GP_BUNK`, `GP_DIALOG`, `GP_GAMEOVER`, `GP_CHALLENGE`,
`GP_HANGAR_ARSENAL`.
Inside a screen pak, each top-level [RATC](../INDEX.md) bundle is **one (context × language)
build of that screen**. Its own header is the screen's **element declaration table**, and
the elements themselves follow as children:
| child | what it is |
|---|---|
| `<name>.t32` | the sprite ([T8aD](texture-color-k8888.md)) |
| `<name>.rat` | that sprite's **layout record** (this document) |
| `<screen>loop1.rat` | a looping sprite animation (see below) |
### The bundle header — element declaration table
```
0x14 u32 entry count
0x20 entry[count], 60 bytes each:
+0 char[28] element name, NUL-padded ("pgp_ttrl_eff10.t32", "pgp_ttrl_btn10.rat")
+28 u32 ×4 flags (0xffffffff / 0xffffffff / 0 / 0xffffffff on every entry seen)
+48 u32 pivot X
+52 u32 pivot Y
+56 u32 0
```
The table lists **both** sprites and `.rat` records — it is the screen's element list.
`pgp_ttrl` declares 11: six `eff*`, `msg`, and four `.rat`s (`title`, `btn10..12`).
**Verified:** for all 7 `.t32` entries the declared pivot is *exactly* half the decoded
texture's dimensions — `eff10` 408×120 → 204,60; `eff21` 428×360 → 214,180; `msg` 381×38 →
190,19; and so on, 7/7 with no mismatch (`tools/re-capture/ratc_decls.py`).
`GP_PAUSE_MENU.pak`'s six bundles are `{in-mission, tutorial} × {English, Japanese}`, with
the two in-mission builds each present twice at identical size. The purpose of that
duplicate is **NEEDS-HUMAN** (resolution or aspect variant?), and where the other four
shipped languages live is likewise unresolved — this pak holds only EN and JP.
Naming is transparent: `pgp` = pause screen, `pgp_ttrl_` = its tutorial context, `btnNN` =
menu item, `btnNNf` = that item's **focused** sprite, `eff*` = frame/glow decoration,
`deli*` = the item divider, `title`, `msg`.
## Record layout
Big-endian u32 throughout (Xbox 360), and **tag-driven**: 4-char ASCII tags (`opt `,
`PRMD`, `end `) mark sections, so a record is a stream of blocks rather than a fixed struct.
A minimal record (a static button) is 165 bytes:
```
0x00 "RATC" magic — a RATC bundle reused as a data record
0x08 u32 payload size
0x14 u32 entry count (loop1.rat: 3, matching its 3 sprite names)
0x18 u32 design width = 1280
0x1c u32 design height = 720
0x20 char[16] the sprite this record places, e.g. "pgpbtn00.t32"
0x50 u32 pivot X = texture width / 2
0x54 u32 pivot Y = texture height / 2
...
── placement block ──
u32 scale X = 100 (percent)
u32 scale Y = 100
u32 tint = 0xffffffff (RGBA, white = untinted)
u32 X ← top-left position
u32 Y ←
u32 time (keyframe records only)
...
"opt " u32 len char[len] link to another record, e.g. "pgpbtn00f.rat"
```
- **X/Y is the sprite's top-left**, not its centre: compositing at these coordinates
reproduces the screenshot, which drawing centred on them would not.
- **The pivot at 0x50/0x54 is half the texture size** — 4 of the 5 plain sprites match
exactly (`pgpbtn00` 86×42 → 43,21; `pgpbtn05` 221×42 → 110,21; `pgptitle` 202×73 →
101,36; `pgpbtn04` 172×43 → 86,22). It is a rotation/scale centre, not a draw offset.
- **Animated elements are a keyframe list.** `pgptitle.rat` (752 B) is the same placement
block repeated with a varying trailing `time` field — the PAUSE title's fly-in.
- **`opt `** carries a length-prefixed record name. On `pgpbtn00.rat` it points at
`pgpbtn00f.rat`, i.e. *normal state → focused state*. Focused records place their sprite
42 px left and 8 px up of the base, because the focused art includes the selection ring
that hangs off the left edge; their pivot is a constant (21,25) rather than half-size.
- `<screen>loop1.rat` is **not** a screen composition — it is a looping sprite animation:
a 3-name table (`pgpeff34/35/36.t32`) plus ~30 keyframes all at one position.
- The small (380 B) top-level entries are **`PRMD` primitives**, not sprites: a colour and
four explicit corner coordinates `(0,0) (1280,0) (0,720) (1280,720)` — the full-screen
quad that dims the scene behind the pause menu — terminated by `end `.
### The records are language-independent
`pgpbtn00.rat` is **byte-identical** in the English and Japanese bundles. The layout is
authored once and only the `.t32` sprites are swapped, which has two consequences:
- The baked pivot belongs to *whichever build the record was authored from*, not to the
sprite actually shipped beside it. That is why `pgpbtn01`'s pivot (113 → a 226 px wide
texture) matches neither the English sprite (207) nor the Japanese one (148).
- The split is visible inside a single bundle: in the **English** tutorial build, every
`.t32` declaration carries the correct English pivot (7/7), while the `.rat` declarations
carry Japanese-derived ones (`btn10` → 43,21 = 86/2, the *Japanese* sprite). So the
`.t32` table is regenerated per language and the `.rat` layer is inherited from the
Japanese master.
- **Do not infer anything about a language from a texture size** — see the traps below.
## Evidence
Positions were read out of the records and checked against a screenshot of the running
game, twice, in that order — the records were never fitted to the picture.
1. **Differential.** Across `pgpbtn00/01/04/05.rat`, exactly one field varies and it steps
`268 → 338 → 408 → 478` — a constant 70 px pitch — while the field before it is 226 in
all four. A vertical menu: constant X, evenly spaced Y.
2. **Absolute placement — the decisive test.** The *tutorial* build's records give
546/288, 546/358, 546/428 and 540/119. Compositing its sprites at exactly those numbers,
with no offset and no fitting, reproduces the screenshot (image above).
3. **Pivot.** 4 of 5 plain sprites carry exactly half their texture's dimensions at
0x50/0x54 (above).
> A caution on step 2, because the first pass here got it subtly wrong: the screenshot is
> of the **tutorial** pause menu, so only the `pgp_ttrl_*` records can be checked against
> it. The in-mission records (X = 226) also map onto the same screenshot under a single
> constant offset — but that only works because both builds share the 70 px pitch, and it
> proves nothing. The in-mission coordinates remain **unverified**: confirming them needs a
> screenshot of a pause during an actual mission.
## It generalizes — the title screen
The same method run against `GP_TITLE.pak` reproduces the **main menu**, which is a
different screen with a different item count and a different pitch:
![main menu real vs rebuilt](captures/ui-layout/title-mainmenu-real-vs-rebuilt.png)
`ptbtn01..05.rat` give X = 542 for all five and Y = 162 / 242 / 322 / 402 / 482 — an
**80 px** pitch, where the pause menu used 70. Measured against the screenshot, the sprite
tops land at a constant **+46 px** for all five (one reads 45, a 1-px edge-detection
wobble), and 46 is exactly the 45 px of Xenia window chrome plus one. So the record's Y is
the sprite's top edge in the guest framebuffer, to the pixel, on a second screen.
`GP_TITLE.pak` also splits by sub-screen the way the pause pak splits by context:
`ptbtn00` alone (the `PRESS Ⓐ BUTTON` prompt), `ptbtn01..05` (main menu), `ptbtn11..13`
(the EXTRAS submenu), plus `pgloading_*` for the loading screen.
## Two traps this caught
Both were mistakes made during this analysis, caught by comparing against the real game —
recording them because a static-only reading would have shipped them:
- **Texture width does not identify a language.** English `RESUME` (166 px) and Japanese
`再開` (86 px) differ hugely, but Japanese `通信ログ` (148 px) is within a few px of an
English label. The first language assignment made here was wrong; rendering the sprites
is the only reliable check. (The records being language-independent makes this worse:
a record's baked pivot implies a texture width that matches *no* shipped sprite.)
- **The in-mission and tutorial pause menus are different sprite sets, not one set
re-packed.** In-mission is `RESUME / RADIO LOG / OPTIONS / BACK TO TITLE` (4 items,
`pgpbtnNN`); the tutorial is `RESUME / OPTIONS / BACK TO MENU` (3 items,
`pgp_ttrl_btn1N`). Matching the 3-item screenshot against the 4-item set suggests a
runtime slot-packing rule that does not exist.
## Next
- **The `eff*` / `deli*` / `msg` placements are still missing** — those sprites have no
`.rat` of their own, and `loop1.rat` turned out to be an animation, not a composition.
So the screen's draw list lives somewhere not yet found (the parent RATC's own header
region, or title code). That is the gap between the rebuild above and a complete screen.
- The same method should now unroll the other screens directly; `GP_HANGAR_ARSENAL.pak`
(789 T8aD + 510 RATC) is the big one, and the ARSENAL `DATA SHEET` panel documented in
[weapon-datasheet-runtime.md](../weapon-datasheet-runtime.md) is a ready-made oracle for it.
- Tooling: `crates/sylpheed-formats/examples/ui_screen.rs` (inventory a screen pak, carve a
named RATC child), `sylpheed-cli pak textures` (decode every sprite).

View File

@@ -1,232 +0,0 @@
# Weapon DATA SHEET — runtime capture (Route B)
**Status:** 🟡 first dynamic capture, 2026-07-28. The Arsenal's *Gallery Mode* panel is a
direct runtime readout of the IDXD weapon record, which makes it an oracle for the fields
the disc leaves **defaulted**. Two field mappings are ✅ `CONFIRMED`; two defaulted values
are recovered at 🟡 `PROBABLE`. Captured from the retail game under Xenia Canary
(software Vulkan, headless) — see [the container recipe](#how-this-was-captured).
## Problem
`sylpheed-formats::game_data` reads the combat tables out of `dat/GP_MAIN_GAME_E.pak`, but
**a field left at its default value carries no value on disc** — the key is present in the
IDXD string pool with no value token in front of it. Those defaults live in title code, so
a static read can only ever say "not set", never *what* the game uses. That is a real hole
for the reimplementation: e.g. `Weapon_DSaber_P_wep_01_Beam` — the Delta Saber's starting
beam gun — has no `TriggerShotCount` and no `Power` on disc.
Ruled out first: the defaults are **not** hiding in another pak. `hidden/DefTables.pak`
contains no `WEAPON`-schema (`0x6ab4825a`) objects at all, and the other `GP_MAIN_GAME_*`
paks are localized duplicates of the English one.
The two scratch analyses that produced the shopping list live next to the crate:
`crates/sylpheed-formats/examples/defaulted_fields.rs` (per-schema: which keys are declared
but defaulted, and by whom) and `examples/default_owners.rs` (per-key: every owner, valued
or `<DEFAULT>`).
> Caveat on those tools: they classify a token as a *value* only if it is numeric or
> non-identifier-shaped. **Boolean/enum-valued fields therefore read as `<DEFAULT>`
> spuriously** (`Yes`, `Homing`, `Single`, `Burst` are identifier-shaped). Every finding
> below concerns numeric fields, where the classification is sound.
## Finding — the DATA SHEET reads the record
In **ARSENAL → (weapon type) → Y (Gallery Mode)** each entry shows a `DATA SHEET`, and it
is shown for weapons that have **not** been developed yet — only fully hidden (dashed)
entries are withheld. The same panel appears in **HANGAR → (hard point)**, with an extra
`Weapon Type` row.
| DATA SHEET row | IDXD field | Confidence | Evidence |
|---|---|---|---|
| `Ammo Capacity` | `LoadingCount` | ✅ CONFIRMED | every one of the 10 identified entries lands on a `LoadingCount` that exists in its own weapon-type tab — and see the circularity note below |
| `Max. Lock Ons` | `TriggerShotCount` | ✅ CONFIRMED | FALCON 9AM = 12 vs `wep_02`'s 12; BUZZARD 10AM = 22 vs `wep_04`'s 22 (two distinctive values, two independent records) |
| `Hard Point` | mount slot | ✅ CONFIRMED | matches the HANGAR slot the weapon is mountable/equipped on (`STILETTO BG I` → NOSE, `FALCON 9AM` → MAIN WEAPON1) |
| `Range Class` | bucket of `MaximumRange` | 🟡 PROBABLE | monotone in the on-disc metres, see the bracket table below |
| `Damage Class` | bucket of `Power` | 🟡 PROBABLE | monotone in the on-disc power, see below |
| `Weight Class` | bucket of the hangar-table `Weight` | ❔ HYPOTHESIS | only one clean pair so far (`wep_33`, Weight 0.3 → "Light") |
| `Speed Class` | ❔ | ❔ | present only on missiles (MPM = A, ASM = B); no on-disc pairing established |
| `Sight Homing`, `Lock on Overlap` | ❔ (`Available` / ``) | ❔ | the plausible on-disc partners (`Homing`, `OverlapLockon`) are identifier-valued and not yet decoded; **NEEDS-HUMAN** |
### Why the `Ammo Capacity` evidence is not circular
Each UI entry was **identified** by matching its `Ammo Capacity` against the records in
that weapon-type tab, so "the ammo matches" cannot on its own prove the mapping. What does:
1. **The match is forced and unique.** For 10 of the 11 entries, exactly one record in that
tab carries that number (`600` occurs three times overall — `wep_04`, `wep_24`, `wep_55`
— but in three different tabs: MPM, BEAM, B/R). An unrelated quantity would not land on
a valid, tab-unique `LoadingCount` eleven times running.
2. **A second, independent field then agrees.** FALCON 9AM and BUZZARD 10AM were pinned by
ammo alone, and their `Max. Lock Ons` (12, 22) then matched those same records'
`TriggerShotCount` (12, 22) — values that play no part in the identification. A wrong
identification would have to be wrong twice, consistently.
3. **One entry is identified without ammo at all.** STILETTO BG I is described in-game as
"the initially mounted Delta Saber beam gun" and is the weapon the HANGAR shows equipped
on the nose; `wep_01_Beam` is the corresponding record, and its `LoadingCount` 6000 is
what the panel shows.
The weakest identification is LIGHT MACHINE GUN MG I: three guns share `LoadingCount = 3000`
(`wep_09`, `wep_33`, `wep_83`). `wep_33` is picked on `Weight Class = Light` (it has by far
the smallest `Mass`, 1.5 vs 3.6 / 33.0) — 🟡 PROBABLE, not certain.
## Finding — recovered defaults
| Weapon | UI name | Field | On disc | **Runtime** | Conf. |
|---|---|---|---|---|---|
| `Weapon_DSaber_P_wep_05_ASMissile` | TERRIER SMH | `TriggerShotCount` | *(defaulted)* | **4** | 🟡 |
| `Weapon_DSaber_P_wep_60_ASMissile` | HOUND SMH | `TriggerShotCount` | *(defaulted)* | **4** | 🟡 |
Both defaulted weapons read **4**, which is consistent with a single title-code default of
`TriggerShotCount = 4` rather than two per-weapon constants — but two samples cannot tell
those apart. ❔ HYPOTHESIS: *the title-code default for `TriggerShotCount` is 4.* It would
be confirmed by a third weapon that defaults the field and also reads 4 (candidates that
were still locked in this save: `wep_27`, `wep_30`, and the seven Beams), or refuted by one
that reads anything else.
`Power` and `MaximumRange` defaults are **not** exactly recoverable from this panel — it
shows only the letter bucket. They are bracketed instead (below).
## Captured rows
All values are from one session on save slot 01 (Stage 02, "At Standby", 5 % clear, 4101 P);
`✓` marks a value that matches the on-disc record exactly.
| UI name | Record | Rng | Dmg | Spd | Weight | Ammo | Lock | Homing | Overlap | Hard point |
|---|---|---|---|---|---|---|---|---|---|---|
| LIGHT MACHINE GUN MG I | `wep_33_Gun` 🟡 | D | E | | Light | 3000 ✓ | | | | NOSE WEAPON (Nose) |
| BROAD SWORD SG I | *see below* | E | D | | Light | 200 | | | | NOSE WEAPON (Nose) |
| STILETTO BG I | `wep_01_Beam` | E | E | | Light | 6000 ✓ | | | | NOSE WEAPON (Nose) |
| DAGGER BG2 | `wep_37_Beam` | D | E | | Light | 4000 ✓ | | | | NOSE WEAPON (Nose) |
| PILUM BP | `wep_24_Beam` | B | D | | Light | 600 ✓ | | | | MAIN WEAPON1 (Fore) |
| FALCON 9AM | `wep_02_Missile` | D | D | A | Heavy | 300 ✓ | 12 ✓ | Available | | MAIN WEAPON1 (Fore) |
| BUZZARD 10AM | `wep_04_Missile` | C | D | A | Heavy | 600 ✓ | 22 ✓ | | Available | MAIN WEAPON1 (Fore) |
| DART 23 ROCKET | `wep_55_Rocket` | C | D | | Heavy | 600 ✓ | | | | MAIN WEAPON1 (Fore) |
| TERRIER SMH | `wep_05_ASMissile` | D | C | B | Heavy | 45 ✓ | **4** | Available | Available | MAIN WEAPON2 (Rear) |
| HOUND SMH | `wep_60_ASMissile` | B | C | B | Medium | 18 ✓ | **4** | Available | Available | MAIN WEAPON2 (Rear) |
| TOMAHAWK ALPHA RAIL GUN | `wep_03_Cannon` | C | C | | Medium | 75 ✓ | | | | MAIN WEAPON3 (Lower) |
**BROAD SWORD SG I is unidentified — NEEDS-HUMAN.** `Ammo Capacity 200` narrows it to
`wep_38` / `wep_41` / `wep_42_Shotgun` (all `LoadingCount = 200`); "SG" and the GUN tab fit
a shotgun. `wep_42` is excluded by range (2500 m would not share class E with `wep_38`/
`wep_41`'s 3000 m *if* the class is a pure range bucket), leaving `wep_38` vs `wep_41`,
which the panel cannot separate. Its `Damage Class D` also does not fit the `Power` bracket
below (all three shotguns are `Power ≤ 16`, i.e. bucket E), so either the identification or
the "Damage Class = bucket of `Power`" model is wrong for shotguns.
### Letter-class brackets
Sorting the identified rows by their on-disc numbers gives monotone, non-overlapping bands:
```
Range Class E: 3000 (wep_01)
D: 3500 · 4000 · 4000 · 4000 (wep_33, wep_37, wep_02, wep_05)
C: 4500 · 5000 · 5000 (wep_55, wep_04, wep_03)
B: 6500 · 6500 (wep_24, wep_60)
Damage Class E: 10 · 14 · 16 (wep_33, wep_01, wep_37)
D: 70 · 75 · 100 (wep_24, wep_04, wep_55)
C: 200 · 400 (wep_03, wep_05)
```
Two consequences for the reimplementation:
- The **thresholds are not pinned** — only bracketed (e.g. the D/C range boundary lies in
(4000, 4500]). More weapons, or a static read of the title-code table, would pin them.
- They **bracket the defaulted numbers**: `wep_02_Missile`'s defaulted `Power` sits in the
D band (≈ 17…150 by the observed edges) and `wep_60_ASMissile`'s in the C band
(≈ 150…500). 🟡 PROBABLE, and only as good as the bucket model.
## How this was captured
Container recipe (`sylph-container/mission.md`), with two corrections worth keeping:
- **Skip the intro movie with A.** The brief warns it crashes; it does not. Skipping cuts
boot-to-main-menu from ~13 min to **~1 min** under lavapipe. (Thanks: user tip.)
- **The title screen falls back to the attract loop within a few seconds**, so a
screenshot→look→tap cycle always misses it. Poll the framebuffer and tap in the same
process. Both are automated in `scratchpad/skip_intro.sh` (movie detected by frame-to-
frame RMSE; title by the green Ⓐ glyph at pixel 625,618).
- Under lavapipe the game polls input at its own low frame rate: **a 60 ms d-pad tap is
dropped roughly half the time**; 200 ms is reliable and 300 ms starts to auto-repeat.
- The Hangar hard-point weapon carousel is cycled with d-pad **down**, not left/right.
Path: title → A → LOAD GAME → slot 01 → *Load game?* **YES** → READY ROOM → ARSENAL →
type tab (LB/RB) → **Y** for the DATA SHEET → d-pad down through the list.
## Open / next
- Only **9 weapons of ~61** are revealed at 5 % completion, and none of the four whose
`LoadingCount` is defaulted (`wep_11`, `wep_28`, `wep_36`, `wep_70`) is among them.
Progressing the save (or a later save) is what unlocks the rest — the panel itself
already shows undeveloped weapons, so no points need to be spent.
- **Craft (`UNIT`-schema) defaults are not reachable this way.** The Hangar exposes exactly
one craft-level runtime number, `Gross Weight` (a class, "Light"). The defaulted craft
fields (`ShieldRatio`, `BarrelRoll_Count*`, `HoldPosition_*Ratio`, `Slalom_TurnCount_Max`,
`UsingChaffRatio`, …) are AI/flight-model constants with no UI surface; they would need
in-flight behavioural measurement or a guest-memory read, not a menu screenshot.
- The `Sight Homing` / `Lock on Overlap` on-disc partners are still unidentified.
Screenshots for every row above: `/sylph-home/re/caps/` in the container.
Evidence PNGs are committed under [`captures/weapon-datasheet/`](captures/weapon-datasheet/)
(64-colour quantized for size; the numbers stay legible).
---
# Addendum — the in-flight HUD (2026-07-28)
Reached via **TUTORIAL → BASIC CONTROLS / HEADS-UP DISPLAY** from the title menu (no
save needed). Evidence: [`captures/hud-runtime/`](captures/hud-runtime/).
## The HUD corroborates the ammo mapping, independently
The Delta Saber's HUD prints its two equipped weapons as `NOSE <TYPE> <AMMO>` and
`MAIN <TYPE> <AMMO>`. With the loadout known from the HANGAR (nose = STILETTO BG I, main =
FALCON 9AM) the HUD reads **`NOSE BM 06000`** and **`MAIN MPM 00300`** — exactly
`wep_01_Beam`'s `LoadingCount = 6000` and `wep_02_Missile`'s `300`.
This matters because it is **not** the Arsenal panel: the weapons were identified from the
HANGAR loadout, and the numbers come from a different renderer in a different game mode. It
is a genuinely independent confirmation of `Ammo Capacity == LoadingCount`.
The type tags (`BM`, `MPM`) are the same short codes as the Arsenal type tabs.
## HUD element inventory
| HUD element | Backing data | Conf. |
|---|---|---|
| `NOSE` / `MAIN` + type tag + 5-digit ammo | equipped weapon, `LoadingCount` | ✅ |
| `HEAT` / `L.HEAT` bar under each weapon | the `Heating` / `Cooling` pair | 🟡 |
| Speed readout + throttle scale (`0`, `100`, `A/B`) | craft velocity | ✅ |
| `SHIELD` and `ARMOR` bars (two separate pools) | craft `HP` + a shield pool | 🟡 |
| Target reticle: name / type / distance + ring **Armor Gauge** | target unit record | 🟡 |
| `RANGE` gauge with `MAIN` and `NOSE` tick markers | each equipped weapon's `MaximumRange`, plotted against target distance | 🟡 |
| `YOU KILLED: WARSHIPS nnnn` + a second counter | score / `ScorePoint` | ❔ |
The `RANGE` gauge is the interesting one for future work: it draws a **per-weapon marker on
a distance scale**, so a weapon whose `MaximumRange` is defaulted on disc (`wep_25`,
`wep_58`, `wep_82`) would have its value *drawn* rather than bucketed into a letter — if the
scale can be calibrated against two weapons with known ranges, that recovers a real number
where the Arsenal panel only gives a class.
## Craft velocity
Full afterburner (RT) peaked at **1193** against `UN_f001_TCAF_DeltaSaber_T`'s on-disc
`MaximumVelocity = 1200`. 🟡 PROBABLE — one observation, and the readout may have been
still climbing. Cruise sat at 350, which is *not* the record's `CruisingVelocity` (700), so
the number is the current throttle setting, not a named constant; don't read more into it.
## Notes for the next session
- The tutorials need **no save game** and are reachable in ~1 min from a cold boot, which
makes them the cheapest way back into a live flight scene.
- `tools/re-capture/autopilot.py` chases the yellow off-screen waypoint arrow (colour +
shape, `-sample` not `-resize`, ~0.65 s per detection). It **finds the arrow reliably but
oscillates** — the proportional gain is too high for the craft's turn rate. It needs a
damping/derivative term before it can actually fly a waypoint.
- The HEADS-UP DISPLAY tutorial reaches a scripted targeting segment where the ship stops
moving (target distance pinned) and the on-screen controller highlights **A**; tapping and
holding A did not advance it. **NEEDS-HUMAN**: what input that segment wants.
- Canary is unstable here: it died twice mid-session (once loading BEAM `Resource3D`, once
hanging on tutorial teardown) with no crash dump. Re-launch is cheap; just don't assume a
long session survives.

View File

@@ -1,20 +0,0 @@
# Runtime-capture harness (sylph-re container)
Screenshot-driven scripts for reading the running retail game's menus under Xenia
Canary + lavapipe, headless. They assume the container helpers `screenshot`,
`vgamepad`, `pad` are on `$PATH` and `HOME=/sylph-home/re`.
| Script | What it does |
|---|---|
| `skip_intro.sh` | Boot → main menu, unattended. Taps A only while the intro movie is actually playing (frame-to-frame RMSE), then once at the `PRESS Ⓐ BUTTON` title. Static logo screens are left alone, so a stray tap can never land on NEW GAME. |
| `wait_title.sh` | Older variant: wait for the title (green Ⓐ glyph at px 625,618) and tap A. Superseded by `skip_intro.sh`. |
| `step.sh` | One Arsenal navigation step (`down`/`up`/`next`/`prev`/`none`) + a compact capture: weapon list stacked over the `DATA SHEET`. |
| `sweep.sh` | Walk a whole weapon-type list, capturing **only** rows that show a `DATA SHEET` — locked rows (a "Conditions to Develop" panel) are detected by the brightness of the `Range Class` label box and skipped. |
| `type.sh` | Change weapon-type tab N times (RB) and report the header strip. |
| `hp.sh` / `cyc.sh` | Hangar hard-point carousel: `cyc.sh` steps it (d-pad **down**, not left/right) and captures the Name + `DATA SHEET`. |
**Input timing under lavapipe:** the game polls input at its own low frame rate, so a
60 ms d-pad tap is dropped roughly half the time. 200 ms is reliable; 300 ms starts to
auto-repeat (two rows per press).
Findings produced with these: [`docs/re/weapon-datasheet-runtime.md`](../../docs/re/weapon-datasheet-runtime.md).

View File

@@ -1,95 +0,0 @@
#!/usr/bin/env python3
"""Fly the Delta Saber toward the tutorial waypoint by chasing the yellow
off-screen direction arrow.
Detection: no PIL/numpy in the box, so the frame comes from `convert ... txt:-`.
Two things make it fast AND correct:
* `-sample` (point sampling, not `-resize`/`-scale` averaging) — a 1-px-thin
glyph keeps its true colour, so the exact colour predicate still fires while
the dump shrinks ~9x (3.5s -> 0.65s).
* shape, not just colour — the instruction-frame bars and the throttle marker
are the same dim yellow, so the arrow is the largest blob that is roughly as
tall as it is wide, with the fixed throttle marker blacklisted by position.
A ~1.1 s control loop is fast enough to converge; the earlier ~6 s loop was not.
"""
import re, subprocess, sys, time
X0, Y0, W, H = 80, 60, 820, 640 # flight view (the arrow also rides the bottom edge)
K = 3.03 # 1/0.33 sample factor
CX, CY = 640, 405 # crosshair
THROTTLE = (382, 456) # fixed yellow decoy on the speed gauge
PX = re.compile(r"^(\d+),(\d+):.*?srgb\((\d+),(\d+),(\d+)\)")
def pad(*a):
subprocess.run(["/opt/sylph/vgamepad.py", *a], check=False)
def arrow(png="/tmp/ap.png"):
subprocess.run(["/opt/sylph/screenshot.sh", png], check=False,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
out = subprocess.run(["convert", png, "-crop", f"{W}x{H}+{X0}+{Y0}", "+repage",
"-sample", "33%", "txt:-"], capture_output=True, text=True).stdout
pts = set()
for l in out.splitlines()[1:]:
m = PX.match(l)
if not m:
continue
x, y, r, g, b = (int(v) for v in m.groups())
if r >= 60 and g >= 48 and b <= 0.35 * g and (r - g) <= 0.45 * r:
pts.add((x, y))
seen, best = set(), None
for p in pts:
if p in seen:
continue
st, c = [p], []
seen.add(p)
while st:
x, y = st.pop(); c.append((x, y))
for dx in (-1, 0, 1):
for dy in (-1, 0, 1):
q = (x + dx, y + dy)
if q in pts and q not in seen:
seen.add(q); st.append(q)
xs = [q[0] for q in c]; ys = [q[1] for q in c]
cx, cy = X0 + sum(xs) / len(c) * K, Y0 + sum(ys) / len(c) * K
if abs(cx - THROTTLE[0]) < 30 and abs(cy - THROTTLE[1]) < 30:
continue
if len(c) < 8:
continue
if best is None or len(c) > best[2]:
best = (cx, cy, len(c))
return best
def main():
steps = int(sys.argv[1]) if len(sys.argv) > 1 else 25
centred = 0
for i in range(steps):
a = arrow()
if a is None:
print(f"{i:2d}: no arrow -> boost (target ahead)")
pad("trig", "RT", "0.55"); time.sleep(1.2); pad("trig", "RT", "0")
centred += 1
if centred >= 6:
print(" arrival likely — stopping"); return 0
continue
x, y, n = a
dx, dy = x - CX, y - CY
if abs(dx) < 70 and abs(dy) < 70:
centred += 1
print(f"{i:2d}: arrow ({x:.0f},{y:.0f}) centred -> boost")
pad("trig", "RT", "0.55"); time.sleep(1.2); pad("trig", "RT", "0")
continue
centred = 0
lx = max(-1.0, min(1.0, dx / 200))
ly = max(-1.0, min(1.0, dy / 160))
print(f"{i:2d}: arrow ({x:.0f},{y:.0f}) n={n} -> LX {lx:+.2f} LY {ly:+.2f}")
pad("axis", "LX", f"{lx:.2f}"); pad("axis", "LY", f"{ly:.2f}")
time.sleep(0.45)
pad("axis", "LX", "0"); pad("axis", "LY", "0")
print("steps exhausted")
return 1
sys.exit(main())

View File

@@ -1,9 +0,0 @@
#!/usr/bin/env bash
# Step the Hangar weapons-container carousel right and capture the Name+DATA SHEET.
set -u
export HOME=/sylph-home/re
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
vgamepad dpad right; sleep 0.30; vgamepad dpad center; sleep 3.0
screenshot /tmp/h.png >/dev/null 2>&1
convert /tmp/h.png -crop 530x480+700+95 +repage "$OUT/$1.png"
echo "$OUT/$1.png"

View File

@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# Cycle the Hangar hard-point weapon carousel one step right and capture the
# Name + DATA SHEET panel (the mountable-weapon list for that hard point).
set -u
export HOME=/sylph-home/re
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
[ "${1:-}" = "none" ] || { vgamepad dpad right; sleep 0.20; vgamepad dpad center; }
sleep 2.5
screenshot /tmp/h.png >/dev/null 2>&1
convert /tmp/h.png -crop 495x460+705+95 +repage "$OUT/$2.png"
echo "$OUT/$2.png"

View File

@@ -1,41 +0,0 @@
#!/usr/bin/env python3
"""Parse a RATC bundle's header SPRITE DECLARATION TABLE: u32 count at 0x14, then
fixed 60-byte entries of [name, NUL-padded | 4 u32 flags | pivotX | pivotY | 0].
Check each pivot against half the decoded texture's real dimensions."""
import struct, sys, glob, re, os
def decls(d):
n = struct.unpack_from(">I", d, 0x14)[0]
out = []
for i in range(n):
o = 0x20 + i * 60
if o + 60 > len(d): break
name = d[o:o+28].split(b"\0")[0].decode("ascii", "replace")
px, py = struct.unpack_from(">II", d, o + 48)
out.append((name, px, py))
return n, out
def texmap(prefix):
t = {}
for f in glob.glob(f"pause-tex/{prefix}_*.png"):
m = re.match(rf".*/{prefix}_(.+)\.t32_(\d+)x(\d+)\.png", f)
if m: t[m.group(1) + ".t32"] = (int(m.group(2)), int(m.group(3)))
return t
for path, prefix in [(sys.argv[1], sys.argv[2])]:
d = open(path, "rb").read()
n, ds = decls(d)
tm = texmap(prefix)
print(f"{os.path.basename(path)}: count={n}, parsed={len(ds)}")
ok = bad = miss = 0
for name, px, py in ds:
if name in tm:
w, h = tm[name]
hit = (px == w // 2 and py == h // 2)
ok, bad = ok + hit, bad + (not hit)
flag = "OK " if hit else "MISMATCH"
print(f" {flag} {name:28s} tex {w:4d}x{h:<4d} half {w//2:4d},{h//2:<4d} decl {px:4d},{py:<4d}")
else:
miss += 1
print(f" ? {name:28s} (no decoded texture) decl {px:4d},{py:<4d}")
print(f" => pivot == half(texture): {ok} ok, {bad} mismatch, {miss} unchecked")

View File

@@ -1,27 +0,0 @@
#!/usr/bin/env bash
# Drive the boot sequence to the main menu without a human in the loop.
# - movie playing (two grabs 0.6s apart differ a lot) -> tap A to skip
# - static screen -> if the green "PRESS (A) BUTTON" glyph is there, tap A and stop
# Static logo screens are left alone, so a stray tap can never land on NEW GAME.
set -u
export HOME=/sylph-home/re
# The X root keeps the DEAD session's last frame, so a fresh launch would be
# detected as "already at the title". Blank it, and wait for the new window.
xsetroot -solid black 2>/dev/null || true
until xdotool search --name "Xenia-canary" >/dev/null 2>&1; do sleep 1; done
deadline=$(( SECONDS + ${1:-900} ))
while [ $SECONDS -lt $deadline ]; do
screenshot /tmp/f1.png >/dev/null 2>&1; sleep 0.6
screenshot /tmp/f2.png >/dev/null 2>&1
d=$(compare -metric RMSE /tmp/f1.png /tmp/f2.png null: 2>&1 | sed 's/ .*//' | cut -d. -f1)
d=${d:-0}
read -r r g b < <(convert /tmp/f2.png -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:)
if [ "$g" -gt 130 ] && [ $((g - r)) -gt 45 ] && [ $((g - b)) -gt 45 ]; then
echo "TITLE at ${SECONDS}s -> A"; vgamepad tap A 250; exit 0
fi
if [ "$d" -gt 1500 ]; then
echo "movie (rmse $d) at ${SECONDS}s -> skip A"; vgamepad tap A 250; sleep 3
fi
sleep 1
done
echo "TIMEOUT"; exit 1

View File

@@ -1,22 +0,0 @@
#!/usr/bin/env bash
# One Arsenal navigation step + a compact capture: the weapon list (which row is
# selected) stacked over the DATA SHEET numeric rows. Everything else is dropped.
# Usage: step.sh <down|up|next|prev|none> <tag> [settle_s]
set -u
export HOME=/sylph-home/re
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
case "${1}" in
down) vgamepad dpad down; sleep 0.20; vgamepad dpad center ;;
up) vgamepad dpad up; sleep 0.20; vgamepad dpad center ;;
next) vgamepad hold RB 0.35 ;;
prev) vgamepad hold LB 0.35 ;;
none) : ;;
esac
sleep "${3:-2.5}"
raw="$OUT/$2.raw.png"
screenshot "$raw" >/dev/null 2>&1
convert "$raw" -crop 500x420+150+180 +repage /tmp/_list.png
convert "$raw" -crop 500x200+700+100 +repage /tmp/_sheet.png
convert /tmp/_list.png /tmp/_sheet.png -background black -append "$OUT/$2.png"
rm -f "$raw"
echo "$OUT/$2.png"

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# Walk down a weapon-type list N rows; capture ONLY rows that show a DATA SHEET
# (locked rows show a "Conditions to Develop" panel instead — detected by the
# brightness of the "Range Class" label box, ~5 when absent, >>20 when present).
set -u
export HOME=/sylph-home/re
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
pfx=$1; n=${2:-8}
for ((i=1;i<=n;i++)); do
vgamepad dpad down; sleep 0.20; vgamepad dpad center; sleep 3.0
screenshot /tmp/s.png >/dev/null 2>&1
m=$(convert /tmp/s.png -crop 150x24+730+116 +repage -colorspace Gray -format "%[fx:int(255*mean)]" info:)
if [ "$m" -gt 45 ]; then
convert /tmp/s.png -crop 500x420+150+180 +repage /tmp/_l.png
convert /tmp/s.png -crop 500x200+700+100 +repage /tmp/_s.png
convert /tmp/_l.png /tmp/_s.png -background black -append "$OUT/$pfx-r$i.png"
echo "row $i: DATA SHEET -> $OUT/$pfx-r$i.png"
else
echo "row $i: locked (mean $m)"
fi
done

View File

@@ -1,11 +0,0 @@
#!/usr/bin/env bash
# Change Arsenal weapon type N times (RB) and report the header strip only.
set -u
export HOME=/sylph-home/re
OUT=/sylph-home/re/caps; mkdir -p "$OUT"
n=${1:-1}
for ((i=0;i<n;i++)); do vgamepad hold RB 0.35; sleep 2.5; done
sleep 1.5
screenshot /tmp/hdr.png >/dev/null 2>&1
convert /tmp/hdr.png -crop 420x50+130+148 +repage "$OUT/hdr.png"
echo "$OUT/hdr.png"

View File

@@ -1,21 +0,0 @@
#!/usr/bin/env bash
# Poll the framebuffer for the "PRESS (A) BUTTON" title screen (the green A glyph
# at ~625,618) and tap A the instant it appears — the title auto-returns to the
# attract loop after a few seconds, which is why a human-paced tap misses it.
set -u
export HOME=/sylph-home/re
TMP=/tmp/title-probe.png
deadline=$(( SECONDS + ${1:-900} ))
while [ $SECONDS -lt $deadline ]; do
if screenshot "$TMP" >/dev/null 2>&1; then
read -r r g b < <(convert "$TMP" -format "%[fx:int(255*p{625,618}.r)] %[fx:int(255*p{625,618}.g)] %[fx:int(255*p{625,618}.b)]" info:)
if [ "$g" -gt 130 ] && [ $((g - r)) -gt 45 ] && [ $((g - b)) -gt 45 ]; then
echo "TITLE detected (rgb $r,$g,$b) at ${SECONDS}s — tapping A"
vgamepad tap A 200
exit 0
fi
fi
sleep 1
done
echo "TIMEOUT: title not seen"
exit 1