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>
137 lines
5.3 KiB
Python
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())
|