OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / player / procedural / procedural_animator.gd

1480 lines · gd ref:
@tool
class_name ProceduralAnimator extends SkeletonModifier3D
## Overgrowth-style procedural animation system.
##
## Full pipeline (matches aschar.as line 14109-14610):
##   1. Write pose to skeleton bones
##   2. Extract 7 key_transforms from posed skeleton
##   3. ChestIK — spring-damper rotation on key_transforms[kChestKey]
##   4. HeadIK — spring-damper rotation on key_transforms[kHeadKey]
##   5. COM balance — compensate horizontal shift from look rotation
##   6. FootIK — raycasts, ground offsets on key_transforms[kLeftLegKey/kRightLegKey]
##   7. HipIK — lower hips, tilt pelvis
##   8. Final COM — compensate in air
##   9. Chain solvers — DrawLeg, DrawBody, DrawHead, DrawArms
##      (write key_transforms back to actual bones via FABRIK)

const PoseLibraryClass := preload("res://scenes/player/procedural/pose_library.gd")
const FootIKClass := preload("res://scenes/player/procedural/foot_ik.gd")
const ChestIKClass := preload("res://scenes/player/procedural/chest_ik.gd")
const HeadIKClass := preload("res://scenes/player/procedural/head_ik.gd")
const TorsoCounterRollClass := preload("res://scenes/player/procedural/torso_counter_roll.gd")
const ChainSolverClass := preload("res://scenes/player/procedural/chain_solver.gd")
const FlipRollClass := preload("res://scenes/player/procedural/flip_roll.gd")
const ArmPhysicsClass := preload("res://scenes/player/procedural/arm_physics.gd")
const LegPhysicsClass := preload("res://scenes/player/procedural/leg_physics.gd")
const TwoBoneIKClass := preload("res://scenes/creatures/two_bone_ik.gd")

# ---- Resources ----
@export var pose_lib: Resource: set = _set_pose_lib

# ---- Locomotion Config ----
@export_group("Locomotion")
@export var walk_stride_length: float = 4.0
@export var run_stride_length: float = 6.0
@export var walk_speed_threshold: float = 0.6
@export var run_speed_threshold: float = 5.0

# ---- IK Systems ----
@export_group("IK")
@export var enable_foot_ik: bool = true
@export var enable_chest_ik: bool = true
@export var enable_head_ik: bool = true
## DrawLeg: keep the feet on foot IK's keys when the pelvis moves (idle sway, look
## balance). Was false "bone axis assumption needs fixing" -- the axis was fine, the
## joints were read one bone too high; ChainSolver.solve_leg_rigify is the fix.
@export var enable_chain_solvers: bool = true

# ---- State (fed by player controller) ----
var horizontal_speed: float = 0.0
var vertical_velocity: float = 0.0
var velocity: Vector3 = Vector3.ZERO       ## Full 3D velocity (for acceleration tilt)
var is_grounded: bool = true
var is_jumping: bool = false
var duck_amount: float = 0.0
var facing_direction: Vector3 = Vector3.FORWARD  ## Model forward (for idle look)
var wall_run_active: bool = false
var wall_run_normal: Vector3 = Vector3.ZERO
var model_up: Vector3 = Vector3.UP  ## the MODEL node's up, for the torso counter-roll
var local_up: Vector3 = Vector3.UP  ## Override for spherical gravity
var on_ledge: bool = false
var ledge_normal: Vector3 = Vector3.ZERO
var is_climbing_ledge: bool = false
var is_pre_jumping: bool = false
## STEP HOP (docs/player-step-up-design.md §4): crouch on the LEAD leg, push, land on the
## step. Fed by the controller (derived for remote players); STEP_HOP replaces
## JUMP/FALL/LAND for it, so hopping up a flight never plays a fall.
var is_step_hopping: bool = false
var step_crouching: bool = false
var step_lead: int = 0                 # 0 = left leg leads, 1 = right
var is_wall_climbing: bool = false
var climb_grip_positions: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO]
var climb_grip_weights: Array[float] = [1.0, 1.0, 1.0, 1.0]
var climb_grip_normals: Array[Vector3] = [Vector3.ZERO, Vector3.ZERO, Vector3.ZERO, Vector3.ZERO]
var climb_wall_normal: Vector3 = Vector3.ZERO
var is_grappling: bool = false
var grapple_anchor_direction: Vector3 = Vector3.ZERO  ## Direction from player to anchor
var grapple_swing_speed: float = 0.0
var aim_direction: Vector3 = Vector3.ZERO  ## Camera aim direction (for arm IK when not grappling)
var surface_up: Vector3 = Vector3.UP       ## Current surface up from controller (for wall climb)
## The BODY's smoothed contact normal, from the controller. FootIK's swing
## clearance needs the slope of the ground the character is walking on, and the
## swing foot -- the one that needs clearing -- has no contact to read it from.
var ground_normal: Vector3 = Vector3.UP
## 1 while the ski key is held, 0 otherwise, smoothed here rather than in the
## controller so the pose eases and the physics does not.
var ski_holding: bool = false
var ski_amount: float = 0.0

# ---- Hip Drop (Overgrowth: duck_amount → physical hip offset, line 8458) ----
var hip_drop_max: float = 0.45  ## Max hip drop at duck_amount=1.0 (meters)

# ---- Climb Undulation (lizard body wave) ----
var _climb_wave_time: float = 0.0
const CLIMB_WAVE_AMPLITUDE: float = 0.12   ## ~7 degrees lateral wave
const CLIMB_WAVE_FREQUENCY: float = 3.0    ## Cycles per unit speed
const CLIMB_WAVE_SPINE_PHASE: float = 0.7  ## Phase offset between spine segments

# ---- Transition speeds (crossfade) ----
var fade_speed_idle: float = 6.0
var fade_speed_walk: float = 8.0
var fade_speed_jump: float = 10.0
var fade_speed_land: float = 12.0
var fade_speed_roll: float = 14.0
var fade_speed_fall: float = 12.0
var fade_speed_wall_run: float = 10.0
var fade_speed_ledge: float = 8.0

# ---- Breathing (Overgrowth Breathe() lines 2322-2350) ----
var _breath_time: float = 0.0
var _breath_speed: float = 0.9
var _breath_amount: float = 0.0

# ---- Idle weight shift (Overgrowth lines 14408-14447) ----
var _idle_stance_amount: float = 0.0  ## 0=moving, 1=standing still
var _idle_time: float = 0.0
var _com_offset: Vector3 = Vector3.ZERO
var _target_com_offset: Vector3 = Vector3.ZERO
var _com_offset_vel: Vector3 = Vector3.ZERO
var _com_shift_timer: float = 0.0

# ---- Idle look around (Overgrowth UpdateEyeLookTarget lines 4247-4313) ----
var _random_look_dir: Vector3 = Vector3.ZERO
var _random_look_delay: float = 1.0

# ---- Acceleration Tilt (Overgrowth: HandleAccelTilt, lines 7920-7938) ----
## Two components: velocity-based tilt + acceleration tilt.
## Applied as rotation of ALL bones around COM (like flip/roll).
## tilt_axis = cross(UP, tilt_direction), angle = length(tilt) / 180 * PI
const TILT_INERTIA_GROUND: float = 0.9    ## Overgrowth exact
const TILT_INERTIA_AIR: float = 0.95      ## Overgrowth exact
const ACCEL_TILT_INERTIA: float = 0.95    ## Overgrowth: pow(0.95, frames) for accel blend
const ACCEL_TILT_DECAY: float = 0.8       ## Overgrowth: pow(0.8, frames) when not moving
## THE HARD CEILING ON BODY LEAN, IN DEGREES.
## _tilt's magnitude IS the rotation applied to every bone about the COM: the
## angle is tilt_length/180*PI radians, so |_tilt| == degrees. Nothing bounded it.
##
## `accel = (velocity - _old_velocity) * 120.0 / frames` with frames ~= 1, so any
## single-frame velocity change is multiplied by 120 -- a landing or a wall impact
## injects hundreds. MEASURED in play (F10 dump, /tmp/hypertps-diag.log):
##   f=700  falling            |tilt| = 994.24   (2.8 full turns)
##   f=737  just landed        |tilt| = 936.06
##   f=1161 mid wall run       |tilt| = 836.41
##   f=1232 STANDING, hspd=0   |tilt| = 224.33
## and _accel_tilt decays slowly, so one impact leaves the character rotated for
## seconds afterwards. That is the reported spin: the MODEL node stays upright
## (modeltilt 2.6 in the same frames) while the BONES are turned hundreds of
## degrees, which is why every model-space gate read it as fine.
##
## 20 degrees is a lean. Overgrowth's own numbers are reproduced faithfully above
## and the divergence is in units, not in the formula, so the ceiling goes here
## rather than in the constants.
const MAX_TILT_DEG: float = 20.0

## How far the PELVIS may end up from the body's own up, in degrees, after every
## other system has had its say. 0 disables the guard. See Phase 12.
@export var max_pelvis_deviation_deg: float = 35.0

## WALL HUG. During a wall run, bring the pelvis and the upper torso closer to
## the wall, as a fraction of the capsule radius (0.3 m) -- so 0.2 is 6 cm each.
## A wall runner is not a standing figure translated sideways; the mass leads and
## the body is nearer the surface than the feet alone would put it.
@export_range(0.0, 1.0, 0.05) var wall_hug_hip: float = 0.2
@export_range(0.0, 1.0, 0.05) var wall_hug_chest: float = 0.2
const WALL_HUG_REFERENCE := 0.3   ## capsule radius, player_procedural.tscn

