PROCITY/pipeline/validate_manifest.py
m3ultra 12eb571357 Lane E R39 (6/n): THREE GLBs shipped as GLASS, not one — transmissionFactor 1 stripped, published, sha1-verified, and now GATED. Plus the arcade's three meshes.
Lane C measured the bookshelf's transmission halving interior draws (opshop/hall 191->104, book/hall
101->51, dept/hall 116->116 control). The cause, with vendored line numbers: three.module.js:16209
enters renderTransmissionPass(opaqueObjects, ...) whenever currentRenderList.transmissive is
non-empty, and :16433 re-renders the WHOLE OPAQUE LIST into a render target — every opaque draw
issued twice. Trigger is material.transmission > 0 (:6890), set from the glTF at GLTFLoader:1202.
And at 1.0 it is not 'a bit glassy': :494 does totalDiffuse = mix(totalDiffuse, transmitted.rgb,
material.transmission) — the diffuse term is REPLACED by the backdrop.

I SCANNED ALL 103 GLBs. Three carried it:
  procity_fit_bookshelf_01      mtl_10218_Bookshelves_v1     shipped and hurting (C's)
  procity_street_longbench_01   lambert3SG                   NOT WIRED — the 5.86 m arcade bench
                                                             the v9 charter names, on my R38
                                                             recommend list for Lane B
  procity_street_streetlight_01 Streetlight_HighResSG3       NOT WIRED — one per ~18 m of street
The two unwired ones are the worse finding: the street budget is 292/300 with EIGHT draws of margin,
and a pre-pass there costs another ~292, not eight. Armed, not fired.

FIXED SURGICALLY. pipeline/strip_transmission.py rewrites only the JSON chunk and copies BIN through,
printing sha1(BIN) before and after: BIN IDENTICAL on all three, tris/dims/mtl/img/tex unchanged.
KHR_materials_ior deliberately left (ior does not trigger the pre-pass).

PUBLISHED + VERIFIED under John's R22 standing authorization via the passwordless tailnet ingress:
6/6 HTTP 200 off the PUBLIC depot, byte counts exact, sha1 identical, depot copies re-scanned clean.
_published.json 56 -> 59.

GATED, which matters more than the fix: validate_manifest.py now HARD FAILS on transmissionFactor>0
with the three.js line numbers and the fix command in the message; glb_stat.py grew a transmission
column and a loud footer. Nobody looked for three epochs because a bookshelf is obviously not glass.

ALSO: the arcade's three meshes, published and catalogued the same round they were generated —
aframe_board 366 tris (<=16 inst), blade_sign 299 (17 inst), keycutter_sign 498 (1 inst). manifest
furniture 13->16, GLBs 36->39, validate 0 errors 0 warnings. blade_sign ships FLAGGED: its back face
carries TRELLIS-hallucinated art where the concept had no data, which is one more reason the
+0-draw perpendicular-quad answer is the recommendation and this GLB is the fallback.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 21:11:34 +10:00

217 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Validate web/assets/manifest.json — runs in Lane F's integration gate.
Checks: JSON parses; every referenced skin exists locally; every fitting/furniture GLB
exists locally (pipeline/_normalized) OR HEADs 200 on the depot; every thumb exists;
footprints & heights are sane; every registry shop type has >=2 facades.
python3 pipeline/validate_manifest.py # local + soft depot check
python3 pipeline/validate_manifest.py --depot # require GLBs live on the depot (post-publish)
Exit 0 = green, 1 = fail. Plain stdlib (no deps), so it runs anywhere.
"""
import json, os, sys, urllib.request, urllib.error
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
ASSETS = os.path.join(ROOT, "web", "assets")
NORM = os.path.join(ROOT, "pipeline", "_normalized")
MANIFEST = os.path.join(ASSETS, "manifest.json")
REGISTRY_TYPES = ["record", "opshop", "toy", "book", "video", "pawn", "milkbar", "dept", "stall"]
STRICT_DEPOT = "--depot" in sys.argv
errors, warnings = [], []
def err(m): errors.append(m)
def warn(m): warnings.append(m)
def head_ok(url):
# The depot contract documents GET only, not HEAD — probe with a 1-byte Range GET so we don't
# download the whole GLB and don't depend on HEAD being supported by the CDN/cache.
try:
# custom UA: Cloudflare 403s the default Python-urllib agent on the public path
req = urllib.request.Request(url, headers={"Range": "bytes=0-0",
"User-Agent": "procity-validator/1.0"})
with urllib.request.urlopen(req, timeout=12) as r:
return r.status in (200, 206)
except urllib.error.HTTPError as e:
if e.code == 416: # range not satisfiable but the file exists
return True
return False
except Exception:
return False
def check_skin(file):
p = os.path.join(ASSETS, file)
if not os.path.isfile(p):
err(f"skin missing: {file}")
def check_transmission(local_path, file):
"""[R39] HARD FAIL on KHR_materials_transmission > 0.
This is a draw-budget check wearing a material's clothes. three.js runs a transmission PRE-PASS
— `renderTransmissionPass(opaqueObjects, …)`, three.module.js:16433, entered at :16209 whenever
`currentRenderList.transmissive.length > 0` — which re-renders the entire OPAQUE list into a
render target, so EVERY draw in that scene is issued twice. Lane C measured the effect of one
such material (the bookshelf's): opshop/hall 191→104 draws, book/hall 101→51, against a control
room with no bookshelf at 116→116. Three GLBs shipped with `transmissionFactor: 1` because a
bookshelf, a park bench and a streetlight are obviously not glass and so nobody ever looked.
A gate is the only thing that makes "nobody looked" impossible. Fix: pipeline/strip_transmission.py
"""
if not os.path.isfile(local_path):
return # depot-only asset: nothing to parse here
try:
import struct
d = open(local_path, "rb").read()
if d[:4] != b"glTF":
return
off, g = 12, None
while off < len(d):
clen, ctype = struct.unpack("<II", d[off:off + 8])
if ctype == 0x4E4F534A:
g = json.loads(d[off + 8: off + 8 + clen].decode("utf-8"))
break
off += 8 + clen
for i, mat in enumerate(g.get("materials", []) if g else []):
t = (mat.get("extensions") or {}).get("KHR_materials_transmission")
if t and t.get("transmissionFactor", 0) > 0:
err(f"{file}: material[{i}] '{mat.get('name')}' has transmissionFactor "
f"{t['transmissionFactor']} — three.js will run a transmission PRE-PASS and "
f"issue every opaque draw in that scene TWICE. Run pipeline/strip_transmission.py")
except Exception as e: # a parse failure is not a licence to skip the check
warn(f"{file}: could not read materials for the transmission check ({str(e)[:60]})")
def check_glb(entry, depot):
file = entry["file"]
local = os.path.join(NORM, file)
on_disk = os.path.isfile(local)
check_transmission(local, file)
live = head_ok(f"{depot}/a/{file}")
if STRICT_DEPOT and not live:
err(f"GLB not live on depot: {file}")
elif not on_disk and not live:
err(f"GLB missing (not local, not on depot): {file}")
elif not live:
warn(f"GLB not yet published to depot (local only): {file}")
# thumb
thumb = entry.get("thumb")
if thumb and not os.path.isfile(os.path.join(ASSETS, thumb)):
err(f"thumb missing: {thumb}")
# footprint / height sanity
fp = entry.get("footprint")
if not (isinstance(fp, list) and len(fp) == 2 and all(0 < x < 12 for x in fp)):
err(f"insane footprint for {file}: {fp}")
h = entry.get("height")
if not (isinstance(h, (int, float)) and 0 < h < 8):
err(f"insane height for {file}: {h}")
def main():
try:
m = json.load(open(MANIFEST))
except Exception as e:
print(f"FAIL: manifest does not parse: {e}")
sys.exit(1)
# GOD3_DEPOT overrides for the direct tailnet path (same env publish.py uses)
depot = os.environ.get("GOD3_DEPOT", m.get("depot", "https://digalot.fyi/3god")).rstrip("/")
sk = m.get("skins", {})
# facades + type coverage
facade = sk.get("facade", {})
for k, v in facade.items():
check_skin(v["file"])
for t in REGISTRY_TYPES:
n = sum(1 for v in facade.values() if t in v.get("types", []))
if n < 2:
err(f"shop type '{t}' has only {n} facade(s) (need >=2)")
# other skin groups
for s in sk.get("sky", []):
check_skin(s["file"])
for g in sk.get("ground", {}).values():
check_skin(g["file"])
for w in sk.get("wall", []):
check_skin(w["file"])
interior = sk.get("interior", {})
for grp in ("floor", "surface"):
for it in interior.get(grp, []):
check_skin(it["file"])
for a in sk.get("awning", []):
check_skin(a["file"])
# fittings + furniture GLBs
n_glb = 0
manifest_files = set()
for grp in ("fittings", "furniture"):
for entry in m.get(grp, {}).values():
check_glb(entry, depot)
manifest_files.add(entry["file"])
n_glb += 1
# audio pack (round-11): if the manifest names an audio file it must ship locally (both the
# ogg primary and the m4a fallback). Silent-happy is a runtime rule, not a licence to dangle refs.
n_audio = 0
def _chk_audio(e):
for key in ("file", "fallback"):
if key in e:
check_skin(e[key])
for grp in m.get("audio", {}).values():
for v in grp.values():
if isinstance(v, dict) and "file" in v:
_chk_audio(v); n_audio += 1
elif isinstance(v, dict): # footstep {surface:[variants]}
for arr in v.values():
for e in arr:
_chk_audio(e); n_audio += 1
# provenance-drift gate: every manifest depot GLB must be recorded in _published.json, so a
# clobbered/stale provenance record (the R5 bug) fails QA loudly instead of hiding.
recpath = os.path.join(ROOT, "pipeline", "_published.json")
try:
record = set(json.load(open(recpath)))
except Exception as e:
err(f"_published.json unreadable: {e}")
record = set()
for f in sorted(manifest_files):
if f.startswith("procity_") and f not in record:
err(f"manifest GLB not in _published.json (provenance drift): {f}")
# pack-index QA (round-8 E2): a bad stock-pack bake fails the same gate as the manifest
try:
import validate_pack
if validate_pack.main() != 0:
err("stock-pack index validation failed (see pack-QA errors above)")
except Exception as e:
warn(f"pack-QA skipped: {e}")
# per-shop atlas QA (v5 G2a, ROUND23 E #4): Lane G's tier-1 atlases are gate-checked from the
# committed files alone — no dealgod DB, no network. No atlases yet ⇒ clean pass.
try:
import validate_atlas
if validate_atlas.main() != 0:
err("per-shop atlas validation failed (see atlas-QA errors above)")
except Exception as e:
warn(f"atlas-QA skipped: {e}")
print(f"manifest v{m.get('version')} — facades {len(facade)}, "
f"skins {sum(len(v) if isinstance(v, list) else (len(v) if isinstance(v, dict) else 0) for v in sk.values())} groups, "
f"GLBs {n_glb}, audio {n_audio}")
for w in warnings:
print(f" WARN {w}")
if errors:
for e in errors:
print(f" ERR {e}")
print(f"\nFAIL — {len(errors)} error(s), {len(warnings)} warning(s)")
sys.exit(1)
print(f"\nOK — 0 errors, {len(warnings)} warning(s)")
sys.exit(0)
if __name__ == "__main__":
main()