# 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 | ❌ **not** difficulty and **not** stage — refuted by probe saves (see below) |
| +40 (u64) | 2014400 | ❔ |
| +48 | 0 | ❔ (`Times Cleared: 0` is on screen, so it is one of the zero fields) |
| +52 | 2 | ❌ see +36 — refuted |
| +56 | 2 | ❌ see +36 — refuted |
| +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**, one byte per item, `4` = owned, `2` = developable now, `0` = locked ([evidence](#the-develop-differential-one-weapon-three-fields)). Only the `4`s are stored state — `2` is re-derived at load. The index space is **solved**: it is `strings.tbl`'s item order, cut items included ([mapping](../arsenal-develop-economy.md)) |

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
```

The two questions here — *what the values mean* and *which list the index runs
over* — are separate, and were answered separately: the develop differential
below settles the first, and four hand-written probe saves settle the second (the
index space is `strings.tbl`'s item order; see
[the economy note](../arsenal-develop-economy.md)). The `54` length matches no
count established elsewhere, and in particular it is **not** `weapon.tbl`'s
equally-54-long id run — that coincidence cost real time.

### 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 **solved** — see
[the Arsenal develop economy](../arsenal-develop-economy.md). The index space is
the item order in `GP_HANGAR_ARSENAL.pak :: eng\strings.tbl`, i.e. the display
order **plus the cut items that only the localisation file lists**, pinned by four
hand-written probe saves (indices 9, 21, 39, 48) and closing exactly at 53. The
retail save's five otherwise-unexplained owned entries are those cut items,
shipped flagged as owned and never rendered.

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 %`

![LOAD GAME details for the state this save holds](../captures/savegame-details-slot02.png)

`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.

## The `SHAB` table is the per-stage record table — record 0 is Stage 01 ✅

**2026-08-11.** `EXTRAS → MISSION SELECT` renders per-stage records, and they
match the table. For Stage 01 the screen shows

> `High Score 4612 P EASY` · `Best Time 05:24.77 EASY`

and `SHAB[0]` is `(a=2, b=4101, c=324773, FILETIME 2026-07-23 20:07:23)`, where
**`c = 324773 ms = 5 m 24.773 s`** — exactly the displayed best time. With
sixteen records and sixteen story stages (see the `.rdata` section above), the
table is **indexed by stage**, and record 0 is Stage 01 — the only stage this
save has cleared.

| field | record 0 | reading |
|---|---|---|
| `a` | 2 | 🟡 the **difficulty of the record** — the screen tags both entries `EASY`, and 2 is the only value present |
| `b` | 4101 | ❔ **not** the displayed high score (4612). It equals GHAD `+24`/`+28`, so it is a points figure, not the score with bonus |
| `c` | **324773** | ✅ **best clear time, ms** — `05:24.77` on screen |
| `d,e` | FILETIME | when the record was set |

This also explains a probe that looked like a dead end: filling `SHAB[1]` changed
nothing on the save screen because the table holds **stage records, not
progress** — and Stage 02, where this save sits, has never been cleared, so its
record is legitimately empty. It promotes the earlier 🟡 "per-stage result
record" guess to ✅ for the layout, with the time field named exactly.

Evidence: [`captures/mission-select-stage01-record.png`](../captures/mission-select-stage01-record.png).
The route is `EXTRAS → MISSION SELECT` from the title menu; only cleared stages
are selectable (16 d-pad presses never left Stage 01), and the screen carries its
own **difficulty selector** (`Y`) — that selector is the *next run's* difficulty,
while the `EASY` beside each record is the difficulty the record was set on.

**The title-menu screens read the auto-save (slot 01), not the newest slot.**
Tested: slot 03 was given `SHAB[0] = (a=1, b=12345, c=60000)` — a one-minute best
time — and MISSION SELECT still showed `4612 P EASY / 05:24.77 EASY`, i.e. slot
01's numbers. So a probe written to a throwaway slot is **invisible** on these
screens.

Two consequences. First, the identification above is unaffected — the match was
against slot 01's own bytes, which is exactly what the screen renders. Second,
**mapping the difficulty enum (`a`) or testing `b` requires editing slot 01**, the
only save carrying real progress. That is deliberately not done here: it is the
user's call, and it is the one edit in this whole line of work that is not
trivially reversible from a throwaway copy. (A full content backup exists at
`/sylph-home/re/content-backup-2026-08-11-preSaveRE`, and slot 01's bytes are
archived, so the call is *recoverable* — it is still not mine to make.)

One more thing this settles: **`High Score 4612` is not stored anywhere.** It is
absent from every file in the content tree — both `.gpd` profile files, all three
saves (compressed and inflated) and `xconfig.settings` — while the best time
`324773` is present in slot 01 alone. So the score shown on that screen is
**computed at display time** from stored fields, which is why `SHAB[0].b = 4101`
does not equal it.

## 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.

## Writing a save back — the container's derived fields

**A hand-written save loads.** Done 2026-08-11 in the sandbox, into our own
throwaway slot 03 (the user's slots 01/02 were not touched and the original of 03
is archived in `captures/`), to settle a question that otherwise needed story
progress — see [the Arsenal develop economy](../arsenal-develop-economy.md).

`savegame.py` round-trips the *payload*; the container needs three more fields,
all functions of the payload, found by diffing two saves whose payloads differed:

| header off | value |
|---|---|
| `+0x30` u32 | `len(deflate stream) + 10` |
| `+0x8c` u16 | `len(payload)` — 545 in every save seen |
| `+0x8e` u32 | `adler32(payload)` — the zlib trailer, duplicated into the header |

Everything else is copied from the donor. That is safe precisely because of the
earlier same-state result: the words that move between two saves of one state are
the FILETIME and uninitialised guest-pointer padding, so nothing else is
validated. [`tools/re-capture/savegame_edit.py`](../../../tools/re-capture/savegame_edit.py)
implements it and **re-wraps a real save byte-identically**, which is the check
that the three fields above are the only derived ones.

What the title does with an edited save is itself a finding: on load it
**re-derives** which items are *developable* and announces the difference ("You
can now develop …"). So the blob's `4`s are authoritative state and its `2`s are
not — see the economy note.

### What the probes then refuted: the whole GHAD block is **not** stage or difficulty

The three fields that all hold `2` were read as "difficulty or stage, undecidable
from one save" from the day the format was parsed. Writing saves makes it
decidable, and the answer is **neither** — and the sweep did not stop there.

Method — [`boot_menu.sh`](../../../tools/re-capture/boot_menu.sh) boots to the
title menu **without loading anything**, and LOAD GAME's slot list renders each
slot's Details panel (`STAGE`, `Game Status`, `Points`, `Times Cleared`) plus a
row (date, `Difficulty`, flight time, clear ratio). Extra slots can be fabricated
— copy the save directory and its `Headers/…/gameNN.header`, patching the
UTF-16BE display string and the ASCII `gameNN` inside it — so **five probes fit
in one boot**, read-only, nothing loaded.

**Probed, all with no effect on `STAGE 02` or `Difficulty EASY`:** every scalar in
the GHAD block — `+0`, `+12`, `+16`, `+20`, `+28`, `+32`, `+36` (at 1, 3 and 9),
`+40` (u64), `+48`, `+52`, `+56`, `+60`, `+64` (raw) — plus `SHAB[0].a`, plus the
`SHAB` **fill count** in both directions (record 1 filled with a copy of record 0;
record 0 cleared). Sixteen elements.

The panel genuinely re-reads each slot — the control is slot 02, which holds
`Points 4101` and `Clear Ratio 5 %` and displays exactly that while its
neighbours show `101` and `6 %`. A further control: patching a slot's **header**
string to `STAGE09 HARD` changed nothing, and the row's date follows the
**container FILETIME**, so the row is payload-formatted, not header text.

**What is left**, and it is a much narrower set than when this started: the
**phase string** (`GP_BUNK`), the trailer (`"BUNK"` + `0x09150000`), or the blob.
One honest caveat on the negative: **every save on disc is genuinely Stage 02 /
EASY**, so "the field was not found" and "the panel does not vary those two
labels per slot" are not yet separated. The next move is not another probe — it
is to find the code that formats `STAGE %02d` and read which offset it loads,
which the static DB can answer directly.

### The read side, and where the save state lives in memory

The serializer at `0x822C00E8` has a mirror, found by disassembling around it —
and the call graph names the whole read path:

| function | role | pairs with |
|---|---|---|
| `0x822C0380` | **deserializer** — reads `GDAA`, the phase string, then calls the two below | `0x822C00E8` |
| `0x822BF858` | **GHAD reader**, called with `save+8` | `0x822BF678` |
| `0x822BFD60` | **`SHAB` table reader**, called with `save+136`, count 16 | — |

The GHAD reader is a plain byte-for-byte copy — unrolled per field, no clamping
or validation anywhere in it, which is why a hand-written value survives into the
object exactly as written (and why the develop-blob probes worked).

**The save state lives at offset `+304` of a game object.** The deserializer's
only caller sets it up as `lwz r11,4(r30)` → `lwz r11,0(r11)` → `addi r4,r11,304`
(`0x82290594`–`0x8229059c`). Combined with the layout above, every field has a
fixed absolute offset in that object, which is what a live-RAM read needs:

```
save base   = obj + 304        GHAD block = obj + 312
flight time = obj + 316        clear ratio = obj + 320
Points      = obj + 336        develop blob = obj + 380
SHAB table  = obj + 440        (obj + 304 + 136)
```

**What this did not do, and why:** finding the panel's populate code by searching
for those absolute offsets does not work — the address space is dense enough that
`lwz rX, 336(rY)` has 193 matches, and the windows that contain several of the
right offsets at once are false positives: one is a **vtable thunk table** whose
slots happen to sit at 308–340, another is a float-parameter block. Naming the
stage and difficulty fields needs the save-screen's populate path reached from
the screen side (its config record and sprite-key lookup), not from the offsets.

### What the screen itself is built from (static, `.rdata`)

The LOAD/SAVE screen's config **key list is compiled into the executable**, as a
pointer array of key strings — the same shape as the Arsenal's (whose keys sit a
few hundred bytes earlier and match the pak record exactly, which is what
identifies these as config keys rather than loose strings). The save-screen run
starts at **`0x820a0074`**:

```
game, replay, LOADING, GAME, REPLAY, SELECTOR, SAVE_BASES, SAVE_MENUS,
LOAD_BASES, LOAD_MENUS, THUMBNAIL, FONT, EX_FONT, AUTO_SAVED,
AUTO_SAVED_DETAIL, MISSION_SELECT, MISSION_SELECT_DETAIL, CLEARED, EMPTY,
EMPTY_DETAIL, SLOT, VOLUME, STRINGS,
GAME_EASY, GAME_NORMAL, GAME_HARD, GAME_VERY_HARD,
STATE_STAND_BY, STATE_STAGE_CLEAR, STATE_GAME_CLEAR,
LOCATE, …, STAGE01 … STAGE16, CLEAR_TIME_RANKING
```

Three things fall out, none of which needed the emulator:

- **There is a fourth difficulty.** `GAME_VERY_HARD` exists alongside EASY /
  NORMAL / HARD, while the stage tables only carry `BonusPoint_EASY/NORMAL/HARD`.
  So VERY HARD is 🟡 an unlockable tier the stage bonus table does not price.
- **`Game Status` is a three-valued enum** — `STATE_STAND_BY` (what our save
  shows as `At Standby`), `STATE_STAGE_CLEAR`, `STATE_GAME_CLEAR`.
- **The screen can label exactly 16 stages**, `STAGE01`…`STAGE16`. That matches
  `weapon.tbl`'s `AUTO_SETTINGS` list — `stage01…16` plus `tutorial01…06` and
  `challenge01…06` — so **16 is the story-stage count**. ✅ **The "22 stages"
  figure used elsewhere in these notes is now reconciled**: the disc carries 22
  stage *containers*, `Stage_S01…S16` **plus `Stage_S24…S29`** — 16 story + 6
  extra. Both numbers are right; they count different things, and neither should
  be edited to match the other.

The panel's own field names are here too, with their screen positions:
`Points`, `FlightTime` (`%03d:%02d:%02d`), `ClearTimes`, `CompletionRate`,
`RatioOfClear`, `Index`, `Label`, `Date`, `BrokenData`.

**This does not name the stage/difficulty save fields**, and it explains why the
probe sweep could not either: the screen selects a *sprite key by index*, so the
value it indexes with is whatever the loader put in the live object — and the
strings are reached through a config lookup, not an immediate, so there is no
`lis/ori` pair to xref back to the selecting code. Chasing it further means
disassembling the save-screen's populate path, not another grep.

Editing beyond a throwaway slot is still the user's call.
