Files
Papay-Shooter/tools/verify_character.py
T
Nicholas ButzkeandClaude Opus 5 2b3e29dd30 fix(pipeline): check that a character is CORRECT, not merely well-formed
Seven characters shipped "All checks passed" and four were visibly broken
in game — lying on their backs at seven times scale, facing backwards, or
holding a gun that floated near their chest. Nothing in the suite was
wrong; it just never asked the questions that mattered. That distinction
is the whole lesson, and it is now written down in the skill as
references/failure-modes.md, generalised per failure.

Two root causes are measured and certain:

- flatten_and_scale() normalises the bounding box along Blender Z because
  Z is up. For a model that arrives lying along Y that measures the
  character's THICKNESS, so it scales by ~7 and leaves them on their back.
  One assumption, both symptoms. The trap is that the normalised number
  always comes out right — the exporter maps Blender Z to glTF Y, so "is
  the height 1.75" passes on a character who is 7.5 m tall lying down.
  All three casualties are VRM files that went through a Blender
  round-trip and came back with a baked axis rotation.

- SkinnedPlayerModel.set_weapon() finds the hand with three hardcoded
  spellings, which match none of the four non-Rigify rigs — their hands
  resolve perfectly in the sidecar as "Right wrist" and "J_Bip_R_Hand".
  When it misses, the weapon is parented to the model root at a fixed
  chest offset, so it is not attached to the character at all. Same class
  of bug as the leg check that name-matched thigh/shin. rig_map.py exists
  so nothing downstream has to guess a bone name; only some consumers read
  the roles it publishes.

Three new hard checks, none needing more than the vertices and the
sidecar:

  character stands up in world space   — against WORLD up, not against the
      model's own proportions. "Is the spine the longest axis?" catches
      nothing: a model rotated as a whole is internally consistent and
      passes it comfortably.
  character is a plausible size / height
  every role the runtime needs is resolved

They separate the four good characters from the three broken ones on the
first run. Also fixed the posture measurement to read vertices rather than
object.bound_box, which is cached and still stale right after an import —
it reported a 1.75 m character as 1.18 m tall.

Recorded but not yet fixed: kiyoko faces backwards (facing is inferred by
two independent mechanisms and verified by neither), miku's grown hair
chains stretch under animation (generated chains are never validated
against the geometry they drive), and the mannequin's rifle hold does not
convince despite resolving correctly.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-26 16:02:06 -04:00

349 lines
15 KiB
Python

