From 11f72520043cd4255500aca6f104a2f3c9f07e29 Mon Sep 17 00:00:00 2001 From: MintMachineHQ Date: Fri, 23 Oct 2026 23:56:30 -0600 Subject: [PATCH] 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. --- README.md | 2 +- darling-lol-launcher.sh | 14 ++ docs/COMPATIBILITY.md | 83 +++++++ docs/TROUBLESHOOTING.md | 26 +++ tests/test_crash_report.py | 166 ++++++++++++++ tests/test_doctor.py | 215 ++++++++++++++++++ tests/test_perf_baseline.py | 150 +++++++++++++ tools/crash-report.py | 311 ++++++++++++++++++++++++++ tools/doctor.py | 431 ++++++++++++++++++++++++++++++++++++ tools/perf-baseline.py | 211 ++++++++++++++++++ 10 files changed, 1608 insertions(+), 1 deletion(-) create mode 100644 docs/COMPATIBILITY.md create mode 100644 tests/test_crash_report.py create mode 100644 tests/test_doctor.py create mode 100644 tests/test_perf_baseline.py create mode 100644 tools/crash-report.py create mode 100644 tools/doctor.py create mode 100644 tools/perf-baseline.py diff --git a/README.md b/README.md index 6dc7f33..a0212d6 100644 --- a/README.md +++ b/README.md @@ -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**. diff --git a/darling-lol-launcher.sh b/darling-lol-launcher.sh index f8a124c..9992bd4 100644 --- a/darling-lol-launcher.sh +++ b/darling-lol-launcher.sh @@ -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. diff --git a/docs/COMPATIBILITY.md b/docs/COMPATIBILITY.md new file mode 100644 index 0000000..b2e1d52 --- /dev/null +++ b/docs/COMPATIBILITY.md @@ -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: / ` 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). diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index f4a5488..1f017de 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -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` diff --git a/tests/test_crash_report.py b/tests/test_crash_report.py new file mode 100644 index 0000000..bd1b64e --- /dev/null +++ b/tests/test_crash_report.py @@ -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 on ') + 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'], '') + 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() diff --git a/tests/test_doctor.py b/tests/test_doctor.py new file mode 100644 index 0000000..5261ee6 --- /dev/null +++ b/tests/test_doctor.py @@ -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() diff --git a/tests/test_perf_baseline.py b/tests/test_perf_baseline.py new file mode 100644 index 0000000..13116fa --- /dev/null +++ b/tests/test_perf_baseline.py @@ -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() diff --git a/tools/crash-report.py b/tools/crash-report.py new file mode 100644 index 0000000..440e3f6 --- /dev/null +++ b/tools/crash-report.py @@ -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 '', ''), + (hostname or socket.gethostname(), ''), + ] + 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': '' 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()) diff --git a/tools/doctor.py b/tools/doctor.py new file mode 100644 index 0000000..7979f56 --- /dev/null +++ b/tools/doctor.py @@ -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()) diff --git a/tools/perf-baseline.py b/tools/perf-baseline.py new file mode 100644 index 0000000..528ffb5 --- /dev/null +++ b/tools/perf-baseline.py @@ -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: /%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())