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

@ -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: