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:
MintMachineHQ committed 2026-10-23 23:56:30 -06:00
1 parent 24276b91c6
commit 11f7252004
10 files changed
+1608 -1

No files matched your search

+1 -1
View File
@@ -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**.
+14
View File
@@ -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.
+83
View File
@@ -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).
+26
View File
@@ -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`
+166
View File
@@ -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()
+215
View File
@@ -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()
+150
View File
@@ -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()
+311
View File
@@ -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
View File
@@ -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())
+211
View File
@@ -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())