## How much of FootIK's planting authority survives on a WALL. At 1.0 the feet are
## pinned to the wall plane and whichever locomotion cycle is playing is flattened
## into a shuffle -- measured 0.68 m of along-wall foot travel against a run
## stride of 1.5-2 m. Below 1.0 the cycle shows through at the cost of the feet
## tracking the surface less exactly.
@export_range(0.0, 1.0, 0.05) var wall_foot_plant_weight: float = 0.45

## How far the ARMS go toward the fall pose during a wall run. The legs keep the
## run cycle regardless; this only touches the arm chain.
@export_range(0.0, 1.0, 0.05) var wall_arm_trail: float = 0.75
const ARM_BONES: Array[String] = [
	"DEF-shoulder.L", "DEF-upper_arm.L", "DEF-forearm.L", "DEF-hand.L",
	"DEF-shoulder.R", "DEF-upper_arm.R", "DEF-forearm.R", "DEF-hand.R",
]
var _tilt: Vector3 = Vector3.ZERO          ## Current tilt (smoothed)
var _target_tilt: Vector3 = Vector3.ZERO   ## Target tilt
var _accel_tilt: Vector3 = Vector3.ZERO    ## Acceleration component
var _old_velocity: Vector3 = Vector3.ZERO  ## Previous frame velocity for accel calc

# ---- Internal Animation State ----
var _cycle_phase: float = 0.0
@warning_ignore("unused_private_class_variable")
var _distance_traveled: float = 0.0  # Used for future stride sync

# Crossfade
var _active_pose: Dictionary = {}
var _fading_poses: Array = []

# State tracking
enum AnimState { IDLE, WALK, RUN, JUMP, FALL, LAND, ROLL, WALL_RUN, LEDGE_HANG, LEDGE_CLIMB, WALL_CLIMB, GRAPPLE, STEP_HOP }
## The stride at the moment a step hop began: its legs are what the hop bends.
var _hop_stride: Dictionary = {}
var _anim_state: int = AnimState.IDLE
var _land_timer: float = 0.0
var LAND_DURATION: float = 0.2

# Leg stride carry-over: freeze locomotion leg pose at jump time, blend out in air
var _jump_leg_pose: Dictionary = {}   # Locomotion pose captured at jump start
var _jump_leg_blend: float = 0.0      # 1.0 at jump, fades to 0.0
const JUMP_LEG_FADE_SPEED: float = 2.5  # How fast the stride legs blend out (per second)
const LEG_BONES: Array = [
	"DEF-thigh.L", "DEF-shin.L", "DEF-foot.L", "DEF-toe.L",
	"DEF-thigh.R", "DEF-shin.R", "DEF-foot.R", "DEF-toe.R",
]

# IK systems (spring-damper state holders)
var _foot_ik: RefCounted = null
var _chest_ik: RefCounted = null
var _head_ik: RefCounted = null
var _torso_roll: RefCounted = null

# Look direction (set by controller)
var look_direction: Vector3 = Vector3.ZERO

# Flip/Roll system (Overgrowth fliproll.as)
var _flip_roll: RefCounted = null
## Cached at load: the bone-rotation diagnostic runs every frame.
static var _dbg_bones_on: bool = OS.has_environment("HTPS_MODELLOG")

# Arm secondary physics (Overgrowth DrawArms Verlet, lines 12420-12698)
var _arm_physics: RefCounted = null

# Leg secondary physics (Verlet dangle during grapple)
var _leg_physics: RefCounted = null

# Output: world-space right hand bone position (for grapple rope origin)
var right_hand_world_pos: Vector3 = Vector3.ZERO

# Key transform indices (Overgrowth line 13631-13639)
enum KeyTransform { HEAD, LEFT_ARM, RIGHT_ARM, LEFT_LEG, RIGHT_LEG, CHEST, HIP, COUNT }

# Bone name mapping for Rigify DEF- convention
const BONE_MAP := {
	"hip": "DEF-spine",
	"spine": "DEF-spine.001",
	"chest": "DEF-spine.003",
	"neck": "DEF-spine.005",
	"head": "DEF-spine.006",
	"thigh_l": "DEF-thigh.L",
	"shin_l": "DEF-shin.L",
	"foot_l": "DEF-foot.L",
	"toe_l": "DEF-toe.L",
	"thigh_r": "DEF-thigh.R",
	"shin_r": "DEF-shin.R",
	"foot_r": "DEF-foot.R",
	"toe_r": "DEF-toe.R",
	"shoulder_l": "DEF-shoulder.L",
	"upper_arm_l": "DEF-upper_arm.L",
	"forearm_l": "DEF-forearm.L",
	"hand_l": "DEF-hand.L",
	"shoulder_r": "DEF-shoulder.R",
	"upper_arm_r": "DEF-upper_arm.R",
	"forearm_r": "DEF-forearm.R",
	"hand_r": "DEF-hand.R",
}

# Key masses for COM estimate (Overgrowth-style approximate weights)
const KEY_MASSES: Array[float] = [
	0.08,  # HEAD
	0.06,  # LEFT_ARM
	0.06,  # RIGHT_ARM
	0.16,  # LEFT_LEG
	0.16,  # RIGHT_LEG
	0.32,  # CHEST
	0.16,  # HIP
]


func _set_pose_lib(value: PoseLibrary) -> void:
	pose_lib = value
	if pose_lib:
		print("[ProceduralAnimator] PoseLibrary set with ", pose_lib.get_pose_names().size(), " poses: ", pose_lib.get_pose_names())
	# Pre-create foot IK so exclude_rid can be set before first _process_modification
	if _foot_ik == null and enable_foot_ik:
		_foot_ik = _make_foot_ik()


## A foot touched down (0 left, 1 right) — FootIK's swing falling back to planted while
## grounded. The player plays its footstep on this, so steps follow the stride.
signal foot_planted(foot: int)


func _make_foot_ik() -> RefCounted:
	var f: RefCounted = FootIKClass.new()
	f.set("on_touchdown", func(foot: int) -> void: foot_planted.emit(foot))
	return f


## Start a ground roll in the given direction.
func start_roll(direction: Vector3) -> void:
	if _flip_roll == null:
		_flip_roll = FlipRollClass.new()
	_flip_roll.start_roll(direction)


## Start an air flip (double jump).
func start_flip(facing_direction: Vector3) -> void:
	if _flip_roll == null:
		_flip_roll = FlipRollClass.new()
	_flip_roll.start_flip(facing_direction)


## Jump during a ground roll -> a handspring (FlipRoll.start_handspring). False when the
## roll is not far enough in, or there is no roll.
func start_handspring() -> bool:
	return _flip_roll != null and _flip_roll.start_handspring()


func can_handspring() -> bool:
	return _flip_roll != null and _flip_roll.can_handspring()


## Whether the character is currently flipping/rolling.
func is_flipping() -> bool:
	return _flip_roll != null and _flip_roll.flipping


## Get roll velocity override (for player controller to apply).
func get_roll_velocity() -> Vector3:
	if _flip_roll == null:
		return Vector3.ZERO
	return _flip_roll.get_roll_velocity()


