Skip to content

feat: 0.5.0 muscle layer — JOINT_MOTOR mode (torque-bounded Jolt velocity motors) - #100

Open
blugart-dev wants to merge 5 commits into
mainfrom
feat/muscle-layer
Open

blugart-dev wants to merge 5 commits into
mainfrom
feat/muscle-layer

Conversation

@blugart-dev

@blugart-dev blugart-dev commented Sep 12, 2026 •

Copy link
Copy Markdown
Owner

Summary

Milestone 0.5.0 — Muscle layer (docs/PLAN.md): RagdollTuning.muscle_mode = JOINT_MOTOR executes the resolver's command through each joint's Jolt angular velocity motor with force limit = BoneDefinition.muscle_torque × strength ratio × muscle_strength_scale. Strength is a torque, gravity stays on, a limp bone has a zero force limit, and a hit produces a real, bounded reaction. JOINT_MOTOR is now the default (per the maintainer's request); the legacy VELOCITY_OVERWRITE path is untouched and available on the tuning.

Suite: 245 tests, all passing (21 new in test_muscle_layer.gd, plus the trace recorder); all 8 demos load headless clean; legacy regression suites (test_rig_fidelity, test_runtime_rig, test_state_machine) unchanged and green.

What was built

  • SpringResolver motor path: per joint, the relative-rotation error in the joint frame × muscle_gain per tick + feed-forward, clamped, written as the motor target; force limit from the profile torque × strength ratio. Parent-first so a child reads its parent's target from this tick. Foot/arm IK overrides work unchanged.
  • Root driven through a kinematic anchor body: the pelvis is joined by a limit-free Generic6DOFJoint3D to a static body (Hips_anchor) the resolver teleports to its target every tick; the joint's angular motor (muscle_root_torque, 400 N·m) and linear motor (muscle_root_force, 2500 N) drive it as bounded forces. The anchor leads the pelvis by ≤ 1 rad / 0.5 m and sits on it while limp, so Jolt's swing-twist motor axes never degenerate. Three earlier root designs were measured and rejected on the maintainer's scene: a velocity pin on the pelvis (diluted by the joint solve: 3.5 cm low, 3 Hz bounce, the whole-body wobble), a fixed world joint (got up upside down after a ragdoll on the ground), and re-anchoring that joint by rebuilding it (left every limb motor inert). Verified: after a ragdoll every shooting-range character gets up upright with ~2° error.
  • Limb motors commanded in Jolt's swing-twist angle space (the limits' space) instead of a rotation vector: joints thrown to their limits by a ragdoll come back instead of stalling.
  • Balance tip-over off in motor mode: a held pelvis cannot topple; CoM-vs-feet ratio spikes of 1.0–1.5 were ragdolling a standing staggered character. Knockdowns come from ragdoll_probability / pain / explicit triggers until 0.6.0's balance layer owns the decision.
  • Tooling: KickbackTraceRecorder (F5 in the demos) writes a per-tick trace of every character to user://kickback_traces/*.jsonl; tools/bench/trace_report.py summarises sag, bounce frequency, error spikes, ground clipping, root-motor saturation and frame pacing; tools/bench/scene_probe.gd loads any demo scene headless and logs the characters with no input.
  • BoneDefinition.muscle_torque + SkeletonDetector.MUSCLE_TORQUE_TABLE (hip 200, knee 150, spine 150, shoulder 60, elbow 40, neck 30, wrist 10, ankle 60), set by both profile factories.
  • New tuning group "Muscle Layer": muscle_mode, muscle_strength_scale, muscle_gain (0.10), muscle_max_angular_velocity, muscle_root_pin, muscle_root_torque, muscle_angular_damp, muscle_linear_damp.
  • tools/bench/ybot_bench.gd: the acceptance bench on the real character (idle / react_front / deterministic bullet on the hand; BENCH_HZ, BENCH_DIAG, BENCH_VARIANT).
  • muscle_strength_curve (0.5): torque = muscle_torque × ratio^curve. The hit/stagger/fatigue reductions were tuned as blend fractions; as raw torque the 10 % stagger floor collapsed a staggered character into a ragdoll within 0.3 s. √ keeps limp = 0 and full = 1.
  • The root's world motor and pin hold at full authority until the bone is limp (_root_hold_factor): they stand in for balance until 0.6.0; scaled by hit strength they let a stagger topple.
  • Fix: KickbackCharacter resolves a null profile/tuning once and hands concrete resources to every controller (a null passed through configure() used to strip the controller's roles and disable foot/arm IK).

Engine facts measured (now in GODOT_CONSTRAINTS.md)

  • Motor target velocity axes are mirrored (−1 on X/Y/Z).
  • The 6DOF angular motor is solved on swing-twist axes: twist about the parent frame's X, swing about the child frame's Y/Z. Commanding all three in one frame leaves every joint of a real idle 2–4° short; the harness cannot see it (single-axis rotations). This also explains part of the spike's tracking deficit.

Ybot bench (60 Hz, foot IK on)

Mode IDLE mean / max REACT mean HIT peak / recover
legacy 0.78 / 1.65 10.1 1.3° / 4 ticks
JOINT_MOTOR 0.94 / 3.54 24.5 2.4° / 4 ticks

120 Hz: motor beats legacy on idle and react (0.73 / 2.43 idle, 10.6 react). 30 Hz idle is 4.9° with the defaults (was a 15° ring). Acceptance per docs/PLAN.md met at 60 and 120 Hz; the plan records two criteria revised after the first bench run (the original TRACK and HIT targets were written before the bench existed and were unmet even by legacy).

Open, documented as MISSES in docs/PLAN.md, carried to 0.6.0: react_front tracks at 2.4× legacy against a 1.75× bar and the hand-hit deflection is 1.8× legacy against a 3× bar — the per-axis command is robust but less responsive on violent motion, and hits read less vividly than legacy (the muscle knobs are now Tuning Lab sliders). Also: 30 Hz hand hits can wrap a wrist limit; feet are not yet load-bearing. Resolver CPU ≈ 1.4× legacy on a quiet run (µs timing is noisy on Windows).

Human gate (visual, in the editor)

Every demo now runs on JOINT_MOTOR by default:

  • idle looks alive, not floaty
  • a body shot in the hand visibly flinches and the arm recovers on its own (expect LESS deflection than legacy; the MUSCLES sliders in the Tuning Lab change it)
  • knock a character down (Explosion / rapid Bullet) and confirm it gets back up UPRIGHT
  • a corpse (trigger_ragdoll / set_persistent) falls and settles at real gravity
  • get-up recovery completes and stands cleanly
  • stress test with 20 characters holds 60 fps on the dev machine

On pass: merge, bump plugin.cfg to 0.5.0, tag.

🤖 Generated with Claude Code

https://claude.ai/code/session_013gMUV3TEWBfATLUiYpmX71

blugart-dev and others added 5 commits September 13, 2026 00:40
…city motors)

