Files
CosmicClash/Game/scripts/match_sim.gd
T
Josh Creek 866efa0d9b fix(multiplayer): server no longer rate-limits a backlog it caused itself
Closes task 5.10's three recording gaps, and the gap-closing found a real
input-loss bug.

Replay log: a failed write now ends the log permanently instead of
desyncing every later record's framing; close() is called from _exit_tree
with a summary, since the RefCounted destructor closes it implicitly but
never says whether the log is complete; rejected packets are recorded
with their reason in the kind byte (framing unchanged, FORMAT_VERSION 2
so "no rejects" differs from "this build never recorded them"). Recording
is capped at 8 per peer per window - uncapped, the diagnostic is a remote
disk-fill amplifier, since the attacker picks the packet rate. Uncapped
totals live on MatchSim and survive the peer's disconnect.

The bug: a 2s host stall has the client sending at 60Hz throughout, and
ENet delivers that whole backlog in the first window after resume - 70 of
an honest client's packets rejected as "rate limit exceeded". Redundancy
does not cover it, because the dropped packets are contiguous: 0 of 70
rescued, and 82 of 923 sequences (8.88%, ~1.4s of input) never reached
the server, against 0.00% with no stall. Every prediction gate passed.

Fixed by granting each already-tracked peer a capped, two-window packet
grace when the server detects its own wall-clock stall. Rate-limit
rejects 70 -> 0, sequences missing 8.88% -> 0.00%, seq-guard rejects
9 -> 0. Controls on the unfixed build lost 4.34/7.52/7.86%. All three
abuse roles still disconnect and no flood induced a stall, so the grace
cannot be farmed.

Also corrects an earlier wrong conclusion: the reviewer's free-flight
p95 0.688 is real and reproduces on two processes with 0.0% snapshot
loss. The plain --role=client drive fails the 0.5 free-flight bound in
3 of 8 runs because that drive is mostly a contact test - the harness
comment already said so - leaving a cohort as small as 12 samples.
Near-surface error is genuinely several times open-air error, so the
calibrated bound now belongs to --exercise-free-flight alone and the
plain role asserts the always-well-sampled all-cohort percentiles at
1.2/2.0, printing the free-flight numbers as reported-not-asserted.
6/6 plain runs pass where 3/7 failed; tightening to 0.3 still fails.

tools/replay_dump.gd reads a log back: counts by kind, plus how much of
the input sequence stream reached the server once redundancy is counted.
2026-08-21 16:10:43 +01:00

503 lines
25 KiB
GDScript

