Files
rigdoctor/src/rigdoctor/gui/games_page.py
T
jessey 33c554c29f
tests / core (pull_request) Successful in 16s
tests / gui-smoke (pull_request) Successful in 27s
feat(ai): import & analyze Windows crash dumps (.dmp) — 0.41.0
Games page gains an "Import crash dump…" button (shown when an AI provider
is configured) that parses a Proton/Wine minidump and explains it via the
opt-in AI assistant. New stdlib core/minidump.py reads the MDMP streams
(crash reason, faulting module, OS/CPU, module list), optionally enriched
by minidump_stackwalk if installed. Adds ai_knowledge facts for exception
codes + faulting-module signatures, a MinidumpDialog, and CLI parity via
`rigdoctor ai dump <file>`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 18:39:52 +02:00

572 lines
22 KiB
Python

"""Games page (M6 in the GUI): pick Steam libraries and browse detected games.
Libraries are opt-in — the user checks which ones to scan. The list is loaded from the
cache instantly, then a background rescan refreshes it and flags games installed since the
last scan (a "NEW" badge here + a count on the sidebar nav).
"""
from __future__ import annotations
import os
import threading
import time
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
QLineEdit,
QMessageBox,
QPushButton,
QScrollArea,
QVBoxLayout,
QWidget,
)
from ..config import load_config, update_config
from .diagnostic_dialog import DiagnosticDialog
from .minidump_dialog import MinidumpDialog
from .theme import ACCENT, GOOD, MUTED, WARN
def _game_row(name: str, sublabel: str, size: str, is_new: bool, appid: str = "", on_diagnose=None) -> QFrame:
card = QFrame()
card.setObjectName("Card")
h = QHBoxLayout(card)
h.setContentsMargins(16, 10, 16, 10)
h.setSpacing(10)
left = QVBoxLayout()
left.setSpacing(2)
title = QLabel(name)
title.setStyleSheet("font-weight: 600; background: transparent;")
title.setWordWrap(True)
left.addWidget(title)
if sublabel:
sub = QLabel(sublabel)
sub.setObjectName("Muted")
left.addWidget(sub)
h.addLayout(left, 1)
if is_new:
badge = QLabel("NEW")
badge.setStyleSheet(
f"color: {GOOD}; border: 1px solid {GOOD}; border-radius: 6px; "
f"padding: 1px 6px; font-weight: 700; background: transparent;"
)
h.addWidget(badge, 0, Qt.AlignmentFlag.AlignVCenter)
size_label = QLabel(size)
size_label.setObjectName("Muted")
size_label.setMinimumWidth(80)
size_label.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
h.addWidget(size_label, 0)
if on_diagnose is not None:
diag_btn = QPushButton("Run Diagnostic")
diag_btn.setObjectName("ActionButton")
diag_btn.setCursor(Qt.CursorShape.PointingHandCursor)
diag_btn.clicked.connect(lambda: on_diagnose(name, appid))
h.addWidget(diag_btn, 0)
return card
class GamesPage(QWidget):
_libraries_ready = Signal(object) # list[dict(path, label, count, selected)]
_scanned = Signal(object) # steam.ScanResult
new_count_changed = Signal(int) # newly-installed game count (for the nav badge)
_diag_done = Signal(object) # DiagnosticResult — focused capture analyzed
_dump_parsed = Signal(object) # minidump.MinidumpReport — imported .dmp (or None)
def __init__(self) -> None:
super().__init__()
self.setObjectName("Page")
self._libraries_ready.connect(self._render_libraries)
self._scanned.connect(self._render_games)
self._diag_done.connect(self._on_diag_done)
self._dump_parsed.connect(self._on_dump_parsed)
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)
root.setContentsMargins(20, 18, 20, 18)
root.setSpacing(16)
header = QHBoxLayout()
title = QLabel("Games")
title.setObjectName("PageTitle")
header.addWidget(title)
header.addStretch(1)
self._status = QLabel("")
self._status.setObjectName("Muted")
header.addWidget(self._status)
# Import a Windows crash dump (.dmp) from a Proton game and analyze it with AI.
# Shown only when an AI provider is configured (AI analysis is the point).
self._import_btn = QPushButton("Import crash dump…")
self._import_btn.clicked.connect(self._import_dump)
header.addWidget(self._import_btn)
self._autocap_btn = QPushButton("Auto-capture…")
self._autocap_btn.clicked.connect(self._show_autocapture)
header.addWidget(self._autocap_btn)
self._rescan_btn = QPushButton("Rescan")
self._rescan_btn.setObjectName("PrimaryButton")
self._rescan_btn.clicked.connect(self.refresh)
header.addWidget(self._rescan_btn)
root.addLayout(header)
# In-progress diagnostic banner (hidden until a focused capture is running).
self._banner = QFrame()
self._banner.setObjectName("Card")
self._banner.setStyleSheet(f"#Card {{ border: 1px solid {ACCENT}; }}")
banner_h = QHBoxLayout(self._banner)
banner_h.setContentsMargins(16, 10, 16, 10)
banner_h.setSpacing(10)
self._banner_label = QLabel("")
self._banner_label.setWordWrap(True)
self._banner_label.setStyleSheet(f"color: {ACCENT}; font-weight: 700; background: transparent;")
banner_h.addWidget(self._banner_label, 1)
self._finish_btn = QPushButton("Finish && analyze") # && → literal & (not a mnemonic)
self._finish_btn.setObjectName("ActionButton")
self._finish_btn.clicked.connect(self._finish_diagnostic)
banner_h.addWidget(self._finish_btn)
self._discard_btn = QPushButton("Discard")
self._discard_btn.clicked.connect(self._discard_diagnostic)
banner_h.addWidget(self._discard_btn)
self._banner.hide()
root.addWidget(self._banner)
# Hard-crash banner: a previous diagnostic ended without a clean stop.
self._crash_banner = QFrame()
self._crash_banner.setObjectName("Card")
self._crash_banner.setStyleSheet(f"#Card {{ border: 1px solid {WARN}; }}")
crash_h = QHBoxLayout(self._crash_banner)
crash_h.setContentsMargins(16, 10, 16, 10)
crash_h.setSpacing(10)
self._crash_label = QLabel("")
self._crash_label.setWordWrap(True)
self._crash_label.setStyleSheet(f"color: {WARN}; font-weight: 700; background: transparent;")
crash_h.addWidget(self._crash_label, 1)
self._analyze_btn = QPushButton("Analyze crash")
self._analyze_btn.setObjectName("ActionButton")
self._analyze_btn.clicked.connect(self._analyze_crash)
crash_h.addWidget(self._analyze_btn)
self._dismiss_btn = QPushButton("Dismiss")
self._dismiss_btn.clicked.connect(self._dismiss_crash)
crash_h.addWidget(self._dismiss_btn)
self._crash_banner.hide()
root.addWidget(self._crash_banner)
self._diag_timer = QTimer(self)
self._diag_timer.setInterval(1000)
self._diag_timer.timeout.connect(self._poll_diag)
# Libraries (opt-in checkboxes)
lib_card = QFrame()
lib_card.setObjectName("Card")
lib_v = QVBoxLayout(lib_card)
lib_v.setContentsMargins(16, 12, 16, 12)
lib_v.setSpacing(6)
lib_head = QLabel("Steam libraries")
lib_head.setStyleSheet("font-weight: 700; background: transparent;")
lib_v.addWidget(lib_head)
self._lib_box = QVBoxLayout()
self._lib_box.setSpacing(6)
lib_v.addLayout(self._lib_box)
self._lib_hint = QLabel("Looking for Steam libraries…")
self._lib_hint.setObjectName("Muted")
self._lib_hint.setWordWrap(True)
lib_v.addWidget(self._lib_hint)
root.addWidget(lib_card)
# Games list
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent;")
self._container = QWidget()
self._list = QVBoxLayout(self._container)
self._list.setContentsMargins(0, 0, 0, 0)
self._list.setSpacing(8)
self._list.setAlignment(Qt.AlignmentFlag.AlignTop)
scroll.setWidget(self._container)
root.addWidget(scroll, 1)
self._load_cached() # instant display from the last scan
QTimer.singleShot(400, self.refresh) # then rescan in the background on launch
self._check_crash() # surface an interrupted (crashed) diagnostic
self._refresh_import_btn() # show Import only if AI is configured
# --- loading ----------------------------------------------------------------------
def _load_cached(self) -> None:
from ..core import steam
cache = steam.load_cache() or {}
self._new_appids = set(cache.get("new_appids", []))
games = steam.cached_games()
if games:
self._populate_games(games, self._new_appids)
self.new_count_changed.emit(len(self._new_appids))
def refresh(self) -> None:
if self._busy:
return
self._busy = True
self._rescan_btn.setEnabled(False)
self._status.setText("Scanning Steam libraries…")
threading.Thread(target=self._work, daemon=True).start()
def _work(self) -> None:
from ..core import launchers, steam
try:
selected = {os.path.realpath(p) for p in steam.selected_library_paths()}
libs = [
{"path": lib.path, "label": lib.label, "selected": lib.path in selected,
"count": len(steam.scan_library(lib.path))}
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)
# --- rendering --------------------------------------------------------------------
def _render_libraries(self, libs) -> None:
while self._lib_box.count():
item = self._lib_box.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
if not libs:
self._lib_hint.setText("No Steam libraries detected. Is Steam installed?")
self._lib_hint.show()
return
self._lib_hint.hide()
for lib in libs:
label = lib["path"]
if lib["label"]:
label += f" [{lib['label']}]"
cb = QCheckBox(f"{label} · {lib['count']} games")
cb.setChecked(lib["selected"])
cb.toggled.connect(lambda checked, p=lib["path"]: self._toggle_library(p, checked))
self._lib_box.addWidget(cb)
def _toggle_library(self, path: str, checked: bool) -> None:
selected = {os.path.realpath(p) for p in (load_config().get("steam_libraries") or [])}
if checked:
selected.add(os.path.realpath(path))
else:
selected.discard(os.path.realpath(path))
update_config(steam_libraries=sorted(selected))
self.refresh()
def _render_games(self, result) -> None:
self._busy = False
self._rescan_btn.setEnabled(True)
if result is None:
self._status.setText("scan failed")
return
self._new_appids = set(result.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(games)} games · {time.strftime('%H:%M:%S')}{suffix}{non_steam}"
)
self.new_count_changed.emit(new)
def _populate_games(self, games, new_appids: set[str]) -> None:
from ..core import steam
while self._list.count():
item = self._list.takeAt(0)
w = item.widget()
if w is not None:
w.deleteLater()
if not games:
empty = QLabel(
"No games to show yet — check a Steam library above to scan it for games."
)
empty.setObjectName("Muted")
empty.setWordWrap(True)
self._list.addWidget(empty)
self._list.addStretch(1)
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,
sublabel,
steam.human_size(g.size_bytes),
g.appid in new_appids,
appid=appid,
on_diagnose=self._start_diagnostic,
))
self._list.addStretch(1)
# --- guided diagnostic (M6/D12) ---------------------------------------------------
def _start_diagnostic(self, name: str, appid: str = "") -> None:
from ..core import diagnostic, steam
if diagnostic.is_running():
QMessageBox.information(
self, "RigDoctor",
"A capture is already running — finish or discard it first.")
return
# Tell the user what the flow actually is, and offer to launch the game for them.
box = QMessageBox(self)
box.setIcon(QMessageBox.Icon.Information)
box.setWindowTitle(f"Run Diagnostic — {name}")
box.setText(f"Record a focused diagnostic while you play {name}?")
box.setInformativeText(
"RigDoctor will capture sensors in the background. Then:\n\n"
"1. Play the game and try to reproduce the freeze / black screen / crash.\n"
"2. When you're done — or after a hard freeze and reboot — come back here and "
"click “Finish & analyze”.\n\n"
"Your readings are saved continuously, so even a hard lock won't lose them."
)
launch_btn = box.addButton("Launch game && start", QMessageBox.ButtonRole.AcceptRole)
start_btn = box.addButton("Start without launching", QMessageBox.ButtonRole.ActionRole)
box.addButton("Cancel", QMessageBox.ButtonRole.RejectRole)
if not appid:
launch_btn.setEnabled(False) # no appid → can't ask Steam to launch it
box.exec()
clicked = box.clickedButton()
if clicked not in (launch_btn, start_btn):
return
if diagnostic.start(game=name) is None:
QMessageBox.warning(self, "RigDoctor", "Couldn't start the capture.")
return
launched = steam.launch_game(appid) if clicked is launch_btn else False
self._diag_game = name
self._finish_btn.setEnabled(True)
self._discard_btn.setEnabled(True)
self._banner.show()
self._diag_timer.start()
self._poll_diag()
if clicked is launch_btn and not launched:
QMessageBox.information(
self, "RigDoctor",
"Recording started, but couldn't launch the game automatically — "
"launch it yourself, then click “Finish & analyze” when you're done.")
def _poll_diag(self) -> None:
from ..core import diagnostic
status = diagnostic.active()
if not status:
self._diag_timer.stop() # recorder exited on its own
return
samples = status.get("samples", 0)
lost = " · ⚠ GPU-lost detected" if status.get("gpu_lost") else ""
game = status.get("game") or self._diag_game or "your game"
self._banner_label.setText(
f"● Recording {game} — play it and reproduce the problem, then click "
f"“Finish & analyze”. ({samples} samples{lost})"
)
def _finish_diagnostic(self) -> None:
self._diag_timer.stop()
self._finish_btn.setEnabled(False)
self._discard_btn.setEnabled(False)
self._banner_label.setText("Analyzing… (running the health report)")
threading.Thread(target=self._work_finish, daemon=True).start()
def _work_finish(self) -> None:
from ..core import diagnostic
try:
result = diagnostic.finish()
except Exception:
result = None
self._diag_done.emit(result)
def _on_diag_done(self, result) -> None:
self._banner.hide()
self._crash_banner.hide()
self._finish_btn.setEnabled(True)
self._discard_btn.setEnabled(True)
self._analyze_btn.setEnabled(True)
if result is None:
QMessageBox.warning(self, "RigDoctor", "The diagnostic couldn't be analyzed.")
return
DiagnosticDialog(result, self).exec()
def _discard_diagnostic(self) -> None:
from ..core import reccontrol
self._diag_timer.stop()
reccontrol.stop_background()
self._banner.hide()
def _show_autocapture(self) -> None:
from ..core import wrap
option = wrap.launch_option()
dlg = QDialog(self)
dlg.setWindowTitle("Auto-capture in Steam")
dlg.resize(580, 250)
v = QVBoxLayout(dlg)
v.setContentsMargins(20, 18, 20, 16)
v.setSpacing(12)
info = QLabel(
"Capture automatically every time you launch a game — no need to click "
"Run Diagnostic.\n\n"
"1. In Steam, right-click the game → Properties → Launch Options.\n"
"2. Paste the line below.\n\n"
"RigDoctor starts a focused capture when the game launches and stops it on exit. "
"If the game hard-freezes, you'll get a crash report next time you open RigDoctor."
)
info.setWordWrap(True)
v.addWidget(info)
row = QHBoxLayout()
field = QLineEdit(option)
field.setReadOnly(True)
row.addWidget(field, 1)
copy = QPushButton("Copy")
copy.setObjectName("PrimaryButton")
copy.clicked.connect(lambda: QApplication.clipboard().setText(option))
row.addWidget(copy)
v.addLayout(row)
buttons = QHBoxLayout()
buttons.addStretch(1)
close = QPushButton("Close")
close.clicked.connect(dlg.accept)
buttons.addWidget(close)
v.addLayout(buttons)
dlg.exec()
# --- import a crash dump (.dmp) ---------------------------------------------------
def _refresh_import_btn(self) -> None:
from ..core import ai
self._import_btn.setVisible(ai.is_configured())
def _import_dump(self) -> None:
from ..core import ai
if not ai.is_configured():
QMessageBox.information(
self, "RigDoctor",
"Set up an AI provider first (Settings → AI assistant) to analyze a crash dump.")
return
path, _ = QFileDialog.getOpenFileName(
self, "Import crash dump", os.path.expanduser("~"),
"Crash dumps (*.dmp);;All files (*)")
if not path:
return
self._import_btn.setEnabled(False)
self._status.setText("Parsing crash dump…")
threading.Thread(target=self._work_import, args=(path,), daemon=True).start()
def _work_import(self, path: str) -> None:
from ..core import minidump
try:
report = minidump.parse(path) # parses + runs minidump_stackwalk if installed
except Exception:
report = None
self._dump_parsed.emit(report)
def _on_dump_parsed(self, report) -> None:
self._import_btn.setEnabled(True)
self._status.setText("")
if report is None or not report.ok:
detail = report.error if report is not None else "Couldn't read the file."
QMessageBox.warning(
self, "Import crash dump", f"Couldn't analyze the dump — {detail}")
return
MinidumpDialog(report, self).exec()
# --- hard-crash recovery ----------------------------------------------------------
def _check_crash(self) -> None:
from ..core import diagnostic
info = diagnostic.pending_crash()
if info is None:
self._crash_banner.hide()
return
game = info.game or "your last game"
extra = " · ⚠ GPU-lost was captured" if info.gpu_lost else ""
self._crash_label.setText(
f"⚠ Your last diagnostic for {game} ended unexpectedly — likely a hard crash "
f"({info.samples} samples{extra}). Analyze it to see the final readings and the "
f"likely cause from the system logs."
)
self._analyze_btn.setEnabled(True)
self._crash_banner.show()
def _analyze_crash(self) -> None:
from ..core import diagnostic
diagnostic.acknowledge_crash() # don't prompt again for this one
self._analyze_btn.setEnabled(False)
self._crash_label.setText("Analyzing the crash (final readings + system logs)…")
threading.Thread(target=self._work_analyze_crash, daemon=True).start()
def _work_analyze_crash(self) -> None:
from ..core import diagnostic
try:
result = diagnostic.analyze_crash()
except Exception:
result = None
self._diag_done.emit(result)
def _dismiss_crash(self) -> None:
from ..core import diagnostic
diagnostic.acknowledge_crash()
self._crash_banner.hide()
# --- nav badge integration --------------------------------------------------------
def showEvent(self, event) -> None: # noqa: N802 (Qt override)
# Viewing the list acknowledges the new games: clear the sidebar badge. The NEW
# tags stay on the rows for this session so the user can still spot them.
super().showEvent(event)
self._refresh_import_btn() # AI may have been configured since this page was built
if self._new_appids:
from ..core import steam
threading.Thread(target=steam.acknowledge_new, daemon=True).start()
self.new_count_changed.emit(0)
# Reflect a capture that's still running (e.g. started earlier, navigated back).
from ..core import diagnostic
if diagnostic.is_running():
status = diagnostic.active() or {}
self._diag_game = status.get("game") or self._diag_game
self._banner.show()
if not self._diag_timer.isActive():
self._diag_timer.start()
else:
self._check_crash() # re-surface an interrupted diagnostic if one is pending