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.
6.3 KiB
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 |
| 1–15 | 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.