Compare commits

..

35 Commits

Author SHA1 Message Date
jessey 410f8882ee Merge pull request 'feat(ai): import & analyze Windows crash dumps (.dmp) — 0.41.0' (#45) from feat/ram-speed into main
release / test (push) Successful in 12s
release / release (push) Successful in 14s
Reviewed-on: #45
2026-05-25 16:41:03 +00:00
jessey 1da7816741 Merge branch 'main' into feat/ram-speed
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 26s
2026-05-25 16:40:10 +00:00
jessey 33c554c29f 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>
2026-05-25 18:39:52 +02:00
jessey 31178bace8 Merge pull request 'feat(memory): flag RAM below rated speed (XMP/EXPO not enabled) — 0.40.0' (#44) from feat/ram-speed into main
release / test (push) Successful in 13s
release / release (push) Successful in 16s
Reviewed-on: #44
2026-05-22 15:00:25 +00:00
jessey 04e8d72bce 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>
2026-05-22 17:00:02 +02:00
jessey fb468e83c2 Merge pull request 'feat(displays): monitors w/ resolution+refresh in Inventory; flag sub-max refresh in Health — 0.39.0' (#43) from feat/displays into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #43
2026-05-22 14:56:15 +00:00
jessey b006fa6b8d feat(displays): monitors w/ resolution+refresh in Inventory; flag sub-max refresh in Health — 0.39.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
New core/displays.py reads connected monitors via GNOME Mutter DisplayConfig over
D-Bus (busctl --json; works on X11 + Wayland), falling back to xrandr on other X11
desktops. Inventory's Display section now lists each monitor's resolution + current
refresh (e.g. 'DP-1 · Samsung LC34G55T: 3440x1440 @ 165 Hz'). System Health
(check_displays) flags a monitor running below its max refresh AT THE CURRENT
resolution (e.g. 165 Hz panel set to 60 Hz) — never suggests lowering resolution.
+tests (Mutter JSON + xrandr parsers, health check).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:55:33 +02:00
jessey b20e8dfc3a Merge pull request 'docs: public apt setup — one-line source with arch=all (no token, no notices)' (#42) from docs/public-registry into main
release / test (push) Successful in 11s
release / release (push) Successful in 16s
Reviewed-on: #42
2026-05-22 14:52:02 +00:00
jessey 9fe9a6576f Merge pull request 'feat(health): flag NVMe PCIe links below capability (lane-sharing) — 0.38.0' (#41) from feat/inventory-pcie into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #41
2026-05-22 14:51:12 +00:00
jessey 07bc722209 feat(health): flag NVMe PCIe links below capability (lane-sharing) — 0.38.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
check_pcie_links() warns when an NVMe drive negotiates fewer lanes than it
supports — almost always motherboard lane-sharing (a GPU/second card or another
M.2 stealing lanes), the case the user asked about — and reports speed-only
reductions as info (slower slot / idle ASPM). GPU is excluded: NVIDIA drops its
PCIe gen+width at idle, so a snapshot would false-alarm. Reuses inventory
read_link/nvme_controllers (refactored to public). Wired into run_health_checks;
+tests. Folded into the 0.38.0 PCIe work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:49:47 +02:00
jessey d405bf7caf Merge pull request 'feat(inventory): show NVMe PCIe link gen/width, flag downtrains — 0.38.0' (#40) from feat/inventory-pcie into main
release / test (push) Successful in 13s
release / release (push) Successful in 15s
Reviewed-on: #40
2026-05-22 14:45:46 +00:00
jessey 9bb0f9a684 feat(inventory): show NVMe PCIe link gen/width, flag downtrains — 0.38.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
Each NVMe drive's Inventory entry now shows its negotiated PCIe link (e.g.
'· PCIe Gen4 x4') from sysfs (current/max link speed+width), and flags drives
running below their capability ('Gen3 x4 (capable of Gen4 x4)') — so you can
confirm a Gen4 SSD is in a Gen4 slot. SATA disks show no PCIe link. Renders in
the GUI Inventory, CLI, and the Markdown/JSON export automatically. +tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:45:08 +02:00
jessey 4bbc0fa97e Merge pull request 'docs: add Dashboard/Inventory/Share screenshots to the README' (#39) from docs/readme-screenshots into main
release / test (push) Successful in 12s
release / release (push) Successful in 15s
Reviewed-on: #39
2026-05-22 14:43:13 +00:00
jessey a0f8055328 docs: add Dashboard/Inventory/Share screenshots to the README
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 26s
Adds assets/screenshots/{dashboard,inventory,share}.png and a Screenshots section
(Dashboard + Inventory side by side, Share below).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:40:47 +02:00
jessey 323451428b Merge pull request 'fix(update): route the self-update by install kind (apt/pip/source) — 0.37.1' (#38) from fix/updater-by-install into main
release / test (push) Successful in 11s
release / release (push) Successful in 16s
Reviewed-on: #38
2026-05-22 14:40:19 +00:00
jessey 479189ee4e fix(update): route the self-update by install kind (apt/pip/source) — 0.37.1
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
rigdoctor update assumed a pip/venv install and ran 'python -m pip install', which
fails on a .deb (system python has no pip; you can't pip-upgrade a dpkg package).
Add updates.install_kind() (dpkg ownership / venv / source-checkout detection,
cached) and route apply_update: pip self-updates in place; apt and source installs
return guidance instead. CLI and the GUI Update button show the apt/git command.
Adds tests/test_updates.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:39:42 +02:00
jessey 51133e4042 Merge pull request 'feat(gui): scrollable pages + version footer — 0.37.0' (#37) from fix/scrollable-pages into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #37
2026-05-22 14:29:56 +00:00
jessey bcf6ac2656 feat(gui): scrollable pages + version footer — 0.37.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 31s
Wrap each page (except self-scrolling Dashboard/Health/Inventory and the Share
terminal) in a QScrollArea, so long pages scroll when too tall (Settings'
Uninstall is reachable again) and the window is no longer pinned to the tallest
page's height — min height drops from >screen to ~600px, so it can be resized
smaller. Add a bottom footer showing 'RigDoctor v<version>' bottom-right (moved
out of the sidebar); themed #Footer with a top border.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:29:14 +02:00
jessey 81c7757546 docs: public apt setup — one-line source with arch=all (no token, no notices)
tests / core (pull_request) Successful in 11s
tests / gui-smoke (pull_request) Successful in 27s
Registry is public now: drop the token/auth.conf.d, use the signed-by one-line
source with arch=all so apt doesn't emit 'doesn't support architecture amd64'
notices (our package is Architecture: all). Drop the curl|sh bootstrap idea.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 16:05:03 +02:00
jessey d59261f021 Merge pull request 'docs: registry is public now — drop the token/auth.conf.d from apt setup' (#36) from docs/public-registry into main
release / test (push) Successful in 13s
release / release (push) Successful in 15s
Reviewed-on: #36
2026-05-22 13:58:13 +00:00
jessey 44923b771a docs: registry is public now — drop the token/auth.conf.d from apt setup
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
REQUIRE_SIGNIN_VIEW is off and the repo is public, so anonymous apt works. The
apt instructions no longer need a read:package token or auth.conf.d — just the
signing key + a deb822 Signed-By source.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:57:40 +02:00
jessey eaaf14c58a Merge pull request 'fix(cli): correct the missing-PySide6 hint to the real apt packages — 0.36.1' (#35) from docs/apt-proper into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #35
2026-05-22 13:49:28 +00:00
jessey 7779131cf9 Merge branch 'main' into docs/apt-proper
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 27s
2026-05-22 13:48:36 +00:00
jessey 87fa678ccb fix(cli): correct the missing-PySide6 hint to the real apt packages — 0.36.1
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 26s
rigdoctor gui suggested 'apt install python3-pyside6' (no such package on
Debian/Ubuntu). Point to the split modules instead.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:48:20 +02:00
jessey c5e24b3984 Merge pull request 'docs: document the proper (GPG-verified, deb822) apt setup' (#34) from docs/apt-proper into main
release / test (push) Successful in 12s
release / release (push) Successful in 14s
Reviewed-on: #34
2026-05-22 13:46:10 +00:00
jessey 21cc6a4813 docs: document the proper (GPG-verified, deb822) apt setup
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 27s
Replace the trusted=yes apt instructions with the proper method: read:package
token, registry signing key dearmored into /etc/apt/keyrings, credentials in
auth.conf.d, and a modern deb822 .sources file with Signed-By + Architectures:
all. Keeps the trusted=yes one-liner as a noted fallback for unsigned registries.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:44:41 +02:00
jessey ee73049248 Merge pull request 'fix(deb): auto-install all deps — correct PySide6 names + bundle tools — 0.36.0' (#33) from fix/deb-pyside6-deps into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #33
2026-05-22 13:39:01 +00:00
jessey 3a8ad5bd5d fix(deb): auto-install all deps — correct PySide6 names + bundle tools — 0.36.0
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 29s
The old Recommends named python3-pyside6 (no such package on Debian/Ubuntu —
PySide6 is split per module), so apt skipped it and the GUI couldn't start.
Now Recommends the real modules (python3-pyside6.qt{widgets,gui,websockets,svg}
+ python3-pyte) AND the optional diagnostic/gaming tools (smartmontools,
lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode,
mangohud), so 'apt install rigdoctor' sets up the whole toolset automatically —
no manual installs. cpupower -> Suggests. Verified all candidates resolve in apt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:38:12 +02:00
jessey e8b84bf046 Merge pull request 'docs: rewrite README to be user-first (install + use)' (#32) from docs/readme-users into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #32
2026-05-22 13:32:41 +00:00
jessey 2342dd83aa docs: rewrite README to be user-first (install + use)
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 29s
Lead with what RigDoctor does, then install (.deb/apt incl. the private-registry
auth.conf.d + trusted=yes notes, and the .run), then usage (GUI/tray/CLI),
requirements, and privacy. Move the dev content (from-source, tests, docs links)
into a short Development section at the end. Drops the stale status/decisions/
repo-layout planning sections from the top.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:31:36 +02:00
jessey a028fe6d38 Merge pull request 'ci: make apt registry upload idempotent (tolerate 409)' (#31) from fix/apt-409 into main
release / test (push) Successful in 12s
release / release (push) Successful in 16s
Reviewed-on: #31
2026-05-22 13:26:47 +00:00
jessey a6453335e9 ci: make apt registry upload idempotent (tolerate 409)
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 28s
Gitea's Debian registry is immutable, so re-uploading an existing version returns
409. With --fail that aborted the release on any re-run / repeat push at the same
version. Now we capture the HTTP code: 2xx = uploaded, 409 = already published
(skip), anything else = fail with the body. Also fixed the stale skip message
(REGISTRY_TOKEN, not PACKAGES_TOKEN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:21:27 +02:00
jessey baec47dd4e Merge pull request 'assets: project avatar (gauge + heartbeat) for Gitea' (#30) from chore/avatar into main
release / test (push) Successful in 12s
release / release (push) Failing after 15s
Reviewed-on: #30
2026-05-22 13:18:59 +00:00
jessey 47ecb702e7 Merge branch 'main' into chore/avatar
tests / core (pull_request) Successful in 12s
tests / gui-smoke (pull_request) Successful in 28s
2026-05-22 13:17:28 +00:00
jessey dc719f6a89 assets: project avatar (gauge + heartbeat) for Gitea
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 27s
512x512 PNG (assets/avatar.png) rendered from assets/avatar.svg, matching the app
icon's gauge-ring + heartbeat motif on a dark gradient. Upload as the repo avatar.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 15:16:58 +02:00
27 changed files with 1669 additions and 138 deletions
+8 -2
View File
@@ -113,13 +113,19 @@ jobs:
run: |
set -euo pipefail
if [ -z "${PKG_TOKEN:-}" ]; then
echo "PACKAGES_TOKEN not set — skipping apt publish (the .deb is still a release asset)."
echo "REGISTRY_TOKEN not set — skipping apt publish (the .deb is still a release asset)."
exit 0
fi
OWNER="${{ github.repository_owner }}"
URL="${{ github.server_url }}/api/packages/${OWNER}/debian/pool/stable/main/upload"
for f in dist/*.deb; do
echo "Uploading $(basename "$f") to the apt registry…"
curl -sS --fail --user "${OWNER}:${PKG_TOKEN}" --upload-file "$f" "$URL"
code=$(curl -sS -o /tmp/apt_upload.txt -w '%{http_code}' \
--user "${OWNER}:${PKG_TOKEN}" --upload-file "$f" "$URL" || true)
case "$code" in
2*) echo " uploaded ($code)";;
409) echo " already published ($code) — skipping (registry versions are immutable)";;
*) echo " upload failed ($code):"; cat /tmp/apt_upload.txt || true; exit 1;;
esac
done
echo "apt source: deb ${{ github.server_url }}/api/packages/${OWNER}/debian stable main"
+86
View File
@@ -5,6 +5,92 @@ 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.41.0] - 2026-05-25
### Added
- **Import a crash dump (`.dmp`) and explain it with AI.** The **Games** page gains an
"Import crash dump…" button (shown once an AI provider is configured) that opens a Windows
minidump — the kind a Proton/Wine game writes when it hard-crashes — parses it, and hands the
result to the opt-in AI assistant (D24; cloud sends still ask first). A new stdlib
`core/minidump.py` reads the `MDMP` streams with `struct` (no new deps): the exception / crash
reason (e.g. access violation `0xC0000005`), the **faulting module** (which DLL the crash
address lands in — `nvwgf2umx.dll`, `d3d11.dll`, an anticheat, the game's own `.exe`…), OS/CPU,
and the loaded-module list. If `minidump_stackwalk` (Breakpad) or `minidump-stackwalk`
(rust-minidump) is on PATH, its fuller report is appended best-effort. The model is told the
dump came from a Windows process under Proton, so fixes stay Linux/Proton-side (Proton version,
DXVK/VKD3D, driver, launch options) — never Windows admin/registry steps. New `ai_knowledge`
facts cover the common exception codes and faulting-module signatures. CLI parity:
`rigdoctor ai dump <file>`.
## [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
resolution and current/max refresh — e.g. `DP-1 · Samsung LC34G55T → 3440x1440 @ 165 Hz`. Reads
GNOME's Mutter `DisplayConfig` over D-Bus (works on X11 *and* Wayland), falling back to `xrandr`
on other X11 desktops.
- **System Health flags monitors below their max refresh.** If a monitor supports a higher refresh
at its current resolution (e.g. a 165 Hz panel set to 60 Hz — an easily-missed gaming setting),
Health reports it with the fix (raise it in Display settings). Max is computed at the *current*
resolution, so it never suggests dropping resolution.
## [0.38.0] - 2026-05-22
### Added
- **PCIe link in the Inventory.** Each NVMe drive now shows its negotiated PCIe link next to the
model — e.g. `Samsung SSD 980 PRO 1TB (931.5G) · PCIe Gen4 x4` — read from sysfs
(`current/max_link_speed` + width). If a drive negotiates below its capability (a slower M.2
slot, lane-sharing, or a downtrain) it's flagged: `PCIe Gen3 x4 (capable of Gen4 x4)`. So you
can confirm a Gen4 SSD is actually in a Gen4 slot. (SATA disks show no PCIe link.)
- **System Health flags downtrained NVMe links.** A new check warns when an NVMe drive negotiates
fewer PCIe lanes than it supports (almost always motherboard **lane-sharing** — a GPU/second
card or another M.2 stealing lanes) and notes speed-only reductions as info (a slower slot or
idle ASPM). The GPU is deliberately excluded — NVIDIA drops its PCIe gen/width at idle, so a
snapshot would false-alarm.
## [0.37.1] - 2026-05-22
### Fixed
- **`rigdoctor update` now uses the right method for how RigDoctor was installed.** It detects
apt (`.deb`), pip (venv/`.run`), or source installs (`updates.install_kind()`); only pip
installs self-update in place. An apt install no longer fails with "No module named pip" —
it (and the GUI Update button) shows `sudo apt update && sudo apt install --only-upgrade
rigdoctor`; a source checkout points to `git pull`.
## [0.37.0] - 2026-05-22
### Added
- **Version footer** — a footer across the bottom of the window shows `RigDoctor v<version>` in
the bottom-right (moved out of the sidebar).
### Fixed
- **Pages scroll when content doesn't fit, and the window is no longer pinned to the tallest
page's height.** Long pages (Settings, Tuning, …) get a scrollbar when too tall — so controls
like Uninstall are always reachable — and the window can now be resized smaller than the screen
(min height dropped from "taller than the screen" to ~600px). Pages that manage their own
scroll/fill (Dashboard, System Health, Inventory, Share) are unchanged.
## [0.36.1] - 2026-05-22
### Fixed
- `rigdoctor gui` printed the wrong fix when PySide6 is missing — it suggested the non-existent
`python3-pyside6` package. Now it names the real split modules
(`python3-pyside6.qt{widgets,gui,websockets,svg}` + `python3-pyte`).
## [0.36.0] - 2026-05-22
### Fixed
- **`.deb` now installs all dependencies automatically — no manual tool install.** The previous
`Recommends: python3-pyside6` named a package that doesn't exist on Debian/Ubuntu (PySide6 is
split per module), so apt silently skipped it and the GUI wouldn't start. Now it Recommends the
actual modules the GUI imports — `python3-pyside6.qt{widgets,gui,websockets,svg}` + `python3-pyte`.
### Changed
- **`apt install rigdoctor` sets up the whole toolset.** The `.deb` also Recommends the optional
diagnostic/gaming tools (smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin,
libsecret-tools, gamemode, mangohud) so they install by default — users never hand-install
tools. `cpupower` is a Suggests (kernel-tied); `--no-install-recommends` still gives CLI-only.
## [0.35.0] - 2026-05-22
### Added
- **`.deb` package (M9 / D8)** — `packaging/make_deb.py` builds a `rigdoctor_<version>_all.deb`
+98 -113
View File
@@ -1,152 +1,137 @@
# RigDoctor
A **modular diagnostics, monitoring, and health-check toolkit for Linux gamers.**
**Hardware monitoring & crash diagnostics for Linux gamers.** Live sensors, crash-safe
logging, plain-language health reports, per-game diagnostics, and optional AI explanations —
in a desktop app, a tray applet, or the terminal. Ubuntu/Debian + NVIDIA first.
> **Status:** 🟢 Phase 1 (MVP) complete. The **sensor core (M1)**, **crash-capture logger
> (M3)**, and **health report (M4)** all work — live `snapshot`/`monitor`, crash-safe `record`
> with a post-crash report, and `report` to scan logs/SMART/driver for likely causes. A
> desktop GUI (M10) ties them together (dashboard, recording, health). See `docs/ROADMAP.md`.
Linux gaming faults are hard to pin down — GPUs falling off the PCIe bus, black screens
mid-game, silent thermal/VRAM throttling, driver/Proton mismatches. The useful data is
scattered across `nvidia-smi`, `/sys`, `journalctl`, and SMART, and the readings right before a
freeze are usually lost. RigDoctor pulls it together and keeps the evidence.
## Why this exists
## Features
Linux gaming hardware faults are hard to diagnose: GPUs falling off the PCIe bus, the screen
suddenly going black mid-game, silent thermal/VRAM throttling, power transients,
driver/library mismatches, Proton quirks, and CPU governor / power-profile misconfiguration.
The data needed to diagnose them is scattered across `nvidia-smi`, `/sys/class/hwmon`,
`journalctl`, SMART, and more — and the most useful readings (the ones right before a hard
freeze) are usually lost because nothing flushed them to disk.
- **Live monitoring** — a dark desktop **dashboard** (history graphs + per-subsystem cards), a
**tray applet** with at-a-glance status, and a terminal view (`rigdoctor monitor`).
- **Crash-safe recording** — background logger that `fsync`s every sample, so the state right
before a hard freeze survives. Manual, always-on, or auto-start when a game launches.
- **Health report** — scans `journalctl`/SMART/driver for likely causes (Xid, OOM, disk
errors, throttling…) and explains them with suggested fixes.
- **Per-game diagnostics** — pick a game, capture while you play, get a focused report; hard
crashes are detected and analysed on next launch.
- **Gaming tune-ups** — flags risky settings (CPU governor, PCIe ASPM, persistence mode…) with
**one-click, reversible fixes**.
- **Proactive alerts** — desktop notifications on overheating and critical kernel events
(GPU-lost, Xid, out-of-memory, disk I/O).
- **AI explanations** *(optional, opt-in)* — explain a diagnostic in plain language with a
**local model (Ollama)** or **Claude**, or **import a Windows crash dump (`.dmp`)** from a
Proton game and have it parsed and analysed. Never automatic; only when you press the button.
- **Shareable reports** — zip a diagnostic (logs, inventory, AI transcript) to hand to someone,
or share a live **terminal session** for remote help.
- **Self-updating** — `apt upgrade`, or the in-app updater.
RigDoctor pulls all of that into one modular tool: live monitoring, crash-safe logging, a
one-shot health report, and an interactive installer that only sets up the modules a given
user actually needs for their hardware.
## Screenshots
**Seed use cases:** an RTX 3070 that intermittently "falls off the bus" under heavy GPU load
(Path of Exile on Linux, Escape from Tarkov on Windows), and a monitor going black mid-game.
See `docs/SPEC.md` §1.
| Dashboard | Inventory |
|---|---|
| ![Dashboard — live sensors](assets/screenshots/dashboard.png) | ![Inventory — hardware/OS](assets/screenshots/inventory.png) |
## How you run it
**Share** — a read-only or interactive terminal session over the relay, for remote help:
RigDoctor is **GUI-first** — the desktop app is the primary way in — but every feature is
also available headless:
- **Desktop GUI** — graphical dashboard, recording controls, log browser, reports. The
default interface for most users.
- **Tray applet** — a small top-menu-bar applet with quick actions and at-a-glance status.
- **CLI** — full functionality from the terminal; works over SSH and in scripts.
![Share — shared terminal session](assets/screenshots/share.png)
The GUI/tray are optional modules; a headless (CLI-only) install loses no capability.
## Install
## Key decisions (settled)
### Debian / Ubuntu — `.deb`
| Topic | Decision |
|-------|----------|
| Name | **RigDoctor** |
| Language / stack | **Python 3 + Qt (PySide6)** — core/CLI/daemon stdlib-only; Qt only for GUI/tray |
| Primary distro | **Ubuntu** (Debian via apt); others best-effort later |
| Primary GPU | **NVIDIA** first; AMD, then Intel later |
| MVP | **Sensor core + crash logger + health report** (NVIDIA-only, CLI-first) |
| Distribution | **User-local install** (self-updating from the public repo, no root); **`.deb`** optional |
| Scope of action | **Read-only + suggestions** (no auto-apply yet) |
| Stress tests | **Out of scope** |
Full rationale and the still-open questions are in `docs/DECISIONS.md`.
## Repo layout
| Path | Purpose |
|------|---------|
| `docs/SPEC.md` | Product specification — vision, requirements, modules (the main planning doc) |
| `docs/ARCHITECTURE.md` | Technical design — core engine, front-ends, daemon, installer |
| `docs/MODULES.md` | Catalog of modules with scope, dependencies, status |
| `docs/ROADMAP.md` | Phased milestones |
| `docs/DECISIONS.md` | Decision log + remaining open questions |
| `src/rigdoctor/` | Source code — `core/` engine + sources, `cli.py`, `render.py` |
| `installer/` | Installer / `.deb` packaging (empty until Phase 4) |
| `tests/` | Tests (stdlib `unittest`) |
## Install (user-local, no root)
RigDoctor installs into a private venv under `~/.local` — no root, self-updating:
The simplest path: grab the latest **`rigdoctor_<version>_all.deb`** from the
[releases page](https://git.jesseyvanofferen.com/jessey/rigdoctor/releases) and install it —
apt pulls the GUI dependencies (PySide6, pyte) automatically:
```bash
./install.sh # from a source checkout or the self-extracting .run
./install.sh --ref v0.0.6 # install a specific released tag (needs a token)
./install.sh --uninstall # remove it
sudo apt install ./rigdoctor_*_all.deb # CLI only: add --no-install-recommends
```
This adds `rigdoctor` / `rigdoctor-gui` to `~/.local/bin` and a desktop entry. Each release
also ships a one-file **`.run`** installer (download, `chmod +x`, run). Updates are gated to
accounts on the Git server (a Personal Access Token); save one via the GUI **Setup → Update
access** panel or `rigdoctor login`, then `rigdoctor update` (or the sidebar button).
## Install (`.deb`, system-wide)
Each release also ships a **`.deb`** (`Architecture: all`, M9/D8). Download it from the release
and install with apt (pulls the GUI deps — PySide6/pyte — via Recommends):
**Or add the apt repository** for `apt install` + automatic updates (the registry is public and
GPG-signed — no token needed):
```bash
sudo apt install ./rigdoctor_<version>_all.deb # CLI-only: add --no-install-recommends
sudo curl https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key -o /etc/apt/keyrings/gitea-jessey.asc
echo "deb [arch=all signed-by=/etc/apt/keyrings/gitea-jessey.asc] https://git.jesseyvanofferen.com/api/packages/jessey/debian stable main" | sudo tee /etc/apt/sources.list.d/gitea.list
sudo apt update
sudo apt install rigdoctor
```
When the apt registry is enabled on the server, you can instead add it as a source and
`sudo apt update && sudo apt install rigdoctor` (with `apt upgrade` for updates):
Then `sudo apt upgrade` keeps it current.
Then `sudo apt upgrade` keeps it current.
### Any distro — self-extracting `.run` (no root)
Download **`rigdoctor-<version>-installer.run`** from the releases page and run it. It installs
into a private virtualenv under `~/.local` (no root), adds the launchers + desktop entry, and
opens the first-run setup wizard:
```bash
curl -fsSL https://git.jesseyvanofferen.com/api/packages/jessey/debian/repository.key \
| sudo tee /etc/apt/keyrings/gitea-rigdoctor.asc > /dev/null
echo "deb [signed-by=/etc/apt/keyrings/gitea-rigdoctor.asc] \
https://git.jesseyvanofferen.com/api/packages/jessey/debian stable main" \
| sudo tee /etc/apt/sources.list.d/rigdoctor.list
sh rigdoctor-*-installer.run
```
## Run it (dev)
### Updating & removing
Stdlib-only, no install needed (target is Python ≥ 3.11; tested on 3.14):
- **`.deb`:** `sudo apt upgrade` (or reinstall a newer `.deb`).
- **`.run` / user-local:** the in-app **Update** button, or `rigdoctor update`.
- **Remove:** `sudo apt remove rigdoctor`, or `rigdoctor uninstall` for the user-local install.
## Using it
Launch **RigDoctor** from your app menu, or:
```bash
PYTHONPATH=src python3 -m rigdoctor snapshot # one-shot sensor read
PYTHONPATH=src python3 -m rigdoctor snapshot --json
PYTHONPATH=src python3 -m rigdoctor monitor -n 1 # live view (Ctrl-C to quit)
PYTHONPATH=src python3 -m rigdoctor sources # list detected sensor sources
PYTHONPATH=src python3 -m unittest discover -s tests
rigdoctor-gui # desktop app (+ tray)
rigdoctor --help # everything from the terminal (works over SSH)
```
### Crash-capture logger (M3)
A crash-safe background logger (JSONL, `fsync` per sample, bounded by rotation) for catching
the state right before a freeze:
Handy CLI commands:
```bash
rigdoctor record start # start logging in the background
rigdoctor record status # is it running? latest readings, sample count
rigdoctor record stop # stop it
rigdoctor record report # post-crash summary: peaks, events, last samples
rigdoctor record run # run in the foreground (the systemd-ready entrypoint)
rigdoctor snapshot # one-shot reading of every sensor
rigdoctor monitor # live terminal dashboard
rigdoctor report # health report (logs / SMART / driver)
rigdoctor diagnose start|finish # capture while gaming, then analyse
rigdoctor gameenv # flag risky gaming settings + fixes
rigdoctor inventory # hardware/OS inventory
rigdoctor ai explain # AI explanation of the current findings (opt-in)
rigdoctor bundle # zip the latest diagnostic into a shareable report
```
Logs live in `~/.local/share/rigdoctor/logs/`. It detects GPU "lost"/hang (nvidia-smi query
timeout) and writes an event marker. Trigger modes (always-on / game-launch) and the
`systemd --user` service arrive in Phase 4.
## Requirements
### Desktop GUI (M10)
- **Linux** — Ubuntu/Debian first-class (the `.deb`); the `.run` works on any distro with
Python ≥ 3.11.
- **GPU** — NVIDIA fully supported (via `nvidia-smi`); AMD/Intel sensors are best-effort.
- **CLI/daemon** need only Python 3 (stdlib). The **GUI/tray** add **PySide6** (`python3-pyside6`).
- Optional tools unlock more: `smartmontools`, `lm-sensors`, `gamemode`, `mangohud`. The setup
wizard offers to install them.
The GUI uses PySide6 (Qt) — the only part of RigDoctor that needs a non-stdlib dep:
## Privacy
Everything stays on your machine — no telemetry, no phone-home. The AI assistant is **off by
default** and runs only when you explicitly trigger it; with Ollama nothing leaves the machine,
and the Claude option asks before sending. Reports are local files; they leave only if you share
the zip.
## Development
RigDoctor's core is stdlib-only Python; the GUI/tray use PySide6.
```bash
pip install -e '.[gui]' # core + PySide6, gives `rigdoctor` and `rigdoctor-gui`
rigdoctor gui # or: rigdoctor-gui
git clone https://git.jesseyvanofferen.com/jessey/rigdoctor && cd rigdoctor
pip install -e ".[gui]" # core + GUI; omit [gui] for CLI-only
python -m unittest discover -s tests # run the test suite
PYTHONPATH=src python3 -m rigdoctor snapshot # run without installing
```
It opens a dark-themed window with sidebar navigation and a **live dashboard** over the
same sensor core — circular gauges for the headline metrics plus collapsible per-subsystem
cards (GPU/CPU/memory/storage) with temperature-colored values (icey-blue → green → red).
The **Logs** and **Health** sections are full pages (recording controls + post-crash report;
and the kernel-log / SMART / driver scan). **Inventory** is a placeholder until M5 lands.
Without the GUI extra, `pip install -e .` gives just the stdlib-only CLI.
## Start here
1. Read `docs/SPEC.md` for what we're building.
2. Read `docs/ROADMAP.md` for the build order (Phase 1 = the MVP).
3. Read `docs/DECISIONS.md` for the settled decisions (D1D15).
</content>
Design docs live in `docs/``SPEC.md` (vision/requirements), `ARCHITECTURE.md`,
`MODULES.md` (module catalog), `ROADMAP.md`, and `DECISIONS.md` (the decision log).
Contributions: branch off `main`, keep tests green (CI runs them on PRs), and bump the version
+ `CHANGELOG.md` for shipped changes.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<defs>
<radialGradient id="bg" cx="50%" cy="42%" r="78%">
<stop offset="0%" stop-color="#1b2230"/>
<stop offset="100%" stop-color="#0d0f13"/>
</radialGradient>
</defs>
<rect width="512" height="512" fill="url(#bg)"/>
<!-- gauge ring -->
<circle cx="256" cy="256" r="168" fill="none" stroke="#2a2f39" stroke-width="28"/>
<!-- accent sweep -->
<path d="M256 88 a168 168 0 1 1 -118.8 49.2" fill="none" stroke="#38bdf8"
stroke-width="28" stroke-linecap="round"/>
<!-- heartbeat / monitoring trace -->
<path d="M120 264 H200 L232 192 L280 336 L312 264 H392" fill="none" stroke="#e6e8eb"
stroke-width="28" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 798 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

+9 -4
View File
@@ -2,9 +2,13 @@
Pure-Python app, so it's `Architecture: all`: we stage the package into dist-packages, drop the
two launchers in /usr/bin, install the desktop entry + icon, write a DEBIAN/control, and call
`dpkg-deb`. The core is stdlib (`Depends: python3`); the GUI/tray deps are **Recommends**
(`python3-pyside6`, `python3-pyte`) so `apt install rigdoctor` gives the full app by default,
while `--no-install-recommends` yields a CLI-only install.
`dpkg-deb`. The core is stdlib (`Depends: python3`); everything else is **Recommends** so a
plain `apt install rigdoctor` sets up the whole toolset automatically (users never hand-install
deps) — the GUI modules (Debian/Ubuntu split PySide6 per module, so we name
`python3-pyside6.qt{widgets,gui,websockets,svg}`) + `python3-pyte`, plus the diagnostic/gaming
tools (smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode,
mangohud). `--no-install-recommends` still yields a CLI-only install; `cpupower` is a Suggests
(kernel-tied/heavy).
Run: `python packaging/make_deb.py` → `dist/rigdoctor_<version>_all.deb`.
"""
@@ -57,7 +61,8 @@ Maintainer: {maintainer}
Section: utils
Priority: optional
Depends: python3 (>= 3.11)
Recommends: python3-pyside6, python3-pyte
Recommends: python3-pyside6.qtwidgets, python3-pyside6.qtgui, python3-pyside6.qtwebsockets, python3-pyside6.qtsvg, python3-pyte, smartmontools, lm-sensors, dmidecode, pciutils, libnotify-bin, libsecret-tools, gamemode, mangohud
Suggests: linux-tools-generic
Homepage: {homepage}
Description: Hardware monitoring & crash diagnostics for Linux gamers
RigDoctor monitors GPU/CPU temperatures, load, and sensors, captures crash
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "rigdoctor"
version = "0.35.0"
version = "0.41.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md"
requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.35.0"
__version__ = "0.41.0"
+24 -2
View File
@@ -55,8 +55,9 @@ def cmd_gui(args) -> int:
from .gui.app import main as gui_main
except ImportError as exc:
print("The GUI needs PySide6, which isn't installed.")
print(" Install it with: pip install 'rigdoctor[gui]'")
print(" or on Ubuntu: sudo apt install python3-pyside6")
print(" Ubuntu/Debian: sudo apt install python3-pyside6.qtwidgets "
"python3-pyside6.qtgui python3-pyside6.qtwebsockets python3-pyside6.qtsvg python3-pyte")
print(" pip: pip install 'rigdoctor[gui]'")
print(f" ({exc})")
return 2
return gui_main([sys.argv[0]])
@@ -262,6 +263,10 @@ def cmd_update(args) -> int:
print("\nWhat's new:\n" + "\n".join(" " + ln for ln in notes.splitlines()) + "\n")
if args.check:
return 0
kind = updates.install_kind()
if kind != "pip": # apt/source installs aren't pip-updatable — show the right command
print(updates.update_hint(kind))
return 0
print(f"Installing {tag}")
rc, out = updates.apply_update(tag)
print(out[-2000:])
@@ -461,6 +466,20 @@ def cmd_ai(args) -> int:
print(msg)
return 0 if ok else 1
if sub == "dump":
# Parse a Windows .dmp minidump (e.g. from a Proton game crash) and explain it.
from .core import minidump
report = minidump.parse(args.file)
if not report.ok:
print(f"Couldn't analyze the dump — {report.error}")
return 1
print(minidump.to_text(report))
print(f"\nAsking {ai.provider_label()} to explain {os.path.basename(args.file)}\n")
ok, msg = ai.explain(minidump.to_ai_text(report))
print(msg)
return 0 if ok else 1
# explain: gather the current health findings and ask the provider to explain them.
from .core import health
@@ -702,6 +721,9 @@ def build_parser() -> argparse.ArgumentParser:
ai_sub.add_parser("status", help="show the configured provider (contacts nothing)").set_defaults(func=cmd_ai)
ai_sub.add_parser("test", help="send a tiny probe to verify connectivity").set_defaults(func=cmd_ai)
ai_sub.add_parser("explain", help="explain the current health findings with AI").set_defaults(func=cmd_ai)
dump_p = ai_sub.add_parser("dump", help="parse a Windows .dmp crash dump and explain it with AI")
dump_p.add_argument("file", help="path to the .dmp minidump (e.g. from a Proton game crash)")
dump_p.set_defaults(func=cmd_ai)
ai_p.set_defaults(func=cmd_ai, ai_cmd=None)
bundle_p = sub.add_parser("bundle", help="zip the latest stored diagnostic into a report bundle (M15)")
+29
View File
@@ -76,6 +76,35 @@ ENTRIES: list[tuple[tuple[str, ...], str]] = [
(("fork without exec", "skipping destruction"),
"BENIGN: 'pid X != Y, skipping destruction (fork without exec?)' is routine Steam/Proton "
"process bookkeeping, not an error."),
# --- crash-dump (.dmp) reasoning -------------------------------------------------
(("access violation", "0xc0000005", "0xc0000006"),
"Windows exception 0xC0000005 (access violation) = the game read/wrote/executed memory it "
"wasn't allowed to. A write/read to a low address (near 0x0) is a null-pointer dereference, "
"usually a game or graphics-driver bug; under Proton it's often a DXVK/VKD3D or Proton-version "
"issue. Identify the faulting MODULE to localize the fault."),
(("stack overflow", "0xc00000fd"),
"Windows exception 0xC00000FD (stack overflow) = unbounded recursion or a huge stack "
"allocation in the crashing module — almost always a software bug in that module."),
(("0xc0000409", "stack buffer overrun", "fast fail"),
"Windows 0xC0000409 (stack buffer overrun / __fastfail) = a security check tripped on memory "
"corruption; frequently anticheat or a DRM/overlay injecting into the game. Suspect overlays "
"(Steam/Discord/MSI Afterburner-equivalents) and anticheat compatibility under Proton."),
(("0xc0000374", "heap corruption"),
"Windows 0xC0000374 (heap corruption) = something scribbled over heap memory earlier; the "
"crash point is a symptom, not the cause. Often a mod, an injected overlay, or unstable RAM."),
(("nvwgf2umx", "nvoglv", "nvd3dum", "nvldumd"),
"A faulting NVIDIA user-mode driver DLL (nvwgf2umx/nvoglv/nvd3dum) means the crash happened "
"inside the GPU driver under Proton. On Linux this points at the NVIDIA driver + the "
"DXVK/VKD3D translation layer: try a different driver branch or Proton/Proton-GE version, "
"clear the DXVK shader cache, and revert any GPU overclock/undervolt."),
(("easyanticheat", "eac", "battleye", "beclient", "anticheat"),
"A faulting anticheat module (EasyAntiCheat/BattlEye) under Proton is usually a compatibility "
"problem: confirm the title's anticheat has Proton/Linux support enabled and try the Proton "
"version the community recommends for it (often Proton-GE or a specific Valve build)."),
(("d3d11.dll", "d3d12.dll", "dxgi.dll", "d3d9.dll", "dxvk", "vkd3d"),
"A crash in a Direct3D/DXGI module under Proton runs through DXVK (D3D9/10/11) or VKD3D-Proton "
"(D3D12). Try a known-good Proton version, update/override DXVK-VKD3D, clear the shader cache, "
"and check the GPU driver — these are the usual fixes for D3D faults on Linux."),
]
+148
View File
@@ -0,0 +1,148 @@
"""Connected displays (M5): resolution + current/max refresh per monitor.
GNOME exposes the authoritative data over D-Bus (Mutter `DisplayConfig.GetCurrentState`),
which works on both X11 and Wayland — read via `busctl --json`. Plain X11 desktops fall back
to `xrandr`. Other Wayland compositors (sway/KDE) aren't covered yet and degrade to empty.
Stdlib only; every probe fails soft. Max refresh is computed at the *current* resolution, so
"can go faster" never suggests dropping resolution.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
from dataclasses import dataclass
# A few common PNP monitor-vendor IDs → friendly names (best-effort; unknown codes pass through).
_PNP = {
"SAM": "Samsung", "DEL": "Dell", "GSM": "LG", "LGD": "LG", "AUS": "ASUS", "ACR": "Acer",
"BNQ": "BenQ", "MSI": "MSI", "AOC": "AOC", "VSC": "ViewSonic", "HWP": "HP", "HPN": "HP",
"PHL": "Philips", "GBT": "Gigabyte", "APP": "Apple", "DGC": "Dell",
}
@dataclass
class Monitor:
connector: str # e.g. "DP-1"
name: str # e.g. "Samsung LC34G55T" ("" if unknown, e.g. xrandr)
width: int
height: int
refresh: float # current Hz
max_refresh: float # max Hz available at the current resolution
@property
def can_go_faster(self) -> bool:
"""True if a meaningfully higher refresh is available at the current resolution."""
return self.max_refresh - self.refresh > 1.0
def label(self) -> str:
return f"{self.connector} · {self.name}".rstrip(" ·") if self.name else self.connector
def _run(cmd: list[str], timeout: float = 8.0) -> str:
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
if proc.returncode == 0:
return proc.stdout
except (subprocess.SubprocessError, OSError):
pass
return ""
def _parse_mutter(out: str) -> list[Monitor]:
"""Parse `busctl --json` output of Mutter DisplayConfig.GetCurrentState.
data = [serial, monitors, logical_monitors, props]; each monitor is
[[connector, vendor, product, serial], [modes], props]; each mode is
[id, width, height, refresh, scale, [scales], {props}] where props may hold is-current.
"""
try:
data = json.loads(out)["data"]
raw_monitors = data[1]
except (json.JSONDecodeError, KeyError, IndexError, TypeError):
return []
monitors: list[Monitor] = []
for mon in raw_monitors:
try:
connector, vendor, product = mon[0][0], mon[0][1], mon[0][2]
modes = mon[1]
except (IndexError, TypeError):
continue
current = None
for m in modes:
props = m[6] if len(m) > 6 and isinstance(m[6], dict) else {}
if (props.get("is-current") or {}).get("data"):
current = m
break
if current is None:
continue
w, h, r = int(current[1]), int(current[2]), float(current[3])
max_r = max((float(m[3]) for m in modes if int(m[1]) == w and int(m[2]) == h), default=r)
name = f"{_PNP.get(vendor, vendor)} {product}".strip()
monitors.append(Monitor(connector, name, w, h, r, max_r))
return monitors
def _parse_xrandr(out: str) -> list[Monitor]:
"""Parse `xrandr --query`: an output line with the active WxH+x+y, then indented mode lines
whose rates carry `*` for the current one."""
monitors: list[Monitor] = []
out_re = re.compile(r"^(\S+) connected.*?(\d+)x(\d+)\+\d+\+\d+")
mode_re = re.compile(r"^\s+(\d+)x(\d+)\s+(.+)$")
name = ""
cw = ch = 0
cur_r = max_r = 0.0
def flush() -> None:
if name and cw and cur_r:
monitors.append(Monitor(name, "", cw, ch, cur_r, max_r or cur_r))
for line in out.splitlines():
mo = out_re.match(line)
if mo:
flush()
name, cw, ch = mo.group(1), int(mo.group(2)), int(mo.group(3))
cur_r = max_r = 0.0
continue
mm = mode_re.match(line)
if mm and name and int(mm.group(1)) == cw and int(mm.group(2)) == ch:
for tok in mm.group(3).split():
try:
rate = float(tok.rstrip("*+"))
except ValueError:
continue
max_r = max(max_r, rate)
if "*" in tok:
cur_r = rate
flush()
return monitors
def _mutter() -> list[Monitor]:
exe = shutil.which("busctl")
if not exe:
return []
out = _run([exe, "--user", "--json=short", "call", "org.gnome.Mutter.DisplayConfig",
"/org/gnome/Mutter/DisplayConfig", "org.gnome.Mutter.DisplayConfig",
"GetCurrentState"])
return _parse_mutter(out) if out.strip() else []
def _xrandr() -> list[Monitor]:
if not shutil.which("xrandr"):
return []
return _parse_xrandr(_run(["xrandr", "--query"]))
def collect() -> list[Monitor]:
"""Connected monitors, via the first backend that returns any (Mutter, then xrandr)."""
for backend in (_mutter, _xrandr):
try:
monitors = backend()
except Exception:
monitors = []
if monitors:
return monitors
return []
+75
View File
@@ -251,6 +251,78 @@ def check_live_temps() -> list[Finding]:
)]
def check_pcie_links() -> list[Finding]:
"""Flag NVMe drives linked below their PCIe capability — a slower slot or, most often,
motherboard lane-sharing where a GPU/second card or another M.2 steals lanes from the slot.
Width reductions are reliable (reported as warnings); speed-only reductions are info (they can
also be normal link power management at idle). The GPU is intentionally not checked here:
NVIDIA drops its PCIe gen *and* width at idle, so a point-in-time snapshot is misleading.
"""
from . import inventory
findings: list[Finding] = []
for name, dev in inventory.nvme_controllers():
cur_g, cur_w, max_g, max_w = inventory.read_link(dev)
if not cur_g or not max_g:
continue
if max_w and cur_w and cur_w != max_w: # fewer lanes → almost always lane-sharing
findings.append(Finding(
WARNING, "PCIe", f"{name} linked at x{cur_w} (supports x{max_w})",
f"{name} negotiated PCIe Gen{cur_g} x{cur_w}, but the drive supports "
f"Gen{max_g} x{max_w}. Fewer lanes is usually motherboard lane-sharing — a GPU or a "
"second card in a PCIe slot, or another populated M.2, can steal lanes from this slot.",
"Check your board manual's lane-sharing table; move the drive to a full-x4 "
"(often CPU-attached) M.2 slot."))
elif cur_g < max_g: # full width but a lower generation → slower slot or idle ASPM
findings.append(Finding(
INFO, "PCIe", f"{name} linked at Gen{cur_g} (supports Gen{max_g})",
f"{name} negotiated PCIe Gen{cur_g} but supports Gen{max_g}. This can be a slower "
"(chipset or older) M.2 slot, or normal link power management (ASPM) at idle.",
"If you expect full speed, check the slot and the BIOS PCIe/ASPM settings."))
return findings
def check_displays() -> list[Finding]:
"""Flag monitors running below their max refresh rate at the current resolution — e.g. a
165 Hz panel set to 60 Hz, a common and easily-missed gaming setting (read-only suggestion)."""
from . import displays
findings: list[Finding] = []
for m in displays.collect():
if m.can_go_faster:
findings.append(Finding(
INFO, "Display",
f"{m.connector} at {round(m.refresh)} Hz (supports {round(m.max_refresh)} Hz)",
f"{m.name or m.connector} is running at {round(m.refresh)} Hz at "
f"{m.width}x{m.height}, but supports {round(m.max_refresh)} Hz at that resolution.",
"Raise the refresh rate in your desktop's Display settings (GNOME: Settings → Displays)."))
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).
@@ -273,5 +345,8 @@ def run_health_checks(include_journal: bool = True) -> list[Finding]:
else:
findings += check_smart()
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
+109 -5
View File
@@ -9,6 +9,7 @@ from __future__ import annotations
import json
import os
import platform
import re
import shutil
import subprocess
from dataclasses import dataclass
@@ -85,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:
@@ -98,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)
@@ -123,6 +157,64 @@ def _gpu() -> Section:
return Section("GPU", [("Device", g) for g in gpus] or [("Device", "unknown")])
# PCIe link speed (GT/s) → generation.
_PCIE_GEN = {"2.5": 1, "5": 2, "5.0": 2, "8": 3, "8.0": 3, "16": 4, "16.0": 4, "32": 5, "32.0": 5}
def _gen(speed: str) -> int | None:
"""Map a sysfs link speed like '16.0 GT/s PCIe' to its PCIe generation (4)."""
tok = speed.strip().split()[0] if speed.strip() else ""
return _PCIE_GEN.get(tok)
def read_link(dev: Path) -> tuple[int | None, str, int | None, str]:
"""Negotiated/max PCIe link for a PCI device dir: (cur_gen, cur_width, max_gen, max_width).
Widths are the raw sysfs strings (e.g. '4'); gens are ints (4) or None when unreadable.
"""
def rd(name: str) -> str:
try:
return (dev / name).read_text().strip()
except OSError:
return ""
return (_gen(rd("current_link_speed")), rd("current_link_width"),
_gen(rd("max_link_speed")), rd("max_link_width"))
def _link_desc(dev: Path) -> str:
"""Describe a PCI device's negotiated PCIe link, noting if it's below its max.
e.g. 'PCIe Gen4 x4', or 'PCIe Gen3 x4 (capable of Gen4 x4)' when downtrained / in a
slower slot.
"""
cur_g, cur_w, max_g, max_w = read_link(dev)
if not cur_g or not cur_w:
return ""
desc = f"PCIe Gen{cur_g} x{cur_w}"
if max_g and (cur_g < max_g or (max_w and cur_w != max_w)):
desc += f" (capable of Gen{max_g} x{max_w})"
return desc
def nvme_controllers() -> list[tuple[str, Path]]:
"""Each NVMe controller as (name, pci-device-dir), e.g. ('nvme0', /sys/.../device)."""
base = Path("/sys/class/nvme")
try:
entries = [p for p in base.iterdir() if re.fullmatch(r"nvme\d+", p.name)]
except OSError:
return []
return sorted((p.name, p / "device") for p in entries)
def _nvme_link(block_name: str) -> str:
"""PCIe link for an NVMe block device (nvme0n1 → controller nvme0); '' for non-NVMe."""
m = re.match(r"(nvme\d+)", block_name)
if not m:
return ""
return _link_desc(Path("/sys/class/nvme") / m.group(1) / "device")
def _storage() -> Section:
items: list[tuple[str, str]] = []
# TYPE first so MODEL (which can contain spaces) is the trailing field.
@@ -133,15 +225,27 @@ def _storage() -> Section:
continue
name, size = parts[1], parts[2]
model = parts[3] if len(parts) > 3 else ""
items.append((name, f"{model} ({size})".strip()))
desc = f"{model} ({size})".strip()
link = _nvme_link(name) # NVMe PCIe gen/width (e.g. Gen4 x4), flags downtrains
if link:
desc += f" · {link}"
items.append((name, desc))
return Section("Storage", items or [("Disks", "unknown")])
def _display() -> Section:
return Section("Display", [
from . import displays
items = [
("Session", os.environ.get("XDG_SESSION_TYPE", "unknown")),
("Desktop", os.environ.get("XDG_CURRENT_DESKTOP") or os.environ.get("DESKTOP_SESSION", "unknown")),
])
]
for m in displays.collect():
val = f"{m.width}x{m.height} @ {round(m.refresh)} Hz"
if m.can_go_faster:
val += f" (supports {round(m.max_refresh)} Hz)"
items.append((m.label(), val))
return Section("Display", items)
def _dmidecode() -> dict:
+314
View File
@@ -0,0 +1,314 @@
"""Parse a Windows crash dump (``.dmp`` minidump) into text the AI can reason over (M14).
Linux gamers get these from Windows games running under **Proton/Wine**: the game's
crash handler (Crashpad/Breakpad, Unreal/Unity, or Wine itself) writes a binary minidump
when the title hard-crashes. The file is binary, so we can't hand it to a model directly —
we parse the documented ``MDMP`` streams with stdlib :mod:`struct` (no pip deps, per the
core rule) and pull out the parts that actually diagnose a crash:
* the **exception / crash reason** (e.g. access violation 0xC0000005),
* the **faulting module** (which DLL the crash address lands in — ``nvwgf2umx.dll``,
``d3d11.dll``, an anticheat, the game's own .exe…),
* **OS / CPU** info, and the **loaded module list**.
If ``minidump_stackwalk`` (Breakpad) or ``minidump-stackwalk`` (rust-minidump) is on PATH,
its fuller report is appended best-effort; we never depend on it.
The result feeds the existing opt-in AI flow (:mod:`ai`) exactly like the sensor findings do.
"""
from __future__ import annotations
import shutil
import struct
import subprocess
import time
from dataclasses import dataclass, field
from pathlib import Path
from .health import CRITICAL, INFO, Finding
# --- MDMP on-disk layout (all little-endian, packed) --------------------------------
_SIGNATURE = b"MDMP"
_HEADER = struct.Struct("<4sIIIIIQ") # sig, ver, n_streams, dir_rva, csum, time, flags
_DIRECTORY = struct.Struct("<III") # stream_type, data_size, data_rva
_SYSINFO = struct.Struct("<HHHBBIIIII") # arch, lvl, rev, n_cpu, prod, maj, min, build, plat, csd
_MODULE_STRIDE = 108 # sizeof(MINIDUMP_MODULE)
# Stream types we read (MINIDUMP_STREAM_TYPE).
_MODULE_LIST = 4
_EXCEPTION = 6
_SYSTEM_INFO = 7
_COMMENT_A = 10
_COMMENT_W = 11
_ARCH = {0: "x86", 5: "ARM", 6: "IA-64", 9: "x86-64", 12: "ARM64", 0xFFFF: "unknown"}
_PLATFORM = {0x8201: "Linux", 0x8202: "Solaris", 0x8203: "macOS", 0x8204: "iOS",
0x8205: "Android", 0x8207: "NaCl"}
# Common Windows exception (NTSTATUS) codes — what the model needs named, not raw hex.
_EXCEPTION_NAMES = {
0x80000003: "Breakpoint",
0x80000004: "Single step",
0xC0000005: "Access violation",
0xC0000006: "In-page error",
0xC000001D: "Illegal instruction",
0xC0000025: "Noncontinuable exception",
0xC000008C: "Array bounds exceeded",
0xC000008E: "Float divide by zero",
0xC0000090: "Float invalid operation",
0xC0000094: "Integer divide by zero",
0xC0000095: "Integer overflow",
0xC0000096: "Privileged instruction",
0xC00000FD: "Stack overflow",
0xC0000135: "DLL not found",
0xC0000142: "DLL initialization failed",
0xC0000374: "Heap corruption",
0xC0000409: "Stack buffer overrun / fast fail",
0xC000041D: "Fatal user-callback exception",
0xE06D7363: "C++ exception (MSVC)",
}
_ACCESS = {0: "reading", 1: "writing", 8: "executing"} # AV ExceptionInformation[0]
_STACKWALK_BINS = ("minidump_stackwalk", "minidump-stackwalk")
_MODULES_SHOWN = 80 # cap the module list so the AI prompt stays bounded
@dataclass
class Module:
name: str # basename only
base: int
size: int
@dataclass
class MinidumpReport:
path: str
ok: bool = False
error: str = ""
crash_reason: str = ""
exception_code: int | None = None
exception_address: int | None = None
faulting_module: str | None = None
crashing_thread: int | None = None
os_name: str = ""
cpu_arch: str = ""
cpu_count: int = 0
timestamp: int | None = None
modules: list[Module] = field(default_factory=list)
comment: str = ""
stackwalk: str = ""
def parse(path, *, run_stackwalk: bool = True) -> MinidumpReport:
"""Parse a ``.dmp`` file. Never raises — a bad/unsupported file returns ``ok=False``."""
report = MinidumpReport(path=str(path))
try:
data = Path(path).read_bytes()
except OSError as exc:
report.error = f"can't read the file: {exc}"
return report
if len(data) < _HEADER.size or data[:4] != _SIGNATURE:
report.error = "not a Windows minidump (missing the 'MDMP' signature)."
return report
try:
_sig, _ver, n_streams, dir_rva, _csum, ts, _flags = _HEADER.unpack_from(data, 0)
report.timestamp = ts or None
streams = _streams(data, dir_rva, n_streams)
_read_system_info(data, streams.get(_SYSTEM_INFO), report)
report.modules = _read_modules(data, streams.get(_MODULE_LIST))
_read_exception(data, streams.get(_EXCEPTION), report)
report.comment = _read_comment(data, streams)
except (struct.error, ValueError, IndexError) as exc:
report.error = f"the minidump looks corrupt or unsupported: {exc}"
return report
if report.exception_address is not None:
report.faulting_module = _module_at(report.modules, report.exception_address)
report.ok = True
if run_stackwalk:
report.stackwalk = stackwalk(path)
return report
def _streams(data: bytes, dir_rva: int, n: int) -> dict[int, tuple[int, int]]:
"""Map stream_type -> (data_size, data_rva). First occurrence of each type wins."""
out: dict[int, tuple[int, int]] = {}
for i in range(n):
off = dir_rva + i * _DIRECTORY.size
if off + _DIRECTORY.size > len(data):
break
stype, size, rva = _DIRECTORY.unpack_from(data, off)
out.setdefault(stype, (size, rva))
return out
def _read_system_info(data: bytes, loc, report: MinidumpReport) -> None:
if not loc:
return
_size, rva = loc
arch, _lvl, _rev, n_cpu, _prod, major, minor, build, platform, _csd = \
_SYSINFO.unpack_from(data, rva)
report.cpu_arch = _ARCH.get(arch, f"arch 0x{arch:x}")
report.cpu_count = n_cpu
if platform == 2: # VER_PLATFORM_WIN32_NT
report.os_name = f"Windows {major}.{minor}.{build}"
elif platform in _PLATFORM:
ver = f" {major}.{minor}.{build}" if (major or minor or build) else ""
report.os_name = _PLATFORM[platform] + ver
else:
report.os_name = f"platform 0x{platform:x} {major}.{minor}.{build}"
def _read_modules(data: bytes, loc) -> list[Module]:
if not loc:
return []
_size, rva = loc
(count,) = struct.unpack_from("<I", data, rva)
base_off = rva + 4
modules: list[Module] = []
for i in range(count):
rec = base_off + i * _MODULE_STRIDE
if rec + _MODULE_STRIDE > len(data):
break
base, = struct.unpack_from("<Q", data, rec)
size, = struct.unpack_from("<I", data, rec + 8)
name_rva, = struct.unpack_from("<I", data, rec + 20)
modules.append(Module(_read_mdstring(data, name_rva), base, size))
return modules
def _read_exception(data: bytes, loc, report: MinidumpReport) -> None:
if not loc:
return
_size, rva = loc
thread_id, = struct.unpack_from("<I", data, rva) # MINIDUMP_EXCEPTION_STREAM
code, = struct.unpack_from("<I", data, rva + 8) # ExceptionRecord.ExceptionCode
address, = struct.unpack_from("<Q", data, rva + 24) # ExceptionRecord.ExceptionAddress
n_params, = struct.unpack_from("<I", data, rva + 32)
report.crashing_thread = thread_id
report.exception_code = code
report.exception_address = address
report.crash_reason = _describe_exception(data, rva, code, n_params)
def _describe_exception(data: bytes, rva: int, code: int, n_params: int) -> str:
name = _EXCEPTION_NAMES.get(code, "Unknown exception")
reason = f"{name} (0x{code:08X})"
if code in (0xC0000005, 0xC0000006) and n_params >= 2:
op = struct.unpack_from("<Q", data, rva + 40)[0] # ExceptionInformation[0]
addr = struct.unpack_from("<Q", data, rva + 48)[0] # ExceptionInformation[1]
reason += f" {_ACCESS.get(op, 'accessing')} 0x{addr:X}"
return reason
def _read_mdstring(data: bytes, rva: int) -> str:
"""A MINIDUMP_STRING (u32 byte-length + UTF-16LE), returned as a basename."""
if not rva or rva + 4 > len(data):
return ""
length, = struct.unpack_from("<I", data, rva)
start = rva + 4
raw = data[start:start + length]
text = raw.decode("utf-16-le", "replace").strip("\x00")
return text.replace("\\", "/").rsplit("/", 1)[-1] or text
def _read_comment(data: bytes, streams: dict[int, tuple[int, int]]) -> str:
if _COMMENT_W in streams:
size, rva = streams[_COMMENT_W]
return data[rva:rva + size].decode("utf-16-le", "replace").strip("\x00").strip()
if _COMMENT_A in streams:
size, rva = streams[_COMMENT_A]
return data[rva:rva + size].decode("utf-8", "replace").strip("\x00").strip()
return ""
def _module_at(modules: list[Module], address: int) -> str | None:
for m in modules:
if m.base <= address < m.base + m.size:
return m.name
return None
def stackwalk(path, timeout: float = 25.0, max_chars: int = 12000) -> str:
"""Best-effort fuller report from an external stackwalker, or '' if none is installed."""
exe = next((shutil.which(name) for name in _STACKWALK_BINS if shutil.which(name)), None)
if not exe:
return ""
try:
proc = subprocess.run(
[exe, str(path)], capture_output=True, text=True, timeout=timeout, check=False)
except (OSError, subprocess.SubprocessError):
return ""
return (proc.stdout or "").strip()[:max_chars]
# --- rendering ----------------------------------------------------------------------
def to_text(report: MinidumpReport) -> str:
"""Human-readable structured summary (also shown in the GUI)."""
name = Path(report.path).name
lines = [f"Crash dump: {name}"]
if report.crash_reason:
lines.append(f"Crash reason: {report.crash_reason}")
if report.faulting_module:
lines.append(f"Faulting module: {report.faulting_module}")
elif report.exception_address is not None:
lines.append(f"Faulting address: 0x{report.exception_address:X} (no module matched)")
if report.crashing_thread is not None:
lines.append(f"Crashing thread: {report.crashing_thread}")
if report.os_name:
lines.append(f"OS: {report.os_name}")
if report.cpu_arch:
cpus = f" ({report.cpu_count} logical)" if report.cpu_count else ""
lines.append(f"CPU: {report.cpu_arch}{cpus}")
if report.timestamp:
lines.append("Captured: " + time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(report.timestamp)))
if report.modules:
shown = report.modules[:_MODULES_SHOWN]
more = len(report.modules) - len(shown)
lines.append(f"\nLoaded modules ({len(report.modules)}):")
lines += [f"- {m.name}" for m in shown if m.name]
if more > 0:
lines.append(f"- (+{more} more)")
if report.comment:
lines.append(f"\nDump comment:\n{report.comment[:1000]}")
return "\n".join(lines)
def to_ai_text(report: MinidumpReport) -> str:
"""The block sent to the model: Proton/Linux framing + summary + stackwalk."""
framing = (
"These findings come from a Windows crash minidump (.dmp) produced by a game running "
"under Proton/Wine on Linux. The faulting modules are Windows DLLs inside the Proton "
"prefix, so the crash is a Windows-process fault but the fixes are Linux/Proton-side "
"(Proton version, DXVK/VKD3D, GPU driver, launch options, shader cache) — never Windows "
"admin/registry steps."
)
parts = [framing, "", to_text(report)]
if report.stackwalk:
parts.append("\nminidump_stackwalk output:\n" + report.stackwalk)
return "\n".join(parts)
def to_findings(report: MinidumpReport) -> list[Finding]:
"""Render the dump as Finding cards for the GUI (mirrors the health report)."""
findings: list[Finding] = []
detail_bits = []
if report.faulting_module:
detail_bits.append(f"in {report.faulting_module}")
if report.exception_address is not None:
detail_bits.append(f"at 0x{report.exception_address:X}")
detail = (report.crash_reason or "Crash recorded")
if detail_bits:
detail += " " + " ".join(detail_bits) + "."
findings.append(Finding(
CRITICAL, "Crash dump",
f"Crash in {report.faulting_module}" if report.faulting_module else "Crash recorded",
detail,
"Use “Explain with AI” for likely causes and Proton-side fixes.",
))
env_bits = [b for b in (report.os_name, report.cpu_arch and f"{report.cpu_arch} CPU") if b]
if env_bits:
findings.append(Finding(
INFO, "Crash dump", "Dump environment", " · ".join(env_bits)))
return findings
+55 -3
View File
@@ -8,11 +8,14 @@ state for the UI; `apply_update` performs the no-root self-update.
from __future__ import annotations
import functools
import json
import shutil
import subprocess
import sys
import urllib.error
import urllib.request
from pathlib import Path
from .. import __version__
from ..config import load_token
@@ -31,6 +34,50 @@ UP_TO_DATE = "up-to-date"
AVAILABLE = "available"
APT_PACKAGE = "rigdoctor"
def _dpkg_owns(path: Path) -> bool:
"""True if dpkg reports `path` belongs to a package (i.e. an apt/.deb install)."""
if not shutil.which("dpkg"):
return False
try:
r = subprocess.run(["dpkg", "-S", str(path)], capture_output=True, text=True, timeout=5)
except (subprocess.SubprocessError, OSError):
return False
return r.returncode == 0 and APT_PACKAGE in r.stdout
@functools.lru_cache(maxsize=1)
def install_kind() -> str:
"""How RigDoctor was installed: 'apt' (.deb), 'pip' (venv/.run), or 'dev' (source checkout).
Decides which updater to use: only 'pip' can self-update in place; apt is root/dpkg-managed
and source is VCS-managed, so those are guided rather than auto-applied.
"""
pkg = Path(__file__).resolve().parents[1] # .../rigdoctor
if _dpkg_owns(pkg / "__init__.py"):
return "apt"
if sys.prefix != sys.base_prefix: # inside a venv → the pip/.run install
return "pip"
if (pkg.parents[1] / "pyproject.toml").exists(): # repo checkout
return "dev"
if str(pkg).startswith("/usr/") or "/dist-packages/" in str(pkg):
return "apt" # system-managed but no dpkg record — still don't pip
return "pip"
def update_hint(kind: str | None = None) -> str:
"""Human guidance for installs that can't self-update via pip (apt / source)."""
kind = kind or install_kind()
if kind == "apt":
return ("Installed via apt — update with:\n"
f" sudo apt update && sudo apt install --only-upgrade {APT_PACKAGE}")
if kind == "dev":
return "Running from a source checkout — update with `git pull`."
return ""
def _parse(version: str) -> tuple[int, ...]:
return tuple(int(p) for p in version.lstrip("vV").split(".") if p.isdigit())
@@ -100,11 +147,16 @@ def list_releases(limit: int = 15, timeout: float = 6.0) -> tuple[list[tuple[str
def apply_update(tag: str) -> tuple[int, str]:
"""Self-update the current (user-local) install to `tag` via authenticated pip.
"""Update to `tag` using the method matching how RigDoctor was installed.
Installs `rigdoctor[gui] @ git+https://oauth2:<token>@/rigdoctor.git@<tag>` into
the running environment. Returns (exit_code, output) with the token scrubbed.
Only pip/venv installs are upgraded in place (authenticated pip install of
`rigdoctor[gui] @ git+https://oauth2:<token>@/rigdoctor.git@<tag>`). apt and source
installs can't be (root/dpkg- or VCS-managed), so they return guidance instead of
attempting pip. Returns (exit_code, output) with the token scrubbed.
"""
kind = install_kind()
if kind != "pip":
return (1, update_hint(kind))
token = load_token()
if not token:
return (1, "No update token configured. Run `rigdoctor login`.")
+54
View File
@@ -16,6 +16,7 @@ from PySide6.QtWidgets import (
QApplication,
QCheckBox,
QDialog,
QFileDialog,
QFrame,
QHBoxLayout,
QLabel,
@@ -29,6 +30,7 @@ from PySide6.QtWidgets import (
from ..config import load_config, update_config
from .diagnostic_dialog import DiagnosticDialog
from .minidump_dialog import MinidumpDialog
from .theme import ACCENT, GOOD, MUTED, WARN
@@ -79,6 +81,7 @@ class GamesPage(QWidget):
_scanned = Signal(object) # steam.ScanResult
new_count_changed = Signal(int) # newly-installed game count (for the nav badge)
_diag_done = Signal(object) # DiagnosticResult — focused capture analyzed
_dump_parsed = Signal(object) # minidump.MinidumpReport — imported .dmp (or None)
def __init__(self) -> None:
super().__init__()
@@ -86,6 +89,7 @@ class GamesPage(QWidget):
self._libraries_ready.connect(self._render_libraries)
self._scanned.connect(self._render_games)
self._diag_done.connect(self._on_diag_done)
self._dump_parsed.connect(self._on_dump_parsed)
self._busy = False
self._new_appids: set[str] = set()
self._extra_games: list = [] # non-Steam (Lutris/Heroic), appended after a scan
@@ -103,6 +107,11 @@ class GamesPage(QWidget):
self._status = QLabel("")
self._status.setObjectName("Muted")
header.addWidget(self._status)
# Import a Windows crash dump (.dmp) from a Proton game and analyze it with AI.
# Shown only when an AI provider is configured (AI analysis is the point).
self._import_btn = QPushButton("Import crash dump…")
self._import_btn.clicked.connect(self._import_dump)
header.addWidget(self._import_btn)
self._autocap_btn = QPushButton("Auto-capture…")
self._autocap_btn.clicked.connect(self._show_autocapture)
header.addWidget(self._autocap_btn)
@@ -192,6 +201,7 @@ class GamesPage(QWidget):
self._load_cached() # instant display from the last scan
QTimer.singleShot(400, self.refresh) # then rescan in the background on launch
self._check_crash() # surface an interrupted (crashed) diagnostic
self._refresh_import_btn() # show Import only if AI is configured
# --- loading ----------------------------------------------------------------------
@@ -450,6 +460,49 @@ class GamesPage(QWidget):
v.addLayout(buttons)
dlg.exec()
# --- import a crash dump (.dmp) ---------------------------------------------------
def _refresh_import_btn(self) -> None:
from ..core import ai
self._import_btn.setVisible(ai.is_configured())
def _import_dump(self) -> None:
from ..core import ai
if not ai.is_configured():
QMessageBox.information(
self, "RigDoctor",
"Set up an AI provider first (Settings → AI assistant) to analyze a crash dump.")
return
path, _ = QFileDialog.getOpenFileName(
self, "Import crash dump", os.path.expanduser("~"),
"Crash dumps (*.dmp);;All files (*)")
if not path:
return
self._import_btn.setEnabled(False)
self._status.setText("Parsing crash dump…")
threading.Thread(target=self._work_import, args=(path,), daemon=True).start()
def _work_import(self, path: str) -> None:
from ..core import minidump
try:
report = minidump.parse(path) # parses + runs minidump_stackwalk if installed
except Exception:
report = None
self._dump_parsed.emit(report)
def _on_dump_parsed(self, report) -> None:
self._import_btn.setEnabled(True)
self._status.setText("")
if report is None or not report.ok:
detail = report.error if report is not None else "Couldn't read the file."
QMessageBox.warning(
self, "Import crash dump", f"Couldn't analyze the dump — {detail}")
return
MinidumpDialog(report, self).exec()
# --- hard-crash recovery ----------------------------------------------------------
def _check_crash(self) -> None:
@@ -498,6 +551,7 @@ class GamesPage(QWidget):
# Viewing the list acknowledges the new games: clear the sidebar badge. The NEW
# tags stay on the rows for this session so the user can still spot them.
super().showEvent(event)
self._refresh_import_btn() # AI may have been configured since this page was built
if self._new_appids:
from ..core import steam
+39 -6
View File
@@ -20,6 +20,7 @@ from PySide6.QtWidgets import (
QMainWindow,
QMessageBox,
QPushButton,
QScrollArea,
QStackedWidget,
QSystemTrayIcon,
QTextEdit,
@@ -51,6 +52,10 @@ _NAV = [
("App", ["Settings", "Share"]),
]
_PAGES = [name for _section, names in _NAV for name in names]
# Pages that manage their own scrolling (pinned header + inner scroll) or must fill the
# viewport (the Share terminal) — these are added to the stack as-is; every other page is
# wrapped in a QScrollArea so it scrolls when too tall and doesn't pin the window's height.
_NO_WRAP = {"Dashboard", "System Health", "Inventory", "Share"}
_ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
@@ -68,7 +73,11 @@ class MainWindow(QMainWindow):
central = QWidget()
self.setCentralWidget(central)
layout = QHBoxLayout(central)
outer = QVBoxLayout(central)
outer.setContentsMargins(0, 0, 0, 0)
outer.setSpacing(0)
body = QWidget()
layout = QHBoxLayout(body)
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(0)
@@ -100,11 +109,14 @@ class MainWindow(QMainWindow):
"Share": self.share_page,
}
for name in _PAGES:
self._stack.addWidget(self._pages[name])
page = self._pages[name]
self._stack.addWidget(page if name in _NO_WRAP else self._scrollable(page))
content_layout.addWidget(self._stack)
layout.addWidget(self._build_sidebar())
layout.addWidget(content, 1)
outer.addWidget(body, 1)
outer.addWidget(self._build_footer())
self._worker = SamplerWorker(interval=interval)
self._worker.sampled.connect(self.dashboard.update_sample)
@@ -216,9 +228,6 @@ class MainWindow(QMainWindow):
v.addStretch(1)
live = QLabel(f'<span style="color:{ACCENT};">●</span> <span style="color:{MUTED};">Live</span>')
v.addWidget(live)
version = QLabel(f"v{__version__}")
version.setObjectName("Muted")
v.addWidget(version)
changelog_btn = QPushButton("Changelog")
changelog_btn.setObjectName("LinkButton")
changelog_btn.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -248,6 +257,27 @@ class MainWindow(QMainWindow):
v.addWidget(self._restart_btn)
return bar
def _scrollable(self, page: QWidget) -> QScrollArea:
"""Wrap a page so it scrolls when taller than the window — and so the window can shrink
below the page's natural height instead of being pinned to it."""
area = QScrollArea()
area.setWidget(page)
area.setWidgetResizable(True)
area.setFrameShape(QFrame.Shape.NoFrame)
area.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
return area
def _build_footer(self) -> QFrame:
bar = QFrame()
bar.setObjectName("Footer")
h = QHBoxLayout(bar)
h.setContentsMargins(14, 5, 16, 5)
h.addStretch(1)
version = QLabel(f"RigDoctor v{__version__}")
version.setObjectName("Muted")
h.addWidget(version)
return bar
def _restart(self) -> None:
gui = os.path.join(os.path.dirname(sys.executable), "rigdoctor-gui")
if os.path.exists(gui):
@@ -259,6 +289,9 @@ class MainWindow(QMainWindow):
def _apply_update(self) -> None:
if not self._latest_tag:
return
if updates.install_kind() != "pip": # apt/source: can't pip-update — show the command
QMessageBox.information(self, "Update RigDoctor", updates.update_hint())
return
box = QMessageBox(self)
box.setWindowTitle(f"Update to {self._latest_tag}")
box.setText(f"Update RigDoctor to {self._latest_tag}?")
@@ -424,7 +457,7 @@ class MainWindow(QMainWindow):
self._update_label.setText("update check unavailable")
elif state == updates.AVAILABLE:
self._update_label.setText(f'<span style="color:{GOOD};">{tag} available</span>')
self._update_btn.setText(f"Update to {tag}")
self._update_btn.setText(f"Update to {tag}" if updates.install_kind() == "pip" else "How to update")
self._update_btn.setVisible(True)
if self._alert_monitor.enabled and tag != self._notified_update_tag:
self._notified_update_tag = tag # once per version, not every poll
+182
View File
@@ -0,0 +1,182 @@
"""Results view for an imported crash dump (.dmp, M14): parsed summary + AI explanation.
Mirrors :class:`DiagnosticDialog` the same opt-in, streamed "Explain with AI" flow (D24),
applied to a Windows minidump parsed by :mod:`core.minidump` instead of a sensor capture.
"""
from __future__ import annotations
import threading
from pathlib import Path
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QTextCursor
from PySide6.QtWidgets import (
QDialog,
QFrame,
QHBoxLayout,
QLabel,
QMessageBox,
QPushButton,
QScrollArea,
QTextEdit,
QVBoxLayout,
QWidget,
)
from ..core import minidump
from .widgets import finding_card
class MinidumpDialog(QDialog):
_chunk = Signal(str) # streamed token delta (worker thread -> GUI)
_explained = Signal(object) # (ok, full_text) when the AI stream finishes
def __init__(self, report: minidump.MinidumpReport, parent=None) -> None:
super().__init__(parent)
self._report = report
self._stream_view = None
self._stream_status = None
self._chunk.connect(self._on_chunk)
self._explained.connect(self._on_explained)
name = Path(report.path).name
self.setWindowTitle(f"Crash dump — {name}")
self.resize(660, 680)
root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 16)
root.setSpacing(14)
title = QLabel(f"Crash dump — {name}")
title.setObjectName("PageTitle")
root.addWidget(title)
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setFrameShape(QFrame.Shape.NoFrame)
scroll.setStyleSheet("background: transparent;")
body = QWidget()
col = QVBoxLayout(body)
col.setContentsMargins(0, 0, 0, 0)
col.setSpacing(10)
col.setAlignment(Qt.AlignmentFlag.AlignTop)
# Parsed summary (crash reason / faulting module / OS / CPU / modules) — monospace.
summary_head = QLabel("Dump summary")
summary_head.setStyleSheet("font-weight: 700; background: transparent;")
col.addWidget(summary_head)
summary = QLabel(minidump.to_text(report))
summary.setObjectName("Report")
summary.setFont(QFont("monospace"))
summary.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
summary.setWordWrap(False)
summary.setStyleSheet(
"background: #0d0f13; color: #cfd3da; border: 1px solid #2a2f39; "
"border-radius: 8px; padding: 10px;"
)
col.addWidget(summary)
findings = minidump.to_findings(report)
find_head = QLabel(f"Findings ({len(findings)})")
find_head.setStyleSheet("font-weight: 700; background: transparent;")
col.addWidget(find_head)
for finding in findings:
col.addWidget(finding_card(finding))
if report.stackwalk: # only when an external stackwalker was available
sw_head = QLabel("minidump_stackwalk output")
sw_head.setStyleSheet("font-weight: 700; background: transparent;")
col.addWidget(sw_head)
sw = QTextEdit()
sw.setObjectName("Report")
sw.setReadOnly(True)
sw.setFont(QFont("monospace"))
sw.setPlainText(report.stackwalk)
sw.setMinimumHeight(160)
col.addWidget(sw)
scroll.setWidget(body)
root.addWidget(scroll, 1)
buttons = QHBoxLayout()
self._explain_btn = QPushButton("Explain with AI")
self._explain_btn.clicked.connect(self._explain_with_ai)
from ..core import ai
self._explain_btn.setVisible(ai.is_configured()) # opt-in only; hidden if not set up
buttons.addWidget(self._explain_btn)
buttons.addStretch(1)
close = QPushButton("Close")
close.setObjectName("PrimaryButton")
close.clicked.connect(self.accept)
buttons.addWidget(close)
root.addLayout(buttons)
# --- AI explanation (M14, D24) — streamed; runs only on this button press ----------
def _explain_with_ai(self) -> None:
from ..core import ai
if not ai.is_local(): # cloud provider → explicit consent before sending data
confirm = QMessageBox.question(
self, "Send to AI provider",
f"This sends the parsed crash dump to {ai.provider_label()}.\n\nContinue?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.No,
)
if confirm != QMessageBox.StandardButton.Yes:
return
self._explain_btn.setEnabled(False)
dialog = self._open_stream_dialog()
threading.Thread(target=self._work_explain, daemon=True).start()
dialog.exec() # streaming fills the view live via signals during this nested loop
self._stream_view = self._stream_status = None
self._explain_btn.setEnabled(True)
def _work_explain(self) -> None:
from ..core import ai
text = minidump.to_ai_text(self._report)
ok, reply = ai.explain_stream(text, on_chunk=lambda d: self._chunk.emit(d))
self._explained.emit((ok, reply))
def _on_chunk(self, delta: str) -> None:
if self._stream_view is None:
return
self._stream_view.moveCursor(QTextCursor.MoveOperation.End)
self._stream_view.insertPlainText(delta) # live plain text as tokens arrive
self._stream_view.ensureCursorVisible()
def _on_explained(self, result) -> None:
ok, text = result
if self._stream_view is not None:
if ok:
self._stream_view.setMarkdown(text) # re-render the finished answer as Markdown
else:
self._stream_view.setPlainText(f"AI explanation failed:\n\n{text}")
if self._stream_status is not None:
self._stream_status.setText(
"AI-generated suggestions — verify before acting, especially anything that changes "
"settings or data." if ok else "The request failed.")
def _open_stream_dialog(self) -> QDialog:
"""A live dialog the AI streams into; finalized to rendered Markdown when done."""
from ..core import ai
dlg = QDialog(self)
dlg.setWindowTitle(f"AI explanation — {ai.provider_label()}")
dlg.resize(620, 520)
lay = QVBoxLayout(dlg)
view = QTextEdit()
view.setObjectName("Report")
view.setReadOnly(True)
lay.addWidget(view)
status = QLabel("Streaming from the model…")
status.setObjectName("Muted")
status.setWordWrap(True)
lay.addWidget(status)
close = QPushButton("Close")
close.setObjectName("PrimaryButton")
close.clicked.connect(dlg.accept)
lay.addWidget(close, alignment=Qt.AlignmentFlag.AlignRight)
self._stream_view = view
self._stream_status = status
return dlg
+2
View File
@@ -68,6 +68,8 @@ QMainWindow, #ContentArea, #Page {{ background: {BG}; }}
QLabel {{ background: transparent; }}
#Sidebar {{ background: {SIDEBAR}; border-right: 1px solid {CARD_BORDER}; }}
#Footer {{ background: {SIDEBAR}; border-top: 1px solid {CARD_BORDER}; }}
#Footer QLabel {{ font-size: 11px; }}
#AppTitle {{ font-size: 17px; font-weight: 800; }}
#AppSubtitle {{ color: {MUTED}; font-size: 11px; }}
+67
View File
@@ -0,0 +1,67 @@
"""Tests for display detection (Mutter D-Bus JSON + xrandr parsers)."""
import unittest
from rigdoctor.core import displays
# Minimal Mutter GetCurrentState (busctl --json) shape: current mode is 60 Hz, panel max 165 Hz.
_MUTTER_60 = (
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-preferred":{"type":"b","data":true}}]'
'],{}]],[],{}]}'
)
_MUTTER_MAX = (
'{"type":"x","data":[1,[[["DP-1","SAM","LC34G55T","S"],['
'["3440x1440@165",3440,1440,165.0,1.0,[1.0],{"is-current":{"type":"b","data":true}}],'
'["3440x1440@60",3440,1440,60.0,1.0,[1.0],{}]'
'],{}]],[],{}]}'
)
_XRANDR_60 = """Screen 0: minimum 8 x 8, current 3440 x 1440, maximum 16384 x 16384
DP-1 connected primary 3440x1440+0+0 (normal left inverted right x axis y axis) 800mm x 335mm
3440x1440 60.00*+ 165.00 100.00
2560x1440 165.00 60.00
HDMI-1 disconnected (normal left inverted right x axis y axis)
"""
class MutterParseTests(unittest.TestCase):
def test_parses_and_flags_higher_refresh(self):
mons = displays._parse_mutter(_MUTTER_60)
self.assertEqual(len(mons), 1)
m = mons[0]
self.assertEqual(m.connector, "DP-1")
self.assertEqual(m.name, "Samsung LC34G55T") # PNP code SAM mapped
self.assertEqual((m.width, m.height), (3440, 1440))
self.assertEqual(round(m.refresh), 60)
self.assertEqual(round(m.max_refresh), 165)
self.assertTrue(m.can_go_faster)
def test_at_max_is_not_flagged(self):
m = displays._parse_mutter(_MUTTER_MAX)[0]
self.assertEqual(round(m.refresh), 165)
self.assertFalse(m.can_go_faster)
def test_garbage_returns_empty(self):
self.assertEqual(displays._parse_mutter("not json"), [])
self.assertEqual(displays._parse_mutter("{}"), [])
class XrandrParseTests(unittest.TestCase):
def test_current_and_max_refresh(self):
mons = displays._parse_xrandr(_XRANDR_60)
self.assertEqual(len(mons), 1) # disconnected output ignored
m = mons[0]
self.assertEqual(m.connector, "DP-1")
self.assertEqual((m.width, m.height), (3440, 1440))
self.assertEqual(round(m.refresh), 60)
self.assertEqual(round(m.max_refresh), 165)
self.assertTrue(m.can_go_faster)
def test_empty_returns_empty(self):
self.assertEqual(displays._parse_xrandr(""), [])
if __name__ == "__main__":
unittest.main()
+78 -1
View File
@@ -1,8 +1,20 @@
"""Tests for the M4 health report's log scanner (synthetic input)."""
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core.health import CRITICAL, WARNING, run_health_checks, scan_journal_text
from rigdoctor.core import displays, health
from rigdoctor.core.health import (
CRITICAL,
INFO,
WARNING,
check_displays,
check_memory_speed,
check_pcie_links,
run_health_checks,
scan_journal_text,
)
class HealthScanTests(unittest.TestCase):
@@ -42,5 +54,70 @@ class HealthScanTests(unittest.TestCase):
self.assertEqual(ranks, sorted(ranks))
class PcieLinkCheckTests(unittest.TestCase):
def _with_link(self, cur_g, cur_w, max_g, max_w):
# one fake NVMe controller returning the given link tuple
return (mock.patch("rigdoctor.core.inventory.nvme_controllers",
return_value=[("nvme0", Path("/x"))]),
mock.patch("rigdoctor.core.inventory.read_link",
return_value=(cur_g, cur_w, max_g, max_w)))
def test_reduced_width_is_a_warning_about_lane_sharing(self):
ctrls, link = self._with_link(4, "2", 4, "4") # Gen4 x2 but supports x4
with ctrls, link:
findings = check_pcie_links()
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0].severity, WARNING)
self.assertIn("lane-sharing", findings[0].detail)
def test_reduced_speed_only_is_info(self):
ctrls, link = self._with_link(3, "4", 4, "4") # Gen3 x4 but supports Gen4
with ctrls, link:
findings = check_pcie_links()
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0].severity, INFO)
def test_full_speed_no_finding(self):
ctrls, link = self._with_link(4, "4", 4, "4")
with ctrls, link:
self.assertEqual(check_pcie_links(), [])
class DisplayCheckTests(unittest.TestCase):
def test_lower_than_max_refresh_is_flagged(self):
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 60.0, 165.0)
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
findings = check_displays()
self.assertEqual(len(findings), 1)
self.assertEqual(findings[0].severity, INFO)
self.assertIn("165", findings[0].title)
def test_at_max_refresh_no_finding(self):
mon = displays.Monitor("DP-1", "Samsung LC34G55T", 3440, 1440, 165.0, 165.0)
with mock.patch("rigdoctor.core.displays.collect", return_value=[mon]):
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()
+46
View File
@@ -1,6 +1,8 @@
"""Tests for the M5 system inventory (render + dict round-trip; collect on real system)."""
import tempfile
import unittest
from pathlib import Path
from rigdoctor.core import inventory
from rigdoctor.core.inventory import Section
@@ -26,5 +28,49 @@ class InventoryTests(unittest.TestCase):
self.assertIn("- **Model:** Test CPU", md)
class PcieLinkTests(unittest.TestCase):
def test_gen_mapping(self):
self.assertEqual(inventory._gen("16.0 GT/s PCIe"), 4)
self.assertEqual(inventory._gen("8.0 GT/s PCIe"), 3)
self.assertIsNone(inventory._gen(""))
def _fake_dev(self, cur_s, cur_w, max_s, max_w) -> Path:
d = Path(tempfile.mkdtemp())
(d / "current_link_speed").write_text(cur_s)
(d / "current_link_width").write_text(cur_w)
(d / "max_link_speed").write_text(max_s)
(d / "max_link_width").write_text(max_w)
return d
def test_link_at_full_speed(self):
dev = self._fake_dev("16.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4")
self.assertEqual(inventory._link_desc(dev), "PCIe Gen4 x4")
def test_link_downtrained_flags_capability(self):
dev = self._fake_dev("8.0 GT/s PCIe", "4", "16.0 GT/s PCIe", "4")
self.assertEqual(inventory._link_desc(dev), "PCIe Gen3 x4 (capable of Gen4 x4)")
def test_non_nvme_has_no_link(self):
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()
+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()
+64
View File
@@ -0,0 +1,64 @@
"""Tests for the M13 updater: install detection + routing the update to the right method."""
import unittest
from unittest import mock
from rigdoctor.core import updates
class InstallKindTests(unittest.TestCase):
def setUp(self):
updates.install_kind.cache_clear()
def tearDown(self):
updates.install_kind.cache_clear()
def test_apt_when_dpkg_owns_the_package(self):
with mock.patch.object(updates, "_dpkg_owns", return_value=True):
self.assertEqual(updates.install_kind(), "apt")
def test_pip_when_running_in_a_venv(self):
with mock.patch.object(updates, "_dpkg_owns", return_value=False), \
mock.patch.object(updates.sys, "prefix", "/opt/venv"), \
mock.patch.object(updates.sys, "base_prefix", "/usr"):
self.assertEqual(updates.install_kind(), "pip")
class ApplyUpdateRoutingTests(unittest.TestCase):
def test_apt_returns_guidance_and_never_runs_pip(self):
with mock.patch.object(updates, "install_kind", return_value="apt"), \
mock.patch("subprocess.run") as run:
rc, out = updates.apply_update("v9.9.9")
self.assertEqual(rc, 1)
self.assertIn("apt install --only-upgrade", out)
run.assert_not_called()
def test_dev_returns_guidance_and_never_runs_pip(self):
with mock.patch.object(updates, "install_kind", return_value="dev"), \
mock.patch("subprocess.run") as run:
rc, out = updates.apply_update("v9.9.9")
self.assertIn("git pull", out)
run.assert_not_called()
def test_pip_install_runs_pip(self):
proc = mock.Mock(returncode=0, stdout="Successfully installed", stderr="")
with mock.patch.object(updates, "install_kind", return_value="pip"), \
mock.patch.object(updates, "load_token", return_value="TOK"), \
mock.patch("subprocess.run", return_value=proc) as run:
rc, _out = updates.apply_update("v1.2.3")
self.assertEqual(rc, 0)
cmd = run.call_args[0][0]
self.assertIn("pip", cmd)
self.assertIn("install", cmd)
class UpdateHintTests(unittest.TestCase):
def test_apt_hint_names_the_apt_command(self):
self.assertIn("apt install --only-upgrade rigdoctor", updates.update_hint("apt"))
def test_dev_hint_says_git_pull(self):
self.assertIn("git pull", updates.update_hint("dev"))
if __name__ == "__main__":
unittest.main()