OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / player / player_procedural.gd

4532 lines · gd ref:
extends CharacterBody3D

## Overgrowth-style player controller
## Ported from aschar.as, aircontrols.as, playercontrol.as, fliproll.as, ledgegrab.as
## All constants match Overgrowth defaults unless noted.

# ---- State Machine ----
enum State { MOVEMENT, AIRBORNE, WALL_CLIMB, GRAPPLE, GRAPPLE_WINCH, FLY, SWIM }
var state: int = State.MOVEMENT
var fly_mode: bool = false
var _pre_fly_position: Vector3 = Vector3.ZERO  # saved position before fly mode
## Quake liquid swim state (local player only)
var _in_liquid: bool = false
var _water_level: int = 0            # SV_CheckWater: 0 dry, 1 feet, 2 mid (swim), 3 eye (blend)
var _liquid_overlay: ColorRect = null

# ---- Movement Constants (Overgrowth exact) ----
const BASE_RUN_SPEED: float = 8.0
const TRUE_MAX_SPEED: float = 12.0
const WALK_ACCEL: float = 35.0
const STANDING_FRICTION: float = 0.95
const WALK_SPEED: float = 0.2           ## Walk key clamps target_velocity magnitude
const E: float = 2.71828

# ---- Gravity ----
const GRAVITY: float = 9.8
## Along-wall speed below which the facing keeps the direction it already had.
## Without hysteresis the sign flips every time along-wall velocity crosses zero.
const WALL_RUN_FACING_FLIP_SPEED: float = 1.5
## Per-frame fraction the smoothed tangent moves toward the raw one. Low enough to
## average out facet chatter, high enough to follow a wall that genuinely curves.
const WALL_RUN_TANGENT_SMOOTH: float = 0.08
const TERMINAL_VELOCITY: float = 50.0
const TERMINAL_DAMPING: float = 0.99

# ---- Jump ----
const JUMP_INITIAL_VELOCITY: float = 12.0   ## 3x Overgrowth (was 5.0) for higher jumps
const JUMP_AIR_CONTROL: float = 3.0
const AIR_SPEED_CAP: float = 14.0            ## Max horizontal speed from air control alone
const AIR_DECEL: float = 0.98                ## Friction when no input in air (per frame at 60fps)
const JUMP_FUEL: float = 5.0
const JUMP_FUEL_BURN: float = 10.0

# ---- Wall Run ----
const WALL_RUN_MIN_SPEED: float = 4.0
const WALL_RUN_FALL_LIMIT: float = -4.0

# ---- Wall Climb (Lizard Walk) ----
const STICK_FORCE: float = 10.0             ## Pushes body into surface for floor detection
const SURFACE_ALIGN_SPEED: float = 6.0      ## Slerp speed for capsule reorientation
const MODEL_ALIGN_SPEED: float = 8.0        ## Slerp speed for model basis alignment
const DEFAULT_FLOOR_MAX_ANGLE: float = 1.047  ## ~60 degrees (Godot default)

# ---- Crouch ----
const DUCK_ACCEL: float = 200.0       ## Stiffer spring (Overgrowth=120, ours higher for snappier feel)
const DUCK_VEL_INERTIA: float = 0.82  ## Less damping = faster settle (Overgrowth=0.89)
const DUCK_SPEED_MULT: float = 0.5

# ---- Thresholds ----
const RUN_THRESHOLD: float = 0.8
const WALK_THRESHOLD: float = 0.6
const GROUND_NORMAL_Y_THRESHOLD: float = 0.5

# ---- Fall Damage ----
const SHOCK_DAMAGE_THRESHOLD: float = 30.0
const SHOCK_DAMAGE_MULTIPLIER: float = 0.1

# ---- Turn ----
const TURN_VELOCITY: float = 15.0
const MOUSE_SENSITIVITY: float = 0.3

## Quake liquid swim (WinQuake/sv_user.c SV_WaterMove) + content blend (view.c V_CalcBlend).
const WATER_SPEED_SCALE: float = 0.7    # wishspeed *= 0.7 (water is 70% of ground speed)
const WATER_FRICTION: float = 4.0       # sv_friction
const WATER_ACCEL: float = 10.0         # sv_accelerate
const WATER_IDLE_SINK: float = 0.18     # idle: drift toward the bottom (Quake wishvel[2]-=60)
const LIQUID_BLEND := {
	"water": Color(130.0 / 255.0, 80.0 / 255.0, 50.0 / 255.0, 128.0 / 255.0),
	"lava": Color(255.0 / 255.0, 80.0 / 255.0, 0.0 / 255.0, 150.0 / 255.0),
	"slime": Color(0.0 / 255.0, 25.0 / 255.0, 5.0 / 255.0, 150.0 / 255.0),
}
const ZOOM_STEP: float = 0.5
const ZOOM_MIN: float = 1.0
const ZOOM_MAX: float = 20.0

# ---- Ledge Grab ----
const LEDGE_GRAB_MAX_FALL_SPEED: float = -20.0  ## Can't grab if falling faster
const LEDGE_GRAB_REACH: float = 1.2             ## How far above to check for ledge
const LEDGE_GRAB_FORWARD: float = 1.5           ## How far forward to check
const LEDGE_CLIMB_SPEED: float = 3.0            ## Speed of climb-up lerp
const WALL_GRAB_RANGE: float = 2.0              ## Raycast range for shift+wall grab

# ---- Landing Particles ----
const LandingParticles := preload("res://scenes/player/landing_particles.tscn")
const ProceduralAnimatorScript := preload("res://scenes/player/procedural/procedural_animator.gd")
const WallClimbClass := preload("res://scenes/player/procedural/wall_climb.gd")
const GrappleSystemClass := preload("res://scenes/player/procedural/grapple_system.gd")

# ---- Character Profile (exported for future species) ----
@export_group("Character Profile")
@export var run_speed: float = 8.0
@export var leg_sphere_size: float = 0.45

# ---- Procedural Animation (forwarded to ProceduralAnimator at runtime) ----
@export_group("Procedural Animation")
@export var hip_drop_max: float = 0.45          ## Max hip drop when crouching (meters)
@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
@export var enable_foot_ik: bool = true
@export var enable_chest_ik: bool = true
@export var enable_head_ik: bool = true

# ---- Jump Timing (forwarded to animator + controller) ----
@export_group("Jump Timing")
@export_range(0.0, 0.3, 0.01) var pre_jump_delay: float = 0.04     ## Anticipation crouch duration
@export_range(0.0, 0.3, 0.01) var jump_threshold_time: float = 0.05 ## Min ground time before jump
@export_range(5.0, 30.0, 1.0) var fade_speed_jump: float = 18.0     ## Crossfade into jump pose
@export_range(5.0, 30.0, 1.0) var fade_speed_land: float = 18.0     ## Crossfade into land pose
@export_range(5.0, 30.0, 1.0) var fade_speed_fall: float = 16.0     ## Crossfade into fall pose
@export_range(0.05, 0.8, 0.05) var land_duration: float = 0.2       ## Land pose hold time

# ---- Grapple (forwarded to GrappleSystem at runtime) ----
@export_group("Grapple")
@export_enum("Rope", "Winch") var grapple_mode: int = 1  ## 0=rope swing, 1=winch pull
@export var grapple_max_range: float = 50.0
@export var grapple_max_speed: float = 25.0
@export var grapple_reel_speed: float = 5.0
@export var grapple_rope_damping: float = 0.98
@export var grapple_launch_speed: float = 8.0    ## Initial impulse toward anchor when firing from ground
@export var grapple_pull_force: float = 15.0     ## Direct winch force when reeling in (Shift)
@export var grapple_ground_grace: float = 0.3    ## Seconds before ground contact releases grapple
@export_subgroup("Winch")
@export var winch_base_speed: float = 12.0       ## Default pull speed toward anchor (m/s)
@export var winch_fast_speed: float = 22.0       ## Pull speed when holding Shift
@export var winch_slow_speed: float = 5.0        ## Pull speed when holding Ctrl
@export var winch_gravity_scale: float = 0.6     ## Gravity multiplier during winch (0=no gravity, 1=full)
@export var winch_arrive_dist: float = 2.0       ## Auto-release when this close to anchor

# ---- Fly Mode ----
@export_group("Fly Mode")
## Fly speed. Raised for the 917 km world: at the old 60 m/s sprint, crossing it
## took over four hours. These are the BASE speeds; fly_speed_scale multiplies
## them and is what you actually reach for.
@export var fly_speed: float = 50.0
@export var fly_speed_fast: float = 250.0

## Runtime multiplier on both, adjusted with ALT + mouse wheel while flying.
##
## NOT ctrl: ctrl is crouch, which in fly mode is descend, so ctrl+wheel would
## mean "go down and change speed" at the same time. NOT shift either: that is
## sprint, i.e. the fast fly speed. Alt is the only free modifier -- alt_pressed
## appears zero times in project.godot's input map.
##
## Plain wheel still zooms the spring arm, which _unhandled_input's own comment
## says is intended while flying, so this takes a modifier rather than stealing
## the wheel outright.
##
## The range spans four orders of magnitude on purpose: this world is 917 km
## across and a fixed speed cannot serve both "inspect a rock face" and "get to
## that mountain range". Top end is 250 * 200 = 50 km/s, which crosses the world
## in about 18 seconds.
var fly_speed_scale: float = 1.0
const FLY_SCALE_MIN := 0.1
const FLY_SCALE_MAX := 200.0
const FLY_SCALE_STEP := 1.4

## HOLD TO SKI, the way Hudbot's HappyJump did it: "the built in ski script, to
## be bound to Turn Left. When true, you can bind your jump key to it and use it
## instead of a ski script" (Tribes/Hudbot/New Prefs.txt). Tribes 2 later built
## the same thing in as hold-jump.
##
## Emergent skiing only starts where traction has already lost to gravity, past
## about 40 degrees. This is the agency half: hold jump and you ski at ANY angle,
## including the flat, where Tribes players kept "100% of your speed and
## momentum".
##
## A TAP STILL JUMPS. The hold has to outlast ski_hold_delay before the servo is
## suppressed, and pre_jump_delay (0.08 s) is shorter -- so holding gives you one
## hop and then skis, which is exactly what a Tribes player looked like.
@export_group("Skiing")
@export var ski_hold_enabled: bool = true
## How long jump must be held, on the ground, before it means ski rather than jump.
@export_range(0.0, 0.5, 0.01) var ski_hold_delay: float = 0.15
## Traction while holding. 0.0 is Tribes Classic: no grip, pure momentum.
@export_range(0.0, 1.0, 0.05) var ski_hold_traction: float = 0.0

## SKIING is emergent now, not a setting. In Tribes it was the suspension of
## ground friction while staying in contact for gravity -- players jump-spammed
## so friction never applied, and happymod/Hudbot's HappyJump automated it. There
## is no friction here any more, so what remains is traction: on steep ground the
## feet cannot push, gravity along the surface wins, and you slide and accumulate
## speed. Same mechanic, no knob.
## An OPTIONAL top-speed penalty for climbing, in the v = v_flat / (1 + k * tan)
## sense (Naismith). DEFAULT 0: the budget model already makes steep ground
## expensive by taking away traction, and stacking a speed cap on top of that is
## the double-charging that made moderate slopes feel sticky. Raise it only if
## climbing should also feel slow where you still have grip.
## THE GRADE AT WHICH THE FEET STOP HOLDING. Below it you can walk, with less
## left over to move with the steeper it gets; AT it the whole budget goes on not
## sliding, so you stop; ABOVE it the feet cannot hold and the ground carries you.
##
## One number, and it decides BOTH where climbing ends and where skiing begins --
## they are the same instant, because holding is paid before moving. That is not a
## simplification, it is what the model says.
##
## It used to be an unnamed 39.3 degrees that fell out of a smoothstep tied to
## floor_max_angle, so floor_max_angle read 60 and the character stopped at 39.
## 45 degrees is mu = 1.0, rubber on rock, and leaves a 15 degree band up to
## floor_max_angle where steep ground carries you.
@export var slide_angle: float = deg_to_rad(45.0)
@export_range(0.0, 4.0, 0.1) var slope_climb_cost: float = 0.0

# ---- Wall Run ----
@export_group("Wall Run")
## Traction GRANTED on a wall, which has none of its own. Reaches this value at
## WALL_RUN_MIN_SPEED along the wall and fades to nothing as that speed is lost.
@export_range(0.0, 2.0, 0.05) var wall_run_traction: float = 0.9
## How hard you press into the wall. Without a normal force there is no traction
## to grant, and no contact for _detect_walls to keep finding.
@export_range(0.0, 8.0, 0.5) var wall_run_press: float = 2.0
## How far the body leans off vertical during a wall run, as a fraction of the
## way from world up to the wall's outward normal. 0.5 would be ~45 degrees.
##
## ON, at ~45 degrees. It was 0 while the lean went through the two-mechanism
## path that flipped the player; it now has a single mechanism and a forward that
## cannot invert (see update_model_orientation), and the sign-flipping vector that
## caused both failures is deleted. 0 still turns it off.
## Fraction of the way from upright to the wall's outward normal, so 0.5 is 45
## degrees. Brought back toward vertical a little on request -- 0.4 is 36 degrees.
@export_range(0.0, 1.0, 0.05) var wall_run_tilt: float = 0.4

# ---- Lizard Walk Tuning ----
@export_group("Lizard Walk")
@export var lizard_back_offset: float = 0.8     ## Backward shift along facing (centers torso between grips)
@export var lizard_height: float = 0.4          ## Height above surface (bent-leg clearance)
@export var lizard_speed: float = 6.0           ## Max crawl speed (m/s)
@export var lizard_hand_forward: float = 0.40   ## Hand grip forward offset from body center
@export var lizard_hand_lateral: float = 0.5    ## Hand grip lateral spread (wider = arms further apart)
@export var lizard_foot_back: float = 0.3       ## Foot grip backward offset from body center
@export var lizard_foot_lateral: float = 0.5    ## Foot grip lateral spread

# ---- State Variables ----
var ground_normal: Vector3 = Vector3.UP
var gravity_dir: Vector3 = Vector3.DOWN  ## Set externally for spherical gravity
var ground_time: float = 0.0
var air_time: float = 0.0
var surface_friction: float = 1.0
var feet_moving: bool = false           ## Overgrowth: tracks if character has movement intent
var max_speed: float = 8.0              ## Recalculated per frame (slope, duck, sprint)

# Grapple
var grapple_grace_timer: float = 0.0  ## Grace period before ground-release kicks in
var _winch_anchor: Vector3 = Vector3.ZERO  ## Anchor point for winch mode
var _winch_active: bool = false
var _dc_terrain: Node3D = null  ## Cached DCTerrain for LOD freeze (found at runtime)

# Jump
var jump_fuel_remaining: float = 0.0
var down_jump_fuel: float = 0.0
var pre_jump_timer: float = 0.0
var jump_held: bool = false

## Hold-to-ski state. Accumulates only while grounded, so a jump in mid-air
## cannot arm it.
var _jump_hold_time: float = 0.0
var ski_holding: bool = false

# Sprint / Walk
var sprinting: bool = false
var walking: bool = false               ## Walk toggle (caps speed to 0.2x)

# Crouch
var duck_amount: float = 0.0
var duck_vel: float = 0.0
var target_duck_amount: float = 0.0

# Flip/Roll
var _has_double_jumped: bool = false

# Wall run
var wall_contact: bool = false
var wall_normal_detected: Vector3 = Vector3.ZERO
var wall_collider: Object = null
var wall_run_active: bool = false
var wall_run_time: float = 0.0
var wall_run_normal: Vector3 = Vector3.ZERO
## Which way along the wall tangent the model faces. Latched, see above.
var _wall_run_tangent_sign: float = 1.0
## Low-passed wall tangent, cosmetic facing only.
var _wall_run_tangent_s: Vector3 = Vector3.ZERO
var wall_run_direction: Vector3 = Vector3.ZERO
var _has_hit_wall: bool = false         ## Overgrowth: prevents re-triggering wall run
var _wall_climb: WallClimb = null
var _grapple: GrappleSystem = null
var _lizard_mode: bool = false  ## Debug toggle: force lizard walk on flat ground
var _climb_debug_timer: float = 0.0
var _climb_debug_visual: Node3D = null  ## Debug visualization for capsule/grips
var _climb_debug_frame_skip: int = 0
var _climb_debug_enabled: bool = false  ## Toggle with K key at runtime (OFF by default for performance)
## Wall-run diagnostics. The gate cannot reproduce a flickering wall contact --
## it holds the player against one wall -- so these log the two things that would
## make the lean tumble in play: the state toggling, and the target up swinging.
var _wr_was_active: bool = false
var _wr_last_up: Vector3 = Vector3.UP
var _climb_bob_phase: float = 0.0  ## Sine wave phase for torso bob during lizard walk
var _grip_particles: Array[CPUParticles3D] = []  ## 4 particle emitters at grip positions
var _climb_model_quat: Quaternion = Quaternion.IDENTITY  ## Slerped model orientation

# Surface alignment (lizard walk / wall climb)
var surface_up: Vector3 = Vector3.UP         ## Current capsule up direction
var _target_surface_up: Vector3 = Vector3.UP ## Slerped toward each frame
var _last_desired_velocity: Vector3 = Vector3.ZERO  ## Pre-slide velocity for post-slide recovery
var _camera_climb_forward: Vector3 = Vector3.FORWARD  ## Camera forward on surface tangent (top_level)
var _body_climb_forward: Vector3 = Vector3.FORWARD   ## Body forward on surface tangent (avoids wall_up degeneration)
var _grapple_rope_visual: MeshInstance3D = null

# Ledge grab
var _on_ledge: bool = false
var _ledge_point: Vector3 = Vector3.ZERO
var _ledge_normal: Vector3 = Vector3.ZERO
var _ledge_climb_progress: float = 0.0
var _ledge_start_pos: Vector3 = Vector3.ZERO
var _ledge_delay: float = 0.0

# Sync
var sync_state: PackedByteArray = PackedByteArray()
var _decoded_position: Vector3 = Vector3.ZERO
var _decoded_rotation_y: float = 0.0
var _decoded_mesh_rotation_y: float = 0.0
var _decoded_velocity: Vector3 = Vector3.ZERO
var _decoded_on_floor: bool = true
var _decoded_state: int = State.MOVEMENT
var _decoded_surface_up: Vector3 = Vector3.UP
var _last_sync_time: float = 0.0
var _last_synced_position: Vector3 = Vector3.ZERO
var _sync_velocity: Vector3 = Vector3.ZERO
const SYNC_INTERVAL: float = 0.05
const SNAP_DISTANCE: float = 5.0

# ---- STEP-UP (docs/player-step-up-design.md; user 2026-10-05) ----------------------
# The capsule (r 0.3, floor_max_angle 60°) rides an edge only up to ~0.15 m on its own,
# and our stairs rise 0.224 m a step, so every step was a wall (gate_step_up 6).
#   walk band  h <= step_up (16 units, 0.45 m — user 2026-10-05: "we need to be able to
#              run up much higher, say 16 max"): the body is MOVED onto the tread — no
#              velocity (the on-floor up-velocity clamp stays right), no airborne frame
#   leap band  step_up < h <= hop_max, RUNNING: a running leap launched BEFORE the riser
#              at the run's own speed, harder for a taller step (user: "the character
#              has to leap harder with every higher step so it needs to happen at a
#              similar speed as a normal run") — _try_leap
#              From (near) standstill: the single-leg crouch-hop (hop_crouch_s)
#   above      unchanged: jump / ledge grab
## Highest step walked up without leaving the floor (m). 0 = off.
@export var step_up: float = 0.45
## Highest step hopped up automatically when running into it (m). 0 = off.
@export var hop_max: float = 0.79
## Speed into the step needed to hop (m/s): brushing past a counter must not launch you.
@export var hop_min_speed: float = 1.0
## The lead leg's crouch before the push (s), at hop_max; shorter for lower steps.
@export var hop_crouch_s: float = 0.12
## How fast the drawn body catches up with a step (m/s): 3.6 = 6 cm a frame at 60 fps,
## above the ~3 m/s climb of running up our stairs.
@export var step_catchup_speed: float = 3.6
## Drawn-height offset (m) the model and camera start at after a sudden rise/drop
## while grounded; decays to 0 — a step reads as a step, not a teleport.
var _step_smooth: float = 0.0
var _hop_timer: float = 0.0           # > 0 while the lead leg crouches
var _hop: Dictionary = {}             # {h, dir, land} for the pending push
var _step_hopping: bool = false       # airborne part of a hop
var _hop_lead: int = 0                # 0 = left, 1 = right (alternates up a flight)
var step_stats := {"ups": 0, "downs": 0, "hops": 0}

# Internal
var _was_on_floor: bool = true
var _y_before_slide: float = 0.0
var _footstep_timer: float = 0.0
const FOOTSTEP_INTERVAL: float = 0.35
var _roll_face_angle: float = 0.0      ## Locked facing during roll

# Safety / recovery
## Auto-respawn floor, an ABSOLUTE Y. -200 is right for the hand-built maps,
## whose ground sits near zero. A GENERATED map declares its own vertical band
## and can sit far below that -- DC Erosion centres its 10.6 km of relief on
## zero (a physics constraint: Godot character physics degrades with ALTITUDE,
## measured in scenes/test/slope_travel_gate.gd), so its floor is -10900 and its
## valleys reach -5300. Against a fixed -200 every player standing in a valley
## would be auto-respawned where they stood.
##
## Footsteps played, and which foot each was (0 left, 1 right) — gate_footstep_rhythm.
var footstep_count: int = 0
var footstep_feet: Array[int] = []
## So it is resolved from the terrain's own floor_y, once, and left at the
## default when there is no generator to ask -- the built-in-generator maps,
## whose ground really is near zero.
const FALL_RESPAWN_Y_DEFAULT: float = -200.0
const FALL_RESPAWN_MARGIN: float = 500.0   ## below the floor, so the hard clip is never the trigger
var _fall_respawn_y: float = FALL_RESPAWN_Y_DEFAULT
var _fall_respawn_resolved: bool = false
const SAFETY_CHECK_INTERVAL: float = 0.25
var _safety_timer: float = 0.0
var _camera_stuck_time: float = 0.0
var _camera_unstick_pending_mask: int = -1  ## >=0 means restore mask next physics frame

# ---- Node References ----
@onready var model: Node3D = $Model
@onready var camera_head: Node3D = $CameraHead
@onready var camera: Camera3D = $CameraHead/CameraPivot/SpringArm3D/Camera3D
@onready var camera_pivot: Node3D = $CameraHead/CameraPivot
@onready var spring_arm: SpringArm3D = $CameraHead/CameraPivot/SpringArm3D
@onready var ground_ray: RayCast3D = $GroundRay
@onready var collision_standing: CollisionShape3D = $CollisionStanding
@onready var collision_crouching: CollisionShape3D = $CollisionCrouching
@onready var flashlight: OmniLight3D = $OmniLight3D
@onready var input_sync: Node = $InputSync
@onready var name_label: Label3D = $NameLabel
@onready var snd_jump: AudioStreamPlayer3D = $Sounds/Jump
@onready var snd_land: AudioStreamPlayer3D = $Sounds/Land
@onready var snd_footstep_1: AudioStreamPlayer3D = $Sounds/Footstep1
@onready var snd_footstep_2: AudioStreamPlayer3D = $Sounds/Footstep2
@onready var snd_footstep_3: AudioStreamPlayer3D = $Sounds/Footstep3
@onready var snd_light: AudioStreamPlayer3D = $Sounds/ToggleLight
var _footstep_sounds: Array[AudioStreamPlayer3D] = []

# ---- Procedural Animation ----
var _proc_animator: Node = null  # ProceduralAnimator (SkeletonModifier3D)


func _ready() -> void:
	_diag("[diag] player _ready: _dbg_model_on=%s  env=%s  authority=%s" % [str(_dbg_model_on), str(OS.has_environment("HTPS_MODELLOG")), str(is_multiplayer_authority())])
	# Discoverable by other systems (CreatureSpawner, etc.) via group lookup —
	# avoids hard NodePath refs that break when scene tree layout shifts (e.g.
	# game.gd spawning the player under Players/<id> instead of map-relative).
	add_to_group("player")

	_footstep_sounds = [snd_footstep_1, snd_footstep_2, snd_footstep_3]

	# Exclude own body from SpringArm3D collision
	spring_arm.add_excluded_object(get_rid())

	# Create ProceduralAnimator and attach to skeleton inside tumble model
	var tumble_model: Node3D = $Model/TumbleModel
	if tumble_model:
		var skeleton: Skeleton3D = _find_child_of_type(tumble_model, "Skeleton3D") as Skeleton3D
		if skeleton:
			var animator: SkeletonModifier3D = ProceduralAnimatorScript.new()
			animator.name = "ProceduralAnimator"
			# Load pose library if it exists
			var lib_path: String = "res://scenes/player/procedural/tumble_poses.tres"
			if ResourceLoader.exists(lib_path):
				animator.pose_lib = load(lib_path)
				print("[PlayerProcedural] Loaded PoseLibrary from ", lib_path)
			else:
				push_warning("[PlayerProcedural] No PoseLibrary at ", lib_path, " — extract poses first")
			skeleton.add_child(animator)
			# Disable the AnimationPlayer so it doesn't fight us
			var anim_player: AnimationPlayer = _find_child_of_type(tumble_model, "AnimationPlayer") as AnimationPlayer
			if anim_player:
				anim_player.active = false
			_proc_animator = animator
			# Forward exported tuning values
			animator.hip_drop_max = hip_drop_max
			animator.walk_stride_length = walk_stride_length
			animator.run_stride_length = run_stride_length
			animator.walk_speed_threshold = walk_speed_threshold
			animator.run_speed_threshold = run_speed_threshold
			animator.enable_foot_ik = enable_foot_ik
			animator.enable_chest_ik = enable_chest_ik
			animator.enable_head_ik = enable_head_ik
			animator.fade_speed_jump = fade_speed_jump
			animator.fade_speed_land = fade_speed_land
			animator.fade_speed_fall = fade_speed_fall
			animator.foot_planted.connect(_on_foot_planted)
			animator.LAND_DURATION = land_duration
			print("[PlayerProcedural] ProceduralAnimator attached to ", skeleton.get_path())
		else:
			push_warning("[PlayerProcedural] No Skeleton3D found in TumbleModel")

	# Forward jump timing exports to controller vars
	# pre_jump_delay and jump_threshold_time used directly from @export

	_wall_climb = WallClimbClass.new()
	_wall_climb.set_exclude(get_rid())

	_grapple = GrappleSystemClass.new()
	_grapple.max_range = grapple_max_range
	_grapple.max_grapple_speed = grapple_max_speed
	_grapple.reel_speed = grapple_reel_speed
	_grapple.rope_damping = grapple_rope_damping

	# Grapple rope visual
	var rope_mesh_inst := MeshInstance3D.new()
	rope_mesh_inst.name = "GrappleRopeVisual"
	var rope_imm_mesh := ImmediateMesh.new()
	rope_mesh_inst.mesh = rope_imm_mesh
	var rope_mat := StandardMaterial3D.new()
	rope_mat.albedo_color = Color(0.4, 0.35, 0.3)  # Brown rope
	rope_mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	rope_mesh_inst.material_override = rope_mat
	# Add as child with top_level so it doesn't rotate but gets freed with player
	rope_mesh_inst.top_level = true
	add_child(rope_mesh_inst)
	_grapple_rope_visual = rope_mesh_inst

	if is_local_player():
		camera.current = true
		Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
		name_label.visible = false
		_setup_liquid_overlay()
	else:
		camera.current = false
	_init_nameplate()


