diff --git a/docs/re/arsenal-develop-economy.md b/docs/re/arsenal-develop-economy.md index 3d5a0ce..aefc3ff 100644 --- a/docs/re/arsenal-develop-economy.md +++ b/docs/re/arsenal-develop-economy.md @@ -135,16 +135,57 @@ either: `blob[39] = 4` would put the Tomahawk right, but then `blob[33] = 4` lands on `Arrow_27_Rocket` (Stage 2 + 5000 P, unreachable here) and 45/46/47 still land on locked SPECIAL items. -**Status: ❔ NEEDS-HUMAN / needs the next differential.** The value alphabet and -the first 32 indices are settled; the tail is not, and should not be written into -the reimplementation as if it were. The cheap way to settle it is a second -development in a **late** category (CANNON or SPECIAL) — whichever index moves -names the tail directly. It needs points: the balance is 101 P, so it waits on a -mission payout. +### Tested actively, with a hand-written save + +Waiting for a mission payout was not necessary. The save container's derived +fields turned out to be reproducible (see +[`savegame_edit.py`](../../tools/re-capture/savegame_edit.py) — the whole +container re-wraps **byte-identically**), so the blob could be *written* and the +result read off the screen. A probe save was built in our own throwaway slot 03 +with the blob zeroed except `0, 9, 21, 33, 38, 45 = 4` and `39, 46 = 2`, and it +**loaded**. + +What it showed: + +- **The controls rendered.** `9` → BEAM shows `STILETTO BG1 Developed`; `21` → + MULTIPURPOSE MISSILE shows `FALCON 9AM Developed`. So a hand-written `4` does + reach the screen, and indices 9 and 21 are those items — now by experiment, + not by correlation. +- **The tail probes rendered nothing.** `33` should have made a BOMB/ROCKET row + `Developed` (row 3 of that list, on screen, not below the fold) — it stayed + dashed. `45` should have made a SPECIAL row `Developed` — the SPECIAL tab + stayed entirely empty. And `38` should have been the Tomahawk — instead the + CANNON list showed `TOMAHAWK ALPHA RAIL GUN — 0 P`, i.e. *not owned*. +- **Clearing the real save's tail `4`s cost the Tomahawk its status.** In the + unedited save the Tomahawk reads `Developed` while `blob[38] = 0`; the probe + cleared `{22, 26, 39, 46, 47}` and the Tomahawk stopped being owned — so its + flag is one of the cleared entries, with **39** the positional candidate. A + uniform `+1` shift past index 32 would explain that, but it fails elsewhere: + under it the real save's `45/46/47` are Chaff Flare Dispencer / Shield Doubler + / Booster, and the SPECIAL tab is empty in that same save. + +So the tail is not a shifted copy of the list either. **Status: ❔** — the value +alphabet and indices 0–31 are settled; indices ≥32 are not, and must not go into +the reimplementation. The next probe is cheap now that saves can be written: set +one tail index at a time and read which row changes. + +### Two behaviours the probe exposed + +- **`2` is derived, not stored.** On load the title recomputes which items are + developable from its own conditions and *announces the difference* — zeroing + Light Machine Gun MG1 and Broad Sword SG1 produced "You can now develop Broad + Sword …" and both came back priced in the list. Only the owned (`4`) entries + are authoritative state; writing `2` is pointless, and writing `0` over an + item whose conditions are met is undone at load. +- **The free items are bought for zero, not granted.** `TOMAHAWK ALPHA RAIL GUN + — 0 P` is what an unowned no-cost item looks like. That is the real reason a + save where the player never spent points still shows items as `Developed` + (this note previously said "granted" — the mechanism is a 0 P purchase). ## Evidence - [`captures/arsenal-develop-conditions.csv`](captures/arsenal-develop-conditions.csv) — all 60 conditions blocks. - [`captures/arsenal-categories-all.png`](captures/arsenal-categories-all.png) — the eight category tabs. - [`captures/arsenal-gun-below-fold.png`](captures/arsenal-gun-below-fold.png) — Broad Sword SG1, the entry that is not visible without scrolling. +- [`captures/arsenal-probe-save-result.png`](captures/arsenal-probe-save-result.png) — the hand-written save's result: controls at 9/21 render `Developed`, the tail probes render nothing, and the Tomahawk shows `0 P`. - [`captures/arsenal-gun-list-predevelop.png`](captures/arsenal-gun-list-predevelop.png) · [`captures/arsenal-mg1-developed.png`](captures/arsenal-mg1-developed.png) — the differential itself. diff --git a/docs/re/captures/arsenal-probe-save-result.png b/docs/re/captures/arsenal-probe-save-result.png new file mode 100644 index 0000000..fb7a67e Binary files /dev/null and b/docs/re/captures/arsenal-probe-save-result.png differ diff --git a/docs/re/structures/savegame-format.md b/docs/re/structures/savegame-format.md index 880856b..25c1272 100644 --- a/docs/re/structures/savegame-format.md +++ b/docs/re/structures/savegame-format.md @@ -268,8 +268,32 @@ it would settle: 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. -**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. +## 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. + +Editing beyond a throwaway slot is still the user's call. diff --git a/tools/re-capture/savegame_edit.py b/tools/re-capture/savegame_edit.py new file mode 100755 index 0000000..b25fa73 --- /dev/null +++ b/tools/re-capture/savegame_edit.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +"""Write a Project Sylpheed save back out — the container's derived fields included. + +`savegame.py` parses and re-serializes the *payload* byte-identically. This adds +the rest of the container, which is what a hand-edited save actually needs: + + GDHA header (146 B) ++ zlib.compress(payload, 9) + +Three header fields are functions of the payload and must be recomputed, or the +title will read a stale length and reject / mis-read the file. They were found by +diffing two real saves whose payloads differed (see docs/re/structures/savegame-format.md): + + +0x30 u32 len(deflate stream) + 10 + +0x8c u16 len(payload) (545 for every save seen) + +0x8e u32 adler32(payload) (the zlib trailer, duplicated) + +Everything else is copied from the donor save. The words that differ between two +saves of the same state are the container FILETIME and uninitialised guest-pointer +padding — they are **not** validated, which is why copying them is safe. + +**A hand-written save loads.** Verified 2026-08-11: a save whose 54-byte develop +blob was rewritten by hand booted, loaded, and rendered the edited state in the +Arsenal. Note what the title does on load, though — it **re-derives** which items +are *developable* from its own conditions and announces the difference ("You can +now develop …"), so only the *owned* (`4`) entries are authoritative; writing `2` +is pointless and writing `0` over an owned item is undone for anything whose +conditions are met. + +Usage: + savegame_edit.py --blob i=v[,i=v...] # patch develop-blob entries + savegame_edit.py --blob-zero # clear the whole blob first + savegame_edit.py --set u32_24=1000 # patch a GHAD field + +Always write to a **new slot or a throwaway one**, never over a save you want. +""" +import struct +import sys +import zlib + +import savegame as sg + +HDR_LEN_OFF = 0x30 +HDR_PAYLEN_OFF = 0x8C +HDR_ADLER_OFF = 0x8E + + +def wrap(header: bytes, payload: bytes) -> bytes: + """Rebuild the GDHA container around a (possibly edited) payload.""" + comp = zlib.compress(payload, 9) + if comp[:2] != b"\x78\xda": + raise ValueError("expected a 78 da zlib stream, got %s" % comp[:2].hex()) + h = bytearray(header) + struct.pack_into(">I", h, HDR_LEN_OFF, len(comp) + 10) + struct.pack_into(">H", h, HDR_PAYLEN_OFF, len(payload)) + struct.pack_into(">I", h, HDR_ADLER_OFF, zlib.adler32(payload) & 0xFFFFFFFF) + return bytes(h) + comp + + +def main(): + if len(sys.argv) < 3: + sys.exit(__doc__) + src, dst = sys.argv[1], sys.argv[2] + args = sys.argv[3:] + + header, payload = sg.unwrap(open(src, "rb").read()) + parsed = sg.parse(payload) + if sg.serialize(parsed) != payload: + sys.exit("refusing to edit: the payload does not round-trip byte-identically") + + blob = bytearray(parsed["ghad"]["blob_68"]) + for i, a in enumerate(args): + if a == "--blob-zero": + blob = bytearray(len(blob)) + elif a == "--blob": + for pair in args[i + 1].split(","): + k, v = pair.split("=") + blob[int(k)] = int(v) + elif a == "--set": + k, v = args[i + 1].split("=") + parsed["ghad"][k] = int(v, 0) + parsed["ghad"]["blob_68"] = bytes(blob) + + out = wrap(header, sg.serialize(parsed)) + open(dst, "wb").write(out) + print("wrote %s (%d bytes)" % (dst, len(out))) + print("blob:", " ".join("%02x" % b for b in blob)) + + +if __name__ == "__main__": + main()