Comparing the two same-state saves (both 276 bytes), only 12 of 69 u32 words differ, and nine of those carry guest addresses -- 0xBC/0xBD/0x70 prefixes, the same regions the runtime work uses. Three differ by exactly 0x101080 and two by exactly 0x300000: a shared constant offset is what a relocated heap does to a pointer and not what data does. The split is sharp: pointer-shaped words are 26% of the file and 44% of them differ; non-pointer words are 8%. So a quarter of this structure is captured heap addresses that change run to run regardless of play. That is the concrete form of the page's existing 'much of the rest is uninitialised memory', now demonstrated by constant deltas rather than inferred from odd-looking values. Also records that my first pass was wrong: a byte-wise three-way diff reported 135 of 276 bytes differing, but game03 is 280 bytes with a ZERO-byte common suffix, so offsets do not correspond past 0x00A. The valid equal-length pair gives 30 bytes in 12 words. The inflated figure looked plausible -- half a save changing is what dense state would do -- which is why it is written down.
634 lines
35 KiB
Markdown
634 lines
35 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 | 🟡 **Times Cleared** — mirrored to header `+0x28`, and the panel prints `Times Cleared: 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 | 🟡 **Game Status enum** — mirrored to header `+0x18`; `0` renders `At Standby`, matching the screen's own `STATE_STAND_BY / STATE_STAGE_CLEAR / STATE_GAME_CLEAR` list |
|
||
| +52 | 2 | ✅ **STAGE NUMBER, 1-based** — set it to 5 (with the header mirror, below) and the game reads `STAGE 05 — Star System Escape`, loads it, and **flies Stage 05** |
|
||
| +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 %`
|
||
|
||

