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:
DownloadPizza
2026-06-11 14:43:57 +02:00
parent 3d5f03c50f
commit e2a2984925
16 changed files with 1823 additions and 0 deletions

160
build_wbt.py Normal file
View File

@@ -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 <wbt> sanity: load->pack->reload == identity
build_wbt.py rename <wbt> <first> <second> [-o out.wbt] set name indices (0-31)
build_wbt.py pack <wbt> -o out.wbt recompute hashes, write a fresh .wbt
build_wbt.py get-icon <wbt> [-o out.png] extract the walker icon to a PNG (upright)
build_wbt.py set-icon <wbt> <png> [-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()

47
dump_loot_bytes.py Normal file
View File

@@ -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))

89
extract_data.py Normal file
View File

@@ -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))

101
extract_i2.py Normal file
View File

@@ -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('<i',self.b,self.o)[0]; self.o+=4; return v
def i64(self):
v=struct.unpack_from('<q',self.b,self.o)[0]; self.o+=8; return v
def s(self):
n=self.i32()
if n<0 or n>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<len(tr) else (tr[0] if tr else '')) for t,tr in terms}
json.dump({'_source':'I2 LanguageSourceAsset (I2Languages), data.unity3d',
'languages':languages,'english_index':eng,'count':len(table),
'terms':table}, open(OUT,'w'), indent=2, ensure_ascii=False)
from collections import Counter
ns=Counter(k.split('/')[0] for k in table)
print("\ntop term-key prefixes:")
for k,c in ns.most_common(30): print(f" {c:4d} {k}")
print("\nwrote",OUT)
if __name__=='__main__':
main()

73
extract_loot.py Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Extract SAND loot/drop tables from the two Odin-binary LootTablesConfig assets.
Decodes conf_worldLootTablesStormConfig + conf_worldLootTablesVoyageConfig (Odin
SerializedFormat=0 Binary) via odin_read, flattens to a clean dict:
{ region: { lootTableId: [ {itemBlueprint, countMin, countMax, ...}, ... ] } }
Writes extracted/loot_tables.json (+ reports any unexpected fields / coverage).
"""
import os, sys, json
import odin_read
EX = "/home/downloadpizza/sand_tools/extracted"
REGIONS = {
"Storm": "conf_worldLootTablesStormConfig.odin.bin",
"Voyage": "conf_worldLootTablesVoyageConfig.odin.bin",
}
def odin_list(node):
"""Unwrap an Odin List<T> 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()

107
harvest_hashes.py Normal file
View 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()

89
loot_probe.py Normal file
View File

@@ -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"<bytes {len(x)}>" if isinstance(x,(bytes,bytearray)) else str(x))
except Exception as e:
print(f" (save skipped: {e})")

132
make_crafting_wiki.py Normal file
View File

