The whole retail save is 545 bytes: a GDHA container wrapping a zlib payload,
which is a chunk stream — 'GDAA', a length-prefixed game-phase name (GP_BUNK,
one of the title's GP_* screen ids), a 'GHAD' 122-byte progress block, a
count-prefixed table of 16 20-byte save slots, and a trailer.
None of it is guessed. The layout comes from the serializer at 0x822C00E8 and
its callee 0x822BF678: the writer primitive is 0x821885A8(stream, buf, len), the
slot count 16 and the 20-byte stride are literals, and the GHAD block is ten u32,
a u64, four u32, a raw 4-byte field and a raw 54-byte blob = 122 bytes, which is
exactly what the file carries. savegame.py re-serializes the parse and asserts
byte-identity.
Because the struct is written field-by-field with no packing changes, a payload
offset is also the offset in the live save object -- save+8 for GHAD, save+136
for the slot table, and 136+16*20 = 456 is the serializer's very next access.
Confirmed independently: slot 0's trailing two u32 decode as a FILETIME to
2026-07-23 20:07:23 UTC, and the content header's display string (written by the
game) says 07/23/2026 21:08 at UTC+1; the 15 empty slots hold 2006-01-01 rather
than zero.
Field meanings are left ❔ on purpose -- the naming oracle is a diff of two
saves, and all four copies on disk are byte-identical, so a second save has to
be made first. Nothing has been written back to any save.
167 lines
6.4 KiB
Python
167 lines
6.4 KiB
Python
#!/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:
|
|
|
|
<content>/<XUID>/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 <savedata> # parse and print
|
|
savegame.py <savedata> --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())
|