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:
Josh Creek
2026-08-21 09:17:19 +01:00
parent 3d3024ae8a
commit 75f485667b
70 changed files with 2212 additions and 272 deletions
@@ -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
+87 -6
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://dcw2l88s5as1w
+1
View File
@@ -0,0 +1 @@
uid://bul5evnyqmk2r
+24
View File
@@ -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
+108
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
uid://3ytp2tiefu6u
+1
View File
@@ -0,0 +1 @@
uid://bk1qxbe10nql6
+1
View File
@@ -0,0 +1 @@
uid://qp75ucmgd6kr
+1
View File
@@ -0,0 +1 @@
uid://v1c1ne02bal
+1
View File
@@ -0,0 +1 @@
uid://5awtyloaix6o
+1
View File
@@ -0,0 +1 @@
uid://g5ty301vv5ui
+1
View File
@@ -0,0 +1 @@
uid://b42fq1fsu0q24
+1
View File
@@ -0,0 +1 @@
uid://dlej3jgbua05l
+1
View File
@@ -0,0 +1 @@
uid://dr7b7036oovhv
+24 -5
View File
@@ -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:
+1
View File
@@ -0,0 +1 @@
uid://bn5tgox8nci0f
+285 -28
View File
@@ -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
+87
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
uid://h05nb2to3b8j
+1
View File
@@ -0,0 +1 @@
uid://cs4omraphmwgb
+1
View File
@@ -0,0 +1 @@
uid://dya0kloj28pp7