# Read-only structural gate for a derived body GLB: assert a variant is interchangeable with the # canonical body as far as the engine is concerned. # # python tools/verify_body_variant.py [--ref ] [--json] # # Exit 0 = pass, 2 = fail. Plain stdlib so it runs under system python (no numpy needed). # # What it is guarding against, all of which actually happened while building the nude variant: # * the material silently renamed to "MI_Body_Lena.001" because a donor import had reserved the # name — the game keys outfits off the material name, # * an attribute set drifting (a TANGENT appearing, a UV set vanishing), # * the skin losing joints or having them REORDERED — Godot binds outfit parts by joint name, # * the stray Icosphere node disappearing or multiplying, # * the texture ending up referenced by URI instead of embedded, so the GLB is no longer # self-contained, # * the mesh splitting into several primitives, which changes how the body is drawn. import sys, os, json, struct, argparse COMP_FMT = {5120: "b", 5121: "B", 5122: "h", 5123: "H", 5125: "I", 5126: "f"} NCOMP = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4, "MAT4": 16} # This tool lives in the ANIMATION repo (moved from ariki-game 2026-08-06: it authors # characters, and characters/REGISTRY.md governs its subjects). The canonical RIGGED body it # compares against still lives in the game repo, so that path is RESOLVED, never assumed: # $ARIKI_GAME wins, otherwise a sibling checkout is guessed and then verified. An # ariki-relative default that silently fails to resolve is a known trap in this repo — see # .agents/wiki/ARCHITECTURE.md on the retargeters' --target. REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) GAME = os.environ.get("ARIKI_GAME") or os.path.abspath(os.path.join(REPO, os.pardir, "ariki-game")) REF = os.path.join(GAME, "assets/quaternius/derived-bodies/Ariki_Female_QuatSkin.glb") def game_asset(path, flag): """Fail loudly on an unresolved cross-repo path instead of reading the wrong file.""" if not os.path.isfile(path): raise SystemExit( f"FATAL: cannot find the canonical body\n" f" looked in: {path}\n" f" That is an ariki-game asset. Pass {flag} explicitly, or set ARIKI_GAME\n" f" to your ariki-game checkout (the sibling-directory guess failed).") return path IBM_TOL = 1e-4 # inverse-bind matrices: roundtrip noise measured at 4.6e-6 HEIGHT_TOL = 0.01 # metres WEIGHT_TOL = 0.01 class Glb: def __init__(self, path): self.path = path with open(path, "rb") as f: blob = f.read() magic, _, _ = struct.unpack_from(" best[0]: best = (n, m, p) return best[1], best[2] def joint_names(self): if not self.d.get("skins"): return [] return [self.d["nodes"][j].get("name", f"<{j}>") for j in self.d["skins"][0]["joints"]] def facts(g): mesh, prim = g.body() pos = g.read(prim["attributes"]["POSITION"]) ys = [p[1] for p in pos] # glTF Y-up mat = g.d["materials"][prim["material"]] if "material" in prim else {} imgs = g.d.get("images", []) return { "mesh_name": mesh.get("name"), "n_meshes": len(g.d["meshes"]), "n_primitives": sum(len(m["primitives"]) for m in g.d["meshes"]), "attributes": sorted(prim["attributes"].keys()), "verts": len(pos), "tris": len(g.read(prim["indices"])) // 3, "height": round(max(ys) - min(ys), 4), "material": mat.get("name"), "n_skins": len(g.d.get("skins", [])), "joints": g.joint_names(), "node_names": sorted(n.get("name", "") for n in g.d["nodes"]), "images_embedded": all("uri" not in im for im in imgs), "n_images": len(imgs), "image_names": [im.get("name") for im in imgs], } def main(): ap = argparse.ArgumentParser() ap.add_argument("variant") ap.add_argument("--ref", default=REF) ap.add_argument("--json", action="store_true") a = ap.parse_args() v, r = Glb(a.variant), Glb(game_asset(a.ref, "--ref")) fv, fr = facts(v), facts(r) fails, warns = [], [] def need(cond, msg): (fails if not cond else warns if False else fails).append(msg) if not cond else None if fv["n_primitives"] != 1: fails.append(f"body must be 1 primitive, found {fv['n_primitives']}") if fv["attributes"] != fr["attributes"]: fails.append(f"attribute set {fv['attributes']} != reference {fr['attributes']}") if fv["material"] != fr["material"]: fails.append(f"material '{fv['material']}' != reference '{fr['material']}'") if fv["mesh_name"] != fr["mesh_name"]: fails.append(f"mesh name '{fv['mesh_name']}' != reference '{fr['mesh_name']}'") if fv["n_skins"] != 1: fails.append(f"expected exactly 1 skin, found {fv['n_skins']}") if fv["joints"] != fr["joints"]: if sorted(fv["joints"]) == sorted(fr["joints"]): fails.append("joints are REORDERED vs reference (same names, different order)") else: miss = set(fr["joints"]) - set(fv["joints"]) extra = set(fv["joints"]) - set(fr["joints"]) fails.append(f"joint list differs: {len(fv['joints'])} vs {len(fr['joints'])}" + (f", missing {sorted(miss)[:6]}" if miss else "") + (f", extra {sorted(extra)[:6]}" if extra else "")) if fv["node_names"] != fr["node_names"]: miss = set(fr["node_names"]) - set(fv["node_names"]) extra = set(fv["node_names"]) - set(fr["node_names"]) fails.append(f"node-name set differs (missing {sorted(miss)[:5]}, extra {sorted(extra)[:5]})") if not fv["images_embedded"]: fails.append("texture is referenced by URI, not embedded — GLB is not self-contained") if fv["n_images"] != fr["n_images"]: fails.append(f"{fv['n_images']} images, reference has {fr['n_images']}") if abs(fv["height"] - fr["height"]) > HEIGHT_TOL: fails.append(f"height {fv['height']} m vs reference {fr['height']} m") # inverse-bind matrices must be untouched: the rig has to keep binding identically if v.d.get("skins") and r.d.get("skins"): iv = v.d["skins"][0].get("inverseBindMatrices") ir = r.d["skins"][0].get("inverseBindMatrices") if iv is not None and ir is not None: mv, mr = v.read(iv), r.read(ir) if len(mv) != len(mr): fails.append(f"{len(mv)} inverse-bind matrices vs {len(mr)}") else: worst = max(abs(x - y) for a_, b_ in zip(mv, mr) for x, y in zip(a_, b_)) if worst > IBM_TOL: fails.append(f"inverse-bind matrices drifted by {worst:.2e} (> {IBM_TOL})") else: warns.append(f"inverse-bind max drift {worst:.2e} OK") # skin weights must still normalise mesh, prim = v.body() if "WEIGHTS_0" in prim["attributes"]: acc = v.d["accessors"][prim["attributes"]["WEIGHTS_0"]] rows = v.read(prim["attributes"]["WEIGHTS_0"]) norm = 1.0 if acc["componentType"] == 5121: norm = 255.0 elif acc["componentType"] == 5123: norm = 65535.0 bad = sum(1 for row in rows if abs(sum(row) / norm - 1.0) > WEIGHT_TOL) if bad: fails.append(f"{bad} vertices have skin weights not summing to 1") else: warns.append(f"skin weights normalised across {len(rows)} verts") if a.json: print(json.dumps({"variant": fv, "reference": fr, "fails": fails, "notes": warns}, indent=2)) else: print(f"variant {os.path.basename(a.variant)}") print(f"reference {os.path.basename(a.ref)}") for k in ("mesh_name", "n_primitives", "attributes", "verts", "tris", "height", "material", "n_skins", "n_images", "image_names", "images_embedded"): same = " " if fv[k] == fr[k] else " *" print(f"{same} {k:16s} {fv[k]}" + ("" if fv[k] == fr[k] else f" (ref: {fr[k]})")) print(f" joints {len(fv['joints'])}" f" {'(identical order)' if fv['joints'] == fr['joints'] else '(DIFFERS)'}") for w in warns: print(f" note: {w}") for f in fails: print(f" FAIL: {f}") print("PASS" if not fails else f"FAIL ({len(fails)} problem(s))") return 0 if not fails else 2 sys.exit(main())