@@ -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 &amp; weapons)', 2),
'Recipes_Armament_Workbench_T2': ('Armaments recipes — Tier 2 (advanced ammo &amp; 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']} &times; {link(i['itemId'])}" for i in lst)
def outputs(lst):
return ' + '.join(f"{o['amount']} &times; {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("* <code>walker_compCrafting_Open_1x1</code> — '''S&amp;H Armaments Workbench''' (1×1)")
out.append("* <code>walker_compCrafting_Small_Wood_1x1</code> — '''S&amp;H Compact Armaments Workshop''' (1×1)")
out.append("* <code>walker_compCrafting_Wood_2x1</code> — '''S&amp;H Armaments Workshop''' (1×2)")
out.append("There is also a '''KF Sewing Workshop''' (<code>walker_compCraftingUtility_Wood_2x1</code>) "
"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 "
"(<code>Recipes_Utility_Workbench_T1</code>, <code>Recipes_Armament_Workbench_T1/T2</code>). "
"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&rarr;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 <code>CraftingRecipeBundle</code> 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()

75
make_items_wiki.py Normal file
View File

@@ -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<ItemDefinition>). 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('|', '&#124;').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']}]] || <code>{iid}</code> "
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 '
'(<code>CheatItemDefinitionsData.Items</code>): an entry is included only if the game '
'defines it as a carriable item. This deliberately excludes damage-type name variants '
'(<code>_Ranged</code>/<code>_Melee</code>) 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()

157
make_loot_wiki.py Normal file
View File

@@ -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}&ndash;{hi}'
def esc(s):
return (s or '').replace('|', '&#124;')
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 "
"&mdash; Voyage is the leaner region (152 of 193 tables give smaller stacks). Both "
"amounts are shown side by side below; a count like ''20&ndash;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 &mdash; 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)} || <code>{t}</code> || [[{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'<code>{x}</code>' for x in ts[:12])
if len(ts) > 12:
shown += f' … (+{len(ts)-12} more)'
out.append('|-')
out.append(f"| [[{esc(display(iid))}]] || <code>{iid}</code> || {len(ts)} || {shown}")
out.append('|}')
out.append('')
out.append('== Notes ==')
out.append('* Source: the game\'s <code>LootTablesConfig</code> assets '
'(<code>conf_worldLootTablesStormConfig</code> / <code>…VoyageConfig</code>), '
'Odin-serialized; decoded from the binary payload.')
out.append('* Each drop entry is <code>{item, countMin, countMax}</code>; the count shown is '
'<code>min&ndash;max</code> (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()

223
odin_read.py Normal file
View File

@@ -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('<i',self.d,self.p)[0]; self.p+=4; return v
def u32(self):
v=struct.unpack_from('<I',self.d,self.p)[0]; self.p+=4; return v
def i64(self):
v=struct.unpack_from('<q',self.d,self.p)[0]; self.p+=8; return v
def u64(self):
v=struct.unpack_from('<Q',self.d,self.p)[0]; self.p+=8; return v
def f32(self):
v=struct.unpack_from('<f',self.d,self.p)[0]; self.p+=4; return v
def f64(self):
v=struct.unpack_from('<d',self.d,self.p)[0]; self.p+=8; return v
def i16(self):
v=struct.unpack_from('<h',self.d,self.p)[0]; self.p+=2; return v
def u16(self):
v=struct.unpack_from('<H',self.d,self.p)[0]; self.p+=2; return v
def string(self):
flag=self.u8(); ln=self.i32()
if flag==0:
b=self.d[self.p:self.p+ln]; self.p+=ln
return b.decode('latin1')
else:
b=self.d[self.p:self.p+ln*2]; self.p+=ln*2
return b.decode('utf-16-le')
def peek(self):
return self.d[self.p] if self.p<self.n else END_OF_STREAM
def read_type_entry(self):
b=self.peek()
if b==TYPE_NAME:
self.p+=1; tid=self.i32(); s=self.string(); self.types[tid]=s; return s
if b==TYPE_ID:
self.p+=1; tid=self.i32(); return self.types.get(tid)
return None
def read_node(self,is_ref):
t=self.read_type_entry()
nid=self.i32() if is_ref else None
node=Node(t,nid)
while True:
if self.eof(): break
b=self.u8()
if b==END_OF_NODE: break
name=None
if b in NAMED: name=self.string()
val=self.read_value(b)
if name is not None: node.fields[name]=val
else: node.items.append(val)
return node
def read_array(self):
length=self.i64()
items=[]
while True:
if self.eof(): break
b=self.peek()
if b==END_OF_ARRAY:
self.p+=1; break
self.p+=1
name=None
if b in NAMED: name=self.string()
items.append(self.read_value(b))
if len(items)>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])

68
recover_key.py Normal file
View File

@@ -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 <wbt> [<wbt> ...] # 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('<i', dec, 0)[0] != len(dec):
return False
return dec[5:16] == b'textureSize' and b'textureRawData' in dec[:64] and b'walker' in dec
if __name__ == '__main__':
files = sys.argv[1:] or sys.exit("usage: recover_key.py <wbt> ...")
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)")

126
render_wiki.py Normal file
View File

