feat(multiplayer): Phase 5 tasks 5.6-5.10 - disconnects, spectators, replay log

Completes Phase 5's implementation. Every task is verified at 1v1; the
3v3 phase gate itself has not been run and remains outstanding.

5.6/5.7 disconnects: a ship is never despawned. The slot keeps it and
swaps the controller (--fill-bots gives it a bot, the default leaves it
inert per §1.4), sets `stalled` immediately so the nameplate greys out
rather than waiting ~500ms for the abandoned jitter buffer to starve,
and reserves the slot for 30s keyed by player name so a reconnect gets
the same ship back.

5.7 was a real bug, found by the test rather than by review:
SlotInfo.controller was declared RLShipController, but the takeover
swaps in an AIShipController or the base controller - the narrower type
makes that assignment fail its type check, leaving the field pointing at
the controller set_controller() just queue_free()d. It surfaced as
controller_valid=false on the first run. The per-tick action write is
now also gated on `is RLShipController`, since a disconnected slot's bot
drives itself and overwriting it from a starving buffer would pin it to
the departed player's last input.

§6.4's two rules conflict: reserve for 30s, but abort when the last
human leaves. Applied naively the abort wins instantly in a 1v1 and the
reservation can never be redeemed, making reconnect unreachable exactly
when it matters. Abort now waits for no connections AND no outstanding
reservations.

5.8 spectators: a slotless peer spawns no ship and receives the same
snapshot broadcast. HUDController.spectator_mode keeps the clock, score
and goal celebration and hides only the ship instrument cluster - it
previously push_error'd and bailed, leaving a spectator with a dead HUD.
Camera cycles ships in slot order then the ball. --max-spectators caps
it, counted from the live peer list so a dropped spectator cannot leak a
unit of the cap.

5.9 escape respawn: new GameMode._on_bodies_respawned() virtual;
NetworkedMatch bumps reset_gen through Phase 2's deferred path so the
bump and the respawned pose land in the same broadcast. Single-player
modes are unaffected - the base is a no-op.

5.10 replay log: scripts/replay_log.gd, --replay-log=<path>, storing the
wire bytes verbatim in both directions rather than re-serialising - a
re-encode would launder away precisely the malformed payload being
chased. A live 6s match recorded 1115 records (557 inputs / 558
snapshots) and a stored snapshot decodes back to server_tick=100
match_state=WARMUP bodies=2.

Note for future work: --check-only --script is the only thing that
catches a parse error in networked_match.gd, because the unit runner
never loads it. Two separate breakages passed the full unit suite while
breaking every two-process run. A new class_name also needs --import
before it resolves.

Test surface: --role=host-disconnect (three-process 5.6/5.7 scenario),
--match-length=<s>, --replay-log, --fill-bots/--no-fill-bots,
--max-spectators. The ball-contact scenario now steers at the ball with
closed-loop real input instead of a hand-tuned fixed heading, which 5.3
broke by adding KICKOFF_YAW_JITTER; thrusting while turning took it from
2/3 to 5/5.

