Compare commits

...

5 Commits

Author SHA1 Message Date
VinPropane 01cfd4ec4d
Merge pull request #37 from ViPro-Technologies/NEON-16-idle-jitter-followup
NEON-16 idle jitter followup
2026-04-10 01:19:07 -04:00
VinPropane a66bf885f2 NEON-16: apply repo-local gdformat
Apply the repo-local gdformat output to the touched GDScript files so the pre-push formatting hook passes. This is a formatting-only follow-up to the lint-hook cleanup.
2026-04-10 00:50:29 -04:00
VinPropane 4b861a69df NEON-16: satisfy GDScript lint hooks
Refactor the bump escape helper and shared test script loading so the branch passes the repo's pre-push GDScript lint checks. This keeps the runtime behavior intact while unblocking push and PR creation.
2026-04-10 00:48:11 -04:00
VinPropane 1f5c1e7619 NEON-16: harden idle jitter follow-up
Harden idle settling so flat ground, obstacle contacts, and rare bump-edge cases stop drifting instead of looping corrective nudges. Also resolves the saved review follow-up items and syncs the runtime docs to the final feet-height and idle-anchor behavior.
2026-04-10 00:46:53 -04:00
VinPropane 6f4f5d4350 NEON-16: code review — idle jitter follow-up branch
docs/reviews/2026-04-10-NEON-16-followup.md: Request changes — two
blocking gdlint issues (export var order, line-length on
vertical_arrival_error) plus suggestions and nits.
2026-04-10 00:39:14 -04:00
5 changed files with 487 additions and 62 deletions

View File

