fix(multiplayer): adversarial review fixes for Phase 2

An Opus subagent's adversarial review of Phase 2 found real bugs the
smoke tests couldn't catch, since constant-velocity dead reckoning still
moves a ship far enough to pass a "moved > 1.0" check:

- The interpolator never actually interpolated. NetInterpolator.to_tick()
  assumes physics_frame * TICK_MS == Time.get_ticks_msec() on the server,
  which is off by a steady ~45-55ms in practice (real startup work before
  the first physics step, widened by any dropped tick). Every sample_at()
  call took the extrapolation branch, 100% of the time, defeating the
  interpolation buffer entirely. Fixed with a shared, min-filtered rolling
  bias estimate in networked_match.gd, applied before every to_tick() call.

- Goals caused a ~27m visual slide: _reset_gen was bumped before the
  queued teleport actually landed, so the client's buffer-clear kept
  exactly the stale in-goal sample and lerped a slide to the next, real
  one. Fixed by tracking the tick the goal was detected on and only
  bumping the generation once strictly later ticks confirm the teleport
  has landed - a naive "next _physics_process" boolean flag doesn't
  work, since a goal Area's body_entered fires before that same tick's
  _physics_process runs, not on the next one.

- _local_input_sampler (a Node, never added to the tree) was never freed
  - this was the unexplained "3 resources still in use at exit" warning
  on every Phase 2 test run.

- Ball angular velocity decoded 8x too small (rescale_avel was never
  called); get_server_time_estimate_ms() was used before the clock had
  synced; net_sim.gd's delayed-send timer stopped ticking while the tree
  was paused and didn't check connection status before firing;
  _broadcast_snapshot's ball index could silently break if a ship were
  ever despawned; declared-but-unemitted HUD lifecycle signals showed a
  permanently frozen timer widget.

Also confirmed, empirically, several things the review checked and found
fine: a hostile client sending malformed input cannot crash the server,
skipping GameMode's super() drops nothing load-bearing, deterministic
slot assignment is correct with 2 real simultaneous clients, and RPC
authority enforcement genuinely rejects a forging client.

All fixes verified with real two-process runs (including forcing an
actual goal and reading the server's own broadcast stream) and temporary
instrumentation, removed once each fix was confirmed. Full Phase 1 +
Phase 2 regression suite, including the net-sim-latency milestone gate,
re-run clean after every fix.
This commit is contained in:
Josh Creek
2026-08-20 12:43:33 +01:00
parent 7b150ef72e
commit 14698d4ccb
3 changed files with 169 additions and 12 deletions
+19 -1
View File
@@ -87,7 +87,14 @@ func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> voi
if delay_sec <= 0.0:
dispatch.call()
return
get_tree().create_timer(delay_sec, false).timeout.connect(func() -> void: _fire(dispatch, target_peer_id))
# process_always = true: a simulated wire delay must keep counting down
# even if the local SceneTree pauses (match_mode.gd's goal-pause does
# this today; multiplayer-todo.md §8 already flags get_tree().paused
# stopping the client's own send/receive loop as a separate refactor
# item). Pausing this timer too would let a paused client's in-flight
# packets pile up and arrive in a burst on unpause instead of on their
# simulated schedule.
get_tree().create_timer(delay_sec, true).timeout.connect(func() -> void: _fire(dispatch, target_peer_id))
# Re-validates the target right before a DELAYED send actually fires.
@@ -107,10 +114,21 @@ func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> voi
# time to change since the caller's own validation, and matching the
# pre-NetSim behaviour exactly there is what keeps NetSim a true no-op when
# no CLI flags are given.
#
# Known residual gap, judged not worth the complexity for debug-only
# tooling: if this process shuts down AND reconnects (a fresh host()/join())
# within one delayed send's hold time, multiplayer_peer is a real peer again
# and get_peers() may coincidentally contain the same target_peer_id from
# the new session, so a stale send from the old session could slip through.
# Closing that fully would need a generation counter bumped on every
# shutdown/host/join and stamped on each scheduled send — disproportionate
# for a latency simulator that only ever runs in manual/CI testing.
func _fire(dispatch: Callable, target_peer_id: int) -> void:
var peer := multiplayer.multiplayer_peer
if peer == null or peer is OfflineMultiplayerPeer:
return
if peer is ENetMultiplayerPeer and peer.get_connection_status() != MultiplayerPeer.CONNECTION_CONNECTED:
return
if target_peer_id != -1 and target_peer_id not in multiplayer.get_peers():
return
dispatch.call()