Files
darling-lol/tools/perf-baseline.py
T
MintMachineHQ 11f7252004 feat(tools): add host doctor, crash report and frame-pacing baseline
Turns "it does not work" into an attachable report instead of a log-less
issue. Three read-only host tools, reachable from the launcher:

- doctor.py checks GPU/driver, Vulkan ICDs, display session, memory, disk,
  overlay layer, core-dump limit, i915 panel self-refresh and prefix/runtime
  state. --json and --report produce an issue attachment; warnings do not fail.
- crash-report.py bundles the doctor checks, process table, dserver and
  launcher log tails, graphics kernel messages, display state and recent core
  dumps. Environment is never collected and home/user/host are redacted.
- perf-baseline.py parses the game's own netstats.csv into average FPS,
  p50/p95/p99 frame time, slowest-quarter FPS and hitch count, with --json and
  --append for before/after comparisons.

COMPATIBILITY.md records the verified UHD 620 row with measured numbers
(24-32 avg FPS, p99 44-98 ms) and explains how to submit another one.
2026-10-23 23:56:30 -06:00

212 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""Measure frame pacing from the game's own `netstats.csv` logs.
League writes one row per ten seconds of a match, including `game.frametime_average`
(seconds per frame) and `game.time`. This tool turns those rows into numbers that
can be compared before and after a performance change:
- average FPS over the match,
- p50/p95/p99 frame time,
- average FPS of the slowest quarter of the match (the part you feel as stutter),
- the count of samples at least twice as slow as the match median (hitches).
It never reads or starts the game; it only parses files that are already on disk.
Usage:
python3 tools/perf-baseline.py # every match in the prefix
python3 tools/perf-baseline.py --last 5 # the five newest matches
python3 tools/perf-baseline.py --json
python3 tools/perf-baseline.py --append baseline.csv
python3 tools/perf-baseline.py --prefix /path/to/prefix --csv path/to/netstats.csv
`--append` writes one row per match to a CSV file, so several runs on the same
laptop can be compared later without re-parsing the logs.
"""
import argparse
import csv
import json
import math
import os
import re
import statistics
import sys
import time
from pathlib import Path
FRAMETIME_COLUMN = 'game.frametime_average'
TIME_COLUMN = 'game.time'
DEFAULT_GAME_LOGS = Path(
'Applications/League of Legends.app/Contents/LoL/Logs/GameLogs')
HITCH_FACTOR = 2.0
APPEND_HEADER = (
'measured', 'match', 'samples', 'seconds', 'avg_fps', 'mean_ms',
'p50_ms', 'p95_ms', 'p99_ms', 'slowest_quarter_fps', 'slowest_quarter_ms',
'hitches', 'host',
)
def default_prefix():
return Path(os.environ.get('DPREFIX') or (Path.home() / '.darling')).expanduser()
def discover(prefix=None):
"""Return netstats files, newest first, for a Darling prefix."""
prefix = Path(prefix or default_prefix()).expanduser()
return sorted(Path(prefix, DEFAULT_GAME_LOGS).glob('*/*_netstats.csv'),
key=lambda path: path.stat().st_mtime, reverse=True)
def read_rows(path):
"""Yield (game_time, frametime_seconds) for usable rows."""
try:
handle = open(path, newline='')
except OSError:
return
with handle:
for row in csv.DictReader(handle):
try:
frametime = float(row.get(FRAMETIME_COLUMN) or '')
game_time = float(row.get(TIME_COLUMN) or 0)
except (TypeError, ValueError):
continue
if math.isfinite(frametime) and frametime > 0:
yield game_time, frametime
def match_label(path):
match = re.search(r'(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})',
Path(path).parent.name or Path(path).name)
if match:
return '%s %s:%s' % (match.group(1), match.group(2), match.group(3))
return Path(path).parent.name
def percentile(values, fraction):
"""Nearest-rank percentile; values may be unsorted."""
if not values:
return 0.0
ordered = sorted(values)
index = max(0, min(len(ordered) - 1, math.ceil(fraction * len(ordered)) - 1))
return ordered[index]
def measure(path, measured=None):
"""Return one match's metrics as a flat dict."""
samples = list(read_rows(path))
if not samples:
return None
times = [time_value for time_value, _ in samples]
frames = [frametime for _, frametime in samples]
seconds = max(times) - min(times) if len(times) > 1 else 10.0 * len(samples)
focus = sorted(frames)[-max(1, math.ceil(len(frames) / 4)):]
median = statistics.median(frames)
return {
'measured': measured or time.strftime('%Y-%m-%dT%H:%M:%S'),
'match': match_label(path),
'path': str(path),
'samples': len(frames),
'seconds': round(seconds, 1),
'avg_fps': round(len(frames) / sum(frames), 1),
'mean_ms': round(statistics.mean(frames) * 1000, 2),
'p50_ms': round(percentile(frames, 0.50) * 1000, 2),
'p95_ms': round(percentile(frames, 0.95) * 1000, 2),
'p99_ms': round(percentile(frames, 0.99) * 1000, 2),
'slowest_quarter_fps': round(len(focus) / sum(focus), 1),
'slowest_quarter_ms': round(statistics.mean(focus) * 1000, 2),
'hitches': sum(1 for frametime in frames if frametime >= median * HITCH_FACTOR),
'host': host_summary(),
}
def host_summary():
"""Short host name for the append CSV, so rows from several machines differ."""
model = ''
try:
model = Path('/sys/devices/virtual/dmi/id/product_name').read_text().strip()
except OSError:
pass
cpu = ''
try:
for line in Path('/proc/cpuinfo').read_text().splitlines():
if line.startswith('model name'):
cpu = line.split(':', 1)[1].strip()
break
except OSError:
pass
return ' / '.join(part for part in (model, cpu) if part) or 'unknown'
def collect(paths, limit=None):
results = []
for path in paths[:limit] if limit else paths:
result = measure(path)
if result:
results.append(result)
return results
def render(results):
if not results:
return ('No netstats.csv files found. Play a match first, or pass '
'--prefix/--csv.\nExpected under: <prefix>/%s' % DEFAULT_GAME_LOGS)
lines = [
'%-17s %6s %6s %6s %6s %6s %6s %5s' % (
'match', 'avgfps', 'p50ms', 'p95ms', 'p99ms', 'slowfps', 'slowms', 'hitch'),
]
for result in results:
lines.append('%-17s %6.1f %6.2f %6.2f %6.2f %6.1f %6.2f %5d' % (
result['match'], result['avg_fps'], result['p50_ms'], result['p95_ms'],
result['p99_ms'], result['slowest_quarter_fps'],
result['slowest_quarter_ms'], result['hitches']))
lines.append('')
lines.append('avgfps over the match, p95/p99 frame time, slowfps/slowms over the'
' slowest quarter,')
lines.append('hitch = samples at least %.0fx the median frame time.' % HITCH_FACTOR)
return '\n'.join(lines)
def append_csv(results, path):
path = Path(path).expanduser()
path.parent.mkdir(parents=True, exist_ok=True)
exists = path.is_file() and path.stat().st_size > 0
with open(path, 'a', newline='') as handle:
writer = csv.DictWriter(handle, fieldnames=APPEND_HEADER, extrasaction='ignore')
if not exists:
writer.writeheader()
for result in results:
writer.writerow(result)
return path
def main(argv=None):
parser = argparse.ArgumentParser(description='Measure League frame pacing from netstats.csv logs.')
parser.add_argument('--prefix', type=Path, help='Darling prefix (default: DPREFIX or ~/.darling)')
parser.add_argument('--csv', type=Path, action='append',
help='measure this netstats.csv (repeatable); skips discovery')
parser.add_argument('--last', type=int, help='only the N newest matches')
parser.add_argument('--json', action='store_true', help='print JSON instead of a table')
parser.add_argument('--append', type=Path, help='append one CSV row per match to this file')
args = parser.parse_args(argv)
if args.csv:
paths = args.csv
else:
prefix = args.prefix or default_prefix()
paths = discover(prefix)
results = collect(paths, args.last)
if args.json:
print(json.dumps({'schema': 1, 'tool': 'darling-lol perf-baseline',
'host': host_summary(), 'matches': results}, indent=2))
else:
print(render(results))
if args.append and results:
written = append_csv(results, args.append)
if not args.json:
print('Appended %d row(s) to %s' % (len(results), written))
return 0 if results else 1
if __name__ == '__main__':
sys.exit(main())