class_name ReplayLog extends RefCounted # Append-only binary server replay log (multiplayer-todo.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. const MAGIC := 0x50524343 const FORMAT_VERSION := 1 const HEADER_SIZE := 8 const RECORD_HEADER_SIZE := 11 enum RecordKind { INPUT = 0, # client -> server, as received SNAPSHOT = 1, # server -> client, as sent } var _file: FileAccess = null var records_written := 0 var bytes_written := 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) func _write(kind: int, tick: int, peer_id: int, payload: PackedByteArray) -> void: if _file == null: 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()) 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) 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}