RagdollTuning.muscle_mode = JOINT_MOTOR executes the resolver's command
(joint-space rotation error x muscle_gain per tick + feed-forward) through
each Generic6DOFJoint3D angular motor with force limit = BoneDefinition.
muscle_torque x strength ratio x muscle_strength_scale. Strength is a torque,
gravity stays on, a limp bone has a zero force limit, hits react physically.
The pelvis is attached to the world by a limit-free joint whose motor drives
its orientation (muscle_root_torque); its position pin stays, scaled.
Legacy VELOCITY_OVERWRITE is untouched and remains the default until the
visual gate passes.

Engine facts measured (GODOT_CONSTRAINTS.md): motor target axes mirrored;
the 6DOF angular motor is solved on swing-twist axes — twist in the parent
frame's X, swing in the child frame's Y/Z (single-frame commands leave every
joint of a real idle 2-4 deg short).

Ybot bench (tools/bench/ybot_bench.gd, 60 Hz): idle 0.80/3.42 deg vs legacy
0.78/1.65 under real gravity; react_front 16.3 vs 10.1 (torque-limited);
bullet on the hand 6.3 deg peak, muscle recovery in 4 ticks (legacy 1.3).
120 Hz beats legacy. 30 Hz documented as open (non-colliding feet ring).

Also: BoneDefinition.muscle_torque + SkeletonDetector.MUSCLE_TORQUE_TABLE;
KickbackCharacter resolves null profile/tuning once (a null passed through
configure() used to strip the controller's roles and kill foot/arm IK);
test_muscle_layer.gd (16 tests); docs (REFERENCE muscle section, INTEGRATION,
CHANGELOG, PLAN checklist + measured acceptance, MUSCLE_SPIKE addendum).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gMUV3TEWBfATLUiYpmX71
…e; root holds until limp

- RagdollTuning.muscle_mode defaults to JOINT_MOTOR; VELOCITY_OVERWRITE kept
  for comparison (the legacy-characterising suites pin it explicitly).
- muscle_strength_curve (0.5): torque = muscle_torque x ratio^curve. The hit /
  stagger / fatigue reductions were tuned as blend fractions; as raw torque the
  10 % stagger floor collapsed a staggered character into a ragdoll in 0.3 s.
- The root's world motor and pin hold at full authority until the bone is limp
  (_root_hold_factor) — they stand in for balance until 0.6.0; scaled by hit
  strength they let a stagger topple.
- Tests: stagger stays standing and recovers in motor mode; a motor pushing
  into a limit yields a bounded amount; joint count tolerates the root world
  joint; 241 tests green, all demos load.
- Docs: REFERENCE / INTEGRATION / CHANGELOG / PLAN / README / CLAUDE.md for the
  flipped default. plugin.cfg bump + tag wait for the visual gate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gMUV3TEWBfATLUiYpmX71
…e), tip-over off in motor mode; trace recorder + scene probe tooling

