From 33c554c29f47139ebf0c3ffd032da962b44a596b Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Mon, 25 May 2026 18:39:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai):=20import=20&=20analyze=20Windows=20cr?= =?UTF-8?q?ash=20dumps=20(.dmp)=20=E2=80=94=200.41.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 16 ++ README.md | 3 +- pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/cli.py | 17 ++ src/rigdoctor/core/ai_knowledge.py | 29 +++ src/rigdoctor/core/minidump.py | 314 +++++++++++++++++++++++++++ src/rigdoctor/gui/games_page.py | 54 +++++ src/rigdoctor/gui/minidump_dialog.py | 182 ++++++++++++++++ tests/test_minidump.py | 163 ++++++++++++++ 10 files changed, 779 insertions(+), 3 deletions(-) create mode 100644 src/rigdoctor/core/minidump.py create mode 100644 src/rigdoctor/gui/minidump_dialog.py create mode 100644 tests/test_minidump.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3e2bc..b7035ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,22 @@ 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.41.0] - 2026-05-25 +### Added +- **Import a crash dump (`.dmp`) and explain it with AI.** The **Games** page gains an + "Import crash dump…" button (shown once an AI provider is configured) that opens a Windows + minidump — the kind a Proton/Wine game writes when it hard-crashes — parses it, and hands the + result to the opt-in AI assistant (D24; cloud sends still ask first). A new stdlib + `core/minidump.py` reads the `MDMP` streams with `struct` (no new deps): the exception / crash + reason (e.g. access violation `0xC0000005`), the **faulting module** (which DLL the crash + address lands in — `nvwgf2umx.dll`, `d3d11.dll`, an anticheat, the game's own `.exe`…), OS/CPU, + and the loaded-module list. If `minidump_stackwalk` (Breakpad) or `minidump-stackwalk` + (rust-minidump) is on PATH, its fuller report is appended best-effort. The model is told the + dump came from a Windows process under Proton, so fixes stay Linux/Proton-side (Proton version, + DXVK/VKD3D, driver, launch options) — never Windows admin/registry steps. New `ai_knowledge` + facts cover the common exception codes and faulting-module signatures. CLI parity: + `rigdoctor ai dump `. + ## [0.40.0] - 2026-05-22 ### Added - **RAM speed / XMP-EXPO check.** Inventory now shows each module's configured speed and, when it's diff --git a/README.md b/README.md index 38c3d49..1c900ab 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ freeze are usually lost. RigDoctor pulls it together and keeps the evidence. - **Proactive alerts** — desktop notifications on overheating and critical kernel events (GPU-lost, Xid, out-of-memory, disk I/O). - **AI explanations** *(optional, opt-in)* — explain a diagnostic in plain language with a - **local model (Ollama)** or **Claude**. Never automatic; only when you press the button. + **local model (Ollama)** or **Claude**, or **import a Windows crash dump (`.dmp`)** from a + Proton game and have it parsed and analysed. Never automatic; only when you press the button. - **Shareable reports** — zip a diagnostic (logs, inventory, AI transcript) to hand to someone, or share a live **terminal session** for remote help. - **Self-updating** — `apt upgrade`, or the in-app updater. diff --git a/pyproject.toml b/pyproject.toml index df39b4c..68e09ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.40.0" +version = "0.41.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 94d561d..d60de26 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.40.0" +__version__ = "0.41.0" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index a10e58c..507b316 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -466,6 +466,20 @@ def cmd_ai(args) -> int: print(msg) return 0 if ok else 1 + if sub == "dump": + # Parse a Windows .dmp minidump (e.g. from a Proton game crash) and explain it. + from .core import minidump + + report = minidump.parse(args.file) + if not report.ok: + print(f"Couldn't analyze the dump — {report.error}") + return 1 + print(minidump.to_text(report)) + print(f"\nAsking {ai.provider_label()} to explain {os.path.basename(args.file)}…\n") + ok, msg = ai.explain(minidump.to_ai_text(report)) + print(msg) + return 0 if ok else 1 + # explain: gather the current health findings and ask the provider to explain them. from .core import health @@ -707,6 +721,9 @@ def build_parser() -> argparse.ArgumentParser: ai_sub.add_parser("status", help="show the configured provider (contacts nothing)").set_defaults(func=cmd_ai) ai_sub.add_parser("test", help="send a tiny probe to verify connectivity").set_defaults(func=cmd_ai) ai_sub.add_parser("explain", help="explain the current health findings with AI").set_defaults(func=cmd_ai) + dump_p = ai_sub.add_parser("dump", help="parse a Windows .dmp crash dump and explain it with AI") + dump_p.add_argument("file", help="path to the .dmp minidump (e.g. from a Proton game crash)") + dump_p.set_defaults(func=cmd_ai) ai_p.set_defaults(func=cmd_ai, ai_cmd=None) bundle_p = sub.add_parser("bundle", help="zip the latest stored diagnostic into a report bundle (M15)") diff --git a/src/rigdoctor/core/ai_knowledge.py b/src/rigdoctor/core/ai_knowledge.py index da8c38f..336fb1d 100644 --- a/src/rigdoctor/core/ai_knowledge.py +++ b/src/rigdoctor/core/ai_knowledge.py @@ -76,6 +76,35 @@ ENTRIES: list[tuple[tuple[str, ...], str]] = [ (("fork without exec", "skipping destruction"), "BENIGN: 'pid X != Y, skipping destruction (fork without exec?)' is routine Steam/Proton " "process bookkeeping, not an error."), + # --- crash-dump (.dmp) reasoning ------------------------------------------------- + (("access violation", "0xc0000005", "0xc0000006"), + "Windows exception 0xC0000005 (access violation) = the game read/wrote/executed memory it " + "wasn't allowed to. A write/read to a low address (near 0x0) is a null-pointer dereference, " + "usually a game or graphics-driver bug; under Proton it's often a DXVK/VKD3D or Proton-version " + "issue. Identify the faulting MODULE to localize the fault."), + (("stack overflow", "0xc00000fd"), + "Windows exception 0xC00000FD (stack overflow) = unbounded recursion or a huge stack " + "allocation in the crashing module — almost always a software bug in that module."), + (("0xc0000409", "stack buffer overrun", "fast fail"), + "Windows 0xC0000409 (stack buffer overrun / __fastfail) = a security check tripped on memory " + "corruption; frequently anticheat or a DRM/overlay injecting into the game. Suspect overlays " + "(Steam/Discord/MSI Afterburner-equivalents) and anticheat compatibility under Proton."), + (("0xc0000374", "heap corruption"), + "Windows 0xC0000374 (heap corruption) = something scribbled over heap memory earlier; the " + "crash point is a symptom, not the cause. Often a mod, an injected overlay, or unstable RAM."), + (("nvwgf2umx", "nvoglv", "nvd3dum", "nvldumd"), + "A faulting NVIDIA user-mode driver DLL (nvwgf2umx/nvoglv/nvd3dum) means the crash happened " + "inside the GPU driver under Proton. On Linux this points at the NVIDIA driver + the " + "DXVK/VKD3D translation layer: try a different driver branch or Proton/Proton-GE version, " + "clear the DXVK shader cache, and revert any GPU overclock/undervolt."), + (("easyanticheat", "eac", "battleye", "beclient", "anticheat"), + "A faulting anticheat module (EasyAntiCheat/BattlEye) under Proton is usually a compatibility " + "problem: confirm the title's anticheat has Proton/Linux support enabled and try the Proton " + "version the community recommends for it (often Proton-GE or a specific Valve build)."), + (("d3d11.dll", "d3d12.dll", "dxgi.dll", "d3d9.dll", "dxvk", "vkd3d"), + "A crash in a Direct3D/DXGI module under Proton runs through DXVK (D3D9/10/11) or VKD3D-Proton " + "(D3D12). Try a known-good Proton version, update/override DXVK-VKD3D, clear the shader cache, " + "and check the GPU driver — these are the usual fixes for D3D faults on Linux."), ] diff --git a/src/rigdoctor/core/minidump.py b/src/rigdoctor/core/minidump.py new file mode 100644 index 0000000..950859a --- /dev/null +++ b/src/rigdoctor/core/minidump.py @@ -0,0 +1,314 @@ +"""Parse a Windows crash dump (``.dmp`` minidump) into text the AI can reason over (M14). + +Linux gamers get these from Windows games running under **Proton/Wine**: the game's +crash handler (Crashpad/Breakpad, Unreal/Unity, or Wine itself) writes a binary minidump +when the title hard-crashes. The file is binary, so we can't hand it to a model directly — +we parse the documented ``MDMP`` streams with stdlib :mod:`struct` (no pip deps, per the +core rule) and pull out the parts that actually diagnose a crash: + + * the **exception / crash reason** (e.g. access violation 0xC0000005), + * the **faulting module** (which DLL the crash address lands in — ``nvwgf2umx.dll``, + ``d3d11.dll``, an anticheat, the game's own .exe…), + * **OS / CPU** info, and the **loaded module list**. + +If ``minidump_stackwalk`` (Breakpad) or ``minidump-stackwalk`` (rust-minidump) is on PATH, +its fuller report is appended best-effort; we never depend on it. + +The result feeds the existing opt-in AI flow (:mod:`ai`) exactly like the sensor findings do. +""" + +from __future__ import annotations + +import shutil +import struct +import subprocess +import time +from dataclasses import dataclass, field +from pathlib import Path + +from .health import CRITICAL, INFO, Finding + +# --- MDMP on-disk layout (all little-endian, packed) -------------------------------- +_SIGNATURE = b"MDMP" +_HEADER = struct.Struct("<4sIIIIIQ") # sig, ver, n_streams, dir_rva, csum, time, flags +_DIRECTORY = struct.Struct(" MinidumpReport: + """Parse a ``.dmp`` file. Never raises — a bad/unsupported file returns ``ok=False``.""" + report = MinidumpReport(path=str(path)) + try: + data = Path(path).read_bytes() + except OSError as exc: + report.error = f"can't read the file: {exc}" + return report + if len(data) < _HEADER.size or data[:4] != _SIGNATURE: + report.error = "not a Windows minidump (missing the 'MDMP' signature)." + return report + try: + _sig, _ver, n_streams, dir_rva, _csum, ts, _flags = _HEADER.unpack_from(data, 0) + report.timestamp = ts or None + streams = _streams(data, dir_rva, n_streams) + _read_system_info(data, streams.get(_SYSTEM_INFO), report) + report.modules = _read_modules(data, streams.get(_MODULE_LIST)) + _read_exception(data, streams.get(_EXCEPTION), report) + report.comment = _read_comment(data, streams) + except (struct.error, ValueError, IndexError) as exc: + report.error = f"the minidump looks corrupt or unsupported: {exc}" + return report + if report.exception_address is not None: + report.faulting_module = _module_at(report.modules, report.exception_address) + report.ok = True + if run_stackwalk: + report.stackwalk = stackwalk(path) + return report + + +def _streams(data: bytes, dir_rva: int, n: int) -> dict[int, tuple[int, int]]: + """Map stream_type -> (data_size, data_rva). First occurrence of each type wins.""" + out: dict[int, tuple[int, int]] = {} + for i in range(n): + off = dir_rva + i * _DIRECTORY.size + if off + _DIRECTORY.size > len(data): + break + stype, size, rva = _DIRECTORY.unpack_from(data, off) + out.setdefault(stype, (size, rva)) + return out + + +def _read_system_info(data: bytes, loc, report: MinidumpReport) -> None: + if not loc: + return + _size, rva = loc + arch, _lvl, _rev, n_cpu, _prod, major, minor, build, platform, _csd = \ + _SYSINFO.unpack_from(data, rva) + report.cpu_arch = _ARCH.get(arch, f"arch 0x{arch:x}") + report.cpu_count = n_cpu + if platform == 2: # VER_PLATFORM_WIN32_NT + report.os_name = f"Windows {major}.{minor}.{build}" + elif platform in _PLATFORM: + ver = f" {major}.{minor}.{build}" if (major or minor or build) else "" + report.os_name = _PLATFORM[platform] + ver + else: + report.os_name = f"platform 0x{platform:x} {major}.{minor}.{build}" + + +def _read_modules(data: bytes, loc) -> list[Module]: + if not loc: + return [] + _size, rva = loc + (count,) = struct.unpack_from(" len(data): + break + base, = struct.unpack_from(" None: + if not loc: + return + _size, rva = loc + thread_id, = struct.unpack_from(" str: + name = _EXCEPTION_NAMES.get(code, "Unknown exception") + reason = f"{name} (0x{code:08X})" + if code in (0xC0000005, 0xC0000006) and n_params >= 2: + op = struct.unpack_from(" str: + """A MINIDUMP_STRING (u32 byte-length + UTF-16LE), returned as a basename.""" + if not rva or rva + 4 > len(data): + return "" + length, = struct.unpack_from(" str: + if _COMMENT_W in streams: + size, rva = streams[_COMMENT_W] + return data[rva:rva + size].decode("utf-16-le", "replace").strip("\x00").strip() + if _COMMENT_A in streams: + size, rva = streams[_COMMENT_A] + return data[rva:rva + size].decode("utf-8", "replace").strip("\x00").strip() + return "" + + +def _module_at(modules: list[Module], address: int) -> str | None: + for m in modules: + if m.base <= address < m.base + m.size: + return m.name + return None + + +def stackwalk(path, timeout: float = 25.0, max_chars: int = 12000) -> str: + """Best-effort fuller report from an external stackwalker, or '' if none is installed.""" + exe = next((shutil.which(name) for name in _STACKWALK_BINS if shutil.which(name)), None) + if not exe: + return "" + try: + proc = subprocess.run( + [exe, str(path)], capture_output=True, text=True, timeout=timeout, check=False) + except (OSError, subprocess.SubprocessError): + return "" + return (proc.stdout or "").strip()[:max_chars] + + +# --- rendering ---------------------------------------------------------------------- + +def to_text(report: MinidumpReport) -> str: + """Human-readable structured summary (also shown in the GUI).""" + name = Path(report.path).name + lines = [f"Crash dump: {name}"] + if report.crash_reason: + lines.append(f"Crash reason: {report.crash_reason}") + if report.faulting_module: + lines.append(f"Faulting module: {report.faulting_module}") + elif report.exception_address is not None: + lines.append(f"Faulting address: 0x{report.exception_address:X} (no module matched)") + if report.crashing_thread is not None: + lines.append(f"Crashing thread: {report.crashing_thread}") + if report.os_name: + lines.append(f"OS: {report.os_name}") + if report.cpu_arch: + cpus = f" ({report.cpu_count} logical)" if report.cpu_count else "" + lines.append(f"CPU: {report.cpu_arch}{cpus}") + if report.timestamp: + lines.append("Captured: " + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(report.timestamp))) + if report.modules: + shown = report.modules[:_MODULES_SHOWN] + more = len(report.modules) - len(shown) + lines.append(f"\nLoaded modules ({len(report.modules)}):") + lines += [f"- {m.name}" for m in shown if m.name] + if more > 0: + lines.append(f"- (+{more} more)") + if report.comment: + lines.append(f"\nDump comment:\n{report.comment[:1000]}") + return "\n".join(lines) + + +def to_ai_text(report: MinidumpReport) -> str: + """The block sent to the model: Proton/Linux framing + summary + stackwalk.""" + framing = ( + "These findings come from a Windows crash minidump (.dmp) produced by a game running " + "under Proton/Wine on Linux. The faulting modules are Windows DLLs inside the Proton " + "prefix, so the crash is a Windows-process fault but the fixes are Linux/Proton-side " + "(Proton version, DXVK/VKD3D, GPU driver, launch options, shader cache) — never Windows " + "admin/registry steps." + ) + parts = [framing, "", to_text(report)] + if report.stackwalk: + parts.append("\nminidump_stackwalk output:\n" + report.stackwalk) + return "\n".join(parts) + + +def to_findings(report: MinidumpReport) -> list[Finding]: + """Render the dump as Finding cards for the GUI (mirrors the health report).""" + findings: list[Finding] = [] + detail_bits = [] + if report.faulting_module: + detail_bits.append(f"in {report.faulting_module}") + if report.exception_address is not None: + detail_bits.append(f"at 0x{report.exception_address:X}") + detail = (report.crash_reason or "Crash recorded") + if detail_bits: + detail += " " + " ".join(detail_bits) + "." + findings.append(Finding( + CRITICAL, "Crash dump", + f"Crash in {report.faulting_module}" if report.faulting_module else "Crash recorded", + detail, + "Use “Explain with AI” for likely causes and Proton-side fixes.", + )) + env_bits = [b for b in (report.os_name, report.cpu_arch and f"{report.cpu_arch} CPU") if b] + if env_bits: + findings.append(Finding( + INFO, "Crash dump", "Dump environment", " · ".join(env_bits))) + return findings diff --git a/src/rigdoctor/gui/games_page.py b/src/rigdoctor/gui/games_page.py index 7f1176d..2ef3b8a 100644 --- a/src/rigdoctor/gui/games_page.py +++ b/src/rigdoctor/gui/games_page.py @@ -16,6 +16,7 @@ from PySide6.QtWidgets import ( QApplication, QCheckBox, QDialog, + QFileDialog, QFrame, QHBoxLayout, QLabel, @@ -29,6 +30,7 @@ from PySide6.QtWidgets import ( from ..config import load_config, update_config from .diagnostic_dialog import DiagnosticDialog +from .minidump_dialog import MinidumpDialog from .theme import ACCENT, GOOD, MUTED, WARN @@ -79,6 +81,7 @@ class GamesPage(QWidget): _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__() @@ -86,6 +89,7 @@ class GamesPage(QWidget): 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 @@ -103,6 +107,11 @@ class GamesPage(QWidget): 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) @@ -192,6 +201,7 @@ class GamesPage(QWidget): 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 ---------------------------------------------------------------------- @@ -450,6 +460,49 @@ class GamesPage(QWidget): 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: @@ -498,6 +551,7 @@ class GamesPage(QWidget): # 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 diff --git a/src/rigdoctor/gui/minidump_dialog.py b/src/rigdoctor/gui/minidump_dialog.py new file mode 100644 index 0000000..9c145e2 --- /dev/null +++ b/src/rigdoctor/gui/minidump_dialog.py @@ -0,0 +1,182 @@ +"""Results view for an imported crash dump (.dmp, M14): parsed summary + AI explanation. + +Mirrors :class:`DiagnosticDialog` — the same opt-in, streamed "Explain with AI" flow (D24), +applied to a Windows minidump parsed by :mod:`core.minidump` instead of a sensor capture. +""" + +from __future__ import annotations + +import threading +from pathlib import Path + +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QFont, QTextCursor +from PySide6.QtWidgets import ( + QDialog, + QFrame, + QHBoxLayout, + QLabel, + QMessageBox, + QPushButton, + QScrollArea, + QTextEdit, + QVBoxLayout, + QWidget, +) + +from ..core import minidump +from .widgets import finding_card + + +class MinidumpDialog(QDialog): + _chunk = Signal(str) # streamed token delta (worker thread -> GUI) + _explained = Signal(object) # (ok, full_text) when the AI stream finishes + + def __init__(self, report: minidump.MinidumpReport, parent=None) -> None: + super().__init__(parent) + self._report = report + self._stream_view = None + self._stream_status = None + self._chunk.connect(self._on_chunk) + self._explained.connect(self._on_explained) + name = Path(report.path).name + self.setWindowTitle(f"Crash dump — {name}") + self.resize(660, 680) + + root = QVBoxLayout(self) + root.setContentsMargins(20, 18, 20, 16) + root.setSpacing(14) + + title = QLabel(f"Crash dump — {name}") + title.setObjectName("PageTitle") + root.addWidget(title) + + scroll = QScrollArea() + scroll.setWidgetResizable(True) + scroll.setFrameShape(QFrame.Shape.NoFrame) + scroll.setStyleSheet("background: transparent;") + body = QWidget() + col = QVBoxLayout(body) + col.setContentsMargins(0, 0, 0, 0) + col.setSpacing(10) + col.setAlignment(Qt.AlignmentFlag.AlignTop) + + # Parsed summary (crash reason / faulting module / OS / CPU / modules) — monospace. + summary_head = QLabel("Dump summary") + summary_head.setStyleSheet("font-weight: 700; background: transparent;") + col.addWidget(summary_head) + summary = QLabel(minidump.to_text(report)) + summary.setObjectName("Report") + summary.setFont(QFont("monospace")) + summary.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse) + summary.setWordWrap(False) + summary.setStyleSheet( + "background: #0d0f13; color: #cfd3da; border: 1px solid #2a2f39; " + "border-radius: 8px; padding: 10px;" + ) + col.addWidget(summary) + + findings = minidump.to_findings(report) + find_head = QLabel(f"Findings ({len(findings)})") + find_head.setStyleSheet("font-weight: 700; background: transparent;") + col.addWidget(find_head) + for finding in findings: + col.addWidget(finding_card(finding)) + + if report.stackwalk: # only when an external stackwalker was available + sw_head = QLabel("minidump_stackwalk output") + sw_head.setStyleSheet("font-weight: 700; background: transparent;") + col.addWidget(sw_head) + sw = QTextEdit() + sw.setObjectName("Report") + sw.setReadOnly(True) + sw.setFont(QFont("monospace")) + sw.setPlainText(report.stackwalk) + sw.setMinimumHeight(160) + col.addWidget(sw) + + scroll.setWidget(body) + root.addWidget(scroll, 1) + + buttons = QHBoxLayout() + self._explain_btn = QPushButton("Explain with AI") + self._explain_btn.clicked.connect(self._explain_with_ai) + from ..core import ai + self._explain_btn.setVisible(ai.is_configured()) # opt-in only; hidden if not set up + buttons.addWidget(self._explain_btn) + buttons.addStretch(1) + close = QPushButton("Close") + close.setObjectName("PrimaryButton") + close.clicked.connect(self.accept) + buttons.addWidget(close) + root.addLayout(buttons) + + # --- AI explanation (M14, D24) — streamed; runs only on this button press ---------- + def _explain_with_ai(self) -> None: + from ..core import ai + + if not ai.is_local(): # cloud provider → explicit consent before sending data + confirm = QMessageBox.question( + self, "Send to AI provider", + f"This sends the parsed crash dump to {ai.provider_label()}.\n\nContinue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if confirm != QMessageBox.StandardButton.Yes: + return + self._explain_btn.setEnabled(False) + dialog = self._open_stream_dialog() + threading.Thread(target=self._work_explain, daemon=True).start() + dialog.exec() # streaming fills the view live via signals during this nested loop + self._stream_view = self._stream_status = None + self._explain_btn.setEnabled(True) + + def _work_explain(self) -> None: + from ..core import ai + + text = minidump.to_ai_text(self._report) + ok, reply = ai.explain_stream(text, on_chunk=lambda d: self._chunk.emit(d)) + self._explained.emit((ok, reply)) + + def _on_chunk(self, delta: str) -> None: + if self._stream_view is None: + return + self._stream_view.moveCursor(QTextCursor.MoveOperation.End) + self._stream_view.insertPlainText(delta) # live plain text as tokens arrive + self._stream_view.ensureCursorVisible() + + def _on_explained(self, result) -> None: + ok, text = result + if self._stream_view is not None: + if ok: + self._stream_view.setMarkdown(text) # re-render the finished answer as Markdown + else: + self._stream_view.setPlainText(f"AI explanation failed:\n\n{text}") + if self._stream_status is not None: + self._stream_status.setText( + "AI-generated suggestions — verify before acting, especially anything that changes " + "settings or data." if ok else "The request failed.") + + def _open_stream_dialog(self) -> QDialog: + """A live dialog the AI streams into; finalized to rendered Markdown when done.""" + from ..core import ai + + dlg = QDialog(self) + dlg.setWindowTitle(f"AI explanation — {ai.provider_label()}") + dlg.resize(620, 520) + lay = QVBoxLayout(dlg) + view = QTextEdit() + view.setObjectName("Report") + view.setReadOnly(True) + lay.addWidget(view) + status = QLabel("Streaming from the model…") + status.setObjectName("Muted") + status.setWordWrap(True) + lay.addWidget(status) + close = QPushButton("Close") + close.setObjectName("PrimaryButton") + close.clicked.connect(dlg.accept) + lay.addWidget(close, alignment=Qt.AlignmentFlag.AlignRight) + self._stream_view = view + self._stream_status = status + return dlg diff --git a/tests/test_minidump.py b/tests/test_minidump.py new file mode 100644 index 0000000..80eaaea --- /dev/null +++ b/tests/test_minidump.py @@ -0,0 +1,163 @@ +"""Tests for the .dmp minidump parser (M14) — builds a synthetic MDMP, no external tools.""" + +import struct +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from rigdoctor.core import minidump + + +def _synthetic_dump() -> bytes: + """A minimal but valid MDMP: header + SystemInfo + Exception + 2-module ModuleList. + + Layout (absolute file offsets): header@0, directory@32, SystemInfo@68, Exception@96, + ModuleList@264, name strings@484. Module0 spans the exception address, so it's faulting. + """ + buf = bytearray(600) + struct.pack_into("<4sIIIIIQ", buf, 0, b"MDMP", 0xA793, 3, 32, 0, 1_700_000_000, 0) + struct.pack_into("` parses then explains via the configured provider.""" + + def _args(self, **over): + import argparse + base = {"ai_cmd": "dump", "file": ""} + base.update(over) + return argparse.Namespace(**base) + + def test_dump_parses_and_explains(self): + from rigdoctor.core import ai + + with tempfile.NamedTemporaryFile(suffix=".dmp", delete=False) as fh: + fh.write(_synthetic_dump()) + path = fh.name + try: + with mock.patch.object(ai, "is_configured", return_value=True), \ + mock.patch.object(ai, "provider_label", return_value="Claude (test)"), \ + mock.patch.object(minidump, "stackwalk", return_value=""), \ + mock.patch.object(ai, "explain", return_value=(True, "Likely DXVK.")) as explain: + from rigdoctor import cli + rc = cli.cmd_ai(self._args(file=path)) + finally: + Path(path).unlink(missing_ok=True) + self.assertEqual(rc, 0) + sent = explain.call_args[0][0] + self.assertIn("Proton", sent) # the Linux/Proton framing reached the model + self.assertIn("game.exe", sent) + + def test_dump_bad_file_returns_error(self): + from rigdoctor.core import ai + + with mock.patch.object(ai, "is_configured", return_value=True): + from rigdoctor import cli + rc = cli.cmd_ai(self._args(file="/nope/missing.dmp")) + self.assertEqual(rc, 1) + + +if __name__ == "__main__": + unittest.main()