Three things the lab could not do. WRISTS. Each hand had one scalar, a twist about the barrel. That is the only axis a hand wrapping a cylinder is free in ONCE the arc onto the barrel is solved — which is true of the support hand, was never true of the trigger hand, and in neither case left a way to cock a wrist forward or break it inward. Both now take pitch, yaw and roll, applied in the GUN's frame so the three sliders mean the same thing whether the muzzle is down at low ready or level down the sights. Zero is exactly the old behaviour, since the roll term defaulted to zero too. HAND POINTS. `gun_stock` and `gun_fore` are the distances along the weapon at which each hand sits, and they were labelled by what they measure rather than by whose hand it is. They now say TRIGGER and SUPPORT, next to the off-barrel shifts for the same two hands, so the four controls that place a hand read as four controls that place a hand. POSES. The hold's knobs are now per pose, and the lab shows one pose's at a time. Half of them mean something different at low ready than down the sights; showing both sets at once meant every slider on screen was for one of two poses with nothing saying which. Selecting a pose rebuilds the panel. Two poses, not four, and deliberately: the runtime blends between exactly two holds on `ads`. Running and Crouched are locomotion states that still use the low-ready hold, so they edit the same numbers — and the heading says so, rather than letting someone tune "Running" and wonder why standing still changed. Offering four independent tunings would be inventing a capability the code does not have, and the fourth would silently do nothing. `pitch` is the case that forced the design: down the sights the muzzle follows the CAMERA, so there is nothing there to tune. It exists at low ready and nowhere else, and a spec table where a knob names the poses it applies to is what lets that be said instead of shipping a control that does nothing. hold_pose_check asserts both halves — that no pose shows another's knobs, that aiming offers no muzzle pitch, that the heading names the hold being edited, and that all twelve wrist axes turn the hand they name. Its first version reported every wrist axis as moving the hand by 0.0 degrees, which is precisely the answer it would have given if the wrists had never been implemented: it read `get_bone_pose_rotation` from a SceneTree script, and Godot restores every bone's local pose after the modifier pass. The repo has a reference section about exactly this and it still cost a cycle. Measured through a PoseProbe, every axis turns its hand ~20 degrees. Co-Authored-By: Claude Opus 5 <[email protected]>
9.0 KiB
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 | 17–35 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 |
hold_pose_check.gd |
the lab shows only the selected pose's knobs; every wrist axis turns its hand | 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_checkasserts 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_checkasserts 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_checkandanchor_drag_checkboth measure in the GUN's frame rather than the world's, and have to. The hold BREATHES — asin(_time * 2.2) * 0.012on 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.hold_pose_checkmeasures the wrists through aPoseProbe, and had to learn it the same way everything else did: readingget_bone_pose_rotationfrom the SceneTree reported every wrist axis as turning the hand by 0.0 degrees — the identical answer it would give if the wrists had never been implemented. See READ THIS FIRST. That trap is still the most expensive one in this repo.surface_class_checkFAILS 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:
- Is the measurement inside the modifier pass? (Above. Do this first.)
- 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. - 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. - 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.