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.
274 lines
14 KiB
GDScript
274 lines
14 KiB
GDScript
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)
|