Files
CosmicClash/Game/scripts/net_sim.gd
T
Josh Creek 14698d4ccb 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.
2026-08-20 12:43:33 +01:00

135 lines
6.7 KiB
GDScript

extends Node
# Autoload (project.godot [autoload] NetSim). Debug-only, seeded
# latency/jitter/loss/duplicate decorator around outgoing RPC dispatch —
# task 2.8. A pure passthrough (send() calls dispatch.call() immediately)
# unless CLI flags are given, so every existing test and the real game are
# byte-for-byte unaffected by this autoload merely existing.
#
# CLI (read once, in this process's own OS.get_cmdline_user_args()):
# --net-sim-latency=<ms> one-way delay added before each wrapped send
# --net-sim-jitter=<ms> extra uniform-random 0..jitter added per send
# --net-sim-loss=<0..1> fraction of sends dropped entirely (never sent)
# --net-sim-dup=<0..1> probability a send is ALSO sent a second time
# --net-sim-seed=<int> RNG seed (default fixed, so a bad run reproduces
# unless a CI/local run deliberately wants a
# different one — same "seeded so failures
# reproduce" bar as §11's testing section sets)
#
# "Asymmetric-capable" per §7 task 2.8 is not a separate feature: each
# process reads only its own CLI args and only delays its own outgoing
# sends, so running the host and client with different flags (e.g. a
# lossy-upload client against a clean host) already produces asymmetric
# behaviour with no extra plumbing.
#
# Call sites build a zero-argument Callable that performs the actual
# rpc_id()/rpc() dispatch, so NetSim never needs to know per-call argument
# shapes. IMPORTANT for callers that embed a timestamp in the call (e.g.
# NetworkManager's _ping/_pong): capture Time.get_ticks_msec() *before*
# calling send(), not inside the wrapped Callable — the delay is meant to
# simulate wire transit *after* the packet is "sent", so a timestamp taken
# inside the delayed closure would silently absorb this process's own
# outbound leg out of any round-trip measurement built on top of it.
#
# Wraps MatchSim.send_input / send_snapshot per the doc's task 2.8 scope,
# plus NetworkManager's _ping/_pong dispatch — the latter is a deliberate
# addition beyond the literal task text: it's the only RTT measurement that
# already exists and is already tested (tests/clock_smoke.gd, task 1.8), so
# routing it through NetSim is what makes "`--net-sim-latency 80` measurably
# raises observed RTT" (this task's own stated acceptance criterion)
# checkable today, without waiting on Phase 3's per-peer snapshot echo.
const DEFAULT_SEED := 20260820
var latency_ms := 0.0
var jitter_ms := 0.0
var loss_fraction := 0.0
var dup_fraction := 0.0
var _rng := RandomNumberGenerator.new() # owned instance — never the global RNG, task 0.7's rule
func _ready() -> void:
var seed_value := DEFAULT_SEED
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--net-sim-latency="):
latency_ms = maxf(0.0, arg.get_slice("=", 1).to_float())
elif arg.begins_with("--net-sim-jitter="):
jitter_ms = maxf(0.0, arg.get_slice("=", 1).to_float())
elif arg.begins_with("--net-sim-loss="):
loss_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0)
elif arg.begins_with("--net-sim-dup="):
dup_fraction = clampf(arg.get_slice("=", 1).to_float(), 0.0, 1.0)
elif arg.begins_with("--net-sim-seed="):
seed_value = arg.get_slice("=", 1).to_int()
_rng.seed = seed_value
func is_active() -> bool:
return latency_ms > 0.0 or jitter_ms > 0.0 or loss_fraction > 0.0 or dup_fraction > 0.0
# target_peer_id: the specific remote peer this dispatch is addressed to
# (rpc_id's target), or -1 for a broadcast / not a targeted send. Only used
# to re-validate a delayed send right before it actually fires — see _fire.
func send(dispatch: Callable, target_peer_id: int = -1) -> void:
if not is_active():
dispatch.call()
return
if _rng.randf() < loss_fraction:
return
_schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0)
if _rng.randf() < dup_fraction:
_schedule(dispatch, target_peer_id, (latency_ms + _rng.randf() * jitter_ms) / 1000.0)
func _schedule(dispatch: Callable, target_peer_id: int, delay_sec: float) -> void:
if delay_sec <= 0.0:
dispatch.call()
return
# 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.
# NetSim's whole point is to hold a packet in flight past the moment it was
# queued, and in that window the target peer (or this process's own
# connection) can legitimately be gone — a disconnect mid-match, or this
# process's own shutdown() already having reset multiplayer_peer to a fresh
# OfflineMultiplayerPeer. Firing anyway reproduced two real bugs while
# building this task: "Attempt to call RPC with unknown peer ID" (stale
# remote target — networked_match.gd's own get_peers() filter on
# _broadcast_snapshot only checked validity at *schedule* time, and the
# target had disconnected by the time the delayed send actually fired) and
# "'_recv_input' on yourself is not allowed by selected mode" (this
# process's own peer was already torn down, so peer id 1 now refers to
# itself instead of the server). The synchronous (delay_sec <= 0 / NetSim
# inactive) path is deliberately NOT re-validated here — nothing has had
# 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()