Regression: 87 unit tests; free-flight LAN p99 0.094m with 0 hard snaps;
transition gate 0.00%; ball contact 5/5; lifecycle goal cycle and full
match to RESULTS/LOBBY; disconnect+reconnect; two-bot CI.
This commit is contained in:
Josh Creek
2026-08-21 10:25:15 +01:00
parent 3d6906b981
commit a5cbc977b5
11 changed files with 708 additions and 14 deletions
+33
View File
@@ -30,6 +30,11 @@ class_name HUDController
@onready var camera_mode_label = get_node_or_null("Control/Instruments/Cluster/CameraModeLabel")
var ship: Node
# §6.3 (task 5.8). Set by the game mode BEFORE this node enters the tree when
# the local peer has no ship of its own. Distinct from `ship == null` by
# accident: a missing ship is still an error for a player, and silently
# degrading to a spectator HUD would hide that.
var spectator_mode := false
var _last_score := {0: 0, 1: 0}
var _goal_tween: Tween
@@ -43,6 +48,16 @@ func _initialize_hud():
# this runs — not discovered via group, since the "ship" group can have
# 2+ members and there's no reliable way to tell which one is "ours".
if not ship:
# §6.3 (task 5.8): a spectator legitimately has no ship of its own, and
# must still get the score, clock and goal celebration. Only the
# per-ship instrument cluster is meaningless without one, so hide that
# and carry on wiring everything else — this used to push_error and
# bail, which left a spectator with a completely dead HUD.
if spectator_mode:
print("HUDController: spectator mode — hiding ship instruments")
_hide_ship_instruments()
_connect_mode_signals()
return
push_error("HUDController: No ship assigned")
return
@@ -59,6 +74,24 @@ func _initialize_hud():
if camera_rig and camera_rig.has_signal("camera_mode_changed"):
camera_rig.camera_mode_changed.connect(_on_ship_camera_mode_changed)
_connect_mode_signals()
func _hide_ship_instruments() -> void:
# The per-ship cluster (speed, altitude, thrust, boost, camera mode) has no
# meaning without a ship. Everything else on the HUD still does.
for node in [speed_gauge, altitude_gauge, camera_mode_label]:
if node and is_instance_valid(node):
node.visible = false
var cluster := get_node_or_null("Control/Instruments/Cluster")
if cluster and is_instance_valid(cluster):
cluster.visible = false
# Everything that depends on the MODE rather than on owning a ship: clock,
# score, team identity, match-ended, kickoff countdown. A spectator gets all
# of it.
func _connect_mode_signals() -> void:
# Connect to game manager's timer signal; modes without a timer
# (e.g. free play) just don't show one
var game_manager = get_tree().get_first_node_in_group("game")
+13
View File
@@ -297,13 +297,26 @@ func _physics_process(_delta: float) -> void:
func _respawn_escaped_bodies() -> void:
var respawned := false
for ship in ships:
if is_instance_valid(ship) and _is_escaped(ship.global_position):
push_warning("GameMode: ship escaped the enclosed arena — check boundary colliders")
_reset_body(ship, _ship_spawn_transforms[ship])
respawned = true
if is_instance_valid(ball) and _is_escaped(ball.global_position):
push_warning("GameMode: ball escaped the enclosed arena — check boundary colliders")
_reset_body(ball, arena.get_ball_spawn())
respawned = true
if respawned:
_on_bodies_respawned()
# Virtual (task 5.9). An escape respawn is a teleport, and a networked client
# interpolating toward it would smoothly slide a body the width of the arena
# and then fight the correction. NetworkedMatch overrides this to bump
# reset_gen so clients hard-snap instead. Single-player modes need nothing.
func _on_bodies_respawned() -> void:
pass
func _is_escaped(position: Vector3) -> bool:
+5
View File
@@ -264,6 +264,11 @@ func _recv_input(bytes: PackedByteArray) -> void:
return
var decoded := NetCodec.unpack_input(bytes)
# Carry the verbatim wire bytes alongside the decode. Task 5.10's replay
# log stores exactly what arrived rather than a re-serialisation, which is
# the whole reason it can reproduce a reported snap: a re-encode would
# launder away precisely the malformed or edge-case payload being chased.
decoded["raw"] = bytes
input_received.emit(peer_id, decoded)
+289 -4
View File
@@ -107,7 +107,13 @@ class SlotInfo:
var team: int
var spawn_index: int
var ship: Ship
var controller: RLShipController # server only
# Base type, NOT RLShipController: §6.4's takeover swaps in either an
# AIShipController (--fill-bots) or the inert base controller, and a
# narrower declared type makes that assignment fail its type check — which
# leaves this field pointing at the controller set_controller() just
# queue_free()d. Exactly task 5.7's dangling reference, and it showed up as
# controller_valid=false the first time the disconnect test ran.
var controller: ShipController # server only
var jitter_buffer := InputJitterBuffer.new() # server only (§3.2)
# Server only. Consecutive packets rejected by the seq-range guard, reset by
# any accepted one. The guard's bound is derived from a value only an
@@ -115,6 +121,12 @@ class SlotInfo:
# permanently — see the guard's own comment in _on_input_received.
var consecutive_seq_rejects := 0
var last_client_send_ms := 0 # server only: echoed back per-peer next snapshot (§2.4)
# §6.4 (tasks 5.6/5.7). A ship is NEVER despawned on disconnect — the slot
# keeps its ship and swaps the controller, so body order (and therefore
# every snapshot index) stays stable for the whole match.
var player_name := "" # identity key for reconnect; peer_id changes across a reconnect
var disconnected := false
var reserved_until_tick := -1 # server only: slot held for this player until here
var interpolator := NetInterpolator.new() # client only
var visual_smoother_reset := true
var visual_position_offset := Vector3.ZERO
@@ -255,6 +267,14 @@ var _clock_running := false
var _last_emitted_second := -1
@export var match_length_seconds := 150.0
# §6.4's --fill-bots takeover controller. Mirrors match_mode.gd's exports so a
# server operator configures the replacement bot exactly as a single-player
# match configures its opponent, rather than through a second parallel scheme.
@export_group("Disconnect fill bot")
@export_file("*.json") var bot_model_path: String = ""
@export_range(1, 60) var bot_reaction_ticks: int = 8
@export_range(0.0, 1.0) var bot_action_noise: float = 0.0
# §6.2 step 6. The tick play resumes on — the countdown's own end. Both peers
# derive the displayed count from this and their own server-tick estimate, so
# nothing depends on a local Timer staying in step.
@@ -265,6 +285,18 @@ var _pending_kickoff := {}
# Freeze is applied on a strictly later tick than the kickoff teleport that
# precedes it — see _apply_kickoff. -1 when nothing is pending.
var _pending_freeze_tick := -1
# §1.4: public servers default to leaving an abandoned ship inert rather than
# handing it to a bot, so a disconnect cannot change the competitive balance
# of a match in progress. --fill-bots opts in.
var _fill_bots := false
# Task 5.10, server only. null unless --replay-log= was passed.
var _replay_log: ReplayLog = null
# §6.3 (task 5.8), client only.
var _is_spectator := false
var _spectator_target_index := 0
# §6.3's "cap with --max-spectators". Server only; 0 disables spectating
# entirely, negative means unlimited.
var _max_spectators := -1
var _last_emitted_countdown := -1
var _in_overtime := false
var _match_over := false
@@ -277,7 +309,24 @@ func _ready() -> void:
_kickoff_rng.randomize()
if multiplayer.is_server():
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--match-length="):
if arg == "--fill-bots":
_fill_bots = true
elif arg == "--no-fill-bots":
_fill_bots = false
elif arg.begins_with("--max-spectators="):
_max_spectators = maxi(0, arg.get_slice("=", 1).to_int())
elif arg.begins_with("--replay-log="):
# Task 5.10. Diagnostic only: a log that cannot be opened must
# never stop the server serving the match.
var replay_path := arg.get_slice("=", 1)
_replay_log = ReplayLog.new()
var replay_err := _replay_log.open_for_write(replay_path)
if replay_err != OK:
push_warning("NetworkedMatch: could not open replay log %s (%s)" % [replay_path, error_string(replay_err)])
_replay_log = null
else:
print("NetworkedMatch: recording replay log to %s" % replay_path)
elif arg.begins_with("--match-length="):
# Regulation is 150s; a smoke test cannot wait that long to see
# FULL_TIME/OVERTIME/RESULTS, so allow an override. Server-side
# only — a client cannot shorten anyone's match.
@@ -357,6 +406,7 @@ func _start_server() -> void:
slot.peer_id = peer_id
slot.team = info.team
slot.spawn_index = spawn_index
slot.player_name = info.player_name
slot.controller = RLShipController.new()
slot.ship = spawn_ship(info.team, spawn_index, slot.controller)
_slots.append(slot)
@@ -366,6 +416,8 @@ func _start_server() -> void:
MatchSim.send_match_config(arena_path, peer_ids, teams, spawn_indices)
MatchSim.input_received.connect(_on_input_received)
NetworkManager.client_disconnected.connect(_on_client_disconnected)
MatchNet.player_joined.connect(_on_player_joined_midmatch)
# §6.1: the arena, ball and every slot's ship now exist and match_config is
# out, so LOADING is genuinely over. Task 5.3 gates this on the clients'
@@ -454,6 +506,8 @@ func _on_input_received(peer_id: int, decoded: Dictionary) -> void:
# Fall through and accept: this is the escape hatch, not a
# missing `return`.
slot.consecutive_seq_rejects = 0
if _replay_log != null:
_replay_log.record_input(Engine.get_physics_frames(), peer_id, decoded.get("raw", PackedByteArray()))
jb.ingest(seq, decoded["actions"])
slot.last_client_send_ms = decoded["client_send_ms"]
return
@@ -876,6 +930,152 @@ func _on_goal_registered(conceding_team: int) -> void:
_broadcast_clock_state()
# Task 5.9. Server-only by construction: _respawn_escaped_bodies() is gated on
# _owns_world_simulation(). The bump uses Phase 2's deferred path because the
# respawn only QUEUES a teleport — bumping now would broadcast the new
# generation alongside the still-escaped position, which is exactly the 27m
# slide that fix exists to prevent.
# --- §6.4 disconnects and reconnects (tasks 5.6/5.7) -----------------------
const SLOT_RESERVATION_SECONDS := 30.0
func _on_client_disconnected(peer_id: int) -> void:
if not multiplayer.is_server():
return
for slot in _slots:
if slot.peer_id != peer_id or slot.disconnected:
continue
slot.disconnected = true
slot.reserved_until_tick = Engine.get_physics_frames() + int(SLOT_RESERVATION_SECONDS * SimConstants.TICK_HZ)
_swap_slot_controller(slot, _build_takeover_controller())
print("NetworkedMatch: peer %d (%s) disconnected; ship kept, slot reserved for %.0fs" % [
peer_id, slot.player_name, SLOT_RESERVATION_SECONDS
])
break
_abort_if_abandoned()
# §6.4 has two rules that pull against each other: reserve a departed player's
# slot for 30s, and abort to the lobby once the last human leaves. Applied
# naively the abort wins instantly in a 1v1 — the moment the only player drops,
# the match is torn down and their reservation can never be redeemed, which
# makes the reconnect path unreachable exactly when it matters most (a single
# player whose connection blipped). The reservation therefore takes precedence:
# abort only once nobody is connected AND nobody is still expected back.
func _abort_if_abandoned() -> void:
if MatchState.is_terminal(match_state):
return
var now := Engine.get_physics_frames()
for slot in _slots:
if not slot.disconnected:
return # somebody is still playing
if slot.reserved_until_tick >= 0 and now <= slot.reserved_until_tick:
return # somebody may still come back
print("NetworkedMatch: no players left and no reservations outstanding, aborting to lobby")
_set_match_state(MatchState.State.LOBBY)
get_tree().change_scene_to_file.call_deferred(ScenePaths.LOBBY)
# Task 5.7. Ship.set_controller() calls queue_free() on the OUTGOING
# controller, so slot.controller is a dangling reference the instant the swap
# happens — and _physics_process writes slot.controller.action every single
# tick. Rebinding must therefore happen in the same transaction as the swap,
# never as a follow-up statement that an early return or an await could skip.
func _swap_slot_controller(slot: SlotInfo, replacement: ShipController) -> void:
if not is_instance_valid(slot.ship):
slot.controller = null
return
slot.ship.set_controller(replacement)
slot.controller = replacement
func _build_takeover_controller() -> ShipController:
if _fill_bots:
return _build_opponent(bot_model_path, bot_reaction_ticks, bot_action_noise, "NetworkedMatch")
# §6.4's default for public servers: inert but still simulated, exactly the
# placeholder GameMode already uses for an unfilled slot. An abandoned ship
# that keeps flying on its last input would be worse than one that coasts.
return ShipController.new()
# Called when a peer joins while this match is already running. Returns true if
# it reclaimed a reserved slot (§6.4's 30s identity-keyed reservation).
func _try_reclaim_slot(peer_id: int, player_name: String) -> bool:
if not multiplayer.is_server():
return false
var now := Engine.get_physics_frames()
for slot in _slots:
if not slot.disconnected or slot.player_name == "" or slot.player_name != player_name:
continue
if slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
continue # reservation lapsed; this is a fresh joiner, not a return
slot.peer_id = peer_id
slot.disconnected = false
slot.reserved_until_tick = -1
# Reset the input pipeline: the returning client starts its sequence
# numbering from scratch, and the old buffer's cursor belongs to a
# different epoch entirely (input_jitter_buffer.gd's seeding comment).
slot.jitter_buffer = InputJitterBuffer.new()
slot.consecutive_seq_rejects = 0
_swap_slot_controller(slot, RLShipController.new())
print("NetworkedMatch: peer %d reclaimed %s's reserved slot" % [peer_id, player_name])
return true
return false
# §6.3/§6.4. A peer joining while this match runs is either a returning player
# claiming their reserved slot, or a late joiner — who spectates until the next
# kickoff, because swapping a controller at a kickoff boundary is free and
# mid-play it is not.
func _on_player_joined_midmatch(peer_id: int, player_name: String) -> void:
if not multiplayer.is_server() or _slots.is_empty():
return
if _try_reclaim_slot(peer_id, player_name):
return
if _max_spectators >= 0 and _spectator_count() > _max_spectators:
print("NetworkedMatch: spectator cap (%d) reached, disconnecting peer %d" % [_max_spectators, peer_id])
# Same call the abuse paths use (match_sim.gd:285, match_net.gd:207) —
# default force=false, so ENet flushes cleanly rather than leaving the
# server's own peer bookkeeping inconsistent (§9 gotcha on force=true).
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
return
print("NetworkedMatch: peer %d (%s) joined mid-match; spectating until the next kickoff" % [peer_id, player_name])
# Connected peers that hold no slot. Counted from the live peer list rather
# than tracked incrementally, so a spectator that drops cannot leak a unit of
# the cap permanently.
func _spectator_count() -> int:
var slotted := {}
for slot in _slots:
if not slot.disconnected:
slotted[slot.peer_id] = true
var count := 0
for peer_id in multiplayer.get_peers():
if not slotted.has(peer_id):
count += 1
return count
func _expire_slot_reservations() -> void:
var now := Engine.get_physics_frames()
for slot in _slots:
if slot.disconnected and slot.reserved_until_tick >= 0 and now > slot.reserved_until_tick:
slot.reserved_until_tick = -1
print("NetworkedMatch: %s's slot reservation lapsed" % slot.player_name)
# The abort was deferred while this reservation was live; now that
# it has lapsed, re-check whether anyone is left at all.
_abort_if_abandoned()
func _on_bodies_respawned() -> void:
if not multiplayer.is_server():
return
_pending_reset_gen_bump = true
_pending_reset_gen_bump_tick = Engine.get_physics_frames()
func _on_goal_scored(_conceding_team: int) -> void:
# Deliberately empty. Before task 5.4 this reset the world the instant the
# sensor fired, which is precisely the "server reset fires while clients
@@ -897,7 +1097,10 @@ func _broadcast_snapshot() -> void:
# total-garbage failure mode the moment that stops being true, and the
# fix costs nothing.
for slot in _slots:
bodies.append(_ship_to_net_body_state(slot.ship, slot.jitter_buffer.stalled) if is_instance_valid(slot.ship) else NetBodyState.new())
# §6.4: `stalled` is what greys out the nameplate, so a disconnected
# player must set it immediately rather than waiting the ~500ms it
# takes their abandoned jitter buffer to starve into the same state.
bodies.append(_ship_to_net_body_state(slot.ship, slot.jitter_buffer.stalled or slot.disconnected) if is_instance_valid(slot.ship) else NetBodyState.new())
if is_instance_valid(ball):
bodies.append(_ball_to_net_body_state(ball))
var segment := NetCodec.pack_snapshot_body_segment(server_tick, match_state, _reset_gen, bodies)
@@ -924,6 +1127,8 @@ func _broadcast_snapshot() -> void:
# genuine sustained server starvation event.
var advertised_depth := -2 if slot.jitter_buffer.starved_ticks >= STARVATION_ADVERTISEMENT_TICKS else slot.jitter_buffer.depth()
var bytes := NetCodec.pack_snapshot(last_input_seq, advertised_depth, slot.last_client_send_ms, segment)
if _replay_log != null:
_replay_log.record_snapshot(server_tick, bytes)
MatchSim.send_snapshot(slot.peer_id, bytes)
@@ -1026,7 +1231,15 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
if is_local:
_my_slot = slot
# §6.3 (task 5.8): a peer with no slot is a spectator. It receives the
# identical snapshot broadcast (zero extra server work), spawns no ship of
# its own, and points a camera rig at somebody else's.
_is_spectator = _my_slot == null
_spawn_hud()
if _is_spectator:
_spectator_target_index = 0
_point_spectator_camera()
print("NetworkedMatch: no slot for this peer — spectating (%d ship(s) + ball)" % _slots.size())
if is_instance_valid(_my_slot) and is_instance_valid(_my_slot.ship):
spawn_camera_rig(_my_slot.ship)
_my_slot.ship.ball_contact.connect(_on_local_ball_contact)
@@ -1062,9 +1275,70 @@ func _on_match_config_received(arena_path: String, peer_ids: PackedInt32Array, t
func _spawn_hud() -> void:
hud = HUD_SCENE.instantiate()
# BEFORE add_child: HUDController reads this in _initialize_hud(), which
# runs one process frame after _ready(). Setting it afterwards would be a
# race against that frame, and losing it means a spectator's HUD
# push_error()s about a missing ship and wires up nothing at all.
hud.spectator_mode = _is_spectator
add_child(hud)
# §6.3's "points a camera rig at a chosen ship or the ball", plus the target
# cycling. Targets are every ship in slot order, then the ball.
# §6.3's "cycle targets". Bound to the existing `reset_ball` action, which is
# already a mode-level key and is meaningless to a spectator (it only fires in
# Free Play), rather than adding a new binding to project.godot for one mode.
func _unhandled_input(event: InputEvent) -> void:
if not _is_spectator:
return
if event.is_action_pressed("reset_ball"):
cycle_spectator_target(1)
get_viewport().set_input_as_handled()
func _spectator_target_count() -> int:
return _slots.size() + (1 if is_instance_valid(ball) else 0)
func _point_spectator_camera() -> void:
var count := _spectator_target_count()
if count == 0:
return
_spectator_target_index = posmod(_spectator_target_index, count)
var target: Node3D = null
if _spectator_target_index < _slots.size():
target = _slots[_spectator_target_index].ship
else:
target = ball
if not is_instance_valid(target):
return
if not is_instance_valid(_camera_rig):
# spawn_camera_rig types its parameter as Ship, so the ball can only
# ever be a LATER target, never the one the rig is created with.
var first_ship: Ship = null
for slot in _slots:
if is_instance_valid(slot.ship):
first_ship = slot.ship
break
if first_ship == null:
return
spawn_camera_rig(first_ship)
if is_instance_valid(_camera_rig):
_camera_rig.target = target
if is_instance_valid(hud):
hud.ship = target if target is Ship else null
func cycle_spectator_target(step: int = 1) -> void:
if not _is_spectator:
return
var count := _spectator_target_count()
if count == 0:
return
_spectator_target_index = posmod(_spectator_target_index + step, count)
_point_spectator_camera()
func _send_local_input(record_prediction: bool = true) -> void:
if _slots.is_empty():
return # match_config hasn't arrived yet
@@ -1527,6 +1801,7 @@ func _physics_process(_delta: float) -> void:
# Also before the broadcast, so a transition taken this tick ships in
# this tick's own match_state byte rather than trailing it by one.
_update_match_state()
_expire_slot_reservations()
# Countdown and clock are derived from absolute ticks on both peers, so
# these run on the client too.
_apply_pending_freeze()
@@ -1543,7 +1818,17 @@ func _physics_process(_delta: float) -> void:
# integration. This preserves the existing one-tick server input delay
# while keeping snapshot.last_input_seq truthfully coupled to its body.
for slot in _slots:
slot.controller.action = slot.jitter_buffer.consume()
# is_instance_valid, not a null check: set_controller() queue_free()s
# the outgoing controller on every disconnect swap, and a freed
# object is non-null right up until the frame it is collected.
var consumed := slot.jitter_buffer.consume()
# Only a live player's slot is driven by the wire. A slot whose
# player disconnected now holds a bot or the inert base controller
# (§6.4), which drives itself — overwriting its action every tick
# from a permanently-starving jitter buffer would pin it to the
# departed player's last input forever.
if is_instance_valid(slot.controller) and slot.controller is RLShipController:
(slot.controller as RLShipController).action = consumed
if _pending_reset_gen_bump and Engine.get_physics_frames() > _pending_reset_gen_bump_tick:
_reset_gen = (_reset_gen + 1) % 256
_pending_reset_gen_bump = false
+131
View File
@@ -0,0 +1,131 @@
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}
+1
View File
@@ -0,0 +1 @@
uid://bfrexwrkq3cia