#!/usr/bin/env python3 """matchcheck.py -- cross-peer reconciliation of BT411 match forensic logs. Each game instance in a networked session writes a matchlog_*.txt (see game/reconstructed/matchlog.hpp for the event catalogue). MP damage authority is VICTIM-side, so no single peer's log holds the whole match: collect every player's file into one directory and run python tools/matchcheck.py Report: * roster -- one row per log: machine, version, self host id, mission window * damage matrix -- authoritative received damage (DMG lines, victim-side) cross-checked against the shooters' claimed FIRE/PROJ/SPLASH/RAM totals * kills/deaths -- authoritative DEATH inst=M vs the replicated inst=R sightings on every other peer, killer attribution, PLAYER_DEAD counts * scores -- per-player award totals by type * anomalies -- version skew, damage applied on a replicant, unresolved zones, out-of-economy amounts, deaths that failed to replicate, mid-mission disconnects, fire with no matching victim-side application """ import os import re import sys from collections import defaultdict LINE_RE = re.compile( r"^(?P[A-Z_]+) t=(?P\d+) w=(?P[\d:.]+) st=(?P-?\d+) ?(?P.*)$") KV_RE = re.compile(r'(\w+)=("[^"]*"|\S+)') RUNNING_STATE = None # learned: the st= value on MISSION lines # The armor-point economy observed live: weapons 3.4-11.8/hit, rams up to # tens after normalization, splash <= weapon amounts. Amounts far outside # are flagged, not judged. AMT_MAX = 200.0 def parse_kv(body): out = {} for key, value in KV_RE.findall(body): out[key] = value.strip('"') return out def parse_log(path): events = [] bad = 0 with open(path, "r", errors="replace") as f: for raw in f: raw = raw.rstrip("\n") if not raw: continue m = LINE_RE.match(raw) if not m: bad += 1 continue ev = { "tag": m.group("tag"), "t": int(m.group("t")), "w": m.group("w"), "st": int(m.group("st")), } ev.update(parse_kv(m.group("body"))) events.append(ev) return events, bad def eid(value): """'3:22' -> (3, 22); tolerate missing.""" if value is None: return None parts = value.split(":") if len(parts) != 2: return None try: return (int(parts[0]), int(parts[1])) except ValueError: return None class Log(object): def __init__(self, path): self.path = path self.name = os.path.basename(path) self.events, self.bad_lines = parse_log(path) hdrs = [e for e in self.events if e["tag"] == "HDR"] self.hdr = hdrs[0] if hdrs else {} self.version = self.hdr.get("ver", "?") self.machine = self.hdr.get("machine", "?") # Self host id: the host part of this log's VEHICLE player ids # (players are made locally), falling back to inst=M DMG victims. self.self_host = None for e in self.events: if e["tag"] == "VEHICLE" and eid(e.get("player")): self.self_host = eid(e["player"])[0] break if self.self_host is None: for e in self.events: if e["tag"] == "DMG" and e.get("inst") == "M" and eid(e.get("victim")): self.self_host = eid(e["victim"])[0] break def mission_events(self): run_states = set(e["st"] for e in self.events if e["tag"] == "MISSION") if not run_states: return self.events # everything from the first MISSION line onward for i, e in enumerate(self.events): if e["tag"] == "MISSION": return self.events[i:] return self.events def fmt_id(pair): return "%d:%d" % pair if pair else "?" def main(argv): paths = [] for a in argv: if os.path.isdir(a): # accept both a pod's own file (matchlog_*.txt) and the relay's # collected copy (_matchlog_*.txt) paths += [os.path.join(a, f) for f in sorted(os.listdir(a)) if "matchlog_" in f and f.endswith(".txt")] else: paths.append(a) if not paths: print(__doc__) return 1 logs = [Log(p) for p in paths] anomalies = [] # ---------------- roster ---------------- print("=" * 72) print("ROSTER (%d logs)" % len(logs)) print("=" * 72) versions = set() for lg in logs: versions.add(lg.version) n_mission = sum(1 for e in lg.events if e["tag"] == "MISSION") print(" %-38s host=%-4s machine=%-12s ver=%s%s" % ( lg.name, lg.self_host, lg.machine, lg.version, "" if n_mission else " [never reached RunningMission]")) if lg.bad_lines: anomalies.append("%s: %d unparseable lines" % (lg.name, lg.bad_lines)) if len(versions) > 1: anomalies.append("VERSION SKEW across peers: %s" % ", ".join(sorted(versions))) host_log = {lg.self_host: lg for lg in logs if lg.self_host is not None} # ---------------- players / vehicles ---------------- player_of_mech = {} # mech eid -> player eid for lg in logs: for e in lg.events: if e["tag"] == "VEHICLE": mech, player = eid(e.get("mech")), eid(e.get("player")) if mech and player: player_of_mech[mech] = player # ---------------- damage reconciliation ---------------- # authoritative: DMG inst=M in the victim-host's own log recv = defaultdict(float) # (from_host, victim_host) -> amount recv_n = defaultdict(int) dealt = defaultdict(float) # (shooter_host, tgt_host) -> claimed amount dealt_n = defaultdict(int) for lg in logs: for e in lg.events: if e["tag"] == "DMG": victim, source = eid(e.get("victim")), eid(e.get("from")) amt = float(e.get("amt", 0)) if e.get("inst") == "R": anomalies.append("%s: DMG applied on a REPLICANT (%s) -- " "authority violation" % (lg.name, e.get("victim"))) continue if victim is None: continue key = (source[0] if source else -1, victim[0]) recv[key] += amt recv_n[key] += 1 if int(e.get("zone", "-1")) < 0: anomalies.append("%s: DMG with UNRESOLVED zone (victim %s t=%s)" % (lg.name, e.get("victim"), e["t"])) if amt <= 0 or amt > AMT_MAX: anomalies.append("%s: out-of-economy DMG amount %.3f (victim %s)" % (lg.name, amt, e.get("victim"))) elif e["tag"] in ("FIRE", "PROJ"): if e["tag"] == "FIRE" and e.get("mech") == "0": continue # scenery target: never reconciles shooter, target = eid(e.get("shooter")), eid(e.get("tgt")) if shooter and target: key = (shooter[0], target[0]) dealt[key] += float(e.get("amt", 0)) dealt_n[key] += 1 elif e["tag"] == "SPLASH": shooter, victim = eid(e.get("shooter")), eid(e.get("victim")) if shooter and victim: key = (shooter[0], victim[0]) dealt[key] += float(e.get("amt", 0)) dealt_n[key] += 1 elif e["tag"] == "RAM": inf, victim = eid(e.get("inflictor")), eid(e.get("victim")) if inf and victim: key = (inf[0], victim[0]) dealt[key] += float(e.get("amt", 0)) dealt_n[key] += 1 print() print("=" * 72) print("DAMAGE (received = victim-side authoritative; claimed = shooter-side)") print("=" * 72) pairs = sorted(set(list(recv.keys()) + list(dealt.keys()))) print(" %-14s %14s %14s %s" % ("shooter->victim", "claimed", "received", "delta")) for key in pairs: c, r = dealt.get(key, 0.0), recv.get(key, 0.0) # only cross-check when both hosts' logs are present have_both = key[0] in host_log and key[1] in host_log delta = "" if have_both and (c or r): diff = c - r delta = "%+.1f" % diff base = max(abs(c), abs(r), 1e-6) if abs(diff) > 0.05 * base + 1.0: delta += " <-- MISMATCH" anomalies.append( "damage pair %d->%d: claimed %.1f (%d ev) vs received %.1f (%d ev)" % (key[0], key[1], c, dealt_n[key], r, recv_n[key])) elif not have_both: delta = "(missing a peer's log)" print(" %4d -> %-6d %10.1f (%3d) %10.1f (%3d) %s" % ( key[0], key[1], c, dealt_n.get(key, 0), r, recv_n.get(key, 0), delta)) # ---------------- kills / deaths ---------------- print() print("=" * 72) print("KILLS / DEATHS") print("=" * 72) deaths_auth = [] # (victim eid, killer eid, log, t) death_seen = defaultdict(set) # victim eid -> set(observer host) for lg in logs: for e in lg.events: if e["tag"] == "DEATH": victim = eid(e.get("victim")) if victim is None: continue if e.get("inst") == "M": deaths_auth.append((victim, eid(e.get("killer")), lg, e)) death_seen[victim].add(lg.self_host) for victim, killer, lg, e in deaths_auth: observers = death_seen.get(victim, set()) missing = [h for h in host_log if h not in observers and h is not None] print(" %s killed by %s (w=%s, seen on %d/%d peers%s)" % ( fmt_id(victim), fmt_id(killer), e["w"], len(observers), len(host_log), "" if not missing else "; MISSING on hosts %s" % missing)) if missing: anomalies.append("death of %s did not replicate to hosts %s" % (fmt_id(victim), missing)) pd = defaultdict(int) for lg in logs: for e in lg.events: if e["tag"] == "PLAYER_DEAD": p = eid(e.get("player")) pd[p] = max(pd[p], int(e.get("deaths", 0))) for p, n in sorted(pd.items()): print(" player %s: %d death(s)" % (fmt_id(p), n)) # ---------------- scores ---------------- print() print("=" * 72) print("SCORES (last reported total per player, per its own log)") print("=" * 72) final = {} for lg in logs: for e in lg.events: if e["tag"] == "SCORE": p = eid(e.get("player")) if p and p[0] == lg.self_host: final[p] = (float(e.get("total", 0)) + float(e.get("award", 0)), int(e.get("kills", 0))) for p, (total, kills) in sorted(final.items()): print(" player %s: score ~%.1f kills=%d" % (fmt_id(p), total, kills)) # ---------------- connectivity ---------------- for lg in logs: # The MISSION line's st= value IS the RunningMission state id; a # PEER_DOWN in any LATER state (EndingMission teardown) is the normal # end-of-mission exit, not a mid-match disconnect. run_state = None for e in lg.events: if e["tag"] == "MISSION": run_state = e["st"] if (e["tag"] == "PEER_DOWN" and run_state is not None and e["st"] == run_state): anomalies.append("%s: PEER_DOWN host=%s MID-MISSION (w=%s)" % (lg.name, e.get("host"), e["w"])) # ---------------- anomalies ---------------- print() print("=" * 72) print("ANOMALIES (%d)" % len(anomalies)) print("=" * 72) if not anomalies: print(" none -- the match reconciles cleanly") for a in anomalies: print(" ! %s" % a) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))