## ⛔ TAKE THE DELTA, DO NOT INVENT ONE.
##
## This overrode the DEPRECATED `_process_modification()`, which is passed
## nothing, and then called get_physics_process_delta_time() -- a constant 1/60
## regardless of frame rate. But Skeleton3D.modifier_callback_mode_process is
## left at Godot's default (IDLE), so the callback fires once per RENDERED FRAME.
## Every spring, damper and lerp below therefore advanced at fps/60 of real time.
##
## MEASURED, gait cycles per real second at a constant 8.00 m/s ground speed:
##     25 fps -> 0.5519    60 fps -> 1.3296    144 fps -> 3.1941
## exactly proportional to frame rate (0.415 vs 25/60 = 0.417; 2.402 vs
## 144/60 = 2.400) -- the limbs cycled 5.8x faster at 144 than at 25 while
## covering the SAME ground. Movement itself is frame-rate independent (0.33%
## across 12 runs), so this layer alone is what reads as "speed changes with fps".
##
## The engine already computes the right delta and hands it over
## (scene/3d/skeleton_3d.cpp): NOTIFICATION_INTERNAL_PROCESS advances by
## get_process_delta_time(), NOTIFICATION_INTERNAL_PHYSICS_PROCESS by
## get_physics_process_delta_time(), Skeleton3D::advance() ACCUMULATES it, and
## _process_modifiers() passes the total and resets it. So the correct delta
## follows modifier_callback_mode_process automatically -- switch that to PHYSICS
## and this function keeps working, with no edit here.
##
## Substituting our own delta was wrong twice over: once as the physics constant
## (ran fast with frame rate), once as get_process_delta_time() clamped to 1/30
## (ran SLOW below 30 fps, because the clamp bound). Both are guesses where the
## engine supplies a fact.
func _process_modification_with_delta(delta: float) -> void:
	var skeleton: Skeleton3D = get_skeleton()
	if not skeleton or not pose_lib:
		return

	var gx: Transform3D = skeleton.global_transform

	# ---- Phase 1: Pose selection + write to bones ----
	_update_cycle_phase(delta)
	_update_anim_state(delta)
	var target_pose: Dictionary = _compute_current_pose()
	var final_pose: Dictionary = _blend_with_fading(target_pose, delta)
	_apply_pose_to_skeleton(skeleton, final_pose)

	# ---- Phase 1.5: Hip drop handled by player controller ----
	# Overgrowth lowers the collision sphere offset, not skeleton bones.
	# We lower the model node (Node3D) in player_procedural.gd so the whole
	# skeleton moves down while feet stay at ground level via foot IK.

	# ---- Phase 1.6: Breathing (Overgrowth Breathe() lines 2322-2350) ----
	_update_breathing(skeleton, delta)

	# ---- Phase 1.7: Ledge hang arm raise ----
	if on_ledge:
		_apply_ledge_arm_raise(skeleton, gx)

	# ---- Phase 1.8: Acceleration tilt (Overgrowth HandleAccelTilt + UpdateTilt) ----
	_update_acceleration_tilt(delta)

	# ---- Phase 1.9: Idle systems (weight shift, stance amount) ----
	_update_idle_systems(delta)

	# ---- Phase 2: Extract key_transforms from posed skeleton ----
	var key_transforms: Array[Transform3D] = []
	key_transforms.resize(KeyTransform.COUNT)
	_extract_key_transforms(skeleton, gx, key_transforms)

	# ---- Phase 3: COM estimate (before IK) ----
	var original_com: Vector3 = _estimate_com(key_transforms)

	# ---- Phase 4: ChestIK — spring-damper on key_transforms[CHEST] ----
	# Pre-IK snapshot for feedback prevention
	var pre_ik_globals: Dictionary = _snapshot_bone_globals(skeleton)

	# Compute effective look direction (blend with idle random gaze)
	var effective_look: Vector3 = _compute_effective_look()

	# Disable chest IK during wall climb (fights prone spine), keep head IK
	if enable_chest_ik and not is_wall_climbing:
		if _chest_ik == null:
			_chest_ik = ChestIKClass.new()
		_chest_ik.look_direction = effective_look
		_chest_ik.idle_amount = _idle_stance_amount
		var chest_rot: Quaternion = _chest_ik.apply(skeleton, delta, pre_ik_globals)
		key_transforms[KeyTransform.CHEST] = Transform3D(
			Basis(chest_rot) * key_transforms[KeyTransform.CHEST].basis,
			key_transforms[KeyTransform.CHEST].origin)

	# ---- Phase 5: HeadIK — spring-damper on key_transforms[HEAD] ----
	if enable_head_ik:
		if _head_ik == null:
			_head_ik = HeadIKClass.new()
		_head_ik.look_direction = effective_look
		_head_ik.idle_amount = _idle_stance_amount
		_head_ik.apply(skeleton, delta, pre_ik_globals)
		# Re-extract head transform after head IK modified bones
		var head_idx: int = skeleton.find_bone(BONE_MAP["head"])
		if head_idx >= 0:
			key_transforms[KeyTransform.HEAD] = gx * skeleton.get_bone_global_pose(head_idx)

	# ---- Phase 5.5: Torso counter-roll — keep the head level with the GROUND ----
	# Wall running tilts the whole body toward the wall; without this the head
	# tilts with it. ChestIK and HeadIK cannot do this: both are yaw+pitch in the
	# skeleton's own frame and neither has a roll axis. Runs on the SAME pre-IK
	# snapshot so it composes rather than feeding back.
	if _torso_roll == null:
		_torso_roll = TorsoCounterRollClass.new()
	# Gated on the LEAN, not on the state: with wall_run_tilt at 0 the body does
	# not tilt, so there is nothing to counter and this must not touch the spine.
	var want_roll: bool = wall_run_active and model_up.angle_to(Vector3.UP) > 0.05
	_torso_roll.apply(skeleton, delta, want_roll, pre_ik_globals,
			model_up, facing_direction)
	# ---- Phase 5.6: Wall hug — pelvis and upper torso nearer the surface ----
	if wall_run_active and wall_run_normal.length_squared() > 0.01 \
			and (wall_hug_hip > 0.0 or wall_hug_chest > 0.0):
		# wall_run_normal points INTO the wall, so it is already the direction to
		# move toward it. Converted into each bone's PARENT frame, like every other
		# bone write here, so it composes rather than fighting the pose.
		var toward: Vector3 = wall_run_normal.normalized()
		for pair in [["hip", wall_hug_hip], ["chest", wall_hug_chest]]:
			var amt: float = float(pair[1]) * WALL_HUG_REFERENCE
			if amt <= 0.0:
				continue
			var bidx: int = skeleton.find_bone(BONE_MAP[String(pair[0])])
			if bidx < 0:
				continue
			var par: int = skeleton.get_bone_parent(bidx)
			var pbasis: Basis = (gx * skeleton.get_bone_global_pose(par)).basis if par >= 0 else gx.basis
			skeleton.set_bone_pose_position(bidx,
					skeleton.get_bone_pose_position(bidx) + pbasis.inverse() * (toward * amt))

	var head_idx_cr: int = skeleton.find_bone(BONE_MAP["head"])
	if head_idx_cr >= 0:
		key_transforms[KeyTransform.HEAD] = gx * skeleton.get_bone_global_pose(head_idx_cr)

	# ---- Phase 6: COM compensation from look rotation ----
	# Track accumulated body offset for fallback application when chain solvers disabled
	var _body_offset_accum: Vector3 = Vector3.ZERO
	if is_grounded:
		var post_look_com: Vector3 = _estimate_com(key_transforms)
		var balance_offset: Vector3 = original_com - post_look_com
		balance_offset.y = 0.0  # Only horizontal compensation
		key_transforms[KeyTransform.HEAD].origin += balance_offset
		key_transforms[KeyTransform.CHEST].origin += balance_offset
		key_transforms[KeyTransform.HIP].origin += balance_offset
		key_transforms[KeyTransform.LEFT_ARM].origin += balance_offset
		key_transforms[KeyTransform.RIGHT_ARM].origin += balance_offset
		_body_offset_accum += balance_offset

	# ---- Phase 6.5: Idle weight shift — apply COM sway to key_transforms ----
	if _idle_stance_amount > 0.01 and is_grounded:
		var sway: Vector3 = _com_offset * _idle_stance_amount
		# Overgrowth aschar.as:14423: target_com = (right_foot - left_foot) * com_offset.x
		# + facing * com_offset.z -- x is a FRACTION OF THE STANCE, not metres. Read
		# as metres it swung the body +/-5 cm instead of +/-5% of the foot spacing
		# (~1.5 cm), 3x what Overgrowth does.
		var feet: Vector3 = key_transforms[KeyTransform.RIGHT_LEG].origin - key_transforms[KeyTransform.LEFT_LEG].origin
		feet -= local_up * feet.dot(local_up)
		var body_offset: Vector3 = feet * sway.x + local_up * sway.y + facing_direction * sway.z
		key_transforms[KeyTransform.HIP].origin += body_offset
		key_transforms[KeyTransform.CHEST].origin += body_offset
		key_transforms[KeyTransform.HEAD].origin += body_offset
		_body_offset_accum += body_offset

	# ---- Phase 7: FootIK — raycasts + ground offsets ----
	# Disable foot IK during wall climb (it sweeps relative to gravity, not surface)
	if enable_foot_ik and not is_wall_climbing:
		if _foot_ik == null:
			_foot_ik = _make_foot_ik()
		# A wall run IS footed contact, just not with the ground: FootIK's first
		# act is `if not is_grounded: fade out; return`, and is_grounded is
		# is_on_floor(), which is false on a wall. And it sweeps along local_up, so
		# it has to be pointed at the WALL or it looks for ground below the player.
		var ik_grounded: bool = is_grounded or wall_run_active
		# -wall_run_normal, NOT wall_run_normal. _detect_walls stores
		# `-col.get_normal()`, and Godot's slide normal points OUT of the surface
		# toward the body, so wall_run_normal points INTO the wall. FootIK sweeps
		# from +up to -up, so "up" has to be the direction AWAY from the wall for
		# the sweep to travel toward it.
		# GROUND CONTACT WINS. wall_run_active can latch while you are, to every
		# appearance, walking: _detect_walls runs in AIRBORNE and sphere-sweeps 2 m
		# whenever sprint is held, is_on_floor() flickers as you cross steep ground,
		# and anything past 60 degrees passes its wall test. When that happened the
		# feet were sent hunting SIDEWAYS along the wall normal while the player stood
		# on a floor -- measured in wall_run_ground_gate as both feet missing
		# everything, so FootIK planted on nothing at all.
		#
		# A wall run is footed contact with a WALL, which by definition is not
		# happening while you are standing on the ground. So real ground beats a
		# latched flag, and only an ungrounded wall run redirects the sweep.
		var ik_up: Vector3 = local_up
		if wall_run_active and not is_grounded:
			ik_up = (-wall_run_normal).normalized()
		# On a wall, ik_up IS the surface normal, so the slope relative to it is
		# zero and the gait clearance correctly contributes nothing -- a wall run
		# is not a climb up a hill.
		var ik_surface: Vector3 = ik_up if wall_run_active else ground_normal
		# On a wall, hand FootIK less planting authority so the run cycle survives.
		_foot_ik.plant_weight = wall_foot_plant_weight if (wall_run_active and not is_grounded) else 1.0
		var ski_target: float = 1.0 if (ski_holding and is_grounded) else 0.0
		ski_amount = lerpf(ski_target, ski_amount, pow(0.05, delta * 60.0))
		_foot_ik.ski_amount = ski_amount
		_foot_ik.apply(skeleton, delta, ik_grounded, horizontal_speed, ik_up, ik_surface)
		# Re-extract leg/hip transforms after foot IK
		for i in range(2):
			var foot_bone: String = BONE_MAP["foot_l"] if i == 0 else BONE_MAP["foot_r"]
			var foot_idx: int = skeleton.find_bone(foot_bone)
			if foot_idx >= 0:
				key_transforms[KeyTransform.LEFT_LEG + i] = gx * skeleton.get_bone_global_pose(foot_idx)
		var hip_idx: int = skeleton.find_bone(BONE_MAP["hip"])
		if hip_idx >= 0:
			key_transforms[KeyTransform.HIP] = gx * skeleton.get_bone_global_pose(hip_idx)

	# ---- Phase 8: Final COM compensation (in air) ----
	if not is_grounded:
		var final_com: Vector3 = _estimate_com(key_transforms)
		var balance_offset: Vector3 = original_com - final_com
		for i in range(KeyTransform.COUNT):
			key_transforms[i].origin += balance_offset
		_body_offset_accum += balance_offset

	# ---- Phase 9: the body offset onto the pelvis, then DrawLeg ----
	# Overgrowth adds body_offset to hip_offset and DrawLeg solves each leg to its
	# UNCHANGED foot key, so the body sways over planted feet. Chest IK, head IK and
	# the arm physics already write their bones directly, so of the four chain
	# solvers only DrawLeg has anything left to do -- and the old one read the joints
	# one bone too high (see ChainSolver.solve_leg_rigify). With it off, shifting the
	# pelvis carried both feet: they slid 12-26 mm under the idle sway alone.
	#
	# ⛔ NO 1 cm DEAD ZONE. This was `length_squared() > 0.0001`, i.e. offsets
	# under 1 cm were DROPPED: as the idle sway crossed zero the whole body popped
	# 1 cm to the origin and back (probe_stand_wobble track: -0.0097 -> 0.0000,
	# 0.0000 -> 0.0100). A continuous offset must be applied continuously.
	if _body_offset_accum != Vector3.ZERO:
		var hip_idx: int = skeleton.find_bone(BONE_MAP["hip"])
		if hip_idx >= 0:
			# ⛔ IN THE PARENT BONE'S FRAME, not the skeleton's. A pose position is
			# relative to the bone's PARENT, and DEF-spine's parent is `root`, which
			# is rotated (its +Z runs up the chain). This used gx.basis.inverse(), so
			# every offset landed on the wrong axes -- measured: a 5 mm drop moved the
			# hip socket 5 mm with 0.00 mm of it downward. The sway's "up" pushed the
			# pelvis sideways, which is what put the straight idle legs out of reach.
			_shift_bone_world(skeleton, gx, hip_idx, _body_offset_accum)
			# The legs back onto the foot keys captured after Phase 7 (before this
			# shift). Grounded only: in the air Phase 8 moves the feet WITH the body
			# on purpose. Not on a wall (grip IK owns the limbs) or mid-flip
			# (Phase 10 turns the whole body after this).
			if enable_chain_solvers and is_grounded and not is_wall_climbing and not is_flipping():
				# Hips down just enough that both ankles stay within reach (DoHipIK).
				var up: Vector3 = gx.basis.y.normalized()
				var dl: float = ChainSolverClass.hip_drop_for_reach(skeleton, gx, BONE_MAP["thigh_l"], BONE_MAP["shin_l"],
						BONE_MAP["foot_l"], key_transforms[KeyTransform.LEFT_LEG], up)
				var dr: float = ChainSolverClass.hip_drop_for_reach(skeleton, gx, BONE_MAP["thigh_r"], BONE_MAP["shin_r"],
						BONE_MAP["foot_r"], key_transforms[KeyTransform.RIGHT_LEG], up)
				var drop: float = maxf(dl, dr)
				if drop > 0.0:
					_shift_bone_world(skeleton, gx, hip_idx, -up * drop)
				ChainSolverClass.solve_leg_rigify(skeleton, gx, BONE_MAP["thigh_l"], BONE_MAP["shin_l"],
						BONE_MAP["foot_l"], key_transforms[KeyTransform.LEFT_LEG])
				ChainSolverClass.solve_leg_rigify(skeleton, gx, BONE_MAP["thigh_r"], BONE_MAP["shin_r"],
						BONE_MAP["foot_r"], key_transforms[KeyTransform.RIGHT_LEG])

	# ---- Phase 9.5: Arm secondary physics (Verlet) ----
	if _arm_physics == null:
		_arm_physics = ArmPhysicsClass.new()
	_arm_physics.character_velocity = velocity
	_arm_physics.max_speed = 8.0
	_arm_physics.is_grounded = is_grounded
	_arm_physics.is_flipping = (_flip_roll != null and _flip_roll.flipping) or is_wall_climbing
	_arm_physics.is_grappling = is_grappling
	_arm_physics.local_up = local_up
	_arm_physics.grapple_anchor_dir = grapple_anchor_direction
	_arm_physics.apply(skeleton, delta)

	# ---- Phase 9.6: Spine + leg + trail arm Verlet (dangle during grapple) ----
	if _leg_physics == null:
		_leg_physics = LegPhysicsClass.new()
	_leg_physics.character_velocity = velocity
	_leg_physics.local_up = local_up
	_leg_physics.is_grappling = is_grappling
	_leg_physics.grapple_tension_dir = grapple_anchor_direction
	_leg_physics.grapple_anchor_dir = grapple_anchor_direction
	_leg_physics.apply(skeleton, delta)

	# ---- Phase 9.7: Grapple arm IK (AFTER spine Verlet — chest parent is final) ----
	if is_grappling and grapple_anchor_direction != Vector3.ZERO:
		_apply_grapple_ik(skeleton, delta)

	# Output right hand world position (for grapple rope visual origin)
	var _hand_r_idx: int = skeleton.find_bone(BONE_MAP["hand_r"])
	if _hand_r_idx >= 0:
		right_hand_world_pos = (gx * skeleton.get_bone_global_pose(_hand_r_idx)).origin

	# ---- Phase 10: Tilt + Flip/Roll — rotate ALL bones around COM ----
	# Overgrowth: flip_modifier = flip_rotation * tilt_rotation * flip_modifier (line 13931)
	if _flip_roll == null:
		_flip_roll = FlipRollClass.new()
	var flip_rot: Quaternion = _flip_roll.update(delta)

	# Build tilt rotation (Overgrowth lines 13928-13931)
	# Skip tilt during grapple — Verlet body physics handles orientation,
	# and tilt fights the trailing arm/leg positions.
	var tilt_rot: Quaternion = Quaternion.IDENTITY
	if not is_grappling:
		var tilt_length: float = _tilt.length()
		if tilt_length > 0.001:
			var tilt_axis: Vector3 = Vector3.UP.cross(_tilt).normalized()
			if tilt_axis.length_squared() > 0.001:
				tilt_rot = Quaternion(tilt_axis, tilt_length / 180.0 * PI)

	# Combined rotation
	var combined_rot: Quaternion = flip_rot * tilt_rot

	# BONE-SPACE ROTATION IS INVISIBLE TO EVERY MODEL-SPACE CHECK. This rotates ALL
	# bones about the COM and never touches the Model node, so a play session that
	# reproduced the body fully inverted printed NO [model] lines and NO [wall-run]
	# lines at all -- the Model stayed upright and no wall run was ever entered.
	# Whatever turns the character over lives here, not in update_model_orientation,
	# and every gate in scenes/test/ measures the Model node.
	#
	# Triggered on the symptom: only once the combined rotation is past ~25 degrees.
	if OS.has_environment("HTPS_MODELLOG"):
		var deg: float = rad_to_deg(2.0 * acos(clampf(absf(combined_rot.w), 0.0, 1.0)))
		if deg > 25.0:
			print("[bones] f=%d TOTAL=%5.1f deg  flip=%5.1f (flipping=%s)  tilt=%5.1f (|_tilt|=%.2f)  grounded=%s wr=%s"
					% [Engine.get_physics_frames(), deg,
					   rad_to_deg(2.0 * acos(clampf(absf(flip_rot.w), 0.0, 1.0))), str(is_flipping()),
					   rad_to_deg(2.0 * acos(clampf(absf(tilt_rot.w), 0.0, 1.0))), _tilt.length(),
					   str(is_grounded), str(wall_run_active)])
	if not combined_rot.is_equal_approx(Quaternion.IDENTITY):
		_apply_flip_rotation(skeleton, gx, combined_rot)

	# ---- Phase 10.5: Climb body undulation (lateral spine wave) ----
	if is_wall_climbing and skeleton:
		_apply_climb_undulation(skeleton, delta)

	# ---- Phase 11: Wall climb — spine deformation + limb IK ----
	if is_wall_climbing and skeleton:
		var gx_inv: Transform3D = gx.affine_inverse()

		# Per-spine-joint surface conforming via ROTATION (not position).
		# Each spine bone rotates to face the blended normal between
		# hand grips (front) and foot grips (back). This curves the spine
		# around floor→wall transitions without fighting the model position.
		var hand_normal: Vector3 = Vector3.ZERO
		var hand_count: int = 0
		var foot_normal: Vector3 = Vector3.ZERO
		var foot_count: int = 0
		for i in range(2):
			if climb_grip_weights[i] > 0.1:
				hand_normal += climb_grip_normals[i]
				hand_count += 1
		for i in range(2, 4):
			if climb_grip_weights[i] > 0.1:
				foot_normal += climb_grip_normals[i]
				foot_count += 1
		if hand_count > 0:
			hand_normal = hand_normal.normalized()
		else:
			hand_normal = surface_up
		if foot_count > 0:
			foot_normal = foot_normal.normalized()
		else:
			foot_normal = surface_up

		# Only apply spine conforming when normals actually differ (surface transition)
		if hand_normal.dot(foot_normal) < 0.95:
			var spine_blend: Array[float] = [0.0, 0.3, 0.7, 0.9]
			var spine_bones_conform: Array[String] = [
				BONE_MAP["hip"], BONE_MAP["spine"], BONE_MAP["chest"], BONE_MAP["neck"]
			]
			for si in range(spine_bones_conform.size()):
				var bone_idx: int = skeleton.find_bone(spine_bones_conform[si])
				if bone_idx < 0:
					continue
				var parent_idx: int = skeleton.get_bone_parent(bone_idx)
				if parent_idx < 0:
					continue
				# Blend normal for this bone
				var bone_normal: Vector3 = foot_normal.slerp(hand_normal, spine_blend[si])
				# Compute rotation from current surface_up toward this bone's local normal
				var rot_angle: float = surface_up.angle_to(bone_normal)
				if rot_angle < 0.01:
					continue
				var rot_axis: Vector3 = surface_up.cross(bone_normal)
				if rot_axis.length_squared() < 0.001:
					continue
				rot_axis = rot_axis.normalized()
				var conform_rot := Quaternion(rot_axis, rot_angle * 0.5)  # 50% blend
				var parent_global: Transform3D = gx * skeleton.get_bone_global_pose(parent_idx)
				var local_rot: Quaternion = (parent_global.basis.inverse() * Basis(conform_rot) * parent_global.basis).get_rotation_quaternion()
				var current_rot: Quaternion = skeleton.get_bone_pose_rotation(bone_idx)
				skeleton.set_bone_pose_rotation(bone_idx, local_rot * current_rot)

		# Limb IK — hands and feet reach toward grip positions
		# Left hand (grip 0)
		if climb_grip_weights[0] > 0.1:
			var target_local: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, climb_grip_positions[0])).origin
			_two_bone_ik(skeleton, _bone_idx(skeleton, "upper_arm_l"),
				_bone_idx(skeleton, "forearm_l"), _bone_idx(skeleton, "hand_l"), target_local)
		# Right hand (grip 1)
		if climb_grip_weights[1] > 0.1:
			var target_local: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, climb_grip_positions[1])).origin
			_two_bone_ik(skeleton, _bone_idx(skeleton, "upper_arm_r"),
				_bone_idx(skeleton, "forearm_r"), _bone_idx(skeleton, "hand_r"), target_local)
		# Left foot (grip 2)
		if climb_grip_weights[2] > 0.1:
			var target_local: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, climb_grip_positions[2])).origin
			_two_bone_ik(skeleton, _bone_idx(skeleton, "thigh_l"),
				_bone_idx(skeleton, "shin_l"), _bone_idx(skeleton, "foot_l"), target_local)
		# Right foot (grip 3)
		if climb_grip_weights[3] > 0.1:
			var target_local: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, climb_grip_positions[3])).origin
			_two_bone_ik(skeleton, _bone_idx(skeleton, "thigh_r"),
				_bone_idx(skeleton, "shin_r"), _bone_idx(skeleton, "foot_r"), target_local)

	# ---- Phase 12: THE PELVIS MAY NOT END UP SIDEWAYS ----
	#
	# A bound on the composed result, deliberately, and not a root-cause fix.
	# SIX things can rotate this body -- the rest pose, the animation pose, FootIK's
	# hip solve and trunk counter, Chest/Head IK, the counter-roll, and Phase 10's
	# rotation of every bone about the COM -- and the visible orientation is their
	# PRODUCT. Chasing which one is responsible has failed repeatedly, and while it
	# failed the character stayed broken. A ceiling on the product is cheap, it is
	# checkable, and it holds no matter which contributor misbehaves.
	#
	# It is also a diagnostic: if the pelvis stops going sideways with this in, the
	# cause is upstream of here and _dbg_record's per-frame HIP= column says by how
	# much it was being pulled.
	#
	# Measured against the MODEL's up, not the world's, so a legitimate wall-run
	# lean (45 degrees by spec) and spherical gravity are untouched -- this only
	# catches the pelvis departing from the body it belongs to.
	# ⛔ NOT DURING A FLIP OR ROLL. Phase 10 turns the whole body a full 360 degrees
	# about the COM on purpose, and this clamp pulled the pelvis -- the parent of the
	# legs and spine -- back to 35 degrees every frame, so neither ever turned over
	# (gate_flip_roll: peak exactly 35.0 deg). It landed 2026-08-31; the roll and
	# double jump were reported broken the next day, alongside the Jolt switch.
	var flip_rotating: bool = _flip_roll != null and (_flip_roll.flipping or _flip_roll.settling)
	if max_pelvis_deviation_deg > 0.0 and not flip_rotating:
		var hip_i: int = skeleton.find_bone(BONE_MAP["hip"])
		if hip_i >= 0:
			var body_up: Vector3 = gx.basis.y.normalized()
			var hip_up: Vector3 = (gx * skeleton.get_bone_global_pose(hip_i)).basis.y.normalized()
			var dev: float = hip_up.angle_to(body_up)
			var lim: float = deg_to_rad(max_pelvis_deviation_deg)
			if dev > lim:
				var axis: Vector3 = hip_up.cross(body_up)
				if axis.length_squared() > 1e-8:
					# Rotate the pelvis back to the limit, in the bone's PARENT frame so
					# this composes with whatever produced the excess instead of
					# replacing it.
					var corr := Quaternion(axis.normalized(), dev - lim)
					var par: int = skeleton.get_bone_parent(hip_i)
					var pb: Basis = (gx * skeleton.get_bone_global_pose(par)).basis if par >= 0 else gx.basis
					var loc: Quaternion = (pb.inverse() * Basis(corr) * pb).get_rotation_quaternion()
					skeleton.set_bone_pose_rotation(hip_i,
							loc * skeleton.get_bone_pose_rotation(hip_i))


