1 changed files with 748 additions and 0 deletions
@ -0,0 +1,748 @@ |
|||||
|
#!/usr/bin/env python3 |
||||
|
""" |
||||
|
Q2Pro demo (.dm2) parser - extracts player stats and generates Markdown reports. |
||||
|
|
||||
|
Usage: |
||||
|
python dem_parser.py demo.dm2 # stdout |
||||
|
python dem_parser.py demo.dm2 -o out.md # output file |
||||
|
python dem_parser.py *.dm2 # batch - foo_report.md per file |
||||
|
python dem_parser.py /path/to/dir/ # batch - all .dm2 in directory |
||||
|
python dem_parser.py *.dm2 -y # batch, always continue on errors |
||||
|
""" |
||||
|
|
||||
|
import struct |
||||
|
import re |
||||
|
import sys |
||||
|
import os |
||||
|
import zlib |
||||
|
import argparse |
||||
|
from pathlib import Path |
||||
|
|
||||
|
# svc_ message types (inc/common/protocol.h) |
||||
|
SVC_MUZZLEFLASH = 1 |
||||
|
SVC_MUZZLEFLASH2 = 2 |
||||
|
SVC_TEMP_ENTITY = 3 |
||||
|
SVC_LAYOUT = 4 |
||||
|
SVC_INVENTORY = 5 |
||||
|
SVC_NOP = 6 |
||||
|
SVC_DISCONNECT = 7 |
||||
|
SVC_RECONNECT = 8 |
||||
|
SVC_SOUND = 9 |
||||
|
SVC_PRINT = 10 |
||||
|
SVC_STUFFTEXT = 11 |
||||
|
SVC_SERVERDATA = 12 |
||||
|
SVC_CONFIGSTRING = 13 |
||||
|
SVC_SPAWNBASELINE = 14 |
||||
|
SVC_CENTERPRINT = 15 |
||||
|
SVC_DOWNLOAD = 16 |
||||
|
SVC_PLAYERINFO = 17 |
||||
|
SVC_PACKETENTITIES = 18 |
||||
|
SVC_DELTAPACKETENTITIES = 19 |
||||
|
SVC_FRAME = 20 |
||||
|
SVC_ZPACKET = 21 |
||||
|
SVC_ZDOWNLOAD = 22 |
||||
|
SVC_GAMESTATE = 23 |
||||
|
SVC_SETTING = 24 |
||||
|
|
||||
|
# Configstring indices - new protocol (34/36, inc/shared/shared.h) |
||||
|
CS_MAXCLIENTS = 60 |
||||
|
CS_MODELS = 62 |
||||
|
CS_SOUNDS = CS_MODELS + 8192 |
||||
|
CS_IMAGES = CS_SOUNDS + 2048 |
||||
|
CS_LIGHTS = CS_IMAGES + 2048 |
||||
|
CS_ITEMS = CS_LIGHTS + 256 |
||||
|
CS_PLAYERSKINS = CS_ITEMS + 256 # = 12862 — "name\model\skin" |
||||
|
CS_GENERAL = CS_PLAYERSKINS + 256 # = 13118 |
||||
|
|
||||
|
# OpenTDM configstring offsets (CS_GENERAL + N, g_local.h:139) |
||||
|
CS_TDM_TEAM_A_NAME = CS_GENERAL + 0 |
||||
|
CS_TDM_TEAM_B_NAME = CS_GENERAL + 1 |
||||
|
CS_TDM_TEAM_A_STATUS = CS_GENERAL + 2 |
||||
|
CS_TDM_TEAM_B_STATUS = CS_GENERAL + 3 |
||||
|
|
||||
|
# Old protocol configstring indices (protocol 26, also used by opentdm/openffa) |
||||
|
CS_PLAYERSKINS_OLD = 1312 |
||||
|
CS_GENERAL_OLD = 1568 |
||||
|
|
||||
|
STAT_FRAGS = 14 # g_local.h:65 (opentdm), q_shared.h:1029 (openffa) |
||||
|
|
||||
|
EOF_MARKER = 0xFFFFFFFF |
||||
|
|
||||
|
|
||||
|
class ParseError(Exception): |
||||
|
pass |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Binary buffer reader |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
class Buffer: |
||||
|
def __init__(self, data): |
||||
|
self.data = data if isinstance(data, (bytes, bytearray)) else 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 of buffer (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 of buffer (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 of buffer (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 of buffer (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 in buffer") |
||||
|
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.remaining()} remaining)") |
||||
|
self.pos += n |
||||
|
|
||||
|
def read_bytes(self, n): |
||||
|
if self.pos + n > len(self.data): |
||||
|
raise ParseError(f"Cannot read {n} bytes") |
||||
|
v = self.data[self.pos:self.pos + n] |
||||
|
self.pos += n |
||||
|
return v |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Demo state |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
class DemoState: |
||||
|
def __init__(self): |
||||
|
self.protocol = 34 |
||||
|
self.gamedir = '' |
||||
|
self.mapname = '' |
||||
|
self.client_num = -1 |
||||
|
self.configstrings = {} |
||||
|
self.cvars = {} # server cvars from svc_stufftext "set" commands |
||||
|
self.layouts = [] # all layout strings found |
||||
|
self.truncated = False # demo ended without svc_disconnect |
||||
|
self.had_disconnect = False |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Message processing |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
def process_messages(buf, state): |
||||
|
"""Process messages from buffer until svc_frame or unknown message.""" |
||||
|
while buf.remaining() > 0: |
||||
|
cmd = buf.read_byte() |
||||
|
|
||||
|
if cmd == SVC_NOP: |
||||
|
continue |
||||
|
|
||||
|
elif cmd in (SVC_DISCONNECT, SVC_RECONNECT): |
||||
|
if cmd == SVC_DISCONNECT: |
||||
|
state.had_disconnect = True |
||||
|
break |
||||
|
|
||||
|
elif cmd == SVC_SERVERDATA: |
||||
|
state.protocol = buf.read_long() |
||||
|
buf.read_long() # server count |
||||
|
buf.read_byte() # attract loop |
||||
|
state.gamedir = buf.read_string() |
||||
|
state.client_num = buf.read_short() |
||||
|
state.mapname = buf.read_string() |
||||
|
|
||||
|
elif cmd == SVC_CONFIGSTRING: |
||||
|
index = buf.read_ushort() |
||||
|
value = buf.read_string() |
||||
|
state.configstrings[index] = value |
||||
|
|
||||
|
elif cmd == SVC_LAYOUT: |
||||
|
layout = buf.read_string() |
||||
|
state.layouts.append(layout) |
||||
|
|
||||
|
elif cmd == SVC_PRINT: |
||||
|
buf.read_byte() # print level |
||||
|
buf.read_string() |
||||
|
|
||||
|
elif cmd == SVC_STUFFTEXT: |
||||
|
text = buf.read_string() |
||||
|
# capture "set varname value" server-info cvars |
||||
|
for m in re.finditer(r'\bset\s+(\S+)\s+(\S+)', text): |
||||
|
state.cvars[m.group(1)] = m.group(2) |
||||
|
|
||||
|
elif cmd == SVC_CENTERPRINT: |
||||
|
buf.read_string() |
||||
|
|
||||
|
elif cmd == SVC_SETTING: |
||||
|
buf.skip(8) # 2 × int32 |
||||
|
|
||||
|
elif cmd in (SVC_MUZZLEFLASH, SVC_MUZZLEFLASH2): |
||||
|
buf.skip(3) # entity (short) + weapon (byte) |
||||
|
|
||||
|
elif cmd == SVC_ZPACKET: |
||||
|
len_in = buf.read_ushort() |
||||
|
len_out = buf.read_ushort() # decompressed size hint |
||||
|
compressed = buf.read_bytes(len_in) |
||||
|
try: |
||||
|
decompressed = zlib.decompress(compressed) |
||||
|
inner = Buffer(decompressed) |
||||
|
process_messages(inner, state) |
||||
|
except zlib.error: |
||||
|
break # corrupt compressed data, skip packet |
||||
|
|
||||
|
elif cmd in (SVC_FRAME, SVC_PLAYERINFO, SVC_PACKETENTITIES, |
||||
|
SVC_DELTAPACKETENTITIES, SVC_SPAWNBASELINE, |
||||
|
SVC_GAMESTATE, SVC_DOWNLOAD, SVC_TEMP_ENTITY, |
||||
|
SVC_INVENTORY, SVC_ZDOWNLOAD, SVC_SOUND): |
||||
|
# Frame data or too complex to skip — stop processing this packet |
||||
|
break |
||||
|
|
||||
|
else: |
||||
|
# Unknown message type — stop |
||||
|
break |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# File parsing |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
def parse_demo(path): |
||||
|
"""Read and parse a .dm2 demo file. Returns DemoState.""" |
||||
|
state = DemoState() |
||||
|
|
||||
|
with open(path, 'rb') as f: |
||||
|
data = f.read() |
||||
|
|
||||
|
pos = 0 |
||||
|
while pos < len(data): |
||||
|
if pos + 4 > len(data): |
||||
|
state.truncated = True |
||||
|
break |
||||
|
|
||||
|
msglen = struct.unpack_from('<I', data, pos)[0] |
||||
|
pos += 4 |
||||
|
|
||||
|
if msglen == EOF_MARKER: |
||||
|
break # clean end of file marker |
||||
|
|
||||
|
if msglen == 0: |
||||
|
continue |
||||
|
|
||||
|
if pos + msglen > len(data): |
||||
|
# Truncated final packet — parse what we have |
||||
|
state.truncated = True |
||||
|
msglen = len(data) - pos |
||||
|
|
||||
|
packet = data[pos:pos + msglen] |
||||
|
pos += msglen |
||||
|
|
||||
|
try: |
||||
|
buf = Buffer(packet) |
||||
|
process_messages(buf, state) |
||||
|
except ParseError: |
||||
|
state.truncated = True |
||||
|
# Continue with next packet |
||||
|
|
||||
|
if not state.had_disconnect: |
||||
|
state.truncated = True |
||||
|
|
||||
|
return state |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Layout string parsing |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
_HEADER_WORDS = {'name', 'frags', 'frag', 'dths', 'deaths', 'ping', |
||||
|
'net', 'eff', 'fph', 'time', 'player', 'rank'} |
||||
|
|
||||
|
|
||||
|
def _is_header(text): |
||||
|
words = set(text.lower().split()) |
||||
|
return bool(words & _HEADER_WORDS) |
||||
|
|
||||
|
|
||||
|
def _parse_layout_entries(layout): |
||||
|
"""Parse layout into list of (yv_position, string_content) tuples.""" |
||||
|
entries = [] |
||||
|
for m in re.finditer(r'yv\s+(\d+)\s+string2?\s+"([^"]*)"', layout): |
||||
|
entries.append((int(m.group(1)), m.group(2))) |
||||
|
return entries |
||||
|
|
||||
|
|
||||
|
def _parse_player_tdm(content): |
||||
|
""" |
||||
|
Parse a single OpenTDM player line (fixed-width format). |
||||
|
Format: "%-15.15s %4d %3d %3d %3d" → name, frags, deaths, net, ping |
||||
|
Returns dict or None. |
||||
|
""" |
||||
|
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 _parse_player_ffa(content): |
||||
|
""" |
||||
|
Parse a single OpenFFA player line (fixed-width format). |
||||
|
Format: "%2d %-15s %3d %3d %3d %4d %4s %4d" → rank, name, frags, deaths, eff, ... |
||||
|
Offsets: [0:2]=rank, [3:18]=name, [19:22]=frags, [23:26]=deaths, [27:30]=eff |
||||
|
Returns dict or None. |
||||
|
""" |
||||
|
if len(content) < 27: |
||||
|
return None |
||||
|
rank_s = content[0:2].strip() |
||||
|
if not rank_s.isdigit(): |
||||
|
return None |
||||
|
name = content[3:18].rstrip() if len(content) >= 18 else content[3:].rstrip() |
||||
|
if not name: |
||||
|
return None |
||||
|
nums = re.findall(r'-?\d+', content[18:]) |
||||
|
if len(nums) < 3: |
||||
|
return None |
||||
|
try: |
||||
|
return { |
||||
|
'rank': int(rank_s), |
||||
|
'name': name, |
||||
|
'frags': int(nums[0]), |
||||
|
'deaths': int(nums[1]), |
||||
|
'eff': int(nums[2]), |
||||
|
} |
||||
|
except ValueError: |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def parse_layout_tdm(layout): |
||||
|
""" |
||||
|
Parse an OpenTDM scoreboard layout string. |
||||
|
Returns (team_a_players, team_b_players, team_a_score, team_b_score). |
||||
|
Each player: {'name', 'frags', 'deaths'}. |
||||
|
|
||||
|
OpenTDM layout places both teams side by side: |
||||
|
Column A: yv 56, 64, ... (between header A at yv 48 and header B) |
||||
|
Column B: yv 96, 104, ... (below header B at yv 88) |
||||
|
""" |
||||
|
entries = _parse_layout_entries(layout) |
||||
|
|
||||
|
# Team scores appear at small yv positions (yv 8, yv 16) |
||||
|
# Format: "TeamName SCORE" — last token is the score |
||||
|
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: |
||||
|
score = int(parts[1]) |
||||
|
team_scores.append(score) |
||||
|
except ValueError: |
||||
|
pass |
||||
|
|
||||
|
team_a_score = team_scores[0] if len(team_scores) > 0 else 0 |
||||
|
team_b_score = team_scores[1] if len(team_scores) > 1 else 0 |
||||
|
|
||||
|
# Column header positions (string2 containing " Name " and "Frags") |
||||
|
header_yvs = sorted( |
||||
|
yv for yv, c in entries |
||||
|
if 'Name' in c and 'Frags' in c |
||||
|
) |
||||
|
|
||||
|
# Team A: entries between first and second header yv |
||||
|
# Team B: entries below second header yv |
||||
|
split_yv = header_yvs[1] if len(header_yvs) >= 2 else 9999 |
||||
|
first_header_yv = header_yvs[0] if header_yvs else 0 |
||||
|
|
||||
|
team_a_players = [] |
||||
|
team_b_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: |
||||
|
team_a_players.append(p) |
||||
|
elif yv > split_yv: |
||||
|
team_b_players.append(p) |
||||
|
|
||||
|
return team_a_players, team_b_players, team_a_score, team_b_score |
||||
|
|
||||
|
|
||||
|
def parse_layout_ffa(layout): |
||||
|
""" |
||||
|
Parse an OpenFFA/DM scoreboard layout string. |
||||
|
Returns list of {'rank', 'name', 'frags', 'deaths', 'eff'}. |
||||
|
""" |
||||
|
entries = _parse_layout_entries(layout) |
||||
|
players = [] |
||||
|
|
||||
|
for _yv, content in entries: |
||||
|
if _is_header(content): |
||||
|
continue |
||||
|
p = _parse_player_ffa(content) |
||||
|
if p: |
||||
|
players.append(p) |
||||
|
|
||||
|
return players |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# State helpers |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
def _cs_gen(state): |
||||
|
"""Return CS_GENERAL base index — old or new, detected from demo content.""" |
||||
|
if any(state.configstrings.get(CS_GENERAL_OLD + i) for i in range(8)): |
||||
|
return CS_GENERAL_OLD |
||||
|
return CS_GENERAL |
||||
|
|
||||
|
|
||||
|
def _cs_skins(state): |
||||
|
"""Return CS_PLAYERSKINS base index — old or new.""" |
||||
|
if any(state.configstrings.get(CS_PLAYERSKINS_OLD + i) for i in range(8)): |
||||
|
return CS_PLAYERSKINS_OLD |
||||
|
return CS_PLAYERSKINS |
||||
|
|
||||
|
|
||||
|
def is_opentdm(state): |
||||
|
cs_gen = _cs_gen(state) |
||||
|
return ( |
||||
|
'opentdm' in state.gamedir.lower() |
||||
|
or state.configstrings.get(cs_gen + 0, '').strip() != '' |
||||
|
or state.configstrings.get(cs_gen + 1, '').strip() != '' |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def get_team_names(state): |
||||
|
cs_gen = _cs_gen(state) |
||||
|
team_a = state.configstrings.get(cs_gen + 0, '').strip() or 'Team A' |
||||
|
team_b = state.configstrings.get(cs_gen + 1, '').strip() or 'Team B' |
||||
|
return team_a, team_b |
||||
|
|
||||
|
|
||||
|
def get_team_scores_from_cs(state): |
||||
|
"""Return (score_a, score_b) from CS_TDM_TEAM_A/B_STATUS configstrings.""" |
||||
|
cs_gen = _cs_gen(state) |
||||
|
try: |
||||
|
score_a = int(state.configstrings.get(cs_gen + 2, '').strip()) |
||||
|
except ValueError: |
||||
|
score_a = None |
||||
|
try: |
||||
|
score_b = int(state.configstrings.get(cs_gen + 3, '').strip()) |
||||
|
except ValueError: |
||||
|
score_b = None |
||||
|
return score_a, score_b |
||||
|
|
||||
|
|
||||
|
def get_timelimit(state): |
||||
|
"""Return timelimit in minutes, or None if not found.""" |
||||
|
# OpenTDM uses g_match_time (seconds); fallback to timelimit (minutes) |
||||
|
if 'g_match_time' in state.cvars: |
||||
|
try: |
||||
|
secs = float(state.cvars['g_match_time']) |
||||
|
if secs > 0: |
||||
|
return int(secs // 60) |
||||
|
except ValueError: |
||||
|
pass |
||||
|
if 'timelimit' in state.cvars: |
||||
|
try: |
||||
|
mins = float(state.cvars['timelimit']) |
||||
|
if mins > 0: |
||||
|
return int(mins) |
||||
|
except ValueError: |
||||
|
pass |
||||
|
return None |
||||
|
|
||||
|
|
||||
|
def get_player_names(state): |
||||
|
"""Return {slot_index: name} from CS_PLAYERSKINS configstrings.""" |
||||
|
cs_base = _cs_skins(state) |
||||
|
players = {} |
||||
|
for i in range(256): |
||||
|
val = state.configstrings.get(cs_base + i, '').strip() |
||||
|
if val: |
||||
|
name = val.split('\\')[0].strip() |
||||
|
if name: |
||||
|
players[i] = name |
||||
|
return players |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Markdown report generation |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
_WARN_TRUNCATED = ( |
||||
|
"\n> ⚠ **Demo truncated** — the recording may have ended abruptly (e.g. system reset). " |
||||
|
"Stats are from the last available scoreboard.\n" |
||||
|
) |
||||
|
_WARN_NO_LAYOUT = ( |
||||
|
"\n> ⚠ **Demo truncated** — no scoreboard found in file. " |
||||
|
"Deaths unavailable (`N/A`). Frags from last known frame.\n" |
||||
|
) |
||||
|
|
||||
|
|
||||
|
def _md_table(headers, rows): |
||||
|
lines = [] |
||||
|
lines.append('| ' + ' | '.join(headers) + ' |') |
||||
|
lines.append('|' + '|'.join('-' * (len(h) + 2) for h in headers) + '|') |
||||
|
for row in rows: |
||||
|
lines.append('| ' + ' | '.join(str(c) for c in row) + ' |') |
||||
|
return '\n'.join(lines) |
||||
|
|
||||
|
|
||||
|
def _meta_line(state): |
||||
|
"""Return a metadata line with map name and timelimit.""" |
||||
|
mapname = state.mapname or 'unknown' |
||||
|
timelimit = get_timelimit(state) |
||||
|
tl_str = f"{timelimit} min" if timelimit else "N/A" |
||||
|
return f"**Map:** {mapname} | **Timelimit:** {tl_str}" |
||||
|
|
||||
|
|
||||
|
def generate_report_ffa(state, players, truncated, no_layout=False): |
||||
|
mod = state.gamedir or 'DM' |
||||
|
mapname = state.mapname or 'unknown' |
||||
|
lines = [f"# Demo: {mapname} — {mod}", "", _meta_line(state), ""] |
||||
|
|
||||
|
if no_layout: |
||||
|
lines.append(_WARN_NO_LAYOUT) |
||||
|
elif truncated: |
||||
|
lines.append(_WARN_TRUNCATED) |
||||
|
|
||||
|
if not players: |
||||
|
lines.append("*No player data found in demo file.*") |
||||
|
return '\n'.join(lines) |
||||
|
|
||||
|
players.sort(key=lambda p: p.get('frags', 0), reverse=True) |
||||
|
has_eff = any('eff' in p for p in players) |
||||
|
|
||||
|
if has_eff: |
||||
|
headers = ['#', 'Player', 'Frags', 'Deaths', 'Eff%'] |
||||
|
rows = [ |
||||
|
(p.get('rank', i + 1), p['name'], |
||||
|
p['frags'], p.get('deaths', 'N/A'), f"{p.get('eff', 'N/A')}%") |
||||
|
for i, p in enumerate(players) |
||||
|
] |
||||
|
else: |
||||
|
headers = ['#', 'Player', 'Frags', 'Deaths'] |
||||
|
rows = [ |
||||
|
(i + 1, p['name'], p['frags'], p.get('deaths', 'N/A')) |
||||
|
for i, p in enumerate(players) |
||||
|
] |
||||
|
|
||||
|
lines.append(_md_table(headers, rows)) |
||||
|
return '\n'.join(lines) |
||||
|
|
||||
|
|
||||
|
def generate_report_tdm(state, team_a_name, team_b_name, |
||||
|
team_a_players, team_b_players, |
||||
|
team_a_score, team_b_score, |
||||
|
truncated): |
||||
|
mapname = state.mapname or 'unknown' |
||||
|
lines = [f"# Demo: {mapname} — OpenTDM", "", _meta_line(state), ""] |
||||
|
|
||||
|
if truncated: |
||||
|
lines.append(_WARN_TRUNCATED) |
||||
|
|
||||
|
lines.append("## Team scores") |
||||
|
lines.append("") |
||||
|
a_deaths = sum(p['deaths'] for p in team_a_players) |
||||
|
b_deaths = sum(p['deaths'] for p in team_b_players) |
||||
|
lines.append(_md_table( |
||||
|
['Team', 'Frags', 'Deaths'], |
||||
|
[ |
||||
|
(team_a_name, team_a_score, a_deaths), |
||||
|
(team_b_name, team_b_score, b_deaths), |
||||
|
] |
||||
|
)) |
||||
|
lines.append("") |
||||
|
|
||||
|
for team_name, team_players in [ |
||||
|
(team_a_name, team_a_players), |
||||
|
(team_b_name, team_b_players), |
||||
|
]: |
||||
|
lines.append(f"## {team_name}") |
||||
|
lines.append("") |
||||
|
if not team_players: |
||||
|
lines.append("*No data.*") |
||||
|
else: |
||||
|
team_players.sort(key=lambda p: p['frags'], reverse=True) |
||||
|
lines.append(_md_table( |
||||
|
['Player', 'Frags', 'Deaths'], |
||||
|
[(p['name'], p['frags'], p['deaths']) for p in team_players] |
||||
|
)) |
||||
|
lines.append("") |
||||
|
|
||||
|
return '\n'.join(lines) |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# Report builder |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
def build_report(state): |
||||
|
last_layout = state.layouts[-1] if state.layouts else None |
||||
|
truncated = state.truncated |
||||
|
no_layout = last_layout is None |
||||
|
|
||||
|
if is_opentdm(state): |
||||
|
team_a_name, team_b_name = get_team_names(state) |
||||
|
if last_layout: |
||||
|
ta_p, tb_p, ta_score, tb_score = parse_layout_tdm(last_layout) |
||||
|
else: |
||||
|
ta_p, tb_p, ta_score, tb_score = [], [], 0, 0 |
||||
|
# Prefer team scores from configstrings (more reliable than layout parsing) |
||||
|
cs_a, cs_b = get_team_scores_from_cs(state) |
||||
|
if cs_a is not None: |
||||
|
ta_score = cs_a |
||||
|
if cs_b is not None: |
||||
|
tb_score = cs_b |
||||
|
return generate_report_tdm( |
||||
|
state, team_a_name, team_b_name, |
||||
|
ta_p, tb_p, ta_score, tb_score, |
||||
|
truncated |
||||
|
) |
||||
|
else: |
||||
|
if last_layout: |
||||
|
players = parse_layout_ffa(last_layout) |
||||
|
if not players: |
||||
|
# Fallback: try TDM pattern (some mods use similar format) |
||||
|
tdm_all = parse_layout_tdm(last_layout) |
||||
|
players = [ |
||||
|
{'name': p['name'], 'frags': p['frags'], 'deaths': p['deaths']} |
||||
|
for p in tdm_all[0] + tdm_all[1] |
||||
|
] |
||||
|
else: |
||||
|
# No layout — fall back to player names from configstrings, no deaths |
||||
|
player_names = get_player_names(state) |
||||
|
players = [{'name': name, 'frags': 0} for name in player_names.values()] |
||||
|
return generate_report_ffa(state, players, truncated, no_layout) |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# File processing |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
def process_file(path, output_path=None): |
||||
|
"""Parse a single demo file and write/return the report.""" |
||||
|
state = parse_demo(path) |
||||
|
report = build_report(state) |
||||
|
if output_path: |
||||
|
with open(output_path, 'w', encoding='utf-8') as f: |
||||
|
f.write(report) |
||||
|
return None |
||||
|
return report |
||||
|
|
||||
|
|
||||
|
# --------------------------------------------------------------------------- |
||||
|
# CLI |
||||
|
# --------------------------------------------------------------------------- |
||||
|
|
||||
|
def main(): |
||||
|
ap = argparse.ArgumentParser( |
||||
|
description='Q2Pro .dm2 demo parser — generates Markdown player stats reports.' |
||||
|
) |
||||
|
ap.add_argument('inputs', nargs='+', |
||||
|
help='.dm2 file(s) or a directory containing .dm2 files') |
||||
|
ap.add_argument('-o', '--output', |
||||
|
help='Output file (single input file only)') |
||||
|
ap.add_argument('-y', '--always-continue', action='store_true', |
||||
|
help='Always continue on errors without prompting') |
||||
|
args = ap.parse_args() |
||||
|
|
||||
|
# Collect .dm2 files |
||||
|
files = [] |
||||
|
for inp in args.inputs: |
||||
|
p = Path(inp) |
||||
|
if p.is_dir(): |
||||
|
files.extend(sorted(p.glob('*.dm2'))) |
||||
|
elif p.suffix.lower() == '.dm2': |
||||
|
if p.exists(): |
||||
|
files.append(p) |
||||
|
else: |
||||
|
print(f"File not found: {inp}", file=sys.stderr) |
||||
|
else: |
||||
|
print(f"Skipping (not .dm2 or directory): {inp}", file=sys.stderr) |
||||
|
|
||||
|
if not files: |
||||
|
print("No .dm2 files found.", file=sys.stderr) |
||||
|
sys.exit(1) |
||||
|
|
||||
|
if len(files) > 1 and args.output: |
||||
|
print("Error: -o/--output can only be used with a single input file.", file=sys.stderr) |
||||
|
sys.exit(1) |
||||
|
|
||||
|
always_continue = args.always_continue |
||||
|
|
||||
|
for demo_path in files: |
||||
|
try: |
||||
|
if len(files) == 1 and not args.output: |
||||
|
# Single file → stdout |
||||
|
report = process_file(demo_path) |
||||
|
print(report) |
||||
|
elif len(files) == 1 and args.output: |
||||
|
# Single file → specified output file |
||||
|
process_file(demo_path, args.output) |
||||
|
print(f"Report saved: {args.output}") |
||||
|
else: |
||||
|
# Batch mode → foo_report.md |
||||
|
out_path = demo_path.parent / (demo_path.stem + '_report.md') |
||||
|
process_file(demo_path, out_path) |
||||
|
print(f"OK {demo_path.name} -> {out_path.name}") |
||||
|
|
||||
|
except Exception as exc: |
||||
|
print(f"\n[ERROR] {demo_path.name}: {exc}", file=sys.stderr) |
||||
|
|
||||
|
if not always_continue and len(files) > 1: |
||||
|
try: |
||||
|
ans = input( |
||||
|
"Continue? [y]es / [n]o / [a]lways continue: " |
||||
|
).strip().lower() |
||||
|
except (EOFError, KeyboardInterrupt): |
||||
|
print("\nAborted.", file=sys.stderr) |
||||
|
sys.exit(1) |
||||
|
|
||||
|
if ans == 'n': |
||||
|
print("Aborted.", file=sys.stderr) |
||||
|
sys.exit(1) |
||||
|
elif ans == 'a': |
||||
|
always_continue = True |
||||
|
# 'y' or anything else → continue |
||||
|
|
||||
|
|
||||
|
if __name__ == '__main__': |
||||
|
main() |
||||
Loading…
Reference in new issue