feat(ai): import & analyze Windows crash dumps (.dmp) — 0.41.0
tests / core (pull_request) Successful in 16s
tests / gui-smoke (pull_request) Successful in 27s

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>
This commit is contained in:
2026-05-25 18:39:52 +02:00
parent 04e8d72bce
commit 33c554c29f
10 changed files with 779 additions and 3 deletions
+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()