## Move a bone by a WORLD-space offset. Pose positions are in the PARENT bone's
## frame, so the offset is converted through the parent's global basis (as the wall
## hug in Phase 5.6 does), never through the skeleton's.
func _shift_bone_world(skeleton: Skeleton3D, gx: Transform3D, bone: int, world_offset: Vector3) -> void:
	var par: int = skeleton.get_bone_parent(bone)
	var pb: Basis = (gx * skeleton.get_bone_global_pose(par)).basis if par >= 0 else gx.basis
	skeleton.set_bone_pose_position(bone, skeleton.get_bone_pose_position(bone) + pb.inverse() * world_offset)


func _bone_idx(skeleton: Skeleton3D, key: String) -> int:
	return skeleton.find_bone(BONE_MAP[key])


## Two-bone IK: rotate upper and lower bones so the tip reaches target.
## target_pos is in skeleton-local space (NOT world space).
func _two_bone_ik(skeleton: Skeleton3D, upper_idx: int, lower_idx: int,
		tip_idx: int, target_pos: Vector3) -> void:
	TwoBoneIK.solve(skeleton, upper_idx, lower_idx, tip_idx, target_pos)


## Apply flip/roll rotation to ALL bones around center of mass.
## Matches Overgrowth's TransformAllFrameMats(flip_modifier) (aschar.as line 13936).
func _apply_flip_rotation(skeleton: Skeleton3D, gx: Transform3D,
		flip_rotation: Quaternion) -> void:
	# Compute COM from current bone poses
	var com: Vector3 = Vector3.ZERO
	var bone_count: int = skeleton.get_bone_count()
	for i in range(bone_count):
		com += (gx * skeleton.get_bone_global_pose(i)).origin
	com /= float(bone_count)

	# Rotate each root bone's position around COM, and add rotation to orientation
	for i in range(bone_count):
		var parent_idx: int = skeleton.get_bone_parent(i)
		if parent_idx >= 0:
			continue  # Only transform root bones; children follow

		var bone_global: Transform3D = gx * skeleton.get_bone_global_pose(i)
		# Rotate position around COM
		var offset: Vector3 = bone_global.origin - com
		var new_pos: Vector3 = com + flip_rotation * offset
		var pos_delta: Vector3 = gx.basis.inverse() * (new_pos - bone_global.origin)
		skeleton.set_bone_pose_position(i,
			skeleton.get_bone_pose_position(i) + pos_delta)
		# Add rotation to orientation
		var current_rot: Quaternion = skeleton.get_bone_pose_rotation(i)
		var local_rot: Quaternion = (gx.basis.inverse() * Basis(flip_rotation) * gx.basis).get_rotation_quaternion()
		skeleton.set_bone_pose_rotation(i, local_rot * current_rot)


