diff --git a/docs/re/captures/savedata-game02-samestate.bin b/docs/re/captures/savedata-game02-samestate.bin new file mode 100644 index 0000000..4b4057b Binary files /dev/null and b/docs/re/captures/savedata-game02-samestate.bin differ diff --git a/docs/re/captures/savegame-details-slot02.png b/docs/re/captures/savegame-details-slot02.png new file mode 100644 index 0000000..8811129 Binary files /dev/null and b/docs/re/captures/savegame-details-slot02.png differ diff --git a/docs/re/structures/savegame-format.md b/docs/re/structures/savegame-format.md index 32e9614..47c36f1 100644 --- a/docs/re/structures/savegame-format.md +++ b/docs/re/structures/savegame-format.md @@ -1,9 +1,9 @@ -# Save file (`savedata`) — container ✅ exact, field meanings ❔ (2026-08-11) +# Save file (`savedata`) — container ✅ exact, 3 fields named ✅, rest ❔ (2026-08-11) **Status: ✅ CONFIRMED for the container and the chunk layout** — parsed off the title's own serializer, not guessed, and verified by a byte-identical round-trip. -**❔ for what most of the numbers mean**; those are marked below and none are -asserted. +**Points, flight time and clear ratio are ✅ named** off the game's own Details +panel; **the rest is still ❔** and none of it is asserted. Parser + round-trip check: [`tools/re-capture/savegame.py`](../../../tools/re-capture/savegame.py) (`savegame.py --verify` re-serializes the parse and asserts equality). @@ -46,7 +46,7 @@ Every element below is read off the serializer, whose writer primitive is | `'GDAA'` | payload magic | `addis 0x4744 / ori 0x4141` at `0x822C00F8` | | `u32 len`, `char[len]` | **current game phase**, here `GP_BUNK` | `lwz r11,0(save)`; if `< 29`, index the GP_* name table at `0x820A5680` | | `'GHAD'` + 122 B | the progress block | `0x822BF678`, called with `save+8` | -| `u32 16`, 16 × (`'SHAB'` + 5×u32) | the save-slot table | `addi r28,r0,16` / stride `addi r29,r29,20` | +| `u32 16`, 16 × (`'SHAB'` + 5×u32) | a 16-record result table (**not** the UI's save slots — see Result 3) | `addi r28,r0,16` / stride `addi r29,r29,20` | | `u32 4`, `"BUNK"`, `'NETA'`, `u32` | trailer | — | **The in-memory struct is written field-by-field with no packing changes, so a @@ -68,28 +68,31 @@ Ten u32, one u64 (`ld r11,40(r30)`), four u32, a raw 4-byte field and a raw 54-byte blob. 10·4 + 8 + 4·4 + 4 + 54 = **122**, which is exactly what the file carries — the layout is closed, with nothing unaccounted for. +Field names below come from the **LOAD/SAVE GAME screen's own Details panel**, +photographed against this exact save (see "Naming the fields", further down). + | off | value in this save | reading | |---|---|---| | +0 | 0 | ❔ | -| +4 | **324773** | ❔ a large per-save counter — credits/score is the obvious guess, untested | -| +8 | 5 | ❔ the save-select screen shows this save at **5 %** | +| +4 | **324773** | ✅ **flight time, milliseconds** — the screen shows `Flight Time 000:05:24` and 324773 ms = 5 m 24.773 s | +| +8 | 5 | ✅ **clear ratio, percent** — the screen shows `Clear Ratio 5 %` | | +12 | 0 | ❔ | | +16 | 1 | ❔ | | +20 | 0 | ❔ | -| +24 | **4101** (`0x1005`) | ❔ same value as +28 and as slot 0's field b | -| +28 | **4101** (`0x1005`) | ❔ | +| +24 | **4101** (`0x1005`) | ✅ **Points** — the Details panel shows `Points 4101 P` | +| +28 | **4101** (`0x1005`) | 🟡 Points again — a second copy (running vs. banked total?); one save cannot separate them | | +32 | 79 | ❔ | -| +36 | 2 | ❔ equals slot 0's field a; the header calls this save `EASY` | +| +36 | 2 | 🟡 **difficulty or stage** — the panel shows `Difficulty EASY` *and* `STAGE 02`, and +36/+52/+56 all hold 2, so which is which is **not** decidable from one save | | +40 (u64) | 2014400 | ❔ | -| +48 | 0 | ❔ | -| +52 | 2 | ❔ | -| +56 | 2 | ❔ | +| +48 | 0 | ❔ (`Times Cleared: 0` is on screen, so it is one of the zero fields) | +| +52 | 2 | 🟡 see +36 | +| +56 | 2 | 🟡 see +36 | | +60 | 0 | ❔ | | +64 (raw 4) | `09 15 00 00` | ❔ the trailer's u32 is the **same** value | -| +68 (raw 54) | see below | ❔ 54 one-byte entries, values only ∈ {0, 2, 4} | +| +68 (raw 54) | see below | ❔ 54 one-byte entries, values only ∈ {0, 2, 4} — histogram `{4: 10, 2: 6, 0: 38}` | The 54-byte blob is the interesting one — a fixed-length array of small -enumerated states, 20 of the 54 non-zero: +enumerated states, **16 of the 54 non-zero** (ten 4s, six 2s): ``` 04 02 00 00 00 02 00 00 00 04 02 00 02 00 00 00 00 00 00 00 00 04 04 00 00 00 04 @@ -99,7 +102,7 @@ enumerated states, 20 of the 54 non-zero: 54 matches no count we have already established (22 stages, 110 units, 126 weapons, ~61 Arsenal entries), so **do not** assume which list it indexes. -### The slot table (16 × 20 bytes) +### The 16-record `SHAB` table (16 × 20 bytes) | slot | a | b | c | FILETIME | |---|---|---|---|---| @@ -112,8 +115,76 @@ own display string — written by the game, parsed by nobody here — says `07/23/2026 21:08` (UTC+1). The 15 unused slots all hold 2006-01-01, a plausible epoch default rather than zero, which is a second check on the same reading. -Fields a/b/c of slot 0 are copies of GHAD +36, +24/+28 and +4. So the slot record -is a **summary** of a save, which is what a slot-select screen needs. +Fields a/b/c of record 0 are copies of GHAD +36, +24/+28 and +4 — i.e. +difficulty/stage, Points and flight time, the same triple the Details panel +prints. What the table is a table *of* is settled in Result 3 below: not saves. + +## Naming the fields — a second save, made in-game (2026-08-11) + +The one save on disc could not name anything, so a second one was made. Route, +all with 60 ms d-pad taps, driven by +[`tools/re-capture/nav_probe.sh`](../../../tools/re-capture/nav_probe.sh) (boots +to the READY ROOM, then walks a scripted step list, screenshotting after every +step **and stamping every save file's md5** — so the trail says exactly which +keypress wrote a save): + +> READY ROOM → ↓×4 **SYSTEM** → A → *(≈50 s to load under lavapipe)* → +> SYSTEM MENU `BACK / LOAD GAME / SAVE GAME / OPTIONS / BACK TO TITLE` → ↓↓ +> **SAVE GAME** → A → slot list, **first empty slot preselected** → A → +> `Save game?` → **cursor starts on YES** → A. + +Two traps, both paid for: the SYSTEM screen takes **>15 s** to load and inputs +sent during it are dropped; and the save confirm starts on **YES**, unlike the +load confirm which starts on NO — the first attempt sent the load flow's extra +`↑` and so pressed **NO**, writing nothing (the md5 stamp is what showed this). + +### Result 1 — the payload is a pure function of game state ✅ + +Saving the just-loaded state into empty slot 02 produced `game02/savedata`. Its +**545-byte payload is byte-identical** to `game01`'s — same phase, same GHAD +block, same slot table, same FILETIME inside it. Only the GDHA *header* differs, +and every differing byte is either the container FILETIME at `0x0A` or one of the +guest-pointer words (`0xBC455680`→`0xBC474020`, `0xBD4B6200`→`0xBD3B5180`, +`0x70D8FD50`→`0x70A8FD50`, …). **That empirically confirms the "uninitialised +padding" call**: those words moved between two saves of identical state, by the +deltas you would expect of heap addresses. + +So the payload carries **no timestamp, no slot number and no save name** — a +save's identity lives entirely in its content header (`Game02 08/11/2026 06:55 +STAGE02 EASY` vs `Game01 07/23/2026 21:08 STAGE02 EASY`; the `__thumbnail.png`s +are identical too). Everything in the 545 bytes is game state, which is also a +second check that the parse leaves nothing unexplained. + +### Result 2 — the Details panel names the numbers + +Highlighting a slot fills a **Details** panel. Against this save it reads: + +> `STAGE 02 — Declaration of War` · `Game Status: At Standby` · +> `Points 4101 P` · `Times Cleared: 0 Times` · +> `Difficulty EASY` · `Flight Time 000:05:24` · `Clear Ratio 5 %` + +![LOAD GAME details for the state this save holds](../captures/savegame-details-slot02.png) + +`4101`, `324773` and `5` are all in the GHAD block, which is what pins **Points**, +**flight time (ms)** and **clear ratio** above. Note `Clear Ratio 5 %` with +`Times Cleared: 0` and one stage finished is consistent with clear ratio = +stages cleared ÷ 20 — the slot list is also 20 entries — but that is ❔. + +**What one save still cannot decide:** `Difficulty EASY`, `STAGE 02` and two other +fields all have the value **2**, and `Times Cleared: 0` collides with five zero +fields. Separating them needs a save whose stage or difficulty differs, not +another copy of this one. + +### Result 3 — the 16 `SHAB` records are not the UI's save slots + +The save UI has **20** slots; the payload has **16** records. Slot 0's FILETIME +stayed at 2026-07-23 in a save written on 2026-08-11, and the 20-slot list is +built from separate `gameNN` files. So `SHAB[]` is **part of the game state**, +not a directory of saves. Record 0 is `(2, 4101, 324773, 2026-07-23 20:07:23)` — +difficulty/stage, Points, flight time in ms, and when — i.e. the same summary as +the Details panel, which makes **a per-stage result record** the obvious 🟡 read +(one stage finished → one record filled). Falsifiable the moment a second stage +is cleared: `SHAB[1]` should fill. ## What this does and does not unlock @@ -122,13 +193,16 @@ state, so it does not yet answer the question that motivated it — whether stag progress can be reached without winning missions ([mission outcome](../mission-outcome-stage02.md)). -**The naming oracle is a differential, and only one save exists** (all four -copies on disk — the live one, both `content-backup-*` snapshots and the `prof2` -profile — are byte-identical, md5 `142b4f432e0e…`). So the next step is to -*make* a second save: change one thing in-game that the player can change -cheaply, save, and diff. Developing or buying a single Arsenal item should move -+4 (if it is currency) and one entry of the 54-byte blob, which would name two -fields at once. +**The differential has been run once** (see above) and it named three fields, but +a same-state save cannot separate the fields that share a value. The next +differentials, in order of what they would settle: + +1. **A save at a different difficulty** — start a new game on NORMAL, save, + compare: whichever of +36/+52/+56 moves is the difficulty. +2. **A save after clearing a stage** — settles stage vs. difficulty, `Times + Cleared`, whether `SHAB[1]` fills, and whether the clear ratio steps by 5 %. +3. **A save after developing one Arsenal item** — should move one entry of the + 54-byte blob and name it. **Nothing here has been written back to a save.** Editing is a separate decision and needs the user's go-ahead; the round-trip in `savegame.py` exists so that an diff --git a/tools/re-capture/nav_probe.sh b/tools/re-capture/nav_probe.sh new file mode 100755 index 0000000..febb0f8 --- /dev/null +++ b/tools/re-capture/nav_probe.sh @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# Boot to the READY ROOM and walk a scripted d-pad sequence, screenshotting after +# every step and stamping the save file's md5+mtime alongside — so the trail says +# not just what was on screen but exactly which keypress wrote a save. +# +# Boot half is launch_mission.sh's verified route, stopped at the READY ROOM. +# Run as ONE BLOCKING FOREGROUND call (see docs/re/session-lifetime notes). +# +# Usage: nav_probe.sh [...] +# step = "d"/"u"/"l"/"r" (d-pad), "A"/"B"/"X"/"Y" (button), "wN" (wait N s) +set -u +export HOME=/sylph-home/re SDL_AUDIODRIVER=dummy DISPLAY=:98 +SD="$(cd "$(dirname "$0")" && pwd)" +SHOTS=/sylph-home/re/shots +SAVE=/sylph-home/re/.local/share/Xenia/content/E0300000EFBEA3D4/535107D4/00000001/game01/savedata +TAG="${1:?tag}"; shift +OUT="/sylph-home/re/$TAG"; mkdir -p "$OUT" "$SHOTS" + +alive(){ ps -o pid=,stat= -C xenia_canary 2>/dev/null | awk '$2 !~ /^Z/ {print $1}'; } +ensure_display(){ + if ! xdpyinfo -display "$DISPLAY" >/dev/null 2>&1; then + rm -f "/tmp/.X${DISPLAY#:}-lock" 2>/dev/null || true + nohup bash -c 'Xvfb "$0" -screen 0 1280x720x24 -ac -nolisten tcp \ + +extension GLX +extension RANDR >/tmp/xvfb98.log 2>&1' "$DISPLAY" /dev/null 2>&1 & + for _ in $(seq 1 50); do xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 && break; sleep 0.2; done + nohup env DISPLAY="$DISPLAY" HOME=/sylph-home openbox /tmp/openbox98.log 2>&1 & + sleep 1 + fi + xdpyinfo -display "$DISPLAY" >/dev/null 2>&1 || { echo "DISPLAY UNAVAILABLE"; exit 1; } +} +CDIR=/sylph-home/re/.local/share/Xenia/content/E0300000EFBEA3D4/535107D4/00000001 +stamp(){ printf '%-14s %s\n' "$1" \ + "$(find "$CDIR" -name savedata -printf '%f ' -exec md5sum {} \; 2>/dev/null \ + | awk '{printf "%s=%s ", $3, substr($2,1,8)}' | sed 's#.*/00000001/##g')"; } +step(){ vgamepad dpad "$1"; sleep 0.25; vgamepad dpad center; sleep 0.7; } + +pkill -x xenia_canary 2>/dev/null; sleep 2 +[ -n "$(alive)" ] && { kill -9 $(alive) 2>/dev/null; sleep 2; } +rm -f /dev/shm/xenia_memory_* /dev/shm/xenia_code_cache_* 2>/dev/null +ensure_display +cd /sylph-home/re +nohup run-canary --audio --apu=sdl --log_mask=13 \ + --logged_profile_slot_0_xuid=E0300000EFBEA3D4 /dev/null 2>&1 & +sleep 5 +"$SD/skip_intro.sh" 600 || { echo "BOOT FAILED (skip_intro exit $?)"; exit 1; } +sleep 14 +step down # NEW GAME -> LOAD GAME +vgamepad tap A 250; sleep 8 +vgamepad tap A 250; sleep 4 # "Load game?" -- cursor starts on NO +step up +vgamepad tap A 250 +sleep 28 +screenshot "$SHOTS/$TAG-00-readyroom.png" >/dev/null 2>&1 +stamp "boot" + +i=0 +for s in "$@"; do + i=$((i+1)) + case "$s" in + d) step down ;; u) step up ;; l) step left ;; r) step right ;; + A|B|X|Y) vgamepad tap "$s" 250; sleep 2 ;; + w*) sleep "${s#w}" ;; + *) echo "unknown step $s"; continue ;; + esac + screenshot "$(printf '%s/%s-%02d-%s.png' "$SHOTS" "$TAG" "$i" "$s")" >/dev/null 2>&1 + stamp "$i:$s" +done +echo "NAV PROBE DONE ($TAG, $i steps)"