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:
160
build_wbt.py
Normal file
160
build_wbt.py
Normal 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
47
dump_loot_bytes.py
Normal 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
89
extract_data.py
Normal 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
101
extract_i2.py
Normal 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
73
extract_loot.py
Normal 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
107
harvest_hashes.py
Normal 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
89
loot_probe.py
Normal 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
132
make_crafting_wiki.py
Normal 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 & 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("* <code>walker_compCrafting_Open_1x1</code> — '''S&H Armaments Workbench''' (1×1)")
|
||||||
|
out.append("* <code>walker_compCrafting_Small_Wood_1x1</code> — '''S&H Compact Armaments Workshop''' (1×1)")
|
||||||
|
out.append("* <code>walker_compCrafting_Wood_2x1</code> — '''S&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→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
75
make_items_wiki.py
Normal 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('|', '|').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
157
make_loot_wiki.py
Normal 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}–{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)} || <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–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
223
odin_read.py
Normal 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
68
recover_key.py
Normal 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
126
render_wiki.py
Normal 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: × / <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
208
sand.py
Executable 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
81
unitybundle.py
Normal 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
87
walker_hashes.py
Normal 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'}")
|
||||||
Reference in New Issue
Block a user