@@ -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, <code>, __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'<a class="wlink" href="#">{html.escape(lab)}</a>'
t = re.sub(r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]', lk, t)
t = re.sub(r"'''(.+?)'''", r'<strong>\1</strong>', t)
t = re.sub(r"''(.+?)''", r'<em>\1</em>', t)
return t # note: &times; / <code> 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'<h{lvl}>{inline(h.group(2))}</h{lvl}>')
i += 1; continue
if ln.startswith('{|'): # table
cls = 'wikitable'
if 'sortable' in ln: cls += ' sortable'
out.append(f'<table class="{cls}">')
i += 1
while i < len(lines) and not lines[i].startswith('|}'):
row = lines[i]
if row.startswith('!'):
cells = row[1:].split('!!')
out.append('<tr>' + ''.join(
f'<th>{inline(_celltext(c))}</th>' for c in cells) + '</tr>')
elif row.startswith('|-'):
pass
elif row.startswith('|'):
cells = row[1:].split('||')
out.append('<tr>' + ''.join(
f'<td>{inline(_celltext(c))}</td>' for c in cells) + '</tr>')
i += 1
out.append('</table>')
i += 1; continue
if ln.startswith('* '):
items = []
while i < len(lines) and lines[i].startswith('* '):
items.append(f'<li>{inline(lines[i][2:])}</li>'); i += 1
out.append('<ul>' + ''.join(items) + '</ul>'); continue
if ln.strip() == '':
i += 1; continue
out.append(f'<p>{inline(ln)}</p>')
i += 1
if cats:
out.append('<div class="catlinks"><strong>Categories</strong>: ' +
' | '.join(f'<a class="wlink" href="#">{html.escape(c)}</a>' for c in cats) +
'</div>')
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 = """<!doctype html><html><head><meta charset="utf-8">
<title>{title} - SAND Wiki (local test)</title>
<style>
body{{font-family:sans-serif;background:#f6f6f6;margin:0;color:#202122}}
.mw-body{{max-width:60em;margin:1em auto;background:#fff;border:1px solid #a2a9b1;padding:1.5em 2em}}
h1{{font-family:'Linux Libertine',Georgia,serif;font-weight:normal;border-bottom:1px solid #a2a9b1;padding-bottom:.2em;margin-top:0}}
h2{{font-family:'Linux Libertine',Georgia,serif;font-weight:normal;border-bottom:1px solid #a2a9b1;padding-bottom:.2em;margin-top:1.2em}}
table.wikitable{{background:#f8f9fa;border:1px solid #a2a9b1;border-collapse:collapse;margin:1em 0}}
table.wikitable>tr>th,table.wikitable>tr>td,table.wikitable th,table.wikitable td{{border:1px solid #a2a9b1;padding:.3em .6em}}
table.wikitable th{{background:#eaecf0;text-align:center}}
table.sortable th{{cursor:pointer}}
table.sortable th::after{{content:" \\2195";color:#888;font-size:.8em}}
a.wlink{{color:#0645ad;text-decoration:none}}
a.wlink:hover{{text-decoration:underline}}
.catlinks{{border:1px solid #a2a9b1;background:#f8f9fa;padding:.4em .8em;margin-top:1.5em;font-size:.9em}}
.note{{background:#fef6e7;border:1px solid #fc3;padding:.4em .8em;font-size:.85em;margin-bottom:1em}}
</style></head><body><div class="mw-body">
<div class="note">Local render for testing — not a live MediaWiki. Links are inert; sorting is basic JS.</div>
<h1>{title}</h1>
{body}
</div>
<script>
document.querySelectorAll('table.sortable').forEach(function(t){{
t.querySelectorAll('th').forEach(function(th,ci){{
th.addEventListener('click',function(){{
var rows=Array.from(t.querySelectorAll('tr')).slice(1);
var num=th.textContent.match(/Time/);
var dir=th._d=!th._d;
rows.sort(function(a,b){{
var x=a.cells[ci].textContent.trim(),y=b.cells[ci].textContent.trim();
if(num){{x=parseFloat(x)||0;y=parseFloat(y)||0;return dir?x-y:y-x;}}
return dir?x.localeCompare(y):y.localeCompare(x);
}});
rows.forEach(function(r){{t.appendChild(r);}});
}});
}});
}});
</script></body></html>"""
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}")

208
sand.py Executable file
View File

@@ -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 <wbt> -> write decoded bytes
snap [--all | <wbt>...] -> decode + save numbered snapshot per file
diff <before.dec> <after.dec> -> human-readable diff
check <wbt> -> one-shot: snap + diff against latest prior snap
watch <wbt> -> 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])<merge_gap and merged[-1][0]=='df':
merged[-1] = ('df', merged[-1][1], r[2])
elif merged and merged[-1][0]==r[0]:
merged[-1] = (r[0], merged[-1][1], r[2])
else:
merged.append(list(r))
return [tuple(r) for r in merged if r[0]=='df'], len(a), len(b)
def filter_noise(diffs, file_stem):
noise = KNOWN_NOISE.get('*', []) + KNOWN_NOISE.get(file_stem, [])
def overlaps(s, e):
return any(s < no+nn and e > 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()

81
unitybundle.py Normal file
View File

@@ -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<n:
tok=src[i]; i+=1
lit=tok>>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)

87
walker_hashes.py Normal file
View File

@@ -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<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'}")