class_name LocalPredictionHistory extends RefCounted const NetBodyState = preload("res://scripts/net_body_state.gd") # Client-owned local-ship prediction history (multiplayer-todo.md §4.3). # This is deliberately independent of NetworkedMatch and the scene tree so # sequence/ring behaviour can be tested from scripted traces. Each entry is # tagged with its full sequence number: an old value in a wrapped slot is # never accepted as a prediction for a newer sequence. # # Acknowledge and record are separate producer/consumer clocks. The input # sender can continue producing while snapshots stop arriving, so record() # explicitly marks overflow once more than RING_SIZE unacknowledged sequence # positions exist. It still retains the newest representable window, but # callers can see that an authoritative resync is required instead of # mistaking a wrapped overwrite for a valid comparison. # # resync_required is a live condition, NOT a latch: compare_authoritative() # clears it again once acknowledgements have genuinely caught back up (see # that method). This mirrors input_jitter_buffer.gd's `stalled`, which # likewise drops back to false the moment a normal tick is consumed again. # A latched flag would mean one transient ~2s stall anywhere in a match # permanently pinned every later tick into "needs a hard resync", which is # exactly the behaviour soft correction exists to avoid — and it would also # cap overflow_count at 1 forever, since a second episode could never # observe the flag going false again. # # Two things a "matched" result does NOT guarantee, flagged for whoever # builds task 4.3's actual correction logic on top of this: # # 1. A "matched" result can still be reporting stale data. The slot-tag # equality check in get_prediction() guarantees a match's payload # genuinely belongs to the queried seq (never wrong-seq data mislabeled # as right), but nothing in the "matched" status itself says HOW OLD # that entry is. Under sparse recording (record() is not called with # strictly consecutive seqs — see the record() comment below), an entry # from well over RING_SIZE ticks ago can still report "matched" for a # query landing on its untouched residue. resync_required correctly # stays true in that case (the span guard below is exact), but the # comparison payload itself carries no matched_stale/age distinction. A # caller wanting to reject "matched but ancient" needs to separately # check newest_recorded_seq - seq itself. # # 2. HISTORICAL, now fixed — kept because the reasoning still constrains # callers. record() used to be called twice for the same seq with a # DIFFERENT action on the release path (delta == 0), the later call # silently overwriting the slot. That was wrong, not merely imprecise: # LocalInputTimeline.issue() deliberately does NOT mutate _actions[seq] # for an already-issued sequence ("may be in flight or consumed"), so # the overwrite made this ring contradict the wire — it claimed an # action for S that was never sent for S. networked_match.gd now skips # recording entirely on a release tick, leaving the original (correct) # predicted[S] in place. Callers must keep it that way: an already- # recorded sequence's ACTION is immutable here, exactly as it is in the # timeline. Only overwrite_state()/rebase_state_range() may revise an # entry, and only its state. # # 3. A sequence can be ISSUED without ever being locally SIMULATED. The # input_lead controller's attack path (delta > 1) skips sequence numbers # to buy server-side buffer margin: those gap sequences are filled with # repeat-last actions and sent, but the client took exactly ONE physics # step that tick, so no post-step state exists for them. They are # recorded via record_unsimulated() and report "unsimulated_gap" rather # than "missing_not_recorded" — a routine consequence of this client's # own lead control, NOT evidence of history loss, and specifically not a # hard-snap condition. Distinguishing them matters: treating them as # missing history teleported the ship and armed resync suppression # several times a minute during ordinary play. const RING_SIZE := 128 var _ring_seq: PackedInt32Array = PackedInt32Array() var _ring_entry: Array = [] var _has_recorded := false var newest_recorded_seq := -1 var last_acknowledged_seq := 0 var overflow_count := 0 var resync_required := false func _init() -> void: _ring_seq.resize(RING_SIZE) _ring_entry.resize(RING_SIZE) for i in RING_SIZE: _ring_seq[i] = -1 # A reset starts a new authoritative epoch. Retained inputs/states describe # the old world and must never be compared to the new kickoff state. func begin_epoch() -> void: for i in RING_SIZE: _ring_seq[i] = -1 _ring_entry[i] = null _has_recorded = false newest_recorded_seq = -1 last_acknowledged_seq = 0 resync_required = false # Stores a private copy of both action and state. Returns true when this # record crossed the unacknowledged-capacity boundary; the caller does not # need that return today, but it makes the eviction event observable rather # than silent when reconciliation starts applying corrections in Phase 4.3. func record(seq: int, action: ShipAction, state: NetBodyState, contact_window: bool = false) -> bool: var overflowed_now := false if not _has_recorded or seq > newest_recorded_seq: if seq - last_acknowledged_seq > RING_SIZE: # Only the LEADING edge of an episode counts: resync_required is # still true for every subsequent tick of the same stall, and # counting those would report one outage as hundreds. Because # compare_authoritative() can now clear the flag, a genuinely # separate later episode does increment this again. overflowed_now = not resync_required resync_required = true if overflowed_now: overflow_count += 1 newest_recorded_seq = seq _has_recorded = true var idx := posmod(seq, RING_SIZE) _ring_seq[idx] = seq _ring_entry[idx] = { "action": action.copy(), "state": state.copy(), "contact_window": contact_window, "unsimulated": false, } return overflowed_now # Records a sequence that was issued and sent but never locally simulated — # an attack's skipped sequence numbers (see note 3 in this file's header). # It advances the same newest/overflow bookkeeping record() does, because the # sequence genuinely is outstanding and the server will genuinely acknowledge # it; only the post-step state is absent, because the client never computed # one. Deliberately carries the action anyway: it is what went on the wire, so # a caller diagnosing an acknowledgement still has the honest command, and # nothing here has to invent a state to keep the ring dense. func record_unsimulated(seq: int, action: ShipAction) -> bool: var overflowed_now := false if not _has_recorded or seq > newest_recorded_seq: if seq - last_acknowledged_seq > RING_SIZE: overflowed_now = not resync_required resync_required = true if overflowed_now: overflow_count += 1 newest_recorded_seq = seq _has_recorded = true var idx := posmod(seq, RING_SIZE) _ring_seq[idx] = seq _ring_entry[idx] = { "action": action.copy(), "state": null, "contact_window": false, "unsimulated": true, } return overflowed_now # Returns independent copies so diagnostic/reconciliation consumers cannot # mutate a retained prediction by accident. func get_prediction(seq: int) -> Dictionary: var idx := posmod(seq, RING_SIZE) if _ring_seq[idx] != seq: return {} var entry: Dictionary = _ring_entry[idx] if bool(entry.get("unsimulated", false)): # No state to hand back — see note 3. Callers must check this flag # before touching "state"; it is null, not a zeroed NetBodyState, # specifically so a caller that forgets fails loudly instead of # silently comparing against the origin. return { "seq": seq, "action": (entry["action"] as ShipAction).copy(), "state": null, "contact_window": false, "unsimulated": true, } return { "seq": seq, "action": (entry["action"] as ShipAction).copy(), "state": (entry["state"] as NetBodyState).copy(), "contact_window": bool(entry.get("contact_window", false)), "unsimulated": false, } # Reconciliation changes the state paired with already-sent input, never the # input itself. This is deliberately a no-op for an absent/skipped sequence: # input-lead control permits sparse sequence numbers, so there is no honest # action to invent for such a slot. func overwrite_state(seq: int, state: NetBodyState) -> bool: var idx := posmod(seq, RING_SIZE) if _ring_seq[idx] != seq: return false var entry: Dictionary = _ring_entry[idx] if bool(entry.get("unsimulated", false)): # Writing a state here would manufacture a local prediction for a # sequence this client never simulated, which is exactly the fabricated # history §4.4 forbids. The slot stays stateless. return false entry["state"] = state.copy() return true func overwrite_state_range(from_seq: int, to_seq: int, state: NetBodyState) -> void: for seq in range(from_seq, to_seq + 1): overwrite_state(seq, state) # Carries an authoritative same-sequence correction through the retained # future. This is intentionally a transport operation, not a synthetic # physics replay: the live Jolt body has already advanced through the real # contact world, and a soft correction must not leave its later comparisons # describing the old trajectory. func rebase_state_range(from_seq: int, to_seq: int, position_delta: Vector3, rotation_delta: Quaternion, linear_velocity_delta: Vector3, angular_velocity_delta: Vector3) -> void: for seq in range(from_seq, to_seq + 1): var prediction := get_prediction(seq) if prediction.is_empty() or bool(prediction.get("unsimulated", false)): continue var state: NetBodyState = prediction["state"] state.position += position_delta state.rotation = (rotation_delta * state.rotation).normalized() state.linear_velocity += linear_velocity_delta state.angular_velocity += angular_velocity_delta overwrite_state(seq, state) # Produces comparison data only. Applying a snap, teleport, velocity delta, # or visual offset belongs to later Phase 4 tasks. func compare_authoritative(seq: int, authoritative: NetBodyState) -> Dictionary: if seq > last_acknowledged_seq: last_acknowledged_seq = seq var prediction := get_prediction(seq) if prediction.is_empty(): return { "status": _missing_status(seq), "seq": seq, "authoritative_state": authoritative.copy(), } # A successful match is the only evidence that the acknowledgement clock # has genuinely caught back up, so it is the only thing allowed to clear # resync_required — a "missing_evicted"/"missing_not_recorded" result # proves the opposite, and must leave the flag alone. # # The extra span check is not redundant. record() is not guaranteed to be # called with consecutive sequences: input_lead_controller.update() can # return 0 or up to 1+3, so the client's seq can skip forward, leaving a # ring slot holding a tag OLDER than newest_recorded_seq - RING_SIZE # (its residue was simply never rewritten). get_prediction() would still # report that as "matched", so matching alone does not imply the # outstanding window is back within capacity. Gate on the exact inverse # of record()'s own trip inequality instead, which holds regardless of # how sparsely sequences were recorded. if newest_recorded_seq - last_acknowledged_seq <= RING_SIZE: resync_required = false if bool(prediction.get("unsimulated", false)): # Reaching this sequence at all proves the acknowledgement clock is # healthy — the entry is present and correctly tagged — so the # resync_required clear above still applies. There is simply nothing # to compare, because the client never simulated this sequence. return { "status": "unsimulated_gap", "seq": seq, "action": prediction["action"], "authoritative_state": authoritative.copy(), } var predicted_state: NetBodyState = prediction["state"] var position_error := authoritative.position - predicted_state.position var rotation_error_radians := predicted_state.rotation.angle_to(authoritative.rotation) return { "status": "matched", "seq": seq, "action": prediction["action"], "predicted_state": predicted_state, "authoritative_state": authoritative.copy(), "position_error": position_error, "position_error_magnitude": position_error.length(), "rotation_error_radians": rotation_error_radians, "rotation_error_degrees": rad_to_deg(rotation_error_radians), "linear_velocity_error": authoritative.linear_velocity - predicted_state.linear_velocity, "angular_velocity_error": authoritative.angular_velocity - predicted_state.angular_velocity, "contact_window": bool(prediction.get("contact_window", false)), } func _missing_status(seq: int) -> String: if _has_recorded and seq <= newest_recorded_seq - RING_SIZE: return "missing_evicted" return "missing_not_recorded"