From e2a2984925c8ac056312f3ad93f92eff5ba60c8f Mon Sep 17 00:00:00 2001 From: DownloadPizza Date: Thu, 11 Jun 2026 14:43:57 +0200 Subject: [PATCH] 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. --- build_wbt.py | 160 ++++++++++++++++++++++++++++++ dump_loot_bytes.py | 47 +++++++++ extract_data.py | 89 +++++++++++++++++ extract_i2.py | 101 +++++++++++++++++++ extract_loot.py | 73 ++++++++++++++ harvest_hashes.py | 107 ++++++++++++++++++++ loot_probe.py | 89 +++++++++++++++++ make_crafting_wiki.py | 132 +++++++++++++++++++++++++ make_items_wiki.py | 75 ++++++++++++++ make_loot_wiki.py | 157 +++++++++++++++++++++++++++++ odin_read.py | 223 ++++++++++++++++++++++++++++++++++++++++++ recover_key.py | 68 +++++++++++++ render_wiki.py | 126 ++++++++++++++++++++++++ sand.py | 208 +++++++++++++++++++++++++++++++++++++++ unitybundle.py | 81 +++++++++++++++ walker_hashes.py | 87 ++++++++++++++++ 16 files changed, 1823 insertions(+) create mode 100644 build_wbt.py create mode 100644 dump_loot_bytes.py create mode 100644 extract_data.py create mode 100644 extract_i2.py create mode 100644 extract_loot.py create mode 100644 harvest_hashes.py create mode 100644 loot_probe.py create mode 100644 make_crafting_wiki.py create mode 100644 make_items_wiki.py create mode 100644 make_loot_wiki.py create mode 100644 odin_read.py create mode 100644 recover_key.py create mode 100644 render_wiki.py create mode 100755 sand.py create mode 100644 unitybundle.py create mode 100644 walker_hashes.py diff --git a/build_wbt.py b/build_wbt.py new file mode 100644 index 0000000..655d47b --- /dev/null +++ b/build_wbt.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Build / edit SAND .wbt walker saves offline. + +Pipeline (both directions verified byte-exact on all 5 local walkers): + load: gunzip -> XOR -> Newtonsoft-BSON decode + save: BSON encode -> XOR -> gzip +pymongo's bson.encode reproduces Newtonsoft.Bson byte-for-byte for these docs, +so a decoded->encoded round-trip is identity. + +What "arbitrary" means here: + * You can freely EDIT a loaded walker: rename, move/rotate/add/remove parts and + connections, swap texture, etc. pack() recomputes the two OFFLINE hashes + (CompartmentsHash, ConnectionsHash) so the file stays self-consistent. + * DefinitionsHash (aggregate) and per-part DefinitionHash are SERVER-SOURCED. + pack() copies DefinitionsHash from the source doc and reuses each part's + DefinitionHash. That is correct as long as the *set of part definitions* + doesn't change. If you introduce a part whose EpbId we've never harvested a + DefinitionHash for, pack() raises -- we can't invent that value offline. + Known DefinitionHashes live in extracted/definition_hashes_known.json + (currently 18/126 parts; build a part in-game once to harvest more). + +CLI: + build_wbt.py repack sanity: load->pack->reload == identity + build_wbt.py rename [-o out.wbt] set name indices (0-31) + build_wbt.py pack -o out.wbt recompute hashes, write a fresh .wbt + build_wbt.py get-icon [-o out.png] extract the walker icon to a PNG (upright) + build_wbt.py set-icon [-o out] paint a PNG as the icon (auto-resize+flip) +""" +import sys, gzip, json, os, argparse, pathlib +from bson import decode, encode +import walker_hashes as wh + +KEY = bytes.fromhex('70dd1f2a0b4a'); CHUNK = 0xA000 +KNOWN = pathlib.Path('/home/downloadpizza/sand_tools' + '/extracted/definition_hashes_known.json') + +def _xor(b: bytes) -> bytes: + return bytes(b[i] ^ KEY[(i % CHUNK) % 6] for i in range(len(b))) + +def load(path) -> dict: + """gunzip -> XOR -> BSON -> dict (full container, not just doc['walker']).""" + return decode(_xor(gzip.decompress(pathlib.Path(path).read_bytes()))) + +def _known_defhashes() -> dict: + if not KNOWN.exists(): + return {} + d = json.load(open(KNOWN)) + out = {} + for epb, e in d['parts'].items(): + if e.get('DefinitionHash'): + out[epb] = e['DefinitionHash'] + return out + +def pack(doc: dict, *, recompute=True, strict=True) -> bytes: + """Recompute offline hashes on doc['walker'] and return loadable .wbt bytes. + + recompute: refresh CompartmentsHash/ConnectionsHash from the parts/connections. + strict: error if any placed part lacks a known DefinitionHash (i.e. its + server-sourced value isn't recorded), since the file would carry a + DefinitionHash the server can't validate. + """ + w = doc['walker'] + if strict: + known = _known_defhashes() + missing = [] + for c in [w['Chassis']] + w['Compartments']: + epb = c['EpbId'] + have = c.get('DefinitionHash') + if not have and epb not in known: + missing.append(epb) + if missing: + raise ValueError( + "no known DefinitionHash for EpbId(s): " + ", ".join(sorted(set(missing))) + + "\n -> build that part in-game once and re-run harvest_hashes.py, " + "or pass strict=False to bypass.") + # backfill any part missing its per-part DefinitionHash from the known table + for c in [w['Chassis']] + w['Compartments']: + if not c.get('DefinitionHash') and c['EpbId'] in known: + c['DefinitionHash'] = known[c['EpbId']] + if recompute: + w['CompartmentsHash'] = wh.compartments_hash(w['Compartments']) + w['ConnectionsHash'] = wh.connections_hash(w['Connections']) + # DefinitionsHash (aggregate over CompartmentDefinitionDto) is server-sourced; + # left as-is -- valid while the definition set is unchanged. + return gzip.compress(_xor(encode(doc))) + +def save(doc: dict, path, **kw): + pathlib.Path(path).write_bytes(pack(doc, **kw)) + +# ---- CLI ---- +def cmd_repack(a): + doc = load(a.input) + blob = pack(doc, strict=False) + back = load_bytes(blob) + same = back == doc + print(f"load->pack->reload identity: {'OK' if same else 'MISMATCH'}") + if not same: + for k in doc: + if doc[k] != back.get(k): + print(" differs:", k) + +def load_bytes(blob: bytes) -> dict: + return decode(_xor(gzip.decompress(blob))) + +def cmd_rename(a): + doc = load(a.input) + doc['firstNameIndex'] = a.first + doc['secondNameIndex'] = a.second + out = a.output or a.input + save(doc, out, strict=False) + print(f"set firstNameIndex={a.first} secondNameIndex={a.second} -> {out}") + +def cmd_pack(a): + doc = load(a.input) + save(doc, a.output, strict=not a.no_strict) + print(f"packed -> {a.output} " + f"(CompartmentsHash={doc['walker']['CompartmentsHash'][:8]} " + f"ConnectionsHash={doc['walker']['ConnectionsHash'][:8]})") + +# textureRawData is RGBA8888 stored bottom-up (Unity convention); flip on the way +# in/out so PNGs are upright both in files and in-engine. Icon isn't hashed. +def cmd_get_icon(a): + from PIL import Image + doc = load(a.input) + sz = doc['textureSize'] + img = Image.frombytes('RGBA', (sz, sz), bytes(doc['textureRawData'])) + img = img.transpose(Image.FLIP_TOP_BOTTOM) + out = a.output or (pathlib.Path(a.input).stem + '.png') + img.save(out) + print(f"wrote {out} ({sz}x{sz} RGBA)") + +def cmd_set_icon(a): + from PIL import Image + doc = load(a.input) + sz = doc['textureSize'] + img = Image.open(a.png).convert('RGBA') + if img.size != (sz, sz): + img = img.resize((sz, sz), Image.LANCZOS) + img = img.transpose(Image.FLIP_TOP_BOTTOM) # -> bottom-up for in-engine upright + doc['textureRawData'] = img.tobytes() + out = a.output or a.input + save(doc, out, strict=False) # icon doesn't affect any hash + print(f"set icon {a.png} -> {out}") + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sp = ap.add_subparsers(dest='cmd', required=True) + p = sp.add_parser('repack'); p.add_argument('input'); p.set_defaults(fn=cmd_repack) + p = sp.add_parser('rename'); p.add_argument('input'); p.add_argument('first', type=int) + p.add_argument('second', type=int); p.add_argument('-o', '--output'); p.set_defaults(fn=cmd_rename) + p = sp.add_parser('pack'); p.add_argument('input'); p.add_argument('-o', '--output', required=True) + p.add_argument('--no-strict', action='store_true'); p.set_defaults(fn=cmd_pack) + p = sp.add_parser('get-icon'); p.add_argument('input'); p.add_argument('-o', '--output') + p.set_defaults(fn=cmd_get_icon) + p = sp.add_parser('set-icon'); p.add_argument('input'); p.add_argument('png') + p.add_argument('-o', '--output'); p.set_defaults(fn=cmd_set_icon) + a = ap.parse_args(); a.fn(a) + +if __name__ == '__main__': + main() diff --git a/dump_loot_bytes.py b/dump_loot_bytes.py new file mode 100644 index 0000000..486e2cd --- /dev/null +++ b/dump_loot_bytes.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python3 +"""Dump the raw Odin SerializedBytes for the two LootTablesConfig assets, and do a +first-pass analysis: hexdump head, and list ASCII strings (>=3 chars) found.""" +import os, sys, json, re, UnityPy +from UnityPy.helpers.TypeTreeGenerator import TypeTreeGenerator + +GAME = "/mnt/d/SteamLibrary/steamapps/common/Sand Playtest" +BD = os.path.join(GAME, "Sand_Data/StreamingAssets/aa/StandaloneWindows64") +META = os.path.join(GAME, "Sand_Data/il2cpp_data/Metadata/global-metadata.dat") +DLL = os.path.join(GAME, "GameAssembly.dll") +OUT = "/home/downloadpizza/sand_tools/extracted" + +gen = TypeTreeGenerator("6000.0.40f1") +gen.load_il2cpp(open(DLL, "rb").read(), open(META, "rb").read()) +env = UnityPy.load(os.path.join(BD, "configuration_assets_all.bundle"), + os.path.join(BD, "sand_monoscripts.bundle")) + +for o in env.objects: + if o.type.name != "MonoBehaviour": + continue + try: + d = o.read(); nm = getattr(d, "m_Name", "") or "" + except Exception: + continue + if "LootTables" not in nm: + continue + script = d.m_Script.read() + full = (script.m_Namespace + "." if script.m_Namespace else "") + script.m_ClassName + nodes = json.loads(gen.get_nodes_as_json(script.m_AssemblyName, full)) + tree = o.read_typetree(nodes) + sb = tree["serializationData"]["SerializedBytes"] + data = bytes(sb) + out_bin = os.path.join(OUT, f"{nm}.odin.bin") + open(out_bin, "wb").write(data) + print(f"\n=== {nm}: {len(data)} bytes -> {out_bin}") + print("head hex:", data[:64].hex()) + # ascii strings + strings = re.findall(rb"[\x20-\x7e]{3,}", data) + uniq = [] + seen = set() + for s in strings: + t = s.decode() + if t not in seen: + seen.add(t); uniq.append(t) + print(f"{len(strings)} ascii runs, {len(uniq)} unique. First 60 unique:") + for t in uniq[:60]: + print(" ", repr(t)) diff --git a/extract_data.py b/extract_data.py new file mode 100644 index 0000000..789b850 --- /dev/null +++ b/extract_data.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Extract SAND MonoBehaviour data (loot tables, crafting graph, …) using IL2CPP typetrees. + +Builds a UnityPy TypeTreeGenerator from GameAssembly.dll + global-metadata.dat (Unity +6000.0.40f1), loads the relevant bundles into one environment so PPtr references resolve, +reads each MonoBehaviour against its generated typetree, and rewrites PPtrs as the target's +m_Name where known. Output: structured JSON in extracted/. +""" +import os, sys, json, UnityPy +from UnityPy.helpers.TypeTreeGenerator import TypeTreeGenerator + +GAME = "/mnt/d/SteamLibrary/steamapps/common/Sand Playtest" +BD = os.path.join(GAME, "Sand_Data/StreamingAssets/aa/StandaloneWindows64") +META = os.path.join(GAME, "Sand_Data/il2cpp_data/Metadata/global-metadata.dat") +DLL = os.path.join(GAME, "GameAssembly.dll") +OUT = "/home/downloadpizza/sand_tools/extracted" +UNITY = "6000.0.40f1" + +def build_generator(): + g = TypeTreeGenerator(UNITY) + g.load_il2cpp(open(DLL, "rb").read(), open(META, "rb").read()) + return g + +_node_cache = {} +def nodes_for(gen, script): + key = (script.m_AssemblyName, script.m_Namespace, script.m_ClassName) + if key not in _node_cache: + full = (script.m_Namespace + "." if script.m_Namespace else "") + script.m_ClassName + try: + _node_cache[key] = gen.get_nodes(script.m_AssemblyName, full) + except Exception as e: + _node_cache[key] = None + return _node_cache[key] + +def load_env(*bundles): + paths = [os.path.join(BD, b) for b in bundles] + return UnityPy.load(*paths) + +def build_name_index(env): + """path_id -> m_Name for every object we can cheaply name (GameObjects, named MBs).""" + idx = {} + for o in env.objects: + try: + if o.type.name in ("GameObject",): + idx[o.path_id] = o.read().m_Name + except Exception: + pass + return idx + +def read_mb(gen, obj, name_idx=None): + """Read a MonoBehaviour into a plain dict; resolve PPtr dicts to {'->': name|pathid}.""" + d = obj.read() + try: + script = d.m_Script.read() + except Exception: + return None + nodes = nodes_for(gen, script) + if not nodes: + return None + tree = obj.read_typetree(nodes) + return _clean(tree, name_idx, obj) + +def _clean(v, name_idx, obj): + if isinstance(v, dict): + # PPtr shape + if set(v.keys()) >= {"m_FileID", "m_PathID"} and len(v) <= 3: + pid = v["m_PathID"] + if pid == 0: + return None + nm = name_idx.get(pid) if name_idx else None + return {"ref": nm or pid} + return {k: _clean(x, name_idx, obj) for k, x in v.items()} + if isinstance(v, list): + return [_clean(x, name_idx, obj) for x in v] + return v + +if __name__ == "__main__": + print("building generator…", flush=True) + gen = build_generator() + print("generator ready", flush=True) + # quick validation on one loot table + env = load_env("lootsets_assets_all.bundle", "sand_monoscripts.bundle", + "epb_assets_all.bundle", "configuration_assets_all.bundle") + name_idx = build_name_index(env) + print("name index:", len(name_idx), "entries", flush=True) + o = next(x for x in env.objects if x.type.name == "MonoBehaviour" + and x.read().m_Name == "POIShipMediumWeapons") + import pprint + pprint.pprint(read_mb(gen, o, name_idx)) diff --git a/extract_i2.py b/extract_i2.py new file mode 100644 index 0000000..04007a9 --- /dev/null +++ b/extract_i2.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Extract the I2 Localization term table (English) from SAND's I2Languages asset. + +The typetree read trips on an I2 alignment quirk, but each TermData is +self-describing, so we parse the MonoBehaviour body directly. Layout (Unity +serialization, little-endian, 4-byte aligned strings/arrays), from dump.cs: + + engine header: m_GameObject(12) m_Enabled(1,align4) m_Script(12) m_Name(string) + LanguageSourceData mSource: + UInt8 x3 flags (align4) + TermData[] mTerms: int count, then per term: + string Term + int32 TermType + string Description + string[] Languages (the translations, one per language) + byte[] Flags + string[] Languages_Touch + UInt8 CaseInsensitiveTerms (align4) + int32 OnMissingTranslation + string mTerm_AppName + LanguageData[] mLanguages: int count, then per language: + string Name; string Code; byte Flags; byte Compressed (align4) +""" +import os, struct, json + +GAME = "/mnt/d/SteamLibrary/steamapps/common/Sand Playtest" +DU = os.path.join(GAME, "Sand_Data/data.unity3d") +OUT = "/home/downloadpizza/sand_tools/i2_terms_en.json" + +class R: + def __init__(self, b, off=0): self.b=b; self.o=off + def align(self): + self.o = (self.o + 3) & ~3 + def u8(self): + v=self.b[self.o]; self.o+=1; return v + def i32(self): + v=struct.unpack_from('10_000_000: raise ValueError(f"bad str len {n} @ {self.o}") + v=self.b[self.o:self.o+n].decode('utf-8','replace'); self.o+=n; self.align(); return v + def bytes(self): + n=self.i32(); v=self.b[self.o:self.o+n]; self.o+=n; self.align(); return v + def str_array(self): + n=self.i32(); return [self.s() for _ in range(n)] + +def parse(raw): + r=R(raw) + # engine header + r.i32(); r.i64() # m_GameObject + r.u8(); r.align() # m_Enabled + r.i32(); r.i64() # m_Script + name=r.s() # m_Name + # mSource — each bool is individually 4-byte aligned + r.u8(); r.align(); r.u8(); r.align(); r.u8(); r.align() + nterms=r.i32() + terms=[] + for _ in range(nterms): + term=r.s() + desc=r.s() # TermType is not serialized; Description (usually empty) + langs=r.str_array() # the translations, one per language + flags=r.bytes() # one flag byte per language + touch=r.str_array() # Languages_Touch + terms.append((term, langs)) + languages=[] + try: # trailing block is best-effort (English is index 0 regardless) + r.u8(); r.align() # CaseInsensitiveTerms + r.i32() # OnMissingTranslation + r.s() # mTerm_AppName + nlang=r.i32() + for _ in range(nlang): + lname=r.s(); lcode=r.s(); r.u8(); r.align(); r.u8(); r.align() + languages.append((lname,lcode)) + except Exception: + pass + return name, languages, terms + +def main(): + import UnityPy + env=UnityPy.load(DU) + obj=next(o for o in env.objects if o.type.name=='MonoBehaviour' + and len(o.get_raw_data())==3816792) + name, languages, terms = parse(obj.get_raw_data()) + print("asset:",name,"| languages:",languages) + eng=next((i for i,(n,c) in enumerate(languages) + if c.lower().startswith('en') or n.lower().startswith('english')),0) + print("english index:",eng,"| terms:",len(terms)) + table={t:(tr[eng] if eng node -> python list of elements.""" + if node is None: return [] + if isinstance(node, list): return node + items = node.get("$items", []) + # List nodes serialize as one inner array: $items == [[...elements...]] + out = [] + for chunk in items: + if isinstance(chunk, list): out.extend(chunk) + else: out.append(chunk) + return out + +def main(): + result = {} + extra_fields = set() + item_ids = set() + for region, fn in REGIONS.items(): + path = os.path.join(EX, fn) + data = open(path, "rb").read() + parsed = odin_read.parse(data) + assert parsed["consumed"] == parsed["total"], f"{region}: incomplete parse" + tables = odin_list(parsed["roots"]["_lootTables"]) + region_out = {} + for t in tables: + tid = t.get("lootTableId") + items = odin_list(t.get("items")) + rows = [] + for it in items: + row = {k: v for k, v in it.items() if k != "$type"} + for k in row: + if k not in ("itemBlueprint", "countMin", "countMax"): + extra_fields.add(k) + if "itemBlueprint" in row: + item_ids.add(row["itemBlueprint"]) + rows.append(row) + region_out[tid] = rows + result[region] = region_out + print(f"{region}: {len(region_out)} loot tables, " + f"{sum(len(v) for v in region_out.values())} drop rows") + out = os.path.join(EX, "loot_tables.json") + json.dump(result, open(out, "w"), indent=1, ensure_ascii=False) + print(f"\nwrote {out}") + print(f"unique item blueprints referenced: {len(item_ids)}") + if extra_fields: + print(f"NOTE extra (non count) fields present: {sorted(extra_fields)}") + # how many drop-table item ids are NOT in our authoritative item registry? + reg_path = os.path.join(EX, "items_registry.json") + if os.path.exists(reg_path): + reg = set(json.load(open(reg_path))["items"].keys()) + unknown = sorted(i for i in item_ids if i not in reg) + print(f"item blueprints not in items_registry: {len(unknown)}") + for u in unknown[:40]: + print(" ", u) + +if __name__ == "__main__": + main() diff --git a/harvest_hashes.py b/harvest_hashes.py new file mode 100644 index 0000000..13b6ce5 --- /dev/null +++ b/harvest_hashes.py @@ -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 ... # 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() diff --git a/loot_probe.py b/loot_probe.py new file mode 100644 index 0000000..d85ed2b --- /dev/null +++ b/loot_probe.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +"""Read the configuration bundle's MonoBehaviours via IL2CPP typetree and find the +loot tables. Determine definitively: are they plain (readable) or Odin-binary blobs?""" +import os, sys, json, UnityPy +from UnityPy.helpers.TypeTreeGenerator import TypeTreeGenerator + +GAME = "/mnt/d/SteamLibrary/steamapps/common/Sand Playtest" +BD = os.path.join(GAME, "Sand_Data/StreamingAssets/aa/StandaloneWindows64") +META = os.path.join(GAME, "Sand_Data/il2cpp_data/Metadata/global-metadata.dat") +DLL = os.path.join(GAME, "GameAssembly.dll") +OUT = "/home/downloadpizza/sand_tools/extracted" + +print("building generator...", flush=True) +gen = TypeTreeGenerator("6000.0.40f1") +gen.load_il2cpp(open(DLL, "rb").read(), open(META, "rb").read()) +print("ready", flush=True) + +env = UnityPy.load(os.path.join(BD, "configuration_assets_all.bundle"), + os.path.join(BD, "sand_monoscripts.bundle")) + +_cache = {} +def nodes_for(script): + full = (script.m_Namespace + "." if script.m_Namespace else "") + script.m_ClassName + key = (script.m_AssemblyName, full) + if key not in _cache: + try: + _cache[key] = json.loads(gen.get_nodes_as_json(script.m_AssemblyName, full)) + except Exception as e: + _cache[key] = None + return _cache[key] + +# list all MBs with class + whether they look loot/odin +mbs = [o for o in env.objects if o.type.name == "MonoBehaviour"] +print(f"{len(mbs)} MonoBehaviours in configuration bundle\n", flush=True) +loot_objs = [] +for o in mbs: + try: + d = o.read(); nm = getattr(d, "m_Name", "") or "" + script = d.m_Script.read(); cls = script.m_ClassName + except Exception as e: + nm, cls, script = "?", f"<{e}>", None + raw = o.get_raw_data() + has_loot = b"LootTable" in raw or b"Storm" in raw or b"Voyage" in raw + mark = " <== LOOT" if has_loot else "" + print(f" {nm:45s} {cls:35s} {len(raw):8d}B{mark}", flush=True) + if has_loot or "Loot" in cls: + loot_objs.append((o, nm, cls, script)) + +print(f"\n--- inspecting {len(loot_objs)} loot-ish objects ---", flush=True) +for o, nm, cls, script in loot_objs: + print(f"\n### {nm} ({cls})", flush=True) + if script is None: + print(" no script"); continue + nodes = nodes_for(script) + if not nodes: + print(" no typetree nodes"); continue + try: + tree = o.read_typetree(nodes) + except Exception as e: + print(f" typetree read FAILED: {e}"); continue + keys = list(tree.keys()) if isinstance(tree, dict) else type(tree) + print(f" top-level keys: {keys}", flush=True) + # Odin signature: only m_GameObject/m_Enabled/m_Script/m_Name + serializationData + sd = tree.get("serializationData") if isinstance(tree, dict) else None + if sd: + print(f" ODIN serializationData fields: {list(sd.keys())}", flush=True) + for f in ("SerializedBytes", "SerializedBytesString", "SerializationNodes"): + v = sd.get(f) + if v: + print(f" {f}: len={len(v)}", flush=True) + # dump a trimmed view + def trim(v, depth=0): + if depth > 3: return "..." + if isinstance(v, dict): + return {k: trim(x, depth+1) for k, x in list(v.items())[:8]} + if isinstance(v, list): + return [trim(x, depth+1) for x in v[:3]] + (["...+%d"%(len(v)-3)] if len(v) > 3 else []) + if isinstance(v, (bytes, bytearray)): + return f"<{len(v)} bytes>" + return v + import pprint + pprint.pprint(trim(tree), width=120) + # save full + safe = "".join(c if c.isalnum() else "_" for c in nm)[:40] + try: + json.dump(tree, open(os.path.join(OUT, f"_loot_{safe}.json"), "w"), + default=lambda x: f"" if isinstance(x,(bytes,bytearray)) else str(x)) + except Exception as e: + print(f" (save skipped: {e})") diff --git a/make_crafting_wiki.py b/make_crafting_wiki.py new file mode 100644 index 0000000..52e8a91 --- /dev/null +++ b/make_crafting_wiki.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +"""Generate a MediaWiki crafting page from extracted/crafting_recipes.json.""" +import json, re, os + +EX = '/home/downloadpizza/sand_tools/extracted' +WIKI = '/home/downloadpizza/sand_tools/wiki' +SRC = os.path.join(EX, 'crafting_recipes.json') +NAMES = os.path.join(EX, 'item_names.json') +OUT = os.path.join(WIKI, 'Crafting.mediawiki') + +STATION_SHORT = { + 'Recipes_Utility_Workbench_T1': 'Sewing (Utility)', + 'Recipes_Armament_Workbench_T1': 'Armaments T1', + 'Recipes_Armament_Workbench_T2': 'Armaments T2', +} + +# real in-game display names (I2 localization); fall back to humanize() if absent +_NAMES = json.load(open(NAMES))['items'] if os.path.exists(NAMES) else {} +def display(item_id): + n = _NAMES.get(item_id, {}).get('name') + return n if n else humanize(item_id) + +STATIONS = { + 'Recipes_Utility_Workbench_T1': ('Utility / Sewing recipes (fabric, armour, food, valuables)', 1), + 'Recipes_Armament_Workbench_T1': ('Armaments recipes — Tier 1 (basic ammo & weapons)', 2), + 'Recipes_Armament_Workbench_T2': ('Armaments recipes — Tier 2 (advanced ammo & weapons)', 3), + 'TestRecipesBundle': ('Test recipes (debug)', 99), +} + +ALIAS = {'item_coinCrown': 'Crown'} + +def camel_split(s): + return re.findall(r'[A-Za-z][a-z]*\d*', s) + +def words(tok): + return ' '.join(w.upper() if re.match(r'^[A-Za-z]\d+$', w) # T2, C4 + else w[:1].upper() + w[1:] for w in camel_split(tok)) + +def humanize(item_id): + if item_id in ALIAS: return ALIAS[item_id] + s = item_id + is_item = s.startswith('item_') + if is_item: s = s[5:] + toks = s.split('_') + if toks[0].startswith('resource'): toks[0] = toks[0][len('resource'):] + # item_* IDs use camelCase base + underscore variants; bare IDs use underscore as space + if is_item and len(toks) > 1: + return words(toks[0]) + ' (' + ', '.join(words(v) for v in toks[1:]) + ')' + return ' '.join(words(t) for t in toks) + +def link(item_id): + return f'[[{display(item_id)}]]' + +def ingredients(lst): + return ' + '.join(f"{i['amount']} × {link(i['itemId'])}" for i in lst) + +def outputs(lst): + return ' + '.join(f"{o['amount']} × {link(o['itemId'])}" for o in lst) + +def fmt_time(t): + return f"{int(t)}s" if t == int(t) else f"{t}s" + +def table(recs): + rows = ['{| class="wikitable sortable"', + '! Output !! Ingredients !! data-sort-type="number" | Time'] + for r in recs: + rows.append('|-') + rows.append(f"| {outputs(r['outputs'])} || {ingredients(r['inputs'])} " + f"|| {fmt_time(r['craftTimeSeconds'])}") + rows.append('|}') + return '\n'.join(rows) + +def main(): + data = json.load(open(SRC))['recipes'] + order = sorted(data, key=lambda k: STATIONS.get(k, (k, 50))[1]) + real = [k for k in order if STATIONS.get(k, ('', 50))[1] < 50] + total = sum(len(data[k]) for k in real) + + out = [] + out.append("Crafting in '''SAND''' is performed at workbenches placed on your walker. Each " + "workbench tier unlocks a set of recipes that turn salvaged resources into ammo, " + "weapons, armour and utility items.") + out.append('') + out.append(f"This page lists all {total} craftable recipes (plus developer test recipes), " + "extracted directly from the game files. Item names are the in-game (English) names.") + out.append('') + out.append('__TOC__') + out.append('') + out.append('== Workbenches ==') + out.append("Crafting compartments that can be placed on a walker (from the buildable " + "compartment database):") + out.append("* walker_compCrafting_Open_1x1 — '''S&H Armaments Workbench''' (1×1)") + out.append("* walker_compCrafting_Small_Wood_1x1 — '''S&H Compact Armaments Workshop''' (1×1)") + out.append("* walker_compCrafting_Wood_2x1 — '''S&H Armaments Workshop''' (1×2)") + out.append("There is also a '''KF Sewing Workshop''' (walker_compCraftingUtility_Wood_2x1) " + "associated with the utility/fabric recipes, but it is '''not''' in the buildable " + "compartment list in this build.") + out.append('') + out.append("'''Note:''' The recipe sets below come from the game's three recipe bundles " + "(Recipes_Utility_Workbench_T1, Recipes_Armament_Workbench_T1/T2). " + "Which workbench compartment loads which bundle is decided in game code at runtime and " + "is '''not''' stored in the asset data, so the exact compartment→recipe pairing " + "below is inferred from the bundle names and item types, not confirmed.") + out.append('') + out.append('== Recipes ==') + out.append(f'All {total} production recipes in a single sortable table. ' + 'Click a column header to sort (e.g. by station or craft time).') + out.append('') + out.append('{| class="wikitable sortable"') + out.append('! Station !! Output !! Ingredients !! data-sort-type="number" | Time') + for k in real: + station = STATION_SHORT.get(k, k) + for r in data[k]: + out.append('|-') + out.append(f"| {station} || {outputs(r['outputs'])} || {ingredients(r['inputs'])} " + f"|| {fmt_time(r['craftTimeSeconds'])}") + out.append('|}') + out.append('') + out.append('== Notes ==') + out.append('* Recipes are extracted from the game\'s CraftingRecipeBundle assets; ' + 'item names are the in-game English names.') + out.append('* "Time" is the base crafting duration in seconds.') + out.append('* Developer/debug recipes are omitted.') + out.append('') + out.append('[[Category:Crafting]]') + text = '\n'.join(out) + os.makedirs(WIKI, exist_ok=True) + open(OUT, 'w').write(text) + print(f"wrote {OUT} ({len(text)} chars, {total} recipes in one table)") + +if __name__ == '__main__': + main() diff --git a/make_items_wiki.py b/make_items_wiki.py new file mode 100644 index 0000000..09d6b61 --- /dev/null +++ b/make_items_wiki.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Generate a MediaWiki Items page from the authoritative item registry. + +Source = items_registry.json, built from CheatItemDefinitionsData.Items +(List). An entry exists iff the game defines it as a carriable +item (it has an ItemDefinition / StorageStack). Damage-type name variants +(_Ranged/_Melee) and world objects are NOT in this list, by design. +""" +import json, os + +EX = '/home/downloadpizza/sand_tools/extracted' +WIKI = '/home/downloadpizza/sand_tools/wiki' +OUT = os.path.join(WIKI, 'Items.mediawiki') + +reg = json.load(open(os.path.join(EX, 'items_registry.json')))['items'] + +CAT_LABEL = { + 'WEAPON': 'Weapon', 'AMMO': 'Ammo', 'TURRET_AMMO': 'Turret Ammo', + 'RESOURCE_T1': 'Resource (T1)', 'RESOURCE_T2': 'Resource (T2)', 'RESOURCE_T3': 'Resource (T3)', + 'ARMOR': 'Armor', 'BACKPACK': 'Backpack', 'ENERGY': 'Energy', 'FOOD': 'Food', 'MONEY': 'Money', + 'KEY': 'Key', 'SMALL_VALUABLE': 'Small Valuable', 'LARGE_VALUABLE': 'Large Valuable', + 'RAID_EXPLOSIVES': 'Raid Explosive', 'WEAPON_BELT': 'Weapon Belt', + 'UTILITY_CONSUMABLE': 'Utility Consumable', 'ATTACK_CONSUMABLE': 'Attack Consumable', +} + +def esc(s): + return (s or '').replace('\n', ' ').replace('|', '|').strip() + +def stack(n): + return '—' if n >= 100000 else str(n) # 100000/1000000 = effectively unlimited + +def main(): + out = [] + out.append("Complete list of carriable '''items''' in '''SAND''' — everything the game defines as an " + "actual inventory item (i.e. it has an item definition). Names and descriptions are the " + "in-game English text.") + out.append('') + out.append(f"{len(reg)} items. The table is sortable — click a header to group by category. " + "(Damage-type name variants and world objects are intentionally excluded; this is the " + "real pickup-able item set.)") + out.append('') + out.append('__TOC__') + out.append('') + out.append('== Items ==') + out.append('{| class="wikitable sortable"') + out.append('! Category !! Name !! Item ID !! data-sort-type="number" | Stack !! Short description') + rows = sorted(reg.items(), key=lambda kv: (kv[1]['type'], kv[1]['name'].lower())) + for iid, info in rows: + cat = CAT_LABEL.get(info['type'], info['type'].title()) + out.append('|-') + out.append(f"| {cat} || [[{info['name']}]] || {iid} " + f"|| {stack(info['storageStack'])} || {esc(info['shortDescription'])}") + out.append('|}') + out.append('') + out.append('== Notes ==') + out.append('* The item set comes from the game\'s item-definition registry ' + '(CheatItemDefinitionsData.Items): an entry is included only if the game ' + 'defines it as a carriable item. This deliberately excludes damage-type name variants ' + '(_Ranged/_Melee) and world objects, which merely have ' + 'display names but are not inventory items.') + out.append('* "Stack" is the item\'s storage stack limit; "—" means effectively unlimited.') + out.append('* Full long-form descriptions exist for most items and can be added per page.') + out.append('') + out.append('[[Category:Items]]') + text = '\n'.join(out) + os.makedirs(WIKI, exist_ok=True) + open(OUT, 'w').write(text) + from collections import Counter + c = Counter(CAT_LABEL.get(v['type'], v['type']) for v in reg.values()) + print(f"wrote {OUT} ({len(text)} chars, {len(reg)} items)") + for k, v in c.most_common(): + print(f" {v:3d} {k}") + +if __name__ == '__main__': + main() diff --git a/make_loot_wiki.py b/make_loot_wiki.py new file mode 100644 index 0000000..53a8d41 --- /dev/null +++ b/make_loot_wiki.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Generate a MediaWiki Loot/Drop-tables page from extracted/loot_tables.json. + +Source = the two Odin-binary LootTablesConfig assets (Storm + Voyage), decoded by +odin_read.py. Both regions share the same 193 table ids and the same item set per +table; only the counts differ (Voyage is leaner). So each (table,item) is one row +with separate Storm and Voyage count columns. Item names are the in-game I2 names. +""" +import json, os, re + +EX = '/home/downloadpizza/sand_tools/extracted' +WIKI = '/home/downloadpizza/sand_tools/wiki' +SRC = os.path.join(EX, 'loot_tables.json') +NAMES = os.path.join(EX, 'item_names.json') +OUT = os.path.join(WIKI, 'Loot.mediawiki') + +_NAMES = json.load(open(NAMES))['items'] if os.path.exists(NAMES) else {} + +def camel_split(s): + return re.findall(r'[A-Za-z][a-z]*\d*', s) + +def words(tok): + return ' '.join(w.upper() if re.match(r'^[A-Za-z]\d+$', w) else w[:1].upper() + w[1:] + for w in camel_split(tok)) + +def humanize(item_id): + s = item_id + is_item = s.startswith('item_') + if is_item: s = s[5:] + toks = s.split('_') + if toks and toks[0].startswith('resource'): toks[0] = toks[0][len('resource'):] + if is_item and len(toks) > 1: + return words(toks[0]) + ' (' + ', '.join(words(v) for v in toks[1:]) + ')' + return ' '.join(words(t) for t in toks) + +def display(item_id): + n = _NAMES.get(item_id, {}).get('name') + return n if n else humanize(item_id) + +CAT_RULES = [ + ('weapons_container_resupply', 'Weapon resupply'), + ('weapons_container', 'Weapons container'), + ('shells_container', 'Shells container'), + ('valuables_container', 'Valuables container'), + ('resource_container', 'Resource container'), + ('food_container', 'Food container'), + ('med_container', 'Medical container'), + ('buriedTreasure', 'Buried treasure'), + ('mobLoot_ghoulRange', 'Mob drop — Ghoul (ranged)'), + ('mobLoot_ghoulMeleeShovel', 'Mob drop — Ghoul (shovel)'), + ('mobLoot_ghoulMelee', 'Mob drop — Ghoul (melee)'), + ('ironcladLoot_packedTurret', 'Ironclad — packed turret'), + ('ironcladLoot_lootBoxEntity', 'Ironclad — loot box (entity)'), + ('ironcladLoot_lootBox_mandat','Ironclad — mandatory alloy'), + ('ironcladLoot_lootBox', 'Ironclad — loot box'), + ('ironcladLoot_repairKit', 'Ironclad — repair kit'), + ('ironcladLoot', 'Ironclad'), + ('navalMine', 'Naval mine'), + ('aurogenCrystal', 'Aurogen crystal'), +] +def category(tid): + for pre, lab in CAT_RULES: + if tid.startswith(pre): + return lab + return 'Other' + +def cnt(rows_by_id, item): + r = rows_by_id.get(item) + if r is None: + return '—' + lo, hi = r['countMin'], r['countMax'] + return str(lo) if lo == hi else f'{lo}–{hi}' + +def esc(s): + return (s or '').replace('|', '|') + +def main(): + d = json.load(open(SRC)) + storm, voyage = d['Storm'], d['Voyage'] + tids = sorted(storm.keys(), key=lambda t: (category(t), t)) + + # build per-table id->row maps for fast count lookup + smap = {t: {r['itemBlueprint']: r for r in storm[t]} for t in storm} + vmap = {t: {r['itemBlueprint']: r for r in voyage.get(t, [])} for t in storm} + + nrows = sum(len(storm[t]) for t in tids) + out = [] + out.append("'''Loot tables''' for '''SAND''' — what each lootable source (containers, buried " + "treasure, naval mines, ironclad cargo, and mob kills) can drop, and in what " + "quantity. Extracted directly from the game's drop-table configuration.") + out.append('') + out.append("The game ships two regional drop-table sets, '''Storm''' and '''Voyage'''. They " + "contain the same loot tables with the same items, but the drop ''amounts'' differ " + "— Voyage is the leaner region (152 of 193 tables give smaller stacks). Both " + "amounts are shown side by side below; a count like ''20–25'' is a random " + "range, a single number is fixed.") + out.append('') + out.append(f"{len(tids)} loot tables, {nrows} item drops. The table is sortable — click a " + "header to group by category, table, or item.") + out.append('') + out.append('__TOC__') + out.append('') + out.append('== Drop tables ==') + out.append('{| class="wikitable sortable"') + out.append('! Category !! Loot table !! Item !! data-sort-type="number" | Storm ' + '!! data-sort-type="number" | Voyage') + for t in tids: + cat = category(t) + for r in storm[t]: + iid = r['itemBlueprint'] + out.append('|-') + out.append(f"| {esc(cat)} || {t} || [[{esc(display(iid))}]] " + f"|| {cnt(smap[t], iid)} || {cnt(vmap[t], iid)}") + out.append('|}') + out.append('') + + # reverse index: item -> which tables drop it + out.append('== Where each item drops ==') + out.append('For each lootable item, the loot tables that can drop it.') + out.append('') + item_to_tables = {} + for t in tids: + for r in storm[t]: + item_to_tables.setdefault(r['itemBlueprint'], []).append(t) + out.append('{| class="wikitable sortable"') + out.append('! Item !! Item ID !! data-sort-type="number" | # tables !! Dropped by') + for iid in sorted(item_to_tables, key=lambda i: display(i).lower()): + ts = item_to_tables[iid] + shown = ', '.join(f'{x}' for x in ts[:12]) + if len(ts) > 12: + shown += f' … (+{len(ts)-12} more)' + out.append('|-') + out.append(f"| [[{esc(display(iid))}]] || {iid} || {len(ts)} || {shown}") + out.append('|}') + out.append('') + out.append('== Notes ==') + out.append('* Source: the game\'s LootTablesConfig assets ' + '(conf_worldLootTablesStormConfig / …VoyageConfig), ' + 'Odin-serialized; decoded from the binary payload.') + out.append('* Each drop entry is {item, countMin, countMax}; the count shown is ' + 'min–max (a single value means min == max).') + out.append('* A few drop entries are drop-only blueprint variants (e.g. mob/mine-drop resource ' + 'variants and world treasure piles) that are not separate carriable items; they are ' + 'shown under their in-game display name where one exists.') + out.append("* This lists ''what'' a table can drop. The ''chance'' of a given table rolling, and " + "how many POI containers use each table, are decided elsewhere in game logic.") + out.append('') + out.append('[[Category:Loot]]') + + text = '\n'.join(out) + os.makedirs(WIKI, exist_ok=True) + open(OUT, 'w').write(text) + print(f"wrote {OUT} ({len(text)} chars, {len(tids)} tables, {nrows} rows, " + f"{len(item_to_tables)} distinct items)") + +if __name__ == '__main__': + main() diff --git a/odin_read.py b/odin_read.py new file mode 100644 index 0000000..7692460 --- /dev/null +++ b/odin_read.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Minimal reader for Sirenix Odin 'Binary' DataFormat (SerializedFormat=0). + +Implements the BinaryDataReader entry-stream well enough to reconstruct the object +tree (named fields, nodes, arrays, primitives, strings, references). Produces a +generic Python structure; the caller maps it to loot-table semantics. + +Reference: Sirenix.Serialization.BinaryDataReader / BinaryEntryType enum. +String wire format: [flag:1 byte (0=8bit,1=16bit)] [charCount:int32] [chars]. +""" +import struct + +# BinaryEntryType +INVALID=0 +NAMED_START_REF=1; UNNAMED_START_REF=2 +NAMED_START_STRUCT=3; UNNAMED_START_STRUCT=4 +END_OF_NODE=5 +START_OF_ARRAY=6; END_OF_ARRAY=7 +PRIMITIVE_ARRAY=8 +NAMED_INTERNAL_REF=9; UNNAMED_INTERNAL_REF=10 +NAMED_EXT_REF_INDEX=11; UNNAMED_EXT_REF_INDEX=12 +NAMED_EXT_REF_GUID=13; UNNAMED_EXT_REF_GUID=14 +NAMED_SBYTE=15; UNNAMED_SBYTE=16 +NAMED_BYTE=17; UNNAMED_BYTE=18 +NAMED_SHORT=19; UNNAMED_SHORT=20 +NAMED_USHORT=21; UNNAMED_USHORT=22 +NAMED_INT=23; UNNAMED_INT=24 +NAMED_UINT=25; UNNAMED_UINT=26 +NAMED_LONG=27; UNNAMED_LONG=28 +NAMED_ULONG=29; UNNAMED_ULONG=30 +NAMED_FLOAT=31; UNNAMED_FLOAT=32 +NAMED_DOUBLE=33; UNNAMED_DOUBLE=34 +NAMED_DECIMAL=35; UNNAMED_DECIMAL=36 +NAMED_CHAR=37; UNNAMED_CHAR=38 +NAMED_STRING=39; UNNAMED_STRING=40 +NAMED_GUID=41; UNNAMED_GUID=42 +NAMED_BOOL=43; UNNAMED_BOOL=44 +NAMED_NULL=45; UNNAMED_NULL=46 +TYPE_NAME=47; TYPE_ID=48 +END_OF_STREAM=49 +NAMED_EXT_REF_STRING=50; UNNAMED_EXT_REF_STRING=51 + +NAMED = {NAMED_START_REF,NAMED_START_STRUCT,NAMED_INTERNAL_REF,NAMED_EXT_REF_INDEX, + NAMED_EXT_REF_GUID,NAMED_SBYTE,NAMED_BYTE,NAMED_SHORT,NAMED_USHORT,NAMED_INT, + NAMED_UINT,NAMED_LONG,NAMED_ULONG,NAMED_FLOAT,NAMED_DOUBLE,NAMED_DECIMAL, + NAMED_CHAR,NAMED_STRING,NAMED_GUID,NAMED_BOOL,NAMED_NULL,NAMED_EXT_REF_STRING} + +class Node: + """A reconstructed reference/struct node: type name + fields + unnamed items.""" + __slots__=("type","id","fields","items") + def __init__(self,type,id): + self.type=type; self.id=id; self.fields={}; self.items=[] + def to_py(self): + d={} + if self.type: d["$type"]=_short(self.type) + for k,v in self.fields.items(): + d[k]=_topy(v) + if self.items: + d["$items"]=[_topy(x) for x in self.items] + return d + +def _short(t): + # trim 'Namespace.Class, Assembly' -> Class (keep generics short-ish) + base=t.split(',')[0] + return base + +def _topy(v): + if isinstance(v,Node): return v.to_py() + if isinstance(v,list): return [_topy(x) for x in v] + return v + +class Ref: + def __init__(self,kind,val): self.kind=kind; self.val=val + def to_py(self): return {"$ref":self.val,"kind":self.kind} + +class Reader: + def __init__(self,data): + self.d=data; self.p=0; self.n=len(data); self.types={} + def eof(self): return self.p>=self.n + def u8(self): + v=self.d[self.p]; self.p+=1; return v + def i32(self): + v=struct.unpack_from('length+8: break # safety + return items + + def read_value(self,b): + # b is the entry byte already consumed (and name already read if NAMED) + if b in (NAMED_START_REF,UNNAMED_START_REF): + return self.read_node(True) + if b in (NAMED_START_STRUCT,UNNAMED_START_STRUCT): + return self.read_node(False) + if b==START_OF_ARRAY: + return self.read_array() + if b==PRIMITIVE_ARRAY: + cnt=self.i32(); bpe=self.i32() + raw=self.d[self.p:self.p+cnt*bpe]; self.p+=cnt*bpe + return {"$primarray":cnt,"bytesPer":bpe} + if b in (NAMED_INTERNAL_REF,UNNAMED_INTERNAL_REF): + return Ref("internal",self.i32()) + if b in (NAMED_EXT_REF_INDEX,UNNAMED_EXT_REF_INDEX): + return Ref("ext_index",self.i32()) + if b in (NAMED_EXT_REF_GUID,UNNAMED_EXT_REF_GUID): + g=self.d[self.p:self.p+16]; self.p+=16; return Ref("ext_guid",g.hex()) + if b in (NAMED_EXT_REF_STRING,UNNAMED_EXT_REF_STRING): + return Ref("ext_string",self.string()) + if b in (NAMED_SBYTE,UNNAMED_SBYTE): + v=self.u8(); return v-256 if v>127 else v + if b in (NAMED_BYTE,UNNAMED_BYTE): return self.u8() + if b in (NAMED_SHORT,UNNAMED_SHORT): return self.i16() + if b in (NAMED_USHORT,UNNAMED_USHORT): return self.u16() + if b in (NAMED_INT,UNNAMED_INT): return self.i32() + if b in (NAMED_UINT,UNNAMED_UINT): return self.u32() + if b in (NAMED_LONG,UNNAMED_LONG): return self.i64() + if b in (NAMED_ULONG,UNNAMED_ULONG): return self.u64() + if b in (NAMED_FLOAT,UNNAMED_FLOAT): return self.f32() + if b in (NAMED_DOUBLE,UNNAMED_DOUBLE): return self.f64() + if b in (NAMED_DECIMAL,UNNAMED_DECIMAL): + raw=self.d[self.p:self.p+16]; self.p+=16; return {"$decimal":raw.hex()} + if b in (NAMED_CHAR,UNNAMED_CHAR): + v=self.d[self.p:self.p+2]; self.p+=2; return v.decode('utf-16-le') + if b in (NAMED_STRING,UNNAMED_STRING): return self.string() + if b in (NAMED_GUID,UNNAMED_GUID): + g=self.d[self.p:self.p+16]; self.p+=16; return g.hex() + if b in (NAMED_BOOL,UNNAMED_BOOL): return self.u8()!=0 + if b in (NAMED_NULL,UNNAMED_NULL): return None + if b==END_OF_STREAM: return None + raise ValueError(f"unknown entry byte {b} at pos {self.p-1}") + + def read_top(self): + """Read entries at the document root until end-of-stream.""" + roots={} + items=[] + while not self.eof(): + b=self.u8() + if b in (END_OF_STREAM,INVALID): break + if b==END_OF_NODE or b==END_OF_ARRAY: continue + name=None + if b in NAMED: name=self.string() + val=self.read_value(b) + if name is not None: roots[name]=val + else: items.append(val) + return roots, items + +def parse(data): + r=Reader(data) + roots,items=r.read_top() + return {"roots":{k:_topy(v) for k,v in roots.items()}, + "items":[_topy(x) for x in items], + "consumed":r.p,"total":r.n,"types":r.types} + +if __name__=="__main__": + import sys,json + data=open(sys.argv[1],'rb').read() + res=parse(data) + print(f"consumed {res['consumed']}/{res['total']} bytes") + print(f"{len(res['types'])} types registered") + out=sys.argv[2] if len(sys.argv)>2 else None + if out: + json.dump(res,open(out,'w'),indent=1,ensure_ascii=False) + print("wrote",out) + else: + print(json.dumps(res,indent=1,ensure_ascii=False)[:4000]) diff --git a/recover_key.py b/recover_key.py new file mode 100644 index 0000000..28e2ca1 --- /dev/null +++ b/recover_key.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Recover the SAND .wbt XOR key after a game update changes it — no RE needed. + +Known-plaintext crib: the walker icon (textureRawData) is RGBA8888 and its empty +space is the solid background pixel BG = 02 05 0D 00 (RGBA 2,5,13,0). The BSON +header before the pixel data is a CONSTANT size, so pixel byte 0 always lands at +decoded offset 0x2A: + 4 (int32 BSON doc length) + + 17 (textureSize field: 0x10 + "textureSize\0"(12) + int32(4)) + + 21 (textureRawData header: 0x05 + "textureRawData\0"(15) + int32 len(4) + subtype(1)) + = 42 = 0x2A +Pixel (0,0) is the bottom-left of the image (Unity bottom-up), which is background +on a normal walker, so a long run of BG sits right at offset 0x2A — inside the +first 0xA000 cipher chunk, where the keystream residue is just i % keylen. + +Recovery: for each i in [0x2A, 0xA000), + key[i % keylen] = enc[i] XOR BG[(i - 0x2A) % 4] +Majority-vote each residue (non-background pixels are the minority and wash out), +try keylen 6 first (current), then 1..16, and accept the key whose full decode +parses as BSON. Independent of whatever the new key bytes are. + +Usage: + recover_key.py [ ...] # print recovered key(s) +""" +import sys, gzip, struct +from collections import Counter + +BG = bytes((0x02, 0x05, 0x0D, 0x00)) # background pixel, RGBA (2,5,13,0) +PIXOFF = 0x2A # fixed decoded offset of pixel byte 0 +CHUNK = 0xA000 + +def recover(path, keylens=(6,) + tuple(range(1, 17))): + raw = gzip.decompress(open(path, 'rb').read()) + end = min(CHUNK, len(raw)) + tried = set() + for L in keylens: + if L in tried: + continue + tried.add(L) + votes = [Counter() for _ in range(L)] + for i in range(PIXOFF, end): + kb = raw[i] ^ BG[(i - PIXOFF) % 4] + votes[i % L][kb] += 1 + if any(not v for v in votes): + continue + key = bytes(v.most_common(1)[0][0] for v in votes) + if _verifies(raw, key): + return key, L + return None, None + +def _verifies(raw, key): + L = len(key) + dec = bytes(raw[i] ^ key[(i % CHUNK) % L] for i in range(len(raw))) + # cheap structural check: BSON doc length == total, and known field names present + if len(dec) < 8: + return False + if struct.unpack_from(' ...") + for f in files: + key, L = recover(f) + if key: + print(f"{f.split('/')[-1][:8]} KEY = {key.hex().upper()} (keylen {L})") + else: + print(f"{f.split('/')[-1][:8]} FAILED (no BG run / unexpected layout)") diff --git a/render_wiki.py b/render_wiki.py new file mode 100644 index 0000000..6e82761 --- /dev/null +++ b/render_wiki.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Render the generated MediaWiki crafting page to a standalone HTML file with +MediaWiki (Vector skin) styling, so it can be eyeballed locally. Handles exactly +the subset of wikitext our generator emits: headings, bold/italic, [[links]], +{| wikitable sortable |}, * lists, , __TOC__, [[Category:]]. +""" +import re, sys, html, pathlib + +SRC = pathlib.Path('/home/downloadpizza/sand_tools/wiki/Crafting.mediawiki') +OUT = pathlib.Path.home() / 'sand_tools' / 'wiki_site' / 'index.html' + +def inline(t): + # links: [[Target]] or [[Target|Label]] + def lk(m): + tgt = m.group(1); lab = m.group(2) or tgt + return f'{html.escape(lab)}' + t = re.sub(r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]', lk, t) + t = re.sub(r"'''(.+?)'''", r'\1', t) + t = re.sub(r"''(.+?)''", r'\1', t) + return t # note: × / already valid HTML, pass through + +def render(wt): + out = [] + cats = [] + lines = wt.splitlines() + i = 0 + while i < len(lines): + ln = lines[i] + if ln.strip() == '__TOC__': + i += 1; continue + m = re.match(r'\[\[Category:([^\]]+)\]\]', ln.strip()) + if m: + cats.append(m.group(1)); i += 1; continue + h = re.match(r'^(=+)\s*(.*?)\s*=+\s*$', ln) + if h: + lvl = len(h.group(1)) + out.append(f'{inline(h.group(2))}') + i += 1; continue + if ln.startswith('{|'): # table + cls = 'wikitable' + if 'sortable' in ln: cls += ' sortable' + out.append(f'') + i += 1 + while i < len(lines) and not lines[i].startswith('|}'): + row = lines[i] + if row.startswith('!'): + cells = row[1:].split('!!') + out.append('' + ''.join( + f'' for c in cells) + '') + elif row.startswith('|-'): + pass + elif row.startswith('|'): + cells = row[1:].split('||') + out.append('' + ''.join( + f'' for c in cells) + '') + i += 1 + out.append('
{inline(_celltext(c))}
{inline(_celltext(c))}
') + i += 1; continue + if ln.startswith('* '): + items = [] + while i < len(lines) and lines[i].startswith('* '): + items.append(f'
  • {inline(lines[i][2:])}
  • '); i += 1 + out.append('
      ' + ''.join(items) + '
    '); continue + if ln.strip() == '': + i += 1; continue + out.append(f'

    {inline(ln)}

    ') + i += 1 + if cats: + out.append('') + return '\n'.join(out) + +def _celltext(c): + # strip optional "attr=... | text" cell prefix + if '|' in c and not c.strip().startswith('['): + # only split on a | that's an attribute separator (before any [[ ) + pre, _, post = c.partition('|') + if '[[' not in pre: + return post.strip() + return c.strip() + +PAGE = """ +{title} - SAND Wiki (local test) +
    +
    Local render for testing — not a live MediaWiki. Links are inert; sorting is basic JS.
    +

    {title}

    +{body} +
    +""" + +if __name__ == '__main__': + wt = SRC.read_text() + OUT.parent.mkdir(parents=True, exist_ok=True) + OUT.write_text(PAGE.format(title='Crafting', body=render(wt))) + print(f"wrote {OUT}") diff --git a/sand.py b/sand.py new file mode 100755 index 0000000..0170c0b --- /dev/null +++ b/sand.py @@ -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 -> write decoded bytes + snap [--all | ...] -> decode + save numbered snapshot per file + diff -> human-readable diff + check -> one-shot: snap + diff against latest prior snap + watch -> 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]) 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() diff --git a/unitybundle.py b/unitybundle.py new file mode 100644 index 0000000..7bfc177 --- /dev/null +++ b/unitybundle.py @@ -0,0 +1,81 @@ +"""Minimal UnityFS bundle extractor (LZ4/LZ4HC + uncompressed blocks).""" +import struct + +def lz4_decompress(src, dst_size): + out=bytearray(); i=0; n=len(src) + while i>4 + if lit==15: + while True: + bb=src[i]; i+=1; lit+=bb + if bb!=255: break + out+=src[i:i+lit]; i+=lit + if i>=n: break + off=src[i]|(src[i+1]<<8); i+=2 + ml=tok&15 + if ml==15: + while True: + bb=src[i]; i+=1; ml+=bb + if bb!=255: break + ml+=4 + start=len(out)-off + for j in range(ml): + out.append(out[start+j]) + return bytes(out[:dst_size]) + +def extract(path): + """Return (nodes, data_bytes). nodes=[(off,size,flags,name)].""" + b=open(path,'rb').read() + if b[:7]!=b'UnityFS': raise ValueError('not UnityFS') + p=8 + ver,=struct.unpack_from('>I',b,p); p+=4 + def rstr(p): + e=b.index(b'\x00',p); return b[p:e].decode(), e+1 + uver,p=rstr(p); ueng,p=rstr(p) + size,=struct.unpack_from('>q',b,p); p+=8 + cblk,=struct.unpack_from('>I',b,p); p+=4 + ublk,=struct.unpack_from('>I',b,p); p+=4 + flags,=struct.unpack_from('>I',b,p); p+=4 + if flags & 0x80: # blocksInfo at end + bi=b[len(b)-cblk:] + else: + if flags & 0x200: + p=(p+15)&~15 + bi=b[p:p+cblk]; p+=cblk + comp=flags&0x3f + blocks_info=bi if comp==0 else lz4_decompress(bi,ublk) + q=16 + bc,=struct.unpack_from('>I',blocks_info,q); q+=4 + blocks=[] + for _ in range(bc): + u,c,f=struct.unpack_from('>IIH',blocks_info,q); q+=10 + blocks.append((u,c,f)) + ncount,=struct.unpack_from('>I',blocks_info,q); q+=4 + nodes=[] + for _ in range(ncount): + off,sz,fl=struct.unpack_from('>qqI',blocks_info,q); q+=20 + e=blocks_info.index(b'\x00',q); name=blocks_info[q:e].decode(); q=e+1 + nodes.append((off,sz,fl,name)) + if not (flags & 0x80): + pass + data=bytearray() + for (u,c,f) in blocks: + blk=b[p:p+c]; p+=c + data += blk if (f&0x3f)==0 else lz4_decompress(blk,u) + return nodes, bytes(data) + +if __name__=='__main__': + import sys,glob + needle=sys.argv[1].encode() if len(sys.argv)>1 else b'actualVersion' + d='/mnt/d/SteamLibrary/steamapps/common/Sand Playtest/Sand_Data/StreamingAssets/aa/StandaloneWindows64' + for f in sorted(glob.glob(d+'/*.bundle')): + import os + if os.path.getsize(f) > 30_000_000: # skip huge ones in quick pass + continue + try: + nodes,data=extract(f) + if needle in data: + print('HIT', os.path.basename(f), 'at', data.find(needle)) + except Exception as e: + print('ERR', os.path.basename(f), e) diff --git a/walker_hashes.py b/walker_hashes.py new file mode 100644 index 0000000..7bd8254 --- /dev/null +++ b/walker_hashes.py @@ -0,0 +1,87 @@ +#!/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> + -> {"Sockets":[{"Key":{x,y,z},"Value":[{"Key":"","Value":{"state":"","count":""}}]}]} +""" +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'}")