Four probes against slot 01 (the save the title-menu screens read), each backed up and restored byte-identically: - SHAB[1..7] filled -> MISSION SELECT still locks Stage02-08, cursor will not leave Stage01. The per-stage record table is not the unlock gate; the earlier SHAB[1] attempt used a throwaway slot and could not show this. - GHAD +36/+52/+56 = 5 -> still locked, panel still STAGE 02 / EASY. - GHAD +16 = 4 -> panel still STAGE 02, so it is not a 0-based stage index. - live-RAM writes into the loaded GDHA header -> panel unchanged. The useful part is why the panel cannot answer this: guest RAM at that screen holds NO payload bytes (neither the 4101/4101/79 triple nor the 54-byte develop blob), but it does hold the save's GDHA container header with a summary copy of Points, flight time, clear ratio and a FILETIME. savegame_edit.py copies the donor header verbatim, so a payload edit leaves that summary stale — "the panel did not change" therefore cannot separate "not the stage field" from "the panel never reads the payload". Future stage/difficulty probes must patch the header too, or be judged by what the game does on load. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
29 KiB
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
(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) |
| +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). Only the 4s 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) |
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). 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. 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
(compare against savedata-game02-samestate.bin
with savegame.py), and the screenshots
before ·
after ·
Details panel.
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 (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.pngs
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.
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).
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:
- A save at a different difficulty — start a new game on NORMAL, save, compare: whichever of +36/+52/+56 moves is the difficulty.
- A save after clearing a stage — settles stage vs. difficulty,
Times Cleared, whetherSHAB[1]fills, and whether the clear ratio steps by 5 %. A save after developing one Arsenal item— done, see the develop differential. 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.
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
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 4s are authoritative state and its 2s 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 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_HARDexists alongside EASY / NORMAL / HARD, while the stage tables only carryBonusPoint_EASY/NORMAL/HARD. So VERY HARD is 🟡 an unlockable tier the stage bonus table does not price. Game Statusis a three-valued enum —STATE_STAND_BY(what our save shows asAt Standby),STATE_STAGE_CLEAR,STATE_GAME_CLEAR.- The screen can label exactly 16 stages,
STAGE01…STAGE16. That matchesweapon.tbl'sAUTO_SETTINGSlist —stage01…16plustutorial01…06andchallenge01…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…S16plusStage_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:
- The Details panel is not a payload oracle. The numbers it shows exist in
the header too, and
savegame_edit.pycopies 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.) - The panel is not live-editable either: writing the header copy in
/dev/shmand 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.