extends Node
# Autoload (project.godot [autoload] MatchSim). Phase 2 simulation RPCs:
# match_config (server assigns arena + deterministic slot order from
# MatchNet.roster), input (client -> server, per-tick action), snapshot
# (server -> client, NetCodec-packed body state), and a small score_update
# for the HUD. Lives on an autoload per §1.3's derived decision ("All
# hot-path RPCs live on autoloads") even though these are scoped to
# whichever match happens to be running — a scene-node RPC target would
# need matching NodePaths across peers, which an autoload sidesteps
# entirely, and it's what lets NetworkedMatch itself stay a plain scene
# node with no networking-identity concerns of its own.
#
# Channel intent per §2.1: 0 reliable (match_config, score_update), 1
# unreliable-ordered (input), 2 unreliable-ordered (snapshot) — not yet
# verified against ENet's own reserved system channel offset (§2.1's own
# "verify empirically" hedge); if that turns out to matter these indices
# will need adjusting, not the RPC design itself.
const NetCodec = preload("res://scripts/net_codec.gd")
signal match_config_received(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array)
signal input_received(peer_id: int, decoded: Dictionary) # decoded: see NetCodec.unpack_input
# Task 5.10. A packet this autoload dropped before it could ever reach a match,
# with the verbatim bytes — the replay log's whole reason to exist is the field
# report "my input did nothing", and an accepted-input-only log has thrown away
# exactly the evidence that would explain it. `reason` is an InputRejectReason;
# the transport layer deliberately does not know about the replay format's own
# record kinds, so the mapping lives at the listener.
signal input_rejected(peer_id: int, reason: int, bytes: PackedByteArray)
signal snapshot_received(decoded: Dictionary) # decoded: see NetCodec.unpack_snapshot
signal score_update_received(score: Dictionary)
signal state_change_received(state: int, at_tick: int) # §6.1 MatchState.State
# §6.2 step 6. positions/rotations are body-order: every slot in order, then
# the ball — the same order the snapshot uses, so one convention covers both.
# rotations is 4 floats per body (x, y, z, w).
signal kickoff_received(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int)
signal goal_scored_received(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int)
signal clock_state_received(running: bool, end_tick: int, remaining_ticks: int, at_tick: int)
signal match_bootstrap_received(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int)
# Input validation (multiplayer-todo.md §3.1 steps 2-3, task 3.4). Deliberately
# lives here rather than in NetworkedMatch: framing/rate abuse is a protocol-
# level concern independent of any particular match's roster/slot state, and
# this autoload already owns the RPC that receives the raw bytes.
#
# 60Hz * 1.5 + 20, per §3.1 step 2's own numbers.
const RATE_LIMIT_PACKETS_PER_SEC := 110
# "Same for a byte budget" (§3.1 step 2) — the worst-case legitimate packet
# is a full-redundancy input (INPUT_HEADER_SIZE + MAX_REDUNDANCY entries,
# the "40 B input" §2.3 sizes to), so the byte budget is just the packet
# budget scaled by that worst-case size — no separate constant to keep in
# sync by hand.
const RATE_LIMIT_BYTES_PER_SEC := RATE_LIMIT_PACKETS_PER_SEC * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
const RATE_LIMIT_WINDOW_MS := 1000
# Leaky-bucket excess tolerance, expressed in the same "N seconds' worth of
# budget" terms the original consecutive-streak design used. An adversarial
# review found that design — a streak counter that HARD-RESET to 0 on any
# single clean window — was trivially evaded by a duty-cycled flood (burst,
# then one clean window, repeat): reproduced sustaining ~33x the packet
# budget indefinitely with zero disconnect warnings. A leaky bucket doesn't
# care how the excess is distributed in time — see the window-roll logic
# below for how it accumulates and drains.
const RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT := RATE_LIMIT_PACKETS_PER_SEC * 3
const RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT := RATE_LIMIT_BYTES_PER_SEC * 3
const MALFORMED_LIMIT_TO_DISCONNECT := 20
# Task 5.10: how many rejected packets per peer per rate-limit window are
# forwarded to `input_rejected`. Sized so an honest client — whose rejects are
# occasional by definition, since a client rejected every tick is a bug the log
# is meant to catch — is never sampled away, while a flood cannot turn the log
# into unbounded attacker-controlled disk writes.
const REJECTS_RECORDED_PER_WINDOW := 8
# Server-stall grace (found by task 5.10's own reject recording, which is the
# only reason it was visible at all).
#
# When the server stalls — a 2s SIGSTOP stands in for a GC/IO/scheduler hitch —
# the client keeps sending at 60Hz throughout, and ENet delivers that entire
# backlog in the first window after resume. Measured: 70 of an HONEST client's
# input packets rejected as "rate limit exceeded", against a limit the client
# never came close to violating on its own. Redundancy does not cover it: the
# dropped packets are CONTIGUOUS, so each one's redundancy window falls inside
# the same dropped run — 0 of 70 were rescued, and 82 of 923 sequences (8.88%,
# ~1.4s of that player's input) never reached the server at all, versus 0.00%
# missing on an otherwise identical run with no stall. Every prediction gate
# still passed, which is exactly why this needed the log to find.
#
# So: don't rate-limit a backlog the server itself caused. The grace is capped,
# expires after two windows, and is granted only to peers already being
# tracked, so it cannot be farmed by a peer that connects during the stall. An
# attacker who can induce server stalls to earn budget already has a strictly
# worse capability than sending extra input packets.
const STALL_DETECT_MS := 250
const MAX_STALL_GRACE_PACKETS := SimConstants.TICK_HZ * 4 # 4s of a 60Hz client's backlog
const STALL_GRACE_WINDOWS := 2
enum InputRejectReason {
MALFORMED = 0,
RATE_LIMIT = 1,
}
class _PeerInputState:
var window_start_ms := 0
var packets_this_window := 0
var bytes_this_window := 0
# Leaky bucket: grows by this window's actual total, drains by one
# window's worth of budget, every window — regardless of whether that
# window was itself over or under budget. A steady rate at or under
# budget nets to zero forever (never accumulates); any sustained AVERAGE
# above budget accumulates over time no matter how it's shaped into
# bursts, unlike a streak counter a clean gap can reset to 0.
var excess_packets := 0.0
var excess_bytes := 0.0
var malformed_count := 0
# Reject-recording budget for the current window. Without it the diagnostic
# is a remote disk-fill amplifier: the attacker chooses the flood rate, and
# every dropped packet would otherwise become a disk write. Capped per
# window, reset with the window itself, so an honest client's occasional
# reject is always captured while a flood contributes a bounded sample.
var rejects_recorded_this_window := 0
# Extra packets this peer may send before the limiter treats it as abuse,
# granted when the SERVER stalls and expiring shortly after.
var grace_packets := 0
var grace_windows_left := 0
var _peer_input_state: Dictionary = {} # peer_id -> _PeerInputState, server only
# Uncapped lifetime reject totals, so the sampled log can be read against the
# true figure — "8 rate-limit rejects recorded" means nothing on its own when
# the recorder itself stops at 8 per window. Deliberately NOT part of
# _PeerInputState, which is erased the moment a peer disconnects: a departed
# peer's reject history is exactly what the post-mortem wants, and the first
# version of this lost it (every summary printed an empty dictionary, because
# the client had always disconnected by the time the server tore the match
# down). peer_id -> {"malformed": int, "rate_limit": int}.
var _reject_totals: Dictionary = {}
var _last_physics_ms := 0
# Bandwidth (task 3.7's debug overlay): only the two 60Hz hot-path channels
# (input, snapshot) — match_config/score_update are low-frequency control
# messages, not what §2's byte-budget analysis or a live overlay cares
# about. Rolling per-second counters, recomputed opportunistically on each
# send/receive rather than on a timer — nothing needs the rate outside of
# an on-demand overlay read anyway. Use get_bytes_sent_per_sec() /
# get_bytes_received_per_sec() to READ these, not the raw fields directly
# — see those functions for why.
const BANDWIDTH_WINDOW_MS := 1000
var bytes_sent_per_sec := 0.0
var bytes_received_per_sec := 0.0
var _sent_window_start_ms := 0
var _sent_window_bytes := 0
var _received_window_start_ms := 0
var _received_window_bytes := 0
# An adversarial review found bytes_*_per_sec only ever gets recomputed
# INSIDE _track_sent()/_track_received() — i.e. only when traffic actually
# arrives — so if traffic stops entirely (right before a disconnect, or
# during exactly the kind of outage this overlay exists to diagnose), the
# last computed rate displays forever instead of decaying toward zero.
# Report zero once meaningfully more than one window has passed with
# nothing tracked, rather than trusting a stale field.
func get_bytes_sent_per_sec() -> float:
if Time.get_ticks_msec() - _sent_window_start_ms > BANDWIDTH_WINDOW_MS * 2:
return 0.0
return bytes_sent_per_sec
func get_bytes_received_per_sec() -> float:
if Time.get_ticks_msec() - _received_window_start_ms > BANDWIDTH_WINDOW_MS * 2:
return 0.0
return bytes_received_per_sec
func _ready() -> void:
NetworkManager.client_disconnected.connect(func(peer_id: int) -> void: _peer_input_state.erase(peer_id))
# Seeded here, not left at 0, so the first physics frame measures a frame
# gap rather than the whole process uptime.
_last_physics_ms = Time.get_ticks_msec()
# Server-side stall watchdog. A SIGSTOPped or hitching process doesn't run this
# either, so the first physics frame after the stall is the one that sees the
# whole wall-clock gap — which is precisely the size of the client backlog
# about to arrive. Grace is handed only to peers ALREADY sending input, so a
# peer that connects during the stall gets none of it.
func _physics_process(_delta: float) -> void:
var now := Time.get_ticks_msec()
var gap := now - _last_physics_ms
_last_physics_ms = now
if not multiplayer.is_server() or _peer_input_state.is_empty():
return
if gap < STALL_DETECT_MS:
return
var credit: int = mini(int(float(gap) * SimConstants.TICK_HZ / 1000.0), MAX_STALL_GRACE_PACKETS)
for peer_id in _peer_input_state:
var state: _PeerInputState = _peer_input_state[peer_id]
state.grace_packets = mini(state.grace_packets + credit, MAX_STALL_GRACE_PACKETS)
state.grace_windows_left = STALL_GRACE_WINDOWS
push_warning("MatchSim: server stalled %dms — granting %d packets of rate-limit grace to %d peer(s)" % [
gap, credit, _peer_input_state.size()
])
func _track_sent(n: int) -> void:
var now := Time.get_ticks_msec()
if now - _sent_window_start_ms >= BANDWIDTH_WINDOW_MS:
bytes_sent_per_sec = _sent_window_bytes * 1000.0 / maxf(1.0, float(now - _sent_window_start_ms))
_sent_window_start_ms = now
_sent_window_bytes = 0
_sent_window_bytes += n
func _track_received(n: int) -> void:
var now := Time.get_ticks_msec()
if now - _received_window_start_ms >= BANDWIDTH_WINDOW_MS:
bytes_received_per_sec = _received_window_bytes * 1000.0 / maxf(1.0, float(now - _received_window_start_ms))
_received_window_start_ms = now
_received_window_bytes = 0
_received_window_bytes += n
# Server only: the last match_config actually sent, so a client whose own
# scene load (and therefore its match_config_received listener) finishes
# AFTER the server already broadcast can still get it — a one-shot
# broadcast alone is racy against however long the client takes to reach
# the point where it's listening, and Godot signals never buffer for a
# late connection. request_match_config() closes that race by turning
# delivery into "ask until you get it" instead of "hope you were already
# listening." Also covers a late joiner mid-match (Phase 5 will still need
# to add live match *state*, not just this static config, for that case).
var _last_match_config: Dictionary = {}
func send_match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
_last_match_config = {
"arena_path": arena_path, "peer_ids": peer_ids, "teams": teams, "spawn_indices": spawn_indices,
}
_match_config.rpc(arena_path, peer_ids, teams, spawn_indices)
# Also the client's cue to ask for live match state — see
# NetworkedMatch._on_match_config_requested. A late joiner's bootstrap has the
# SAME race match_config has: the server sends it when the peer joins the
# roster, which is before that peer has loaded the match scene and connected
# its listeners, so a one-shot send is simply missed. Delivery has to be
# "ask until you get it" for both.
signal match_config_requested(peer_id: int)
func request_match_config() -> void:
_request_match_config.rpc_id(1)
func send_input(bytes: PackedByteArray) -> void:
_track_sent(bytes.size())
# bytes is already fully packed (any timestamps it carries are already
# fixed), so wrapping the dispatch itself is enough — task 2.8.
NetSim.send(func() -> void: _recv_input.rpc_id(1, bytes), 1)
func send_snapshot(peer_id: int, bytes: PackedByteArray) -> void:
_track_sent(bytes.size())
NetSim.send(func() -> void: _snapshot.rpc_id(peer_id, bytes), peer_id)
func send_score_update(score: Dictionary) -> void:
_score_update.rpc(score)
# §6.1 task 5.1. Reliable channel 0, and it carries the ABSOLUTE tick the
# transition happened on rather than a duration — §6.2's closing note: on a
# lossy link ENet's RTO can stretch a lifecycle burst to ~600ms, and a
# duration would then be applied from whenever it happened to arrive.
# The same state also rides every snapshot's match_state byte, so a client
# that misses this entirely still converges (see NetworkedMatch's own
# _on_snapshot_received) — this RPC exists to make the transition PROMPT and
# to carry `at_tick`, not to be the sole channel.
func send_state_change(state: int, at_tick: int) -> void:
_state_change.rpc(state, at_tick)
# §1's "seeded RNG for kickoff jitter" decision, enforced: the server sends the
# resulting TRANSFORMS, never a seed. Shared-seed determinism would require
# both sides to consume the RNG stream in identical order forever, and the
# first randf() anyone later adds to the reset path silently desyncs kickoff
# positions with no error message. A few hundred bytes once per kickoff cannot
# rot that way.
func send_kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
_kickoff.rpc(positions, rotations, countdown_start_tick, reset_gen)
func send_goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
_goal_scored.rpc(scoring_team, score, goal_tick, resume_tick)
# remaining_ticks is authoritative while `running` is false: a stopped clock
# cannot be derived from end_tick minus the current tick, or it drains through
# every goal pause and kickoff countdown.
func send_clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
_clock_state.rpc(running, end_tick, remaining_ticks, at_tick)
# §6.2 step 2 / §6.3: everything a peer needs to reconstruct the CURRENT match
# on arrival, sent to one peer rather than broadcast.
#
# match_config alone is not enough and never was: it carries arena and roster
# only, so a late joiner or a reconnecting player had no score, no clock, and
# no match state until the next goal or transition happened to fire. An
# adversarial review caught that; §6.2 step 2's `welcome` is specified to carry
# exactly this set, so this is that message under a name that does not clash
# with MatchNet's own lobby-level welcome.
func send_match_bootstrap(peer_id: int, state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
_match_bootstrap.rpc_id(peer_id, state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
@rpc("authority", "call_remote", "reliable", 0)
func _match_config(arena_path: String, peer_ids: PackedInt32Array, teams: PackedInt32Array, spawn_indices: PackedInt32Array) -> void:
match_config_received.emit(arena_path, peer_ids, teams, spawn_indices)
@rpc("any_peer", "call_remote", "reliable", 0)
func _request_match_config() -> void:
if not multiplayer.is_server() or _last_match_config.is_empty():
return
var peer_id := multiplayer.get_remote_sender_id()
_match_config.rpc_id(
peer_id, _last_match_config["arena_path"], _last_match_config["peer_ids"],
_last_match_config["teams"], _last_match_config["spawn_indices"]
)
match_config_requested.emit(peer_id)
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func _recv_input(bytes: PackedByteArray) -> void:
if not multiplayer.is_server():
return
_track_received(bytes.size())
var peer_id := multiplayer.get_remote_sender_id()
var state: _PeerInputState = _peer_input_state.get(peer_id)
if state == null:
state = _PeerInputState.new()
_peer_input_state[peer_id] = state
# Rolling 1s window (§3.1 step 2). Rolled over lazily on the first
# packet past the window boundary, not on a timer — this RPC only ever
# runs when a packet actually arrives, so there's nothing to roll over
# when nothing is arriving anyway.
var now_ms := Time.get_ticks_msec()
if now_ms - state.window_start_ms >= RATE_LIMIT_WINDOW_MS:
# The leaky bucket drains against the SAME budget the window itself was
# policed with, grace included — otherwise a server stall would still
# accumulate excess toward a disconnect for traffic the server just
# explicitly allowed.
state.excess_packets = maxf(0.0, state.excess_packets + float(state.packets_this_window) - float(_packet_budget(state)))
state.excess_bytes = maxf(0.0, state.excess_bytes + float(state.bytes_this_window) - float(_byte_budget(state)))
state.window_start_ms = now_ms
state.packets_this_window = 0
state.bytes_this_window = 0
state.rejects_recorded_this_window = 0
if state.grace_windows_left > 0:
state.grace_windows_left -= 1
if state.grace_windows_left == 0:
state.grace_packets = 0
if state.excess_packets > RATE_LIMIT_EXCESS_PACKETS_TO_DISCONNECT or state.excess_bytes > RATE_LIMIT_EXCESS_BYTES_TO_DISCONNECT:
# Record before disconnecting, same reasoning as _count_malformed:
# the log should contain the packet that ended the connection, not
# stop one short of it.
_emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes)
_disconnect_abusive_peer(peer_id, "input rate limit exceeded (excess_packets=%.0f excess_bytes=%.0f)" % [state.excess_packets, state.excess_bytes])
return
state.packets_this_window += 1
state.bytes_this_window += bytes.size()
if state.packets_this_window > _packet_budget(state) or state.bytes_this_window > _byte_budget(state):
# Over budget for the current window — drop, counted above at the next
# window roll.
_emit_reject(peer_id, state, InputRejectReason.RATE_LIMIT, bytes)
return
# Framing (§3.1 step 3), validated before decoding — unpack_input can't
# be trusted to catch this itself: StreamPeerBuffer silently zero-fills
# past EOF rather than erroring (found during Phase 2's adversarial
# review's hostile-client stress test), so a too-short or size-mismatched
# payload would otherwise decode "successfully" into garbage actions
# instead of being rejected.
if bytes.size() < NetCodec.INPUT_HEADER_SIZE:
_count_malformed(peer_id, state, bytes)
return
var count: int = bytes[5] # type_version(1) + seq(4) precede count — see pack_input's own layout
if count == 0 or count > NetCodec.MAX_REDUNDANCY or bytes.size() != NetCodec.INPUT_HEADER_SIZE + count * NetCodec.INPUT_ENTRY_SIZE:
_count_malformed(peer_id, state, bytes)
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)
# The budget a peer is actually policed against right now: the standing limit
# plus any outstanding server-stall grace. Bytes scale with packets by the same
# worst-case-packet factor RATE_LIMIT_BYTES_PER_SEC itself is derived from, so
# the two budgets can never drift apart by hand.
func _packet_budget(state: _PeerInputState) -> int:
return RATE_LIMIT_PACKETS_PER_SEC + state.grace_packets
func _byte_budget(state: _PeerInputState) -> int:
return _packet_budget(state) * (NetCodec.INPUT_HEADER_SIZE + NetCodec.MAX_REDUNDANCY * NetCodec.INPUT_ENTRY_SIZE)
func _count_malformed(peer_id: int, state: _PeerInputState, bytes: PackedByteArray) -> void:
state.malformed_count += 1
# Emitted before the disconnect check so the packet that finally crossed
# the limit is itself in the log, not just the 19 before it.
_emit_reject(peer_id, state, InputRejectReason.MALFORMED, bytes)
if state.malformed_count >= MALFORMED_LIMIT_TO_DISCONNECT:
_disconnect_abusive_peer(peer_id, "too many malformed input packets (%d)" % state.malformed_count)
func _emit_reject(peer_id: int, state: _PeerInputState, reason: int, bytes: PackedByteArray) -> void:
var totals: Dictionary = _reject_totals.get(peer_id, {"malformed": 0, "rate_limit": 0})
var key := "rate_limit" if reason == InputRejectReason.RATE_LIMIT else "malformed"
totals[key] = int(totals[key]) + 1
_reject_totals[peer_id] = totals
if state.rejects_recorded_this_window >= REJECTS_RECORDED_PER_WINDOW:
return
state.rejects_recorded_this_window += 1
input_rejected.emit(peer_id, reason, bytes)
# Server-side, diagnostic. peer_id -> {"malformed": int, "rate_limit": int},
# uncapped and surviving the peer's disconnect. Peers with no rejects at all
# never appear, so an empty dictionary means a clean session.
func get_reject_totals() -> Dictionary:
return _reject_totals.duplicate(true)
func _disconnect_abusive_peer(peer_id: int, reason: String) -> void:
push_warning("MatchSim: disconnecting peer %d for abuse: %s" % [peer_id, reason])
_peer_input_state.erase(peer_id)
if multiplayer.multiplayer_peer is ENetMultiplayerPeer:
multiplayer.multiplayer_peer.disconnect_peer(peer_id)
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func _snapshot(bytes: PackedByteArray) -> void:
_track_received(bytes.size())
var decoded := NetCodec.unpack_snapshot(bytes)
snapshot_received.emit(decoded)
@rpc("authority", "call_remote", "reliable", 0)
func _state_change(state: int, at_tick: int) -> void:
# "authority" already means a forging client is rejected by Godot itself
# (verified for _match_config/_score_update/_snapshot during Phase 2), but
# an authoritative server sending a state this build doesn't know about is
# a real forward-compatibility case — drop it rather than driving the
# client into an undefined state.
if not MatchState.is_valid(state):
push_warning("MatchSim: ignoring unknown match_state %d from server" % state)
return
state_change_received.emit(state, at_tick)
@rpc("authority", "call_remote", "reliable", 0)
func _kickoff(positions: PackedVector3Array, rotations: PackedFloat32Array, countdown_start_tick: int, reset_gen: int) -> void:
# 4 quaternion floats per body. A mismatch means a corrupt or hostile
# payload; dropping it is safe because the snapshot stream still carries
# authoritative poses and the next kickoff will re-sync.
if rotations.size() != positions.size() * 4:
push_warning("MatchSim: kickoff payload mismatch (%d positions, %d rotation floats)" % [positions.size(), rotations.size()])
return
kickoff_received.emit(positions, rotations, countdown_start_tick, reset_gen)
@rpc("authority", "call_remote", "reliable", 0)
func _goal_scored(scoring_team: int, score: Dictionary, goal_tick: int, resume_tick: int) -> void:
goal_scored_received.emit(scoring_team, score, goal_tick, resume_tick)
@rpc("authority", "call_remote", "reliable", 0)
func _clock_state(running: bool, end_tick: int, remaining_ticks: int, at_tick: int) -> void:
clock_state_received.emit(running, end_tick, remaining_ticks, at_tick)
@rpc("authority", "call_remote", "reliable", 0)
func _match_bootstrap(state: int, at_tick: int, score: Dictionary, end_tick: int, clock_running: bool, reset_gen: int, remaining_ticks: int) -> void:
if not MatchState.is_valid(state):
push_warning("MatchSim: ignoring bootstrap with unknown match_state %d" % state)
return
match_bootstrap_received.emit(state, at_tick, score, end_tick, clock_running, reset_gen, remaining_ticks)
@rpc("authority", "call_remote", "reliable", 0)
func _score_update(score: Dictionary) -> void:
score_update_received.emit(score)