diff --git a/CHANGELOG.md b/CHANGELOG.md index 32e6308..8f3e2bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ 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.40.0] - 2026-05-22 +### Added +- **RAM speed / XMP-EXPO check.** Inventory now shows each module's configured speed and, when it's + below the rated speed, the rating (e.g. `4800 MT/s (rated 5600)`); **System Health** flags it + ("RAM at 4800 MT/s (rated 5600 MT/s)") with the fix — enable XMP/EXPO in BIOS. With the profile + off, dmidecode only reports the JEDEC base, so the rated speed is read from both dmidecode and + the part number (matched against known DDR5 speed grades, so no false positives). Needs dmidecode + (root / launch elevation). Completes the "underperforming hardware" trio with PCIe gen + refresh. + ## [0.39.0] - 2026-05-22 ### Added - **Displays in the Inventory.** A new `core/displays.py` lists each connected monitor with its diff --git a/pyproject.toml b/pyproject.toml index 3a54b29..df39b4c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.39.0" +version = "0.40.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 798a259..94d561d 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.39.0" +__version__ = "0.40.0" diff --git a/src/rigdoctor/core/health.py b/src/rigdoctor/core/health.py index e271f13..9be430c 100644 --- a/src/rigdoctor/core/health.py +++ b/src/rigdoctor/core/health.py @@ -300,6 +300,29 @@ def check_displays() -> list[Finding]: return findings +def check_memory_speed() -> list[Finding]: + """Flag RAM running below its rated speed — i.e. the XMP (Intel) / EXPO (AMD) profile isn't + enabled, leaving memory bandwidth on the table. Needs dmidecode (root); silent without it.""" + from . import elevation, inventory + + priv = elevation.privileged() + dmi = priv["dmidecode"] if (priv and priv.get("dmidecode")) else inventory._dmidecode() + worst: tuple[int, int] | None = None # (configured, rated) with the biggest gap + for m in dmi.get("memory", []): + configured, rated = inventory.module_speed(m) + if configured and rated and configured < rated: + if worst is None or (rated - configured) > (worst[1] - worst[0]): + worst = (configured, rated) + if worst is None: + return [] + configured, rated = worst + return [Finding( + INFO, "Memory", f"RAM at {configured} MT/s (rated {rated} MT/s)", + f"Memory is running at {configured} MT/s but the modules are rated {rated} MT/s — the " + "XMP/EXPO profile isn't enabled, so you're leaving memory bandwidth on the table.", + "Enable XMP (Intel) or EXPO (AMD) in your BIOS/UEFI to run at the rated speed.")] + + def run_health_checks(include_journal: bool = True) -> list[Finding]: """Run all checks and return findings sorted by severity (worst first). @@ -324,5 +347,6 @@ def run_health_checks(include_journal: bool = True) -> list[Finding]: findings += check_live_temps() findings += check_pcie_links() findings += check_displays() + findings += check_memory_speed() # uses elevation data if present, else dmidecode (root) findings.sort(key=lambda f: _ORDER.get(f.severity, 9)) return findings diff --git a/src/rigdoctor/core/inventory.py b/src/rigdoctor/core/inventory.py index 2393f72..772a790 100644 --- a/src/rigdoctor/core/inventory.py +++ b/src/rigdoctor/core/inventory.py @@ -86,6 +86,35 @@ def _firmware(dmi: dict) -> Section: return Section("Firmware", items) +# Common DDR5 XMP/EXPO speed grades (MT/s) — used to read a kit's rated speed from its part +# number, since with XMP/EXPO off dmidecode only reports the JEDEC base (e.g. 4800). +_DDR_SPEEDS = {4800, 5200, 5600, 6000, 6200, 6400, 6600, 6800, 7000, 7200, 7600, 8000, 8200, 8400} + + +def _mts(value: str) -> int | None: + """Parse a dmidecode speed like '4800 MT/s' (or 'MHz') to its integer MT/s.""" + m = re.match(r"\s*(\d+)", value or "") + return int(m.group(1)) if m else None + + +def _rated_from_part(part: str) -> int | None: + """The highest known DDR speed-grade appearing as a 4-digit token in a part number.""" + grades = [int(n) for n in re.findall(r"(? tuple[int | None, int | None]: + """(configured, rated) MT/s for a dmidecode Memory Device. + + Configured = what it's actually running at; rated = the highest of dmidecode's reported max + and the part-number speed-grade (so an unapplied XMP/EXPO profile is still detected). + """ + configured = _mts(m.get("Configured Memory Speed") or m.get("Configured Clock Speed") or m.get("Speed", "")) + candidates = [s for s in (_mts(m.get("Speed", "")), _rated_from_part(m.get("Part Number", ""))) if s] + rated = max(candidates) if candidates else None + return configured, rated + + def _memory(dmi: dict) -> Section: items: list[tuple[str, str]] = [] try: @@ -99,8 +128,12 @@ def _memory(dmi: dict) -> Section: if modules: items.append(("Modules", str(len(modules)))) for i, m in enumerate(modules): - desc = " · ".join(p for p in (m.get("Size"), m.get("Type"), m.get("Speed"), m.get("Part Number")) if p) - items.append((f"Slot {i}", desc)) + configured, rated = module_speed(m) + speed = f"{configured} MT/s" if configured else m.get("Speed", "") + if rated and configured and rated > configured: # XMP/EXPO not applied + speed += f" (rated {rated})" + parts = (m.get("Size"), m.get("Type"), speed, m.get("Part Number")) + items.append((f"Slot {i}", " · ".join(p for p in parts if p))) elif shutil.which("dmidecode"): items.append(("Modules", "run with admin for module details")) return Section("Memory", items) diff --git a/tests/test_health.py b/tests/test_health.py index 6485017..4d6078d 100644 --- a/tests/test_health.py +++ b/tests/test_health.py @@ -10,6 +10,7 @@ from rigdoctor.core.health import ( INFO, WARNING, check_displays, + check_memory_speed, check_pcie_links, run_health_checks, scan_journal_text, @@ -97,5 +98,26 @@ class DisplayCheckTests(unittest.TestCase): self.assertEqual(check_displays(), []) +class MemorySpeedCheckTests(unittest.TestCase): + def _dmi(self, configured, part): + return {"memory": [{"Configured Memory Speed": configured, "Speed": configured, + "Part Number": part}]} + + def test_flags_unapplied_expo(self): + dmi = self._dmi("4800 MT/s", "CMK32GX5M2B5600Z36") + with mock.patch("rigdoctor.core.elevation.privileged", return_value=None), \ + mock.patch("rigdoctor.core.inventory._dmidecode", return_value=dmi): + findings = check_memory_speed() + self.assertEqual(len(findings), 1) + self.assertEqual(findings[0].severity, INFO) + self.assertIn("5600", findings[0].title) + + def test_no_flag_at_rated(self): + dmi = self._dmi("5600 MT/s", "CMK32GX5M2B5600Z36") + with mock.patch("rigdoctor.core.elevation.privileged", return_value=None), \ + mock.patch("rigdoctor.core.inventory._dmidecode", return_value=dmi): + self.assertEqual(check_memory_speed(), []) + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_inventory.py b/tests/test_inventory.py index fc78804..44702dd 100644 --- a/tests/test_inventory.py +++ b/tests/test_inventory.py @@ -54,5 +54,23 @@ class PcieLinkTests(unittest.TestCase): self.assertEqual(inventory._nvme_link("sda"), "") +class MemorySpeedTests(unittest.TestCase): + def test_rated_speed_from_part_number(self): + self.assertEqual(inventory._rated_from_part("CMK32GX5M2B5600Z36"), 5600) + self.assertEqual(inventory._rated_from_part("F5-6000J3038F16G"), 6000) + self.assertIsNone(inventory._rated_from_part("NoSpeedHere")) + + def test_detects_unapplied_expo(self): + # XMP/EXPO off: dmidecode only sees JEDEC 4800; the 5600 is in the part number. + m = {"Configured Memory Speed": "4800 MT/s", "Speed": "4800 MT/s", + "Part Number": "CMK32GX5M2B5600Z36"} + self.assertEqual(inventory.module_speed(m), (4800, 5600)) + + def test_at_rated_speed(self): + m = {"Configured Memory Speed": "5600 MT/s", "Speed": "5600 MT/s", + "Part Number": "CMK32GX5M2B5600Z36"} + self.assertEqual(inventory.module_speed(m), (5600, 5600)) + + if __name__ == "__main__": unittest.main()