Merge pull request 'feat(ai): import & analyze Windows crash dumps (.dmp) — 0.41.0' (#45) from feat/ram-speed into main
release / test (push) Successful in 12s
release / release (push) Successful in 14s

Reviewed-on: #45
This commit was merged in pull request #45.
This commit is contained in:
2026-05-25 16:41:03 +00:00
10 changed files with 779 additions and 3 deletions
+16
View File
@@ -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 <file>`.
## [0.40.0] - 2026-05-22
### Added
- **RAM speed / XMP-EXPO check.** Inventory now shows each module's configured speed and, when it's
+2 -1
View File
@@ -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.
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.40.0"
__version__ = "0.41.0"
+17
View File
@@ -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)")
+29
View File
@@ -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."),
]
+314
View File
@@ -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("<III") # stream_type, data_size, data_rva
_SYSINFO = struct.Struct("<HHHBBIIIII") # arch, lvl, rev, n_cpu, prod, maj, min, build, plat, csd
_MODULE_STRIDE = 108 # sizeof(MINIDUMP_MODULE)
# Stream types we read (MINIDUMP_STREAM_TYPE).
_MODULE_LIST = 4
_EXCEPTION = 6
_SYSTEM_INFO = 7
_COMMENT_A = 10
_COMMENT_W = 11
_ARCH = {0: "x86", 5: "ARM", 6: "IA-64", 9: "x86-64", 12: "ARM64", 0xFFFF: "unknown"}
_PLATFORM = {0x8201: "Linux", 0x8202: "Solaris", 0x8203: "macOS", 0x8204: "iOS",
0x8205: "Android", 0x8207: "NaCl"}
# Common Windows exception (NTSTATUS) codes — what the model needs named, not raw hex.
_EXCEPTION_NAMES = {
0x80000003: "Breakpoint",
0x80000004: "Single step",
0xC0000005: "Access violation",
0xC0000006: "In-page error",
0xC000001D: "Illegal instruction",
0xC0000025: "Noncontinuable exception",
0xC000008C: "Array bounds exceeded",
0xC000008E: "Float divide by zero",
0xC0000090: "Float invalid operation",
0xC0000094: "Integer divide by zero",
0xC0000095: "Integer overflow",
0xC0000096: "Privileged instruction",
0xC00000FD: "Stack overflow",
0xC0000135: "DLL not found",
0xC0000142: "DLL initialization failed",
0xC0000374: "Heap corruption",
0xC0000409: "Stack buffer overrun / fast fail",
0xC000041D: "Fatal user-callback exception",
0xE06D7363: "C++ exception (MSVC)",
}
_ACCESS = {0: "reading", 1: "writing", 8: "executing"} # AV ExceptionInformation[0]
_STACKWALK_BINS = ("minidump_stackwalk", "minidump-stackwalk")
_MODULES_SHOWN = 80 # cap the module list so the AI prompt stays bounded
@dataclass
class Module:
name: str # basename only
base: int
size: int
@dataclass
class MinidumpReport:
path: str
ok: bool = False
error: str = ""
crash_reason: str = ""
exception_code: int | None = None
exception_address: int | None = None
faulting_module: str | None = None
crashing_thread: int | None = None
os_name: str = ""
cpu_arch: str = ""
cpu_count: int = 0
timestamp: int | None = None
modules: list[Module] = field(default_factory=list)
comment: str = ""
stackwalk: str = ""
def parse(path, *, run_stackwalk: bool = True) -> 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("<I", data, rva)
base_off = rva + 4
modules: list[Module] = []
for i in range(count):
rec = base_off + i * _MODULE_STRIDE
if rec + _MODULE_STRIDE > len(data):
break
base, = struct.unpack_from("<Q", data, rec)
size, = struct.unpack_from("<I", data, rec + 8)
name_rva, = struct.unpack_from("<I", data, rec + 20)
modules.append(Module(_read_mdstring(data, name_rva), base, size))
return modules
def _read_exception(data: bytes, loc, report: MinidumpReport) -> None:
if not loc:
return
_size, rva = loc
thread_id, = struct.unpack_from("<I", data, rva) # MINIDUMP_EXCEPTION_STREAM
code, = struct.unpack_from("<I", data, rva + 8) # ExceptionRecord.ExceptionCode
address, = struct.unpack_from("<Q", data, rva + 24) # ExceptionRecord.ExceptionAddress
n_params, = struct.unpack_from("<I", data, rva + 32)
report.crashing_thread = thread_id
report.exception_code = code
report.exception_address = address
report.crash_reason = _describe_exception(data, rva, code, n_params)
def _describe_exception(data: bytes, rva: int, code: int, n_params: int) -> 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("<Q", data, rva + 40)[0] # ExceptionInformation[0]
addr = struct.unpack_from("<Q", data, rva + 48)[0] # ExceptionInformation[1]
reason += f" {_ACCESS.get(op, 'accessing')} 0x{addr:X}"
return reason
def _read_mdstring(data: bytes, rva: int) -> 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("<I", data, rva)
start = rva + 4
raw = data[start:start + length]
text = raw.decode("utf-16-le", "replace").strip("\x00")
return text.replace("\\", "/").rsplit("/", 1)[-1] or text
def _read_comment(data: bytes, streams: dict[int, tuple[int, int]]) -> 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
+54
View File
@@ -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
+182
View File
@@ -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
+163
View File
@@ -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("<III", buf, 32, 7, 28, 68) # SystemInfoStream
struct.pack_into("<III", buf, 44, 6, 168, 96) # ExceptionStream
struct.pack_into("<III", buf, 56, 4, 220, 264) # ModuleListStream
# SystemInfo: x86-64, 16 CPUs, Windows 10.0.19041 (PlatformId 2 = Win32 NT).
struct.pack_into("<HHHBBIIIII", buf, 68, 9, 0, 0, 16, 1, 10, 0, 19041, 2, 0)
# Exception: access violation (write) at 0x140001234.
struct.pack_into("<I", buf, 96, 4321) # ThreadId
struct.pack_into("<I", buf, 96 + 8, 0xC0000005) # ExceptionCode
struct.pack_into("<Q", buf, 96 + 24, 0x140001234) # ExceptionAddress
struct.pack_into("<I", buf, 96 + 32, 2) # NumberParameters
struct.pack_into("<Q", buf, 96 + 40, 1) # info[0] = write
struct.pack_into("<Q", buf, 96 + 48, 0x0) # info[1] = faulting address
# ModuleList: 2 modules.
struct.pack_into("<I", buf, 264, 2)
m0, m1 = 268, 268 + minidump._MODULE_STRIDE
struct.pack_into("<Q", buf, m0, 0x140000000) # base
struct.pack_into("<I", buf, m0 + 8, 0x100000) # size (spans the exception address)
struct.pack_into("<I", buf, m0 + 20, 484) # name RVA
struct.pack_into("<Q", buf, m1, 0x180000000)
struct.pack_into("<I", buf, m1 + 8, 0x080000)
struct.pack_into("<I", buf, m1 + 20, 522)
name0 = "C:\\Games\\game.exe".encode("utf-16-le")
struct.pack_into("<I", buf, 484, len(name0))
buf[488:488 + len(name0)] = name0
name1 = "nvwgf2umx.dll".encode("utf-16-le")
struct.pack_into("<I", buf, 522, len(name1))
buf[526:526 + len(name1)] = name1
return bytes(buf)
class ParseTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.NamedTemporaryFile(suffix=".dmp", delete=False)
self._tmp.write(_synthetic_dump())
self._tmp.close()
self.path = self._tmp.name
def tearDown(self):
Path(self.path).unlink(missing_ok=True)
def _parse(self):
return minidump.parse(self.path, run_stackwalk=False)
def test_parses_exception_and_faulting_module(self):
r = self._parse()
self.assertTrue(r.ok, r.error)
self.assertEqual(r.exception_code, 0xC0000005)
self.assertIn("Access violation", r.crash_reason)
self.assertIn("writing 0x0", r.crash_reason)
self.assertEqual(r.faulting_module, "game.exe") # basename, address inside module0
self.assertEqual(r.crashing_thread, 4321)
def test_parses_system_info_and_modules(self):
r = self._parse()
self.assertEqual(r.os_name, "Windows 10.0.19041")
self.assertEqual(r.cpu_arch, "x86-64")
self.assertEqual(r.cpu_count, 16)
self.assertEqual([m.name for m in r.modules], ["game.exe", "nvwgf2umx.dll"])
def test_to_text_and_ai_text(self):
r = self._parse()
text = minidump.to_text(r)
self.assertIn("game.exe", text)
self.assertIn("nvwgf2umx.dll", text)
self.assertIn("Access violation", text)
ai_text = minidump.to_ai_text(r)
self.assertIn("Proton", ai_text) # Linux/Proton framing for the model
self.assertIn("Crash reason", ai_text)
def test_to_findings(self):
findings = minidump.to_findings(self._parse())
self.assertEqual(findings[0].severity, minidump.CRITICAL)
self.assertIn("game.exe", findings[0].title)
def test_run_stackwalk_false_skips_external_tool(self):
self.assertEqual(self._parse().stackwalk, "")
class RobustnessTests(unittest.TestCase):
def test_non_minidump_file(self):
with tempfile.NamedTemporaryFile(suffix=".dmp", delete=False) as fh:
fh.write(b"not a dump at all")
path = fh.name
try:
r = minidump.parse(path, run_stackwalk=False)
finally:
Path(path).unlink(missing_ok=True)
self.assertFalse(r.ok)
self.assertIn("signature", r.error)
def test_missing_file(self):
r = minidump.parse("/nonexistent/does-not-exist.dmp", run_stackwalk=False)
self.assertFalse(r.ok)
self.assertIn("can't read", r.error)
def test_stackwalk_absent_returns_empty(self):
with mock.patch.object(minidump.shutil, "which", return_value=None):
self.assertEqual(minidump.stackwalk("/whatever.dmp"), "")
class CliDumpTests(unittest.TestCase):
"""`rigdoctor ai dump <file>` 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()