Files
SandTools/walker/walker_hashes.py
DownloadPizza a44e4db1c3 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.
2026-06-11 14:49:33 +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'}")