The weapon is a child of a BoneAttachment3D on the trigger hand, so the two were welded by construction. Every degree `wrist_r` turned swung the barrel the same degree off the aim line — and took with it every control that could have corrected for it, because they are all expressed relative to that same hand. There was no combination of sliders that aligned a hand to a gun, which is the one thing the knob exists for. Both outcomes are now computed where the hand's local pose is set: the rotation the hand would take without the wrist offset, and the one it takes with it. The hand gets the second; the difference between them is exactly the counter-rotation the weapon mount needs, in the hand's own local frame, and `SkinnedPlayerModel._hold_weapon_still` applies it to the mount each frame. The gun ends up precisely where the solver put it. That also gives the two controls a clean split, which is what makes them usable together: TRIGGER / SUPPORT WRIST (hold) turns the HAND, gun stays on the aim line Grip roll / pitch / yaw (anchors) turns the GUN inside the hand Identity when `wrist_r` is untuned, so a character nobody has tuned mounts its weapon exactly as before. Applied in `_process` rather than inside the modifier pass on purpose. The gun's mount is not something the skeleton owns, and the compensated value only changes when a slider moves or the ADS blend travels, so one frame of lag is a fraction of a degree; reaching into the modifier to touch a scene node would be worse. wrist_gun_check asserts both halves, because only asserting the first is how this shipped broken: the hand must TURN, or the knob does nothing, and the gun must NOT, or the knob cannot be used. Across both poses and all three axes the hand turns 28.2-28.7 degrees for a 0.5 rad knob and the gun moves 0.1-0.6 — against the ~28 it would move if it were still following the wrist. The residue is the arm's own IK settling, since the hand's rotation feeds the chain that places the shoulder. Co-Authored-By: Claude Opus 5 <[email protected]>
169 lines
9.1 KiB
Markdown
169 lines
9.1 KiB
Markdown
# 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 |
|
||
| `wrist_gun_check.gd` | the wrist turns the hand and NOT the gun welded to it | hand ~28°, gun < 1° |
|
||
| `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.
|
||
- `hold_pose_check` measures the wrists through a `PoseProbe`, and had to learn
|
||
it the same way everything else did: reading `get_bone_pose_rotation` from 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_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:
|
||
|
||
```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.
|