Found by the user in shooting_range.tscn: the whole body wobbled with no input
and the feet clipped the floor. Measured: the pelvis stood 3.5 cm below its
target and bounced at ~3 Hz (13.5 mm p-p). Cause: the root position pin was a
velocity written to the pelvis alone, diluted by the joint solve across the
~55 kg hanging from it. Broadcasting the correction to every body was wrong
too — it cancels gravity for all of them and discards impulses applied between
ticks.

- SpringResolver: the root world joint now carries a LINEAR motor
  (muscle_root_force, 2500 N) holding the pelvis with a bounded force inside
  the solver: within 1 mm of target, no bounce, feet at legacy height.
  Root torque/force are not scaled by muscle_strength_scale (balance, not
  muscle).
- ActiveRagdollController: the balance-driven tip-over does not fire in
  JOINT_MOTOR mode — a held pelvis cannot topple; CoM-vs-feet spikes of
  1.0-1.5 were ragdolling a standing staggered character.
- KickbackTraceRecorder (F5 in the demos) + tools/bench/trace_report.py:
  per-tick trace of every character to user://kickback_traces/*.jsonl and
  an offline summary (sag, bounce frequency, error spikes, ground clipping,
  root-motor saturation, frame pacing). tools/bench/scene_probe.gd loads any
  demo scene headless and logs the characters with no input.
- Tests: pelvis stands within 6 mm of target without bouncing; trace
  recorder end to end. 243 tests green, all demos load.
- Docs: REFERENCE / INTEGRATION / CHANGELOG / PLAN / README / CLAUDE.md.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gMUV3TEWBfATLUiYpmX71
… angle space (get-up no longer upside down)

User report: characters got up UPSIDE DOWN after a ragdoll on the ground, and
wobbled until they settled there. Measured: with a fixed world joint the
pelvis lies 90 deg+ from the joint frame, Jolt's swing-twist motor axes
degenerate and the orientation motor drives to the wrong stable point.
Re-anchoring by rebuilding that constraint (reassigning node_b) left every
other joint's motor inert (correct commands, full torque, zero motion).

- SpringResolver: the root is joined to a static ANCHOR body teleported every
  tick to the (foot-IK-shifted) target, clamped to lead the pelvis by <= 1 rad
  / 0.5 m and placed on the pelvis while limp, so the joint's relative rotation
  stays small and nothing is ever rebuilt. Angular (muscle_root_torque) and
  linear (muscle_root_force) motors drive it as bounded forces; the linear
  command is expressed in the anchor frame.
- Limb (and root) motors are commanded per axis in Jolt's swing-twist angle
  space — the space the limits are defined and verified in — instead of a
  rotation vector, which only agreed near rest and stalled joints thrown to
  their limits.
- Balance tip-over off in JOINT_MOTOR mode (held pelvis cannot topple).
- Tuning Lab: MUSCLES slider section (gain, strength scale, curve, root
  torque/force).
- Verified: shooting-range ragdoll -> get-up upright on all 5 targets, 2 deg
  worst error; idle 0.94 deg; 245 tests green, all demos load.
- Recorded as MISSES in docs/PLAN.md: react_front 24.5 deg (2.4x legacy) and
  hand-hit deflection 2.4 deg (1.8x) — robustness won over vividness for now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013gMUV3TEWBfATLUiYpmX71
Probe for the zero-force-limit latch that was committed with the anchor-root
fix; the finding it chased is covered by test_muscle_layer.gd.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018RbYJ9LscLhHypHqWfhtGF
@blugart-dev

Copy link
Copy Markdown
Owner Author

Maintainer decision 2026-09-13: main stays frozen until the whole thing feels amazing. This PR is kept open as the 0.5.0 record and is not to be merged yet. Integration continues on the new develop branch (created at d0305a1, this PR's head); 0.6.0 balance + behaviors is on feat/balance-behaviors → develop. A single release PR develop → main will supersede this one when the feel gate passes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant