From 398e8ae0af4ba35f03d361645b2884fb35ed42ce Mon Sep 17 00:00:00 2001 From: "Claude (auto-RE)" Date: Tue, 11 Aug 2026 05:40:26 +0000 Subject: [PATCH] =?UTF-8?q?re:=20the=20save=20file=20is=20a=20GDHA/zlib=20?= =?UTF-8?q?chunk=20stream=20=E2=80=94=20layout=20read=20off=20the=20title'?= =?UTF-8?q?s=20own=20serializer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/re/INDEX.md | 1 + docs/re/captures/savedata-stage02-5pct.bin | Bin 0 -> 276 bytes docs/re/structures/savegame-format.md | 137 +++++++++++++++++ tools/re-capture/savegame.py | 166 +++++++++++++++++++++ 4 files changed, 304 insertions(+) create mode 100644 docs/re/captures/savedata-stage02-5pct.bin create mode 100644 docs/re/structures/savegame-format.md create mode 100644 tools/re-capture/savegame.py diff --git a/docs/re/INDEX.md b/docs/re/INDEX.md index 6444286..016efbb 100644 --- a/docs/re/INDEX.md +++ b/docs/re/INDEX.md @@ -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 `.t32` sprite has a `.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 diff --git a/docs/re/captures/savedata-stage02-5pct.bin b/docs/re/captures/savedata-stage02-5pct.bin new file mode 100644 index 0000000000000000000000000000000000000000..45da12cb14f34029ff1cd5d812f25dcb7c7e320f GIT binary patch literal 276 zcmZ>F@o;2dVCZ1HD|KJQKj#*Z4FXIMN`RGt<;zkan-#(blRXR!e<$4K|8COMZ@U5{ zvd1;70YvZhP6AO3P5qV^K=fYmw==-Bcd|i~M7hFV?_`UD8-D}#C>hr@iSkbX$pQHZ z3>gduq6~qSD&Ak=En9J`xV*3=B_Sc9oWG-->+9#T?*%1^42;wCy&F}unOO{+G#JA; z(mt>!sco2WXC8BasgHr7U*Li=3H}oaO --verify` re-serializes the parse and asserts equality). + +## Where it lives + +``` +//535107D4/00000001/game01/savedata 545 B payload +//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> +``` + +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. diff --git a/tools/re-capture/savegame.py b/tools/re-capture/savegame.py new file mode 100644 index 0000000..770f074 --- /dev/null +++ b/tools/re-capture/savegame.py @@ -0,0 +1,166 @@ +#!/usr/bin/env python3 +"""Parse (and re-serialize) a Project Sylpheed save file. + +The retail save lives in Xenia's content tree as a single file: + + //535107D4/00000001/game01/savedata + +It is a `GDHA` container whose payload is **zlib-deflated** (`78 da`). The +payload is a self-describing chunk stream written by the title's serializer at +`0x822C00E8`; every field below is read off that function and its callee +`0x822BF678`, not guessed: + + 'GDAA' container magic + u32 len, char[len] current game phase, e.g. "GP_BUNK" + (index < 29 into the GP_* name table at + 0x820A5680; the .pe lists GP_HANGAR, + GP_READY_ROOM, GP_BUNK, GP_MOVIE, + GP_OPTIONS, GP_MISSION_*) + 'GHAD' + 122 bytes the progress block (see GHAD_LAYOUT) + u32 16, then 16 x ('SHAB' + 5*u32) the save-slot table + u32 4, "BUNK", 'NETA', u32 trailer + +`u32 16` and the 20-byte slot stride are literals in the serializer +(`addi r28,r0,16` / `addi r29,r29,20` at 0x822C01B0/0x822C0264), and the +in-memory struct is written field-by-field with no packing changes, so a file +offset here IS the offset in the live save object (base + 8 for GHAD, base + 136 +for the slot table, whose end at base + 456 is the next field the serializer +touches). + +Usage: + savegame.py # parse and print + savegame.py --verify # parse, re-serialize, assert byte-identical +""" +import struct +import sys +import zlib + +# (name, offset in the GHAD block, size). Read off 0x822BF678: ten u32, one u64 +# (`ld r11,40(r30)`), four more u32, a raw 4-byte field at +64 and a raw 54-byte +# blob at +68 — 122 bytes, which is exactly what the file carries. +GHAD_LAYOUT = [ + ("u32_00", 0, 4), ("u32_04", 4, 4), ("u32_08", 8, 4), ("u32_12", 12, 4), + ("u32_16", 16, 4), ("u32_20", 20, 4), ("u32_24", 24, 4), ("u32_28", 28, 4), + ("u32_32", 32, 4), ("u32_36", 36, 4), + ("u64_40", 40, 8), + ("u32_48", 48, 4), ("u32_52", 52, 4), ("u32_56", 56, 4), ("u32_60", 60, 4), + ("raw_64", 64, 4), + ("blob_68", 68, 54), +] +GHAD_SIZE = 122 +SLOT_COUNT = 16 +SLOT_FIELDS = 5 # 5 u32 per slot; the last two read as a FILETIME + + +def unwrap(raw): + """GDHA container -> (header bytes, inflated payload).""" + if raw[:4] != b"GDHA": + raise ValueError("not a GDHA container: %r" % raw[:4]) + i = raw.find(b"\x78\xda") + if i < 0: + raise ValueError("no zlib stream") + return raw[:i], zlib.decompress(raw[i:]) + + +def parse(payload): + if payload[:4] != b"GDAA": + raise ValueError("not a GDAA payload: %r" % payload[:4]) + o = 4 + (nlen,) = struct.unpack_from(">I", payload, o); o += 4 + phase = payload[o:o + nlen].decode("ascii"); o += nlen + if payload[o:o + 4] != b"GHAD": + raise ValueError("expected GHAD at %#x" % o) + o += 4 + ghad_at = o + ghad = {} + for name, off, size in GHAD_LAYOUT: + b = payload[ghad_at + off: ghad_at + off + size] + ghad[name] = struct.unpack(">I", b)[0] if size == 4 and not name.startswith("raw") \ + else struct.unpack(">Q", b)[0] if size == 8 else b + o = ghad_at + GHAD_SIZE + (count,) = struct.unpack_from(">I", payload, o); o += 4 + slots = [] + for _ in range(count): + if payload[o:o + 4] != b"SHAB": + raise ValueError("expected SHAB at %#x" % o) + o += 4 + vals = struct.unpack_from(">%dI" % SLOT_FIELDS, payload, o) + o += 4 * SLOT_FIELDS + slots.append(vals) + (tlen,) = struct.unpack_from(">I", payload, o); o += 4 + tname = payload[o:o + tlen].decode("ascii"); o += tlen + ttag = payload[o:o + 4]; o += 4 + (tval,) = struct.unpack_from(">I", payload, o); o += 4 + if o != len(payload): + raise ValueError("trailing %d bytes" % (len(payload) - o)) + return dict(phase=phase, ghad=ghad, slot_count=count, slots=slots, + trailer=(tname, ttag.decode("ascii"), tval)) + + +def serialize(p): + out = bytearray(b"GDAA") + out += struct.pack(">I", len(p["phase"])) + p["phase"].encode("ascii") + out += b"GHAD" + blk = bytearray(GHAD_SIZE) + for name, off, size in GHAD_LAYOUT: + v = p["ghad"][name] + blk[off:off + size] = v if isinstance(v, bytes) else \ + struct.pack(">I", v) if size == 4 else struct.pack(">Q", v) + out += blk + out += struct.pack(">I", p["slot_count"]) + for s in p["slots"]: + out += b"SHAB" + struct.pack(">%dI" % SLOT_FIELDS, *s) + tname, ttag, tval = p["trailer"] + out += struct.pack(">I", len(tname)) + tname.encode("ascii") + out += ttag.encode("ascii") + struct.pack(">I", tval) + return bytes(out) + + +def filetime(hi, lo): + """The two trailing slot u32 read as a Windows FILETIME (100 ns since 1601).""" + import datetime + ticks = (hi << 32) | lo + if not ticks: + return "-" + try: + return (datetime.datetime(1601, 1, 1) + + datetime.timedelta(microseconds=ticks // 10)).strftime("%Y-%m-%d %H:%M:%S") + except OverflowError: + return "?" + + +def main(): + path = sys.argv[1] + raw = open(path, "rb").read() + hdr, payload = unwrap(raw) + p = parse(payload) + if "--verify" in sys.argv: + again = serialize(p) + ok = again == payload + print("round-trip %s (%d bytes)" % ("OK — byte-identical" if ok else "MISMATCH", len(payload))) + if not ok: + for i, (a, b) in enumerate(zip(payload, again)): + if a != b: + print(" first diff at %#x: %02x != %02x" % (i, a, b)) + break + return 0 if ok else 1 + print("container : GDHA, %d bytes header + %d bytes deflate -> %d bytes" + % (len(hdr), len(raw) - len(hdr), len(payload))) + print("phase : %s" % p["phase"]) + print("GHAD:") + for name, off, size in GHAD_LAYOUT: + v = p["ghad"][name] + print(" +%-3d %-8s %s" % (off, name, + v.hex(" ") if isinstance(v, bytes) else "%-10d (%#x)" % (v, v))) + print("slots : %d" % p["slot_count"]) + for i, s in enumerate(p["slots"]): + used = any(s[:3]) + print(" [%2d] %s a=%-6d b=%-8d c=%-10d time=%s" + % (i, "USED " if used else "empty", s[0], s[1], s[2], filetime(s[3], s[4]))) + print("trailer : len=%d name=%r tag=%r val=%#x" + % (len(p["trailer"][0]), p["trailer"][0], p["trailer"][1], p["trailer"][2])) + return 0 + + +if __name__ == "__main__": + sys.exit(main())