mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
75f485667b
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.
127 lines
5.1 KiB
GDScript
127 lines
5.1 KiB
GDScript
class_name NetInterpolator
|
|
extends RefCounted
|
|
|
|
# Buffers recent snapshot samples for ONE remote body and produces
|
|
# interpolated states at any requested (possibly fractional) server tick —
|
|
# used twice per body (multiplayer-todo.md §4.1/§4.6, "dual-time remote
|
|
# entities"): once at the present-time estimate for the collider, once
|
|
# further back at present-minus-INTERP_DELAY for $Visual.
|
|
#
|
|
# server_tick (Engine.get_physics_frames() at send time) maps to an
|
|
# estimated server wall-clock time via TICK_HZ without any extra
|
|
# synchronization: both Engine.get_physics_frames() and Time.get_ticks_msec()
|
|
# count from the same process-start epoch, and physics has been running at
|
|
# a steady TICK_HZ the whole time, so tick_ms_of(tick) = tick * (1000/TICK_HZ)
|
|
# is a valid estimate of "what Time.get_ticks_msec() read on the server when
|
|
# it sent that tick." Callers convert a NetworkManager.get_server_time_estimate_ms()
|
|
# reading into the same tick-space with to_tick(ms) before calling sample_at().
|
|
|
|
const NetBodyState = preload("res://scripts/net_body_state.gd")
|
|
const SimConstants = preload("res://scripts/sim_constants.gd")
|
|
|
|
const MAX_SAMPLES := 16
|
|
# §4.6: "never extrapolate indefinitely — a stuck ship reads better than one
|
|
# flying through a wall."
|
|
const MAX_EXTRAPOLATION_MS := 150.0
|
|
const TICK_MS := 1000.0 / SimConstants.TICK_HZ
|
|
|
|
var _samples: Array[Dictionary] = [] # [{tick:int, state:NetBodyState}], oldest first
|
|
var reset_gen := -1 # -1: no sample yet, so the first real sample is never treated as a mid-flight reset
|
|
|
|
|
|
static func to_tick(server_time_ms: float) -> float:
|
|
return server_time_ms / TICK_MS
|
|
|
|
|
|
# Returns true if this sample's reset_gen differs from the last one seen —
|
|
# the caller's cue to hard-snap instead of interpolating across a
|
|
# server-authoritative teleport (kickoff, goal reset) rather than sliding
|
|
# 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
|
|
_samples.append({"tick": server_tick, "state": state})
|
|
if _samples.size() > MAX_SAMPLES:
|
|
_samples.pop_front()
|
|
return is_reset
|
|
|
|
|
|
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
|
|
|
|
|
|
# target_tick may be fractional (a point in time between two integer ticks).
|
|
func sample_at(target_tick: float) -> NetBodyState:
|
|
if _samples.is_empty():
|
|
return null
|
|
if _samples.size() == 1:
|
|
return _samples[0]["state"]
|
|
if target_tick <= _samples[0]["tick"]:
|
|
return _samples[0]["state"]
|
|
var newest: Dictionary = _samples.back()
|
|
if target_tick >= newest["tick"]:
|
|
return _extrapolate(newest, target_tick)
|
|
for i in range(_samples.size() - 1):
|
|
var a: Dictionary = _samples[i]
|
|
var b: Dictionary = _samples[i + 1]
|
|
if a["tick"] <= target_tick and target_tick <= b["tick"]:
|
|
var a_tick: float = a["tick"]
|
|
var b_tick: float = b["tick"]
|
|
var span := b_tick - a_tick
|
|
var t: float = (target_tick - a_tick) / span if span > 0.0 else 0.0
|
|
return _lerp_state(a["state"], b["state"], t)
|
|
return newest["state"]
|
|
|
|
|
|
func _lerp_state(a: NetBodyState, b: NetBodyState, t: float) -> NetBodyState:
|
|
var out := NetBodyState.new()
|
|
out.position = a.position.lerp(b.position, t)
|
|
out.rotation = a.rotation.slerp(b.rotation, t)
|
|
out.linear_velocity = a.linear_velocity.lerp(b.linear_velocity, t)
|
|
out.angular_velocity = a.angular_velocity.lerp(b.angular_velocity, t)
|
|
out.frozen = b.frozen
|
|
out.turbo = b.turbo
|
|
out.thrust_z = b.thrust_z
|
|
out.stalled = b.stalled
|
|
out.avel_range = b.avel_range
|
|
return out
|
|
|
|
|
|
func _extrapolate(newest: Dictionary, target_tick: float) -> NetBodyState:
|
|
var state: NetBodyState = newest["state"]
|
|
var ticks_ahead: float = target_tick - float(newest["tick"])
|
|
var ms_ahead := ticks_ahead * TICK_MS
|
|
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)
|
|
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
|
|
out.turbo = state.turbo
|
|
out.thrust_z = state.thrust_z
|
|
out.stalled = state.stalled
|
|
out.avel_range = state.avel_range
|
|
return out
|