#!/usr/bin/env python3 """Parse (and re-serialize) a Project Sylpheed save file. The retail save lives in Xenia's content tree as a single file: //535107D4/00000001/game01/savedata It is a `GDHA` container whose payload is **zlib-deflated** (`78 da`). The payload is a self-describing chunk stream written by the title's serializer at `0x822C00E8`; every field below is read off that function and its callee `0x822BF678`, not guessed: 'GDAA' container magic u32 len, char[len] current game phase, e.g. "GP_BUNK" (index < 29 into the GP_* name table at 0x820A5680; the .pe lists GP_HANGAR, GP_READY_ROOM, GP_BUNK, GP_MOVIE, GP_OPTIONS, GP_MISSION_*) 'GHAD' + 122 bytes the progress block (see GHAD_LAYOUT) u32 16, then 16 x ('SHAB' + 5*u32) the save-slot table u32 4, "BUNK", 'NETA', u32 trailer `u32 16` and the 20-byte slot stride are literals in the serializer (`addi r28,r0,16` / `addi r29,r29,20` at 0x822C01B0/0x822C0264), and the in-memory struct is written field-by-field with no packing changes, so a file offset here IS the offset in the live save object (base + 8 for GHAD, base + 136 for the slot table, whose end at base + 456 is the next field the serializer touches). Usage: savegame.py # parse and print savegame.py --verify # parse, re-serialize, assert byte-identical """ import struct import sys import zlib # (name, offset in the GHAD block, size). Read off 0x822BF678: ten u32, one u64 # (`ld r11,40(r30)`), four more u32, a raw 4-byte field at +64 and a raw 54-byte # blob at +68 — 122 bytes, which is exactly what the file carries. GHAD_LAYOUT = [ ("u32_00", 0, 4), ("u32_04", 4, 4), ("u32_08", 8, 4), ("u32_12", 12, 4), ("u32_16", 16, 4), ("u32_20", 20, 4), ("u32_24", 24, 4), ("u32_28", 28, 4), ("u32_32", 32, 4), ("u32_36", 36, 4), ("u64_40", 40, 8), ("u32_48", 48, 4), ("u32_52", 52, 4), ("u32_56", 56, 4), ("u32_60", 60, 4), ("raw_64", 64, 4), ("blob_68", 68, 54), ] GHAD_SIZE = 122 SLOT_COUNT = 16 SLOT_FIELDS = 5 # 5 u32 per slot; the last two read as a FILETIME def unwrap(raw): """GDHA container -> (header bytes, inflated payload).""" if raw[:4] != b"GDHA": raise ValueError("not a GDHA container: %r" % raw[:4]) i = raw.find(b"\x78\xda") if i < 0: raise ValueError("no zlib stream") return raw[:i], zlib.decompress(raw[i:]) def parse(payload): if payload[:4] != b"GDAA": raise ValueError("not a GDAA payload: %r" % payload[:4]) o = 4 (nlen,) = struct.unpack_from(">I", payload, o); o += 4 phase = payload[o:o + nlen].decode("ascii"); o += nlen if payload[o:o + 4] != b"GHAD": raise ValueError("expected GHAD at %#x" % o) o += 4 ghad_at = o ghad = {} for name, off, size in GHAD_LAYOUT: b = payload[ghad_at + off: ghad_at + off + size] ghad[name] = struct.unpack(">I", b)[0] if size == 4 and not name.startswith("raw") \ else struct.unpack(">Q", b)[0] if size == 8 else b o = ghad_at + GHAD_SIZE (count,) = struct.unpack_from(">I", payload, o); o += 4 slots = [] for _ in range(count): if payload[o:o + 4] != b"SHAB": raise ValueError("expected SHAB at %#x" % o) o += 4 vals = struct.unpack_from(">%dI" % SLOT_FIELDS, payload, o) o += 4 * SLOT_FIELDS slots.append(vals) (tlen,) = struct.unpack_from(">I", payload, o); o += 4 tname = payload[o:o + tlen].decode("ascii"); o += tlen ttag = payload[o:o + 4]; o += 4 (tval,) = struct.unpack_from(">I", payload, o); o += 4 if o != len(payload): raise ValueError("trailing %d bytes" % (len(payload) - o)) return dict(phase=phase, ghad=ghad, slot_count=count, slots=slots, trailer=(tname, ttag.decode("ascii"), tval)) def serialize(p): out = bytearray(b"GDAA") out += struct.pack(">I", len(p["phase"])) + p["phase"].encode("ascii") out += b"GHAD" blk = bytearray(GHAD_SIZE) for name, off, size in GHAD_LAYOUT: v = p["ghad"][name] blk[off:off + size] = v if isinstance(v, bytes) else \ struct.pack(">I", v) if size == 4 else struct.pack(">Q", v) out += blk out += struct.pack(">I", p["slot_count"]) for s in p["slots"]: out += b"SHAB" + struct.pack(">%dI" % SLOT_FIELDS, *s) tname, ttag, tval = p["trailer"] out += struct.pack(">I", len(tname)) + tname.encode("ascii") out += ttag.encode("ascii") + struct.pack(">I", tval) return bytes(out) def filetime(hi, lo): """The two trailing slot u32 read as a Windows FILETIME (100 ns since 1601).""" import datetime ticks = (hi << 32) | lo if not ticks: return "-" try: return (datetime.datetime(1601, 1, 1) + datetime.timedelta(microseconds=ticks // 10)).strftime("%Y-%m-%d %H:%M:%S") except OverflowError: return "?" def main(): path = sys.argv[1] raw = open(path, "rb").read() hdr, payload = unwrap(raw) p = parse(payload) if "--verify" in sys.argv: again = serialize(p) ok = again == payload print("round-trip %s (%d bytes)" % ("OK — byte-identical" if ok else "MISMATCH", len(payload))) if not ok: for i, (a, b) in enumerate(zip(payload, again)): if a != b: print(" first diff at %#x: %02x != %02x" % (i, a, b)) break return 0 if ok else 1 print("container : GDHA, %d bytes header + %d bytes deflate -> %d bytes" % (len(hdr), len(raw) - len(hdr), len(payload))) print("phase : %s" % p["phase"]) print("GHAD:") for name, off, size in GHAD_LAYOUT: v = p["ghad"][name] print(" +%-3d %-8s %s" % (off, name, v.hex(" ") if isinstance(v, bytes) else "%-10d (%#x)" % (v, v))) print("slots : %d" % p["slot_count"]) for i, s in enumerate(p["slots"]): used = any(s[:3]) print(" [%2d] %s a=%-6d b=%-8d c=%-10d time=%s" % (i, "USED " if used else "empty", s[0], s[1], s[2], filetime(s[3], s[4]))) print("trailer : len=%d name=%r tag=%r val=%#x" % (len(p["trailer"][0]), p["trailer"][0], p["trailer"][1], p["trailer"][2])) return 0 if __name__ == "__main__": sys.exit(main())