|
||
|
||
`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.
|
||
|
||
## Stage unlock: four probes, all negative — and a method correction (2026-08-13)
|
||
|
||
The motivating question ("can stage progress be reached without winning
|
||
missions?") got its first tests **against slot 01**, the save the title-menu
|
||
screens actually read. Slot 01 was backed up, edited, and restored
|
||
byte-identically after each run (md5 `142b4f43…`, verified twice).
|
||
|
||
| probe (in slot 01) | oracle | result |
|
||
|---|---|---|
|
||
| `SHAB[1..7]` filled with record 0's shape | `EXTRAS → MISSION SELECT` | **Stage02–08 stay greyed out**; the cursor will not leave Stage01 (two d-pad steps, no movement) |
|
||
| GHAD `+36 = +52 = +56 = 5` | MISSION SELECT + LOAD GAME Details for slot 01 | **still locked**, panel **still `STAGE 02`/`EASY`** |
|
||
| GHAD `+16 = 4` (a 0-based stage index would read `STAGE 05`) | LOAD GAME Details for slot 01 | **still `STAGE 02`** |
|
||
| live-RAM writes into the loaded container header (Points, and its `+0x14`) | LOAD GAME Details, re-rendered by moving the cursor off and back | **no change** |
|
||
|
||
So the per-stage record table is **not** the unlock gate — that is now tested on
|
||
the save the screens read, where the earlier `SHAB[1]` attempt was written to a
|
||
throwaway slot and could prove nothing. And `+16` joins `+36/+52/+56` as **not**
|
||
the displayed stage; the earlier refutation of those three stands, now confirmed
|
||
against slot 01 itself.
|
||
|
||
**The method correction, which is the useful part.** Searching guest RAM at the
|
||
LOAD GAME screen finds **no payload bytes at all** — neither the `4101/4101/79`
|
||
triple nor the 54-byte develop blob is anywhere in the guest address space — but
|
||
it does find the save's **`GDHA` container header**, loaded verbatim, carrying a
|
||
summary copy:
|
||
|
||
```
|
||
+0x00 'GDHA' +0x04 136 +0x08 FILETIME (2026-07-23 20:07:23)
|
||
+0x14 2 +0x1c 4101 (Points) +0x20 324773 (flight ms) +0x24 5 (clear ratio)
|
||
```
|
||
|
||
Two consequences:
|
||
|
||
1. **The Details panel is not a payload oracle.** The numbers it shows exist in
|
||
the *header* too, and `savegame_edit.py` copies the donor header verbatim — so a
|
||
payload edit leaves a stale summary beside it, and "the panel did not change"
|
||
cannot distinguish *"that field is not the stage"* from *"the panel never reads
|
||
the payload"*. Any future stage/difficulty probe must patch the header copy as
|
||
well, or be judged by what the game **does** on load rather than by the panel.
|
||
(This does not disturb the Points/flight-time/clear-ratio namings: those values
|
||
sit at both places and the two agree.)
|
||
2. The panel is **not** live-editable either: writing the header copy in
|
||
`/dev/shm` and forcing a re-render changed nothing, so the strings are formatted
|
||
before that copy is consulted again.
|
||
|
||
**Where this leaves the question.** Field guessing is now measurably the wrong
|
||
tool: three of the four candidate fields are eliminated and the remaining ones
|
||
(`+0`, `+12`, `+20`, `+32 = 79`, `+40 = 2014400`, `+48`, `+60`, `+64 = 09 15 00 00`)
|
||
have no oracle that a probe can read. The two routes that do have one are
|
||
**static analysis of the loader** — find what the title reads when it decides the
|
||
current mission and the selectable set, the same way the serializer at
|
||
`0x822C00E8` was found — or **clearing a stage in game**, which also settles
|
||
`Times Cleared` and whether `SHAB[1]` fills.
|
||
|
||
## ✅ SOLVED: `+52` is the stage, and the save picks the mission (2026-08-13)
|
||
|
||
**One `u32` chooses which of the 16 story stages the game plays.** Set GHAD `+52`
|
||
(and its header mirror, below) to 5 and the title reads
|
||
`STAGE 05 — Star System Escape`, loads it, briefs the **Gallia Asteroid Group**,
|
||
and takes off into an asteroid field with a different objective, roster and pilot
|
||
chatter — from a save whose only edited bytes are that field and its mirror
|
||
([details panel](../captures/savegame-stage05-probe-details.png) ·
|
||
[in flight](../captures/savegame-stage05-probe-inflight.png)).
|
||
|
||
### Why the earlier probes said "not the stage"
|
||
|
||
The header builder at `0x822870b4…0x82287128` copies a **summary of the progress
|
||
block into the GDHA container header**, and the screens read *that*:
|
||
|
||
| header | ← progress block (GHAD offset) | in this save |
|
||
|---|---|---|
|
||
| `+0x10` | `+0` | 0 |
|
||
| `+0x14` | **`+52`** | 2 → the panel's `STAGE 02` |
|
||
| `+0x18` | `+48` | 0 → `Game Status: At Standby` |
|
||
| `+0x1c` | `+24` | 4101 → `Points` |
|
||
| `+0x20` | `+4` | 324773 → `Flight Time` |
|
||
| `+0x24` | *computed* (`0x822842B0`, also cached to `+8`) | 5 → `Clear Ratio` |
|
||
| `+0x28` | `+12` | 0 → `Times Cleared` |
|
||
|
||
`savegame_edit.py` copies the donor header verbatim, so a payload-only edit leaves
|
||
that summary stale and the panel keeps showing the **old** stage — which is exactly
|
||
what made `+52` look refuted. **Patch both** (`hdr[0x14] = stage` and
|
||
`ghad[+52] = stage`) and the whole chain follows. The payload writer
|
||
`0x822BF678` walks the block field-by-field from `save+8`, so payload offset ==
|
||
progress-block offset; the block itself sits at **game-state `+312`** (Points at
|
||
`+336` = `312+24`, flight at `+316`), which is how the header mapping was read off
|
||
the disassembly rather than guessed.
|
||
|
||
The content header's **display string** (`Game01 07/23/2026 21:08 STAGE02 EASY`)
|
||
is separate and was *not* patched, so the slot row still reads `STAGE02` while the
|
||
Details panel reads `STAGE 05` — harmless, and a useful reminder that the row and
|
||
the panel have different sources.
|
||
|
||
### What this unlocks
|
||
|
||
Any story stage is now reachable without playing to it: write the probe into
|
||
slot 01 (back it up first — restore verified byte-identical here) and the existing
|
||
`tools/re-capture/launch_mission.sh` route loads it and takes off. That opens
|
||
**runtime captures in containers other than `Stage_S02`** — the remaining 85 XBG7
|
||
misses, the 24-vertex box identity, ship-placement generalisation to other classes
|
||
— and **per-stage unit definitions** for the Route-B coverage that grows by
|
||
visiting missions.
|
||
|
||
Still open: `+36` and `+56` (both 2, one of which is likely `Difficulty`, since the
|
||
header carries no difficulty field and the display string does), and the
|
||
`+32 = 79` / `+40 = 2014400` / `+64 = 09 15 00 00` fields.
|
||
|
||
## ✅ The save embeds live guest pointers — which is why diffing saves misleads
|
||
|
||
**2026-08-26.** Comparing the two committed saves that represent the **same game
|
||
state** (`savedata-stage02-5pct.bin` and `savedata-game02-samestate.bin`, both
|
||
276 bytes), only **12 of 69 `u32` words differ** — and most of those are not
|
||
data.
|
||
|
||
**Nine carry guest addresses**: values with a `0xBC…`, `0xBD…` or `0x70…`
|
||
prefix, the same regions the runtime work uses (`0xbdb59668` for the objective
|
||
counter, `0x820af030` for entity records). Three of them differ by **exactly
|
||
`0x101080`** and two by exactly `0x300000` — a shared constant offset is what a
|
||
relocated heap does to a pointer, and it is not what data does.
|
||
|
||
+0x04C 0xBD4B6200 -> 0xBD3B5180 delta 0x101080
|
||
+0x064 0xBD4B6330 -> 0xBD3B52B0 delta 0x101080
|
||
+0x06C 0xBD4B6338 -> 0xBD3B52B8 delta 0x101080
|
||
+0x070 0x70D8FD50 -> 0x70A8FD50 delta 0x300000
|
||
+0x080 0x70D8FD60 -> 0x70A8FD60 delta 0x300000
|
||
|
||
The split is sharp across the whole file:
|
||
|
||
| | words | differ between same-state saves |
|
||
|---|---|---|
|
||
| pointer-shaped (`0x70–0x8F`, `0xB0–0xBF`) | 18 of 69 (26 %) | **8 (44 %)** |
|
||
| everything else | 51 | **4 (8 %)** |
|
||
|
||
So **a quarter of this structure is captured heap addresses**, and they change
|
||
run to run regardless of what the player did. This is the concrete form of the
|
||
page's existing "much of the rest is uninitialised memory" — now demonstrated by
|
||
the constant deltas rather than inferred from the values looking odd.
|
||
|
||
**Practical consequence**: a byte diff between two saves is dominated by pointer
|
||
churn. Only the 51 non-pointer words carry state worth attributing, and of those
|
||
just four move between identical states:
|
||
|
||
+0x008 0x01DD1ADF -> 0x01DD2956 high half `0x01DD` stable, low half moves
|
||
+0x00C 0x144F6CDA -> 0x120EAB37
|
||
+0x038 0xDD0FF734 -> 0xAF0C96D7
|
||
+0x084 0x006800C0 -> 0x006E3A40
|
||
|
||
🟡 `+0x008` looks like a counter or timestamp — a stable high half with a moving
|
||
low half is that shape. The other three look unstructured; a checksum would fit
|
||
but nothing here tests it.
|
||
|
||
### ⚠️ A three-way diff of these files is invalid
|
||
|
||
My first pass diffed all three saves byte-wise and reported **135 of 276 bytes
|
||
(48.9 %) differing**. That number is wrong. `savedata-game03-developed-mg1.bin`
|
||
is **280 bytes**, not 276, and the longest common suffix with the others is
|
||
**zero bytes** — so the files are not a common layout with an insertion, and
|
||
comparing offset *i* across them is meaningless past the first divergence at
|
||
`0x00A`. The valid comparison is the equal-length pair, and it gives **30 bytes
|
||
in 12 words**, not 135.
|
||
|
||
Worth stating because the inflated figure looked plausible: half the file
|
||
changing between saves is exactly what one would expect if saves were dense
|
||
state, and nothing about it invites a second look.
|