class_name ReplayLog extends RefCounted # Append-only binary server replay log (multiplayer-next.md task 5.10). # # The highest-value debuggability investment in Phase 5, and cheap precisely # because the packets are ALREADY flat bytes: this stores them verbatim rather # than re-serialising game state. Without it, "my ship snapped" is permanently # unreproducible from a field report — the CI gate catches regressions, but it # cannot debug a player's bad night. # # Deliberately a standalone RefCounted with no scene/RPC dependency, like # net_codec.gd and input_jitter_buffer.gd, so it can be unit-tested against a # scripted record/read cycle with no live match. # # Format. Little-endian throughout, matching StreamPeerBuffer's own defaults # and NetCodec's wire encoding: # # magic u32 'CCRP' (0x50524343) # version u16 FORMAT_VERSION # tick_hz u16 so a reader can convert ticks to seconds without guessing # then, repeated: # kind u8 RecordKind # tick u32 server tick (Engine.get_physics_frames()) # peer_id u32 sender for INPUT, 0 for SNAPSHOT # length u16 payload byte count # payload length bytes, exactly as it went on the wire # # `length` is a u16 because both hot-path packets are far under 64KB (a 1v1 # snapshot is ~59 bytes) and MatchSim.MAX_INPUT_LENGTH already rejects # anything larger on the way in. # # Framing is kind-agnostic, so new RecordKind values are additive — a reader # that doesn't know a kind still walks past it correctly. The version bump to 2 # exists anyway because absence is otherwise ambiguous: without it, a log with # no REJECTED_* records cannot be told apart from one written by a build that # never recorded rejections in the first place, which is exactly the question # "the server dropped my input" needs answered. const MAGIC := 0x50524343 const FORMAT_VERSION := 2 const HEADER_SIZE := 8 const RECORD_HEADER_SIZE := 11 enum RecordKind { INPUT = 0, # client -> server, accepted and handed to the jitter buffer SNAPSHOT = 1, # server -> client, as sent # Rejections. An accepted-input-only log answers "what did the server # simulate", but the field report that actually needs a replay is usually # "my input did nothing" — and the packets that would explain it are # precisely the ones the old log discarded. Each reason is its own kind # rather than a reason field, so the framing above is unchanged. REJECTED_MALFORMED = 2, # failed §3.1 step 3 framing validation REJECTED_RATE_LIMIT = 3, # over budget for the current 1s window REJECTED_SEQ_GUARD = 4, # seq beyond the slot's ingest bound (§3.1 step 4) } var _file: FileAccess = null var records_written := 0 var bytes_written := 0 # Set once a write actually fails (disk full, removed volume). Everything after # it is dropped: a partial record would desync the framing of every record that # follows, turning a truncation into a corrupt file. var write_failed := false var records_dropped := 0 # Returns OK, or an error code. A replay log is diagnostic: a caller that # cannot open one should carry on serving the match, not refuse to start. func open_for_write(path: String) -> Error: _file = FileAccess.open(path, FileAccess.WRITE) if _file == null: return FileAccess.get_open_error() _file.store_32(MAGIC) _file.store_16(FORMAT_VERSION) _file.store_16(SimConstants.TICK_HZ) bytes_written = HEADER_SIZE return OK func is_open() -> bool: return _file != null func record_input(tick: int, peer_id: int, payload: PackedByteArray) -> void: _write(RecordKind.INPUT, tick, peer_id, payload) func record_snapshot(tick: int, payload: PackedByteArray) -> void: _write(RecordKind.SNAPSHOT, tick, 0, payload) # `kind` must be one of the REJECTED_* values; the caller knows why it dropped # the packet and nothing here can re-derive it. func record_rejected_input(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void: _write(kind, tick, peer_id, payload) func _write(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void: if _file == null or write_failed: return if payload.size() > 0xFFFF: # Cannot happen through the real ingress paths (see the header note), # but truncating silently would corrupt every later record's framing. push_warning("ReplayLog: dropping an oversized %d-byte payload" % payload.size()) records_dropped += 1 return _file.store_8(kind) _file.store_32(tick) _file.store_32(peer_id) _file.store_16(payload.size()) if payload.size() > 0: _file.store_buffer(payload) # Checked via get_error() rather than the store_* return values because it # reports the same condition once for the whole record instead of six times, # and because a diagnostic log that has quietly stopped writing is worse # than no log at all — the reader would see a plausible short match rather # than a failure. One write error ends the log permanently. var err := _file.get_error() if err != OK: write_failed = true records_dropped += 1 push_warning("ReplayLog: write failed (%s) after %d records — log closed early" % [error_string(err), records_written]) _file.close() _file = null return records_written += 1 bytes_written += RECORD_HEADER_SIZE + payload.size() func close() -> void: if _file == null: return _file.close() _file = null # Reads a whole log back. Returns {"tick_hz": int, "records": Array} or an # empty Dictionary if the file is missing/not a replay log. Static and # self-contained so an offline tool — or a test — can consume a log without # instantiating anything. static func read_all(path: String) -> Dictionary: var f := FileAccess.open(path, FileAccess.READ) if f == null: return {} if f.get_length() < HEADER_SIZE or f.get_32() != MAGIC: f.close() return {} var version := f.get_16() var tick_hz := f.get_16() var records: Array = [] # Bound every read on the declared length rather than trusting EOF: # FileAccess silently zero-fills past the end, exactly as StreamPeerBuffer # does, so a truncated file would otherwise decode as an endless run of # zero-length records at tick 0. while f.get_position() + RECORD_HEADER_SIZE <= f.get_length(): var kind := f.get_8() var tick := f.get_32() var peer_id := f.get_32() var length := f.get_16() if f.get_position() + length > f.get_length(): push_warning("ReplayLog: truncated final record in %s" % path) break records.append({ "kind": kind, "tick": tick, "peer_id": peer_id, "payload": f.get_buffer(length) if length > 0 else PackedByteArray(), }) f.close() return {"version": version, "tick_hz": tick_hz, "records": records}