mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
feat(multiplayer): Phase 4 prediction correctness + two input-death fixes
Closes Phase 4's outstanding action-sequence-correctness invariant, then fixes two server-side bugs an adversarial review of that work uncovered. Server simulation, bot observations, collision resources and tick rate are unchanged: the server_physics_parity trace is byte-for-byte identical to HEAD across 360 ticks including both ships' full observation vectors. 4.11 - prediction history filed under the ISSUING sequence _send_local_input filed each post-step predicted state under the timeline's estimate of the sequence the server would consume this tick, trailing issuance by input_lead. The body had integrated the intent issued under _input_seq, so predicted[S] held "state after the intent from now" while the server's authority for S is "state after action(S)". They agree only while the stick is still. Filing under _input_seq costs nothing: which action the ship uses is decided in LocalNetShipController.get_action() and is untouched. Every prior Phase 4 gate held its input steady, and a steady input cannot falsify a sequence label - the 60s runs honestly reported marker=0/3784. New --exercise-input-transitions role toggles thrust every 6 ticks; it is the only gate that can catch a label regression. Verified non-vacuous: the old label fails it at 50%. 4.12 - issued-but-unsimulated sequences, and the release path An attack (delta > 1) issues and sends several sequences for one local physics step. Those gap sequences had no recorded prediction, so a server ack of one reported missing_not_recorded - indistinguishable from ring loss, costing a teleport and resync suppression several times a minute. They are now recorded stateless via record_unsimulated() and answered with a new "skip" decision mode. Free-flight hard snaps: 25/8/4 -> 0/0/0. A release (delta == 0) re-recorded at the unchanged _input_seq, filing the current intent under a sequence that went out carrying a different action; LocalInputTimeline deliberately refuses to mutate an issued sequence, so the ring contradicted the wire. Recording is now skipped on release ticks. 4.13 - two Phase 3 bugs silently killing player input (a) InputJitterBuffer.consume() advanced last_applied_seq on every tick including a starve. Since ingest() discards seq <= last_applied_seq, one starve on a sequence the client had not sent yet stranded the stream one ahead of arrivals permanently - both sides advancing in lockstep, every honest packet discarded on arrival. The client's own input_lead release is enough to trigger it, so input died for ~30 ticks roughly every 6.5s on a clean LAN. Now only gives up on a sequence once strictly newer data proves it lost. Silent-client stall and ring-overflow resync are unchanged. (b) The seq-range guard bounded incoming seq against highest_ingested_seq, which only advances inside ingest(), which that guard gates. After a ~2s host hitch every packet was rejected forever with no diagnostic (600+ consecutive rejections reproduced via SIGSTOP). Third iteration of this guard; each previous version bounded against a value only the accepted path could advance. Adds an escape after 10 consecutive rejections, which grants an attacker nothing the rate limiter does not already bound. (c) The transitions gate reported PASS at 3.76% while input was completely dead, because suppression stops _record_metrics - a worse outage yields fewer samples and a LOWER rate. Now scales the required sample count with run length and asserts the wire's server_stalled bit. Reverting both fixes makes it fail at samples 292/600, server_stalled=true, input_lead=12. Fixing (a) also explained a residual the review had already traced: 151 of 151 action-marker mismatches were the server repeating a stale action on a starve, not a prediction defect. Marker is now 0.00% in all three conditions (was 1.7-2.5%), and free-flight p99 improved to 0.141/0.168/0.154m from 0.170/0.176/0.184m. Two pre-existing test defects fixed alongside: the ball gate asserted RTT-masking on a link with no RTT (flaked 2 in 5; now asserted only at rtt >= 20ms, 5/5 under latency), and the two-bot CI compared scores across a 3-5s window (now polls the scores the server actually held; note score_changed is emitted only on the client path). QA: 72 unit tests; 60s free-flight at LAN/80+-20ms/5% loss; transition gate in all three; 2.0s and 3.5s host-freeze recovery; ball contact x5; two-bot CI x3; all three abuse roles; net/match_net/clock/lobby smokes. Phase 4 sign-off still pending a human playtest at ~100ms RTT - the milestone asks how it feels, which no gate here answers.
This commit is contained in:
@@ -26,6 +26,8 @@ metadata/_edit_group_ = true
|
||||
[node name="CollisionShape3D" type="CollisionShape3D" parent="."]
|
||||
shape = SubResource("SphereShape3D_c5p07")
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="."]
|
||||
[node name="Visual" type="Node3D" parent="."]
|
||||
|
||||
[node name="MeshInstance3D" type="MeshInstance3D" parent="Visual"]
|
||||
transform = Transform3D(0.5, 0, 0, 0, 0.5, 0, 0, 0, 0.5, 0, 0, 0)
|
||||
mesh = ExtResource("1_ball")
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
class_name AdaptiveInputDepthController
|
||||
extends RefCounted
|
||||
|
||||
# Client-only policy for choosing whether the server's input jitter buffer may
|
||||
# run at depth zero. It deliberately does not change server buffering, action
|
||||
# encoding, or bot behavior; NetworkedMatch pins --test-bot clients at depth 1.
|
||||
|
||||
const TARGET_DEPTH_SAFE := 1
|
||||
const TARGET_DEPTH_LOW_LATENCY := 0
|
||||
const CLEAN_JITTER_MS := 3.0
|
||||
const EXIT_JITTER_MS := 5.0
|
||||
const REQUIRED_STABLE_TICKS := 240
|
||||
const REENTRY_COOLDOWN_TICKS := 120
|
||||
|
||||
var target_depth := TARGET_DEPTH_SAFE
|
||||
var stable_low_jitter_ticks := 0
|
||||
var cooldown_ticks := 0
|
||||
|
||||
|
||||
func update(rtt_ms: float, jitter_ms: float, advertised_depth: int) -> int:
|
||||
if cooldown_ticks > 0:
|
||||
cooldown_ticks -= 1
|
||||
# -2 is a genuine server starvation sentinel. -1 means no header yet and
|
||||
# must not be mistaken for starvation.
|
||||
if advertised_depth < -1 or jitter_ms > EXIT_JITTER_MS:
|
||||
target_depth = TARGET_DEPTH_SAFE
|
||||
stable_low_jitter_ticks = 0
|
||||
cooldown_ticks = REENTRY_COOLDOWN_TICKS
|
||||
return target_depth
|
||||
if rtt_ms >= 0.0 and jitter_ms < CLEAN_JITTER_MS:
|
||||
stable_low_jitter_ticks += 1
|
||||
if stable_low_jitter_ticks >= REQUIRED_STABLE_TICKS and cooldown_ticks == 0:
|
||||
target_depth = TARGET_DEPTH_LOW_LATENCY
|
||||
else:
|
||||
stable_low_jitter_ticks = 0
|
||||
target_depth = TARGET_DEPTH_SAFE
|
||||
return target_depth
|
||||
@@ -0,0 +1 @@
|
||||
uid://dofgtukllr7yr
|
||||
@@ -0,0 +1 @@
|
||||
uid://kkge43vtwhyv
|
||||
+32
-2
@@ -18,9 +18,13 @@ const MAX_SPEED := 32.0
|
||||
|
||||
var _boundary: ArenaBoundary
|
||||
var _trail: GPUParticles3D
|
||||
@onready var visual: Node3D = $Visual
|
||||
|
||||
var _pending_teleport: Transform3D
|
||||
var _has_pending_teleport := false
|
||||
var _pending_teleport_linear_velocity := Vector3.ZERO
|
||||
var _pending_teleport_angular_velocity := Vector3.ZERO
|
||||
var _pending_teleport_has_velocity := false
|
||||
|
||||
|
||||
# Queues an authoritative teleport, applied at the top of the next
|
||||
@@ -30,6 +34,18 @@ var _has_pending_teleport := false
|
||||
func queue_teleport(to: Transform3D) -> void:
|
||||
_pending_teleport = to
|
||||
_has_pending_teleport = true
|
||||
_pending_teleport_has_velocity = false
|
||||
|
||||
|
||||
# Kept parallel to Ship's network correction hook. A locally predicted ball
|
||||
# must resume from the authoritative velocity after a correction; gameplay
|
||||
# resets still deliberately use queue_teleport() and zero both velocities.
|
||||
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
|
||||
_pending_teleport = to
|
||||
_pending_teleport_linear_velocity = new_linear_velocity
|
||||
_pending_teleport_angular_velocity = new_angular_velocity
|
||||
_pending_teleport_has_velocity = true
|
||||
_has_pending_teleport = true
|
||||
|
||||
|
||||
# -1 = use the real linear_velocity (default; see _physics_process below).
|
||||
@@ -39,6 +55,13 @@ func queue_teleport(to: Transform3D) -> void:
|
||||
# state that will never reflect the ball's true remote motion.
|
||||
var _visual_speed_override: float = -1.0
|
||||
|
||||
# Prediction correction hook: exactly like Ship's visual offset, but kept
|
||||
# here so a locally predicted ball can move its collider to authority while
|
||||
# the mesh catches up over a short presentation-only decay.
|
||||
var net_visual_offset := Vector3.ZERO
|
||||
const NET_VISUAL_OFFSET_DECAY := 0.88
|
||||
const MAX_NET_VISUAL_OFFSET := 0.4
|
||||
|
||||
|
||||
func set_visual_speed(speed: float) -> void:
|
||||
_visual_speed_override = speed
|
||||
@@ -55,6 +78,12 @@ func _ready() -> void:
|
||||
|
||||
|
||||
func _physics_process(_delta: float) -> void:
|
||||
if net_visual_offset != Vector3.ZERO:
|
||||
net_visual_offset = net_visual_offset.limit_length(MAX_NET_VISUAL_OFFSET)
|
||||
net_visual_offset *= pow(NET_VISUAL_OFFSET_DECAY, _delta * 60.0)
|
||||
if net_visual_offset.length_squared() < 0.0001:
|
||||
net_visual_offset = Vector3.ZERO
|
||||
visual.position = net_visual_offset
|
||||
if _trail:
|
||||
var speed := _visual_speed_override if _visual_speed_override >= 0.0 else linear_velocity.length()
|
||||
var speed_ratio := clampf(speed / MAX_SPEED, 0.0, 1.0)
|
||||
@@ -96,8 +125,9 @@ func _integrate_forces(state: PhysicsDirectBodyState3D) -> void:
|
||||
if _has_pending_teleport:
|
||||
_has_pending_teleport = false
|
||||
state.transform = _pending_teleport
|
||||
state.linear_velocity = Vector3.ZERO
|
||||
state.angular_velocity = Vector3.ZERO
|
||||
state.linear_velocity = _pending_teleport_linear_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||
state.angular_velocity = _pending_teleport_angular_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||
_pending_teleport_has_velocity = false
|
||||
reset_physics_interpolation()
|
||||
|
||||
if _boundary:
|
||||
|
||||
@@ -140,15 +140,39 @@ func consume() -> ShipAction:
|
||||
last_action = _ring_action[idx]
|
||||
starved_ticks = 0
|
||||
stalled = false
|
||||
else:
|
||||
# Repeat-last, not zero: inputs are heavily autocorrelated at 60Hz,
|
||||
# and the client already predicted with the real input either way,
|
||||
# so repeating minimises expected divergence (§3.2). Only zero after
|
||||
# a sustained stall, so a disconnecting player's ship doesn't fly
|
||||
# into a wall at full throttle forever.
|
||||
starved_ticks += 1
|
||||
if starved_ticks > STARVE_ZERO_TICKS:
|
||||
last_action = ShipAction.new()
|
||||
stalled = true
|
||||
last_applied_seq = expected
|
||||
last_applied_seq = expected
|
||||
return last_action
|
||||
|
||||
# Repeat-last, not zero: inputs are heavily autocorrelated at 60Hz,
|
||||
# and the client already predicted with the real input either way,
|
||||
# so repeating minimises expected divergence (§3.2). Only zero after
|
||||
# a sustained stall, so a disconnecting player's ship doesn't fly
|
||||
# into a wall at full throttle forever.
|
||||
starved_ticks += 1
|
||||
if starved_ticks > STARVE_ZERO_TICKS:
|
||||
last_action = ShipAction.new()
|
||||
stalled = true
|
||||
|
||||
# Only GIVE UP on `expected` when strictly newer data has actually
|
||||
# arrived, which proves it was lost or reordered rather than merely late.
|
||||
#
|
||||
# Advancing unconditionally (what this did originally) is catastrophic
|
||||
# rather than merely lossy, because ingest() discards anything
|
||||
# `seq <= last_applied_seq`. One starve on a sequence the client has not
|
||||
# even sent yet leaves the server permanently one ahead of arrivals:
|
||||
# both sides then advance one per tick, the gap never closes, and every
|
||||
# honest packet is discarded on arrival for the rest of the match. An
|
||||
# adversarial review reproduced exactly that on a clean LAN — the client's
|
||||
# own input_lead RELEASE (delta == 0, which deliberately issues no new
|
||||
# sequence for one tick) is sufficient to trigger it, so it fired roughly
|
||||
# every 6.5s of ordinary play, blacking out input for 30 ticks until the
|
||||
# lead controller's debounce allowed a +3 attack to jump the client clear.
|
||||
#
|
||||
# Holding cannot deadlock: if the client genuinely goes silent,
|
||||
# highest_ingested_seq stops moving, starved_ticks still climbs, and the
|
||||
# STARVE_ZERO_TICKS zeroing plus `stalled` above still fire on schedule.
|
||||
# If it falls far behind instead, the ring-overflow resync above still
|
||||
# jumps the cursor forward. Both escape paths are unchanged.
|
||||
if highest_ingested_seq > expected:
|
||||
last_applied_seq = expected
|
||||
return last_action
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cp818kexskb34
|
||||
@@ -57,12 +57,24 @@ var _clean_surplus_ticks := 0
|
||||
# outgoing packet: ordinarily 1 (ship normally increments its send
|
||||
# sequence by exactly one tick's worth), or 1+N / 0 on a tick where a lead
|
||||
# change actually fires (skip N extra / duplicate the current one).
|
||||
func update(input_buffer_depth: int) -> int:
|
||||
func update(input_buffer_depth: int, target_depth: int = TARGET_DEPTH) -> int:
|
||||
_ticks_since_change += 1
|
||||
if input_buffer_depth < 0:
|
||||
if input_buffer_depth == -1:
|
||||
return 1 # no server depth has arrived yet
|
||||
if input_buffer_depth < -1:
|
||||
# -1 is an explicit server starvation sentinel, distinct from a
|
||||
# healthy zero-depth buffer on an adaptive clean link.
|
||||
_clean_surplus_ticks = 0
|
||||
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
|
||||
var starve_lead := mini(lead + 3, LEAD_MAX)
|
||||
var starve_delta := starve_lead - lead
|
||||
lead = starve_lead
|
||||
_ticks_since_change = 0
|
||||
return 1 + starve_delta
|
||||
return 1
|
||||
target_depth = maxi(0, target_depth)
|
||||
|
||||
if input_buffer_depth <= 0:
|
||||
if input_buffer_depth <= target_depth - 1:
|
||||
# A starve: the server's ring was empty for this player when it
|
||||
# built that snapshot. React immediately, not after 2 seconds of
|
||||
# evidence like release requires — but still debounced against
|
||||
@@ -95,7 +107,7 @@ func update(input_buffer_depth: int) -> int:
|
||||
# buffered depth) follows the real signal alone, below; whether to
|
||||
# keep decrementing `lead`'s own bookkeeping below its documented
|
||||
# floor is a separate, cosmetic-only choice made inside that branch.
|
||||
if input_buffer_depth > TARGET_DEPTH:
|
||||
if input_buffer_depth > target_depth:
|
||||
_clean_surplus_ticks += 1
|
||||
else:
|
||||
_clean_surplus_ticks = 0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bvwwwkf82nkdk
|
||||
@@ -0,0 +1 @@
|
||||
uid://qd513s2cqls3
|
||||
@@ -0,0 +1,78 @@
|
||||
class_name LocalInputTimeline
|
||||
extends RefCounted
|
||||
|
||||
# Client-only sequence/action timeline. It mirrors the stream the server's
|
||||
# InputJitterBuffer will consume: an attack fills its deliberate sequence gap
|
||||
# with repeat-last actions, while a release retransmits immutable data.
|
||||
|
||||
const ShipActionScript = preload("res://scripts/ship_action.gd")
|
||||
const RETAINED_REDUNDANCY := 4
|
||||
|
||||
var latest_issued_seq := 0
|
||||
var latest_applied_seq := -1
|
||||
var configured := false
|
||||
var _actions := {}
|
||||
var _last_issued_action = ShipActionScript.new()
|
||||
var _last_applied_action = ShipActionScript.new()
|
||||
|
||||
|
||||
func configure_initial_delay(delay_ticks: int) -> void:
|
||||
if configured:
|
||||
return
|
||||
latest_applied_seq = -maxi(delay_ticks, 1)
|
||||
configured = true
|
||||
|
||||
|
||||
func issue(delta: int, intent) -> int:
|
||||
if delta <= 0:
|
||||
# An already-issued sequence may be in flight or consumed. Never mutate
|
||||
# it; carry current raw intent to the next unique command instead.
|
||||
return latest_issued_seq
|
||||
var from_seq := latest_issued_seq + 1
|
||||
latest_issued_seq += delta
|
||||
for seq in range(from_seq, latest_issued_seq):
|
||||
_actions[seq] = _last_issued_action.copy()
|
||||
_actions[latest_issued_seq] = intent.copy()
|
||||
_last_issued_action = intent.copy()
|
||||
_prune_consumed_actions()
|
||||
return latest_issued_seq
|
||||
|
||||
|
||||
func consume() -> Dictionary:
|
||||
latest_applied_seq += 1
|
||||
if _actions.has(latest_applied_seq):
|
||||
_last_applied_action = _actions[latest_applied_seq].copy()
|
||||
_prune_consumed_actions()
|
||||
return {"seq": latest_applied_seq, "action": _last_applied_action.copy()}
|
||||
|
||||
|
||||
# The action actually issued for a sequence, or null if it is no longer
|
||||
# retained. Returns a copy: the timeline's stored actions are immutable once
|
||||
# issued (see issue()), and handing out the live object would let a caller
|
||||
# break that from the outside.
|
||||
func action_for(seq: int):
|
||||
if not _actions.has(seq):
|
||||
return null
|
||||
return _actions[seq].copy()
|
||||
|
||||
|
||||
func packet_actions(max_count: int) -> Array:
|
||||
var out: Array = []
|
||||
for seq in range(latest_issued_seq, maxi(0, latest_issued_seq - max_count), -1):
|
||||
if not _actions.has(seq):
|
||||
break
|
||||
out.append(_actions[seq].copy())
|
||||
return out
|
||||
|
||||
|
||||
func retained_action_count() -> int:
|
||||
return _actions.size()
|
||||
|
||||
|
||||
func _prune_consumed_actions() -> void:
|
||||
# Preserve the local command needed for the server's redundancy window,
|
||||
# then discard actions that are older than both consumption and backup use.
|
||||
var keep_from := latest_issued_seq - RETAINED_REDUNDANCY + 1
|
||||
for seq in _actions.keys():
|
||||
if int(seq) < keep_from:
|
||||
_actions.erase(seq)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8nwh3anyddm5
|
||||
@@ -0,0 +1,27 @@
|
||||
class_name LocalNetShipController
|
||||
extends ShipController
|
||||
|
||||
const LocalInputTimeline = preload("res://scripts/local_input_timeline.gd")
|
||||
|
||||
var source: ShipController
|
||||
var timeline: LocalInputTimeline
|
||||
var last_applied_seq := -1
|
||||
var last_sampled_intent: ShipAction
|
||||
|
||||
|
||||
func _init(new_source: ShipController, new_timeline: LocalInputTimeline) -> void:
|
||||
source = new_source
|
||||
timeline = new_timeline
|
||||
last_sampled_intent = ShipAction.new()
|
||||
|
||||
|
||||
func get_action() -> ShipAction:
|
||||
# Ship invokes this exactly once per local physics tick. Prediction must use
|
||||
# the player's current intent immediately; the timeline is transmission and
|
||||
# immutable-redundancy bookkeeping only. Advance its cursor solely to label
|
||||
# this post-step state at the estimated server-consumption sequence; never
|
||||
# use its queued action to delay local control.
|
||||
last_sampled_intent = source.get_action().copy()
|
||||
var label := timeline.consume()
|
||||
last_applied_seq = int(label["seq"])
|
||||
return last_sampled_intent.copy()
|
||||
@@ -0,0 +1 @@
|
||||
uid://dyaoxrjb006a8
|
||||
@@ -42,20 +42,31 @@ const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
# caller wanting to reject "matched but ancient" needs to separately
|
||||
# check newest_recorded_seq - seq itself.
|
||||
#
|
||||
# 2. record() can be called twice for the same seq with a DIFFERENT action,
|
||||
# when input_lead_controller's release path resends a duplicated seq
|
||||
# (delta == 0) — the later call silently overwrites the ring slot, so
|
||||
# the stored action becomes whichever of the two calls happened last.
|
||||
# This matches what the WIRE ends up sending for that seq (the resend
|
||||
# replaces the redundancy history's front entry — see
|
||||
# networked_match.gd's _send_local_input), but if the SERVER had
|
||||
# already consumed the seq from the first packet before the resend
|
||||
# arrived, the server's applied action and this ring's stored action for
|
||||
# that same seq can disagree. Narrow (release only fires after 120 ticks
|
||||
# of sustained surplus depth, when the server is least likely to be
|
||||
# right on the edge of consuming that exact seq) but real; a future
|
||||
# replay-based catch-up (task 4.5) built on this history should not
|
||||
# assume the stored action is provably what the server actually applied.
|
||||
# 2. HISTORICAL, now fixed — kept because the reasoning still constrains
|
||||
# callers. record() used to be called twice for the same seq with a
|
||||
# DIFFERENT action on the release path (delta == 0), the later call
|
||||
# silently overwriting the slot. That was wrong, not merely imprecise:
|
||||
# LocalInputTimeline.issue() deliberately does NOT mutate _actions[seq]
|
||||
# for an already-issued sequence ("may be in flight or consumed"), so
|
||||
# the overwrite made this ring contradict the wire — it claimed an
|
||||
# action for S that was never sent for S. networked_match.gd now skips
|
||||
# recording entirely on a release tick, leaving the original (correct)
|
||||
# predicted[S] in place. Callers must keep it that way: an already-
|
||||
# recorded sequence's ACTION is immutable here, exactly as it is in the
|
||||
# timeline. Only overwrite_state()/rebase_state_range() may revise an
|
||||
# entry, and only its state.
|
||||
#
|
||||
# 3. A sequence can be ISSUED without ever being locally SIMULATED. The
|
||||
# input_lead controller's attack path (delta > 1) skips sequence numbers
|
||||
# to buy server-side buffer margin: those gap sequences are filled with
|
||||
# repeat-last actions and sent, but the client took exactly ONE physics
|
||||
# step that tick, so no post-step state exists for them. They are
|
||||
# recorded via record_unsimulated() and report "unsimulated_gap" rather
|
||||
# than "missing_not_recorded" — a routine consequence of this client's
|
||||
# own lead control, NOT evidence of history loss, and specifically not a
|
||||
# hard-snap condition. Distinguishing them matters: treating them as
|
||||
# missing history teleported the ship and armed resync suppression
|
||||
# several times a minute during ordinary play.
|
||||
|
||||
const RING_SIZE := 128
|
||||
|
||||
@@ -76,11 +87,23 @@ func _init() -> void:
|
||||
_ring_seq[i] = -1
|
||||
|
||||
|
||||
# A reset starts a new authoritative epoch. Retained inputs/states describe
|
||||
# the old world and must never be compared to the new kickoff state.
|
||||
func begin_epoch() -> void:
|
||||
for i in RING_SIZE:
|
||||
_ring_seq[i] = -1
|
||||
_ring_entry[i] = null
|
||||
_has_recorded = false
|
||||
newest_recorded_seq = -1
|
||||
last_acknowledged_seq = 0
|
||||
resync_required = false
|
||||
|
||||
|
||||
# Stores a private copy of both action and state. Returns true when this
|
||||
# record crossed the unacknowledged-capacity boundary; the caller does not
|
||||
# need that return today, but it makes the eviction event observable rather
|
||||
# than silent when reconciliation starts applying corrections in Phase 4.3.
|
||||
func record(seq: int, action: ShipAction, state: NetBodyState) -> bool:
|
||||
func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: bool = false) -> bool:
|
||||
var overflowed_now := false
|
||||
if not _has_recorded or seq > newest_recorded_seq:
|
||||
if seq - last_acknowledged_seq > RING_SIZE:
|
||||
@@ -100,6 +123,37 @@ func record(seq: int, action: ShipAction, state: NetBodyState) -> bool:
|
||||
_ring_entry[idx] = {
|
||||
"action": action.copy(),
|
||||
"state": state.copy(),
|
||||
"contact_window": contact_window,
|
||||
"unsimulated": false,
|
||||
}
|
||||
return overflowed_now
|
||||
|
||||
|
||||
# Records a sequence that was issued and sent but never locally simulated —
|
||||
# an attack's skipped sequence numbers (see note 3 in this file's header).
|
||||
# It advances the same newest/overflow bookkeeping record() does, because the
|
||||
# sequence genuinely is outstanding and the server will genuinely acknowledge
|
||||
# it; only the post-step state is absent, because the client never computed
|
||||
# one. Deliberately carries the action anyway: it is what went on the wire, so
|
||||
# a caller diagnosing an acknowledgement still has the honest command, and
|
||||
# nothing here has to invent a state to keep the ring dense.
|
||||
func record_unsimulated(seq: int, action: ShipAction) -> bool:
|
||||
var overflowed_now := false
|
||||
if not _has_recorded or seq > newest_recorded_seq:
|
||||
if seq - last_acknowledged_seq > RING_SIZE:
|
||||
overflowed_now = not resync_required
|
||||
resync_required = true
|
||||
if overflowed_now:
|
||||
overflow_count += 1
|
||||
newest_recorded_seq = seq
|
||||
_has_recorded = true
|
||||
var idx := posmod(seq, RING_SIZE)
|
||||
_ring_seq[idx] = seq
|
||||
_ring_entry[idx] = {
|
||||
"action": action.copy(),
|
||||
"state": null,
|
||||
"contact_window": false,
|
||||
"unsimulated": true,
|
||||
}
|
||||
return overflowed_now
|
||||
|
||||
@@ -111,13 +165,68 @@ func get_prediction(seq: int) -> Dictionary:
|
||||
if _ring_seq[idx] != seq:
|
||||
return {}
|
||||
var entry: Dictionary = _ring_entry[idx]
|
||||
if bool(entry.get("unsimulated", false)):
|
||||
# No state to hand back — see note 3. Callers must check this flag
|
||||
# before touching "state"; it is null, not a zeroed NetBodyState,
|
||||
# specifically so a caller that forgets fails loudly instead of
|
||||
# silently comparing against the origin.
|
||||
return {
|
||||
"seq": seq,
|
||||
"action": (entry["action"] as ShipAction).copy(),
|
||||
"state": null,
|
||||
"contact_window": false,
|
||||
"unsimulated": true,
|
||||
}
|
||||
return {
|
||||
"seq": seq,
|
||||
"action": (entry["action"] as ShipAction).copy(),
|
||||
"state": (entry["state"] as NetBodyState).copy(),
|
||||
"contact_window": bool(entry.get("contact_window", false)),
|
||||
"unsimulated": false,
|
||||
}
|
||||
|
||||
|
||||
# Reconciliation changes the state paired with already-sent input, never the
|
||||
# input itself. This is deliberately a no-op for an absent/skipped sequence:
|
||||
# input-lead control permits sparse sequence numbers, so there is no honest
|
||||
# action to invent for such a slot.
|
||||
func overwrite_state(seq: int, state: NetBodyState) -> bool:
|
||||
var idx := posmod(seq, RING_SIZE)
|
||||
if _ring_seq[idx] != seq:
|
||||
return false
|
||||
var entry: Dictionary = _ring_entry[idx]
|
||||
if bool(entry.get("unsimulated", false)):
|
||||
# Writing a state here would manufacture a local prediction for a
|
||||
# sequence this client never simulated, which is exactly the fabricated
|
||||
# history §4.4 forbids. The slot stays stateless.
|
||||
return false
|
||||
entry["state"] = state.copy()
|
||||
return true
|
||||
|
||||
|
||||
func overwrite_state_range(from_seq: int, to_seq: int, state: NetBodyState) -> void:
|
||||
for seq in range(from_seq, to_seq + 1):
|
||||
overwrite_state(seq, state)
|
||||
|
||||
|
||||
# Carries an authoritative same-sequence correction through the retained
|
||||
# future. This is intentionally a transport operation, not a synthetic
|
||||
# physics replay: the live Jolt body has already advanced through the real
|
||||
# contact world, and a soft correction must not leave its later comparisons
|
||||
# describing the old trajectory.
|
||||
func rebase_state_range(from_seq: int, to_seq: int, position_delta: Vector3, rotation_delta: Quaternion, linear_velocity_delta: Vector3, angular_velocity_delta: Vector3) -> void:
|
||||
for seq in range(from_seq, to_seq + 1):
|
||||
var prediction := get_prediction(seq)
|
||||
if prediction.is_empty() or bool(prediction.get("unsimulated", false)):
|
||||
continue
|
||||
var state: NetBodyState = prediction["state"]
|
||||
state.position += position_delta
|
||||
state.rotation = (rotation_delta * state.rotation).normalized()
|
||||
state.linear_velocity += linear_velocity_delta
|
||||
state.angular_velocity += angular_velocity_delta
|
||||
overwrite_state(seq, state)
|
||||
|
||||
|
||||
# Produces comparison data only. Applying a snap, teleport, velocity delta,
|
||||
# or visual offset belongs to later Phase 4 tasks.
|
||||
func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
|
||||
@@ -148,6 +257,18 @@ func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
|
||||
if newest_recorded_seq - last_acknowledged_seq <= RING_SIZE:
|
||||
resync_required = false
|
||||
|
||||
if bool(prediction.get("unsimulated", false)):
|
||||
# Reaching this sequence at all proves the acknowledgement clock is
|
||||
# healthy — the entry is present and correctly tagged — so the
|
||||
# resync_required clear above still applies. There is simply nothing
|
||||
# to compare, because the client never simulated this sequence.
|
||||
return {
|
||||
"status": "unsimulated_gap",
|
||||
"seq": seq,
|
||||
"action": prediction["action"],
|
||||
"authoritative_state": authoritative.copy(),
|
||||
}
|
||||
|
||||
var predicted_state: NetBodyState = prediction["state"]
|
||||
var position_error := authoritative.position - predicted_state.position
|
||||
var rotation_error_radians := predicted_state.rotation.angle_to(authoritative.rotation)
|
||||
@@ -163,6 +284,7 @@ func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary:
|
||||
"rotation_error_degrees": rad_to_deg(rotation_error_radians),
|
||||
"linear_velocity_error": authoritative.linear_velocity - predicted_state.linear_velocity,
|
||||
"angular_velocity_error": authoritative.angular_velocity - predicted_state.angular_velocity,
|
||||
"contact_window": bool(prediction.get("contact_window", false)),
|
||||
}
|
||||
|
||||
|
||||
@@ -170,4 +292,3 @@ func _missing_status(seq: int) -> String:
|
||||
if _has_recorded and seq <= newest_recorded_seq - RING_SIZE:
|
||||
return "missing_evicted"
|
||||
return "missing_not_recorded"
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://goarfpbthyf6
|
||||
@@ -0,0 +1 @@
|
||||
uid://b8300uu0s6jqt
|
||||
@@ -0,0 +1 @@
|
||||
uid://bk81de78uwut
|
||||
@@ -0,0 +1 @@
|
||||
uid://bc1r0cqvtbqec
|
||||
@@ -0,0 +1 @@
|
||||
uid://bbb72h1ue0hdp
|
||||
@@ -27,6 +27,23 @@ func _ready() -> void:
|
||||
func _unhandled_input(event: InputEvent) -> void:
|
||||
if event.is_action_pressed("toggle_net_overlay") and _label:
|
||||
_label.visible = not _label.visible
|
||||
return
|
||||
if not _label or not _label.visible or not (event is InputEventKey) or not event.pressed or event.echo:
|
||||
return
|
||||
var game := get_tree().get_first_node_in_group("game")
|
||||
if game == null or not game.has_method("adjust_prediction_tuning"):
|
||||
return
|
||||
# Client-only live tuning: [/] threshold, -/= visual decay, ,/. visual
|
||||
# offset, P present-time A/B. Deliberately no project input actions: these
|
||||
# diagnostics never enter ShipAction or server/controller code.
|
||||
match event.keycode:
|
||||
KEY_BRACKETLEFT: game.adjust_prediction_tuning(-0.1)
|
||||
KEY_BRACKETRIGHT: game.adjust_prediction_tuning(0.1)
|
||||
KEY_MINUS: game.adjust_prediction_tuning(0.0, -0.01)
|
||||
KEY_EQUAL: game.adjust_prediction_tuning(0.0, 0.01)
|
||||
KEY_COMMA: game.adjust_prediction_tuning(0.0, 0.0, -0.05)
|
||||
KEY_PERIOD: game.adjust_prediction_tuning(0.0, 0.0, 0.05)
|
||||
KEY_P: game.adjust_prediction_tuning(0.0, 0.0, 0.0, true)
|
||||
|
||||
|
||||
func _process(_delta: float) -> void:
|
||||
@@ -51,10 +68,15 @@ func _process(_delta: float) -> void:
|
||||
if game and game.has_method("get_net_debug_stats"):
|
||||
stats = game.get_net_debug_stats()
|
||||
var stalled_suffix := " STALLED" if stats.get("server_stalled", false) else ""
|
||||
_label.text = "NET: client RTT %.1fms jitter %.1fms offset %.1fms\nbuf depth %s lead %s loss %.1f%% snap age %.1fms%s\nout %s in %s" % [
|
||||
var prediction: Dictionary = stats.get("prediction", {})
|
||||
_label.text = "NET: client RTT %.1fms jitter %.1fms offset %.1fms\nbuf depth %s (target %s) lead %s loss %.1f%% snap age %.1fms%s\npred pos p50/p95/p99 %.3f / %.3f / %.3fm\npred rot p50/p95/p99 %.2f / %.2f / %.2fdeg snaps %.2f/min\nremote residual p99 %.3fm / %.2fdeg A/B present=%s\ntune [/] pos %.2f -/= decay ,/. offset %.2f P toggle\nout %s in %s" % [
|
||||
NetworkManager.rtt_ms, NetworkManager.jitter_ms, NetworkManager.clock_offset_ms,
|
||||
str(stats.get("input_buffer_depth", -1)), str(stats.get("input_lead", "-")),
|
||||
str(stats.get("input_buffer_depth", -1)), str(stats.get("input_target_depth", "-")), str(stats.get("input_lead", "-")),
|
||||
stats.get("snapshot_loss_pct", 0.0), stats.get("snapshot_age_ms", 0.0), stalled_suffix,
|
||||
prediction.get("position_error_p50", 0.0), prediction.get("position_error_p95", 0.0), prediction.get("position_error_p99", 0.0),
|
||||
prediction.get("rotation_error_p50", 0.0), prediction.get("rotation_error_p95", 0.0), prediction.get("rotation_error_p99", 0.0), prediction.get("hard_snap_rate_per_min", 0.0),
|
||||
stats.get("remote_residual_position_p99", 0.0), stats.get("remote_residual_rotation_p99", 0.0), str(game.remote_visual_present_time_enabled if game else false),
|
||||
prediction.get("position_threshold", 0.0), prediction.get("max_visual_offset", 0.0),
|
||||
_format_kbps(MatchSim.get_bytes_sent_per_sec()), _format_kbps(MatchSim.get_bytes_received_per_sec()),
|
||||
]
|
||||
else:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cn2rdmwfo7phi
|
||||
@@ -39,12 +39,16 @@ static func to_tick(server_time_ms: float) -> float:
|
||||
# across the arena. Clears buffered history on a reset so a stale
|
||||
# pre-reset sample can never bracket a post-reset one.
|
||||
func add_sample(server_tick: int, state: NetBodyState, sample_reset_gen: int) -> bool:
|
||||
# Never let a stale unreliable snapshot rewrite the epoch. The previous
|
||||
# ordering cleared samples on its reset byte before checking tick order,
|
||||
# so a delayed pre-reset packet could alternately flip generations and
|
||||
# repeatedly cancel an active local ball handoff.
|
||||
if not _samples.is_empty() and server_tick <= _samples.back()["tick"]:
|
||||
return false
|
||||
var is_reset := reset_gen != -1 and sample_reset_gen != reset_gen
|
||||
if is_reset:
|
||||
_samples.clear()
|
||||
reset_gen = sample_reset_gen
|
||||
if not _samples.is_empty() and server_tick <= _samples.back()["tick"]:
|
||||
return is_reset # stale/duplicate (unreliable_ordered should already prevent this, but don't trust it blindly)
|
||||
_samples.append({"tick": server_tick, "state": state})
|
||||
if _samples.size() > MAX_SAMPLES:
|
||||
_samples.pop_front()
|
||||
@@ -55,6 +59,10 @@ func has_samples() -> bool:
|
||||
return not _samples.is_empty()
|
||||
|
||||
|
||||
func accepts_tick(server_tick: int) -> bool:
|
||||
return _samples.is_empty() or server_tick > int(_samples.back()["tick"])
|
||||
|
||||
|
||||
func latest() -> NetBodyState:
|
||||
return _samples.back()["state"] if not _samples.is_empty() else null
|
||||
|
||||
@@ -103,7 +111,11 @@ func _extrapolate(newest: Dictionary, target_tick: float) -> NetBodyState:
|
||||
var clamped_ms := clampf(ms_ahead, 0.0, MAX_EXTRAPOLATION_MS)
|
||||
var out := NetBodyState.new()
|
||||
out.position = state.position + state.linear_velocity * (clamped_ms / 1000.0)
|
||||
out.rotation = state.rotation
|
||||
var angular_speed := state.angular_velocity.length()
|
||||
if angular_speed > 0.00001:
|
||||
out.rotation = (Quaternion(state.angular_velocity / angular_speed, angular_speed * (clamped_ms / 1000.0)) * state.rotation).normalized()
|
||||
else:
|
||||
out.rotation = state.rotation
|
||||
out.linear_velocity = state.linear_velocity
|
||||
out.angular_velocity = state.angular_velocity
|
||||
out.frozen = state.frozen
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cgb1vcapxami7
|
||||
@@ -0,0 +1,273 @@
|
||||
extends RefCounted
|
||||
|
||||
# Local-ship reconciliation policy (multiplayer-todo.md §4.4). Kept out of
|
||||
# NetworkedMatch so the decision table is pure-testable; the imperative half
|
||||
# only writes Ship's existing Jolt-safe queued correction hooks.
|
||||
|
||||
const DEFAULT_HARD_POSITION_ERROR := 2.0
|
||||
const DEFAULT_HARD_ROTATION_ERROR_DEGREES := 60.0
|
||||
const DEFAULT_MAX_VISUAL_OFFSET := 0.4
|
||||
const METRIC_SAMPLE_CAPACITY := 3600 # one minute at the 60Hz snapshot rate
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd")
|
||||
|
||||
var _last_reset_gen := -1 # first snapshot establishes baseline, never resets
|
||||
var _position_errors: Array[float] = []
|
||||
var _rotation_errors: Array[float] = []
|
||||
var _free_flight_position_errors: Array[float] = []
|
||||
var _free_flight_rotation_errors: Array[float] = []
|
||||
var _visual_correction_errors: Array[float] = []
|
||||
var _free_flight_visual_correction_errors: Array[float] = []
|
||||
var _hard_snap_count := 0
|
||||
var _decision_count := 0
|
||||
var _resync_until_seq := -1
|
||||
var _metrics_started_ms := -1
|
||||
var hard_position_error := DEFAULT_HARD_POSITION_ERROR
|
||||
var hard_rotation_error_degrees := DEFAULT_HARD_ROTATION_ERROR_DEGREES
|
||||
var max_visual_offset := DEFAULT_MAX_VISUAL_OFFSET
|
||||
var _hard_snap_reasons := {}
|
||||
var _hard_snap_cohorts := {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
|
||||
var _cohort_counts := {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
|
||||
|
||||
|
||||
static func decide(comparison: Dictionary, local_frozen: bool, reset_changed: bool, position_threshold: float = DEFAULT_HARD_POSITION_ERROR, rotation_threshold_degrees: float = DEFAULT_HARD_ROTATION_ERROR_DEGREES) -> Dictionary:
|
||||
var authoritative: NetBodyState = comparison.get("authoritative_state", null)
|
||||
if reset_changed:
|
||||
return {"mode": "hard", "reason": "reset_gen"}
|
||||
# An attack's skipped sequence is issued, sent, and acknowledged, but never
|
||||
# locally simulated — there is no predicted state to compare and nothing is
|
||||
# wrong. It is not history loss and must not teleport the ship or arm resync
|
||||
# suppression: the lead controller produces these during ordinary play, and
|
||||
# treating them as missing history cost several unnecessary hard snaps a
|
||||
# minute. Skip the acknowledgement; the next simulated sequence (at most a
|
||||
# tick or two later, since the server consumes one per tick) reconciles
|
||||
# normally against real data.
|
||||
if comparison.get("status", "") == "unsimulated_gap":
|
||||
return {"mode": "skip", "reason": "unsimulated_gap"}
|
||||
if comparison.get("status", "missing_not_recorded") != "matched":
|
||||
return {"mode": "hard", "reason": comparison.get("status", "missing")}
|
||||
if authoritative == null or authoritative.frozen != local_frozen:
|
||||
return {"mode": "hard", "reason": "frozen_mismatch"}
|
||||
if float(comparison["position_error_magnitude"]) > position_threshold:
|
||||
return {"mode": "hard", "reason": "position_error"}
|
||||
if float(comparison["rotation_error_degrees"]) > rotation_threshold_degrees:
|
||||
return {"mode": "hard", "reason": "rotation_error"}
|
||||
return {"mode": "soft", "reason": "within_thresholds"}
|
||||
|
||||
|
||||
static func soft_corrected_transform(current_transform: Transform3D, comparison: Dictionary) -> Transform3D:
|
||||
var authoritative: NetBodyState = comparison["authoritative_state"]
|
||||
var predicted: NetBodyState = comparison["predicted_state"]
|
||||
var position_delta: Vector3 = authoritative.position - predicted.position
|
||||
var rotation_delta := Basis(authoritative.rotation.normalized()) * Basis(predicted.rotation.normalized()).inverse()
|
||||
return Transform3D(
|
||||
(rotation_delta * current_transform.basis).orthonormalized(),
|
||||
current_transform.origin + position_delta
|
||||
)
|
||||
|
||||
|
||||
func reconcile(comparison: Dictionary, ship: Ship, reset_gen: int, current_seq: int, history: LocalPredictionHistory) -> Dictionary:
|
||||
var reset_changed := _last_reset_gen != -1 and reset_gen != _last_reset_gen
|
||||
_last_reset_gen = reset_gen
|
||||
var comparison_seq := int(comparison.get("seq", -1))
|
||||
# A reset is an epoch boundary, never ordinary stale traffic. It must
|
||||
# preempt an outstanding missing-history suppression or the first reset
|
||||
# snapshot could be discarded and every later snapshot share its generation.
|
||||
if reset_changed:
|
||||
_resync_until_seq = -1
|
||||
var reset_decision := decide(comparison, ship.freeze, true, hard_position_error, hard_rotation_error_degrees)
|
||||
_record_metrics(comparison, reset_decision)
|
||||
var reset_authority: NetBodyState = comparison.get("authoritative_state", null)
|
||||
if reset_authority != null:
|
||||
ship.queue_teleport_with_velocity(Transform3D(Basis(reset_authority.rotation), reset_authority.position), reset_authority.linear_velocity, reset_authority.angular_velocity)
|
||||
ship.net_visual_offset = Vector3.ZERO
|
||||
ship.net_visual_rotation_offset = Quaternion.IDENTITY
|
||||
if is_instance_valid(ship.visual):
|
||||
ship.visual.position = Vector3.ZERO
|
||||
ship.visual.basis = Basis.IDENTITY
|
||||
_resync_until_seq = current_seq + 1
|
||||
return reset_decision
|
||||
if _resync_until_seq >= 0:
|
||||
if comparison.get("status", "") == "matched" and comparison_seq >= _resync_until_seq:
|
||||
_resync_until_seq = -1
|
||||
else:
|
||||
return {"mode": "suppressed", "reason": "awaiting_resync"}
|
||||
var decision := decide(comparison, ship.freeze, false, hard_position_error, hard_rotation_error_degrees)
|
||||
_record_metrics(comparison, decision)
|
||||
if decision["mode"] == "skip":
|
||||
# Deliberately before the authority write below: a skipped acknowledgement
|
||||
# leaves the body, the visual offset and _resync_until_seq exactly as they
|
||||
# were. Nothing about this sequence is unhealthy, so nothing is corrected
|
||||
# and nothing is suppressed.
|
||||
return decision
|
||||
var authoritative: NetBodyState = comparison.get("authoritative_state", null)
|
||||
if authoritative == null:
|
||||
return decision
|
||||
if comparison.get("status", "") == "matched" and decision["reason"] != "reset_gen":
|
||||
# Transport the same-sequence authority error through current Jolt state
|
||||
# and retained predictions. This deliberately avoids fake single-body
|
||||
# replay, which cannot reproduce contact impulses/friction.
|
||||
var predicted: NetBodyState = comparison["predicted_state"]
|
||||
var position_delta: Vector3 = comparison["position_error"]
|
||||
var velocity_error: Vector3 = comparison["linear_velocity_error"]
|
||||
var angular_velocity_error: Vector3 = comparison["angular_velocity_error"]
|
||||
var rotation_delta := (authoritative.rotation.normalized() * predicted.rotation.normalized().inverse()).normalized()
|
||||
history.overwrite_state(int(comparison["seq"]), authoritative)
|
||||
history.rebase_state_range(int(comparison["seq"]) + 1, current_seq, position_delta, rotation_delta, velocity_error, angular_velocity_error)
|
||||
var old_basis := ship.global_transform.basis
|
||||
var corrected_transform := soft_corrected_transform(ship.global_transform, comparison)
|
||||
# Apply both velocity deltas to the live body atomically with pose. The
|
||||
# same deltas are transported through retained history above.
|
||||
ship.queue_teleport_with_velocity(corrected_transform, ship.linear_velocity + velocity_error, ship.angular_velocity + angular_velocity_error)
|
||||
var position_error: Vector3 = comparison["position_error"]
|
||||
if decision["mode"] == "soft":
|
||||
ship.net_visual_offset = (ship.global_transform.basis.inverse() * -position_error).limit_length(max_visual_offset)
|
||||
# The body rotates in world space. Convert the inverse correction to
|
||||
# the child visual's local basis so its global orientation is preserved
|
||||
# through the physical correction (B_old^-1 Δ^-1 B_old).
|
||||
var local_visual_delta: Basis = old_basis.inverse() * Basis(rotation_delta.inverse()) * old_basis
|
||||
ship.net_visual_rotation_offset = local_visual_delta.get_rotation_quaternion() * ship.net_visual_rotation_offset
|
||||
else:
|
||||
ship.net_visual_offset = Vector3.ZERO
|
||||
ship.net_visual_rotation_offset = Quaternion.IDENTITY
|
||||
if is_instance_valid(ship.visual):
|
||||
ship.visual.position = Vector3.ZERO
|
||||
ship.visual.basis = Basis.IDENTITY
|
||||
else:
|
||||
# Reset/missing state has no trustworthy delta. Place authority once;
|
||||
# callers must wait for a new matched history entry before correction.
|
||||
ship.queue_teleport_with_velocity(Transform3D(Basis(authoritative.rotation), authoritative.position), authoritative.linear_velocity, authoritative.angular_velocity)
|
||||
ship.net_visual_offset = Vector3.ZERO
|
||||
ship.net_visual_rotation_offset = Quaternion.IDENTITY
|
||||
if is_instance_valid(ship.visual):
|
||||
ship.visual.position = Vector3.ZERO
|
||||
ship.visual.basis = Basis.IDENTITY
|
||||
# Retain no fabricated future. Once local input history contains a
|
||||
# newly acknowledged sequence, normal delta reconciliation resumes.
|
||||
_resync_until_seq = current_seq + 1
|
||||
return decision
|
||||
|
||||
|
||||
func get_metrics() -> Dictionary:
|
||||
return {
|
||||
"sample_count": _position_errors.size(),
|
||||
"position_error_p50": _percentile(0.50),
|
||||
"position_error_p95": _percentile(0.95),
|
||||
"position_error_p99": _percentile(0.99),
|
||||
"rotation_error_p50": _rotation_percentile(0.50),
|
||||
"rotation_error_p95": _rotation_percentile(0.95),
|
||||
"rotation_error_p99": _rotation_percentile(0.99),
|
||||
"free_flight_sample_count": _free_flight_position_errors.size(),
|
||||
"free_flight_position_error_p95": _percentile_from(_free_flight_position_errors, 0.95),
|
||||
"free_flight_position_error_p99": _percentile_from(_free_flight_position_errors, 0.99),
|
||||
"free_flight_rotation_error_p95": _percentile_from(_free_flight_rotation_errors, 0.95),
|
||||
"free_flight_rotation_error_p99": _percentile_from(_free_flight_rotation_errors, 0.99),
|
||||
"visual_correction_p95": _percentile_from(_visual_correction_errors, 0.95),
|
||||
"visual_correction_p99": _percentile_from(_visual_correction_errors, 0.99),
|
||||
"free_flight_visual_correction_p95": _percentile_from(_free_flight_visual_correction_errors, 0.95),
|
||||
"free_flight_visual_correction_p99": _percentile_from(_free_flight_visual_correction_errors, 0.99),
|
||||
"hard_snap_count": _hard_snap_count,
|
||||
"hard_snap_rate_per_min": _hard_snap_rate_per_min(),
|
||||
"hard_snap_reasons": _hard_snap_reasons.duplicate(),
|
||||
"hard_snap_cohorts": _hard_snap_cohorts.duplicate(),
|
||||
"cohorts": _cohort_counts.duplicate(),
|
||||
"position_threshold": hard_position_error,
|
||||
"rotation_threshold_degrees": hard_rotation_error_degrees,
|
||||
"max_visual_offset": max_visual_offset,
|
||||
}
|
||||
|
||||
|
||||
func clear_metrics() -> void:
|
||||
_position_errors.clear()
|
||||
_rotation_errors.clear()
|
||||
_free_flight_position_errors.clear()
|
||||
_free_flight_rotation_errors.clear()
|
||||
_visual_correction_errors.clear()
|
||||
_free_flight_visual_correction_errors.clear()
|
||||
_hard_snap_count = 0
|
||||
_decision_count = 0
|
||||
_resync_until_seq = -1
|
||||
_metrics_started_ms = -1
|
||||
_hard_snap_reasons.clear()
|
||||
_hard_snap_cohorts = {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
|
||||
_cohort_counts = {"free_flight": 0, "contact": 0, "reset": 0, "resync": 0, "unsimulated": 0}
|
||||
|
||||
|
||||
func _record_metrics(comparison: Dictionary, decision: Dictionary) -> void:
|
||||
if _metrics_started_ms < 0:
|
||||
_metrics_started_ms = Time.get_ticks_msec()
|
||||
_decision_count += 1
|
||||
var cohort := _cohort_for(comparison, decision)
|
||||
if decision["mode"] == "hard":
|
||||
_hard_snap_count += 1
|
||||
var reason := str(decision.get("reason", "unknown"))
|
||||
_hard_snap_reasons[reason] = int(_hard_snap_reasons.get(reason, 0)) + 1
|
||||
_hard_snap_cohorts[cohort] = int(_hard_snap_cohorts.get(cohort, 0)) + 1
|
||||
_cohort_counts[cohort] = int(_cohort_counts.get(cohort, 0)) + 1
|
||||
if comparison.get("status", "") == "matched":
|
||||
# Only same-sequence predictions are quality samples. Recovery events
|
||||
# still count in their own cohorts/reason ledger, but must not distort
|
||||
# p95/p99 with an error that cannot honestly be measured.
|
||||
_position_errors.append(float(comparison["position_error_magnitude"]))
|
||||
_rotation_errors.append(float(comparison.get("rotation_error_degrees", 0.0)))
|
||||
if cohort == "free_flight":
|
||||
_free_flight_position_errors.append(float(comparison["position_error_magnitude"]))
|
||||
_free_flight_rotation_errors.append(float(comparison.get("rotation_error_degrees", 0.0)))
|
||||
# The visual offset hides at most max_visual_offset of a soft correction.
|
||||
# Record the exposed remainder, never the capped hidden component; hard
|
||||
# corrections are independently gated by their cohort count above.
|
||||
var visual_error := maxf(0.0, float(comparison.get("position_error_magnitude", 0.0)) - max_visual_offset) if decision["mode"] == "soft" else 0.0
|
||||
_visual_correction_errors.append(visual_error)
|
||||
if cohort == "free_flight":
|
||||
_free_flight_visual_correction_errors.append(visual_error)
|
||||
if _position_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_position_errors.pop_front()
|
||||
if _rotation_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_rotation_errors.pop_front()
|
||||
if _free_flight_position_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_free_flight_position_errors.pop_front()
|
||||
if _free_flight_rotation_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_free_flight_rotation_errors.pop_front()
|
||||
if _visual_correction_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_visual_correction_errors.pop_front()
|
||||
if _free_flight_visual_correction_errors.size() > METRIC_SAMPLE_CAPACITY:
|
||||
_free_flight_visual_correction_errors.pop_front()
|
||||
|
||||
|
||||
func _cohort_for(comparison: Dictionary, decision: Dictionary) -> String:
|
||||
if decision.get("reason", "") == "reset_gen":
|
||||
return "reset"
|
||||
if decision.get("reason", "") == "unsimulated_gap":
|
||||
# Its own cohort, not free_flight: these carry no error sample, and
|
||||
# folding them into a quality cohort would silently inflate its count
|
||||
# with rows that contributed no measurement.
|
||||
return "unsimulated"
|
||||
if decision.get("reason", "").begins_with("missing") or _resync_until_seq >= 0:
|
||||
return "resync"
|
||||
if comparison.get("contact_window", false):
|
||||
return "contact"
|
||||
return "free_flight"
|
||||
|
||||
|
||||
func _hard_snap_rate_per_min() -> float:
|
||||
if _metrics_started_ms < 0:
|
||||
return 0.0
|
||||
var elapsed_seconds := maxf(float(Time.get_ticks_msec() - _metrics_started_ms) / 1000.0, 0.001)
|
||||
return float(_hard_snap_count) * 60.0 / elapsed_seconds
|
||||
|
||||
|
||||
func _percentile(fraction: float) -> float:
|
||||
return _percentile_from(_position_errors, fraction)
|
||||
|
||||
|
||||
func _percentile_from(samples: Array[float], fraction: float) -> float:
|
||||
if samples.is_empty():
|
||||
return 0.0
|
||||
var sorted := samples.duplicate()
|
||||
sorted.sort()
|
||||
var index := clampi(roundi((sorted.size() - 1) * fraction), 0, sorted.size() - 1)
|
||||
return sorted[index]
|
||||
|
||||
|
||||
func _rotation_percentile(fraction: float) -> float:
|
||||
return _percentile_from(_rotation_errors, fraction)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c0qjwh4af8pbn
|
||||
@@ -0,0 +1 @@
|
||||
uid://cboh4k3bka8vu
|
||||
@@ -0,0 +1 @@
|
||||
uid://bd1g4evti23ab
|
||||
+555
-174
@@ -1,16 +1,10 @@
|
||||
class_name NetworkedMatch
|
||||
extends GameMode
|
||||
|
||||
# Phase 2: server-authoritative simulation, dumb client (multiplayer-todo.md
|
||||
# §7 Phase 2). The server runs the real physics for every ship — via
|
||||
# RLShipController, fed by each connected player's forwarded input — and
|
||||
# the ball, and broadcasts NetCodec snapshots at 60Hz. The client renders
|
||||
# everything, including its own ship, from the interpolation buffer; there
|
||||
# is no local prediction yet (that's Phase 4), so every body on the client
|
||||
# is FREEZE_MODE_KINEMATIC and driven entirely by incoming snapshots.
|
||||
# Tasks 4.1/4.2 add the seq-tagged recording and comparison plumbing that
|
||||
# Phase 4 will need (LocalPredictionHistory below), but deliberately stop
|
||||
# short of unfreezing or locally simulating anything — that is task 4.3.
|
||||
# Server-authoritative simulation with client-side local-ship prediction.
|
||||
# The server simulates every slot via RLShipController and broadcasts 60Hz
|
||||
# snapshots. A client simulates exactly its own unfrozen slot with one real
|
||||
# controller; every remote slot and the ball stay frozen/interpolated.
|
||||
#
|
||||
# No HUD/Arena child in networked_match.tscn — both are built in code, once
|
||||
# the arena is actually known (the server picks one; the client learns it
|
||||
@@ -32,7 +26,11 @@ const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
const NetInterpolator = preload("res://scripts/net_interpolator.gd")
|
||||
const InputJitterBuffer = preload("res://scripts/input_jitter_buffer.gd")
|
||||
const InputLeadController = preload("res://scripts/input_lead_controller.gd")
|
||||
const AdaptiveInputDepthController = preload("res://scripts/adaptive_input_depth_controller.gd")
|
||||
const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd")
|
||||
const NetShipPredictor = preload("res://scripts/net_ship_predictor.gd")
|
||||
const LocalInputTimeline = preload("res://scripts/local_input_timeline.gd")
|
||||
const LocalNetShipController = preload("res://scripts/local_net_ship_controller.gd")
|
||||
const HUD_SCENE = preload("res://scenes/HUD.tscn")
|
||||
|
||||
# Minimum plausible interpolation delay even on a same-machine/LAN link —
|
||||
@@ -43,6 +41,24 @@ const HUD_SCENE = preload("res://scenes/HUD.tscn")
|
||||
const INTERP_DELAY_MIN_MS := 25.0
|
||||
const INTERP_DELAY_MAX_MS := 200.0
|
||||
const SNAPSHOT_INTERVAL_MS := 1000.0 / 60.0
|
||||
const STARVATION_ADVERTISEMENT_TICKS := 4 # ignores expected connection/startup transit
|
||||
# Consecutive seq-guard rejections before the guard resyncs to the client's
|
||||
# epoch instead of latching shut forever. Well above any honest transient
|
||||
# (a legitimate client never trips the bound at all) and far below the
|
||||
# hundreds of rejections an unrecoverable run produced.
|
||||
const SEQ_REJECT_RESYNC_LIMIT := 10
|
||||
|
||||
# Phase 4.6: a client only predicts the ball immediately after its own ship
|
||||
# touches it. Authority remains buffered throughout the short window.
|
||||
@export var local_ball_prediction_enabled := true
|
||||
# The present-time path passed the two-bot A/B residual gate (<0.3m/<5deg).
|
||||
# Keep delayed interpolation available through the runtime debug toggle for
|
||||
# comparison and regression diagnosis.
|
||||
@export var remote_visual_present_time_enabled := true
|
||||
const BALL_PREDICTION_MAX_MS := 250
|
||||
const BALL_HARD_SNAP_DISTANCE := 3.0
|
||||
const BALL_VISUAL_BLEND_MS := 150
|
||||
const BALL_RECONTACT_COOLDOWN_MS := BALL_PREDICTION_MAX_MS + BALL_VISUAL_BLEND_MS
|
||||
|
||||
# NetInterpolator.to_tick() assumes Time.get_ticks_msec() == physics_frame *
|
||||
# TICK_MS on the SERVER, i.e. that physics frame 0 happened at process-start
|
||||
@@ -79,49 +95,80 @@ class SlotInfo:
|
||||
var ship: Ship
|
||||
var controller: RLShipController # server only
|
||||
var jitter_buffer := InputJitterBuffer.new() # server only (§3.2)
|
||||
# Server only. Consecutive packets rejected by the seq-range guard, reset by
|
||||
# any accepted one. The guard's bound is derived from a value only an
|
||||
# ACCEPTED packet can advance, so without an escape hatch it latches shut
|
||||
# permanently — see the guard's own comment in _on_input_received.
|
||||
var consecutive_seq_rejects := 0
|
||||
var last_client_send_ms := 0 # server only: echoed back per-peer next snapshot (§2.4)
|
||||
var interpolator := NetInterpolator.new() # client only
|
||||
var visual_smoother_reset := true
|
||||
var visual_position_offset := Vector3.ZERO
|
||||
var visual_rotation_offset := Quaternion.IDENTITY
|
||||
|
||||
|
||||
var _slots: Array[SlotInfo] = []
|
||||
var _my_slot: SlotInfo = null # client only
|
||||
var _local_prediction_ready := false # client waits for its first authoritative pose
|
||||
var _ball_interpolator := NetInterpolator.new() # client only
|
||||
# client only: reads local input each tick to forward. Normally a
|
||||
# PlayerShipController that's deliberately never added to a Ship/the tree —
|
||||
# get_action() only touches the global Input singleton, so it needs no
|
||||
# scene context. --test-bot mode (task 3.6) swaps this for a real
|
||||
# AIShipController once the client's own ship is known (see
|
||||
# _on_match_config_received) — unlike PlayerShipController, AIShipController
|
||||
# DOES need real scene context (get_parent() as Ship, plus ball/teammate/
|
||||
# opponent discovery via groups), so it's parented onto _my_slot.ship via
|
||||
# Ship.set_controller() rather than left floating.
|
||||
var _local_input_sampler: ShipController = PlayerShipController.new()
|
||||
var _ball_shadow_state: NetBodyState = null # newest authority for the frozen remote shadow
|
||||
var _local_ball_proxy: Ball = null # client-only dynamic collision/prediction body
|
||||
var _ball_prediction_until_ms := -1
|
||||
var _ball_recontact_cooldown_until_ms := -1
|
||||
var _ball_prediction_contact_count := 0
|
||||
var _ball_visual_blend_from := Transform3D.IDENTITY
|
||||
var _ball_visual_blend_started_ms := -1
|
||||
var _last_ball_prediction_error := 0.0
|
||||
var _ball_contact_frame := -1
|
||||
var _ball_reveal_frame := -1
|
||||
var _ball_blend_complete_count := 0
|
||||
var _ball_blend_started_count := 0
|
||||
var _ball_blend_max_duration_ms := 0
|
||||
var _ball_hard_handoff_count := 0
|
||||
var _ball_prediction_window_end_count := 0
|
||||
var _ball_prediction_missing_shadow_count := 0
|
||||
var _ball_prediction_reset_cancel_count := 0
|
||||
var _ball_reset_trace: Array[String] = []
|
||||
var _ball_proxy_contact_position := Vector3.ZERO
|
||||
var _ball_proxy_moved_before_authority := false
|
||||
var _ball_proxy_moved_before_authority_count := 0
|
||||
var _ball_shadow_position_on_contact := Vector3.ZERO
|
||||
var _ball_authority_changed_since_contact := false
|
||||
var _remote_position_residuals: Array[float] = []
|
||||
var _remote_rotation_residuals: Array[float] = []
|
||||
var _ball_visual_smoother_reset := true
|
||||
var _ball_visual_position_offset := Vector3.ZERO
|
||||
var _ball_visual_rotation_offset := Quaternion.IDENTITY
|
||||
const REMOTE_VISUAL_SMOOTH_RATE := 14.0
|
||||
const REMOTE_VISUAL_HARD_DISTANCE := 2.0
|
||||
const REMOTE_VISUAL_MAX_OFFSET := 0.4
|
||||
const REMOTE_VISUAL_MAX_ROTATION_DEGREES := 15.0
|
||||
const REMOTE_METRIC_CAPACITY := 3600
|
||||
# --test-bot (task 3.6): CI/regression driver mode, an automated player via
|
||||
# the existing AIShipController instead of a human — see CLAUDE.md's testing
|
||||
# section. Read once in _ready(), consumed in _on_match_config_received.
|
||||
var _test_bot_model_path := "" # client only; non-empty means --test-bot mode is active
|
||||
var _local_input_timeline: LocalInputTimeline = null
|
||||
var _local_net_controller: LocalNetShipController = null
|
||||
var _input_seq := 0 # client only
|
||||
# Redundancy (§3.1): newest-first, capped at NetCodec.MAX_REDUNDANCY, so a
|
||||
# 3-packet burst loss still recovers every tick's action via a later
|
||||
# packet's history. Client only.
|
||||
var _input_history: Array[ShipAction] = []
|
||||
var _local_prediction_history := LocalPredictionHistory.new() # client only; 128-entry seq-tagged history (§4.3)
|
||||
# Latest raw result from LocalPredictionHistory.compare_authoritative(). This
|
||||
# pass records and compares only; Phase 4.3 will consume it to choose and
|
||||
# apply the actual reconciliation correction.
|
||||
#
|
||||
# Read its error fields with the caveat documented on
|
||||
# _local_ship_prediction_state(): until task 4.3 unfreezes and locally
|
||||
# simulates the local ship, the "predicted" side of every comparison is an
|
||||
# interpolated past-snapshot pose, not a forward simulation. The
|
||||
# position_error / rotation_error_radians / *_velocity_error numbers
|
||||
# therefore measure interpolation-vs-authoritative drift, and are NOT
|
||||
# prediction error. Expect them to be small and largely uninformative, and
|
||||
# do not calibrate any snap/blend threshold against them yet.
|
||||
var _local_ship_predictor := NetShipPredictor.new() # client only; reconciliation policy (§4.4)
|
||||
# Latest raw comparison retained for diagnostics. NetShipPredictor consumes
|
||||
# the same result immediately to apply the reconciliation decision.
|
||||
var _last_local_prediction_comparison: Dictionary = {}
|
||||
var _action_marker_samples := 0
|
||||
var _action_marker_mismatches := 0
|
||||
var _pending_local_reconciliation: Dictionary = {} # newest snapshot only; consumed once per physics tick
|
||||
var _last_local_reset_gen := -1
|
||||
var _last_received_snapshot_tick := 0 # client only: echoed back as ack_snapshot_tick
|
||||
var _input_lead_controller := InputLeadController.new() # client only (§3.3)
|
||||
var _last_known_input_buffer_depth := -1 # client only: -1 = no snapshot with this field yet
|
||||
var _has_received_healthy_buffer_depth := false
|
||||
var _adaptive_input_depth := AdaptiveInputDepthController.new()
|
||||
# Loss estimate (task 3.7's debug overlay), client only: snapshots go out
|
||||
# at a steady one-tick cadence, so a server_tick that jumps by more than 1
|
||||
# since the last received one is direct evidence of a dropped or reordered
|
||||
@@ -180,6 +227,14 @@ func _ready() -> void:
|
||||
_test_bot_model_path = "res://bots/promoted/medium.json"
|
||||
elif arg.begins_with("--test-bot-model="):
|
||||
_test_bot_model_path = arg.get_slice("=", 1)
|
||||
elif arg == "--remote-present-time":
|
||||
# Explicit A/B opt-in remains useful even though present time is
|
||||
# now the default; it also makes test intent visible in logs.
|
||||
remote_visual_present_time_enabled = true
|
||||
elif arg == "--remote-delayed":
|
||||
# A/B control: preserves the former delayed-interpolation render
|
||||
# path exactly, with no present-time residual offset applied.
|
||||
remote_visual_present_time_enabled = false
|
||||
MatchSim.match_config_received.connect(_on_match_config_received)
|
||||
MatchSim.snapshot_received.connect(_on_snapshot_received)
|
||||
MatchSim.score_update_received.connect(_on_score_update_received)
|
||||
@@ -205,18 +260,8 @@ func _owns_world_simulation() -> bool:
|
||||
return multiplayer.is_server()
|
||||
|
||||
|
||||
# _local_input_sampler is a plain Node (PlayerShipController extends
|
||||
# ShipController extends Node) that's deliberately never added to the tree
|
||||
# — dropping the last reference to it does not free it. An adversarial
|
||||
# review traced the "3 resources still in use at exit" warning on every
|
||||
# Phase 2 test run directly to this: --verbose named the leaked script
|
||||
# chain (player_ship_controller.gd, ship_controller.gd, ship_action.gd)
|
||||
# exactly, and adding this cleanup made the warning disappear. Runs
|
||||
# unconditionally (not just client-side) since the field is initialized
|
||||
# unconditionally too, despite its "client only" comment.
|
||||
func _exit_tree() -> void:
|
||||
if is_instance_valid(_local_input_sampler):
|
||||
_local_input_sampler.free()
|
||||
pass
|
||||
|
||||
|
||||
# ============================================================
|
||||
@@ -301,10 +346,36 @@ func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
|
||||
# packet-rate limiter (§3.4) already allows. Falls back to seq
|
||||
# itself (never rejects) before the buffer has ever been
|
||||
# seeded — there's no baseline yet to bound against.
|
||||
# THIRD rebound, and the first one that cannot latch. Every previous
|
||||
# version bounded `seq` against a value that only an ACCEPTED packet
|
||||
# can advance (server uptime, then last_applied_seq, then
|
||||
# highest_ingested_seq) — which makes the guard a one-way door: once
|
||||
# a client's live sequence gets far enough ahead, every packet is
|
||||
# rejected, the bound can never move again, and that player's input
|
||||
# is dead for the rest of the match with no diagnostic. An
|
||||
# adversarial review reproduced exactly that with a 2s SIGSTOP host
|
||||
# freeze: 600+ consecutive rejections, the server applying zero
|
||||
# thrust for 1300 sequences while the client's wire carried full
|
||||
# thrust throughout, unrecoverable.
|
||||
#
|
||||
# Keep the bound (it still rejects a single garbage-far-future jump
|
||||
# on the spot) but give it an escape: after SEQ_REJECT_RESYNC_LIMIT
|
||||
# consecutive rejections the client is evidently not a one-off
|
||||
# glitch but a real peer whose epoch has genuinely run away from
|
||||
# ours, so accept the packet and let ingest()/consume()'s existing
|
||||
# resync machinery re-establish the baseline. This grants an
|
||||
# attacker nothing new: walking the epoch forward by sustained
|
||||
# rejection costs the same packets as walking it forward by
|
||||
# acceptance, and §3.4's rate limiter already bounds that rate.
|
||||
var jb := slot.jitter_buffer
|
||||
var seq_bound: int = (jb.highest_ingested_seq if jb.highest_ingested_seq >= 0 else seq) + InputJitterBuffer.RING_SIZE
|
||||
if seq > seq_bound:
|
||||
return
|
||||
slot.consecutive_seq_rejects += 1
|
||||
if slot.consecutive_seq_rejects < SEQ_REJECT_RESYNC_LIMIT:
|
||||
return
|
||||
# Fall through and accept: this is the escape hatch, not a
|
||||
# missing `return`.
|
||||
slot.consecutive_seq_rejects = 0
|
||||
jb.ingest(seq, decoded["actions"])
|
||||
slot.last_client_send_ms = decoded["client_send_ms"]
|
||||
return
|
||||
@@ -361,7 +432,10 @@ func _broadcast_snapshot() -> void:
|
||||
for slot in _slots:
|
||||
if connected_peers.has(slot.peer_id):
|
||||
var last_input_seq := maxi(slot.jitter_buffer.last_applied_seq, 0)
|
||||
var bytes := NetCodec.pack_snapshot(last_input_seq, slot.jitter_buffer.depth(), slot.last_client_send_ms, segment)
|
||||
# -1 is reserved for client "not established" state. -2 reports a
|
||||
# genuine sustained server starvation event.
|
||||
var advertised_depth := -2 if slot.jitter_buffer.starved_ticks >= STARVATION_ADVERTISEMENT_TICKS else slot.jitter_buffer.depth()
|
||||
var bytes := NetCodec.pack_snapshot(last_input_seq, advertised_depth, slot.last_client_send_ms, segment)
|
||||
MatchSim.send_snapshot(slot.peer_id, bytes)
|
||||
|
||||
|
||||
@@ -431,6 +505,13 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
|
||||
spawn_ball()
|
||||
ball.freeze = true
|
||||
ball.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
|
||||
# The authority shadow is presentation-only on clients. Its collider must
|
||||
# not steal an impulse from the dynamic client-only proxy below.
|
||||
ball.collision_layer = 0
|
||||
ball.collision_mask = 0
|
||||
if is_instance_valid((ball as Ball).visual):
|
||||
(ball as Ball).visual.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
|
||||
_spawn_local_ball_proxy()
|
||||
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
for i in peer_ids.size():
|
||||
@@ -439,45 +520,49 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
|
||||
slot.team = teams[i]
|
||||
slot.spawn_index = spawn_indices[i]
|
||||
slot.ship = spawn_ship(slot.team, slot.spawn_index, null)
|
||||
var is_local := slot.peer_id == my_id
|
||||
# Do not let the local dynamic body fall or collide during the
|
||||
# match_config→first-snapshot gap. Prediction starts from a genuine
|
||||
# server pose below, not from an unsynchronised spawn approximation.
|
||||
slot.ship.freeze = true
|
||||
slot.ship.freeze_mode = RigidBody3D.FREEZE_MODE_KINEMATIC
|
||||
# §4.6: manual, per-render-frame $Visual updates must not fight
|
||||
# Godot's own built-in physics interpolation.
|
||||
if is_instance_valid(slot.ship.visual):
|
||||
if not is_local and is_instance_valid(slot.ship.visual):
|
||||
slot.ship.visual.physics_interpolation_mode = Node.PHYSICS_INTERPOLATION_MODE_OFF
|
||||
_slots.append(slot)
|
||||
if slot.peer_id == my_id:
|
||||
if is_local:
|
||||
_my_slot = slot
|
||||
|
||||
_spawn_hud()
|
||||
if is_instance_valid(_my_slot) and is_instance_valid(_my_slot.ship):
|
||||
spawn_camera_rig(_my_slot.ship)
|
||||
_my_slot.ship.ball_contact.connect(_on_local_ball_contact)
|
||||
# Headless training ships intentionally do not install Ship's render-side
|
||||
# body_entered signal. Attach this client-only callback only to the
|
||||
# locally predicted match ship so contact QA sees the same event without
|
||||
# changing training instances.
|
||||
if DisplayServer.get_name() == "headless":
|
||||
_my_slot.ship.body_entered.connect(_on_local_ship_body_entered)
|
||||
if not _test_bot_model_path.is_empty():
|
||||
# --test-bot (task 3.6): swap the human input sampler for a real
|
||||
# --test-bot (task 3.6): attach a real
|
||||
# AIShipController. Unlike PlayerShipController, this one needs
|
||||
# real scene context (get_parent() as Ship for itself, plus
|
||||
# ball/teammate/opponent discovery via groups) — Ship.set_controller()
|
||||
# parents it correctly, satisfying that. Known limitation: this
|
||||
# client's ships are all FREEZE_MODE_KINEMATIC and driven purely by
|
||||
# transform writes (§4.1/§4.6) — nothing here ever writes
|
||||
# linear_velocity/angular_velocity onto them, so ShipObservations
|
||||
# always sees every ship (including this one's own) as
|
||||
# stationary. The policy still produces well-formed, bounded
|
||||
# actions from that degraded input (PolicyNetwork's output layer
|
||||
# is bounded regardless of input quality) — good enough for a CI
|
||||
# traffic generator, which is this task's actual job, not bot
|
||||
# skill.
|
||||
# local bot controller to the genuinely simulated local ship.
|
||||
var bot := AIShipController.new()
|
||||
bot.model_path = _test_bot_model_path
|
||||
_my_slot.ship.set_controller(bot)
|
||||
# Reassigning _local_input_sampler would orphan the original
|
||||
# PlayerShipController it pointed to — the exact same leak class
|
||||
# an adversarial review already caught once for this same field
|
||||
# (it's a plain Node, never in the tree, so nothing else would
|
||||
# ever free it). It's never parented, so free() is safe directly.
|
||||
if is_instance_valid(_local_input_sampler):
|
||||
_local_input_sampler.free()
|
||||
_local_input_sampler = bot
|
||||
_my_slot.ship.add_child(bot)
|
||||
_local_input_timeline = LocalInputTimeline.new()
|
||||
_local_net_controller = LocalNetShipController.new(bot, _local_input_timeline)
|
||||
_my_slot.ship.set_controller(_local_net_controller)
|
||||
else:
|
||||
var player := PlayerShipController.new()
|
||||
_local_input_timeline = LocalInputTimeline.new()
|
||||
_local_net_controller = LocalNetShipController.new(player, _local_input_timeline)
|
||||
_local_net_controller.add_child(player)
|
||||
_my_slot.ship.set_controller(_local_net_controller)
|
||||
|
||||
|
||||
func _spawn_hud() -> void:
|
||||
@@ -488,57 +573,65 @@ func _spawn_hud() -> void:
|
||||
func _send_local_input() -> void:
|
||||
if _slots.is_empty():
|
||||
return # match_config hasn't arrived yet
|
||||
var action := _local_input_sampler.get_action().copy()
|
||||
if not _local_prediction_ready or _my_slot == null or not is_instance_valid(_my_slot.ship):
|
||||
return
|
||||
# Client-owned input_lead control loop (§3.3): ordinarily +1 (ship
|
||||
# increments its send sequence by exactly one tick's worth), but a lead
|
||||
# change this tick skips extra sequence numbers (attack, more server-
|
||||
# side buffer margin) or duplicates the current one (release, delta 0 —
|
||||
# one tick of latency recovered).
|
||||
var delta := _input_lead_controller.update(_last_known_input_buffer_depth)
|
||||
_input_seq += delta
|
||||
# Record this tick's (seq, action, local-ship state) triple. action is
|
||||
# sampled exactly once above; record() makes its own copy for the
|
||||
# longer-lived prediction history. See _local_ship_prediction_state() for
|
||||
# what the "state" half does and does not currently mean.
|
||||
if _my_slot != null and is_instance_valid(_my_slot.ship):
|
||||
_local_prediction_history.record(_input_seq, action, _local_ship_prediction_state(_my_slot.ship, action))
|
||||
# Redundancy (§3.1): carry the last MAX_REDUNDANCY ticks' actions,
|
||||
# newest-first, so a burst of up to (MAX_REDUNDANCY - 1) consecutive
|
||||
# packet losses still lets the server recover every dropped tick's
|
||||
# action from a later packet — InputJitterBuffer.ingest() discards
|
||||
# whichever of these the server already applied, so re-sending old
|
||||
# ticks every packet is harmless, not just tolerated. NetCodec's wire
|
||||
# format has no per-entry seq field — actions[i] is implicitly
|
||||
# "seq - i" — so _input_history must actually BE that many consecutive
|
||||
# ticks, not just "the last few samples taken". A plain push_front on
|
||||
# every tick regardless of delta broke that: an adversarial review
|
||||
# found a lead change silently relabelled older entries (a duplicated
|
||||
# tick shifts everything back by one position without a matching seq
|
||||
# change, and a skip-ahead makes the whole history discontiguous with
|
||||
# the new seq), causing the server to replay already-applied ticks or
|
||||
# apply the wrong redundant copy for a given seq. Handle each case on
|
||||
# its own terms instead of always pushing.
|
||||
if delta == 1:
|
||||
_input_history.push_front(action)
|
||||
if _input_history.size() > NetCodec.MAX_REDUNDANCY:
|
||||
_input_history.resize(NetCodec.MAX_REDUNDANCY)
|
||||
elif delta == 0:
|
||||
# Release: seq didn't advance, so this tick's freshest sample
|
||||
# REPLACES the front entry (still "seq") rather than pushing
|
||||
# everything else back a position under a label that no longer
|
||||
# matches what's actually there.
|
||||
if _input_history.is_empty():
|
||||
_input_history.push_front(action)
|
||||
else:
|
||||
_input_history[0] = action
|
||||
else:
|
||||
# Attack: seq jumped ahead by more than one, so nothing previously
|
||||
# in history is contiguous with the new seq any more — the skipped
|
||||
# range was never sent, by design (that's what "buys more server-
|
||||
# side buffer margin" means). Reset the redundancy window to just
|
||||
# this tick's sample; it rebuilds naturally over the next few
|
||||
# ticks, the same way it does at connection start.
|
||||
_input_history = [action]
|
||||
_update_adaptive_input_target()
|
||||
var reported_depth := -2 if _last_known_input_buffer_depth == -2 else (_last_known_input_buffer_depth if _has_received_healthy_buffer_depth else -1)
|
||||
var delta := _input_lead_controller.update(reported_depth, _current_input_target_depth())
|
||||
if _local_input_timeline == null or _local_net_controller == null:
|
||||
return
|
||||
var applied_action := _my_slot.ship.get_current_action_copy()
|
||||
var previous_issued_seq := _input_seq
|
||||
_input_seq = _local_input_timeline.issue(delta, _local_net_controller.last_sampled_intent)
|
||||
# The body used the raw action immediately, and that action was issued under
|
||||
# _input_seq this tick — so _input_seq is the sequence whose post-step state
|
||||
# this is. Label it there.
|
||||
#
|
||||
# This deliberately does NOT delay local control: which action the ship uses
|
||||
# is decided in LocalNetShipController.get_action() (still the raw current
|
||||
# intent, still immediate) and is untouched by which seq its resulting state
|
||||
# is filed under. The previous label, _local_net_controller.last_applied_seq,
|
||||
# was the timeline's ESTIMATE of the sequence the server would consume this
|
||||
# tick — input_lead ticks behind issuance — so predicted[S] held "state after
|
||||
# integrating the intent from now" while the server's authoritative state for
|
||||
# S is "state after integrating action(S)", sampled input_lead ticks earlier.
|
||||
# Those agree only while the stick is still, which is why a held-input trace
|
||||
# could never falsify it and a transition-heavy one reports ~9% action-marker
|
||||
# mismatch.
|
||||
var history_seq := _input_seq
|
||||
if delta > 0:
|
||||
# An attack (delta > 1) issues and SENDS several sequences for this one
|
||||
# local physics step; only the newest carries the action the body just
|
||||
# integrated. The skipped ones are real outstanding sequences the server
|
||||
# will acknowledge, but the client never simulated them, so they are
|
||||
# recorded stateless rather than left absent — absent is indistinguishable
|
||||
# from genuine ring loss, and cost a teleport plus resync suppression
|
||||
# every time the lead controller attacked.
|
||||
for gap_seq in range(previous_issued_seq + 1, history_seq):
|
||||
if gap_seq <= 0:
|
||||
continue
|
||||
var gap_action = _local_input_timeline.action_for(gap_seq)
|
||||
if gap_action != null:
|
||||
_local_prediction_history.record_unsimulated(gap_seq, gap_action)
|
||||
if history_seq > 0:
|
||||
_local_prediction_history.record(history_seq, applied_action, _local_ship_prediction_state(_my_slot.ship, applied_action), _my_slot.ship.net_prediction_contact_window)
|
||||
# delta <= 0 is a release: the timeline deliberately does NOT mutate an
|
||||
# already-issued sequence, so re-recording here would file the CURRENT intent
|
||||
# under a sequence that went out carrying a different action — the ring would
|
||||
# then contradict the wire, and the action marker would (correctly) report a
|
||||
# mismatch whenever the server had already consumed the original. The existing
|
||||
# predicted[S] is right; leave it alone. The extra unlabelled local step is
|
||||
# precisely the tick of latency the release exists to recover.
|
||||
_input_history.clear()
|
||||
for packet_action in _local_input_timeline.packet_actions(NetCodec.MAX_REDUNDANCY):
|
||||
_input_history.append(packet_action)
|
||||
if _input_history.is_empty():
|
||||
return
|
||||
var bytes := NetCodec.pack_input(_input_seq, _last_received_snapshot_tick, Time.get_ticks_msec(), _input_history)
|
||||
MatchSim.send_input(bytes)
|
||||
|
||||
@@ -559,19 +652,45 @@ func _on_snapshot_received(decoded: Dictionary) -> void:
|
||||
# own slot's server-side InputJitterBuffer.depth() at send time, which
|
||||
# is exactly what the input_lead control loop (§3.3) needs.
|
||||
_last_known_input_buffer_depth = decoded["input_buffer_depth"]
|
||||
# Compare the server state for this client's own fixed slot against the
|
||||
# entry tagged with the exact input sequence the server applied. Do not
|
||||
# correct the body here yet: this result is intentionally inspection data
|
||||
# for the later snap/blend pass, and (per
|
||||
# _local_ship_prediction_state()) is not yet true prediction error.
|
||||
if _last_known_input_buffer_depth >= 0:
|
||||
_has_received_healthy_buffer_depth = true
|
||||
# Compare against the same input sequence then reconcile the genuinely
|
||||
# locally-simulated ship. The predictor owns the snap-vs-soft decision.
|
||||
if _my_slot != null:
|
||||
var my_index := _slots.find(_my_slot)
|
||||
if my_index >= 0 and my_index < bodies.size():
|
||||
_last_local_prediction_comparison = _local_prediction_history.compare_authoritative(decoded["last_input_seq"], bodies[my_index])
|
||||
if not _local_prediction_ready:
|
||||
var initial: NetBodyState = bodies[my_index]
|
||||
if _local_input_timeline != null:
|
||||
var one_way_ms := maxf(NetworkManager.rtt_ms * 0.5, 0.0)
|
||||
var label_delay_ticks := ceili(one_way_ms / SNAPSHOT_INTERVAL_MS) + _current_input_target_depth()
|
||||
_local_input_timeline.configure_initial_delay(label_delay_ticks)
|
||||
_my_slot.ship.queue_teleport_with_velocity(Transform3D(Basis(initial.rotation), initial.position), initial.linear_velocity, initial.angular_velocity)
|
||||
_my_slot.ship.freeze = false
|
||||
_local_prediction_ready = true
|
||||
else:
|
||||
# Receipt can run from both process callbacks. Stage immutable wire
|
||||
# data only: comparison mutates acknowledgement/history state and
|
||||
# must happen atomically with the correction below.
|
||||
_pending_local_reconciliation = {
|
||||
"ack_seq": decoded["last_input_seq"],
|
||||
"authoritative": (bodies[my_index] as NetBodyState).copy(),
|
||||
"reset_gen": reset_gen,
|
||||
}
|
||||
_update_tick_bias(server_tick)
|
||||
for i in _slots.size():
|
||||
if i < bodies.size():
|
||||
_slots[i].interpolator.add_sample(server_tick, bodies[i], reset_gen)
|
||||
if i < bodies.size():
|
||||
if _slots[i] != _my_slot:
|
||||
var slot := _slots[i]
|
||||
var accepts_remote_tick := slot.interpolator.accepts_tick(server_tick)
|
||||
var remote_reset := accepts_remote_tick and slot.interpolator.reset_gen != -1 and reset_gen != slot.interpolator.reset_gen
|
||||
if remote_reset:
|
||||
slot.visual_smoother_reset = true
|
||||
slot.visual_position_offset = Vector3.ZERO
|
||||
slot.visual_rotation_offset = Quaternion.IDENTITY
|
||||
elif accepts_remote_tick:
|
||||
_accumulate_remote_residual(slot.interpolator, server_tick, bodies[i], slot)
|
||||
slot.interpolator.add_sample(server_tick, bodies[i], reset_gen)
|
||||
if bodies.size() > _slots.size():
|
||||
var ball_state: NetBodyState = bodies[_slots.size()]
|
||||
# unpack_snapshot() decodes every body's angular_velocity assuming
|
||||
@@ -580,38 +699,29 @@ func _on_snapshot_received(decoded: Dictionary) -> void:
|
||||
# — dormant today (nothing reads decoded angular_velocity yet) but
|
||||
# silently wrong the moment ball-spin VFX or Phase 4 prediction does.
|
||||
NetCodec.rescale_avel(ball_state, NetCodec.BALL_AVEL_RANGE)
|
||||
_ball_interpolator.add_sample(server_tick, ball_state, reset_gen)
|
||||
_ball_shadow_state = ball_state.copy()
|
||||
if _ball_prediction_until_ms >= 0 and ball_state.position.distance_to(_ball_shadow_position_on_contact) > 0.01:
|
||||
_ball_authority_changed_since_contact = true
|
||||
var accepts_ball_tick := _ball_interpolator.accepts_tick(server_tick)
|
||||
var ball_was_reset := accepts_ball_tick and _ball_interpolator.reset_gen != -1 and reset_gen != _ball_interpolator.reset_gen
|
||||
if accepts_ball_tick and not ball_was_reset:
|
||||
_accumulate_ball_residual(_ball_interpolator, server_tick, ball_state)
|
||||
var ball_reset := _ball_interpolator.add_sample(server_tick, ball_state, reset_gen)
|
||||
if ball_reset:
|
||||
_ball_reset_trace.append("%d:%d" % [server_tick, reset_gen])
|
||||
if _ball_reset_trace.size() > 12:
|
||||
_ball_reset_trace.pop_front()
|
||||
_ball_visual_smoother_reset = true
|
||||
_ball_visual_position_offset = Vector3.ZERO
|
||||
_ball_visual_rotation_offset = Quaternion.IDENTITY
|
||||
_cancel_ball_prediction_for_reset(ball_state)
|
||||
if is_instance_valid(_local_ball_proxy) and _ball_prediction_until_ms < 0:
|
||||
_local_ball_proxy.queue_teleport_with_velocity(Transform3D(Basis(ball_state.rotation), ball_state.position), ball_state.linear_velocity, ball_state.angular_velocity)
|
||||
|
||||
|
||||
# NOT a prediction yet, despite the name — the name is for task 4.3, which
|
||||
# is what will make it true. Pre-4.3 EVERY ship on the client, including this
|
||||
# client's own, is freeze = true / FREEZE_MODE_KINEMATIC (see _apply_match_config,
|
||||
# which sets that uniformly with no exception for _my_slot) and is moved only
|
||||
# by NetInterpolator transform writes derived from ALREADY-RECEIVED, past
|
||||
# server snapshots. Nothing locally simulates the local ship, and nothing ever
|
||||
# writes linear_velocity/angular_velocity onto it.
|
||||
#
|
||||
# So what this samples is "wherever the interpolator had smoothed the ship to
|
||||
# at packet-send time", NOT "where the action sampled this tick will put the
|
||||
# ship". The consequences for anyone reading the comparison output:
|
||||
# - linear_velocity/angular_velocity here are NOT zero — a first pass at
|
||||
# this comment claimed they were, but FREEZE_MODE_KINEMATIC derives a
|
||||
# body's velocity from its own consecutive transform writes, so these
|
||||
# fields genuinely reflect the interpolator's implied motion (confirmed
|
||||
# live: non-zero, direction-correct velocities while driving). What they
|
||||
# are NOT is the result of locally simulating the sampled action's
|
||||
# thrust/rotation through the ship's own force formulas.
|
||||
# - the resulting position_error / rotation_error_radians measure how far
|
||||
# an interpolated PAST pose (and its implied velocity) sits from the
|
||||
# later-arriving authoritative pose for that sequence. That is
|
||||
# interpolation lag, not prediction error, and on a clean link it will
|
||||
# read small and largely uninformative.
|
||||
# - do not calibrate a snap-vs-blend threshold, or benchmark "prediction
|
||||
# quality", against these numbers.
|
||||
# They only become genuine prediction error once task 4.3's net_ship_predictor.gd
|
||||
# unfreezes the local ship and steps it forward locally (multiplayer-todo.md
|
||||
# §4 / §7 tasks 4.3 and 4.5). The recording/matching plumbing is landed first,
|
||||
# on purpose, so 4.3 has a tested ring to build on.
|
||||
# Called from NetworkedMatch._physics_process after Ship._integrate_forces,
|
||||
# so this is the genuine post-step state caused by the local controller's one
|
||||
# action pull. _send_local_input then pairs it with the copied wire action.
|
||||
func _local_ship_prediction_state(ship: Ship, action: ShipAction) -> NetBodyState:
|
||||
var state := NetBodyState.new()
|
||||
state.position = ship.global_position
|
||||
@@ -625,10 +735,97 @@ func _local_ship_prediction_state(ship: Ship, action: ShipAction) -> NetBodyStat
|
||||
return state
|
||||
|
||||
|
||||
# Diagnostic accessor. Same caveat as _local_ship_prediction_state(): the
|
||||
# error fields are interpolation-vs-authoritative drift, not prediction error,
|
||||
# until task 4.3 lands.
|
||||
#
|
||||
func _on_local_ball_contact(_intensity: float, _world_position: Vector3) -> void:
|
||||
if not local_ball_prediction_enabled or multiplayer.is_server() or not is_instance_valid(ball) or not is_instance_valid(_local_ball_proxy):
|
||||
return
|
||||
# body_entered can fire repeatedly while the proxy remains in a manifold.
|
||||
# One touch owns one bounded RTT window; extending it per callback can keep
|
||||
# speculation alive indefinitely and prevents the required blend-back.
|
||||
var now_ms := Time.get_ticks_msec()
|
||||
if _ball_prediction_until_ms >= 0 or now_ms < _ball_recontact_cooldown_until_ms:
|
||||
return
|
||||
var prediction_window_ms := int(minf(maxf(NetworkManager.rtt_ms, SNAPSHOT_INTERVAL_MS), BALL_PREDICTION_MAX_MS))
|
||||
_ball_prediction_until_ms = now_ms + prediction_window_ms
|
||||
_ball_recontact_cooldown_until_ms = now_ms + max(BALL_RECONTACT_COOLDOWN_MS, prediction_window_ms + BALL_VISUAL_BLEND_MS)
|
||||
_ball_visual_blend_started_ms = -1
|
||||
_ball_contact_frame = Engine.get_physics_frames()
|
||||
_ball_reveal_frame = Engine.get_physics_frames()
|
||||
(ball as Ball).visual.visible = false
|
||||
_local_ball_proxy.visual.visible = true
|
||||
_local_ball_proxy.set_visual_speed(-1.0)
|
||||
_ball_prediction_contact_count += 1
|
||||
_ball_proxy_contact_position = _local_ball_proxy.global_position
|
||||
_ball_proxy_moved_before_authority = false
|
||||
_ball_shadow_position_on_contact = _ball_shadow_state.position if _ball_shadow_state != null else _local_ball_proxy.global_position
|
||||
_ball_authority_changed_since_contact = false
|
||||
|
||||
|
||||
func _on_local_ship_body_entered(body: Node) -> void:
|
||||
if body is Ball:
|
||||
_on_local_ball_contact(0.0, (body as Ball).global_position)
|
||||
|
||||
|
||||
func _finish_ball_prediction() -> void:
|
||||
if _ball_prediction_until_ms >= 0 and not _ball_authority_changed_since_contact and is_instance_valid(_local_ball_proxy) and _local_ball_proxy.global_position.distance_to(_ball_proxy_contact_position) > 0.01:
|
||||
if not _ball_proxy_moved_before_authority:
|
||||
_ball_proxy_moved_before_authority = true
|
||||
_ball_proxy_moved_before_authority_count += 1
|
||||
if _ball_prediction_until_ms < 0 or Time.get_ticks_msec() < _ball_prediction_until_ms:
|
||||
return
|
||||
_ball_prediction_until_ms = -1
|
||||
_ball_prediction_window_end_count += 1
|
||||
if not is_instance_valid(ball) or not is_instance_valid(_local_ball_proxy):
|
||||
return
|
||||
(ball as Ball).visual.visible = true
|
||||
_local_ball_proxy.visual.visible = false
|
||||
if _ball_shadow_state == null:
|
||||
_ball_prediction_missing_shadow_count += 1
|
||||
return
|
||||
_last_ball_prediction_error = _local_ball_proxy.global_position.distance_to(_ball_shadow_state.position)
|
||||
if _last_ball_prediction_error > BALL_HARD_SNAP_DISTANCE:
|
||||
# A large disagreement is dishonest to hide. Resume the authoritative
|
||||
# shadow immediately, then re-seed the invisible proxy on next arrival.
|
||||
_ball_visual_blend_started_ms = -1
|
||||
_ball_hard_handoff_count += 1
|
||||
return
|
||||
_ball_visual_blend_from = _local_ball_proxy.visual.global_transform
|
||||
_ball_visual_blend_started_ms = Time.get_ticks_msec()
|
||||
_ball_blend_started_count += 1
|
||||
# Never push the speculative result into authority; only presentation
|
||||
# blends over to the continuously-buffered shadow.
|
||||
|
||||
|
||||
func _cancel_ball_prediction_for_reset(authoritative: NetBodyState) -> void:
|
||||
if _ball_prediction_until_ms >= 0 or _ball_visual_blend_started_ms >= 0:
|
||||
_ball_prediction_reset_cancel_count += 1
|
||||
_ball_prediction_until_ms = -1
|
||||
_ball_recontact_cooldown_until_ms = -1
|
||||
_ball_visual_blend_started_ms = -1
|
||||
_ball_proxy_moved_before_authority = false
|
||||
_ball_authority_changed_since_contact = false
|
||||
_last_ball_prediction_error = 0.0
|
||||
if is_instance_valid(ball):
|
||||
(ball as Ball).visual.visible = true
|
||||
if is_instance_valid(_local_ball_proxy):
|
||||
_local_ball_proxy.visual.visible = false
|
||||
_local_ball_proxy.queue_teleport_with_velocity(Transform3D(Basis(authoritative.rotation), authoritative.position), authoritative.linear_velocity, authoritative.angular_velocity)
|
||||
|
||||
|
||||
func _spawn_local_ball_proxy() -> void:
|
||||
if multiplayer.is_server() or not local_ball_prediction_enabled:
|
||||
return
|
||||
_local_ball_proxy = ball_scene.instantiate() as Ball
|
||||
_local_ball_proxy.name = "LocalBallPredictionProxy"
|
||||
_local_ball_proxy.remove_from_group("ball")
|
||||
add_child(_local_ball_proxy)
|
||||
_local_ball_proxy.global_transform = ball.global_transform
|
||||
_local_ball_proxy.visual.visible = false
|
||||
# This body keeps normal ball-vs-ship/arena collision settings, but exists
|
||||
# only in this client process. It therefore receives the contact impulse on
|
||||
# the same local physics frame without altering server or training physics.
|
||||
|
||||
|
||||
# Diagnostic accessor.
|
||||
# Dictionary.duplicate(true) recurses into Arrays/Dictionaries but copies
|
||||
# Objects (RefCounted included) BY REFERENCE — an adversarial review caught
|
||||
# that this returned a dict sharing its "action"/"predicted_state"/
|
||||
@@ -685,7 +882,25 @@ func _estimated_tick(server_time_ms: float) -> float:
|
||||
func _current_interp_delay_ms() -> float:
|
||||
var rtt := NetworkManager.rtt_ms
|
||||
var one_way := (rtt / 2.0) if rtt >= 0.0 else INTERP_DELAY_MIN_MS
|
||||
return clampf(one_way + SNAPSHOT_INTERVAL_MS * 1.5, INTERP_DELAY_MIN_MS, INTERP_DELAY_MAX_MS)
|
||||
return clampf(one_way + SNAPSHOT_INTERVAL_MS * 1.5 + 2.5 * NetworkManager.jitter_ms, INTERP_DELAY_MIN_MS, INTERP_DELAY_MAX_MS)
|
||||
|
||||
|
||||
func _current_input_target_depth() -> int:
|
||||
# A clean LAN needs no intentionally buffered input tick. Preserve one
|
||||
# tick whenever measured RTT jitter crosses the small threshold; starvation
|
||||
# still triggers the controller's existing fast-attack path either way.
|
||||
# Keep the headless policy-driver protocol at its established depth: these
|
||||
# bots are regression/training tooling, not the human latency experiment.
|
||||
if not _test_bot_model_path.is_empty():
|
||||
return InputLeadController.TARGET_DEPTH
|
||||
return _adaptive_input_depth.target_depth
|
||||
|
||||
|
||||
func _update_adaptive_input_target() -> void:
|
||||
if not _test_bot_model_path.is_empty():
|
||||
_adaptive_input_depth.target_depth = InputLeadController.TARGET_DEPTH
|
||||
return
|
||||
_adaptive_input_depth.update(NetworkManager.rtt_ms, NetworkManager.jitter_ms, _last_known_input_buffer_depth)
|
||||
|
||||
|
||||
# Client-only stats for task 3.7's debug overlay, discovered via the "game"
|
||||
@@ -713,19 +928,67 @@ func get_net_debug_stats() -> Dictionary:
|
||||
# overlay" was false; only the CI gate read it, and only via the
|
||||
# server's own field directly, not the wire bit. Read it here for real.
|
||||
var server_stalled := false
|
||||
if is_instance_valid(_my_slot):
|
||||
var latest := _my_slot.interpolator.latest()
|
||||
if latest != null:
|
||||
server_stalled = latest.stalled
|
||||
if _last_local_prediction_comparison.get("authoritative_state", null) != null:
|
||||
server_stalled = (_last_local_prediction_comparison["authoritative_state"] as NetBodyState).stalled
|
||||
return {
|
||||
"input_buffer_depth": _last_known_input_buffer_depth,
|
||||
"input_lead": _input_lead_controller.lead,
|
||||
"input_target_depth": _current_input_target_depth(),
|
||||
"snapshot_age_ms": snapshot_age_ms,
|
||||
"snapshot_loss_pct": snapshot_loss_pct,
|
||||
"server_stalled": server_stalled,
|
||||
"prediction": _local_ship_predictor.get_metrics(),
|
||||
"ball_prediction_contacts": _ball_prediction_contact_count,
|
||||
"ball_prediction_active": _ball_prediction_until_ms >= 0,
|
||||
"ball_prediction_error": _last_ball_prediction_error,
|
||||
"ball_contact_frame": _ball_contact_frame,
|
||||
"ball_reveal_frame": _ball_reveal_frame,
|
||||
"ball_blend_complete_count": _ball_blend_complete_count,
|
||||
"ball_blend_started_count": _ball_blend_started_count,
|
||||
"ball_blend_max_duration_ms": _ball_blend_max_duration_ms,
|
||||
"ball_hard_handoff_count": _ball_hard_handoff_count,
|
||||
"ball_prediction_window_end_count": _ball_prediction_window_end_count,
|
||||
"ball_prediction_missing_shadow_count": _ball_prediction_missing_shadow_count,
|
||||
"ball_prediction_reset_cancel_count": _ball_prediction_reset_cancel_count,
|
||||
"ball_reset_trace": _ball_reset_trace.duplicate(),
|
||||
"ball_proxy_moved_before_authority": _ball_proxy_moved_before_authority_count > 0,
|
||||
"ball_proxy_moved_before_authority_count": _ball_proxy_moved_before_authority_count,
|
||||
"ball_authority_changed_since_contact": _ball_authority_changed_since_contact,
|
||||
"remote_residual_position_p99": _remote_percentile(_remote_position_residuals, 0.99),
|
||||
"remote_residual_rotation_p99": _remote_percentile(_remote_rotation_residuals, 0.99),
|
||||
"latest_prediction_error": _last_local_prediction_comparison.get("position_error", Vector3.ZERO),
|
||||
"latest_prediction_velocity_error": _last_local_prediction_comparison.get("linear_velocity_error", Vector3.ZERO),
|
||||
"action_marker_samples": _action_marker_samples,
|
||||
"action_marker_mismatches": _action_marker_mismatches,
|
||||
}
|
||||
|
||||
|
||||
func adjust_prediction_tuning(position_delta: float = 0.0, decay_delta: float = 0.0, offset_delta: float = 0.0, toggle_present_time: bool = false) -> void:
|
||||
# Debug-only runtime knobs; this object is never instantiated by the server
|
||||
# for an interactive client and cannot change action, collision, or Jolt
|
||||
# simulation parameters.
|
||||
if multiplayer.is_server():
|
||||
return
|
||||
_local_ship_predictor.hard_position_error = clampf(_local_ship_predictor.hard_position_error + position_delta, 0.25, 5.0)
|
||||
_local_ship_predictor.max_visual_offset = clampf(_local_ship_predictor.max_visual_offset + offset_delta, 0.05, 2.0)
|
||||
if _my_slot != null and is_instance_valid(_my_slot.ship):
|
||||
_my_slot.ship.set_network_visual_tuning(_my_slot.ship.net_visual_offset_decay + decay_delta, _local_ship_predictor.max_visual_offset)
|
||||
if toggle_present_time:
|
||||
remote_visual_present_time_enabled = not remote_visual_present_time_enabled
|
||||
_reset_remote_visual_smoothers()
|
||||
|
||||
|
||||
func _reset_remote_visual_smoothers() -> void:
|
||||
for slot in _slots:
|
||||
if slot != _my_slot:
|
||||
slot.visual_smoother_reset = true
|
||||
slot.visual_position_offset = Vector3.ZERO
|
||||
slot.visual_rotation_offset = Quaternion.IDENTITY
|
||||
_ball_visual_smoother_reset = true
|
||||
_ball_visual_position_offset = Vector3.ZERO
|
||||
_ball_visual_rotation_offset = Quaternion.IDENTITY
|
||||
|
||||
|
||||
# Collider time: present-time estimate, applied once per physics tick.
|
||||
func _physics_process(_delta: float) -> void:
|
||||
# Automatic multiplayer polling is disabled project-wide (task 1.3) —
|
||||
@@ -738,23 +1001,25 @@ func _physics_process(_delta: float) -> void:
|
||||
if _owns_world_simulation():
|
||||
_respawn_escaped_bodies()
|
||||
if multiplayer.is_server():
|
||||
# Once per tick, before the step (§3.2) — RLShipController reads
|
||||
# .action lazily in the ship's own _integrate_forces, which for this
|
||||
# tick already ran (physics step precedes _physics_process, §9
|
||||
# gotcha 34), so this actually takes effect on the NEXT tick's step.
|
||||
# That's the same one-tick input latency Phase 2 already had; this
|
||||
# just replaces "read the newest packet naively" with a real
|
||||
# sequence-tracked ring buffer that survives redundant/reordered/
|
||||
# lost packets.
|
||||
# _physics_process runs after this frame's _integrate_forces. Snapshot
|
||||
# FIRST: the body state therefore still describes the sequence consumed
|
||||
# on the prior callback. Sending after consume mislabeled that old state
|
||||
# with NEXT tick's input sequence, making every client reconciliation
|
||||
# comparison one action off and causing the Phase 4 snap cascade.
|
||||
_broadcast_snapshot()
|
||||
# The newly consumed action is deliberately installed for NEXT frame's
|
||||
# integration. This preserves the existing one-tick server input delay
|
||||
# while keeping snapshot.last_input_seq truthfully coupled to its body.
|
||||
for slot in _slots:
|
||||
slot.controller.action = slot.jitter_buffer.consume()
|
||||
if _pending_reset_gen_bump and Engine.get_physics_frames() > _pending_reset_gen_bump_tick:
|
||||
_reset_gen = (_reset_gen + 1) % 256
|
||||
_pending_reset_gen_bump = false
|
||||
_broadcast_snapshot()
|
||||
return
|
||||
|
||||
_send_local_input()
|
||||
_consume_local_reconciliation()
|
||||
_finish_ball_prediction()
|
||||
# get_server_time_estimate_ms() is meaningless before the first pong
|
||||
# lands (network_manager.gd's own doc comment says so explicitly) — an
|
||||
# adversarial review found this was used unguarded here, which against
|
||||
@@ -767,12 +1032,36 @@ func _physics_process(_delta: float) -> void:
|
||||
var server_time_est := NetworkManager.get_server_time_estimate_ms()
|
||||
var collider_tick := _estimated_tick(server_time_est)
|
||||
for slot in _slots:
|
||||
if is_instance_valid(slot.ship) and slot.interpolator.has_samples():
|
||||
if slot != _my_slot and is_instance_valid(slot.ship) and slot.interpolator.has_samples():
|
||||
_apply_collider_state(slot.ship, slot.interpolator.sample_at(collider_tick))
|
||||
if is_instance_valid(ball) and _ball_interpolator.has_samples():
|
||||
_apply_collider_state(ball, _ball_interpolator.sample_at(collider_tick))
|
||||
|
||||
|
||||
func _consume_local_reconciliation() -> void:
|
||||
if _pending_local_reconciliation.is_empty() or _my_slot == null or not is_instance_valid(_my_slot.ship):
|
||||
return
|
||||
var pending := _pending_local_reconciliation
|
||||
_pending_local_reconciliation = {}
|
||||
var reset_gen: int = pending["reset_gen"]
|
||||
# Reset starts an isolated history epoch before its state is compared.
|
||||
if _last_local_reset_gen != -1 and _last_local_reset_gen != reset_gen:
|
||||
_local_prediction_history.begin_epoch()
|
||||
_last_local_reset_gen = reset_gen
|
||||
var comparison := _local_prediction_history.compare_authoritative(int(pending["ack_seq"]), pending["authoritative"])
|
||||
if comparison.get("status", "") == "matched":
|
||||
var action: ShipAction = comparison["action"]
|
||||
var authority: NetBodyState = comparison["authoritative_state"]
|
||||
_action_marker_samples += 1
|
||||
if absf(action.thrust.z - authority.thrust_z) > 0.26:
|
||||
_action_marker_mismatches += 1
|
||||
_last_local_prediction_comparison = comparison
|
||||
# Must match the clock _send_local_input files predictions under, since this
|
||||
# is the upper bound of the rebase range over retained history.
|
||||
var current_seq := _input_seq
|
||||
_local_ship_predictor.reconcile(comparison, _my_slot.ship, reset_gen, current_seq, _local_prediction_history)
|
||||
|
||||
|
||||
# Visual time: present-minus-INTERP_DELAY, applied once per rendered frame —
|
||||
# separate from the collider update above so a high-refresh client samples
|
||||
# remote motion at true render rate instead of repeating the same 60Hz value
|
||||
@@ -795,13 +1084,26 @@ func _process(_delta: float) -> void:
|
||||
if NetworkManager.rtt_ms < 0.0:
|
||||
return
|
||||
var server_time_est := NetworkManager.get_server_time_estimate_ms()
|
||||
var visual_tick := _estimated_tick(server_time_est - _current_interp_delay_ms())
|
||||
var visual_time := server_time_est if remote_visual_present_time_enabled else server_time_est - _current_interp_delay_ms()
|
||||
var visual_tick := _estimated_tick(visual_time)
|
||||
for slot in _slots:
|
||||
if is_instance_valid(slot.ship) and slot.interpolator.has_samples():
|
||||
_apply_ship_visual_state(slot.ship, slot.interpolator.sample_at(visual_tick))
|
||||
if slot != _my_slot and is_instance_valid(slot.ship) and slot.interpolator.has_samples():
|
||||
_apply_ship_visual_state(slot.ship, slot.interpolator.sample_at(visual_tick), _delta, slot)
|
||||
if is_instance_valid(ball) and _ball_interpolator.has_samples():
|
||||
var state := _ball_interpolator.sample_at(visual_tick)
|
||||
if state != null:
|
||||
if _ball_prediction_until_ms < 0 and is_instance_valid((ball as Ball).visual):
|
||||
var target := Transform3D(Basis(state.rotation), state.position)
|
||||
if _ball_visual_blend_started_ms >= 0:
|
||||
var elapsed := Time.get_ticks_msec() - _ball_visual_blend_started_ms
|
||||
var t := clampf(float(elapsed) / float(BALL_VISUAL_BLEND_MS), 0.0, 1.0)
|
||||
(ball as Ball).visual.global_transform = _ball_visual_blend_from.interpolate_with(target, t)
|
||||
if t >= 1.0:
|
||||
_ball_blend_max_duration_ms = maxi(_ball_blend_max_duration_ms, elapsed)
|
||||
_ball_visual_blend_started_ms = -1
|
||||
_ball_blend_complete_count += 1
|
||||
else:
|
||||
_apply_ball_visual_state(target, _delta)
|
||||
(ball as Ball).set_visual_speed(state.linear_velocity.length())
|
||||
|
||||
|
||||
@@ -811,14 +1113,93 @@ func _apply_collider_state(body: RigidBody3D, state: NetBodyState) -> void:
|
||||
body.global_transform = Transform3D(Basis(state.rotation), state.position)
|
||||
|
||||
|
||||
func _apply_ship_visual_state(ship: Ship, state: NetBodyState) -> void:
|
||||
func _apply_ship_visual_state(ship: Ship, state: NetBodyState, delta: float, slot: SlotInfo) -> void:
|
||||
if state == null:
|
||||
return
|
||||
if is_instance_valid(ship.visual):
|
||||
ship.visual.global_transform = Transform3D(Basis(state.rotation), state.position)
|
||||
var target := Transform3D(Basis(state.rotation), state.position)
|
||||
# Keep the delayed-interpolation A/B control genuinely unchanged. The
|
||||
# follower is only evaluating present-time rendering, never silently
|
||||
# adding a second lag source to the baseline path.
|
||||
if not remote_visual_present_time_enabled:
|
||||
ship.visual.global_transform = target
|
||||
slot.visual_smoother_reset = false
|
||||
elif slot.visual_smoother_reset:
|
||||
ship.visual.global_transform = target
|
||||
slot.visual_smoother_reset = false
|
||||
else:
|
||||
var t := clampf(1.0 - exp(-REMOTE_VISUAL_SMOOTH_RATE * delta), 0.0, 1.0)
|
||||
slot.visual_position_offset = slot.visual_position_offset.lerp(Vector3.ZERO, t)
|
||||
slot.visual_rotation_offset = slot.visual_rotation_offset.slerp(Quaternion.IDENTITY, t)
|
||||
ship.visual.global_transform = Transform3D(Basis(slot.visual_rotation_offset * state.rotation), target.origin + slot.visual_position_offset)
|
||||
ship.set_visual_action(state.thrust_z, state.turbo)
|
||||
|
||||
|
||||
func _apply_ball_visual_state(target: Transform3D, delta: float) -> void:
|
||||
if not is_instance_valid(ball) or not is_instance_valid((ball as Ball).visual):
|
||||
return
|
||||
var visual := (ball as Ball).visual
|
||||
if not remote_visual_present_time_enabled:
|
||||
visual.global_transform = target
|
||||
_ball_visual_smoother_reset = false
|
||||
elif _ball_visual_smoother_reset:
|
||||
visual.global_transform = target
|
||||
_ball_visual_smoother_reset = false
|
||||
else:
|
||||
var t := clampf(1.0 - exp(-REMOTE_VISUAL_SMOOTH_RATE * delta), 0.0, 1.0)
|
||||
_ball_visual_position_offset = _ball_visual_position_offset.lerp(Vector3.ZERO, t)
|
||||
_ball_visual_rotation_offset = _ball_visual_rotation_offset.slerp(Quaternion.IDENTITY, t)
|
||||
visual.global_transform = Transform3D(Basis(_ball_visual_rotation_offset * target.basis.get_rotation_quaternion()), target.origin + _ball_visual_position_offset)
|
||||
|
||||
|
||||
func _accumulate_remote_residual(interpolator: NetInterpolator, tick: int, authoritative: NetBodyState, slot: SlotInfo) -> void:
|
||||
if interpolator.has_samples():
|
||||
var predicted := interpolator.sample_at(tick)
|
||||
if predicted != null:
|
||||
var position_residual := authoritative.position - predicted.position
|
||||
_remote_position_residuals.append(position_residual.length())
|
||||
_remote_rotation_residuals.append(rad_to_deg(predicted.rotation.angle_to(authoritative.rotation)))
|
||||
if _remote_position_residuals.size() > REMOTE_METRIC_CAPACITY:
|
||||
_remote_position_residuals.pop_front()
|
||||
_remote_rotation_residuals.pop_front()
|
||||
if remote_visual_present_time_enabled:
|
||||
slot.visual_position_offset = (slot.visual_position_offset - position_residual).limit_length(REMOTE_VISUAL_MAX_OFFSET)
|
||||
var residual_rotation := (predicted.rotation * authoritative.rotation.inverse()).normalized()
|
||||
if rad_to_deg(Quaternion.IDENTITY.angle_to(residual_rotation)) <= REMOTE_VISUAL_MAX_ROTATION_DEGREES:
|
||||
slot.visual_rotation_offset = (residual_rotation * slot.visual_rotation_offset).normalized()
|
||||
else:
|
||||
slot.visual_rotation_offset = Quaternion.IDENTITY
|
||||
|
||||
|
||||
func _accumulate_ball_residual(interpolator: NetInterpolator, tick: int, authoritative: NetBodyState) -> void:
|
||||
if not interpolator.has_samples():
|
||||
return
|
||||
var predicted := interpolator.sample_at(tick)
|
||||
if predicted == null:
|
||||
return
|
||||
var position_residual := authoritative.position - predicted.position
|
||||
_remote_position_residuals.append(position_residual.length())
|
||||
_remote_rotation_residuals.append(rad_to_deg(predicted.rotation.angle_to(authoritative.rotation)))
|
||||
if _remote_position_residuals.size() > REMOTE_METRIC_CAPACITY:
|
||||
_remote_position_residuals.pop_front()
|
||||
_remote_rotation_residuals.pop_front()
|
||||
if remote_visual_present_time_enabled:
|
||||
_ball_visual_position_offset = (_ball_visual_position_offset - position_residual).limit_length(REMOTE_VISUAL_MAX_OFFSET)
|
||||
var residual_rotation := (predicted.rotation * authoritative.rotation.inverse()).normalized()
|
||||
if rad_to_deg(Quaternion.IDENTITY.angle_to(residual_rotation)) <= REMOTE_VISUAL_MAX_ROTATION_DEGREES:
|
||||
_ball_visual_rotation_offset = (residual_rotation * _ball_visual_rotation_offset).normalized()
|
||||
else:
|
||||
_ball_visual_rotation_offset = Quaternion.IDENTITY
|
||||
|
||||
|
||||
func _remote_percentile(samples: Array[float], fraction: float) -> float:
|
||||
if samples.is_empty():
|
||||
return 0.0
|
||||
var sorted := samples.duplicate()
|
||||
sorted.sort()
|
||||
return sorted[clampi(roundi((sorted.size() - 1) * fraction), 0, sorted.size() - 1)]
|
||||
|
||||
|
||||
func _on_score_update_received(new_score: Dictionary) -> void:
|
||||
score = new_score.duplicate()
|
||||
score_changed.emit(score.duplicate())
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://da8db6ofcbjt2
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlis4s1io7tnd
|
||||
@@ -0,0 +1 @@
|
||||
uid://ci6xqqmag4axj
|
||||
+52
-3
@@ -109,6 +109,9 @@ var _boundary: ArenaBoundary
|
||||
|
||||
var _pending_teleport: Transform3D
|
||||
var _has_pending_teleport := false
|
||||
var _pending_teleport_linear_velocity := Vector3.ZERO
|
||||
var _pending_teleport_angular_velocity := Vector3.ZERO
|
||||
var _pending_teleport_has_velocity := false
|
||||
|
||||
|
||||
# Queues an authoritative teleport, applied at the top of the next
|
||||
@@ -118,6 +121,18 @@ var _has_pending_teleport := false
|
||||
func queue_teleport(to: Transform3D) -> void:
|
||||
_pending_teleport = to
|
||||
_has_pending_teleport = true
|
||||
_pending_teleport_has_velocity = false
|
||||
|
||||
|
||||
# Network hard snaps need the server velocity as their new starting point,
|
||||
# unlike gameplay resets which deliberately zero it. Keep the write queued:
|
||||
# Jolt only permits state mutation from _integrate_forces.
|
||||
func queue_teleport_with_velocity(to: Transform3D, new_linear_velocity: Vector3, new_angular_velocity: Vector3) -> void:
|
||||
_pending_teleport = to
|
||||
_pending_teleport_linear_velocity = new_linear_velocity
|
||||
_pending_teleport_angular_velocity = new_angular_velocity
|
||||
_pending_teleport_has_velocity = true
|
||||
_has_pending_teleport = true
|
||||
|
||||
|
||||
# --- Netcode correction hooks (Phase 4; see multiplayer-todo.md §4.4) ---
|
||||
@@ -133,7 +148,19 @@ var net_vel_correction := Vector3.ZERO
|
||||
# visibly teleporting the mesh. Same decay convention as drag/righting
|
||||
# torque (_tick_scaled) above.
|
||||
var net_visual_offset := Vector3.ZERO
|
||||
var net_visual_rotation_offset := Quaternion.IDENTITY
|
||||
const NET_VISUAL_OFFSET_DECAY := 0.88
|
||||
const MAX_VISUAL_OFFSET := 0.4
|
||||
var net_prediction_contact_window := false # client telemetry only
|
||||
var net_visual_offset_decay := NET_VISUAL_OFFSET_DECAY
|
||||
var net_visual_offset_max := MAX_VISUAL_OFFSET
|
||||
|
||||
|
||||
func set_network_visual_tuning(decay: float, max_offset: float) -> void:
|
||||
# Called only by the local client debug overlay. Server/training ships keep
|
||||
# the constants above and therefore retain their exact existing behavior.
|
||||
net_visual_offset_decay = clampf(decay, 0.5, 0.99)
|
||||
net_visual_offset_max = clampf(max_offset, 0.05, 2.0)
|
||||
|
||||
|
||||
# Feeds thrust_z/turbo into the movement VFX for a ship with no local
|
||||
@@ -144,6 +171,14 @@ func set_visual_action(thrust_z: float, turbo: bool) -> void:
|
||||
_current_action.thrust.z = thrust_z
|
||||
_current_action.turbo = turbo
|
||||
|
||||
|
||||
# The local network sender reads this after this tick's _integrate_forces,
|
||||
# rather than pulling PlayerShipController a second time. That preserves the
|
||||
# one get_action() call per physics tick contract.
|
||||
func get_current_action_copy() -> ShipAction:
|
||||
return _current_action.copy()
|
||||
|
||||
|
||||
# Instrument signals for efficient data distribution
|
||||
signal speed_changed(speed: float)
|
||||
signal attitude_changed(pitch: float, roll: float, yaw: float)
|
||||
@@ -389,12 +424,20 @@ func _has_telemetry_listeners() -> bool:
|
||||
|
||||
|
||||
func _integrate_forces(state):
|
||||
# Reconciliation telemetry needs to distinguish genuine free flight from
|
||||
# Jolt contact windows. This is read only by the locally predicted client;
|
||||
# it never changes forces, actions, collision state, or server behavior.
|
||||
if not multiplayer.is_server():
|
||||
net_prediction_contact_window = state.get_contact_count() > 0
|
||||
if _has_pending_teleport:
|
||||
_has_pending_teleport = false
|
||||
state.transform = _pending_teleport
|
||||
state.linear_velocity = Vector3.ZERO
|
||||
state.angular_velocity = Vector3.ZERO
|
||||
state.linear_velocity = _pending_teleport_linear_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||
state.angular_velocity = _pending_teleport_angular_velocity if _pending_teleport_has_velocity else Vector3.ZERO
|
||||
_pending_teleport_has_velocity = false
|
||||
reset_physics_interpolation()
|
||||
if is_instance_valid(visual):
|
||||
visual.reset_physics_interpolation()
|
||||
|
||||
# --- Netcode correction hook (Phase 4) --- guarded: both fields default
|
||||
# to Vector3.ZERO and nothing writes them yet, so neither branch runs
|
||||
@@ -403,10 +446,16 @@ func _integrate_forces(state):
|
||||
state.linear_velocity += net_vel_correction
|
||||
net_vel_correction = Vector3.ZERO
|
||||
if net_visual_offset != Vector3.ZERO:
|
||||
net_visual_offset *= _tick_scaled(NET_VISUAL_OFFSET_DECAY, state.step)
|
||||
net_visual_offset = net_visual_offset.limit_length(net_visual_offset_max)
|
||||
net_visual_offset *= _tick_scaled(net_visual_offset_decay, state.step)
|
||||
if net_visual_offset.length_squared() < 0.0001:
|
||||
net_visual_offset = Vector3.ZERO
|
||||
visual.position = net_visual_offset
|
||||
if net_visual_rotation_offset != Quaternion.IDENTITY:
|
||||
net_visual_rotation_offset = net_visual_rotation_offset.slerp(Quaternion.IDENTITY, 1.0 - _tick_scaled(net_visual_offset_decay, state.step))
|
||||
if absf(net_visual_rotation_offset.angle_to(Quaternion.IDENTITY)) < 0.001:
|
||||
net_visual_rotation_offset = Quaternion.IDENTITY
|
||||
visual.basis = Basis(net_visual_rotation_offset)
|
||||
|
||||
# One action per physics tick, pulled from the controller (deterministic)
|
||||
_current_action = controller.get_action() if controller else _inert_action
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bkn4t3jpiekba
|
||||
@@ -0,0 +1,31 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const AdaptiveInputDepthController = preload("res://scripts/adaptive_input_depth_controller.gd")
|
||||
|
||||
|
||||
func test_starts_safe_and_enters_zero_only_after_sustained_clean_samples() -> void:
|
||||
var policy := AdaptiveInputDepthController.new()
|
||||
assert_eq(policy.target_depth, 1, "starts at one buffered tick")
|
||||
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS - 1:
|
||||
assert_eq(policy.update(8.0, 2.0, 0), 1, "does not enter zero before enough stable observations")
|
||||
assert_eq(policy.update(8.0, 2.0, 0), 0, "enters zero after the stable observation threshold")
|
||||
|
||||
|
||||
func test_starvation_exits_zero_immediately_and_enforces_cooldown() -> void:
|
||||
var policy := AdaptiveInputDepthController.new()
|
||||
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS:
|
||||
policy.update(8.0, 2.0, 0)
|
||||
assert_eq(policy.target_depth, 0, "precondition: clean link entered zero")
|
||||
assert_eq(policy.update(8.0, 2.0, -2), 1, "starvation sentinel immediately restores one tick")
|
||||
for _i in AdaptiveInputDepthController.REENTRY_COOLDOWN_TICKS:
|
||||
assert_eq(policy.update(8.0, 2.0, 0), 1, "cooldown prevents immediate zero-depth re-entry")
|
||||
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS:
|
||||
policy.update(8.0, 2.0, 0)
|
||||
assert_eq(policy.target_depth, 0, "zero-depth can re-enter only after cooldown plus a fresh stable window")
|
||||
|
||||
|
||||
func test_high_jitter_exits_zero_immediately() -> void:
|
||||
var policy := AdaptiveInputDepthController.new()
|
||||
for _i in AdaptiveInputDepthController.REQUIRED_STABLE_TICKS:
|
||||
policy.update(8.0, 2.0, 0)
|
||||
assert_eq(policy.update(8.0, 5.1, 0), 1, "jitter above exit threshold restores safe depth immediately")
|
||||
@@ -0,0 +1 @@
|
||||
uid://crkx670s4ma3j
|
||||
@@ -93,14 +93,22 @@ func test_ring_wraparound_does_not_confuse_a_stale_slot_with_a_fresh_one() -> vo
|
||||
buf.ingest(0, [_action(0.0)])
|
||||
buf.consume()
|
||||
|
||||
# Advance last_applied_seq well past one full lap of the ring (32
|
||||
# entries) purely via starvation, with nothing re-ingested — every
|
||||
# ring slot's stored seq is now far behind "expected" at each step, so
|
||||
# none of them should ever be misread as valid.
|
||||
# Advance last_applied_seq well past one full lap of the ring (32 entries)
|
||||
# so every stored slot tag is far behind "expected" and none may be
|
||||
# misread as valid.
|
||||
#
|
||||
# This used to drive that purely by starvation with nothing re-ingested.
|
||||
# It can't any more, and shouldn't: starvation only gives up on a sequence
|
||||
# once strictly newer data proves it lost, because advancing past a
|
||||
# sequence the client has not sent yet permanently strands the stream (see
|
||||
# test_starving_ahead_of_the_client_does_not_permanently_discard_its_input).
|
||||
# Drive it the way the real failure does instead — the client's epoch runs
|
||||
# ahead while the intervening packets are lost.
|
||||
var far := InputJitterBuffer.RING_SIZE * 3
|
||||
buf.ingest(far, [_action(0.1)])
|
||||
for i in InputJitterBuffer.RING_SIZE * 2:
|
||||
buf.consume()
|
||||
assert_eq(buf.last_applied_seq, InputJitterBuffer.RING_SIZE * 2, "advanced purely by starvation")
|
||||
assert_true(buf.stalled, "long starvation run ends stalled")
|
||||
assert_true(buf.last_applied_seq > InputJitterBuffer.RING_SIZE, "advanced past a full lap of the ring")
|
||||
|
||||
# Now a fresh packet lands at the seq the ring slot for "expected" was
|
||||
# LAST used for, one full lap ago — if slot-tagging didn't work, this
|
||||
@@ -151,3 +159,76 @@ func test_ring_overflow_resyncs_to_fresh_data_instead_of_starving_forever() -> v
|
||||
# Normal sequential consumption resumes correctly from the resync point.
|
||||
var next := buf.consume()
|
||||
assert_almost_eq(next.thrust.z, float(expected_resync_seq + 1) * 0.01, 0.0001, "next tick continues in order from the resync point")
|
||||
|
||||
|
||||
# --- Starvation must not strand the stream (adversarial review, B2) ---------
|
||||
# consume() used to advance last_applied_seq on EVERY tick including a starve.
|
||||
# Because ingest() discards anything `seq <= last_applied_seq`, one starve on a
|
||||
# sequence the client had not sent yet left the server permanently one ahead of
|
||||
# arrivals: both sides then advance one per tick, the gap never closes, and
|
||||
# every honest packet is discarded on arrival. Reproduced on a clean LAN — the
|
||||
# client's own input_lead release (delta == 0, which issues no new sequence for
|
||||
# one tick) was enough to trigger it, roughly every 6.5s of ordinary play.
|
||||
|
||||
func _thrust(value: float) -> ShipAction:
|
||||
var a := ShipAction.new()
|
||||
a.thrust = Vector3(0.0, 0.0, value)
|
||||
return a
|
||||
|
||||
|
||||
func test_starving_ahead_of_the_client_does_not_permanently_discard_its_input() -> void:
|
||||
var buffer := InputJitterBuffer.new()
|
||||
buffer.ingest(1, [_thrust(1.0)])
|
||||
assert_almost_eq(buffer.consume().thrust.z, 1.0, 0.001, "seq 1 applies normally")
|
||||
|
||||
# The client issues NO new sequence this tick (an input_lead release), so
|
||||
# nothing newer than seq 1 exists. The server must keep expecting seq 2
|
||||
# rather than consuming — and discarding — it.
|
||||
assert_almost_eq(buffer.consume().thrust.z, 1.0, 0.001, "a starve repeats the last action")
|
||||
assert_eq(buffer.last_applied_seq, 1, "and does NOT advance past a sequence the client has not sent")
|
||||
|
||||
# The client's next real packet must still be accepted and applied.
|
||||
buffer.ingest(2, [_thrust(-1.0)])
|
||||
assert_almost_eq(buffer.consume().thrust.z, -1.0, 0.001, "the next honest input is still applied, not discarded")
|
||||
|
||||
|
||||
func test_sustained_release_pattern_does_not_black_out_input() -> void:
|
||||
# The full B2 shape: client and server both advance one per tick, but the
|
||||
# client duplicates one sequence (a release). Pre-fix, every packet from
|
||||
# this point on was discarded and the ship froze for 30 ticks.
|
||||
var buffer := InputJitterBuffer.new()
|
||||
buffer.ingest(1, [_thrust(1.0)])
|
||||
buffer.consume()
|
||||
buffer.consume() # release tick: server starves
|
||||
|
||||
var applied_real_input := 0
|
||||
for seq in range(2, 40):
|
||||
buffer.ingest(seq, [_thrust(1.0)])
|
||||
if absf(buffer.consume().thrust.z - 1.0) < 0.001:
|
||||
applied_real_input += 1
|
||||
assert_true(applied_real_input >= 35, "input keeps flowing after a release (applied %d/38)" % applied_real_input)
|
||||
assert_true(not buffer.stalled, "and the buffer never reports a stall")
|
||||
|
||||
|
||||
func test_a_genuinely_lost_packet_is_still_skipped_rather_than_waited_on() -> void:
|
||||
# The control for the two tests above: holding must not become "wait
|
||||
# forever". When strictly newer data has arrived, the missing sequence is
|
||||
# provably lost or reordered and must be given up on immediately.
|
||||
var buffer := InputJitterBuffer.new()
|
||||
buffer.ingest(1, [_thrust(1.0)])
|
||||
buffer.consume()
|
||||
buffer.ingest(3, [_thrust(-1.0)]) # seq 2 never arrives; 3 does
|
||||
buffer.consume() # starves on 2, but 3 is newer -> skip it
|
||||
assert_eq(buffer.last_applied_seq, 2, "a lost sequence is skipped once newer data exists")
|
||||
assert_almost_eq(buffer.consume().thrust.z, -1.0, 0.001, "and the newer sequence applies on the next tick")
|
||||
|
||||
|
||||
func test_a_silent_client_still_zeroes_and_stalls_on_schedule() -> void:
|
||||
# The other control: holding must not defeat the disconnect behaviour.
|
||||
var buffer := InputJitterBuffer.new()
|
||||
buffer.ingest(1, [_thrust(1.0)])
|
||||
buffer.consume()
|
||||
for i in InputJitterBuffer.STARVE_ZERO_TICKS + 2:
|
||||
buffer.consume()
|
||||
assert_true(buffer.stalled, "a silent client still stalls")
|
||||
assert_almost_eq(buffer.last_action.thrust.z, 0.0, 0.001, "and its ship still stops")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cl18xku0mr8d0
|
||||
@@ -21,6 +21,13 @@ func test_healthy_depth_is_a_normal_tick_and_no_immediate_release() -> void:
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN, "release needs 2s clean, not 10 ticks")
|
||||
|
||||
|
||||
func test_zero_target_treats_an_empty_clean_link_buffer_as_healthy() -> void:
|
||||
var c := InputLeadController.new()
|
||||
for i in 10:
|
||||
assert_eq(c.update(0, 0), 1, "adaptive zero-depth target does not attack on a clean empty buffer")
|
||||
assert_eq(c.lead, InputLeadController.LEAD_MIN, "clean-link target preserves the minimum lead")
|
||||
|
||||
|
||||
# §3.3: "on any starve, increase by up to 3 immediately" — but debounced by
|
||||
# MIN_CHANGE_INTERVAL_TICKS so it isn't literally same-tick.
|
||||
func test_starve_triggers_fast_attack_after_debounce_floor() -> void:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://cpbo0x2qjjgs6
|
||||
@@ -0,0 +1,44 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const LocalInputTimeline = preload("res://scripts/local_input_timeline.gd")
|
||||
const ShipAction = preload("res://scripts/ship_action.gd")
|
||||
|
||||
|
||||
func _action(z: float) -> ShipAction:
|
||||
var result := ShipAction.new()
|
||||
result.thrust.z = z
|
||||
return result
|
||||
|
||||
|
||||
func test_attack_gap_is_materialized_as_repeat_last_actions() -> void:
|
||||
var timeline := LocalInputTimeline.new()
|
||||
timeline.configure_initial_delay(1)
|
||||
timeline.issue(1, _action(0.25))
|
||||
timeline.issue(4, _action(0.75))
|
||||
assert_eq(timeline.latest_issued_seq, 5, "attack advances the outgoing sequence by the requested lead delta")
|
||||
var packet := timeline.packet_actions(4)
|
||||
assert_eq(packet.size(), 4, "redundancy packet carries contiguous actions across the attack gap")
|
||||
assert_almost_eq(packet[0].thrust.z, 0.75, 0.001, "newest attack action is preserved")
|
||||
assert_almost_eq(packet[1].thrust.z, 0.25, 0.001, "gap is repeat-last, matching server consumption")
|
||||
assert_almost_eq(packet[3].thrust.z, 0.25, 0.001, "all attack-gap entries are materialized")
|
||||
|
||||
|
||||
func test_release_retransmits_immutable_sequence_and_carries_new_intent_forward() -> void:
|
||||
var timeline := LocalInputTimeline.new()
|
||||
timeline.configure_initial_delay(1)
|
||||
timeline.issue(1, _action(0.2))
|
||||
timeline.issue(0, _action(0.9))
|
||||
assert_eq(timeline.latest_issued_seq, 1, "release does not relabel an issued action")
|
||||
assert_almost_eq(timeline.packet_actions(4)[0].thrust.z, 0.2, 0.001, "release retransmits immutable issued data")
|
||||
timeline.issue(1, _action(0.9))
|
||||
assert_almost_eq(timeline.packet_actions(4)[0].thrust.z, 0.9, 0.001, "new intent appears on the next unique sequence")
|
||||
|
||||
|
||||
func test_consumption_repeats_last_through_unissued_delay_slots() -> void:
|
||||
var timeline := LocalInputTimeline.new()
|
||||
timeline.configure_initial_delay(3)
|
||||
timeline.issue(1, _action(0.6))
|
||||
assert_almost_eq(timeline.consume()["action"].thrust.z, 0.0, 0.001, "initial delay begins at neutral action")
|
||||
assert_almost_eq(timeline.consume()["action"].thrust.z, 0.0, 0.001, "delay repeats last action")
|
||||
assert_almost_eq(timeline.consume()["action"].thrust.z, 0.0, 0.001, "sequence zero remains neutral")
|
||||
assert_almost_eq(timeline.consume()["action"].thrust.z, 0.6, 0.001, "issued command applies at its scheduled sequence")
|
||||
@@ -0,0 +1 @@
|
||||
uid://0njkbi808cgm
|
||||
@@ -57,6 +57,38 @@ func test_record_and_lookup_do_not_alias_action_or_state() -> void:
|
||||
assert_almost_eq((second_lookup["state"] as NetBodyState).position.x, 3.0, 0.0001, "lookup state cannot mutate ring")
|
||||
|
||||
|
||||
func test_overwrite_state_keeps_the_original_action() -> void:
|
||||
var history := LocalPredictionHistory.new()
|
||||
history.record(8, _action(0.25), _state(1.0))
|
||||
var corrected := _state(9.0)
|
||||
assert_true(history.overwrite_state(8, corrected), "an existing prediction can be backfilled after a snap")
|
||||
assert_true(not history.overwrite_state(9, corrected), "backfill never invents an action for an unrecorded sequence")
|
||||
var prediction := history.get_prediction(8)
|
||||
assert_almost_eq((prediction["action"] as ShipAction).thrust.z, 0.25, 0.0001, "backfill preserves already-sent action")
|
||||
assert_almost_eq((prediction["state"] as NetBodyState).position.x, 9.0, 0.0001, "backfill replaces only state")
|
||||
|
||||
|
||||
func test_rebase_carries_a_soft_correction_through_future_history_once() -> void:
|
||||
var history := LocalPredictionHistory.new()
|
||||
history.record(10, _action(0.1), _state(1.0))
|
||||
history.record(11, _action(0.2), _state(2.0))
|
||||
history.record(12, _action(0.3), _state(3.0))
|
||||
var first_rotation := Quaternion(Vector3.UP, 0.25)
|
||||
history.overwrite_state(10, _state(9.0))
|
||||
history.rebase_state_range(11, 12, Vector3(0.5, -1.0, 0.25), first_rotation, Vector3(1.0, 0.0, 0.0), Vector3(0.0, 2.0, 0.0))
|
||||
var after_first := history.get_prediction(12)
|
||||
assert_almost_eq((after_first["state"] as NetBodyState).position.x, 3.5, 0.0001, "first correction reaches the future state")
|
||||
assert_almost_eq((after_first["action"] as ShipAction).thrust.z, 0.3, 0.0001, "rebase never alters the paired input")
|
||||
|
||||
# The next acknowledgement compares to the rebased state, so only its new
|
||||
# delta is transported. This trace catches the old bug where the first
|
||||
# delta remained in history and was applied again on every snapshot.
|
||||
history.rebase_state_range(12, 12, Vector3(-0.2, 0.0, 0.0), Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO)
|
||||
var after_second := history.get_prediction(12)
|
||||
assert_almost_eq((after_second["state"] as NetBodyState).position.x, 3.3, 0.0001, "a second correction applies only its own delta")
|
||||
assert_almost_eq((after_second["state"] as NetBodyState).linear_velocity.x, 7.0, 0.0001, "future velocity is rebased with the correction")
|
||||
|
||||
|
||||
func test_slot_tags_reject_wrapped_stale_predictions() -> void:
|
||||
var history := LocalPredictionHistory.new()
|
||||
history.record(1, _action(0.1), _state(1.0))
|
||||
@@ -159,3 +191,70 @@ func test_stale_matched_comparison_does_not_clear_a_live_backlog() -> void:
|
||||
|
||||
history.compare_authoritative(far, _state(float(far)))
|
||||
assert_true(not history.resync_required, "acknowledging the newest sequence does clear it")
|
||||
|
||||
|
||||
# --- Unsimulated (attack-gap) sequences -------------------------------------
|
||||
# The input_lead controller's attack path issues and SENDS several sequences
|
||||
# for one local physics step. Those skipped sequences are genuinely outstanding
|
||||
# — the server will acknowledge them — but the client never computed a
|
||||
# post-step state for them. They must be distinguishable from real history
|
||||
# loss, because history loss is a hard-snap condition and this is not.
|
||||
|
||||
func test_unsimulated_gap_is_not_reported_as_missing_history() -> void:
|
||||
var history := LocalPredictionHistory.new()
|
||||
history.record(10, _action(1.0), _state(1.0))
|
||||
history.record_unsimulated(11, _action(1.0))
|
||||
history.record_unsimulated(12, _action(1.0))
|
||||
history.record(13, _action(2.0), _state(2.0))
|
||||
|
||||
var gap := history.compare_authoritative(11, _state(9.0))
|
||||
assert_eq(gap["status"], "unsimulated_gap", "an issued-but-unsimulated seq reports its own status")
|
||||
assert_eq(gap["seq"], 11, "the comparison still identifies the acknowledged sequence")
|
||||
assert_true(gap.has("authoritative_state"), "authority is still handed back for diagnostics")
|
||||
assert_true(not gap.has("position_error"), "no error can be computed without a predicted state")
|
||||
|
||||
var real := history.compare_authoritative(13, _state(2.0))
|
||||
assert_eq(real["status"], "matched", "a genuinely simulated seq still reconciles normally")
|
||||
|
||||
|
||||
func test_unsimulated_gap_carries_the_action_that_went_on_the_wire() -> void:
|
||||
var history := LocalPredictionHistory.new()
|
||||
history.record_unsimulated(4, _action(0.75))
|
||||
var gap := history.compare_authoritative(4, _state(0.0))
|
||||
assert_almost_eq(gap["action"].thrust.z, 0.75, 0.001, "the filled repeat-last action is retained honestly")
|
||||
|
||||
|
||||
func test_unsimulated_slot_refuses_a_fabricated_state() -> void:
|
||||
# §4.4 forbids manufacturing history. Writing a state into a slot the client
|
||||
# never simulated would do exactly that, so both write paths must decline.
|
||||
var history := LocalPredictionHistory.new()
|
||||
history.record_unsimulated(7, _action(1.0))
|
||||
assert_true(not history.overwrite_state(7, _state(5.0)), "overwrite_state declines an unsimulated slot")
|
||||
assert_eq(history.compare_authoritative(7, _state(0.0))["status"], "unsimulated_gap", "the slot stays stateless")
|
||||
|
||||
history.rebase_state_range(7, 7, Vector3.ONE, Quaternion.IDENTITY, Vector3.ONE, Vector3.ONE)
|
||||
assert_eq(history.compare_authoritative(7, _state(0.0))["status"], "unsimulated_gap", "rebase skips it rather than seeding a state")
|
||||
|
||||
|
||||
func test_rebase_skips_unsimulated_entries_without_stopping_at_them() -> void:
|
||||
# A gap sitting between two real predictions must not truncate the rebase:
|
||||
# the entries after it still describe the pre-correction trajectory.
|
||||
var history := LocalPredictionHistory.new()
|
||||
history.record(1, _action(1.0), _state(1.0))
|
||||
history.record_unsimulated(2, _action(1.0))
|
||||
history.record(3, _action(1.0), _state(1.0))
|
||||
|
||||
history.rebase_state_range(1, 3, Vector3(10.0, 0.0, 0.0), Quaternion.IDENTITY, Vector3.ZERO, Vector3.ZERO)
|
||||
var after := history.get_prediction(3)
|
||||
assert_almost_eq(after["state"].position.x, _state(1.0).position.x + 10.0, 0.001, "the entry past the gap was still rebased")
|
||||
|
||||
|
||||
func test_unsimulated_gap_still_advances_the_acknowledgement_clock() -> void:
|
||||
# The gap sequence IS outstanding, so it must count toward the ring's
|
||||
# unacknowledged-capacity bookkeeping exactly as a simulated one does —
|
||||
# otherwise a burst of attacks would silently under-report the backlog.
|
||||
var history := LocalPredictionHistory.new()
|
||||
history.record(1, _action(1.0), _state(1.0))
|
||||
history.record_unsimulated(200, _action(1.0))
|
||||
assert_eq(history.newest_recorded_seq, 200, "an unsimulated record advances the newest-seq cursor")
|
||||
assert_true(history.resync_required, "it also trips the same unacknowledged-capacity guard")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://deh3hsn12c4k2
|
||||
@@ -0,0 +1 @@
|
||||
uid://dcw2l88s5as1w
|
||||
@@ -0,0 +1 @@
|
||||
uid://bul5evnyqmk2r
|
||||
@@ -0,0 +1,24 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const NetInterpolator = preload("res://scripts/net_interpolator.gd")
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
|
||||
|
||||
func test_extrapolation_integrates_angular_velocity() -> void:
|
||||
var interpolator := NetInterpolator.new()
|
||||
var first := NetBodyState.new()
|
||||
first.angular_velocity = Vector3.UP * PI
|
||||
interpolator.add_sample(1, first, 0)
|
||||
var second := first.copy()
|
||||
interpolator.add_sample(2, second, 0)
|
||||
var result := interpolator.sample_at(2.0 + 0.1 * 1000.0 / NetInterpolator.TICK_MS)
|
||||
assert_almost_eq(result.rotation.angle_to(Quaternion(Vector3.UP, PI * 0.1)), 0.0, 0.001, "present-time extrapolation advances rotation from angular velocity")
|
||||
|
||||
|
||||
func test_stale_pre_reset_packet_cannot_reopen_an_old_epoch() -> void:
|
||||
var interpolator := NetInterpolator.new()
|
||||
interpolator.add_sample(10, NetBodyState.new(), 0)
|
||||
interpolator.add_sample(20, NetBodyState.new(), 1)
|
||||
assert_eq(interpolator.reset_gen, 1, "newer reset establishes the new epoch")
|
||||
assert_true(not interpolator.add_sample(15, NetBodyState.new(), 0), "late old-generation snapshot is rejected before reset handling")
|
||||
assert_eq(interpolator.reset_gen, 1, "stale packet cannot flip reset generation back")
|
||||
@@ -0,0 +1 @@
|
||||
uid://b6uev62y5va25
|
||||
@@ -0,0 +1,108 @@
|
||||
extends "res://tests/test_case.gd"
|
||||
|
||||
const NetShipPredictor = preload("res://scripts/net_ship_predictor.gd")
|
||||
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
||||
const LocalPredictionHistory = preload("res://scripts/local_prediction_history.gd")
|
||||
|
||||
|
||||
func _authoritative() -> NetBodyState:
|
||||
return NetBodyState.new()
|
||||
|
||||
|
||||
func _matched(position_error: float = 0.0, rotation_error_degrees: float = 0.0) -> Dictionary:
|
||||
return {
|
||||
"status": "matched",
|
||||
"authoritative_state": _authoritative(),
|
||||
"position_error_magnitude": position_error,
|
||||
"rotation_error_degrees": rotation_error_degrees,
|
||||
}
|
||||
|
||||
|
||||
func test_soft_correction_within_thresholds() -> void:
|
||||
var decision := NetShipPredictor.decide(_matched(2.0, 60.0), false, false)
|
||||
assert_eq(decision["mode"], "soft", "thresholds are strict greater-than, so exact boundary soft-corrects")
|
||||
assert_eq(decision["reason"], "within_thresholds", "soft decision records why")
|
||||
|
||||
|
||||
func test_hard_correction_for_missing_prediction_or_reset() -> void:
|
||||
var missing := NetShipPredictor.decide({"status": "missing_evicted", "authoritative_state": _authoritative()}, false, false)
|
||||
assert_eq(missing["mode"], "hard", "an unavailable same-sequence prediction must snap")
|
||||
assert_eq(missing["reason"], "missing_evicted", "missing cause remains inspectable")
|
||||
var reset := NetShipPredictor.decide(_matched(), false, true)
|
||||
assert_eq(reset["mode"], "hard", "a new reset generation never blends across a teleport")
|
||||
|
||||
|
||||
func test_hard_correction_for_flags_or_large_error() -> void:
|
||||
var frozen_state := _matched()
|
||||
(frozen_state["authoritative_state"] as NetBodyState).frozen = true
|
||||
assert_eq(NetShipPredictor.decide(frozen_state, false, false)["reason"], "frozen_mismatch", "authority frozen flag wins over local simulation")
|
||||
assert_eq(NetShipPredictor.decide(_matched(2.01), false, false)["reason"], "position_error", "position beyond 2m snaps")
|
||||
assert_eq(NetShipPredictor.decide(_matched(0.0, 60.01), false, false)["reason"], "rotation_error", "rotation beyond 60 degrees snaps")
|
||||
|
||||
|
||||
func test_soft_correction_applies_past_authority_as_a_delta_to_current_pose() -> void:
|
||||
var predicted := _authoritative()
|
||||
predicted.position = Vector3(10.0, 0.0, 0.0)
|
||||
var authoritative := _authoritative()
|
||||
authoritative.position = Vector3(10.5, 0.0, 0.0)
|
||||
var comparison := {
|
||||
"predicted_state": predicted,
|
||||
"authoritative_state": authoritative,
|
||||
}
|
||||
var current := Transform3D(Basis.IDENTITY, Vector3(14.0, 2.0, -3.0))
|
||||
var corrected := NetShipPredictor.soft_corrected_transform(current, comparison)
|
||||
assert_almost_eq(corrected.origin.x, 14.5, 0.0001, "the correction moves current state by the same-sequence error")
|
||||
assert_almost_eq(corrected.origin.y, 2.0, 0.0001, "unrelated current coordinates are preserved")
|
||||
assert_almost_eq(corrected.origin.z, -3.0, 0.0001, "soft correction does not rewind to the old authoritative pose")
|
||||
|
||||
|
||||
func test_missing_history_places_once_then_suppresses_old_acknowledgements() -> void:
|
||||
var predictor := NetShipPredictor.new()
|
||||
var ship := Ship.new()
|
||||
var history := LocalPredictionHistory.new()
|
||||
var missing := {"status": "missing_not_recorded", "seq": 4, "authoritative_state": _authoritative()}
|
||||
assert_eq(predictor.reconcile(missing, ship, 0, 10, history)["mode"], "hard", "first unavailable acknowledgement performs one resync placement")
|
||||
assert_eq(predictor.reconcile(missing, ship, 0, 11, history)["mode"], "suppressed", "older unavailable acknowledgements do not create a snap burst")
|
||||
ship.free()
|
||||
|
||||
|
||||
func test_reset_preempts_missing_history_suppression() -> void:
|
||||
var predictor := NetShipPredictor.new()
|
||||
var ship := Ship.new()
|
||||
var history := LocalPredictionHistory.new()
|
||||
var missing := {"status": "missing_not_recorded", "seq": 4, "authoritative_state": _authoritative()}
|
||||
predictor.reconcile(missing, ship, 0, 10, history)
|
||||
assert_eq(predictor.reconcile(missing, ship, 0, 11, history)["mode"], "suppressed", "old missing acknowledgement is suppressed during recovery")
|
||||
var reset_authority := _authoritative()
|
||||
reset_authority.position = Vector3(7.0, 2.0, -3.0)
|
||||
var reset_missing := {"status": "missing_not_recorded", "seq": 5, "authoritative_state": reset_authority}
|
||||
var reset := predictor.reconcile(reset_missing, ship, 1, 12, history)
|
||||
assert_eq(reset["reason"], "reset_gen", "a changed reset generation preempts recovery suppression")
|
||||
ship.free()
|
||||
|
||||
|
||||
# An attack's skipped sequence is acknowledged by the server but was never
|
||||
# locally simulated. It is a routine product of this client's own lead control
|
||||
# — not history loss — so it must not be treated as a snap condition.
|
||||
|
||||
func test_unsimulated_gap_is_skipped_not_snapped() -> void:
|
||||
var decision := NetShipPredictor.decide({"status": "unsimulated_gap", "seq": 5, "authoritative_state": _authoritative()}, false, false)
|
||||
assert_eq(decision["mode"], "skip", "an unsimulated gap is neither soft nor hard")
|
||||
assert_eq(decision["reason"], "unsimulated_gap", "the reason names the real cause")
|
||||
|
||||
|
||||
func test_genuine_missing_history_is_still_a_hard_snap() -> void:
|
||||
# The control for the test above: the two statuses must not have been
|
||||
# collapsed together while making gaps benign.
|
||||
for status in ["missing_not_recorded", "missing_evicted"]:
|
||||
var decision := NetShipPredictor.decide({"status": status, "seq": 5, "authoritative_state": _authoritative()}, false, false)
|
||||
assert_eq(decision["mode"], "hard", "%s must still hard-correct" % status)
|
||||
|
||||
|
||||
func test_a_reset_still_wins_over_an_unsimulated_gap() -> void:
|
||||
# Ordering guard: reset_gen is an epoch boundary and outranks everything,
|
||||
# including the new skip path — otherwise a gap landing on the reset
|
||||
# snapshot would silently discard the epoch change.
|
||||
var decision := NetShipPredictor.decide({"status": "unsimulated_gap", "seq": 5, "authoritative_state": _authoritative()}, false, true)
|
||||
assert_eq(decision["mode"], "hard", "reset still takes precedence")
|
||||
assert_eq(decision["reason"], "reset_gen", "and is still attributed to the reset")
|
||||
@@ -0,0 +1 @@
|
||||
uid://luffsv8s5huc
|
||||
@@ -0,0 +1 @@
|
||||
uid://3ytp2tiefu6u
|
||||
@@ -0,0 +1 @@
|
||||
uid://bk1qxbe10nql6
|
||||
@@ -0,0 +1 @@
|
||||
uid://qp75ucmgd6kr
|
||||
@@ -0,0 +1 @@
|
||||
uid://v1c1ne02bal
|
||||
@@ -0,0 +1 @@
|
||||
uid://5awtyloaix6o
|
||||
@@ -0,0 +1 @@
|
||||
uid://g5ty301vv5ui
|
||||
@@ -0,0 +1 @@
|
||||
uid://b42fq1fsu0q24
|
||||
@@ -0,0 +1 @@
|
||||
uid://dlej3jgbua05l
|
||||
@@ -0,0 +1 @@
|
||||
uid://dr7b7036oovhv
|
||||
@@ -9,17 +9,34 @@ extends Node
|
||||
# godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=client
|
||||
|
||||
const PORT := 7812
|
||||
const SETTLE_SECONDS := 2.0 # time to let match_config + a few snapshots land before checking spawn state
|
||||
const DRIVE_SECONDS := 2.0 # time to hold forward thrust and let the ship actually move
|
||||
const HOST_LIFETIME_SECONDS := 10.0
|
||||
const DEFAULT_SETTLE_SECONDS := 2.0 # time to let match_config + a few snapshots land before checking spawn state
|
||||
const DEFAULT_DRIVE_SECONDS := 2.0 # time to hold forward thrust and let the ship actually move
|
||||
|
||||
var _role := ""
|
||||
var _settle_seconds := DEFAULT_SETTLE_SECONDS
|
||||
var _drive_seconds := DEFAULT_DRIVE_SECONDS
|
||||
var _exercise_ball_contact := false
|
||||
var _exercise_free_flight := false
|
||||
var _exercise_input_transitions := false
|
||||
var _warmup_seconds := 0.0
|
||||
|
||||
|
||||
func _ready() -> void:
|
||||
for arg in OS.get_cmdline_user_args():
|
||||
if arg.begins_with("--role="):
|
||||
_role = arg.substr("--role=".length())
|
||||
elif arg.begins_with("--settle-seconds="):
|
||||
_settle_seconds = maxf(0.5, arg.get_slice("=", 1).to_float())
|
||||
elif arg.begins_with("--drive-seconds="):
|
||||
_drive_seconds = maxf(0.5, arg.get_slice("=", 1).to_float())
|
||||
elif arg == "--exercise-ball-contact":
|
||||
_exercise_ball_contact = true
|
||||
elif arg == "--exercise-free-flight":
|
||||
_exercise_free_flight = true
|
||||
elif arg == "--exercise-input-transitions":
|
||||
_exercise_input_transitions = true
|
||||
elif arg.begins_with("--warmup-seconds="):
|
||||
_warmup_seconds = maxf(0.0, arg.get_slice("=", 1).to_float())
|
||||
|
||||
match _role:
|
||||
"host":
|
||||
@@ -75,7 +92,9 @@ func _on_host_player_joined(_peer_id: int, _name: String) -> void:
|
||||
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
|
||||
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
|
||||
get_tree().root.add_child.call_deferred(hooks)
|
||||
hooks.run_host_check.call_deferred(HOST_LIFETIME_SECONDS)
|
||||
# The host must outlive client settle + drive, plus connection/shutdown
|
||||
# slack. This keeps --drive-seconds useful for sustained prediction QA.
|
||||
hooks.run_host_check.call_deferred(_settle_seconds + _warmup_seconds + _drive_seconds + 4.0)
|
||||
|
||||
|
||||
func _on_client_welcomed() -> void:
|
||||
@@ -84,7 +103,7 @@ func _on_client_welcomed() -> void:
|
||||
get_tree().change_scene_to_file.call_deferred("res://scenes/networked_match.tscn")
|
||||
var hooks := preload("res://tests/networked_match_test_hooks.gd").new()
|
||||
get_tree().root.add_child.call_deferred(hooks)
|
||||
hooks.run_client_check.call_deferred(SETTLE_SECONDS, DRIVE_SECONDS)
|
||||
hooks.run_client_check.call_deferred(_settle_seconds, _drive_seconds, _exercise_ball_contact, _exercise_free_flight, _warmup_seconds, _exercise_input_transitions)
|
||||
|
||||
|
||||
func _on_abuser_welcomed() -> void:
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://bn5tgox8nci0f
|
||||
@@ -15,6 +15,7 @@ extends Node
|
||||
# annotations wherever `:=` would otherwise fail to infer one.
|
||||
|
||||
const NetworkedMatchScript = preload("res://scripts/networked_match.gd")
|
||||
const BALL_BLEND_ACCEPTANCE_MS := 170 # 150ms contract + one rendered-frame allowance
|
||||
|
||||
|
||||
func _is_networked_match(node: Node) -> bool:
|
||||
@@ -42,12 +43,12 @@ func run_host_check(lifetime_seconds: float) -> void:
|
||||
await get_tree().create_timer(lifetime_seconds * 0.6).timeout
|
||||
if _is_networked_match(match_scene) and not match_scene.ships.is_empty():
|
||||
var ship: Ship = match_scene.ships[0]
|
||||
print("SMOKE INFO: host ship final position=%s (spawned, driven by client input if any arrived)" % str(ship.global_position))
|
||||
print("SMOKE INFO: host ship final position=%s action=%s (spawned, driven by client input if any arrived)" % [str(ship.global_position), str(ship.get_current_action_copy().thrust)])
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
func run_client_check(settle_seconds: float, drive_seconds: float, exercise_ball_contact: bool = false, exercise_free_flight: bool = false, warmup_seconds: float = 0.0, exercise_input_transitions: bool = false) -> void:
|
||||
await get_tree().create_timer(settle_seconds).timeout
|
||||
|
||||
var match_scene := get_tree().current_scene
|
||||
@@ -64,7 +65,7 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
var hud_ok: bool = is_instance_valid(match_scene.hud)
|
||||
var start_position := Vector3.ZERO
|
||||
if my_slot_ok:
|
||||
start_position = my_slot.ship.visual.global_position
|
||||
start_position = my_slot.ship.global_position
|
||||
|
||||
print("SMOKE INFO: client slots_ok=%s ball_ok=%s my_slot_ok=%s camera_ok=%s hud_ok=%s start_pos=%s" % [
|
||||
str(slots_ok), str(ball_ok), str(my_slot_ok), str(camera_ok), str(hud_ok), str(start_position)
|
||||
@@ -74,6 +75,7 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
print("SMOKE FAIL: spawn/wiring check failed")
|
||||
get_tree().quit(1)
|
||||
return
|
||||
match_scene._local_ship_predictor.clear_metrics()
|
||||
|
||||
# Drive forward thrust (a real, held key state — exercises the actual
|
||||
# client input path, not a synthetic RPC call) and confirm the ship
|
||||
@@ -81,22 +83,76 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
# value) actually moved — proving input reached the server, the server
|
||||
# applied real thruster force, broadcast it back, and the client's
|
||||
# interpolator produced smooth motion from it.
|
||||
Input.action_press("move_forward")
|
||||
await get_tree().create_timer(drive_seconds * 0.5).timeout
|
||||
if exercise_ball_contact:
|
||||
# Slot T0/S0 needs a short diagonal burst to reach the centre ball.
|
||||
# Release it immediately and leave a >150ms observation window before
|
||||
# the normal drive, so a subsequent goal reset cannot mask blend-back.
|
||||
Input.action_press("move_forward")
|
||||
Input.action_press("move_right")
|
||||
await get_tree().create_timer(1.1).timeout
|
||||
Input.action_release("move_right")
|
||||
Input.action_release("move_forward")
|
||||
await get_tree().create_timer(0.35).timeout
|
||||
if not exercise_free_flight and not exercise_input_transitions:
|
||||
Input.action_press("move_forward")
|
||||
if warmup_seconds > 0.0:
|
||||
await get_tree().create_timer(warmup_seconds).timeout
|
||||
match_scene._local_ship_predictor.clear_metrics()
|
||||
var free_flight_peak_distance := 0.0
|
||||
if exercise_input_transitions:
|
||||
# Deliberate action-sequence-label probe. A HELD input cannot falsify
|
||||
# the history's seq labelling: while thrust is constant, "the intent
|
||||
# from this tick" and "the action the server consumes for seq S" carry
|
||||
# the same value whichever seq the state is filed under, so the action
|
||||
# marker reports 0 mismatches under a correct AND an incorrect label.
|
||||
# Only a transition exposes the difference, and it exposes it for
|
||||
# roughly input_lead ticks per edge. Toggle forward thrust on a short
|
||||
# period so the run is mostly edges.
|
||||
await _run_input_transition_trace(drive_seconds)
|
||||
elif exercise_free_flight:
|
||||
# A straight 60-second forward trace reaches the goal/wall in seconds
|
||||
# and turns the supposed free-flight QA run into a contact test. Hover
|
||||
# in the open volume with alternating vertical thrust and yaw instead:
|
||||
# it remains a sustained real thrust/turn/airborne trace without ever
|
||||
# manufacturing a wall or goal contact.
|
||||
free_flight_peak_distance = await _run_free_flight_trace(my_slot.ship, start_position, drive_seconds)
|
||||
else:
|
||||
await get_tree().create_timer(drive_seconds * 0.5).timeout
|
||||
|
||||
# Task 2.6: the server-computed thrust_z it broadcast in the snapshot
|
||||
# should have reached this client's interpolator and be readable off
|
||||
# the latest sample — this is what set_visual_action's engine-flame
|
||||
# wiring actually reads, so it's the real thing to check, not just
|
||||
# "the ship physically moved" (which 2.6 doesn't claim on its own).
|
||||
var latest_state = my_slot.interpolator.latest()
|
||||
var thrust_z_ok: bool = latest_state != null and latest_state.thrust_z > 0.5
|
||||
print("SMOKE INFO: mid-drive thrust_z=%.2f (expect >0.5 while holding forward)" % (latest_state.thrust_z if latest_state != null else -1.0))
|
||||
# Phase 4.3: own ship is a genuine unfrozen local simulation. Its slot
|
||||
# intentionally receives no NetInterpolator samples; a controller attached
|
||||
# to the body supplies the one action used by this tick's physics step.
|
||||
var local_prediction_ok: bool = not my_slot.ship.freeze \
|
||||
and my_slot.ship.controller != null \
|
||||
and my_slot.ship.controller.get_parent() == my_slot.ship \
|
||||
and not my_slot.interpolator.has_samples() \
|
||||
and (my_slot.ship.get_current_action_copy().thrust.z > 0.5 or absf(my_slot.ship.get_current_action_copy().thrust.y) > 0.5)
|
||||
print("SMOKE INFO: local_prediction=%s freeze=%s controller_attached=%s local_interpolator_samples=%s" % [
|
||||
str(local_prediction_ok), str(my_slot.ship.freeze), str(my_slot.ship.controller != null and my_slot.ship.controller.get_parent() == my_slot.ship), str(my_slot.interpolator.has_samples())
|
||||
])
|
||||
|
||||
await get_tree().create_timer(drive_seconds * 0.5).timeout
|
||||
if not exercise_free_flight and not exercise_input_transitions:
|
||||
await get_tree().create_timer(drive_seconds * 0.5).timeout
|
||||
Input.action_release("move_forward")
|
||||
Input.action_release("move_up")
|
||||
Input.action_release("move_down")
|
||||
Input.action_release("turn_left")
|
||||
Input.action_release("turn_right")
|
||||
if exercise_ball_contact:
|
||||
# Leave enough wall time for the bounded RTT window plus the 150ms
|
||||
# handoff blend to finish before inspecting lifecycle telemetry.
|
||||
await get_tree().create_timer(0.35).timeout
|
||||
|
||||
var end_position: Vector3 = my_slot.ship.visual.global_position
|
||||
var end_position: Vector3 = my_slot.ship.global_position
|
||||
var prediction_stats: Dictionary = match_scene.get_net_debug_stats().get("prediction", {})
|
||||
var net_stats: Dictionary = match_scene.get_net_debug_stats()
|
||||
print("SMOKE INFO: prediction samples=%s raw_p95=%.3f raw_p99=%.3f free_samples=%s raw_free_p95=%.3f raw_free_p99=%.3f visual_free_p95=%.3f visual_free_p99=%.3f hard_snaps=%s free_hard_snaps=%s rate=%.2f/min hard_reasons=%s cohorts=%s marker=%s/%s replay=%s lead=%s target=%s buffer=%s ball_contacts=%s latest_error=%s latest_velocity_error=%s" % [
|
||||
str(prediction_stats.get("sample_count", 0)), prediction_stats.get("position_error_p95", 0.0), prediction_stats.get("position_error_p99", 0.0),
|
||||
str(prediction_stats.get("free_flight_sample_count", 0)), prediction_stats.get("free_flight_position_error_p95", 0.0), prediction_stats.get("free_flight_position_error_p99", 0.0), prediction_stats.get("free_flight_visual_correction_p95", 0.0), prediction_stats.get("free_flight_visual_correction_p99", 0.0),
|
||||
str(prediction_stats.get("hard_snap_count", 0)), str(prediction_stats.get("hard_snap_cohorts", {}).get("free_flight", 0)), prediction_stats.get("hard_snap_rate_per_min", 0.0), str(prediction_stats.get("hard_snap_reasons", {})), str(prediction_stats.get("cohorts", {})), str(net_stats.get("action_marker_mismatches", 0)), str(net_stats.get("action_marker_samples", 0)), str(prediction_stats.get("last_replay_count", 0)),
|
||||
str(net_stats.get("input_lead", "-")), str(net_stats.get("input_target_depth", "-")), str(net_stats.get("input_buffer_depth", "-")), str(net_stats.get("ball_prediction_contacts", 0)),
|
||||
str(net_stats.get("latest_prediction_error", Vector3.ZERO)), str(net_stats.get("latest_prediction_velocity_error", Vector3.ZERO))
|
||||
])
|
||||
var moved := start_position.distance_to(end_position)
|
||||
# Horizontal-only (XZ), not full 3D distance: an adversarial review
|
||||
# found a 1.2s window of completely dead input still registers ~1.07m
|
||||
@@ -106,6 +162,7 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
# horizontal force (see ship.gd), so measuring XZ displacement can't
|
||||
# be satisfied by gravity alone, regardless of spawn height or timing.
|
||||
var moved_horizontal := Vector2(end_position.x, end_position.z).distance_to(Vector2(start_position.x, start_position.z))
|
||||
var verification_movement := free_flight_peak_distance if exercise_free_flight else moved_horizontal
|
||||
print("SMOKE INFO: client ship moved %.2fm (%.2fm horizontal) (start=%s end=%s) while holding forward thrust for %.1fs" % [
|
||||
moved, moved_horizontal, str(start_position), str(end_position), drive_seconds
|
||||
])
|
||||
@@ -115,15 +172,180 @@ func run_client_check(settle_seconds: float, drive_seconds: float) -> void:
|
||||
# A generous, not-tuned-to-the-decimal bound: this is a wiring smoke
|
||||
# test, not a physics-accuracy test (net_codec's own tests already cover
|
||||
# quantisation precision).
|
||||
var success := moved_horizontal > 1.0 and thrust_z_ok
|
||||
print("SMOKE %s: client observed %.2fm horizontal of server-authoritative movement via interpolation, thrust_z_ok=%s" % [
|
||||
"PASS" if success else "FAIL", moved_horizontal, str(thrust_z_ok)
|
||||
# The contact path intentionally includes a goal/reset in this trace, so
|
||||
# its expected authoritative snaps are reported separately rather than
|
||||
# contaminating the contact-free prediction gate.
|
||||
# The scheduled local timeline now predicts the same command stream the
|
||||
# server consumes, so both the same-sequence raw residual and the exposed
|
||||
# render discontinuity are meaningful free-flight gates. Hard corrections
|
||||
# remain separately gated by cohort.
|
||||
var raw_quality_p95: float = float(prediction_stats.get("free_flight_position_error_p95", INF))
|
||||
var raw_quality_p99: float = float(prediction_stats.get("free_flight_position_error_p99", INF))
|
||||
var raw_rotation_p95: float = float(prediction_stats.get("free_flight_rotation_error_p95", INF))
|
||||
var raw_rotation_p99: float = float(prediction_stats.get("free_flight_rotation_error_p99", INF))
|
||||
var quality_p95: float = float(prediction_stats.get("free_flight_visual_correction_p95", INF))
|
||||
var quality_p99: float = float(prediction_stats.get("free_flight_visual_correction_p99", INF))
|
||||
var quality_samples := int(prediction_stats.get("free_flight_sample_count", 0))
|
||||
var free_flight_hard_snaps := int(prediction_stats.get("hard_snap_cohorts", {}).get("free_flight", 99))
|
||||
# The action-sequence-label gate. Every other mode here holds its inputs
|
||||
# steady or near-steady, and a steady input CANNOT falsify the history's
|
||||
# seq labelling: while the commanded action is constant, "the intent from
|
||||
# this tick" and "the action the server consumes for seq S" carry the same
|
||||
# value under a correct and an incorrect label alike, so the action marker
|
||||
# reads 0/N either way. That is precisely how a real mislabelling survived
|
||||
# every earlier Phase 4 gate. Only this mode's forced edges expose it, so
|
||||
# only this mode asserts on the marker.
|
||||
#
|
||||
# Measured separation is wide, not marginal: labelling post-step state at
|
||||
# the server-consumption estimate reported 9.3% mismatch on LAN
|
||||
# (input_lead 1) and 24% at 80±20ms (input_lead 3) — it scales with the
|
||||
# lead, as the mechanism predicts — against 0-1.3% once filed under the
|
||||
# issuing sequence. The residual is seq-delta events (an attack issues
|
||||
# several sequences for one local physics step, a release duplicates one),
|
||||
# which relabelling does not claim to fix.
|
||||
var marker_samples := int(net_stats.get("action_marker_samples", 0))
|
||||
var marker_mismatches := int(net_stats.get("action_marker_mismatches", 99))
|
||||
var marker_rate := float(marker_mismatches) / float(maxi(marker_samples, 1))
|
||||
# The sample floor scales with the run, and that is load-bearing rather than
|
||||
# tidiness. An adversarial review reproduced a total, permanent input
|
||||
# blackout (a 3.5s host freeze) that this gate reported as PASS at 3.76%:
|
||||
# once reconciliation is suppressed, _record_metrics stops being called, so
|
||||
# the marker stops sampling entirely — the WORSE the outage, the FEWER
|
||||
# samples and the LOWER the reported mismatch rate. A flat ">= 200" is
|
||||
# satisfied by the handful of acks either side of the outage. Snapshots ack
|
||||
# at ~60Hz, so require half of nominal and a run this short is provably
|
||||
# still exchanging input for most of its length.
|
||||
var marker_sample_floor := maxi(200, int(drive_seconds * 30.0))
|
||||
var marker_samples_ok := marker_samples >= marker_sample_floor
|
||||
# Server-side starvation bit, round-tripped over the wire. A client flying
|
||||
# on pure prediction with the server ignoring it satisfies every other
|
||||
# assertion here, because all of them read the CLIENT's own action and
|
||||
# position.
|
||||
var server_stalled := bool(net_stats.get("server_stalled", false))
|
||||
# 5%, not 3%: the irreducible residual is seq-delta events and it scales
|
||||
# with input_lead, reaching 2.22% at lead 3 under 80±20ms — too close to a
|
||||
# 3% line for a CI gate. Separation from a genuinely mislabelled build is
|
||||
# 10-20x either way (control runs measure 24-50%), so the extra headroom
|
||||
# costs no real detection power. Tighten this only alongside recording
|
||||
# predictions for an attack's filled gap sequences.
|
||||
const MAX_ACTION_MARKER_MISMATCH_RATE := 0.05
|
||||
var action_label_ok: bool = marker_samples_ok and not server_stalled and marker_rate < MAX_ACTION_MARKER_MISMATCH_RATE
|
||||
var prediction_quality_ok: bool = action_label_ok if exercise_input_transitions else \
|
||||
prediction_stats.get("hard_snap_count", 99) < 4 if exercise_ball_contact else \
|
||||
quality_samples >= 30 \
|
||||
and raw_quality_p95 < 0.5 \
|
||||
and raw_quality_p99 < 2.0 \
|
||||
and raw_rotation_p95 < 5.0 \
|
||||
and raw_rotation_p99 < 15.0 \
|
||||
and quality_p95 < 0.5 \
|
||||
and quality_p99 < 2.0 \
|
||||
and free_flight_hard_snaps == 0
|
||||
# ball_proxy_moved_before_authority counts ticks where the predicted proxy
|
||||
# had visibly moved BEFORE the next authoritative ball state arrived. That
|
||||
# is only a meaningful — or even achievable — claim when there is real RTT
|
||||
# to mask: snapshots land every ~16.7ms at 60Hz, so on a loopback LAN the
|
||||
# whole pre-authority window is about one physics tick and whether it is
|
||||
# observed is a coin flip on arrival timing. Measured 2 failures in 5 LAN
|
||||
# runs, versus 5/5 passes (count 2-3) at --net-sim-latency=80, with the
|
||||
# same-frame reveal itself correct in every single run either way.
|
||||
#
|
||||
# So require it only when the link actually has latency to hide, and let
|
||||
# the same-frame reveal carry the gate on LAN — that is the real claim
|
||||
# ("your own touches register on contact, not ~RTT later") and it is not
|
||||
# racy. Runs asserting the masking behaviour should pass --net-sim-latency.
|
||||
var rtt_ms := NetworkManager.rtt_ms
|
||||
var rtt_masks_authority := rtt_ms >= 20.0
|
||||
var proxy_motion_ok: bool = not rtt_masks_authority or int(net_stats.get("ball_proxy_moved_before_authority_count", 0)) > 0
|
||||
var ball_contact_ok := not exercise_ball_contact or (int(net_stats.get("ball_prediction_contacts", 0)) > 0 \
|
||||
and int(net_stats.get("ball_contact_frame", -1)) == int(net_stats.get("ball_reveal_frame", -2)) \
|
||||
and int(net_stats.get("ball_blend_complete_count", 0)) > 0 \
|
||||
and int(net_stats.get("ball_blend_max_duration_ms", BALL_BLEND_ACCEPTANCE_MS)) <= BALL_BLEND_ACCEPTANCE_MS \
|
||||
and proxy_motion_ok)
|
||||
var success := verification_movement > 1.0 and local_prediction_ok and prediction_quality_ok and ball_contact_ok
|
||||
print("SMOKE %s: client locally predicted %.2fm horizontal, local_prediction_ok=%s prediction_quality_ok=%s" % [
|
||||
"PASS" if success else "FAIL", moved_horizontal, str(local_prediction_ok), str(prediction_quality_ok)
|
||||
])
|
||||
if exercise_input_transitions:
|
||||
print("SMOKE %s: action-sequence labelling under forced input transitions (marker=%d/%d = %.2f%% mismatch, want <%.0f%%; samples %d/%d required; server_stalled=%s; input_lead=%s)" % [
|
||||
"PASS" if action_label_ok else "FAIL", marker_mismatches, marker_samples, marker_rate * 100.0, MAX_ACTION_MARKER_MISMATCH_RATE * 100.0,
|
||||
marker_samples, marker_sample_floor, str(server_stalled), str(net_stats.get("input_lead", "-")),
|
||||
])
|
||||
if exercise_ball_contact:
|
||||
print("SMOKE %s: local dynamic ball proxy registered a same-frame reveal (contact_frame=%s reveal_frame=%s pre_authority_motion=%s hard_handoffs=%s blend_started=%s blends=%s blend_max_ms=%s ends=%s missing_shadow=%s reset_cancels=%s reset_trace=%s)" % [
|
||||
"PASS" if ball_contact_ok else "FAIL", str(net_stats.get("ball_contact_frame", -1)), str(net_stats.get("ball_reveal_frame", -1)), str(net_stats.get("ball_proxy_moved_before_authority_count", 0)),
|
||||
str(net_stats.get("ball_hard_handoff_count", 0)), str(net_stats.get("ball_blend_started_count", 0)), str(net_stats.get("ball_blend_complete_count", 0)), str(net_stats.get("ball_blend_max_duration_ms", 0)),
|
||||
str(net_stats.get("ball_prediction_window_end_count", 0)), str(net_stats.get("ball_prediction_missing_shadow_count", 0)), str(net_stats.get("ball_prediction_reset_cancel_count", 0)), str(net_stats.get("ball_reset_trace", [])),
|
||||
])
|
||||
print("SMOKE INFO: ball pre-authority motion %s (rtt=%.1fms; asserted only at >=20ms, see proxy_motion_ok)" % [
|
||||
"asserted and met" if rtt_masks_authority else "not asserted on this near-zero-RTT link", rtt_ms,
|
||||
])
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
|
||||
# Toggles forward thrust every TOGGLE_TICKS physics frames for the requested
|
||||
# duration, then leaves it pressed so the caller's own local_prediction_ok
|
||||
# check still sees a live commanded action. Yaw alternates alongside it purely
|
||||
# to keep the ship from driving straight into a wall and turning a labelling
|
||||
# probe into a contact test.
|
||||
# Samples the server's own score every physics frame for `seconds`, appending
|
||||
# each distinct value. Lets the comparison below check a client's recorded
|
||||
# score against a state the server genuinely passed through, rather than
|
||||
# against whatever it happens to hold seconds later.
|
||||
func _await_recording_score(match_scene, seconds: float, history: Array[String]) -> void:
|
||||
var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
|
||||
while Time.get_ticks_msec() < deadline:
|
||||
var current := JSON.stringify(match_scene.score)
|
||||
if history[history.size() - 1] != current:
|
||||
history.append(current)
|
||||
await get_tree().physics_frame
|
||||
|
||||
|
||||
func _run_input_transition_trace(duration_seconds: float) -> void:
|
||||
const TOGGLE_TICKS := 6 # ~100ms at 60Hz: several edges per second
|
||||
var frames := int(duration_seconds * 60.0)
|
||||
var pressed := false
|
||||
var yaw_left := false
|
||||
for frame in frames:
|
||||
if frame % TOGGLE_TICKS == 0:
|
||||
pressed = not pressed
|
||||
if pressed:
|
||||
Input.action_press("move_forward")
|
||||
else:
|
||||
Input.action_release("move_forward")
|
||||
if frame % (TOGGLE_TICKS * 4) == 0:
|
||||
yaw_left = not yaw_left
|
||||
Input.action_release("turn_right" if yaw_left else "turn_left")
|
||||
Input.action_press("turn_left" if yaw_left else "turn_right")
|
||||
await get_tree().physics_frame
|
||||
Input.action_release("turn_left")
|
||||
Input.action_release("turn_right")
|
||||
Input.action_press("move_forward")
|
||||
await get_tree().physics_frame
|
||||
|
||||
|
||||
func _run_free_flight_trace(ship: Ship, start_position: Vector3, duration_seconds: float) -> float:
|
||||
var elapsed := 0.0
|
||||
var peak_distance := 0.0
|
||||
while elapsed < duration_seconds:
|
||||
Input.action_release("move_down")
|
||||
Input.action_press("move_up")
|
||||
var up_seconds := minf(0.7, duration_seconds - elapsed)
|
||||
await get_tree().create_timer(up_seconds).timeout
|
||||
elapsed += up_seconds
|
||||
peak_distance = maxf(peak_distance, ship.global_position.distance_to(start_position))
|
||||
if elapsed >= duration_seconds:
|
||||
break
|
||||
Input.action_release("move_up")
|
||||
Input.action_press("move_down")
|
||||
var down_seconds := minf(0.3, duration_seconds - elapsed)
|
||||
await get_tree().create_timer(down_seconds).timeout
|
||||
elapsed += down_seconds
|
||||
peak_distance = maxf(peak_distance, ship.global_position.distance_to(start_position))
|
||||
return peak_distance
|
||||
|
||||
|
||||
# task 3.4: MatchSim._recv_input must count malformed packets and disconnect
|
||||
# after MALFORMED_LIMIT_TO_DISCONNECT (20) of them. Calls the RPC directly
|
||||
# with garbage bytes rather than going through networked_match.gd's own
|
||||
@@ -252,6 +474,25 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
return
|
||||
print("SMOKE INFO: host ship_count=%d slot_count=%d" % [match_scene.ships.size(), match_scene._slots.size()])
|
||||
|
||||
# Every score the SERVER has actually held, in order. The comparison below
|
||||
# used to check each client's recorded score against the server's score at
|
||||
# READ time — but the clients write their files several seconds earlier
|
||||
# (they wait run_seconds from their own later start, then the host waits
|
||||
# run_seconds + 5 more), so any goal scored in that window failed the run
|
||||
# with both bots agreeing perfectly with each other and only "disagreeing"
|
||||
# with a future they could not have seen. It was latent until the input
|
||||
# blackout fix (§3.2) made the bots effective enough to reliably score a
|
||||
# SECOND goal: reproduced 2 of 3 runs, and each failure had server=2 vs
|
||||
# both clients=1. Cross-peer agreement is the real claim here, so assert
|
||||
# that both clients agree with each other AND that what they saw is a
|
||||
# state the server genuinely passed through.
|
||||
# Polled, not signal-driven: score_changed is emitted only in
|
||||
# _on_score_update_received, i.e. the CLIENT path. The server mutates
|
||||
# `score` directly in _record_goal and never emits, so connecting here
|
||||
# silently recorded nothing but the initial 0-0 (verified — it made all
|
||||
# three runs fail with a one-entry history).
|
||||
var score_history: Array[String] = [JSON.stringify(match_scene.score)]
|
||||
|
||||
# An adversarial review found this driver's original checks (snapshot
|
||||
# count, a server-FORCED goal's cross-peer score agreement) don't
|
||||
# depend on client input ever reaching the server at all — it kept
|
||||
@@ -288,7 +529,7 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
# (margin too tight again, or client run_seconds changing) fails loudly
|
||||
# here instead of silently passing on residual grace.
|
||||
var movement_check_delay := maxf(1.0, run_seconds - 2.0)
|
||||
await get_tree().create_timer(movement_check_delay).timeout
|
||||
await _await_recording_score(match_scene, movement_check_delay, score_history)
|
||||
var connected_peers := multiplayer.get_peers()
|
||||
var input_reached_server := true
|
||||
for slot in match_scene._slots:
|
||||
@@ -309,12 +550,13 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
# Extra buffer beyond run_seconds: clients run for their own run_seconds
|
||||
# measured from THEIR (later) start, so waiting only run_seconds here
|
||||
# would race their score files not being written yet.
|
||||
await get_tree().create_timer(run_seconds + 5.0 - movement_check_delay).timeout
|
||||
print("SMOKE INFO: host final score=%s" % str(match_scene.score))
|
||||
await _await_recording_score(match_scene, run_seconds + 5.0 - movement_check_delay, score_history)
|
||||
print("SMOKE INFO: host final score=%s (server held: %s)" % [str(match_scene.score), str(score_history)])
|
||||
|
||||
var slots_ok: bool = match_scene._slots.size() == 2
|
||||
var scores_agree := true
|
||||
var scores_seen := 0
|
||||
var client_scores: Array[String] = []
|
||||
for slot in match_scene._slots:
|
||||
var path := "/tmp/cosmicclash_ci_score_%d.txt" % slot.peer_id
|
||||
if not FileAccess.file_exists(path):
|
||||
@@ -325,10 +567,16 @@ func run_ci_host_check(run_seconds: float) -> void:
|
||||
var client_score := f.get_as_text()
|
||||
f.close()
|
||||
scores_seen += 1
|
||||
var expected := JSON.stringify(match_scene.score)
|
||||
if client_score != expected:
|
||||
print("SMOKE FAIL: peer %d saw score %s, server has %s" % [slot.peer_id, client_score, expected])
|
||||
client_scores.append(client_score)
|
||||
if not score_history.has(client_score):
|
||||
print("SMOKE FAIL: peer %d saw score %s, which the server never held (history %s)" % [slot.peer_id, client_score, str(score_history)])
|
||||
scores_agree = false
|
||||
# The strong half: two independent peers must have reached the SAME view.
|
||||
for other in client_scores:
|
||||
if other != client_scores[0]:
|
||||
print("SMOKE FAIL: peers disagree with each other: %s" % str(client_scores))
|
||||
scores_agree = false
|
||||
break
|
||||
|
||||
var success: bool = slots_ok and scores_agree and scores_seen == 2 and input_reached_server
|
||||
print("SMOKE %s: CI host run (slots_ok=%s scores_agree=%s scores_seen=%d/2 input_reached_server=%s)" % [
|
||||
@@ -360,6 +608,11 @@ func run_ci_client_check(run_seconds: float) -> void:
|
||||
# eaten out of run_seconds and for the odd dropped/simulated-lossy tick.
|
||||
var min_expected := int((run_seconds - 2.0) * 30.0)
|
||||
var snapshot_count_ok: bool = snapshot_count[0] >= min_expected
|
||||
var net_stats: Dictionary = match_scene.get_net_debug_stats()
|
||||
var present_time := bool(match_scene.remote_visual_present_time_enabled)
|
||||
var remote_position_p99 := float(net_stats.get("remote_residual_position_p99", INF))
|
||||
var remote_rotation_p99 := float(net_stats.get("remote_residual_rotation_p99", INF))
|
||||
var remote_quality_ok := not present_time or (remote_position_p99 < 0.3 and remote_rotation_p99 < 5.0)
|
||||
|
||||
var my_id := multiplayer.get_unique_id()
|
||||
var score_path := "/tmp/cosmicclash_ci_score_%d.txt" % my_id
|
||||
@@ -367,11 +620,15 @@ func run_ci_client_check(run_seconds: float) -> void:
|
||||
f.store_string(JSON.stringify(match_scene.score))
|
||||
f.close()
|
||||
|
||||
print("SMOKE INFO: client-bot snapshot_count=%d (want >= %d) slots_ok=%s final_score=%s" % [
|
||||
snapshot_count[0], min_expected, str(slots_ok), str(match_scene.score),
|
||||
print("SMOKE INFO: client-bot snapshot_count=%d (want >= %d) slots_ok=%s final_score=%s remote_present_time=%s residual_p99=%.3fm/%.3fdeg" % [
|
||||
snapshot_count[0], min_expected, str(slots_ok), str(match_scene.score), str(present_time), remote_position_p99, remote_rotation_p99,
|
||||
])
|
||||
var success: bool = slots_ok and snapshot_count_ok
|
||||
var success: bool = slots_ok and snapshot_count_ok and remote_quality_ok
|
||||
print("SMOKE %s: CI client-bot run" % ("PASS" if success else "FAIL"))
|
||||
await get_tree().create_timer(0.3).timeout
|
||||
# The host validates live server-side motion at `run_seconds - 2`. The
|
||||
# first client may have entered its scene before the second one joined,
|
||||
# so it otherwise can finish and disconnect just before that sample. Stay
|
||||
# connected long enough for the host to observe both real input streams.
|
||||
await get_tree().create_timer(3.0).timeout
|
||||
NetworkManager.shutdown()
|
||||
get_tree().quit(0 if success else 1)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
uid://dd7h2nqpa3n8u
|
||||
@@ -0,0 +1,87 @@
|
||||
extends SceneTree
|
||||
|
||||
# Deterministic server/training-path trace for Phase 4's client-only
|
||||
# guarantee. The external parity command copies this file into a `git archive
|
||||
# HEAD` tree and compares its output byte-for-byte against the worktree.
|
||||
#
|
||||
# It drives the real Ship scene through a fixed controller, records every
|
||||
# physics-step pose/velocity and the observation vector, and deliberately
|
||||
# avoids NetworkedMatch/client code. Any accidental change to shared forces,
|
||||
# integration, drag, rotation, or observations changes the trace.
|
||||
|
||||
const SHIP_SCENE = preload("res://objects/ship.tscn")
|
||||
const BALL_SCENE = preload("res://objects/ball.tscn")
|
||||
const ARENA_BOUNDARY_SCENE = preload("res://objects/arena_boundary.tscn")
|
||||
const ShipObservations = preload("res://scripts/ship_observations.gd")
|
||||
const ShipControllerScript = preload("res://scripts/ship_controller.gd")
|
||||
const ShipActionScript = preload("res://scripts/ship_action.gd")
|
||||
|
||||
|
||||
class FixedController extends ShipControllerScript:
|
||||
var tick := 0
|
||||
|
||||
func get_action():
|
||||
tick += 1
|
||||
var action := ShipActionScript.new()
|
||||
# Three deterministic segments exercise forward/vertical/strafe force,
|
||||
# yaw/pitch/roll torque, drag, and action transitions.
|
||||
if tick < 121:
|
||||
action.thrust = Vector3(0.25, 0.50, 1.0)
|
||||
action.rotation = Vector3(0.10, 0.35, -0.15)
|
||||
elif tick < 241:
|
||||
action.thrust = Vector3(-0.40, -0.20, 0.65)
|
||||
action.rotation = Vector3(-0.25, -0.20, 0.30)
|
||||
else:
|
||||
action.thrust = Vector3(0.15, 0.10, -0.35)
|
||||
action.rotation = Vector3(0.0, 0.15, 0.0)
|
||||
return action
|
||||
|
||||
|
||||
func _init() -> void:
|
||||
call_deferred("_run")
|
||||
|
||||
|
||||
func _run() -> void:
|
||||
# Use the same arena, ball and two-ship roster shape as a real match. This
|
||||
# deliberately exercises collision resources, scene setup and the complete
|
||||
# padded observation layout rather than a synthetic isolated rigid body.
|
||||
root.add_child(ARENA_BOUNDARY_SCENE.instantiate())
|
||||
var ship = SHIP_SCENE.instantiate()
|
||||
ship.team = 0
|
||||
ship.spawn_index = 0
|
||||
root.add_child(ship)
|
||||
ship.global_position = Vector3(-4.0, 5.0, 4.0)
|
||||
ship.set_controller(FixedController.new())
|
||||
var opponent = SHIP_SCENE.instantiate()
|
||||
opponent.team = 1
|
||||
opponent.spawn_index = 0
|
||||
root.add_child(opponent)
|
||||
opponent.global_position = Vector3(5.0, 6.0, -5.0)
|
||||
opponent.set_controller(FixedController.new())
|
||||
var observation_ball = BALL_SCENE.instantiate()
|
||||
root.add_child(observation_ball)
|
||||
observation_ball.global_position = Vector3(0.0, 5.5, 0.0)
|
||||
observation_ball.linear_velocity = Vector3(0.7, 0.0, -0.4)
|
||||
var team0: Array[Ship] = [ship]
|
||||
var team1: Array[Ship] = [opponent]
|
||||
var trace: Array[String] = []
|
||||
for tick in 360:
|
||||
await physics_frame
|
||||
var observation: Array = ShipObservations.build(ship, [], team1, observation_ball, Vector3(0.0, 0.0, -27.0))
|
||||
var opponent_observation: Array = ShipObservations.build(opponent, [], team0, observation_ball, Vector3(0.0, 0.0, 27.0))
|
||||
var observation_values: Array[String] = []
|
||||
for value in observation:
|
||||
observation_values.append(str(roundi(float(value) * 100000.0)))
|
||||
for value in opponent_observation:
|
||||
observation_values.append(str(roundi(float(value) * 100000.0)))
|
||||
trace.append("%d:%d,%d,%d:%d,%d,%d:%d,%d,%d:%d,%d,%d:%d,%d,%d:%s" % [
|
||||
tick,
|
||||
roundi(ship.global_position.x * 100000.0), roundi(ship.global_position.y * 100000.0), roundi(ship.global_position.z * 100000.0),
|
||||
roundi(ship.linear_velocity.x * 100000.0), roundi(ship.linear_velocity.y * 100000.0), roundi(ship.linear_velocity.z * 100000.0),
|
||||
roundi(ship.angular_velocity.x * 100000.0), roundi(ship.angular_velocity.y * 100000.0), roundi(ship.angular_velocity.z * 100000.0),
|
||||
roundi(opponent.global_position.x * 100000.0), roundi(opponent.global_position.y * 100000.0), roundi(opponent.global_position.z * 100000.0),
|
||||
roundi(observation_ball.global_position.x * 100000.0), roundi(observation_ball.global_position.y * 100000.0), roundi(observation_ball.global_position.z * 100000.0),
|
||||
",".join(observation_values),
|
||||
])
|
||||
print("PHASE4_SERVER_PARITY ", "|".join(trace))
|
||||
quit()
|
||||
@@ -0,0 +1 @@
|
||||
uid://h05nb2to3b8j
|
||||
@@ -0,0 +1 @@
|
||||
uid://cs4omraphmwgb
|
||||
@@ -0,0 +1 @@
|
||||
uid://dya0kloj28pp7
|
||||
@@ -0,0 +1 @@
|
||||
uid://i3ir30b4iyjo
|
||||
@@ -0,0 +1 @@
|
||||
uid://wfoej6cmtjou
|
||||
+86
-17
@@ -4,7 +4,7 @@ Working document for the online multiplayer effort. `TODO.md` points here.
|
||||
|
||||
Everything below is written so an agent (or a person) can pick up a single numbered task, do it, verify it against a stated acceptance criterion, and stop. Sections 1–6 are the decisions those tasks assume; read them before picking up work in Phase 2 or later.
|
||||
|
||||
**Status: Phase 0 done, Phase 1 done, Phase 2 done, Phase 3 done — both phase gates passing.** A real two-process 1v1 runs: a headless server hosts, a client joins through the lobby, spawns into a server-picked arena, drives its ship via real held input (now with real redundancy, a server-side jitter buffer, and a client-owned adaptive `input_lead`), and renders server-authoritative movement (verified: 22–31 m over a 2 s held-thrust drive, purely from interpolated snapshots) with camera and HUD attached — holding under `--net-sim-latency 80 --net-sim-loss 0.05`, Phase 3's own gate condition, on both the human smoke test and a two-headless-bot CI run (task 3.6) that forces a goal and confirms both bots independently agree on the resulting score. Input is now also validated and abuse-resistant: a hostile client sending malformed or flooded packets gets disconnected, verified with two permanent regression tests that bypass the honest client encoder entirely. No own-ship/ball prediction yet (Phase 4) — everything the client renders, including its own ship, comes from the interpolation buffer. See §7 for per-task status and evidence.
|
||||
**Status: Phase 4's correctness gates are green; sign-off waits on a human playtest. Phase 3 needed two real fixes to get there (task 4.13).** The client now has local-ship delta-rebase reconciliation, client-only ball touch prediction, adaptive input-depth signalling, and experimental remote present-time visuals; all server simulation, bot action/observation behaviour, collision resources, and tick rate remain unchanged. The action-sequence-correctness gap that blocked Phase 4 was a mislabelled prediction history, now fixed and permanently gated (task 4.11). An adversarial review of that fix then found two Phase 3 bugs that were silently killing a connected player's input — periodically on a clean LAN, and permanently after any ~2 s host hitch — both now fixed with verified controls (task 4.13). What remains is not a measurement: nobody has played it at ~100 ms RTT to judge feel, which is what the milestone actually asks. See §7 for the implemented work, evidence, and the one open architectural question (a contact-cohort-only shadow world).
|
||||
|
||||
---
|
||||
|
||||
@@ -278,14 +278,19 @@ Comparing server state at tick `A` against **`predicted[A]`** — the client's o
|
||||
```
|
||||
- **`MAX_VISUAL_OFFSET = 0.4 m`**, not 2.0. The hull is 4 m long; a 2 m offset means being rendered half a ship-length from your own collider for ~280 ms, so you clip walls you visibly cleared — a felt bug in a game built around wall-riding. Beyond 0.4 m, show the correction. A visible correction is honest; an invisible 2 m lie is not.
|
||||
|
||||
**HARD SNAP**
|
||||
**HARD CORRECT**
|
||||
|
||||
- Set body transform and velocities from the server values, **caught up** to the current tick (below), `reset_physics_interpolation()` on the body *and* on `$Visual`, zero the visual offset.
|
||||
- **Backfill the prediction ring** for ticks `A..current`. Do **not** clear it — "missing `predicted[A]`" is itself a snap condition, so clearing guarantees the next snapshot also snaps, turning isolated snaps into bursts.
|
||||
- Apply the same sequence-matched authoritative pose and velocity delta to the current local body, reset body and `$Visual` interpolation, and clear the visual offset. It is physically the same correction as soft correction; only its presentation differs.
|
||||
|
||||
**Catch-up replays the ship's own force formulas, not ballistic dead-reckoning.** Input-free extrapolation is not unbiased: turbo acceleration is `150 × 2.5 / 5` = **75 m/s²**, so a 6-tick catch-up lands ~0.375 m short *in the direction the player is accelerating*, on every snap, and the ship feels permanently rubbery under sustained thrust. Replaying 6–12 stored `ShipAction`s through `apply_thruster_forces` / `apply_rotation_forces` / `apply_drag_and_limits` / `apply_righting_torque` (`ship.gd:361-486` — pure float math, no Jolt dependency) is ~30 lines and ~1000 float ops.
|
||||
**Settled Phase 4 decision — delta transport, not one-body replay.** For every matched snapshot, overwrite `predicted[A]` with authority, transport its pose and linear/angular-velocity delta through each retained state `A+1..current`, and apply that same delta once to the live local Jolt body. This keeps retained history coherent, so a later snapshot does not correct an already-corrected pre-delta trajectory a second time.
|
||||
|
||||
> This is **not** world rollback and does not touch locked decision 1. It replays one body against a frozen world and needs no determinism guarantee.
|
||||
Do **not** analytically replay stored actions. That approximation cannot reproduce Jolt integration or contact manifolds (friction, restitution, walls, ships, and ball), therefore it becomes least trustworthy exactly where reconciliation is most noticeable. This is still neither whole-world rollback nor a change to server physics: it is client-only state transport around a server-authoritative simulation.
|
||||
|
||||
For reset generation changes, place exact authority, begin a new history epoch, and do not consume pre-reset actions. For missing or overflowed history, place authority once and suppress stale acknowledgements until a new matched sequence is recorded; never manufacture future history by filling it with one stale authority state.
|
||||
|
||||
> Same-sequence **pre-correction** residual remains diagnostic telemetry. With a server input jitter buffer, it is not by itself a presentation-quality gate: the server may have integrated an action at a different physical instant from the client. Acceptance must report it separately by free-flight/contact/reset/resync cohort, while gating post-correction/presentation error and hard-snap behaviour.
|
||||
>
|
||||
> That the two sides integrate the **same action** for a given sequence is a separate claim, and a checkable one — it is what the action marker and task 4.11's `--exercise-input-transitions` gate exist for. Keep the two apart: "right action, different instant" is expected here; "wrong action" is a bug, and was one.
|
||||
|
||||
### 4.5 Camera and visuals
|
||||
|
||||
@@ -881,20 +886,79 @@ No own-ship prediction yet: the client renders everything, including its own shi
|
||||
|
||||
| # | Task | Acceptance |
|
||||
|---|---|---|
|
||||
| 4.1 `[D:3.1]` | `LocalNetShipController`: single sample per tick, `copy()`, input history ring | Exactly one `get_action()` per tick; no aliasing in the history |
|
||||
| 4.2 `[D:4.1]` | Prediction state ring (128) and snapshot→`predicted[A]` matching | `predicted[A]` resolves for every snapshot on a clean link |
|
||||
| 4.3 `[D:4.2, 0.14]` | `net_ship_predictor.gd`: snap-vs-blend decision, full-immediate velocity correction, teleport queue, `reset_gen` handling, **ring backfill after a snap** | A snap is never followed by an immediately-forced second snap |
|
||||
| 4.4 `[D:4.3, 0.2]` | Visual offset with `_tick_scaled(0.88)` decay, `MAX_VISUAL_OFFSET = 0.4`, `reset_physics_interpolation()` on body **and** `$Visual` | No mesh smear on snap; no visible offset beyond 0.4 m |
|
||||
| 4.5 `[D:4.3]` `[P]` | Catch-up by **replaying stored `ShipAction`s** through the ship's own force formulas | No directional bias under sustained turbo; ship does not feel rubbery |
|
||||
| 4.6 `[D:4.3]` | **Ball local prediction**: dynamic locally from the tick your predicted ship contacts it for `min(RTT, 250 ms)`; server ball applied to a shadow copy throughout; blend back over 150 ms, hard-snap past 3 m. Triggered by the existing `Ship.ball_contact` (`ship.gd:115`) | Your own touches register visually on contact, not ~RTT later; behind a setting |
|
||||
| 4.7 `[D:4.4]` `[P]` | Tuning pass with debug-menu sliders: snap thresholds, decay `k`, `MAX_VISUAL_OFFSET`, `INTERP_DELAY` | Values recorded in this document once settled |
|
||||
| 4.8 `[D:4.4]` `[P]` | Prediction-error telemetry (p50/p95/p99, snap rate) into the overlay and the CI assertions | Snap rate <1/min in normal 1v1 play at 80 ms simulated RTT |
|
||||
| **4.9** `[D:4.4]` | **L1 — extrapolate remote `$Visual` to present time** (§5.6): render remote ships and the ball at `server_time_est` rather than `server_time_est - INTERP_DELAY`, feeding the residual through 4.4's soft-correct pipeline. **Collapses §4.1's dual clock** — collider and visual share one time, so §5.4b's `_process`/`_physics_process` split for remote bodies is removed. Keep interpolation behind a flag for A/B | **≈30 ms off world response** (174 → ~144 before L4). Measured p99 extrapolation error < 0.3 m and < 5°; correction pops are visible on hard direction changes and nowhere else; A/B against the interpolated path is a deliberate, recorded judgement |
|
||||
| **4.10** `[D:4.9]` `[P]` | **L3 — adaptive jitter-buffer depth**: target 0 on links with jitter below a threshold, rising to 1+ under jitter, replacing §3.3's fixed `target_depth = 1` | ≈8 ms off world response on clean links with no increase in starve rate; degrades to today's behaviour under `--net-sim-jitter 20` |
|
||||
| 4.1 `[D:3.1]` | **DONE.** Immediate local input with immutable sequence/redundancy bookkeeping; server/training action semantics unchanged | 60 unit tests and 60s LAN/jitter/loss runs pass |
|
||||
| 4.2 `[D:4.1]` | **DONE.** 128-entry sequence-tagged prediction history and snapshot matching | Same-sequence free-flight samples resolve in all 60s runs |
|
||||
| 4.3 `[D:4.2, 0.14]` | **DONE.** Atomic staged reconciliation, delta rebase, epoch/reset and missing-history recovery | No free-flight hard snaps across LAN, 80±20ms, or 5% loss 60s runs |
|
||||
| 4.4 `[D:4.3, 0.2]` | **DONE.** Client-only bounded position and rotation visual offsets/decay; interpolation reset | Free-flight p99 raw residual ≤0.207m; exposed visual p99 0m in final matrix |
|
||||
| 4.5 `[D:4.3]` `[P]` | **REJECTED / SUPERSEDED.** Analytic one-body action replay was removed in favour of same-sequence delta transport | Jolt/contact nondeterminism makes replay unsuitable; see §4.4 |
|
||||
| 4.6 `[D:4.3]` | **DONE.** Client-only dynamic proxy, authoritative shadow, RTT-limited reveal, 150 ms blend and 3 m handoff | Final contact run: same-frame reveal, 4 blends, max 152ms, no hard handoff |
|
||||
| 4.7 `[D:4.4]` `[P]` | **DONE.** Client-only debug keys tune thresholds, decay, visual offset and remote-present A/B | Defaults remain 2m/60°/0.4m and render-only tuning never reaches server/training |
|
||||
| 4.8 `[D:4.4]` `[P]` | **DONE.** p50/p95/p99 residual telemetry, elapsed-time snap rate, reason/cohort counters | Final free-flight p99: LAN .150m; 80±20ms .149m; 5% loss .207m; zero hard snaps |
|
||||
| **4.9** `[D:4.4]` | **DONE.** Present-time remote visual extrapolation, angular integration, and render-only residual correction; delayed interpolation remains an A/B debug mode | Final two-bot present-time p99 ≤.208m / 3.146°, below .3m / 5° gate |
|
||||
| **4.10** `[D:4.9]` `[P]` | **DONE.** Signed starvation sentinel and client hysteresis/cooldown; headless `--test-bot` remains target depth 1 | Jitter run observed starvation fallback; stable runs preserve safe target behavior |
|
||||
|
||||
> **Ball prediction is not optional and not Phase 8.** With §4.1 in place the touch registers correctly on the server, but the ball still *renders* a third of a beat late — your ship visibly passes through it before it moves. In a game whose entire point is hitting a ball, that is the difference between "networked" and "broken", and it is the same machinery as own-ship prediction applied to one more body. Do it while the prediction code is warm. Buffering server ball state into a *shadow* copy (rather than discarding it) is what lets you measure disagreement continuously instead of discovering a 3 m error at window end.
|
||||
|
||||
**Phase gate — MILESTONE:** at simulated 100 ms RTT both the ship and the ball feel local; corrections are invisible in free flight and read as bumps on contact. **World response measures ≤130 ms at 60 ms RTT** (§5.6's L1 + L4 target), on whatever graphics settings the machine is running.
|
||||
| 4.11 `[D:4.2]` | **DONE.** Prediction history is filed under the **issuing** sequence, and a forced-input-transition trace gates the label | Marker mismatch 0.00–1.3% (was 9.3% LAN / 24% at 80±20ms); control run at the old label fails the same gate at 50% |
|
||||
| 4.12 `[D:4.11]` | **DONE.** Issued-but-unsimulated (attack-gap) sequences are recorded and skipped rather than diagnosed as history loss; the release path no longer re-files an already-issued sequence | Free-flight hard snaps 0 across all three 60 s conditions, down from 25/8/4 `missing_not_recorded` |
|
||||
| **4.13** `[D:4.12]` | **DONE — two server-side input-death bugs found by adversarial review, both reproduced and fixed with controls.** A starve no longer advances past a sequence the client has not sent; the seq-range guard can no longer latch shut permanently | Marker 0.00% in all three conditions (was 1.7–2.5%); 2.0 s and 3.5 s host freezes now recover; control runs with each fix reverted fail the gate |
|
||||
|
||||
**Phase gate — correctness gates MET; the milestone's felt-quality half remains untested.** The action-sequence-correctness gap is closed and permanently gated (4.11), the two seq-delta paths it exposed are fixed (4.12), and an adversarial review's two server-side input-death bugs are fixed with controls (4.13). What has *not* happened is the original milestone's actual subject: nobody has played this with hands on a controller at ~100 ms RTT to judge whether ship and ball feel local and whether contact corrections read as bumps. Numbers cannot answer that, and the contact cohort is where the remaining known weakness lives (see the shadow-world note below). Sign off after a human playtest, not before.
|
||||
|
||||
> **Read 4.13 before trusting any earlier Phase 4 evidence.** Until this session the server was silently discarding a connected player's input for ~30 ticks roughly every 6.5 seconds on a clean LAN, and permanently after any ~2 s host hitch. Every Phase 4 number recorded before 4.13 was measured through that, and the gates reported green throughout — for the same reason they missed the label bug in 4.11: a steady input cannot distinguish "the server repeated my last action" from "the server applied my real action".
|
||||
|
||||
**The mislabelled prediction history, and why every earlier gate missed it.** `_send_local_input` filed each post-step predicted state under `_local_net_controller.last_applied_seq` — the timeline's *estimate of the sequence the server would consume this tick*, which trails issuance by `input_lead`. The body had actually integrated the current raw intent, issued under `_input_seq`. So `predicted[S]` held "state after integrating the intent from now" while the server's authority for `S` is "state after integrating `action(S)`", sampled `input_lead` ticks earlier. The two agree **only while the commanded action is constant** — and every Phase 4 acceptance trace held its input steady (`move_forward` held, or the free-flight hover alternating on a 0.7 s/0.3 s period). A steady input cannot falsify a sequence label: the marker reads 0/N under a correct and an incorrect label alike. The 60-second free-flight runs genuinely reported `marker=0/3784`; the instrument was fine, the trace was blind.
|
||||
|
||||
Filing the state under `_input_seq` fixes it and costs nothing. The code comment that had rejected this ("avoids turning client prediction into an input-delay queue") conflated *which action the ship uses* — decided in `LocalNetShipController.get_action()`, still the raw current intent, still immediate, untouched by this change — with *which sequence its resulting state is filed under*. Measured with `--exercise-input-transitions` (below):
|
||||
|
||||
| condition | `input_lead` | old label | filed under `_input_seq` |
|
||||
|---|---|---|---|
|
||||
| LAN | 1 | 35/376 (9.3%) | 0–6/456–582 (0–1.3%) |
|
||||
| LAN, adversarial toggle phase | 1 | 289/576 (50.2%) | — |
|
||||
| 80±20 ms | 3 | 97/404 (24%) | 0/424 (0%) |
|
||||
|
||||
Mismatch scales with `input_lead`, exactly as the mechanism predicts. It also **cut pre-existing `missing_not_recorded` hard snaps 4×** on the 60 s LAN free-flight run (25 → 6, 24.8/min → 6.0/min): the old label's per-tick +1 cursor was an estimate that could drift off the sequence the server actually acknowledged, while an issued sequence is by construction the thing the server acknowledges.
|
||||
|
||||
**Task 4.12 — the two seq-delta paths, and what is left.** Relabelling exposed two further places where the history disagreed with the wire, both now fixed:
|
||||
|
||||
- **Attack gaps (`delta > 1`).** The lead controller skips sequence numbers to buy server buffer margin. Those sequences are filled with repeat-last actions and genuinely **sent**, and the server genuinely acknowledges them — but the client took exactly one physics step that tick, so no post-step state exists for them. They were simply absent from the ring, which `compare_authoritative` could only report as `missing_not_recorded`: indistinguishable from real ring loss, and therefore a hard snap, a full authority teleport, and armed resync suppression **several times a minute during ordinary play**. They are now recorded stateless via `record_unsimulated()` and report their own `unsimulated_gap` status, which `NetShipPredictor.decide()` answers with a new `"skip"` mode — no correction, no teleport, no suppression, no snap counted, its own metrics cohort. The next simulated sequence (a tick or two later) reconciles normally. **Result: free-flight hard snaps went from 25 / 8 / 4 to 0 / 0 / 0** across the LAN, 80±20 ms and 5%-loss 60-second runs; the doc's own long-standing target was <1/min and LAN was measuring 24.8/min.
|
||||
- **Release (`delta == 0`).** `_send_local_input` re-recorded at the unchanged `_input_seq`, filing the *current* intent under a sequence that had already gone out carrying a different action. `LocalInputTimeline.issue()` deliberately refuses to mutate an already-issued sequence ("may be in flight or consumed"), so the ring was contradicting the wire outright. Recording is now skipped entirely on a release tick; the existing `predicted[S]` is already correct, and the extra unlabelled local step is precisely the tick of latency the release exists to recover.
|
||||
|
||||
**The residual is solved — it was not a prediction bug at all.** An adversarial review intersected every sequence the server starved on against every sequence the marker flagged, across five two-process runs: **151 of 151 mismatches were the server repeating a stale action on a starve**, zero unexplained. When the server starves on seq `S` it repeats `action(S-k)` but still acks `S`, so the snapshot's `thrust_z` honestly describes a different action than `predicted[S]` — the marker was correctly reporting a real client/server disagreement that prediction did not cause and could not fix. The apparent correlation with `input_lead` was a confound: the conditions that raise the lead are the conditions that produce starves. Fixing the starvation cause (task 4.13 below) took the marker to **0.00% in all three conditions**, including 80±20 ms and 5% loss where it had been 1.7–2.5%.
|
||||
|
||||
Two sub-findings from that investigation, recorded because both are counter-intuitive: `dequantize_thrust_z_bin(quantize_thrust_z_bin(0.0))` returns **0.142857**, not 0.0 (7 bins over [-1,1], `roundi(3.5) == 4`), so a server-reported `thrust_z` of 0.14 literally means "exactly zero" — the 0.26 threshold absorbs it, as designed. And `_pending_local_reconciliation` keeps only the newest snapshot, so acks are dropped whenever two snapshots land in one physics tick: **the marker under-samples, and the true action-disagreement rate is higher than it reports.**
|
||||
|
||||
> **The client-only shadow Jolt world is still the open question, but it is now scoped to the contact cohort alone.** Even perfectly labelled, the client predicts contacts against remote ships and the ball sitting at interpolated-*delayed* positions, so a contact-cohort prediction cannot be sequence-correct in the live world — no amount of bookkeeping fixes that, and a shadow world is the only thing that does. It is a large subsystem and effectively the whole-world rollback §1's locked decisions set out to avoid, so **do not build it before a playtest says the contact cohort actually reads badly to a human.** Free flight no longer needs it.
|
||||
|
||||
**New smoke role — `--exercise-input-transitions`.** Toggles forward thrust every 6 physics ticks (~100 ms) with alternating yaw, and asserts the action marker stays under 5% mismatch over ≥200 samples. This is the **only** gate here that can catch a sequence-label regression, for the reason above, so it must not be folded into the steady-input free-flight run:
|
||||
|
||||
```
|
||||
godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=host --drive-seconds=8
|
||||
godot --headless --path Game res://tests/networked_match_smoke.tscn -- --role=client --drive-seconds=8 --exercise-input-transitions
|
||||
```
|
||||
|
||||
Verified against a working control: reverting the one-line label makes this gate fail at 50.2% mismatch, so it is not vacuous.
|
||||
|
||||
### Task 4.13 — two server-side input-death bugs the Phase 4 gates could not see
|
||||
|
||||
Both are **Phase 3 code**, both predate this session, and both were found by an adversarial review of the Phase 4 changes rather than by any gate. Neither was caused by 4.11/4.12; both are squarely in the way of Phase 4's *feel* milestone, so they are fixed here.
|
||||
|
||||
**(a) A starve stranded the input stream one sequence ahead of arrivals — permanently.** `InputJitterBuffer.consume()` set `last_applied_seq = expected` on **every** tick, including a starve. Because `ingest()` discards anything `seq <= last_applied_seq`, a single starve on a sequence the client had not sent yet left the server permanently one ahead: both sides then advance one per tick, the gap never closes, and **every honest packet is discarded on arrival**. The client's own `input_lead` RELEASE (`delta == 0`, which deliberately issues no new sequence for one tick) is sufficient to trigger it — so this fired roughly **every 6.5 seconds of ordinary play on a clean LAN**, blacking out input for 30 ticks until the lead controller's `MIN_CHANGE_INTERVAL_TICKS` debounce permitted a +3 attack to jump the client clear. The reviewer measured 2 blackouts in a 23 s run and 2 in a 30 s run, with the host applying the *same repeated action* for 30 consecutive ticks while the wire carried fresh input every one of them. Fixed by only giving up on `expected` when strictly newer data has arrived, which proves it lost rather than merely late. Both escape paths are untouched: a silent client still zeroes and stalls on `STARVE_ZERO_TICKS`, and a far-behind consumer still hits the ring-overflow resync.
|
||||
|
||||
**(b) The seq-range guard was a one-way door.** `_on_input_received` bounded incoming `seq` against `jb.highest_ingested_seq + RING_SIZE` — but `highest_ingested_seq` only ever advances *inside* `ingest()`, which that same guard gates. Once a client's live sequence got more than 32 ahead (a host stall drops the intervening packets wholesale, since input is unreliable), every subsequent packet was rejected, the bound could never move again, and **that player's input was dead for the rest of the match with no diagnostic**. Reproduced with a 2 s `SIGSTOP` host freeze: 600+ consecutive rejections, the server applying zero thrust across 1300 sequences while the client's wire carried full thrust throughout. This is the **third** iteration of this guard, and the structural lesson is that each previous version bounded against a value only the accepted path could advance. Fixed by keeping the bound but adding an escape: after `SEQ_REJECT_RESYNC_LIMIT` (10) consecutive rejections, accept and let the existing resync machinery re-establish the baseline. This grants an attacker nothing — walking the epoch forward by sustained rejection costs the same packets as walking it forward by acceptance, and §3.4's rate limiter already bounds that rate.
|
||||
|
||||
**(c) The gate printed PASS while input was permanently dead.** The `--exercise-input-transitions` gate reported `SMOKE PASS` at 3.76% mismatch on a run where input was completely dead, because *suppressed reconciliation stops calling `_record_metrics`* — so the worse the outage, the fewer marker samples and the **lower** the reported mismatch rate. Every other assertion in that path (`local_prediction_ok`, `moved > 1.0`) reads the client's own action and position, which a client flying purely on prediction satisfies perfectly. Fixed by scaling the required sample count with run length (`max(200, drive_seconds * 30)`, half of nominal 60 Hz) and asserting the wire's `server_stalled` bit. **Verified non-vacuous:** reverting both fixes and re-running the 3.5 s freeze fails at `samples 292/600` with `server_stalled=true` and `input_lead=12` (LEAD_MAX) — while reporting `marker=1/292 = 0.34%`, which the old gate would have passed.
|
||||
|
||||
**QA matrix, re-run in full after 4.11 + 4.12 + 4.13** (all green): **72 unit tests**; 60 s free-flight at LAN / 80±20 ms / 5% loss — p99 raw **0.141 / 0.168 / 0.154 m**, exposed visual p99 0.000 m, **0 hard snaps in every condition**, marker 0/3484, 0/3049, 0/3397; forced-input-transition gate at LAN, 80±20 ms **and** 5% loss, all **0.00%**; 2.0 s and 3.5 s `SIGSTOP` host-freeze recovery; ball contact ×5; two-bot CI ×3; all three abuse roles; `net_smoke`, `match_net_smoke` (incl. `host_recycle`), `clock_smoke`, `lobby_smoke`.
|
||||
|
||||
Note the free-flight p99 **improved** (0.170/0.176/0.184 → 0.141/0.168/0.154) and `input_lead` now sits at 1 on LAN instead of oscillating to 3–4. Both are downstream of 4.13(a): the periodic blackouts were degrading prediction accuracy and driving the lead controller.
|
||||
|
||||
**Two test defects fixed alongside, both pre-existing and both surfaced by 4.13(a):**
|
||||
|
||||
- **Ball-contact gate flaked 2 in 5.** `ball_proxy_moved_before_authority_count` requires the predicted proxy to have visibly moved *before the next authoritative ball state arrives* — but snapshots land every ~16.7 ms at 60 Hz, so on a loopback LAN the entire pre-authority window is about one physics tick and observing it is a coin flip. It is also the least interesting case: the counter measures RTT-masking, and LAN has no RTT to mask. Measured 5/5 passes (count 2–3) at `--net-sim-latency=80`. Now asserted only when `NetworkManager.rtt_ms >= 20`, with the same-frame reveal — the test's real claim, correct in every run either way — carrying the gate on LAN. Runs asserting the masking behaviour should pass `--net-sim-latency`.
|
||||
- **Two-bot CI compared scores across a 3–5 s window.** The host checked each client's recorded score against its own score at *read* time, but clients write theirs several seconds earlier; any goal in between failed the run with both bots agreeing perfectly with each other. Latent until 4.13(a) made the bots effective enough to reliably score a second goal — then it failed 2 of 3 runs, every failure `server=2` vs `both clients=1`. The host now polls and records every score it actually holds, and asserts both clients agree **with each other** and that what they saw is a state the server genuinely passed through. 3/3 green, including a run ending 1–1 where the clients had recorded 0–1. (Polling, not `score_changed`: that signal is emitted only in `_on_score_update_received`, the *client* path — the server mutates `score` directly in `_record_goal` and never emits. Connecting to it recorded nothing but the initial 0–0.)
|
||||
|
||||
> **Follow-up, not done:** `LocalNetShipController.last_applied_seq` is now write-only and `LocalInputTimeline.consume()` is vestigial to the reconciler (still unit-tested, still advancing `_last_applied_action`, but nothing reads the result). Left in place rather than removed as unreviewed scope — but it now looks load-bearing and is not.
|
||||
|
||||
### Phase 5 — Match lifecycle
|
||||
|
||||
@@ -1030,6 +1094,11 @@ No own-ship prediction yet: the client renders everything, including its own shi
|
||||
44. **When adding a "is this connection still healthy" check to a test, sample it while the peer is still actively connected — not after its own end-of-run disconnect, which produces symptoms indistinguishable from the bug being checked for.** Fixing gotcha 43 first sampled `InputJitterBuffer.stalled` and ship movement *after* the full test run (plus a buffer for score-file writes), which meant both readings came from ~4s after the bot had already legitimately shut down — a departed peer's input naturally starves and goes `stalled=true` too, and that's correct, expected behaviour, not the bug. Move the check to a moment still comfortably inside the peer's own active connection window.
|
||||
45. **Two fixes landed in the same commit, each individually correct in isolation, can share a variable and silently cancel each other out — and a fix's own unit test can miss it by testing the mechanism in isolation from the thing that defeats it.** Gotcha 39's resync fix and gotcha 42's guard rebound were reviewed, tested, and verified independently, each against its own scenario, both passing. Combined, the guard caps the exact variable (`highest_ingested_seq`) the resync's own trigger condition depends on, making it permanently unreachable — recreating the original critical bug at a *lower* failure threshold than before either fix existed. The resync's own new unit test called `InputJitterBuffer.ingest()` directly, which is correct in isolation but bypasses the guard entirely, so it could never have caught this regardless of how thorough it was on its own terms. **When two fixes in the same round touch the same subsystem, explicitly re-test the combination end-to-end** (here: a real `SIGSTOP` freeze against the actual production RPC call path, not a direct unit-level call into the class the fix lives in) — passing tests for each fix individually is not evidence the pair composes correctly.
|
||||
46. **A guard that bounds an incoming value against the *consumer's* position, rather than against the *producer's* own epoch, re-introduces exactly the "consumer can never catch up past a stall" failure it's often added specifically to prevent.** The seq-range guard bounded `seq` against `last_applied_seq` (advanced only by `consume()`, i.e. gated on however fast the physics tick loop is actually running) rather than `highest_ingested_seq` (advanced by `ingest()`, i.e. gated on however fast packets are actually arriving and being processed by `poll()`) — during a stall where ticks fall behind but polling keeps pace (the common case: a single-frame hitch, or `Engine.max_physics_steps_per_frame` capping tick catch-up while `poll()` itself isn't similarly capped), bounding against the lagging consumer rejects the very packets that would let the buffer refill and the resync condition ever trigger. Bound against whichever side of a producer/consumer pair is not the one already known to be falling behind.
|
||||
47. **A trace that holds its inputs steady cannot falsify anything about *which sequence* a prediction is filed under — and "we hold thrust for 60 seconds" describes almost every prediction test people write.** Phase 4's history was filed under the wrong sequence (the estimated server-consumption seq, `input_lead` ticks behind issuance, instead of the issuing seq), and the dedicated action-marker instrument built to catch exactly that reported a flawless `marker=0/3784` across 60-second LAN, 80±20 ms and 5%-loss runs. It was not broken: while the commanded action is constant, "the intent from this tick" and "the action the server consumes for seq S" hold the same *value*, so a right and a wrong label are indistinguishable. Only an input **edge** separates them, and only for about `input_lead` ticks per edge. The bug then scales with `input_lead` — 9.3% mismatch at lead 1, 24% at lead 3 — meaning it was worst precisely on the impaired links the test matrix existed to cover, and invisible in all of them. **When a test is meant to validate a label, an index, or a phase relationship rather than a magnitude, the trace has to change that quantity frequently**; a steady-state trace validates the magnitude and silently asserts nothing about the label.
|
||||
48. **A guard whose bound is derived from a value only the ACCEPTED path can advance is a latch, not a guard.** The seq-range check has now been written three times — bounded against server uptime, then `last_applied_seq`, then `highest_ingested_seq` — and all three could permanently reject an honest client's input, because in every version the quantity being compared against could only move forward via a packet that got through. Once enough drift or loss accumulated, nothing could ever move it again. The property to check when writing a guard like this is not "is the bound correct?" but "**if this guard rejects everything from now on, what advances the bound?**" If the answer is "an accepted packet", it needs an independent escape path (here: resync after N consecutive rejections) regardless of how well-chosen the bound is.
|
||||
49. **Advancing a consumer cursor past data that has not arrived is not a lossy shortcut — it is permanent, because the producer-side filter then rejects the very data being waited for.** `InputJitterBuffer.consume()` advanced `last_applied_seq` on a starve, and `ingest()` discards `seq <= last_applied_seq`. One starve on a sequence the client had not sent yet therefore stranded the stream one ahead of arrivals *forever* — both sides advancing in lockstep, the gap never closing, every packet discarded on arrival. The client's own routine `input_lead` release was enough to trigger it, roughly every 6.5 s on a clean LAN. **Only give up on an expected item once strictly newer data proves it lost**; "it hasn't arrived yet" and "it will never arrive" are different states and must not share a code path.
|
||||
50. **A metric that stops sampling during a failure will report that failure as healthy.** The action-marker gate printed `SMOKE PASS` at 3.76 % on a run where the player's input was permanently dead — because reconciliation suppression stops `_record_metrics` being called, so the worse the outage, the fewer samples and the *lower* the computed mismatch **rate**. Every rate-shaped assertion needs a companion assertion on the **denominator** (here: a sample count scaled to run length), or an outage silently becomes an absence of evidence and then evidence of absence.
|
||||
51. **An architectural blocker inherited from a previous session is a claim to verify, not a premise to build on.** Phase 4 was handed over blocked on approval for a client-only shadow Jolt world — a large subsystem, and effectively the whole-world rollback §1's locked decisions rule out. The actual same-sequence defect turned out to be a one-line mislabel, falsifiable in about an hour with instrumentation that already existed; the shadow world remains genuinely necessary for the *contact* cohort but nothing else, which is a far smaller commitment than "Phase 4 is blocked on it." Reconstruct the failing invariant from the code and reproduce it against a control before accepting a scope estimate attached to it — especially when the recommendation arrives without the cheaper alternative recorded as tested.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user