#!/usr/bin/env python3 """GUTS asset manifest builder (Lane D) — catalogues web/assets/** into manifest.json. The manifest is a CATALOGUE OF WHAT EXISTS, never a wishlist: it is generated by scanning the shipped files, so it can't drift from reality and can't promise a texture that isn't there. That is what makes the runtime law safe (TECH.md): every entry is optional, `assets.get()` returns null on miss, and the game boots fine with an empty assets/gen/. python3 pipeline/build_manifest.py # scan -> web/assets/manifest.json python3 pipeline/build_manifest.py --dry-run # print what it would write Stdlib only — runs anywhere, including in qa.sh. """ import json, os, sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ASSETS = os.path.join(ROOT, "web", "assets") GEN = os.path.join(ASSETS, "gen") AUDIO = os.path.join(ASSETS, "audio") MODELS = os.path.join(ASSETS, "models") OUT = os.path.join(ASSETS, "manifest.json") BATCH_TEX = os.path.join(ROOT, "pipeline", "batch_textures.json") BATCH_AUD = os.path.join(ROOT, "pipeline", "batch_audio.json") DEFAULT_TILE = [4, 16] # [repeats around theta, units of s per repeat] — see LANE_D_NOTES def ls(d, ext): if not os.path.isdir(d): return [] return sorted(f for f in os.listdir(d) if f.endswith(ext)) def build(): tex_meta = json.load(open(BATCH_TEX)) if os.path.exists(BATCH_TEX) else {} aud_meta = json.load(open(BATCH_AUD)) if os.path.exists(BATCH_AUD) else {} m = {"textures": {}, "matcaps": {}, "models": {}, "audio": {"beds": {}, "sfx": {}}} for f in ls(GEN, ".webp"): slug = f[:-5] if slug.endswith("_n"): continue # normals are attached to their albedo if slug.startswith("matcap_"): m["matcaps"][slug[len("matcap_"):]] = {"url": f"gen/{f}"} continue e = {"url": f"gen/{f}"} if os.path.exists(os.path.join(GEN, f"{slug}_n.webp")): e["normal"] = f"gen/{slug}_n.webp" e["tile"] = (tex_meta.get(slug) or {}).get("tile") or DEFAULT_TILE m["textures"][slug] = e for f in ls(MODELS, ".glb"): slug = f[:-4] e = {"url": f"models/{f}"} tris = (tex_meta.get(slug) or {}).get("tris") if tris: e["tris"] = tris m["models"][slug] = e for f in ls(AUDIO, ".ogg"): slug = f[:-4] meta = aud_meta.get(slug) or {} cat = "beds" if meta.get("category", "sfx") in ("ambience", "music", "bed") else "sfx" key = meta.get("key") or slug.split("-", 1)[-1] e = {"ogg": f"audio/{f}"} if os.path.exists(os.path.join(AUDIO, f"{slug}.m4a")): e["m4a"] = f"audio/{slug}.m4a" for k in ("loop", "gain", "seconds"): if k in meta: e[k] = meta[k] m["audio"][cat][key] = e return m if __name__ == "__main__": m = build() n = (len(m["textures"]), len(m["matcaps"]), len(m["models"]), len(m["audio"]["beds"]), len(m["audio"]["sfx"])) print(f"manifest: {n[0]} textures, {n[1]} matcaps, {n[2]} models, " f"{n[3]} beds, {n[4]} sfx") if "--dry-run" in sys.argv: print(json.dumps(m, indent=2, sort_keys=True)) sys.exit(0) os.makedirs(ASSETS, exist_ok=True) json.dump(m, open(OUT, "w"), indent=2, sort_keys=True) print(f"wrote {OUT}")