func is_local_player() -> bool:
	return is_multiplayer_authority()


## -1 = never pushed, 0 = false, 1 = true. Only cross into the GDExtension on
## a CHANGE: _derive_terrain_lod_frozen() calls this every physics frame.
var _lod_frozen_applied: int = -1

func _set_terrain_lod_frozen(frozen: bool) -> void:
	var want := 1 if frozen else 0
	if want == _lod_frozen_applied:
		return
	if _dc_terrain == null:
		# Find DCTerrain node once (has set_lod_frozen method from GDExtension)
		_dc_terrain = _find_node_with_method(get_tree().root, "set_lod_frozen")
	if _dc_terrain:
		_dc_terrain.set_lod_frozen(frozen)
		_lod_frozen_applied = want


## THE LOD FREEZE IS DERIVED, NOT LATCHED -- and that is a bug fix, not a tidy-up.
##
## lod_frozen_ gates the ENTIRE octree update (dc_terrain.cpp:690), so if it is
## ever left true the world stops refining and everything behind you keeps
## whatever LOD it had when the flag stuck. It used to be driven by twelve
## scattered calls -- three freeze sites (winch fire, grapple fire,
## _enter_wall_climb_on_surface) against nine unfreeze sites -- and one missed
## exit path out of any of those three states leaves it on for the rest of the
## session. Nine unfreeze sites is not thoroughness, it is the shape of a
## latch that has already been patched several times.
##
## A DERIVED FLAG CANNOT BE LOST. It is recomputed from `state` every physics
## frame, so however the player leaves WALL_CLIMB / GRAPPLE / GRAPPLE_WINCH --
## including paths nobody has written yet -- the freeze lifts on the next frame.
##
## Called at the TOP of _physics_process on purpose: _process_local has early
## returns, so anything at the bottom is exactly what a missed path skips.
## Using the previous frame's state costs one frame of lag on a signal that
## controls terrain refinement, which is invisible.
func _derive_terrain_lod_frozen() -> void:
	_set_terrain_lod_frozen(
		state == State.WALL_CLIMB
		or state == State.GRAPPLE
		or state == State.GRAPPLE_WINCH)


## Read the auto-respawn floor off the terrain's generator. Latches once the
## TERRAIN is found, not once this is first called: at the first safety check
## the map may still be loading, and latching then would pin the default for the
## rest of the session.
func _resolve_fall_respawn_y() -> void:
	if _dc_terrain == null:
		_dc_terrain = _find_node_with_method(get_tree().root, "set_lod_frozen")
	if _dc_terrain == null:
		return  # no terrain yet -- try again on the next safety check
	_fall_respawn_resolved = true
	var gen: Variant = _dc_terrain.get("generator")
	if gen == null or not gen.has_method("get_floor_y"):
		return  # built-in generator: the -200 default is correct
	_fall_respawn_y = float(gen.get_floor_y()) - FALL_RESPAWN_MARGIN
	DebugLog.log("game", "[player] auto-respawn floor from terrain floor_y: %.0f"
			% _fall_respawn_y)


func _find_node_with_method(node: Node, method: String) -> Node:
	if node.has_method(method):
		return node
	for child in node.get_children():
		var found: Node = _find_node_with_method(child, method)
		if found:
			return found
	return null


# ---- Input ----

func _unhandled_input(event: InputEvent) -> void:
	if not is_local_player():
		return
	if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
		# Degrees per pixel from Settings (Controls tab; was the fixed MOUSE_SENSITIVITY 0.3);
		# y is negative when "Invert mouse Y" is on.
		var look: Vector2 = Settings.mouse_look_scale()
		if state == State.WALL_CLIMB:
			# Camera is top_level during climb (Fix 2): rotate forward vector
			# around surface_up for yaw, pitch on pivot as normal.
			var yaw_angle: float = deg_to_rad(-event.relative.x * look.x)
			var yaw_rot: Basis = Basis(surface_up, yaw_angle)
			_camera_climb_forward = (yaw_rot * _camera_climb_forward).normalized()
			camera_pivot.rotate_x(deg_to_rad(-event.relative.y * look.y))
			camera_pivot.rotation.x = clampf(camera_pivot.rotation.x, deg_to_rad(-89), deg_to_rad(89))
		else:
			camera_head.rotate_y(deg_to_rad(-event.relative.x * look.x))
			camera_pivot.rotate_x(deg_to_rad(-event.relative.y * look.y))
			camera_pivot.rotation.x = clampf(camera_pivot.rotation.x, deg_to_rad(-80), deg_to_rad(80))
		if state == State.WALL_CLIMB:
			# Camera is top_level — derive yaw from forward vector
			input_sync.camera_rotation_y = atan2(-_camera_climb_forward.x, -_camera_climb_forward.z)
		else:
			input_sync.camera_rotation_y = camera_head.rotation.y
		input_sync.camera_pitch = camera_pivot.rotation.x
	# THE WHEEL BELONGS TO THE UI WHEN THERE IS A CURSOR. This was a raw
	# InputEventMouseButton check with no context gate — exactly the shape the
	# comment below describes for the winch — so in build mode's CURSOR sub-mode
	# scrolling a panel also zoomed the camera behind it. During RMB-hold temporary
	# look wants_pointer() is false, so the wheel zooms again, which is right: you
	# are flying, not pointing.
	if event is InputEventMouseButton and not GameMode.wants_pointer():
		var mb := event as InputEventMouseButton
		if mb.pressed:
			# ALT + wheel while flying sets fly speed instead of zooming. Ctrl is
			# crouch (descend in fly mode) and shift is sprint, so both are taken;
			# without the modifier the wheel keeps zooming, which is intentional.
			var alt_held: bool = Input.is_key_pressed(KEY_ALT)
			# Inside the Shift+F fly modal the PLAIN wheel is speed (Blender's), no Alt.
			if state == State.FLY and (alt_held or GameMode.is_fly_modal()):
				if mb.button_index == MOUSE_BUTTON_WHEEL_UP:
					fly_speed_scale = minf(fly_speed_scale * FLY_SCALE_STEP, FLY_SCALE_MAX)
				elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
					fly_speed_scale = maxf(fly_speed_scale / FLY_SCALE_STEP, FLY_SCALE_MIN)
				if mb.button_index == MOUSE_BUTTON_WHEEL_UP or mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
					print("[fly] speed x%.2f  ->  %.0f m/s walk, %.0f m/s sprint" % [
							fly_speed_scale, fly_speed * fly_speed_scale,
							fly_speed_fast * fly_speed_scale])
			elif mb.button_index == MOUSE_BUTTON_WHEEL_UP:
				spring_arm.spring_length = maxf(spring_arm.spring_length - ZOOM_STEP, ZOOM_MIN)
			elif mb.button_index == MOUSE_BUTTON_WHEEL_DOWN:
				spring_arm.spring_length = minf(spring_arm.spring_length + ZOOM_STEP, ZOOM_MAX)
	# THROUGH THE KEYMAP, so the CONTEXT governs it. As a raw is_action_pressed this
	# fired in build mode too — where `player` is suppressed and you have no body —
	# so a left click while flying fired the winch, overwrote `velocity` and threw
	# `state` out of FLY. That is "clicking left mouse button messes up flymode", and
	# it is the same shape as Ctrl+Z toggling slow motion: an action check no context
	# could see. Keymap.tapped consults the stack, so build mode simply never sees it.
	if Keymap.tapped(event, "grapple"):
		if _grapple.active or _winch_active:
			# Release — carry momentum
			if _winch_active:
				_winch_active = false
				_set_terrain_lod_frozen(false)
				state = State.AIRBORNE
				air_time = 0.0
			else:
				var carry_vel: Vector3 = _grapple.release()
				_set_terrain_lod_frozen(false)
				velocity = carry_vel
				state = State.AIRBORNE
				air_time = 0.0
		else:
			# Fire — raycast from camera crosshair into the world.
			var space := get_world_3d().direct_space_state
			var cam_dir: Vector3 = -camera.global_transform.basis.z
			var right_vec: Vector3 = camera_head.global_transform.basis.x
			var fire_origin: Vector3 = global_position + (-gravity_dir) * 1.2 + right_vec * 0.3
			if grapple_mode == 1:
				# Winch mode — simple raycast, no rope sim
				var query := PhysicsRayQueryParameters3D.new()
				query.from = fire_origin
				query.to = fire_origin + cam_dir * grapple_max_range
				query.exclude = [get_rid()]
				query.collision_mask = 0xFFFFFFFF
				var result: Dictionary = space.intersect_ray(query)
				if not result.is_empty():
					_winch_anchor = result["position"]
					_winch_active = true
					_set_terrain_lod_frozen(true)
					state = State.GRAPPLE_WINCH
					grapple_grace_timer = grapple_ground_grace
					wall_run_active = false
					_on_ledge = false
					if _wall_climb and _wall_climb.active:
						_wall_climb.stop()
					# Immediate launch toward anchor
					var to_anchor: Vector3 = (_winch_anchor - global_position).normalized()
					velocity = to_anchor * winch_base_speed
					if is_on_floor():
						var winch_up: Vector3 = -gravity_dir
						var winch_vert: float = velocity.dot(winch_up)
						if winch_vert < winch_base_speed * 0.4:
							velocity += winch_up * (winch_base_speed * 0.4 - winch_vert)
					DebugLog.log("game", "[Winch] FIRED → anchor=%s dist=%.1f" % [_winch_anchor, global_position.distance_to(_winch_anchor)])
				else:
					DebugLog.log("game", "[Winch] MISS")
			else:
				# Rope mode — full rope simulation
				if _grapple.fire(fire_origin, cam_dir, space, [get_rid()]):
					_set_terrain_lod_frozen(true)
					state = State.GRAPPLE
					grapple_grace_timer = grapple_ground_grace
					wall_run_active = false
					_on_ledge = false
					if _wall_climb and _wall_climb.active:
						_wall_climb.stop()
					var to_anchor: Vector3 = (_grapple.anchor_point - global_position).normalized()
					var launch_vel: Vector3 = to_anchor * grapple_launch_speed
					if is_on_floor():
						var grapple_up: Vector3 = -gravity_dir
						var vert_comp: float = launch_vel.dot(grapple_up)
						if vert_comp < grapple_launch_speed * 0.5:
							launch_vel += grapple_up * (grapple_launch_speed * 0.5 - vert_comp)
					velocity = launch_vel
					DebugLog.log("game", "[Grapple] FIRED → anchor=%s dist=%.1f" % [_grapple.anchor_point, fire_origin.distance_to(_grapple.anchor_point)])
				else:
					DebugLog.log("game", "[Grapple] MISS")
	if Keymap.tapped(event, "toggle_light"):
		_toggle_light.rpc()
	if Keymap.tapped(event, "respawn"):
		_respawn.rpc()
	# Debug visual toggle. MOVED OFF K, which is the level editor's carve key — one
	# press used to do both.
	if Keymap.tapped(event, "player.climb_debug"):
		_climb_debug_enabled = !_climb_debug_enabled
		_wall_climb.debug_enabled = _climb_debug_enabled
		if not _climb_debug_enabled:
			# Clear debug visuals immediately
			if _climb_debug_visual:
				for child in _climb_debug_visual.get_children():
					child.queue_free()
			_wall_climb._clear_debug()
		DebugLog.log("game", "Climb visuals: %s" % ("ON" if _climb_debug_enabled else "OFF"))
	# Camera unstick (F8 key) — resets spring_arm to its zoom default and forces a
	# collision re-evaluation. Use when the camera is jammed against geometry and
	# refuses to extend back. Only addresses stale spring state — won't help if the
	# camera origin (CameraHead/CameraPivot) is itself inside a wall.
	if Keymap.tapped(event, "player.camera_unstick"):
		var prev_mask: int = spring_arm.collision_mask
		spring_arm.spring_length = 3.0
		spring_arm.collision_mask = 0
		await get_tree().physics_frame
		spring_arm.collision_mask = prev_mask
		DebugLog.log("game", "Camera unstuck (F8)")

	# F10: DUMP EVERYTHING, ON DEMAND. Four conditional instruments in a row read
	# nothing while the body was visibly wrong on screen, because each was gated on
	# the mechanism I happened to suspect -- so each could only confirm a guess, and
	# its silence was indistinguishable from the guess being wrong. This is gated on
	# nothing except you pressing it at the moment you can see the problem.
	if Keymap.tapped(event, "debug.player_dump"):   # Ctrl+F10 (was bare F10)
		_dump_state()

	# Lizard mode debug toggle -- Ctrl+Shift+F6. Was a bare `keycode == KEY_L`,
	# which is also builder.entity_links on the level map: one press did both.
	if Keymap.tapped(event, "player.lizard_debug"):
		_lizard_mode = !_lizard_mode
		if _lizard_mode:
			# Enter lizard walk on ground — use ground normal as surface
			var sn: Vector3 = ground_normal if is_on_floor() else Vector3.UP
			_enter_wall_climb_on_surface(sn)
			_measure_skeleton_anatomy()
			DebugLog.log("game", "[Lizard] ON — surface_up=%s" % sn)
		else:
			_exit_wall_climb()
			state = State.MOVEMENT
			ground_time = 0.0
			DebugLog.log("game", "[Lizard] OFF")
	if event.is_action_pressed("ui_cancel"):
		Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
	elif event is InputEventMouseButton and event.pressed and Input.mouse_mode == Input.MOUSE_MODE_VISIBLE:
		# See player.gd: ask the mode, never name a panel.
		if not GameMode.wants_pointer():
			Input.mouse_mode = Input.MOUSE_MODE_CAPTURED


# ---- Target Velocity (Overgrowth: GetTargetVelocity in playercontrol.as) ----

func _get_target_velocity() -> Vector3:
	var input_dir: Vector2 = input_sync.input_direction
	var local_up: Vector3 = -gravity_dir
	var forward := -camera_head.global_transform.basis.z
	# Project onto tangent plane (perpendicular to local up)
	forward = (forward - local_up * forward.dot(local_up)).normalized()
	var right := camera_head.global_transform.basis.x
	right = (right - local_up * right.dot(local_up)).normalized()
	var target_vel := forward * -input_dir.y + right * input_dir.x

	# Overgrowth: walk key clamps to kWalkSpeed (0.2)
	if walking:
		if target_vel.length_squared() > WALK_SPEED * WALK_SPEED:
			target_vel = target_vel.normalized() * WALK_SPEED
	else:
		target_vel = target_vel.limit_length(1.0)

	return target_vel


# ---- Main Loop ----

func _physics_process(delta: float) -> void:
	# Authoritative every frame; the scattered call sites below are now
	# redundant and can only be wrong for at most one frame.
	#
	# ⚠ LOCAL PLAYER ONLY. _physics_process runs for REMOTE players too, and
	# the terrain LOD is a property of THIS client's camera -- a remote peer
	# entering wall-climb must not freeze our octree. The scattered calls were
	# accidentally safe here only because remote players never take the input
	# paths that reach them; a derived flag is evaluated for everyone, so the
	# gate has to be explicit.
	if is_local_player():
		_derive_terrain_lod_frozen()
		_process_local(delta)
	else:
		_process_remote(delta)


func _process_local(delta: float) -> void:
	# Restore camera collision_mask after a one-frame auto-unstick.
	if _camera_unstick_pending_mask >= 0:
		spring_arm.collision_mask = _camera_unstick_pending_mask
		_camera_unstick_pending_mask = -1

	# Auto safety: fall-under-terrain respawn + auto camera-unstick.
	_safety_timer += delta
	if _safety_timer >= SAFETY_CHECK_INTERVAL:
		_safety_timer = 0.0
		# Fall-under-terrain → respawn
		if not _fall_respawn_resolved:
			_resolve_fall_respawn_y()
	# ARRIVING: held at the arrival point, no gravity, no input, no fall-respawn,
	# until the ground under it is real (see arrive()).
	if _arrival_t >= 0.0:
		_arrival_tick(delta)
		return
		if global_position.y < _fall_respawn_y:
			DebugLog.log("game", "Auto-respawn: fell under terrain (y=%.1f < %.1f)" % [global_position.y, _fall_respawn_y])
			_respawn.rpc()
			return
		# Camera stuck against geometry → drop collision for one physics frame so spring extends
		if spring_arm.spring_length > 0.5:
			var actual_dist: float = (camera.global_position - spring_arm.global_position).length()
			if actual_dist < spring_arm.spring_length * 0.2:
				_camera_stuck_time += SAFETY_CHECK_INTERVAL
				if _camera_stuck_time >= 1.0:
					_camera_stuck_time = 0.0
					_camera_unstick_pending_mask = spring_arm.collision_mask
					spring_arm.collision_mask = 0
			else:
				_camera_stuck_time = 0.0

	var _dbg_pos_start: Vector3 = global_position
	# Track input states
	jump_held = Input.is_action_pressed("jump")
	# Hold-to-ski: only while actually standing on something, so the timer cannot
	# arm mid-flight and turn a landing into a slide.
	if jump_held and is_on_floor():
		_jump_hold_time += delta
	else:
		_jump_hold_time = 0.0
	ski_holding = ski_hold_enabled and _jump_hold_time >= ski_hold_delay
	sprinting = Input.is_action_pressed("sprint")
	walking = Input.is_action_pressed("walk") if InputMap.has_action("walk") else false

	# Pre-jump timer (Overgrowth: pre_jump_time countdown)
	# Only fire jump if still in MOVEMENT state (prevents jump from ledge walk-off)
	if pre_jump_timer > 0.0:
		pre_jump_timer -= delta
		if pre_jump_timer <= 0.0:
			if state == State.MOVEMENT:
				var flipping_now: bool = _proc_animator != null and _proc_animator.is_flipping()
				if not flipping_now:
					_execute_jump()
			else:
				pre_jump_timer = 0.0  # Cancel pre-jump if no longer on ground

	# STEP HOP: the lead leg's crouch runs out -> push (cancelled if we left the ground).
	if _hop_timer > 0.0:
		_hop_timer -= delta
		if _hop_timer <= 0.0:
			if state == State.MOVEMENT and not _hop.is_empty():
				_launch_hop()
			else:
				_hop = {}

	# Flip/roll: suppress normal movement when active
	var flipping: bool = _proc_animator != null and _proc_animator.is_flipping()

	# Quake liquid swim (SV_WaterMove) overrides ground/air locomotion + gravity. Special
	# states (fly/grapple/wall-climb) keep their own physics. Only the DISPATCH is swapped —
	# `state` stays MOVEMENT/AIRBORNE so the post-match move_and_slide + sync write run normally.
	_dbg_record()
	_update_water_state()
	var _disp_state := state
	if _in_liquid and (state == State.MOVEMENT or state == State.AIRBORNE):
		_disp_state = State.SWIM
	# State dispatch
	match _disp_state:
		State.SWIM:
			_apply_water_move(delta)
			air_time += delta
		State.MOVEMENT:
			_update_ground_normal()
			_update_surface_friction()
			_update_duck(delta)
			if not flipping:
				_update_movement(delta)
			_check_roll()
			_check_jump()
			ground_time += delta
			# Roll velocity override (Overgrowth: UpdateRollVelocity with inertia blend)
			if flipping and _proc_animator:
				var roll_vel: Vector3 = _proc_animator.get_roll_velocity()
				if roll_vel != Vector3.ZERO:
					var frames: float = delta * 60.0
					velocity.x = lerpf(roll_vel.x, velocity.x, pow(0.95, frames))
					velocity.z = lerpf(roll_vel.z, velocity.z, pow(0.95, frames))
				# Lock facing during roll
				model.rotation.y = _roll_face_angle
				_dbg_model("roll_face_lock")
		State.AIRBORNE when _step_hopping:
			# A STEP HOP is not a jump: no fuel, no double jump, no flip, no wall or ledge
			# grabs — a ballistic arc onto the step, with the horizontal push HELD so the
			# riser it rises against cannot slide it to a stop.
			_apply_gravity(delta)
			velocity = _hop_push + (-gravity_dir) * velocity.dot(-gravity_dir)
			air_time += delta
		State.AIRBORNE:
			_update_jump_fuel(delta)
			if not flipping:
				_update_air_control(delta)
			# A HANDSPRING OPENS OUT IN THE AIR: the crouch is otherwise frozen while
			# airborne, so the roll's full duck (and its 0.45 m hip drop) rode the whole
			# way over. The duck spring runs here only for it; FlipRoll asks for 0.
			if _proc_animator and _proc_animator._flip_roll and _proc_animator._flip_roll.handspring:
				_update_duck(delta)
			_apply_gravity(delta)
			_check_double_jump()
			_check_air_flip()
			_detect_walls()
			_update_wall_run(delta)
			_check_wall_jump()
			_check_ledge_grab(delta)
			air_time += delta
		State.WALL_CLIMB:
			_update_wall_climb_state(delta)
		State.GRAPPLE:
			_update_grapple(delta)
		State.GRAPPLE_WINCH:
			_update_winch(delta)
		State.FLY:
			_update_fly(delta)

	# Ledge grab: freeze position while on ledge
	if _on_ledge:
		_update_ledge_hold(delta)
	elif _ledge_climb_progress > 0.0:
		_update_ledge_climb(delta)
	else:
		# Terminal velocity
		if velocity.length() > TERMINAL_VELOCITY:
			velocity *= TERMINAL_DAMPING

		# Physics step (fly/grapple/winch handle their own move_and_slide)
		if state != State.FLY and state != State.GRAPPLE and state != State.GRAPPLE_WINCH:
			if state == State.MOVEMENT and not flipping:
				if not _try_leap():
					_try_step_up(delta)
			var _pre_slide_xf := global_transform
			var _pre_slide_v := velocity
			_y_before_slide = velocity.dot(-gravity_dir)
			var _blk_from: Vector3 = global_position
			var _blk_want: Vector3 = velocity * delta
			var _floor_n: Vector3 = get_floor_normal() if (state == State.MOVEMENT and is_on_floor()) else Vector3.ZERO
			move_and_slide()
			_record_block(delta, _blk_from, _blk_want)
			_smooth_unexpected_rise(_blk_from, _floor_n)
			if state == State.MOVEMENT and not flipping:
				_step_after_slide(_pre_slide_xf, _pre_slide_v, delta)
			_keep_step_speed()
			# Fork A: Post-slide velocity recovery during wall climb.
			# If move_and_slide consumed velocity (stuck at surface classification
			# boundary), re-inject tangent velocity (Bug 5: threshold 0.3→0.5, injection 0.5→0.7).
			if state == State.WALL_CLIMB and _last_desired_velocity.length_squared() > 0.01:
				var post_speed: float = velocity.length()
				var desired_speed: float = _last_desired_velocity.length()
				if post_speed < desired_speed * 0.5:
					var tangent: Vector3 = _last_desired_velocity - surface_up * _last_desired_velocity.dot(surface_up)
					velocity += tangent * 0.7
					# Bug 5: position nudge along tangent when stuck
					if tangent.length_squared() > 0.01:
						global_position += tangent.normalized() * 0.02

	# State transitions after physics
	match state:
		State.MOVEMENT:
			if not is_on_floor() and not _on_ledge and not _try_step_down():
				ground_time = 0.0
				state = State.AIRBORNE
				wall_run_active = false
				wall_run_time = 0.0
				_has_hit_wall = false
		State.AIRBORNE:
			if is_on_floor():
				_on_land()
		State.GRAPPLE:
			pass  # Grapple handles its own transitions
		State.GRAPPLE_WINCH:
			pass  # Winch handles its own transitions
		State.FLY:
			pass  # Fly mode — no state transitions

	# Surface alignment recovery: when NOT wall climbing, force default capsule orientation.
	# Collision shape transforms are in parent-local space — only rotate during WALL_CLIMB
	# where we control the parent orientation. In normal states (including spherical gravity
	# where the parent body is rotated externally), keep default local transforms.
	if state != State.WALL_CLIMB:
		surface_up = -gravity_dir
		_target_surface_up = surface_up
		# Collision shapes stay in default parent-local orientation
		collision_standing.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.79, 0))
		collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))

	update_model_orientation(delta, flipping)
	_update_step_smooth(delta)

	# Feed state to procedural animator
	_update_proc_animator()

	# Update grapple rope visual
	_update_grapple_rope_visual()

	# Footsteps: on the animator's foot_planted (_on_foot_planted), not a timer — a fixed
	# 0.35 s gave ~2.9 steps/s at any speed (gate_footstep_rhythm: 18 steps at half speed
	# where the stride makes 8.7).

	_was_on_floor = is_on_floor()

	# Debug: detect position teleport or NaN
	if is_nan(global_position.x) or is_nan(global_position.y) or is_nan(global_position.z):
		print("NaN POSITION! state=%s vel=%s gravity=%s" % [state, velocity, gravity_dir])
		global_position = _dbg_pos_start
		velocity = Vector3.ZERO

	# Sync state
	sync_state = _encode_sync_state(global_position, camera_head.rotation.y,
			model.rotation.y, velocity, is_on_floor(), state, surface_up)


# ---- MOVEMENT State ----

