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.
121 lines
5.7 KiB
GDScript
121 lines
5.7 KiB
GDScript
class_name InputLeadController
|
|
extends RefCounted
|
|
|
|
# Client-owned input_lead control loop (multiplayer-todo.md §3.3, task 3.3).
|
|
# Standalone RefCounted, same reason as input_jitter_buffer.gd — scene-free
|
|
# so it's directly unit-testable against scripted depth traces.
|
|
#
|
|
# §3.3's own rationale for why this is the CLIENT's job alone, not shared
|
|
# with any server-side adaptation: three control loops acting on one plant
|
|
# (buffer occupancy) with different time constants is a textbook
|
|
# oscillation, and on a jittery link it presents to the player as
|
|
# intermittent sticky controls that are nearly impossible to attribute.
|
|
# The server (InputJitterBuffer, §3.2) only ever reports input_buffer_depth
|
|
# — it does nothing adaptive with it.
|
|
#
|
|
# "Lead" is realized concretely as extra distance between this client's own
|
|
# outgoing sequence numbers and what the server has actually consumed:
|
|
# skipping a sequence number (jumping the client's own seq counter by more
|
|
# than 1 for one tick) buys the server one more tick of buffered depth
|
|
# before it would starve; duplicating one (not incrementing the seq counter
|
|
# for one tick — the same seq gets sent again) narrows that margin by one
|
|
# tick of latency. The server's own ring buffer doesn't need to know this
|
|
# happened: a skipped seq just means "the redundant copies of it never
|
|
# existed, it's an ordinary drop" (already handled), and a duplicated seq
|
|
# is a same-seq resend, already discarded harmlessly once consumed
|
|
# (InputJitterBuffer.ingest()'s "seq <= last_applied_seq" check).
|
|
#
|
|
# Fast attack, slow release — a symmetric ±1-per-N-ticks slew would take
|
|
# two full seconds to absorb a single wifi spike, during which the player
|
|
# steers and the ship does not turn, "the most rage-inducing failure mode
|
|
# in any netcode" per §3.3's own words.
|
|
|
|
const LEAD_MIN := 1
|
|
const LEAD_MAX := 12
|
|
# "Never change it more than once per 30 ticks" (§3.3) — the floor that
|
|
# binds the fast-attack side; slow-release's own 60-tick cadence already
|
|
# exceeds it, so this one constant covers both.
|
|
const MIN_CHANGE_INTERVAL_TICKS := 30
|
|
const RELEASE_INTERVAL_TICKS := 60
|
|
const CLEAN_SURPLUS_TICKS := 120 # 2s at 60Hz
|
|
# §3.3: "target_depth = 1 (16.7 ms), not 2." Release only fires when the
|
|
# server-reported depth is genuinely ABOVE this — see update()'s own
|
|
# comment for why gating on `lead` alone (an adversarial review's original
|
|
# finding here) was wrong.
|
|
const TARGET_DEPTH := 1
|
|
|
|
var lead := LEAD_MIN
|
|
|
|
var _ticks_since_change := 0
|
|
var _clean_surplus_ticks := 0
|
|
|
|
|
|
# Call once per client physics tick with the most recently known server-
|
|
# reported input_buffer_depth for THIS client's own slot (echoed in every
|
|
# snapshot, §3.2) — or -1 if no snapshot carrying that field has arrived
|
|
# yet. Returns the seq delta the caller should add for this tick's
|
|
# 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, target_depth: int = TARGET_DEPTH) -> int:
|
|
_ticks_since_change += 1
|
|
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 <= 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
|
|
# MIN_CHANGE_INTERVAL_TICKS so a burst of consecutive starve
|
|
# reports doesn't compound into repeated, overlapping jumps.
|
|
_clean_surplus_ticks = 0
|
|
if _ticks_since_change >= MIN_CHANGE_INTERVAL_TICKS and lead < LEAD_MAX:
|
|
var new_lead := mini(lead + 3, LEAD_MAX)
|
|
var delta := new_lead - lead
|
|
lead = new_lead
|
|
_ticks_since_change = 0
|
|
return 1 + delta
|
|
return 1
|
|
|
|
# Release must react to the ACTUAL server-reported depth, not to this
|
|
# controller's own memory of past attacks. A first pass at this fix
|
|
# added the depth check above but left the OLD gate, `lead > LEAD_MIN`,
|
|
# still ANDed onto the final condition below — so a backlog this
|
|
# controller did NOT itself cause (a server hitch, persistent client/
|
|
# server clock drift, a ring resync) still could never be drained:
|
|
# with lead pinned at its starting floor, that clause always failed
|
|
# even while input_buffer_depth sat well above target. A second
|
|
# adversarial review caught it, confirmed by this file's own
|
|
# test_release_drains_a_backlog_it_never_caused_itself, whose original
|
|
# assertion text literally said "lead cannot release below its own
|
|
# floor even under large surplus" as if that were correct.
|
|
#
|
|
# The fix splits the one gate into two separate decisions: whether to
|
|
# duplicate this tick's seq (the only thing that actually narrows real
|
|
# 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:
|
|
_clean_surplus_ticks += 1
|
|
else:
|
|
_clean_surplus_ticks = 0
|
|
|
|
if _clean_surplus_ticks >= CLEAN_SURPLUS_TICKS and _ticks_since_change >= RELEASE_INTERVAL_TICKS:
|
|
if lead > LEAD_MIN:
|
|
lead -= 1
|
|
_ticks_since_change = 0
|
|
return 0 # duplicate this tick's seq — one tick of latency recovered
|
|
return 1
|