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.
This commit is contained in:
2026-08-11 06:02:21 +00:00
parent 398e8ae0af
commit 882dbc2ae2
4 changed files with 166 additions and 24 deletions

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 900 KiB

View File

@@ -1,9 +1,9 @@
# Save file (`savedata`) — container ✅ exact, field meanings ❔ (2026-08-11)
# 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.
**❔ for what most of the numbers mean**; those are marked below and none are
asserted.
**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).
@@ -46,7 +46,7 @@ Every element below is read off the serializer, whose writer primitive is
| `'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 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
@@ -68,28 +68,31 @@ 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** | ❔ a large per-save counter — credits/score is the obvious guess, untested |
| +8 | 5 | ❔ the save-select screen shows this save at **5 %** |
| +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`) | ❔ same value as +28 and as slot 0's field b |
| +28 | **4101** (`0x1005`) | |
| +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 | ❔ equals slot 0's field a; the header calls this save `EASY` |
| +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 | ❔ |
| +52 | 2 | |
| +56 | 2 | |
| +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} |
| +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, 20 of the 54 non-zero:
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
@@ -99,7 +102,7 @@ enumerated states, 20 of the 54 non-zero:
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)
### The 16-record `SHAB` table (16 × 20 bytes)
| slot | a | b | c | FILETIME |
|---|---|---|---|---|
@@ -112,8 +115,76 @@ 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.
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).
### 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.
## What this does and does not unlock
@@ -122,13 +193,16 @@ state, so it does not yet answer the question that motivated it — whether stag
progress can be reached without winning missions
([mission outcome](../mission-outcome-stage02.md)).
**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.
**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