guts/pipeline/validate_manifest.py
jing dade036f69 [lane D] Round 1: pipeline scripts + assets.js loader
Ports PROCITY's pipeline shapes to GUTS (dry-run first, idempotent, resumable):
- gen_textures.py: flux_local wall/matcap pack, ART_BIBLE prompts, provenance JSON
- derive_maps.py: exact-tiling (4-way cosine partition-of-unity) + Sobel normals + WebP
- build_manifest.py / validate_manifest.py: catalogue-what-exists + qa hook
- gen_audio.py: procedural numpy synth, seamless loops, ogg+m4a dual-ship
- js/core/assets.js: manifest loader, null-on-miss, ?localassets=0 empty boot

Generation runs next on the m3ultra box.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 01:45:12 +10:00

137 lines
5.3 KiB
Python

#!/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("<HH", b[26:30])
return w & 0x3FFF, h & 0x3FFF
if fourcc == b"VP8L":
n = struct.unpack("<I", b[21:25])[0]
return (n & 0x3FFF) + 1, ((n >> 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())