//! `savedata` — the retail save file. //! //! The whole save is **545 bytes**. It lives in the console's content tree as a //! single file and there is no second one, so everything the game remembers //! between sessions is in here: //! //! ```text //! //535107D4/00000001/game01/savedata //! ``` //! //! # Container //! //! ```text //! 'GDHA' <146-byte header> //! ``` //! //! Much of the header is **uninitialised memory** — words like `0x828F3DA8` are //! guest virtual addresses that leaked out of the struct's padding — so it is not //! byte-reproducible and must not be read as meaningful. Three header fields //! *are* derived from the payload ([`Header::derived`]), and a handful more are a //! **summary copy** of payload fields ([`Header::summary`]) — which matters more //! than it sounds: the in-game Details panel reads the *summary*, not the //! payload, so editing a payload field alone leaves a stale panel beside it. A //! probe that judges "did that field change the display?" without patching the //! mirror cannot tell *"wrong field"* from *"the panel never reads the payload"*. //! //! # Payload //! //! A chunk stream, read off the title's own serializer at `0x822C00E8` (its //! writer primitive is `0x821885A8(stream, buf, len)`) rather than guessed, and //! verified by a byte-identical round-trip: //! //! ```text //! 'GDAA' payload magic //! u32 len, char[len] current game phase, e.g. "GP_BUNK" //! 'GHAD' + 122 bytes the progress block //! u32 16, 16 x ('SHAB' + 5 x u32) the per-stage record table //! u32 4, "BUNK", 'NETA', u32 trailer //! ``` //! //! The in-memory struct is written field-by-field with no packing changes, so a //! payload offset **is** the offset in the live save object: GHAD at `save+8`, //! the record table at `save+136`, and the serializer's next access after the //! table is `lwz r11,456(save)` — exactly `136 + 16*20`. //! //! # Confidence //! //! The container and the chunk layout are ✅ `CONFIRMED`. Individual GHAD fields //! are **not** uniformly known — eleven are still ❔ — so every field carries its //! own [`Confidence`] and a viewer is expected to show it rather than present the //! lot as solved. See `docs/re/structures/savegame-format.md`. use std::io::Read; /// How well a field is understood. Mirrors the `docs/re` convention. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Confidence { /// Named and verified against the game. Confirmed, /// A well-supported reading, not proven. Probable, /// Unidentified. Unknown, /// A reading that was tested and **refuted** — recorded so it is not retried. Refuted, } /// How to render a GHAD word. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FieldKind { /// Plain 32-bit count / enum / id. U32, /// 64-bit word. U64, /// Milliseconds, displayed as `m:ss.mmm`. Millis, /// Percent. Percent, /// Raw bytes. Raw, /// The per-item Arsenal development blob. DevelopBlob, } /// One documented field of the GHAD progress block. #[derive(Debug, Clone, Copy)] pub struct FieldSpec { /// Offset within the 122-byte GHAD block. pub offset: usize, /// Byte width. pub size: usize, pub kind: FieldKind, pub confidence: Confidence, /// Short name, or `""` when the field is unidentified. pub name: &'static str, /// What is known — including what was ruled out. pub note: &'static str, } /// The GHAD block's layout, read off `0x822BF678`: ten u32, one u64 /// (`ld r11,40(r30)`), four u32, a raw 4-byte field and a raw 54-byte blob. /// 10·4 + 8 + 4·4 + 4 + 54 = **122**, exactly what the file carries — the layout /// is closed, with nothing unaccounted for. pub const GHAD_LAYOUT: &[FieldSpec] = &[ FieldSpec { offset: 0, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" }, FieldSpec { offset: 4, size: 4, kind: FieldKind::Millis, confidence: Confidence::Confirmed, name: "FlightTime", note: "Milliseconds. 324773 ms against the panel's `Flight Time 000:05:24`." }, FieldSpec { offset: 8, size: 4, kind: FieldKind::Percent, confidence: Confidence::Confirmed, name: "ClearRatio", note: "Percent. Not a stage counter: developing one Arsenal weapon stepped it 5 → 6, so it counts collection too." }, FieldSpec { offset: 12, size: 4, kind: FieldKind::U32, confidence: Confidence::Probable, name: "TimesCleared", note: "Mirrored to header +0x28; the panel prints `Times Cleared`." }, FieldSpec { offset: 16, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" }, FieldSpec { offset: 20, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" }, FieldSpec { offset: 24, size: 4, kind: FieldKind::U32, confidence: Confidence::Confirmed, name: "Points", note: "The spendable balance. Separated from +28 by a develop differential: spending 4000 P moved only this field (4101 → 101)." }, FieldSpec { offset: 28, size: 4, kind: FieldKind::U32, confidence: Confidence::Probable, name: "PointsTotal?", note: "Not the displayed Points — it did NOT move when 4000 P was spent. A lifetime/earned total is the obvious read; unproven until a save is taken after earning." }, FieldSpec { offset: 32, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" }, FieldSpec { offset: 36, size: 4, kind: FieldKind::U32, confidence: Confidence::Refuted, name: "", note: "Tested as difficulty and as stage; probe saves refuted both." }, FieldSpec { offset: 40, size: 8, kind: FieldKind::U64, confidence: Confidence::Unknown, name: "", note: "" }, FieldSpec { offset: 48, size: 4, kind: FieldKind::U32, confidence: Confidence::Probable, name: "GameStatus", note: "Mirrored to header +0x18; 0 renders `At Standby`, matching the screen's STATE_STAND_BY / STATE_STAGE_CLEAR / STATE_GAME_CLEAR list." }, FieldSpec { offset: 52, size: 4, kind: FieldKind::U32, confidence: Confidence::Confirmed, name: "Stage", note: "1-based stage number. Set it to 5 (with the header mirror) and the game reads `STAGE 05 — Star System Escape`, loads it, and flies it." }, FieldSpec { offset: 56, size: 4, kind: FieldKind::U32, confidence: Confidence::Refuted, name: "", note: "See +36 — refuted." }, FieldSpec { offset: 60, size: 4, kind: FieldKind::U32, confidence: Confidence::Unknown, name: "", note: "" }, FieldSpec { offset: 64, size: 4, kind: FieldKind::Raw, confidence: Confidence::Unknown, name: "", note: "The trailer's u32 carries the same value." }, FieldSpec { offset: 68, size: 54, kind: FieldKind::DevelopBlob, confidence: Confidence::Confirmed, name: "DevelopState", note: "One byte per Arsenal item. Index space is strings.tbl's item order, cut items included, closing at 53." }, ]; /// Size of the GHAD progress block. pub const GHAD_SIZE: usize = 122; /// Records in the per-stage table. pub const RECORD_COUNT: usize = 16; /// `u32`s per record, after the `SHAB` tag. pub const RECORD_FIELDS: usize = 5; /// Length of the Arsenal development blob. pub const DEVELOP_BLOB_LEN: usize = 54; /// State of one Arsenal item in the development blob. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DevelopState { /// `0` — not yet available. Locked, /// `2` — available to develop now. **Re-derived at load**, not stored state. Developable, /// `4` — owned. Developed, /// Anything else, which no save has yet shown. Other(u8), } impl DevelopState { pub fn from_byte(b: u8) -> Self { match b { 0 => Self::Locked, 2 => Self::Developable, 4 => Self::Developed, other => Self::Other(other), } } } /// One `SHAB` record — a per-stage result, record 0 being Stage 01. /// /// These are **not** the UI's 20 save slots, which is what the count of 16 /// first suggested. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct StageRecord { /// 🟡 difficulty. pub a: u32, /// ❔ a points figure — equals GHAD `+24`/`+28`, so not the displayed high /// score (which is computed at display time and stored nowhere). pub b: u32, /// ✅ best clear time in milliseconds — 324 773 against the screen's /// `05:24.77`. pub best_time_ms: u32, /// Windows FILETIME, high then low word. pub time_hi: u32, pub time_lo: u32, } impl StageRecord { /// Whether the record holds a result at all. pub fn is_used(&self) -> bool { self.a != 0 || self.b != 0 || self.best_time_ms != 0 } /// The record's FILETIME as 100 ns ticks since 1601, or `None` if unset. pub fn filetime_ticks(&self) -> Option { let t = (u64::from(self.time_hi) << 32) | u64::from(self.time_lo); (t != 0).then_some(t) } } /// The GDHA container header. #[derive(Debug, Clone)] pub struct Header { /// The header bytes, verbatim. pub bytes: Vec, } /// A header word that mirrors a payload field, with where it came from. #[derive(Debug, Clone, Copy)] pub struct Mirror { pub header_offset: usize, pub value: u32, /// The GHAD offset this copies, if it is a GHAD field. pub ghad_offset: Option, pub name: &'static str, } impl Header { fn be32(&self, o: usize) -> u32 { if o + 4 > self.bytes.len() { return 0; } u32::from_be_bytes([ self.bytes[o], self.bytes[o + 1], self.bytes[o + 2], self.bytes[o + 3], ]) } /// The summary copy the in-game Details panel actually reads. /// /// This is the trap the format doc records: a payload edit that leaves these /// stale shows no change on the panel, which is *not* evidence that the /// payload field was the wrong one. pub fn summary(&self) -> Vec { vec![ Mirror { header_offset: 0x14, value: self.be32(0x14), ghad_offset: Some(52), name: "Stage" }, Mirror { header_offset: 0x1c, value: self.be32(0x1c), ghad_offset: Some(24), name: "Points" }, Mirror { header_offset: 0x20, value: self.be32(0x20), ghad_offset: Some(4), name: "FlightTime" }, Mirror { header_offset: 0x24, value: self.be32(0x24), ghad_offset: Some(8), name: "ClearRatio" }, Mirror { header_offset: 0x28, value: self.be32(0x28), ghad_offset: Some(12), name: "TimesCleared" }, ] } /// The three header fields derived from the payload — all that stands between /// a parse and a hand-written save the title will load. pub fn derived(&self) -> Vec { vec![ Mirror { header_offset: 0x30, value: self.be32(0x30), ghad_offset: None, name: "deflate length + 10" }, Mirror { header_offset: 0x8c, value: u32::from(u16::from_be_bytes([self.bytes.get(0x8c).copied().unwrap_or(0), self.bytes.get(0x8d).copied().unwrap_or(0)])), ghad_offset: None, name: "payload length" }, Mirror { header_offset: 0x8e, value: self.be32(0x8e), ghad_offset: None, name: "adler32(payload)" }, ] } } /// A fully parsed save. #[derive(Debug, Clone)] pub struct SaveGame { pub header: Header, /// The inflated payload, verbatim — the round-trip compares against this. pub payload: Vec, /// Current game phase, e.g. `GP_BUNK`. One of the title's screen ids. pub phase: String, /// The 122-byte progress block. pub ghad: Vec, /// The per-stage record table. pub records: Vec, /// Trailer: `(name, tag, value)`. pub trailer: (String, String, u32), } /// What went wrong reading a save. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SaveError { NotGdha, NoZlibStream, Inflate(String), NotGdaa, Truncated(&'static str), /// A chunk tag was not where the serializer puts it. BadTag { expected: &'static str, at: usize }, /// Bytes left over after the trailer — the layout is supposed to close /// exactly, so this means the parse is wrong, not that the file has extras. TrailingBytes(usize), } impl std::fmt::Display for SaveError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::NotGdha => write!(f, "not a GDHA container"), Self::NoZlibStream => write!(f, "no zlib stream in the container"), Self::Inflate(e) => write!(f, "inflate failed: {e}"), Self::NotGdaa => write!(f, "payload is not GDAA"), Self::Truncated(what) => write!(f, "payload truncated reading {what}"), Self::BadTag { expected, at } => write!(f, "expected {expected} at {at:#x}"), Self::TrailingBytes(n) => write!(f, "{n} bytes left after the trailer"), } } } impl std::error::Error for SaveError {} fn be32(b: &[u8], o: usize) -> Option { b.get(o..o + 4) .map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])) } /// Split a `GDHA` container into its header and inflated payload. pub fn unwrap_container(raw: &[u8]) -> Result<(Header, Vec), SaveError> { if raw.len() < 4 || &raw[0..4] != b"GDHA" { return Err(SaveError::NotGdha); } let at = raw .windows(2) .position(|w| w == [0x78, 0xDA]) .ok_or(SaveError::NoZlibStream)?; let mut out = Vec::new(); flate2::read::ZlibDecoder::new(&raw[at..]) .read_to_end(&mut out) .map_err(|e| SaveError::Inflate(e.to_string()))?; Ok(( Header { bytes: raw[..at].to_vec(), }, out, )) } /// Parse an inflated payload. pub fn parse_payload(payload: &[u8]) -> Result { if payload.len() < 4 || &payload[0..4] != b"GDAA" { return Err(SaveError::NotGdaa); } let mut o = 4usize; let nlen = be32(payload, o).ok_or(SaveError::Truncated("phase length"))? as usize; o += 4; let phase = payload .get(o..o + nlen) .map(|s| String::from_utf8_lossy(s).to_string()) .ok_or(SaveError::Truncated("phase name"))?; o += nlen; if payload.get(o..o + 4) != Some(b"GHAD") { return Err(SaveError::BadTag { expected: "GHAD", at: o }); } o += 4; let ghad = payload .get(o..o + GHAD_SIZE) .ok_or(SaveError::Truncated("GHAD block"))? .to_vec(); o += GHAD_SIZE; let count = be32(payload, o).ok_or(SaveError::Truncated("record count"))? as usize; o += 4; let mut records = Vec::with_capacity(count); for _ in 0..count { if payload.get(o..o + 4) != Some(b"SHAB") { return Err(SaveError::BadTag { expected: "SHAB", at: o }); } o += 4; let mut v = [0u32; RECORD_FIELDS]; for slot in v.iter_mut() { *slot = be32(payload, o).ok_or(SaveError::Truncated("record"))?; o += 4; } records.push(StageRecord { a: v[0], b: v[1], best_time_ms: v[2], time_hi: v[3], time_lo: v[4], }); } let tlen = be32(payload, o).ok_or(SaveError::Truncated("trailer length"))? as usize; o += 4; let tname = payload .get(o..o + tlen) .map(|s| String::from_utf8_lossy(s).to_string()) .ok_or(SaveError::Truncated("trailer name"))?; o += tlen; let ttag = payload .get(o..o + 4) .map(|s| String::from_utf8_lossy(s).to_string()) .ok_or(SaveError::Truncated("trailer tag"))?; o += 4; let tval = be32(payload, o).ok_or(SaveError::Truncated("trailer value"))?; o += 4; if o != payload.len() { return Err(SaveError::TrailingBytes(payload.len() - o)); } Ok(SaveGame { header: Header { bytes: Vec::new() }, payload: payload.to_vec(), phase, ghad, records, trailer: (tname, ttag, tval), }) } /// Parse a whole `savedata` file. pub fn parse(raw: &[u8]) -> Result { let (header, payload) = unwrap_container(raw)?; let mut save = parse_payload(&payload)?; save.header = header; Ok(save) } impl SaveGame { /// Re-serialize the payload. /// /// This is the correctness check for the whole layout: the serializer writes /// the struct field-by-field with no packing, so a correct parse must /// reproduce the payload **byte for byte**. See [`SaveGame::round_trips`]. pub fn serialize_payload(&self) -> Vec { let mut out = Vec::with_capacity(self.payload.len()); out.extend_from_slice(b"GDAA"); out.extend_from_slice(&(self.phase.len() as u32).to_be_bytes()); out.extend_from_slice(self.phase.as_bytes()); out.extend_from_slice(b"GHAD"); out.extend_from_slice(&self.ghad); out.extend_from_slice(&(self.records.len() as u32).to_be_bytes()); for r in &self.records { out.extend_from_slice(b"SHAB"); for v in [r.a, r.b, r.best_time_ms, r.time_hi, r.time_lo] { out.extend_from_slice(&v.to_be_bytes()); } } let (name, tag, val) = &self.trailer; out.extend_from_slice(&(name.len() as u32).to_be_bytes()); out.extend_from_slice(name.as_bytes()); out.extend_from_slice(tag.as_bytes()); out.extend_from_slice(&val.to_be_bytes()); out } /// Whether re-serializing reproduces the payload exactly. pub fn round_trips(&self) -> bool { self.serialize_payload() == self.payload } /// Read a GHAD field as an unsigned integer. `None` for the raw/blob fields. pub fn ghad_value(&self, spec: &FieldSpec) -> Option { match spec.size { 4 => be32(&self.ghad, spec.offset).map(u64::from), 8 => self .ghad .get(spec.offset..spec.offset + 8) .map(|s| u64::from_be_bytes(s.try_into().unwrap())), _ => None, } } /// Raw bytes of a GHAD field. pub fn ghad_bytes(&self, spec: &FieldSpec) -> &[u8] { self.ghad .get(spec.offset..spec.offset + spec.size) .unwrap_or(&[]) } /// The per-item Arsenal development state. /// /// Index space is `strings.tbl`'s item order — the Arsenal's display order /// *plus* the cut items only the localisation file lists — closing at 53. /// `weapon.tbl`'s id list is **not** the index space; that it is also 54 long /// is a coincidence, and the two agree only to index 32. pub fn develop_state(&self) -> Vec { self.ghad .get(68..68 + DEVELOP_BLOB_LEN) .map(|b| b.iter().copied().map(DevelopState::from_byte).collect()) .unwrap_or_default() } /// Convenience: the 1-based stage number (GHAD `+52`). pub fn stage(&self) -> Option { be32(&self.ghad, 52) } /// Convenience: the spendable points balance (GHAD `+24`). pub fn points(&self) -> Option { be32(&self.ghad, 24) } /// Convenience: total flight time in milliseconds (GHAD `+4`). pub fn flight_time_ms(&self) -> Option { be32(&self.ghad, 4) } /// Convenience: clear ratio in percent (GHAD `+8`). pub fn clear_ratio_pct(&self) -> Option { be32(&self.ghad, 8) } } /// Format a millisecond count the way the game's panels do (`m:ss.mmm`). pub fn fmt_millis(ms: u32) -> String { let (m, rem) = (ms / 60_000, ms % 60_000); format!("{}:{:02}.{:03}", m, rem / 1000, rem % 1000) } #[cfg(test)] mod tests { use super::*; /// Build a payload with the exact chunk stream the serializer writes. fn synth_payload(phase: &str, stage: u32, points: u32) -> Vec { let mut ghad = vec![0u8; GHAD_SIZE]; ghad[4..8].copy_from_slice(&324_773u32.to_be_bytes()); // flight time ghad[8..12].copy_from_slice(&5u32.to_be_bytes()); // clear ratio ghad[24..28].copy_from_slice(&points.to_be_bytes()); ghad[52..56].copy_from_slice(&stage.to_be_bytes()); ghad[68] = 4; // item 0 developed ghad[69] = 2; // item 1 developable let mut p = Vec::new(); p.extend_from_slice(b"GDAA"); p.extend_from_slice(&(phase.len() as u32).to_be_bytes()); p.extend_from_slice(phase.as_bytes()); p.extend_from_slice(b"GHAD"); p.extend_from_slice(&ghad); p.extend_from_slice(&(RECORD_COUNT as u32).to_be_bytes()); for i in 0..RECORD_COUNT { p.extend_from_slice(b"SHAB"); let vals = if i == 0 { [2u32, 4101, 324_773, 0x01DC_0000, 0x1234_5678] } else { [0, 0, 0, 0, 0] }; for v in vals { p.extend_from_slice(&v.to_be_bytes()); } } p.extend_from_slice(&4u32.to_be_bytes()); p.extend_from_slice(b"BUNK"); p.extend_from_slice(b"NETA"); p.extend_from_slice(&0x0915_0000u32.to_be_bytes()); p } #[test] fn parses_the_chunk_stream() { let p = synth_payload("GP_BUNK", 2, 4101); let s = parse_payload(&p).unwrap(); assert_eq!(s.phase, "GP_BUNK"); assert_eq!(s.ghad.len(), GHAD_SIZE); assert_eq!(s.records.len(), RECORD_COUNT); assert_eq!(s.trailer, ("BUNK".into(), "NETA".into(), 0x0915_0000)); assert_eq!(s.stage(), Some(2)); assert_eq!(s.points(), Some(4101)); assert_eq!(s.flight_time_ms(), Some(324_773)); assert_eq!(s.clear_ratio_pct(), Some(5)); } #[test] fn round_trips_byte_identically() { // The layout is closed, so a correct parse must reproduce the payload // exactly. This is the property the whole spec rests on. let p = synth_payload("GP_BUNK", 2, 4101); let s = parse_payload(&p).unwrap(); assert!(s.round_trips()); assert_eq!(s.serialize_payload(), p); } #[test] fn record_zero_is_stage_one() { let s = parse_payload(&synth_payload("GP_BUNK", 2, 4101)).unwrap(); assert!(s.records[0].is_used()); assert!(!s.records[1].is_used()); assert_eq!(s.records[0].best_time_ms, 324_773); assert!(s.records[0].filetime_ticks().is_some()); assert!(s.records[1].filetime_ticks().is_none()); } #[test] fn develop_blob_alphabet() { let s = parse_payload(&synth_payload("GP_BUNK", 2, 4101)).unwrap(); let d = s.develop_state(); assert_eq!(d.len(), DEVELOP_BLOB_LEN); assert_eq!(d[0], DevelopState::Developed); assert_eq!(d[1], DevelopState::Developable); assert_eq!(d[2], DevelopState::Locked); } #[test] fn ghad_layout_is_closed() { // 10*4 + 8 + 4*4 + 4 + 54 = 122, exactly what the file carries. If this // ever fails the layout has grown a gap and the round-trip is a lie. let total: usize = GHAD_LAYOUT.iter().map(|f| f.size).sum(); assert_eq!(total, GHAD_SIZE); let mut at = 0; for f in GHAD_LAYOUT { assert_eq!(f.offset, at, "gap before {:#x}", f.offset); at += f.size; } } #[test] fn rejects_a_non_save() { assert_eq!(parse(b"not a save at all").unwrap_err(), SaveError::NotGdha); assert_eq!(parse_payload(b"XXXX").unwrap_err(), SaveError::NotGdaa); } #[test] fn trailing_bytes_are_an_error_not_a_shrug() { let mut p = synth_payload("GP_BUNK", 2, 4101); p.push(0); assert_eq!(parse_payload(&p).unwrap_err(), SaveError::TrailingBytes(1)); } #[test] fn millis_format_matches_the_panel() { assert_eq!(fmt_millis(324_773), "5:24.773"); } }