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 -- [--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 -- [--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