## Orient the MODEL: upright normally, leaned toward the wall during a wall run,
## and turned toward the direction of travel once it is upright.
##
## Extracted from _process_local so a gate can drive it without also driving
## input, gravity and move_and_slide. scenes/test/wall_run_gate.gd measured a
## flat 0 degrees of tilt purely because it could not reach this code.
func update_model_orientation(delta: float, flipping: bool) -> void:
	# Model rotation toward movement (Overgrowth: InterpDirections with pow(0.85, frames))
	if not flipping and state != State.WALL_CLIMB:  # Wall climb handles its own model orientation
		# During a wall run the model leans toward the wall instead of standing
		# upright. Only the MODEL: the collision capsule stays in its default
		# parent-local orientation (the block above), because rotating the body
		# would change what counts as a floor mid-run.
		var local_up: Vector3 = _wall_run_model_up()

		# ONE MECHANISM DURING A WALL RUN, AND A FORWARD THAT CANNOT INVERT.
		#
		# Two earlier attempts flipped the player. The second named its own cause:
		# the model's forward followed wall_run_direction, computed as
		#     if (dot(dir, velocity) < 0) dir = -dir
		# which inverts frame to frame when along-wall velocity passes through
		# zero, spinning the model 180 degrees back and forth. bec6a02 removed the
		# last use of that vector and it is deleted outright now.
		#
		# The forward here is the model's OWN current forward, projected onto the
		# leaned plane. It is continuous by construction -- it cannot invert,
		# because it is derived from where the model already points rather than
		# from a velocity that can cross zero.
		#
		# And it is ONE mechanism: the branches below write `model.rotation.y`, a
		# single local Euler component, which does not preserve a 45 degree roll
		# and fights the slerp every frame. A wall run never reaches them.
		if wall_run_active and wall_run_tilt > 0.0:
			var wl_up: Vector3 = _wall_run_model_up()
			# POINT ALONG THE WALL, NOT WHEREVER THE MODEL HAPPENED TO BE POINTING.
			# This took the model's OWN forward and projected it onto the leaned plane,
			# which is unanchored: with the up steady the target equals the current
			# basis, so the facing keeps whatever it entered with and picks up a
			# vertical component from the lean. Measured 30.3 degrees off the wall
			# tangent, which reads as running up the wall at an angle.
			#
			# The anchor is the WALL TANGENT: horizontal, derived from the wall normal
			# and gravity, so it is as stable as the wall is. Only the SIGN comes from
			# velocity, and it is hysteretic -- a bare `if dot < 0: flip` inverts every
			# time along-wall speed crosses zero, which spun the model 180 degrees back
			# and forth in an earlier attempt (bec6a02). Anchoring to the velocity
			# DIRECTION instead was tried too and was worse: on a faceted wall the
			# velocity is deflected frame to frame and the facing chased the noise.
			var up_w: Vector3 = -gravity_dir
			var tangent: Vector3 = up_w.cross(wall_run_normal)
			var wl_fwd: Vector3
			if tangent.length_squared() > 1e-6:
				tangent = tangent.normalized()
				var along: float = tangent.dot(velocity)
				# 0.0 means "not yet chosen for this run" -- take the first reading of
				# any sign at all, rather than defaulting to +1 and waiting for the
				# hysteresis threshold to overturn a direction that was never chosen.
				if _wall_run_tangent_sign == 0.0:
					if absf(along) > 0.01:
						_wall_run_tangent_sign = 1.0 if along > 0.0 else -1.0
				elif absf(along) > WALL_RUN_FACING_FLIP_SPEED:
					_wall_run_tangent_sign = 1.0 if along > 0.0 else -1.0
				tangent *= (_wall_run_tangent_sign if _wall_run_tangent_sign != 0.0 else 1.0)
				# SMOOTHED, because the tangent inherits every wobble in the wall normal.
				# Anchoring to the raw tangent is exact on a plane (0.0 deg off) and
				# chases facets on anything else -- measured 11.4 deg off on a wall of
				# +/- 3 degree segments, worse than the 3.1 the unanchored version got.
				# This is a COSMETIC direction only: it feeds the model's facing, never
				# contact or physics, so filtering it cannot cost grip the way rejecting
				# wall_run_normal samples did.
				# Snap rather than slerp when the target is behind us: a slerp between
				# near-opposite vectors has no well-defined axis and crawls.
				if _wall_run_tangent_s.length_squared() < 0.5 \
						or _wall_run_tangent_s.dot(tangent) < -0.5:
					_wall_run_tangent_s = tangent
				else:
					_wall_run_tangent_s = _wall_run_tangent_s.slerp(tangent,
							clampf(WALL_RUN_TANGENT_SMOOTH * delta * 60.0, 0.0, 1.0)).normalized()
				wl_fwd = _wall_run_tangent_s - wl_up * _wall_run_tangent_s.dot(wl_up)
			else:
				wl_fwd = -model.global_transform.basis.z
				wl_fwd -= wl_up * wl_fwd.dot(wl_up)
			if wl_fwd.length_squared() < 0.01:
				wl_fwd = -camera_head.global_transform.basis.z
				wl_fwd -= wl_up * wl_fwd.dot(wl_up)
			if wl_fwd.length_squared() > 0.01:
				_slerp_model_to(wl_up, wl_fwd.normalized(), delta)
				return
		# If model basis is tilted (post-wall-climb transition), slerp back to surface-upright
		var model_up: Vector3 = model.global_transform.basis.y.normalized()
		if model_up.angle_to(local_up) > 0.05:
			# Slerp model basis toward surface-upright while preserving facing
			var current_quat: Quaternion = model.global_transform.basis.orthonormalized().get_rotation_quaternion()
			var model_fwd: Vector3 = -model.global_transform.basis.z
			var flat_fwd: Vector3 = (model_fwd - local_up * model_fwd.dot(local_up))
			if flat_fwd.length_squared() < 0.01:
				flat_fwd = (-global_transform.basis.z - local_up * (-global_transform.basis.z).dot(local_up))
				if flat_fwd.length_squared() < 0.01:
					flat_fwd = Vector3.FORWARD
			flat_fwd = flat_fwd.normalized()
			var target_z: Vector3 = -flat_fwd
			var target_x: Vector3 = local_up.cross(target_z)
			if target_x.length_squared() < 0.001:
				target_x = Vector3.RIGHT
			target_x = target_x.normalized()
			target_z = target_x.cross(local_up).normalized()
			var target_basis: Basis = Basis(target_x, local_up, target_z)
			var target_quat: Quaternion = target_basis.orthonormalized().get_rotation_quaternion()
			var result_quat: Quaternion = current_quat.slerp(target_quat, clampf(MODEL_ALIGN_SPEED * delta, 0.0, 1.0))
			model.global_transform.basis = Basis(result_quat)
			_dbg_model("upright_recovery")
		else:
			# Turn toward movement velocity projected onto surface tangent plane
			var local_vel: Vector3 = global_transform.basis.inverse() * velocity
			if Vector3(local_vel.x, 0, local_vel.z).length() > 0.3:
				var target_angle := atan2(-local_vel.x, -local_vel.z)
				model.rotation.y = lerp_angle(model.rotation.y, target_angle, TURN_VELOCITY * delta)
				_dbg_model("turn_toward_vel")


## Slerp the model toward the basis with `up` up and `fwd` forward. One place that
## builds a target basis, so the wall-run lean and the upright recovery cannot
## drift apart.
func _slerp_model_to(up: Vector3, fwd: Vector3, delta: float) -> void:
	var target_z: Vector3 = -fwd
	var target_x: Vector3 = up.cross(target_z)
	if target_x.length_squared() < 0.001:
		target_x = Vector3.RIGHT
	target_x = target_x.normalized()
	target_z = target_x.cross(up).normalized()
	var target_quat: Quaternion = Basis(target_x, up, target_z).orthonormalized().get_rotation_quaternion()
	var current_quat: Quaternion = model.global_transform.basis.orthonormalized().get_rotation_quaternion()
	model.global_transform.basis = Basis(
			current_quat.slerp(target_quat, clampf(MODEL_ALIGN_SPEED * delta, 0.0, 1.0)))
	_dbg_model("slerp_model_to")


## The direction the MODEL treats as up. Vertical normally; during a wall run,
## tilted `wall_run_tilt` of the way toward the wall's outward normal.
##
## THE SIGN. `_detect_walls` stores `wall_normal_detected = -col.get_normal()`,
## and Godot's slide normal points OUT of the surface toward the body -- so
## `wall_run_normal` points INTO the wall and the OUTWARD normal is its negation.
## A wall runner's head leans AWAY from the wall while the feet press into it, so
## up tilts toward the outward normal. FootIK is handed the same vector (the
## animator), which is what makes the feet sweep toward the wall rather than
## looking for ground below.
func _wall_run_model_up() -> Vector3:
	var up: Vector3 = -gravity_dir
	var result: Vector3 = up
	if wall_run_active and wall_run_tilt > 0.0 and wall_run_normal.length_squared() >= 0.01:
		var outward: Vector3 = (-wall_run_normal).normalized()
		var axis: Vector3 = up.cross(outward)
		if axis.length_squared() >= 1e-6:
			result = (Basis(axis.normalized(), up.angle_to(outward) * wall_run_tilt) * up).normalized()
		# else: wall normal parallel to gravity — not a wall, stay upright

	# Diagnostics, logged only on a transition or a large swing, so this is quiet
	# in normal play and loud in exactly the case the gate cannot stage.
	if wall_run_active != _wr_was_active:
		DebugLog.log("game", "[wall-run] %s  normal=%s target_up=%s"
				% ["ENTER" if wall_run_active else "EXIT", str(wall_run_normal), str(result)])
		_wr_was_active = wall_run_active
	elif wall_run_active:
		var swing: float = rad_to_deg(result.angle_to(_wr_last_up))
		if swing > 15.0:
			DebugLog.log("game", "[wall-run] target up SWUNG %.0f deg in one frame — "
					% swing + "normal=%s up=%s" % [str(wall_run_normal), str(result)])
	_wr_last_up = result
	return result


func _update_ground_normal() -> void:
	if ground_ray.is_colliding():
		var contact_normal := ground_ray.get_collision_normal()
		ground_normal = ground_normal * 0.9 + contact_normal * 0.1
	elif is_on_floor():
		ground_normal = get_floor_normal()


func _update_surface_friction() -> void:
	surface_friction = 1.0
	for i in get_slide_collision_count():
		var col := get_slide_collision(i)
		var collider := col.get_collider()
		if collider is StaticBody3D and collider.physics_material_override:
			surface_friction = collider.physics_material_override.friction
			break


## Move along a contact surface: spend the traction budget on HOLDING against the
## fall line first, then on reaching `desired` with whatever is left.
##
## Shared by walking and wall running because they are the same problem. A wall is
## a slope at 90 degrees; the only difference is that a wall has no natural
## traction, so something has to grant it.
##
## Never scales velocity -- it clamps ACCELERATION, which is what conserves
## momentum (Torque3D Player::updateMove).
func _apply_surface_move(delta: float, surface_normal: Vector3,
		desired: Vector3, traction: float) -> void:
	# Gravity along the surface. On the flat this is exactly zero; on a wall it is
	# the whole of gravity.
	var g_along: Vector3 = gravity_dir * GRAVITY
	g_along -= surface_normal * g_along.dot(surface_normal)
	var budget: float = WALK_ACCEL * traction * delta

	# HOLDING COMES FIRST. One clamped vector cannot both resist the fall line and
	# reach a target speed; when the budget is near what gravity demands it
	# alternates between them, which is the fight between traversing and sliding.
	# Edging is the real answer: grip pays for not falling, and the remainder
	# carries you along.
	#
	# APPLY THE NET, NEVER ADD-THEN-CANCEL. This used to be `velocity += g_along*dt`
	# followed by `velocity += -g_along*dt * (spend/hold_need)`, which is the same
	# number in algebra and NOT the same number in float: v + a - a leaves ~1e-7 of
	# residue, sign essentially random.
	#
	# That residue was the downhill bounce. Godot skips floor snapping entirely
	# when velocity.dot(up_direction) > 0 -- it reads any upward velocity as a jump
	# -- so a residue of +6e-8 m/s, sixty nanometres per second, made move_and_slide
	# return on_floor=false with zero collisions. MEASURED: every frame with a zero
	# or negative residue kept the floor; the first positive one lost it. From there
	# State.MOVEMENT stops running and the character bounces down the hill.
	#
	# When the feet can cover the whole demand the factor below is exactly 1.0 - 1.0
	# = 0.0, so velocity is left bit-for-bit untouched. When they cannot, the
	# remainder is what slides you -- the same physics, computed once.
	var g_step: Vector3 = g_along * delta
	var hold_need: float = g_step.length()
	if hold_need > 0.0001:
		var spend: float = minf(hold_need, budget)
		velocity += g_step * (1.0 - spend / hold_need)
		budget -= spend
	else:
		velocity += g_step

	var run_acc: Vector3 = desired - velocity
	run_acc -= surface_normal * run_acc.dot(surface_normal)
	var need: float = run_acc.length()
	if need > budget and need > 0.0001:
		run_acc *= budget / need
	velocity += run_acc


func _update_movement(delta: float) -> void:
	# THE VELOCITY LAYER IS TRIBES, THE BODY LAYER IS OVERGROWTH.
	#
	# Overgrowth treats velocity as AUTHORED: you ask for a speed and the
	# character is corrected toward it every frame, `horiz *= pow(E,
	# -WALK_ACCEL*dt/max_speed)`. Momentum is an error to be removed. That is the
	# right call for deliberate, contact-rich movement at human scale, and it is
	# why Overgrowth can afford its body layer -- which we keep, unchanged.
	#
	# It is the wrong call for a 917 km map with 60 km peaks, and it was the real
	# reason the player felt glued to the terrain. Every slope fix before this one
	# -- the metabolic speed curve, the threshold retune, the ski weight -- was a
	# patch on a model that destroys momentum by construction.
	#
	# Torque3D's Player::updateMove is the lineal descendant of the Tribes engine
	# (Darkstar -> Tribes 2 -> Torque -> Torque3D, MIT). It has NO friction term
	# at all:
	#
	#     VectorF runAcc = pv - (mVelocity + acc);      // desired minus current
	#     if (runSpeed > maxAcc) runAcc *= maxAcc / runSpeed;   // clamp ACCEL
	#     acc += runAcc;                                 // never scale velocity
	#
	# Input is an acceleration BUDGET, not a velocity target. Below
	# horizResistSpeed (38 m/s) nothing resists you at all, and runSurfaceAngle is
	# 80 degrees.
	#
	# THREE THINGS FALL OUT OF THE SWAP, and they are all deletions:
	#
	#   - The slope branches dissolve. Overgrowth strips and redirects uphill
	#     input because a velocity-TARGET model produces targets the character
	#     cannot achieve on steep ground. With a budget, walking uphill simply
	#     means the budget cannot overcome gravity: you slow, stop, and slide.
	#   - The momentum ratchet goes. `max_speed = max(speed*0.98, max_speed)`
	#     existed to stop the friction term eating momentum it should not have.
	#   - SKIING IS EMERGENT. It was a bolted-on weight that suppressed friction;
	#     now it is simply what happens when traction approaches zero, which is
	#     what steep ground means. Same mechanic, no special case.
	var target_dir := _get_target_velocity()
	feet_moving = target_dir.length_squared() > 0.0

	var up: Vector3 = -gravity_dir
	var flat_n: Vector3 = ground_normal - up * ground_normal.dot(up)
	var slope_sin: float = flat_n.length()          # sin(ground angle)

	# TRACTION: how much acceleration the feet can actually generate here. Full on
	# the flat, fading to nothing as the ground approaches the limit the body
	# itself calls a floor. This is the ONE place terrain enters the model, and it
	# is physically what a slope does to you -- it does not cap your speed, it
	# takes away your ability to push.
	# THE THRESHOLD IS NOW EXACT AND NAMED, WHICH IT WAS NOT. The old curve was
	# smoothstep(1 - sin(t)/sin(floor_max_angle)). It reached zero at
	# floor_max_angle -- but holding against the fall line is paid FIRST out of this
	# budget, so what was left to MOVE with ran out at 39.3 degrees. floor_max_angle
	# said 60, the character stopped at 39, and no constant anywhere said 39.
	#
	# `hold_frac` is the grip needed just to stand. `margin` is what is available
	# above that: +1 on the flat, exactly 0 at slide_angle, negative beyond. So the
	# budget covers holding and nothing else precisely AT slide_angle, and past it
	# cannot cover holding at all.
	#
	# NOT a Coulomb cone, deliberately. A real cone caps flat acceleration at mu*g
	# and slides at atan(mu), so WALK_ACCEL = 35 m/s^2 would imply mu >= 3.57 and no
	# sliding below 74 degrees. 35 is a responsiveness number, not a friction
	# coefficient, so traction stays a grip FRACTION and slide_angle is set directly.
	var slope_cos: float = sqrt(maxf(1.0 - slope_sin * slope_sin, 0.0001))
	var hold_frac: float = GRAVITY * slope_sin / WALK_ACCEL
	var margin: float = 1.0 - (slope_sin / slope_cos) / tan(clampf(slide_angle, 0.05, 1.5))
	var traction: float = clampf(hold_frac + (1.0 - hold_frac) * margin, 0.0, 1.0)
	# Ice and the like reduce what you can push against, rather than scaling the
	# velocity you already have.
	traction *= clampf(surface_friction, 0.0, 1.0)

	# HOLD TO SKI. The same mechanism as steep ground -- take the grip away -- so
	# there is one thing that makes you slide and two reasons it can happen.
	if ski_holding:
		traction = minf(traction, ski_hold_traction)

	# The DESIRED velocity, along the contact plane so you walk across a slope
	# rather than into it.
	var speed: float = TRUE_MAX_SPEED if sprinting else run_speed
	speed = lerpf(speed, speed * DUCK_SPEED_MULT, duck_amount)
	if slope_climb_cost > 0.0 and slope_sin > 0.0:
		var cs: float = sqrt(maxf(1.0 - slope_sin * slope_sin, 0.0001))
		speed /= (1.0 + slope_climb_cost * (slope_sin / cs))
	var desired: Vector3 = target_dir * speed
	desired -= ground_normal * desired.dot(ground_normal)
	max_speed = speed                                # reported, no longer a governor

	# THE BUDGET'S OWN INPUTS, recorded where they are decided.
	#
	# The motion trace showed the character decelerating to a standstill with
	# ZERO contacts and then unable to accelerate again -- so the thing stopping
	# it is in here, not in the terrain. Which term is doing it cannot be read
	# off velocity: traction is a product of slope, slide_angle, surface_friction
	# and the ski hold, and any one of them at zero produces the same standstill.
	_dbg_fresh = true
	_dbg_traction = traction
	_dbg_slope_sin = slope_sin
	_dbg_hold_frac = hold_frac
	_dbg_margin = margin
	_dbg_desired = desired.length()
	_dbg_input = target_dir.length()
	_dbg_friction = surface_friction
	_dbg_ski = ski_holding

	_apply_surface_move(delta, ground_normal, desired, traction)

	# NEVER HAND GODOT AN UPWARD VELOCITY WHILE STANDING ON SOMETHING.
	# move_and_slide skips floor snapping entirely whenever
	# velocity.dot(up_direction) > 0 -- it reads ANY upward velocity as a jump, and
	# returns on_floor = false with zero collisions.
	#
	# The projections in _apply_surface_move are exact in algebra and not in float.
	# `run_acc -= n * run_acc.dot(n)` should leave nothing in the normal direction
	# and instead leaves ~1e-7. MEASURED on a 5 degree descent: every frame whose
	# residue was zero or negative kept the floor, and the first frame it came out
	# at +0.000000059605 lost it. Sixty nanometres per second, and from there
	# State.MOVEMENT stops running -- no traction, no surface projection, no budget
	# -- until the character lands. That is the whole downhill bounce, and it is why
	# it appeared at slopes as gentle as 5 degrees and in no particular order.
	#
	# Clamping the up component costs nothing real: Godot slides it out itself while
	# on the floor, so a positive value was never going to survive the move, and
	# climbing works through Godot projecting the MOTION onto the floor plane rather
	# than through velocity.y. What must NOT be touched is the horizontal content --
	# projecting the whole velocity onto the contact plane instead compounds a
	# factor of cos^2(t) per frame against the clamped budget and settles a 20
	# degree descent at 2.6 m/s against a target of 7.5.
	#
	# _check_jump runs AFTER _update_movement, so jumps are unaffected.
	if is_on_floor():
		var up_v: float = velocity.dot(-gravity_dir)
		if up_v > 0.0:
			velocity -= (-gravity_dir) * up_v



# ---- AIRBORNE State ----

func _apply_gravity(delta: float) -> void:
	if not is_on_floor() and not _on_ledge:
		velocity += gravity_dir * GRAVITY * delta


func _update_jump_fuel(delta: float) -> void:
	# Overgrowth exact: aircontrols.as UpdateAirControls
	var local_up: Vector3 = -gravity_dir
	var vertical_speed: float = velocity.dot(local_up)
	if jump_held and jump_fuel_remaining > 0.0 and vertical_speed > 0.0:
		jump_fuel_remaining -= JUMP_FUEL_BURN * delta
		velocity += local_up * JUMP_FUEL_BURN * delta
	elif not jump_held:
		jump_fuel_remaining = 0.0
		# Overgrowth: down_jetpack_fuel pushes down after apex
		if down_jump_fuel > 0.0:
			down_jump_fuel -= JUMP_FUEL_BURN * delta
			velocity += gravity_dir * JUMP_FUEL_BURN * delta


func _update_air_control(delta: float) -> void:
	if _on_ledge:
		return
	var frames: float = delta * 60.0
	var target_vel := _get_target_velocity()
	var has_input: bool = target_vel.length_squared() > 0.01

	if has_input:
		# Apply air control force on tangent plane
		var local_up_air: Vector3 = -gravity_dir
		velocity += target_vel * JUMP_AIR_CONTROL * delta
		# Clamp horizontal speed so air control can't exceed cap
		var vert_air: float = velocity.dot(local_up_air)
		var horiz_air: Vector3 = velocity - local_up_air * vert_air
		var h_speed: float = horiz_air.length()
		if h_speed > AIR_SPEED_CAP:
			horiz_air = horiz_air * (AIR_SPEED_CAP / h_speed)
			velocity = horiz_air + local_up_air * vert_air
	else:
		# Gentle air deceleration when no input (preserves most momentum)
		var local_up_air: Vector3 = -gravity_dir
		var vert_air: float = velocity.dot(local_up_air)
		var horiz_air: Vector3 = velocity - local_up_air * vert_air
		var decel: float = pow(AIR_DECEL, frames)
		horiz_air *= decel
		velocity = horiz_air + local_up_air * vert_air


# ---- Wall Run (Overgrowth: aircontrols.as HitWall/UpdateWallRun/LostWallContact) ----

func _detect_walls() -> void:
	## Detect walls via slide collisions OR shift-grab raycast
	wall_contact = false
	wall_normal_detected = Vector3.ZERO
	wall_collider = null

	# Method 1: Slide collision detection (passive — touching a wall)
	for i in get_slide_collision_count():
		var col := get_slide_collision(i)
		var normal := col.get_normal()
		if absf(normal.dot(up_direction)) < 0.3:
			wall_contact = true
			wall_normal_detected = -normal
			wall_collider = col.get_collider()
			break

	# Method 2: Shift-grab sphere sweep (active — reach toward nearest wall)
	if not wall_contact and Input.is_action_pressed("sprint"):
		var space := get_world_3d().direct_space_state
		var local_up: Vector3 = -gravity_dir
		var origin := global_position + local_up * 1.0
		var h_vel := velocity - local_up * velocity.dot(local_up)
		var cast_dir: Vector3
		if h_vel.length() > 0.5:
			cast_dir = h_vel.normalized()
		else:
			cast_dir = -model.global_transform.basis.z
			cast_dir.y = 0.0
			cast_dir = cast_dir.normalized()
		var dirs: Array[Vector3] = [cast_dir]
		dirs.append(Vector3(-cast_dir.z, 0, cast_dir.x))
		dirs.append(Vector3(cast_dir.z, 0, -cast_dir.x))
		var excludes: Array[RID] = [get_rid()]
		for d in dirs:
			var result := SphereSweep.sweep(space, origin, origin + d * WALL_GRAB_RANGE, 0.2, excludes)
			if not result.is_empty():
				var n: Vector3 = result["normal"]
				if absf(n.dot(up_direction)) < 0.5:  # Wall-ish (allows overhangs up to ~60°)
					wall_contact = true
					wall_normal_detected = -n
					break

	# WHY THE WALL RUN DID OR DID NOT START. Three instruments in a row read
	# nothing while the body was visibly wrong on screen -- no [wall-run], no
	# [model], no [bones] -- which taken together say nothing is ROTATING anything,
	# and the pose on screen is a FALL pose: horizontal, all four limbs splayed.
	# That is what a player looks like sliding down a wall having never entered a
	# wall run at all. So log the PRECONDITIONS rather than the consequences.
	if OS.has_environment("HTPS_MODELLOG") and state == State.AIRBORNE and Engine.get_physics_frames() % 6 == 0:
		_diag("[wr?] f=%d contact=%-5s sprint=%-5s hspd=%5.2f wr=%-5s climb_gate=%s anim=%d"
				% [Engine.get_physics_frames(), str(wall_contact),
				   str(Input.is_action_pressed("sprint")), _horizontal_speed(),
				   str(wall_run_active), str(_horizontal_speed() < 3.0),
				   (_proc_animator._anim_state if _proc_animator != null else -1)])

	# Transition to wall climbing if conditions met.
	#
	# NOT OUT OF A WALL RUN. A climb and a run are entered on the SAME conditions --
	# wall contact, sprint held, airborne -- and the only thing telling them apart is
	# horizontal speed under 3 m/s. Nothing used to exclude a run already in
	# progress, so any wall run that decayed below 3 m/s silently became a climb, and
	# the climb writes the model basis itself (Basis(_climb_model_quat)) to cling the
	# body FLAT to the surface. That is the reported "wall run just spins the player
	# perpendicular to the wall", and the screenshots of a player lying flat and
	# upside-down on a wall face.
	#
	# MEASURED with every model-basis writer logged (HTPS_MODELLOG): the wall run
	# starts at frame 72 with slerp_model_to writing and the lean growing correctly
	# through 6 degrees; at frame 84 the state flips to WALL_CLIMB and wallclimb_quat
	# becomes the only writer, tilt 42.7 and then a flat 90.0 for the rest of the
	# run. Twelve frames -- 0.2 s -- of wall run. The same rig at 12 m/s never enters
	# WALL_CLIMB at all, which is why every gate that held a steady speed passed.
	#
	# Slowing down during a run now ends it through _update_wall_run's own checks,
	# which is what they are for. Approaching a wall slowly still climbs.
	if wall_contact and Input.is_action_pressed("sprint") and state == State.AIRBORNE \
			and not wall_run_active:
		if _horizontal_speed() < 3.0:
			_enter_wall_climb()


func _update_wall_run(delta: float) -> void:
	if not wall_contact and not wall_run_active:
		return

	# Need shift held to start or maintain wall run
	var grab_held: bool = Input.is_action_pressed("sprint")

	if wall_contact and not wall_run_active and grab_held:
		wall_run_active = true
		_has_hit_wall = true
		wall_run_time = 0.0
		# EACH RUN STARTS ITS OWN FACING. The smoothed tangent and its sign are
		# latched, and they used to survive from the previous wall run -- so running
		# one way, dropping off, and running back the other way began with the OLD
		# direction still held. Worse, slerping a vector toward its own opposite is
		# ill-conditioned (the rotation axis is undefined), so it barely moved and
		# the model kept facing the first way for the whole second run.
		_wall_run_tangent_s = Vector3.ZERO
		_wall_run_tangent_sign = 0.0
		wall_run_normal = wall_normal_detected
		return

	if not wall_run_active:
		return

	# Release shift = drop off wall
	if not grab_held:
		_lost_wall_contact()
		return

	wall_run_time += delta

	# Lose wall after falling too fast (but more generous with shift held)
	if wall_run_time > 0.2 and velocity.dot(-gravity_dir) < WALL_RUN_FALL_LIMIT:
		_lost_wall_contact()
		return

	if not wall_contact:
		if wall_run_time > 0.25:
			_lost_wall_contact()
		return

	wall_run_normal = wall_normal_detected
	# wall_run_direction USED TO BE COMPUTED HERE, with
	#     if (dot(dir, velocity) < 0) dir = -dir
	# which inverts frame to frame whenever along-wall velocity passes through
	# zero. Nothing reads it any more (bec6a02 removed the last use), and leaving
	# a sign-flipping vector lying around is how it got picked up twice.

	# A WALL IS A SLOPE AT 90 DEGREES. It has no natural traction, so wall running
	# is not a special case that cancels gravity -- it is something GRANTING the
	# hold on a surface that has none, after which the ordinary surface move
	# traverses it: hold against the fall line, move along with the remainder.
	#
	# What this replaces: 85% of gravity cancelled by hand, then
	#     velocity = wall_run_direction * max(along_wall, WALL_RUN_MIN_SPEED)
	#              + wall_up * vert_speed
	# which OVERWROTE velocity, forced a minimum speed along the wall, and took
	# its direction from wall_run_direction -- whose sign flips frame to frame
	# when along-wall velocity passes through zero. That sign flip is what spun
	# the model 180 degrees back and forth in two earlier attempts at the lean.
	#
	# The grant FADES WITH SPEED, which is physical rather than arbitrary: you can
	# only run along a wall while you still have speed along it, which is both the
	# real technique and the game convention. Lose the speed and traction goes
	# with it, and you slide off exactly as a steep slope would drop you.
	var wall_out: Vector3 = (-wall_run_normal).normalized()
	var along: Vector3 = velocity - wall_out * velocity.dot(wall_out)
	var along_speed: float = along.length()
	var grant: float = wall_run_traction * clampf(along_speed / maxf(WALL_RUN_MIN_SPEED, 0.01), 0.0, 1.0)

	# Where the player wants to go, on the wall's plane.
	var wish: Vector3 = _get_target_velocity()
	wish -= wall_out * wish.dot(wall_out)
	var wall_desired: Vector3 = Vector3.ZERO
	if wish.length_squared() > 0.0001:
		wall_desired = wish.normalized() * (TRUE_MAX_SPEED if sprinting else run_speed)

	_apply_surface_move(delta, wall_out, wall_desired, grant)

	# PRESSING INTO THE WALL IS WHAT MAKES THE GRANT PHYSICAL: there is no traction
	# on a vertical surface without a normal force. Scaled by the grant, so it
	# fades with speed alongside it -- and it is also what keeps the contact alive
	# for _detect_walls, which the old code did with a bare `+= into_wall * 2.0`.
	velocity += wall_run_normal.normalized() * wall_run_press * grant * delta


