#!/usr/bin/env python3 """GUTS manifest validator (Lane D) — the qa.sh hook. Exit 0 = green, 1 = errors. Enforces the house laws that a manifest can violate (TECH.md): * every referenced url actually exists on disk (a manifest that lies is worse than none) * WebP dimensions <= 1024, matcaps square * every wall texture carries a sane `tile` hint (Lane A reads it) * audio ships OGG **and** M4A (Safari has no Opus-in-ogg) and stays under the 10 MB budget * no orphan files in assets/gen (harvested but uncatalogued => invisible to the game) Stdlib ONLY — no numpy, no PIL. qa.sh runs on any box, including ones with a bare python3, so the WebP header is parsed by hand below. python3 pipeline/validate_manifest.py [--quiet] """ import json, os, struct, sys ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) ASSETS = os.path.join(ROOT, "web", "assets") MANIFEST = os.path.join(ASSETS, "manifest.json") AUDIO_BUDGET_MB = 10.0 MAX_DIM = 1024 errs, warns = [], [] def webp_size(path): """(w, h) from a WebP header — VP8 (lossy) / VP8L (lossless) / VP8X (extended). Returns None if the fourcc is unrecognised rather than guessing.""" with open(path, "rb") as f: b = f.read(32) if len(b) < 30 or b[:4] != b"RIFF" or b[8:12] != b"WEBP": return None fourcc = b[12:16] if fourcc == b"VP8 ": # frame tag (3B) + start code 9d 01 2a (3B) then 16-bit w,h with 2 scale bits on top w, h = struct.unpack("> 14) & 0x3FFF) + 1 if fourcc == b"VP8X": w = b[24] | (b[25] << 8) | (b[26] << 16) h = b[27] | (b[28] << 8) | (b[29] << 16) return w + 1, h + 1 return None def check_file(rel, label): p = os.path.join(ASSETS, rel) if not os.path.exists(p): errs.append(f"{label}: missing file {rel}") return None return p def main(): quiet = "--quiet" in sys.argv if not os.path.exists(MANIFEST): # An absent manifest is LEGAL: the game must boot asset-free. Say so and pass. print("validate_manifest: no manifest.json — asset-free boot (legal, TECH.md §assets)") return 0 try: m = json.load(open(MANIFEST)) except Exception as e: print(f"validate_manifest: manifest.json does not parse: {e}") return 1 seen = set() for name, e in (m.get("textures") or {}).items(): p = check_file(e.get("url", ""), f"texture {name}") if p: seen.add(os.path.basename(p)) wh = webp_size(p) if wh and max(wh) > MAX_DIM: errs.append(f"texture {name}: {wh[0]}x{wh[1]} exceeds {MAX_DIM} (house law)") if "normal" in e: np_ = check_file(e["normal"], f"texture {name} normal") if np_: seen.add(os.path.basename(np_)) t = e.get("tile") if not (isinstance(t, list) and len(t) == 2 and all(isinstance(v, (int, float)) and v > 0 for v in t)): errs.append(f"texture {name}: bad tile {t!r} — want [repeats_theta, units_per_s]") for name, e in (m.get("matcaps") or {}).items(): p = check_file(e.get("url", ""), f"matcap {name}") if p: seen.add(os.path.basename(p)) wh = webp_size(p) if wh and wh[0] != wh[1]: errs.append(f"matcap {name}: {wh[0]}x{wh[1]} is not square") if wh and max(wh) > MAX_DIM: errs.append(f"matcap {name}: {wh[0]}x{wh[1]} exceeds {MAX_DIM}") for name, e in (m.get("models") or {}).items(): check_file(e.get("url", ""), f"model {name}") tris = e.get("tris") if tris and tris > 5000: errs.append(f"model {name}: {tris} tris exceeds house law 5000 (decimate)") if not tris: warns.append(f"model {name}: no tris count recorded (run glb_stat)") audio_bytes = 0 for cat in ("beds", "sfx"): for name, e in ((m.get("audio") or {}).get(cat) or {}).items(): for fmt in ("ogg", "m4a"): if fmt not in e: errs.append(f"audio {cat}/{name}: no {fmt} (dual-ship law: Safari needs m4a)") continue p = check_file(e[fmt], f"audio {cat}/{name}") if p: audio_bytes += os.path.getsize(p) if audio_bytes / 1e6 > AUDIO_BUDGET_MB: errs.append(f"audio total {audio_bytes/1e6:.1f}MB exceeds {AUDIO_BUDGET_MB}MB budget") gendir = os.path.join(ASSETS, "gen") if os.path.isdir(gendir): for f in sorted(os.listdir(gendir)): if f.endswith(".webp") and f not in seen: warns.append(f"orphan {f} in gen/ — harvested but not in manifest " f"(re-run build_manifest.py)") if not quiet: n = m.get("textures", {}), m.get("matcaps", {}), (m.get("audio") or {}) print(f"validate_manifest: {len(n[0])} textures, {len(n[1])} matcaps, " f"{len(n[2].get('beds', {}))} beds, {len(n[2].get('sfx', {}))} sfx, " f"audio {audio_bytes/1e6:.2f}/{AUDIO_BUDGET_MB}MB") for w in warns: print(f" warn: {w}") for e in errs: print(f" ERROR: {e}") return 1 if errs else 0 if __name__ == "__main__": sys.exit(main())