Organize the 16 loose scripts by concern:
walker/ -- .wbt save tooling (sand, build_wbt, walker_hashes,
harvest_hashes, recover_key)
wikigen/ -- MediaWiki page generators (make_*_wiki, render_wiki)
bundle/ -- Unity/Odin asset extraction (unitybundle, odin_read,
extract_*, loot_probe, dump_loot_bytes)
The only cross-script imports (build_wbt->walker_hashes,
extract_loot->odin_read) live within the same folder, so each
script's dir on sys.path[0] keeps them resolving with no code
changes. All data paths are absolute, so the moves don't affect
I/O. Named the code dir wikigen/ to avoid colliding with the
generated wiki/ output dir; ignore the regenerable wiki_site/ render.
74 lines
2.9 KiB
Python
74 lines
2.9 KiB
Python
#!/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()
|