PROCITY/pipeline/check_contract.py
m3ultra 8ad446a48a Lane E R41 (§41.1+§41.2): 46 clips and 110 fittings — and every handover number was the wrong unit
MOTION (§41.1). The clip library goes 8 -> 46: ten idles, eight browse, eight sit/lean,
eight social, six locomotion, six venue, in SIX grouped GLBs (one fetch each), 3.35 MB —
LESS than the 4.29 MB the old eight cost, via lossless dedup + int16 rotations (worst
error 0.0034 deg). All six verified skeleton-only (tris 0, meshes 0, nodes 66): ZERO DRAW,
which is what makes this round affordable. No retarget was run and none was wanted — the
bank and the peds are the same mixamorig skeleton, so retargeting would add error AND bake
the ped mesh into the clip, ending zero-draw. MIRPAMO 'make smoke' green to prove the tool,
then deliberately unused. Three seeds in the brief were duds and got substituted: the whole
*_Degree_Turn set is RIFLE-AIMING, and two 'examine' clips are 262 KB static poses, not
motion. R16 flat-body trap re-checked on all 46 (spine tilt 24 samples/clip, 0 frames >75
deg); turn_in_place auto-demoted from loopable on a 36.7 deg seam.

RULING 1 — the Bandai hazard, closed non-destructively and better than specified. 3,077 CC
BY-NC clips sat unzoned in a neutrally-named path. Renamed PER FILE (3,077/3,077) not just
the directory, because mirpamo names output <rig>@<clip>.glb — the _NC-research marker now
PROPAGATES into any retargeted GLB automatically. Nothing deleted; ultra's red/bandai
re-verified as canonical. Ledger corrected: the manifest claimed CC-BY-NC-ND, the bundled
licence text says CC BY-NC, no ND.

PROPS (§41.2). 110 assets from three libraries, published, sha1-verified, 0 validator errors
both modes. THE FINDING: every handover number was a TRIANGLE count, and triangles were
never the binding constraint — DRAW CALLS were. As handed over this cargo cost 14,140 draws
against a 162-draw margin; it ships at 117. '3dstore passes as-is' was true on tris/metres/
Draco/textures and FALSE on draws — 30 of 46 files carried up to 16 materials on one mesh
(one per record sleeve), so a 1,020-tri tub cost 16 draws; baked to COLOR_0, 288 -> 40, zero
tri drift, A/B identical. dj-gear's own manifest claimed median 6,288 tris / 18 under budget;
measured 51,528 and 12, with draws to 2,145. Pub props were NOT metre-correct (every source
unit-normalised to max dim 1.00 m) and 40x decimation was impossible as specified (loungeChair
is 94% non-manifold).

Ruling 5 vs the draw budget was a real conflict (one mixer = 4.6x a whole room); resolved by
joining SCENERY selectively while every control node keeps its object/name/pivot — verified
by parsing the SHIPPED GLB, not the tool that wrote it: PASS 4 / STATIC 10 / N/A 17 / FAIL 0.
deck_1200_rigged = 5 draws with Platter_SPIN, Arm_YAW, Fader_PITCH, Btn_STARTSTOP intact.

The R40 four-surface transmission gate FIRED ON LIVE CARGO: 5 genuinely transmissive
materials (cartridge dust-covers, a mixer meter window) that would each have doubled every
opaque draw in the room. 9 assets rejected on eyeball after decimation rather than shipping
mush. Two existing-tooling bugs fixed: normalize.py's yaw/up were silent no-ops (the jukebox
exported 0.40 m instead of 0.97 m), and footprints now measure the shipped GLB.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 18:10:12 +10:00

97 lines
4.0 KiB
Python