func _lost_wall_contact() -> void:
	## Overgrowth: LostWallContact.
	##
	## THE FACING IS NOT SNAPPED, BECAUSE THE VECTOR IT SNAPPED TO WAS NOT A FACING.
	## This read `model.rotation.y = atan2(-_wall_run_facing.x, -_wall_run_facing.z)`
	## where _wall_run_facing was captured on entry as `-wall_run_normal` -- a wall
	## NORMAL, which points out of the wall and is therefore roughly PERPENDICULAR
	## to the direction you are running. So every dropout turned the model about 90
	## degrees, instantly, and on ground that makes contact flicker it dropped out
	## and re-engaged over and over. That is the model spinning about its hip.
	##
	## MEASURED (wall_run_curve_gate): on a wall faceted +/- 3 degrees the ONLY
	## frame that moves the facing more than 3 degrees is the frame wall_run_active
	## goes false, and it moves 92.25 degrees. The lean itself is stable -- the flat
	## control travels 0.0 degrees over the same run, which is why the flat gate
	## never saw this.
	##
	## Nothing needs to replace it. The model keeps the facing it has, and
	## update_model_orientation's upright-recovery branch slerps the lean out while
	## PRESERVING that facing -- which is what "restore facing" was meant to do.
	wall_run_active = false


func _check_wall_jump() -> void:
	## Overgrowth: WantsToJumpOffWall → StartWallJump(wall_dir * -1)
	if not wall_run_active:
		return
	if not input_sync.wants_jump:
		return

	# Jump away from wall (Overgrowth: velocity = GetJumpVelocity * 0.5 + original horizontal)
	var away_dir := -wall_run_normal  # away from wall
	away_dir.y = 0.0
	away_dir = away_dir.normalized()

	# Half-strength jump + away velocity
	var wall_up: Vector3 = -gravity_dir
	velocity = away_dir * run_speed * 0.5
	# Set vertical component along local up
	velocity = velocity - wall_up * velocity.dot(wall_up) + wall_up * JUMP_INITIAL_VELOCITY

	# Reset fuel for wall jump
	jump_fuel_remaining = JUMP_FUEL
	down_jump_fuel = JUMP_FUEL * 0.5
	_has_double_jumped = false

	wall_run_active = false
	_has_hit_wall = false
	_play_pool(snd_jump, JUMP_POOL, 1.0)


# ---- Ledge Grab (Overgrowth: ledgegrab.as CheckLedges) ----

func _check_ledge_grab(delta: float) -> void:
	if _on_ledge:
		return
	if _ledge_delay > 0.0:
		_ledge_delay -= delta
		return
	# Need shift held to grab ledges
	if not Input.is_action_pressed("sprint"):
		return
	if velocity.dot(-gravity_dir) < LEDGE_GRAB_MAX_FALL_SPEED:
		return
	var flipping: bool = _proc_animator != null and _proc_animator.is_flipping()
	if flipping:
		return

	var space := get_world_3d().direct_space_state
	var local_up: Vector3 = -gravity_dir
	var head_pos := global_position + local_up * 1.6

	# Cast in multiple directions to find a ledge (forward + left/right)
	var forward := -model.global_transform.basis.z
	forward.y = 0.0
	forward = forward.normalized()
	var right := Vector3(-forward.z, 0, forward.x)

	var dirs: Array[Vector3] = [forward]
	dirs.append((forward + right * 0.5).normalized())
	dirs.append((forward - right * 0.5).normalized())
	dirs.append(right)
	dirs.append(-right)

	var excludes: Array[RID] = [get_rid()]
	for dir in dirs:
		# Sphere sweep toward wall
		var wall_result := SphereSweep.sweep(space, head_pos, head_pos + dir * LEDGE_GRAB_FORWARD, 0.15, excludes)
		if wall_result.is_empty():
			continue

		# Check for surface above wall hit
		var wall_hit_pos: Vector3 = wall_result["point"]
		var above_wall: Vector3 = wall_hit_pos + local_up * LEDGE_GRAB_REACH
		var top_result := SphereSweep.sweep(space, above_wall, above_wall - local_up * LEDGE_GRAB_REACH * 2.0, 0.1, excludes)
		if top_result.is_empty():
			continue

		var top_normal: Vector3 = top_result["normal"]
		if top_normal.dot(local_up) < 0.5:
			continue

		var top_pos: Vector3 = top_result["point"]
		var ledge_height: float = top_pos.dot(local_up)
		var feet_height: float = global_position.dot(local_up)
		if ledge_height < feet_height - 0.5 or ledge_height > feet_height + 3.5:
			continue

		# Grab the ledge
		_on_ledge = true
		_ledge_point = top_pos
		_ledge_normal = wall_result["normal"]
		_ledge_climb_progress = 0.0
		_ledge_start_pos = global_position
		velocity = Vector3.ZERO
		_has_hit_wall = false
		wall_run_active = false
		return


func _update_ledge_hold(delta: float) -> void:
	## Hang on ledge — release shift or press crouch to drop, jump to climb
	if not Input.is_action_pressed("sprint") or Input.is_action_just_pressed("crouch"):
		_drop_from_ledge()
		return

	if input_sync.wants_jump:
		_climb_ledge()
		return

	# Hold position at ledge (hands at ledge height, body below)
	var hang_pos := _ledge_point + _ledge_normal * 0.3 - (-gravity_dir) * 1.4
	global_position = global_position.lerp(hang_pos, 10.0 * delta)
	velocity = Vector3.ZERO

	# Face the wall
	var face_dir := -_ledge_normal
	face_dir.y = 0.0
	face_dir = face_dir.normalized()
	model.rotation.y = atan2(-face_dir.x, -face_dir.z)
	_dbg_model("ledge_hang_face")


func _drop_from_ledge() -> void:
	_on_ledge = false
	_ledge_delay = 0.3  # Overgrowth: ledge_delay prevents re-grab
	velocity = gravity_dir.normalized()  # Small downward push


func _climb_ledge() -> void:
	## Timed climb to top of ledge (0.35s lerp instead of instant teleport)
	_on_ledge = false
	_ledge_climb_progress = 0.001  # Start climb (>0 signals climbing)
	_ledge_start_pos = global_position
	_ledge_delay = 0.3
	velocity = Vector3.ZERO
	_has_hit_wall = false
	# Disable collision during climb to prevent clipping on ledge edge
	collision_standing.disabled = true
	collision_crouching.disabled = true


func _update_ledge_climb(delta: float) -> void:
	## Arc from hang position up and over the ledge (avoids clipping through terrain)
	if _ledge_climb_progress <= 0.0 or _ledge_climb_progress >= 1.0:
		return
	_ledge_climb_progress += delta / 0.35  # 0.35s climb duration
	var t: float = clampf(_ledge_climb_progress, 0.0, 1.0)
	var ledge_up: Vector3 = -gravity_dir
	var target_pos: Vector3 = _ledge_point + _ledge_normal * 0.5 + ledge_up * 0.1
	# Two-phase arc: first go UP past the ledge, then move OVER to the top
	# Phase 1 (t 0→0.5): rise vertically past ledge height + push away from wall
	# Phase 2 (t 0.5→1): move horizontally onto ledge top
	var ledge_top: Vector3 = _ledge_point + ledge_up * 0.6
	# Project start pos onto tangent plane, then set height to ledge_top height along up
	var start_tangent: Vector3 = _ledge_start_pos - ledge_up * _ledge_start_pos.dot(ledge_up)
	var top_height_along_up: float = ledge_top.dot(ledge_up)
	var normal_tangent: Vector3 = _ledge_normal - ledge_up * _ledge_normal.dot(ledge_up)
	var rise_pos: Vector3 = start_tangent + normal_tangent * 0.3 + ledge_up * top_height_along_up
	if t < 0.5:
		# Rising phase
		var phase_t: float = t / 0.5
		global_position = _ledge_start_pos.lerp(rise_pos, phase_t)
	else:
		# Over phase
		var phase_t: float = (t - 0.5) / 0.5
		global_position = rise_pos.lerp(target_pos, phase_t)
	velocity = Vector3.ZERO
	if _ledge_climb_progress >= 1.0 or _ledge_climb_progress > 3.0:
		# Complete (or safety timeout — 3x normal duration)
		_ledge_climb_progress = 0.0
		state = State.MOVEMENT
		ground_time = 0.0
		# Re-enable collision
		collision_standing.disabled = false
		collision_crouching.disabled = true


# ---- Wall Climb ----

func _enter_wall_climb() -> void:
	# wall_normal_detected points INTO wall; negate for surface normal (away from wall)
	_enter_wall_climb_on_surface(-wall_normal_detected)


## surface_normal: the normal pointing AWAY from the surface (same as wall_climb.wall_normal).
func _enter_wall_climb_on_surface(surface_normal: Vector3) -> void:
	state = State.WALL_CLIMB
	wall_run_active = false
	_set_terrain_lod_frozen(true)
	# Use crouching capsule during lizard mode — shorter capsule clips less on curves
	collision_standing.disabled = true
	collision_crouching.disabled = false
	# Set target surface up for capsule reorientation
	_target_surface_up = surface_normal.normalized()
	# Allow any surface angle to count as floor during climb
	floor_max_angle = PI
	_climb_model_quat = model.transform.basis.orthonormalized().get_rotation_quaternion()
	# Push exported grip layout to wall climb system
	_wall_climb.set_grip_layout(lizard_hand_forward, lizard_hand_lateral, lizard_foot_back, lizard_foot_lateral)
	# Reset collision to default local transforms (body rotation handles surface alignment)
	collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))
	_last_desired_velocity = Vector3.ZERO
	# Camera Fix 2: detach from body rotation, track forward vector separately
	var cam_fwd: Vector3 = -camera_head.global_transform.basis.z
	_camera_climb_forward = (cam_fwd - _target_surface_up * cam_fwd.dot(_target_surface_up))
	if _camera_climb_forward.length_squared() < 0.01:
		_camera_climb_forward = Vector3.FORWARD
	_camera_climb_forward = _camera_climb_forward.normalized()
	# Body forward: init from model facing, project onto surface
	var body_fwd: Vector3 = -model.global_transform.basis.z
	_body_climb_forward = (body_fwd - _target_surface_up * body_fwd.dot(_target_surface_up))
	if _body_climb_forward.length_squared() < 0.01:
		_body_climb_forward = Vector3.FORWARD
	_body_climb_forward = _body_climb_forward.normalized()
	camera_head.top_level = true
	# Snap camera position on entry (Bug 3: avoid separation)
	camera_head.global_position = global_position + _target_surface_up * 0.5
	# Start grip system (wall_normal = away from wall surface)
	var attach_pos := global_position + surface_up * lizard_height
	_wall_climb.start(attach_pos, surface_normal.normalized(), -gravity_dir)
	velocity = Vector3.ZERO
	# Debug visualization handled by _update_climb_debug_visual (K toggle)
	_wall_climb.debug_enabled = _climb_debug_enabled
	# Create grip surface particles
	_create_grip_particles()


func _update_wall_climb_state(delta: float) -> void:
	# Release sprint = fall (unless in lizard debug mode)
	if not _lizard_mode and not Input.is_action_pressed("sprint"):
		_exit_wall_climb()
		return

	# Jump = wall jump away
	if input_sync.wants_jump:
		var climb_up: Vector3 = -gravity_dir
		var away: Vector3 = _wall_climb.wall_normal
		away = (away - climb_up * away.dot(climb_up)).normalized()
		velocity = away * run_speed * 0.5
		velocity = velocity - climb_up * velocity.dot(climb_up) + climb_up * JUMP_INITIAL_VELOCITY
		_exit_wall_climb()
		state = State.AIRBORNE
		_lizard_mode = false
		_play_pool(snd_jump, JUMP_POOL, 1.0)
		return

	# Climb input: WASD relative to wall surface
	var input_dir := Vector2.ZERO
	if Input.is_action_pressed("move_left"):
		input_dir.x -= 1.0
	if Input.is_action_pressed("move_right"):
		input_dir.x += 1.0
	if Input.is_action_pressed("move_forward"):
		input_dir.y += 1.0
	if Input.is_action_pressed("move_back"):
		input_dir.y -= 1.0

	# Movement via move_and_slide (keeps collision detection working).
	# Dynamic up_direction tracks the surface so walls/ceilings count as floor.
	var move_dir: Vector3 = (_wall_climb.wall_right * input_dir.x + _wall_climb.wall_up * input_dir.y)
	if move_dir.length_squared() > 0.01:
		move_dir = move_dir.normalized()
	velocity = velocity.lerp(move_dir * lizard_speed, 15.0 * delta)

	# Stick force into surface (replaces gravity during climb)
	velocity += -surface_up * STICK_FORCE * delta

	# Dynamic up_direction = current surface normal
	up_direction = surface_up

	# Anti-stick settings for wall transitions
	floor_block_on_wall = false
	floor_snap_length = 0.5

	# Save pre-slide velocity for post-slide recovery (Fork A)
	_last_desired_velocity = velocity

	# move_and_slide runs later in _process_local (line ~595)

	# After physics: update surface normal from floor detection + raycasts
	var space := get_world_3d().direct_space_state
	if is_on_floor():
		var floor_n: Vector3 = get_floor_normal()
		if floor_n.length_squared() > 0.5:
			_target_surface_up = floor_n
	else:
		# Not on floor — raycast to find surface
		var ray_from: Vector3 = global_position + surface_up * 0.5
		var ray_to: Vector3 = global_position - surface_up * 2.0
		var ray_result: Dictionary = SphereSweep.sweep(space, ray_from, ray_to, 0.15, [get_rid()])
		if not ray_result.is_empty():
			_target_surface_up = ray_result["normal"]
		else:
			var grip_normal: Vector3 = _wall_climb.get_averaged_normal()
			if grip_normal.length_squared() > 0.5:
				_target_surface_up = grip_normal

	# Predictive surface raycast (Fix 4): sweep 0.5m ahead in movement direction.
	# Blends into _target_surface_up so body begins rotating before reaching transition.
	var pred_vel: Vector3 = velocity - surface_up * velocity.dot(surface_up)
	if pred_vel.length_squared() > 0.1:
		var look_ahead: Vector3 = global_position + pred_vel.normalized() * 0.5
		var pred_from: Vector3 = look_ahead + surface_up * 0.5
		var pred_to: Vector3 = look_ahead - surface_up * 2.0
		var pred_result: Dictionary = SphereSweep.sweep(space, pred_from, pred_to, 0.15, [get_rid()])
		if not pred_result.is_empty():
			var normal_change: float = _target_surface_up.angle_to(pred_result["normal"])
			_target_surface_up = _target_surface_up.slerp(pred_result["normal"], 0.3)
			# Bug 5: sharp edge — reduce snap to prevent old-surface pull-back
			if normal_change > deg_to_rad(45.0):
				floor_snap_length = 0.05

	# Update body climb forward: re-project onto tangent plane, steer toward movement
	_body_climb_forward = (_body_climb_forward - surface_up * _body_climb_forward.dot(surface_up))
	if _body_climb_forward.length_squared() < 0.01:
		_body_climb_forward = _wall_climb.wall_up if _wall_climb.wall_up.length_squared() > 0.01 else Vector3.FORWARD
	_body_climb_forward = _body_climb_forward.normalized()
	var body_move_dir: Vector3 = velocity - surface_up * velocity.dot(surface_up)
	if body_move_dir.length_squared() > 0.1:
		_body_climb_forward = _body_climb_forward.slerp(body_move_dir.normalized(), 4.0 * delta).normalized()

	# Rotate CharacterBody3D itself to match surface (Fix 1)
	_rotate_body_to_surface(delta)

	# Sync grip system — use MODEL facing for grip placement
	var model_facing: Vector3 = model.global_transform.basis.y
	var surface_facing: Vector3 = (model_facing - surface_up * model_facing.dot(surface_up))
	if surface_facing.length_squared() < 0.01:
		surface_facing = _wall_climb.wall_up
	surface_facing = surface_facing.normalized()
	_wall_climb.set_grip_layout(lizard_hand_forward, lizard_hand_lateral, lizard_foot_back, lizard_foot_lateral)
	_wall_climb.sync(global_position + surface_up * lizard_height, -gravity_dir, surface_facing)

	# Update grips
	var tangent_vel: Vector3 = velocity - surface_up * velocity.dot(surface_up)
	var valid_grips: int = _wall_climb.update(space, delta, tangent_vel)

	# Orient model to surface
	_orient_model_to_surface(delta)

	# Camera Fix 2: top_level camera follows body, oriented to surface.
	# Re-project forward onto surface tangent (handles surface_up changes smoothly).
	_camera_climb_forward = (_camera_climb_forward - surface_up * _camera_climb_forward.dot(surface_up))
	if _camera_climb_forward.length_squared() < 0.01:
		_camera_climb_forward = _wall_climb.wall_up if _wall_climb.wall_up.length_squared() > 0.01 else Vector3.FORWARD
	_camera_climb_forward = _camera_climb_forward.normalized()
	# Build camera basis: Y = surface_up, -Z = forward
	var cam_right: Vector3 = _camera_climb_forward.cross(surface_up)
	if cam_right.length_squared() < 0.001:
		cam_right = Vector3.RIGHT
	cam_right = cam_right.normalized()
	_camera_climb_forward = surface_up.cross(cam_right).normalized()
	camera_head.global_transform.basis = Basis(cam_right, surface_up, -_camera_climb_forward)
	# Position: lerp to body + offset along surface normal (Bug 3: rate 10→20, snap if >2m)
	var cam_target_pos: Vector3 = global_position + surface_up * 0.5
	var cam_dist: float = camera_head.global_position.distance_to(cam_target_pos)
	if cam_dist > 2.0:
		camera_head.global_position = cam_target_pos
	else:
		camera_head.global_position = camera_head.global_position.lerp(cam_target_pos, 20.0 * delta)

	# Update grip particles
	_update_grip_particles()

	# Debug visualization (K key toggle)
	if _climb_debug_enabled:
		_climb_debug_frame_skip += 1
		if _climb_debug_frame_skip >= 3:
			_climb_debug_frame_skip = 0
			_update_climb_debug_visual()

	# Fall if not enough grips (but not in lizard debug mode)
	if valid_grips < 2 and not _lizard_mode:
		_exit_wall_climb()
		return

	# Ledge check: only on actual walls, NEVER in lizard mode.
	# Lizard mode crawls over edges — no ledge grab.
	if not _lizard_mode:
		var is_actual_wall: bool = _wall_climb.wall_normal.dot(-gravity_dir) < 0.7
		if input_dir.y > 0.0 and is_actual_wall:
			var above := _wall_climb.body_position + _wall_climb.wall_up * 1.0
			var wall_above := SphereSweep.sweep(space,
				above + _wall_climb.wall_normal * 0.5,
				above - _wall_climb.wall_normal * 0.5,
				0.15, [get_rid()])
			if wall_above.is_empty():
				_try_ledge_from_climb()


func _exit_wall_climb() -> void:
	_wall_climb.stop()
	_set_terrain_lod_frozen(false)
	state = State.AIRBORNE
	velocity = gravity_dir.normalized()  # Small push in gravity direction
	_has_hit_wall = false
	# Snap model to upright immediately — no slerp from prone (causes shaking)
	_target_surface_up = -gravity_dir
	surface_up = -gravity_dir
	up_direction = surface_up
	# Capture model world-space facing before resetting body
	var exit_up: Vector3 = -gravity_dir
	var exit_model_fwd: Vector3 = -model.global_transform.basis.z
	# Reset CharacterBody3D basis to upright (undo surface rotation)
	global_transform.basis = Basis.IDENTITY
	# Reset model to upright using captured facing
	var flat_fwd: Vector3 = (exit_model_fwd - exit_up * exit_model_fwd.dot(exit_up))
	if flat_fwd.length_squared() < 0.01:
		flat_fwd = Vector3.FORWARD
	flat_fwd = flat_fwd.normalized()
	var exit_z: Vector3 = -flat_fwd
	var exit_x: Vector3 = exit_up.cross(exit_z).normalized()  # up × backward = RIGHT
	exit_z = exit_x.cross(exit_up).normalized()  # re-orthogonalize
	model.transform.basis = Basis(exit_x, exit_up, exit_z).orthonormalized()
	_dbg_model("wallclimb_exit")
	model.position = Vector3.ZERO
	_climb_model_quat = Quaternion.IDENTITY
	# Restore floor properties and camera
	floor_max_angle = DEFAULT_FLOOR_MAX_ANGLE
	floor_snap_length = 0.1
	floor_block_on_wall = true
	# Camera Fix 2: restore as child, compute yaw from climb forward
	camera_head.top_level = false
	camera_head.position = Vector3(0, 1.5, 0)
	var exit_cam_fwd: Vector3 = _camera_climb_forward
	exit_cam_fwd.y = 0
	if exit_cam_fwd.length_squared() > 0.01:
		exit_cam_fwd = exit_cam_fwd.normalized()
		camera_head.rotation = Vector3(0, atan2(-exit_cam_fwd.x, -exit_cam_fwd.z), 0)
	else:
		camera_head.rotation = Vector3.ZERO
	# Restore collision
	collision_standing.disabled = false
	collision_crouching.disabled = true
	collision_standing.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.79, 0))
	collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))
	# Clean up debug visual
	if _climb_debug_visual:
		for child in _climb_debug_visual.get_children():
			child.queue_free()
	# Clean up grip particles
	_destroy_grip_particles()


## Rotate the CharacterBody3D itself so local Y = surface_up.
## This aligns the physics hull with the logical orientation — collision shapes
## stay at DEFAULT local transforms and the parent rotation handles surface alignment.
## Only called during WALL_CLIMB state.
func _rotate_body_to_surface(delta: float) -> void:
	# Slerp surface_up toward target (world space)
	var angle: float = surface_up.angle_to(_target_surface_up)
	if angle > 0.001:
		var t: float = clampf(SURFACE_ALIGN_SPEED * delta, 0.0, 1.0)
		surface_up = surface_up.slerp(_target_surface_up, t).normalized()

	# Set CharacterBody3D up_direction (world space)
	up_direction = surface_up

	# Build target basis for CharacterBody3D: local Y = surface_up.
	# Use _body_climb_forward as forward reference (avoids wall_up degeneration at sphere equator).
	var ref_fwd: Vector3 = _body_climb_forward
	if ref_fwd.length_squared() < 0.01:
		var grav_up: Vector3 = -gravity_dir
		ref_fwd = (grav_up - surface_up * grav_up.dot(surface_up))
		if ref_fwd.length_squared() < 0.01:
			ref_fwd = Vector3.FORWARD
	ref_fwd = ref_fwd.normalized()

	# Orthogonalize: X = fwd x up, Z = X x up (Godot: -Z = forward)
	var target_x: Vector3 = ref_fwd.cross(surface_up)
	if target_x.length_squared() < 0.001:
		target_x = Vector3.RIGHT
	target_x = target_x.normalized()
	var target_z: Vector3 = target_x.cross(surface_up).normalized()
	var target_basis: Basis = Basis(target_x, surface_up, target_z)

	# Slerp the CharacterBody3D's global basis toward target
	var current_quat: Quaternion = global_transform.basis.orthonormalized().get_rotation_quaternion()
	var target_quat: Quaternion = target_basis.orthonormalized().get_rotation_quaternion()
	var body_t: float = clampf(SURFACE_ALIGN_SPEED * delta, 0.0, 1.0)
	global_transform.basis = Basis(current_quat.slerp(target_quat, body_t))

	# Collision shapes at DEFAULT local transforms — parent rotation handles alignment
	collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))


## Orient model to lie flat on surface (prone/lizard pose).
## Also positions the model so skeleton midpoint aligns with grip body center.
## Sets model.transform (local) to avoid global/local conflicts.
func _orient_model_to_surface(delta: float) -> void:
	# Facing = camera forward projected onto surface tangent plane
	var cam_fwd: Vector3 = -camera_head.global_transform.basis.z
	var facing: Vector3 = (cam_fwd - surface_up * cam_fwd.dot(surface_up))
	if facing.length_squared() < 0.01:
		facing = _wall_climb.wall_up if _wall_climb.wall_up.length_squared() > 0.01 else Vector3.FORWARD
	facing = facing.normalized()

	# Build prone basis in WORLD space:
	# Z = surface_up (back faces up), Y = facing (head forward), X = right
	var basis_z: Vector3 = surface_up
	var basis_y: Vector3 = facing
	var basis_x: Vector3 = basis_y.cross(basis_z)
	if basis_x.length_squared() < 0.001:
		basis_x = basis_y.cross(Vector3.UP)
		if basis_x.length_squared() < 0.001:
			basis_x = basis_y.cross(Vector3.RIGHT)
	basis_x = basis_x.normalized()
	basis_y = basis_z.cross(basis_x).normalized()
	var target_world_basis: Basis = Basis(basis_x, basis_y, basis_z)

	# Convert to parent-local basis
	var parent_basis: Basis = global_transform.basis
	var target_local_basis: Basis = parent_basis.inverse() * target_world_basis

	# Slerp in local space
	var current_quat: Quaternion = model.transform.basis.orthonormalized().get_rotation_quaternion()
	var target_quat: Quaternion = target_local_basis.orthonormalized().get_rotation_quaternion()
	var t: float = clampf(TURN_VELOCITY * delta, 0.0, 1.0)
	_climb_model_quat = _climb_model_quat.slerp(target_quat, t)
	# Store for use in _update_proc_animator — position is set there
	model.transform.basis = Basis(_climb_model_quat)
	_dbg_model("wallclimb_quat")

	# Spider-style body positioning: model follows grip centroid.
	# The grip centroid IS the center of the 4 feet — body is always centered.
	# Offset along averaged surface normal for body clearance.
	var grip_center: Vector3 = _wall_climb.get_grip_centroid()
	var avg_normal: Vector3 = _wall_climb.get_averaged_normal()

	# Position model at grip centroid + offset along surface normal + spine backward shift
	var target_world: Vector3 = grip_center + avg_normal * lizard_height
	# Shift backward along facing so skeleton midpoint (not hip) aligns with centroid
	target_world -= _wall_climb.wall_up * lizard_back_offset

	# Breathing bob
	var dt: float = get_physics_process_delta_time()
	var climb_speed: float = _horizontal_speed()
	if climb_speed > 0.3:
		_climb_bob_phase += dt * climb_speed * 4.0
		target_world += avg_normal * sin(_climb_bob_phase) * 0.04
	else:
		_climb_bob_phase += dt * 0.8
		target_world += avg_normal * sin(_climb_bob_phase) * 0.012

	# Convert to parent-local and lerp smoothly
	var target_local: Vector3 = global_transform.affine_inverse() * target_world
	model.position = model.position.lerp(target_local, 10.0 * dt)


