re: the save file is a GDHA/zlib chunk stream — layout read off the title's own serializer
The whole retail save is 545 bytes: a GDHA container wrapping a zlib payload,
which is a chunk stream — 'GDAA', a length-prefixed game-phase name (GP_BUNK,
one of the title's GP_* screen ids), a 'GHAD' 122-byte progress block, a
count-prefixed table of 16 20-byte save slots, and a trailer.
None of it is guessed. The layout comes from the serializer at 0x822C00E8 and
its callee 0x822BF678: the writer primitive is 0x821885A8(stream, buf, len), the
slot count 16 and the 20-byte stride are literals, and the GHAD block is ten u32,
a u64, four u32, a raw 4-byte field and a raw 54-byte blob = 122 bytes, which is
exactly what the file carries. savegame.py re-serializes the parse and asserts
byte-identity.
Because the struct is written field-by-field with no packing changes, a payload
offset is also the offset in the live save object -- save+8 for GHAD, save+136
for the slot table, and 136+16*20 = 456 is the serializer's very next access.
Confirmed independently: slot 0's trailing two u32 decode as a FILETIME to
2026-07-23 20:07:23 UTC, and the content header's display string (written by the
game) says 07/23/2026 21:08 at UTC+1; the 15 empty slots hold 2006-01-01 rather
than zero.
Field meanings are left ❔ on purpose -- the naming oracle is a diff of two
saves, and all four copies on disk are byte-identical, so a second save has to
be made first. Nothing has been written back to any save.
This commit is contained in:
@@ -24,6 +24,7 @@ Promote to a prose `structures/…md` file when a format needs behavioural notes
|
||||
| Weapon fields defaulted on disc | ✅ | [runtime struct](structures/weapon-struct-runtime.md) · [DATA SHEET route](weapon-datasheet-runtime.md) | **Solved.** Canary maps guest RAM into `/dev/shm`, so the parsed `Weapon`/`Shell` objects are readable live; their layout is solved against disc ground truth (zero contradictions over 100+ records). All 126 weapons, exact numbers, no story progress needed — [4 393 values](captures/weapon-runtime-fields.csv) the disc does not carry. Supersedes the letter-bucket limit of the DATA SHEET route, which now serves as the independent cross-check |
|
||||
| Unit (craft/vessel) fields defaulted on disc | ✅/🟡 | [runtime struct](structures/unit-struct-runtime.md) | The parsed `unit\UN_*.tbl` definition object, vtable `0x820af844`, ≥`0x380` bytes, one per unit — **discovered, not assumed** (`unit_discover.py`), and distinguished from the spawned-entity class `0x820af030` by being one-per-ID and byte-constant within a run. Across runs only pointer words move — `--crosscheck` proves **no reported field offset is run-dependent** (two words, `+0x2c8`/`+0x2d0`, are stage-dependent and remain unidentified). 27 fields ✅ (21 units, 7 runs); the `Maneuver` block is **schema declaration order, 4 bytes/field, base `0x9c` with a two-slot gap after `AA_Roll_Min`** (29 anchors, 0 conflicts), which also pins 5 fields *no* disc record ever values. Angles are **radians at runtime, degrees on disc**. Unlike weapons, unit definitions are instantiated **per stage**, so coverage (21/110) grows by visiting missions — but a defaulted field is **not** a global constant: `Size_Y` provably inherits `Size_X` (7 independent units, 6 distinct values), and three more sibling rules are recorded ❔, recovering 65 values in units never visited — [values](captures/unit-runtime-fields.csv) |
|
||||
| UI screen layout (`.rat`) | ✅/🟡 | [ui-rat-layout](structures/ui-rat-layout.md) | One pak per UI screen; each RATC = one (context × language) build; every `<name>.t32` sprite has a `<name>.rat` **layout record** (BE u32; 1280×720 design space; scale/tint/X/Y, keyframes for animated elements, `opt ` link to the focused state). **The tutorial PAUSE menu and the title main menu both rebuild pixel-accurately from the disc.** `loop1.rat` (screen-level draw order) not yet decoded |
|
||||
| Save file (`savedata`) | ✅/❔ | [savegame-format](structures/savegame-format.md) + [`tools/re-capture/savegame.py`](../../tools/re-capture/savegame.py) | `GDHA` container, zlib payload, chunk stream (`GDAA` / phase name / `GHAD` 122 B progress block / 16×20 B slot table / trailer). **Container and layout read off the title's own serializer `0x822C00E8` and verified by a byte-identical round-trip**; the whole save is 545 B. Payload offsets are also the live save object's offsets (`save+8` GHAD, `save+136` slots). Field *meanings* are ❔ — only the phase name, the slot count and the FILETIME are confirmed (the latter cross-checked against the content header's own display string). Naming the rest needs a second save to diff |
|
||||
|
||||
## Runtime / dynamic-capture technique
|
||||
|
||||
|
||||
BIN
docs/re/captures/savedata-stage02-5pct.bin
Normal file
BIN
docs/re/captures/savedata-stage02-5pct.bin
Normal file
Binary file not shown.
137
docs/re/structures/savegame-format.md
Normal file
137
docs/re/structures/savegame-format.md
Normal file
@@ -0,0 +1,137 @@
|
||||
# Save file (`savedata`) — container ✅ exact, field meanings ❔ (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.
|
||||
|
||||
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) | the save-slot table | `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.
|
||||
|
||||
| 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 %** |
|
||||
| +12 | 0 | ❔ |
|
||||
| +16 | 1 | ❔ |
|
||||
| +20 | 0 | ❔ |
|
||||
| +24 | **4101** (`0x1005`) | ❔ same value as +28 and as slot 0's field b |
|
||||
| +28 | **4101** (`0x1005`) | ❔ |
|
||||
| +32 | 79 | ❔ |
|
||||
| +36 | 2 | ❔ equals slot 0's field a; the header calls this save `EASY` |
|
||||
| +40 (u64) | 2014400 | ❔ |
|
||||
| +48 | 0 | ❔ |
|
||||
| +52 | 2 | ❔ |
|
||||
| +56 | 2 | ❔ |
|
||||
| +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} |
|
||||
|
||||
The 54-byte blob is the interesting one — a fixed-length array of small
|
||||
enumerated states, 20 of the 54 non-zero:
|
||||
|
||||
```
|
||||
04 02 00 00 00 02 00 00 00 04 02 00 02 00 00 00 00 00 00 00 00 04 04 00 00 00 04
|
||||
02 00 00 00 02 00 04 00 00 00 00 00 04 00 00 00 00 00 04 04 04 00 00 00 00 00 00
|
||||
```
|
||||
|
||||
54 matches no count we have already established (22 stages, 110 units, 126
|
||||
weapons, ~61 Arsenal entries), so **do not** assume which list it indexes.
|
||||
|
||||
### The slot 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 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.
|
||||
|
||||
## 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)).
|
||||
|
||||
**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.
|
||||
|
||||
**Nothing here has been written back to a save.** Editing is a separate decision
|
||||
and needs the user's go-ahead; the round-trip in `savegame.py` exists so that an
|
||||
edit *could* be made safely (parse → change one field → re-serialize → re-deflate
|
||||
→ re-wrap), and so that a diff of two saves can be read structurally instead of
|
||||
as raw bytes.
|
||||
Reference in New Issue
Block a user