#!/usr/bin/env python3
"""Assert the DJ-gear named-node contract survives normalization — read from the GLB, not Blender.
python3 pipeline/check_contract.py SRC_DIR OUT_DIR MAP.json
python3 pipeline/check_contract.py --list FILE.glb [FILE.glb ...]
The contract 90sDJsim's GEAR driver reads (dj-gear/README.md) is five bare node names:
Platter_SPIN · Arm_YAW · Fader_PITCH · Fader_CROSS · Btn_STARTSTOP
Integrator ruling 5 exists because `gltf-transform optimize` silently deleted `Fader_CROSS` from a
696-node mixer. "Silently" is the operative word, so this check parses the **shipped GLB's** node
list rather than asking the tool that produced it — a scene-graph scan inside Blender still sees
control EMPTIES that were never exported, and would have reported a contract that the file does
not actually carry.
Verdict per model: PASS = every contract node present in the source is present in the output.
FAIL = one or more were lost (the ruling-5 failure mode).
N/A = the source carried none (a cartridge has no crossfader).
Exit 1 if any FAIL.
"""
import json, os, struct, sys
CONTRACT = ["Platter_SPIN", "Arm_YAW", "Fader_PITCH", "Fader_CROSS", "Btn_STARTSTOP"]
def glb_nodes(path):
"""Node names from a GLB's JSON chunk. Dependency-free — no Blender, no pygltflib."""
d = open(path, "rb").read()
if d[:4] != b"glTF":
raise ValueError("not a GLB")
off = 12
while off + 8 <= 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"))
return [n.get("name", "") for n in g.get("nodes", [])]
off += 8 + clen
raise ValueError("no JSON chunk")
def contract_of(path):
names = set(glb_nodes(path))
return [c for c in CONTRACT if c in names]
def main():
if "--list" in sys.argv:
for f in sys.argv[sys.argv.index("--list") + 1:]:
print(f"{os.path.basename(f):46} {','.join(contract_of(f)) or '-'}")
return 0
if len(sys.argv) < 4:
print(__doc__)
return 2
src_dir, out_dir, mapfile = sys.argv[1], sys.argv[2], sys.argv[3]
pairs = json.load(open(mapfile)) # [{"src":"TTM54i.glb","file":"procity_...glb","mode":...}]
rows, fails = [], 0
for p in pairs:
s = os.path.join(src_dir, p["src"])
o = os.path.join(out_dir, p["file"])
if not (os.path.isfile(s) and os.path.isfile(o)):
rows.append({**p, "verdict": "MISSING"})
fails += 1
continue
before, after = contract_of(s), contract_of(o)
if not before:
verdict = "N/A"
elif p.get("mode") == "static":
# a static dressing copy is one joined mesh BY DESIGN — it is not claimed to be
# drivable, so a missing contract here is expected, not a failure.
verdict = "STATIC (contract intentionally dropped)" if not after else "STATIC"
elif set(before) <= set(after):
verdict = "PASS"
else:
verdict = "FAIL"
fails += 1
rows.append({**p, "contract_before": before, "contract_after": after, "verdict": verdict})
w = max(len(r["file"]) for r in rows) if rows else 40
print(f"{'shipped file':{w}} {'mode':7} {'source contract':46} {'shipped contract':46} verdict")
print("-" * (w + 110))
for r in rows:
print(f"{r['file']:{w}} {r.get('mode',''):7} "
f"{','.join(r.get('contract_before', [])) or '-':46} "
f"{','.join(r.get('contract_after', [])) or '-':46} {r['verdict']}")
n_pass = sum(1 for r in rows if r["verdict"] == "PASS")
n_na = sum(1 for r in rows if r["verdict"] == "N/A")
n_st = sum(1 for r in rows if str(r["verdict"]).startswith("STATIC"))
print(f"\nPASS {n_pass} · STATIC {n_st} · N/A {n_na} · FAIL {fails}")
json.dump(rows, open(os.path.join(out_dir, "_contract_check.json"), "w"), indent=2)
return 1 if fails else 0
if __name__ == "__main__":
sys.exit(main())