Files
Syplheed-Reborn/tools/re-capture/savegame_edit.py
Claude (auto-RE) f5f95be0a1 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>
2026-08-11 18:10:28 +00:00

91 lines
3.5 KiB
Python
Executable File

#!/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()