From 04e8d72bcecdead2541fe10f7dba986c732bd3fd Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 17:00:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(memory):=20flag=20RAM=20below=20rated=20sp?= =?UTF-8?q?eed=20(XMP/EXPO=20not=20enabled)=20=E2=80=94=200.40.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inventory shows configured RAM speed + the rated speed when lower ('4800 MT/s (rated 5600)'); System Health flags it with the fix (enable XMP/EXPO in BIOS). With the profile off dmidecode only reports the JEDEC base, so the rated speed comes from dmidecode's max OR the part number, matched against known DDR5 speed grades to avoid false positives. inventory.module_speed() shared by both; needs dmidecode (root/launch elevation). +tests (incl. the user's CMK..5600 kit → (4800, 5600)). Completes the underperforming-hardware trio with PCIe gen + refresh rate. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 ++++++++ pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/core/health.py | 24 +++++++++++++++++++++ src/rigdoctor/core/inventory.py | 37 +++++++++++++++++++++++++++++++-- tests/test_health.py | 22 ++++++++++++++++++++ tests/test_inventory.py | 18 ++++++++++++++++ 7 files changed, 110 insertions(+), 4 deletions(-) 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()