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:
107
harvest_hashes.py
Normal file
107
harvest_hashes.py
Normal file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Harvest per-part hashes from SAND walker saves (100% offline, no injection/proxy).
|
||||
|
||||
Every compartment placed in the in-game editor writes its DefinitionHash (and
|
||||
CompartmentHash) into the .wbt save. This scans all saves, decodes them, and
|
||||
merges every (EpbId -> {DefinitionHash, CompartmentHash}) into a JSON table,
|
||||
deduping and keeping a history of any older hash values seen per part.
|
||||
|
||||
Re-run it any time after building/saving new walkers to grow the table.
|
||||
|
||||
Usage:
|
||||
harvest_hashes.py # scan the live Walkers dir + snapshots, update table
|
||||
harvest_hashes.py <dir> ... # scan extra dirs of .wbt too
|
||||
"""
|
||||
import sys, gzip, json, glob, os, hashlib
|
||||
|
||||
KEY = bytes.fromhex('70dd1f2a0b4a'); CHUNK = 0xA000
|
||||
WBT_DIR = '/home/downloadpizza/sand_tools/Walkers' # symlink -> live game saves (*.wbt)
|
||||
EXTRACTED = '/home/downloadpizza/sand_tools/extracted'
|
||||
OUT = os.path.join(EXTRACTED, 'definition_hashes_known.json')
|
||||
COMPDB = os.path.join(EXTRACTED, 'CompartmentsDatabase.json')
|
||||
|
||||
try:
|
||||
from bson import decode
|
||||
except ImportError:
|
||||
sys.exit("need pymongo: ~/sand_tools/venv/bin/pip install pymongo")
|
||||
|
||||
def decode_wbt(path):
|
||||
raw = gzip.decompress(open(path, 'rb').read())
|
||||
dec = bytes(raw[i] ^ KEY[(i % CHUNK) % 6] for i in range(len(raw)))
|
||||
return decode(dec)
|
||||
|
||||
def main():
|
||||
dirs = [WBT_DIR, os.path.join(os.path.expanduser('~'), 'sand_tools', 'snapshots')] + sys.argv[1:]
|
||||
files = []
|
||||
for d in dirs:
|
||||
files += glob.glob(os.path.join(d, '*.wbt'))
|
||||
# snapshots are decoded already (.dec) -> handle separately
|
||||
dec_files = []
|
||||
for d in dirs:
|
||||
dec_files += glob.glob(os.path.join(d, '*.dec'))
|
||||
|
||||
# rebuild fresh from saves+snapshots (the authoritative source); snapshots persist
|
||||
# history even if live .wbt files are later deleted.
|
||||
table = {}
|
||||
|
||||
def note(epb, comp, deff):
|
||||
e = table.setdefault(epb, {'DefinitionHash': None, 'DefinitionHash_history': [],
|
||||
'CompartmentHash': None, 'CompartmentHash_history': []})
|
||||
for field, hist, val in [('DefinitionHash', 'DefinitionHash_history', deff),
|
||||
('CompartmentHash', 'CompartmentHash_history', comp)]:
|
||||
if not val:
|
||||
continue
|
||||
if e[field] and e[field] != val and e[field] not in e[hist]:
|
||||
e[hist].append(e[field])
|
||||
e[field] = val
|
||||
|
||||
scanned = 0
|
||||
def walkers_from(doc):
|
||||
w = doc['walker']
|
||||
return [w['Chassis']] + w['Compartments']
|
||||
|
||||
for f in sorted(set(files)):
|
||||
try:
|
||||
doc = decode_wbt(f); scanned += 1
|
||||
except Exception as ex:
|
||||
print(f"skip {os.path.basename(f)}: {ex}"); continue
|
||||
for c in walkers_from(doc):
|
||||
note(c['EpbId'], c.get('CompartmentHash'), c.get('DefinitionHash'))
|
||||
for f in sorted(set(dec_files)):
|
||||
try:
|
||||
doc = decode(open(f, 'rb').read()); scanned += 1
|
||||
except Exception:
|
||||
continue
|
||||
try:
|
||||
for c in walkers_from(doc):
|
||||
note(c['EpbId'], c.get('CompartmentHash'), c.get('DefinitionHash'))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# cross-check CompartmentHash against CompartmentsDatabase (authoritative current value)
|
||||
total_parts = None
|
||||
if os.path.exists(COMPDB):
|
||||
db = json.load(open(COMPDB))
|
||||
total_parts = len(db['compartments'])
|
||||
calc = {c['entityId']: hashlib.md5(json.dumps(c, separators=(',', ':')).encode()).hexdigest().upper()
|
||||
for c in db['compartments']}
|
||||
for epb, e in table.items():
|
||||
if epb in calc:
|
||||
e['CompartmentHash_computed'] = calc[epb]
|
||||
|
||||
out = {
|
||||
'_note': ("Per-part hashes harvested from walker saves. CompartmentHash is also computed "
|
||||
"offline from CompartmentsDatabase (CompartmentHash_computed, authoritative). "
|
||||
"DefinitionHash is server-sourced and only known for parts that appear in a save — "
|
||||
"build a part in-game and re-run this to capture it."),
|
||||
'definition_known': sum(1 for e in table.values() if e.get('DefinitionHash')),
|
||||
'total_parts_in_db': total_parts,
|
||||
'parts': dict(sorted(table.items())),
|
||||
}
|
||||
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
||||
json.dump(out, open(OUT, 'w'), indent=2)
|
||||
print(f"scanned {scanned} saves; {out['definition_known']} parts with DefinitionHash"
|
||||
+ (f" / {total_parts} total" if total_parts else "") + f"\nwrote {OUT}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user