Extends the previous refutation to a sweep. Probed with no effect on STAGE 02 or Difficulty EASY: every scalar in the GHAD block (+0, +12, +16, +20, +28, +32, +36 at 1/3/9, +40 u64, +48, +52, +56, +60, +64 raw), SHAB[0].a, and the SHAB FILL COUNT in both directions -- record 1 filled with a copy of record 0, and record 0 cleared. The "stage = filled-record count + 1" idea dies with it, and so does the reading that made SHAB a per-stage result table by that route. The panel does re-read each slot: slot 02 holds Points 4101 / Clear Ratio 5 % and displays exactly that while its neighbours show 101 / 6 %. Left: the phase string, the trailer, or the blob. Recorded caveat -- every save on disc is genuinely Stage 02 EASY, so "field not found" and "panel does not vary those two labels per slot" are not yet separated, and another probe round cannot separate them. The next move is static: find the code that formats STAGE %02d and read which offset it loads. savegame_edit.py --set now packs an int into raw_* byte fields. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
102 lines
4.0 KiB
Python
Executable File
102 lines
4.0 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("=")
|
|
# `raw_*` fields are stored as bytes; accept an int and pack it BE.
|
|
old = parsed["ghad"][k]
|
|
parsed["ghad"][k] = (
|
|
int(v, 0).to_bytes(len(old), "big") if isinstance(old, bytes) else int(v, 0)
|
|
)
|
|
elif a == "--slot":
|
|
# --slot <record>,<field>=<value>, e.g. --slot 0,0=3
|
|
where, v = args[i + 1].split("=")
|
|
rec, fld = (int(x) for x in where.split(","))
|
|
row = list(parsed["slots"][rec])
|
|
row[fld] = int(v, 0)
|
|
parsed["slots"][rec] = tuple(row)
|
|
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()
|