#!/usr/bin/env python3 """SAND .wbt save-file toolkit. Envelope (verified from GameAssembly.dll, XorCryptography.Encrypt @ RVA 0xBE9B40): Save: BSON(Newtonsoft) -> XOR encrypt -> gzip compress Load: gunzip -> XOR decrypt -> BSON parse Inner cipher is XOR with a 6-byte key, applied PER 0xA000-byte chunk with the key index RESET to 0 at every chunk boundary (the stream is processed in buffer.Length = 0xA000 reads, and the keystream restarts each read): decoded[i] = raw[i] XOR KEY[(i % 0xA000) % 6] KEY = 70 DD 1F 2A 0B 4A (recovered from known BSON plaintext "textureSize" header; the .cctor allocates a 6-element byte[] and InitializeArray-copies it). Decoded payload is a single Newtonsoft BSON document (WalkerBlueprintContainerSerializableProxy): textureSize, textureRawData, walker{...}, format, iconVersion, firstNameIndex, secondNameIndex, creationTime, name, isBackup. Name parts are 0-based indices into the Unity Localization tables WalkerFirstName / WalkerSecondName. Subcommands: decode -> write decoded bytes snap [--all | ...] -> decode + save numbered snapshot per file diff -> human-readable diff check -> one-shot: snap + diff against latest prior snap watch -> poll the file forever; run check on every mtime change """ import sys, gzip, argparse, pathlib, re, time KEY = bytes.fromhex('70dd1f2a0b4a') CHUNK = 0xA000 WBT_DIR = pathlib.Path('/home/downloadpizza/sand_tools/Walkers') # symlink -> live game saves SNAP_DIR = pathlib.Path.home() / 'sand_tools' / 'snapshots' # Known fields to suppress in diffs (per-file). Each entry: (offset, length, note) KNOWN_NOISE = { # file_uuid_prefix : list of (offset, length, label) '*': [ (0x000000, 2, 'leading 2-byte per-save field'), ], '215949d3': [ (0x1040f3, 4, 'per-save nonce/timestamp'), ], } def decode_bytes(raw: bytes) -> bytes: out = bytearray(len(raw)) for i in range(len(raw)): out[i] = raw[i] ^ KEY[(i % CHUNK) % 6] return bytes(out) def encode_bytes(dec: bytes) -> bytes: # XOR is symmetric; same transform re-encrypts a (modified) decoded payload. return decode_bytes(dec) def decode_file(path: pathlib.Path) -> bytes: return decode_bytes(gzip.decompress(path.read_bytes())) def stem8(path: pathlib.Path) -> str: return path.stem[:8] def next_snap_path(file_stem: str) -> pathlib.Path: SNAP_DIR.mkdir(parents=True, exist_ok=True) existing = sorted(SNAP_DIR.glob(f'{file_stem}_v*.dec')) n = 1 if existing: nums = [int(re.search(r'_v(\d+)\.dec$', p.name).group(1)) for p in existing] n = max(nums) + 1 return SNAP_DIR / f'{file_stem}_v{n:03d}.dec' def latest_snap(file_stem: str) -> pathlib.Path | None: snaps = sorted(SNAP_DIR.glob(f'{file_stem}_v*.dec')) return snaps[-1] if snaps else None def cmd_decode(args): data = decode_file(pathlib.Path(args.input)) out = pathlib.Path(args.output) if args.output else None if out: out.write_bytes(data); print(f"wrote {out} ({len(data)} bytes)", file=sys.stderr) else: sys.stdout.buffer.write(data) def cmd_snap(args): if args.all: files = sorted(WBT_DIR.glob('*.wbt')) else: files = [pathlib.Path(p) for p in args.files] for f in files: data = decode_file(f) out = next_snap_path(stem8(f)) out.write_bytes(data) print(f"snap: {f.name[:8]}... -> {out.name} ({len(data)} bytes)") def diff_regions(a: bytes, b: bytes, merge_gap=8): common = min(len(a), len(b)) regions = [] state = None; start = 0 for i in range(common): eq = a[i] == b[i] cur = 'eq' if eq else 'df' if state is None: state = cur; start = i elif cur != state: regions.append((state, start, i)); state = cur; start = i regions.append((state, start, common)) merged = [] for r in regions: if merged and r[0]=='eq' and (r[2]-r[1]) no for no, nn, _ in noise) kept = [d for d in diffs if not overlaps(d[1], d[2])] suppressed = len(diffs) - len(kept) return kept, suppressed def render_diffs(a, b, diffs, ctx=12): for t, s, e in diffs: n = e - s pre = a[max(0,s-ctx):s].hex() post = a[e:e+ctx].hex() print(f"@ {s:#010x} .. {e:#010x} ({n} bytes)") print(f" -before: {pre} [{a[s:e].hex()}] {post}") print(f" +after : {pre} [{b[s:e].hex()}] {post}") if n <= 4: bv = int.from_bytes(a[s:e], 'little') av = int.from_bytes(b[s:e], 'little') print(f" LE int : {bv} -> {av} (Δ {av-bv:+d})") print() sys.stdout.flush() def cmd_diff(args): a = pathlib.Path(args.before).read_bytes() b = pathlib.Path(args.after).read_bytes() diffs, la, lb = diff_regions(a, b) file_stem = re.match(r'([0-9a-f]{8})', pathlib.Path(args.before).name) stem = file_stem.group(1) if file_stem else '' if not args.no_filter: diffs, suppressed = filter_noise(diffs, stem) else: suppressed = 0 print(f"sizes: before={la} after={lb} Δ={lb-la:+d}") print(f"{len(diffs)} differing region(s)" + (f" ({suppressed} known-noise suppressed)" if suppressed else "")) print() render_diffs(a, b, diffs) def cmd_check(args): path = pathlib.Path(args.input) stem = stem8(path) prev = latest_snap(stem) data = decode_file(path) out = next_snap_path(stem) out.write_bytes(data) print(f"snap: {path.name[:8]}... -> {out.name}") if not prev: print("(no prior snapshot to diff against)") return a = prev.read_bytes() b = data diffs, la, lb = diff_regions(a, b) if not args.no_filter: diffs, suppressed = filter_noise(diffs, stem) else: suppressed = 0 print(f"diff vs {prev.name}: sizes {la}/{lb} Δ={lb-la:+d}") print(f"{len(diffs)} region(s)" + (f" ({suppressed} known-noise suppressed)" if suppressed else "")) print() render_diffs(a, b, diffs) def cmd_watch(args): path = pathlib.Path(args.input) print(f"watching {path.name} (poll every {args.interval}s, Ctrl-C to stop)", flush=True) last_mtime = path.stat().st_mtime while True: try: time.sleep(args.interval) except KeyboardInterrupt: print("stopped."); return try: cur = path.stat().st_mtime except FileNotFoundError: continue if cur != last_mtime: last_mtime = cur print(f"\n[{time.strftime('%H:%M:%S')}] change detected", flush=True) cmd_check(args) def main(): ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) sp = ap.add_subparsers(dest='cmd', required=True) p = sp.add_parser('decode'); p.add_argument('input'); p.add_argument('-o', '--output'); p.set_defaults(fn=cmd_decode) p = sp.add_parser('snap'); g = p.add_mutually_exclusive_group(required=True) g.add_argument('--all', action='store_true'); g.add_argument('files', nargs='*', default=[]) p.set_defaults(fn=cmd_snap) p = sp.add_parser('diff'); p.add_argument('before'); p.add_argument('after'); p.add_argument('--no-filter', action='store_true'); p.set_defaults(fn=cmd_diff) p = sp.add_parser('check'); p.add_argument('input'); p.add_argument('--no-filter', action='store_true'); p.set_defaults(fn=cmd_check) p = sp.add_parser('watch'); p.add_argument('input'); p.add_argument('--no-filter', action='store_true'); p.add_argument('--interval', type=float, default=1.0); p.set_defaults(fn=cmd_watch) args = ap.parse_args() args.fn(args) if __name__ == '__main__': main()