tools: SAND .wbt + game-data extraction scripts
Python tooling for decoding walker saves and mining game data: sand.py / build_wbt.py / walker_hashes.py / harvest_hashes.py (.wbt codec + hashes), extract_*/loot_probe/odin_read/unitybundle (asset parsing), make_*_wiki + render_wiki (wiki generation), recover_key. Paths point at the local extracted/, wiki/, and Walkers symlink.
This commit is contained in:
208
sand.py
Executable file
208
sand.py
Executable file
@@ -0,0 +1,208 @@
|
||||
#!/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 <wbt> -> write decoded bytes
|
||||
snap [--all | <wbt>...] -> decode + save numbered snapshot per file
|
||||
diff <before.dec> <after.dec> -> human-readable diff
|
||||
check <wbt> -> one-shot: snap + diff against latest prior snap
|
||||
watch <wbt> -> 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])<merge_gap and merged[-1][0]=='df':
|
||||
merged[-1] = ('df', merged[-1][1], r[2])
|
||||
elif merged and merged[-1][0]==r[0]:
|
||||
merged[-1] = (r[0], merged[-1][1], r[2])
|
||||
else:
|
||||
merged.append(list(r))
|
||||
return [tuple(r) for r in merged if r[0]=='df'], len(a), len(b)
|
||||
|
||||
def filter_noise(diffs, file_stem):
|
||||
noise = KNOWN_NOISE.get('*', []) + KNOWN_NOISE.get(file_stem, [])
|
||||
def overlaps(s, e):
|
||||
return any(s < no+nn and e > 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()
|
||||
Reference in New Issue
Block a user