Files
CosmicClash/Game/scripts/net_debug_overlay.gd
T
Josh Creek cf73074e27 fix(multiplayer): resolve composition regression from second adversarial review
A second adversarial review of the previous fix commit found two of its
nine fixes silently defeated each other: the seq-range guard (fix for a
MEDIUM epoch-mismatch finding) capped the exact variable the ring-overflow
resync (fix for the original CRITICAL finding) depends on, making the
resync unreachable in production and recreating permanent input death at
a lower failure threshold, reachable via ordinary server tick loss alone.

- CRITICAL: rebind the seq-range guard to InputJitterBuffer's own
  highest_ingested_seq (now public) instead of the consumer-side
  last_applied_seq, so it tracks the client's send epoch rather than a
  value that can lag arbitrarily far behind during a stall.
- HIGH: InputLeadController's release logic still ANDed the old
  `lead > LEAD_MIN` gate onto the new depth-driven condition, so a
  backlog the controller never caused still couldn't drain. Split into
  two independent decisions: the seq-duplicate action follows real
  depth alone; lead's own bookkeeping separately never drops below its
  floor.
- MEDIUM: widen the CI driver's movement/stalled sampling margin
  (run_seconds - 2.0, was - 0.5) and assert the peer is still in
  multiplayer.get_peers() at sample time, since the old margin let the
  check pass on residual starvation grace after a bot had already
  disconnected.
- LOW: measure horizontal-only displacement in the human smoke test's
  movement check — the old 3D-distance bar was beatable by pure
  gravity settling with fully dead input.
- LOW: fix a real "clean stderr" violation (match_net.gd broadcasting
  a departure notice to a peer whose ENet channels are already torn
  down, including a second peer disconnecting in the same poll batch)
  by deferring the notification to the next idle frame.
- Wire the server's per-slot stalled bit into the client debug overlay
  for real — a prior commit message claimed this already reached the
  overlay when only the CI gate actually read it.

Re-verified end-to-end against the real production RPC path (not just
unit tests in isolation, which is how the composition bug got past the
first round): a 2-bot CI match with a 1.5s host SIGSTOP freeze injected
mid-run, well past the 0.6s threshold the review reproduced the bug at,
now recovers cleanly on repeated runs with zero stderr noise.
2026-08-20 18:26:12 +01:00

66 lines
2.7 KiB
GDScript

extends CanvasLayer
# Autoload: toggleable network debug overlay (F4 by default — see
# toggle_net_overlay in project.godot's [input]). Read-only against
# NetworkManager's clock state (task 1.8). Mirrors perf_overlay.gd's pattern
# — headless-guarded, hidden by default, no gameplay-state writes.
var _label: Label
func _ready() -> void:
if DisplayServer.get_name() == "headless":
set_process(false)
return
layer = 100
_label = Label.new()
_label.add_theme_font_size_override("font_size", 14)
_label.add_theme_color_override("font_color", Color(0.5, 0.8, 1.0))
_label.add_theme_color_override("font_shadow_color", Color(0, 0, 0, 0.85))
_label.add_theme_constant_override("shadow_offset_x", 1)
_label.add_theme_constant_override("shadow_offset_y", 1)
_label.position = Vector2(12, 90)
_label.visible = false
add_child(_label)
func _unhandled_input(event: InputEvent) -> void:
if event.is_action_pressed("toggle_net_overlay") and _label:
_label.visible = not _label.visible
func _process(_delta: float) -> void:
if not _label or not _label.visible:
return
if NetworkManager.is_server:
_label.text = "NET: server, %d peer(s) out %s in %s" % [
MatchNet.roster.size(), _format_kbps(MatchSim.get_bytes_sent_per_sec()), _format_kbps(MatchSim.get_bytes_received_per_sec()),
]
elif NetworkManager.is_client:
if NetworkManager.rtt_ms < 0.0:
_label.text = "NET: client, connecting (no clock sample yet)"
else:
# task 3.7: RTT, jitter, loss, buffer depth, snapshot age,
# bandwidth all live here now. Prediction error is intentionally
# absent — there is no client-side prediction until Phase 4, so
# there is nothing honest to show for it yet. STALLED shows the
# server's own InputJitterBuffer.stalled bit for this client's
# slot, round-tripped through the wire.
var stats := {}
var game := get_tree().get_first_node_in_group("game")
if game and game.has_method("get_net_debug_stats"):
stats = game.get_net_debug_stats()
var stalled_suffix := " STALLED" if stats.get("server_stalled", false) else ""
_label.text = "NET: client RTT %.1fms jitter %.1fms offset %.1fms\nbuf depth %s lead %s loss %.1f%% snap age %.1fms%s\nout %s in %s" % [
NetworkManager.rtt_ms, NetworkManager.jitter_ms, NetworkManager.clock_offset_ms,
str(stats.get("input_buffer_depth", -1)), str(stats.get("input_lead", "-")),
stats.get("snapshot_loss_pct", 0.0), stats.get("snapshot_age_ms", 0.0), stalled_suffix,
_format_kbps(MatchSim.get_bytes_sent_per_sec()), _format_kbps(MatchSim.get_bytes_received_per_sec()),
]
else:
_label.text = "NET: offline"
func _format_kbps(bytes_per_sec: float) -> String:
return "%.2f KB/s" % (bytes_per_sec / 1000.0)