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>
91 lines
3.5 KiB
Python
Executable File
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()
|