Files
stickman/docs/specs/ragdoll-cleanup.md

30 KiB
Raw Permalink Blame History

Ragdoll Cleanup — Architecture Spec

Source plan: docs/archive/plans/RAGDOLL_CLEANUP.md Target file: scripts/characters/stickman/stickman_rig.gd (only file changed)

Verified code facts

  • _capture_ragdoll_pose() (line 771) stores _captured_ground_y = torso.global_position.y + RAGDOLL_TORSO_RADIUS — the floating bug. _reanchor_root_to_landing() (line 809) consumes it.
  • Ragdoll bodies live on collision_layer/mask = 1 (same as terrain) → a ground raycast MUST exclude them.
  • At runtime each Body/* part node's own geometry is cleared by StkRigAdapter and the .stk shapes are mounted as children (Line2D open / Polygon2D closed) — so visual cloning must walk the part node's children, not the node itself.
  • Per-joint bend direction is stored in the 4 exported vars and queryable via get_joint_bend_flipped(joint); facing_profile applies via PROFILE_FLAGS + a whole-rig X-mirror for LEFT (Master.scale.x = -1).
  • The plan's §4.2 flag table is outdated. Actual PROFILE_FLAGS in code: LEFT == RIGHT == {LeftArm: inv, RightArm: inv, LeftLeg: norm, RightLeg: norm}, FORWARD == {norm, inv, inv, norm}. This spec uses the code's flags.

Fix 1 — Recovery ground probe (raycast)

Replace the torso-radius ground guess with a downward raycast performed inside _capture_ragdoll_pose() (while the ragdoll still exists — RIDs are needed for exclusion).

New constants:

const GROUND_PROBE_START_OFFSET := 50.0    # ray starts this far above the ragdoll top
const GROUND_PROBE_END_OFFSET   := 1500.0  # ray ends this far below the ragdoll bottom
const GROUND_PROBE_TOLERANCE    := 200.0   # accepted-hit band around the ragdoll extent
const GROUND_PROBE_X_OFFSETS    := [0.0, 25.0, -25.0, 50.0, -50.0, 100.0, -100.0]

New helper _probe_ground_y(world_x: float, top_y: float, bottom_y: float, exclude: Array[RID]) -> float:

  • Fans 7 rays at world_x + each offset, from (x, top_y − START_OFFSET) down to (x, bottom_y + END_OFFSET).
  • PhysicsRayQueryParameters2D, collision_mask = 1, collide_with_bodies = true, collide_with_areas = false, query.exclude = exclude (all ragdoll body RIDs).
  • Accept only hits inside the band top_y − TOLERANCE .. bottom_y + TOLERANCE (rejects overhang hits above and gap hits far below); among accepted hits keep the one whose y is closest to bottom_y.
  • Return the best hit y, or bottom_y as fallback (the ragdoll rests on something there even if no ray found it).

_capture_ragdoll_pose() changes:

  • Store body.set_meta("radius", …) at build time (_build_ragdoll_body).
  • Compute top_y / bottom_y from each body's pos.y ± (half + radius) (head circle: half = 0, radius = 100).
  • _captured_ground_y = _probe_ground_y(torso.global_position.x, top_y, bottom_y, exclude_rids).

_reanchor_root_to_landing() — unchanged.

Fix 2 — Ragdoll visual identity

  • Add "visual_path" to each RAGDOLL_BODIES entry (Body/Body, Body/Head, Body/LeftUpperArm, … — the Body/* part node).
  • New _clone_part_visuals(body: RigidBody2D, visual_path: String) -> bool:
    • Look up the part node; for each child that is Line2D or Polygon2D, create a same-type node, copy visual props (points/polygon, width, default_color, cap/joint modes, closed, antialiased), and bake each point into the ragdoll body's local frame: body.to_local(src.to_global(p)); copy position = Vector2.ZERO.
    • add_child the copies; return true if at least one shape was cloned.
  • _build_ragdoll_body() calls the cloner; on false (missing part node / no mounted shapes / empty bbox part) fall back to the existing gray capsule / white circle helpers (_add_ragdoll_visual_capsule / _add_ragdoll_visual_circle become fallback-only).
  • Physics is untouched — collision stays capsule/circle; visuals are cosmetic overlays parented to the rigidbody (rotate/translate with it automatically).

Fix 3 — Profile-aware ragdoll joint limits

  • New const map from ragdoll child key → bend joint name:
const RAGDOLL_JOINT_BEND_JOINTS: Dictionary = {
	"left_lower_arm": "LeftArm", "right_lower_arm": "RightArm",
	"left_lower_leg": "LeftLeg", "right_lower_leg": "RightLeg",
}
  • _build_ragdoll_joint() passes the child key; _apply_ragdoll_joint_limits(pin, limit, child_key):
    • elbow_knee/elbow_knee_ccw entries: flipped := get_joint_bend_flipped( RAGDOLL_JOINT_BEND_JOINTS[child_key]), then mirror-corrected: flipped_world := flipped != (facing_profile == FacingProfile.LEFT) — the LEFT whole-rig X-mirror negates world-space relative angles.
    • flipped_world == true → band [-150°, +5°]; else band [-5°, +150°].
    • shoulder_hip ±160° and neck free: unchanged.
  • Uses the live per-joint flags, so it covers the facing profile and harness right-click overrides. FORWARD reproduces today's static config exactly.

Implementation order

  1. Fix 1 (probe constants + _probe_ground_y + capture changes + radius meta).
  2. Fix 2 (visual_path entries + _clone_part_visuals + fallback wiring).
  3. Fix 3 (joint map + mirror-corrected limits).
  4. Headless --check-only validation of scripts/characters/stickman/stickman_rig.gd.

Acceptance criteria (from plan §7, unchanged)

  • Recovery: feet on terrain on flat/slopes/ramp edges; no floating/clipping.
  • Visuals: ragdoll shows the stickman's own colors/shapes; no gray bones; smooth handoff.
  • Profiles: LEFT/RIGHT/FORWARD bend correctly in ragdoll and after recovery; no hyperextension.
  • No new console errors; ANIMATED behavior untouched.

Round 2 — Recovery snap & tween (post-test bugs)

User-reported symptoms:

  • Walking RIGHT → ragdoll → recover: arms/legs jump to a new pose at tween start.
  • Walking LEFT → ragdoll → recover: whole figure (incl. torso) jumps; body lands left of the ragdoll pile; "feet end up where the torso should be".

Root causes

  1. Mirror-unaware snap math (LEFT). _snap_skeleton_to_pose() / _set_marker_from_body() add world-space direction vectors to rig-local positions, and use the captured relative angle directly for the Torso marker rotation. Under the LEFT whole-rig X-mirror (Master.scale.x = -1) the root's global_rotation decomposes as π − φ, so the captured relative angle equals −A (the negated unmirrored chain angle), not A. Result: the Torso renders ~180° off and every joint offset is X-mirrored — the whole-body jump.
  2. IK re-solve at snap (RIGHT/FORWARD). After the snap re-arms the modification stack, TwoBoneIK re-solves the limbs toward the captured end-effector markers using the profile's flip_bend_direction flags. If the ragdoll fell with a knee/elbow on the other side of the solution line, the limb snaps to the mirrored IK solution at tween start.

Fix design (approved)

  • Mirror-safe snap math: convert every derived joint point through to_local(to_global(captured_pos) + world_dir * half) — the transform math itself handles the mirror and any root rotation. Captured relative angles are converted to chain angles via _chain_angle_from_captured(x) = −x for LEFT, else x.
  • Bone-driven limb recovery: during RECOVERING the 4 TwoBoneIK mods are disabled and the 8 limb Bone2D rotations are driven directly:
    • _ready() resolves the 8 limb bones (LIMB_BONE_PATHS) and captures their authored rest rotations (_rest_bone_rotations) before the first IK solve.
    • _snap_skeleton_to_pose() sets each limb bone's local rotation from the captured body angles: rot_u = A_u − A_torso − bone_angle_u, rot_l = A_l − A_u − bone_angle_l (mirror-corrected chain angles), then disables the 4 TwoBoneIK mods (_set_limb_mods_enabled(false)).
    • _play_stand_up() tweens the snapped bones in parallel with the marker tween (same duration/ease) to their rest rotations.
    • _on_stand_up_finished() and snap_to_standing() re-enable the 4 mods — at that point markers are at STAND_POSE and bones at rest, so the resumed IK solve reproduces the authored standing pose exactly (no end snap).

Round 2 acceptance criteria

  • RIGHT: ragdoll → recover transitions with no limb jump at tween start or end.
  • LEFT: ragdoll → recover snaps back to the exact ragdoll pose (no whole-body jump); figure stands up at the landing spot with feet on the terrain.
  • FORWARD: unchanged smoothness; existing recovery behavior intact.
  • Interrupted recovery (ragdoll re-entry mid-stand-up) and snap_to_standing() still work; no console errors.

Round 3 — Pre-ragdoll state capture + spine-offset fix

User-reported (after Round 2): the first recovery frame still shows arms/legs at wrong positions ("the first frame of recover changes where the arms and legs are before the tween"). User-prescribed design:

  1. Capture the stickman's state on the last frame before ragdoll (limbs, head, torso, facing profile, bend flags) — this is the recovery END target (possibly a mid-walk pose — the animation does not resume, but the transforms are retained).
  2. Capture the state on the last frame of ragdoll — the tween START.
  3. Tween every bone/marker from the ragdoll state to the pre-ragdoll state.

Root cause of the Round 2 jump (verified against master_rig.tscn)

The Torso bone's direction is not the spine. The Head bone's authored local position is (0, −391.5) — the spine runs along the Torso bone's −Y, not its +X. Therefore:

  • torso_marker.rotation = chain_angle(spine_rel) + π/2 − bone_angle_torso (new const TORSO_SPINE_OFFSET := PI / 2.0); at rest spine_rel = −π/2 → marker rotation 0 = STAND_POSE ✓.
  • The limbs' parent chain angle is the bone angle θ_t = marker.rotation + bone_angle_t = chain(spine_rel) + π/2 — Round 2 used chain(spine_rel) directly, so all 8 limb rotations were ~90° off at snap. The limb formula itself (rot = chain(dir) − parent_chain − bone_angle, where the ragdoll body angle IS the bone direction and the Body/* visuals follow the bone node rotation) is otherwise correct.
  • The head bone drives Body/Head via its node rotation (bone_angle excluded from node transforms): head.rotation = chain(head_body_rel) − θ_t (no bone_angle term). Round 2 never set the head bone — the re-armed LookAt rewrote it on the first frame (head jump).

Fix design (approved)

  • Pre-ragdoll capture _capture_pre_ragdoll_state() at the top of _enter_ragdoll(): the 6 marker local positions + rotations, the 8 limb bone rotations, the head bone rotation, facing_profile, the 4 live bend flags, and the head LookAt's enabled state.
  • Corrected snap _snap_skeleton_to_pose():
    • torso marker rotation + TORSO_SPINE_OFFSET;
    • limb rotations with parent chain torso_chain + TORSO_SPINE_OFFSET;
    • NEW: head bone rotation from the head body (no bone_angle term);
    • all snapped rotations wrapped to the shortest path toward their pre-ragdoll targets (_wrap_toward(v, target) = target + wrapf(v − target, −PI, PI)) so the tween never takes the long way around;
    • disable the 4 TwoBoneIK mods AND the head LookAt.
  • Tween _play_stand_up(): markers → pre-ragdoll marker values; 8 limb bones + head bone → pre-ragdoll rotations (same parallel sine ease, 2.0 s).
  • Finish _on_stand_up_finished(): restore the captured facing profile + per-joint bend flags + head LookAt enabled state, re-enable the 4 limb mods → the resumed IK solve reproduces the pre-ragdoll pose exactly (no end snap). snap_to_standing() restores the LookAt state too.
  • _pin_mirrored_head_rotation() gated to ANIMATED only (its per-frame pin would fight the head tween during RECOVERING).
  • _rest_bone_rotations removed (rest pose is no longer a tween target).

Round 3 acceptance criteria

  • First recovery frame is pixel-identical to the last ragdoll frame (limbs, head, torso).
  • Recovery ends in the exact pre-ragdoll pose (mid-walk poses retained; no animation resume), profile + bend flags restored; no end-of-tween snap.
  • LEFT/RIGHT/FORWARD all recover; interrupted recovery and snap_to_standing() still work; no console errors.

Round 4 — Snap angle corrections + runtime debug prints

User-reported (after Round 3): end of recovery matches the pre-ragdoll frame ✓, but the last ragdoll frame and the first recovery frame are "completely different" — body/limbs/head all in the wrong positions, head orientation wrong, arms rotated unrealistically, whole body ~90° off. User requested runtime debug prints of each part/IK at the end of ragdoll and start of recovery.

Root causes (verified against master_rig.tscn + _build_ragdoll_body math)

Chain-angle framework (validated): chain(captured_rel) = A + bone_angle for every bone, where A is the unmirrored node-chain angle (bone_angle is NOT part of node transforms — only the ragdoll body direction includes it; the Body/* visuals follow the node rotation).

  1. Lower-limb parent chain. The lower bone's node parent chain is A_upper (no bone_angle), but the captured upper body angle is A_upper + bone_angle_upper. Round 3 computed rot_l = chain(l) − chain(u) − bone_angle_l, which is off by −bone_angle_upper — LeftLowerArm flipped ~180° (bone_angle_upper = −π) and both lower legs off ~90° (bone_angle_upper = +π/2). Correct: rot_l = chain(l) − chain(u) + bone_angle_upper − bone_angle_lower (validated: reproduces every authored rest rotation exactly).
  2. Head entry offset. The head ragdoll body is a "visual" body spawned with rotation 0 (a circle, not a direction bone), so its cloned visual carries the ENTRY head chain as a baked world offset V_entry = rendered(θ_t_entry + rot_h_entry). Round 3 used rot_h = chain(h_rel) − θ_t_snap, dropping V_entry and the LEFT −π mirror term. Correct: rot_h = chain(h_rel) + head_entry_offset + (θ_t_entry + rot_h_entry) − θ_t_snap where head_entry_offset = +φ (FORWARD/RIGHT) or −(π − φ) (LEFT), φ = global_rotation, and θ_t_entry + rot_h_entry = pre-ragdoll Torso marker rotation + pre-ragdoll head rotation.
  3. Debug prints (user-requested, gated by DEBUG_RECOVERY, set true for this round):
    • _capture_ragdoll_pose(): per body — key, world pos/rot, local pos, relative rot.
    • _snap_skeleton_to_pose(): torso chain/raw/wrapped marker rotation + pre target; per joint the upper/lower chain angles, computed rotations, pre-ragdoll targets; head raw/wrapped/ target; final TwoBoneIK mod + LookAt enabled states.

Round 4 acceptance criteria

  • First recovery frame is pixel-identical to the last ragdoll frame for ALL profiles.
  • No 90°/180° limb flips; head orientation continuous across the transition.
  • Console debug output shows matching angles between the ragdoll-end and recovery-start frames (flip DEBUG_RECOVERY back to false once verified).
  • End of recovery still matches the pre-ragdoll frame; no console errors.

Round 5 — Entry-offset snap + mirror-aware re-basing (from user debug data)

User-provided LEFT-profile debug output revealed three bugs (the end-of-recovery match and the limb formulas were already correct; the limb debug values reconcile exactly once the torso parent is right).

Findings from the debug data

  1. Torso marker rotation double-count (~90° whole-body rotation). The Torso bone's bone_angle is AUTO-CALCULATED (no auto_calculate_length_and_angle = false in the tscn) from its first child (the Head bone at (0, −391.5)) → bone_angle_t = −90°. The original chain − bone_angle_t therefore already encodes the spine offset; Round 3/4's additional +TORSO_SPINE_OFFSET double-counted it: raw = chain + 180 instead of chain + 90 (verified: printed raw=147.64, correct 57.62).
  2. Mirror-unaware re-basing (~552 px X shift). _reanchor_root_to_landing() re-bases the captured rig-local positions by + shift (the world-space root delta). Under the LEFT X-mirror a local +X moves world −X, so every captured position lands off by 2·shift.x (verified: hip_local.x = 386.38 vs expected −165.25, difference = 2·275.85).
  3. Head entry offset doubled (head 180° off). Round 4's entry_offset used global_rotation, which Godot decomposes as −π for the mirrored root (not π − φ), producing −360° instead of −180°.

Fix design (approved — derived from the user's own debug output)

  • Entry-offset capture in _capture_pre_ragdoll_state():
    • torso_chain_offset = chain(spine_entry_rel) − θ_t_entry, where spine_entry_rel is the entry spine direction (head bone − torso bone world positions, minus global_rotation) and θ_t_entry the entry Torso marker rotation.
    • head_chain_offset = chain(−global_rotation) − (θ_t_entry + head_rotation_entry) — the head ragdoll body spawns with rotation 0, so this is its baked entry offset.
    • The chain() form is branch-proof: the mirrored root's global_rotation decomposition (±π) cancels in the difference of two captured angles.
  • Snap _snap_skeleton_to_pose():
    • θ_t = wrap_toward(torso_chain − torso_chain_offset, pre_torso_marker_rot); the torso marker rotation AND the limb parent chain both use this single θ_t value.
    • rot_h = wrap_toward(chain(head_rel) − head_chain_offset − θ_t, pre_head_rotation).
    • TORSO_SPINE_OFFSET, the − bone_angle_t subtraction, and the Round 4 head entry_offset/entry_chain reconstruction are all REMOVED — the captured offsets absorb every authored convention.
  • Mirror-aware re-basing in _reanchor_root_to_landing(): local_shift = to_local(shift) − to_local(Vector2.ZERO) (the root basis's inverse applied to the world delta — negates X under LEFT, handles root rotation too).

Round 5 acceptance criteria

  • Debug output for LEFT: [recover-start] torso ... raw≈57.6 final≈57.6, hip_local ≈ (−165.2, 343.3), head final ≈ −57.6.
  • First recovery frame pixel-identical to the last ragdoll frame (all profiles).
  • Recovery ends at the pre-ragdoll pose at the landing spot; no console errors.
  • Flip DEBUG_RECOVERY back to false once verified.

Round 6 — Natural joint-bend tween paths (no unwrapping)

User-reported (after Round 5): positions now match, but limbs unwrap unrealistically — a knee sweeps the long/backwards way (≈315°) instead of a natural short bend (≈45°). "The body should recover just as smoothly as it ragdolls."

Root cause (verified against the user's FORWARD debug data)

Each bone rotation tweens along the shortest value path toward its pre-ragdoll value. For a joint that fell bent backwards, the shortest path sweeps through the ±180° (fully-backward) side — the knee folds further backwards through the back of the joint — instead of through straight (0), the only natural humanoid path. Example from the data: left lower leg value 286.34 (≡ −73.7°, bent backwards) → pre 120.40: Round 5 tweens 286.34 → 120.40 (−165.9°, through −180°, backwards) instead of 286.34 → 480.40 (+194.1°, through 0/straight).

Fix design (approved)

New helper _natural_joint_target(current, target) -> float:

  • a = wrapf(current, −π, π), b = wrapf(target, −π, π), d = wrapf(b − a, −π, π).
  • If |a| + |b| > π the shortest path crosses ±π (the joint would fold backwards) — take the long way around through straight: d += 2π if d < 0 else d −= 2π.
  • Return current + d (the pre value shifted by whole turns, so the tween END still renders the pre rotation).
  • Applied to the 8 limb bones' tween targets in _play_stand_up() (NOT the torso marker or the head — single segments, shortest path stays natural).

Round 6 acceptance criteria

  • No joint sweeps through the ±180° backward side during the stand-up; elbows/knees pass through straight when the ragdoll bent them backwards.
  • Every limb segment's world-direction sweep stays ≤ 180°; recovery reads as smooth as the ragdoll fall.
  • End pose still exactly the pre-ragdoll pose (targets differ by whole turns only).
  • All profiles; no console errors. Flip DEBUG_RECOVERY to false once verified.

Round 7 — Natural-bend rule restricted to elbows/knees

User-reported (after Round 6): "made it worse, now limbs are going fully past 360 degrees." Verified against the RIGHT-profile debug data: Round 6 applied the avoid-±π rule to ALL 8 limb bones, so the LeftArm SHOULDER took the long way (−185.40 → −450.98, a −265.6° sweep; world sweep −326.2°) instead of the natural shortest +94.4°.

Correction

  • Shoulders/hips (the 4 upper bones) are ball joints — keep the plain shortest path (the snap's existing wrap; Round 5 behavior). Revert Round 6 for the upper bones.
  • Elbows/knees (the 4 lower bones) keep _natural_joint_target — a hinge joint bent backwards must pass through straight (0) instead of folding further through ±π. This was Round 6's correct half and fixes Round 5's −307° lower-leg world spin (now +52.9°).
  • Debug: print each bone's stand-up tween (name, from → to) so the sweeps are visible.

Round 7 acceptance criteria

  • No shoulder/hip sweep exceeds 180°; no elbow/knee folds backwards through ±π.
  • Every limb segment's world-direction sweep ≤ 180° for all profiles.
  • Console shows [stand-up] <bone> <from> -> <to> lines; spot-check the sweeps.
  • End pose still exactly pre-ragdoll; no errors. Flip DEBUG_RECOVERY to false when done.

Round 8 — Minimal world-sweep tween targets (per-segment branch selection)

User-reported (after Round 7): limbs still flip ~360°. Verified against the RIGHT debug data: the per-bone rules compose badly — RightLeg upper swept +62.3° and the Round-7 knee rule chose +200.3° (through straight), so the lower leg's WORLD direction swept +262.6° — a near-full spin. The flaw: choosing each bone's branch in isolation ignores the parent chain's simultaneous sweep.

Fix design (approved)

For each bone in chain order, choose the whole-turn branch of its value sweep (pre − start, ±360°) that minimizes the segment's world-direction sweep (Δθ_torso + ΣΔrot up to and including that bone). Since the candidates are 360° apart, exactly one lands within ±180° of the accumulated parent sweep — every segment's world sweep is guaranteed ≤ 180°:

  • torso marker: existing wrap (Δθ ≤ 180°).
  • upper bone: pick d_u ∈ {pre_u − start_u, ±360} minimizing |Δθ + d_u|.
  • lower bone: pick d_l ∈ {pre_l − start_l, ±360} minimizing |Δθ + d_u + d_l| (d_u already chosen) — the knee/elbow branch follows the minimal world sweep, which is exactly the "through straight when bent backwards" behavior, derived instead of heuristic.
  • head: pick d_h ∈ {pre_h − start_h, ±360} minimizing |Δθ + d_h|.
  • Targets are computed in _snap_skeleton_to_pose() (where the chains are known) and stored in _recovery_bones; _play_stand_up() just tweens them. _natural_joint_target() removed.

Verified against the user's RIGHT data: RightLeg knee −159.7° (world −97.5°), LeftLeg knee +183.6° (world +31.2°), LeftArm elbow −170.6° (world −128.2°); every segment sweep ≤ 180°.

Round 8 acceptance criteria

  • No limb segment's world direction sweeps > 180° for any profile; no visible spins.
  • Knees/elbows pass through straight when the ragdoll bent them backwards (follows from the minimal-world-sweep choice).
  • [stand-up] lines show every segment sweep ≤ 180°; end pose exactly pre-ragdoll.
  • No console errors. Flip DEBUG_RECOVERY to false once verified.

Verified final state

Shipped and verified working. The round-by-round history above is retained for context; the design below is what is actually in scripts/characters/stickman/stickman_rig.gd and documented in AGENTS.md / README.md.

Ragdoll fall

  • Bodies / joints: 10 RigidBody2D (torso capsule r12 mass 8.0, head circle r100 mass 2.0, 8 limb capsules r8 masses 1.0–2.0) + 9 PinJoint2D (softness = RAGDOLL_TARGET_SOFTNESS), built in code and reparented into "RagdollBodyContainer" under the rig's parent; layer/mask 1.
  • Visual cloning: _clone_part_visuals() reads each body entry's visual_path Body/* node and clones every Line2D/Polygon2D child with points baked into the ragdoll body's local frame — no gray bones unless the part is missing/empty (fallback only).
  • Ground probe: _probe_ground_y() fans 7 rays (GROUND_PROBE_X_OFFSETS) around the landing X, from top_y − GROUND_PROBE_START_OFFSET to bottom_y + GROUND_PROBE_END_OFFSET, accepting only hits inside GROUND_PROBE_TOLERANCE of the ragdoll extent and excluding the ragdoll body RIDs (query.exclude). Called in _capture_ragdoll_pose() (landing ground) and again per-ankle in _snap_skeleton_to_pose() (ankle clamp).
  • Joint angular limits: _apply_ragdoll_joint_limits() reads the editable RAGDOLL_JOINT_BAND_LIMITS[profile][joint] = { "lower": deg, "upper": deg } table (the joint's allowed relative angle; 0 = straight; a small side ~5 resists hyperextension and a large side ~150 is the fold; narrow/locked bands such as {60, 100} force a permanently-bent limb). FORWARD arms {-150°, +5°}, legs {-5°, +150°}; LEFT/RIGHT override both sides. Joints missing from the table fall back to the RAGDOLL_JOINTS limit string (elbow_knee/elbow_knee_ccw). Shoulder/hip ±160°, neck free.

Recovery pipeline (final)

  1. Pre-ragdoll capture — _enter_ragdoll() → _capture_pre_ragdoll_state() snapshots into _pre_ragdoll_state: the 6 IK marker values, the 8 limb bone rotations, the head bone rotation, the facing profile, the 4 live bend flags, the head LookAt enabled state, per-leg knee side + flag (leg_sides), and the chain-space entry offsets torso_chain_offset / head_chain_offset. Captured at entry so the snap needs no authored geometry constants, and the LEFT mirror's ambiguous global_rotation decomposition (±π) cancels out. This is the recovery END target (possibly a mid-walk pose; the animation is not resumed).
  2. Ragdoll pose capture — _capture_ragdoll_pose() records each body's rig-local {pos, rot, half} and probes the terrain; the probe context is stored in _probe_top_y / _probe_bottom_y / _probe_exclude.
  3. Re-anchor — _reanchor_root_to_landing() moves the root so the standing feet sit on the ground at the landing X (new_root = (_captured_landing_center.x, _captured_ground_y) + FOOT_OFFSET) and re-bases the captured positions with a mirror-aware local shift local_shift = to_local(shift) − to_local(Vector2.ZERO).
  4. Snap — _snap_skeleton_to_pose() writes the captured pose: torso marker position = to_local(center_world − spine_dir·half), rotation = chain(spine) − torso_chain_offset; limb bones rot_u = chain(u) − θ_t − bone_angle_u, rot_l = chain(l) − chain(u) + bone_angle_u − bone_angle_l; head chain(head) − head_chain_offset − θ_t. All snapped rotations normalized into (−π, π] because Godot's Skeleton2D re-wraps bone rotations internally (otherwise the tween's captured START is corrupted). Ankles clamped onto the terrain. Re-shows Body/*, _rearm_ik_stack(), then disables the 4 arm mods + head LookAt and enables the 2 leg IK mods with flip_bend_direction matched to the captured knee side (leg_sides) or forced by RECOVERY_LEG_SIDE_FORCE.
  5. Stand-up — _play_stand_up() runs a 2.0 s sine-in-out tween of the 6 markers → their pre-ragdoll values and the arm + head bones → the _recovery_bones targets computed by the snap. Upper arm bones use the shortest path; lower arm bones use _natural_joint_target(current, target, offset) (offset = bone_angle_lower − bone_angle_upper; through-straight rule evaluated in the joint's rendered space; a "shortest" entry in RECOVERY_JOINT_PATH bypasses it). The legs are IK-driven toward their tweening, ground-clamped ankle markers; the arm mods + head LookAt stay disabled.
  6. Finish — _on_stand_up_finished() restores the pre-ragdoll facing profile + per-joint bend flags + head LookAt state (_restore_head_lookat_state()), re-enables the arm mods, re-shows Body/*, sets state = ANIMATED, and emits. The resumed IK solve reproduces the pre-ragdoll pose exactly (including mid-walk poses).

_pin_mirrored_head_rotation() pins the head only while ANIMATED (during RECOVERING the stand-up tween drives the head bone itself). set_ragdoll(true) during RECOVERING kills the tween and rebuilds the ragdoll; set_ragdoll(false) during RAGDOLL routes through recovery; calls are idempotent. is_in_ragdoll() stays state == RAGDOLL.

Tuning tables (EDIT ME)

Every per-joint bend decision of the cycle is editable without touching pipeline code:

Const Shape Purpose
RAGDOLL_JOINT_BAND_LIMITS [profile][joint] = { "lower": deg, "upper": deg } Fall knee/elbow PinJoint2D band. Missing entries fall back to the RAGDOLL_JOINTS limit string.
RECOVERY_LEG_SIDE_FORCE [profile][leg] = bool Optional leg IK flip_bend_direction override (true = inverted). Missing = match the captured fall pose's knee side.
RECOVERY_JOINT_PATH [profile][joint] = "shortest" Optional elbow/knee tween-path override. Missing = pass through straight when the shortest path would cross ±180°.

const DEBUG_RECOVERY := false (shipped OFF); _recovery_dbg() remains as the gated trace and the temporary per-step debug prints have been stripped.