A third save, taken in the running game after developing exactly one item
(Light Machine Gun MG I, 4000 P) and declining the mount prompt, moves exactly
three things against the same-state save:
+24 Points 4101 -> 101 and +28 does NOT move, which separates the
pair one save could not tell apart; the
Details panel then reads Points 101 P, so
+24 is the spendable balance
+8 clear ratio 5 -> 6 so the ratio counts collection, not stages
+68 blob[1],[2] 2->4, 0->2 the item bought, and the successor the game
announced as newly developable
One action, two blob transitions, two on-screen events in the same order — which
is what makes 0 locked / 2 developable / 4 developed a reading rather than a
guess, and rules out a plain owned-bitmask (it could not hold the middle state).
Which item each of the 54 indices is stays open: the arsenal id lists in
GP_HANGAR_ARSENAL.pak union to 35, and MG I sits at index 1, not 0.
Also recorded: inside a modal yes/no dialog a 60 ms d-pad tap is ignored (the
list menus accept it), and the develop confirm starts on NO while the mount
prompt right after it starts on YES.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
275 lines
15 KiB
Markdown
275 lines
15 KiB
Markdown
# 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`](../../../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 %`. **It is not a stage counter**: developing one Arsenal weapon stepped it to 6 (see [the develop differential](#the-develop-differential-one-weapon-three-fields)) |
|
||
| +12 | 0 | ❔ |
|
||
| +16 | 1 | ❔ |
|
||
| +20 | 0 | ❔ |
|
||
| +24 | **4101** (`0x1005`) | ✅ **Points — the spendable balance.** Named from the Details panel, and **separated from +28** by the develop differential: spending 4000 P moved *only* this field (4101 → 101) and the panel then read `Points 101 P` |
|
||
| +28 | **4101** (`0x1005`) | 🟡 **not** the displayed Points — it did **not** move when 4000 P was spent. A lifetime/earned total is the obvious read; unproven until a save is taken after *earning* points |
|
||
| +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 | 🟡 **per-item Arsenal development state**, 54 one-byte entries, values only ∈ {0, 2, 4} — histogram `{4: 10, 2: 6, 0: 38}`. `4` = developed, `2` = developable now, `0` = still locked ([evidence](#the-develop-differential-one-weapon-three-fields)) |
|
||
|
||
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
|
||
develop differential below identifies *what the values mean* without settling
|
||
*which list the index runs over* — the two are separate questions, and only the
|
||
first is answered.
|
||
|
||
### The develop differential: one weapon, three fields
|
||
|
||
**2026-08-11, in the running game.** The save state loaded from slot 01 (Stage 02,
|
||
"At Standby", 4101 P) was taken into READY ROOM → ARSENAL → GUN, where exactly one
|
||
item was developed — **Light Machine Gun MG I, 4000 P** — the mount prompt was
|
||
declined so that nothing but the development changed, and the result was saved to
|
||
the first empty slot (03). Slots 01/02 were not touched.
|
||
|
||
Diffing the 545-byte payload of slot 03 against slot 02 (the same state saved
|
||
*before* the development) moves **exactly three things**:
|
||
|
||
| field | before | after | what the screen showed |
|
||
|---|---|---|---|
|
||
| +8 clear ratio | 5 | **6** | slot 03's Details panel: `Clear Ratio 6 %` |
|
||
| +24 Points | 4101 | **101** | the Arsenal's `Points` readout, and slot 03's `Points 101 P` |
|
||
| +68 blob `[1]`, `[2]` | `2`, `0` | **`4`**, **`2`** | "You obtained Light Machine Gun MG I" and "You can now develop Light Swivel Machine Gun MG2H" |
|
||
|
||
Everything else — `+28`, `+4` flight time, `+36/+52/+56`, the `SHAB` table, the
|
||
trailer — is byte-identical. That is what makes the reading of the blob more than
|
||
a guess: **one action produced exactly two blob transitions, and the game
|
||
announced exactly two events, in the same order**. `2 → 4` is the item that was
|
||
bought; `0 → 2` is the tech-tree successor it unlocked. So the alphabet is a
|
||
three-state progression *locked → developable → developed*, and the blob is the
|
||
Arsenal's development state, not a bitmask of owned weapons (a bitmask could not
|
||
represent the middle state).
|
||
|
||
Two things this does **not** settle, and should not be written as if it did:
|
||
|
||
- **Which item each index is.** The index space is 54 wide; the player-arsenal id
|
||
lists in `GP_HANGAR_ARSENAL.pak` union to 35 distinct weapon ids, so the blob is
|
||
not indexed by that list. MG I is at index **1**, not 0, even though it is the
|
||
first row of the first weapon type — so index 0 is something already developed
|
||
that the GUN list does not show first. **NEEDS-HUMAN / needs another
|
||
differential**: develop a second item in a *different* category and see which
|
||
index moves.
|
||
- **Whether the +1 on the clear ratio is per item.** One data point. If it is
|
||
per item, the ratio is a *collection* metric and not a stage metric, which
|
||
matters because it is one of the three numbers the Details panel shows and it
|
||
had been read as mission progress.
|
||
|
||
Evidence: [`savedata-game03-developed-mg1.bin`](../captures/savedata-game03-developed-mg1.bin)
|
||
(compare against [`savedata-game02-samestate.bin`](../captures/savedata-game02-samestate.bin)
|
||
with `savegame.py`), and the screenshots
|
||
[before](../captures/arsenal-gun-list-predevelop.png) ·
|
||
[after](../captures/arsenal-mg1-developed.png) ·
|
||
[Details panel](../captures/savegame-details-slot03-developed.png).
|
||
|
||
### The 16-record `SHAB` 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 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`](../../../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).
|
||
|
||
A third trap, found on the Arsenal run: **a 60 ms d-pad tap is ignored inside a
|
||
modal yes/no dialog** even though it is exactly one step in the list menus. The
|
||
`Do you want to develop this weapon?` cursor did not move until the press was
|
||
held ~250 ms (`nav_probe.sh`'s own `step()` duration). Always screenshot the
|
||
dialog and confirm which side the cursor is on before pressing A — the develop
|
||
confirm starts on **NO**, the "would you like to mount it now?" prompt that
|
||
follows it starts on **YES**, so a fixed key sequence cannot be trusted across
|
||
two dialogs that appear back to back.
|
||
|
||
### 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 (`0xBC455680`→`0xBC474020`, `0xBD4B6200`→`0xBD3B5180`,
|
||
`0x70D8FD50`→`0x70A8FD50`, …). **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.png`s
|
||
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 %`
|
||
|
||

|
||
|
||
`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](../mission-outcome-stage02.md)).
|
||
|
||
**Two differentials have been run.** The same-state one named three fields but
|
||
could not separate fields that shared a value; the develop one (above) separated
|
||
`+24` from `+28` and gave the blob its alphabet. What is left, in order of what
|
||
it 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**~~ — **done**, see
|
||
[the develop differential](#the-develop-differential-one-weapon-three-fields).
|
||
Its follow-up is a *second* development in a different weapon category, which
|
||
is what maps blob indices to items — but it needs Points, and the balance is
|
||
now 101 P, so it waits on a mission payout.
|
||
|
||
**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.
|