Files
Syplheed-Reborn/docs/re/structures/savegame-format.md
Claude (auto-RE) 398e8ae0af re: the save file is a GDHA/zlib chunk stream — layout read off the title's own serializer
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.
2026-08-11 05:40:26 +00:00

6.3 KiB
Raw Blame History

Save file (savedata) — container exact, field meanings (2026-08-11)

Status: CONFIRMED for the container and the chunk layout — parsed off the title's own serializer, not guessed, and verified by a byte-identical round-trip. for what most of the numbers mean; those are marked below and none are asserted.

Parser + round-trip check: tools/re-capture/savegame.py (savegame.py <savedata> --verify re-serializes the parse and asserts equality).

Where it lives

<xenia content>/<XUID>/535107D4/00000001/game01/savedata          545 B payload
<xenia content>/<XUID>/535107D4/Headers/00000001/game01.header    328 B

The header is the Xbox content header, not game data: a UTF-16BE display string (Game01 07/23/2026 21:08 STAGE02 EASY), the internal name game01, and the title id 53 51 07 D4. It is what the save-select screen lists.

savedata is the whole save. 545 bytes — there is no second file, so everything the game remembers between sessions is in here.

Container

'GDHA'  <146-byte header>  <zlib stream, 78 DA>

The deflated payload is 545 bytes. Two of the GDHA header words are a FILETIME and a copy of the slot-0 fields; much of the rest is uninitialised memory — words like 0x828F3DA8, 0xBD4B6200, 0x70D8FD50 are guest virtual addresses that leaked out of the struct's padding, so the header is not byte-reproducible and must not be treated as meaningful. The last four bytes of the file are the zlib stream's own Adler-32, and the same value also appears at header offset 0x8E.

Payload — a chunk stream written by 0x822C00E8

Every element below is read off the serializer, whose writer primitive is 0x821885A8(stream, buf, len):

bytes what source
'GDAA' payload magic addis 0x4744 / ori 0x4141 at 0x822C00F8
u32 len, char[len] current game phase, here GP_BUNK lwz r11,0(save); if < 29, index the GP_* name table at 0x820A5680
'GHAD' + 122 B the progress block 0x822BF678, called with save+8
u32 16, 16 × ('SHAB' + 5×u32) the save-slot table addi r28,r0,16 / stride addi r29,r29,20
u32 4, "BUNK", 'NETA', u32 trailer

The in-memory struct is written field-by-field with no packing changes, so a payload offset is also the offset in the live save object: the GHAD block is save+8, the slot table is save+136, and the serializer's next access after the table is lwz r11,456(save) — exactly 136 + 16*20. That makes this doc a map of the runtime object too, which is what a live-RAM read would need.

The phase name is real: GP_BUNK is one of the title's screen ids, and the .pe carries the neighbouring list GP_HANGAR, GP_READY_ROOM, GP_BUNK, GP_MOVIE, GP_OPTIONS, GP_MISSION_*. (Note the SHAB string that also appears in the .pe is a false hit — it is inside the RTTI name .?AVSCRIPT_COMMAND_PUSHABLE@SilpheedSCS@@. The four-character tags are built as lis/ori immediate pairs, so they are not in the string pool at all.)

The GHAD block (122 bytes, 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, which is exactly what the file carries — the layout is closed, with nothing unaccounted for.

off value in this save reading
+0 0
+4 324773 a large per-save counter — credits/score is the obvious guess, untested
+8 5 the save-select screen shows this save at 5 %
+12 0
+16 1
+20 0
+24 4101 (0x1005) same value as +28 and as slot 0's field b
+28 4101 (0x1005)
+32 79
+36 2 equals slot 0's field a; the header calls this save EASY
+40 (u64) 2014400
+48 0
+52 2
+56 2
+60 0
+64 (raw 4) 09 15 00 00 the trailer's u32 is the same value
+68 (raw 54) see below 54 one-byte entries, values only ∈ {0, 2, 4}

The 54-byte blob is the interesting one — a fixed-length array of small enumerated states, 20 of the 54 non-zero:

04 02 00 00 00 02 00 00 00 04 02 00 02 00 00 00 00 00 00 00 00 04 04 00 00 00 04
02 00 00 00 02 00 04 00 00 00 00 00 04 00 00 00 00 00 04 04 04 00 00 00 00 00 00

54 matches no count we have already established (22 stages, 110 units, 126 weapons, ~61 Arsenal entries), so do not assume which list it indexes.

The slot table (16 × 20 bytes)

slot a b c FILETIME
0 2 4101 324773 2026-07-23 20:07:23
115 0 0 0 2006-01-01 00:00:00

The FILETIME reading is confirmed independently: the last two u32 of slot 0 decode as a Windows FILETIME to 2026-07-23 20:07:23 UTC, and the content header's own display string — written by the game, parsed by nobody here — says 07/23/2026 21:08 (UTC+1). The 15 unused slots all hold 2006-01-01, a plausible epoch default rather than zero, which is a second check on the same reading.

Fields a/b/c of slot 0 are copies of GHAD +36, +24/+28 and +4. So the slot record is a summary of a save, which is what a slot-select screen needs.

What this does and does not unlock

It settles the format. It does not yet settle which field is stage-unlock state, so it does not yet answer the question that motivated it — whether stage progress can be reached without winning missions (mission outcome).

The naming oracle is a differential, and only one save exists (all four copies on disk — the live one, both content-backup-* snapshots and the prof2 profile — are byte-identical, md5 142b4f432e0e…). So the next step is to make a second save: change one thing in-game that the player can change cheaply, save, and diff. Developing or buying a single Arsenal item should move +4 (if it is currency) and one entry of the 54-byte blob, which would name two fields at once.

Nothing here has been written back to a save. Editing is a separate decision and needs the user's go-ahead; the round-trip in savegame.py exists so that an edit could be made safely (parse → change one field → re-serialize → re-deflate → re-wrap), and so that a diff of two saves can be read structurally instead of as raw bytes.