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.
88 lines
4.2 KiB
Python
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'}")
|