Stabilize airborne sitting prediction (#117)

This commit is contained in:
Alexander Sellite 2026-09-02 09:52:06 -04:00
parent bfbcba7109
commit 62af5e7086
2 changed files with 198 additions and 18 deletions

View file

@ -592,7 +592,9 @@ var _character_animation_name: StringName = &""
var _sitting: bool = false
var _sit_after_landing: bool = false
var _sitting_intent_pending: bool = false
var _sitting_intent_target: bool = false
var _sitting_intent_sequence: int = -1
var _latest_authoritative_sitting_ack: int = -1
var _held_fish_visible: bool = false
var _pending_held_fish_texture: Texture2D
var _pending_held_fish_scale: Vector3 = Vector3.ONE
@ -1084,7 +1086,9 @@ func _simulate_movement_physics(delta: float) -> void:
_set_sitting(false)
if _sit_after_landing and not jump_requested and _can_begin_sitting():
_sit_after_landing = false
_set_sitting(true, local_control_enabled)
# Landing resolves the already-queued desire to sit; it is not a new
# input edge and must not restart its acknowledgement window.
_set_sitting(true)
if _sitting:
if jump_requested:
_set_sitting(false, local_control_enabled)
@ -1740,7 +1744,9 @@ func _complete_retract_animation() -> void:
func toggle_sitting() -> void:
if _sit_after_landing:
_sit_after_landing = false
_set_sitting(false, local_control_enabled)
_set_sitting(false)
if local_control_enabled:
_queue_local_sitting_intent(false)
return
var should_sit: bool = not _sitting
if should_sit:
@ -1756,10 +1762,12 @@ func toggle_sitting() -> void:
return
if not _can_begin_sitting():
_sit_after_landing = true
# Queue and repeat the intent until an owner snapshot acknowledges it.
# Previously this branch returned before marking a local sitting intent,
# so a delayed authoritative audit could race the deferred sit.
_set_sitting(false, local_control_enabled)
# Keep the desired state distinct from the physical pose. The host can
# acknowledge this input while its own capsule is still airborne, so a
# standing audit is not yet confirmation that the request was resolved.
_set_sitting(false)
if local_control_enabled:
_queue_local_sitting_intent(true)
return
_set_sitting(should_sit, local_control_enabled)
@ -1778,8 +1786,7 @@ func _set_sitting(
is_local_intent: bool = false,
) -> void:
if is_local_intent:
_sitting_intent_pending = true
_sitting_intent_sequence = -1
_queue_local_sitting_intent(should_sit)
if _sitting == should_sit:
return
_sitting = should_sit
@ -1793,6 +1800,17 @@ func _set_sitting(
_update_character_animation()
func _queue_local_sitting_intent(should_sit: bool) -> void:
# Physics may resolve a deferred sit on the same frame that input is being
# captured. Do not move the sequence watermark when it is still the same
# logical request, or no delayed owner audit could ever catch up to it.
if _sitting_intent_pending and _sitting_intent_target == should_sit:
return
_sitting_intent_pending = true
_sitting_intent_target = should_sit
_sitting_intent_sequence = -1
func is_sitting() -> bool:
return _sitting
@ -1802,7 +1820,10 @@ func _can_begin_sitting() -> bool:
func _cancel_sitting_for_jump() -> void:
if not _sitting and not _sit_after_landing:
var has_pending_sit_request: bool = (
_sitting_intent_pending and _sitting_intent_target
)
if not _sitting and not _sit_after_landing and not has_pending_sit_request:
return
_sit_after_landing = false
_set_sitting(false, local_control_enabled)
@ -1818,6 +1839,8 @@ func get_network_sitting_intent() -> bool:
# the stale seated bit as soon as the reliable jump intent is queued.
if _local_network_jump_intent_pending:
return false
if _sitting_intent_pending:
return _sitting_intent_target
return _sitting or _sit_after_landing
@ -1850,6 +1873,10 @@ func _apply_local_prediction_sitting_state(should_sit: bool) -> void:
_sit_after_landing = false
_set_sitting(false)
return
if not should_sit:
_sit_after_landing = false
_set_sitting(false)
return
if should_sit and not _can_begin_sitting():
_sit_after_landing = true
_set_sitting(false)
@ -2360,7 +2387,9 @@ func reset_network_movement_state() -> void:
end_animation_action()
_sit_after_landing = false
_sitting_intent_pending = false
_sitting_intent_target = false
_sitting_intent_sequence = -1
_latest_authoritative_sitting_ack = -1
_set_sitting(false)
_network_axis = Vector2.ZERO
_network_jump_pending = false
@ -2459,8 +2488,6 @@ func apply_network_space_transition(
func capture_network_input(sequence: int) -> Dictionary:
if _sitting_intent_pending and _sitting_intent_sequence < 0:
_sitting_intent_sequence = sequence
if (
local_control_enabled
and _is_movement_input_enabled()
@ -2468,6 +2495,13 @@ func capture_network_input(sequence: int) -> Dictionary:
and Input.is_action_just_pressed("jump")
):
_queue_local_network_jump_intent()
# Input capture and physics do not have a guaranteed ordering within the
# rendered frame. Cancel the desired sit here as well as in physics so an
# unusually fast owner audit cannot acknowledge the jump first and expose
# the old seated level bit for one outgoing packet.
_cancel_sitting_for_jump()
if _sitting_intent_pending and _sitting_intent_sequence < 0:
_sitting_intent_sequence = sequence
if (
not _is_movement_input_enabled()
or _water_recovery_active
@ -2723,10 +2757,18 @@ func apply_local_prediction_correction(
_reset_local_prediction_error()
return
var acknowledged_input: int = parsed["acknowledged_input"]
var authoritative_sitting: bool = bool(parsed["sitting"])
var sitting_audit_is_stale: bool = (
acknowledged_input < _latest_authoritative_sitting_ack
)
if not sitting_audit_is_stale:
_latest_authoritative_sitting_ack = acknowledged_input
var sitting_intent_acknowledged: bool = (
_sitting_intent_pending
not sitting_audit_is_stale
and _sitting_intent_pending
and _sitting_intent_sequence >= 0
and acknowledged_input >= _sitting_intent_sequence
and authoritative_sitting == _sitting_intent_target
)
if sitting_intent_acknowledged:
_sitting_intent_pending = false
@ -2737,8 +2779,8 @@ func apply_local_prediction_correction(
and acknowledged_input >= _local_network_jump_intent_sequence
):
_clear_local_network_jump_intent()
if not _sitting_intent_pending:
_apply_local_prediction_sitting_state(bool(parsed["sitting"]))
if not _sitting_intent_pending and not sitting_audit_is_stale:
_apply_local_prediction_sitting_state(authoritative_sitting)
var authoritative_position: Vector3 = parsed["position"]
var transit_seconds: float = resolve_local_prediction_transit_seconds(
acknowledged_input,

View file

@ -41,7 +41,7 @@ func _validate_latency_smoothing() -> void:
await _validate_remote_snapshot_smoothing(avatar)
_validate_remote_locomotion_playback_recovery(avatar)
_validate_reliable_jump_intent(avatar)
_validate_airborne_sitting(avatar)
await _validate_airborne_sitting(avatar)
_validate_stale_input_expiry(avatar)
avatar.queue_free()
await process_frame
@ -193,21 +193,157 @@ func _validate_compact_animation_encoding() -> void:
func _validate_airborne_sitting(avatar: Player) -> void:
# Give the prediction unit a real floor so host/client landing can be offset
# while the owner-audit acknowledgement ordering is exercised faithfully.
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.reset_network_movement_state()
avatar.set_local_control(true)
avatar.global_position = Vector3(0.0, 2.0, 0.0)
avatar.velocity = Vector3(0.0, -1.0, 0.0)
avatar.move_and_slide()
assert(not avatar.is_on_floor())
avatar.toggle_sitting()
assert(avatar.get_network_sitting_intent())
assert(not avatar.get_network_sitting_state())
assert(bool(avatar.get("_sitting_intent_pending")))
assert(bool(avatar.get("_sitting_intent_target")))
assert(not bool(avatar.make_network_snapshot(2)["sitting"]))
assert(bool(avatar.capture_network_input(1)["sitting"]))
# Reproduce the high-RTT owner audit race: the host has reached the ground
# and reports the deferred sit while this client's predicted capsule is still
# airborne. Repeated seated snapshots must not zero its falling velocity.
# The host has received the sit input (and echoes its sequence) but has not
# landed yet. Receipt is not completion: keep transmitting the desired state
# instead of accepting this temporary standing pose and starting a true/false
# feedback loop under high RTT.
var host_still_airborne: Dictionary = _network_snapshot(
avatar.global_position,
avatar.velocity,
1,
)
host_still_airborne["grounded"] = false
avatar.apply_local_prediction_correction(
host_still_airborne,
1,
1.0 / 30.0,
0.3,
0.1,
)
assert(bool(avatar.get("_sitting_intent_pending")))
assert(avatar.get_network_sitting_intent())
# Let local prediction land first. A newer host audit can still be standing
# because the host began simulating the delayed input later. It must neither
# stand the local player back up nor change the level-triggered retry bit.
avatar.global_position = Vector3.ZERO
avatar.velocity = Vector3.DOWN
avatar.move_and_slide()
assert(avatar.is_on_floor())
avatar.call("_simulate_movement_physics", 1.0 / 60.0)
assert(avatar.is_sitting())
assert(int(avatar.get("_sitting_intent_sequence")) == 1)
assert(bool(avatar.capture_network_input(2)["sitting"]))
var delayed_standing_audit: Dictionary = _network_snapshot(
avatar.global_position,
Vector3.ZERO,
2,
)
avatar.apply_local_prediction_correction(
delayed_standing_audit,
2,
1.0 / 30.0,
0.3,
0.1,
)
assert(avatar.is_sitting())
assert(bool(avatar.get("_sitting_intent_pending")))
assert(avatar.get_network_sitting_intent())
# Once the host reaches the ground, its matching pose confirms and retires
# the request without disturbing the already-seated local prediction.
var delayed_sit_snapshot: Dictionary = _network_snapshot(
avatar.global_position,
Vector3.ZERO,
2,
)
delayed_sit_snapshot["sitting"] = true
avatar.apply_local_prediction_correction(
delayed_sit_snapshot,
2,
1.0 / 30.0,
0.3,
0.1,
)
assert(avatar.is_sitting())
assert(not bool(avatar.get("_sitting_intent_pending")))
# A late owner audit from before the confirmed seated pose cannot overwrite
# it even if packet ordering is perturbed by a transport or test harness.
avatar.apply_local_prediction_correction(
host_still_airborne,
2,
1.0 / 30.0,
0.3,
0.1,
)
assert(avatar.is_sitting())
# The inverse transition has the same rule: keep the predicted standing pose
# through a delayed seated audit until the host confirms the newer request.
avatar.toggle_sitting()
assert(not avatar.is_sitting())
assert(not bool(avatar.get("_sitting_intent_target")))
assert(not bool(avatar.capture_network_input(3)["sitting"]))
avatar.apply_local_prediction_correction(
delayed_sit_snapshot,
3,
1.0 / 30.0,
0.3,
0.1,
)
assert(not avatar.is_sitting())
assert(bool(avatar.get("_sitting_intent_pending")))
var confirmed_standing: Dictionary = _network_snapshot(
avatar.global_position,
Vector3.ZERO,
3,
)
avatar.apply_local_prediction_correction(
confirmed_standing,
3,
1.0 / 30.0,
0.3,
0.1,
)
assert(not avatar.is_sitting())
assert(not bool(avatar.get("_sitting_intent_pending")))
avatar.apply_local_prediction_correction(
delayed_sit_snapshot,
3,
1.0 / 30.0,
0.3,
0.1,
)
assert(not avatar.is_sitting())
# Retain the original mid-air gravity regression independently of the
# acknowledgement state-machine coverage above.
avatar.reset_network_movement_state()
avatar.global_position = Vector3(0.0, 2.0, 0.0)
avatar.velocity = Vector3(0.0, -1.0, 0.0)
avatar.move_and_slide()
avatar.toggle_sitting()
assert(bool(avatar.capture_network_input(1)["sitting"]))
delayed_sit_snapshot = _network_snapshot(
avatar.global_position,
Vector3.ZERO,
1,
@ -264,6 +400,8 @@ func _validate_airborne_sitting(avatar: Player) -> void:
assert(bool(avatar.get("_sit_after_landing")))
assert(avatar.velocity.y < 4.0)
avatar.reset_network_movement_state()
floor.queue_free()
await physics_frame
func _validate_animation_action_ordering(avatar: Player) -> void: