feat(memory): flag RAM below rated speed (XMP/EXPO not enabled) — 0.40.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 17:00:02 +02:00
parent b006fa6b8d
commit 04e8d72bce
7 changed files with 110 additions and 4 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.39.0"
__version__ = "0.40.0"
+24
View File
@@ -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
+35 -2
View File
@@ -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"(?<!\d)(\d{4})(?!\d)", part or "") if int(n) in _DDR_SPEEDS]
return max(grades) if grades else None
def module_speed(m: dict) -> 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)