## Extract 7 key transforms from the currently posed skeleton.
## These are world-space transforms of the key joints.
func _extract_key_transforms(skeleton: Skeleton3D, gx: Transform3D,
		key_transforms: Array[Transform3D]) -> void:
	# Legs
	var foot_l_idx: int = skeleton.find_bone(BONE_MAP["foot_l"])
	var foot_r_idx: int = skeleton.find_bone(BONE_MAP["foot_r"])
	if foot_l_idx >= 0:
		key_transforms[KeyTransform.LEFT_LEG] = gx * skeleton.get_bone_global_pose(foot_l_idx)
	if foot_r_idx >= 0:
		key_transforms[KeyTransform.RIGHT_LEG] = gx * skeleton.get_bone_global_pose(foot_r_idx)

	# Arms
	var hand_l_idx: int = skeleton.find_bone(BONE_MAP["hand_l"])
	var hand_r_idx: int = skeleton.find_bone(BONE_MAP["hand_r"])
	if hand_l_idx >= 0:
		key_transforms[KeyTransform.LEFT_ARM] = gx * skeleton.get_bone_global_pose(hand_l_idx)
	if hand_r_idx >= 0:
		key_transforms[KeyTransform.RIGHT_ARM] = gx * skeleton.get_bone_global_pose(hand_r_idx)

	# Chest
	var chest_idx: int = skeleton.find_bone(BONE_MAP["chest"])
	if chest_idx >= 0:
		key_transforms[KeyTransform.CHEST] = gx * skeleton.get_bone_global_pose(chest_idx)

	# Hip
	var hip_idx: int = skeleton.find_bone(BONE_MAP["hip"])
	if hip_idx >= 0:
		key_transforms[KeyTransform.HIP] = gx * skeleton.get_bone_global_pose(hip_idx)

	# Head
	var head_idx: int = skeleton.find_bone(BONE_MAP["head"])
	if head_idx >= 0:
		key_transforms[KeyTransform.HEAD] = gx * skeleton.get_bone_global_pose(head_idx)


