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>
315 lines
12 KiB
Python
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:]))
|