func _update_climb_debug_visual() -> void:
	if _climb_debug_visual == null:
		_climb_debug_visual = Node3D.new()
		_climb_debug_visual.name = "ClimbDebugVisual"
		_climb_debug_visual.top_level = true
		add_child(_climb_debug_visual)

	# Clear previous frame — use free() not queue_free() to prevent mesh accumulation
	var children := _climb_debug_visual.get_children()
	for i in range(children.size() - 1, -1, -1):
		children[i].free()

	# Capsule up direction (cyan line from player center)
	_climb_debug_visual.add_child(_make_debug_line_node(
		global_position, global_position + surface_up * 1.5, Color.CYAN))

	# Target surface up (yellow line)
	_climb_debug_visual.add_child(_make_debug_line_node(
		global_position, global_position + _target_surface_up * 1.2, Color.YELLOW))

	# Body position for grips (white sphere)
	_climb_debug_visual.add_child(_make_debug_sphere_node(
		_wall_climb.body_position, 0.08, Color.WHITE))

	# Model/skeleton position (orange sphere — where the skeleton actually is)
	var skel_world_pos: Vector3 = model.global_position
	_climb_debug_visual.add_child(_make_debug_sphere_node(
		skel_world_pos, 0.06, Color.ORANGE))

	# Wall normal from body (magenta)
	_climb_debug_visual.add_child(_make_debug_line_node(
		_wall_climb.body_position,
		_wall_climb.body_position + _wall_climb.wall_normal * 0.5, Color.MAGENTA))

	# Grip IK targets (colored spheres + lines to body)
	var colors: Array[Color] = [Color.RED, Color.BLUE, Color.YELLOW, Color.GREEN]
	for i in range(4):
		var gp: Vector3 = _wall_climb.grip_positions[i]
		var valid: bool = _wall_climb.grip_valid[i]
		var stepping: bool = _wall_climb.grip_stepping[i]
		var c: Color = colors[i] if valid else Color(0.3, 0.3, 0.3)

		# Current position (solid sphere)
		_climb_debug_visual.add_child(_make_debug_sphere_node(gp, 0.06, c))
		# Line from body to current grip
		_climb_debug_visual.add_child(_make_debug_line_node(
			_wall_climb.body_position, gp, Color(c, 0.4)))

		# Planned target (wireframe-style smaller sphere + dashed line) — only when stepping
		if stepping:
			var target: Vector3 = _wall_climb.grip_targets[i]
			_climb_debug_visual.add_child(_make_debug_sphere_node(target, 0.04, Color(c, 0.5)))
			_climb_debug_visual.add_child(_make_debug_line_node(gp, target, Color(c, 0.3)))

		# Ideal position (tiny white sphere — where the grip WANTS to be)
		var ideal: Vector3 = _wall_climb.body_position \
			+ _wall_climb.wall_right * _wall_climb._get_grip_offset(i).x \
			+ _wall_climb.wall_up * _wall_climb._get_grip_offset(i).y
		_climb_debug_visual.add_child(_make_debug_sphere_node(ideal, 0.025, Color(1, 1, 1, 0.3)))

	# Capsule visualization (green wireframe showing active collision shape)
	var active_col: CollisionShape3D = collision_crouching if collision_standing.disabled else collision_standing
	var cap_shape: CapsuleShape3D = active_col.shape as CapsuleShape3D
	if cap_shape:
		var cap_world: Transform3D = global_transform * active_col.transform
		_climb_debug_visual.add_child(_make_debug_capsule(
			cap_world, cap_shape.radius, cap_shape.height, Color(0, 1, 0, 0.35)))

	# Spine bone chain visualization (pink spheres connected by lines)
	var tumble_model: Node3D = model.get_node_or_null("TumbleModel")
	if tumble_model:
		var skeleton: Skeleton3D = _find_child_of_type(tumble_model, "Skeleton3D") as Skeleton3D
		if skeleton:
			var gx: Transform3D = skeleton.global_transform
			var spine_names: Array[String] = [
				"DEF-spine",      # hip
				"DEF-spine.001",  # spine
				"DEF-spine.003",  # chest
				"DEF-spine.005",  # neck
				"DEF-spine.006",  # head
			]
			var prev_pos: Vector3 = Vector3.ZERO
			for j in range(spine_names.size()):
				var idx: int = skeleton.find_bone(spine_names[j])
				if idx < 0:
					continue
				var bone_world: Vector3 = (gx * skeleton.get_bone_global_pose(idx)).origin
				var size: float = 0.04 if j > 0 and j < spine_names.size() - 1 else 0.05
				_climb_debug_visual.add_child(_make_debug_sphere_node(
					bone_world, size, Color(1, 0.4, 0.7)))  # Pink
				if j > 0 and prev_pos != Vector3.ZERO:
					_climb_debug_visual.add_child(_make_debug_line_node(
						prev_pos, bone_world, Color(1, 0.4, 0.7, 0.6)))
				prev_pos = bone_world


func _make_debug_sphere_node(pos: Vector3, radius: float, color: Color) -> MeshInstance3D:
	var mesh := SphereMesh.new()
	mesh.radius = radius
	mesh.height = radius * 2.0
	mesh.radial_segments = 8
	mesh.rings = 4
	var mat := StandardMaterial3D.new()
	mat.albedo_color = color
	mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.no_depth_test = true
	mesh.material = mat
	var inst := MeshInstance3D.new()
	inst.mesh = mesh
	inst.position = pos
	return inst


func _make_debug_capsule(xform: Transform3D, radius: float, height: float, color: Color) -> MeshInstance3D:
	var im := ImmediateMesh.new()
	var half_h: float = height * 0.5 - radius  # Half-height of cylinder section
	var segments: int = 16
	var rings: int = 8

	im.surface_begin(Mesh.PRIMITIVE_LINES)
	im.surface_set_color(color)

	# Vertical lines along the cylinder
	for i in range(segments):
		var angle: float = float(i) / float(segments) * TAU
		var x: float = cos(angle) * radius
		var z: float = sin(angle) * radius
		var top: Vector3 = xform * Vector3(x, half_h, z)
		var bot: Vector3 = xform * Vector3(x, -half_h, z)
		im.surface_add_vertex(top)
		im.surface_add_vertex(bot)

	# Horizontal rings at top, middle, bottom of cylinder
	for ring_y in [-half_h, 0.0, half_h]:
		for i in range(segments):
			var a0: float = float(i) / float(segments) * TAU
			var a1: float = float(i + 1) / float(segments) * TAU
			var p0: Vector3 = xform * Vector3(cos(a0) * radius, ring_y, sin(a0) * radius)
			var p1: Vector3 = xform * Vector3(cos(a1) * radius, ring_y, sin(a1) * radius)
			im.surface_add_vertex(p0)
			im.surface_add_vertex(p1)

	# Top hemisphere arcs (2 perpendicular arcs)
	for axis in range(2):
		for i in range(rings):
			var a0: float = float(i) / float(rings) * PI * 0.5
			var a1: float = float(i + 1) / float(rings) * PI * 0.5
			var p0: Vector3
			var p1: Vector3
			if axis == 0:
				p0 = xform * Vector3(cos(a0) * radius, half_h + sin(a0) * radius, 0)
				p1 = xform * Vector3(cos(a1) * radius, half_h + sin(a1) * radius, 0)
			else:
				p0 = xform * Vector3(0, half_h + sin(a0) * radius, cos(a0) * radius)
				p1 = xform * Vector3(0, half_h + sin(a1) * radius, cos(a1) * radius)
			im.surface_add_vertex(p0)
			im.surface_add_vertex(p1)
			# Mirror for the other side of the arc
			if axis == 0:
				p0 = xform * Vector3(-cos(a0) * radius, half_h + sin(a0) * radius, 0)
				p1 = xform * Vector3(-cos(a1) * radius, half_h + sin(a1) * radius, 0)
			else:
				p0 = xform * Vector3(0, half_h + sin(a0) * radius, -cos(a0) * radius)
				p1 = xform * Vector3(0, half_h + sin(a1) * radius, -cos(a1) * radius)
			im.surface_add_vertex(p0)
			im.surface_add_vertex(p1)

	# Bottom hemisphere arcs
	for axis in range(2):
		for i in range(rings):
			var a0: float = float(i) / float(rings) * PI * 0.5
			var a1: float = float(i + 1) / float(rings) * PI * 0.5
			if axis == 0:
				var p0: Vector3 = xform * Vector3(cos(a0) * radius, -half_h - sin(a0) * radius, 0)
				var p1: Vector3 = xform * Vector3(cos(a1) * radius, -half_h - sin(a1) * radius, 0)
				im.surface_add_vertex(p0)
				im.surface_add_vertex(p1)
				p0 = xform * Vector3(-cos(a0) * radius, -half_h - sin(a0) * radius, 0)
				p1 = xform * Vector3(-cos(a1) * radius, -half_h - sin(a1) * radius, 0)
				im.surface_add_vertex(p0)
				im.surface_add_vertex(p1)
			else:
				var p0: Vector3 = xform * Vector3(0, -half_h - sin(a0) * radius, cos(a0) * radius)
				var p1: Vector3 = xform * Vector3(0, -half_h - sin(a1) * radius, cos(a1) * radius)
				im.surface_add_vertex(p0)
				im.surface_add_vertex(p1)
				p0 = xform * Vector3(0, -half_h - sin(a0) * radius, -cos(a0) * radius)
				p1 = xform * Vector3(0, -half_h - sin(a1) * radius, -cos(a1) * radius)
				im.surface_add_vertex(p0)
				im.surface_add_vertex(p1)

	im.surface_end()

	var mat := StandardMaterial3D.new()
	mat.albedo_color = color
	mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.no_depth_test = true
	mat.vertex_color_use_as_albedo = true
	mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	var inst := MeshInstance3D.new()
	inst.mesh = im
	inst.material_override = mat
	return inst


func _make_debug_line_node(from: Vector3, to: Vector3, color: Color) -> MeshInstance3D:
	var im := ImmediateMesh.new()
	im.surface_begin(Mesh.PRIMITIVE_LINES)
	im.surface_set_color(color)
	im.surface_add_vertex(from)
	im.surface_add_vertex(to)
	im.surface_end()
	var mat := StandardMaterial3D.new()
	mat.albedo_color = color
	mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	mat.no_depth_test = true
	mat.vertex_color_use_as_albedo = true
	var inst := MeshInstance3D.new()
	inst.mesh = im
	inst.material_override = mat
	return inst


# ---- Grip Surface Particles ----

func _create_grip_particles() -> void:
	_destroy_grip_particles()
	var colors: Array[Color] = [Color.RED, Color.BLUE, Color.YELLOW, Color.GREEN]
	# 0-3: Current grip positions (bright). 4-7: Predicted targets (dim).
	for i in range(8):
		var gi: int = i % 4
		var is_target: bool = i >= 4
		var p := CPUParticles3D.new()
		p.name = "GripParticle_%d" % i
		p.top_level = true
		p.emitting = true
		p.amount = 4 if is_target else 6
		p.lifetime = 0.8 if is_target else 0.6
		p.one_shot = false
		p.explosiveness = 0.0
		p.spread = 20.0 if is_target else 15.0
		p.gravity = Vector3.ZERO
		p.initial_velocity_min = 0.05 if is_target else 0.1
		p.initial_velocity_max = 0.15 if is_target else 0.3
		p.direction = Vector3.UP
		p.scale_amount_min = 0.02 if is_target else 0.03
		p.scale_amount_max = 0.04 if is_target else 0.06
		p.color = Color(colors[gi], 0.4) if is_target else colors[gi]
		add_child(p)
		_grip_particles.append(p)


func _update_grip_particles() -> void:
	for i in range(mini(_grip_particles.size(), 8)):
		if i < 4:
			# Current grip positions (bright, larger)
			var p: CPUParticles3D = _grip_particles[i]
			if _wall_climb.grip_valid[i]:
				p.global_position = _wall_climb.grip_positions[i]
				p.direction = _wall_climb.grip_normals[i]
				p.emitting = true
			else:
				p.emitting = false
		else:
			# Predicted target positions (dimmer, smaller)
			var gi: int = i - 4
			var p: CPUParticles3D = _grip_particles[i]
			if _wall_climb.grip_valid[gi]:
				p.global_position = _wall_climb.grip_targets[gi]
				p.direction = _wall_climb.grip_normals[gi]
				p.emitting = true
			else:
				p.emitting = false


func _destroy_grip_particles() -> void:
	for p in _grip_particles:
		if is_instance_valid(p):
			p.free()
	_grip_particles.clear()


func _try_ledge_from_climb() -> void:
	var space := get_world_3d().direct_space_state
	var climb_up: Vector3 = -gravity_dir
	var top := _wall_climb.body_position + _wall_climb.wall_up * 1.2
	var down_result := SphereSweep.sweep(space,
		top + climb_up * 0.5, top - climb_up * 1.0, 0.1, [get_rid()])
	if not down_result.is_empty() and down_result["normal"].dot(climb_up) > 0.5:
		_on_ledge = true
		_ledge_point = down_result["point"]
		_ledge_normal = -_wall_climb.wall_normal
		_ledge_climb_progress = 0.0
		_ledge_start_pos = global_position
		_wall_climb.stop()
		state = State.AIRBORNE
		velocity = Vector3.ZERO


# ---- Grapple ----

func _update_fly(delta: float) -> void:
	# NO PERMANENT WASD (keymap step 1, docs/keymap-blender-279.md §2a): the build camera
	# moves ONLY while flying — RMB held, or the Shift+F fly modal. The rest of the time
	# W A S D, Space, Ctrl, Shift are free for commands (the `fly` context, exclusive,
	# is what makes that safe: no command fires while you fly).
	var km := get_node_or_null("/root/Keymap")
	if km != null and not km.flying():
		velocity = Vector3.ZERO
		return
	var input_dir: Vector2 = input_sync.input_direction
	var forward := -camera_head.global_transform.basis.z
	var right := camera_head.global_transform.basis.x
	var move_dir: Vector3 = (forward * -input_dir.y + right * input_dir.x).normalized()
	var mods: bool = km == null or km.fly_modifiers_allowed()
	var spd: float = (fly_speed_fast if (sprinting and mods) else fly_speed) * fly_speed_scale
	var fly_vel: Vector3 = move_dir * spd
	# Up / down: Blender's E / Q inside the Shift+F modal (Space there is CONFIRM);
	# Space / Ctrl while RMB-looking, as before.
	var modal: bool = GameMode.is_fly_modal()
	var up: bool = (km != null and km.held("fly.up")) if modal else (mods and jump_held)
	var down: bool = (km != null and km.held("fly.down")) if modal else (mods and Input.is_action_pressed("crouch"))
	if up:
		fly_vel += -gravity_dir * spd
	elif down:
		fly_vel += gravity_dir * spd
	# Direct position update — no collision in fly mode
	var pos_before: Vector3 = global_position
	global_position += fly_vel * delta
	velocity = Vector3.ZERO  # Prevent accumulation
	# Debug: detect teleportation
	# Threshold scales with the speed actually commanded. A fixed 100 m fires on
	# every frame once fly_speed_scale is above ~2, which turns a teleport
	# detector into log spam and hides the thing it exists to catch.
	if pos_before.distance_to(global_position) > maxf(100.0, spd * delta * 4.0):
		print("FLY TELEPORT! before=%s after=%s vel=%s" % [pos_before, global_position, fly_vel])


func _update_grapple(delta: float) -> void:
	if not _grapple.active:
		_set_terrain_lod_frozen(false)
		state = State.AIRBORNE
		return

	# Tick grace timer
	if grapple_grace_timer > 0.0:
		grapple_grace_timer -= delta

	# Apply gravity
	velocity += gravity_dir * GRAVITY * delta

	# Get rope constraint force and apply it
	var reel_in: bool = Input.is_action_pressed("sprint")
	var reel_out: bool = Input.is_action_pressed("crouch")
	var rope_force: Vector3 = _grapple.update(delta, global_position, velocity, reel_in, reel_out)

	# Check if entity death caused auto-release during update
	if not _grapple.active:
		# rope_force is actually the release velocity from entity death
		velocity = rope_force
		_set_terrain_lod_frozen(false)
		state = State.AIRBORNE
		return

	velocity += rope_force * delta

	# Direct pull force toward anchor when reeling in (winch behavior)
	if reel_in:
		var to_anchor: Vector3 = (_grapple.anchor_point - global_position).normalized()
		velocity += to_anchor * grapple_pull_force * delta

	# Cap speed
	if velocity.length() > _grapple.max_grapple_speed:
		velocity = velocity.normalized() * _grapple.max_grapple_speed

	# Reduce collision cost during grapple — disable floor snap and limit slides
	var saved_snap: float = floor_snap_length
	var saved_slides: int = max_slides
	floor_snap_length = 0.0
	max_slides = 2
	move_and_slide()
	floor_snap_length = saved_snap
	max_slides = saved_slides

	# If we hit the ground while swinging, transition to movement
	# (skip during grace period so ground-fire works)
	if is_on_floor() and grapple_grace_timer <= 0.0:
		_grapple.release()
		_set_terrain_lod_frozen(false)
		_on_land()


func _update_winch(delta: float) -> void:
	if not _winch_active:
		state = State.AIRBORNE
		return

	# Tick grace timer
	if grapple_grace_timer > 0.0:
		grapple_grace_timer -= delta

	# Direction to anchor
	var to_anchor: Vector3 = _winch_anchor - global_position
	var dist: float = to_anchor.length()

	# Auto-release when close enough
	if dist < winch_arrive_dist:
		_winch_active = false
		_set_terrain_lod_frozen(false)
		state = State.AIRBORNE
		return

	var dir: Vector3 = to_anchor / dist  # normalized

	# Choose pull speed based on input
	var pull_speed: float = winch_base_speed
	if Input.is_action_pressed("sprint"):
		pull_speed = winch_fast_speed
	elif Input.is_action_pressed("crouch"):
		pull_speed = winch_slow_speed

	# Gravity accumulates — creates arc sag below straight line to anchor
	velocity += gravity_dir * GRAVITY * winch_gravity_scale * delta

	# Steer velocity toward anchor without overriding gravity.
	# Blend current velocity toward pull direction — like a steering force.
	var target_vel: Vector3 = dir * pull_speed
	velocity = velocity.lerp(target_vel, 2.0 * delta)

	# Cap speed
	if velocity.length() > grapple_max_speed:
		velocity = velocity.normalized() * grapple_max_speed

	# Move — reduce collision cost during winch
	var saved_snap: float = floor_snap_length
	var saved_slides: int = max_slides
	floor_snap_length = 0.0
	max_slides = 2
	move_and_slide()
	floor_snap_length = saved_snap
	max_slides = saved_slides

	# Ground contact releases (after grace period)
	if is_on_floor() and grapple_grace_timer <= 0.0:
		_winch_active = false
		_set_terrain_lod_frozen(false)
		_on_land()


# ---- Jump ----

func _check_jump() -> void:
	# Overgrowth: WantsToJump && on_ground_time > kjump_threshold_time && !pre_jump
	# Holding jump means SKI, not jump. The tap has already fired by the time this
	# latches (pre_jump_delay 0.08 < ski_hold_delay 0.15), so a tap is unaffected.
	# JUMP DURING A ROLL IS A HANDSPRING (user 2026-10-05) — before the ski hold and the
	# pre-jump crouch: the roll IS the wind-up, so it launches now.
	if input_sync.wants_jump and _proc_animator and _proc_animator.can_handspring():
		_start_handspring()
		return
	if ski_holding:
		return
	if input_sync.wants_jump and ground_time >= jump_threshold_time and pre_jump_timer <= 0.0:
		pre_jump_timer = pre_jump_delay
		# Overgrowth: duck_vel = 30 * (1 - duck_amount * 0.6) for jump anticipation
		duck_vel = 30.0 * (1.0 - duck_amount * 0.6)
		target_duck_amount = 0.5


func _check_roll() -> void:
	if not _proc_animator:
		return
	# Overgrowth: WantsToRoll() = GetInputPressed("crouch") — single press starts roll
	# If already rolling and crouch still held, chain another when current finishes
	if Input.is_action_just_pressed("crouch") and _horizontal_speed() > 1.0:
		if not _proc_animator.is_flipping():
			var move_dir := Vector3(velocity.x, 0, velocity.z).normalized()
			_roll_face_angle = model.rotation.y  # Lock facing at roll start
			_proc_animator.start_roll(move_dir)
	elif Input.is_action_pressed("crouch") and _horizontal_speed() > 1.0:
		# Chain rolls: if current roll finished and crouch still held, start another
		if not _proc_animator.is_flipping():
			var move_dir := Vector3(velocity.x, 0, velocity.z).normalized()
			_roll_face_angle = model.rotation.y
			_proc_animator.start_roll(move_dir)


func _check_double_jump() -> void:
	## Our addition (not in Overgrowth): double-jump with velocity boost
	if not _proc_animator:
		return
	if _has_double_jumped:
		return
	if _proc_animator.is_flipping():
		return
	if not input_sync.wants_jump:
		return
	if _on_ledge:
		return

	_has_double_jumped = true
	var dj_up: Vector3 = -gravity_dir
	var facing := -model.global_transform.basis.z
	facing = (facing - dj_up * facing.dot(dj_up)).normalized()
	_proc_animator.start_flip(facing)
	# Upward velocity boost (slightly weaker than first jump)
	var dj_vert: float = velocity.dot(dj_up)
	velocity = velocity - dj_up * dj_vert + dj_up * JUMP_INITIAL_VELOCITY * 0.8
	_play_varied(snd_jump, 1.0)


func _check_air_flip() -> void:
	## Overgrowth: WantsToFlip() in air → flip without velocity boost
	## (separate from double jump — crouch in air = flip)
	if not _proc_animator:
		return
	if _proc_animator.is_flipping():
		return
	if _on_ledge:
		return
	if Input.is_action_just_pressed("crouch"):
		var facing := -model.global_transform.basis.z
		facing.y = 0.0
		facing = facing.normalized()
		_proc_animator.start_flip(facing)


## Upward launch of a handspring (m/s): about a metre up and ~0.9 s in the air at 9.8,
## long enough for the turn to finish before the feet come down.
const HANDSPRING_UP := 4.5
## Opening kick on the duck spring at launch (duck units per second).
const HANDSPRING_OPEN := 12.0


## The roll's forward speed, kept, plus a hop; the turn carries on in the animator.
## No jump fuel (it is not a held jump) and no double jump out of it.
func _start_handspring() -> void:
	if not _proc_animator.start_handspring():
		return
	var up: Vector3 = -gravity_dir
	var hv: Vector3 = velocity - up * velocity.dot(up)
	velocity = hv + up * HANDSPRING_UP
	# THE PUSH-OFF OPENS THE BODY: the duck spring alone takes ~10 frames to come out of
	# the roll's full crouch, and the body is upside down ~4 frames after launch — so it
	# went over the top still tucked. Kick it open, as the jump's anticipation kicks it.
	duck_vel = -HANDSPRING_OPEN
	pre_jump_timer = 0.0
	jump_fuel_remaining = 0.0
	down_jump_fuel = 0.0
	state = State.AIRBORNE
	ground_time = 0.0
	air_time = 0.0
	_has_double_jumped = true
	_has_hit_wall = false
	_play_pool(snd_jump, JUMP_POOL, 1.0)


func _execute_jump() -> void:
	# Overgrowth: GetJumpVelocity adjusts for slope
	var target_vel := _get_target_velocity()
	var jump_vel := _get_jump_velocity(target_vel, ground_normal)
	velocity = jump_vel

	jump_fuel_remaining = JUMP_FUEL
	down_jump_fuel = JUMP_FUEL * 0.5
	state = State.AIRBORNE
	ground_time = 0.0
	air_time = 0.0
	_has_double_jumped = false
	_has_hit_wall = false
	_play_pool(snd_jump, JUMP_POOL, 1.0)


func _get_jump_velocity(target_vel: Vector3, gnd_normal: Vector3) -> Vector3:
	## Overgrowth exact: GetJumpVelocity (aircontrols.as:387-420)
	## Adjusts jump direction based on ground slope
	var local_up: Vector3 = -gravity_dir
	# Project target_vel onto tangent plane and add upward component
	var tangent_vel: Vector3 = target_vel - local_up * target_vel.dot(local_up)
	var jump_vel := tangent_vel * run_speed + local_up * JUMP_INITIAL_VELOCITY

	var jump_dir := jump_vel.normalized()
	if jump_dir.dot(gnd_normal) < 0.3:
		# On steep slope: reconstruct jump in ground space
		var ground_up := gnd_normal
		var ground_front := target_vel
		if ground_front.length_squared() < 0.001:
			ground_front = local_up.cross(Vector3(1, 0, 0))
			if ground_front.length_squared() < 0.001:
				ground_front = local_up.cross(Vector3(0, 0, 1))
			ground_front = ground_front.normalized()
		var ground_right := ground_up.cross(ground_front)
		ground_right = (ground_right - local_up * ground_right.dot(local_up)).normalized()
		ground_front = ground_right.cross(ground_up).normalized()
		ground_up = ground_front.cross(ground_right).normalized()

		# Reconstruct in ground space
		jump_vel = ground_right * 0.0 \
			+ ground_up * JUMP_INITIAL_VELOCITY \
			+ ground_front * target_vel.length() * run_speed

	# Clamp upward component
	var up_speed: float = jump_vel.dot(local_up)
	if up_speed > JUMP_INITIAL_VELOCITY:
		jump_vel += local_up * (JUMP_INITIAL_VELOCITY - up_speed)

	return jump_vel


func _on_land() -> void:
	state = State.MOVEMENT
	wall_run_active = false
	wall_run_time = 0.0
	jump_fuel_remaining = 0.0
	down_jump_fuel = 0.0
	_has_double_jumped = false
	_has_hit_wall = false

	# Fall damage check (Overgrowth: shock > 30 → damage = (shock-30) * 0.1)
	var shock := absf(_y_before_slide)
	if shock > SHOCK_DAMAGE_THRESHOLD:
		var _damage := (shock - SHOCK_DAMAGE_THRESHOLD) * SHOCK_DAMAGE_MULTIPLIER
		# TODO: apply to health system, trigger ragdoll on high damage

	# Overgrowth Land() lines 8429-8431: feed impact into crouch spring
	# duck_amount = 1.0; duck_vel = land_speed * 0.3
	# We scale duck_amount by impact speed: small hops = partial, big falls = full crouch
	var land_speed: float = clampf(-_y_before_slide, 2.0, 30.0)
	if land_speed > 3.0:
		# Overgrowth always sets duck_amount=1.0, we scale: full at land_speed≥8
		duck_amount = clampf(land_speed / 8.0, duck_amount, 1.0)
		duck_vel = land_speed * 0.4  # Stronger downward spring impulse
		target_duck_amount = 0.0  # Spring bounces back to standing

	air_time = 0.0

	# A STEP HOP lands as a step, not a fall: no squash, no landing sound.
	if _step_hopping:
		_step_hopping = false
		duck_amount = minf(duck_amount, 0.2)
		duck_vel = 0.0
		return

	# Landing sound + particles
	if not _was_on_floor:
		_play_pool(snd_land, LAND_POOL, 1.0)
		if _y_before_slide < -17.0:
			var particles := LandingParticles.instantiate()
			get_tree().current_scene.add_child(particles)
			particles.global_position = global_position


