1 changed files with 747 additions and 0 deletions
@ -0,0 +1,747 @@ |
|||
#!/usr/bin/env python3 |
|||
""" |
|||
MVD2 demo parser — generates a CSV report from all .mvd2 files in CWD. |
|||
|
|||
Usage: |
|||
cd /path/to/demos && python3 mvd_csv.py |
|||
python3 mvd_csv.py /path/to/demos/ # specify dir |
|||
python3 mvd_csv.py -o report.csv # specify output file |
|||
""" |
|||
|
|||
import struct |
|||
import re |
|||
import sys |
|||
import csv |
|||
import argparse |
|||
from pathlib import Path |
|||
from datetime import datetime |
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Protocol constants (q2pro inc/common/protocol.h, inc/shared/shared.h) |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
MVD_MAGIC = b'MVD2' |
|||
|
|||
MVD_NOP = 1 |
|||
MVD_SERVERDATA = 4 |
|||
MVD_CONFIGSTRING = 5 |
|||
MVD_FRAME = 6 |
|||
MVD_UNICAST = 8 |
|||
MVD_UNICAST_R = 9 |
|||
MVD_MULTICAST_ALL = 10 |
|||
MVD_MULTICAST_ALL_R = 13 |
|||
|
|||
SVCMD_BITS = 5 |
|||
SVCMD_MASK = 0x1F |
|||
|
|||
# SVC opcodes used inside unicast messages |
|||
SVC_LAYOUT = 4 |
|||
SVC_PRINT = 10 |
|||
SVC_STUFFTEXT = 11 |
|||
SVC_CONFIGSTRING = 13 |
|||
|
|||
PROTO_MVD_EXTLIMITS = 2011 |
|||
PROTO_MVD_EXTLIMITS_2 = 2012 |
|||
MVF_EXTLIMITS = 1 |
|||
|
|||
# Old configstring remap (default, used for version < 2011) |
|||
CS_PLAYERSKINS_OLD = 1312 |
|||
CS_GENERAL_OLD = 1568 |
|||
MAX_CS_OLD = 2080 |
|||
|
|||
# Extended configstring remap (version >= 2011 with MVF_EXTLIMITS) |
|||
CS_PLAYERSKINS_NEW = 12862 |
|||
CS_GENERAL_NEW = 13118 |
|||
MAX_CS_NEW = 13630 |
|||
|
|||
# OpenTDM configstring offsets from CS_GENERAL (opentdm/g_local.h) |
|||
# CS_GENERAL+0 team A name |
|||
# CS_GENERAL+1 team B name |
|||
# CS_GENERAL+2 team A score |
|||
# CS_GENERAL+3 team B score |
|||
# CS_GENERAL+4 timelimit countdown string (initial value = configured limit) |
|||
# CS_GENERAL+6 game status ("Warmup", "Match End", …) |
|||
# CS_GENERAL+MAX_CLIENTS(256)+slot "playername (Hometeam|Visitors)" |
|||
|
|||
MAX_CLIENTS = 256 |
|||
|
|||
_HEADER_WORDS = {'name', 'frags', 'frag', 'dths', 'deaths', 'ping', |
|||
'net', 'eff', 'fph', 'time', 'player', 'rank'} |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Buffer reader |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
class ParseError(Exception): |
|||
pass |
|||
|
|||
|
|||
class Buffer: |
|||
def __init__(self, data): |
|||
self.data = bytes(data) |
|||
self.pos = 0 |
|||
|
|||
def remaining(self): |
|||
return len(self.data) - self.pos |
|||
|
|||
def read_byte(self): |
|||
if self.pos >= len(self.data): |
|||
raise ParseError("Unexpected end (byte)") |
|||
v = self.data[self.pos]; self.pos += 1 |
|||
return v |
|||
|
|||
def read_short(self): |
|||
if self.pos + 2 > len(self.data): |
|||
raise ParseError("Unexpected end (short)") |
|||
v = struct.unpack_from('<h', self.data, self.pos)[0]; self.pos += 2 |
|||
return v |
|||
|
|||
def read_ushort(self): |
|||
if self.pos + 2 > len(self.data): |
|||
raise ParseError("Unexpected end (ushort)") |
|||
v = struct.unpack_from('<H', self.data, self.pos)[0]; self.pos += 2 |
|||
return v |
|||
|
|||
def read_long(self): |
|||
if self.pos + 4 > len(self.data): |
|||
raise ParseError("Unexpected end (long)") |
|||
v = struct.unpack_from('<i', self.data, self.pos)[0]; self.pos += 4 |
|||
return v |
|||
|
|||
def read_string(self): |
|||
try: |
|||
end = self.data.index(b'\x00', self.pos) |
|||
except ValueError: |
|||
raise ParseError("Unterminated string") |
|||
s = self.data[self.pos:end].decode('latin-1', errors='replace') |
|||
self.pos = end + 1 |
|||
return s |
|||
|
|||
def skip(self, n): |
|||
if self.pos + n > len(self.data): |
|||
raise ParseError(f"Cannot skip {n} bytes") |
|||
self.pos += n |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Demo state |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
class MvdState: |
|||
def __init__(self): |
|||
self.mapname = '' |
|||
self.gamedir = '' |
|||
self.configstrings = {} |
|||
self.configstrings_first = {} # first-seen value per index |
|||
self.cvars = {} |
|||
self.layouts = [] |
|||
self.truncated = False |
|||
self.cs_playerskins = CS_PLAYERSKINS_OLD |
|||
self.cs_general = CS_GENERAL_OLD |
|||
self.cs_end = MAX_CS_OLD |
|||
self._in_serverdata = False # True only while processing MVD_SERVERDATA configs |
|||
self._match_phase = False # True after CS_GENERAL+6 transitions to non-warmup |
|||
self.match_confirmed_slots = {} # slot → raw value, set via MVD_CONFIGSTRING after match starts |
|||
|
|||
def set_cs(self, idx, val): |
|||
if idx not in self.configstrings_first: |
|||
self.configstrings_first[idx] = val |
|||
self.configstrings[idx] = val |
|||
if idx == 0: |
|||
self.mapname = val.strip() |
|||
|
|||
# Detect match phase start via explicit MVD_CONFIGSTRING (not SERVERDATA) |
|||
if not self._in_serverdata and idx == self.cs_general + 6: |
|||
vl = ''.join(chr(c & 0x7F) for c in val.encode('latin-1')).strip().lower() |
|||
if vl not in ('', 'warmup', 'countdown'): |
|||
self._match_phase = True |
|||
|
|||
# Track spectator string updates that happen during match phase |
|||
if not self._in_serverdata and self._match_phase: |
|||
base = self.cs_general + MAX_CLIENTS |
|||
if base <= idx < base + MAX_CLIENTS: |
|||
self.match_confirmed_slots[idx - base] = val |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Unicast SVC scanner (layout + stufftext) |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def _scan_unicast(buf, length, state): |
|||
""" |
|||
Scan unicast payload for svc_layout and svc_stufftext. |
|||
length is the payload size NOT including the clientNum byte. |
|||
""" |
|||
client_num = buf.read_byte() # consume clientNum (1 byte, outside length) |
|||
end = buf.pos + length |
|||
if end > len(buf.data): |
|||
end = len(buf.data) |
|||
|
|||
while buf.pos < end: |
|||
try: |
|||
cmd = buf.read_byte() |
|||
except ParseError: |
|||
break |
|||
|
|||
if cmd == SVC_LAYOUT: |
|||
try: |
|||
layout = buf.read_string() |
|||
state.layouts.append(layout) |
|||
except ParseError: |
|||
break |
|||
|
|||
elif cmd == SVC_STUFFTEXT: |
|||
try: |
|||
text = buf.read_string() |
|||
for m in re.finditer(r'\bset\s+(\S+)\s+(\S+)', text): |
|||
state.cvars[m.group(1)] = m.group(2) |
|||
except ParseError: |
|||
break |
|||
|
|||
elif cmd == SVC_PRINT: |
|||
try: |
|||
buf.read_byte() |
|||
buf.read_string() |
|||
except ParseError: |
|||
break |
|||
|
|||
elif cmd == SVC_CONFIGSTRING: |
|||
try: |
|||
buf.read_ushort() |
|||
buf.read_string() |
|||
except ParseError: |
|||
break |
|||
|
|||
else: |
|||
break # unknown/complex SVC — bail on rest of unicast |
|||
|
|||
buf.pos = end |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# MVD message parser |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def _process_mvd_message(buf, state): |
|||
while buf.remaining() > 0: |
|||
try: |
|||
raw = buf.read_byte() |
|||
except ParseError: |
|||
break |
|||
|
|||
extrabits = raw >> SVCMD_BITS |
|||
cmd = raw & SVCMD_MASK |
|||
|
|||
if cmd == MVD_NOP: |
|||
continue |
|||
|
|||
elif cmd == MVD_SERVERDATA: |
|||
try: |
|||
protocol = buf.read_long() |
|||
if protocol != 37: |
|||
return |
|||
version = buf.read_ushort() |
|||
if version >= PROTO_MVD_EXTLIMITS_2: |
|||
flags = buf.read_ushort() |
|||
else: |
|||
flags = extrabits |
|||
use_new = (version >= PROTO_MVD_EXTLIMITS and bool(flags & MVF_EXTLIMITS)) |
|||
if use_new: |
|||
state.cs_playerskins = CS_PLAYERSKINS_NEW |
|||
state.cs_general = CS_GENERAL_NEW |
|||
state.cs_end = MAX_CS_NEW |
|||
else: |
|||
state.cs_playerskins = CS_PLAYERSKINS_OLD |
|||
state.cs_general = CS_GENERAL_OLD |
|||
state.cs_end = MAX_CS_OLD |
|||
|
|||
buf.read_long() # servercount |
|||
state.gamedir = buf.read_string() |
|||
buf.read_short() # clientNum |
|||
|
|||
state._in_serverdata = True |
|||
while True: |
|||
idx = buf.read_ushort() |
|||
if idx == state.cs_end: |
|||
break |
|||
val = buf.read_string() |
|||
state.set_cs(idx, val) |
|||
state._in_serverdata = False |
|||
except ParseError: |
|||
state._in_serverdata = False |
|||
state.truncated = True |
|||
return # entity baselines follow — bail on rest of packet |
|||
|
|||
elif cmd == MVD_CONFIGSTRING: |
|||
try: |
|||
idx = buf.read_ushort() |
|||
val = buf.read_string() |
|||
state.set_cs(idx, val) |
|||
except ParseError: |
|||
state.truncated = True |
|||
return |
|||
|
|||
elif cmd in (MVD_UNICAST, MVD_UNICAST_R): |
|||
try: |
|||
length = buf.read_byte() | (extrabits << 8) |
|||
_scan_unicast(buf, length, state) |
|||
except ParseError: |
|||
state.truncated = True |
|||
return |
|||
|
|||
elif MVD_MULTICAST_ALL <= cmd <= MVD_MULTICAST_ALL_R + 2: |
|||
# multicast: length byte, optional leafnum, data payload |
|||
try: |
|||
length = buf.read_byte() | (extrabits << 8) |
|||
to = cmd - MVD_MULTICAST_ALL |
|||
if to >= 3: |
|||
to -= 3 |
|||
if to != 0: |
|||
buf.read_ushort() # leafnum |
|||
buf.skip(length) |
|||
except ParseError: |
|||
state.truncated = True |
|||
return |
|||
|
|||
elif cmd == 17: # mvd_print |
|||
try: |
|||
buf.read_byte() |
|||
buf.read_string() |
|||
except ParseError: |
|||
state.truncated = True |
|||
return |
|||
|
|||
else: |
|||
return # mvd_frame (6) or mvd_sound (16) — bail |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# File parser |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def parse_mvd2(path): |
|||
state = MvdState() |
|||
|
|||
with open(path, 'rb') as f: |
|||
data = f.read() |
|||
|
|||
if len(data) < 4 or data[:4] != MVD_MAGIC: |
|||
raise ParseError(f"Not an MVD2 file: {path}") |
|||
|
|||
pos = 4 |
|||
had_eof = False |
|||
|
|||
while pos < len(data): |
|||
if pos + 2 > len(data): |
|||
state.truncated = True |
|||
break |
|||
msglen = struct.unpack_from('<H', data, pos)[0] |
|||
pos += 2 |
|||
if msglen == 0: |
|||
had_eof = True |
|||
break |
|||
if pos + msglen > len(data): |
|||
state.truncated = True |
|||
msglen = len(data) - pos |
|||
packet = data[pos:pos + msglen] |
|||
pos += msglen |
|||
try: |
|||
_process_mvd_message(Buffer(packet), state) |
|||
except ParseError: |
|||
state.truncated = True |
|||
|
|||
if not had_eof: |
|||
state.truncated = True |
|||
|
|||
return state |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Layout parsing (fallback, from dem_parser.py) |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def _is_header(text): |
|||
return bool(set(text.lower().split()) & _HEADER_WORDS) |
|||
|
|||
|
|||
def _parse_layout_entries(layout): |
|||
return [(int(m.group(1)), m.group(2)) |
|||
for m in re.finditer(r'yv\s+(\d+)\s+string2?\s+"([^"]*)"', layout)] |
|||
|
|||
|
|||
def _parse_player_tdm(content): |
|||
if len(content) < 20: |
|||
return None |
|||
name = content[:15].rstrip() |
|||
if not name or _is_header(name): |
|||
return None |
|||
nums = re.findall(r'-?\d+', content[15:]) |
|||
if len(nums) < 2: |
|||
return None |
|||
try: |
|||
return {'name': name, 'frags': int(nums[0]), 'deaths': int(nums[1])} |
|||
except ValueError: |
|||
return None |
|||
|
|||
|
|||
def _best_scoreboard_layout(layouts): |
|||
"""Return the last layout that has a proper scoreboard header.""" |
|||
for layout in reversed(layouts): |
|||
entries = _parse_layout_entries(layout) |
|||
if any('Name' in c and 'Frags' in c for _, c in entries): |
|||
return layout |
|||
return None |
|||
|
|||
|
|||
def parse_layout_tdm(layout): |
|||
entries = _parse_layout_entries(layout) |
|||
|
|||
team_scores = [] |
|||
for yv, content in sorted(entries, key=lambda x: x[0]): |
|||
if yv > 30 or _is_header(content) or not content.strip(): |
|||
continue |
|||
parts = content.rsplit(None, 1) |
|||
if len(parts) == 2: |
|||
try: |
|||
team_scores.append(int(parts[1])) |
|||
except ValueError: |
|||
pass |
|||
|
|||
ta_score = team_scores[0] if len(team_scores) > 0 else 0 |
|||
tb_score = team_scores[1] if len(team_scores) > 1 else 0 |
|||
|
|||
header_yvs = sorted(yv for yv, c in entries if 'Name' in c and 'Frags' in c) |
|||
split_yv = header_yvs[1] if len(header_yvs) >= 2 else 9999 |
|||
first_header_yv = header_yvs[0] if header_yvs else 0 |
|||
|
|||
ta_players, tb_players = [], [] |
|||
for yv, content in entries: |
|||
if _is_header(content) or not content.strip(): |
|||
continue |
|||
p = _parse_player_tdm(content) |
|||
if p is None: |
|||
continue |
|||
if first_header_yv < yv <= split_yv: |
|||
ta_players.append(p) |
|||
elif yv > split_yv: |
|||
tb_players.append(p) |
|||
|
|||
return ta_players, tb_players, ta_score, tb_score |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# State helpers |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def _strip_q2color(s): |
|||
"""Remove Quake II high-bit color encoding and strip whitespace.""" |
|||
return ''.join(chr(c & 0x7F) for c in s.encode('latin-1')).strip() |
|||
|
|||
|
|||
def get_team_names(state): |
|||
gen = state.cs_general |
|||
a = _strip_q2color(state.configstrings.get(gen, '')) or 'Team A' |
|||
b = _strip_q2color(state.configstrings.get(gen + 1, '')) or 'Team B' |
|||
return a, b |
|||
|
|||
|
|||
def get_team_scores_from_cs(state): |
|||
gen = state.cs_general |
|||
try: |
|||
sa = int(_strip_q2color(state.configstrings.get(gen + 2, ''))) |
|||
except ValueError: |
|||
sa = None |
|||
try: |
|||
sb = int(_strip_q2color(state.configstrings.get(gen + 3, ''))) |
|||
except ValueError: |
|||
sb = None |
|||
return sa, sb |
|||
|
|||
|
|||
def get_timelimit(state): |
|||
""" |
|||
Extract configured timelimit (minutes) from the initial value of |
|||
CS_TDM_TIMELIMIT_STRING (CS_GENERAL+4), which OpenTDM sets to 'MM:SS' |
|||
at server startup before the countdown begins. |
|||
""" |
|||
gen = state.cs_general |
|||
raw = state.configstrings_first.get(gen + 4, '') |
|||
decoded = _strip_q2color(raw) |
|||
m = re.match(r'^(\d+):(\d{2})$', decoded) |
|||
if m: |
|||
mins = int(m.group(1)) |
|||
secs = int(m.group(2)) |
|||
# round up to nearest minute |
|||
return mins + (1 if secs > 0 else 0) |
|||
return None |
|||
|
|||
|
|||
def get_players_from_spectator_strings(state): |
|||
""" |
|||
Return (team_a_names, team_b_names) using CS_GENERAL+256+slot entries. |
|||
Format: "playername (<teamlabel>)" where <teamlabel> may be the actual |
|||
team name from CS_GENERAL+0/1 OR generic "Hometeam"/"Visitors". |
|||
Filters out [MVDSPEC], empty names, and duplicates. |
|||
""" |
|||
gen = state.cs_general |
|||
base = gen + MAX_CLIENTS # CS_TDM_SPECTATOR_STRINGS |
|||
|
|||
name_a = _strip_q2color(state.configstrings.get(gen, '')).lower() |
|||
name_b = _strip_q2color(state.configstrings.get(gen + 1, '')).lower() |
|||
|
|||
def _is_team_a(label): |
|||
l = label.lower() |
|||
return l == 'hometeam' or (name_a and l == name_a) |
|||
|
|||
def _is_team_b(label): |
|||
l = label.lower() |
|||
return l == 'visitors' or (name_b and l == name_b) |
|||
|
|||
team_a, team_b = [], [] |
|||
seen = set() |
|||
|
|||
for i in range(MAX_CLIENTS): |
|||
val = _strip_q2color(state.configstrings.get(base + i, '')) |
|||
if not val: |
|||
continue |
|||
m = re.match(r'^(.+?)\s+\((.+)\)$', val) |
|||
if not m: |
|||
continue |
|||
name = m.group(1).strip() |
|||
label = m.group(2).strip() |
|||
if not name or '[MVDSPEC]' in name or name in seen: |
|||
continue |
|||
seen.add(name) |
|||
if _is_team_a(label): |
|||
team_a.append(name) |
|||
elif _is_team_b(label): |
|||
team_b.append(name) |
|||
# unknown label → skip (spectator or unrecognised) |
|||
|
|||
return team_a, team_b |
|||
|
|||
|
|||
def get_players_from_skins(state, team_a_name, team_b_name): |
|||
""" |
|||
Last-resort fallback: assign players from CS_PLAYERSKINS by matching |
|||
player name against team names. Works well for 1v1 where team name = player. |
|||
""" |
|||
base = state.cs_playerskins |
|||
team_a, team_b, unknown = [], [], [] |
|||
seen = set() |
|||
|
|||
for i in range(MAX_CLIENTS): |
|||
raw = state.configstrings.get(base + i, '').strip() |
|||
if not raw: |
|||
continue |
|||
name = raw.split('\\')[0].strip() |
|||
if not name or '[MVDSPEC]' in name or name in seen: |
|||
continue |
|||
seen.add(name) |
|||
if name.lower() == team_a_name.lower(): |
|||
team_a.append(name) |
|||
elif name.lower() == team_b_name.lower(): |
|||
team_b.append(name) |
|||
else: |
|||
unknown.append(name) |
|||
|
|||
# distribute unmatched players evenly (best effort) |
|||
if unknown and (not team_a or not team_b): |
|||
half = len(unknown) // 2 or len(unknown) |
|||
if not team_a: |
|||
team_a, unknown = unknown[:half], unknown[half:] |
|||
if not team_b: |
|||
team_b, unknown = unknown[:len(unknown)], [] |
|||
|
|||
return team_a, team_b |
|||
|
|||
|
|||
def get_players_from_match_phase(state): |
|||
""" |
|||
Return (team_a, team_b) using spectator string entries that were explicitly |
|||
re-sent via MVD_CONFIGSTRING after the game status transitioned from warmup |
|||
to a match state. These are the confirmed match participants. |
|||
""" |
|||
gen = state.cs_general |
|||
name_a = _strip_q2color(state.configstrings.get(gen, '')).lower() |
|||
name_b = _strip_q2color(state.configstrings.get(gen + 1, '')).lower() |
|||
|
|||
def _is_team_a(label): |
|||
l = label.lower() |
|||
return l == 'hometeam' or (name_a and l == name_a) |
|||
|
|||
def _is_team_b(label): |
|||
l = label.lower() |
|||
return l == 'visitors' or (name_b and l == name_b) |
|||
|
|||
team_a, team_b = [], [] |
|||
seen = set() |
|||
|
|||
for slot, val in state.match_confirmed_slots.items(): |
|||
stripped = _strip_q2color(val) |
|||
m = re.match(r'^(.+?)\s+\((.+)\)$', stripped) |
|||
if not m: |
|||
continue |
|||
name = m.group(1).strip() |
|||
label = m.group(2).strip() |
|||
if not name or '[MVDSPEC]' in name or name in seen: |
|||
continue |
|||
seen.add(name) |
|||
if _is_team_a(label): |
|||
team_a.append(name) |
|||
elif _is_team_b(label): |
|||
team_b.append(name) |
|||
|
|||
return team_a, team_b |
|||
|
|||
|
|||
def _date_from_filename(name): |
|||
m = re.search(r'(\d{8})-(\d{6})', name) |
|||
if m: |
|||
try: |
|||
dt = datetime.strptime(m.group(1) + m.group(2), '%Y%m%d%H%M%S') |
|||
return dt.strftime('%Y-%m-%d %H:%M:%S') |
|||
except ValueError: |
|||
pass |
|||
return '' |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# Row builder |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
def build_row(path, state): |
|||
filename = path.name |
|||
date = _date_from_filename(filename) |
|||
# CS_MODELS_OLD+1=33 or CS_MODELS_NEW+1=63 holds "maps/q2dm1.bsp" |
|||
cs_bsp_idx = 33 if state.cs_playerskins == CS_PLAYERSKINS_OLD else 63 |
|||
bsp = state.configstrings.get(cs_bsp_idx, '').strip() |
|||
mapname = Path(bsp).stem if bsp else (state.mapname or path.stem.split('_')[-1]) |
|||
|
|||
team_a_name, team_b_name = get_team_names(state) |
|||
cs_score_a, cs_score_b = get_team_scores_from_cs(state) |
|||
|
|||
# Primary: spectator strings explicitly re-confirmed after match start. |
|||
# OpenTDM re-sends entries for actual match participants when game status |
|||
# transitions from Warmup/Countdown to Match. |
|||
ta_names, tb_names = get_players_from_match_phase(state) |
|||
|
|||
# Pre-compute best layout (used as fallback at multiple levels). |
|||
layout_ta, layout_tb, layout_sa, layout_sb = [], [], None, None |
|||
best_layout = _best_scoreboard_layout(state.layouts) |
|||
if best_layout: |
|||
ta_p, tb_p, layout_sa, layout_sb = parse_layout_tdm(best_layout) |
|||
layout_ta = [p['name'] for p in ta_p] |
|||
layout_tb = [p['name'] for p in tb_p] |
|||
|
|||
# If match-phase gave nothing, prefer layout over all spectator strings. |
|||
# (OpenTDM may transition status without re-sending spectator confirmations.) |
|||
if not ta_names and not tb_names: |
|||
if layout_ta or layout_tb: |
|||
ta_names, tb_names = layout_ta, layout_tb |
|||
if cs_score_a is None: cs_score_a = layout_sa |
|||
if cs_score_b is None: cs_score_b = layout_sb |
|||
else: |
|||
ta_names, tb_names = get_players_from_spectator_strings(state) |
|||
|
|||
# Fill any single missing team from layout. |
|||
if not ta_names and layout_ta: |
|||
ta_names = layout_ta |
|||
if cs_score_a is None: cs_score_a = layout_sa |
|||
if not tb_names and layout_tb: |
|||
tb_names = layout_tb |
|||
if cs_score_b is None: cs_score_b = layout_sb |
|||
|
|||
# If one team is still missing (no layout either), try spectator strings. |
|||
if not ta_names or not tb_names: |
|||
spec_a, spec_b = get_players_from_spectator_strings(state) |
|||
if not ta_names: ta_names = spec_a |
|||
if not tb_names: tb_names = spec_b |
|||
|
|||
# Last resort: player skin configstrings + team name matching. |
|||
if not ta_names and not tb_names: |
|||
ta_names, tb_names = get_players_from_skins(state, team_a_name, team_b_name) |
|||
|
|||
players_a = ','.join(ta_names) |
|||
players_b = ','.join(tb_names) |
|||
|
|||
score_a = cs_score_a if cs_score_a is not None else 0 |
|||
score_b = cs_score_b if cs_score_b is not None else 0 |
|||
|
|||
timelimit = get_timelimit(state) |
|||
timelimit_str = str(timelimit) if timelimit is not None else '' |
|||
|
|||
return { |
|||
'file': filename, |
|||
'date': date, |
|||
'map': mapname, |
|||
'players_a': players_a, |
|||
'players_b': players_b, |
|||
'score': f'{score_a}:{score_b}', |
|||
'timelimit_min': timelimit_str, |
|||
'truncated': 'yes' if state.truncated else 'no', |
|||
} |
|||
|
|||
|
|||
# --------------------------------------------------------------------------- |
|||
# CLI |
|||
# --------------------------------------------------------------------------- |
|||
|
|||
FIELDNAMES = [ |
|||
'file', 'date', 'map', |
|||
'players_a', 'score', 'players_b', |
|||
'timelimit_min', 'truncated', |
|||
] |
|||
|
|||
|
|||
def main(): |
|||
ap = argparse.ArgumentParser( |
|||
description='Parse all .mvd2 demos in a directory and output a CSV report.' |
|||
) |
|||
ap.add_argument('directory', nargs='?', default='.', |
|||
help='Directory containing .mvd2 files (default: current dir)') |
|||
ap.add_argument('-o', '--output', default='', |
|||
help='Output CSV file (default: mvd_report.csv in the demo dir)') |
|||
args = ap.parse_args() |
|||
|
|||
demo_dir = Path(args.directory).resolve() |
|||
if not demo_dir.is_dir(): |
|||
print(f"Error: not a directory: {demo_dir}", file=sys.stderr) |
|||
sys.exit(1) |
|||
|
|||
files = sorted(demo_dir.glob('*.mvd2')) |
|||
if not files: |
|||
print(f"No .mvd2 files found in {demo_dir}", file=sys.stderr) |
|||
sys.exit(1) |
|||
|
|||
out_path = (Path(args.output).resolve() if args.output |
|||
else demo_dir / 'mvd_report.csv') |
|||
|
|||
rows = [] |
|||
for demo_path in files: |
|||
try: |
|||
state = parse_mvd2(demo_path) |
|||
row = build_row(demo_path, state) |
|||
rows.append(row) |
|||
trunc = ' [TRUNCATED]' if state.truncated else '' |
|||
print(f"OK {demo_path.name}{trunc}") |
|||
except Exception as exc: |
|||
print(f"ERR {demo_path.name}: {exc}", file=sys.stderr) |
|||
rows.append({ |
|||
'file': demo_path.name, |
|||
'date': _date_from_filename(demo_path.name), |
|||
'map': '', 'players_a': '', 'players_b': '', |
|||
'score': '', 'timelimit_min': '', 'truncated': 'error', |
|||
}) |
|||
|
|||
with open(out_path, 'w', newline='', encoding='utf-8') as f: |
|||
writer = csv.DictWriter(f, fieldnames=FIELDNAMES, delimiter=';') |
|||
writer.writeheader() |
|||
writer.writerows(rows) |
|||
|
|||
print(f"\nReport saved: {out_path} ({len(rows)} demos)") |
|||
|
|||
|
|||
if __name__ == '__main__': |
|||
main() |
|||
Loading…
Reference in new issue