re: saves can be written back, which settles what the develop blob's 4s mean and refutes the tail

The container's derived fields turned out to be reproducible -- length+10 at
+0x30, payload length at +0x8c, adler32(payload) at +0x8e, everything else
copied -- and savegame_edit.py re-wraps a real save BYTE-IDENTICALLY, which is
the check that those three are the only ones. A hand-written save then loaded.

That replaced a blocked experiment (the tail question needed a mission payout,
and none of the currently developable items even sit in the disputed range) with
a direct one: write the blob, read the Arsenal.

  - controls: 4 at index 9 -> STILETTO BG1 Developed, 21 -> FALCON 9AM
    Developed. A hand-written 4 reaches the screen.
  - tail: 4 at 33 and 45 left their rows dashed (both on screen, not below the
    fold), and 38 left TOMAHAWK ALPHA RAIL GUN at "0 P" -- not owned. So the
    tail is not the weapon.tbl order continued.
  - clearing the real save's {22,26,39,46,47} cost the Tomahawk its Developed
    status, which puts its flag in that set (39 positionally) -- but a uniform
    +1 fails for SPECIAL, so no shift is asserted. Indices >=32 stay marked.

Two behaviours fell out. The title RE-DERIVES developable state on load and
announces it ("You can now develop Broad Sword ..."), so only the 4s are stored
state and a written 2 is pointless. And a no-cost item is bought for 0 P rather
than granted -- TOMAHAWK at "0 P" is what unowned looks like -- which is the
actual reason items read Developed in a save where nothing was spent.

Slot 03 was restored from its archived original (md5 verified); slots 01/02 were
never touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:10:28 +00:00
parent f88f4f6847
commit f5f95be0a1
4 changed files with 166 additions and 11 deletions

View File

@@ -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 lands on `Arrow_27_Rocket` (Stage 2 + 5000 P, unreachable here) and 45/46/47
still land on locked SPECIAL items. still land on locked SPECIAL items.
**Status: ❔ NEEDS-HUMAN / needs the next differential.** The value alphabet and ### Tested actively, with a hand-written save
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 Waiting for a mission payout was not necessary. The save container's derived
development in a **late** category (CANNON or SPECIAL) — whichever index moves fields turned out to be reproducible (see
names the tail directly. It needs points: the balance is 101 P, so it waits on a [`savegame_edit.py`](../../tools/re-capture/savegame_edit.py) — the whole
mission payout. 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 031 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 ## Evidence
- [`captures/arsenal-develop-conditions.csv`](captures/arsenal-develop-conditions.csv) — all 60 conditions blocks. - [`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-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-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. - [`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.

Binary file not shown.

After

Width:  |  Height:  |  Size: 381 KiB

View File

@@ -268,8 +268,32 @@ it would settle:
is what maps blob indices to items — but it needs Points, and the balance is 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. now 101 P, so it waits on a mission payout.
**Nothing here has been written back to a save.** Editing is a separate decision ## Writing a save back — the container's derived fields
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 **A hand-written save loads.** Done 2026-08-11 in the sandbox, into our own
→ re-wrap), and so that a diff of two saves can be read structurally instead of throwaway slot 03 (the user's slots 01/02 were not touched and the original of 03
as raw bytes. 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.

View File

@@ -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 <in> <out> --blob i=v[,i=v...] # patch develop-blob entries
savegame_edit.py <in> <out> --blob-zero # clear the whole blob first
savegame_edit.py <in> <out> --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()