mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
ff725e1ffa
"Spectate now, take the slot at the next kickoff" was a print statement. The server logged it and never acted; on the client, _is_spectator was assigned once in _on_match_config_received and never revisited - and that handler returns early whenever _slots is non-empty, so no rebroadcast could promote an in-match spectator. The reconnect path only worked because a returning player is a fresh process. Server: late joiners are queued in arrival order and the queue is drained from _begin_kickoff, before the reset transforms are read, so a promoted player's ship is placed by that same kickoff and the controller swap lands on an already-frozen body. A slot is available only once its player has gone AND their 30s reservation has lapsed - §6.4 outranks §6.3, since taking a reserved slot would quietly break the reconnect promise. _abort_if_abandoned now counts a waiting spectator as somebody present, or the one person queued for the slot that just opened is dumped to the lobby at the moment they were about to get it. Client: new broadcast slot_assigned (reliable, channel 0). Broadcast because every client holds its own slot list and one naming the wrong peer keeps flying somebody else's ship as a remote body; reliable because no per-snapshot field would re-converge a client that missed it. The promoted client undoes what made the body remote - fresh interpolator, physics interpolation back on, offsets cleared - and deliberately does not unfreeze, clearing _local_prediction_ready so the next snapshot teleports it to a real authoritative pose first. The controller-attach block moved to _take_local_ownership rather than being copied. New --role=host-latejoin/--role=client-latejoin and --slot-reservation-seconds=. Verified 4/4 both sides: queued, NOT promoted merely because the reservation lapsed, takes the slot at the kickoff, same ship instance, and both peers independently measure ~45.7m under its input. Control with a 90s reservation: kickoff fires, nothing is promoted, the slot still reads the departed player's name.
520 lines
26 KiB
GDScript
520 lines
26 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)
|
|
# §6.3 task 5.8: a spectator has been given a vacated slot at a kickoff.
|
|
signal slot_assigned_received(peer_id: int, slot_index: 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)
|
|
|
|
|
|
# §6.3's late-joiner promotion. BROADCAST, not addressed to the new owner
|
|
# alone: every client holds its own copy of the slot list, and a peer_id that
|
|
# only the promoted client learns about leaves everyone else's copy naming a
|
|
# player who is no longer in that seat. Reliable channel 0 — a client that
|
|
# misses this keeps flying somebody else's ship as a remote body forever, and
|
|
# unlike match_state there is no per-snapshot field that would re-converge it.
|
|
func send_slot_assigned(peer_id: int, slot_index: int) -> void:
|
|
_slot_assigned.rpc(peer_id, slot_index)
|
|
|
|
|
|
@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("authority", "call_remote", "reliable", 0)
|
|
func _slot_assigned(peer_id: int, slot_index: int) -> void:
|
|
slot_assigned_received.emit(peer_id, slot_index)
|
|
|
|
|
|
@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)
|