mirror of
https://github.com/jcreek/CosmicClash.git
synced 2026-09-10 16:04:04 +00:00
866efa0d9b
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.
111 lines
4.0 KiB
GDScript
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
|