Files
BT411/tools/matchcheck.py
T
arcattackandClaude Opus 4.8 753540de96 MP match forensics: per-peer matchlog + relay auto-upload + matchcheck
For the 8-player playtest.  Damage authority is VICTIM-side, so no single
peer sees the whole match: every -net instance now writes a compact
machine-parseable matchlog_<date>_<time>_<pid>.txt (auto-armed by -net;
BT_MATCHLOG=1/0 overrides; solo silent), and at mission end a relay-mode
pod dials the relay game port (the seat-request throwaway-dial pattern,
envelope route -9) and streams the file back -- the relay saves every
peer's copy under matchlogs/ on the operator's machine, so testers send
nothing by hand.  tools/matchcheck.py <dir> reconciles all of them:
damage claimed (FIRE/PROJ/SPLASH/RAM, shooter-side) vs applied (DMG,
victim-side authoritative), kill/death replication across observers,
PLAYER_DEAD counts, scores, version skew, replicant-application and
unattributed-damage anomalies, mid-mission disconnects.

Hooks at the authoritative sites: Mech::TakeDamageMessageHandler (DMG,
post-base, zone + post-application damageLevel), MechWeapon::
SendDamageMessage (FIRE), projectile impact/splash/collision dispatch
(PROJ/SPLASH/RAM), wreck entry (DEATH -- a ONE-SHOT latch at
MovementMode 9, NOT the transition: a replicant arrives at 9 straight
from the type-6 record header and never runs the transition branch),
BTPlayer vehicle/death/score handlers (VEHICLE/PLAYER_DEAD/SCORE), zone
crit cascade (CRIT), APP.cpp RunningMission (MISSION), L4NET host
handlers (PEER_UP/PEER_DOWN).  Every line t=/w=/st=-stamped + fflushed.

Verified 2-node: mesh run pairs A's 22 FIRE 1:1 with B's 22 DMG (same
amounts, cylinder-resolved zones, ~200ms latency); force-damage kill run
logs 103 DMG + 3 DEATH inst=M + 3 PLAYER_DEAD across three respawn
cycles victim-side and the kill SCOREs shooter-side; relay-mode run
auto-uploaded BOTH peers' files at the mission-clock stop and matchcheck
produced the full report with exactly one (correct) anomaly -- the force
hook's claim-less damage, the unattributed-damage detector working.
Relay gained a route-specific 8MB cap for the upload frame (game frames
stay capped at 1600).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-22 13:22:10 -05:00

315 lines
12 KiB
Python

#!/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 <dir-or-files...>
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<tag>[A-Z_]+) t=(?P<t>\d+) w=(?P<w>[\d:.]+) st=(?P<st>-?\d+) ?(?P<body>.*)$")
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 (<peer-ip>_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:]))