formats: consolidate ui_layout onto the declaration table; add a savegame parser

Two independent lines landed a `.rat` reading and neither was the whole
picture, so this merges them into one module and fixes what the merge exposed.

ui_layout — the screen is the BUNDLE, not the set of .rat records
------------------------------------------------------------------
`feat/ui-layout-preview` parsed `.rat` records; the autopilot stack documented
the RATC header and probed it in `examples/screen_layout.rs` but never landed a
library module. The `.rat`-only reading structurally cannot see an element that
has no record -- the `eff*` frame corners, the `deli*` dividers, `msg` -- which
is exactly what the committed real-vs-rebuilt capture shows missing. Rebuilt
around the header:

  * element declaration table at 0x20 (60-byte entries: name, parent index at
    +32, kind flags, pivot) = the back-to-front draw list;
  * the placement region after it = per-element keyframe groups.

Verified against the disc, each against a fact the docs state independently:
`pgpeff02a` -> parent 3 = `pgpeff02`; `pgp_ttrl_btn10` rests at (546,288); the
pause buttons sit at 268/337/407/478, the documented 70 px pitch; the Arsenal
carries X = -516. The tutorial PAUSE menu now composites 11/11 elements and
matches the real screen more closely than the earlier rebuild did.

Three defects found while validating, none of which any test would have caught:

  * the keyframe block is 40 bytes with X/Y/time at +28/+32/+36 and an
    alpha-ramping ARGB at +0 -- the fade, previously unread;
  * a group's data stops 4 bytes short of its last block's time slot, so that
    word is the NEXT group's element index. Reading it produced times like
    1869640736 and silently corrupted the max-dwell pick. Last-frame time is
    now `None`;
  * the `.rat` sprite-name field is not 16 bytes. Capping it there truncated
    `pgp_ttrl_title.t32` to `pgp_ttrl_title.t`, which resolved against nothing
    and dropped 4 of 11 tutorial elements from the composite.

Max-dwell also needed a tie-break: on equal gaps take the LATER frame, or
`pgpmsg` reports the y=645 fly-through instead of the y=605 it settles at.

savegame -- a Rust port of tools/re-capture/savegame.py
------------------------------------------------------
GDHA container, zlib payload, chunk stream (GDAA / phase / GHAD 122 B / 16x20 B
SHAB / trailer). Every GHAD word carries its own confidence rather than the
block being presented as solved: 6 named, 2 recorded as REFUTED (+36, +56 were
tested as difficulty and as stage and are neither), 7 still unknown.

Tested against the three real saves committed under docs/re/captures -- no disc
and no emulator needed. The load-bearing assertion is the byte-identical
round-trip; the develop differential is asserted as a property (spending 4000 P
moves +24 and not its twin +28, steps the clear ratio, and moves exactly two
blob entries), and the header summary is checked to agree with the payload it
mirrors -- the trap that makes the Details panel a bad oracle.

CLI: `screen list|info|render` and `save info`, so both are checkable headlessly
in the same spirit as `mesh render`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-17 18:59:27 +02:00
parent 6d5b13e395
commit dc96bace6f
6 changed files with 1717 additions and 160 deletions

View File

