Files
Papay-Shooter/.claude/skills/character-pipeline/references/verification.md
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

107 lines
4.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Verification — and the trap that invalidated all of it
## READ THIS FIRST
**Godot restores every bone's local pose after the `SkeletonModifier3D` pass.**
So calling `force_update_all_bone_transforms()` and reading
`get_bone_global_pose()` from a `SceneTree` script, from `_process`, or anywhere
outside that pass recomputes the globals from the **animation alone**. The
shooter pose layer and the cloth solver are simply not in what you measure.
`debug/cloth_clip_check.gd` did exactly this. It reported the same ~95 mm of
leg-inside-skirt with collision fully enabled **and with the collision call
commented out**. Every number ever taken from that tool before 2026-07-26 is
void, and several rounds of "tuning did nothing" in the history were reading a
pose the solver never touched.
**To measure a pose layer, add your own `SkeletonModifier3D` as a child of the
`Skeleton3D` AFTER the one you care about, and snapshot inside its
`_process_modification()`.** The `PoseProbe` class in `cloth_clip_check.gd` and
`travel_dir_check.gd` is the pattern.
Two related traps:
- **Headless runs uncapped**, so the engine delta is sub-millisecond and anything
integrated barely moves. Set `SpringBones.fixed_delta = 1.0/60.0`.
- **A single frame of a locomotion clip measures the clip.** A run cycle twists
the torso against the hips by tens of degrees twice per stride, swamping
anything a pose layer does. Average over a stride.
## The tools
| Tool | Measures | Good |
|---|---|---|
| `spawn_smoke_test.gd` | spawn, skins, anim tree, camera, state cycling | 29 OK, 0 failures |
| `cloth_clip_check.gd` | leg-inside-cloth per movement state, per vertex | idle < 25 mm |
| `cloth_settle_check.gd` | deg/frame at a dead idle, contacts/frame | skirt < 0.1, hair < 0.01 |
| `cloth_perf_check.gd` | ms per character per frame | ~2.6 ms |
| `cloth_allow_check.gd` | how much of each limb the rest-clearance cap makes the solver blind to | 1735 mm on Taila |
| `cloth_stretch_check.gd` | mesh tearing between panels | no 3× edges |
| `travel_dir_check.gd` | stride direction vs. travel direction | < 10° except a capped sidestep |
| `limb_deform_check.gd` | joint collapse | knee ~0.99 |
| `verify_character.py` | meshes, bones, weights of a SOURCE model | several meshes, cloth bones present |
| `anim_capture.gd` / `orbit_capture.gd` | renders, for looking | — |
Run them:
```bash
godot --headless --path . -s res://debug/<tool>.gd
godot --headless --path . -s res://debug/<tool>.gd -- res://assets/characters/skins/<name>.glb
```
Scripts run with `-s` MUST extend `SceneTree`. A `Node` script never quits and
hangs forever.
## Measure the right quantity
`cloth_clip_check.gd` used to report "how much CLOSER the leg got than the artist
modelled it". A hem 200 mm clear of a shin legitimately comes 180 mm closer when
the leg kicks out in a slide, and counting that as a failure buried the real
clipping under motion the character is supposed to have. It now reports how far
INSIDE a capsule a cloth vertex is, over and above however far inside it was
modelled — only cloth actually within the capsule can be showing a leg through.
It also applies the collider's `from` offset, so it tests the same band of thigh
the solver is defending. Measuring the full bone tests the hip cap the solver
deliberately excludes and reports it as clipping no tuning can fix.
## What the suite still does not check
It verifies that a character is WELL-FORMED, not that it is CORRECT. Those are
different properties, and only the first was ever asserted — which is how four
characters shipped "All checks passed" while lying on their backs, seven times
too large, facing backwards, or unable to hold a gun. See `failure-modes.md`.
`posture` and `bone roles reachable at runtime` are now hard checks. Still
missing, and worth adding when a source next exposes them: facing measured on the
OUTPUT, and per-vertex validation that a generated cloth chain actually tracks
the geometry it was given.
## Diagnosing "the solver isn't working"
In order:
1. **Is the measurement inside the modifier pass?** (Above. Do this first.)
2. **Does the solver SEE the contact?** `debug_hit_report()` — bone → deepest
overlap it found. If ~0 while the mesh is deep inside a leg, the collision
hull does not cover the geometry that is clipping.
3. **Does it CONVERGE?** `debug_residual_report()` — overlap left after the
relaxation. Seen 93 mm, left 95 mm is a standing fight, not slow convergence;
quadrupling the iterations will buy nothing. Find what is pulling back.
4. **Only then, tune.**
That order was learned the hard way: the drape, the bend limits, the backstop,
the iteration count and the hull sampling were each suspected and tested, and
the answer was in step 1.
## Also run
```bash
godot --headless --path . -s res://movement/tests/run_fsm_tests.gd # 11 tests
godot --headless --path . --check-only --script res://<file>.gd # syntax
```
Autoload identifiers report false "not found" errors under `--check-only`
ignore those.