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>
153 lines
5.7 KiB
Rust
153 lines
5.7 KiB
Rust
//! The save parser against the three real saves committed under `docs/re/captures`.
|
|
//!
|
|
//! These need no disc and no emulator — the samples are in the repo — so this
|
|
//! runs in plain `cargo test`. The load-bearing assertion is the **byte-identical
|
|
//! round-trip**: the title's serializer writes its struct field by field with no
|
|
//! packing, so a correct parse must reproduce the payload exactly. Anything less
|
|
//! means a field has the wrong width or the chunk stream has an unread gap.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use sylpheed_formats::savegame::{self, Confidence, DevelopState, GHAD_SIZE, RECORD_COUNT};
|
|
|
|
fn captures() -> PathBuf {
|
|
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/re/captures")
|
|
}
|
|
|
|
fn sample(name: &str) -> Vec<u8> {
|
|
std::fs::read(captures().join(name)).unwrap_or_else(|e| panic!("read {name}: {e}"))
|
|
}
|
|
|
|
const SAMPLES: [&str; 3] = [
|
|
"savedata-game02-samestate.bin",
|
|
"savedata-game03-developed-mg1.bin",
|
|
"savedata-stage02-5pct.bin",
|
|
];
|
|
|
|
#[test]
|
|
fn every_sample_parses_and_round_trips_byte_identically() {
|
|
for name in SAMPLES {
|
|
let raw = sample(name);
|
|
let save = savegame::parse(&raw).unwrap_or_else(|e| panic!("{name}: {e}"));
|
|
assert_eq!(save.payload.len(), 545, "{name}: the whole save is 545 bytes");
|
|
assert_eq!(save.ghad.len(), GHAD_SIZE, "{name}");
|
|
assert_eq!(save.records.len(), RECORD_COUNT, "{name}");
|
|
assert!(
|
|
save.round_trips(),
|
|
"{name}: re-serializing did not reproduce the payload"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn phase_is_one_of_the_titles_screen_ids() {
|
|
for name in SAMPLES {
|
|
let save = savegame::parse(&sample(name)).unwrap();
|
|
assert!(
|
|
save.phase.starts_with("GP_"),
|
|
"{name}: phase {:?} is not a GP_* screen id",
|
|
save.phase
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn trailer_closes_the_stream() {
|
|
for name in SAMPLES {
|
|
let save = savegame::parse(&sample(name)).unwrap();
|
|
let (tname, tag, _) = &save.trailer;
|
|
assert_eq!(tname, "BUNK", "{name}");
|
|
assert_eq!(tag, "NETA", "{name}");
|
|
}
|
|
}
|
|
|
|
/// The develop differential: the `game03` save was taken after developing exactly
|
|
/// one Arsenal weapon (Light Machine Gun MG I, 4000 P) from the `game02` state.
|
|
/// Exactly three things moved, and this is what gave the blob its alphabet.
|
|
#[test]
|
|
fn developing_one_weapon_moves_points_ratio_and_two_blob_entries() {
|
|
let before = savegame::parse(&sample("savedata-game02-samestate.bin")).unwrap();
|
|
let after = savegame::parse(&sample("savedata-game03-developed-mg1.bin")).unwrap();
|
|
|
|
// Points fell by the item's 4000 P cost — and +28 did NOT move, which is what
|
|
// separates the spendable balance from its twin.
|
|
let (p0, p1) = (before.points().unwrap(), after.points().unwrap());
|
|
assert_eq!(p0 - p1, 4000, "Points should fall by the 4000 P cost");
|
|
let twin = |s: &savegame::SaveGame| {
|
|
savegame::GHAD_LAYOUT
|
|
.iter()
|
|
.find(|f| f.offset == 28)
|
|
.and_then(|f| s.ghad_value(f))
|
|
.unwrap()
|
|
};
|
|
assert_eq!(twin(&before), twin(&after), "+28 must not move on a spend");
|
|
|
|
// The clear ratio counts collection, not only stages.
|
|
assert_eq!(
|
|
after.clear_ratio_pct().unwrap(),
|
|
before.clear_ratio_pct().unwrap() + 1
|
|
);
|
|
|
|
// Blob: the bought item became developed, its successor became developable.
|
|
let (b, a) = (before.develop_state(), after.develop_state());
|
|
let moved: Vec<usize> = (0..b.len()).filter(|&i| b[i] != a[i]).collect();
|
|
assert_eq!(moved.len(), 2, "exactly two blob entries move, got {moved:?}");
|
|
assert_eq!(a[moved[0]], DevelopState::Developed);
|
|
assert_eq!(a[moved[1]], DevelopState::Developable);
|
|
}
|
|
|
|
/// The two saves of the same state differ only in the header (its FILETIME and
|
|
/// the uninitialised pointer padding) — the payload is a pure function of game
|
|
/// state.
|
|
#[test]
|
|
fn payload_is_a_pure_function_of_game_state() {
|
|
let a = savegame::parse(&sample("savedata-game02-samestate.bin")).unwrap();
|
|
let b = savegame::parse(&sample("savedata-stage02-5pct.bin")).unwrap();
|
|
assert_eq!(
|
|
a.payload, b.payload,
|
|
"the same state saved twice must deflate to the same payload"
|
|
);
|
|
}
|
|
|
|
/// The summary copy the Details panel reads must agree with the payload it
|
|
/// mirrors — the two are written together, and a disagreement is what a
|
|
/// payload-only edit produces.
|
|
#[test]
|
|
fn header_summary_agrees_with_the_payload_it_mirrors() {
|
|
for name in SAMPLES {
|
|
let save = savegame::parse(&sample(name)).unwrap();
|
|
for m in save.header.summary() {
|
|
let Some(goff) = m.ghad_offset else { continue };
|
|
let spec = savegame::GHAD_LAYOUT
|
|
.iter()
|
|
.find(|f| f.offset == goff)
|
|
.expect("mirror names a real GHAD field");
|
|
let payload_value = save.ghad_value(spec).unwrap();
|
|
assert_eq!(
|
|
u64::from(m.value),
|
|
payload_value,
|
|
"{name}: header {:#x} ({}) disagrees with GHAD +{}",
|
|
m.header_offset,
|
|
m.name,
|
|
goff
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The eleven still-unknown GHAD fields are a documented fact, not an oversight.
|
|
/// If a future session names one, this count moves — deliberately.
|
|
#[test]
|
|
fn unknown_fields_are_still_declared_unknown() {
|
|
let unknown = savegame::GHAD_LAYOUT
|
|
.iter()
|
|
.filter(|f| f.confidence == Confidence::Unknown)
|
|
.count();
|
|
let refuted = savegame::GHAD_LAYOUT
|
|
.iter()
|
|
.filter(|f| f.confidence == Confidence::Refuted)
|
|
.count();
|
|
assert_eq!(unknown, 7, "unknown GHAD fields");
|
|
assert_eq!(refuted, 2, "fields tested and refuted (+36, +56)");
|
|
}
|