# ---- STEP-UP (docs/player-step-up-design.md) -----------------------------------
#
# The classic stair-step move (Quake / Source StepMove), done with test_move so nothing
# is moved until the whole step is known to work: lift, carry forward, drop onto the
# tread. Run only when this frame's horizontal motion is BLOCKED by something steeper
# than floor_max_angle — a slope is move_and_slide's business.

## Forward carry at the lifted height, at least this much (m). With a 0.3 m capsule the
## drop then meets a tread edge at <= ~53°, which counts as floor (60°); a slower step
## (a walk at 1.6 m/s moves 2.7 cm a frame) would meet it at 65° and be refused.
const STEP_PROBE_MIN := 0.06
## How far onto the top a hop is aimed past the edge it found (m).
const HOP_LAND_IN := 0.25
## Clearance a hop's apex has over the step (m).
const HOP_CLEARANCE := 0.12
## Hops this close together alternate legs (a flight); otherwise the gait phase decides.
const HOP_FLIGHT_S := 1.0

var _hop_push: Vector3 = Vector3.ZERO
## Why the last step attempt did or did not step (gates and probes read it).
var step_last := ""
## The horizontal velocity a step-up was taken at (ZERO = no step this frame).
var _step_keep_hv: Vector3 = Vector3.ZERO


## THE STEP, AFTER THE FACT (Quake/Source StepMove: try the move, and if a wall ate it,
## try it stepped). The pre-check (_try_step_up) tests only this frame's horizontal
## motion, and the real slide — with its vertical part and iterations — still hit risers
## it had called "clear": 0 m/s at every riser of 0.45 m-tread stairs (gate_step_up 11,
## 2026-10-05). If the slide lost most of the speed against something steeper than a
## floor whose top is within step_up, redo the move as a step from where it started.
func _step_after_slide(pre_xf: Transform3D, pre_v: Vector3, delta: float) -> void:
	if step_up <= 0.0 or _step_keep_hv != Vector3.ZERO or _hop_timer > 0.0 or pre_jump_timer > 0.0:
		return
	var up := -gravity_dir
	var pre_hv := pre_v - up * pre_v.dot(up)
	var now_hv := velocity - up * velocity.dot(up)
	if pre_hv.length() < 1.0 or now_hv.length() > pre_hv.length() * 0.6:
		return
	var dir := pre_hv.normalized()
	var contact := Vector3.INF
	for i in get_slide_collision_count():
		var col := get_slide_collision(i)
		if col.get_normal().dot(up) < cos(floor_max_angle) - 0.01 and col.get_normal().dot(dir) < -0.3:
			contact = col.get_position()
			break
	if contact == Vector3.INF:
		return
	var top := _obstacle_top_at(up, contact + dir * 0.03)
	if top < 0.02 or top > step_up + STEP_BAND_TOL:
		return
	var reach := ((contact - pre_xf.origin) - up * (contact - pre_xf.origin).dot(up)).length()
	var carry := maxf(pre_hv.length() * delta, reach - 0.3 + 0.07)
	var s := _step_probe(pre_xf, up, step_up, dir * maxf(carry, STEP_PROBE_MIN))
	step_last = "after-slide top %.3f %s" % [top, "FAILED" if s.is_empty() else "rise %.3f" % float(s["rise"])]
	if s.is_empty() or float(s["rise"]) <= 0.02:
		return
	var rise: float = float(s["rise"]) - (global_position - pre_xf.origin).dot(up)
	global_position = s["pos"]
	_step_smooth -= rise
	step_stats["ups"] = int(step_stats["ups"]) + 1
	_steps_recent += 1.0
	_step_keep_hv = pre_hv


## A STEP KEEPS THE RUN'S SPEED (Quake's StepMove keeps the stepped move's velocity).
## The step lands the capsule on the tread's EDGE, and that frame's slide against the
## corner stripped the speed: the run re-accelerated from ~0.6 m/s on every step
## (_probe_stairs_feet, 2026-10-05). Restored after the slide, horizontal only, while
## grounded — the slide's vertical part (and the next frame's up-clamp) is unchanged.
func _keep_step_speed() -> void:
	if _step_keep_hv == Vector3.ZERO:
		return
	var keep := _step_keep_hv
	_step_keep_hv = Vector3.ZERO
	if not is_on_floor():
		return
	var up := -gravity_dir
	velocity = keep + up * velocity.dot(up)
var _remote_hop: bool = false
var _remote_was_grounded: bool = true
var _last_hop_t: float = -10.0


func _try_step_up(delta: float) -> void:
	if step_up <= 0.0 or _hop_timer > 0.0 or pre_jump_timer > 0.0:
		return
	var up := -gravity_dir
	var hv := velocity - up * velocity.dot(up)
	var speed := hv.length()
	if speed < 0.05:
		return
	var dir := hv / speed
	var motion := hv * delta
	var xf := global_transform
	var c := KinematicCollision3D.new()
	# FROM 2 cm UP: from the feet the first contact reported was often the tread UNDER
	# the capsule (normal ~(0, 0.99, 0.13)), read as "a slope, the slide will climb it"
	# — while the riser ahead then stopped the run dead (step_last trace, 2026-10-05).
	# 2 cm clears what we stand on; any riser taller than that still blocks.
	if not test_move(xf.translated(up * 0.02), motion, c):
		step_last = "clear"
		return
	var n := c.get_normal()
	if n.dot(up) >= cos(floor_max_angle) - 0.01:
		step_last = "slope n=%s" % str(n.snapped(Vector3.ONE * 0.01))
		return                                  # a climbable slope or an edge it rides
	# THE BAND IS DECIDED BY THE OBSTACLE'S TOP, measured — not by where the walk probe
	# lands: the capsule's round bottom rests on the tread EDGE a few cm low and then
	# rides the rest, so a 0.45 m ledge "probed" at 0.39 and was walked up (no hop).
	# AT THE POINT HIT, just past the riser — not a fixed distance ahead: 0.41 m ahead is
	# past a 0.30 m tread, onto the step AFTER the one in the way, so ordinary stairs
	# read as 0.448 m, nothing stepped, and the riser stopped the run dead every step
	# (user 2026-10-05: "player stair step is all wrong"; _probe_stairs_feet: 2.24 m/s
	# on the stairs against 8.00 on the flat, 0 m/s at each riser).
	var top := _obstacle_top_at(up, c.get_position() + dir * 0.03)
	step_last = "top %.3f n=%s at %s" % [top, str(n.snapped(Vector3.ONE * 0.01)), str(c.get_position().snapped(Vector3.ONE * 0.01))]
	if top > hop_max + 0.01:
		return                                  # too tall: jump / ledge grab as before
	if top <= step_up + STEP_BAND_TOL:
		# CARRY PAST THE CONTACT, not just this frame's motion: with the riser 4 cm ahead a
		# 7 cm carry left the capsule's axis 0.27 m short of the tread edge, the drop met
		# the corner at 64° (steeper than the 60° floor) and the step was REFUSED — the
		# riser then stopped the run dead, every step (step_last trace, 2026-10-05).
		# travel + 0.07 puts the axis within ~0.23 m of the edge: a walkable corner.
		var carry := maxf(motion.length(), c.get_travel().length() + 0.07)
		var s := _step_probe(xf, up, step_up, dir * maxf(carry, STEP_PROBE_MIN))
		step_last += " walk-probe %s" % ("FAILED" if s.is_empty() else "rise %.3f" % float(s["rise"]))
		if not s.is_empty():
			var rise: float = s["rise"]
			if rise > 0.02:
				global_position = s["pos"]
				_step_smooth -= rise
				step_stats["ups"] = int(step_stats["ups"]) + 1
				_steps_recent += 1.0
				_step_keep_hv = hv
		return
	# Too tall to walk up: a HOP, if we are actually going INTO it.
	if hop_max <= step_up:
		return
	var face := n - up * n.dot(up)
	if face.length() < 1.0e-3 or hv.dot(-face.normalized()) < hop_min_speed:
		return
	var s2 := _step_probe(xf, up, hop_max + 0.02, dir * maxf(motion.length(), 0.35))
	if s2.is_empty():
		return
	var h: float = s2["rise"]
	if h <= step_up or h > hop_max + 0.01:
		return
	_begin_hop(h, dir, s2["pos"])


## Height (above the feet) of the top of whatever is under `point` (a spot just past the
## face that blocked us), or -1 if there is nothing there, or INF if it is taller than
## the ray reaches (the ray starts inside it).
func _obstacle_top_at(up: Vector3, point: Vector3, from_h: float = -1.0) -> float:
	var space := get_world_3d().direct_space_state
	# Down through `point`, from above the hop band (or `from_h` over the feet — the
	# SECOND riser of a flight is a whole step higher, and a ray starting inside it
	# reads INF), to just under the feet.
	var at := point - up * (point - global_position).dot(up)
	var start_h := from_h if from_h > 0.0 else hop_max + 0.1
	var q := PhysicsRayQueryParameters3D.create(at + up * start_h, at - up * 0.05)
	q.exclude = [get_rid()]
	q.hit_from_inside = true
	var hit := space.intersect_ray(q)
	if hit.is_empty():
		return -1.0
	if (hit["normal"] as Vector3) == Vector3.ZERO:
		return INF
	return ((hit["position"] as Vector3) - global_position).dot(up)


## Lift by `lift_h` (less under a low ceiling), carry `fwd`, drop onto a walkable top.
## {pos, rise} of the resting place, or {} if any leg of it fails.
func _step_probe(xf: Transform3D, up: Vector3, lift_h: float, fwd: Vector3) -> Dictionary:
	var c := KinematicCollision3D.new()
	var lift := up * lift_h
	if test_move(xf, lift, c):
		lift = c.get_travel()
	var x2 := xf.translated(lift)
	if test_move(x2, fwd, c):
		return {}
	var x3 := x2.translated(fwd)
	if not test_move(x3, -up * (lift.length() + 0.05), c):
		return {}
	if c.get_normal().dot(up) < cos(floor_max_angle):
		return {}
	var land: Vector3 = x3.origin + c.get_travel()
	return {"pos": land, "rise": (land - xf.origin).dot(up)}


## THE RUNNING LEAP (user 2026-10-05: "a leaping run up higher inclines instead of the
## not effective current jump ... it needs to happen at a similar speed as a normal
## run"). The crouch-hop STOPPED at the riser, crouched and pushed at the 1-2 m/s that
## reached the top. Running, a step in the leap band is seen AHEAD (a test_move along
## the run, as far as a leap could be launched from) and the leap leaves the ground
## BEFORE the riser, at the run's own horizontal speed, with exactly the up-speed that
## step needs: sqrt(2 g (h + clearance)) — a taller step, a harder leap, the same pace.
## The horizontal speed is held through the air (the airborne hop branch), so it lands
## running and the next leap of a flight follows straight on.
##
## LAUNCH POINT: the capsule reaches the riser after d/speed seconds; it must by then be
## at least as high as the top (after t1, the first crossing of `top`) and still rising
## (before the apex). It leaves at the first frame d <= speed * (t1 + t_apex) / 2 — the
## middle of that window. Too close already (a late sighting) still leaves: the held
## push slides it up the riser and over.
const LEAP_MIN_SPEED := 3.0
## Up a flight, each leap stays above the line of nosings this long (s) — one running
## stride; it lands a tread or two up and leaps again.
const LEAP_INCLINE_T := 0.25
## The hardest leap (m/s up). A jump leaves at 12, so remote derivation (1-10 m/s off
## the ground) still tells them apart.
const LEAP_MAX_UP := 9.0
## The walk band's measuring tolerance: the feet rest a few mm into the floor, so a
## 16-unit (0.448 m) step measured 0.456 and was leapt instead of walked.
const STEP_BAND_TOL := 0.02


func _try_leap() -> bool:
	if hop_max <= step_up or _hop_timer > 0.0 or pre_jump_timer > 0.0 or _step_hopping:
		return false
	var up := -gravity_dir
	var hv := velocity - up * velocity.dot(up)
	var speed := hv.length()
	if speed < LEAP_MIN_SPEED:
		return false
	var dir := hv / speed
	var v_max := sqrt(2.0 * GRAVITY * (hop_max + HOP_CLEARANCE))
	var look := speed * v_max / GRAVITY + 0.05
	var c := KinematicCollision3D.new()
	if not test_move(global_transform.translated(up * 0.02), dir * look, c):
		return false
	var n := c.get_normal()
	if n.dot(up) >= cos(floor_max_angle) - 0.01 or n.dot(dir) > -0.3:
		return false
	var d := c.get_travel().length()
	var top := _obstacle_top_at(up, c.get_position() + dir * 0.03)
	if top <= step_up + STEP_BAND_TOL or top > hop_max + 0.01:
		return false
	# AN INCLINE, or one step? A second riser past this one makes a flight: at run speed
	# no leap can land tread by tread on steep steps (a 0.6 m leap at 8 m/s carries
	# ~3.4 m and hit the NEXT riser), so the leap is sized to the SLOPE instead: clear
	# this nosing, then stay above the line of nosings for LEAP_INCLINE_T — it lands on
	# whichever tread that brings, still running, and the next leap follows.
	var slope := 0.0
	var c2 := KinematicCollision3D.new()
	var past := global_transform.translated(up * (top + 0.05)).translated(dir * (d + 0.05))
	var seen := "none"
	if test_move(past, dir * look, c2):
		seen = "n=%s" % str(c2.get_normal().snapped(Vector3.ONE * 0.01))
		if c2.get_normal().dot(up) < cos(floor_max_angle) - 0.01:
			var top2 := _obstacle_top_at(up, c2.get_position() + dir * 0.03, top + hop_max + 0.1)
			var d2 := d + 0.05 + c2.get_travel().length()
			seen += " top2 %.2f d2 %.2f" % [top2, d2]
			if top2 > top + 0.05 and top2 - top <= hop_max + 0.01 and d2 > d + 0.1:
				slope = (top2 - top) / (d2 - d)
	var v_up := sqrt(2.0 * GRAVITY * (top + HOP_CLEARANCE))
	if slope > 0.05:
		var v := slope * speed + GRAVITY * LEAP_INCLINE_T * 0.5 + 1.0
		for _i in 3:                                  # t* depends on v: iterate
			var ts := _first_crossing(v, top + 0.05)
			v = slope * speed + GRAVITY * (ts + LEAP_INCLINE_T * 0.5)
		v_up = clampf(maxf(v, v_up), v_up, LEAP_MAX_UP)
	# THE LATEST CLEAN LAUNCH: leave when the riser is exactly as far as the run covers
	# while rising to its top — never earlier (an early leap overshoots into the next
	# riser); closer than that (a landing right at a riser) leaves at once, and the held
	# push slides it up the face and over.
	var t_star := _first_crossing(v_up, top + 0.05)
	if d > speed * t_star:
		step_last = "leap: %.2f m to a %.2f m step (slope %.2f), not yet" % [d, top, slope]
		return false
	# Room to land: the top, a capsule past the riser, has standing space.
	if _step_probe(global_transform, up, top + 0.05, dir * (d + 0.35)).is_empty():
		step_last = "leap: no room on the %.2f m step" % top
		return false
	_pick_lead()
	_hop_push = hv
	velocity = hv + up * v_up
	state = State.AIRBORNE
	_step_hopping = true
	ground_time = 0.0
	air_time = 0.0
	jump_fuel_remaining = 0.0
	down_jump_fuel = 0.0
	step_stats["hops"] = int(step_stats["hops"]) + 1
	step_last = "leap: %.2f m step from %.2f m at %.1f m/s, up %.2f m/s (slope %.2f; next %s)" % [top, d, speed, v_up, slope, seen]
	return true


## Seconds until a launch at `v_up` first reaches height `h` (INF if it never does).
static func _first_crossing(v_up: float, h: float) -> float:
	var disc := v_up * v_up - 2.0 * GRAVITY * h
	if disc < 0.0:
		return INF
	return (v_up - sqrt(disc)) / GRAVITY


## THE LEAD LEG: up a flight the legs alternate; a first hop takes the foot that is in
## front in the stride (the gait phase's first half leads with the left).
func _pick_lead() -> void:
	var now := Time.get_ticks_msec() / 1000.0
	if now - _last_hop_t < HOP_FLIGHT_S:
		_hop_lead = 1 - _hop_lead
	else:
		var ph: float = float(_proc_animator.get("_cycle_phase")) if _proc_animator != null else 0.0
		_hop_lead = 0 if ph < 0.5 else 1
	_last_hop_t = now


func _begin_hop(h: float, dir: Vector3, land: Vector3) -> void:
	_hop = {"h": h, "dir": dir, "land": land}
	_hop_timer = hop_crouch_s * clampf(h / maxf(hop_max, 0.01), 0.5, 1.0)
	_pick_lead()
	# The crouch: the hip drops (the duck spring), harder for a taller step. Kept under
	# 0.5 so the capsule never swaps to the crouching shape mid-hop.
	target_duck_amount = 0.35 * clampf(h / maxf(hop_max, 0.01), 0.5, 1.0)
	duck_vel = 12.0 * clampf(h / maxf(hop_max, 0.01), 0.5, 1.0)


## The push: a ballistic arc whose apex clears the step by HOP_CLEARANCE and whose
## horizontal speed reaches HOP_LAND_IN past the edge by the time it is back down.
func _launch_hop() -> void:
	var up := -gravity_dir
	var h: float = _hop["h"]
	var dir: Vector3 = _hop["dir"]
	var target: Vector3 = (_hop["land"] as Vector3) + dir * HOP_LAND_IN
	var v_up := sqrt(2.0 * GRAVITY * (h + HOP_CLEARANCE))
	var t := v_up / GRAVITY + sqrt(2.0 * HOP_CLEARANCE / GRAVITY)
	var flat := target - global_position
	flat -= up * flat.dot(up)
	var v_f := clampf(flat.length() / t, hop_min_speed, run_speed)
	_hop_push = dir * v_f
	velocity = _hop_push + up * v_up
	state = State.AIRBORNE
	_step_hopping = true
	ground_time = 0.0
	air_time = 0.0
	jump_fuel_remaining = 0.0
	down_jump_fuel = 0.0
	target_duck_amount = 0.0
	_hop = {}
	step_stats["hops"] = int(step_stats["hops"]) + 1


## Walking DOWN: the floor went away this frame, but the next tread is within step_up
## below — snap onto it instead of going airborne (no FALL/LAND every tread).
func _try_step_down() -> bool:
	if step_up <= 0.0 or not _was_on_floor or _step_hopping or pre_jump_timer > 0.0 \
			or _hop_timer > 0.0 or velocity.dot(-gravity_dir) > 0.1:
		return false
	var up := -gravity_dir
	var c := KinematicCollision3D.new()
	if not test_move(global_transform, -up * step_up, c):
		return false
	if c.get_normal().dot(up) < cos(floor_max_angle):
		return false
	var drop: float = -c.get_travel().dot(up)
	global_position += c.get_travel()
	apply_floor_snap()
	if not is_on_floor():
		global_position -= c.get_travel()
		return false
	_step_smooth += drop
	step_stats["downs"] = int(step_stats["downs"]) + 1
	_steps_recent += 1.0
	return true


## A grounded body that moved UP or DOWN more than its floor's slope explains (the
## capsule riding a small edge, a step-up, a floor snap) has its DRAWN height eased
## instead: the difference goes into _step_smooth. On a slope the rise is explained by
## the slope (the floor normal before the slide), so climbing a hill is not lagged.
func _smooth_unexpected_rise(from: Vector3, floor_n: Vector3) -> void:
	if floor_n == Vector3.ZERO or state != State.MOVEMENT or not is_on_floor():
		return
	var up := -gravity_dir
	var d := global_position - from
	var dy := d.dot(up)
	var dh := d - up * dy
	# THE SLOPE IS THE GROUND RAY'S (ground_normal: the ray at the feet, smoothed), NOT
	# the contact's floor normal. At a tread EDGE the contact normal is the capsule
	# resting on the corner (~34° just after a step): read as a slope it "explains" the
	# rest of a step-up and predicts rises/drops that never happen, so both directions
	# popped (gate_step_up 7). The ray sees the tread tops on stairs and the real slope
	# on a hill.
	# The RAY's own hit, not ground_normal: when the ray misses (it starts a few mm
	# inside a tread the capsule is resting on the edge of) _update_ground_normal falls
	# back to get_floor_normal() — the edge again. A miss here means "no slope known":
	# flat. On a real slope the ray (0.3 m below the feet) always hits.
	var gn: Vector3 = ground_ray.get_collision_normal().normalized() if ground_ray.is_colliding() else up
	var ny := gn.dot(up)
	if ny < 0.1:
		return
	var expected := -(gn - up * ny).dot(dh) / ny
	var un := dy - expected
	if absf(un) > 0.01:
		_step_smooth -= un


## The drawn height catches up at a FIXED RATE (step_catchup_speed), not a spring: a
## spring soft enough not to pop a 0.4 m step lags 0.3-0.4 m behind a body running up
## stairs (~3 m/s of climb for 0.224 m treads at run speed), which sinks the feet into
## the treads. A rate above the climb rate keeps up and still removes the pops.
##
## AND AT LEAST THE CLIMB RATE: once the stairs are climbed at speed (~6 m/s up 0.224 m
## risers on 0.30 m treads at a run) a fixed 3.6 m/s let the offset ACCUMULATE — the
## body and camera rode ~0.4 m low and the feet sank into the treads
## (_probe_stairs_feet, 2026-10-05). The rate tracks 1.3x the body's smoothed vertical
## speed: one step still eases in, a flight is drawn at the speed it is climbed.
func _update_step_smooth(delta: float) -> void:
	var rate := maxf(step_catchup_speed, absf(_climb_rate) * 1.3)
	# BOUNDED WHILE CLIMBING: a run up a short flight (8 m/s, ~6 m/s of climb) is over
	# before any rate estimate converges, and the drawn body rode 0.6 m low. Climbing
	# faster than 1.5 m/s, the body is drawn at most STEP_LAG_CLIMBING below where it
	# is; a single step (no sustained climb) keeps the full ease.
	# A FLIGHT, not a lone step: steps coming back to back (a decaying count over ~0.3 s
	# past 1.5) or a sustained climb. A run up a short flight is over in 0.25 s — before
	# a 0.4 s climb average converges — and rode 0.6 m low; capping a LONE step instead
	# popped it (gate_step_up 7).
	var flight := _steps_recent > 1.5 or absf(_climb_sustained) > 2.0
	var cap := STEP_LAG_CLIMBING if flight else 1.0
	_steps_recent = maxf(0.0, _steps_recent - delta / 0.3)
	_step_smooth = clampf(move_toward(_step_smooth, 0.0, rate * delta), -cap, cap)
	# The body's vertical speed over the last ~0.15 s (step-ups and slides alike).
	if delta > 0.0:
		var vy := (global_position.y - _climb_last_y) / delta
		_climb_rate = lerpf(_climb_rate, vy, 1.0 - exp(-delta / 0.15))
		_climb_sustained = lerpf(_climb_sustained, vy, 1.0 - exp(-delta / 0.4))
	_climb_last_y = global_position.y


var _climb_rate := 0.0
var _climb_sustained := 0.0
var _steps_recent := 0.0              # +1 per step, decays over ~0.3 s
var _climb_last_y := 0.0
const STEP_LAG_CLIMBING := 0.15


# ---- Crouch (Overgrowth exact: UpdateDuckAmount) ----

func _update_duck(delta: float) -> void:
	var frames: float = delta * 60.0

	# Overgrowth: WantsToCrouch + speed-based partial crouch while moving
	if Input.is_action_pressed("crouch"):
		target_duck_amount = 1.0
		# Overgrowth: while moving fast, partial crouch (0.3 at speed 5+)
		if _horizontal_speed() > 1.0:
			target_duck_amount = lerpf(1.0, 0.3, minf(1.0, sqrt(_horizontal_speed()) / sqrt(5.0)))
	elif pre_jump_timer <= 0.0 and _hop_timer <= 0.0:
		target_duck_amount = 0.0

	# Flip/roll duck override
	if _proc_animator and _proc_animator.is_flipping():
		var flip_duck: float = _proc_animator._flip_roll.get_duck_amount() if _proc_animator._flip_roll else -1.0
		if flip_duck >= 0.0:
			target_duck_amount = flip_duck

	# Overgrowth exact spring-damper: duck_vel += (target - current) * dt * 120
	duck_vel += (target_duck_amount - duck_amount) * delta * DUCK_ACCEL
	duck_vel *= pow(DUCK_VEL_INERTIA, frames)
	duck_amount += duck_vel * delta
	duck_amount = clampf(duck_amount, 0.0, 1.0)

	# Toggle collision shapes
	if duck_amount > 0.5 and not collision_standing.disabled:
		collision_standing.disabled = true
		collision_crouching.disabled = false
	elif duck_amount <= 0.5 and not collision_crouching.disabled:
		collision_crouching.disabled = true
		collision_standing.disabled = false


# ---- Remote ----

func _process_remote(delta: float) -> void:
	var d: Dictionary = _decode_sync_state(sync_state)
	if d.is_empty():
		return
	_decoded_position = d["position"]
	_decoded_rotation_y = d["rotation_y"]
	_decoded_mesh_rotation_y = d["mesh_rotation_y"]
	_decoded_velocity = d["velocity"]
	_decoded_on_floor = d["on_floor"]
	_decoded_state = d["state"]
	_decoded_surface_up = d["surface_up"]

	var now: float = Time.get_ticks_msec() / 1000.0
	var time_since_sync: float = now - _last_sync_time

	if _decoded_position != _last_synced_position:
		_sync_velocity = _decoded_velocity
		_last_synced_position = _decoded_position
		_last_sync_time = now
		time_since_sync = 0.0

	var target_pos: Vector3 = _decoded_position
	if time_since_sync > SYNC_INTERVAL:
		target_pos += _sync_velocity * (time_since_sync - SYNC_INTERVAL)

	if global_position.distance_to(target_pos) > SNAP_DISTANCE:
		global_position = target_pos
	else:
		global_position = global_position.lerp(target_pos, 10.0 * delta)

	velocity = _decoded_velocity

	# Sync controller state for remote wall climb / lizard walk
	var remote_climbing := (_decoded_state == State.WALL_CLIMB)
	if remote_climbing:
		surface_up = surface_up.slerp(_decoded_surface_up, SURFACE_ALIGN_SPEED * delta)
		state = State.WALL_CLIMB
		# Orient body and model to match remote surface
		_rotate_body_to_surface(delta)
		_orient_model_to_surface(delta)
		# Reconstruct grip targets from position + surface for animator
		if _wall_climb and not _wall_climb.active:
			_wall_climb.start(global_position, -_decoded_surface_up, Vector3.UP)
		if _wall_climb and _wall_climb.active:
			var facing := velocity.normalized() if velocity.length() > 0.1 else -model.global_transform.basis.z
			_wall_climb.sync(global_position + surface_up * lizard_height, -gravity_dir, facing)
	else:
		if state == State.WALL_CLIMB and _wall_climb and _wall_climb.active:
			_wall_climb.stop()
		state = _decoded_state
		surface_up = surface_up.slerp(Vector3.UP, 10.0 * delta)
		model.rotation.y = lerp_angle(model.rotation.y, _decoded_mesh_rotation_y, 10.0 * delta)
		_dbg_model("remote_avatar")

	camera_head.rotation.y = lerp_angle(camera_head.rotation.y, input_sync.camera_rotation_y, 10.0 * delta)
	camera_pivot.rotation.x = lerp_angle(camera_pivot.rotation.x, input_sync.camera_pitch, 10.0 * delta)

	# A REMOTE STEP HOP, derived (user 2026-10-05: "derive remotely" — no sync bytes):
	# leaving the ground with an up-speed a hop has and a jump does not. A jump leaves
	# at 12 m/s and a double jump at 9.6; a hop to hop_max at most sqrt(2 g (0.79 +
	# 0.12)) = 4.2 m/s. It lasts until they are grounded again; the lead leg alternates
	# like the local one (the crouch itself is not seen remotely — it happens grounded).
	var v_up_r: float = _decoded_velocity.dot(-gravity_dir)
	if not _remote_hop and _remote_was_grounded and _decoded_state == State.AIRBORNE \
			and v_up_r > 1.0 and v_up_r < 10.0:   # a leap up a flight reaches 9 (LEAP_MAX_UP)
		_remote_hop = true
		_hop_lead = 1 - _hop_lead
	elif _remote_hop and (_decoded_on_floor or _decoded_state != State.AIRBORNE):
		_remote_hop = false
	_remote_was_grounded = _decoded_on_floor and _decoded_state == State.MOVEMENT

	# Feed state to procedural animator for remote players too
	_update_proc_animator()


