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:
@@ -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<()> {
|
||||
|
||||
Reference in New Issue
Block a user