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:
@@ -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.
|
||||
|
||||
BIN
docs/re/captures/arsenal-probe-save-result.png
Normal file
BIN
docs/re/captures/arsenal-probe-save-result.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 381 KiB |
@@ -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.
|
||||
|
||||
90
tools/re-capture/savegame_edit.py
Executable file
90
tools/re-capture/savegame_edit.py
Executable 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()
|
||||
Reference in New Issue
Block a user