GP_HANGAR_ARSENAL.pak's screen config points at weapon.tbl (item ids, in the 8-category display order) and strings.tbl (names, descriptions, and a "Conditions to obtain" block per item). The id run No_Equipment .. Wep_83 is exactly 54 long -- the save blob's length -- and all 60 conditions blocks are extracted to a CSV: gates are stage completion, a predecessor item, or an ace kill; costs run 3000-350000 P, and 20 items cost nothing once gated (which is why items the player never bought read as Developed). Predicting the save state from those conditions -- before looking at the blob -- says exactly six items are developable here, and the blob's six 2s sit on those six, in weapon.tbl order, at indices 1/5/10/12/27/31. With the four obtained items and the differential's own two transitions that is twelve concordances over indices 0-31, nothing fitted. Broad Sword SG1 at index 5 needed scrolling the GUN list to see, which is the only one the first screenshots missed. The tail is NOT settled and is marked so: the Tomahawk is weapon.tbl index 38 and the screen shows it Developed, but blob[38] = 0, and the other tail 4s (33/39/45/46/47) land on items the Arsenal shows as locked -- the SPECIAL tab is entirely empty. A +1 shift does not repair it either. Settling it needs a second development in a late category, which needs a mission payout. Also recorded: IDXD string pools dedupe repeated values, so only the first record of a table can be read from the token stream -- record 2 shows just its unique values, record 6 no cost at all. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
276 lines
15 KiB
Markdown
276 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).
|
||
|
||
**Which item each index is** was chased separately and is **half settled** — see
|
||
[the Arsenal develop economy](../arsenal-develop-economy.md). In short: the index
|
||
space is the `WEAPONS` id list in `GP_HANGAR_ARSENAL.pak :: eng\weapon.tbl`, whose
|
||
`No_Equipment … Wep_83` run is exactly 54 long; twelve independent concordances
|
||
confirm indices **0–31** (including all six `2`s, predicted from the disc's own
|
||
"Conditions to obtain" text before the blob was looked at), while the tail from
|
||
index 33 on contradicts the screen and is left ❔.
|
||
|
||
One more thing this does **not** settle, and should not be written as if it did:
|
||
|
||
- **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.
|