# ---- Sync Encoding (same format as player.gd for interop) ----

static func _encode_sync_state(pos: Vector3, rot_y: float, mesh_rot_y: float,
		vel: Vector3, on_floor: bool, ctrl_state: int = 0,
		surf_up: Vector3 = Vector3.UP) -> PackedByteArray:
	var buf := PackedByteArray()
	buf.resize(24)
	buf.encode_s16(0, clampi(roundi(pos.x * 100.0), -32768, 32767))
	buf.encode_s16(2, clampi(roundi(pos.y * 100.0), -32768, 32767))
	buf.encode_s16(4, clampi(roundi(pos.z * 100.0), -32768, 32767))
	buf.encode_u16(6, roundi(fmod(rot_y + TAU, TAU) / TAU * 65535.0) % 65536)
	buf.encode_u16(8, roundi(fmod(mesh_rot_y + TAU, TAU) / TAU * 65535.0) % 65536)
	buf.encode_s16(10, clampi(roundi(vel.x * 100.0), -32768, 32767))
	buf.encode_s16(12, clampi(roundi(vel.y * 100.0), -32768, 32767))
	buf.encode_s16(14, clampi(roundi(vel.z * 100.0), -32768, 32767))
	buf[16] = 1 if on_floor else 0
	buf[17] = clampi(ctrl_state, 0, 255)
	# Surface up normal (unit vector, s16 at 0.01 precision)
	buf.encode_s16(18, clampi(roundi(surf_up.x * 16383.0), -16383, 16383))
	buf.encode_s16(20, clampi(roundi(surf_up.y * 16383.0), -16383, 16383))
	buf.encode_s16(22, clampi(roundi(surf_up.z * 16383.0), -16383, 16383))
	return buf


static func _decode_sync_state(buf: PackedByteArray) -> Dictionary:
	if buf.size() < 18:
		return {}
	var d := {
		"position": Vector3(
			buf.decode_s16(0) / 100.0,
			buf.decode_s16(2) / 100.0,
			buf.decode_s16(4) / 100.0),
		"rotation_y": buf.decode_u16(6) / 65535.0 * TAU,
		"mesh_rotation_y": buf.decode_u16(8) / 65535.0 * TAU,
		"velocity": Vector3(
			buf.decode_s16(10) / 100.0,
			buf.decode_s16(12) / 100.0,
			buf.decode_s16(14) / 100.0),
		"on_floor": (buf[16] & 1) != 0,
		"state": buf[17] if buf.size() > 17 else 0,
		"surface_up": Vector3.UP,
	}
	if buf.size() >= 24:
		d["surface_up"] = Vector3(
			buf.decode_s16(18) / 16383.0,
			buf.decode_s16(20) / 16383.0,
			buf.decode_s16(22) / 16383.0).normalized()
	return d


# ---- Grapple Rope Visual ----

func _update_grapple_rope_visual() -> void:
	if _grapple_rope_visual == null:
		return
	var imm: ImmediateMesh = _grapple_rope_visual.mesh as ImmediateMesh
	if imm == null:
		return
	imm.clear_surfaces()

	# Get actual right hand bone position from animator
	var hand_pos: Vector3 = _get_right_hand_world_pos()

	# Winch mode — straight line from hand to anchor
	if _winch_active:
		imm.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
		imm.surface_add_vertex(hand_pos)
		imm.surface_add_vertex(_winch_anchor)
		imm.surface_end()
		return

	# Rope mode — particle chain, override player end with hand bone
	if not _grapple.active:
		return

	var points: PackedVector3Array = _grapple.get_rope_points()
	if points.size() < 2:
		return

	imm.surface_begin(Mesh.PRIMITIVE_LINE_STRIP)
	# First point is anchor, last is player — replace last with hand bone
	for i in range(points.size()):
		if i == points.size() - 1:
			imm.surface_add_vertex(hand_pos)
		else:
			imm.surface_add_vertex(points[i])
	imm.surface_end()


# ---- Procedural Animation Bridge ----

func _update_proc_animator() -> void:
	if not _proc_animator:
		return
	# Hip drop: lower model node based on duck_amount (Overgrowth: collision sphere offset)
	# Foot IK compensates so feet stay at ground level.
	if state == State.WALL_CLIMB:
		# Model position and orientation are set in _orient_model_to_surface.
		pass
	else:
		# + the STEP-UP ease: after a step the drawn body starts where it was and catches
		# up (critically damped), the camera with it — a step, not a teleport.
		model.position = Vector3(0, -duck_amount * hip_drop_max + _step_smooth, 0)
		if state == State.MOVEMENT or state == State.AIRBORNE:
			camera_head.position = Vector3(0, 1.5 + _step_smooth, 0)
	# STEP HOP: the crouch (lead leg loading) and the arc — remote players derive it from
	# the replicated state (see _process_remote), so no sync bytes were added.
	_proc_animator.is_step_hopping = _step_hopping or _hop_timer > 0.0 or _remote_hop
	_proc_animator.step_crouching = _hop_timer > 0.0
	_proc_animator.step_lead = _hop_lead
	_proc_animator.horizontal_speed = _horizontal_speed()
	var anim_up: Vector3 = -gravity_dir
	_proc_animator.local_up = anim_up
	_proc_animator.vertical_velocity = velocity.dot(anim_up)
	_proc_animator.velocity = velocity  # Full 3D velocity for acceleration tilt
	# Remote avatars run no physics, so is_on_floor() is always false → the
	# animator would pick "fall" forever. Use the synced grounded flag for them.
	_proc_animator.is_grounded = is_on_floor() if is_multiplayer_authority() else _decoded_on_floor
	_proc_animator.is_jumping = (state == State.AIRBORNE and velocity.dot(anim_up) > 0.5)
	_proc_animator.duck_amount = duck_amount
	_proc_animator.facing_direction = -model.global_transform.basis.z
	# The MODEL's up, which is what the torso counter-roll measures against.
	# NOT the skeleton's: the rig carries an import rotation, so its basis.y is
	# not the body's up and a roll computed from it is meaningless.
	_proc_animator.model_up = model.global_transform.basis.y.normalized()
	_proc_animator.wall_run_active = wall_run_active
	_proc_animator.wall_run_normal = wall_run_normal
	_proc_animator.on_ledge = _on_ledge
	_proc_animator.ledge_normal = _ledge_normal
	_proc_animator.is_climbing_ledge = (_ledge_climb_progress > 0.0 and _ledge_climb_progress < 1.0)
	_proc_animator.is_wall_climbing = (state == State.WALL_CLIMB)
	_proc_animator.surface_up = surface_up
	_proc_animator.ground_normal = ground_normal
	_proc_animator.ski_holding = ski_holding
	if state == State.WALL_CLIMB:
		_proc_animator.climb_grip_positions = _wall_climb.grip_positions.duplicate()
		_proc_animator.climb_grip_weights = _wall_climb.grip_weights.duplicate()
		_proc_animator.climb_grip_normals = _wall_climb.grip_normals.duplicate()
		_proc_animator.climb_wall_normal = _wall_climb.wall_normal
	_proc_animator.is_grappling = _grapple.active or _winch_active
	if _grapple.active:
		_proc_animator.grapple_anchor_direction = _grapple.get_anchor_direction(global_position)
		_proc_animator.grapple_swing_speed = Vector2(velocity.x, velocity.z).length()
	elif _winch_active:
		_proc_animator.grapple_anchor_direction = (_winch_anchor - global_position).normalized()
		_proc_animator.grapple_swing_speed = Vector2(velocity.x, velocity.z).length()
	# Always feed camera aim direction for right-arm IK (ready-to-fire pose)
	_proc_animator.aim_direction = -camera.global_transform.basis.z
	_proc_animator.is_pre_jumping = (pre_jump_timer > 0.0)
	# Feed camera look direction to chest/head IK.
	# During wall climb: disable chest IK but feed look_direction for head-only look.
	if state == State.WALL_CLIMB:
		# Head should point where camera looks (away from camera, into scene).
		# The model is prone so we need the full 3D look direction, not just
		# tangent projection — the head IK handles the bone-space conversion.
		var cam_look_climb: Vector3 = -camera.global_transform.basis.z
		_proc_animator.look_direction = cam_look_climb
	elif state == State.GRAPPLE and _grapple.active:
		# During grapple: head looks toward the anchor point
		var to_anchor: Vector3 = (_grapple.anchor_point - global_position).normalized()
		_proc_animator.look_direction = to_anchor
	elif state == State.GRAPPLE_WINCH and _winch_active:
		var to_anchor: Vector3 = (_winch_anchor - global_position).normalized()
		_proc_animator.look_direction = to_anchor
	else:
		var cam_look: Vector3 = -camera.global_transform.basis.z
		var char_forward: Vector3 = model.global_transform.basis.z
		if cam_look.dot(char_forward) > 0.0:
			_proc_animator.look_direction = cam_look
		else:
			_proc_animator.look_direction = Vector3.ZERO
	# Pass character body RID so foot IK raycasts exclude our own collider
	if _proc_animator._foot_ik != null and not _proc_animator._foot_ik.exclude_rid.is_valid():
		_proc_animator._foot_ik.exclude_rid = get_rid()


# ---- Helpers ----

func _get_right_hand_world_pos() -> Vector3:
	if _proc_animator and _proc_animator.right_hand_world_pos != Vector3.ZERO:
		return _proc_animator.right_hand_world_pos
	# Fallback: offset from body
	var right_vec: Vector3 = camera_head.global_transform.basis.x
	return global_position + (-gravity_dir) * 1.2 + right_vec * 0.3


func _measure_skeleton_anatomy() -> void:
	var tumble_model: Node3D = model.get_node_or_null("TumbleModel")
	if not tumble_model:
		return
	var skeleton: Skeleton3D = _find_child_of_type(tumble_model, "Skeleton3D") as Skeleton3D
	if not skeleton:
		return
	var gx: Transform3D = skeleton.global_transform
	var bones: Dictionary = {}
	var names: Array[String] = [
		"DEF-spine", "DEF-spine.001", "DEF-spine.003", "DEF-spine.005", "DEF-spine.006",
		"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",
		"DEF-thigh.L", "DEF-shin.L", "DEF-foot.L",
		"DEF-thigh.R", "DEF-shin.R", "DEF-foot.R",
	]
	for bn in names:
		var idx: int = skeleton.find_bone(bn)
		if idx >= 0:
			bones[bn] = (gx * skeleton.get_bone_global_pose(idx)).origin
	# Print positions relative to hip
	var hip: Vector3 = bones.get("DEF-spine", global_position)
	print("=== SKELETON ANATOMY (world-space, relative to hip) ===")
	for bn in names:
		if bones.has(bn):
			var rel: Vector3 = bones[bn] - hip
			print("  %s: (%.3f, %.3f, %.3f) dist_from_hip=%.3f" % [bn, rel.x, rel.y, rel.z, rel.length()])
	# Print limb segment lengths
	var _dist := func(a: String, b: String) -> float:
		if bones.has(a) and bones.has(b):
			return bones[a].distance_to(bones[b])
		return -1.0
	print("=== LIMB LENGTHS ===")
	print("  Upper arm L: %.3f" % _dist.call("DEF-upper_arm.L", "DEF-forearm.L"))
	print("  Forearm L:   %.3f" % _dist.call("DEF-forearm.L", "DEF-hand.L"))
	print("  Total arm L: %.3f" % (_dist.call("DEF-upper_arm.L", "DEF-forearm.L") + _dist.call("DEF-forearm.L", "DEF-hand.L")))
	print("  Thigh L:     %.3f" % _dist.call("DEF-thigh.L", "DEF-shin.L"))
	print("  Shin L:      %.3f" % _dist.call("DEF-shin.L", "DEF-foot.L"))
	print("  Total leg L: %.3f" % (_dist.call("DEF-thigh.L", "DEF-shin.L") + _dist.call("DEF-shin.L", "DEF-foot.L")))
	print("  Hip to head: %.3f" % _dist.call("DEF-spine", "DEF-spine.006"))
	print("  Shoulder span: %.3f" % _dist.call("DEF-upper_arm.L", "DEF-upper_arm.R"))
	print("  Hip span:    %.3f" % _dist.call("DEF-thigh.L", "DEF-thigh.R"))


func _horizontal_speed() -> float:
	var local_up: Vector3 = -gravity_dir
	var horiz: Vector3 = velocity - local_up * velocity.dot(local_up)
	return horiz.length()


func _find_child_of_type(node: Node, type_name: String) -> Node:
	for child in node.get_children():
		if child.get_class() == type_name:
			return child
		var found: Node = _find_child_of_type(child, type_name)
		if found:
			return found
	return null


# ---- RPCs ----

@rpc("any_peer", "call_local", "reliable")
func _toggle_light() -> void:
	flashlight.visible = not flashlight.visible
	_play_varied(snd_light, 0.5)


## MICRO-VARIATION on every play (cozy-SFX research 2026-10-06: an identical repeat is
## what makes a sound grate): ±`semitones` of pitch and ±1.5 dB, re-rolled each time.
## Not AudioStreamRandomizer: with one sample per sound it adds nothing over this, and a
## randomizer on Land crashed the engine with unbounded recursion every run of
## gate_step_up (bare repros — shared, freed, bursts — did not; cause not found).
## VARIANT POOLS (assets/sounds/player/cozy, Kenney CC0 — see its CREDITS.md). preload:
## only dependencies reach the PCK (export_filter "selected_scenes").
const _COZY := "res://assets/sounds/player/cozy/"
const JUMP_POOL: Array[AudioStream] = [preload(_COZY + "jump_0.ogg"), preload(_COZY + "jump_1.ogg"),
		preload(_COZY + "jump_2.ogg"), preload(_COZY + "jump_3.ogg")]
const LAND_POOL: Array[AudioStream] = [preload(_COZY + "land_0.ogg"), preload(_COZY + "land_1.ogg"),
		preload(_COZY + "land_2.ogg"), preload(_COZY + "land_3.ogg"), preload(_COZY + "land_4.ogg")]
const STEP_TERRAIN: Array[AudioStream] = [preload(_COZY + "step_terrain_0.ogg"), preload(_COZY + "step_terrain_1.ogg"),
		preload(_COZY + "step_terrain_2.ogg"), preload(_COZY + "step_terrain_3.ogg"), preload(_COZY + "step_terrain_4.ogg")]
const STEP_HARD: Array[AudioStream] = [preload(_COZY + "step_hard_0.ogg"), preload(_COZY + "step_hard_1.ogg"),
		preload(_COZY + "step_hard_2.ogg"), preload(_COZY + "step_hard_3.ogg"), preload(_COZY + "step_hard_4.ogg"),
		preload(_COZY + "step_hard_5.ogg"), preload(_COZY + "step_hard_6.ogg"), preload(_COZY + "step_hard_7.ogg"),
		preload(_COZY + "step_hard_8.ogg"), preload(_COZY + "step_hard_9.ogg")]


## A FOOTFALL from the animation (FootIK's swing returning to planted, while grounded).
## COZY STEPS (Kenney CC0): the terrain's chunks are bodiless server bodies (null
## collider) — soft dirt; anything with a node (brushes, floors, props) — the hard set.
## The step node sits at -8 dB, under the mix.
func _on_foot_planted(foot: int) -> void:
	if not is_on_floor() or Vector2(velocity.x, velocity.z).length() < 0.5:
		return
	var col := get_last_slide_collision()
	var on_terrain := col != null and col.get_collider() == null
	_play_pool(_footstep_sounds[0], STEP_TERRAIN if on_terrain else STEP_HARD, 1.5)
	footstep_count += 1
	footstep_feet.append(foot)


## A NO-REPEAT pick from `pool` (never the variant this player played last — an
## identical repeat is what makes a step grate), then the pitch / volume jitter.
## Our own picker, not AudioStreamRandomizer (see _play_varied).
func _play_pool(p: AudioStreamPlayer3D, pool: Array[AudioStream], semitones: float) -> void:
	var last: int = p.get_meta("sfx_last", -1)
	var i := randi() % pool.size()
	if pool.size() > 1 and i == last:
		i = (i + 1 + randi() % (pool.size() - 1)) % pool.size()
	p.set_meta("sfx_last", i)
	p.stream = pool[i]
	_play_varied(p, semitones)


func _play_varied(p: AudioStreamPlayer3D, semitones: float) -> void:
	# Around the node's AUTHORED values, captured on first use.
	if not p.has_meta("sfx_base"):
		p.set_meta("sfx_base", Vector2(p.pitch_scale, p.volume_db))
	var base: Vector2 = p.get_meta("sfx_base")
	p.pitch_scale = base.x * pow(2.0, randf_range(-semitones, semitones) / 12.0)
	p.volume_db = base.y + randf_range(-1.5, 1.5)
	p.play()


@rpc("any_peer", "call_local", "reliable")
func _respawn() -> void:
	var spawns := get_tree().get_nodes_in_group("spawn_points")
	# If no spawn points, stay at current position (don't reset to origin)
	var at: Vector3 = spawns[randi() % spawns.size()].global_position if not spawns.is_empty() else global_position
	arrive(at)


# ---- Arrival ----------------------------------------------------------------
#
# THE ONE WAY INTO A POSITION: first spawn, teleport / map change, R and the fall
# respawn, a restored saved position. Each used to set global_position and let
# gravity run — onto spawn points 17-121 m in the air, 12 m inside rock, or onto
# terrain whose collision did not exist yet (gate_arrival, ArrivalGround).

## Give up waiting for the ground after this long and stand on the best answer.
const ARRIVAL_TIMEOUT := 15.0
## A hold shorter than this (flat floors, built collision) shows no veil at all.
const ARRIVAL_VEIL_AFTER := 0.2
## Feet this far above the resolved ground; the capsule's origin is at its feet.
const ARRIVAL_CLEARANCE := 0.02

var _arrival_point := Vector3.ZERO
## Where the arrival ASKED to be. The ground is always resolved from here, never from
## the hold point: held on a first, coarse candidate, a down-ray from the hold missed
## the finer surface that then appeared 1.5 m ABOVE it and stood the player on an
## older one inside the terrain (map_mountain: released at 123.78 under a surface at
## 125.27, fell through 1.1 s later).
var _arrival_origin := Vector3.ZERO
var _arrival_snap := true
## < 0 = not arriving; otherwise seconds held so far.
var _arrival_t := -1.0
var _arrival_veil: CanvasLayer = null
## Preloaded, not by class_name: a new class_name is unknown to a tree whose class
## cache predates it, and that parse error takes the whole player script down.
const _Arrival := preload("res://scenes/player/arrival_ground.gd")


## Arrive at `point`: held there until the ground is real, then stood on it.
## snap_down = false keeps an AIRBORNE point in the air (a saved position recorded
## mid-jump); a buried point is lifted regardless.
func arrive(point: Vector3, snap_down: bool = true) -> void:
	_reset_motion_state()
	global_position = point
	if not is_local_player():
		return    # a remote copy's position comes from its owner's sync
	_arrival_point = point
	_arrival_origin = point
	_arrival_snap = snap_down
	_arrival_t = 0.0


func is_arriving() -> bool:
	return _arrival_t >= 0.0


func _arrival_tick(delta: float) -> void:
	_arrival_t += delta
	velocity = Vector3.ZERO
	global_position = _arrival_point
	var excl: Array[RID] = [get_rid()]
	var r: Dictionary = _Arrival.resolve(get_world_3d().direct_space_state, _arrival_origin, _arrival_snap,
			excl, _Arrival.find_terrain(self))
	if not r["ready"] and _arrival_t < ARRIVAL_TIMEOUT:
		if _arrival_t >= ARRIVAL_VEIL_AFTER:
			_set_arrival_veil(true)
		# A candidate below (or above, buried) that is not final yet: HOLD THERE. The
		# camera rides with the body and drives the LOD, so held 121 m up (map_mountain)
		# the ground below never reached its finest LOD and only the timeout let go.
		if r["found"] and _arrival_snap:
			_arrival_point = (r["ground"] as Vector3) + Vector3(0, ARRIVAL_CLEARANCE, 0)
		return
	if not r["ready"]:
		push_warning("[Player] arrival at %s: ground not ready after %.0f s (%s) — releasing anyway"
				% [_arrival_origin, ARRIVAL_TIMEOUT, r["why"]])
	var g: Vector3 = r["ground"]
	global_position = g + Vector3(0, ARRIVAL_CLEARANCE, 0) if g != _arrival_origin else g
	DebugLog.log("game", "ARRIVED at %s after %.2f s: %s" % [global_position, _arrival_t, r["why"]])
	_arrival_t = -1.0
	_set_arrival_veil(false)


func _set_arrival_veil(on: bool) -> void:
	if on:
		if _arrival_veil != null:
			return
		_arrival_veil = CanvasLayer.new()
		_arrival_veil.layer = 100
		var bg := ColorRect.new()
		bg.color = Color.BLACK
		bg.set_anchors_preset(Control.PRESET_FULL_RECT)
		bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
		_arrival_veil.add_child(bg)
		var l := Label.new()
		l.text = "Arriving…"
		l.set_anchors_preset(Control.PRESET_CENTER)
		bg.add_child(l)
		add_child(_arrival_veil)
		return
	if _arrival_veil == null:
		return
	var veil := _arrival_veil
	_arrival_veil = null
	var bg2 := veil.get_child(0) as ColorRect
	var tw := create_tween()
	tw.tween_property(bg2, "modulate:a", 0.0, 0.4)
	tw.tween_callback(veil.queue_free)


## Everything a teleport / respawn must clear: velocity, state, crouch, ledge,
## wall run, wall climb.
func _reset_motion_state() -> void:
	velocity = Vector3.ZERO
	state = State.MOVEMENT
	ground_time = 0.0
	duck_amount = 0.0
	target_duck_amount = 0.0
	_on_ledge = false
	_ledge_climb_progress = 0.0
	_has_hit_wall = false
	wall_run_active = false
	if _wall_climb and _wall_climb.active:
		_wall_climb.stop()
	_lizard_mode = false
	_destroy_grip_particles()
	surface_up = Vector3.UP
	_target_surface_up = Vector3.UP
	up_direction = Vector3.UP
	floor_max_angle = DEFAULT_FLOOR_MAX_ANGLE
	# Ensure collision shapes are in valid state
	collision_standing.disabled = false
	collision_crouching.disabled = true
	collision_standing.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.79, 0))
	collision_crouching.transform = Transform3D(Basis.IDENTITY, Vector3(0, 0.4, 0))
	# Reset model to upright
	model.transform.basis = Basis.IDENTITY
	_dbg_model("hard_reset")
	model.position = Vector3.ZERO
	_climb_model_quat = Quaternion.IDENTITY
	# Reset winch
	_winch_active = false
	_set_terrain_lod_frozen(false)
	if _grapple and _grapple.active:
		_grapple.release()


# ---- Quake liquids (water / lava / slime) — ported for the procedural player ----

func _setup_liquid_overlay() -> void:
	# Full-screen tint for the Quake content color-shift (V_CalcBlend) when the head is
	# submerged. ColorRect on its own CanvasLayer; the colour's alpha carries the blend %.
	var layer := CanvasLayer.new()
	layer.name = "LiquidBlend"
	layer.layer = 1
	add_child(layer)
	var rect := ColorRect.new()
	rect.set_anchors_preset(Control.PRESET_FULL_RECT)
	rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
	rect.visible = false
	layer.add_child(rect)
	_liquid_overlay = rect


func _update_water_state() -> void:
	# Quake waterlevel (SV_CheckWater): feet / mid / eye samples. global_position is the capsule
	# feet; centre 0.79, head/eye 1.5. Swim engages at >= 2 (mid submerged); blend at 3 (head under).
	var tree := get_tree()
	var mid_liq := LiquidVolume.content_at(tree, global_position + Vector3(0, 0.79, 0))
	var eye_liq := LiquidVolume.content_at(tree, global_position + Vector3(0, 1.5, 0))
	_water_level = 0
	if not LiquidVolume.content_at(tree, global_position + Vector3(0, 0.1, 0)).is_empty():
		_water_level = 1
	if not mid_liq.is_empty():
		_water_level = 2
	if not eye_liq.is_empty():
		_water_level = 3
	_in_liquid = _water_level >= 2
	_update_liquid_blend(eye_liq)   # non-empty only when the head (eye) point is submerged


func _update_liquid_blend(view_liquid: Dictionary) -> void:
	if _liquid_overlay == null:
		return
	var cls := String(view_liquid.get("class", ""))
	if cls != "" and LIQUID_BLEND.has(cls):
		_liquid_overlay.color = LIQUID_BLEND[cls]
		_liquid_overlay.visible = true
	else:
		_liquid_overlay.visible = false


func _apply_water_move(delta: float) -> void:
	# Faithful SV_WaterMove (WinQuake/sv_user.c) at BASE_RUN_SPEED. You swim where you LOOK —
	# wish vector from the camera basis (full 3D incl. pitch), like Quake's v_angle vectors.
	var input_dir: Vector2 = input_sync.input_direction
	var fwd := -camera.global_transform.basis.z
	var right := camera.global_transform.basis.x
	var up := (-gravity_dir).normalized() if gravity_dir.length_squared() > 0.5 else Vector3.UP
	var wishvel := fwd * -input_dir.y + right * input_dir.x
	if input_sync.wants_jump:
		wishvel += up                                  # jump == swim up (Quake upmove)
	if input_dir == Vector2.ZERO and not input_sync.wants_jump:
		wishvel = -up * WATER_IDLE_SINK                # idle → drift toward the bottom
	var wishspeed := minf(wishvel.length(), 1.0) * BASE_RUN_SPEED * WATER_SPEED_SCALE
	var speed := velocity.length()
	var newspeed := 0.0
	if speed > 0.0:
		newspeed = maxf(speed - delta * speed * WATER_FRICTION, 0.0)
		velocity *= newspeed / speed
	if wishspeed > 0.0:
		var addspeed := wishspeed - newspeed
		if addspeed > 0.0:
			var accelspeed := minf(WATER_ACCEL * wishspeed * delta, addspeed)
			velocity += wishvel.normalized() * accelspeed


