This repository has been archived on 2026-09-16. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Syplheed-Reborn/crates/sylpheed-formats/tests/savegame_samples.rs
MechaCat02 dc96bace6f 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>
2026-08-17 18:59:27 +02:00

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