Files
CosmicClash/Game/tools/replay_dump.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

111 lines
4.0 KiB
GDScript

extends SceneTree
# Offline reader for a task 5.10 replay log (.ccrp). A log nobody can read is
# only half a feature — this is the tool that turned "the server dropped some
# input" into the exact numbers that found the stall/rate-limiter interaction
# documented in MatchSim's own header.
#
# godot --headless --path Game --script res://tools/replay_dump.gd -- <path> [--records]
#
# Default output is one summary line: record counts by kind, plus how much of
# the client's input SEQUENCE stream actually reached the server once each
# packet's redundancy entries are counted. That last number is the one that
# matters — a rejected-packet count is not an input-loss count, because a
# packet carries several recent actions, so sporadic loss is usually covered
# by its neighbours. Contiguous loss is not, which is exactly what a server
# stall produces.
#
# --records additionally prints every record. Expect thousands.
const KIND_NAMES := ["INPUT", "SNAPSHOT", "REJECTED_MALFORMED", "REJECTED_RATE_LIMIT", "REJECTED_SEQ_GUARD"]
func _init() -> void:
var path := ""
var verbose := false
for arg in OS.get_cmdline_user_args():
if arg == "--records":
verbose = true
else:
path = arg
if path.is_empty():
print("usage: --script res://tools/replay_dump.gd -- <path.ccrp> [--records]")
quit(1)
return
var log_data := ReplayLog.read_all(path)
if log_data.is_empty():
print("not a replay log (or missing): %s" % path)
quit(1)
return
var counts := {}
var covered := {} # seq -> true, including redundancy entries
var heads := {} # seq -> true, arrived as a packet's own head
var rejected_heads := {} # seq -> reject kind
for record in log_data["records"]:
var kind: int = record["kind"]
counts[kind] = int(counts.get(kind, 0)) + 1
var payload: PackedByteArray = record["payload"]
if verbose:
print(" tick=%d kind=%s peer=%d len=%d" % [
record["tick"], _kind_name(kind), record["peer_id"], payload.size()
])
# Snapshots are server->client and carry no input sequence; rejected
# packets are by definition not always well-formed, so only decode what
# the framing check already passed.
if kind == ReplayLog.RecordKind.SNAPSHOT or payload.size() < NetCodec.INPUT_HEADER_SIZE:
continue
var decoded := NetCodec.unpack_input(payload)
var seq: int = decoded["seq"]
if kind == ReplayLog.RecordKind.INPUT:
heads[seq] = true
for i in (decoded["actions"] as Array).size():
covered[seq - i] = true
else:
rejected_heads[seq] = kind
var parts: Array[String] = []
for kind in range(KIND_NAMES.size()):
parts.append("%s=%d" % [KIND_NAMES[kind], int(counts.get(kind, 0))])
var unknown := 0
for kind in counts:
if int(kind) >= KIND_NAMES.size():
unknown += int(counts[kind])
if unknown > 0:
parts.append("unknown_kinds=%d" % unknown)
print("%s: version=%d tick_hz=%d records=%d %s" % [
path, log_data["version"], log_data["tick_hz"], log_data["records"].size(), " ".join(parts)
])
var seqs: Array = covered.keys()
seqs.sort()
if seqs.is_empty():
print(" no accepted input — nothing to say about sequence coverage")
quit(0)
return
var lo: int = seqs[0]
var hi: int = seqs[-1]
var missing := 0
for seq in range(lo, hi + 1):
if not covered.has(seq):
missing += 1
# Of the sequences whose own packet was rejected, how many still arrived
# inside some other packet's redundancy window? Zero here means the loss
# was contiguous, which is the signature of a stall rather than a lossy
# link — redundancy only protects against sporadic loss.
var rescued := 0
for seq in rejected_heads:
if covered.has(seq):
rescued += 1
print(" seq %d..%d (%d): heads=%d covered=%d missing=%d (%.2f%%); rejected_heads=%d rescued_by_redundancy=%d" % [
lo, hi, hi - lo + 1, heads.size(), covered.size(), missing,
100.0 * float(missing) / float(hi - lo + 1), rejected_heads.size(), rescued,
])
quit(0)
func _kind_name(kind: int) -> String:
return KIND_NAMES[kind] if kind >= 0 and kind < KIND_NAMES.size() else "UNKNOWN(%d)" % kind