33c554c29f
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>
164 lines
6.4 KiB
Python
164 lines
6.4 KiB
Python
"""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()
|