@ -19,11 +19,11 @@ The main scene uses **`World/IsometricFollowCamera`** (`scripts/isometric_follow
With the game server running ([`server/README.md`](../server/README.md)), each valid floor click sends a **`MoveCommand`** (**`POST`**) and a follow-up **`GET`** for **`PositionState`**. The server still **snaps** authority to the target (NEON-4/19); the client **moves** toward that verified position using **`NavigationAgent3D`** + a **baked mesh** instead of teleporting on the **`GET`** (NEON-8). **Boot** `sync_from_server()` **snaps** once so spawn matches the server.
**Tradeoff (prototype):** `player.gd` often steers **straight in xz** toward the goal when the picks **Y** is below the capsule origin (smooth **stepped bumps**). **Automatic routing around tall obstacles on one click is not guaranteed** — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
**Tradeoff (prototype):** `player.gd` only steers **straight in xz** toward the goal for real **descend** cases, where the picks **Y** is below the capsule **feet** by a small margin (smooth **stepped bumps**). Same-height floor clicks now stay on the baked **`NavigationAgent3D`** path instead of looking like a descend just because the capsule origin sits above the floor. **Automatic routing around tall obstacles on one click is still not guaranteed** for those true descend-bypass cases — use **several clicks** to go around e.g. the gray **`Obstacle`** when needed.
**NEON-7 / movement QA bumps:** On **run**, **`spawn_short_random_bumps`** adds **two** green cylinders, each on its own **`StaticBody3D`** **sibling** of **`Floor`** under **`NavigationRegion3D`** (**`walkable`** on bump roots — avoids compound **internal-edge** jitter vs floor+cylinder on one body). Bump meshes use Godot group **`random_floor_bump_mesh`**. **Collision radius** = mesh **+ `COLLISION_RADIUS_EXTRA`** (see **`scripts/random_floor_bump_collision_constants.gd`**, capped by **`COLLISION_RADIUS_MAX`**). **`bake_navigation_mesh(false)`** after spawn.
**Idle stability (NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** after motion. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**.
**Idle stability (NEON-16):** **Jolt Physics**; **TPS** **120**. **`physics/common/physics_interpolation`** is **off** — with **on**, small physics **position** changes on bump **edges** were **blended** across render frames and looked like **ghosting / extra jitter**. **`snap_to_server()`** still calls **`reset_physics_interpolation()`** for compatibility if you turn interpolation on later. **`floor_max_angle`** **~50°** walking / **~35°** idle; **loose** angle **~0.8 s** after walk stops. **Walk step assist**. **Idle rim / straddle:** **moving** `floor_max_angle` when floor normal is **shallow** or slide hits mix **floor + wall**. One idle **`move_and_slide()`**, rim **settle**, **`random_floor_bump_mesh`** **lip / rim / vertical-wall** escape (**`IDLE_BUMP_ESCAPE_STEP`**, **`PLAYER_CAPSULE_RADIUS`**, collider fudge from **`random_floor_bump_collision_constants.gd`**). **Flat idle hardening:** when support is already effectively level (floor normal almost **`Vector3.UP`** and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle slide / escape loop instead of nudging the capsule anyway, even if physics reports incidental extra contacts on an otherwise flat support. In that stable grounded state the player also keeps an **idle x/z anchor**, so any residual horizontal creep is clamped away until movement resumes or support stops looking stable. Goal arrival and descend-bypass checks both use the capsule **feet height** rather than the body origin, so flat floor clicks clear and route normally instead of lingering in a straight-line obstacle push. **`Player`:** interp **Off**, **`avoidance_enabled`** **false**, **`floor_block_on_wall`** **true** — do **not** rewrite **`global_transform`** in **`_process`** (can **ghost** **`CharacterBody3D`**). **`_snap_capsule_upright()`** now short-circuits when the basis is already identity. Idle **`FLOOR_SNAP_IDLE`** ~**11 cm**; walking **`FLOOR_SNAP_MOVING`** ~**0.32**. **Rendering:** **`Mat_player_capsule`**, **`cast_shadow = 0`**, capsule mesh **+Y ~3.4 cm** (visual-only vs **`CapsuleShape3D`** — less **z-fight** vs floor/bump tops), **`light_specular = 0`**, **`msaa_3d = 2`**. **`safe_margin`** **0.055**.
- **Scripts:** `scripts/ground_pick.gd` (walkable pick + `target_chosen`; occluder bodies tagged `"occluder"` are passed through unconditionally so clicks reach the ground behind them — NEON-30), `scripts/position_authority_client.gd` (`PositionAuthorityClient`: POST move, GET verify; second signal arg = boot snap vs nav goal), `scripts/player.gd` (path-follow), `scripts/isometric_follow_camera.gd` + `scripts/camera_state.gd` (NEON-25 follow), thin `scripts/main.gd` (nav bake on first frame, then wiring).
- **Scene:** `scenes/main.tscn` — walkable **`StaticBody3D`** geometry lives under **`World/NavigationRegion3D`**. `main.gd` waits one **`process_frame`**, spawns **random test bumps** on **`Floor`**, then **`bake_navigation_mesh(false)`** (main-thread bake), then waits two **`physics_frame`**s so **`NavigationServer3D`** has a map before agents query paths. The baked **`NavigationMesh`** asset in the scene file is **stale** until you run or re-bake in the editor.
@ -36,7 +36,7 @@ With the game server running ([`server/README.md`](../server/README.md)), each v
3. Open the client in Godot and run the main scene (**F5**). The player should **snap** to the servers default position (e.g. **(-5, 0.9, -5)** per `Game:DefaultPosition` / NEON-6 walk demo).
4. **Left-click** the floor: the client **POST**s the target, then **GET**s position; the capsule **walks** toward the authoritative target (may follow nav waypoints or bee-line in xz per the tradeoff above). NEON-7 reject clicks still show the reject label and do **not** start a path.
5. Click the **far pad** or **pedestal top** (from spawn) to confirm **`horizontal_step_exceeded`** / **`vertical_step_exceeded`** behavior is unchanged.
6. After a move (or at spawn), **stand idle** with no click goal for **10+ seconds** and confirm the capsule does **not** visibly vibrate on the floor or the **random green bumps** (positions change each run).
6. After a move (or at spawn), **stand idle** with no click goal for **10+ seconds** and confirm the capsule does **not** visibly vibrate or drift in **x/z** on flat floor, then repeat on the **random green bumps** (positions change each run).
If the server is **down**, boot **`GET`** fails silently (check Output for warnings); clicks while a request is in flight are ignored until it finishes.

View File

@ -21,6 +21,10 @@ const FLOOR_SNAP_MOVING: float = 0.32
const FLOOR_SNAP_IDLE: float = 0.11
## Below this floor-normal dot up, idle tick uses **moving** `floor_max_angle` (rim).
const IDLE_RIM_MIN_FLOOR_UP_DOT: float = 0.968
## Stable flat idle support: skip corrective idle motion when support is effectively level.
const STABLE_IDLE_FLOOR_MIN_UP_DOT: float = 0.9998
## Rare edge contacts can keep corrective nudges alive forever; budget them, then hold x/z.
const IDLE_MANUAL_CORRECTION_MAX_TICKS: int = 8
## Horizontal nudge per tick for rim / straddle settle (**`_maybe_idle_rim_settle_nudge`**).
const IDLE_RIM_SETTLE_STEP: float = 0.004
const DESCEND_GOAL_Y_MARGIN: float = 0.06
@ -38,11 +42,22 @@ const BUMP_COLLISION_CONSTS_SCRIPT: Script = preload(
const IDLE_BUMP_ESCAPE_STEP: float = 0.014
## Player capsule radius; wall touch can happen past axis distance **`col_r`**.
const PLAYER_CAPSULE_RADIUS: float = 0.4
const DEBUG_IDLE_TRACE_DELTA_EPS: float = 0.0001
const DEBUG_IDLE_TRACE_HEARTBEAT_FRAMES: int = 60
const DEBUG_TRANSFORM_TRACE_DELTA_EPS: float = 0.000001
@export var debug_idle_trace: bool = false
var _has_walk_goal: bool = false
var _auth_walk_goal: Vector3 = Vector3.ZERO
var _floor_angle_loose_ticks: int = 0
var _step_assist_cooldown: int = 0
var _idle_anchor_active: bool = false
var _idle_anchor_xz: Vector2 = Vector2.ZERO
var _idle_manual_correction_ticks: int = 0
var _debug_last_idle_xz: Vector2 = Vector2.INF
var _debug_idle_heartbeat: int = 0
var _debug_last_transform_xz: Vector2 = Vector2.INF
var _debug_trace_frame: int = 0
@onready var _nav_agent: NavigationAgent3D = $NavigationAgent3D
@ -52,12 +67,16 @@ func _ready() -> void:
_nav_agent.avoidance_enabled = false
_nav_agent.set_target_position(global_position)
floor_block_on_wall = true
if debug_idle_trace:
set_notify_transform(true)
func set_authoritative_nav_goal(world_pos: Vector3) -> void:
_auth_walk_goal = world_pos
_has_walk_goal = true
_floor_angle_loose_ticks = 0
_idle_anchor_active = false
_idle_manual_correction_ticks = 0
_nav_agent.set_target_position(world_pos)
@ -65,6 +84,8 @@ func clear_nav_goal() -> void:
velocity = Vector3.ZERO
_has_walk_goal = false
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
_idle_anchor_active = false
_idle_manual_correction_ticks = 0
_nav_agent.set_target_position(global_position)
@ -74,6 +95,8 @@ func snap_to_server(world_pos: Vector3) -> void:
_has_walk_goal = false
_floor_angle_loose_ticks = 0
_step_assist_cooldown = 0
_idle_anchor_active = false
_idle_manual_correction_ticks = 0
_nav_agent.set_target_position(world_pos)
reset_physics_interpolation()
@ -154,12 +177,133 @@ func _set_horizontal_velocity_toward(point: Vector3) -> void:
velocity.y = 0.0
static func capsule_feet_y(body_origin_y: float, capsule_half_height: float) -> float:
return body_origin_y - capsule_half_height
static func vertical_arrival_error(
goal_y: float, body_origin_y: float, capsule_half_height: float
) -> float:
return absf(goal_y - capsule_feet_y(body_origin_y, capsule_half_height))
func _physics_idle_tick() -> void:
velocity = Vector3.ZERO
floor_snap_length = FLOOR_SNAP_IDLE
move_and_slide()
static func idle_support_is_stable(
on_floor: bool, floor_normal: Vector3, _slide_normals: Array[Vector3], loose_ticks: int
) -> bool:
## `_slide_normals` is reserved for future extra-contact analysis.
if not on_floor or loose_ticks > 0:
return false
if floor_normal.dot(Vector3.UP) < STABLE_IDLE_FLOOR_MIN_UP_DOT:
return false
return true
func _current_slide_normals() -> Array[Vector3]:
var normals: Array[Vector3] = []
for i: int in get_slide_collision_count():
normals.append(get_slide_collision(i).get_normal())
return normals
func _stable_idle_support() -> bool:
return idle_support_is_stable(
is_on_floor(), get_floor_normal(), _current_slide_normals(), _floor_angle_loose_ticks
)
func _hold_idle_anchor() -> void:
var pos := global_position
if not _idle_anchor_active:
_idle_anchor_xz = Vector2(pos.x, pos.z)
_idle_anchor_active = true
return
global_position = Vector3(_idle_anchor_xz.x, pos.y, _idle_anchor_xz.y)
func _debug_trace_idle_state(tag: String) -> void:
if not debug_idle_trace or _has_walk_goal:
_debug_idle_heartbeat = 0
_debug_last_idle_xz = Vector2.INF
return
var pos_xz := Vector2(global_position.x, global_position.z)
var should_log: bool = false
if _debug_last_idle_xz == Vector2.INF:
should_log = true
elif pos_xz.distance_to(_debug_last_idle_xz) > DEBUG_IDLE_TRACE_DELTA_EPS:
should_log = true
elif _debug_idle_heartbeat >= DEBUG_IDLE_TRACE_HEARTBEAT_FRAMES:
should_log = true
if should_log:
push_warning(
(
(
"NEON-16 idle trace [%s] pos=%s vel=%s on_floor=%s loose=%d "
+ "stable=%s anchor=%s anchor_xz=%s floor=%s slides=%s"
)
% [
tag,
global_position,
velocity,
is_on_floor(),
_floor_angle_loose_ticks,
_stable_idle_support(),
_idle_anchor_active,
_idle_anchor_xz,
get_floor_normal(),
_current_slide_normals(),
]
)
)
_debug_last_idle_xz = pos_xz
_debug_idle_heartbeat = 0
else:
_debug_idle_heartbeat += 1
func _debug_trace_transform(tag: String) -> void:
if not debug_idle_trace:
return
var pos_xz := Vector2(global_position.x, global_position.z)
if (
_debug_last_transform_xz != Vector2.INF
and pos_xz.distance_to(_debug_last_transform_xz) < DEBUG_TRANSFORM_TRACE_DELTA_EPS
and tag != "physics"
):
return
print(
(
(
"NEON-16 transform trace [%s] frame=%d pos=%s vel=%s has_goal=%s "
+ "on_floor=%s loose=%d stable=%s anchor=%s anchor_xz=%s"
)
% [
tag,
_debug_trace_frame,
global_position,
velocity,
_has_walk_goal,
is_on_floor(),
_floor_angle_loose_ticks,
_stable_idle_support(),
_idle_anchor_active,
_idle_anchor_xz,
]
)
)
_debug_last_transform_xz = pos_xz
func _notification(what: int) -> void:
if what == NOTIFICATION_TRANSFORM_CHANGED:
_debug_trace_transform("notify")
func _idle_ridged_floor_contacts() -> bool:
var upish := 0
var wallish := 0
@ -172,14 +316,14 @@ func _idle_ridged_floor_contacts() -> bool:
return upish >= 1 and wallish >= 1
func _maybe_idle_rim_settle_nudge() -> void:
func _maybe_idle_rim_settle_nudge() -> bool:
if get_slide_collision_count() < 1:
return
return false
var shallow_floor: bool = (
is_on_floor() and get_floor_normal().dot(Vector3.UP) < IDLE_RIM_MIN_FLOOR_UP_DOT
)
if not shallow_floor and not _idle_ridged_floor_contacts():
return
return false
var h := Vector3.ZERO
if shallow_floor:
h = Vector3(get_floor_normal().x, 0.0, get_floor_normal().z)
@ -191,14 +335,15 @@ func _maybe_idle_rim_settle_nudge() -> void:
if nh.length_squared() > h.length_squared():
h = nh
if h.length_squared() < 1e-12:
return
return false
var step: Vector3 = -h.normalized() * IDLE_RIM_SETTLE_STEP
if test_move(global_transform, step, null, safe_margin):
return
return false
global_position += step
return true
func _maybe_idle_bump_proximity_escape() -> void:
func _maybe_idle_bump_proximity_escape() -> bool:
var feet_y: float = global_position.y - CAPSULE_HALF_HEIGHT
for node: Node in get_tree().get_nodes_in_group(
BUMP_COLLISION_CONSTS_SCRIPT.RANDOM_FLOOR_BUMP_MESH_GROUP
@ -229,41 +374,55 @@ func _maybe_idle_bump_proximity_escape() -> void:
var radial: Vector3 = Vector3(dx / d, 0.0, dz / d)
var away: Vector3 = radial * IDLE_BUMP_ESCAPE_STEP
var wall_band_outer: float = col_r + PLAYER_CAPSULE_RADIUS + 0.22
# Outside visual disc but inside fat collision — clear the invisible lip.
if d > r_mesh * 0.9 and d < col_r + 0.32:
if not test_move(global_transform, away, null, safe_margin):
global_position += away
return
var should_escape: bool = d > r_mesh * 0.9 and d < col_r + 0.32
# On / above the disc, hugging the **visual** rim — step onto open slab.
if (
feet_y >= bottom_y - 0.06
and feet_y <= top_y + 0.1
and d >= r_mesh * 0.86
and d <= r_mesh + 0.14
):
if not test_move(global_transform, away, null, safe_margin):
global_position += away
return
should_escape = (
should_escape
or (
feet_y >= bottom_y - 0.06
and feet_y <= top_y + 0.1
and d >= r_mesh * 0.86
and d <= r_mesh + 0.14
)
)
# Beside vertical cylinder: axis distance may exceed **`col_r`** while capsule still touches wall.
if (
feet_y <= top_y + 0.14
and d > r_mesh * 0.82
and d < wall_band_outer
and (is_on_wall() or _idle_ridged_floor_contacts())
):
if not test_move(global_transform, away, null, safe_margin):
global_position += away
return
should_escape = (
should_escape
or (
feet_y <= top_y + 0.14
and d > r_mesh * 0.82
and d < wall_band_outer
and (is_on_wall() or _idle_ridged_floor_contacts())
)
)
if not should_escape:
continue
if test_move(global_transform, away, null, safe_margin):
return false
global_position += away
return true
return false
func _apply_idle_manual_correction() -> bool:
# Prefer explicit bump-rim escape over the generic shallow-floor settle so spawned
# cylinder edges resolve radially off the lip instead of orbiting around it.
if _maybe_idle_bump_proximity_escape():
return true
return _maybe_idle_rim_settle_nudge()
func _snap_capsule_upright() -> void:
# TODO(NS-24 follow-on): With facing yaw, clear pitch/roll only; keep Y rotation.
# Full **`Basis.IDENTITY`** would erase look direction.
if global_transform.basis.orthonormalized().is_equal_approx(Basis.IDENTITY):
return
var p: Vector3 = global_position
global_transform = Transform3D(Basis.IDENTITY, p)
func _physics_process(_delta: float) -> void:
_debug_trace_frame += 1
var use_loose_floor_angle: bool = _has_walk_goal or _floor_angle_loose_ticks > 0
if not _has_walk_goal:
var floor_up_dot: float = get_floor_normal().dot(Vector3.UP)
@ -274,42 +433,76 @@ func _physics_process(_delta: float) -> void:
FLOOR_MAX_ANGLE_MOVING_DEG if use_loose_floor_angle else FLOOR_MAX_ANGLE_IDLE_DEG
)
if not _has_walk_goal:
if _stable_idle_support():
_idle_manual_correction_ticks = 0
velocity = Vector3.ZERO
floor_snap_length = FLOOR_SNAP_IDLE
_hold_idle_anchor()
_debug_trace_idle_state("stable")
_debug_trace_transform("physics")
_snap_capsule_upright()
return
if _idle_manual_correction_ticks >= IDLE_MANUAL_CORRECTION_MAX_TICKS:
velocity = Vector3.ZERO
floor_snap_length = FLOOR_SNAP_IDLE
if _floor_angle_loose_ticks > 0:
_floor_angle_loose_ticks -= 1
_hold_idle_anchor()
_debug_trace_idle_state("budgeted")
_debug_trace_transform("physics")
_snap_capsule_upright()
return
_idle_anchor_active = false
_physics_idle_tick()
_maybe_idle_rim_settle_nudge()
_maybe_idle_bump_proximity_escape()
if _apply_idle_manual_correction():
_idle_manual_correction_ticks += 1
else:
_idle_manual_correction_ticks = 0
if _floor_angle_loose_ticks > 0:
_floor_angle_loose_ticks -= 1
_debug_trace_idle_state("corrective")
_debug_trace_transform("physics")
_snap_capsule_upright()
return
var full_to_goal: Vector3 = _auth_walk_goal - global_position
var horiz_dist: float = Vector2(full_to_goal.x, full_to_goal.z).length()
var vert_err: float = absf(full_to_goal.y)
var vert_err: float = vertical_arrival_error(
_auth_walk_goal.y, global_position.y, CAPSULE_HALF_HEIGHT
)
if horiz_dist <= ARRIVE_EPS and vert_err <= VERT_ARRIVE_EPS:
velocity = Vector3.ZERO
_has_walk_goal = false
_floor_angle_loose_ticks = FLOOR_ANGLE_LOOSE_TICKS_AFTER_STOP
_idle_anchor_active = false
_idle_manual_correction_ticks = 0
_nav_agent.set_target_position(global_position)
_physics_idle_tick()
_maybe_idle_rim_settle_nudge()
_maybe_idle_bump_proximity_escape()
if _apply_idle_manual_correction():
_idle_manual_correction_ticks += 1
_debug_trace_transform("physics")
_snap_capsule_upright()
return
_idle_manual_correction_ticks = 0
var nav_map: RID = _nav_agent.get_navigation_map()
if NavigationServer3D.map_get_iteration_id(nav_map) == 0:
_set_horizontal_velocity_toward(_auth_walk_goal)
floor_snap_length = FLOOR_SNAP_MOVING
move_and_slide()
_after_walk_move_and_slide()
_debug_trace_transform("physics")
_snap_capsule_upright()
return
if _auth_walk_goal.y < global_position.y - DESCEND_GOAL_Y_MARGIN:
var feet_y: float = capsule_feet_y(global_position.y, CAPSULE_HALF_HEIGHT)
if _auth_walk_goal.y < feet_y - DESCEND_GOAL_Y_MARGIN:
_set_horizontal_velocity_toward(_auth_walk_goal)
floor_snap_length = FLOOR_SNAP_MOVING
move_and_slide()
_after_walk_move_and_slide()
_debug_trace_transform("physics")
_snap_capsule_upright()
return
@ -328,4 +521,5 @@ func _physics_process(_delta: float) -> void:
floor_snap_length = FLOOR_SNAP_MOVING
move_and_slide()
_after_walk_move_and_slide()
_debug_trace_transform("physics")
_snap_capsule_upright()

View File

@ -1,13 +1,15 @@
# Tests for res://scripts/player.gd (minimal tree: CharacterBody3D + NavigationAgent3D).
extends GdUnitTestSuite
const PLAYER_SCRIPT: Script = preload("res://scripts/player.gd")
func _make_player() -> CharacterBody3D:
var body := CharacterBody3D.new()
var nav := NavigationAgent3D.new()
nav.name = "NavigationAgent3D"
body.add_child(nav)
body.set_script(load("res://scripts/player.gd"))
body.set_script(PLAYER_SCRIPT)
auto_free(body)
add_child(body)
return body
@ -24,9 +26,11 @@ func test_snap_to_server_resets_goal_position_and_velocity() -> void:
func test_set_authoritative_nav_goal_sets_goal_and_target() -> void:
var p := _make_player()
p.set("_idle_anchor_active", true)
var goal := Vector3(10.0, 0.0, 3.0)
p.set_authoritative_nav_goal(goal)
assert_that(p.get("_has_walk_goal")).is_true()
assert_that(p.get("_idle_anchor_active")).is_false()
assert_that(p.get("_auth_walk_goal")).is_equal(goal)
var nav: NavigationAgent3D = p.get_node("NavigationAgent3D") as NavigationAgent3D
assert_that(nav.target_position).is_equal(goal)
@ -39,5 +43,87 @@ func test_clear_nav_goal_clears_velocity_and_nav_target() -> void:
p.clear_nav_goal()
assert_that(p.velocity).is_equal(Vector3.ZERO)
assert_that(p.get("_has_walk_goal")).is_false()
assert_that(p.get("_idle_anchor_active")).is_false()
var nav: NavigationAgent3D = p.get_node("NavigationAgent3D") as NavigationAgent3D
assert_that(nav.target_position).is_equal(p.global_position)
func test_idle_support_is_stable_on_flat_floor_without_wall_contacts() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP]
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0)
assert_that(stable).is_true()
func test_idle_support_is_stable_false_when_floor_is_not_flat_enough() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP]
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(
true, Vector3(0.02, 0.995, 0.0).normalized(), slide_normals, 0
)
assert_that(stable).is_false()
func test_idle_support_is_stable_true_when_flat_floor_has_extra_contacts() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP, Vector3(0.0, 0.6, 0.8).normalized()]
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 0)
assert_that(stable).is_true()
func test_idle_support_is_stable_false_when_loose_ticks_active() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP]
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(true, Vector3.UP, slide_normals, 12)
assert_that(stable).is_false()
func test_idle_support_is_stable_false_when_not_on_floor() -> void:
var slide_normals: Array[Vector3] = [Vector3.UP]
var stable: bool = PLAYER_SCRIPT.idle_support_is_stable(false, Vector3.UP, slide_normals, 0)
assert_that(stable).is_false()
func test_hold_idle_anchor_sets_anchor_first_then_clamps_xz() -> void:
var p := _make_player()
p.global_position = Vector3(1.0, 0.5, 2.0)
p.call("_hold_idle_anchor")
assert_that(p.get("_idle_anchor_active")).is_true()
assert_that(p.get("_idle_anchor_xz")).is_equal(Vector2(1.0, 2.0))
p.global_position = Vector3(1.2, 0.7, 2.3)
p.call("_hold_idle_anchor")
assert_that(p.global_position).is_equal(Vector3(1.0, 0.7, 2.0))
func test_snap_to_server_clears_idle_anchor() -> void:
var p := _make_player()
p.set("_idle_anchor_active", true)
p.set("_idle_anchor_xz", Vector2(9.0, 9.0))
p.snap_to_server(Vector3(3.0, 0.9, 4.0))
assert_that(p.get("_idle_anchor_active")).is_false()
func test_capsule_feet_y_uses_body_origin_minus_half_height() -> void:
var feet_y: float = PLAYER_SCRIPT.capsule_feet_y(0.545678, 0.5)
assert_that(absf(feet_y - 0.045678)).is_less(0.000001)
func test_vertical_arrival_error_uses_capsule_feet_height() -> void:
var goal_y := 0.045678
var body_origin_y := 0.545678
var err: float = PLAYER_SCRIPT.vertical_arrival_error(goal_y, body_origin_y, 0.5)
assert_that(absf(err)).is_less(0.000001)
func test_vertical_arrival_error_stays_large_for_origin_height_only_match() -> void:
var goal_y := 0.545678
var body_origin_y := 0.545678
var err: float = PLAYER_SCRIPT.vertical_arrival_error(goal_y, body_origin_y, 0.5)
assert_that(absf(err - 0.5)).is_less(0.000001)
func test_flat_floor_goal_is_not_below_feet_height_margin() -> void:
var goal_y := 0.0
var body_origin_y := 0.4
var descend_margin := PLAYER_SCRIPT.get("DESCEND_GOAL_Y_MARGIN") as float
var below_feet_margin: bool = (
goal_y < PLAYER_SCRIPT.capsule_feet_y(body_origin_y, 0.5) - descend_margin
)
assert_that(below_feet_margin).is_false()

View File

@ -12,64 +12,83 @@
## Goal, scope, and out-of-scope
**Goal:** **Stable rest pose** — no perceptible micro-vibration when horizontal velocity is zero, there is no active walk goal, and the avatar stands on walkable geometry, including prototype floors and **NEON-7** stepped bumps.
**Goal:** Re-open **NEON-16** to restore a **stable rest pose** after later camera work made the prototype jitter worse: no perceptible idle vibration on flat walkable ground, and no continuous **x/z** drift while the avatar has no active walk goal.
**In scope**
- Diagnose and fix (or materially reduce) idle jitter on the prototype **`CharacterBody3D`** player driven by **`player.gd`** (`move_and_slide`, floor interaction, **`NavigationAgent3D`** coupling as relevant).
- Reproduce the current regression in the prototype scene and separate **true player transform drift** from **camera presentation amplification**.
- Diagnose and fix idle jitter in the prototype **`CharacterBody3D`** player driven by **`player.gd`** and/or the follow rig in **`isometric_follow_camera.gd`** when evidence shows the camera is making tiny corrections read as larger visible jitter.
- Preserve **NEON-8** behavior: walking, arrival at goal, **`snap_to_server`** on boot, descend bypass tradeoff, and **NEON-7** validation UX on the server side (client must not regress those flows).
- Document non-obvious fixes in **`client/README.md`** and/or the **`player.gd`** header (per Jira).
- Add or update focused **GdUnit** coverage for any new pure helper or guard logic introduced by the fix.
- Document non-obvious fixes in **`client/README.md`** and the living plan if the final solution changes the anti-jitter pattern or camera integration expectations.
**Out of scope**
- Full prediction/reconciliation, server-side nav, or redesign of **MoveCommand** / **PositionAuthorityClient** contracts.
- Replacing the capsule with a different locomotion stack unless a minimal inspector tweak is sufficient.
- Replacing the locomotion stack or redesigning the isometric follow camera beyond the smallest anti-jitter hardening needed for this regression.
- Product decisions about enabling camera yaw/orbit; this story only hardens the fixed-yaw prototype behavior.
## Acceptance criteria checklist
- [x] At rest (no click-to-move goal, feet on floor), **no visible vibration** in the default prototype scene for **several seconds** of observation.
- [x] **Walking**, **arrival**, and **`snap_to_server`** still behave correctly; **no regression** on NEON-7 bumps or the **NEON-8** obstacle / descend-bypass tradeoff documented in **`client/README.md`**.
- [x] If a **scene** or **project** setting change is required, it is **checked in** and called out for designers (README or short comment in scene notes).
- [ ] At rest (no click-to-move goal, feet on floor), **no visible vibration** in the default prototype scene for **several seconds** of observation on flat ground, and no measurable-looking **x/z** drift at idle.
- [ ] **Walking**, **arrival**, and **`snap_to_server`** still behave correctly; **no regression** on NEON-7 bumps or the **NEON-8** obstacle / descend-bypass tradeoff documented in **`client/README.md`**.
- [ ] If camera-follow hardening is required, it does **not** introduce new visible lag, framing drift, or occlusion regressions in the current E1.M2 stack.
- [ ] If a **scene** or **project** setting change is required, it is **checked in** and called out for designers (README or short comment in scene notes).
## Resolution (shipped)
## Resolution
**Jolt Physics** + **`physics_interpolation` off**; QA bumps on **separate `StaticBody3D`** bodies (no floor+bump compound internal edges); idle **`floor_max_angle`** loosened on rim/straddle; **rim settle** + **`random_floor_bump_mesh`** lip/wall escape; **`NavigationAgent3D.avoidance_enabled`** false, **`floor_block_on_wall`** true; **no `global_transform` in `_process`**; **visual** capsule **mesh Y lift** vs collision to reduce **z-fight** with bump tops. Shared bump collider fudge: **`client/scripts/random_floor_bump_collision_constants.gd`**.
**Re-opened 2026-04-09 after NEON-25/26/27/30 follow-camera work:** the earlier NEON-16 mitigation shipped, but testing now reports worse visible idle jitter even on a flat surface. The current branch treats that as **real idle correction noise first**, not purely a camera problem.
**Implemented on this branch:** when the capsule already has **stable flat support** (floor normal effectively upright and no post-arrival loose-floor window), **`player.gd`** now skips the corrective idle **`move_and_slide()`** / rim-escape loop entirely and keeps the body parked. The flat-idle classification was broadened so incidental extra contacts on an otherwise flat support do not re-enable the old correction loop. Stable grounded idle also keeps an explicit **x/z anchor**, clamping away any residual horizontal creep until movement resumes or support is no longer classified as stable. Goal arrival and the descend-bypass check now both measure against the capsule **feet height** instead of the body origin, which stops same-height floor clicks near obstacles from dropping into the old straight-line wall push. **`_snap_capsule_upright()`** also avoids rewriting **`global_transform`** when the basis is already identity, reducing needless transform churn during idle.
## Technical approach
1. **Reproduce and isolate** in Godot 4.x editor: Remote **Transform** on the player (and camera parent if suspected), **Visible Collision Shapes**, and frame / physics profiler to see whether jitter is position, floor snap, or render-only.
2. **CharacterBody3D / physics** — iterate on documented levers: **`move_and_slide`** at idle with **`floor_snap_length = 0`**; **`safe_margin`**; compound **`Floor`** collision; **`physics/3d/physics_engine`** (**Jolt Physics** vs Godot Physics — Jolt targets internal-edge / compound-shape jitter); **`floor_stop_on_slope`**, **`motion_mode`**, **`up_direction`** if still needed.
3. **Navigation agent** — confirm **`NavigationAgent3D`** target updates at rest (`set_target_position(global_position)` on clear/arrive) do not trigger path churn each frame; adjust only if measurements show agent-driven motion.
4. **Camera / parenting** — rule out camera or child node noise if transform root is stable.
5. **Ship** the smallest change that clears the AC (code, scene, or project), then update **`client/README.md`** to remove or narrow the “Known issue” idle jitter line and describe the chosen pattern.
1. **Reproduce and isolate** in Godot 4.x editor: Remote **Transform** on the player and camera rig, **Visible Collision Shapes**, and short idle observation on flat ground first, then random bumps, to determine whether the player root is really moving in **x/z**.
2. **CharacterBody3D / physics** — inspect the current idle path in **`player.gd`**: idle **`move_and_slide()`**, rim settle, bump proximity escape, and upright snap. Prevent continuous idle correction on stable flat contacts while preserving the original bump / rim escape behavior that NEON-16 added for rougher geometry.
3. **Camera / parenting** — if the player still only moves by tiny amounts, harden **`isometric_follow_camera.gd`** so the rig does not magnify sub-threshold target motion. Preferred direction: smooth or deadzone the **focus** used by **`desired_eye_world()`**, **`look_at()`**, and occlusion instead of smoothing only the eye position.
4. **Navigation agent** — confirm **`NavigationAgent3D`** rest targets (`set_target_position(global_position)` on clear/arrive) are not reintroducing path churn that feeds the idle correction loop.
5. **Ship** the smallest change that clears the AC, then update **`client/README.md`** and related docs with the final anti-jitter pattern and verification notes.
**Shipped note:** Idle uses **non-zero** **`floor_snap_length`** (see **`player.gd`**) rather than zero snap from the investigation list; straddle/rim logic and bump geometry split were the main levers. Code review: **`docs/reviews/2026-04-05-NEON-16.md`**.
## Decisions
| Topic | Choice | Rationale |
|------|---------|-----------|
| **Regression framing** | Treat this as a **re-opened NEON-16** fix, not a brand new camera story. | Jira already tracks idle vibration, and the new symptom is the same bug surface with later camera work as a likely amplifier. |
| **Debug order** | Prove whether **player transform drift** exists before shipping a camera-only mitigation. | A camera-only band-aid could hide a real **`CharacterBody3D`** rest-state bug. |
| **Camera hardening scope** | Only harden follow behavior if player-side cleanup is insufficient or if raw focus tracking is clearly amplifying tiny motion. | Keeps the fix minimal and avoids reopening broader E1.M2 follow-camera design work. |
| **Flat idle strategy** | Detect **stable flat support** and skip corrective idle movement in that state. | The regression report is specifically about persistent idle **x/z** drift on a flat surface, where the old rim/bump correction loop should not be necessary. |
| **Stable support classification** | Let a strongly upright floor normal win even when physics reports incidental extra contacts. | Flat supports were still falling back into the correction path when side contacts were reported even though the capsule should have been considered settled. |
| **Residual drift containment** | Keep a stable-grounded idle **x/z anchor** and restore it each frame while idle remains stable. | Even if the exact remaining source of horizontal creep is hard to reproduce in headless runs, this guarantees flat idle does not visibly wander once the player has settled. |
| **Descend bypass framing** | Treat “below the player” using the capsule **feet**, not the body origin. | Floor picks on the same slab always sit below the capsule origin, which was incorrectly forcing direct steering into obstacle walls and reintroducing jitter after contact. |
| **Upright snap writes** | Skip the upright rewrite when the basis is already **`Basis.IDENTITY`**. | Avoids touching **`global_transform`** every idle frame when there is nothing to correct. |
## Files to add
| Path | Purpose |
|------|---------|
| `client/scripts/random_floor_bump_collision_constants.gd` | Single source for QA bump **`CylinderShape3D`** radius fudge vs mesh and **`RANDOM_FLOOR_BUMP_MESH_GROUP`** (shared by **`random_floor_bumps.gd`** and **`player.gd`** lip escape). |
| None. | This follow-up should fit in existing movement, camera, test, and documentation files. |
## Files to modify
| Path | Rationale |
|------|-----------|
| `client/scripts/player.gd` | Idle **`move_and_slide`** policy, walking snap, arrival path, bump lip escape (imports **`random_floor_bump_collision_constants.gd`**). |
| `client/scenes/main.tscn` | Player **`CharacterBody3D`** / **`NavigationAgent3D`** inspector properties, collision layers, or nav region only if diagnosis requires it (AC: designer-visible changes documented). |
| `client/project.godot` | Physics or project defaults (e.g. physics ticks, 3D defaults) only if required and safe for the whole project. |
| `client/README.md` | Replace **Known issue: idle / rest jitter** with the fix summary and any manual verification deltas. |
| `client/scripts/player.gd` | Primary idle-motion diagnosis and fix: idle **`move_and_slide`** policy, rim/bump escape guards, and any extracted helper logic used to distinguish stable floor contact from real corrective motion. |
| `client/test/player_test.gd` | Add regression coverage for any new idle guard/helper logic added to **`player.gd`**. |
| `client/README.md` | Update the documented anti-jitter pattern and manual verification steps once the final fix is known. |
## Tests
| Action | What to cover |
|--------|----------------|
| **Manual (required)** | Run main scene with server as in **NEON-4/NEON-8** README steps: stand idle **10+ s** at spawn and after a move; click-move including **stepped bumps** (NEON-7 props); cold boot **`snap_to_server`**; far/vertical rejects still show NEON-7 UX; optional multi-click around **`Obstacle`** per NEON-8 tradeoff. |
| **Automated** | **None** — no Godot/GDScript test harness in repo; server **xUnit** suites unchanged unless an unrelated regression appears (not expected). |
| **Manual (required)** | Run main scene with server as in **NEON-4/NEON-8** README steps: stand idle **10+ s** at spawn on flat ground, stand idle again after a move, idle on the random green bumps, click-move including **stepped bumps** (NEON-7 props), cold boot **`snap_to_server`**, far/vertical rejects, and NEON-30 occluder click-through sanity. |
| **Automated** | Updated **`client/test/player_test.gd`** with stable-idle plus capsule-feet helper coverage. Headless GdUnit run: **52 tests, 0 errors, 0 failures** via Godot **4.6** and **`res://addons/gdUnit4/bin/GdUnitCmdTool.gd`**. One-off headless idle probe against **`main.tscn`** observed **`x_range=0`** and **`z_range=0`** across a **240-physics-frame** idle sample after settle. |
## Open questions / risks
**Resolved for prototype QA:** Primary causes were **compound internal edges** (mitigated by per-bump bodies), **physics/render** artifacts (**interpolation**, **`CharacterBody3D`** transform desync, **z-fight**), and **rim/wall** contacts (mitigated by idle policy + small escapes). **Remaining:** **`_snap_capsule_upright()`** uses full **identity basis** — when **yaw** facing is added, preserve **Y** rotation only (see **`TODO`** in **`player.gd`**). Moving platforms / uneven nav mesh not validated.
- The current regression report says jitter is now worse **even on flat ground**, so the earlier assumption that the problem was mainly bump/rim contact may no longer be sufficient.
- A player-only fix may still leave visible follow jitter if the camera continues to aim at raw focus each render frame.
- A camera-only fix may hide real **`CharacterBody3D`** drift and make future movement regressions harder to see.
- **`_snap_capsule_upright()`** still uses full **identity basis** — when **yaw** facing is added, preserve **Y** rotation only (see **`TODO`** in **`player.gd`**). Moving platforms / uneven nav mesh not validated.
## PR / review

View File

@ -0,0 +1,126 @@
# Code review — NEON-16 idle jitter follow-up (Godot client)
**Date:** 2026-04-10
**Scope:** Branch `NEON-16-idle-jitter-followup` vs `origin/main` @ `2aff663`. Issue **NEON-16** (re-opened). No PR URL supplied; working-tree diff reviewed.
**Base:** `origin/main` @ `2aff663` (post-NEON-30 merge)
---
## Verdict
**Request changes** — two CI-blocking lint issues must be fixed before merge.
---
## Summary
This branch re-opens NEON-16 to eliminate residual idle x/z drift on flat ground that became worse after the NEON-25/26/27/30 follow-camera work. The fix is localized entirely to `player.gd`: a stable-flat-support fast-path that skips the corrective `move_and_slide()` / rim-escape loop, an explicit x/z anchor to clamp away residual horizontal creep while the player is classified as stable, a correction-budget cap (`IDLE_MANUAL_CORRECTION_MAX_TICKS`) to prevent edge contacts from running the loop indefinitely, and a capsule-feet reference point for both goal-arrival and descend-bypass checks. `_snap_capsule_upright()` gains an early-out when the basis is already identity. Debug tracing is gated behind `@export var debug_idle_trace`. Thirteen new unit tests cover the new static helpers and state invariants. Server contracts are untouched; risk is client-only.
---
## Documentation checked
| Document | Result |
|----------|--------|
| `docs/plans/NEON-16-implementation-plan.md` | **Matches** — Resolution section updated, Decisions table present, Files to modify and Tests sections reflect what shipped. AC boxes not yet checked (branch not merged). |
| `docs/reviews/2026-04-05-NEON-16.md` | **Matches** — existing review is the prior-story approval; this is a follow-up branch against the same issue. No bullets to strike through in the old file from this diff. |
| `docs/decomposition/modules/E1_M1_InputAndMovementRuntime.md` | **Matches** — change is client movement-loop polish; server authority (`PositionState`, `MoveCommand`) and all contracts unchanged. Module status ("Ready — prototype") does not need updating for this fix. |
| `docs/decomposition/modules/module_dependency_register.md` | **N/A** — no new modules or dependencies introduced. |
| `docs/decomposition/modules/client_server_authority.md` | **N/A** — no authority boundary changes. |
| `client/README.md` | **Matches** — README updated in both the tradeoff note (descend-bypass now uses feet height) and the Idle stability paragraph (flat-idle hardening, x/z anchor, identity-basis short-circuit). Manual verification step 6 updated to check x/z specifically on flat floor then on bumps. |
---
## Blocking issues
1. ~~**`@export var debug_idle_trace` declaration order violates `gdlint` `class-definitions-order` (CI will fail).**~~ Done. Moved `@export var debug_idle_trace` to the const/export block in `client/scripts/player.gd` so it now precedes all non-export vars.
`@export` vars must appear *before* public and private vars. Currently `debug_idle_trace` is on line 56, sandwiched between private vars that start on line 49:
```gdscript
# current (wrong order)
var _idle_anchor_active: bool = false
var _idle_anchor_xz: Vector2 = Vector2.ZERO
var _idle_manual_correction_ticks: int = 0
@export var debug_idle_trace: bool = false # ← must move up
var _debug_last_idle_xz: Vector2 = Vector2.INF
...
```
Move `@export var debug_idle_trace` to immediately after the last `const` block (before any `var` declarations), or at minimum before the first `var _…` line, so it precedes all non-export vars per gdlint order: `const → @export vars → public vars → private vars`.
2. ~~**`vertical_arrival_error` function signature is 109 characters — exceeds the 100-char `gdlint` `max-line-length` limit (CI will fail).**~~ Done. Wrapped the signature across multiple lines in `client/scripts/player.gd` to satisfy `max-line-length`.
```gdscript
# 109 chars — too long
static func vertical_arrival_error(goal_y: float, body_origin_y: float, capsule_half_height: float) -> float:
```
Wrap the parameter list:
```gdscript
static func vertical_arrival_error(
goal_y: float, body_origin_y: float, capsule_half_height: float
) -> float:
```
---
## Suggestions
1. ~~**`set_notify_transform(true)` is always enabled regardless of `debug_idle_trace`.**~~ Done. `client/scripts/player.gd` now only registers transform-change notifications in `_ready()` when `debug_idle_trace` is enabled.
`_notification(NOTIFICATION_TRANSFORM_CHANGED)` is invoked by the engine on every transform write (physics, snap, anchor hold) in both debug and release builds. The callback itself bails quickly when `debug_idle_trace` is false, so functional correctness is fine, but the notification dispatch fires unconditionally at 120 TPS. Consider gating the registration:
```gdscript
if debug_idle_trace:
set_notify_transform(true)
```
Or enable it only in debug builds (`OS.is_debug_build()`). At two bumps and one player this overhead is negligible, but the pattern will scale poorly if the per-node notification cost is revisited later.
2. ~~**`idle_support_is_stable`: the `slide_normals` parameter is silently ignored via `@warning_ignore` + no-op self-assignment.**~~ Done. Renamed the parameter to `_slide_normals` and replaced the no-op assignment with a brief reserved-for-future comment in `client/scripts/player.gd`.
The intent (reserve for future extra-contact analysis) is reasonable, but the current pattern is easy to misread:
```gdscript
static func idle_support_is_stable(
on_floor: bool, floor_normal: Vector3, slide_normals: Array[Vector3], loose_ticks: int
) -> bool:
@warning_ignore("unused_parameter")
slide_normals = slide_normals # ← does nothing
...
```
Either drop the parameter until it is used (callers pass `_current_slide_normals()` which allocates a new array each call), or add a `##` doc comment explicitly noting "reserved — not yet evaluated". If the parameter is intentionally kept for API-stability reasons, a `@warning_ignore` on the declaration rather than a no-op assignment is cleaner in GDScript.
3. ~~**`set_authoritative_nav_goal` clears `_idle_anchor_active` but has no test coverage for that invariant.**~~ Done. `client/test/player_test.gd` now seeds `_idle_anchor_active` before `set_authoritative_nav_goal()` and asserts the flag is cleared.
The new test `test_snap_to_server_clears_idle_anchor` covers `snap_to_server`, and `test_clear_nav_goal_clears_velocity_and_nav_target` checks `_idle_anchor_active`. The same assertion for `set_authoritative_nav_goal` is absent; a one-liner would close the gap.
4. ~~**`_floor_angle_loose_ticks` is never decremented in the "budgeted" anchor path.**~~ Done. Added the missing decrement to the budgeted idle-anchor branch in `client/scripts/player.gd`.
When `_idle_manual_correction_ticks >= IDLE_MANUAL_CORRECTION_MAX_TICKS` the code holds the anchor and returns early — skipping the decrement that only lives in the corrective branch. In practice the player is already anchored so the loose angle has no visible effect, but arrival with bumps could hold `FLOOR_MAX_ANGLE_MOVING_DEG` longer than intended if the budget is hit quickly. Consider adding a decrement in the budgeted path:
```gdscript
if _floor_angle_loose_ticks > 0:
_floor_angle_loose_ticks -= 1
```
---
## Nits
- `_debug_trace_frame` increments every `_physics_process` tick unconditionally (including during walking). This is fine for a debug tool; just be aware it will wrap around to `int` overflow after very long sessions (non-issue at 120 TPS unless someone leaves it running for ~200 days).
- The `@warning_ignore("unused_parameter")` annotation placement inside the function body (rather than on the parameter/declaration) is valid GDScript but unusual. See suggestion 2.
- `_maybe_idle_bump_proximity_escape`: when `test_move` blocks an escape step the function returns `false` immediately, stopping any further bump scan. This was the pre-existing behavior and is unchanged; just noting it persists in the new `bool`-return form.
---
## Verification
Per `NEON-16-implementation-plan.md` and updated `client/README.md` step 6:
1. Fix the two blocking lint issues and run `gdlint client/scripts client/test` + `gdformat --check client/scripts client/test` locally to confirm clean.
2. Run headless GdUnit: `godot --headless --script res://addons/gdUnit4/bin/GdUnitCmdTool.gd --add res://test/` — expect **52+ tests, 0 errors, 0 failures**.
3. Run `main.tscn` with server: stand idle **10+ s** at spawn on flat ground; confirm no visible vibration and no x/z drift.
4. Walk to a destination, stop, idle again — same stability check.
5. Idle on the random green bumps for **10+ s** — capsule should not drift radially or oscillate.
6. Click-move including stepped bumps (NEON-7 props); confirm walking, arrival, and `snap_to_server` on cold boot are unaffected.
7. NEON-30 occluder click-through sanity (click behind an occluder body — ground pick still registers).