@@ -109,6 +109,63 @@ enum Commands {
#[command(subcommand)]
cmd: AudioCommands,
},
/// UI screen tools — reassemble a screen from its RATC bundle
Screen {
#[command(subcommand)]
cmd: ScreenCommands,
},
/// Save file (`savedata`) tools
Save {
#[command(subcommand)]
cmd: SaveCommands,
},
}
#[derive(Subcommand)]
enum ScreenCommands {
/// List the screen builds in a UI pak, with their element counts
List {
/// Path to a `GP_*.pak`
pak: PathBuf,
},
/// Print one build's element declaration table and resting placements
Info {
/// Path to a `GP_*.pak`
pak: PathBuf,
/// Which build (index into `screen list`); default = the largest
#[arg(long)]
build: Option<usize>,
},
/// Composite one build to a PNG — the headless self-verify for the viewer
Render {
/// Path to a `GP_*.pak`
pak: PathBuf,
/// Output PNG
output: PathBuf,
/// Which build (index into `screen list`); default = the largest
#[arg(long)]
build: Option<usize>,
/// Draw the focused-state (`*f`) records over their base elements
#[arg(long)]
focus: bool,
/// Draw `loop*` sprite animations
#[arg(long)]
animated: bool,
},
}
#[derive(Subcommand)]
enum SaveCommands {
/// Parse a `savedata` file and print every field with its confidence
Info {
/// Path to a `savedata` file
file: PathBuf,
/// Also print the still-unidentified fields
#[arg(long)]
all: bool,
},
}
#[derive(Subcommand)]
@@ -237,9 +294,289 @@ async fn main() -> Result<()> {
Commands::Audio { cmd } => match cmd {
AudioCommands::Info { file } => cmd_audio_info(&file),
},
Commands::Screen { cmd } => match cmd {
ScreenCommands::List { pak } => cmd_screen_list(&pak),
ScreenCommands::Info { pak, build } => cmd_screen_info(&pak, build),
ScreenCommands::Render { pak, output, build, focus, animated } => {
cmd_screen_render(&pak, &output, build, focus, animated)
}
},
Commands::Save { cmd } => match cmd {
SaveCommands::Info { file, all } => cmd_save_info(&file, all),
},
}
}
// ── UI screens ───────────────────────────────────────────────────────────────
/// Every RATC entry of a UI pak that parses as a screen build, with its bytes.
fn screen_builds(pak: &Path) -> Result<Vec<(usize, Vec<u8>)>> {
use sylpheed_formats::{pak::PakArchive, ui_layout};
let ar = PakArchive::open(pak).context("open pak")?;
let mut out = Vec::new();
for (i, e) in ar.entries().iter().enumerate() {
let Ok(bytes) = ar.read(e) else { continue };
if ui_layout::is_build(&bytes) {
out.push((i, bytes));
}
}
Ok(out)
}
/// Resolve `--build`: an explicit index into the build list, else the largest
/// build (a screen pak's biggest bundle is the full screen; the small ones are
/// language or context variants).
fn pick_build(builds: &[(usize, Vec<u8>)], want: Option<usize>) -> Result<usize> {
if builds.is_empty() {
anyhow::bail!("no screen builds in this pak");
}
match want {
Some(i) if i < builds.len() => Ok(i),
Some(i) => anyhow::bail!("build {i} out of range (0..{})", builds.len()),
None => Ok(builds
.iter()
.enumerate()
.max_by_key(|(_, (_, b))| b.len())
.map(|(i, _)| i)
.unwrap()),
}
}
fn cmd_screen_list(pak: &Path) -> Result<()> {
use sylpheed_formats::ui_layout;
let builds = screen_builds(pak)?;
println!("{} screen build(s) in {}", builds.len(), pak.display());
for (i, (entry, bytes)) in builds.iter().enumerate() {
match ui_layout::parse_build(bytes) {
Some(b) => println!(
" [{i}] entry {entry:<3} {:>8} B {}x{} {} elements, {} sprites{}{}",
bytes.len(),
b.design_w,
b.design_h,
b.elements.len(),
b.sprites.len(),
b.context_hint
.as_deref()
.map(|c| format!(" context={c}"))
.unwrap_or_default(),
if b.from_fallback { " (fallback)" } else { "" },
),
None => println!(" [{i}] entry {entry:<3} {:>8} B (unparsed)", bytes.len()),
}
}
Ok(())
}
fn cmd_screen_info(pak: &Path, want: Option<usize>) -> Result<()> {
use sylpheed_formats::ui_layout;
let builds = screen_builds(pak)?;
let idx = pick_build(&builds, want)?;
let bytes = &builds[idx].1;
let b = ui_layout::parse_build(bytes).context("build did not parse")?;
println!(
"build [{idx}] {}x{} {} elements {} sprites{}",
b.design_w,
b.design_h,
b.elements.len(),
b.sprites.len(),
if b.from_fallback {
" (recovered from .rat records — the declaration table was unusable)"
} else {
""
}
);
println!(
"{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} rest / keyframes",
"#", "element", "parent", "kind", "pivot", "kf"
);
for el in &b.elements {
// The resting pose is the max-dwell keyframe, not the first or the last.
let rest = match el.rest() {
None => "".to_string(),
Some(k) if el.keyframes.len() == 1 => format!("({},{})", k.x, k.y),
Some(k) => {
// The final frame of a group carries no time — print it as `-`
// rather than inventing one.
let t = |t: Option<u32>| t.map(|v| v.to_string()).unwrap_or_else(|| "-".into());
format!(
"rest ({},{}) t={} [{}]",
k.x,
k.y,
t(k.time),
el.keyframes
.iter()
.map(|f| format!("{}:{},{}", t(f.time), f.x, f.y))
.collect::<Vec<_>>()
.join(" ")
)
}
};
println!(
"{:<3} {:<30} {:>7} {:>8} {:>12} {:>4} {rest}",
el.index,
el.name,
el.parent.map(|p| p.to_string()).unwrap_or_else(|| "-".into()),
format!("{:#x}", el.kind),
format!("({},{})", el.pivot_x, el.pivot_y),
el.keyframes.len(),
);
if let Some(link) = &el.focus_link {
println!("{:<3} {:<30} → focus {link}", "", "");
}
}
Ok(())
}
fn cmd_screen_render(
pak: &Path,
output: &Path,
want: Option<usize>,
focus: bool,
animated: bool,
) -> Result<()> {
use sylpheed_formats::ui_layout::{self, ComposeOptions};
let builds = screen_builds(pak)?;
let idx = pick_build(&builds, want)?;
let bytes = &builds[idx].1;
let b = ui_layout::parse_build(bytes).context("build did not parse")?;
let screen = ui_layout::compose(
&b,
bytes,
ComposeOptions {
include_focus: focus,
include_animated: animated,
},
None,
);
image::save_buffer(
output,
&screen.rgba,
screen.width,
screen.height,
image::ExtendedColorType::Rgba8,
)
.context("write PNG")?;
println!(
"build [{idx}]: drew {}/{} elements → {} ({}x{})",
screen.drawn.len(),
b.elements.len(),
output.display(),
screen.width,
screen.height
);
if !screen.missing.is_empty() {
println!(" sprites that did not resolve/decode: {:?}", screen.missing);
}
let undrawn: Vec<&str> = b
.elements
.iter()
.filter(|e| !screen.drawn.contains(&e.index))
.map(|e| e.name.as_str())
.collect();
if !undrawn.is_empty() {
println!(" not drawn ({}): {undrawn:?}", undrawn.len());
}
Ok(())
}
// ── save file ────────────────────────────────────────────────────────────────
fn cmd_save_info(file: &Path, all: bool) -> Result<()> {
use sylpheed_formats::savegame::{
self, Confidence, DevelopState, FieldKind, GHAD_LAYOUT,
};
let raw = std::fs::read(file).context("read save")?;
let save = savegame::parse(&raw).map_err(|e| anyhow::anyhow!("{e}"))?;
let mark = |c: Confidence| match c {
Confidence::Confirmed => "OK ",
Confidence::Probable => "~ ",
Confidence::Unknown => "? ",
Confidence::Refuted => "REF",
};
println!(
"container : GDHA, {} B header + {} B deflate → {} B payload",
save.header.bytes.len(),
raw.len() - save.header.bytes.len(),
save.payload.len()
);
println!(
"round-trip: {}",
if save.round_trips() {
"byte-identical"
} else {
"MISMATCH — the parse is wrong"
}
);
println!("phase : {}", save.phase);
println!("\nGHAD progress block:");
for f in GHAD_LAYOUT {
if !all && f.confidence == Confidence::Unknown && f.name.is_empty() {
continue;
}
let value = match f.kind {
FieldKind::Millis => save
.ghad_value(f)
.map(|v| format!("{v} ms ({})", savegame::fmt_millis(v as u32))),
FieldKind::Percent => save.ghad_value(f).map(|v| format!("{v} %")),
FieldKind::Raw | FieldKind::DevelopBlob => Some(
save.ghad_bytes(f)
.iter()
.map(|b| format!("{b:02x}"))
.collect::<Vec<_>>()
.join(" "),
),
_ => save.ghad_value(f).map(|v| format!("{v}")),
}
.unwrap_or_else(|| "-".into());
println!(
" {} +{:<3} {:<14} {}",
mark(f.confidence),
f.offset,
if f.name.is_empty() { "(unnamed)" } else { f.name },
value
);
if all && !f.note.is_empty() {
println!(" {}", f.note);
}
}
let dev = save.develop_state();
let owned = dev.iter().filter(|d| **d == DevelopState::Developed).count();
let ready = dev
.iter()
.filter(|d| **d == DevelopState::Developable)
.count();
println!(
"\nArsenal : {owned} developed, {ready} developable, {} locked (of {})",
dev.len() - owned - ready,
dev.len()
);
println!("\nper-stage records (SHAB — NOT the UI's save slots):");
for (i, r) in save.records.iter().enumerate() {
if !r.is_used() {
continue;
}
println!(
" stage {:02} difficulty~{} points?{} best {} ",
i + 1,
r.a,
r.b,
savegame::fmt_millis(r.best_time_ms)
);
}
println!("\nheader summary (what the in-game Details panel reads):");
for m in save.header.summary() {
println!(" +{:#04x} {:<14} {}", m.header_offset, m.name, m.value);
}
println!(" — a payload edit that leaves these stale shows no change on the panel,");
println!(" which is not evidence that the payload field was the wrong one.");
Ok(())
}
// ── audio info ───────────────────────────────────────────────────────────────
fn cmd_audio_info(file: &Path) -> Result<()> {