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. record() can be called twice for the same seq with a DIFFERENT action, # when input_lead_controller's release path resends a duplicated seq # (delta == 0) — the later call silently overwrites the ring slot, so # the stored action becomes whichever of the two calls happened last. # This matches what the WIRE ends up sending for that seq (the resend # replaces the redundancy history's front entry — see # networked_match.gd's _send_local_input), but if the SERVER had # already consumed the seq from the first packet before the resend # arrived, the server's applied action and this ring's stored action for # that same seq can disagree. Narrow (release only fires after 120 ticks # of sustained surplus depth, when the server is least likely to be # right on the edge of consuming that exact seq) but real; a future # replay-based catch-up (task 4.5) built on this history should not # assume the stored action is provably what the server actually applied. 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 # 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) -> 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(), } 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] return { "seq": seq, "action": (entry["action"] as ShipAction).copy(), "state": (entry["state"] as NetBodyState).copy(), } # 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 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, } func _missing_status(seq: int) -> String: if _has_recorded and seq <= newest_recorded_seq - RING_SIZE: return "missing_evicted" return "missing_not_recorded"