#!/usr/bin/env python3
"""
Check a built character GLB against the things that have actually gone wrong.
Every assertion here corresponds to a real defect this project shipped, so a
green run means those specific failures are gone rather than that the file
merely loads:
CROSS-LEG BLEED A vertex pulled by both legs sits between them and stays
there while they separate, stretching every triangle around
it. The old nearest-bone rebind left 2817 of these on
Taila, worst at a dead 50/50 — the boots and thighs that
characters/skin_leg_repair.gd was written to patch at
runtime. Authored weights have none.
INFLUENCE SPREAD 86% of vertices carrying the full four influences is the
signature of K=4 Euclidean weighting, not of an artist.
Real weights are mostly one or two bones.
PART SPLIT One joined mesh means body, cloth and hair deform under one
rule. Separate meshes per material is what lets a thigh
stay solid while a skirt drapes.
ORPHAN CHAINS A Rigify DEF-rig exports its chain roots on the armature
root. If the rebuild missed one, that limb or strand floats
in place while the body moves.
CLIP MOTION A retarget that silently fails produces clips that exist
but never move — the rest-pose statue this pipeline has
produced before. Every clip must actually rotate the hips
and the legs.
CLOTH IS FREE Cloth bones must carry no keys, or the clips would fight
the spring solver for them.
Usage:
blender --background --python tools/verify_character.py -- <character.glb> [rig.json]
Exits non-zero if any check fails, so it can gate the pipeline.
"""
import bpy
import json
import os
import sys
from collections import defaultdict
from mathutils import Vector
argv = sys.argv
argv = argv[argv.index("--") + 1:] if "--" in argv else []
if not argv:
print(__doc__)
sys.exit(1)
PATH = argv[0]
SIDECAR = argv[1] if len(argv) > 1 else os.path.splitext(PATH)[0] + ".rig.json"
LEG_HINTS = ("thigh", "shin", "foot", "toe", "upleg", "calf")
# Resolved bone roles from the sidecar — filled in below. Every check that needs
# to know "which bones are the legs" asks THIS, not the name hints.
#
# The hints are a fallback for a build with no sidecar, and they are only ever
# right for rigs that happen to spell things the way the library does. A VRoid
# rig calls its thigh J_Bip_L_UpperLeg: no hint matches it, so the cross-leg
# bleed check found no leg vertex groups at all and PASSED vacuously, and the
# leg-motion check declared every locomotion clip static while the legs were in
# fact animating perfectly. Two green-looking lies from one missing lookup.
ROLE_LEGS = {"L": set(), "R": set()}
failures = []
warnings = []
# Set from the sidecar. A model whose source had no skeleton at all has to go
# through autorig, and its weights are then a nearest-bone fit by construction —
# there is no better result to demand. The deformation checks still RUN and
# still print, so the cost is visible, but they cannot fail a build that had no
# alternative; SkinLegRepair covers those models at load time instead.
weights_authored = True
def check(ok, label, detail="", needs_authored_weights=False, advisory=False):
soft = advisory or (needs_authored_weights and not weights_authored)
tag = "PASS" if ok else ("WARN" if soft else "FAIL")
print(f" [{tag}] {label}" + (f" — {detail}" if detail else ""))
if ok:
return
if soft:
warnings.append(f"{label}{detail}")
else:
failures.append(label)
def side_of(name):
if name in ROLE_LEGS["L"]:
return -1
if name in ROLE_LEGS["R"]:
return 1
n = name.lower()
if not any(h in n for h in LEG_HINTS):
return 0
if n.endswith(".l") or ".l." in n or "left" in n:
return -1
if n.endswith(".r") or ".r." in n or "right" in n:
return 1
return 0
SIDECAR_INFO = {}
if os.path.exists(SIDECAR):
with open(SIDECAR, "r", encoding="utf-8") as f:
SIDECAR_INFO = json.load(f)
weights_authored = bool(SIDECAR_INFO.get("weights_authored", True))
_roles = SIDECAR_INFO.get("roles", {})
for _r in ("thigh", "shin", "foot", "toe"):
for _s in ("L", "R"):
_b = _roles.get(f"{_r}.{_s}")
if _b:
ROLE_LEGS[_s].add(_b)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
bpy.ops.import_scene.gltf(filepath=PATH)
arms = [o for o in bpy.data.objects if o.type == "ARMATURE"]
if not arms:
print("FAIL: no armature")
sys.exit(1)
arm = max(arms, key=lambda a: len(a.data.bones))
meshes = [o for o in bpy.data.objects if o.type == "MESH" and o.vertex_groups]
print(f"\n=== {os.path.basename(PATH)}{len(arm.data.bones)} bones, "
f"{len(meshes)} meshes, {sum(len(m.data.vertices) for m in meshes)} verts ===\n")
# ---------------------------------------------------------------- deformation
total_bleed = 0
worst_bleed = 0.0
infl = defaultdict(int)
for m in meshes:
gside = {g.index: side_of(g.name) for g in m.vertex_groups}
for v in m.data.vertices:
wl = wr = 0.0
n = 0
for g in v.groups:
if g.weight <= 0.005:
continue
n += 1
s = gside.get(g.group, 0)
if s == -1:
wl += g.weight
elif s == 1:
wr += g.weight
infl[n] += 1
if wl > 0.005 and wr > 0.005:
total_bleed += 1
worst_bleed = max(worst_bleed, min(wl, wr) / (wl + wr))
nverts = sum(infl.values()) or 1
four = infl.get(4, 0) / nverts
# Not zero: a skirt hem genuinely spans both legs, and Taila's artist left 17
# such vertices (0.1%) on purpose. The failure mode being caught is the SOLVER
# signature — the nearest-bone rebind put 16% of the model in this state.
bleed_frac = total_bleed / nverts
check(bleed_frac < 0.005, "cross-leg blending is limited to draping cloth",
f"{total_bleed} verts ({bleed_frac * 100:.1f}%), worst minority share {worst_bleed:.2f}",
needs_authored_weights=True)
check(four < 0.5, "influences look authored, not solved",
f"{four * 100:.0f}% of verts carry 4 influences; spread {dict(sorted(infl.items()))}",
needs_authored_weights=True)
# ADVISORY, not a gate. Several meshes is what we want — it is how body, cloth
# and hair stay separable for materials, for the outline pass and for the cloth
# solver's hull extraction — but the OUTPUT cannot tell "the pipeline joined
# them" from "the artist authored one mesh". Quaternius' mannequin is a single
# mesh on purpose and was failing a check about damage that had not happened.
#
# The join path leaves two signatures that ARE unambiguous, and both are hard
# checks above: cross-leg weight bleed, and the 4-influences-everywhere spread
# of a nearest-bone rebind. Those catch what this was standing in for.
check(len(meshes) > 1, "model keeps its per-part meshes",
f"{len(meshes)} mesh{'es' if len(meshes) != 1 else ''}"
+ (" — fine for a single-piece model; a costume should be several"
if len(meshes) == 1 else ""),
advisory=True)
# ------------------------------------------------------------------- posture
#
# Is this character the right size and the right way up?
#
# Nothing asked before, and it is the single cheapest check in the file. The
# scale step normalises the bounding box along Blender's Z because Z is up — for
# a model that arrives lying along Y that measures the character's THICKNESS, so
# it gets scaled by ~7 and left on its back. Three of seven shipped that way.
#
# The trap is that the normalised number always comes out right: the export maps
# Blender Z to glTF Y, so "is the height 1.75" passes on a character who is
# 7.5 m tall lying down. The real question is whether the axis that was
# normalised is the one the SKELETON is tall along.
# From the VERTICES, not from object.bound_box — that is cached and is still
# stale immediately after an import, which quietly reported a 1.75 m character
# as 1.18 m tall.
lo = [1e9] * 3
hi = [-1e9] * 3
for m in meshes:
mw = m.matrix_world
for v in m.data.vertices:
p = mw @ v.co
for k in range(3):
lo[k] = min(lo[k], p[k])
hi[k] = max(hi[k], p[k])
ext = [hi[k] - lo[k] for k in range(3)]
up_axis = None
_hips = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("hips", ""))
_head = arm.data.bones.get(SIDECAR_INFO.get("roles", {}).get("head", ""))
if _hips and _head:
d = [abs(_head.head_local[k] - _hips.head_local[k]) for k in range(3)]
up_axis = d.index(max(d))
if up_axis is not None and max(ext) > 1e-4:
tall = ext.index(max(ext))
others = [ext[k] for k in range(3) if k != up_axis]
detail = ("%.2f m tall along %s, %.2f x %.2f m across"
% (ext[up_axis], "XYZ"[up_axis], others[0], others[1]))
# Against WORLD up (Blender Z), not against the widest axis. A model that has
# been rotated as a whole is internally consistent — its spine IS its longest
# axis — so comparing the two agrees with itself and catches nothing. The
# question is whether the character stands up in the world the game runs in.
check(up_axis == 2, "character stands up in world space",
detail + ("" if up_axis == 2 else
" — the spine runs along %s, not Z, so the character is "
"lying down; whatever was normalised to the target height "
"was not their height" % "XYZ"[up_axis]))
check(max(others) < 2.5, "character is a plausible size", detail)
check(1.2 < ext[up_axis] < 2.4, "character is a plausible height", detail)
# --------------------------------------------------- reachable from the runtime
#
# The game looks these up to attach a weapon and to drive the pose layer. It used
# to do it with hardcoded spellings, so four characters whose roles resolved
# perfectly still could not hold a gun. The roles are the contract; if one the
# runtime needs is missing from the sidecar, the character will be subtly broken
# in a way no other check here can see.
_roles = SIDECAR_INFO.get("roles", {})
if _roles:
needed = ["hips", "head", "hand.R", "hand.L", "upper_arm.R", "upper_arm.L",
"forearm.R", "forearm.L", "thigh.L", "thigh.R", "shin.L", "shin.R"]
absent = [r for r in needed
if not _roles.get(r) or _roles[r] not in arm.data.bones]
check(not absent, "every role the runtime needs is resolved",
"missing or unresolvable: %s" % absent)
# ------------------------------------------------------------------- skeleton
def is_rootish(b):
return b is None or any(t in b.name.lower()
for t in ("root", "master", "armature"))
orphans = [b.name for b in arm.data.bones
if is_rootish(b.parent) and not is_rootish(b)]
check(len(orphans) <= 1, "every chain is attached to the body",
f"{len(orphans)} bones still on the armature root: {orphans[:6]}")
# ----------------------------------------------------------------------- clips
actions = {a.name: a for a in bpy.data.actions}
print(f"\n {len(actions)} clips: {', '.join(sorted(actions))}\n")
check(len(actions) >= 10, "the canonical clip set shipped", f"{len(actions)} clips")
def curves(action):
legacy = getattr(action, "fcurves", None)
if legacy is not None:
return list(legacy)
out = []
for layer in getattr(action, "layers", []):
for strip in layer.strips:
for cbag in getattr(strip, "channelbags", []):
out.extend(cbag.fcurves)
return out
def bone_of(path):
if 'pose.bones["' not in path:
return None
s = path.index('"') + 1
return path[s:path.index('"', s)]
cloth = set()
if os.path.exists(SIDECAR):
with open(SIDECAR, "r", encoding="utf-8") as f:
info = json.load(f)
for c in info.get("chains", []):
cloth.update(c["bones"])
print(f" sidecar: {len(info.get('chains', []))} cloth chains "
f"({len(cloth)} bones), {len(info.get('twist', []))} twist bones")
# A chain whose bones have no measurable extent is DEAD: the runtime drops
# any bone whose tip is shorter than a millimetre, so such a chain simulates
# nothing and the costume is welded solid — silently, because the sidecar
# still reports it. hikari shipped ten of them; her cosmetic bones are
# zero-length terminators from a rig that had been through two toolchains.
dead = 0
for c in info.get("chains", []):
ext = max((sum(v * v for v in t) ** 0.5 for t in c.get("tips", [])),
default=0.0)
if ext < 0.001:
dead += 1
if info.get("chains"):
check(dead == 0, "cloth chains have measurable extent",
f"{dead} of {len(info['chains'])} chains are zero-length and will "
f"not simulate", advisory=True)
else:
warnings.append(f"no sidecar at {SIDECAR}")
# Clips whose legs MUST move. The library's Pistol_Idle_Loop and Pistol_Shoot
# are upper-body clips with genuinely static legs, so demanding leg motion from
# every clip fails on a correct build.
LOCOMOTION = {"Idle", "Walk", "Run", "Sprint", "Jump", "Fall", "Land",
"CrouchIdle", "CrouchWalk", "Dash"}
# The thighs and shins by ROLE, so this works on any rig's spelling.
leg_bones = {b for s in ROLE_LEGS.values() for b in s}
if not leg_bones:
leg_bones = {b.name for b in arm.data.bones
if any(h in b.name.lower() for h in ("thigh", "shin"))}
frozen = []
legless = []
keyed_cloth = set()
for name, action in sorted(actions.items()):
moved = defaultdict(float)
for fc in curves(action):
b = bone_of(fc.data_path)
if not b or len(fc.keyframe_points) < 2:
continue
if b in cloth:
keyed_cloth.add(b)
vals = [kp.co.y for kp in fc.keyframe_points]
moved[b] = max(moved[b], max(vals) - min(vals))
if max(moved.values(), default=0.0) < 0.005:
frozen.append(name)
legs = max((v for b, v in moved.items() if b in leg_bones), default=0.0)
if name in LOCOMOTION and legs < 0.01:
legless.append(f"{name}({legs:.4f})")
check(not frozen, "no clip retargeted to a frozen rest pose",
f"frozen: {', '.join(frozen)}" if frozen else "")
check(not legless, "locomotion clips animate the legs",
f"static legs: {', '.join(legless)}" if legless else "")
check(not keyed_cloth, "cloth bones carry no animation keys",
f"{len(keyed_cloth)} keyed: {sorted(keyed_cloth)[:5]}")
print()
for w in warnings:
print(f" [WARN] {w}")
if failures:
print(f"\n{len(failures)} CHECK(S) FAILED: {failures}\n")
sys.exit(1)
print("\nAll checks passed.\n")