Files
Syplheed-Reborn/docs/re/structures/savegame-format.md
Claude (auto-RE) 882dbc2ae2 re: a second save names Points, flight time and clear ratio — and shows the payload is pure state
Made a second save in-game (READY ROOM -> SYSTEM -> SAVE GAME -> empty slot) so
the format had a differential to read against. nav_probe.sh drives it: boot to
the READY ROOM, walk a scripted step list, screenshot after every step and stamp
every save file's md5, so the trail says which keypress wrote a save. That stamp
is what caught the first attempt failing -- the save confirm starts on YES,
unlike the load confirm which starts on NO, so the load flow's extra up-press
selected NO and wrote nothing.

Result 1: saving the same loaded state into a new slot produces a BYTE-IDENTICAL
545-byte payload. Only the GDHA header moves, and every byte that moves is either
the container FILETIME or one of the guest-pointer words -- which empirically
confirms those words are uninitialised padding rather than data. So the payload
holds no timestamp, no slot number and no name; a save's identity is entirely in
its content header.

Result 2: the LOAD/SAVE screen's Details panel prints Points 4101 P, Flight Time
000:05:24 and Clear Ratio 5 % for exactly this state, which names GHAD +24
(Points), +4 (flight time in ms -- 324773 ms = 5m24.773s) and +8 (clear ratio %).
Difficulty EASY and STAGE 02 both being 2, and three fields holding 2, is left
undecided on purpose: one save cannot separate them.

Result 3: the 16 SHAB records are not the UI's save slots. The UI has 20, slots
are separate gameNN files, and record 0's FILETIME stayed at 2026-07-23 in a save
written on 2026-08-11 -- so the table is part of the game state, most likely a
per-stage result record (one stage finished, one record filled), which the next
cleared stage will falsify or confirm.

The original save was backed up first and is untouched; the new save went to an
empty slot.
2026-08-11 06:02:21 +00:00

11 KiB
Raw Blame History

Save file (savedata) — container exact, 3 fields named , rest (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. Points, flight time and clear ratio are named off the game's own Details panel; the rest is still and none of it is 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) a 16-record result table (not the UI's save slots — see Result 3) 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.

Field names below come from the LOAD/SAVE GAME screen's own Details panel, photographed against this exact save (see "Naming the fields", further down).

off value in this save reading
+0 0
+4 324773 flight time, milliseconds — the screen shows Flight Time 000:05:24 and 324773 ms = 5 m 24.773 s
+8 5 clear ratio, percent — the screen shows Clear Ratio 5 %
+12 0
+16 1
+20 0
+24 4101 (0x1005) Points — the Details panel shows Points 4101 P
+28 4101 (0x1005) 🟡 Points again — a second copy (running vs. banked total?); one save cannot separate them
+32 79
+36 2 🟡 difficulty or stage — the panel shows Difficulty EASY and STAGE 02, and +36/+52/+56 all hold 2, so which is which is not decidable from one save
+40 (u64) 2014400
+48 0 (Times Cleared: 0 is on screen, so it is one of the zero fields)
+52 2 🟡 see +36
+56 2 🟡 see +36
+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} — histogram {4: 10, 2: 6, 0: 38}

The 54-byte blob is the interesting one — a fixed-length array of small enumerated states, 16 of the 54 non-zero (ten 4s, six 2s):

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 16-record SHAB 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 record 0 are copies of GHAD +36, +24/+28 and +4 — i.e. difficulty/stage, Points and flight time, the same triple the Details panel prints. What the table is a table of is settled in Result 3 below: not saves.

Naming the fields — a second save, made in-game (2026-08-11)

The one save on disc could not name anything, so a second one was made. Route, all with 60 ms d-pad taps, driven by tools/re-capture/nav_probe.sh (boots to the READY ROOM, then walks a scripted step list, screenshotting after every step and stamping every save file's md5 — so the trail says exactly which keypress wrote a save):

READY ROOM → ↓×4 SYSTEM → A → (≈50 s to load under lavapipe) → SYSTEM MENU BACK / LOAD GAME / SAVE GAME / OPTIONS / BACK TO TITLE → ↓↓ SAVE GAME → A → slot list, first empty slot preselected → A → Save game?cursor starts on YES → A.

Two traps, both paid for: the SYSTEM screen takes >15 s to load and inputs sent during it are dropped; and the save confirm starts on YES, unlike the load confirm which starts on NO — the first attempt sent the load flow's extra and so pressed NO, writing nothing (the md5 stamp is what showed this).

Result 1 — the payload is a pure function of game state

Saving the just-loaded state into empty slot 02 produced game02/savedata. Its 545-byte payload is byte-identical to game01's — same phase, same GHAD block, same slot table, same FILETIME inside it. Only the GDHA header differs, and every differing byte is either the container FILETIME at 0x0A or one of the guest-pointer words (0xBC4556800xBC474020, 0xBD4B62000xBD3B5180, 0x70D8FD500x70A8FD50, …). That empirically confirms the "uninitialised padding" call: those words moved between two saves of identical state, by the deltas you would expect of heap addresses.

So the payload carries no timestamp, no slot number and no save name — a save's identity lives entirely in its content header (Game02 08/11/2026 06:55 STAGE02 EASY vs Game01 07/23/2026 21:08 STAGE02 EASY; the __thumbnail.pngs are identical too). Everything in the 545 bytes is game state, which is also a second check that the parse leaves nothing unexplained.

Result 2 — the Details panel names the numbers

Highlighting a slot fills a Details panel. Against this save it reads:

STAGE 02 — Declaration of War · Game Status: At Standby · Points 4101 P · Times Cleared: 0 Times · Difficulty EASY · Flight Time 000:05:24 · Clear Ratio 5 %

LOAD GAME details for the state this save holds

4101, 324773 and 5 are all in the GHAD block, which is what pins Points, flight time (ms) and clear ratio above. Note Clear Ratio 5 % with Times Cleared: 0 and one stage finished is consistent with clear ratio = stages cleared ÷ 20 — the slot list is also 20 entries — but that is .

What one save still cannot decide: Difficulty EASY, STAGE 02 and two other fields all have the value 2, and Times Cleared: 0 collides with five zero fields. Separating them needs a save whose stage or difficulty differs, not another copy of this one.

Result 3 — the 16 SHAB records are not the UI's save slots

The save UI has 20 slots; the payload has 16 records. Slot 0's FILETIME stayed at 2026-07-23 in a save written on 2026-08-11, and the 20-slot list is built from separate gameNN files. So SHAB[] is part of the game state, not a directory of saves. Record 0 is (2, 4101, 324773, 2026-07-23 20:07:23) — difficulty/stage, Points, flight time in ms, and when — i.e. the same summary as the Details panel, which makes a per-stage result record the obvious 🟡 read (one stage finished → one record filled). Falsifiable the moment a second stage is cleared: SHAB[1] should fill.

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 differential has been run once (see above) and it named three fields, but a same-state save cannot separate the fields that share a value. The next differentials, in order of what they would settle:

  1. A save at a different difficulty — start a new game on NORMAL, save, compare: whichever of +36/+52/+56 moves is the difficulty.
  2. A save after clearing a stage — settles stage vs. difficulty, Times Cleared, whether SHAB[1] fills, and whether the clear ratio steps by 5 %.
  3. A save after developing one Arsenal item — should move one entry of the 54-byte blob and name it.

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.