Files
Papay-Shooter/.claude/skills/character-pipeline/references/verification.md
T
Nicholas ButzkeandClaude Opus 5 b1c8bab714 feat(rig lab): drag the anchors themselves, not just the gun under them
The lab could move the GUN and not the anchor points the hands are solved onto.
`grip_offset` slides the weapon around inside the fist; `gun_fore` and
`gun_stock` are distances ALONG the barrel, so the trigger and support hands
could travel up and down the weapon's own axis and nowhere else. Nothing could
take a hand off that axis, which is what a handguard below the bore, an angled
foregrip, or a pistol whose grip is nowhere near its barrel line all need.

Two things fix that.

`grip_shift` and `fore_shift` give the two hand anchors real three-dimensional
freedom, expressed in the GUN's own across/up/along frame so a sideways nudge
stays sideways as the weapon pitches between low ready and ADS. Zero is exactly
the old behaviour. Their z overlaps the along-axis distances, which is redundant
and deliberate: keeping those separate is what lets the reach solver slide the
support hand back down the handguard without undoing a considered sideways
offset.

And the markers are now draggable. They already showed the anchors; now they
are handles. The one under the mouse swells and draws through the body — depth
testing is right for judging whether a hand reached its target and wrong for a
handle, because at any useful framing the hands occlude all three.

Verified three ways, and each one had to be rebuilt once:

  anchor_shift_check first compared absolute positions and reported a 3.5 mm
  error that was the character BREATHING — there is a sin() on the muzzle pitch,
  so no anchor is ever in the same place twice. Measuring each anchor relative
  to the one it hangs off, rotated into the current gun basis, cancels the
  breathing, the ADS blend and the recoil exactly. 48 checks, six characters,
  both poses.

  anchor_drag_check asserts the drag writes the knob the MOUSE asked for,
  derived independently from the camera: 0.00-0.01 mm on all three. It does not
  assert the marker lands under the cursor, because it does not — the anchors
  hang off the shoulder and the arm chasing them moves the shoulder, so a drag
  settles at 0.77x-1.13x. Small enough to ignore interactively.

  That feedback first read as 1.5x-1.8x, because the cases were compounding on
  each other, and waiting LONGER for the pose to settle made it worse rather
  than better — which is the opposite of how a settling error behaves and is
  what gave it away.

The buttstock case also failed for a while on a bug entirely in the test: it
read an absent knob as zero when `pocket_hip` defaults to (30, -70, 60) mm. The
lab has a note about that trap in `_reset`. It is just as easy to walk into from
a test, and now has one there too.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-07-28 01:05:37 -04:00

8.5 KiB
Raw Blame History

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
surface_class_check.gd every surface resolves from the sidecar, not the fallback 0 fallbacks on all six skins
character_picker_check.gd the escape-menu roster: skeleton, clips, surfaces, and that the pose MOVES 0 failures
rig_anchor_check.gd a grip anchor physically moves the weapon, and clears 0 failures
anchor_shift_check.gd the hand anchors move in the GUN's frame, both poses 0 failures
anchor_drag_check.gd dragging a marker writes the knob the mouse asked for 0 failures
anim_capture.gd / orbit_capture.gd renders, for looking
roster_capture.gd one photo of every character, from the picker
ui_capture.gd one photo of every menu screen
rest_pose_check.gd each rig's bind-pose limb directions vs. the library's see below

What rest_pose_check actually established

It was written to test a suspicion — that the rest-relative retarget silently assumes both rigs rest alike — and it disproved it. Miku's arms rest 41° off the animation library's and Taila's 32°, and both animate correctly. The delta retarget handles a rest-pose difference, which is what it is for. Do not go looking there again.

It also demonstrates the measurement trap in miniature. Written as "the direction from a bone to its FIRST CHILD", it reported kiyoko's and aria's legs 71° off — because a thigh's first child is as likely to be a skirt bone as a shin, and it was measuring the hang of a skirt panel. Pointing it at the next limb BY ROLE dropped both to 1°. The same rule as everywhere else in this pipeline: resolve roles, never take whatever the rig happens to hand you.

Assert the consequence, not the plumbing

Three of these exist because the obvious check passes on a broken system.

  • character_picker_check asserts the skeleton's pose CHANGES over a dozen frames. Asking the model which clip it is playing does not work: that is a variable the class sets on itself, and it reads "Idle" just as happily when the animation tree is not ticking at all.
  • rig_anchor_check asserts the weapon MOVES by the offset asked for. An anchor system is easy to build so that the sliders move, the file saves and the JSON round-trips while the gun does not budge — the value read into a variable nobody consumed. It measures in the attachment's frame, not the world's: the attachment tracks a bone on an animating skeleton, so a world-space delta is mostly the idle animation.
  • anchor_shift_check and anchor_drag_check both measure in the GUN's frame rather than the world's, and have to. The hold BREATHES — a sin(_time * 2.2) * 0.012 on the muzzle pitch — so no anchor is ever at the same world position twice, and comparing absolute positions reported a 3.5 mm error that was the character inhaling. Taking each anchor relative to the one it hangs off and rotating into the current gun basis cancels the breathing, the ADS blend and the recoil kick exactly, because all three move the basis and the anchor together.
  • surface_class_check FAILS on a surface that falls through to the heuristic instead of resolving from the table. A model whose names stopped matching still renders — the fallback catches it — and quietly loses its per-class art direction. Nothing else would report that.

And four of the last five real defects came from LOOKING, not from asserting: a preview showing the back of the character's head, a turntable that carried on from the previous character, an unstyled list, and momo's idle pose. Every one passed every assertion. Run roster_capture and ui_capture and open the PNGs.

Run them:

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

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.