From 135fd9110fcda86e3ec2a6881b20fab67d5d55e7 Mon Sep 17 00:00:00 2001 From: Voyager Date: Tue, 25 Aug 2026 00:12:11 -0400 Subject: [PATCH] stabilize movement reconciliation states --- player/player.gd | 63 ++++++++++++-- tests/movement_multiplayer_validation.gd | 102 ++++++++++++++++++++++- 2 files changed, 156 insertions(+), 9 deletions(-) diff --git a/player/player.gd b/player/player.gd index 5ae4cac..64d4ffe 100644 --- a/player/player.gd +++ b/player/player.gd @@ -1036,6 +1036,8 @@ func _simulate_movement_physics(delta: float) -> void: or (_network_authoritative_simulation and _network_jump_pending) ) ) + if jump_requested: + _cancel_sitting_for_jump() # Sitting deliberately stops movement processing, so never allow a stale # grounded flag or a reconciled network state to leave an airborne avatar in # that early-return path. Preserve the intent and sit once landing is real. @@ -1635,6 +1637,7 @@ func _complete_retract_animation() -> void: func toggle_sitting() -> void: if _sit_after_landing: _sit_after_landing = false + _set_sitting(false, local_control_enabled) return var should_sit: bool = not _sitting if should_sit: @@ -1667,12 +1670,12 @@ func _set_sitting( should_sit: bool, is_local_intent: bool = false, ) -> void: - if _sitting == should_sit: - return - _sitting = should_sit if is_local_intent: _sitting_intent_pending = true _sitting_intent_sequence = -1 + if _sitting == should_sit: + return + _sitting = should_sit if _sitting: velocity = Vector3.ZERO _character_animation_name = &"" @@ -1687,11 +1690,23 @@ func _can_begin_sitting() -> bool: return is_on_floor() and velocity.y <= 0.0 +func _cancel_sitting_for_jump() -> void: + if not _sitting and not _sit_after_landing: + return + _sit_after_landing = false + _set_sitting(false, local_control_enabled) + + func get_network_sitting_state() -> bool: return _sitting func get_network_sitting_intent() -> bool: + # Jumping is an explicit cancellation of both a current sit and a deferred + # sit. Input capture can run before the matching physics frame, so suppress + # the stale seated bit as soon as the reliable jump intent is queued. + if _local_network_jump_intent_pending: + return false return _sitting or _sit_after_landing @@ -2352,6 +2367,21 @@ func apply_local_prediction_correction( _local_prediction_hard_corrections += 1 _reset_local_prediction_error() return + if _should_defer_local_prediction_correction(parsed): + # A host and its client do not reach the apex or landing on the same + # frame. Correcting that expected vertical disagreement while either + # side is airborne can pull the local body off narrow platforms. + _reset_local_prediction_error() + return + # Both simulations agree the player is standing. Let ordinary floor motion + # and gravity resolve the vertical component; a stale height sample must not + # press a locally controlled player down or turn a horizontal correction + # into an early floor collision. + error_offset.y = 0.0 + error_distance = error_offset.length() + if error_distance <= LOCAL_PREDICTION_CORRECTION_THRESHOLD: + _reset_local_prediction_error() + return var error_direction: Vector3 = error_offset.normalized() if ( not _local_prediction_error_direction.is_zero_approx() @@ -2386,22 +2416,34 @@ func apply_local_prediction_correction( correction.normalized() * LOCAL_PREDICTION_MAX_SOFT_CORRECTION_STEP ) - _apply_camera_safe_local_correction(correction) - _local_prediction_soft_corrections += 1 + if _apply_camera_safe_local_correction(correction): + _local_prediction_soft_corrections += 1 -func _apply_camera_safe_local_correction(correction: Vector3) -> void: +func _should_defer_local_prediction_correction(parsed: Dictionary) -> bool: + if not bool(parsed.get("grounded", true)): + return true + return _local_network_jump_intent_pending or not is_on_floor() + + +func _apply_camera_safe_local_correction(correction: Vector3) -> bool: if correction.is_zero_approx(): - return + return false var previous_visual_position: Vector3 = _visuals.global_position var previous_camera_position: Vector3 = _camera_yaw.global_position + var previous_body_position: Vector3 = global_position var base_visual_local_position: Vector3 = ( _visuals.position - _local_reconciliation_visual_offset ) var base_camera_local_position: Vector3 = ( _camera_yaw.position - _local_reconciliation_camera_offset ) - global_position += correction + # Reconciliation is still physical movement. Going through the physics + # server prevents a delayed host audit from placing the capsule through a + # tree, cliff, or stepping stone on the way to the authoritative position. + move_and_collide(correction) + if global_position.is_equal_approx(previous_body_position): + return false _visuals.global_position = previous_visual_position _camera_yaw.global_position = previous_camera_position _local_reconciliation_visual_offset = ( @@ -2410,6 +2452,7 @@ func _apply_camera_safe_local_correction(correction: Vector3) -> void: _local_reconciliation_camera_offset = ( _camera_yaw.position - base_camera_local_position ) + return true func _reset_local_prediction_error() -> void: @@ -2625,6 +2668,9 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary: if snapshot.has("casting") and typeof(snapshot.get("casting")) != TYPE_BOOL: return {} var casting: bool = bool(snapshot.get("casting", false)) + if snapshot.has("grounded") and typeof(snapshot.get("grounded")) != TYPE_BOOL: + return {} + var grounded: bool = bool(snapshot.get("grounded", true)) if ( not parsed_position.is_finite() or not parsed_velocity.is_finite() @@ -2636,6 +2682,7 @@ func _parse_network_snapshot(snapshot: Dictionary) -> Dictionary: "velocity": parsed_velocity, "visual_yaw": visual_yaw, "animation_state": animation_state, + "grounded": grounded, "acknowledged_input": acknowledged_input, "sitting": sitting, "casting": casting, diff --git a/tests/movement_multiplayer_validation.gd b/tests/movement_multiplayer_validation.gd index c9af294..7073bea 100644 --- a/tests/movement_multiplayer_validation.gd +++ b/tests/movement_multiplayer_validation.gd @@ -37,7 +37,7 @@ func _validate_latency_smoothing() -> void: _validate_compact_animation_encoding() _validate_animation_action_ordering(avatar) _validate_transit_estimation() - _validate_remote_snapshot_smoothing(avatar) + await _validate_remote_snapshot_smoothing(avatar) _validate_remote_locomotion_playback_recovery(avatar) _validate_reliable_jump_intent(avatar) _validate_airborne_sitting(avatar) @@ -145,11 +145,20 @@ func _validate_compact_animation_encoding() -> void: func _validate_airborne_sitting(avatar: Player) -> void: avatar.reset_network_movement_state() + avatar.set_local_control(true) avatar.set("_sit_after_landing", true) assert(avatar.get_network_sitting_intent()) assert(not avatar.get_network_sitting_state()) assert(not bool(avatar.make_network_snapshot(2)["sitting"])) assert(bool(avatar.capture_network_input(1)["sitting"])) + avatar.call("_queue_local_network_jump_intent") + var jumping_input: Dictionary = avatar.capture_network_input(2) + assert(bool(jumping_input["jump"])) + assert(not bool(jumping_input["sitting"])) + avatar.call("_cancel_sitting_for_jump") + assert(not bool(avatar.get("_sit_after_landing"))) + assert(bool(avatar.get("_sitting_intent_pending"))) + avatar.reset_network_movement_state() # Even a malformed or stale reconciliation that marks an airborne avatar as # seated must not bypass gravity and freeze it in place. @@ -304,8 +313,24 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: ) assert(avatar.global_position.x <= maximum_extrapolated_x + 0.1) + # Reconciliation only applies after both simulations report a grounded body. + # Give the unit avatar a real floor so this exercises the production path. + var floor := StaticBody3D.new() + floor.collision_layer = 1 + floor.collision_mask = 0 + floor.position = Vector3(0.0, -0.1, 0.0) + var floor_shape := CollisionShape3D.new() + var floor_box := BoxShape3D.new() + floor_box.size = Vector3(100.0, 0.2, 100.0) + floor_shape.shape = floor_box + floor.add_child(floor_shape) + root.add_child(floor) + await physics_frame avatar.set_local_control(true) avatar.global_position = Vector3(0.8, 0.0, 0.0) + avatar.velocity = Vector3.DOWN + avatar.move_and_slide() + assert(avatar.is_on_floor()) avatar.apply_local_prediction_correction( moving_snapshot, 2, @@ -358,6 +383,42 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: int(avatar.get("_local_prediction_soft_corrections")) == 1 ) + # Host and client jump timing naturally differs. An airborne authoritative + # snapshot must not drag the local capsule sideways or down before landing. + avatar.global_position = Vector3(3.0, 0.0, 0.0) + avatar.velocity = Vector3(0.0, 1.0, 0.0) + var airborne_snapshot: Dictionary = drift_snapshot.duplicate(true) + airborne_snapshot["grounded"] = false + for _audit: int in 6: + avatar.apply_local_prediction_correction( + airborne_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(avatar.global_position.is_equal_approx(Vector3(3.0, 0.0, 0.0))) + assert(int(avatar.get("_local_prediction_soft_corrections")) == 1) + avatar.velocity = Vector3.ZERO + + # The local body may reach its apex after the host has already landed. The + # local airborne state still owns presentation and must not be pulled toward + # the host until it has landed too. + avatar.global_position = Vector3(3.0, 2.0, 0.0) + avatar.velocity = Vector3.ZERO + avatar.move_and_slide() + assert(not avatar.is_on_floor()) + for _audit: int in 6: + avatar.apply_local_prediction_correction( + drift_snapshot, + 20, + 1.0 / 30.0, + 0.0, + 0.1, + ) + assert(avatar.global_position.is_equal_approx(Vector3(3.0, 2.0, 0.0))) + assert(int(avatar.get("_local_prediction_soft_corrections")) == 1) + # Genuine divergence still recovers immediately, as do the separate reliable # teleport and water-recovery paths used by gameplay transitions. avatar.global_position = Vector3(10.0, 0.0, 0.0) @@ -373,6 +434,45 @@ func _validate_remote_snapshot_smoothing(avatar: Player) -> void: int(avatar.get("_local_prediction_hard_corrections")) == 1 ) + # Soft reconciliation must respect the same terrain collision as ordinary + # player movement instead of phasing the capsule through a solid obstacle. + var blocker := StaticBody3D.new() + blocker.collision_layer = 1 + blocker.collision_mask = 0 + blocker.position = Vector3(1.0, 1.0, 0.0) + var blocker_shape := CollisionShape3D.new() + var blocker_box := BoxShape3D.new() + blocker_box.size = Vector3(0.2, 4.0, 4.0) + blocker_shape.shape = blocker_box + blocker.add_child(blocker_shape) + root.add_child(blocker) + await physics_frame + avatar.global_position = Vector3.ZERO + avatar.velocity = Vector3.ZERO + avatar.call("_clear_local_reconciliation_offsets") + var blocked_visual_position: Vector3 = ( + avatar.get_node("Visuals") as Node3D + ).global_position + var blocked_camera_position: Vector3 = ( + avatar.get_node("CameraYaw") as Node3D + ).global_position + assert(bool(avatar.call( + "_apply_camera_safe_local_correction", + Vector3(2.0, 0.0, 0.0), + ))) + assert(avatar.global_position.x > 0.0 and avatar.global_position.x < 0.6) + assert( + (avatar.get_node("Visuals") as Node3D).global_position + == blocked_visual_position + ) + assert( + (avatar.get_node("CameraYaw") as Node3D).global_position + == blocked_camera_position + ) + blocker.queue_free() + floor.queue_free() + await physics_frame + func _validate_remote_locomotion_playback_recovery(avatar: Player) -> void: avatar.configure_network_remote(false)