//! 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 { 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 = (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)"); }