mirror of
https://github.com/lucannez64/darling-lol.git
synced 2026-10-26 19:47:42 +00:00
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.
This commit is contained in:
1 parent
24276b91c6
commit
11f7252004
10 files changed
+1608
-1
No files matched your search
@@ -23,7 +23,7 @@
|
||||
|
||||
If someone else wants to contact me know that i'm open to jobs, part-time remote only as a employee and full time remote as a freelancer for a mission
|
||||
|
||||
A short manual ARAM run and the current compatibility status are documented in [docs/STATUS.md](docs/STATUS.md).
|
||||
A short manual ARAM run and the current compatibility status are documented in [docs/STATUS.md](docs/STATUS.md). Real machines that have played, and their measured frame times, are listed in [docs/COMPATIBILITY.md](docs/COMPATIBILITY.md); `darling-lol --doctor` and `darling-lol --perf` produce the data for a new row.
|
||||
|
||||
Experimental source patches and tools for running the **macOS League of Legends client on x86-64 Linux through Darling**.
|
||||
|
||||
|
||||
@@ -15,6 +15,20 @@ if [ -z "${DPREFIX:-}" ] && [ -f "$WORKSPACE/build/config.json" ]; then
|
||||
"$WORKSPACE/build/config.json" 2>/dev/null) || DPREFIX=
|
||||
fi
|
||||
export DPREFIX="${DPREFIX:-$HOME/.darling}"
|
||||
# Read-only host tools. They run here rather than in the namespace so they can
|
||||
# see the real process table, kernel messages and display, and so a crash report
|
||||
# still works while a session holds the workspace lock.
|
||||
host_tool() {
|
||||
local tool="$SHARE_DIR/tools/$1.py"
|
||||
shift
|
||||
[ -f "$tool" ] || tool="$(dirname -- "$0")/tools/$(basename -- "${tool}")"
|
||||
exec python3 "$tool" "$@"
|
||||
}
|
||||
case "${1:-}" in
|
||||
--doctor) shift; host_tool doctor "$@";;
|
||||
--report) shift; host_tool crash-report "$@";;
|
||||
--perf) shift; host_tool perf-baseline "$@";;
|
||||
esac
|
||||
if [ "${1:-}" = --set-prefix ]; then
|
||||
[ $# -eq 2 ] || { echo 'Usage: darling-lol --set-prefix PATH' >&2; exit 2; }
|
||||
# Resolve here: the namespace starts in the workspace, not in this directory.
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Compatibility matrix
|
||||
|
||||
This page answers one question: **on which machines has darling-lol actually
|
||||
been used to play, and what did it run like?**
|
||||
|
||||
It is a record of real reports, not a list of every configuration that might
|
||||
work. "Verified" below means the Riot Client opened, League launched, a match
|
||||
was played manually, and the store was reachable. Experimental or untested
|
||||
hardware is not listed until someone sends a report.
|
||||
|
||||
The project is unofficial and carries account risk. Never test on an account you
|
||||
care about, and never play ranked while testing (see the warning in the
|
||||
[README](../README.md)).
|
||||
|
||||
## How to add a row
|
||||
|
||||
Run these on the machine you played on, after a match:
|
||||
|
||||
```sh
|
||||
darling-lol --doctor # host, GPU, Vulkan, session, prefix checks
|
||||
darling-lol --doctor --report doctor.json # same checks as a JSON attachment
|
||||
darling-lol --perf # frame pacing from the game's own logs
|
||||
darling-lol --perf --json # the same numbers as JSON
|
||||
```
|
||||
|
||||
`lol doctor` needs no runtime and starts nothing, so it is safe to run at any
|
||||
time. `lol perf` reads the `netstats.csv` files that League already writes; it
|
||||
does not touch the client or the game.
|
||||
|
||||
Open an issue titled `Compatibility: <distro> / <GPU>` and attach `doctor.json`
|
||||
plus the `perf --json` output. Do not paste a full `~/.darling` log or your home
|
||||
directory; `lol report` produces a redacted bundle if more diagnostics are
|
||||
needed. Then add a row here in a pull request, or mention the issue and a
|
||||
maintainer will add it.
|
||||
|
||||
### What the numbers mean
|
||||
|
||||
- `avgfps` is the mean of League's own ten-second `game.frametime_average`
|
||||
samples over the whole match.
|
||||
- `p50/p95/p99ms` are frame times: the typical frame, the slow 5 %, and the
|
||||
slowest 1 %.
|
||||
- `slowfps` is the average FPS over the slowest quarter of the match, which is
|
||||
closer to what stutter feels like than the headline average.
|
||||
- `hitch` counts ten-second samples whose frame time is at least twice the
|
||||
match median.
|
||||
- FPS depends on map, graphics settings, charger/power state, other programs and
|
||||
shader-cache warmth. Treat comparisons as useful only when those match.
|
||||
|
||||
## Verified configurations
|
||||
|
||||
| Distro / desktop | CPU / GPU | Driver | Session | Kernel | Runtime | Result | Frame pacing |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| Omarchy (Arch), Hyprland | Intel i5-10210U / Intel UHD 620 | `i915`, Mesa ANV | Wayland + Xwayland | 7.2.5 | patched Darling, `-O2` libc/libc++ | Client, game, ARAM/Swiftplay and store work; playable at low settings | 24–32 avg FPS over five ARAM matches; p99 44–98 ms; slowest quarter 20–27 FPS |
|
||||
|
||||
Notes for the UHD 620 row:
|
||||
|
||||
- Measured 2026-10-23 with `tools/perf-baseline.py` on five match logs, no
|
||||
FPS overlay. The laptop has 11.5 GiB RAM and a 15 W Comet Lake CPU.
|
||||
- The 24 FPS match is also the longest log and the one with the most samples;
|
||||
the spread between matches is larger than the difference between recent
|
||||
builds, so single-match comparisons are not meaningful.
|
||||
- Intel panel self-refresh (`i915.enable_psr`) was on for part of this period.
|
||||
See [panel self-refresh](TROUBLESHOOTING.md#blocky-video-or-flickering-startup-logo).
|
||||
- Expect first-match shader compilation to show up as a low `p99` until the
|
||||
cache is warm.
|
||||
|
||||
The Ryzen 5 5500 / RX 6600 development machine is faster and is documented
|
||||
separately in [status](STATUS.md#game-fps-2026-10-1314). It is not a laptop
|
||||
baseline and should not be read as the expected result on integrated graphics.
|
||||
|
||||
## Not verified
|
||||
|
||||
These are common laptop configurations without a confirmed play test here.
|
||||
Reports are welcome, but treat them as unknown rather than supported:
|
||||
|
||||
- Recent AMD integrated graphics (Radeon 680M/780M) under RADV.
|
||||
- NVIDIA hybrid laptops using PRIME render offload.
|
||||
- Fedora Silverblue and other immutable distributions with Nix.
|
||||
- Intel Arc discrete GPUs.
|
||||
|
||||
Known driver caveats live in the
|
||||
[troubleshooting guide](TROUBLESHOOTING.md#nvidia) and
|
||||
[hybrid graphics notes](TROUBLESHOOTING.md#hybrid-graphics-laptops-amdintel-igpu--nvidia).
|
||||
@@ -1,5 +1,31 @@
|
||||
# Troubleshooting
|
||||
|
||||
## Before reporting a problem
|
||||
|
||||
Run the host checks first; they start nothing and work while a client is open:
|
||||
|
||||
```sh
|
||||
darling-lol --doctor
|
||||
```
|
||||
|
||||
It reports the GPU/driver, Vulkan, display session, memory, disk, overlay layer
|
||||
and prefix state, and ends with the checks that need attention. If you open an
|
||||
issue, attach the machine-readable report and a crash bundle instead of a
|
||||
hand-copied log:
|
||||
|
||||
```sh
|
||||
darling-lol --doctor --report doctor.json
|
||||
darling-lol --report # ~/.cache/darling-lol/reports/*.tar.gz
|
||||
darling-lol --perf # frame pacing from the game's own logs
|
||||
```
|
||||
|
||||
The crash bundle contains the doctor checks, the process table, the runtime and
|
||||
launcher log tails, graphics kernel messages, display state and recent core
|
||||
dumps. It does not collect environment variables, tokens or passwords, and it
|
||||
replaces your home directory, user name and host name with placeholders unless
|
||||
you pass `--no-redact`. See [compatibility](COMPATIBILITY.md) for what the
|
||||
performance numbers mean and for verified machines.
|
||||
|
||||
## NixOS launcher fails before Riot opens
|
||||
|
||||
A message saying Darling must be setuid root is not fixed by running `sudo`
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Crash/hang bundle: process table, log tails, redaction; never starts anything."""
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / 'tools/crash-report.py'
|
||||
SPEC = importlib.util.spec_from_file_location('crash_report', SCRIPT)
|
||||
crash_report = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(crash_report)
|
||||
|
||||
|
||||
def make_process(proc, pid, ppid, state, comm, argv):
|
||||
folder = Path(proc) / str(pid)
|
||||
folder.mkdir(parents=True)
|
||||
(folder / 'stat').write_text('%d (%s) %s %d 0 0 0\n' % (pid, comm, state, ppid))
|
||||
(folder / 'comm').write_text(comm + '\n')
|
||||
(folder / 'cmdline').write_bytes(b'\0'.join(part.encode() for part in argv) + b'\0')
|
||||
return folder
|
||||
|
||||
|
||||
class ProcessSnapshotTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.proc = Path(self.folder.name)
|
||||
make_process(self.proc, 100, 1, 'S', 'systemd', ['/usr/lib/systemd/systemd', '--user'])
|
||||
make_process(self.proc, 101, 100, 'S', 'mldr', ['/Volumes/mldr', 'RiotClientServices'])
|
||||
make_process(self.proc, 102, 101, 'Z', 'League of Legends', ['/game/League of Legends'])
|
||||
(self.proc / 'self').mkdir()
|
||||
(self.proc / 'uptime').write_text('1.0 2.0\n')
|
||||
|
||||
def tearDown(self):
|
||||
self.folder.cleanup()
|
||||
|
||||
def test_snapshot_parses_stat_with_spaces_in_comm(self):
|
||||
rows = crash_report.process_snapshot(self.proc)
|
||||
self.assertEqual([row['pid'] for row in rows], [100, 101, 102])
|
||||
game = rows[-1]
|
||||
self.assertEqual(game['comm'], 'League of Legends')
|
||||
self.assertEqual(game['ppid'], 101)
|
||||
self.assertEqual(game['state'], 'Z')
|
||||
self.assertIn('League of Legends', game['argv'])
|
||||
|
||||
def test_relevant_selects_runtime_client_and_zombies(self):
|
||||
rows = crash_report.process_snapshot(self.proc)
|
||||
relevant = crash_report.relevant_processes(rows)
|
||||
self.assertEqual([row['pid'] for row in relevant], [101, 102])
|
||||
|
||||
def test_format_has_both_sections(self):
|
||||
rows = crash_report.process_snapshot(self.proc)
|
||||
text = crash_report.format_processes(rows, crash_report.relevant_processes(rows))
|
||||
self.assertIn('# all processes owned by this user', text)
|
||||
self.assertIn('# runtime, client and zombie processes', text)
|
||||
self.assertIn('101\t100\tS\tmldr', text)
|
||||
|
||||
|
||||
class HelperTests(unittest.TestCase):
|
||||
def test_scrub_replaces_identity(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
home = Path(folder)
|
||||
text = '%s/secret joe on desktop-1' % home
|
||||
cleaned = crash_report.scrub(text, home=home, username='joe', hostname='desktop-1')
|
||||
self.assertEqual(cleaned, '~/secret <user> on <host>')
|
||||
self.assertEqual(crash_report.scrub('', home=home), '')
|
||||
|
||||
def test_read_tail_keeps_last_lines(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
log = Path(folder) / 'dserver.log'
|
||||
log.write_text(''.join('line %d\n' % index for index in range(10)))
|
||||
text, error = crash_report.read_tail(log, limit=3)
|
||||
self.assertIsNone(error)
|
||||
self.assertEqual(text, 'line 7\nline 8\nline 9\n')
|
||||
|
||||
def test_read_tail_reports_missing_file(self):
|
||||
text, error = crash_report.read_tail('/nonexistent/dserver.log')
|
||||
self.assertIsNone(text)
|
||||
self.assertIn('No such file', error)
|
||||
|
||||
def test_human_size(self):
|
||||
self.assertEqual(crash_report.human_size(512), '512 B')
|
||||
self.assertEqual(crash_report.human_size(2048), '2 KiB')
|
||||
self.assertEqual(crash_report.human_size(3 * 1024 ** 2), '3 MiB')
|
||||
|
||||
def test_bundle_name_has_label(self):
|
||||
name = crash_report.bundle_name('hang')
|
||||
self.assertTrue(name.startswith('lol-report-'))
|
||||
self.assertTrue(name.endswith('-hang.tar.gz'))
|
||||
|
||||
|
||||
class BuildTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.folder.name)
|
||||
self.prefix = self.root / 'prefix'
|
||||
self.proc = self.root / 'proc'
|
||||
self.out = self.root / 'reports'
|
||||
self.proc.mkdir()
|
||||
make_process(self.proc, 200, 1, 'S', 'darlingserver', ['/usr/libexec/darlingserver'])
|
||||
make_process(self.proc, 201, 200, 'Z', 'mldr', ['/mldr'])
|
||||
log = self.prefix / 'private/var/log/dserver.log'
|
||||
log.parent.mkdir(parents=True)
|
||||
log.write_text(str(Path.home()) + '/.darling running\nsecret marker\n')
|
||||
self.launch = self.root / 'launch.log'
|
||||
self.launch.write_text('launcher line\n')
|
||||
self.env = {'USER': 'joe', 'HOME': str(Path.home()), 'SECRET_TOKEN': 'do-not-publish'}
|
||||
|
||||
def tearDown(self):
|
||||
self.folder.cleanup()
|
||||
|
||||
def build(self, **overrides):
|
||||
options = dict(prefix=self.prefix, out_dir=self.out, launch_log=self.launch,
|
||||
proc_root=self.proc, which=lambda name: None, env=self.env)
|
||||
options.update(overrides)
|
||||
return crash_report.build(**options)
|
||||
|
||||
def test_bundle_contains_expected_files_and_summary(self):
|
||||
archive, manifest = self.build()
|
||||
self.assertTrue(archive.is_file())
|
||||
self.assertEqual(manifest['processes'], {'total': 2, 'relevant': 2, 'zombies': 1})
|
||||
self.assertEqual(manifest['redacted'], True)
|
||||
self.assertEqual(manifest['prefix'], '<prefix>')
|
||||
with tarfile.open(archive) as tar:
|
||||
names = set(tar.getnames())
|
||||
self.assertIn('lol-report/README.txt', names)
|
||||
self.assertIn('lol-report/report.json', names)
|
||||
self.assertIn('lol-report/processes.txt', names)
|
||||
self.assertIn('lol-report/logs/dserver.log.tail', names)
|
||||
self.assertIn('lol-report/logs/kernel.txt', names)
|
||||
self.assertIn('lol-report/coredumps.txt', names)
|
||||
report = json.loads(tar.extractfile('lol-report/report.json').read())
|
||||
self.assertEqual(report['doctor']['checks'][0]['id'], 'kernel')
|
||||
|
||||
def test_bundle_redacts_home_and_secrets(self):
|
||||
archive, _ = self.build()
|
||||
with tarfile.open(archive) as tar:
|
||||
combined = ''
|
||||
for member in tar.getmembers():
|
||||
if member.isfile():
|
||||
combined += tar.extractfile(member).read().decode()
|
||||
self.assertNotIn(str(Path.home()), combined)
|
||||
self.assertNotIn('do-not-publish', combined)
|
||||
self.assertIn('~/.darling running', combined)
|
||||
|
||||
def test_no_redact_keeps_paths(self):
|
||||
archive, manifest = self.build(redact=False)
|
||||
self.assertEqual(manifest['redacted'], False)
|
||||
self.assertEqual(manifest['prefix'], str(self.prefix))
|
||||
with tarfile.open(archive) as tar:
|
||||
text = tar.extractfile('lol-report/logs/dserver.log.tail').read().decode()
|
||||
self.assertIn(str(Path.home()), text)
|
||||
|
||||
def test_missing_logs_are_explained(self):
|
||||
archive, manifest = self.build(launch_log=None)
|
||||
self.assertIn('logs/launch.log.tail', manifest['files'])
|
||||
with tarfile.open(archive) as tar:
|
||||
text = tar.extractfile('lol-report/logs/launch.log.tail').read().decode()
|
||||
self.assertIn('no launcher log', text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Host checks used by `darling-lol --doctor`; never starts the client or runtime."""
|
||||
import contextlib
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / 'tools/doctor.py'
|
||||
SPEC = importlib.util.spec_from_file_location('doctor', SCRIPT)
|
||||
doctor = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(doctor)
|
||||
|
||||
GIB = 1024 ** 3
|
||||
|
||||
|
||||
def facts(**overrides):
|
||||
base = {
|
||||
'host': {'distro': 'Test', 'kernel': '6.8.0', 'arch': 'x86_64', 'python': '3.12.0'},
|
||||
'session': {'type': 'wayland', 'desktop': 'Hyprland', 'display': ':0', 'wayland_display': 'wayland-1'},
|
||||
'memory': {'total': 16 * GIB, 'swap': 8 * GIB, 'total_gib': 16.0, 'swap_gib': 8.0},
|
||||
'disk': {'free': 100 * GIB, 'free_gib': 100.0},
|
||||
'gpus': [{'card': 'card0', 'driver': 'i915', 'vendor_id': '0x8086',
|
||||
'device_id': '0x9b41', 'name': 'UHD 620'}],
|
||||
'vulkan_icds': ['/usr/share/vulkan/icd.d/intel_icd.json'],
|
||||
'prefix': '/tmp/prefix',
|
||||
'prefix_exists': True,
|
||||
'prefix_launchd': True,
|
||||
'prefix_socket': True,
|
||||
'prefix_state': {'pid': 0, 'state': 'absent', 'stale_markers': 0},
|
||||
'psr': '0',
|
||||
'core_limit': '0',
|
||||
'environment': {},
|
||||
'tools': {},
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class CheckTests(unittest.TestCase):
|
||||
def test_kernel_rejects_old_release(self):
|
||||
self.assertEqual(doctor.check_kernel(facts(host={'kernel': '5.4.0'}))[0], 'warn')
|
||||
self.assertEqual(doctor.check_kernel(facts(host={'kernel': '6.8.0-arch1'}))[0], 'ok')
|
||||
self.assertEqual(doctor.check_kernel(facts(host={'kernel': 'unparsed'}))[0], 'warn')
|
||||
|
||||
def test_memory_requires_minimum(self):
|
||||
low = facts(memory={'total': 2 * GIB, 'swap': 0, 'total_gib': 2.0, 'swap_gib': 0.0})
|
||||
tight = facts(memory={'total': 6 * GIB, 'swap': 0, 'total_gib': 6.0, 'swap_gib': 0.0})
|
||||
self.assertEqual(doctor.check_memory(low)[0], 'fail')
|
||||
self.assertEqual(doctor.check_memory(tight)[0], 'warn')
|
||||
self.assertEqual(doctor.check_memory(facts())[0], 'ok')
|
||||
unknown = facts(memory={'total': 0, 'swap': 0, 'total_gib': 0.0, 'swap_gib': 0.0})
|
||||
self.assertEqual(doctor.check_memory(unknown)[0], 'ok')
|
||||
|
||||
def test_disk_space(self):
|
||||
self.assertEqual(doctor.check_disk(facts(disk={'free': 1 * GIB, 'free_gib': 1.0}))[0], 'fail')
|
||||
self.assertEqual(doctor.check_disk(facts(disk={'free': 10 * GIB, 'free_gib': 10.0}))[0], 'warn')
|
||||
self.assertEqual(doctor.check_disk(facts())[0], 'ok')
|
||||
|
||||
def test_gpu_driver_reports_card(self):
|
||||
status, message = doctor.check_gpu(facts())
|
||||
self.assertEqual(status, 'ok')
|
||||
self.assertIn('UHD 620 (i915)', message)
|
||||
self.assertEqual(doctor.check_gpu(facts(gpus=[]))[0], 'fail')
|
||||
nouveau = facts(gpus=[{'card': 'card0', 'driver': 'nouveau', 'vendor_id': '0x10de',
|
||||
'device_id': '0x1f91', 'name': 'GTX 1650'}])
|
||||
self.assertEqual(doctor.check_gpu(nouveau)[0], 'warn')
|
||||
|
||||
def test_vulkan_icds(self):
|
||||
self.assertEqual(doctor.check_vulkan(facts())[0], 'ok')
|
||||
status, message = doctor.check_vulkan(facts(vulkan_icds=[]))
|
||||
self.assertEqual(status, 'fail')
|
||||
self.assertIn('Mesa', message)
|
||||
|
||||
def test_session_needs_display_on_wayland(self):
|
||||
self.assertEqual(doctor.check_session(facts())[0], 'ok')
|
||||
missing = facts(session={'type': 'wayland', 'desktop': '', 'display': '', 'wayland_display': 'wayland-1'})
|
||||
self.assertEqual(doctor.check_session(missing)[0], 'fail')
|
||||
x11 = facts(session={'type': 'x11', 'desktop': 'i3', 'display': ':1', 'wayland_display': ''})
|
||||
self.assertEqual(doctor.check_session(x11)[0], 'ok')
|
||||
bare = facts(session={'type': '', 'desktop': '', 'display': '', 'wayland_display': ''})
|
||||
self.assertEqual(doctor.check_session(bare)[0], 'warn')
|
||||
|
||||
def test_mangohud_is_a_failure(self):
|
||||
self.assertEqual(doctor.check_hud(facts(environment={'MANGOHUD': '1'}))[0], 'fail')
|
||||
self.assertEqual(doctor.check_hud(facts(environment={'DISABLE_MANGOHUD': '1'}))[0], 'ok')
|
||||
self.assertEqual(doctor.check_hud(facts())[0], 'ok')
|
||||
|
||||
def test_core_dumps_and_psr(self):
|
||||
self.assertEqual(doctor.check_core_dumps(facts(core_limit='unlimited'))[0], 'warn')
|
||||
self.assertEqual(doctor.check_core_dumps(facts(core_limit='0'))[0], 'ok')
|
||||
self.assertEqual(doctor.check_psr(facts(psr='1'))[0], 'warn')
|
||||
self.assertEqual(doctor.check_psr(facts(psr='0'))[0], 'ok')
|
||||
|
||||
def test_prefix_must_be_installed(self):
|
||||
self.assertEqual(doctor.check_prefix(facts(prefix_exists=False))[0], 'fail')
|
||||
self.assertEqual(doctor.check_prefix(facts(prefix_launchd=False))[0], 'fail')
|
||||
self.assertEqual(doctor.check_prefix(facts())[0], 'ok')
|
||||
|
||||
def test_runtime_states(self):
|
||||
self.assertEqual(doctor.check_runtime(facts(prefix_state={'pid': 42, 'state': 'running',
|
||||
'stale_markers': 0}))[0], 'ok')
|
||||
self.assertEqual(doctor.check_runtime(facts(prefix_state={'pid': 42, 'state': 'stale',
|
||||
'stale_markers': 0}))[0], 'warn')
|
||||
self.assertEqual(doctor.check_runtime(facts(prefix_state={'pid': 0, 'state': 'absent',
|
||||
'stale_markers': 0}))[0], 'ok')
|
||||
|
||||
def test_stale_marker_count(self):
|
||||
self.assertEqual(doctor.check_stale_markers(
|
||||
facts(prefix_state={'pid': 0, 'state': 'absent', 'stale_markers': 2}))[0], 'ok')
|
||||
self.assertEqual(doctor.check_stale_markers(
|
||||
facts(prefix_state={'pid': 0, 'state': 'absent', 'stale_markers': 10}))[0], 'warn')
|
||||
|
||||
|
||||
class PrefixStateTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.folder.name)
|
||||
self.prefix = self.root / 'prefix'
|
||||
self.prefix.mkdir()
|
||||
self.proc = self.root / 'proc'
|
||||
self.proc.mkdir()
|
||||
|
||||
def tearDown(self):
|
||||
self.folder.cleanup()
|
||||
|
||||
def test_absent(self):
|
||||
self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['state'], 'absent')
|
||||
|
||||
def test_running(self):
|
||||
(self.prefix / '.init.pid').write_text('123\n')
|
||||
status = self.proc / '123'
|
||||
status.mkdir()
|
||||
(status / 'status').write_text('Name:\tdarlingserver\nState:\tS (sleeping)\n')
|
||||
self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['state'], 'running')
|
||||
|
||||
def test_zombie_and_missing_pid_are_stale(self):
|
||||
(self.prefix / '.init.pid').write_text('123\n')
|
||||
status = self.proc / '123'
|
||||
status.mkdir()
|
||||
(status / 'status').write_text('Name:\tdarlingserver\nState:\tZ (zombie)\n')
|
||||
self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['state'], 'stale')
|
||||
(status / 'status').unlink()
|
||||
status.rmdir()
|
||||
self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['state'], 'stale')
|
||||
|
||||
def test_stale_markers_are_counted(self):
|
||||
(self.prefix / '.init.pid.stale-1-2').write_text('1')
|
||||
(self.prefix / '.init.pid.stale-3-4').write_text('3')
|
||||
self.assertEqual(doctor.prefix_state(self.prefix, self.proc)['stale_markers'], 2)
|
||||
|
||||
|
||||
class GpuAndVulkanTests(unittest.TestCase):
|
||||
def test_read_gpus_follows_driver_link(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
sys_root = Path(folder)
|
||||
card = sys_root / 'class/drm/card0/device'
|
||||
card.mkdir(parents=True)
|
||||
(card / 'vendor').write_text('0x8086\n')
|
||||
(card / 'device').write_text('0x9b41\n')
|
||||
(card / 'product_name').write_text('Intel UHD 620\n')
|
||||
drivers = sys_root / 'drivers/i915'
|
||||
drivers.mkdir(parents=True)
|
||||
(card / 'driver').symlink_to(drivers)
|
||||
gpus = doctor.read_gpus(sys_root)
|
||||
self.assertEqual(gpus, [{'card': 'card0', 'driver': 'i915', 'vendor_id': '0x8086',
|
||||
'device_id': '0x9b41', 'name': 'Intel UHD 620'}])
|
||||
|
||||
def test_vulkan_icds_deduplicate(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
icd = Path(folder) / 'vulkan/icd.d'
|
||||
icd.mkdir(parents=True)
|
||||
(icd / 'intel.json').write_text('{}')
|
||||
found = doctor.vulkan_icds({'XDG_DATA_DIRS': folder})
|
||||
self.assertIn(str(icd / 'intel.json'), found)
|
||||
self.assertEqual(len(found), len(set(found)))
|
||||
|
||||
|
||||
class ReportTests(unittest.TestCase):
|
||||
def test_run_checks_isolates_a_broken_probe(self):
|
||||
results = doctor.run_checks({'host': {}})
|
||||
by_id = {result['id']: result for result in results}
|
||||
self.assertEqual(len(results), len(doctor.CHECKS))
|
||||
self.assertEqual(by_id['kernel']['status'], 'warn')
|
||||
self.assertIn('could not run', by_id['kernel']['message'])
|
||||
|
||||
def test_summary_counts(self):
|
||||
results = [{'status': 'ok'}, {'status': 'ok'}, {'status': 'warn'}, {'status': 'fail'}]
|
||||
self.assertEqual(doctor.summarize(results), {'ok': 2, 'warn': 1, 'fail': 1})
|
||||
|
||||
def test_report_has_no_environment_values(self):
|
||||
data = facts(environment={'DISPLAY': ':0', 'RCLOL_REGION': 'EUW'})
|
||||
document = doctor.report(data, doctor.run_checks(data))
|
||||
self.assertEqual(document['schema'], doctor.SCHEMA)
|
||||
self.assertEqual(document['environment'], {'DISPLAY': ':0', 'RCLOL_REGION': 'EUW'})
|
||||
self.assertNotIn('MANGOHUD', document['environment'])
|
||||
|
||||
def test_cli_writes_json(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
prefix = Path(folder) / 'prefix'
|
||||
prefix.mkdir()
|
||||
out = Path(folder) / 'doctor.json'
|
||||
stream = io.StringIO()
|
||||
with contextlib.redirect_stdout(stream):
|
||||
code = doctor.main(['--prefix', str(prefix), '--report', str(out)])
|
||||
self.assertIn(code, (0, 1))
|
||||
document = json.loads(out.read_text())
|
||||
self.assertEqual(document['tool'], 'darling-lol doctor')
|
||||
self.assertIn('checks', document)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Frame-pacing baseline from League's own netstats.csv logs; reads files only."""
|
||||
import contextlib
|
||||
import csv
|
||||
import importlib.util
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = ROOT / 'tools/perf-baseline.py'
|
||||
SPEC = importlib.util.spec_from_file_location('perf_baseline', SCRIPT)
|
||||
perf = importlib.util.module_from_spec(SPEC)
|
||||
SPEC.loader.exec_module(perf)
|
||||
|
||||
HEADER = ['game.frametime_average', 'game.frametime_variance', 'game.time',
|
||||
'network.rtt_average', 'network.packets_lost']
|
||||
|
||||
|
||||
def write_match(folder, frametimes, name='2026-10-23T17-03-29'):
|
||||
target = Path(folder) / name / ('%s_netstats.csv' % name)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(target, 'w', newline='') as handle:
|
||||
writer = csv.writer(handle)
|
||||
writer.writerow(HEADER)
|
||||
for index, frametime in enumerate(frametimes):
|
||||
writer.writerow([frametime, 0.001, index * 10.0, 30.0, 0])
|
||||
return target
|
||||
|
||||
|
||||
class MeasureTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.folder.name)
|
||||
|
||||
def tearDown(self):
|
||||
self.folder.cleanup()
|
||||
|
||||
def test_metrics(self):
|
||||
path = write_match(self.root, [0.03] * 6 + [0.06, 0.09])
|
||||
result = perf.measure(path)
|
||||
self.assertEqual(result['match'], '2026-10-23 17:03')
|
||||
self.assertEqual(result['samples'], 8)
|
||||
self.assertEqual(result['seconds'], 70.0)
|
||||
self.assertEqual(result['avg_fps'], 24.2)
|
||||
self.assertEqual(result['p50_ms'], 30.0)
|
||||
self.assertEqual(result['p95_ms'], 90.0)
|
||||
self.assertEqual(result['slowest_quarter_fps'], 13.3)
|
||||
self.assertEqual(result['slowest_quarter_ms'], 75.0)
|
||||
self.assertEqual(result['hitches'], 2)
|
||||
|
||||
def test_bad_and_empty_rows_are_skipped(self):
|
||||
path = self.root / '2026-01-01T00-00-00' / '2026-01-01T00-00-00_netstats.csv'
|
||||
path.parent.mkdir()
|
||||
path.write_text(','.join(HEADER) + '\n'
|
||||
',0,0,0,0\n'
|
||||
'0.000,0,0,0,0\n'
|
||||
'nan,0,0,0,0\n'
|
||||
'0.050,0,10,0,0\n'
|
||||
'oops,0,20,0,0\n')
|
||||
result = perf.measure(path)
|
||||
self.assertEqual(result['samples'], 1)
|
||||
self.assertEqual(result['avg_fps'], 20.0)
|
||||
|
||||
def test_rows_without_frametime_return_none(self):
|
||||
path = self.root / '2026-01-01T00-00-00' / '2026-01-01T00-00-00_netstats.csv'
|
||||
path.parent.mkdir()
|
||||
path.write_text(','.join(HEADER) + '\n')
|
||||
self.assertIsNone(perf.measure(path))
|
||||
|
||||
def test_percentile_bounds(self):
|
||||
self.assertEqual(perf.percentile([], 0.5), 0.0)
|
||||
self.assertEqual(perf.percentile([3, 1, 2], 0.0), 1)
|
||||
self.assertEqual(perf.percentile([3, 1, 2], 0.5), 2)
|
||||
self.assertEqual(perf.percentile([3, 1, 2], 1.0), 3)
|
||||
|
||||
def test_match_label_falls_back_to_folder(self):
|
||||
self.assertEqual(perf.match_label('/tmp/odd-folder/file.csv'), 'odd-folder')
|
||||
|
||||
|
||||
class DiscoveryTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.prefix = Path(self.folder.name)
|
||||
self.logs = self.prefix / perf.DEFAULT_GAME_LOGS
|
||||
self.old = write_match(self.logs, [0.03], '2026-10-21T10-00-00')
|
||||
self.new = write_match(self.logs, [0.03], '2026-10-23T10-00-00')
|
||||
os.utime(self.old, (1000, 1000))
|
||||
os.utime(self.new, (2000, 2000))
|
||||
unrelated = self.logs / '2026-10-22T10-00-00' / 'other.csv'
|
||||
unrelated.parent.mkdir(parents=True)
|
||||
unrelated.write_text('x')
|
||||
|
||||
def tearDown(self):
|
||||
self.folder.cleanup()
|
||||
|
||||
def test_newest_first_and_only_netstats(self):
|
||||
self.assertEqual(perf.discover(self.prefix), [self.new, self.old])
|
||||
|
||||
|
||||
class OutputTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.folder = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self.folder.name)
|
||||
self.path = write_match(self.root, [0.04, 0.04, 0.04, 0.08])
|
||||
self.result = perf.measure(self.path)
|
||||
|
||||
def tearDown(self):
|
||||
self.folder.cleanup()
|
||||
|
||||
def test_render_table(self):
|
||||
text = perf.render([self.result])
|
||||
self.assertIn('avgfps', text)
|
||||
self.assertIn('2026-10-23 17:03', text)
|
||||
self.assertIn('hitch', text)
|
||||
|
||||
def test_render_empty_explains_expected_path(self):
|
||||
text = perf.render([])
|
||||
self.assertIn('No netstats.csv files found', text)
|
||||
self.assertIn('Applications/League of Legends.app', text)
|
||||
|
||||
def test_append_writes_header_once(self):
|
||||
path = self.root / 'baseline.csv'
|
||||
perf.append_csv([self.result], path)
|
||||
perf.append_csv([self.result], path)
|
||||
with open(path, newline='') as handle:
|
||||
rows = list(csv.DictReader(handle))
|
||||
self.assertEqual(len(rows), 2)
|
||||
self.assertEqual(rows[0]['match'], '2026-10-23 17:03')
|
||||
self.assertEqual(rows[0]['avg_fps'], '20.0')
|
||||
self.assertEqual(path.read_text().count('measured,match'), 1)
|
||||
|
||||
def test_json_cli(self):
|
||||
stream = io.StringIO()
|
||||
with contextlib.redirect_stdout(stream):
|
||||
code = perf.main(['--csv', str(self.path), '--json'])
|
||||
self.assertEqual(code, 0)
|
||||
document = json.loads(stream.getvalue())
|
||||
self.assertEqual(document['matches'][0]['samples'], 4)
|
||||
|
||||
def test_cli_without_matches_returns_one(self):
|
||||
missing = self.root / 'missing.csv'
|
||||
with contextlib.redirect_stdout(io.StringIO()):
|
||||
self.assertEqual(perf.main(['--csv', str(missing)]), 1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bundle the diagnostics needed to explain a Darling/League crash or hang.
|
||||
|
||||
The bundle never starts or stops anything. It collects the host report from
|
||||
`doctor.py`, the Riot/Darling process table, the tail of the runtime and
|
||||
launcher logs, recent kernel messages and the last core dumps, and writes one
|
||||
gzipped tarball that can be attached to an issue.
|
||||
|
||||
Home directory, user name and host name are replaced by placeholders by
|
||||
default; environment variables are an allowlist, so tokens are never included.
|
||||
|
||||
Usage:
|
||||
python3 tools/crash-report.py
|
||||
python3 tools/crash-report.py --out ~/reports --prefix ~/.darling
|
||||
python3 tools/crash-report.py --no-redact --json
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
TOOLS = Path(__file__).resolve().parent
|
||||
if str(TOOLS) not in sys.path:
|
||||
sys.path.insert(0, str(TOOLS))
|
||||
|
||||
import doctor # noqa: E402 (same directory as this script)
|
||||
|
||||
LOG_TAIL_LINES = 4000
|
||||
KERNEL_PATTERN = re.compile(
|
||||
r'i915|amdgpu|radeon|\bxe\b|drm|vulkan|GPU HANG|out of memory|oom-kill|segfault|traps:', re.I)
|
||||
PROCESS_NAMES = (
|
||||
'darlingserver', 'mldr', 'launchd', 'RiotClientServices', 'LeagueClientUx',
|
||||
'LeagueClient', 'League of Legends', 'CrBrowserMain', 'CrGpuMain', 'CrRendererMain',
|
||||
'CrUtilityMain', 'wine', 'gamescope', 'Xwayland',
|
||||
)
|
||||
|
||||
|
||||
def read_tail(path, limit=LOG_TAIL_LINES):
|
||||
try:
|
||||
with open(path, 'r', errors='replace') as handle:
|
||||
lines = handle.readlines()
|
||||
except OSError as error:
|
||||
return None, str(error)
|
||||
return ''.join(lines[-limit:]), None
|
||||
|
||||
|
||||
def process_snapshot(proc_root=Path('/proc')):
|
||||
"""`ps`-like table for this user plus every zombie, without calling ps."""
|
||||
rows = []
|
||||
uid = os.getuid()
|
||||
for entry in Path(proc_root).iterdir():
|
||||
if not entry.name.isdigit():
|
||||
continue
|
||||
try:
|
||||
stat = (entry / 'stat').read_text()
|
||||
# Field 22 onwards follows the last ')' because comm may contain spaces.
|
||||
rest = stat.rsplit(')', 1)[1].split()
|
||||
state = rest[0]
|
||||
ppid = int(rest[1])
|
||||
owner = entry.stat().st_uid
|
||||
if owner != uid and state != 'Z':
|
||||
continue
|
||||
comm = (entry / 'comm').read_text().strip()
|
||||
argv = (entry / 'cmdline').read_bytes().replace(b'\0', b' ').decode(errors='replace').strip()
|
||||
except (OSError, ValueError, IndexError):
|
||||
continue
|
||||
rows.append({
|
||||
'pid': int(entry.name),
|
||||
'ppid': ppid,
|
||||
'state': state,
|
||||
'comm': comm,
|
||||
'argv': argv[:300],
|
||||
})
|
||||
return sorted(rows, key=lambda row: (row['pid']))
|
||||
|
||||
|
||||
def relevant_processes(rows):
|
||||
"""Processes worth reading first: the runtime, the client and leftovers."""
|
||||
selected = []
|
||||
for row in rows:
|
||||
if row['state'] == 'Z':
|
||||
selected.append(row)
|
||||
elif any(name in row['comm'] or name in row['argv'] for name in PROCESS_NAMES):
|
||||
selected.append(row)
|
||||
return selected
|
||||
|
||||
|
||||
def format_processes(rows, relevant):
|
||||
lines = ['# all processes owned by this user, plus zombies',
|
||||
'pid\tppid\tstate\tcomm\targv']
|
||||
for row in rows:
|
||||
lines.append('%s\t%s\t%s\t%s\t%s' % (row['pid'], row['ppid'], row['state'], row['comm'], row['argv']))
|
||||
lines.append('')
|
||||
lines.append('# runtime, client and zombie processes')
|
||||
for row in relevant:
|
||||
lines.append('%s\t%s\t%s\t%s\t%s' % (row['pid'], row['ppid'], row['state'], row['comm'], row['argv']))
|
||||
return '\n'.join(lines) + '\n'
|
||||
|
||||
|
||||
def run_command(command, timeout=10):
|
||||
try:
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=timeout, check=False)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
return None, str(error)
|
||||
if result.returncode != 0:
|
||||
return (result.stdout or ''), (result.stderr or 'exit %d' % result.returncode).strip()
|
||||
return result.stdout, None
|
||||
|
||||
|
||||
def kernel_messages(which):
|
||||
if not which('dmesg'):
|
||||
return '# dmesg is not installed; kernel messages are unavailable\n'
|
||||
output, error = run_command(['dmesg', '--ctime'])
|
||||
if error and not output:
|
||||
return '# dmesg is not readable: %s\n' % error
|
||||
lines = [line for line in output.splitlines() if KERNEL_PATTERN.search(line)]
|
||||
return '\n'.join(lines[-300:]) + '\n' if lines else '# no graphics-related kernel messages\n'
|
||||
|
||||
|
||||
def coredumps(which):
|
||||
if not which('coredumpctl'):
|
||||
return '# coredumpctl is not installed\n'
|
||||
output, error = run_command(['coredumpctl', '--no-pager', 'list', '-n', '10'])
|
||||
if error and not output:
|
||||
return '# coredumpctl failed: %s\n' % error
|
||||
return output or '# no core dumps\n'
|
||||
|
||||
|
||||
def display_state(which, env):
|
||||
if env.get('WAYLAND_DISPLAY') and which('hyprctl'):
|
||||
output, error = run_command(['hyprctl', 'monitors', '-j'])
|
||||
clients, client_error = run_command(['hyprctl', 'clients', '-j'])
|
||||
return '# hyprctl monitors\n%s\n# hyprctl clients\n%s\n%s' % (
|
||||
output or error, clients or client_error, '')
|
||||
if which('xrandr') and env.get('DISPLAY'):
|
||||
output, error = run_command(['xrandr', '--query'])
|
||||
if output:
|
||||
return output
|
||||
return '# no display tool available; DISPLAY=%r WAYLAND_DISPLAY=%r\n' % (
|
||||
env.get('DISPLAY'), env.get('WAYLAND_DISPLAY'))
|
||||
|
||||
|
||||
def scrub(text, home=None, username=None, hostname=None):
|
||||
if not text:
|
||||
return text
|
||||
replacements = [
|
||||
(str(home or Path.home()), '~'),
|
||||
(username or os.environ.get('USER') or '', '<user>'),
|
||||
(hostname or socket.gethostname(), '<host>'),
|
||||
]
|
||||
for value, placeholder in replacements:
|
||||
if value:
|
||||
text = text.replace(value, placeholder)
|
||||
return text
|
||||
|
||||
|
||||
def bundle_name(label=None):
|
||||
stamp = time.strftime('%Y%m%d-%H%M%S', time.localtime())
|
||||
return 'lol-report-%s%s.tar.gz' % (stamp, '-' + label if label else '')
|
||||
|
||||
|
||||
def build(prefix, out_dir, launch_log=None, label=None, redact=True,
|
||||
proc_root=Path('/proc'), which=None, env=None):
|
||||
"""Write the bundle and return its path and manifest."""
|
||||
env = dict(os.environ if env is None else env)
|
||||
which = which or shutil.which
|
||||
prefix = Path(prefix or env.get('DPREFIX') or (Path.home() / '.darling')).expanduser()
|
||||
out_dir = Path(out_dir).expanduser()
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive = out_dir / bundle_name(label)
|
||||
|
||||
facts = doctor.gather(prefix=prefix, env=env, proc_root=proc_root, which=which)
|
||||
checks = doctor.run_checks(facts)
|
||||
rows = process_snapshot(proc_root)
|
||||
relevant = relevant_processes(rows)
|
||||
|
||||
dserver_log = Path(prefix) / 'private/var/log/dserver.log'
|
||||
log_text, log_error = read_tail(dserver_log)
|
||||
launch_text, launch_error = (read_tail(launch_log) if launch_log else (None, 'no launcher log'))
|
||||
|
||||
entries = {
|
||||
'processes.txt': format_processes(rows, relevant),
|
||||
'logs/dserver.log.tail': log_text or '# unavailable: %s\n' % log_error,
|
||||
'logs/launch.log.tail': launch_text or '# unavailable: %s\n' % launch_error,
|
||||
'logs/kernel.txt': kernel_messages(which),
|
||||
'display.txt': display_state(which, env),
|
||||
'coredumps.txt': coredumps(which),
|
||||
}
|
||||
if redact:
|
||||
entries = {name: scrub(text, username=env.get('USER')) for name, text in entries.items()}
|
||||
checks = [
|
||||
dict(check, title=scrub(check['title'], username=env.get('USER')),
|
||||
message=scrub(check['message'], username=env.get('USER')))
|
||||
for check in checks
|
||||
]
|
||||
|
||||
manifest = {
|
||||
'schema': doctor.SCHEMA,
|
||||
'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'host': {key: facts['host'][key] for key in ('distro', 'kernel', 'arch')},
|
||||
'python': platform.python_version(),
|
||||
'prefix': '<prefix>' if redact else facts['prefix'],
|
||||
'redacted': redact,
|
||||
'doctor': {'summary': doctor.summarize(checks), 'checks': checks},
|
||||
'processes': {'total': len(rows), 'relevant': len(relevant), 'zombies': sum(1 for row in rows if row['state'] == 'Z')},
|
||||
'files': sorted(entries),
|
||||
}
|
||||
if redact:
|
||||
manifest['host']['distro'] = scrub(facts['host']['distro'], username=env.get('USER'))
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix='lol-report-') as temporary:
|
||||
root = Path(temporary)
|
||||
(root / 'logs').mkdir()
|
||||
(root / 'report.json').write_text(json.dumps(manifest, indent=2) + '\n')
|
||||
for name, text in entries.items():
|
||||
(root / name).write_text(text)
|
||||
(root / 'README.txt').write_text(readme(manifest))
|
||||
with tarfile.open(archive, 'w:gz') as tar:
|
||||
for path in sorted(root.rglob('*')):
|
||||
if path.is_file():
|
||||
tar.add(path, arcname=str(Path('lol-report') / path.relative_to(root)))
|
||||
|
||||
return archive, manifest
|
||||
|
||||
|
||||
def readme(manifest):
|
||||
return '''darling-lol crash report
|
||||
========================
|
||||
|
||||
Created: %(created)s
|
||||
Host: %(distro)s, kernel %(kernel)s (%(arch)s)
|
||||
Prefix: %(prefix)s
|
||||
Redacted: %(redacted)s
|
||||
|
||||
What is inside:
|
||||
- report.json: the doctor checks, host summary and process counts
|
||||
- processes.txt: this user's processes plus every zombie, and the subset that is
|
||||
part of the Darling runtime or the Riot/League client
|
||||
- logs/dserver.log.tail: the last %(lines)d lines of the runtime log
|
||||
- logs/launch.log.tail: the last lines the launcher wrote
|
||||
- logs/kernel.txt: graphics-related kernel messages
|
||||
- display.txt: monitors and windows (or an explanation when unavailable)
|
||||
- coredumps.txt: recent systemd-coredump entries, if any
|
||||
|
||||
Tokens, passwords and other environment secrets are not collected. Home
|
||||
directory, user name and host name are replaced with placeholders unless the
|
||||
bundle was created with --no-redact.
|
||||
''' % {
|
||||
'created': manifest['created'],
|
||||
'distro': manifest['host']['distro'],
|
||||
'kernel': manifest['host']['kernel'],
|
||||
'arch': manifest['host']['arch'],
|
||||
'prefix': manifest['prefix'],
|
||||
'redacted': manifest['redacted'],
|
||||
'lines': LOG_TAIL_LINES,
|
||||
}
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Bundle Darling/League crash and hang diagnostics.')
|
||||
parser.add_argument('--prefix', type=Path, help='Darling prefix (default: DPREFIX or ~/.darling)')
|
||||
parser.add_argument('--out', type=Path,
|
||||
default=Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'darling-lol/reports',
|
||||
help='directory for the tarball (default: ~/.cache/darling-lol/reports)')
|
||||
parser.add_argument('--launch-log', type=Path, help='launcher log to include')
|
||||
parser.add_argument('--label', help='short label added to the file name')
|
||||
parser.add_argument('--no-redact', action='store_true', help='keep the real home path, user and host name')
|
||||
parser.add_argument('--json', action='store_true', help='print the manifest')
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
launch_log = args.launch_log
|
||||
if launch_log is None:
|
||||
candidate = Path(__file__).resolve().parents[1] / 'build/lol-launch.log'
|
||||
launch_log = candidate if candidate.is_file() else None
|
||||
|
||||
archive, manifest = build(
|
||||
prefix=args.prefix,
|
||||
out_dir=args.out,
|
||||
launch_log=launch_log,
|
||||
label=args.label,
|
||||
redact=not args.no_redact,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(manifest, indent=2))
|
||||
print('Report: %s (%s)' % (archive, human_size(archive.stat().st_size)))
|
||||
summary = manifest['doctor']['summary']
|
||||
print('%d ok, %d warning(s), %d failure(s); %d zombie process(es)' % (
|
||||
summary['ok'], summary['warn'], summary['fail'], manifest['processes']['zombies']))
|
||||
print('Attach this file to the issue; it contains no tokens or environment secrets.')
|
||||
return 0
|
||||
|
||||
|
||||
def human_size(size):
|
||||
for unit in ('B', 'KiB', 'MiB'):
|
||||
if size < 1024:
|
||||
return '%.0f %s' % (size, unit)
|
||||
size /= 1024
|
||||
return '%.1f GiB' % size
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+431
@@ -0,0 +1,431 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check whether this host can run darling-lol, and write a report to attach to an issue.
|
||||
|
||||
The checks never start the game, the client or the runtime. They read host
|
||||
information, the prefix and the environment, so the command is safe to run at
|
||||
any time, including while a client is open.
|
||||
|
||||
Usage:
|
||||
python3 tools/doctor.py # human-readable summary
|
||||
python3 tools/doctor.py --json # machine-readable report
|
||||
python3 tools/doctor.py --report FILE # write the JSON report to FILE
|
||||
|
||||
Exit status is 1 when a required check fails; warnings do not fail.
|
||||
"""
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
SCHEMA = 1
|
||||
# League of Legends, the Riot Client and shader caches need room to grow. A
|
||||
# checkout/build needs much more, but that is reported by the installer.
|
||||
MIN_FREE_DISK_GIB = 5
|
||||
WARN_FREE_DISK_GIB = 15
|
||||
MIN_RAM_GIB = 4
|
||||
WARN_RAM_GIB = 8
|
||||
MIN_KERNEL = (5, 10)
|
||||
# A report is public when it is attached to an issue, so only the variables
|
||||
# that describe the runtime environment are collected.
|
||||
ENV_ALLOWLIST = (
|
||||
'XDG_SESSION_TYPE',
|
||||
'XDG_CURRENT_DESKTOP',
|
||||
'WAYLAND_DISPLAY',
|
||||
'DISPLAY',
|
||||
'MANGOHUD',
|
||||
'DISABLE_MANGOHUD',
|
||||
'RCLOL_REGION',
|
||||
'RCLOL_LOCALE',
|
||||
'RCLOL_CDN_IPV4_ONLY',
|
||||
'RCLOL_RECOVER_STALE_CLIENT',
|
||||
'RCLOL_CLIENT_IN_GAME_TRICKS',
|
||||
'DPREFIX',
|
||||
'DARLING_WORKSPACE',
|
||||
)
|
||||
|
||||
|
||||
def read_text(path, default=''):
|
||||
try:
|
||||
return Path(path).read_text()
|
||||
except OSError:
|
||||
return default
|
||||
|
||||
|
||||
def os_release(path='/etc/os-release'):
|
||||
values = {}
|
||||
for line in read_text(path).splitlines():
|
||||
if '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
values[key] = value.strip().strip('"')
|
||||
return values
|
||||
|
||||
|
||||
def meminfo(path='/proc/meminfo'):
|
||||
values = {}
|
||||
for line in read_text(path).splitlines():
|
||||
match = re.match(r'(\w+):\s+(\d+)', line)
|
||||
if match:
|
||||
values[match.group(1)] = int(match.group(2)) * 1024
|
||||
return values
|
||||
|
||||
|
||||
def gib(value):
|
||||
return round(value / (1024 ** 3), 1)
|
||||
|
||||
|
||||
def kernel_tuple(release):
|
||||
numbers = re.findall(r'^\d+(?:\.\d+)*', release)
|
||||
if not numbers:
|
||||
return (0,)
|
||||
return tuple(int(part) for part in numbers[0].split('.'))
|
||||
|
||||
|
||||
def read_gpus(sys_root=Path('/sys')):
|
||||
"""Return one entry per DRM card with its driver and PCI identity."""
|
||||
cards = []
|
||||
drm = Path(sys_root) / 'class/drm'
|
||||
try:
|
||||
entries = sorted(drm.iterdir())
|
||||
except OSError:
|
||||
return cards
|
||||
for entry in entries:
|
||||
if not re.fullmatch(r'card\d+', entry.name):
|
||||
continue
|
||||
device = entry / 'device'
|
||||
driver = ''
|
||||
try:
|
||||
driver = (device / 'driver').resolve().name
|
||||
except OSError:
|
||||
pass
|
||||
vendor = read_text(device / 'vendor').strip()
|
||||
product = read_text(device / 'device').strip()
|
||||
name = ''
|
||||
for candidate in (device / 'product_name', entry / 'product_name'):
|
||||
name = read_text(candidate).strip()
|
||||
if name:
|
||||
break
|
||||
cards.append({
|
||||
'card': entry.name,
|
||||
'driver': driver,
|
||||
'vendor_id': vendor,
|
||||
'device_id': product,
|
||||
'name': name,
|
||||
})
|
||||
return cards
|
||||
|
||||
|
||||
def vulkan_icds(env=None):
|
||||
"""Vulkan ICD manifests the loader can see, including Nix's driver store."""
|
||||
env = env or os.environ
|
||||
search = [
|
||||
'/usr/share/vulkan/icd.d',
|
||||
'/usr/local/share/vulkan/icd.d',
|
||||
'/run/opengl-driver/share/vulkan/icd.d',
|
||||
'/etc/vulkan/icd.d',
|
||||
os.path.join(env.get('XDG_DATA_DIRS', ''), 'vulkan/icd.d') if env.get('XDG_DATA_DIRS') else None,
|
||||
]
|
||||
found = []
|
||||
for folder in filter(None, search):
|
||||
try:
|
||||
names = sorted(p.name for p in Path(folder).glob('*.json'))
|
||||
except OSError:
|
||||
names = []
|
||||
for name in names:
|
||||
item = os.path.join(folder, name)
|
||||
if item not in found:
|
||||
found.append(item)
|
||||
return found
|
||||
|
||||
|
||||
def prefix_state(prefix, proc_root=Path('/proc')):
|
||||
marker = Path(prefix) / '.init.pid'
|
||||
state = 'absent'
|
||||
pid = 0
|
||||
try:
|
||||
pid = int(marker.read_text().strip())
|
||||
except (OSError, ValueError):
|
||||
pid = 0
|
||||
if pid > 0:
|
||||
status = Path(proc_root) / str(pid) / 'status'
|
||||
text = read_text(status)
|
||||
current = re.search(r'^State:\s+(\S)', text, re.M)
|
||||
if not text:
|
||||
state = 'stale'
|
||||
elif current and current.group(1) == 'Z':
|
||||
state = 'stale'
|
||||
else:
|
||||
state = 'running'
|
||||
stale_markers = len(list(Path(prefix).glob('.init.pid.stale-*')))
|
||||
return {'pid': pid, 'state': state, 'stale_markers': stale_markers}
|
||||
|
||||
|
||||
def gather(prefix=None, env=None, proc_root=Path('/proc'), sys_root=Path('/sys'), which=None):
|
||||
"""Collect facts once; checks below only interpret them and stay testable."""
|
||||
env = dict(os.environ if env is None else env)
|
||||
which = which or shutil.which
|
||||
prefix = Path(prefix or env.get('DPREFIX') or (Path.home() / '.darling')).expanduser()
|
||||
|
||||
release = os_release()
|
||||
memory = meminfo()
|
||||
total = memory.get('MemTotal', 0)
|
||||
swap = memory.get('SwapTotal', 0)
|
||||
try:
|
||||
free_disk = shutil.disk_usage(prefix if prefix.exists() else prefix.parent).free
|
||||
except OSError:
|
||||
free_disk = 0
|
||||
|
||||
socket = prefix / '.darlingserver.sock'
|
||||
runtime_bin = Path('/usr/local/libexec/darling')
|
||||
return {
|
||||
'host': {
|
||||
'distro': release.get('PRETTY_NAME') or platform.system(),
|
||||
'kernel': platform.release(),
|
||||
'arch': platform.machine(),
|
||||
'python': platform.python_version(),
|
||||
},
|
||||
'session': {
|
||||
'type': env.get('XDG_SESSION_TYPE', ''),
|
||||
'desktop': env.get('XDG_CURRENT_DESKTOP', ''),
|
||||
'display': env.get('DISPLAY', ''),
|
||||
'wayland_display': env.get('WAYLAND_DISPLAY', ''),
|
||||
},
|
||||
'memory': {
|
||||
'total': total,
|
||||
'swap': swap,
|
||||
'total_gib': gib(total),
|
||||
'swap_gib': gib(swap),
|
||||
},
|
||||
'disk': {'free': free_disk, 'free_gib': gib(free_disk)},
|
||||
'gpus': read_gpus(sys_root),
|
||||
'vulkan_icds': vulkan_icds(env),
|
||||
'prefix': str(prefix),
|
||||
'prefix_exists': prefix.is_dir(),
|
||||
'prefix_launchd': (runtime_bin / 'sbin/launchd').is_file(),
|
||||
'prefix_socket': socket.exists(),
|
||||
'prefix_state': prefix_state(prefix, proc_root),
|
||||
'psr': read_text(sys_root / 'module/i915/parameters/enable_psr').strip(),
|
||||
'core_limit': _core_limit(),
|
||||
'environment': {key: env[key] for key in ENV_ALLOWLIST if key in env},
|
||||
'tools': {name: which(name) for name in ('xprop', 'xdpyinfo', 'vulkaninfo', 'dmesg', 'coredumpctl')},
|
||||
}
|
||||
|
||||
|
||||
def _core_limit():
|
||||
try:
|
||||
import resource
|
||||
soft, _ = resource.getrlimit(resource.RLIMIT_CORE)
|
||||
return 'unlimited' if soft == resource.RLIM_INFINITY else str(soft)
|
||||
except (ImportError, OSError, ValueError):
|
||||
return 'unknown'
|
||||
|
||||
|
||||
def check_kernel(facts):
|
||||
if kernel_tuple(facts['host']['kernel']) < MIN_KERNEL:
|
||||
return 'warn', 'Kernel %s is older than 5.10; Vulkan and DRM fixes may be missing' % facts['host']['kernel']
|
||||
return 'ok', 'Kernel %s' % facts['host']['kernel']
|
||||
|
||||
|
||||
def check_memory(facts):
|
||||
total = facts['memory']['total']
|
||||
if total and total < MIN_RAM_GIB * 1024 ** 3:
|
||||
return 'fail', 'Only %s GiB RAM; the client and game need more' % facts['memory']['total_gib']
|
||||
if total and total < WARN_RAM_GIB * 1024 ** 3:
|
||||
return 'warn', '%s GiB RAM is tight; close other applications' % facts['memory']['total_gib']
|
||||
return 'ok', '%s GiB RAM, %s GiB swap' % (facts['memory']['total_gib'], facts['memory']['swap_gib'])
|
||||
|
||||
|
||||
def check_disk(facts):
|
||||
free = facts['disk']['free']
|
||||
if free < MIN_FREE_DISK_GIB * 1024 ** 3:
|
||||
return 'fail', 'Only %s GiB free; the client needs a few GiB plus patches' % facts['disk']['free_gib']
|
||||
if free < WARN_FREE_DISK_GIB * 1024 ** 3:
|
||||
return 'warn', '%s GiB free; large patches and shader caches need room' % facts['disk']['free_gib']
|
||||
return 'ok', '%s GiB free' % facts['disk']['free_gib']
|
||||
|
||||
|
||||
def check_gpu(facts):
|
||||
gpus = facts['gpus']
|
||||
if not gpus:
|
||||
return 'fail', 'No DRM card found; Darling needs a GPU with Vulkan'
|
||||
described = []
|
||||
for gpu in gpus:
|
||||
driver = gpu['driver'] or 'unknown driver'
|
||||
name = gpu['name'] or 'GPU %s:%s' % (gpu['vendor_id'], gpu['device_id'])
|
||||
described.append('%s (%s)' % (name, driver))
|
||||
if any(gpu['driver'] == 'nouveau' for gpu in gpus):
|
||||
return 'warn', ', '.join(described) + '; nouveau has limited Vulkan support'
|
||||
return 'ok', ', '.join(described)
|
||||
|
||||
|
||||
def check_vulkan(facts):
|
||||
icds = facts['vulkan_icds']
|
||||
if not icds:
|
||||
return 'fail', 'No Vulkan ICD found; install the Mesa Vulkan driver (vulkan-radeon or vulkan-intel)'
|
||||
names = ', '.join(Path(item).name for item in icds)
|
||||
return 'ok', names
|
||||
|
||||
|
||||
def check_session(facts):
|
||||
session = facts['session']
|
||||
kind = (session['type'] or '').lower()
|
||||
if kind == 'wayland' and not session['display']:
|
||||
return 'fail', 'Wayland session without DISPLAY; the client needs Xwayland'
|
||||
if kind == 'wayland':
|
||||
return 'ok', 'Wayland through Xwayland (%s)' % session['display']
|
||||
if session['display']:
|
||||
return 'ok', 'X11 (%s)' % session['display']
|
||||
return 'warn', 'No X11 DISPLAY; start the launcher from a desktop session'
|
||||
|
||||
|
||||
def check_hud(facts):
|
||||
environment = facts['environment']
|
||||
if environment.get('DISABLE_MANGOHUD') == '1':
|
||||
return 'ok', 'MangoHud layer disabled for this launcher'
|
||||
if environment.get('MANGOHUD'):
|
||||
return 'fail', ('MANGOHUD is set; the overlay reads host /proc entries with guest PIDs and can abort '
|
||||
'the client (issue #38). Unset it and set DISABLE_MANGOHUD=1')
|
||||
return 'ok', 'No inherited MangoHud layer'
|
||||
|
||||
|
||||
def check_core_dumps(facts):
|
||||
if facts['core_limit'] == 'unlimited':
|
||||
return 'warn', 'Core dumps are unlimited; a crashing guest can fill the disk'
|
||||
return 'ok', 'Core dumps are bounded'
|
||||
|
||||
|
||||
def check_psr(facts):
|
||||
if facts['psr'] == '1':
|
||||
return 'warn', ('i915 panel self-refresh is on; this can flicker or hitch the client. '
|
||||
'Set options i915 enable_psr=0 in /etc/modprobe.d and reboot')
|
||||
return 'ok', 'Panel self-refresh is off or not applicable'
|
||||
|
||||
|
||||
def check_prefix(facts):
|
||||
if not facts['prefix_exists']:
|
||||
return 'fail', 'Prefix %s does not exist; run the installer first' % facts['prefix']
|
||||
if not facts['prefix_launchd']:
|
||||
return 'fail', 'Installed launchd is missing; the runtime install did not complete'
|
||||
return 'ok', facts['prefix']
|
||||
|
||||
|
||||
def check_runtime(facts):
|
||||
state = facts['prefix_state']
|
||||
if state['state'] == 'running':
|
||||
return 'ok', 'darlingserver is running (PID %s)' % state['pid']
|
||||
if state['state'] == 'stale':
|
||||
return 'warn', 'Stale darlingserver PID marker; the next launch recovers it'
|
||||
return 'ok', 'Runtime is stopped'
|
||||
|
||||
|
||||
def check_stale_markers(facts):
|
||||
count = facts['prefix_state']['stale_markers']
|
||||
if count > 3:
|
||||
return 'warn', '%d stale runtime markers in the prefix; safe to delete' % count
|
||||
return 'ok', 'No stale runtime markers'
|
||||
|
||||
|
||||
CHECKS = (
|
||||
('kernel', 'Kernel', check_kernel),
|
||||
('memory', 'Memory', check_memory),
|
||||
('disk', 'Disk space', check_disk),
|
||||
('gpu', 'GPU driver', check_gpu),
|
||||
('vulkan', 'Vulkan driver', check_vulkan),
|
||||
('session', 'Display session', check_session),
|
||||
('hud', 'Overlay layer', check_hud),
|
||||
('core', 'Core dumps', check_core_dumps),
|
||||
('psr', 'Panel self-refresh', check_psr),
|
||||
('prefix', 'Darling prefix', check_prefix),
|
||||
('runtime', 'Darling runtime', check_runtime),
|
||||
('markers', 'Runtime markers', check_stale_markers),
|
||||
)
|
||||
|
||||
|
||||
def run_checks(facts):
|
||||
results = []
|
||||
for key, title, check in CHECKS:
|
||||
try:
|
||||
status, message = check(facts)
|
||||
except Exception as error: # one broken probe must not hide the rest
|
||||
status, message = 'warn', 'Check could not run: %s' % error
|
||||
results.append({'id': key, 'title': title, 'status': status, 'message': message})
|
||||
return results
|
||||
|
||||
|
||||
def summarize(results):
|
||||
counts = {'ok': 0, 'warn': 0, 'fail': 0}
|
||||
for result in results:
|
||||
counts[result['status']] = counts.get(result['status'], 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
def report(facts, results):
|
||||
return {
|
||||
'schema': SCHEMA,
|
||||
'tool': 'darling-lol doctor',
|
||||
'created': time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime()),
|
||||
'summary': summarize(results),
|
||||
'host': facts['host'],
|
||||
'session': facts['session'],
|
||||
'memory': facts['memory'],
|
||||
'disk': facts['disk'],
|
||||
'gpus': facts['gpus'],
|
||||
'vulkan_icds': facts['vulkan_icds'],
|
||||
'prefix': facts['prefix'],
|
||||
'environment': facts['environment'],
|
||||
'checks': results,
|
||||
}
|
||||
|
||||
|
||||
def render(facts, results):
|
||||
lines = []
|
||||
host = facts['host']
|
||||
lines.append('darling-lol doctor - %s' % host['distro'])
|
||||
lines.append(' kernel %s python %s %s' % (host['kernel'], host['python'], host['arch']))
|
||||
results_by_id = {result['id']: result for result in results}
|
||||
for key in ('gpu', 'vulkan', 'session', 'memory', 'disk'):
|
||||
lines.append(' %-8s %s' % (results_by_id[key]['title'] + ':', results_by_id[key]['message']))
|
||||
lines.append('')
|
||||
for result in results:
|
||||
if result['status'] == 'ok':
|
||||
continue
|
||||
lines.append('%-4s %s: %s' % (result['status'].upper(), result['title'], result['message']))
|
||||
counts = summarize(results)
|
||||
if counts['fail'] or counts['warn']:
|
||||
lines.append('')
|
||||
lines.append('%d ok, %d warning(s), %d failure(s)' % (counts['ok'], counts['warn'], counts['fail']))
|
||||
if counts['fail']:
|
||||
lines.append('Fix the failures above before reporting other problems.')
|
||||
lines.append('Attach a full report with: darling-lol --doctor --report doctor.json')
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description='Check this host for darling-lol and write an attachable report.')
|
||||
parser.add_argument('--json', action='store_true', help='print the report as JSON')
|
||||
parser.add_argument('--report', type=Path, help='write the JSON report to this file')
|
||||
parser.add_argument('--prefix', type=Path, help='Darling prefix (default: DPREFIX or ~/.darling)')
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
facts = gather(prefix=args.prefix)
|
||||
results = run_checks(facts)
|
||||
document = report(facts, results)
|
||||
|
||||
if args.report:
|
||||
args.report.write_text(json.dumps(document, indent=2) + '\n')
|
||||
if args.json:
|
||||
print(json.dumps(document, indent=2))
|
||||
else:
|
||||
print(render(facts, results))
|
||||
if args.report:
|
||||
print('Report written to %s' % args.report)
|
||||
return 1 if document['summary']['fail'] else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,211 @@
|
||||
#!/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())
|
||||
Reference in new issue
Block a user