refactor: group scripts into walker/ wikigen/ bundle/

Organize the 16 loose scripts by concern:
  walker/  -- .wbt save tooling (sand, build_wbt, walker_hashes,
              harvest_hashes, recover_key)
  wikigen/ -- MediaWiki page generators (make_*_wiki, render_wiki)
  bundle/  -- Unity/Odin asset extraction (unitybundle, odin_read,
              extract_*, loot_probe, dump_loot_bytes)

The only cross-script imports (build_wbt->walker_hashes,
extract_loot->odin_read) live within the same folder, so each
script's dir on sys.path[0] keeps them resolving with no code
changes. All data paths are absolute, so the moves don't affect
I/O. Named the code dir wikigen/ to avoid colliding with the
generated wiki/ output dir; ignore the regenerable wiki_site/ render.
This commit is contained in:
DownloadPizza
2026-06-11 14:49:33 +02:00
parent 2e886f31f0
commit a44e4db1c3
17 changed files with 3 additions and 0 deletions

73
bundle/extract_loot.py Normal file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Extract SAND loot/drop tables from the two Odin-binary LootTablesConfig assets.
Decodes conf_worldLootTablesStormConfig + conf_worldLootTablesVoyageConfig (Odin
SerializedFormat=0 Binary) via odin_read, flattens to a clean dict:
{ region: { lootTableId: [ {itemBlueprint, countMin, countMax, ...}, ... ] } }
Writes extracted/loot_tables.json (+ reports any unexpected fields / coverage).
"""
import os, sys, json
import odin_read
EX = "/home/downloadpizza/sand_tools/extracted"
REGIONS = {
"Storm": "conf_worldLootTablesStormConfig.odin.bin",
"Voyage": "conf_worldLootTablesVoyageConfig.odin.bin",
}
def odin_list(node):
"""Unwrap an Odin List<T> node -> python list of elements."""
if node is None: return []
if isinstance(node, list): return node
items = node.get("$items", [])
# List nodes serialize as one inner array: $items == [[...elements...]]
out = []
for chunk in items:
if isinstance(chunk, list): out.extend(chunk)
else: out.append(chunk)
return out
def main():
result = {}
extra_fields = set()
item_ids = set()
for region, fn in REGIONS.items():
path = os.path.join(EX, fn)
data = open(path, "rb").read()
parsed = odin_read.parse(data)
assert parsed["consumed"] == parsed["total"], f"{region}: incomplete parse"
tables = odin_list(parsed["roots"]["_lootTables"])
region_out = {}
for t in tables:
tid = t.get("lootTableId")
items = odin_list(t.get("items"))
rows = []
for it in items:
row = {k: v for k, v in it.items() if k != "$type"}
for k in row:
if k not in ("itemBlueprint", "countMin", "countMax"):
extra_fields.add(k)
if "itemBlueprint" in row:
item_ids.add(row["itemBlueprint"])
rows.append(row)
region_out[tid] = rows
result[region] = region_out
print(f"{region}: {len(region_out)} loot tables, "
f"{sum(len(v) for v in region_out.values())} drop rows")
out = os.path.join(EX, "loot_tables.json")
json.dump(result, open(out, "w"), indent=1, ensure_ascii=False)
print(f"\nwrote {out}")
print(f"unique item blueprints referenced: {len(item_ids)}")
if extra_fields:
print(f"NOTE extra (non count) fields present: {sorted(extra_fields)}")
# how many drop-table item ids are NOT in our authoritative item registry?
reg_path = os.path.join(EX, "items_registry.json")
if os.path.exists(reg_path):
reg = set(json.load(open(reg_path))["items"].keys())
unknown = sorted(i for i in item_ids if i not in reg)
print(f"item blueprints not in items_registry: {len(unknown)}")
for u in unknown[:40]:
print(" ", u)
if __name__ == "__main__":
main()