refactor: group scripts into walker/ wikigen/ bundle/

Organize the 16 loose scripts by concern:
  walker/  -- .wbt save tooling (sand, build_wbt, walker_hashes,
              harvest_hashes, recover_key)
  wikigen/ -- MediaWiki page generators (make_*_wiki, render_wiki)
  bundle/  -- Unity/Odin asset extraction (unitybundle, odin_read,
              extract_*, loot_probe, dump_loot_bytes)

The only cross-script imports (build_wbt->walker_hashes,
extract_loot->odin_read) live within the same folder, so each
script's dir on sys.path[0] keeps them resolving with no code
changes. All data paths are absolute, so the moves don't affect
I/O. Named the code dir wikigen/ to avoid colliding with the
generated wiki/ output dir; ignore the regenerable wiki_site/ render.
This commit is contained in:
DownloadPizza
2026-06-11 14:49:33 +02:00
parent 2e886f31f0
commit a44e4db1c3
17 changed files with 3 additions and 0 deletions

208
walker/sand.py Executable file
View 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()