From 67665974dc3f11a2d0401a7b1c3d82a03995c0d8 Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 09:46:42 +0200 Subject: [PATCH] =?UTF-8?q?feat(m6):=20PowerMizer=20+=20Wine/Steam=20versi?= =?UTF-8?q?ons=20+=20non-Steam=20launchers=20=E2=80=94=200.22.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M6 leftovers (the watcher defers to M9's trigger-mode work): - gameenv: check_gpu_powermizer (NVIDIA, X; degrades when the gpu target won't resolve), check_wine (wine --version), check_steam_client (dpkg package version); steam.client_version() helper. - core/launchers.py: detect Lutris (read-only SQLite pga.db) and Heroic (Epic legendary + GOG JSON) installed games; Game gained a `launcher` field. - Games page + `rigdoctor games` list non-Steam games alongside Steam, tagged by launcher; Run Diagnostic works on them (auto-launch stays Steam-only). - Tests for launchers (synthetic Lutris db + Heroic json). Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 13 +++++ docs/MODULES.md | 6 ++- docs/ROADMAP.md | 4 +- pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/cli.py | 49 ++++++++++-------- src/rigdoctor/core/gameenv.py | 57 +++++++++++++++++++++ src/rigdoctor/core/launchers.py | 89 +++++++++++++++++++++++++++++++++ src/rigdoctor/core/steam.py | 21 +++++++- src/rigdoctor/gui/games_page.py | 22 ++++++-- tests/test_launchers.py | 67 +++++++++++++++++++++++++ 11 files changed, 299 insertions(+), 33 deletions(-) create mode 100644 src/rigdoctor/core/launchers.py create mode 100644 tests/test_launchers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 44722fa..61199f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to RigDoctor are recorded here. Format follows (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git release tag (so the auto-updater, D18, can compare versions). +## [0.22.0] - 2026-05-22 +### Added +- **M6 breadth.** Environment checks now also report **GPU PowerMizer** mode (NVIDIA, X β€” flags + Adaptive/Auto and suggests Prefer-Max-Performance), the **Wine** version, and the **Steam + client** version. +- **Non-Steam launchers.** Lutris (its SQLite library) and Heroic (Epic + GOG JSON stores) are + detected (`core/launchers.py`) and listed on the Games page and `rigdoctor games`, tagged by + launcher. You can Run Diagnostic on them too (records while you play; auto-launch stays + Steam-only). +### Notes +- The zero-config game watcher (D12 fallback) is deferred to the M9 trigger-mode work, where the + service integration lives. + ## [0.21.0] - 2026-05-22 ### Added - **Live monitor TUI (M2).** `rigdoctor monitor` is now a proper **curses** dashboard: diff --git a/docs/MODULES.md b/docs/MODULES.md index 0258763..365bccc 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -67,8 +67,10 @@ Status: ⬜ not started Β· 🟦 designing Β· 🟨 in progress Β· βœ… done banner β†’ results dialog). **Auto-capture** via the D12 wrapper (`rigdoctor wrap %command%`, `core/wrap.py`; GUI "Auto-capture…" helper). **Hard crashes are detected** (capture left without a clean stop) and flagged on next launch with a crash-boot kernel-log analysis - (`pending_crash`/`analyze_crash` + `health.check_previous_boot`). *Pending:* non-Steam - launchers (Lutris/Heroic), GPU power-profile (PowerMizer) checks, and the zero-config watcher. + (`pending_crash`/`analyze_crash` + `health.check_previous_boot`). **Non-Steam launchers** + (Lutris SQLite + Heroic JSON, `core/launchers.py`) are detected and listed alongside Steam + games; env checks also cover **GPU PowerMizer** (X), **Wine** and **Steam-client** versions. + *Pending:* the zero-config watcher (D12 fallback) β€” landing with M9's trigger-mode work. - **M8 Alerting** β€” threshold/event notifications; integrates with the tray applet (M11). - **M10 Desktop GUI** β€” PySide6 graphical front-end over the core engine. Optional; adds the Qt dependency. Dark-themed window with a **grouped sidebar** (Monitor / Diagnose / System / diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index d2cf577..e8caf29 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -34,7 +34,9 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`). This is also the D12 "pick a game" foundation. *Env-check engine done* (`rigdoctor gameenv` + GUI Environment page): PCIe ASPM, NVIDIA persistence, CPU governor, GameMode, MangoHud, swappiness, shader cache, THP, mitigations, Proton versions β€” read-only with fix commands. - *Pending:* non-Steam launchers (Lutris/Heroic) + GPU power-profile (PowerMizer) checks. + Also: GPU PowerMizer (X), Wine + Steam-client versions, and non-Steam launchers + (Lutris/Heroic, `core/launchers.py`). *Pending:* the zero-config watcher (D12 fallback, + lands with M9's trigger-mode work). - [ ] SMART integration (smartmontools if present) ## Phase 4 β€” Desktop UI & installer diff --git a/pyproject.toml b/pyproject.toml index 3e06d24..0b7dc81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.21.0" +version = "0.22.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 85f61a2..a7a8c57 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor β€” modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.21.0" +__version__ = "0.22.0" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index 1f0205d..182ba41 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -431,34 +431,41 @@ def cmd_gameenv(args) -> int: def cmd_games(args) -> int: - from .core import steam + from dataclasses import asdict + + from .core import launchers, steam selected = steam.selected_library_paths() - if not selected: - print("No Steam libraries selected to scan.") - print(" See them with: rigdoctor games libraries") - print(" Then enable one: rigdoctor games libraries --enable (or --all)") - return 1 - result = steam.rescan() - if args.json: - from dataclasses import asdict + result = steam.rescan() if selected else None + steam_games = result.games if result else [] + extra = launchers.scan() # non-Steam (Lutris/Heroic) + all_games = list(steam_games) + list(extra) + if args.json: print(json.dumps({ - "scanned_at": result.scanned_at, - "new_appids": result.new_appids, - "games": [asdict(g) for g in result.games], + "scanned_at": result.scanned_at if result else None, + "new_appids": result.new_appids if result else [], + "games": [asdict(g) for g in all_games], }, indent=2, ensure_ascii=False)) return 0 - if not result.games: - print("No games found in the selected Steam libraries.") + + if not all_games: + if not selected: + print("No Steam libraries selected and no non-Steam games found.") + print(" Pick a Steam library: rigdoctor games libraries --enable (or --all)") + return 1 + print("No games found.") return 0 - new = set(result.new_appids) - print(f"{len(result.games)} game(s) across {len(selected)} librar(y/ies):\n") - for g in result.games: - flag = " NEW" if g.appid in new else "" - print(f" {g.name:<48} {steam.human_size(g.size_bytes):>9}{flag}") - if new: - print(f"\n{len(new)} newly-installed since the last scan.") + + new = set(result.new_appids) if result else set() + print(f"{len(all_games)} game(s):\n") + for g in all_games: + tag = " NEW" if g.appid in new else "" + src = "" if g.launcher == "steam" else f" [{g.launcher}]" + size = steam.human_size(g.size_bytes) if g.size_bytes else "" + print(f" {g.name:<46}{src:<10} {size:>9}{tag}") + if not selected: + print("\n(no Steam libraries selected β€” `rigdoctor games libraries --all` to add them)") return 0 diff --git a/src/rigdoctor/core/gameenv.py b/src/rigdoctor/core/gameenv.py index c18ced2..9fa0e72 100644 --- a/src/rigdoctor/core/gameenv.py +++ b/src/rigdoctor/core/gameenv.py @@ -71,6 +71,32 @@ def check_pcie_aspm() -> list[Finding]: # --- NVIDIA persistence mode (seed-case relevant) ------------------------------------- +def check_gpu_powermizer() -> list[Finding]: + """NVIDIA PowerMizer preferred-performance mode (X only, via nvidia-settings).""" + if shutil.which("nvidia-settings") is None or not os.environ.get("DISPLAY"): + return [] + try: + proc = subprocess.run( + ["nvidia-settings", "-q", "[gpu:0]/GPUPowerMizerMode", "-t"], + capture_output=True, text=True, timeout=10, + ) + except (subprocess.SubprocessError, OSError): + return [] + raw = proc.stdout.strip().splitlines()[0].strip() if proc.stdout.strip() else "" + if not raw.isdigit(): # no X target / Wayland / query failed β€” skip quietly + return [] + names = {0: "Adaptive", 1: "Prefer Maximum Performance", 2: "Auto"} + name = names.get(int(raw), f"mode {raw}") + if int(raw) == 1: + return [Finding(OK, "GPU", f"GPU PowerMizer: {name}", "The GPU prefers maximum performance.")] + return [Finding( + INFO, "GPU", f"GPU PowerMizer: {name}", + "Adaptive/Auto can downclock the GPU between load spikes, hurting frame consistency.", + "Prefer max performance (X only, resets on reboot): " + "`nvidia-settings -a '[gpu:0]/GPUPowerMizerMode=1'`.", + )] + + def check_gpu_persistence() -> list[Finding]: if shutil.which("nvidia-smi") is None: return [] @@ -235,6 +261,34 @@ def check_mitigations() -> list[Finding]: # --- Proton versions (informational) -------------------------------------------------- +def check_wine() -> list[Finding]: + """System Wine version (used by Lutris / non-Proton games).""" + if shutil.which("wine") is None: + return [] + try: + proc = subprocess.run(["wine", "--version"], capture_output=True, text=True, timeout=10) + except (subprocess.SubprocessError, OSError): + return [] + ver = proc.stdout.strip().split()[0] if proc.stdout.strip() else "" + if not ver: + return [] + return [Finding( + INFO, "Tools", f"Wine: {ver}", + "System Wine β€” used by Lutris and non-Proton titles.", + "Steam games generally run best on Proton; keep Wine current for native/Lutris use.", + )] + + +def check_steam_client() -> list[Finding]: + """Installed Steam client package version.""" + from . import steam + + ver = steam.client_version() + if not ver: + return [] + return [Finding(INFO, "Tools", f"Steam client: {ver}", "The installed Steam package version.")] + + def check_proton() -> list[Finding]: from . import steam @@ -259,6 +313,7 @@ def run_gameenv_checks() -> list[Finding]: findings: list[Finding] = [] findings += check_pcie_aspm() findings += check_gpu_persistence() + findings += check_gpu_powermizer() findings += check_cpu_governor() findings += check_gamemode() findings += check_mangohud() @@ -267,5 +322,7 @@ def run_gameenv_checks() -> list[Finding]: findings += check_thp() findings += check_mitigations() findings += check_proton() + findings += check_wine() + findings += check_steam_client() findings.sort(key=lambda f: _ORDER.get(f.severity, 9)) return findings diff --git a/src/rigdoctor/core/launchers.py b/src/rigdoctor/core/launchers.py new file mode 100644 index 0000000..5d38eb0 --- /dev/null +++ b/src/rigdoctor/core/launchers.py @@ -0,0 +1,89 @@ +"""Non-Steam game detection (M6): Lutris + Heroic installed games. + +Reads each launcher's own install records (Lutris' SQLite library, Heroic's JSON stores), +returning the same `steam.Game` shape tagged with the launcher. Stdlib only; every reader +degrades to [] if the launcher isn't installed or its files can't be parsed. +""" + +from __future__ import annotations + +import json +import os +import sqlite3 +from pathlib import Path + +from .steam import Game + +LUTRIS_DB = Path(os.path.expanduser("~/.local/share/lutris/pga.db")) +HEROIC_DIR = Path(os.path.expanduser("~/.config/heroic")) + + +def _lutris_games() -> list[Game]: + db = LUTRIS_DB + if not db.exists(): + return [] + games: list[Game] = [] + try: + con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) # read-only + try: + rows = con.execute( + "SELECT name, slug FROM games WHERE installed = 1 AND name IS NOT NULL" + ).fetchall() + finally: + con.close() + except (sqlite3.Error, OSError): + return [] + for name, slug in rows: + if name: + games.append(Game(appid=slug or "", name=str(name), library="", installdir="", + launcher="lutris")) + return games + + +def _read_json(path: Path): + try: + return json.loads(path.read_text()) + except (OSError, ValueError): + return None + + +def _heroic_games() -> list[Game]: + base = HEROIC_DIR + if not base.is_dir(): + return [] + games: list[Game] = [] + + # Epic / Legendary: {app_name: {"title": ..., ...}} + epic = _read_json(base / "legendaryConfig" / "legendary" / "installed.json") + if isinstance(epic, dict): + for app_name, info in epic.items(): + if isinstance(info, dict): + games.append(Game(appid=str(app_name), name=info.get("title") or str(app_name), + library="", installdir="", launcher="heroic")) + + # GOG: {"installed": [{"appName", "install_path", "title"?}]} + gog = _read_json(base / "gog_store" / "installed.json") + entries = gog.get("installed") if isinstance(gog, dict) else None + if isinstance(entries, list): + for e in entries: + if not isinstance(e, dict): + continue + install_path = e.get("install_path") or "" + title = e.get("title") or os.path.basename(install_path.rstrip("/")) or str(e.get("appName", "")) + if title: + games.append(Game(appid=str(e.get("appName", "")), name=title, library="", + installdir="", launcher="heroic")) + return games + + +def scan() -> list[Game]: + """Installed non-Steam games (Lutris + Heroic), de-duplicated, sorted by name.""" + seen: set[tuple[str, str]] = set() + out: list[Game] = [] + for game in _lutris_games() + _heroic_games(): + key = (game.launcher, game.name) + if key in seen: + continue + seen.add(key) + out.append(game) + return sorted(out, key=lambda g: g.name.lower()) diff --git a/src/rigdoctor/core/steam.py b/src/rigdoctor/core/steam.py index 56f4b17..4076fb3 100644 --- a/src/rigdoctor/core/steam.py +++ b/src/rigdoctor/core/steam.py @@ -58,10 +58,11 @@ class SteamLibrary: class Game: appid: str name: str - library: str # library path the game lives in + library: str # library path the game lives in (Steam) installdir: str # folder name under /steamapps/common size_bytes: int = 0 last_updated: int = 0 # epoch seconds (acf LastUpdated), 0 if unknown + launcher: str = "steam" # "steam" | "lutris" | "heroic" # --- VDF (Valve Data Format) parsing -------------------------------------------------- @@ -313,7 +314,8 @@ def cached_games() -> list[Game]: cache = load_cache() if not cache: return [] - return [Game(**{k: g.get(k) for k in Game.__dataclass_fields__}) for g in cache.get("games", [])] + # Only pass keys present in the record so dataclass defaults fill any new fields. + return [Game(**{k: g[k] for k in Game.__dataclass_fields__ if k in g}) for g in cache.get("games", [])] def rescan(cfg: dict | None = None) -> ScanResult: @@ -353,6 +355,21 @@ def acknowledge_new() -> None: # --- formatting ----------------------------------------------------------------------- +def client_version() -> str | None: + """The installed Steam package version (apt), or None β€” best-effort, offline.""" + if shutil.which("dpkg-query") is None: + return None + for pkg in ("steam-installer", "steam-launcher", "steam"): + try: + proc = subprocess.run(["dpkg-query", "-W", "-f=${Version}", pkg], + capture_output=True, text=True, timeout=10) + except (subprocess.SubprocessError, OSError): + continue + if proc.returncode == 0 and proc.stdout.strip(): + return proc.stdout.strip() + return None + + def launch_game(appid: str) -> bool: """Best-effort: ask Steam to launch a game by appid (steam:// URL). Non-blocking.""" if not appid: diff --git a/src/rigdoctor/gui/games_page.py b/src/rigdoctor/gui/games_page.py index 8c886a2..7f1176d 100644 --- a/src/rigdoctor/gui/games_page.py +++ b/src/rigdoctor/gui/games_page.py @@ -88,6 +88,7 @@ class GamesPage(QWidget): self._diag_done.connect(self._on_diag_done) self._busy = False self._new_appids: set[str] = set() + self._extra_games: list = [] # non-Steam (Lutris/Heroic), appended after a scan self._diag_game: str | None = None root = QVBoxLayout(self) @@ -213,7 +214,7 @@ class GamesPage(QWidget): threading.Thread(target=self._work, daemon=True).start() def _work(self) -> None: - from ..core import steam + from ..core import launchers, steam try: selected = {os.path.realpath(p) for p in steam.selected_library_paths()} @@ -223,6 +224,10 @@ class GamesPage(QWidget): for lib in steam.discover_libraries() ] self._libraries_ready.emit(libs) + try: + self._extra_games = launchers.scan() # Lutris / Heroic (non-Steam) + except Exception: + self._extra_games = [] self._scanned.emit(steam.rescan()) except Exception: self._scanned.emit(None) @@ -265,11 +270,13 @@ class GamesPage(QWidget): self._status.setText("scan failed") return self._new_appids = set(result.new_appids) - self._populate_games(result.games, self._new_appids) + games = list(result.games) + list(self._extra_games) + self._populate_games(games, self._new_appids) new = len(self._new_appids) suffix = f" Β· {new} new" if new else "" + non_steam = f" Β· {len(self._extra_games)} non-Steam" if self._extra_games else "" self._status.setText( - f"{len(result.games)} games Β· {time.strftime('%H:%M:%S')}{suffix}" + f"{len(games)} games Β· {time.strftime('%H:%M:%S')}{suffix}{non_steam}" ) self.new_count_changed.emit(new) @@ -293,12 +300,17 @@ class GamesPage(QWidget): return for g in games: + launcher = getattr(g, "launcher", "steam") + if launcher != "steam": + sublabel, appid = launcher.title(), "" # non-Steam: can't steam:// launch it + else: + sublabel, appid = (os.path.basename(g.library.rstrip("/")) or g.library), g.appid self._list.addWidget(_game_row( g.name, - os.path.basename(g.library.rstrip("/")) or g.library, + sublabel, steam.human_size(g.size_bytes), g.appid in new_appids, - appid=g.appid, + appid=appid, on_diagnose=self._start_diagnostic, )) self._list.addStretch(1) diff --git a/tests/test_launchers.py b/tests/test_launchers.py new file mode 100644 index 0000000..97c6bd3 --- /dev/null +++ b/tests/test_launchers.py @@ -0,0 +1,67 @@ +"""Tests for M6 non-Steam game detection (Lutris SQLite + Heroic JSON).""" + +import json +import sqlite3 +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from rigdoctor.core import launchers + + +class LutrisTests(unittest.TestCase): + def test_reads_installed_games_only(self): + with tempfile.TemporaryDirectory() as d: + db = Path(d) / "pga.db" + con = sqlite3.connect(db) + con.execute("CREATE TABLE games (id INTEGER, name TEXT, slug TEXT, installed INTEGER)") + con.executemany( + "INSERT INTO games VALUES (?, ?, ?, ?)", + [(1, "Hades", "hades", 1), (2, "Hollow Knight", "hollow-knight", 1), (3, "Old Game", "old", 0)], + ) + con.commit() + con.close() + with mock.patch.object(launchers, "LUTRIS_DB", db), \ + mock.patch.object(launchers, "HEROIC_DIR", Path(d) / "nope"): + games = launchers.scan() + names = {g.name for g in games} + self.assertEqual(names, {"Hades", "Hollow Knight"}) + self.assertTrue(all(g.launcher == "lutris" for g in games)) + + def test_missing_db_is_empty(self): + with tempfile.TemporaryDirectory() as d: + with mock.patch.object(launchers, "LUTRIS_DB", Path(d) / "absent.db"), \ + mock.patch.object(launchers, "HEROIC_DIR", Path(d) / "nope"): + self.assertEqual(launchers.scan(), []) + + +class HeroicTests(unittest.TestCase): + def test_epic_and_gog(self): + with tempfile.TemporaryDirectory() as d: + base = Path(d) / "heroic" + (base / "legendaryConfig" / "legendary").mkdir(parents=True) + (base / "gog_store").mkdir(parents=True) + (base / "legendaryConfig" / "legendary" / "installed.json").write_text( + json.dumps({"abc123": {"title": "Control"}})) + (base / "gog_store" / "installed.json").write_text( + json.dumps({"installed": [{"appName": "777", "title": "The Witcher 3"}]})) + with mock.patch.object(launchers, "LUTRIS_DB", Path(d) / "nope.db"), \ + mock.patch.object(launchers, "HEROIC_DIR", base): + names = {g.name for g in launchers.scan()} + self.assertEqual(names, {"Control", "The Witcher 3"}) + + def test_gog_title_falls_back_to_install_path(self): + with tempfile.TemporaryDirectory() as d: + base = Path(d) / "heroic" + (base / "gog_store").mkdir(parents=True) + (base / "gog_store" / "installed.json").write_text( + json.dumps({"installed": [{"appName": "9", "install_path": "/games/Stardew Valley"}]})) + with mock.patch.object(launchers, "LUTRIS_DB", Path(d) / "nope.db"), \ + mock.patch.object(launchers, "HEROIC_DIR", base): + names = {g.name for g in launchers.scan()} + self.assertEqual(names, {"Stardew Valley"}) + + +if __name__ == "__main__": + unittest.main()