## Estimate center of mass from key transforms (Overgrowth line 13644).
## Weighted average of limb midpoints.
func _estimate_com(key_transforms: Array[Transform3D]) -> Vector3:
	var com: Vector3 = Vector3.ZERO
	var total_mass: float = 0.0

	# Each limb COM ≈ midpoint between its root and tip
	# Legs: midpoint between hip and foot
	for i in range(2):
		var foot_pos: Vector3 = key_transforms[KeyTransform.LEFT_LEG + i].origin
		var hip_pos: Vector3 = key_transforms[KeyTransform.HIP].origin
		var mass: float = KEY_MASSES[KeyTransform.LEFT_LEG + i]
		com += (hip_pos + foot_pos) * 0.5 * mass
		total_mass += mass

	# Arms: midpoint between chest and hand
	for i in range(2):
		var hand_pos: Vector3 = key_transforms[KeyTransform.LEFT_ARM + i].origin
		var chest_pos: Vector3 = key_transforms[KeyTransform.CHEST].origin
		var mass: float = KEY_MASSES[KeyTransform.LEFT_ARM + i]
		com += (chest_pos + hand_pos) * 0.5 * mass
		total_mass += mass

	# Torso: midpoint between hip and chest
	var torso_mass: float = KEY_MASSES[KeyTransform.CHEST]
	com += (key_transforms[KeyTransform.HIP].origin + key_transforms[KeyTransform.CHEST].origin) * 0.5 * torso_mass
	total_mass += torso_mass

	# Head: midpoint between chest and head
	var head_mass: float = KEY_MASSES[KeyTransform.HEAD]
	com += (key_transforms[KeyTransform.CHEST].origin + key_transforms[KeyTransform.HEAD].origin) * 0.5 * head_mass
	total_mass += head_mass

	# Hip mass at hip position
	var hip_mass: float = KEY_MASSES[KeyTransform.HIP]
	com += key_transforms[KeyTransform.HIP].origin * hip_mass
	total_mass += hip_mass

	if total_mass > 0.0:
		com /= total_mass
	return com


# ---- Pose selection (unchanged) ----

func _update_cycle_phase(delta: float) -> void:
	var speed: float = horizontal_speed
	# During wall climb, use full velocity magnitude (surface-tangent speed)
	if is_wall_climbing:
		speed = velocity.length()

	if speed < walk_speed_threshold:
		return
	var stride: float = walk_stride_length
	if speed >= run_speed_threshold:
		stride = run_stride_length
	elif speed > walk_speed_threshold:
		var t: float = (speed - walk_speed_threshold) / (run_speed_threshold - walk_speed_threshold)
		stride = lerpf(walk_stride_length, run_stride_length, clampf(t, 0.0, 1.0))
	var distance_this_frame: float = speed * delta
	_cycle_phase += distance_this_frame / stride
	_cycle_phase = fmod(_cycle_phase, 1.0)


func _update_anim_state(delta: float) -> void:
	var new_state: int = _anim_state

	if _land_timer > 0.0:
		_land_timer -= delta
		if _land_timer <= 0.0:
			new_state = AnimState.IDLE

	# Fade out jump leg stride carry-over
	if _jump_leg_blend > 0.0:
		_jump_leg_blend = maxf(0.0, _jump_leg_blend - JUMP_LEG_FADE_SPEED * delta)

	# Traversal overrides (highest priority)
	if is_grappling:
		new_state = AnimState.GRAPPLE
	elif is_wall_climbing:
		new_state = AnimState.WALL_CLIMB
	elif on_ledge:
		new_state = AnimState.LEDGE_HANG
	elif is_climbing_ledge:
		new_state = AnimState.LEDGE_CLIMB
	elif _flip_roll and _flip_roll.flipping:
		new_state = AnimState.ROLL
	elif wall_run_active:
		new_state = AnimState.WALL_RUN
	elif is_step_hopping:
		new_state = AnimState.STEP_HOP
	elif not is_grounded:
		if vertical_velocity > 0.5:
			new_state = AnimState.JUMP
		else:
			new_state = AnimState.FALL
	elif _land_timer > 0.0:
		new_state = AnimState.LAND
	elif horizontal_speed >= run_speed_threshold:
		new_state = AnimState.RUN
	elif horizontal_speed >= walk_speed_threshold:
		new_state = AnimState.WALK
	else:
		new_state = AnimState.IDLE

	if _anim_state == AnimState.FALL or _anim_state == AnimState.JUMP:
		if is_grounded:
			new_state = AnimState.LAND
			_land_timer = LAND_DURATION

	if new_state != _anim_state:
		# Capture locomotion leg pose when jumping from walk/run
		if new_state == AnimState.JUMP and (_anim_state == AnimState.RUN or _anim_state == AnimState.WALK):
			if _anim_state == AnimState.RUN and pose_lib.has_pose("run_a"):
				_jump_leg_pose = _compute_locomotion_cycle("run_a", "run_b")
			else:
				_jump_leg_pose = _compute_locomotion_cycle("walk_a", "walk_b")
			_jump_leg_blend = 1.0
		if new_state == AnimState.STEP_HOP:
			_hop_stride = _compute_locomotion_cycle("walk_a", "walk_b") \
				if horizontal_speed < run_speed_threshold or not pose_lib.has_pose("run_a") \
				else _compute_locomotion_cycle("run_a", "run_b")
		_transition_to(new_state)
		_anim_state = new_state


func _transition_to(new_state: int) -> void:
	if _active_pose.is_empty():
		return
	var fade_speed: float = fade_speed_walk
	match new_state:
		AnimState.IDLE:
			fade_speed = fade_speed_idle
		AnimState.WALK, AnimState.RUN:
			fade_speed = fade_speed_walk
		AnimState.JUMP:
			fade_speed = fade_speed_jump
		AnimState.LAND:
			fade_speed = fade_speed_land
		AnimState.ROLL:
			fade_speed = fade_speed_roll
		AnimState.FALL:
			fade_speed = fade_speed_fall
		AnimState.WALL_RUN:
			fade_speed = fade_speed_wall_run
		AnimState.LEDGE_HANG:
			fade_speed = fade_speed_ledge
		AnimState.LEDGE_CLIMB:
			fade_speed = 14.0  # Fast snap
		AnimState.WALL_CLIMB:
			fade_speed = 10.0
		AnimState.GRAPPLE:
			fade_speed = 10.0
		AnimState.STEP_HOP:
			fade_speed = fade_speed_jump
	_fading_poses.append({
		"pose": _active_pose.duplicate(),
		"opacity": 1.0,
		"fade_speed": fade_speed,
	})


func _compute_current_pose() -> Dictionary:
	# Jump anticipation: blend current pose toward launch crouch during pre-jump
	var base_pose: Dictionary = _compute_base_pose()
	if is_pre_jumping and pose_lib.has_pose("jump_launch"):
		return PoseLibraryClass.lerp_pose(base_pose, _get_pose_safe("jump_launch"), duck_amount)
	return base_pose


func _compute_base_pose() -> Dictionary:
	match _anim_state:
		AnimState.IDLE:
			return _get_pose_safe("idle")
		AnimState.WALK:
			return _compute_locomotion_cycle("walk_a", "walk_b")
		AnimState.RUN:
			if pose_lib.has_pose("run_a"):
				var walk_pose: Dictionary = _compute_locomotion_cycle("walk_a", "walk_b")
				var run_pose: Dictionary = _compute_locomotion_cycle("run_a", "run_b")
				var t: float = clampf((horizontal_speed - run_speed_threshold) / (run_speed_threshold * 0.5), 0.0, 1.0)
				return PoseLibraryClass.lerp_pose(walk_pose, run_pose, t)
			return _compute_locomotion_cycle("walk_a", "walk_b")
		AnimState.JUMP:
			# Compute normal jump pose (jump_air → fall based on velocity)
			var jump_pose: Dictionary
			if pose_lib.has_pose("jump_air") and pose_lib.has_pose("fall"):
				var t: float = 1.0 - clampf(vertical_velocity / 2.0, 0.0, 1.0)
				jump_pose = PoseLibraryClass.lerp_pose(
					_get_pose_safe("jump_air"),
					_get_pose_safe("fall"), t)
			elif pose_lib.has_pose("jump_air"):
				jump_pose = _get_pose_safe("jump_air")
			else:
				jump_pose = _get_pose_safe("jump_launch")
			# Legs: if jumped from walk/run, blend frozen stride → fall directly
			# (skip jump_air legs entirely — stride IS the jump leg pose)
			if not _jump_leg_pose.is_empty():
				var fall_pose: Dictionary = _get_pose_safe("fall")
				var leg_t: float = 1.0 - _jump_leg_blend  # 0=stride, 1=fall
				for bone_name in LEG_BONES:
					if _jump_leg_pose.has(bone_name) and fall_pose.has(bone_name):
						jump_pose[bone_name] = _jump_leg_pose[bone_name].interpolate_with(
							fall_pose[bone_name], leg_t)
			return jump_pose
		AnimState.FALL:
			return _get_pose_safe("fall")
		AnimState.LAND:
			return _compute_land_pose()
		AnimState.ROLL:
			# A handspring goes over the top OPEN — the airborne jump pose, not the tuck.
			if _flip_roll != null and _flip_roll.handspring:
				return _get_pose_safe("jump_air")
			return _get_pose_safe("roll_start")
		AnimState.WALL_RUN:
			# THE RUN CYCLE, WHICH IS WHAT THE COMMENT ALWAYS CLAIMED.
			# This said "Run cycle with wall tilt applied procedurally" and then
			# returned walk_a/walk_b. A wall run only happens above the wall-run entry
			# speed -- measured at 12 m/s in play, against a run threshold of 5 -- so
			# the walk cycle was never the right pose for any frame of it, and it reads
			# on screen as a character walking sideways along a wall.
			#
			# Blended the same way AnimState.RUN blends, rather than hard-switched, so
			# entering a wall run slowly does not snap the legs.
			var wr_base: Dictionary
			if pose_lib.has_pose("run_a"):
				var wr_walk: Dictionary = _compute_locomotion_cycle("walk_a", "walk_b")
				var wr_run: Dictionary = _compute_locomotion_cycle("run_a", "run_b")
				var wr_t: float = clampf((horizontal_speed - run_speed_threshold)
						/ (run_speed_threshold * 0.5), 0.0, 1.0)
				wr_base = PoseLibraryClass.lerp_pose(wr_walk, wr_run, wr_t)
			else:
				wr_base = _compute_locomotion_cycle("walk_a", "walk_b")
			# THE ARMS TRAIL, THE LEGS RUN. Blends ONLY the arm bones toward the fall
			# pose -- the same swept-back arms a falling player already has -- and
			# leaves the run cycle driving everything else. A pumping run-arm swing is
			# wrong against a wall; the arms are along for the ride.
			return _blend_bones(wr_base, _get_pose_safe("fall"), ARM_BONES, wall_arm_trail)
		AnimState.LEDGE_HANG:
			# Fall pose as base — arms raised procedurally in Phase 1.8
			return _get_pose_safe("fall")
		AnimState.LEDGE_CLIMB:
			# Blend from fall to jump_launch (pulling up)
			return PoseLibraryClass.lerp_pose(
				_get_pose_safe("fall"), _get_pose_safe("jump_launch"), 0.7)
		AnimState.WALL_CLIMB:
			# Crouched idle as base; grip IK overrides all four limbs.
			# Blend locomotion cycle when moving for better spine motion.
			var climb_base: Dictionary = _get_pose_safe("idle")
			if pose_lib.has_pose("land"):
				climb_base = PoseLibraryClass.lerp_pose(climb_base, _get_pose_safe("land"), 0.5)
			if horizontal_speed > 0.3:
				var walk_cycle: Dictionary = _compute_locomotion_cycle("walk_a", "walk_b")
				var t: float = clampf(horizontal_speed / 3.0, 0.0, 0.6)
				climb_base = PoseLibraryClass.lerp_pose(climb_base, walk_cycle, t)
			return climb_base
		AnimState.GRAPPLE:
			# Fall pose as base, right arm IK overrides toward anchor
			return _get_pose_safe("fall")
		AnimState.STEP_HOP:
			return _compute_step_hop_pose()
	return _get_pose_safe("idle")


