Files
SandTools/walker_hashes.py
DownloadPizza e2a2984925 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.
2026-06-11 14:43:57 +02:00

88 lines
4.2 KiB
Python

#!/usr/bin/env python3
"""Reproduce SAND walker hashes offline.
All walker hashes = MD5(UTF8(JsonConvert.SerializeObject(obj))).hexUPPER, where the
default JsonConvert settings use a global StringEnumConverter (enums -> NAME strings),
NullValueHandling.Include, compact formatting, member declaration order.
Verified against all local walkers:
CompartmentHash (per part) = md5_json(placement from CompartmentsDatabase) [compartment_hashes.py]
CompartmentsHash (walker) = md5_json( Compartments list ) <- here
ConnectionsHash (walker) = md5_json( Connections list ) <- here
DefinitionsHash (walker) = md5_json( Compartments.Select(->CompartmentDefinitionDto) )
^ needs the server-sourced CompartmentDefinitionDto objects;
not reproducible offline. Copy from source walker (it only
changes if the set/order of part definitions changes).
DefinitionHash (per part) = md5_json(CompartmentDefinitionDto) -> also server-sourced.
Enum name tables (from dump.cs):
ConnectionSlotType: 0 DOOR,1 HATCH,2 STRUCTURE,3 BALCONY,4 DECK
ConnectionState(MasterserverDtos): 0 DEFAULT,1 DOOR,2 OPEN
ConnectionsCount: 0 FULL,1 PARTIAL,2 ERROR
DecorationsInfo nesting is serialized as KeyValuePair ARRAYS (not JSON objects):
Sockets: Dict<CellCoordinate, Dict<ConnectionSlotType, {state,count}>>
-> {"Sockets":[{"Key":{x,y,z},"Value":[{"Key":"<slot>","Value":{"state":"<st>","count":"<cnt>"}}]}]}
"""
import json, hashlib
SLOT = {0: 'DOOR', 1: 'HATCH', 2: 'STRUCTURE', 3: 'BALCONY', 4: 'DECK'}
STATE = {0: 'DEFAULT', 1: 'DOOR', 2: 'OPEN'}
COUNT = {0: 'FULL', 1: 'PARTIAL', 2: 'ERROR'}
def _md5(s): return hashlib.md5(s.encode()).hexdigest().upper()
def _coord(v): return '{"x":%d,"y":%d,"z":%d}' % (v['x'], v['y'], v['z'])
def _dbl(x): return f'{int(x)}.0' if float(x) == int(x) else repr(float(x))
def _stinfo(d): return '{"state":"%s","count":"%s"}' % (STATE[d['state']], COUNT[d['count']])
def _deco(d):
if d is None:
return 'null'
socks = ','.join(
'{"Key":%s,"Value":[%s]}' % (
_coord(kv['Key']),
','.join('{"Key":"%s","Value":%s}' % (SLOT[iv['Key']], _stinfo(iv['Value']))
for iv in kv['Value']))
for kv in d['Sockets'])
return '{"Sockets":[%s]}' % socks
def compartment_json(c):
p = [('Id', str(c['Id'])), ('EpbId', json.dumps(c['EpbId'])),
('CellCoordinate', _coord(c['CellCoordinate'])),
('DecorationsInfo', _deco(c['DecorationsInfo'])),
('Rotation', _dbl(c['Rotation'])),
('CompartmentHash', json.dumps(c['CompartmentHash'])),
('DefinitionHash', json.dumps(c['DefinitionHash']))]
return '{' + ','.join(f'"{k}":{v}' for k, v in p) + '}'
def connection_json(c):
p = [('Id', str(c['Id'])),
('EpbId', json.dumps(c['EpbId']) if c['EpbId'] is not None else 'null'),
('ActualDirection', _coord(c['ActualDirection'])),
('GridCoordinate', _coord(c['GridCoordinate'])),
('SlotType', '"%s"' % SLOT[c['SlotType']]),
('State', '"%s"' % STATE[c['State']])]
return '{' + ','.join(f'"{k}":{v}' for k, v in p) + '}'
def compartments_hash(compartments):
"""CompartmentsHash from walker['Compartments'] (the BSON-decoded list)."""
return _md5('[' + ','.join(compartment_json(c) for c in compartments) + ']')
def connections_hash(connections):
"""ConnectionsHash from walker['Connections']."""
return _md5('[' + ','.join(connection_json(c) for c in connections) + ']')
if __name__ == '__main__':
import sys, gzip, glob
from bson import decode
KEY = bytes.fromhex('70dd1f2a0b4a'); CHUNK = 0xA000
files = sys.argv[1:] or glob.glob(
'/home/downloadpizza/sand_tools/Walkers/*.wbt')
for f in sorted(files):
raw = gzip.decompress(open(f, 'rb').read())
w = decode(bytes(raw[i] ^ KEY[(i % CHUNK) % 6] for i in range(len(raw))))['walker']
okC = compartments_hash(w['Compartments']) == w['CompartmentsHash']
okN = connections_hash(w['Connections']) == w['ConnectionsHash']
print(f"{f.split('/')[-1][:8]} CompartmentsHash={'OK' if okC else 'FAIL'} "
f"ConnectionsHash={'OK' if okN else 'FAIL'}")