## Enter or leave free flight. Called by GameMode, not by a key: build mode IS the
## free-fly camera, so there is exactly one way into this state and it cannot drift
## from the mode the rest of the game thinks it is in.
##
## The lift on entry is deliberate — you start flying slightly above where you stood,
## so a body inside a floor does not begin its flight embedded in geometry. Position
## is set AFTER the state change, because a state transition would otherwise override it.
func set_fly_mode(enabled: bool) -> void:
	if enabled == fly_mode:
		return
	fly_mode = enabled
	_set_builder_camera(enabled)
	if fly_mode:
		var saved_pos: Vector3 = global_position
		var lift_dir: Vector3 = -gravity_dir if gravity_dir.length_squared() > 0.5 else Vector3.UP
		_pre_fly_position = saved_pos
		state = State.FLY
		velocity = Vector3.ZERO
		global_position = saved_pos + lift_dir * 5.0
		DebugLog.log("game", "FLY ON: was=%s now=%s" % [saved_pos, global_position])
	else:
		state = State.AIRBORNE
		velocity = Vector3.ZERO
		DebugLog.log("game", "FLY OFF at %s" % global_position)


## BUILD MODE IS A CAMERA, NOT A FLYING PLAYER. Entering fly used to leave you as a
## body: third-person spring arm, your own model in front of the crosshair, and a
## collider that still blocked people and triggers. You could fly, but you were still
## playing — which is what "doesn't properly switch to player-less flying" is.
##
## Three things make it a camera, and each is restored exactly on the way out:
##
##   FIRST PERSON. The spring arm collapses to zero, so the view is from the head
##   rather than from behind a body you are trying to see past.
##   NO BODY. $Model is hidden LOCALLY only. Other peers still see you — you have not
##   left the room, you are building in it, and vanishing from other people's screens
##   would be a worse lie than being visible.
##   NO COLLIDER. The shapes are disabled so you do not block a doorway someone else
##   is walking through while you edit it. Fly movement already bypasses collision for
##   its own motion (it sets global_position directly), so this only affects what
##   OTHERS collide with.
func _set_builder_camera(building: bool) -> void:
	if building:
		_pre_build_spring = spring_arm.spring_length
	spring_arm.spring_length = 0.0 if building else _pre_build_spring
	if model != null:
		model.visible = not building
	# SAVE AND RESTORE THE FLAGS THEMSELVES. There is no crouch boolean in this
	# controller — which shape is enabled IS the state (standing, crouching and wall
	# climb each set the pair directly), so reconstructing it on the way out would be
	# guessing at something already written down.
	if building:
		_pre_build_standing = collision_standing.disabled
		_pre_build_crouching = collision_crouching.disabled
		collision_standing.disabled = true
		collision_crouching.disabled = true
	else:
		collision_standing.disabled = _pre_build_standing
		collision_crouching.disabled = _pre_build_crouching


var _pre_build_spring: float = 3.0
var _pre_build_standing: bool = false
var _pre_build_crouching: bool = true


## EVERY WRITER TO THE MODEL BASIS, TAGGED. HTPS_MODELLOG prints one line per
## write per frame, in the order they land. Several mechanisms orient the model
## and the Euler ones do not preserve a roll, so "which wrote last" is the only
## thing that settles an argument about the final orientation.
## Cached at load: this is called nine times a frame and an environment lookup
## per call is not free.
static var _dbg_model_on: bool = OS.has_environment("HTPS_MODELLOG")

## LAST 3 SECONDS, ALWAYS RECORDING. F10 used to dump the CURRENT frame, which is
## useless: you cannot be mid-wall-run and press a key on the same frame. It now
## dumps history, so you press it AFTER the thing you saw.
const DBG_HIST := 180
var _dbg_hist: Array = []
var _dbg_skel: Skeleton3D = null

func _dbg_model(tag: String) -> void:
	if not OS.has_environment("HTPS_MODELLOG"):
		return
	# TRIGGER ON THE SYMPTOM, NOT ON THE SUSPECT. This was gated on wall_run_active
	# or WALL_CLIMB, and a play session that reproduced the body going flat printed
	# NOTHING -- which says the state I assumed was involved may not be. Gating a
	# diagnostic on the mechanism you are trying to identify can only confirm the
	# guess you already made.
	#
	# So: fire whenever the model is actually tilted, whatever put it there. Quiet
	# on an upright character, loud for exactly the frames in the screenshots.
	if absf(model.global_transform.basis.y.normalized().dot(-gravity_dir)) > 0.985:
		return      # within ~10 degrees of upright — nothing to explain
	var u: Vector3 = model.global_transform.basis.y.normalized()
	var flipping: bool = _proc_animator != null and _proc_animator.is_flipping()
	_diag("[model] f=%d %-18s up=(%+.3f,%+.3f,%+.3f) tilt=%5.1f state=%d wr=%s climb=%s ledge=%s flip=%s"
			% [Engine.get_physics_frames(), tag, u.x, u.y, u.z,
			   rad_to_deg(u.angle_to(-gravity_dir)), state, str(wall_run_active),
			   str(state == State.WALL_CLIMB), str(_on_ledge), str(flipping)])


## One frame of everything that could put the body in a wrong pose, printed on
## demand (F10). No env var, no category, no threshold -- the point is that it
## cannot be silent for a reason I failed to anticipate.
func _dbg_record() -> void:
	var mu: Vector3 = model.global_transform.basis.y.normalized()
	# THE BONES, EVERY FRAME. The dump used to sample them only when F10 was
	# pressed -- which is after the thing you saw, so every bone number in this
	# investigation came from a standing character or from a gate. The hip angle
	# DURING the failure was never once measured.
	var hipdeg := -1.0
	var headdeg := -1.0
	if _dbg_skel == null:
		_dbg_skel = _find_skel_dump(self)
	if _dbg_skel != null:
		var hb: int = _dbg_skel.find_bone("DEF-spine")
		if hb >= 0:
			hipdeg = rad_to_deg((_dbg_skel.global_transform * _dbg_skel.get_bone_global_pose(hb)).basis.y.normalized().angle_to(-gravity_dir))
		var hd: int = _dbg_skel.find_bone("DEF-spine.006")
		if hd >= 0:
			headdeg = rad_to_deg((_dbg_skel.global_transform * _dbg_skel.get_bone_global_pose(hd)).basis.y.normalized().angle_to(-gravity_dir))
	var anim: int = _proc_animator._anim_state if _proc_animator != null else -1
	var flipping: bool = _proc_animator != null and _proc_animator.is_flipping()
	var tl: float = _proc_animator._tilt.length() if _proc_animator != null else -1.0
	_dbg_hist.append("f=%d st=%d anim=%2d floor=%d wr=%d cont=%d spr=%d ledge=%d climbp=%.2f hspd=%5.2f modeltilt=%5.1f flip=%d |tilt|=%5.2f HIP=%5.1f HEAD=%5.1f"
			% [Engine.get_physics_frames(), state, anim, int(is_on_floor()),
			   int(wall_run_active), int(wall_contact),
			   int(Input.is_action_pressed("sprint")), int(_on_ledge), _ledge_climb_progress,
			   _horizontal_speed(), rad_to_deg(mu.angle_to(-gravity_dir)), int(flipping), tl, hipdeg, headdeg])
	if _dbg_hist.size() > DBG_HIST:
		_dbg_hist.pop_front()


## F10. Prints the last DBG_HIST frames, collapsing runs of identical state so a
## 3-second window is readable. Legend included because nobody should have to go
## and look the enums up while comparing this to what they just watched.
func _dump_state() -> void:
	_diag("[DUMP] ==== last %d frames ====  st: 0=MOVE 1=AIR 2=WALLCLIMB 3=GRAP 4=WINCH 5=FLY 6=SWIM" % _dbg_hist.size())
	_diag("[DUMP] anim: 0=IDLE 1=WALK 2=RUN 3=JUMP 4=FALL 5=LAND 6=ROLL 7=WALLRUN 8=HANG 9=LCLIMB 10=WCLIMB 11=GRAP")
	var prev := ""
	var reps := 0
	for line in _dbg_hist:
		var key: String = (line as String).split(" hspd=")[0]
		var sig: String = key.substr(key.find(" st="))
		if sig == prev:
			reps += 1
			continue
		if reps > 0:
			_diag("[DUMP]    ... x%d" % reps)
		reps = 0
		prev = sig
		_diag("[DUMP] " + line)
	if reps > 0:
		_diag("[DUMP]    ... x%d" % reps)
	var sk: Skeleton3D = _find_skel_dump(self)
	if sk != null:
		for bn in ["DEF-spine", "DEF-spine.006"]:
			var bi: int = sk.find_bone(bn)
			if bi >= 0:
				var bu: Vector3 = (sk.global_transform * sk.get_bone_global_pose(bi)).basis.y.normalized()
				_diag("[DUMP] now: bone %-14s %.1f deg from world up" % [bn, rad_to_deg(bu.angle_to(Vector3.UP))])
	_diag("[DUMP] ================================")


func _find_skel_dump(n: Node) -> Skeleton3D:
	if n is Skeleton3D:
		return n
	for c in n.get_children():
		var f: Skeleton3D = _find_skel_dump(c)
		if f != null:
			return f
	return null

## DIAGNOSTICS GO TO A FILE, NOT TO print().
## Measured: the player spawns every session (/tmp/hypertps-game.log records
## _spawn_player_for at 20:35:54), and game.gd's _log() does BOTH print() and a
## FileAccess write with an explicit flush(). The FileAccess copy has every line;
## the piped stdout copy does not. So print() is not reliable through dev-run's
## pipeline and FileAccess is, and every "the log is empty" in this investigation
## was that difference rather than anything about the game.
static var _diag_file: FileAccess = null

func _diag(line: String) -> void:
	if _diag_file == null:
		_diag_file = FileAccess.open("/tmp/hypertps-diag.log", FileAccess.WRITE)
		if _diag_file == null:
			return
	_diag_file.seek_end()
	_diag_file.store_line(line)
	_diag_file.flush()


# ---------------------------------------------------------------------------
# BLOCKED-MOTION RECORDER
#
# "The collision is very hard to pass" is a statement about frames where the
# character asked to move and did not. Nothing in this project recorded those,
# so every diagnosis so far has had to start from a guess about WHY -- and four
# instruments in a row (hyper-games #51 section 2.3) were each gated on the
# mechanism their author already suspected, so each could only confirm a guess
# and its silence proved nothing.
#
# This is gated on the SYMPTOM instead: horizontal displacement much smaller
# than the horizontal velocity asked for. It records what was in the way and how
# steep it was, and it runs while you walk rather than where you think to look.
#
# Kept in a ring buffer on the player so the terrain probe can read it out;
# nothing is written to disk until something asks.
const BLOCK_LOG_MAX := 240
## 15 seconds at 60 Hz of raw per-frame motion, judged by nobody.
const MOTION_LOG_MAX := 900
var _motion_log: Array = []
var _dbg_traction := -1.0
var _dbg_slope_sin := 0.0
var _dbg_hold_frac := 0.0
var _dbg_margin := 0.0
var _dbg_desired := 0.0
var _dbg_input := 0.0
var _dbg_friction := 0.0
var _dbg_ski := false
var _dbg_fresh := false
const BLOCK_MIN_WANT := 0.02      # m per frame -- below this you asked for nothing
const BLOCK_RATIO := 0.5          # got less than half of what you asked for
var block_log: Array = []

func _record_block(delta: float, from_pos: Vector3, want: Vector3) -> void:
	if delta <= 0.0:
		return
	var up: Vector3 = -gravity_dir
	var want_h: Vector3 = want - up * want.dot(up)
	var want_len: float = want_h.length()
	var got: Vector3 = global_position - from_pos
	var got_h: Vector3 = got - up * got.dot(up)
	# Progress ALONG the direction asked for. A slide that carries you sideways
	# is not being blocked; being carried backwards is worse than being stopped.
	var progress: float = 0.0 if want_len < 1e-9 else got_h.dot(want_h / want_len)

	# Contact geometry, for the frames that ARE trying to move. The block log had
	# these and the motion trace did not, so the rows that matter most -- pushing
	# against something and going nowhere -- were the ones missing the evidence.
	var m_depth := 0.0
	var m_worst := -1.0
	# WHAT the contacts are, kept per frame rather than summarised away. A
	# contact normal 168 degrees from up -- a ceiling -- was measured on ground
	# whose steepest triangle is 11.8 degrees, with nothing overhead and no
	# stale parent body. That leaves "it is not what I assume it is", and the
	# only way to answer it is to write down the collider and the position at
	# the moment it happens.
	var m_contacts: Array = []
	for ci in range(get_slide_collision_count()):
		var cc := get_slide_collision(ci)
		if cc.get_depth() > m_depth:
			m_depth = cc.get_depth()
		var cd: float = rad_to_deg(acos(clampf(cc.get_normal().dot(up), -1.0, 1.0)))
		if cd > m_worst:
			m_worst = cd
		if m_contacts.size() < 8:
			var ob: Object = cc.get_collider()
			var desc := "bodiless (terrain chunk)"
			if ob != null:
				desc = ob.get_class()
				if ob is Node:
					desc = "%s:%s" % [(ob as Node).name, ob.get_class()]
			m_contacts.append({
				"deg": cd,
				"depth": cc.get_depth(),
				"who": desc,
				"at": cc.get_position(),
				"n": cc.get_normal(),
			})

	# EVERY FRAME, UNCONDITIONALLY.
	#
	# This used to record only frames where progress fell below half of what was
	# asked -- my definition of "blocked" -- and on the map the user reports as
	# unwalkable it recorded NOTHING while the good map also recorded nothing.
	# A movement problem that is stuttering, or being slowed, or losing the
	# floor, produces no such frames at all, so the instrument answered "there
	# is no problem" about a problem that was on the screen.
	#
	# So the trace makes no judgement. It stores what happened; the reader
	# decides what bad looks like, and can change its mind without another
	# session of walking around.
	_motion_log.append({
		"t": Time.get_ticks_msec(),
		"want": want_len,
		"got": progress,
		"speed": (got_h / delta).length(),
		"vy": velocity.dot(up),
		"on_floor": is_on_floor(),
		"contacts": get_slide_collision_count(),
		"ground_deg": rad_to_deg(acos(clampf(ground_normal.dot(up), -1.0, 1.0))),
		"state": state,
		"input": _dbg_input,
		"desired": _dbg_desired,
		"traction": _dbg_traction,
		"friction": _dbg_friction,
		"hold_frac": _dbg_hold_frac,
		"margin": _dbg_margin,
		"ski": _dbg_ski,
		"depth": m_depth,
		"worst_deg": m_worst,
		"contact_info": m_contacts,
		"gnormal": ground_normal,
		# WAS _update_movement EVEN CALLED THIS FRAME? Every term above it is
		# only written inside that function, so when it is skipped -- flipping,
		# a roll, any state that is not MOVEMENT -- the trace repeats the last
		# healthy values and reads as a character that is fine and simply not
		# going anywhere. That is indistinguishable from the real thing without
		# this flag.
		"fresh": _dbg_fresh,
		# `flipping` is a LOCAL in _physics_process, not a member, so ask the
		# animator the same question it asks.
		"flipping": _proc_animator != null and _proc_animator.is_flipping(),
	})
	_dbg_fresh = false
	if _motion_log.size() > MOTION_LOG_MAX:
		_motion_log.pop_front()

	if want_len < BLOCK_MIN_WANT:
		return
	if progress > want_len * BLOCK_RATIO:
		return

	var worst_deg := -1.0
	var worst_normal := Vector3.ZERO
	var walls := 0
	var max_depth := 0.0
	var colliders: Array = []
	for i in range(get_slide_collision_count()):
		var col := get_slide_collision(i)
		var nrm: Vector3 = col.get_normal()
		var deg: float = rad_to_deg(acos(clampf(nrm.dot(up), -1.0, 1.0)))
		if deg > floor_max_angle_deg():
			walls += 1
		if deg > worst_deg:
			worst_deg = deg
			worst_normal = nrm
		# WHAT is touching you, and how far INTO it you are.
		#
		# Neither was recorded, so "three contacts on a 13 degree face" could
		# equally be the terrain, a prop, another player, or the same terrain
		# body reported three times -- and depth separates a surface you are
		# sliding along from one you are embedded in and being pushed out of.
		# A depenetration fight and a slope you cannot climb look identical in
		# the progress number alone.
		if col.get_depth() > max_depth:
			max_depth = col.get_depth()
		var who: Object = col.get_collider()
		var label := "<freed>"
		if who != null:
			label = "%s(%s)" % [who.get_class(), str(who.get("name")) if who.has_method("get") else "?"]
		if not colliders.has(label):
			colliders.append(label)
	block_log.append({
		"t": Time.get_ticks_msec(),
		"pos": global_position,
		"want": want_len,
		"got": progress,
		"frac": progress / want_len,
		"collisions": get_slide_collision_count(),
		"walls": walls,
		"worst_deg": worst_deg,
		"worst_normal": worst_normal,
		"depth": max_depth,
		"colliders": colliders,
		"on_floor": is_on_floor(),
		"state": state,
	})
	if block_log.size() > BLOCK_LOG_MAX:
		block_log.pop_front()


func floor_max_angle_deg() -> float:
	return rad_to_deg(floor_max_angle)


## HOW WELL DID YOU ACTUALLY MOVE, over the window. No threshold, no
## definition of "blocked" -- just what fraction of the motion asked for was
## delivered, how often the floor was lost, and how ragged the delivery was.
##
## Travel efficiency is the number to compare BETWEEN MAPS: it is meaningful
## whether the loss comes from stopping, stuttering, sliding or bouncing.
func motion_summary(window_ms: int = 10000) -> Dictionary:
	var now: int = Time.get_ticks_msec()
	var rows: Array = []
	for e in _motion_log:
		if now - int(e["t"]) <= window_ms and float(e["want"]) > 0.001:
			rows.append(e)
	var out := {
		"frames": rows.size(), "window_ms": window_ms,
		"want_total": 0.0, "got_total": 0.0, "efficiency": 0.0,
		"on_floor_pct": 0.0, "floor_transitions": 0,
		"p10": 0.0, "p50": 0.0, "p90": 0.0,
		"mean_contacts": 0.0, "max_vy": 0.0, "min_vy": 0.0,
		"mean_ground_deg": 0.0,
		"mean_input": 0.0, "mean_desired": 0.0, "min_traction": 9.9,
		"mean_traction": 0.0, "min_friction": 9.9, "ski_frames": 0,
		"stale_frames": 0, "flip_frames": 0,
		"trying_frames": 0, "trying_eff": 0.0, "trying_contacts": 0.0,
		"trying_depth": 0.0, "trying_worst_deg": 0.0,
	}
	if rows.is_empty():
		return out
	var fracs := PackedFloat32Array()
	var on_floor := 0
	var prev_floor: bool = bool(rows[0]["on_floor"])
	var contacts := 0.0
	var gdeg := 0.0
	for e in rows:
		out["want_total"] = float(out["want_total"]) + float(e["want"])
		out["got_total"] = float(out["got_total"]) + float(e["got"])
		fracs.append(float(e["got"]) / maxf(float(e["want"]), 1e-9))
		if bool(e["on_floor"]):
			on_floor += 1
		if bool(e["on_floor"]) != prev_floor:
			out["floor_transitions"] = int(out["floor_transitions"]) + 1
			prev_floor = bool(e["on_floor"])
		contacts += float(e["contacts"])
		gdeg += float(e["ground_deg"])
		var vy: float = float(e["vy"])
		if vy > float(out["max_vy"]): out["max_vy"] = vy
		if vy < float(out["min_vy"]): out["min_vy"] = vy
		out["mean_input"] = float(out["mean_input"]) + float(e.get("input", 0.0))
		out["mean_desired"] = float(out["mean_desired"]) + float(e.get("desired", 0.0))
		out["mean_traction"] = float(out["mean_traction"]) + float(e.get("traction", 0.0))
		if float(e.get("traction", 9.9)) < float(out["min_traction"]):
			out["min_traction"] = e.get("traction", 9.9)
		if float(e.get("friction", 9.9)) < float(out["min_friction"]):
			out["min_friction"] = e.get("friction", 9.9)
		if bool(e.get("ski", false)):
			out["ski_frames"] = int(out["ski_frames"]) + 1
		if not bool(e.get("fresh", false)):
			out["stale_frames"] = int(out["stale_frames"]) + 1
		if bool(e.get("flipping", false)):
			out["flip_frames"] = int(out["flip_frames"]) + 1
	# THE FRAMES WHERE A KEY WAS ACTUALLY DOWN. Standing still and being pinned
	# both show want = 0, because want comes from velocity; separating them is
	# the difference between "the player let go" and "the player cannot move".
	var tw := 0.0
	var tg := 0.0
	var tc := 0.0
	var td := 0.0
	var tdeg := 0.0
	var tn := 0
	for e in rows:
		if float(e.get("input", 0.0)) < 0.01:
			continue
		tn += 1
		tw += float(e["want"])
		tg += float(e["got"])
		tc += float(e["contacts"])
		td = maxf(td, float(e.get("depth", 0.0)))
		tdeg = maxf(tdeg, float(e.get("worst_deg", -1.0)))
	# THE WORST FRAME, INTACT. Averages cannot answer "what was touching me";
	# one real frame can.
	var worst_row: Dictionary = {}
	var worst_score := 1e30
	for e in rows:
		if float(e.get("input", 0.0)) < 0.01:
			continue
		if float(e["want"]) < 0.01:
			continue
		var fr: float = float(e["got"]) / maxf(float(e["want"]), 1e-9)
		if fr < worst_score:
			worst_score = fr
			worst_row = e
	out["worst_frame"] = worst_row

	out["trying_frames"] = tn
	if tn > 0:
		out["trying_eff"] = tg / maxf(tw, 1e-9)
		out["trying_contacts"] = tc / float(tn)
		out["trying_depth"] = td
		out["trying_worst_deg"] = tdeg

	out["efficiency"] = float(out["got_total"]) / maxf(float(out["want_total"]), 1e-9)
	out["on_floor_pct"] = 100.0 * float(on_floor) / float(rows.size())
	out["mean_contacts"] = contacts / float(rows.size())
	out["mean_input"] = float(out["mean_input"]) / float(rows.size())
	out["mean_desired"] = float(out["mean_desired"]) / float(rows.size())
	out["mean_traction"] = float(out["mean_traction"]) / float(rows.size())
	out["mean_ground_deg"] = gdeg / float(rows.size())
	fracs.sort()
	out["p10"] = fracs[int(floor(float(fracs.size() - 1) * 0.10))]
	out["p50"] = fracs[int(floor(float(fracs.size() - 1) * 0.50))]
	out["p90"] = fracs[int(floor(float(fracs.size() - 1) * 0.90))]
	return out


## The raw trace, for writing to disk. Summaries hide shapes.
func motion_trace_csv(window_ms: int = 15000) -> String:
	var now: int = Time.get_ticks_msec()
	var lines: Array[String] = ["t_ms,want_m,got_m,frac,speed_ms,vy_ms,on_floor,contacts,ground_deg,state,input,desired,traction,friction,hold_frac,margin,ski,depth,worst_deg,fresh,flipping"]
	for e in _motion_log:
		if now - int(e["t"]) > window_ms:
			continue
		lines.append("%d,%.5f,%.5f,%.4f,%.3f,%.4f,%d,%d,%.2f,%d,%.3f,%.3f,%.4f,%.3f,%.4f,%.4f,%d,%.5f,%.2f,%d,%d" % [
			int(e["t"]) - now, e["want"], e["got"],
			float(e["got"]) / maxf(float(e["want"]), 1e-9),
			e["speed"], e["vy"], 1 if bool(e["on_floor"]) else 0,
			e["contacts"], e["ground_deg"], e["state"],
			e.get("input", 0.0), e.get("desired", 0.0), e.get("traction", -1.0),
			e.get("friction", 0.0), e.get("hold_frac", 0.0), e.get("margin", 0.0),
			1 if bool(e.get("ski", false)) else 0,
			e.get("depth", 0.0), e.get("worst_deg", -1.0),
			1 if bool(e.get("fresh", false)) else 0,
			1 if bool(e.get("flipping", false)) else 0])
	return "\n".join(PackedStringArray(lines))


## Summary for the terrain probe. Kept here so the definition of "blocked" has
## exactly one home.
func block_summary(window_ms: int = 10000) -> Dictionary:
	var now: int = Time.get_ticks_msec()
	var recent: Array = []
	for e in block_log:
		if now - int(e["t"]) <= window_ms:
			recent.append(e)
	var out := {
		"events_total": block_log.size(),
		"events_window": recent.size(),
		"window_ms": window_ms,
		"floor_max_angle_deg": floor_max_angle_deg(),
		"worst_deg": 0.0, "mean_frac": 0.0, "wall_blocked": 0, "full_stops": 0,
		"max_depth": 0.0,
		"last": null,
	}
	if recent.is_empty():
		return out
	var facc := 0.0
	for e in recent:
		facc += float(e["frac"])
		if float(e["worst_deg"]) > float(out["worst_deg"]):
			out["worst_deg"] = e["worst_deg"]
		if int(e["walls"]) > 0:
			out["wall_blocked"] = int(out["wall_blocked"]) + 1
		if float(e["frac"]) < 0.05:
			out["full_stops"] = int(out["full_stops"]) + 1
		if float(e.get("depth", 0.0)) > float(out["max_depth"]):
			out["max_depth"] = e["depth"]
	out["mean_frac"] = facc / float(recent.size())
	out["last"] = recent[recent.size() - 1]
	return out


# ---- nameplate ---------------------------------------------------------------

## THE LABEL IS THE VERIFIED KEET MEMBER'S ROSTER NAME (hyper-games #89 §3.3).
##
## It used to be self-asserted: an any_peer `_set_player_name` RPC (any peer could
## relabel any other) plus a realtime player-name state copy that went out unreliable
## and was never replayed, so a late joiner saw "Player". KeetService.peer_identity
## binds each mesh peer to a keet member by a proof checked against the key the
## swarm handshake authenticated, and names it from the room roster; this only
## displays that, and follows it by signal (claims, roster, peers leaving).
func _init_nameplate() -> void:
	if KeetService == null or KeetService.peer_identity == null:
		return
	_apply_nameplate()
	if not KeetService.peer_identity.names_changed.is_connected(_apply_nameplate):
		KeetService.peer_identity.names_changed.connect(_apply_nameplate)


func _apply_nameplate() -> void:
	if not is_instance_valid(name_label) or KeetService == null or KeetService.peer_identity == null:
		return
	name_label.text = KeetService.peer_identity.name_for_peer(get_multiplayer_authority())


func _exit_tree() -> void:
	if KeetService == null or KeetService.peer_identity == null:
		return
	if KeetService.peer_identity.names_changed.is_connected(_apply_nameplate):
		KeetService.peer_identity.names_changed.disconnect(_apply_nameplate)