## THE STEP HOP (docs/player-step-up-design.md §4, the user's "slight single leg crouch
## at each step that creates the animation of generating force in that leg"):
##   crouching  the LEAD leg bends toward the launch crouch (it is loading); the trail
##              leg keeps its stride — one leg works, not both
##   airborne   the lead leg extends behind (the push), the trail leg tucks up and
##              forward to reach the step (the launch pose's bent knee)
## Over the stride captured when the hop began, so the upper body keeps running.
func _compute_step_hop_pose() -> Dictionary:
	var base: Dictionary = _hop_stride if not _hop_stride.is_empty() \
		else _compute_locomotion_cycle("walk_a", "walk_b")
	var lead_side := ".L" if step_lead == 0 else ".R"
	var trail_side := ".R" if step_lead == 0 else ".L"
	var lead: Array[String] = []
	var trail: Array[String] = []
	for b: String in LEG_BONES:
		if b.ends_with(lead_side):
			lead.append(b)
		elif b.ends_with(trail_side):
			trail.append(b)
	var crouch: Dictionary = _get_pose_safe("jump_launch")
	if crouch.is_empty():
		return base
	if step_crouching:
		return _blend_bones(base, crouch, lead, clampf(0.5 + duck_amount, 0.0, 0.9))
	var pose := _blend_bones(base, _get_pose_safe("fall"), lead, 0.5)
	return _blend_bones(pose, crouch, trail, 0.7)


## Blend ONLY `bones` from `a` toward `b`, leaving every other bone in `a` alone.
## Poses are bone_name -> Transform3D, so a subset blend is just a subset of keys.
static func _blend_bones(a: Dictionary, b: Dictionary, bones: Array[String],
		t: float) -> Dictionary:
	if t <= 0.0:
		return a
	var out: Dictionary = a.duplicate()
	for bn in bones:
		if a.has(bn) and b.has(bn):
			out[bn] = (a[bn] as Transform3D).interpolate_with(b[bn], t)
	return out


func _compute_land_pose() -> Dictionary:
	# Overgrowth: landing compression comes from the crouch spring, not a separate pose.
	# The duck_amount is set on impact in _on_land() and the spring bounces back.
	# We blend based on duck_amount so the pose follows the spring.
	var recovery_pose: Dictionary = _get_pose_safe("idle")
	if horizontal_speed >= walk_speed_threshold:
		recovery_pose = _compute_locomotion_cycle("walk_a", "walk_b")
	# If we have a land pose, blend it by duck_amount (spring-driven)
	if pose_lib.has_pose("land"):
		return PoseLibraryClass.lerp_pose(recovery_pose, _get_pose_safe("land"), duck_amount)
	return recovery_pose


func _compute_locomotion_cycle(pose_a_name: String, pose_b_name: String) -> Dictionary:
	var pa: Dictionary = _get_pose_safe(pose_a_name)
	var pb: Dictionary = _get_pose_safe(pose_b_name)
	var pa_m: Dictionary = pose_lib.mirror_pose(pose_a_name)
	var pb_m: Dictionary = pose_lib.mirror_pose(pose_b_name)

	var frames: Array = [pa, pb, pa_m, pb_m]
	var segment: int = int(_cycle_phase * 4.0) % 4
	var local_t: float = fmod(_cycle_phase * 4.0, 1.0)

	var i0: int = (segment - 1 + 4) % 4
	var i1: int = segment
	var i2: int = (segment + 1) % 4
	var i3: int = (segment + 2) % 4

	return PoseLibraryClass.catmull_rom_pose(frames[i0], frames[i1], frames[i2], frames[i3], local_t)


func _get_pose_safe(pose_name: String) -> Dictionary:
	if pose_lib and pose_lib.has_pose(pose_name):
		return pose_lib.get_pose(pose_name)
	return {}


func _blend_with_fading(target: Dictionary, delta: float) -> Dictionary:
	_active_pose = target
	var result: Dictionary = target.duplicate()
	var i: int = _fading_poses.size() - 1
	while i >= 0:
		var fp: Dictionary = _fading_poses[i]
		fp["opacity"] -= delta * fp["fade_speed"]
		if fp["opacity"] <= 0.0:
			_fading_poses.remove_at(i)
			i -= 1
			continue
		result = PoseLibraryClass.lerp_pose(result, fp["pose"], fp["opacity"])
		i -= 1
	return result


func _snapshot_bone_globals(skeleton: Skeleton3D) -> Dictionary:
	var gx: Transform3D = skeleton.global_transform
	var result: Dictionary = {}
	for i in range(skeleton.get_bone_count()):
		result[skeleton.get_bone_name(i)] = gx * skeleton.get_bone_global_pose(i)
	return result


func _apply_pose_to_skeleton(skeleton: Skeleton3D, pose: Dictionary) -> void:
	for bone_name: String in pose:
		var bone_idx: int = skeleton.find_bone(bone_name)
		if bone_idx >= 0:
			var xform: Transform3D = pose[bone_name]
			skeleton.set_bone_pose_position(bone_idx, xform.origin)
			skeleton.set_bone_pose_rotation(bone_idx, xform.basis.get_rotation_quaternion())
			skeleton.set_bone_pose_scale(bone_idx, xform.basis.get_scale())


## Overgrowth acceleration tilt (HandleAccelTilt lines 7920-7938, UpdateTilt lines 4236-4241).
## Two components: velocity-based lean + acceleration-based lean.
## Applied as rotation of ALL bones around COM in Phase 10 (not per-bone).
func _update_acceleration_tilt(delta: float) -> void:
	var frames: float = delta * 60.0

	# During wall climb, suppress normal tilt (undulation handles body motion)
	if is_wall_climbing:
		_target_tilt = Vector3.ZERO
		_accel_tilt *= pow(ACCEL_TILT_DECAY, frames)
		_tilt = _tilt * pow(TILT_INERTIA_GROUND, frames)
		_old_velocity = velocity
		return

	if is_grounded:
		# Overgrowth: target_tilt = velocity * 0.5 / char_scale
		_target_tilt = velocity * 0.5
		_target_tilt.y = 0.0  # Only horizontal tilt

		# Acceleration tilt: (velocity - old_vel) * 120 / frames, blended with inertia
		var accel: Vector3 = (velocity - _old_velocity) * 120.0 / maxf(frames, 0.1)
		_accel_tilt = _accel_tilt.lerp(accel, 1.0 - pow(ACCEL_TILT_INERTIA, frames))

		if horizontal_speed < 0.1:
			# Overgrowth: faster decay when not moving
			_target_tilt = Vector3.ZERO
			_accel_tilt *= pow(ACCEL_TILT_DECAY, frames)

		_target_tilt += _accel_tilt
		_target_tilt.y = 0.0
	else:
		_accel_tilt = Vector3.ZERO

	_old_velocity = velocity

	# Wall run: lean into wall (tilt toward wall normal direction)
	if wall_run_active:
		# Lean body toward wall — tilt perpendicular to wall surface
		var wall_lean: Vector3 = -wall_run_normal * 8.0  # 8 degrees into wall
		wall_lean.y = 0.0
		_target_tilt += wall_lean

	# Smooth tilt toward target (Overgrowth UpdateTilt lines 4236-4241)
	var inertia: float = TILT_INERTIA_GROUND if is_grounded else TILT_INERTIA_AIR
	_tilt = _tilt * pow(inertia, frames) + _target_tilt * (1.0 - pow(inertia, frames))
	if _tilt.length() > MAX_TILT_DEG:
		_tilt = _tilt.normalized() * MAX_TILT_DEG


# ---- Breathing (Overgrowth Breathe() lines 2322-2350) ----
## Sinusoidal chest expansion. Frequency increases with speed, amplitude subtle.
func _update_breathing(skeleton: Skeleton3D, delta: float) -> void:
	var frames: float = delta * 60.0
	var speed_ratio: float = clampf(horizontal_speed / maxf(run_speed_threshold * 2.0, 0.1), 0.0, 1.0)

	# Overgrowth: target_breath_speed = 0.9 + 4.0 * speed_ratio
	var target_speed: float = 0.9 + 4.0 * speed_ratio
	var breath_inertia: float = lerpf(0.999, 0.99, speed_ratio)
	_breath_speed = lerpf(target_speed, _breath_speed, pow(breath_inertia, frames))
	_breath_speed = minf(_breath_speed, 5.0)

	_breath_time += delta * _breath_speed
	# Overgrowth: breath_amount = (sin(breath_time) * 0.5 + 0.5) * lerp(0.5, 1.0, speed/5.0)
	_breath_amount = (sin(_breath_time) * 0.5 + 0.5) * lerpf(0.5, 1.0, clampf(horizontal_speed / 5.0, 0.0, 1.0))

	# Apply subtle chest scale (DEF-spine.003)
	var chest_idx: int = skeleton.find_bone(BONE_MAP["chest"])
	if chest_idx >= 0:
		var current_scale: Vector3 = skeleton.get_bone_pose_scale(chest_idx)
		var breath_scale: float = _breath_amount * 0.02  # Very subtle
		skeleton.set_bone_pose_scale(chest_idx, current_scale + Vector3(breath_scale * 0.7, breath_scale, breath_scale * 0.7))


# ---- Idle systems (weight shift + stance amount) ----
func _update_idle_systems(delta: float) -> void:
	var frames: float = delta * 60.0

	# Update idle stance amount (Overgrowth lines 12079-12083)
	if horizontal_speed < 0.3:
		_idle_time += delta
		_idle_stance_amount = lerpf(1.0, _idle_stance_amount, pow(0.94, frames))
	else:
		_idle_time = 0.0
		_idle_stance_amount = lerpf(0.0, _idle_stance_amount, pow(0.98, frames))

	if _idle_stance_amount < 0.01:
		return

	# Random COM target (every 0.7-4.0 seconds)
	_com_shift_timer -= delta
	if _com_shift_timer <= 0.0:
		_target_com_offset = Vector3(
			randf_range(-0.05, 0.05),
			randf_range(-0.005, 0.005),
			randf_range(-0.01, 0.01))
		_com_shift_timer = randf_range(0.7, 4.0)

	# Spring toward target (Overgrowth aschar.as:14420-14422).
	# ⛔ `* delta`, NOT `* frames`. Overgrowth's term is `5.0f * time_step *
	# num_frames` -- the elapsed SECONDS. `frames` is delta*60, which made this
	# spring 60x stiffer (k = 300/s^2, damping ratio ~0.05): a 2.8 Hz ring on
	# every new random target instead of a slow weight shift.
	_com_offset_vel *= pow(0.97, frames)
	_com_offset_vel += (_target_com_offset - _com_offset) * 5.0 * delta
	_com_offset += _com_offset_vel * delta

	# Idle look around: update random gaze direction
	_random_look_delay -= delta
	if _random_look_delay <= 0.0:
		# Pick random direction biased toward facing (Overgrowth lines 4295-4310)
		var rand_dir: Vector3 = Vector3(
			randf_range(-1.0, 1.0),
			randf_range(-0.3, 0.3),
			randf_range(-1.0, 1.0)).normalized()
		# Ensure roughly forward-facing
		if rand_dir.dot(facing_direction) < 0.0:
			rand_dir = -rand_dir
		_random_look_dir = facing_direction.lerp(rand_dir, 0.5).normalized()
		_random_look_delay = randf_range(0.8, 2.0)


## Compute effective look direction, blending camera look with idle random gaze.
func _compute_effective_look() -> Vector3:
	if _idle_stance_amount < 0.01 or look_direction.length_squared() < 0.001:
		return look_direction
	if _random_look_dir.length_squared() < 0.001:
		return look_direction
	# Blend: more idle = more random gaze, but never fully override camera
	return look_direction.lerp(_random_look_dir, _idle_stance_amount * 0.4).normalized()


## Raise arms procedurally for ledge hang (no authored pose needed).
## Rotates upper arms upward to simulate hands gripping ledge.
func _apply_ledge_arm_raise(skeleton: Skeleton3D, gx: Transform3D) -> void:
	for side in ["upper_arm_l", "upper_arm_r"]:
		var idx: int = skeleton.find_bone(BONE_MAP[side])
		if idx < 0:
			continue
		# Rotate upper arm to point upward (~150 degrees from rest)
		var parent_idx: int = skeleton.get_bone_parent(idx)
		if parent_idx < 0:
			continue
		var parent_global: Transform3D = gx * skeleton.get_bone_global_pose(parent_idx)
		# Target: arms pointing up and slightly forward
		var up_rot: Quaternion = Quaternion(Vector3.RIGHT, -2.6)  # ~150 degrees forward-up
		var local_rot: Quaternion = (parent_global.basis.inverse() * Basis(up_rot) * parent_global.basis).get_rotation_quaternion()
		var current_rot: Quaternion = skeleton.get_bone_pose_rotation(idx)
		# Blend 70% toward raised position (not fully, to keep some pose influence)
		skeleton.set_bone_pose_rotation(idx, current_rot.slerp(local_rot * current_rot, 0.7))


func _apply_grapple_ik(skeleton: Skeleton3D, _delta: float) -> void:
	var gx: Transform3D = skeleton.global_transform

	# Right arm IK toward grapple anchor direction
	var shoulder_idx: int = skeleton.find_bone(BONE_MAP["upper_arm_r"])
	var elbow_idx: int = skeleton.find_bone(BONE_MAP["forearm_r"])
	var hand_idx: int = skeleton.find_bone(BONE_MAP["hand_r"])
	if shoulder_idx < 0 or elbow_idx < 0 or hand_idx < 0:
		return

	var shoulder_pos: Vector3 = (gx * skeleton.get_bone_global_pose(shoulder_idx)).origin
	var elbow_pos: Vector3 = (gx * skeleton.get_bone_global_pose(elbow_idx)).origin
	var hand_pos: Vector3 = (gx * skeleton.get_bone_global_pose(hand_idx)).origin

	# Measure actual arm length from current bone chain (respects model scale)
	var upper_len: float = shoulder_pos.distance_to(elbow_pos)
	var lower_len: float = elbow_pos.distance_to(hand_pos)
	var arm_length: float = (upper_len + lower_len) * 0.95  # 95% reach — keeps slight bend

	# Target: extend arm toward anchor/aim direction (world space)
	var target_world: Vector3 = shoulder_pos + grapple_anchor_direction * arm_length

	# Convert to skeleton-local space (TwoBoneIK expects skeleton-local, not world)
	var gx_inv: Transform3D = gx.affine_inverse()
	var target_pos: Vector3 = (gx_inv * Transform3D(Basis.IDENTITY, target_world)).origin

	# Use TwoBoneIK for shoulder→elbow→hand
	TwoBoneIKClass.solve(skeleton, shoulder_idx, elbow_idx, hand_idx, target_pos)

	# Spine + leg dangle handled by LegPhysics Verlet (Phase 9.7).
	# Left arm trails freely via ArmPhysics grapple mode (Phase 9.5).


## Snake-like lateral spine undulation during lizard walk.
## Spine sways side-to-side in the surface plane, with a traveling wave
## from tail to head. Automatically aligns to whatever surface the body is on.
func _apply_climb_undulation(skeleton: Skeleton3D, delta: float) -> void:
	# Use full velocity for speed (not just horizontal — works on walls too)
	var climb_speed: float = velocity.length()
	if climb_speed < 0.1:
		# Smoothly decay wave when stopping
		_climb_wave_time *= 0.95
		return
	_climb_wave_time += delta * climb_speed * CLIMB_WAVE_FREQUENCY

	var gx: Transform3D = skeleton.global_transform

	# Wave axis = surface_up (perpendicular to surface).
	# Rotating spine bones around this axis creates LEFT-RIGHT sway
	# within the surface plane — like a snake slithering on the ground.
	# On walls: surface_up is horizontal, so sway is still lateral.
	# On ceilings: surface_up is DOWN, sway is still in the ceiling plane.
	var wave_axis: Vector3 = surface_up
	if wave_axis.length_squared() < 0.5:
		wave_axis = Vector3.UP

	# All spine bones including neck for full snake-like wave
	var spine_bones: Array[String] = [
		BONE_MAP["hip"], BONE_MAP["spine"], BONE_MAP["chest"], BONE_MAP["neck"]
	]
	for i in range(spine_bones.size()):
		var bone_idx: int = skeleton.find_bone(spine_bones[i])
		if bone_idx < 0:
			continue
		var parent_idx: int = skeleton.get_bone_parent(bone_idx)
		if parent_idx < 0:
			continue

		# Traveling wave: phase offset per bone creates wave from tail→head
		var phase: float = _climb_wave_time - float(i) * CLIMB_WAVE_SPINE_PHASE
		var angle: float = sin(phase) * CLIMB_WAVE_AMPLITUDE

		# Convert world wave axis to bone-local space
		var parent_global: Transform3D = gx * skeleton.get_bone_global_pose(parent_idx)
		var local_axis: Vector3 = (parent_global.basis.inverse() * wave_axis).normalized()
		if local_axis.length_squared() < 0.01:
			continue
		var wave_rot := Quaternion(local_axis, angle)
		var current_rot: Quaternion = skeleton.get_bone_pose_rotation(bone_idx)
		skeleton.set_bone_pose_rotation(bone_idx, wave_rot * current_rot)