diff --git a/.github/iso-check/inspect.sh b/.github/iso-check/inspect.sh deleted file mode 100755 index 7c20c1d..0000000 --- a/.github/iso-check/inspect.sh +++ /dev/null @@ -1,36 +0,0 @@ -#!/usr/bin/env bash -# Looks inside a built ISO's system image (without booting it) and prints -# what this branch changed: sizes, what is and is not installed, which -# services are enabled. Usage: inspect.sh ISO -set -uo pipefail -iso=$1 -sudo apt-get install -y -qq squashfs-tools >/dev/null -sudo mkdir -p /mnt/iso -sudo mount -o loop,ro "$iso" /mnt/iso -echo "== ISO: $(stat -c %s "$iso") bytes" -ls -l /mnt/iso/LiveOS /mnt/iso/boot | grep -E 'img|vmlinuz' -list=$(mktemp) -sudo unsquashfs -ll /mnt/iso/LiveOS/rootfs.img > "$list" 2>/dev/null -echo "== listing: $(wc -l < "$list") entries" -has() { grep -qE "squashfs-root/$1( ->.*)?\$" "$list" && echo "present $1" || echo "ABSENT $1"; } -echo "== packages in the image: $(grep -cE 'squashfs-root/var/lib/pacman/local/[^/]+/desc$' "$list")" -echo "== should be there" -for f in usr/bin/seg etc/init.d/seg-zram etc/init.d/seg-tune etc/runlevels/default/seg-zram etc/runlevels/default/seg-tune \ - etc/seg/tune.conf etc/sysctl.d/99-seg.conf etc/cron.d/seg-fstrim usr/lib/seg/fstrim-weekly \ - etc/makepkg.conf.d/10-seglinux.conf usr/lib/seglinux/zram-setup usr/lib/udev/rules.d/60-seglinux-ioschedulers.rules \ - etc/runit/sv/seg-zram/run etc/dinit.d/seg-zram; do has "$f"; done -if [[ $EDITION == full ]]; then - for f in usr/bin/rg usr/bin/fd usr/bin/eza usr/bin/zoxide etc/bash/bashrc.d/seglinux-rust-tools.bashrc usr/bin/xwayland usr/bin/Xwayland; do has "$f"; done -fi -echo "== should be gone" -for f in usr/bin/Xorg usr/bin/spectacle usr/bin/inxi; do has "$f"; done -echo "kernel header folders: $(grep -cE 'squashfs-root/usr/src/[^/]+$' "$list") (0 expected)" -echo "== enabled in the image" -grep -E 'squashfs-root/etc/runlevels/[a-z]+/[^/]+ ->' "$list" | sed -E 's#.*squashfs-root/etc/runlevels/##; s# ->.*##' | sort | tr '\n' ' ' -echo -echo "== packages that need the most room (installed size, MiB)" -sudo unsquashfs -f -d /tmp/rt /mnt/iso/LiveOS/rootfs.img var/lib/pacman/local >/dev/null 2>&1 -for d in /tmp/rt/var/lib/pacman/local/*/; do - n=$(basename "$d"); size=$(awk '/^%SIZE%/{getline; print $1}' "$d/desc"); echo "$((size / 1048576)) $n" -done | sort -rn | head -12 -echo "total installed MiB: $(for d in /tmp/rt/var/lib/pacman/local/*/; do awk '/^%SIZE%/{getline; s+=$1} END{print s+0}' "$d/desc"; done | awk '{t+=$1} END{printf "%d", t / 1048576}')" diff --git a/.github/iso-check/qemu_check.py b/.github/iso-check/qemu_check.py deleted file mode 100644 index 40b5914..0000000 --- a/.github/iso-check/qemu_check.py +++ /dev/null @@ -1,130 +0,0 @@ -#!/usr/bin/env python3 -"""Boot an ISO headless in QEMU, log in on a text console and run seg. - -Best effort and for the log only: it drives QEMU's monitor (screendump and -sendkey), reads the screen with tesseract and prints what it saw. It always -exits 0; the image checks before it are what fail the job. - -usage: qemu_check.py ISO [boot_seconds] -""" -import os -import socket -import subprocess -import sys -import time - -from PIL import Image - -ISO = sys.argv[1] -BOOT_WAIT = int(sys.argv[2]) if len(sys.argv) > 2 else 240 -MON = "/tmp/qemu-mon.sock" -WORK = "/tmp/qemu-check" -os.makedirs(WORK, exist_ok=True) - -KEYS = {" ": "spc", "-": "minus", ".": "dot", ";": "semicolon", "/": "slash", "\n": "ret", "_": "shift-minus", "=": "equal"} - - -def log(msg): - print(msg, flush=True) - - -class Monitor: - def __init__(self, path): - deadline = time.time() + 30 - while True: - try: - self.sock = socket.socket(socket.AF_UNIX) - self.sock.connect(path) - break - except OSError: - if time.time() > deadline: - raise - time.sleep(0.5) - self.sock.settimeout(5) - self.read() - - def read(self): - data = b"" - try: - while True: - chunk = self.sock.recv(4096) - if not chunk: - break - data += chunk - if data.rstrip().endswith(b"(qemu)"): - break - except socket.timeout: - pass - return data.decode(errors="replace") - - def cmd(self, text): - self.sock.sendall(text.encode() + b"\n") - return self.read() - - def key(self, name): - self.cmd(f"sendkey {name}") - time.sleep(0.12) - - def type(self, text): - for ch in text: - self.key(KEYS.get(ch, ch)) - - def screen_text(self, tag): - ppm = f"{WORK}/{tag}.ppm" - self.cmd(f"screendump {ppm}") - time.sleep(1) - png = f"{WORK}/{tag}.png" - try: - img = Image.open(ppm) - img = img.resize((img.width * 2, img.height * 2)) - img.save(png) - out = subprocess.run(["tesseract", png, "-", "--psm", "6"], capture_output=True, text=True, timeout=60).stdout - except Exception as e: # the screen may not be written yet - return f"(no screen: {e})" - return "\n".join(line for line in out.splitlines() if line.strip()) - - -def main(): - args = ["qemu-system-x86_64", "-m", "3072", "-smp", "2", "-cdrom", ISO, "-boot", "d", - "-display", "none", "-vga", "std", "-monitor", f"unix:{MON},server,nowait", - "-netdev", "user,id=n0", "-device", "virtio-net-pci,netdev=n0"] - if os.access("/dev/kvm", os.R_OK | os.W_OK): - args += ["-enable-kvm", "-cpu", "host"] - log("qemu: using KVM") - else: - args += ["-cpu", "max"] - log("qemu: no KVM, emulating (slow)") - boot_wait = BOOT_WAIT if "-enable-kvm" in args else max(BOOT_WAIT, 420) - qemu = subprocess.Popen(args, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE) - try: - mon = Monitor(MON) - start = time.time() - n = 0 - while time.time() - start < boot_wait: - time.sleep(30) - n += 1 - text = mon.screen_text(f"boot{n}") - log(f"--- screen after {int(time.time() - start)} s:\n" + "\n".join(text.splitlines()[:12])) - # Switch to the second console, where agetty waits for a login. - mon.cmd("sendkey ctrl-alt-f2") - time.sleep(5) - log("--- console 2:\n" + mon.screen_text("tty2")) - mon.type("seglinux\n") - time.sleep(4) - mon.type("seglinux\n") - time.sleep(8) - for i, command in enumerate(["seg info", "seg zram status", "seg tune", "seg svc list --enabled", "seg doctor"]): - mon.type("clear\n") - time.sleep(1) - mon.type(command + "\n") - time.sleep(12) - log(f"--- $ {command}:\n" + mon.screen_text(f"cmd{i}")) - except Exception as e: - log(f"qemu check stopped: {type(e).__name__}: {e}") - finally: - qemu.kill() - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0214499..221a568 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -79,10 +79,17 @@ jobs: # Without the test cfg, where unwrap and expect are compile errors. - run: cargo clippy --locked -- -D warnings - run: cargo test --locked - - name: Dependencies stay minimal (seg and lexopt only) + - name: Dependencies stay minimal (seg, lexopt and libc only) run: | cargo tree --locked -e normal --prefix none | sort -u - test "$(cargo tree --locked -e normal --prefix none | sort -u | wc -l)" -le 2 + test "$(cargo tree --locked -e normal --prefix none | sort -u | wc -l)" -le 3 + # The only unsafe code is the few libc calls of src/game/proc.rs. + - name: unsafe stays in one file + run: | + test "$(grep -rEl '\bunsafe[[:space:]]*(\{|fn|impl|extern)' segctl/src)" = "segctl/src/game/proc.rs" + - name: sudoers rule is valid + run: | + sudo visudo -cf dist/sudoers-seg-game - name: Release binary size run: | cargo build --locked --release @@ -106,7 +113,7 @@ jobs: data/desktop/labwc/autostart data/system/seglinux-terminal data/system/restore-trimmed-files \ data/system/profile-gaming.sh data/greeter/seglinux-greeter-session data/system/zram-setup \ tools/seg/dist/fstrim-weekly tools/seg/dist/runit/*/run tools/seg/dist/runit/seg-zram/finish \ - tools/seg/dist/openrc/seg-tune tools/seg/dist/openrc/seg-zram + tools/seg/dist/openrc/seg-tune tools/seg/dist/openrc/seg-zram tools/seg/dist/openrc/seg-game shellcheck -s bash pkg/segctl/segctl.install shellcheck -s bash iso/iso-profiles/seglinux-lite/live-overlay/etc/skel/.bash_profile - name: YAML, JSON and XML syntax @@ -200,7 +207,9 @@ jobs: pacman-key --populate artix pacman -Sy --noconfirm --needed go-yq iso/scripts/chaotic-aur.sh - - run: iso/scripts/check-packages.sh -p ${{ matrix.profile }} -i ${{ matrix.init }} -k linux-zen + # -x: the packages `seg game setup` can choose (NVIDIA's driver, the + # Vulkan drivers, the games stack) must exist and resolve on top of the image. + - run: iso/scripts/check-packages.sh -p ${{ matrix.profile }} -i ${{ matrix.init }} -k linux-zen -x tools/seg/segctl/tests/setup-packages.txt - name: Packages the Lite edition downloads during Setup if: matrix.profile == 'seglinux-lite' run: iso/scripts/online-packages.sh -i openrc -k linux-zen diff --git a/.github/workflows/iso-check.yml b/.github/workflows/iso-check.yml deleted file mode 100644 index d939bad..0000000 --- a/.github/workflows/iso-check.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: ISO check - -# Builds an image on a branch WITHOUT publishing anything, looks inside it -# and boots the Lite one in QEMU. Starts on a push to the work branch whose -# commit message contains "[iso check]". Temporary: removed once the -# seg/rust-lean-overhaul branch is checked. -on: - push: - branches: [seg/rust-lean-overhaul] - -permissions: - contents: read - -jobs: - iso: - name: Build and inspect (${{ matrix.edition }}, openrc, linux-zen) - if: contains(github.event.head_commit.message, '[iso check]') - runs-on: ubuntu-latest - timeout-minutes: 150 - strategy: - fail-fast: false - matrix: - edition: [lite, full] - env: - EDITION: ${{ matrix.edition }} - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Free disk space - run: | - sudo rm -rf /usr/share/dotnet /usr/local/lib/android /opt/ghc /opt/hostedtoolcache/CodeQL - sudo docker system prune -af > /dev/null - df -h / - - name: Load the loop module - run: sudo modprobe loop - - name: Build - run: | - edition=() - [[ $EDITION == lite ]] && edition=(--lite) - SEGLINUX_PKGVER="0.1.0.$(git rev-list --count origin/main)" \ - ./iso/build.sh "${edition[@]}" -e docker -i openrc -k linux-zen - - name: Inside the image - run: .github/iso-check/inspect.sh "$(ls iso/out/seglinux-*.iso)" - - name: Boot it in QEMU (Lite) - if: matrix.edition == 'lite' - continue-on-error: true - run: | - sudo apt-get install -y -qq qemu-system-x86 tesseract-ocr python3-pil >/dev/null - [[ -e /dev/kvm ]] && sudo chmod 666 /dev/kvm || true - python3 .github/iso-check/qemu_check.py "$(ls iso/out/seglinux-*.iso)" 150 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..8c1c678 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,88 @@ +# Changelog + +Notable changes to SegLinux. The project has no version numbers yet, so this +lists what changed since the last ISO build of 19 October 2026 (`4ca2f45`). +Measurements and the reasons behind each removal are in +[docs/lean-audit.md](docs/lean-audit.md). + +## Unreleased + +### Added + +- **`seg game`**, a small gaming layer in the `seg` tool, the same on OpenRC, + runit, dinit and s6 ([docs/gaming.md](docs/gaming.md#seg-game)): + - `seg game run -- CMD` (Steam: `seg game run -- %command%`) raises the CPU + governor and energy preference and an AMD GPU's power level while a game + runs, gives the game a little more CPU and the best I/O priority, holds + off the screen saver, can pin it to the V-Cache chiplet of a two-chiplet + X3D CPU, and restores everything when the game ends, on Ctrl+C, SIGTERM, + SIGHUP or a crash of `seg`. + - `seg game check`: PASS/WARN/FAIL/SKIP for Vulkan, the GPU driver, 32-bit + drivers, NTSYNC, `vm.max_map_count`, open files, kernel, fallback kernel, + CPU governor and driver, boost permission, hugepages, split lock, + compaction and mitigations, each with the exact fix. + - `seg game setup [--yes]`: the driver packages for the GPUs found in the + computer, and only those. + - `seg game profiles`, per-game profiles in `~/.config/seg/games.toml` + (governor, CPU pinning, MangoHud, gamescope arguments, nice). + - `seg game apply` and the **`seg-game` service** (OpenRC, runit, dinit, + s6): the gaming sysctls and `/dev/ntsync` permissions at boot. + - `seg game audio on|off`: low-latency PipeWire, off by default. + - `seg game unsafe-boost on|off`: `mitigations=off`, the only way to get + it, behind a warning and `--i-understand`. + - `seg tune gaming`: low swappiness, warm caches, short write-back, CPU + left dynamic. + - A sudoers rule for the `gamemode` group (two commands, nothing else), a + MangoHud preset and PipeWire drop-ins under `/usr/share/seg` (all off + until asked for). +- Setup installs the **other kernel** (`linux` beside `linux-zen`, or the + reverse) as a boot menu entry to fall back on when online + (`[performance] fallback_kernel`); the installed kernel is never removed. +- Kernel command line: `transparent_hugepage=madvise` everywhere and + `amd_pstate=active` on AMD processors (an answer file's parameter of the + same name replaces the default). +- `vm.compaction_proactiveness = 0` with the other gaming sysctls. +- NVIDIA: `nvidia_drm modeset=1 fbdev=1` (Wayland compositors, VT switch). +- `seg` (`tools/seg`, package `segctl`): `info`, `svc`, `clean`, `tune`, + `zram`, `doctor` for OpenRC, runit, dinit and s6, with the `seg-zram` and + `seg-tune` services and a weekly `fstrim` cron job. +- `ripgrep`, `fd`, `eza` and `zoxide` in the full edition. +- `docs/lean-audit.md`: what the images weigh before and after, the bloat + table, the bugs found, what was not verified. +- CI: a job for `seg` (fmt, clippy, tests, release size, dependency and + `unsafe` guards, `visudo -cf` of the sudoers rule), `udevadm verify` for + the udev rules, a check for repeated packages, more `shellcheck` targets. + +### Changed + +- `wine` -> `wine-staging` (the Staging patches: esync/fsync/NTSYNC, game + fixes) in the full edition. `lib32-mangohud` was tried and is not in the + Artix repositories, so 32-bit games get no MangoHud overlay. +- Open-file hard limit 524288 -> 1048576 (the kernel's maximum); the + `gamemode` group may use `nice -10`. +- Shader caches: Mesa 4 GB -> 10 GB; NVIDIA's shader cache set to 10 GB and + no longer deleted when full (`/etc/profile.d`, so on every init). +- `makepkg` builds with every thread; the I/O scheduler rule skips partitions + and covers virtual disks. + +### Removed + +- From the full image: `linux-headers` (325 MiB; Setup downloads the running + kernel's headers together with the NVIDIA DKMS driver), `spectacle` and + `opencv`, the X server and its drivers (`xorg-xwayland` stays), `inxi`, + and five packages that were listed twice. The full ISO is 4.7 % smaller and + 475 MiB less is installed. +- `cupsd` is no longer started by default (the packages stay; `seg svc enable + cupsd`). + +### Fixed + +- The NVIDIA DKMS driver could only build because the headers were on every + image: Setup now installs them in that step. +- The artools patch that reads the kernel version without headers applies to + every kernel, not only the non-stock ones. +- The I/O scheduler udev rule matched partitions (errors in the log) and + missed virtual disks. +- `data/system/zram-setup` and the udev rules were not checked by CI. +- `seg doctor` called OpenRC services of the `shutdown` runlevel (`killprocs`, + `mount-ro`, `savecache`) failed; found by booting the Lite image. diff --git a/README.md b/README.md index 1d71170..f8f9c69 100644 --- a/README.md +++ b/README.md @@ -90,11 +90,15 @@ published. kitty as the terminal (File Explorer → Dolphin, Store → Discover, ...). The terminal opens with system information under the SegLinux logo (fastfetch). -* **Built for gaming.** linux-zen kernel, Steam with Proton, Lutris, - GameMode, MangoHud and gamescope; a Steam "Gaming Mode" session; variable - refresh rate and tearing for fullscreen games; zram swap, NTSYNC, I/O - scheduler and sysctl tuning; NVIDIA's driver installed automatically on - GeForce GTX 16/RTX cards. See [docs/gaming.md](docs/gaming.md). +* **Built for gaming.** linux-zen kernel (with the stock kernel beside it to + fall back on), Steam with Proton, Lutris, Wine Staging, GameMode, MangoHud + and gamescope; a Steam "Gaming Mode" session; variable refresh rate and + tearing for fullscreen games; zram swap, NTSYNC, I/O scheduler and sysctl + tuning; NVIDIA's driver installed automatically on GeForce GTX 16/RTX + cards, and only for hardware that is there. `seg game run -- %command%` in + Steam's launch options boosts the CPU and GPU while a game runs and puts + everything back after; `seg game check` says what is missing, with the + fix. See [docs/gaming.md](docs/gaming.md). * **Runs Windows programs.** Double-click an `.exe`, `.msi`, `.lnk` or `.bat`, or type `./setup.exe`: `segrun` inspects the program, prepares a Wine prefix (with DXVK/VKD3D-Proton when the program uses Direct3D) and @@ -103,10 +107,12 @@ published. or dinit; rolling release. * **`seg`, one small tool for the system.** `seg info`, `seg svc` (the same commands on OpenRC, runit, dinit and s6), `seg clean`, `seg tune` - (light, balanced or performance), `seg zram` and `seg doctor`, which finds - problems and prints their fixes. A single Rust binary of about 550 KiB; see + (light, balanced, gaming or performance), `seg zram`, `seg game` (see + above) and `seg doctor`, which finds problems and prints their fixes. A + single Rust binary of about 700 KiB; see [tools/seg](tools/seg/README.md). What the images weigh and what was taken - off them: [docs/lean-audit.md](docs/lean-audit.md). + off them: [docs/lean-audit.md](docs/lean-audit.md). What changed: + [CHANGELOG.md](CHANGELOG.md). ## Website diff --git a/crates/seglinux-installer/src/model.rs b/crates/seglinux-installer/src/model.rs index 283f8cd..b1b869d 100644 --- a/crates/seglinux-installer/src/model.rs +++ b/crates/seglinux-installer/src/model.rs @@ -201,6 +201,7 @@ pub fn demo_facts() -> Facts { memory: 8 * GIB, gpus: vec![], kernel: Some("linux-zen".into()), + cpu_vendor: None, timezones: Some(set(&[ "UTC", "Africa/Tunis", diff --git a/crates/seglinux-setup/autounattend.example.toml b/crates/seglinux-setup/autounattend.example.toml index 5b19cbb..69a6574 100644 --- a/crates/seglinux-setup/autounattend.example.toml +++ b/crates/seglinux-setup/autounattend.example.toml @@ -62,7 +62,8 @@ os_prober = true # list Windows in the boot menu # Gaming and boot tuning. [performance] parallel_boot = true # OpenRC: start services in parallel -kernel_parameters = [] # added to: loglevel=3 quiet nowatchdog nmi_watchdog=0 +kernel_parameters = [] # added to: loglevel=3 quiet nowatchdog nmi_watchdog=0 transparent_hugepage=madvise +fallback_kernel = true # also install the other kernel (linux beside linux-zen), a boot menu entry to fall back on # GPU drivers. "auto" installs NVIDIA's driver for GeForce GTX 16/RTX cards # when the network is up; "never" keeps the open-source driver. diff --git a/crates/seglinux-setup/src/answer.rs b/crates/seglinux-setup/src/answer.rs index 3b002a2..fdc2b12 100644 --- a/crates/seglinux-setup/src/answer.rs +++ b/crates/seglinux-setup/src/answer.rs @@ -266,8 +266,14 @@ pub struct PerformanceSection { /// OpenRC: start services in parallel (`rc_parallel`) for a faster boot. pub parallel_boot: bool, /// Kernel parameters added to SegLinux's defaults - /// (`loglevel=3 quiet nowatchdog nmi_watchdog=0`). + /// (`loglevel=3 quiet nowatchdog nmi_watchdog=0 transparent_hugepage=madvise`, + /// `zswap.enabled=0` with zram, `amd_pstate=active` on AMD processors). + /// A parameter given here replaces the default of the same name. pub kernel_parameters: Vec, + /// Install the other kernel (`linux` next to `linux-zen`, the reverse for + /// the stock kernel) as a boot menu entry to fall back on, when the + /// network is reachable. The kernel that is installed is never removed. + pub fallback_kernel: bool, } impl Default for PerformanceSection { @@ -275,6 +281,7 @@ impl Default for PerformanceSection { Self { parallel_boot: true, kernel_parameters: Vec::new(), + fallback_kernel: true, } } } @@ -342,6 +349,8 @@ pub const DEFAULT_SERVICES: &[&str] = &[ // From the segctl package: zram swap and the saved `seg tune` profile. "seg-zram", "seg-tune", + // The gaming sysctls and /dev/ntsync permissions, on every init. + "seg-game", ]; /// Groups every user joins (missing ones are skipped). diff --git a/crates/seglinux-setup/src/disk.rs b/crates/seglinux-setup/src/disk.rs index ae9b85e..d65e6b6 100644 --- a/crates/seglinux-setup/src/disk.rs +++ b/crates/seglinux-setup/src/disk.rs @@ -270,8 +270,7 @@ impl Layout { )); } if self.zram.is_some() { - // Created and formatted at boot: the udev rule 60-seglinux-zram.rules runs - // seg zram on (the seg-zram service does too). + // Created and formatted by /etc/udev/rules.d/99-seglinux-zram.rules. out.push_str("/dev/zram0 none swap defaults,discard,pri=100 0 0\n"); } out diff --git a/crates/seglinux-setup/src/facts.rs b/crates/seglinux-setup/src/facts.rs index a89e925..9eb18d2 100644 --- a/crates/seglinux-setup/src/facts.rs +++ b/crates/seglinux-setup/src/facts.rs @@ -112,6 +112,8 @@ pub struct Facts { /// Package name of the running kernel (`linux-zen`): the new system gets /// the same one, and its headers when a driver has to be built. pub kernel: Option, + /// `vendor_id` of the processor in /proc/cpuinfo (`AuthenticAMD`). + pub cpu_vendor: Option, /// Known values for validation and pick lists; `None` = not checked. pub timezones: Option>, pub locales: Option>, @@ -144,6 +146,9 @@ impl Facts { memory: memory_bytes().unwrap_or(0), gpus: list_gpus(Path::new("/sys/bus/pci/devices")), kernel: running_kernel(), + cpu_vendor: fs::read_to_string("/proc/cpuinfo") + .ok() + .and_then(|t| parse_cpu_vendor(&t)), timezones: Some(list_timezones(Path::new("/usr/share/zoneinfo"))) .filter(|s| !s.is_empty()), locales: fs::read_to_string("/usr/share/i18n/SUPPORTED") @@ -304,6 +309,14 @@ pub fn running_kernel() -> Option { parse_pkgbase(&pkgbase) } +/// `vendor_id : AuthenticAMD` of the first processor. +pub fn parse_cpu_vendor(text: &str) -> Option { + text.lines().find_map(|l| { + let (key, value) = l.split_once(':')?; + (key.trim() == "vendor_id").then(|| value.trim().to_string()) + }) +} + pub fn parse_pkgbase(text: &str) -> Option { let name = text.lines().next()?.trim(); let valid = !name.is_empty() @@ -509,6 +522,15 @@ mod tests { assert_eq!(parse_meminfo_total("nope"), None); } + #[test] + fn cpu_vendor_from_cpuinfo() { + assert_eq!( + parse_cpu_vendor("processor\t: 0\nvendor_id\t: AuthenticAMD\nmodel name\t: x\n"), + Some("AuthenticAMD".into()) + ); + assert_eq!(parse_cpu_vendor("model name : x\n"), None); + } + #[test] fn kernel_package_from_pkgbase() { assert_eq!(parse_pkgbase("linux-zen\n"), Some("linux-zen".into())); diff --git a/crates/seglinux-setup/src/plan.rs b/crates/seglinux-setup/src/plan.rs index 1f1295d..037435f 100644 --- a/crates/seglinux-setup/src/plan.rs +++ b/crates/seglinux-setup/src/plan.rs @@ -832,6 +832,22 @@ pub fn build( )], }); } + // A second kernel to fall back on: the boot menu lists every installed + // kernel (the newest, linux-zen, stays the default). Optional, like every + // step that needs the network: without it the system has the one kernel. + if answer.performance.fallback_kernel + && facts.network + && let Some(other) = fallback_kernel(facts.kernel.as_deref()) + { + sp.push(Step { + description: format!( + "Installing the {other} kernel as a fallback boot entry (needs a network connection)" + ), + actions: vec![Action::chroot_optional( + Cmd::new("pacman").args(["-Syu", "--noconfirm", "--needed", other]), + )], + }); + } sp.push(Step { description: "Building the boot image (initramfs)".into(), actions: vec![Action::chroot(Cmd::new("mkinitcpio").arg("-P"))], @@ -860,7 +876,10 @@ pub fn build( grub.push(Action::set_key( "/etc/default/grub", "GRUB_CMDLINE_LINUX_DEFAULT", - format!("\"{}\"", kernel_parameters(answer, &layout).join(" ")), + format!( + "\"{}\"", + kernel_parameters(answer, &layout, facts).join(" ") + ), )); let efi_install = |extra: &[&str]| { Cmd::new("grub-install") @@ -1060,7 +1079,7 @@ pub fn build( /// soft-lockup watchdogs (less background work and fewer interrupts while /// gaming), zswap off when zram provides the swap, then the answer file's /// own parameters. -pub fn kernel_parameters(answer: &Answer, layout: &Layout) -> Vec { +pub fn kernel_parameters(answer: &Answer, layout: &Layout, facts: &Facts) -> Vec { let mut params: Vec = ["loglevel=3", "quiet", "nowatchdog", "nmi_watchdog=0"] .iter() .map(|s| s.to_string()) @@ -1068,6 +1087,30 @@ pub fn kernel_parameters(answer: &Answer, layout: &Layout) -> Vec { if layout.zram.is_some() { params.push("zswap.enabled=0".into()); } + // Hugepages only for programs that ask for them (games, databases): the + // kernel default of `always` can stall on compaction. Arch's kernels + // already say madvise; this makes it so on every kernel. + // AMD processors from Zen 2 on do better with amd-pstate in active mode + // (the processor picks its frequency, guided by the energy hints that + // `seg game run` sets). Kernels or processors without it ignore this. + let mut defaults = vec!["transparent_hugepage=madvise"]; + if facts.cpu_vendor.as_deref() == Some("AuthenticAMD") { + defaults.push("amd_pstate=active"); + } + // The answer file's own parameters win: one with the same name replaces + // the default, the rest are added. + let key = |p: &str| p.split('=').next().unwrap_or(p).to_string(); + let own: Vec = answer + .performance + .kernel_parameters + .iter() + .map(|p| key(p)) + .collect(); + for d in defaults { + if !own.contains(&key(d)) { + params.push(d.into()); + } + } for p in &answer.performance.kernel_parameters { if !params.contains(p) { params.push(p.clone()); @@ -1076,6 +1119,17 @@ pub fn kernel_parameters(answer: &Answer, layout: &Layout) -> Vec { params } +/// The kernel to install next to the one that runs, so that the boot menu +/// has a second entry: `linux` beside `linux-zen` or `linux-lts`, `linux-zen` +/// beside the stock kernel. The one that runs is never touched. +pub fn fallback_kernel(running: Option<&str>) -> Option<&'static str> { + match running { + Some("linux-zen") | Some("linux-lts") | Some("linux-hardened") => Some("linux"), + Some("linux") => Some("linux-zen"), + _ => None, + } +} + fn run_command_step(c: &crate::answer::RunCommand) -> Step { Step { description: c @@ -1148,6 +1202,7 @@ mod tests { memory: 4 * GIB, gpus: vec![], kernel: Some("linux-zen".into()), + cpu_vendor: None, timezones: None, locales: None, keyboards: None, @@ -1200,7 +1255,7 @@ mod tests { assert!(text.contains("| /dev/zram0 none swap defaults,discard,pri=100 0 0")); assert!(text.contains("| vm.swappiness = 180")); assert!(text.contains( - "set GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0 zswap.enabled=0\" in /etc/default/grub" + "set GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0 zswap.enabled=0 transparent_hugepage=madvise\" in /etc/default/grub" )); assert!(text.contains("set rc_parallel=\"YES\" in /etc/rc.conf")); // The sign-in screen preselects the SegLinux desktop and the user. @@ -1285,7 +1340,7 @@ mod tests { assert!(!text.contains("/dev/zram0"), "{text}"); assert!( text.contains( - "GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0\"" + "GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog nmi_watchdog=0 transparent_hugepage=madvise\"" ) ); } @@ -1359,13 +1414,127 @@ mod tests { assert!(!full.contains("restore-trimmed-files")); } + fn cmdline(a: &Answer, f: &Facts) -> String { + let text = build_plan(a, f).unwrap().to_string(); + let start = text.find("set GRUB_CMDLINE_LINUX_DEFAULT=\"").unwrap(); + let rest = &text[start + "set GRUB_CMDLINE_LINUX_DEFAULT=\"".len()..]; + rest[..rest.find('"').unwrap()].to_string() + } + + #[test] + fn amd_processors_get_amd_pstate_active_and_others_do_not() { + let mut f = facts(); + f.cpu_vendor = Some("AuthenticAMD".into()); + let c = cmdline(&answer(""), &f); + assert!( + c.ends_with("transparent_hugepage=madvise amd_pstate=active"), + "{c}" + ); + f.cpu_vendor = Some("GenuineIntel".into()); + assert!(!cmdline(&answer(""), &f).contains("amd_pstate")); + f.cpu_vendor = None; + assert!(!cmdline(&answer(""), &f).contains("amd_pstate")); + } + + #[test] + fn an_answer_file_replaces_a_default_of_the_same_name() { + let mut f = facts(); + f.cpu_vendor = Some("AuthenticAMD".into()); + let a = answer( + "[performance]\nkernel_parameters = [\"amd_pstate=passive\", \"transparent_hugepage=always\"]\n", + ); + let c = cmdline(&a, &f); + assert!( + c.contains("amd_pstate=passive") && !c.contains("amd_pstate=active"), + "{c}" + ); + assert!( + c.contains("transparent_hugepage=always") + && !c.contains("transparent_hugepage=madvise"), + "{c}" + ); + // Mitigations are never touched by Setup. + assert!(!c.contains("mitigations"), "{c}"); + } + + #[test] + fn the_other_kernel_is_chosen_to_fall_back_on() { + assert_eq!(fallback_kernel(Some("linux-zen")), Some("linux")); + assert_eq!(fallback_kernel(Some("linux-lts")), Some("linux")); + assert_eq!(fallback_kernel(Some("linux")), Some("linux-zen")); + assert_eq!(fallback_kernel(Some("linux-cachyos")), None); + assert_eq!(fallback_kernel(None), None); + } + + #[test] + fn a_fallback_kernel_is_installed_before_the_boot_image_is_built() { + let f = facts(); + let text = build_plan(&answer(""), &f).unwrap().to_string(); + assert!( + text.contains( + "Installing the linux kernel as a fallback boot entry (needs a network connection)" + ), + "{text}" + ); + let install = text + .find("pacman -Syu --noconfirm --needed linux # may fail") + .unwrap(); + assert!(install < text.find("mkinitcpio -P").unwrap()); + assert!(install < text.find("grub-mkconfig").unwrap()); + + // The stock kernel gets zen beside it. + let mut stock = facts(); + stock.kernel = Some("linux".into()); + assert!( + build_plan(&answer(""), &stock) + .unwrap() + .to_string() + .contains("--needed linux-zen # may fail") + ); + + // Not without a network, when switched off, or for a kernel we do not know. + let mut offline = facts(); + offline.network = false; + assert!( + !build_plan(&answer(""), &offline) + .unwrap() + .to_string() + .contains("fallback boot entry") + ); + let off = answer("[performance]\nfallback_kernel = false\n"); + assert!( + !build_plan(&off, &f) + .unwrap() + .to_string() + .contains("fallback boot entry") + ); + let mut unknown = facts(); + unknown.kernel = None; + assert!( + !build_plan(&answer(""), &unknown) + .unwrap() + .to_string() + .contains("fallback boot entry") + ); + } + + #[test] + fn nothing_removes_a_kernel() { + let text = build_plan(&answer(""), &facts()).unwrap().to_string(); + assert!(!text.contains("pacman -R"), "{text}"); + } + #[test] fn extra_kernel_parameters() { let a = answer( "[performance]\nparallel_boot = false\nkernel_parameters = [\"amd_pstate=active\", \"quiet\"]\n", ); let text = build_plan(&a, &facts()).unwrap().to_string(); - assert!(text.contains("nmi_watchdog=0 zswap.enabled=0 amd_pstate=active\"")); + // The answer file's own amd_pstate is the one that counts, once. + assert!(text.contains( + "nmi_watchdog=0 zswap.enabled=0 transparent_hugepage=madvise amd_pstate=active\"" + )); + assert_eq!(text.matches("amd_pstate").count(), 1, "{text}"); assert!(!text.contains("rc_parallel")); } diff --git a/crates/seglinux-setup/src/tui.rs b/crates/seglinux-setup/src/tui.rs index e14be2a..1d82eb4 100644 --- a/crates/seglinux-setup/src/tui.rs +++ b/crates/seglinux-setup/src/tui.rs @@ -819,6 +819,7 @@ mod tests { memory: 8 * GIB, gpus: vec![], kernel: Some("linux-zen".into()), + cpu_vendor: None, timezones: Some( ["UTC", "Europe/Paris", "Europe/Berlin"] .iter() diff --git a/data/system/limits-esync.conf b/data/system/limits-esync.conf index 6690f7b..6f5ab56 100644 --- a/data/system/limits-esync.conf +++ b/data/system/limits-esync.conf @@ -1,4 +1,10 @@ # esync (Wine-staging, Proton) keeps one file descriptor per Windows # synchronisation object. Raise the hard limit; Wine raises its own soft -# limit up to it. -* hard nofile 524288 +# limit up to it. 1048576 is the most the kernel allows (fs.nr_open) and what +# Steam Deck-class setups use, for games with very many objects. +* hard nofile 1048576 + +# seg game run raises the game's CPU priority above normal (nice -4). Normal +# users may not, unless a limit says so: members of the gamemode group, which +# SegLinux Setup puts every user in, may go down to -10. +@gamemode - nice -10 diff --git a/data/system/modprobe-nvidia.conf b/data/system/modprobe-nvidia.conf new file mode 100644 index 0000000..270cbef --- /dev/null +++ b/data/system/modprobe-nvidia.conf @@ -0,0 +1,4 @@ +# NVIDIA's DRM driver: kernel mode setting (needed by Wayland compositors and +# for a proper VT switch) and the framebuffer console on top of it. Without +# the nvidia driver installed this file does nothing. +options nvidia_drm modeset=1 fbdev=1 diff --git a/data/system/profile-gaming.sh b/data/system/profile-gaming.sh index 644c780..f50ffce 100644 --- a/data/system/profile-gaming.sh +++ b/data/system/profile-gaming.sh @@ -1,6 +1,14 @@ -# SegLinux: room for the shaders of many games in Mesa's disk cache -# (~/.cache/mesa_shader_cache). DXVK 2 and VKD3D-Proton keep their compiled -# shaders there; with Mesa's 1 GB default, installing several games evicts -# them and games stutter while recompiling. Set your own value to override. -: "${MESA_SHADER_CACHE_MAX_SIZE:=4G}" -export MESA_SHADER_CACHE_MAX_SIZE +# SegLinux: room for the shader caches of many games. +# +# Mesa's disk cache (~/.cache/mesa_shader_cache): DXVK 2 and VKD3D-Proton keep +# their compiled shaders there; with Mesa's 1 GB default, installing several +# games evicts them and games stutter while recompiling. +# NVIDIA's driver has its own cache (~/.nv/GLCache, the compute cache and the +# shader cache of Vulkan); its size is in bytes, and by default the driver +# deletes the cache once it is full, so the cleanup is switched off. +# Set your own values to override: they are only set here when not set yet. +# (/etc/environment.d is for systemd; this works on every init.) +: "${MESA_SHADER_CACHE_MAX_SIZE:=10G}" +: "${__GL_SHADER_DISK_CACHE_SIZE:=10737418240}" +: "${__GL_SHADER_DISK_CACHE_SKIP_CLEANUP:=1}" +export MESA_SHADER_CACHE_MAX_SIZE __GL_SHADER_DISK_CACHE_SIZE __GL_SHADER_DISK_CACHE_SKIP_CLEANUP diff --git a/data/system/sysctl-gaming.conf b/data/system/sysctl-gaming.conf index 11b48f0..4f51c5b 100644 --- a/data/system/sysctl-gaming.conf +++ b/data/system/sysctl-gaming.conf @@ -16,3 +16,14 @@ kernel.split_lock_mitigate = 0 # Game launchers and Steam watch many files. fs.inotify.max_user_watches = 524288 + +# The kernel defragments memory in the background to make huge pages +# available; while a game allocates and frees a lot, that work shows up as +# stutter. 0 turns the background work off (the default is 20); a program +# that needs a contiguous block still gets it by compacting on demand. This is +# what SteamOS and the gaming kernels set. +vm.compaction_proactiveness = 0 + +# vm.swappiness is not set here on purpose. With zram swap (the default) a +# high value is right and Setup writes it (90-seglinux-zram.conf); without +# it a low one is, which `seg tune gaming` sets (10). diff --git a/data/system/zram-setup b/data/system/zram-setup index 8d5396c..a0dc70d 100755 --- a/data/system/zram-setup +++ b/data/system/zram-setup @@ -6,12 +6,15 @@ # what zram cannot hold. # # seg (the segctl package) does the work when it is installed: `seg zram on` -# is the same thing, and the seg-zram service runs it too. The rest of this -# file is for systems that were updated to this script before segctl. +# is the same thing, and the seg-zram service runs it too. It leaves a device +# alone that has a size already, as this script does: on installed systems +# SegLinux Setup's own rule (/etc/udev/rules.d/99-seglinux-zram.rules) sizes +# and formats zram0, and fstab switches it on. The rest of this file is for +# systems that were updated to this script before segctl. set -eu PATH=/usr/bin:/usr/sbin:/bin:/sbin if command -v seg >/dev/null 2>&1; then - exec seg zram on + exec seg zram on --if-unconfigured fi dev=/dev/zram0 sys=/sys/block/zram0 diff --git a/docs/gaming.md b/docs/gaming.md index 6b16b0b..33ffaac 100644 --- a/docs/gaming.md +++ b/docs/gaming.md @@ -46,7 +46,7 @@ Steam and Lutris keep their own settings for the games they start. | Area | SegLinux default | Why | |------|------------------|-----| | Init system | **OpenRC** with parallel start (`rc_parallel="YES"`); runit and dinit builds are available | OpenRC is Artix's most used init. It applies `binfmt.d`, `sysctl.d` and `modules-load.d` natively, which the tuning below relies on, and starting services in parallel keeps boot fast. runit boots about as fast but leaves some of those files to its own scripts. | -| Kernel | **linux-zen** | Arch's kernel variant tuned for desktop responsiveness and low latency; `-k linux` builds with the stock kernel | +| Kernel | **linux-zen**, with the stock `linux` kernel beside it as a boot menu entry | linux-zen is Arch's kernel variant tuned for desktop responsiveness and low latency (and has NTSYNC); `-k linux` builds with the stock kernel. Setup installs the other kernel too when online, so a bad update never leaves the machine without a kernel to boot (GRUB lists it under *Advanced options*). Neither is ever removed. | | Desktop | SegLinux Rust shell on **labwc** | A light Wayland compositor: little RAM and CPU left for games. Plasma stays available as a fallback session. | | Swap | **zram** (compressed swap in RAM, up to 8 GiB) | Avoids stutter from swapping to disk when a game fills memory | @@ -56,9 +56,10 @@ Steam and Lutris keep their own settings for the games they start. |---------|-----| | Steam (with Proton) | Steam games; turn on Proton for all titles in Steam → Settings → Compatibility | | Lutris | GOG, Epic, Battle.net, emulators and other launchers | -| Wine, winetricks | Windows programs and games outside Steam (`segrun`) | +| Wine Staging, winetricks | Windows programs and games outside Steam (`segrun`); Staging adds the esync/fsync/NTSYNC patches and game fixes | | GameMode | Performance governor, I/O priority and GPU performance level while a game runs | -| MangoHud | FPS, frame time, CPU/GPU load overlay | +| `seg game` | The same idea without a daemon, plus a readiness check and the driver packages for the GPUs found ([below](#seg-game)) | +| MangoHud | FPS, frame time, CPU/GPU load overlay for 64-bit games (the Artix repositories have no 32-bit MangoHud; 32-bit games get no overlay unless you build `lib32-mangohud` from the AUR) | | gamescope | Valve's gaming compositor: frame limiting, FSR upscaling, Gaming Mode | | Mesa, Vulkan (AMD, Intel, NVK) + 32-bit | Graphics drivers, including for 32-bit games | | NVIDIA driver | Installed by Setup on GeForce GTX 16 / RTX and newer when online (see below) | @@ -94,6 +95,247 @@ gamescope = true gamescope_args = ["-W", "2560", "-H", "1440", "-r", "165", "--adaptive-sync"] ``` +## seg game + +`seg game` is part of [`seg`](seg.md), the small Rust tool that is already on +the system (`segctl` package). It works the same on OpenRC, runit, dinit and +s6, and it needs no daemon of its own. + +```sh +sudo seg game setup --yes # the graphics driver packages for the GPUs in this PC +seg game check # PASS / FAIL for everything games need, with the fix +seg game run -- ./game # run a game with the computer set up for it +``` + +### Steam launch options + +Right-click the game → Properties → General → Launch options: + +| Goal | Launch options | +|---|---| +| Boost while the game runs | `seg game run -- %command%` | +| Boost, and MangoHud (64-bit games) | `seg game run -- mangohud %command%` | +| Boost, inside gamescope (render 1440p, 165 Hz, adaptive sync) | `seg game run -- gamescope -W 2560 -H 1440 -r 165 --adaptive-sync -f -- %command%` | +| A Proton variable as well | `PROTON_LOG=1 seg game run -- %command%` | +| Only the cheap parts (no governor or GPU change) | `seg game run --no-boost -- %command%` | +| Show what would happen and exit | `seg game run --dry-run -- %command%` | + +For Lutris, put `seg game run --` in front of the runner's command (Configure → +System options → Command prefix: `seg game run --`). Games outside Steam: +`seg game run -- wine Game.exe` or `seg game run -- segrun Game.exe`. + +### What `seg game run` does + +While the game runs, and only then: + +| Change | How | Why | Undone | +|---|---|---|---| +| CPU governor `performance` and energy preference `performance` | through the one root helper (`sudo -n seg game boost`, the `gamemode` group; or directly as root) | A dynamic governor can be slow to raise clocks on short bursts, which shows as low "1 % lows" | when the game ends | +| AMD GPU: `power_dpm_force_performance_level = high` | same helper | The driver can sit at a low clock while the CPU is the bottleneck, then ramp late | when the game ends | +| Game gets `nice -4` and best-effort I/O priority 0 | the game's own process (`nice -10` is allowed to the `gamemode` group by `limits.d`) | A little more CPU than a browser or a download in the background | dies with the game | +| Screen saver and idle blanking held off | `org.freedesktop.ScreenSaver.Inhibit` on the session bus (a hand-written D-Bus call; no library) | The screen does not go dark in the middle of a cutscene or with a controller | when the game ends | +| X3D CPUs with two chiplets: the game only uses the cores of the V-Cache chiplet | `--pin vcache` or `pin = "vcache"` in a profile; the chiplet is the one whose L3 cache is bigger (read from `/sys/devices/system/cpu/cpu*/cache`) | Games that like cache run on the cache; the other chiplet clocks higher but has less of it | dies with the game | +| `MESA_SHADER_CACHE_MAX_SIZE=10G`, `__GL_SHADER_DISK_CACHE_SIZE` | environment of the game | See [system tuning](#system-tuning) | dies with the game | + +Several games can run at once: the helper keeps a count (`/run/seg/game-boost`, +mode 0600) of the game processes it was asked for, identified by process id +and start time, so a game that crashed does not keep the boost on and the +last one to end restores the settings that were found. The settings come +back when the game exits, on Ctrl+C, SIGTERM and SIGHUP, and when `seg` +itself panics. If the whole computer loses power the settings are runtime +state anyway: a reboot clears them. + +What it does **not** do: it never changes anything permanently, never turns +on `mitigations=off`, never touches overclocking or voltages, and does not +set the governor to `performance` for the whole system. + +### Per-game profiles + +`~/.config/seg/games.toml`. A profile is picked when its `match` (a glob such +as `eldenring.exe` or `*Launcher*`) fits the name of a file in the command, so +a Proton command line matches the `.exe` at its end. The first profile that +matches wins. A mistake in the file is reported with its line number and the +game starts anyway, without profiles. + +```toml +[[game]] +match = "eldenring.exe" +governor = "performance" # or "keep" (no boost for this game), or a governor name +pin = "vcache" # "vcache", "off" or a CPU list such as "0-7,16-23" +mangohud = true +mangohud_config = "preset" # SegLinux's preset (/usr/share/seg/mangohud-gaming.conf) +gamescope = ["-W", "2560", "-H", "1440", "-r", "165", "--adaptive-sync"] +nice = -5 + +[[game]] +match = "cs2" +pin = "0-7" +``` + +`seg game profiles` checks the file and lists what it found. + +### Reading `seg game check` + +Every line is `PASS` (fine), `WARN` (works, could be better), `FAIL` (games +may crash or run badly) or `SKIP` (cannot be checked here, for example no GPU +in a virtual machine). Each WARN and FAIL prints the exact command that fixes +it, and the exit status is 1 when anything fails. This is the real output of +a build container with no GPU and the stock kernel, to show the format: + +``` +[FAIL] vulkan vulkaninfo did not run (not installed, or no Vulkan driver) + fix: sudo pacman -S vulkan-tools vulkan-icd-loader (then: sudo seg game setup) +[SKIP] gpu driver no graphics card found in /sys/class/drm (virtual machine?) +[FAIL] ntsync /dev/ntsync does not exist: the module is not loaded + fix: sudo modprobe ntsync (always: echo ntsync | sudo tee /etc/modules-load.d/10-ntsync.conf) +[WARN] compaction vm.compaction_proactiveness = 20: the kernel defragments memory in the background, which can stall a game + fix: sudo sysctl -w vm.compaction_proactiveness=0 (always: /etc/sysctl.d/80-seglinux-gaming.conf) +[PASS] mitigations the kernel's default security mitigations are on + +3 passed, 4 to improve, 4 failed +``` + +| Check | Passes when | Why it matters | +|---|---|---| +| vulkan | `vulkaninfo` lists a real device | Nearly every current game (DXVK, VKD3D-Proton) needs Vulkan; only the software renderer (llvmpipe/lavapipe), or no `vulkaninfo`, is a FAIL | +| gpu driver | each GPU has its kernel driver: `amdgpu`, `i915`/`xe`, `nvidia` (or `nouveau` on a pre-Turing NVIDIA card, which has nothing better) | No driver bound, or `nouveau` on a card NVIDIA's driver supports, is a FAIL with `seg game setup --yes`; an unknown driver is a WARN | +| 32-bit drivers | the `lib32-` packages for the GPU are installed (and `[lib32]` is enabled) | Steam and many games are 32-bit | +| ntsync | the kernel is 6.14 or newer, `/dev/ntsync` exists and every user may open it | The kernel implementation of Windows synchronisation, used by Wine 11 and recent Proton | +| max_map_count | `vm.max_map_count` is at least 1048576 | Several games (and Proton) crash with "out of memory" below it | +| open files | the hard limit of open files is at least 524288 | esync keeps a file descriptor per Windows synchronisation object | +| kernel | `linux-zen`, or any other tuned kernel | A WARN, not a FAIL: the stock kernel works | +| fallback kernel | a second kernel is installed | If an update breaks one, the other boots | +| cpu governor | a dynamic governor at rest; SKIP without cpufreq (a VM) | `performance` all the time is a WARN: hot, loud and no better while idle. `seg game run` raises it only while a game runs | +| cpu driver (AMD CPUs only) | `amd-pstate` in active mode (`amd-pstate-epp`) | The CPU picks its own clocks, guided by the energy hint `seg game run` sets; otherwise a WARN with the kernel parameter | +| game boost | you are root, or `sudo -n -l` shows that the helper is allowed (member of `gamemode`) | Without it `seg game run` still starts the game but cannot change the governor or the GPU; the FAIL says whether `segctl` is missing, you are not in the group, or the sudoers file is broken | +| hugepages | transparent hugepages are `madvise` | `always` can stall on memory compaction | +| split lock | `kernel.split_lock_mitigate = 0` | Otherwise games that do split locks are slowed down on purpose | +| compaction | `vm.compaction_proactiveness = 0` | Background defragmentation can show as stutter | +| mitigations | `mitigations=off` is not on the kernel command line | A WARN if it is, with how to undo it | + +### Other `seg game` commands + +* `sudo seg game setup [--yes] [--games] [--lutris]` finds the graphics cards + (`/sys/class/drm`, names from `lspci`) and lists the packages **for those + cards only**: Mesa and the Vulkan driver plus their `lib32-` versions for + AMD and Intel; NVIDIA's open kernel driver (`nvidia-open-dkms` with the + headers of the running kernel, `nvidia-utils`, `lib32-nvidia-utils`, + `nvidia-settings`) for GeForce GTX 16 / RTX and newer; the open-source NVK + driver for older NVIDIA cards, because NVIDIA's current driver does not + support them. `--yes` runs `pacman -S --needed --noconfirm` with the list; + `--games` adds Steam, gamescope, MangoHud (64-bit only), Wine Staging and + winetricks; `--lutris` adds Lutris. It also tells you when `[lib32]` is not + enabled in `/etc/pacman.conf`. +* `sudo seg game apply` applies the gaming sysctls and makes `/dev/ntsync` + usable now; the `seg-game` service runs it at boot on every init. +* `seg game audio on|off|status` lowers PipeWire's buffer to 256 samples at + 48 kHz (about 5 ms), for you only (`~/.config/pipewire/...conf.d`). **Off by + default**: it costs CPU and can make sound crackle on a weak or busy + machine; `seg game audio off` puts the defaults back. +* `sudo seg game unsafe-boost on|off|status` is the only way to get + `mitigations=off`. See [safety](#safety-notes). +* `sudo seg tune gaming` (a saved profile, like `light`/`balanced`/`performance`): + `vm.swappiness` 10 without zram (180 with zram, the value Setup writes), + `vm.vfs_cache_pressure` 50, shorter write-back, and the governor left + dynamic, because `seg game run` raises it only while a game runs. + +### Safety notes + +* **No global unsafe tweaks.** Everything above is either a kernel setting + that Valve's SteamOS and the gaming kernels also use, or lives only as long + as a game runs. The governor is never `performance` all day on its own. +* **The root helper is narrow.** `/etc/sudoers.d/seg-game` lets the `gamemode` + group (Setup puts every user in it; the `gamemode` package uses the same + group) run exactly `/usr/bin/seg game boost on ` and `boost off `. + The program checks that the arguments are a process id and a governor that + exists, and writes only the cpufreq and `amdgpu` power files. A member of + the group can therefore change the CPU governor and the AMD GPU power level + and nothing else; that is the same trust `gamemoded` asks for. +* **`mitigations=off` is opt-in and loud.** `sudo seg game unsafe-boost on` + prints a warning and does nothing unless you add `--i-understand`; it + edits `GRUB_CMDLINE_LINUX_DEFAULT`, rebuilds the boot menu and takes + effect at the next boot. `off` undoes it, `status` says whether it is on, + and `seg game check` shows a WARN while it is. Measure first (see below): + on recent CPUs it is often within the run-to-run noise. Do not use it on + a computer you share or use for banking. +* **The fallback kernel is never removed**, by Setup, `seg clean` or + anything else here. +* **Drivers only for hardware that is there.** `seg game setup` and Setup read + the PCI devices; with no NVIDIA card no NVIDIA package is selected. + +## Measuring: stock versus `seg game run` + +There are no benchmark numbers in this repository, on purpose: they depend on +the CPU, GPU, game, scene and driver. This is how to produce your own and +tell a real difference from noise. Use MangoHud's log, which records frame +times, so you get the average frame rate **and** the "1 % lows" (the +stutter), which is where a governor or a pinning change shows up. + +1. **Fix everything else.** Same game, same settings, same resolution, same + scene (the game's built-in benchmark, or the same 60 seconds walked the + same way), plugged in, nothing else running, the display's refresh rate + unchanged. Turn the in-game frame limiter off for both runs, or set it + the same. +2. **Warm the shader cache** by playing the scene once and discarding that + run, otherwise the first run measures shader compilation. +3. **Log the frame times.** In Steam's launch options, run A (stock) and run B: + + ``` + A: MANGOHUD_CONFIG="output_folder=$HOME/bench/stock,log_interval=0,autostart_log=15,log_duration=60,no_display" mangohud %command% + B: MANGOHUD_CONFIG="output_folder=$HOME/bench/seg,log_interval=0,autostart_log=15,log_duration=60,no_display" seg game run -- mangohud %command% + ``` + + `autostart_log=15,log_duration=60` records 60 seconds starting 15 seconds + after launch, so load screens are left out; `log_interval=0` writes one + row per frame. (A MangoHud version that ignores it writes a row every + 100 ms: still fine for comparing, less exact for lows; use the same + version for both.) `mkdir -p ~/bench/stock ~/bench/seg` first. + `seg game run --no-boost -- ...` is a third variant that shows how much + of any difference is the governor and GPU level and how much the rest. +4. **Repeat at least five times each, alternating** (A B A B ...), so that + thermals and background tasks hit both alike. Reboot between the two + sessions only if you suspect a leak. +5. **Summarise each log.** MangoHud's CSV has a few lines of system + information, then a header line starting with `fps,` and one row per + frame; this reads the `frametime` column (milliseconds): + + ```sh + summarise() { # usage: summarise ~/bench/stock/*.csv + for f in "$@"; do + awk -F, '/^fps,/ { for (i = 1; i <= NF; i++) if ($i == "frametime") c = i; next } + c && $c + 0 > 0 { print $c }' "$f" | LC_ALL=C sort -n | + awk -v name="$f" ' + { ft[NR] = $1; sum += $1 } + END { + n = NR + if (!n) { print name ": no frames"; exit } + k1 = int(n * 0.99); if (k1 < 1) k1 = 1 + k01 = int(n * 0.999); if (k01 < 1) k01 = 1 + for (i = k1 + 1; i <= n; i++) { s1 += ft[i]; n1++ } # slowest 1 % + for (i = k01 + 1; i <= n; i++) { s01 += ft[i]; n01++ } # slowest 0.1 % + printf "%s frames=%d avg=%.1f fps 1%%low=%.1f fps 0.1%%low=%.1f fps p99=%.2f ms max=%.2f ms\n", + name, n, 1000 * n / sum, n1 ? 1000 * n1 / s1 : 0, n01 ? 1000 * n01 / s01 : 0, ft[k1], ft[n] + }' + done + } + summarise ~/bench/stock/*.csv; summarise ~/bench/seg/*.csv + ``` + + (Any awk will do.) "1 % low" here is the frame rate + of the slowest 1 % of frames taken together; other tools define it as + the 99th-percentile frame time, so only compare numbers made by the same + script. +6. **Read the result honestly.** Take the median of each side. A difference + smaller than the spread between runs of the *same* side is noise. The + average rarely moves; if `seg game run` helps, it is usually the 1 % and + 0.1 % lows and the p99 frame time. On a PC that already runs the + `performance` governor all the time, expect no difference at all; the + benefit is the same performance without paying for it when idle. + +For `mitigations=off`, repeat the A/B across a reboot with and without +`seg game unsafe-boost on`; if the lows do not move by more than the spread, +leave it off. + ## System tuning | Setting | File | Effect | @@ -101,10 +343,18 @@ gamescope_args = ["-W", "2560", "-H", "1440", "-r", "165", "--adaptive-sync"] | `vm.max_map_count = 2147483642` | `/etc/sysctl.d/80-seglinux-gaming.conf` | Games that map many memory areas no longer crash (SteamOS value) | | `kernel.split_lock_mitigate = 0` | same | No deliberate slowdown of games that perform split locks | | `nmi_watchdog=0`, `nowatchdog`, watchdog modules blacklisted | kernel command line (Setup), `/usr/lib/modprobe.d/seglinux-nowatchdog.conf` | Fewer periodic interrupts | +| `vm.compaction_proactiveness = 0` | same | No background memory defragmentation while a game allocates (the default is 20); a program that needs a contiguous block still gets it by compacting on demand. What SteamOS sets. | | `fs.inotify.max_user_watches` | sysctl | Launchers watching many files | +| `transparent_hugepage=madvise` | kernel command line (Setup) | Huge pages only for programs that ask (games, databases); `always` can stall on compaction | +| `amd_pstate=active` | kernel command line (Setup, AMD processors only) | The CPU picks its frequency itself, guided by the energy hint `seg game run` sets. Kernels or CPUs without it ignore it. | | I/O schedulers: NVMe none, SSD mq-deadline, HDD BFQ | `/usr/lib/udev/rules.d/60-seglinux-ioschedulers.rules` | Low latency while games stream assets | | NTSYNC | `/etc/modules-load.d/10-ntsync.conf`, udev rule | Windows synchronisation in the kernel, used by Wine 11 and Proton | -| esync file limit | `/etc/security/limits.d/90-seglinux-esync.conf` | For Proton/wine-staging's esync | +| Open files `nofile` hard limit 1048576 | `/etc/security/limits.d/90-seglinux-esync.conf` | esync/fsync keep a file descriptor per Windows synchronisation object (1048576 is the kernel's maximum); Wine raises its own soft limit up to it | +| `nice -10` for the `gamemode` group | same file | Lets `seg game run` raise a game's priority | +| Shader caches 10 GB: `MESA_SHADER_CACHE_MAX_SIZE`, `__GL_SHADER_DISK_CACHE_SIZE`, `__GL_SHADER_DISK_CACHE_SKIP_CLEANUP=1` | `/etc/profile.d/seglinux-gaming.sh` (every init; a value you set wins) | With several games installed, Mesa's 1 GB default evicts compiled shaders and games stutter while recompiling; NVIDIA's driver deletes its cache when full unless told not to. Disk space only. | +| `nvidia_drm modeset=1 fbdev=1` | `/usr/lib/modprobe.d/seglinux-nvidia.conf` | Needed by Wayland compositors and a proper VT switch; does nothing without NVIDIA's driver | +| THP stays `madvise`, mitigations stay on | kernel defaults | Deliberately not tuned further; see [safety](#safety-notes) | +| Applied at boot by the `seg-game` service | `seg game apply` | The sysctls and `/dev/ntsync` permissions on OpenRC, runit, dinit and s6 alike | | zram + `vm.swappiness = 180` | written by Setup | Compressed swap in RAM; `zswap.enabled=0` avoids double compression | | Quiet boot, `loglevel=3` | kernel command line (Setup) | Faster, quieter boot | | Variable refresh rate and tearing | `/usr/share/seglinux/labwc/rc.xml` (`adaptiveSync`, `allowTearing`) | FreeSync/G-Sync for fullscreen games, lowest input latency | @@ -122,8 +372,12 @@ Turing generation or newer (GeForce GTX 16, RTX 20 and later), it installs `nvidia-settings` when the network is reachable (this also updates the system). Without a network connection, or with `[drivers] nvidia = "never"`, the open-source nouveau/NVK driver is used; install the packages later with -`sudo pacman -Syu nvidia-open-dkms nvidia-utils lib32-nvidia-utils`. Older -NVIDIA cards are not supported by NVIDIA's current driver and keep nouveau. +`sudo pacman -Syu nvidia-open-dkms nvidia-utils lib32-nvidia-utils`, or +`sudo seg game setup --yes`, which also adds the headers of the running kernel +that DKMS needs. Older NVIDIA cards (before Turing) are not supported by +NVIDIA's current driver and keep the open-source driver (NVK). The driver is +loaded with `nvidia_drm modeset=1 fbdev=1` +(`/usr/lib/modprobe.d/seglinux-nvidia.conf`). ## Intel graphics and low-end PCs @@ -155,10 +409,10 @@ What SegLinux does for low-end machines: * GameMode switches the CPU to its performance governor while a game runs, and on integrated GPUs, when the GPU is the busy part, lowers the CPU's share of the power budget so the GPU can clock higher (`/etc/gamemode.ini`). -* Mesa's shader cache may grow to 4 GB (`MESA_SHADER_CACHE_MAX_SIZE`, set - in `/etc/profile.d/seglinux-gaming.sh`): DXVK 2 and VKD3D-Proton keep - compiled shaders there, and the 1 GB default makes games recompile them - (stutter) once several are installed. +* Mesa's shader cache may grow to 10 GB (`MESA_SHADER_CACHE_MAX_SIZE`, set + in `/etc/profile.d/seglinux-gaming.sh`, which also sizes NVIDIA's cache): + DXVK 2 and VKD3D-Proton keep compiled shaders there, and the 1 GB default + makes games recompile them (stutter) once several are installed. * Hardware video decoding (`intel-media-driver`, `libva-intel-driver`) takes video playback off the CPU. * The **Lite** ISO (well under 1 GB, see [building.md](building.md#the-lite-edition)) @@ -201,6 +455,10 @@ More on Intel GPUs: ## Checking the setup +`seg game check` is the quick one: Vulkan, the GPU driver and its 32-bit +version, NTSYNC, `vm.max_map_count`, the open files limit, the kernels, the CPU +governor, whether the boost helper may run, hugepages, split lock, compaction +and mitigations, each with its fix (see [above](#reading-seg-game-check)). `segrun doctor` reports Wine, winetricks, Vulkan, NTSYNC, GameMode, MangoHud, gamescope, split lock mitigation, zram, the open file limit, `vm.max_map_count` and binfmt support. diff --git a/docs/lean-audit.md b/docs/lean-audit.md index 2d95e83..9e0c70e 100644 --- a/docs/lean-audit.md +++ b/docs/lean-audit.md @@ -32,16 +32,65 @@ linux-zen), the last ISO build before this work. | Boot time in QEMU | not measured | not measured | | Rust code | 24,849 lines, 7 crates | | -Boot time and idle RAM could not be measured for this work: the sandbox it -ran in has no route to the Artix mirrors, no QEMU and no loop devices, so an -ISO cannot be built or booted there (see "Verification limits" in the final -section). +Boot time and idle RAM could not be measured when the audit was written: the +sandbox it ran in has no route to the Artix mirrors, no QEMU and no loop +devices. The "After" numbers come from a GitHub Actions build instead. + +## After + +Measured by the temporary "ISO check" workflow (since removed) on 20 October +2026, commit `57a1b1f` of this branch: both editions built with the real +`build-in-container.sh` (OpenRC, linux-zen) and the images opened. The +repositories are rolling, so every package is a day newer than in the +"Before" build; that is part of the differences, mostly the small ones. + +| Metric | Lite before | Lite after | Full before | Full after | +|---|---|---|---|---| +| ISO | 774,014,976 B | 774,531,072 B (+0.07 %) | 3,030,378,496 B | **2,887,837,696 B (-4.7 %, -136 MiB)** | +| `LiveOS/rootfs.img` | 637.2 MiB | 637.6 MiB | 2,681.8 MiB | **2,545.8 MiB (-5.1 %)** | +| initramfs | 49.7 MiB | 49.7 MiB | 156.7 MiB | 156.7 MiB | +| Installed system, uncompressed | 1,790.1 MiB | 1,792.0 MiB | 7,139.8 MiB | **about 6,665 MiB (-6.6 %, -475 MiB)** | +| Package list entries (OpenRC) | 48 | 49 (+`segctl`) | 164 (159 unique) | **152 (152 unique)** | +| Packages in the built image | not recorded | 351 | not recorded | 1,083 | +| Services started in the live session | 1 | 1 | 8 | 7 (no `cupsd`) | +| Services Setup enables on an installed system | 14 | 15 (-`cupsd`, +`seg-zram`, +`seg-tune`) | | | +| Kernel header folders on the image | 0 | 0 | 1 (325 MiB) | 0 | +| Rust code | 24,849 lines | 29,610 (24,907 in `crates/`, 4,703 new in `tools/seg/`) | | | +| `seg` binary | | 567,536 B (stripped, `opt-level = "z"`, LTO, `panic = "abort"`) | | | +| Idle RAM | not measured | 430.7 MiB of 2.9 GiB in use, 2 min after boot, QEMU/KVM, logged in on a text console | 792 MB (15 Oct, other setup) | not measured | +| Boot time | not measured | not measured (see below) | not measured | not measured | + +The Lite edition never had kernel headers (it has its own `common.yaml`), so +removing them only shrinks the full edition; Lite grew by the `segctl` +package (a 0.6 MB binary and a few service files) and the day's package +updates. The full edition's saving is the headers (325 MiB), `spectacle` with +`opencv` (113 MiB), the X server stack and `inxi`. + +What the image check confirmed, per edition: `seg`, the zram and tune +services (OpenRC links, runit and dinit definitions), the weekly `fstrim` +job, the makepkg drop-in, the I/O scheduler rule and, in the full edition, +`rg`, `fd`, `eza`, `zoxide`; `Xorg`, `spectacle` and `inxi` are gone. + +What booting Lite in QEMU (KVM, text console, 150 s of waiting) showed: +`linux-zen` 7.2.11 comes up on OpenRC, the `modules` service loads `ntsync`, +`tcp_bbr`, `sch_fq` and `zram`, `seg-tune` and `seg-zram` start, `seg zram +status` reports an active 1.4 GiB zstd device, and `seg info`, `seg tune`, +`seg svc list --enabled` and `seg doctor` work on the real system. It also +found a bug of the new code: `seg doctor` called `killprocs`, `mount-ro` and +`savecache` (OpenRC `shutdown` runlevel) "enabled but stopped". Fixed in +`6e323a4`; the fix is covered by a unit test and was **not** booted again. +The same boot showed `vm.swappiness` 100 under zram; Setup's 180 is kept +since `cfd6ba8`, also not booted again. + +Boot time is not reported: the harness looks at the screen every 31 s, and +QEMU on a shared CI runner is not a stable clock. The full edition was built +and opened, not booted. ## Bloat table | Item | Size / cost | Why it is bloat | Action | |---|---|---|---| -| `linux-zen-headers` (`linux-headers` in the list) | 325.1 MiB, on both editions (Lite's Setup downloads it too) | Only needed to build `nvidia-open-dkms` on GTX 16 / RTX GPUs. Setup already downloads that driver in a step that needs a network. | **remove** from the image; Setup adds the running kernel's headers to the NVIDIA step | +| `linux-zen-headers` (`linux-headers` in the list) | 325.1 MiB, on the full edition (`common.yaml`; Lite has its own list and never had it) | Only needed to build `nvidia-open-dkms` on GTX 16 / RTX GPUs. Setup already downloads that driver in a step that needs a network. | **remove** from the image; Setup adds the running kernel's headers to the NVIDIA step | | `spectacle` (and `opencv`, which only it needs) | `opencv` 112.8 MiB, plus the libraries only opencv uses | Second screenshot tool: the desktop has its own (Print, Super+Shift+S: grim + slurp). Spectacle would only serve the Plasma fallback session. | **remove** | | `xorg-server`, `xf86-input-*`, `xf86-video-*` | tens of MiB | Legacy X server and its drivers. The sessions are Wayland (labwc, Plasma Wayland); applications use XWayland. The Lite image already ships only `xorg-xwayland`. | **replace** with `xorg-xwayland` only, as Lite does | | `inxi` | ~1 MiB (Perl stays: OpenRC needs it) | Third system-information tool beside fastfetch and KInfoCenter | **remove** | @@ -108,7 +157,6 @@ File and line are those of the commit before this work (`4ca2f45`). | `iso/iso-profiles/seglinux/profile.yaml:79-82,123` | `xorg-server`, `xorg-xwayland`, `xdg-user-dirs`, `xdg-utils` and `ttf-liberation` also in `common.yaml:55,77-79,82` | listed once; CI now fails on a repeat | | `crates/seglinux-setup/src/plan.rs:824` | the NVIDIA DKMS driver could only build because 325 MiB of kernel headers were on every image | Setup installs the running kernel's headers in that step | | `iso/scripts/build-in-container.sh:157-160` | the artools patch that reads the kernel version without headers applied to non-stock kernels only; the stock kernel relied on `linux-headers` | applies to every kernel | -| `crates/seglinux-setup/src/disk.rs:273` | comment names `/etc/udev/rules.d/99-seglinux-zram.rules`, which does not exist (it is `/usr/lib/udev/rules.d/60-seglinux-zram.rules`) | corrected | | `.github/workflows/ci.yml:72-76` | `data/system/zram-setup` was not shellchecked; the udev rules were not checked at all | both are now; new job for `seg` | | `crates/seglinux-setup/src/plan.rs:1273` | the swap partition test asserted that the plan never contains the word `zram` | asserts there is no `/dev/zram0` fstab line, which is what it means | @@ -144,3 +192,28 @@ These are product choices, so they were measured and left alone: `qt6-webengine`. 4. **A Qt 6 media player instead of VLC**: removes Qt 5, about 100 MiB. 5. **Brave from Chaotic-AUR**: the only AUR-origin package of the image. + +## Verification limits + +Run for this work: `cargo fmt --check`, `cargo clippy -- -D warnings` and +`cargo test` for the workspace and for `tools/seg`; `shellcheck` on every +shell script of the repository; the CI jobs of `.github/workflows/ci.yml` +(package lists resolve with `pacman -Sp` in an `artixlinux` container, the +udev rules pass `udevadm verify`, no package is listed twice); `seg` itself +inside an Artix container (`info`, `doctor`, `clean`, `tune`, `zram status`, +`svc` against the real OpenRC and dinit layouts); the fstrim script's stamp +logic; both ISOs built in CI and the Lite one booted in QEMU, as above. + +Not done, and not claimed: + +* No ISO was built or booted in the sandbox the work was done in (no + mirrors, no QEMU, no loop devices): every ISO fact above comes from CI. +* Only the OpenRC images were built. The runit, dinit and s6 definitions + were checked against the files the Artix packages install and by their + unit tests, not by booting those inits. +* The full edition was not booted, so its idle RAM and boot time are not + known, and `cfd6ba8` and `6e323a4` (after the image check) were not + booted. +* The installed-system path (Setup writing the files, `grub-install`) was not + run; its plan is covered by the unit tests. +* No real hardware: nothing about GPUs, cpufreq or NVMe was exercised. diff --git a/docs/seg.md b/docs/seg.md index 7ad690e..4bb4aaa 100644 --- a/docs/seg.md +++ b/docs/seg.md @@ -1,8 +1,9 @@ # The seg tool `seg` looks after a SegLinux system: `seg info`, `seg svc` (services on -OpenRC, runit, dinit or s6), `seg clean`, `seg tune`, `seg zram` and +OpenRC, runit, dinit or s6), `seg clean`, `seg tune`, `seg zram`, `seg game` +(boost while a game runs, a readiness check, the right driver packages) and `seg doctor`. It is a small Rust program in `tools/seg` and is installed from -the `segctl` package. +the `segctl` package. For games, see [gaming.md](gaming.md#seg-game). Everything about it is in [tools/seg/README.md](../tools/seg/README.md). diff --git a/docs/unattended-install.md b/docs/unattended-install.md index cea4e0d..eb837bf 100644 --- a/docs/unattended-install.md +++ b/docs/unattended-install.md @@ -82,7 +82,8 @@ start it by hand: `sudo seglinux-setup install --unattend`. | `[bootloader] removable` | `true` | | Also install `\EFI\BOOT\BOOTX64.EFI` | | `[bootloader] os_prober` | `true` | | Add Windows and other systems to the boot menu | | `[performance] parallel_boot` | `true` | | OpenRC: start services in parallel (`rc_parallel`) | -| `[performance] kernel_parameters` | `[]` | | Added to `loglevel=3 quiet nowatchdog nmi_watchdog=0` (and `zswap.enabled=0` with zram) | +| `[performance] kernel_parameters` | `[]` | | Added to `loglevel=3 quiet nowatchdog nmi_watchdog=0 transparent_hugepage=madvise` (and `zswap.enabled=0` with zram, `amd_pstate=active` on AMD processors); a parameter of the same name replaces the default | +| `[performance] fallback_kernel` | `true` | | With a network connection, also install the other kernel (`linux` beside `linux-zen`, `linux-zen` beside the stock one) as a boot menu entry to fall back on; the installed kernel is never removed | | `[drivers] nvidia` | `"auto"` | Driver installation | `"auto"`: install NVIDIA's driver for GeForce GTX 16/RTX and newer when online; `"never"` | | `[services] enable` / `disable` | `[]` | | On top of SegLinux's defaults | | `[[run]] pass`, `command`, `description` | | `RunSynchronous`, `SetupComplete.cmd` | Shell command run in the new system during `specialize` or `setup-complete` | diff --git a/iso/iso-profiles/seglinux/profile.yaml b/iso/iso-profiles/seglinux/profile.yaml index 4405251..1dc673e 100644 --- a/iso/iso-profiles/seglinux/profile.yaml +++ b/iso/iso-profiles/seglinux/profile.yaml @@ -127,13 +127,13 @@ rootfs: - ttf-carlito # Running Windows programs and games - - wine + - wine-staging # Wine with the Staging patches (esync/fsync/NTSYNC, game fixes); provides wine - winetricks - steam # Steam and Proton - lutris # GOG, Epic, Battle.net and other launchers - gamemode # CPU/GPU tuning while a game runs - lib32-gamemode - - mangohud # FPS and frame-time overlay (64-bit games) + - mangohud # FPS and frame-time overlay (64-bit games; Artix has no lib32-mangohud) - gamescope # gaming compositor, Gaming Mode session - dosbox - mesa diff --git a/iso/scripts/check-packages.sh b/iso/scripts/check-packages.sh index 55a9c57..1e76abe 100755 --- a/iso/scripts/check-packages.sh +++ b/iso/scripts/check-packages.sh @@ -4,7 +4,10 @@ # Run on Artix (or in the build container); needs pacman and go-yq. # # iso/scripts/check-packages.sh [-i openrc|runit|dinit|s6] [-k kernel] -# [-p profile] [-c pacman.conf] +# [-p profile] [-c pacman.conf] [-x file] +# +# -x names a file of packages that tools install later (one per line, `#` +# comments): they must exist and resolve together with the root file system. set -euo pipefail iso_dir=$(cd "$(dirname "$0")/.." && pwd) @@ -13,14 +16,16 @@ kernel=linux profile=seglinux conf=$iso_dir/pacman.conf.d/iso-x86_64.conf local_repo=/var/lib/seglinux-repo +extra_file= -while getopts "i:k:p:c:h" opt; do +while getopts "i:k:p:c:x:h" opt; do case $opt in i) init=$OPTARG ;; k) kernel=$OPTARG ;; p) profile=$OPTARG ;; c) conf=$OPTARG ;; - *) sed -n '2,9s/^# \{0,1\}//p' "$0"; exit 2 ;; + x) extra_file=$OPTARG ;; + *) sed -n '2,12s/^# \{0,1\}//p' "$0"; exit 2 ;; esac done @@ -65,6 +70,12 @@ trap 'rm -rf "$db" "$work_conf"' EXIT chmod 755 "$db" cp "$conf" "$work_conf" +extra= +if [[ -n $extra_file ]]; then + [[ -f $extra_file ]] || { echo "no package list at $extra_file" >&2; exit 1; } + extra=$(grep -v '^[[:space:]]*#' "$extra_file" | sed -e "s/^linux-headers$/$kernel-headers/" | sort -u) +fi + skip=() if [[ ! -f $local_repo/seglinux.db ]]; then # The seglinux and segctl packages are built during the ISO build; when @@ -82,7 +93,7 @@ declare -A have=() for name in "${available[@]}"; do have[$name]=1; done missing=() -for name in $rootfs $livefs; do +for name in $rootfs $livefs $extra; do [[ " ${skip[*]} " == *" $name "* ]] && continue [[ -n ${have[$name]:-} ]] && continue # Not a package name: maybe a group or a virtual package someone provides. @@ -111,4 +122,8 @@ resolve() { resolve rootfs $rootfs # shellcheck disable=SC2086 resolve "rootfs+livefs" $rootfs $livefs -echo "All $(echo "$rootfs $livefs" | wc -w) package names of profile '$profile' ($init) are available." +if [[ -n $extra ]]; then + # shellcheck disable=SC2086 + resolve "rootfs+tools ($extra_file)" $rootfs $extra +fi +echo "All $(echo "$rootfs $livefs $extra" | wc -w) package names of profile '$profile' ($init) are available." diff --git a/packaging/seglinux/PKGBUILD b/packaging/seglinux/PKGBUILD index 772cb95..7b07001 100644 --- a/packaging/seglinux/PKGBUILD +++ b/packaging/seglinux/PKGBUILD @@ -6,7 +6,7 @@ pkgname=seglinux pkgver=0.1.0 -pkgrel=9 +pkgrel=10 pkgdesc="SegLinux: lightweight Rust desktop, Windows program and game launcher, graphical installer and gaming tuning" arch=('x86_64') url="https://github.com/SegMind25/SegLinux" @@ -175,6 +175,7 @@ package() { install -Dm644 "$data/system/udev-ioschedulers.rules" "$pkgdir/usr/lib/udev/rules.d/60-seglinux-ioschedulers.rules" install -Dm644 "$data/system/modules-ddcutil.conf" "$pkgdir/etc/modules-load.d/ddcutil.conf" install -Dm644 "$data/system/modprobe-nowatchdog.conf" "$pkgdir/usr/lib/modprobe.d/seglinux-nowatchdog.conf" + install -Dm644 "$data/system/modprobe-nvidia.conf" "$pkgdir/usr/lib/modprobe.d/seglinux-nvidia.conf" install -Dm644 "$data/system/gamemode.ini" "$pkgdir/etc/gamemode.ini" install -Dm644 "$data/system/profile-gaming.sh" "$pkgdir/etc/profile.d/seglinux-gaming.sh" diff --git a/pkg/segctl/PKGBUILD b/pkg/segctl/PKGBUILD index 63dd517..d974cd8 100644 --- a/pkg/segctl/PKGBUILD +++ b/pkg/segctl/PKGBUILD @@ -7,8 +7,8 @@ pkgname=segctl pkgver=0.1.0 -pkgrel=1 -pkgdesc="seg: services, cleaning, tuning, zram swap and health checks for SegLinux, on OpenRC, runit, dinit and s6" +pkgrel=2 +pkgdesc="seg: services, cleaning, tuning, zram swap, game mode and health checks for SegLinux, on OpenRC, runit, dinit and s6" arch=('x86_64') url="https://github.com/SegMind25/SegLinux" # No license has been chosen for the SegLinux repository yet. @@ -19,7 +19,12 @@ depends=('gcc-libs' 'glibc' 'util-linux' 'kmod' 'coreutils') makedepends=('cargo') optdepends=( 'cronie: the weekly fstrim job (/etc/cron.d/seg-fstrim)' - 'pacman: seg clean and seg doctor' + 'pacman: seg clean, seg doctor and seg game setup' + 'sudo: seg game run raises the CPU governor through it (the gamemode group)' + 'vulkan-tools: seg game check asks vulkaninfo' + 'mangohud: per-game overlay (mangohud = true in games.toml)' + 'gamescope: per-game gamescope arguments' + 'pciutils: names of the graphics cards in seg game check and setup' ) # The profile the user chose, and the sysctl values it wrote. backup=( @@ -56,15 +61,27 @@ package() { install -Dm755 target/release/seg "$pkgdir/usr/bin/seg" - # The seg-tune and seg-zram services for each init the project builds - # (they do nothing on one that is not installed). - install -Dm755 -t "$pkgdir/etc/init.d" dist/openrc/seg-tune dist/openrc/seg-zram + # The seg-tune, seg-zram and seg-game services for each init (they do + # nothing on one that is not installed). + install -Dm755 -t "$pkgdir/etc/init.d" dist/openrc/seg-tune dist/openrc/seg-zram dist/openrc/seg-game local svc - for svc in seg-tune seg-zram; do + for svc in seg-tune seg-zram seg-game; do install -Dm755 "dist/runit/$svc/run" "$pkgdir/etc/runit/sv/$svc/run" done install -Dm755 dist/runit/seg-zram/finish "$pkgdir/etc/runit/sv/seg-zram/finish" - install -Dm644 -t "$pkgdir/etc/dinit.d" dist/dinit/seg-tune dist/dinit/seg-zram + install -Dm644 -t "$pkgdir/etc/dinit.d" dist/dinit/seg-tune dist/dinit/seg-zram dist/dinit/seg-game + install -Dm644 -t "$pkgdir/etc/s6/sv/seg-game" dist/s6/seg-game/type dist/s6/seg-game/up + + # seg game run: the root helper it calls through sudo, for the gamemode + # group only (the folder keeps the mode the sudo package gives it). + install -dm750 "$pkgdir/etc/sudoers.d" + install -m440 dist/sudoers-seg-game "$pkgdir/etc/sudoers.d/seg-game" + + # Presets, off until asked for: low-latency PipeWire (seg game audio on) + # and a MangoHud overlay for the game profiles. + install -Dm644 -t "$pkgdir/usr/share/seg/pipewire/pipewire.conf.d" dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf + install -Dm644 -t "$pkgdir/usr/share/seg/pipewire/pipewire-pulse.conf.d" dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf + install -Dm644 dist/mangohud-gaming.conf "$pkgdir/usr/share/seg/mangohud-gaming.conf" # Defaults: the balanced profile. install -Dm644 dist/tune.conf "$pkgdir/etc/seg/tune.conf" diff --git a/pkg/segctl/segctl.install b/pkg/segctl/segctl.install index 55c5e66..0783b82 100644 --- a/pkg/segctl/segctl.install +++ b/pkg/segctl/segctl.install @@ -1,25 +1,40 @@ -# Start the zram swap and the tuning profile at every boot. +# The gamemode group (SegLinux Setup puts every user in it, and the package +# gamemode uses the same name): seg game run may raise the CPU governor for +# its members, through /etc/sudoers.d/seg-game. +make_group() { + getent group gamemode >/dev/null 2>&1 || groupadd -r gamemode >/dev/null 2>&1 || true +} + +# Start the zram swap, the gaming settings and the tuning profile at every boot. # # This may run in a chroot (the image build) whose init is not the host's, so # the init is chosen by what is installed here (--offline), not by PID 1. enable_services() { command -v seg >/dev/null 2>&1 || return 0 local svc - for svc in seg-zram seg-tune; do + for svc in "$@"; do seg svc --offline enable "$svc" >/dev/null 2>&1 || true done } post_install() { - enable_services + make_group + enable_services seg-zram seg-game seg-tune } -# Not on upgrade: someone who disabled a service keeps it disabled. +# An upgrade from before seg game gets that one service and the group; the +# others are left as the user has them (someone who disabled one keeps it so). +post_upgrade() { + make_group + if [ "$(vercmp "$2" 0.1.0-2)" -lt 0 ]; then + enable_services seg-game + fi +} pre_remove() { command -v seg >/dev/null 2>&1 || return 0 local svc - for svc in seg-tune seg-zram; do + for svc in seg-tune seg-game seg-zram; do seg svc --offline disable "$svc" >/dev/null 2>&1 || true done } diff --git a/tools/seg/Cargo.lock b/tools/seg/Cargo.lock index 2bb4d03..5a4b2a8 100644 --- a/tools/seg/Cargo.lock +++ b/tools/seg/Cargo.lock @@ -8,9 +8,16 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "803ec87c9cfb29b9d2633f20cba1f488db3fd53f2158b1024cbefb47ba05d413" +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + [[package]] name = "segctl" version = "0.1.0" dependencies = [ "lexopt", + "libc", ] diff --git a/tools/seg/README.md b/tools/seg/README.md index 9e37d76..3000af6 100644 --- a/tools/seg/README.md +++ b/tools/seg/README.md @@ -1,16 +1,18 @@ # seg One small Rust program to look after a SegLinux system, the same on OpenRC, -runit, dinit and s6. No async runtime and one dependency -([lexopt](https://crates.io/crates/lexopt), a minimal argument parser); the -release binary is about 550 KiB. +runit, dinit and s6. No async runtime and two dependencies +([lexopt](https://crates.io/crates/lexopt), a minimal argument parser, and +[libc](https://crates.io/crates/libc), for the few system calls a game +launcher needs); the release binary is about 700 KiB. ``` seg info distro, kernel, init, uptime, memory, packages, disk seg svc list|enable|disable|start|stop [name] seg clean [--apply] orphans, old cached packages, old logs -seg tune light|balanced|performance|apply|status +seg tune light|balanced|gaming|performance|apply|status seg zram on|off|status +seg game run|check|setup|profiles|apply|audio|unsafe-boost seg doctor look for problems and print their fixes ``` @@ -68,16 +70,22 @@ Dry run unless `--apply` is given. ## seg tune -| | light | balanced | performance | -|---|---|---|---| -| `vm.swappiness` | 100 | 60 | 10 | -| `vm.vfs_cache_pressure` | 150 | 100 | 50 | -| `vm.dirty_ratio` | 10 | 20 | 15 | -| `vm.dirty_background_ratio` | 5 | 10 | 5 | -| CPU governor (first available) | powersave, conservative, schedutil, ondemand | schedutil, ondemand, powersave | performance | -| energy/performance preference | power | balance_performance | performance | - -* With zram swap on, swappiness is at least 100: swapping to compressed memory +| | light | balanced | gaming | performance | +|---|---|---|---|---| +| `vm.swappiness` | 100 | 60 | 10 | 10 | +| `vm.vfs_cache_pressure` | 150 | 100 | 50 | 50 | +| `vm.dirty_ratio` | 10 | 20 | 15 | 15 | +| `vm.dirty_background_ratio` | 5 | 10 | 5 | 5 | +| CPU governor (first available) | powersave, conservative, schedutil, ondemand | schedutil, ondemand, powersave | schedutil, ondemand, powersave (dynamic) | performance | +| energy/performance preference | power | balance_performance | balance_performance | performance | + +`gaming` is for a computer that is mostly used for games: it keeps caches and +writes back in short bursts like `performance`, but leaves the CPU dynamic, +because `seg game run` raises it only while a game runs (and an idle desktop +does not need a hot CPU). `performance` keeps the governor on `performance` +all the time. +* With zram swap on, swappiness is at least 180, the value Setup writes for + zram (/etc/sysctl.d/90-seglinux-zram.conf): swapping to compressed memory is cheaper than re-reading files from disk. * I/O scheduler of every disk: `none` for NVMe and virtual disks (their controller or host schedules), `mq-deadline` for SSDs, `bfq` for spinning @@ -95,16 +103,49 @@ Dry run unless `--apply` is given. `seg zram on` loads the module if needed and makes /dev/zram0 swap: zstd, half the RAM (at most 8 GiB, because the device's table uses memory in -proportion to its size; `--size 2G` overrides), priority 100. It also turns -zswap off (it would compress the same pages twice) and sets -`vm.page-cluster=0`. `seg zram off` swaps off and frees the memory; `status` +proportion to its size; `--size 2G` overrides), priority 100. A device that +already has a size (SegLinux Setup's udev rule gives zram0 one on installed +systems) keeps it, and `--if-unconfigured`, which the package's udev rule +uses, leaves such a device entirely alone. It also turns zswap off (it would +compress the same pages twice) and sets `vm.page-cluster=0`. `seg zram off` swaps off and frees the memory; `status` shows size, use and compression ratio. The `seg-zram` service runs it at boot. +## seg game + +For games; the walkthrough, Steam launch options, the benchmark method and the +safety notes are in [docs/gaming.md](../../docs/gaming.md#seg-game). + +| command | what it does | +|---|---| +| `seg game run [--no-boost] [--pin vcache\|off\|LIST] [--nice N] [--profiles FILE] [--dry-run] -- CMD` | run `CMD` with the governor, GPU level, priorities, screen saver and CPU pinning set for a game, and put it all back when it ends (Ctrl+C, SIGTERM, SIGHUP, a panic included) | +| `seg game check` | PASS/WARN/FAIL/SKIP for Vulkan, GPU driver, lib32 drivers, ntsync, `vm.max_map_count`, open files, kernel, fallback kernel, governor, boost permission, hugepages, split lock, compaction, mitigations; the fix under each; exit 1 if any FAIL | +| `seg game setup [--yes] [--games] [--lutris]` | the driver packages for the GPUs in this computer (from `/sys/class/drm` and `lspci`) and the command that installs them; `--yes` runs it | +| `seg game profiles [FILE]` | check `~/.config/seg/games.toml` and list what it holds | +| `seg game apply` | the gaming sysctls and `/dev/ntsync` permissions now (the `seg-game` service) | +| `seg game audio on\|off\|status` | low-latency PipeWire for the current user | +| `seg game unsafe-boost on\|off\|status` | `mitigations=off`; needs `--i-understand` after a warning | + +How the code is arranged (`segctl/src/game/`): `hw.rs` reads the CPU (vendor, +cache sizes for the V-Cache chiplet, cpufreq) and the GPUs (`/sys/class/drm`, +PCI ids) from a root directory, so the tests use the fake trees in +`segctl/tests/fixtures`; `toml.rs` and `profile.rs` are the profile parser +(a small TOML subset that names the line of a mistake); `boost.rs` is the +root-side state machine (`/run/seg/game-boost`, a lock, owners identified by +pid and start time); `proc.rs` holds **all** the `unsafe` code of the crate +(a handful of libc calls, each with a `SAFETY` comment; CI checks that no +other file has any); `dbus.rs` is a minimal D-Bus client for the screen-saver +inhibit; `run.rs` is `seg game run`; `check.rs`, `setup.rs`, `apply.rs`, +`audio.rs` and `unsafe_boost.rs` are the rest. The privilege model: users +never write to `/sys` themselves; `seg game run` calls +`sudo -n /usr/bin/seg game boost on|off PID [GOVERNOR]`, which the sudoers +file allows for the `gamemode` group, and the helper validates every +argument. + ## seg doctor | check | looks at | fix it prints | |---|---|---| -| services | enabled services that are stopped or crashed | the init's start command, or `seg svc disable` | +| services | services enabled for boot that are stopped or crashed (OpenRC's `shutdown` and `nonetwork` runlevels are not started at boot and are not counted) | the init's start command, or `seg svc disable` | | firmware | `dmesg`: "Direct firmware load for ... failed with error -2" | `pacman -F` to find the package, then install and reboot | | disk | /, /home, /var, /usr under 1 GiB free or 90 % full; /boot under 20 MiB | `seg clean --apply` | | pacman | /var/lib/pacman/db.lck with no pacman running | `sudo rm /var/lib/pacman/db.lck` | @@ -118,9 +159,16 @@ back, which also appears in the log; the report says so. ## Shipped in the package (`pkg/segctl`) * `/usr/bin/seg` -* `seg-tune` and `seg-zram` for OpenRC (`/etc/init.d`), runit - (`/etc/runit/sv`) and dinit (`/etc/dinit.d`); the package enables both - (`seg svc --offline enable`) when it is installed, also in the ISO build's chroot +* `seg-tune`, `seg-zram` and `seg-game` for OpenRC (`/etc/init.d`), runit + (`/etc/runit/sv`), dinit (`/etc/dinit.d`) and, for `seg-game`, s6 + (`/etc/s6/sv`); the package enables them (`seg svc --offline enable`) when + it is installed, also in the ISO build's chroot +* `/etc/sudoers.d/seg-game`: lets the `gamemode` group run + `seg game boost on|off ` as root, nothing else (the package creates + the group) +* `/usr/share/seg/`: the MangoHud preset and the low-latency PipeWire + drop-ins, which do nothing until `mangohud_config = "preset"` or + `seg game audio on` asks for them * `/etc/seg/tune.conf` and `/etc/sysctl.d/99-seg.conf`: the balanced profile * `/usr/lib/seg/fstrim-weekly` and `/etc/cron.d/seg-fstrim`: cron checks every hour and trims once a week (a stamp in /var/lib/seg), so a computer that diff --git a/tools/seg/dist/99-seg.conf b/tools/seg/dist/99-seg.conf index 93d20fc..a26d03d 100644 --- a/tools/seg/dist/99-seg.conf +++ b/tools/seg/dist/99-seg.conf @@ -1,4 +1,4 @@ -# Written by `seg tune balanced`; change it with `sudo seg tune `. +# Written by `seg tune balanced`; change it with `sudo seg tune `. vm.swappiness = 60 vm.vfs_cache_pressure = 100 vm.dirty_ratio = 20 diff --git a/tools/seg/dist/dinit/seg-game b/tools/seg/dist/dinit/seg-game new file mode 100644 index 0000000..4b2b8c7 --- /dev/null +++ b/tools/seg/dist/dinit/seg-game @@ -0,0 +1,4 @@ +# The gaming sysctls and device permissions (/dev/ntsync) at boot. +type = scripted +command = /usr/bin/seg game apply +restart = false diff --git a/tools/seg/dist/mangohud-gaming.conf b/tools/seg/dist/mangohud-gaming.conf new file mode 100644 index 0000000..bcd2d11 --- /dev/null +++ b/tools/seg/dist/mangohud-gaming.conf @@ -0,0 +1,33 @@ +# MangoHud preset for games, shipped by SegLinux. It is OFF by default: a game +# gets it only with `mangohud = true` and `mangohud_config = "preset"` in its +# ~/.config/seg/games.toml profile, or with +# MANGOHUD_CONFIGFILE=/usr/share/seg/mangohud-gaming.conf mangohud %command% +# Your own ~/.config/MangoHud/MangoHud.conf is never touched. + +legacy_layout=false +position=top-left +font_size=20 +background_alpha=0.35 +round_corners=6 + +# What to show: frame rate and frame time (with its graph), CPU and GPU load +# and temperature, memory. +fps +frametime +frame_timing +cpu_stats +cpu_temp +gpu_stats +gpu_temp +vram +ram + +# Logging for benchmarks (docs/gaming.md): Shift+F2 starts and stops a log. +output_folder=/tmp +log_duration=60 +autostart_log=0 +toggle_logging=Shift_L+F2 +log_interval=100 + +# Hide or show the overlay while playing. +toggle_hud=Shift_R+F12 diff --git a/tools/seg/dist/openrc/seg-game b/tools/seg/dist/openrc/seg-game new file mode 100755 index 0000000..6a51b8e --- /dev/null +++ b/tools/seg/dist/openrc/seg-game @@ -0,0 +1,17 @@ +#!/usr/bin/openrc-run +# shellcheck shell=sh disable=SC2034 +# The gaming sysctls and device permissions (/dev/ntsync) at boot, on any +# init: OpenRC's sysctl service does the sysctls too, this makes sure. + +description="Apply SegLinux's gaming sysctls and device permissions" + +depend() { + after sysctl udev + before seg-tune +} + +start() { + ebegin "Applying the gaming settings" + /usr/bin/seg game apply >/dev/null + eend $? +} diff --git a/tools/seg/dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf b/tools/seg/dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf new file mode 100644 index 0000000..13fca96 --- /dev/null +++ b/tools/seg/dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf @@ -0,0 +1,11 @@ +# The same for programs that talk to PipeWire as PulseAudio (Wine, Proton, +# most games): see pipewire.conf.d/99-seg-lowlatency.conf. +pulse.properties = { + pulse.min.req = 256/48000 + pulse.default.req = 256/48000 + pulse.min.quantum = 256/48000 +} + +stream.properties = { + node.latency = 256/48000 +} diff --git a/tools/seg/dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf b/tools/seg/dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf new file mode 100644 index 0000000..1f6f5cd --- /dev/null +++ b/tools/seg/dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf @@ -0,0 +1,9 @@ +# Lower audio latency for games (seg game audio on): a 256-sample buffer at +# 48 kHz is about 5 ms. If sound crackles, `seg game audio off`. +context.properties = { + default.clock.rate = 48000 + default.clock.allowed-rates = [ 48000 ] + default.clock.quantum = 256 + default.clock.min-quantum = 64 + default.clock.max-quantum = 512 +} diff --git a/tools/seg/dist/runit/seg-game/run b/tools/seg/dist/runit/seg-game/run new file mode 100755 index 0000000..4671aaa --- /dev/null +++ b/tools/seg/dist/runit/seg-game/run @@ -0,0 +1,6 @@ +#!/bin/sh +# Applies the gaming sysctls and device permissions once, then stays "up" so +# runit does not start it again. +exec 2>&1 +/usr/bin/seg game apply +exec sleep infinity diff --git a/tools/seg/dist/s6/seg-game/type b/tools/seg/dist/s6/seg-game/type new file mode 100644 index 0000000..bdd22a1 --- /dev/null +++ b/tools/seg/dist/s6/seg-game/type @@ -0,0 +1 @@ +oneshot diff --git a/tools/seg/dist/s6/seg-game/up b/tools/seg/dist/s6/seg-game/up new file mode 100644 index 0000000..24aaef3 --- /dev/null +++ b/tools/seg/dist/s6/seg-game/up @@ -0,0 +1 @@ +/usr/bin/seg game apply diff --git a/tools/seg/dist/sudoers-seg-game b/tools/seg/dist/sudoers-seg-game new file mode 100644 index 0000000..d66b093 --- /dev/null +++ b/tools/seg/dist/sudoers-seg-game @@ -0,0 +1,6 @@ +# seg game run raises the CPU governor and an AMD GPU's power level while a +# game runs, and puts them back. These two commands, with these arguments and +# nothing else, are all the gamemode group may run as root; the program checks +# the process id and the governor, and touches only the cpufreq and amdgpu +# power files. +%gamemode ALL=(root) NOPASSWD: /usr/bin/seg game boost on [0-9]*, /usr/bin/seg game boost off [0-9]* diff --git a/tools/seg/segctl/Cargo.toml b/tools/seg/segctl/Cargo.toml index 00b608c..8c833b3 100644 --- a/tools/seg/segctl/Cargo.toml +++ b/tools/seg/segctl/Cargo.toml @@ -15,3 +15,4 @@ path = "src/main.rs" [dependencies] lexopt = "0.3" +libc = "0.2" diff --git a/tools/seg/segctl/src/doctor.rs b/tools/seg/segctl/src/doctor.rs index 6ac97c9..6889b03 100644 --- a/tools/seg/segctl/src/doctor.rs +++ b/tools/seg/segctl/src/doctor.rs @@ -40,7 +40,7 @@ pub enum Outcome { /// Services that start at boot but are not running. pub fn failed_services(list: &[Service]) -> Vec<&Service> { list.iter() - .filter(|s| s.enabled && matches!(s.state, State::Crashed | State::Stopped)) + .filter(|s| s.runs_at_boot && matches!(s.state, State::Crashed | State::Stopped)) .collect() } @@ -395,6 +395,7 @@ mod tests { Service { name: name.into(), enabled, + runs_at_boot: enabled, state, } } @@ -426,6 +427,14 @@ mod tests { assert_eq!(failed, ["b", "c"]); } + #[test] + fn services_of_runlevels_that_boot_does_not_enter_are_not_failures() { + // OpenRC's killprocs, mount-ro and savecache sit in `shutdown`. + let mut late = svc("killprocs", true, State::Stopped); + late.runs_at_boot = false; + assert!(failed_services(&[late]).is_empty()); + } + #[test] fn the_service_fix_names_the_inits_own_command() { let list = [svc("cupsd", true, State::Crashed)]; diff --git a/tools/seg/segctl/src/game/apply.rs b/tools/seg/segctl/src/game/apply.rs new file mode 100644 index 0000000..37f2327 --- /dev/null +++ b/tools/seg/segctl/src/game/apply.rs @@ -0,0 +1,127 @@ +//! `seg game apply`, run at boot by the `seg-game` service of every init: +//! the gaming sysctls and the permissions of /dev/ntsync. OpenRC and udev do +//! both too; runit, dinit and s6 images may not, and applying twice is +//! harmless. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; + +use crate::error::{Context, Result}; +use crate::sys; +use crate::tune; + +pub const HELP: &str = "\ +Usage: seg game apply + +Applies /etc/sysctl.d/80-seglinux-gaming.conf now (vm.max_map_count, +kernel.split_lock_mitigate, vm.compaction_proactiveness, ...) and makes +/dev/ntsync usable by every user, so Wine and Proton can use NTSYNC. Needs +root. The seg-game service runs it at boot. +"; + +pub const SYSCTL_FILE: &str = "etc/sysctl.d/80-seglinux-gaming.conf"; + +/// Apply the sysctls of the gaming file below `root`; then fix /dev/ntsync. +pub fn apply(root: &Path) -> Result> { + let mut lines = Vec::new(); + let path = root.join(SYSCTL_FILE); + match fs::read_to_string(&path) { + Ok(text) => { + let report = tune::Fs::new(root).apply_sysctl_text(&text)?; + lines.extend(report.lines); + lines.extend(report.warnings.into_iter().map(|w| format!("note: {w}"))); + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + lines.push(format!( + "note: {} does not exist; no sysctls to apply", + path.display() + )); + } + Err(e) => { + return Err(crate::error::Error::io( + format!("reading {}", path.display()), + e, + )); + } + } + let ntsync = root.join("dev/ntsync"); + if let Ok(meta) = fs::metadata(&ntsync) { + let mode = meta.permissions().mode() & 0o777; + if mode & 0o006 != 0o006 { + fs::set_permissions(&ntsync, fs::Permissions::from_mode(0o666)) + .context(format!("opening up {}", ntsync.display()))?; + lines.push(format!("/dev/ntsync {mode:o} -> 666")); + } + } + Ok(lines) +} + +pub fn run() -> Result { + sys::require_root("`seg game apply`")?; + let lines = apply(Path::new("/"))?; + if lines.is_empty() { + outln!("seg game apply: already applied"); + } + for l in lines { + outln!(" {l}"); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + #[test] + fn the_gaming_file_is_applied_and_ntsync_opened() { + let t = TempDir::new(); + t.put(SYSCTL_FILE, "# gaming\nvm.max_map_count = 2147483642\nkernel.split_lock_mitigate = 0\nvm.compaction_proactiveness = 0\n\nvm.unknown_key = 1\n"); + t.put("proc/sys/vm/max_map_count", "65530\n"); + t.put("proc/sys/kernel/split_lock_mitigate", "1\n"); + t.put("proc/sys/vm/compaction_proactiveness", "0\n"); + let ntsync = t.put("dev/ntsync", ""); + fs::set_permissions(&ntsync, fs::Permissions::from_mode(0o600)).unwrap(); + let lines = apply(t.path()).unwrap(); + let read = |rel: &str| { + fs::read_to_string(t.path().join(rel)) + .unwrap() + .trim() + .to_string() + }; + assert_eq!(read("proc/sys/vm/max_map_count"), "2147483642"); + assert_eq!(read("proc/sys/kernel/split_lock_mitigate"), "0"); + assert!( + lines + .iter() + .any(|l| l.contains("vm.max_map_count") && l.contains("65530 -> 2147483642")), + "{lines:?}" + ); + assert!( + lines + .iter() + .any(|l| l.contains("vm.unknown_key") && l.starts_with("note:")), + "{lines:?}" + ); + assert!( + lines.iter().any(|l| l == "/dev/ntsync 600 -> 666"), + "{lines:?}" + ); + assert_eq!( + fs::metadata(&ntsync).unwrap().permissions().mode() & 0o777, + 0o666 + ); + // Again: nothing to do (the unknown key is still a note). + let again = apply(t.path()).unwrap(); + assert!(again.iter().all(|l| l.starts_with("note:")), "{again:?}"); + } + + #[test] + fn no_file_and_no_device_is_not_an_error() { + let t = TempDir::new(); + let lines = apply(t.path()).unwrap(); + assert_eq!(lines.len(), 1); + assert!(lines[0].contains("no sysctls to apply"), "{lines:?}"); + } +} diff --git a/tools/seg/segctl/src/game/audio.rs b/tools/seg/segctl/src/game/audio.rs new file mode 100644 index 0000000..237e59f --- /dev/null +++ b/tools/seg/segctl/src/game/audio.rs @@ -0,0 +1,207 @@ +//! `seg game audio on|off|status`: PipeWire's low-latency settings for +//! games. The two drop-in files the segctl package ships in +//! /usr/share/seg/pipewire are copied into the user's own PipeWire +//! configuration (no root), and removed again by `off`. + +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::error::{Context, Error, Result}; + +pub const HELP: &str = "\ +Usage: seg game audio on|off|status + +on lower PipeWire's buffer (256 samples at 48 kHz, about 5 ms) for you +off back to PipeWire's defaults +status say which it is + +Lower latency means less delay between what happens in a game and what you +hear, at the price of more CPU work for the sound server and, on a weak or busy +computer, crackling. If sound crackles, run `seg game audio off`. It applies +the next time PipeWire starts: log out and in, or restart pipewire, +wireplumber and pipewire-pulse. +"; + +/// (file in the package, folder below ~/.config/pipewire, file name there) +const FILES: [(&str, &str); 2] = [ + ("pipewire.conf.d/99-seg-lowlatency.conf", "pipewire.conf.d"), + ( + "pipewire-pulse.conf.d/99-seg-lowlatency.conf", + "pipewire-pulse.conf.d", + ), +]; + +pub const SHARE: &str = "/usr/share/seg/pipewire"; + +pub enum Action { + On, + Off, + Status, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut word: Option = None; + while let Some(arg) = parser.next()? { + match arg { + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + Value(v) if word.is_none() => word = Some(v.string()?), + other => return Err(other.unexpected().into()), + } + } + match word.as_deref() { + Some("on") => Ok(Action::On), + Some("off") => Ok(Action::Off), + None | Some("status") => Ok(Action::Status), + Some(other) => Err(Error::Usage(format!( + "unknown audio command `{other}` (on, off or status)" + ))), + } +} + +fn targets(config: &Path) -> Vec<(PathBuf, PathBuf)> { + FILES + .iter() + .map(|(src, dir)| { + ( + PathBuf::from(src), + config + .join("pipewire") + .join(dir) + .join("99-seg-lowlatency.conf"), + ) + }) + .collect() +} + +pub fn is_on(config: &Path) -> bool { + targets(config).iter().all(|(_, t)| t.exists()) +} + +pub fn enable(share: &Path, config: &Path) -> Result<()> { + for (src, target) in targets(config) { + let from = share.join(src); + let text = fs::read_to_string(&from).context(format!("reading {}", from.display()))?; + if let Some(dir) = target.parent() { + fs::create_dir_all(dir).context(format!("creating {}", dir.display()))?; + } + fs::write(&target, text).context(format!("writing {}", target.display()))?; + } + Ok(()) +} + +pub fn disable(config: &Path) -> Result { + let mut removed = false; + for (_, target) in targets(config) { + match fs::remove_file(&target) { + Ok(()) => removed = true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(Error::io(format!("removing {}", target.display()), e)), + } + } + Ok(removed) +} + +pub fn run(action: Action) -> Result { + let config = match std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) { + Some(dir) => PathBuf::from(dir), + None => crate::sys::invoking_home() + .ok_or_else(|| Error::other("cannot tell where your configuration is (no HOME)"))? + .join(".config"), + }; + match action { + Action::Status => outln!( + "low-latency audio: {}", + if is_on(&config) { "on" } else { "off" } + ), + Action::On => { + enable(Path::new(SHARE), &config)?; + outln!( + "low-latency audio is on. Log out and in (or restart pipewire, wireplumber and pipewire-pulse). If sound crackles: seg game audio off" + ); + } + Action::Off => { + outln!( + "{}", + if disable(&config)? { + "low-latency audio is off. Log out and in (or restart pipewire) to go back to the defaults." + } else { + "low-latency audio was not on." + } + ); + } + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + fn share() -> TempDir { + let t = TempDir::new(); + t.put( + "pipewire.conf.d/99-seg-lowlatency.conf", + "context.properties = {}\n", + ); + t.put( + "pipewire-pulse.conf.d/99-seg-lowlatency.conf", + "pulse.properties = {}\n", + ); + t + } + + #[test] + fn on_copies_both_files_and_off_removes_them() { + let (s, c) = (share(), TempDir::new()); + assert!(!is_on(c.path())); + enable(s.path(), c.path()).unwrap(); + assert!(is_on(c.path())); + assert_eq!( + fs::read_to_string( + c.path() + .join("pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf") + ) + .unwrap(), + "pulse.properties = {}\n" + ); + // Twice is fine. + enable(s.path(), c.path()).unwrap(); + assert!(disable(c.path()).unwrap()); + assert!(!is_on(c.path())); + assert!(!disable(c.path()).unwrap(), "nothing left to remove"); + } + + #[test] + fn a_missing_package_file_is_an_error() { + let c = TempDir::new(); + let e = enable(Path::new("/nowhere"), c.path()) + .unwrap_err() + .to_string(); + assert!(e.contains("reading /nowhere/pipewire.conf.d"), "{e}"); + } + + #[test] + fn other_files_in_the_config_folders_are_left_alone() { + let (s, c) = (share(), TempDir::new()); + let mine = c.put("pipewire/pipewire.conf.d/50-mine.conf", "x\n"); + enable(s.path(), c.path()).unwrap(); + disable(c.path()).unwrap(); + assert!(mine.exists()); + } + + #[test] + fn the_shipped_files_say_what_the_help_says() { + let pw = include_str!("../../../dist/pipewire/pipewire.conf.d/99-seg-lowlatency.conf"); + assert!(pw.contains("default.clock.quantum")); + assert!(pw.contains("256")); + let pulse = + include_str!("../../../dist/pipewire/pipewire-pulse.conf.d/99-seg-lowlatency.conf"); + assert!(pulse.contains("pulse.min.req")); + assert!(pulse.contains("256/48000")); + } +} diff --git a/tools/seg/segctl/src/game/boost.rs b/tools/seg/segctl/src/game/boost.rs new file mode 100644 index 0000000..3ad81f4 --- /dev/null +++ b/tools/seg/segctl/src/game/boost.rs @@ -0,0 +1,463 @@ +//! The part of `seg game run` that needs root: while at least one game runs, +//! the CPU governor and energy preference are `performance` and an AMD GPU is +//! held at its high power level; when the last game ends, every value goes +//! back to what it was. +//! +//! `seg game run` reaches this through `sudo -n seg game boost on|off PID` +//! (the sudoers file of the segctl package allows exactly that to the +//! `gamemode` group) or calls it directly when it runs as root. Several games +//! share one state file, /run/seg/game-boost, under a lock; a game that died +//! without saying so (SIGKILL, power loss of the session) is noticed by its +//! process id and start time and does not keep the boost alive. + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; + +use super::hw::{self, GpuVendor}; +use super::proc; +use crate::error::{Context, Error, Result}; +use crate::sys; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Owner { + pub pid: u32, + /// Field 22 of /proc/PID/stat: tells a reused pid from the original. + pub start: u64, +} + +#[derive(Debug, Default, PartialEq, Eq)] +pub struct State { + pub owners: Vec, + /// (file, value it had before the boost) + pub saved: Vec<(String, String)>, +} + +const HEADER: &str = "seg-game-boost 1"; + +pub fn serialize(state: &State) -> String { + let mut text = format!("{HEADER}\n"); + for o in &state.owners { + text.push_str(&format!("owner {} {}\n", o.pid, o.start)); + } + for (path, value) in &state.saved { + text.push_str(&format!("saved\t{path}\t{value}\n")); + } + text +} + +/// Parse a state file; a file that is not ours gives an empty state. +pub fn parse_state(text: &str) -> State { + let mut lines = text.lines(); + if lines.next() != Some(HEADER) { + return State::default(); + } + let mut state = State::default(); + for line in lines { + if let Some(rest) = line.strip_prefix("owner ") { + let mut f = rest.split_whitespace(); + if let (Some(pid), Some(start)) = (f.next(), f.next()) + && let (Ok(pid), Ok(start)) = (pid.parse(), start.parse()) + { + state.owners.push(Owner { pid, start }); + } + } else if let Some(rest) = line.strip_prefix("saved\t") + && let Some((path, value)) = rest.split_once('\t') + { + state.saved.push((path.to_string(), value.to_string())); + } + } + state +} + +/// The start time (field 22) of `/proc/PID/stat`. The command name is in +/// parentheses and may contain spaces and parentheses, so count from the +/// last `)`. +pub fn parse_start_time(stat: &str) -> Option { + let after = stat.get(stat.rfind(')')? + 1..)?; + // Fields after the name start at field 3 (state); starttime is 22. + after.split_whitespace().nth(22 - 3)?.parse().ok() +} + +#[derive(Debug, Default)] +pub struct Report { + pub lines: Vec, + pub warnings: Vec, +} + +pub struct Boost { + root: PathBuf, +} + +impl Boost { + pub fn new(root: impl Into) -> Self { + Boost { root: root.into() } + } + + fn state_dir(&self) -> PathBuf { + self.root.join("run/seg") + } + + fn start_time(&self, pid: u32) -> Option { + parse_start_time(&fs::read_to_string(self.root.join(format!("proc/{pid}/stat"))).ok()?) + } + + fn alive(&self, o: &Owner) -> bool { + self.start_time(o.pid) == Some(o.start) + } + + fn load(&self) -> State { + fs::read_to_string(self.state_dir().join("game-boost")) + .map(|t| parse_state(&t)) + .unwrap_or_default() + } + + fn store(&self, state: &State) -> Result<()> { + let path = self.state_dir().join("game-boost"); + if state.owners.is_empty() && state.saved.is_empty() { + let _ = fs::remove_file(&path); + return Ok(()); + } + sys::write_atomic(&path, &serialize(state))?; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) + .context(format!("securing {}", path.display())) + } + + /// Run `f` holding the lock of the state directory. + fn locked(&self, f: impl FnOnce() -> Result) -> Result { + let dir = self.state_dir(); + fs::create_dir_all(&dir).context(format!("creating {}", dir.display()))?; + let _ = fs::set_permissions(&dir, fs::Permissions::from_mode(0o700)); + let lock = dir.join("game.lock"); + let file = fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(&lock) + .context(format!("opening {}", lock.display()))?; + proc::lock_exclusive(&file).context(format!("locking {}", lock.display()))?; + f() + } + + /// The files to change, with the value that makes the computer fast. + pub fn knobs(&self, governor: &str) -> Vec<(PathBuf, String)> { + let mut knobs = Vec::new(); + for dir in hw::cpufreq_dirs(&self.root) { + let available = + sys::read_trimmed(dir.join("scaling_available_governors")).unwrap_or_default(); + if available.split_whitespace().any(|g| g == governor) { + knobs.push((dir.join("scaling_governor"), governor.to_string())); + } + if governor == "performance" { + let epp = dir.join("energy_performance_preference"); + let preferences = + sys::read_trimmed(dir.join("energy_performance_available_preferences")); + let allowed = preferences + .as_deref() + .is_none_or(|p| p.split_whitespace().any(|x| x == "performance")); + if epp.exists() && allowed { + knobs.push((epp, "performance".to_string())); + } + } + } + if governor == "performance" { + for gpu in hw::gpus(&self.root) { + if gpu.vendor == GpuVendor::Amd && gpu.driver.as_deref() == Some("amdgpu") { + let level = self.root.join(format!( + "sys/class/drm/{}/device/power_dpm_force_performance_level", + gpu.card + )); + if level.exists() { + knobs.push((level, "high".to_string())); + } + } + } + } + knobs + } + + /// Start the boost for the game process `pid`, or join the one that is + /// on. The first game decides the governor. + pub fn on(&self, pid: u32, governor: &str) -> Result { + let start = self + .start_time(pid) + .ok_or_else(|| Error::other(format!("no process {pid}")))?; + self.locked(|| { + let mut report = Report::default(); + let mut state = self.load(); + state.owners.retain(|o| self.alive(o)); + state.owners.retain(|o| o.pid != pid); + if state.owners.is_empty() { + // Nobody boosted: remember what is there now, then change it. + // (A leftover `saved` of games that all died is the truth, so + // it is put back first.) + self.restore(&mut state, &mut report); + for (path, value) in self.knobs(governor) { + let Some(before) = sys::read_trimmed(&path) else { + continue; + }; + if before == value { + continue; + } + match fs::write(&path, &value) { + Ok(()) => { + state.saved.push((path.display().to_string(), before)); + } + Err(e) => report.warnings.push(format!("{}: {e}", path.display())), + } + } + report.lines.push(format!( + "boost on: {} settings changed ({governor})", + state.saved.len() + )); + } else { + report + .lines + .push("boost already on for another game".into()); + } + state.owners.push(Owner { pid, start }); + self.store(&state)?; + Ok(report) + }) + } + + fn restore(&self, state: &mut State, report: &mut Report) { + for (path, value) in state.saved.drain(..) { + if let Err(e) = fs::write(&path, &value) { + report.warnings.push(format!("{path}: {e}")); + } + } + } + + /// The game `pid` has ended: when it was the last, put everything back. + pub fn off(&self, pid: u32) -> Result { + self.locked(|| { + let mut report = Report::default(); + let mut state = self.load(); + state.owners.retain(|o| o.pid != pid && self.alive(o)); + if state.owners.is_empty() { + let count = state.saved.len(); + self.restore(&mut state, &mut report); + report + .lines + .push(format!("boost off: {count} settings restored")); + } else { + report.lines.push("boost stays on for another game".into()); + } + self.store(&state)?; + Ok(report) + }) + } + + pub fn status(&self) -> String { + let state = self.load(); + let live = state.owners.iter().filter(|o| self.alive(o)).count(); + format!( + "game boost: {} ({live} game{} running, {} settings saved)\n", + if live > 0 { "on" } else { "off" }, + if live == 1 { "" } else { "s" }, + state.saved.len() + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::sys::testutil::TempDir; + + fn proc_entry(t: &TempDir, pid: u32, start: u64) { + t.put( + &format!("proc/{pid}/stat"), + &format!("{pid} (my game (x)) S 1 {pid} {pid} 0 -1 4194560 100 0 0 0 1 1 0 0 20 0 1 0 {start} 1000 100 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0\n"), + ); + } + + /// Two CPUs with cpufreq, an AMD GPU, and two games' worth of processes. + fn machine() -> TempDir { + let t = TempDir::new(); + for n in [0, 1] { + let d = format!("sys/devices/system/cpu/cpu{n}/cpufreq"); + t.put( + &format!("{d}/scaling_available_governors"), + "performance schedutil powersave\n", + ); + t.put(&format!("{d}/scaling_governor"), "schedutil\n"); + t.put( + &format!("{d}/energy_performance_preference"), + "balance_performance\n", + ); + t.put( + &format!("{d}/energy_performance_available_preferences"), + "default performance balance_performance balance_power power\n", + ); + } + let g = "sys/class/drm/card0/device"; + t.put(&format!("{g}/vendor"), "0x1002\n"); + t.put(&format!("{g}/device"), "0x744c\n"); + t.put( + &format!("{g}/uevent"), + "DRIVER=amdgpu\nPCI_SLOT_NAME=0000:03:00.0\n", + ); + t.put(&format!("{g}/power_dpm_force_performance_level"), "auto\n"); + proc_entry(&t, 100, 5000); + proc_entry(&t, 200, 6000); + t + } + + fn read(t: &TempDir, rel: &str) -> String { + fs::read_to_string(t.path().join(rel)) + .unwrap() + .trim() + .to_string() + } + + const GOV: &str = "sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"; + const EPP: &str = "sys/devices/system/cpu/cpu1/cpufreq/energy_performance_preference"; + const GPU: &str = "sys/class/drm/card0/device/power_dpm_force_performance_level"; + + #[test] + fn state_files_round_trip() { + let s = State { + owners: vec![ + Owner { pid: 1, start: 10 }, + Owner { + pid: 22, + start: 333, + }, + ], + saved: vec![ + ("/sys/a b/gov".into(), "schedutil".into()), + ("/sys/x".into(), "auto".into()), + ], + }; + assert_eq!(parse_state(&serialize(&s)), s); + assert_eq!(parse_state("something else\n"), State::default()); + assert_eq!(parse_state(""), State::default()); + // Damaged lines are skipped, the rest is kept. + let s = parse_state("seg-game-boost 1\nowner x y\nowner 5 6\nsaved\tnotab\n"); + assert_eq!(s.owners, vec![Owner { pid: 5, start: 6 }]); + assert!(s.saved.is_empty()); + } + + #[test] + fn the_start_time_survives_awkward_names() { + let stat = "4242 (Web Content (1)) S 1 4242 4242 0 -1 4194560 1 0 0 0 1 1 0 0 20 0 1 0 987654 1 1 18446744073709551615 1 1 0 0 0 0 0 0 0 0 0 0 17 3 0 0 0 0 0"; + assert_eq!(parse_start_time(stat), Some(987654)); + assert_eq!(parse_start_time("garbage"), None); + assert_eq!(parse_start_time("1 (x) S 1"), None); + } + + #[test] + fn knobs_follow_what_the_machine_offers() { + let t = machine(); + let b = Boost::new(t.path()); + let perf = b.knobs("performance"); + // Two governors, two EPPs, the GPU. + assert_eq!(perf.len(), 5); + assert!(perf.iter().any(|(p, v)| { + p.ends_with("card0/device/power_dpm_force_performance_level") && v == "high" + })); + // Another governor changes the governor only. + assert_eq!(b.knobs("powersave").len(), 2); + // A governor the CPU lacks changes nothing. + assert!(b.knobs("ondemand").is_empty()); + } + + #[test] + fn a_game_boosts_and_the_end_of_the_game_restores() { + let t = machine(); + let b = Boost::new(t.path()); + let r = b.on(100, "performance").unwrap(); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + assert_eq!(read(&t, GOV), "performance"); + assert_eq!(read(&t, EPP), "performance"); + assert_eq!(read(&t, GPU), "high"); + assert!( + b.status().starts_with("game boost: on (1 game running"), + "{}", + b.status() + ); + + let r = b.off(100).unwrap(); + assert!(r.lines[0].contains("5 settings restored"), "{:?}", r.lines); + assert_eq!(read(&t, GOV), "schedutil"); + assert_eq!(read(&t, EPP), "balance_performance"); + assert_eq!(read(&t, GPU), "auto"); + assert!(!t.path().join("run/seg/game-boost").exists()); + assert!(b.status().starts_with("game boost: off")); + } + + #[test] + fn two_games_share_one_boost() { + let t = machine(); + let b = Boost::new(t.path()); + b.on(100, "performance").unwrap(); + // The second game must not save "performance" as the original. + let r = b.on(200, "performance").unwrap(); + assert!(r.lines[0].contains("already on"), "{:?}", r.lines); + b.off(100).unwrap(); + assert_eq!(read(&t, GOV), "performance", "the other game still runs"); + b.off(200).unwrap(); + assert_eq!(read(&t, GOV), "schedutil"); + } + + #[test] + fn a_game_that_died_does_not_keep_the_boost_on() { + let t = machine(); + let b = Boost::new(t.path()); + b.on(100, "performance").unwrap(); + // Game 100 was killed (its process is gone); game 200 starts and ends. + fs::remove_dir_all(t.path().join("proc/100")).unwrap(); + let r = b.on(200, "performance").unwrap(); + assert!(!r.lines[0].contains("already on"), "{:?}", r.lines); + b.off(200).unwrap(); + // Back to the original values, not to "performance". + assert_eq!(read(&t, GOV), "schedutil"); + assert_eq!(read(&t, GPU), "auto"); + } + + #[test] + fn a_reused_process_id_is_not_the_old_game() { + let t = machine(); + let b = Boost::new(t.path()); + b.on(100, "performance").unwrap(); + // pid 100 now belongs to a different process (other start time). + proc_entry(&t, 100, 9999); + b.off(200).unwrap(); + assert_eq!(read(&t, GOV), "schedutil"); + } + + #[test] + fn off_without_on_changes_nothing() { + let t = machine(); + let b = Boost::new(t.path()); + let r = b.off(100).unwrap(); + assert!(r.lines[0].contains("0 settings restored"), "{:?}", r.lines); + assert_eq!(read(&t, GOV), "schedutil"); + } + + #[test] + fn a_missing_process_is_refused() { + let t = machine(); + let e = Boost::new(t.path()).on(777, "performance").unwrap_err(); + assert!(e.to_string().contains("no process 777"), "{e}"); + assert_eq!(read(&t, GOV), "schedutil"); + } + + #[test] + fn the_state_directory_is_private() { + let t = machine(); + let b = Boost::new(t.path()); + b.on(100, "performance").unwrap(); + let mode = |rel: &str| { + fs::metadata(t.path().join(rel)) + .unwrap() + .permissions() + .mode() + & 0o777 + }; + assert_eq!(mode("run/seg"), 0o700); + assert_eq!(mode("run/seg/game-boost"), 0o600); + } +} diff --git a/tools/seg/segctl/src/game/check.rs b/tools/seg/segctl/src/game/check.rs new file mode 100644 index 0000000..af252fc --- /dev/null +++ b/tools/seg/segctl/src/game/check.rs @@ -0,0 +1,947 @@ +//! `seg game check`: is this computer ready for games? One line per +//! question, PASS or FAIL (or WARN when it works but is not as good as it +//! could be), and the exact command that fixes what is not right. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use super::hw::{self, Gpu, GpuVendor}; +use crate::error::Result; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game check + +Checks Vulkan, the 32-bit graphics drivers, the GPU driver, NTSYNC, +vm.max_map_count, the open files limit, the kernel (and a fallback kernel), +the CPU governor, whether `seg game run` may boost the computer, transparent +hugepages, split lock detection, memory compaction and the security +mitigations. Prints PASS, WARN or FAIL for each, and the fix for each FAIL and +WARN. Exits with status 1 when something FAILs. +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Status { + Pass, + Warn, + Fail, + Skip, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Check { + pub name: &'static str, + pub status: Status, + pub detail: String, + pub fix: Option, +} + +fn check( + name: &'static str, + status: Status, + detail: impl Into, + fix: Option<&str>, +) -> Check { + Check { + name, + status, + detail: detail.into(), + fix: fix.map(str::to_string), + } +} + +/// What was found on the computer; `evaluate` only judges it. +#[derive(Debug, Default)] +pub struct Inputs { + pub kernel_release: Option, + pub kernel_package: Option, + /// Kernel images in /boot (`vmlinuz-linux-zen`). + pub kernel_images: Vec, + /// Vulkan devices; None when vulkaninfo is not installed or failed. + pub vulkan_devices: Option>, + pub gpus: Vec, + pub packages: BTreeSet, + pub ntsync_device_mode: Option, + pub max_map_count: Option, + /// (soft, hard); `u64::MAX` for unlimited. + pub nofile: Option<(u64, u64)>, + pub governor: Option, + pub is_root: bool, + pub in_gamemode_group: bool, + /// Whether sudo lets this user run the boost helper without a password + /// (`sudo -n -l ...`); None when it was not asked. + pub boost_allowed: Option, + pub thp: Option, + pub split_lock_mitigate: Option, + pub compaction_proactiveness: Option, + pub cmdline: Option, + pub cpu: Option, + /// `amd-pstate-epp`, `acpi-cpufreq`, `intel_pstate`, ... + pub scaling_driver: Option, +} + +/// `deviceName` lines of `vulkaninfo --summary`. +pub fn parse_vulkan_devices(output: &str) -> Vec { + output + .lines() + .filter_map(|l| { + let l = l.trim(); + let rest = l.strip_prefix("deviceName")?; + Some(rest.trim_start().strip_prefix('=')?.trim().to_string()) + }) + .collect() +} + +/// `/proc/PID/limits`: the soft and hard value of "Max open files". +pub fn parse_nofile(limits: &str) -> Option<(u64, u64)> { + let line = limits.lines().find(|l| l.starts_with("Max open files"))?; + let mut f = line.trim_start_matches("Max open files").split_whitespace(); + let num = |w: &str| { + if w == "unlimited" { + Some(u64::MAX) + } else { + w.parse().ok() + } + }; + Some((num(f.next()?)?, num(f.next()?)?)) +} + +/// The supplementary group ids in /proc/PID/status. +pub fn parse_groups(status: &str) -> Vec { + status + .lines() + .find_map(|l| l.strip_prefix("Groups:")) + .map(|g| { + g.split_whitespace() + .filter_map(|w| w.parse().ok()) + .collect() + }) + .unwrap_or_default() +} + +pub fn group_gid(group_file: &str, name: &str) -> Option { + group_file.lines().find_map(|l| { + let mut f = l.split(':'); + (f.next()? == name).then(|| f.nth(1)?.parse().ok())? + }) +} + +/// Package names from pacman's local database directories (`bash-5.2-1`). +pub fn installed_packages(local_db: &Path) -> BTreeSet { + let Ok(entries) = fs::read_dir(local_db) else { + return BTreeSet::new(); + }; + entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let mut parts = name.rsplitn(3, '-'); + parts.next()?; // pkgrel + parts.next()?; // pkgver + Some(parts.next()?.to_string()) + }) + .collect() +} + +/// The 32-bit packages a GPU needs for 32-bit games (Steam is 32-bit). +pub fn lib32_packages(vendor: GpuVendor) -> &'static [&'static str] { + match vendor { + GpuVendor::Amd => &[ + "lib32-mesa", + "lib32-vulkan-radeon", + "lib32-vulkan-icd-loader", + ], + GpuVendor::Intel => &[ + "lib32-mesa", + "lib32-vulkan-intel", + "lib32-vulkan-icd-loader", + ], + GpuVendor::Nvidia => &["lib32-nvidia-utils", "lib32-vulkan-icd-loader"], + GpuVendor::Other => &[], + } +} + +fn kernel_is_gaming_flavour(release: &str) -> bool { + let r = release.to_lowercase(); + ["zen", "xanmod", "cachyos", "liquorix", "tkg"] + .iter() + .any(|f| r.contains(f)) +} + +pub fn evaluate(i: &Inputs) -> Vec { + use Status::*; + let mut out = Vec::new(); + + // Vulkan + out.push(match &i.vulkan_devices { + None => check( + "vulkan", + Fail, + "vulkaninfo did not run (not installed, or no Vulkan driver)", + Some("sudo pacman -S vulkan-tools vulkan-icd-loader (then: sudo seg game setup)"), + ), + Some(devices) => { + let real: Vec<&String> = devices + .iter() + .filter(|d| !d.to_lowercase().contains("llvmpipe") && !d.to_lowercase().contains("lavapipe")) + .collect(); + if let Some(first) = real.first() { + check("vulkan", Pass, first.as_str(), None) + } else if devices.is_empty() { + check("vulkan", Fail, "vulkaninfo lists no device", Some("sudo seg game setup --yes")) + } else { + check( + "vulkan", + Fail, + "only the software renderer (llvmpipe) is available: games would run at a crawl", + Some("sudo seg game setup --yes (installs the driver for the GPU found)"), + ) + } + } + }); + + // GPU driver in the kernel + if i.gpus.is_empty() { + out.push(check( + "gpu driver", + Skip, + "no graphics card found in /sys/class/drm (virtual machine?)", + None, + )); + } + for gpu in &i.gpus { + let name = hw::describe(gpu); + let driver = gpu.driver.as_deref(); + out.push(match (gpu.vendor, driver) { + (_, None) => check("gpu driver", Fail, format!("{name}: no kernel driver is bound"), Some("sudo seg game setup --yes (then reboot)")), + (GpuVendor::Amd, Some("amdgpu")) | (GpuVendor::Intel, Some("i915" | "xe")) => { + check("gpu driver", Pass, format!("{name}: {}", driver.unwrap_or("")), None) + } + (GpuVendor::Nvidia, Some("nvidia")) => check("gpu driver", Pass, format!("{name}: nvidia"), None), + (GpuVendor::Nvidia, Some("nouveau")) if gpu.nvidia_open_capable() => check( + "gpu driver", + Fail, + format!("{name}: running on the open-source nouveau driver; NVIDIA's own is much faster for games"), + Some("sudo seg game setup --yes (installs nvidia-open-dkms; then reboot)"), + ), + (GpuVendor::Nvidia, Some("nouveau")) => check( + "gpu driver", + Pass, + format!("{name}: nouveau (this card is too old for NVIDIA's current driver)"), + None, + ), + (_, Some(other)) => check("gpu driver", Warn, format!("{name}: driver {other}"), None), + }); + } + + // 32-bit drivers + let mut missing: BTreeSet<&str> = BTreeSet::new(); + for gpu in &i.gpus { + for pkg in lib32_packages(gpu.vendor) { + if !i.packages.contains(*pkg) { + missing.insert(pkg); + } + } + } + out.push(if i.gpus.is_empty() { + check("32-bit drivers", Skip, "no graphics card found", None) + } else if missing.is_empty() { + check("32-bit drivers", Pass, "the lib32 graphics packages are installed", None) + } else { + let list = missing.iter().copied().collect::>().join(" "); + check( + "32-bit drivers", + Fail, + format!("missing: {list} (Steam and many games are 32-bit; the [lib32] repository must be enabled)"), + Some(&format!("sudo pacman -S {list}")), + ) + }); + + // NTSYNC + let version = i.kernel_release.as_deref().and_then(hw::kernel_version); + out.push(match (version, i.ntsync_device_mode) { + (Some(v), _) if v < (6, 14) => check( + "ntsync", + Fail, + format!("kernel {}.{} is older than 6.14, which added NTSYNC", v.0, v.1), + Some("sudo pacman -S linux-zen linux-zen-headers (then pick it in the boot menu)"), + ), + (_, None) => check( + "ntsync", + Fail, + "/dev/ntsync does not exist: the module is not loaded", + Some("sudo modprobe ntsync (always: echo ntsync | sudo tee /etc/modules-load.d/10-ntsync.conf)"), + ), + (_, Some(mode)) if mode & 0o006 != 0o006 => check( + "ntsync", + Fail, + format!("/dev/ntsync is mode {mode:o}: Wine, running as you, cannot use it"), + Some("sudo chmod 666 /dev/ntsync (always: KERNEL==\"ntsync\", MODE=\"0666\" in /etc/udev/rules.d/70-ntsync.rules)"), + ), + (_, Some(_)) => check("ntsync", Pass, "/dev/ntsync is there and usable by Wine and Proton", None), + }); + + // Memory maps + out.push(match i.max_map_count { + Some(n) if n >= 1_048_576 => check("max_map_count", Pass, n.to_string(), None), + Some(n) => check( + "max_map_count", + Fail, + format!("{n}: some games crash below 1048576"), + Some("sudo sysctl -w vm.max_map_count=2147483642 (always: /etc/sysctl.d/80-seglinux-gaming.conf)"), + ), + None => check("max_map_count", Skip, "cannot read vm.max_map_count", None), + }); + + // Open files + out.push(match i.nofile { + Some((_, hard)) if hard >= 524_288 => check( + "open files", + Pass, + format!("hard limit {}", if hard == u64::MAX { "unlimited".to_string() } else { hard.to_string() }), + None, + ), + Some((_, hard)) => check( + "open files", + Fail, + format!("hard limit {hard}: esync needs 524288 or more"), + Some("add `* hard nofile 1048576` to /etc/security/limits.d/90-seglinux-esync.conf, then log out and in"), + ), + None => check("open files", Skip, "cannot read the limits of this process", None), + }); + + // Kernel + out.push(match (&i.kernel_release, &i.kernel_package) { + (Some(r), pkg) if kernel_is_gaming_flavour(r) => check( + "kernel", + Pass, + format!("{r}{}", pkg.as_deref().map(|p| format!(" ({p})")).unwrap_or_default()), + None, + ), + (Some(r), _) => check( + "kernel", + Warn, + format!("{r}: a stock kernel; linux-zen is tuned for desktop latency"), + Some("sudo pacman -S linux-zen linux-zen-headers && sudo grub-mkconfig -o /boot/grub/grub.cfg (the current kernel stays as a fallback)"), + ), + (None, _) => check("kernel", Skip, "cannot read the kernel release", None), + }); + out.push(if i.kernel_images.len() >= 2 { + check("fallback kernel", Pass, i.kernel_images.join(", "), None) + } else { + check( + "fallback kernel", + Warn, + "only one kernel is installed: if an update breaks it, there is nothing to boot instead", + Some("sudo pacman -S linux && sudo grub-mkconfig -o /boot/grub/grub.cfg (it appears under Advanced options)"), + ) + }); + + // Governor + out.push(match i.governor.as_deref() { + None => check("cpu governor", Skip, "no cpufreq on this machine (virtual machine?)", None), + Some("performance") => check( + "cpu governor", + Warn, + "performance all the time: the CPU runs hot and loud even when idle", + Some("sudo seg tune balanced (seg game run switches to performance only while a game runs)"), + ), + Some(g) => check( + "cpu governor", + Pass, + format!("{g} at rest; performance while a game runs under seg game run"), + None, + ), + }); + + // AMD: the amd-pstate driver in active mode, the one with energy hints. + if i.cpu + .as_ref() + .is_some_and(|c| c.vendor == hw::CpuVendor::Amd) + { + out.push(match i.scaling_driver.as_deref() { + Some("amd-pstate-epp") => check("cpu driver", Pass, "amd-pstate in active mode", None), + Some(other) => check( + "cpu driver", + Warn, + format!("{other}: AMD CPUs from Zen 2 on do better with amd_pstate=active (the CPU picks frequencies itself, guided by the energy hints seg game run sets)"), + Some("add amd_pstate=active to GRUB_CMDLINE_LINUX_DEFAULT in /etc/default/grub, then sudo grub-mkconfig -o /boot/grub/grub.cfg and reboot"), + ), + None => check("cpu driver", Skip, "no cpufreq driver (virtual machine?)", None), + }); + } + + // May seg game run boost? + out.push(if i.is_root { + check("game boost", Pass, "running as root", None) + } else if i.boost_allowed == Some(true) { + check("game boost", Pass, "sudo lets you run the boost helper, as the gamemode group may", None) + } else if !i.packages.contains("segctl") { + check( + "game boost", + Fail, + "segctl is not installed, so there is no sudo rule: seg game run cannot raise the governor", + Some("sudo pacman -S segctl"), + ) + } else if i.in_gamemode_group { + check( + "game boost", + Fail, + "you are in the gamemode group but sudo does not allow the boost helper", + Some("sudo visudo -c (a broken file in /etc/sudoers.d disables the rule; /etc/sudoers.d/seg-game comes from segctl)"), + ) + } else { + check( + "game boost", + Fail, + "you are not in the gamemode group (or have not logged in again since): seg game run cannot raise the governor", + Some("sudo gpasswd -a $USER gamemode (then log out and in)"), + ) + }); + + // Transparent hugepages + out.push(match i.thp.as_deref() { + Some(t) if t.contains("[madvise]") => check("hugepages", Pass, "transparent hugepages: madvise", None), + Some(t) => check( + "hugepages", + Warn, + format!("transparent hugepages: {t}; `always` can cause stalls, `madvise` lets games ask"), + Some("echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled (always: transparent_hugepage=madvise on the kernel command line)"), + ), + None => check("hugepages", Skip, "this kernel has no transparent hugepages", None), + }); + + // Split locks + out.push(match i.split_lock_mitigate { + Some(0) => check("split lock", Pass, "kernel.split_lock_mitigate = 0", None), + Some(n) => check( + "split lock", + Warn, + format!("kernel.split_lock_mitigate = {n}: games that do split locks are slowed down on purpose"), + Some("sudo sysctl -w kernel.split_lock_mitigate=0 (always: /etc/sysctl.d/80-seglinux-gaming.conf)"), + ), + None => check("split lock", Skip, "this kernel has no split lock detection setting", None), + }); + + // Compaction + out.push(match i.compaction_proactiveness { + Some(0) => check("compaction", Pass, "vm.compaction_proactiveness = 0", None), + Some(n) => check( + "compaction", + Warn, + format!("vm.compaction_proactiveness = {n}: the kernel defragments memory in the background, which can stall a game"), + Some("sudo sysctl -w vm.compaction_proactiveness=0 (always: /etc/sysctl.d/80-seglinux-gaming.conf)"), + ), + None => check("compaction", Skip, "this kernel has no vm.compaction_proactiveness", None), + }); + + // Mitigations stay on + out.push(match &i.cmdline { + Some(c) if c.split_whitespace().any(|w| w == "mitigations=off") => check( + "mitigations", + Warn, + "mitigations=off is on the kernel command line: faster, but the CPU's protections against Spectre-style attacks are off", + Some("sudo seg game unsafe-boost off"), + ), + Some(_) => check("mitigations", Pass, "the kernel's default security mitigations are on", None), + None => check("mitigations", Skip, "cannot read /proc/cmdline", None), + }); + out +} + +/// Look at the computer. `live` allows starting programs (vulkaninfo, lspci). +pub fn collect(root: &Path, live: bool) -> Inputs { + let read = |rel: &str| sys::read_trimmed(root.join(rel)); + let num = |rel: &str| read(rel).and_then(|t| t.parse::().ok()); + let release = read("proc/sys/kernel/osrelease"); + let mut images: Vec = fs::read_dir(root.join("boot")) + .map(|d| { + d.flatten() + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| n.starts_with("vmlinuz")) + .collect() + }) + .unwrap_or_default(); + images.sort(); + let status = read("proc/self/status").unwrap_or_default(); + let is_root = sys::parse_euid(&status) == Some(0); + let groups = parse_groups(&status); + let gamemode = read("etc/group").and_then(|g| group_gid(&g, "gamemode")); + let vulkan = if live && sys::which("vulkaninfo").is_some() { + sys::run("vulkaninfo", &["--summary"]) + .ok() + .filter(|o| o.ok) + .map(|o| parse_vulkan_devices(&o.stdout)) + } else { + None + }; + Inputs { + kernel_package: hw::kernel_package(root), + kernel_release: release, + kernel_images: images, + vulkan_devices: vulkan, + gpus: hw::gpus(root), + packages: installed_packages(&root.join("var/lib/pacman/local")), + ntsync_device_mode: fs::metadata(root.join("dev/ntsync")) + .ok() + .map(|m| std::os::unix::fs::PermissionsExt::mode(&m.permissions()) & 0o777), + max_map_count: num("proc/sys/vm/max_map_count"), + nofile: read("proc/self/limits").and_then(|l| parse_nofile(&l)), + governor: read("sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"), + is_root, + in_gamemode_group: gamemode.is_some_and(|g| groups.contains(&g)), + boost_allowed: if live && sys::which("sudo").is_some() { + sys::run( + "sudo", + &["-n", "-l", "/usr/bin/seg", "game", "boost", "off", "1"], + ) + .ok() + .map(|o| o.ok) + } else { + None + }, + thp: read("sys/kernel/mm/transparent_hugepage/enabled"), + split_lock_mitigate: num("proc/sys/kernel/split_lock_mitigate"), + compaction_proactiveness: num("proc/sys/vm/compaction_proactiveness"), + cmdline: read("proc/cmdline"), + cpu: Some(hw::cpu(root)), + scaling_driver: read("sys/devices/system/cpu/cpu0/cpufreq/scaling_driver"), + } +} + +pub fn label(s: Status) -> &'static str { + match s { + Status::Pass => "PASS", + Status::Warn => "WARN", + Status::Fail => "FAIL", + Status::Skip => "SKIP", + } +} + +pub fn render(checks: &[Check]) -> String { + let mut text = String::new(); + for c in checks { + text.push_str(&format!( + "[{}] {:<15} {}\n", + label(c.status), + c.name, + c.detail + )); + if let (Status::Fail | Status::Warn, Some(fix)) = (c.status, &c.fix) { + text.push_str(&format!(" {:<15} fix: {fix}\n", "")); + } + } + let count = |s| checks.iter().filter(|c| c.status == s).count(); + text.push_str(&format!( + "\n{} passed, {} to improve, {} failed\n", + count(Status::Pass), + count(Status::Warn), + count(Status::Fail) + )); + text +} + +pub fn run() -> Result { + let inputs = collect(Path::new("/"), true); + let checks = evaluate(&inputs); + out!("{}", render(&checks)); + Ok(u8::from(checks.iter().any(|c| c.status == Status::Fail))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::hw::{fixture, gpus}; + + fn pkgs(names: &[&str]) -> BTreeSet { + names.iter().map(|s| s.to_string()).collect() + } + + /// A machine where everything is right. + fn good() -> Inputs { + Inputs { + kernel_release: Some("7.2.11-zen1-1-zen".into()), + kernel_package: Some("linux-zen".into()), + kernel_images: vec!["vmlinuz-linux".into(), "vmlinuz-linux-zen".into()], + vulkan_devices: Some(vec!["AMD Radeon RX 7900 XTX (RADV NAVI31)".into()]), + gpus: gpus(&fixture("gpu-amd")), + packages: pkgs(&[ + "lib32-mesa", + "lib32-vulkan-radeon", + "lib32-vulkan-icd-loader", + "segctl", + ]), + ntsync_device_mode: Some(0o666), + max_map_count: Some(2_147_483_642), + nofile: Some((1024, 1_048_576)), + governor: Some("powersave".into()), + is_root: false, + in_gamemode_group: true, + boost_allowed: Some(true), + thp: Some("always [madvise] never".into()), + split_lock_mitigate: Some(0), + compaction_proactiveness: Some(0), + cmdline: Some("root=/dev/sda2 quiet".into()), + cpu: Some(hw::cpu(&fixture("ryzen-7950x3d"))), + scaling_driver: Some("amd-pstate-epp".into()), + } + } + + fn status(checks: &[Check], name: &str) -> Vec { + checks + .iter() + .filter(|c| c.name == name) + .map(|c| c.status) + .collect() + } + + #[test] + fn a_ready_machine_passes_everything() { + let checks = evaluate(&good()); + for c in &checks { + assert_eq!(c.status, Status::Pass, "{} -> {}", c.name, c.detail); + assert!(c.fix.is_none()); + } + assert_eq!( + checks.len(), + 15, + "{:?}", + checks.iter().map(|c| c.name).collect::>() + ); + let text = render(&checks); + assert!(text.contains("[PASS] vulkan")); + assert!(text.contains("15 passed, 0 to improve, 0 failed"), "{text}"); + } + + #[test] + fn software_rendering_is_a_failure_with_the_fix() { + let mut i = good(); + i.vulkan_devices = Some(vec!["llvmpipe (LLVM 19.1.7, 256 bits)".into()]); + let c = evaluate(&i); + assert_eq!(status(&c, "vulkan"), [Status::Fail]); + assert!( + c[0].fix + .as_deref() + .unwrap() + .contains("seg game setup --yes") + ); + i.vulkan_devices = None; + assert!( + evaluate(&i)[0] + .fix + .as_deref() + .unwrap() + .contains("vulkan-tools") + ); + i.vulkan_devices = Some(vec![]); + assert_eq!(status(&evaluate(&i), "vulkan"), [Status::Fail]); + } + + #[test] + fn missing_32_bit_drivers_are_named() { + let mut i = good(); + i.packages = pkgs(&["lib32-mesa"]); + let c = evaluate(&i); + let d = c.iter().find(|c| c.name == "32-bit drivers").unwrap(); + assert_eq!(d.status, Status::Fail); + assert!( + d.detail + .contains("lib32-vulkan-icd-loader lib32-vulkan-radeon"), + "{}", + d.detail + ); + assert_eq!( + d.fix.as_deref(), + Some("sudo pacman -S lib32-vulkan-icd-loader lib32-vulkan-radeon") + ); + } + + #[test] + fn nvidia_needs_its_own_driver_and_its_lib32_utils() { + let mut i = good(); + i.gpus = gpus(&fixture("gpu-nvidia-ada-nouveau")); + i.packages = pkgs(&[]); + let c = evaluate(&i); + assert_eq!(status(&c, "gpu driver"), [Status::Fail]); + let d = c.iter().find(|c| c.name == "32-bit drivers").unwrap(); + assert!(d.detail.contains("lib32-nvidia-utils"), "{}", d.detail); + // The proprietary driver loaded: fine. + i.gpus = gpus(&fixture("gpu-nvidia-ada")); + assert_eq!(status(&evaluate(&i), "gpu driver"), [Status::Pass]); + // An old card cannot have it: nouveau is the right driver. + i.gpus = gpus(&fixture("gpu-nvidia-pascal")); + assert_eq!(status(&evaluate(&i), "gpu driver"), [Status::Pass]); + } + + #[test] + fn a_hybrid_laptop_is_checked_card_by_card() { + let mut i = good(); + i.gpus = gpus(&fixture("gpu-hybrid-laptop")); + i.packages = pkgs(&[ + "lib32-mesa", + "lib32-vulkan-intel", + "lib32-vulkan-icd-loader", + "lib32-nvidia-utils", + ]); + let c = evaluate(&i); + assert_eq!(status(&c, "gpu driver"), [Status::Pass, Status::Pass]); + assert_eq!(status(&c, "32-bit drivers"), [Status::Pass]); + } + + #[test] + fn no_gpu_skips_the_gpu_questions() { + let mut i = good(); + i.gpus = vec![]; + let c = evaluate(&i); + assert_eq!(status(&c, "gpu driver"), [Status::Skip]); + assert_eq!(status(&c, "32-bit drivers"), [Status::Skip]); + } + + #[test] + fn ntsync_has_three_ways_to_fail() { + let mut i = good(); + i.ntsync_device_mode = None; + let c = evaluate(&i); + let n = c.iter().find(|c| c.name == "ntsync").unwrap(); + assert_eq!(n.status, Status::Fail); + assert!( + n.fix + .as_deref() + .unwrap() + .starts_with("sudo modprobe ntsync") + ); + i.ntsync_device_mode = Some(0o600); + let n = evaluate(&i) + .into_iter() + .find(|c| c.name == "ntsync") + .unwrap(); + assert!(n.fix.as_deref().unwrap().contains("chmod 666")); + i.kernel_release = Some("6.12.4-arch1-1".into()); + let n = evaluate(&i) + .into_iter() + .find(|c| c.name == "ntsync") + .unwrap(); + assert!(n.detail.contains("older than 6.14"), "{}", n.detail); + } + + #[test] + fn sysctls_and_limits() { + let mut i = good(); + i.max_map_count = Some(65530); + i.nofile = Some((1024, 4096)); + i.split_lock_mitigate = Some(1); + i.compaction_proactiveness = Some(20); + let c = evaluate(&i); + assert_eq!(status(&c, "max_map_count"), [Status::Fail]); + assert_eq!(status(&c, "open files"), [Status::Fail]); + assert_eq!(status(&c, "split lock"), [Status::Warn]); + assert_eq!(status(&c, "compaction"), [Status::Warn]); + let text = render(&c); + assert!( + text.contains("fix: sudo sysctl -w vm.max_map_count=2147483642"), + "{text}" + ); + assert!(text.contains("limits.d/90-seglinux-esync.conf"), "{text}"); + // Older kernels simply lack the keys. + i.split_lock_mitigate = None; + i.compaction_proactiveness = None; + let c = evaluate(&i); + assert_eq!(status(&c, "split lock"), [Status::Skip]); + assert_eq!(status(&c, "compaction"), [Status::Skip]); + } + + #[test] + fn kernels() { + let mut i = good(); + i.kernel_release = Some("6.14.2-arch1-1".into()); + i.kernel_images = vec!["vmlinuz-linux".into()]; + let c = evaluate(&i); + assert_eq!(status(&c, "kernel"), [Status::Warn]); + assert_eq!(status(&c, "fallback kernel"), [Status::Warn]); + let text = render(&c); + assert!( + text.contains("pacman -S linux-zen linux-zen-headers"), + "{text}" + ); + assert!(text.contains("grub-mkconfig"), "{text}"); + } + + #[test] + fn amd_cpus_want_the_active_pstate_driver_and_others_are_not_asked() { + let mut i = good(); + i.scaling_driver = Some("acpi-cpufreq".into()); + let c = evaluate(&i); + assert_eq!(status(&c, "cpu driver"), [Status::Warn]); + assert!(render(&c).contains("amd_pstate=active")); + i.scaling_driver = Some("amd-pstate".into()); + assert_eq!(status(&evaluate(&i), "cpu driver"), [Status::Warn]); + i.scaling_driver = None; + assert_eq!(status(&evaluate(&i), "cpu driver"), [Status::Skip]); + i.cpu = Some(hw::cpu(&fixture("intel-12700"))); + i.scaling_driver = Some("intel_pstate".into()); + assert!(status(&evaluate(&i), "cpu driver").is_empty()); + } + + #[test] + fn the_governor_is_judged_at_rest() { + let mut i = good(); + i.governor = Some("performance".into()); + assert_eq!(status(&evaluate(&i), "cpu governor"), [Status::Warn]); + i.governor = None; + assert_eq!(status(&evaluate(&i), "cpu governor"), [Status::Skip]); + } + + #[test] + fn who_may_boost() { + let mut i = good(); + i.boost_allowed = Some(false); + i.in_gamemode_group = false; + let b = evaluate(&i) + .into_iter() + .find(|c| c.name == "game boost") + .unwrap(); + assert_eq!(b.status, Status::Fail); + assert!( + b.fix + .as_deref() + .unwrap() + .contains("gpasswd -a $USER gamemode") + ); + // In the group and still refused: a sudoers problem. + i.in_gamemode_group = true; + let b = evaluate(&i) + .into_iter() + .find(|c| c.name == "game boost") + .unwrap(); + assert!( + b.fix.as_deref().unwrap().contains("visudo -c"), + "{:?}", + b.fix + ); + // Not asked (not a live check): the group decides what is said. + i.boost_allowed = None; + assert_eq!(status(&evaluate(&i), "game boost"), [Status::Fail]); + // No segctl, no rule. + i.packages.remove("segctl"); + let b = evaluate(&i) + .into_iter() + .find(|c| c.name == "game boost") + .unwrap(); + assert!(b.fix.as_deref().unwrap().contains("pacman -S segctl")); + i.is_root = true; + assert_eq!(status(&evaluate(&i), "game boost"), [Status::Pass]); + } + + #[test] + fn hugepages_and_mitigations() { + let mut i = good(); + i.thp = Some("[always] madvise never".into()); + i.cmdline = Some("quiet mitigations=off".into()); + let c = evaluate(&i); + assert_eq!(status(&c, "hugepages"), [Status::Warn]); + assert_eq!(status(&c, "mitigations"), [Status::Warn]); + assert!(render(&c).contains("seg game unsafe-boost off")); + // A word that only contains the text is not the setting. + i.cmdline = Some("quiet nomitigations=off2".into()); + assert_eq!(status(&evaluate(&i), "mitigations"), [Status::Pass]); + } + + #[test] + fn the_exit_status_follows_the_failures_only() { + let mut i = good(); + i.thp = Some("[always] madvise never".into()); + assert!(!evaluate(&i).iter().any(|c| c.status == Status::Fail)); + i.max_map_count = Some(1); + assert!(evaluate(&i).iter().any(|c| c.status == Status::Fail)); + } + + #[test] + fn parsers() { + let vk = "Devices:\n========\nGPU0:\n\tapiVersion = 1.3.296\n\tdeviceName = AMD Radeon RX 7900 XTX (RADV NAVI31)\n\tdriverName = radv\nGPU1:\n\tdeviceName = llvmpipe (LLVM 19.1.7, 256 bits)\n"; + assert_eq!( + parse_vulkan_devices(vk), + [ + "AMD Radeon RX 7900 XTX (RADV NAVI31)", + "llvmpipe (LLVM 19.1.7, 256 bits)" + ] + ); + let limits = "Limit Soft Limit Hard Limit Units\nMax cpu time unlimited unlimited seconds\nMax open files 1024 1048576 files\n"; + assert_eq!(parse_nofile(limits), Some((1024, 1_048_576))); + assert_eq!( + parse_nofile("Max open files unlimited unlimited files"), + Some((u64::MAX, u64::MAX)) + ); + assert_eq!(parse_nofile(""), None); + assert_eq!( + parse_groups("Name:\tx\nGroups:\t10 1000 998\n"), + [10, 1000, 998] + ); + assert_eq!(parse_groups("Groups:\t\n"), Vec::::new()); + assert_eq!( + group_gid("root:x:0:\ngamemode:x:998:alice\n", "gamemode"), + Some(998) + ); + assert_eq!(group_gid("root:x:0:\n", "gamemode"), None); + } + + #[test] + fn installed_packages_come_from_the_database_folder_names() { + let t = sys::testutil::TempDir::new(); + for n in [ + "lib32-mesa-25.0.1-1", + "linux-zen-headers-7.2.11.zen1-1", + "bash-5.2-1", + "x", + ] { + t.put(&format!("{n}/desc"), ""); + } + t.put("ALPM_DB_VERSION", "9\n"); + let p = installed_packages(t.path()); + assert!(p.contains("lib32-mesa") && p.contains("linux-zen-headers") && p.contains("bash")); + assert!(!p.contains("x")); + assert_eq!(p.len(), 3); + } + + #[test] + fn collecting_from_a_fake_root() { + let t = sys::testutil::TempDir::new(); + t.put("proc/sys/kernel/osrelease", "7.2.11-zen1-1-zen\n"); + t.put("proc/sys/vm/max_map_count", "2147483642\n"); + t.put("proc/sys/kernel/split_lock_mitigate", "0\n"); + t.put( + "sys/devices/system/cpu/cpu0/cpufreq/scaling_governor", + "powersave\n", + ); + t.put( + "sys/kernel/mm/transparent_hugepage/enabled", + "always [madvise] never\n", + ); + t.put("boot/vmlinuz-linux", ""); + t.put("boot/vmlinuz-linux-zen", ""); + t.put("boot/initramfs-linux.img", ""); + t.put("proc/cmdline", "quiet\n"); + t.put( + "proc/cpuinfo", + "vendor_id\t: AuthenticAMD\nmodel name\t: AMD Ryzen 7 5800X\n", + ); + t.put( + "sys/devices/system/cpu/cpu0/cpufreq/scaling_driver", + "amd-pstate-epp\n", + ); + let i = collect(t.path(), false); + assert_eq!(i.kernel_images, ["vmlinuz-linux", "vmlinuz-linux-zen"]); + assert_eq!(i.max_map_count, Some(2_147_483_642)); + assert_eq!(i.split_lock_mitigate, Some(0)); + assert_eq!(i.governor.as_deref(), Some("powersave")); + assert_eq!(i.scaling_driver.as_deref(), Some("amd-pstate-epp")); + assert_eq!(i.cpu.map(|c| c.vendor), Some(hw::CpuVendor::Amd)); + assert_eq!(i.boost_allowed, None, "sudo is only asked in a live check"); + assert_eq!( + i.vulkan_devices, None, + "no programs are started when not live" + ); + assert_eq!(i.ntsync_device_mode, None); + assert_eq!(i.compaction_proactiveness, None); + } +} diff --git a/tools/seg/segctl/src/game/dbus.rs b/tools/seg/segctl/src/game/dbus.rs new file mode 100644 index 0000000..37db50a --- /dev/null +++ b/tools/seg/segctl/src/game/dbus.rs @@ -0,0 +1,442 @@ +//! Keeps the screen from blinking out during a game: a minimal D-Bus client, +//! just enough to call `org.freedesktop.ScreenSaver.Inhibit` on the session +//! bus and hold the connection open (the inhibition ends when it closes). +//! Plasma's PowerDevil and screen locker, GNOME and others implement that +//! interface. No D-Bus library: a method call is a few dozen bytes. + +use std::io::{Read, Write}; +use std::os::linux::net::SocketAddrExt; +use std::os::unix::net::{SocketAddr, UnixStream}; +use std::time::Duration; + +use crate::error::{Error, Result}; + +/// Where the session bus listens, from DBUS_SESSION_BUS_ADDRESS +/// (`unix:path=/run/user/1000/bus` or `unix:abstract=/tmp/dbus-AbC`). +#[derive(Debug, PartialEq, Eq)] +pub enum BusAddress { + Path(String), + Abstract(String), +} + +pub fn parse_address(text: &str) -> Option { + text.split(';').find_map(|entry| { + let rest = entry.strip_prefix("unix:")?; + rest.split(',').find_map(|kv| { + let (k, v) = kv.split_once('=')?; + let v = unescape(v); + match k { + "path" => Some(BusAddress::Path(v)), + "abstract" => Some(BusAddress::Abstract(v)), + _ => None, + } + }) + }) +} + +/// `%2f` style escapes of D-Bus addresses. +fn unescape(text: &str) -> String { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while let Some(&b) = bytes.get(i) { + if b == b'%' + && let Some(hex) = text.get(i + 1..i + 3) + && let Ok(v) = u8::from_str_radix(hex, 16) + { + out.push(v); + i += 3; + } else { + out.push(b); + i += 1; + } + } + String::from_utf8_lossy(&out).into_owned() +} + +fn pad(buf: &mut Vec, align: usize) { + buf.resize(buf.len().next_multiple_of(align), 0); +} + +fn put_u32(buf: &mut Vec, v: u32) { + pad(buf, 4); + buf.extend_from_slice(&v.to_le_bytes()); +} + +fn put_str(buf: &mut Vec, s: &str) { + put_u32(buf, s.len() as u32); + buf.extend_from_slice(s.as_bytes()); + buf.push(0); +} + +/// One header field: `(code, variant)` with a string-like value. +fn put_field(buf: &mut Vec, code: u8, kind: u8, value: &str) { + pad(buf, 8); + buf.push(code); + buf.extend_from_slice(&[1, kind, 0]); + put_str(buf, value); +} + +/// A METHOD_CALL message. `signature` and `body` go together (`"ss"` and +/// two marshalled strings), or are both empty. +pub fn method_call( + serial: u32, + destination: &str, + path: &str, + interface: &str, + member: &str, + signature: &str, + body: &[u8], +) -> Vec { + let mut fields = Vec::new(); + put_field(&mut fields, 1, b'o', path); + put_field(&mut fields, 6, b's', destination); + put_field(&mut fields, 2, b's', interface); + put_field(&mut fields, 3, b's', member); + if !signature.is_empty() { + pad(&mut fields, 8); + fields.push(8); + fields.extend_from_slice(&[1, b'g', 0]); + fields.push(signature.len() as u8); + fields.extend_from_slice(signature.as_bytes()); + fields.push(0); + } + let mut msg = vec![b'l', 1, 0, 1]; + msg.extend_from_slice(&(body.len() as u32).to_le_bytes()); + msg.extend_from_slice(&serial.to_le_bytes()); + msg.extend_from_slice(&(fields.len() as u32).to_le_bytes()); + msg.extend_from_slice(&fields); + pad(&mut msg, 8); + msg.extend_from_slice(body); + msg +} + +pub fn hello(serial: u32) -> Vec { + method_call( + serial, + "org.freedesktop.DBus", + "/org/freedesktop/DBus", + "org.freedesktop.DBus", + "Hello", + "", + &[], + ) +} + +pub fn inhibit(serial: u32, application: &str, reason: &str) -> Vec { + let mut body = Vec::new(); + put_str(&mut body, application); + put_str(&mut body, reason); + method_call( + serial, + "org.freedesktop.ScreenSaver", + "/org/freedesktop/ScreenSaver", + "org.freedesktop.ScreenSaver", + "Inhibit", + "ss", + &body, + ) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Reply { + /// 2 method return, 3 error, 4 signal. + pub kind: u8, + pub reply_serial: Option, + pub error_name: Option, + pub body: Vec, +} + +fn get_u32(data: &[u8], at: usize) -> Option { + Some(u32::from_le_bytes(data.get(at..at + 4)?.try_into().ok()?)) +} + +fn align(at: usize, to: usize) -> usize { + at.div_ceil(to) * to +} + +/// A string-like value at `at` (aligned): its text and where it ends. +fn read_str(data: &[u8], at: usize) -> Option<(String, usize)> { + let at = align(at, 4); + let len = get_u32(data, at)? as usize; + let text = data.get(at + 4..at + 4 + len)?; + Some((String::from_utf8_lossy(text).into_owned(), at + 4 + len + 1)) +} + +/// Parse the part of a message after its first 16 bytes' fixed header: +/// `fields` is the header-field array, `body` the rest. +pub fn parse_reply(kind: u8, fields: &[u8], body: Vec) -> Option { + let mut reply = Reply { + kind, + reply_serial: None, + error_name: None, + body, + }; + // The array starts at offset 16 of the message, a multiple of 8, so + // offsets inside `fields` have the same alignment as in the message. + let mut at = 0; + while at < fields.len() { + at = align(at, 8); + if at >= fields.len() { + break; + } + let code = *fields.get(at)?; + let sig_len = *fields.get(at + 1)? as usize; + let sig = fields.get(at + 2..at + 2 + sig_len)?; + at += 2 + sig_len + 1; + match sig { + b"u" => { + let v = get_u32(fields, align(at, 4))?; + at = align(at, 4) + 4; + if code == 5 { + reply.reply_serial = Some(v); + } + } + b"s" | b"o" => { + let (s, end) = read_str(fields, at)?; + at = end; + if code == 4 { + reply.error_name = Some(s); + } + } + b"g" => { + let len = *fields.get(at)? as usize; + at += 1 + len + 1; + } + _ => return None, + } + } + Some(reply) +} + +fn read_message(stream: &mut UnixStream) -> std::io::Result> { + let mut fixed = [0u8; 16]; + stream.read_exact(&mut fixed)?; + if fixed[0] != b'l' { + return Ok(None); + } + let body_len = u32::from_le_bytes([fixed[4], fixed[5], fixed[6], fixed[7]]) as usize; + let fields_len = u32::from_le_bytes([fixed[12], fixed[13], fixed[14], fixed[15]]) as usize; + if body_len > 1 << 20 || fields_len > 1 << 16 { + return Ok(None); + } + let mut fields = vec![0u8; fields_len]; + stream.read_exact(&mut fields)?; + let padding = align(fields_len, 8) - fields_len; + let mut skip = vec![0u8; padding]; + stream.read_exact(&mut skip)?; + let mut body = vec![0u8; body_len]; + stream.read_exact(&mut body)?; + Ok(parse_reply(fixed[1], &fields, body)) +} + +/// An open inhibition: dropping it closes the connection, which ends it. +pub struct Inhibitor { + _stream: UnixStream, +} + +fn connect(address: &BusAddress) -> std::io::Result { + match address { + BusAddress::Path(p) => UnixStream::connect(p), + BusAddress::Abstract(name) => { + UnixStream::connect_addr(&SocketAddr::from_abstract_name(name.as_bytes())?) + } + } +} + +fn bus_error(e: impl std::fmt::Display) -> Error { + Error::other(format!("session bus: {e}")) +} + +/// Authenticate and say Hello, and return the stream. +fn open(address: &BusAddress, uid: u32) -> Result { + let mut stream = connect(address).map_err(bus_error)?; + let timeout = Some(Duration::from_secs(2)); + stream.set_read_timeout(timeout).map_err(bus_error)?; + stream.set_write_timeout(timeout).map_err(bus_error)?; + let hex: String = uid + .to_string() + .bytes() + .map(|b| format!("{b:02x}")) + .collect(); + stream + .write_all(format!("\0AUTH EXTERNAL {hex}\r\n").as_bytes()) + .map_err(bus_error)?; + // The answer is one line: "OK ". + let mut line = Vec::new(); + let mut byte = [0u8; 1]; + while !line.ends_with(b"\r\n") { + stream.read_exact(&mut byte).map_err(bus_error)?; + line.push(byte[0]); + if line.len() > 256 { + return Err(bus_error("the bus answered nonsense")); + } + } + if !line.starts_with(b"OK") { + return Err(bus_error("authentication was refused")); + } + stream.write_all(b"BEGIN\r\n").map_err(bus_error)?; + stream.write_all(&hello(1)).map_err(bus_error)?; + wait_for(&mut stream, 1)?; + Ok(stream) +} + +/// Read messages until the reply to `serial` arrives (signals such as +/// NameAcquired come first). +fn wait_for(stream: &mut UnixStream, serial: u32) -> Result { + for _ in 0..16 { + let message = read_message(stream).map_err(bus_error)?; + if let Some(r) = message + && r.reply_serial == Some(serial) + { + return match r.kind { + 2 => Ok(r), + _ => Err(bus_error(r.error_name.unwrap_or_else(|| "an error".into()))), + }; + } + } + Err(bus_error("no answer")) +} + +/// Ask the screen saver not to start, for `reason`. +pub fn inhibit_screensaver(application: &str, reason: &str) -> Result { + let address = std::env::var("DBUS_SESSION_BUS_ADDRESS") + .ok() + .and_then(|a| parse_address(&a)) + .or_else(|| { + let uid = crate::sys::euid().ok()?; + let path = format!("/run/user/{uid}/bus"); + std::path::Path::new(&path) + .exists() + .then_some(BusAddress::Path(path)) + }) + .ok_or_else(|| Error::other("no session bus (DBUS_SESSION_BUS_ADDRESS is not set)"))?; + let mut stream = open(&address, crate::sys::euid()?)?; + stream + .write_all(&inhibit(2, application, reason)) + .map_err(bus_error)?; + let reply = wait_for(&mut stream, 2)?; + // The answer carries a cookie; an answer without one is not an inhibition. + get_u32(&reply.body, 0).ok_or_else(|| bus_error("the answer has no cookie"))?; + Ok(Inhibitor { _stream: stream }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn addresses() { + assert_eq!( + parse_address("unix:path=/run/user/1000/bus"), + Some(BusAddress::Path("/run/user/1000/bus".into())) + ); + assert_eq!( + parse_address("unix:abstract=/tmp/dbus-AbC123,guid=0123"), + Some(BusAddress::Abstract("/tmp/dbus-AbC123".into())) + ); + assert_eq!( + parse_address("tcp:host=x,port=1;unix:path=/a%20b/bus"), + Some(BusAddress::Path("/a b/bus".into())) + ); + assert_eq!(parse_address("tcp:host=x"), None); + assert_eq!(parse_address(""), None); + } + + #[test] + fn hello_is_the_message_the_spec_shows() { + // The 128 bytes dbus-daemon expects for Hello: little endian, a + // method call, no body, serial 1, 110 bytes of header fields. + let m = hello(1); + assert_eq!(m.len(), 128); + assert_eq!( + &m[..16], + &[b'l', 1, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 110, 0, 0, 0] + ); + // PATH, then DESTINATION, INTERFACE and MEMBER, each on an 8-byte boundary. + assert_eq!(&m[16..20], &[1, 1, b'o', 0]); + assert_eq!(&m[20..24], &[21, 0, 0, 0]); + assert_eq!(&m[24..45], b"/org/freedesktop/DBus"); + assert_eq!(&m[48..52], &[6, 1, b's', 0]); + assert_eq!(&m[112..116], &[3, 1, b's', 0]); + assert_eq!(&m[116..120], &[5, 0, 0, 0]); + assert_eq!(&m[120..126], b"Hello\0"); + assert_eq!(&m[126..], &[0, 0]); + } + + #[test] + fn inhibit_carries_two_strings_and_their_signature() { + let m = inhibit(2, "seg", "playing a game"); + // body: 4+3+1 (padded to 4) = 8, then 4+14+1 = 19 + let body_len = u32::from_le_bytes([m[4], m[5], m[6], m[7]]) as usize; + assert_eq!(body_len, 8 + 19); + let body = &m[m.len() - body_len..]; + assert_eq!(&body[..4], &[3, 0, 0, 0]); + assert_eq!(&body[4..8], b"seg\0"); + assert_eq!(&body[8..12], &[14, 0, 0, 0]); + assert_eq!(&body[12..27], b"playing a game\0"); + // The header ends 8-aligned, so the body starts aligned. + assert_eq!((m.len() - body_len) % 8, 0); + let text = String::from_utf8_lossy(&m); + assert!(text.contains("org.freedesktop.ScreenSaver")); + assert!(text.contains("Inhibit")); + assert!(m.windows(3).any(|w| w == [b's', b's', 0]), "signature ss"); + } + + /// Fields of a METHOD_RETURN as dbus-daemon sends them: REPLY_SERIAL=2, + /// DESTINATION=":1.5", SENDER="org.freedesktop.ScreenSaver", SIGNATURE=u. + fn return_fields() -> Vec { + let mut f = Vec::new(); + f.extend_from_slice(&[5, 1, b'u', 0]); + f.extend_from_slice(&2u32.to_le_bytes()); + put_field(&mut f, 6, b's', ":1.5"); + put_field(&mut f, 7, b's', "org.freedesktop.ScreenSaver"); + pad(&mut f, 8); + f.extend_from_slice(&[8, 1, b'g', 0, 1, b'u', 0]); + f + } + + #[test] + fn a_method_return_gives_its_serial_and_cookie() { + let r = parse_reply(2, &return_fields(), 4242u32.to_le_bytes().to_vec()).unwrap(); + assert_eq!(r.kind, 2); + assert_eq!(r.reply_serial, Some(2)); + assert_eq!(r.error_name, None); + assert_eq!(get_u32(&r.body, 0), Some(4242)); + } + + #[test] + fn an_error_gives_its_name() { + let mut f = Vec::new(); + put_field(&mut f, 4, b's', "org.freedesktop.DBus.Error.ServiceUnknown"); + pad(&mut f, 8); + f.extend_from_slice(&[5, 1, b'u', 0]); + f.extend_from_slice(&2u32.to_le_bytes()); + let r = parse_reply(3, &f, vec![]).unwrap(); + assert_eq!(r.kind, 3); + assert_eq!(r.reply_serial, Some(2)); + assert_eq!( + r.error_name.as_deref(), + Some("org.freedesktop.DBus.Error.ServiceUnknown") + ); + } + + #[test] + fn damaged_headers_are_not_a_panic() { + assert!(parse_reply(2, &[5, 1], vec![]).is_none()); + assert!(parse_reply(2, &[5, 1, b'q', 0, 0, 0, 0, 0], vec![]).is_none()); + assert_eq!( + parse_reply(2, &[], vec![]).map(|r| r.reply_serial), + Some(None) + ); + // A string that claims to be longer than the data. + assert!(parse_reply(3, &[4, 1, b's', 0, 99, 0, 0, 0, b'x', 0], vec![]).is_none()); + } + + #[test] + fn without_a_bus_it_is_an_error_not_a_hang() { + let r = open(&BusAddress::Path("/definitely/not/a/socket".into()), 1000); + assert!(r.is_err()); + assert!(r.err().unwrap().to_string().starts_with("session bus: ")); + } +} diff --git a/tools/seg/segctl/src/game/hw.rs b/tools/seg/segctl/src/game/hw.rs new file mode 100644 index 0000000..c057b79 --- /dev/null +++ b/tools/seg/segctl/src/game/hw.rs @@ -0,0 +1,504 @@ +//! What the computer is: its CPU (and whether one of its cache complexes is +//! the big V-Cache one of an X3D part) and its graphics cards, read from +//! /proc and /sys below a root directory. + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; + +use crate::sys; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CpuVendor { + Amd, + Intel, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Cpu { + pub vendor: CpuVendor, + pub model: String, +} + +/// `vendor_id` and `model name` of the first processor in /proc/cpuinfo. +pub fn parse_cpuinfo(text: &str) -> Cpu { + let field = |key: &str| { + text.lines().find_map(|l| { + let (k, v) = l.split_once(':')?; + (k.trim() == key).then(|| v.trim().to_string()) + }) + }; + let vendor = match field("vendor_id").as_deref() { + Some("AuthenticAMD") => CpuVendor::Amd, + Some("GenuineIntel") => CpuVendor::Intel, + _ => CpuVendor::Other, + }; + Cpu { + vendor, + model: field("model name").unwrap_or_default(), + } +} + +pub fn cpu(root: &Path) -> Cpu { + parse_cpuinfo(&sys::read_trimmed(root.join("proc/cpuinfo")).unwrap_or_default()) +} + +/// `0-7,16-23` -> the CPU numbers. Anything malformed gives None. +pub fn parse_cpulist(text: &str) -> Option> { + let mut cpus = Vec::new(); + for part in text.trim().split(',') { + match part.split_once('-') { + Some((a, b)) => { + let (a, b): (usize, usize) = (a.trim().parse().ok()?, b.trim().parse().ok()?); + if b < a || b - a > 4096 { + return None; + } + cpus.extend(a..=b); + } + None => cpus.push(part.trim().parse().ok()?), + } + } + cpus.sort_unstable(); + cpus.dedup(); + (!cpus.is_empty()).then_some(cpus) +} + +/// `[0,1,2,3,8]` -> `0-3,8`. +pub fn format_cpulist(cpus: &[usize]) -> String { + let mut out = Vec::new(); + let mut i = 0; + while let Some(&start) = cpus.get(i) { + let mut end = start; + while cpus.get(i + 1) == Some(&(end + 1)) { + end += 1; + i += 1; + } + out.push(if end == start { + start.to_string() + } else { + format!("{start}-{end}") + }); + i += 1; + } + out.join(",") +} + +/// `98304K` / `32M` -> KiB. +pub fn parse_cache_size(text: &str) -> Option { + let text = text.trim(); + let (digits, factor) = if let Some(d) = text.strip_suffix('K') { + (d, 1) + } else if let Some(d) = text.strip_suffix('M') { + (d, 1024) + } else { + (text, 1) + }; + digits.parse::().ok()?.checked_mul(factor) +} + +/// The CPUs that share one last-level cache, and its size. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CacheGroup { + pub cpus: Vec, + pub size_kib: u64, +} + +/// The L3 caches of the machine, one per group of CPUs sharing it, found as +/// `cpu*/cache/index*/` entries of level 3. +pub fn l3_groups(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root.join("sys/devices/system/cpu")) else { + return Vec::new(); + }; + let mut groups: BTreeMap, u64> = BTreeMap::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let name = name.to_string_lossy(); + if !name + .strip_prefix("cpu") + .is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())) + { + continue; + } + let Ok(indexes) = fs::read_dir(entry.path().join("cache")) else { + continue; + }; + for index in indexes.flatten() { + let dir = index.path(); + if sys::read_trimmed(dir.join("level")).as_deref() != Some("3") { + continue; + } + let size = sys::read_trimmed(dir.join("size")).and_then(|s| parse_cache_size(&s)); + let shared = + sys::read_trimmed(dir.join("shared_cpu_list")).and_then(|s| parse_cpulist(&s)); + if let (Some(size), Some(shared)) = (size, shared) { + groups.insert(shared, size); + } + } + } + groups + .into_iter() + .map(|(cpus, size_kib)| CacheGroup { cpus, size_kib }) + .collect() +} + +/// The V-Cache complex of an X3D part with two complexes: the one whose L3 +/// is bigger than another's. None when all complexes are alike (a single +/// complex, or ordinary CPUs): there is nothing to choose then. +pub fn vcache_group(groups: &[CacheGroup]) -> Option<&CacheGroup> { + let biggest = groups.iter().max_by_key(|g| g.size_kib)?; + let smallest = groups.iter().min_by_key(|g| g.size_kib)?; + (biggest.size_kib > smallest.size_kib).then_some(biggest) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GpuVendor { + Amd, + Nvidia, + Intel, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Gpu { + /// `card0` + pub card: String, + pub vendor: GpuVendor, + pub device_id: u16, + /// The kernel driver bound to it (`amdgpu`, `i915`, `xe`, `nvidia`, + /// `nouveau`), if one is. + pub driver: Option, + pub slot: Option, + pub boot_vga: bool, +} + +impl Gpu { + /// Turing (GeForce GTX 16 / RTX 20) and newer: the generations NVIDIA's + /// open kernel modules support (the same rule SegLinux Setup uses). + pub fn nvidia_open_capable(&self) -> bool { + self.vendor == GpuVendor::Nvidia && self.device_id >= 0x1e00 + } +} + +fn parse_hex(text: &str) -> Option { + u16::from_str_radix(text.trim().trim_start_matches("0x"), 16).ok() +} + +/// `DRIVER=` and `PCI_SLOT_NAME=` from a device's uevent file. +pub fn parse_uevent(text: &str) -> (Option, Option) { + let get = |key: &str| { + text.lines() + .find_map(|l| l.strip_prefix(key)?.strip_prefix('=')) + .map(str::to_string) + }; + (get("DRIVER"), get("PCI_SLOT_NAME")) +} + +/// The graphics cards of /sys/class/drm (`card0`, `card1`; not their +/// connectors, `card0-HDMI-A-1`). +pub fn gpus(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root.join("sys/class/drm")) else { + return Vec::new(); + }; + let mut found: Vec = entries + .flatten() + .filter_map(|e| { + let card = e.file_name().into_string().ok()?; + let number = card.strip_prefix("card")?; + if number.is_empty() || !number.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + let dev = e.path().join("device"); + let vendor = match parse_hex(&sys::read_trimmed(dev.join("vendor"))?)? { + 0x1002 => GpuVendor::Amd, + 0x10de => GpuVendor::Nvidia, + 0x8086 => GpuVendor::Intel, + _ => GpuVendor::Other, + }; + let (driver, slot) = + parse_uevent(&sys::read_trimmed(dev.join("uevent")).unwrap_or_default()); + Some(Gpu { + card, + vendor, + device_id: parse_hex(&sys::read_trimmed(dev.join("device"))?)?, + driver, + slot, + boot_vga: sys::read_trimmed(dev.join("boot_vga")).as_deref() == Some("1"), + }) + }) + .collect(); + found.sort_by(|a, b| a.card.cmp(&b.card)); + found +} + +/// The device name `lspci -s SLOT -mm` gives: `1002 "Advanced Micro +/// Devices, Inc. [AMD/ATI]" "Navi 31 [Radeon RX 7900 XT/7900 XTX]"`. +pub fn parse_lspci_name(output: &str) -> Option { + // Machine format: slot, then quoted fields: class, vendor, device, ... + let mut fields = Vec::new(); + let mut rest = output.lines().next()?; + while let Some(start) = rest.find('"') { + let after = rest.get(start + 1..)?; + let end = after.find('"')?; + fields.push(after.get(..end)?.to_string()); + rest = after.get(end + 1..)?; + } + let vendor = fields.get(1)?; + let device = fields.get(2)?; + Some(format!("{vendor} {device}")) +} + +pub fn lspci_name(slot: &str) -> Option { + sys::which("lspci")?; + let out = sys::run("lspci", &["-s", slot, "-mm"]).ok()?; + out.ok.then(|| parse_lspci_name(&out.stdout)).flatten() +} + +/// What each GPU is called, for messages: lspci's name when it can be had. +pub fn describe(gpu: &Gpu) -> String { + let named = gpu.slot.as_deref().and_then(lspci_name); + named.unwrap_or_else(|| { + let vendor = match gpu.vendor { + GpuVendor::Amd => "AMD", + GpuVendor::Nvidia => "NVIDIA", + GpuVendor::Intel => "Intel", + GpuVendor::Other => "unknown vendor", + }; + format!("{vendor} GPU {:04x}", gpu.device_id) + }) +} + +/// `/usr/lib/modules//pkgbase` of the running kernel: `linux-zen`. +pub fn kernel_package(root: &Path) -> Option { + let release = sys::read_trimmed(root.join("proc/sys/kernel/osrelease"))?; + let base = sys::read_trimmed(root.join(format!("usr/lib/modules/{release}/pkgbase")))?; + (!base.is_empty() + && base + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.+".contains(c))) + .then_some(base) +} + +/// `7.2.11-zen1-1-zen` -> (7, 2). +pub fn kernel_version(release: &str) -> Option<(u32, u32)> { + let mut parts = release.trim().split(|c: char| !c.is_ascii_digit()); + Some((parts.next()?.parse().ok()?, parts.next()?.parse().ok()?)) +} + +/// The cpufreq policy directories (one per CPU or group) below `root`. +pub fn cpufreq_dirs(root: &Path) -> Vec { + let Ok(entries) = fs::read_dir(root.join("sys/devices/system/cpu")) else { + return Vec::new(); + }; + let mut dirs: Vec<(usize, PathBuf)> = entries + .flatten() + .filter_map(|e| { + let name = e.file_name().into_string().ok()?; + let n: usize = name.strip_prefix("cpu")?.parse().ok()?; + let dir = e.path().join("cpufreq"); + dir.is_dir().then_some((n, dir)) + }) + .collect(); + dirs.sort(); + dirs.into_iter().map(|(_, d)| d).collect() +} + +#[cfg(test)] +pub fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures") + .join(name) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cpu_lists() { + assert_eq!(parse_cpulist("0-3,8"), Some(vec![0, 1, 2, 3, 8])); + assert_eq!( + parse_cpulist("0-7,16-23\n"), + Some((0..8).chain(16..24).collect()) + ); + assert_eq!(parse_cpulist("5"), Some(vec![5])); + assert_eq!(parse_cpulist("3,1,1"), Some(vec![1, 3])); + for bad in ["", "a", "3-1", "1-", "0-99999", "1,,2"] { + assert_eq!(parse_cpulist(bad), None, "{bad}"); + } + assert_eq!(format_cpulist(&[0, 1, 2, 3, 8]), "0-3,8"); + assert_eq!(format_cpulist(&[4]), "4"); + assert_eq!(format_cpulist(&[]), ""); + let all: Vec = (0..8).chain(16..24).collect(); + assert_eq!(format_cpulist(&all), "0-7,16-23"); + } + + #[test] + fn cache_sizes() { + assert_eq!(parse_cache_size("98304K"), Some(98304)); + assert_eq!(parse_cache_size("32M"), Some(32768)); + assert_eq!(parse_cache_size("512"), Some(512)); + assert_eq!(parse_cache_size("lots"), None); + } + + #[test] + fn cpuinfo() { + let c = cpu(&fixture("ryzen-7950x3d")); + assert_eq!(c.vendor, CpuVendor::Amd); + assert_eq!(c.model, "AMD Ryzen 9 7950X3D 16-Core Processor"); + assert_eq!(cpu(&fixture("intel-12700")).vendor, CpuVendor::Intel); + assert_eq!(cpu(&fixture("vm")).model, "QEMU Virtual CPU version 2.5+"); + assert_eq!(parse_cpuinfo("").vendor, CpuVendor::Other); + } + + #[test] + fn a_dual_ccd_x3d_has_one_vcache_complex() { + let groups = l3_groups(&fixture("ryzen-7950x3d")); + assert_eq!(groups.len(), 2); + let v = vcache_group(&groups).expect("a V-Cache complex"); + assert_eq!(v.size_kib, 98304); + assert_eq!(format_cpulist(&v.cpus), "0-7,16-23"); + } + + #[test] + fn the_cache_decides_not_the_complex_number() { + let groups = l3_groups(&fixture("ryzen-second-ccd-vcache")); + let v = vcache_group(&groups).expect("a V-Cache complex"); + assert_eq!(format_cpulist(&v.cpus), "8-15,24-31"); + } + + #[test] + fn nothing_to_pin_when_the_complexes_are_alike() { + // One complex, all V-Cache (7800X3D). + let groups = l3_groups(&fixture("ryzen-7800x3d")); + assert_eq!(groups.len(), 1); + assert_eq!(vcache_group(&groups), None); + // Two ordinary complexes. + assert_eq!(vcache_group(&l3_groups(&fixture("ryzen-5950x"))), None); + assert_eq!(vcache_group(&l3_groups(&fixture("intel-12700"))), None); + // A virtual machine tells nothing. + assert!(l3_groups(&fixture("vm")).is_empty()); + assert_eq!(vcache_group(&[]), None); + } + + #[test] + fn the_gpu_of_an_amd_desktop() { + let g = gpus(&fixture("gpu-amd")); + assert_eq!(g.len(), 1, "connectors are not cards: {g:?}"); + assert_eq!(g[0].card, "card0"); + assert_eq!(g[0].vendor, GpuVendor::Amd); + assert_eq!(g[0].device_id, 0x744c); + assert_eq!(g[0].driver.as_deref(), Some("amdgpu")); + assert_eq!(g[0].slot.as_deref(), Some("0000:03:00.0")); + assert!(g[0].boot_vga); + } + + #[test] + fn nvidia_generations() { + let ada = &gpus(&fixture("gpu-nvidia-ada"))[0]; + assert!(ada.nvidia_open_capable()); + assert_eq!(ada.driver.as_deref(), Some("nvidia")); + // The same card on the open-source driver, before the proprietary + // one is installed. + assert_eq!( + gpus(&fixture("gpu-nvidia-ada-nouveau"))[0] + .driver + .as_deref(), + Some("nouveau") + ); + let pascal = &gpus(&fixture("gpu-nvidia-pascal"))[0]; + assert_eq!(pascal.vendor, GpuVendor::Nvidia); + assert!(!pascal.nvidia_open_capable()); + assert!(!gpus(&fixture("gpu-amd"))[0].nvidia_open_capable()); + } + + #[test] + fn a_hybrid_laptop_has_two_cards_in_order() { + let g = gpus(&fixture("gpu-hybrid-laptop")); + assert_eq!(g.len(), 2); + assert_eq!( + (g[0].card.as_str(), g[0].vendor, g[0].boot_vga), + ("card0", GpuVendor::Intel, true) + ); + assert_eq!( + (g[1].card.as_str(), g[1].vendor, g[1].boot_vga), + ("card1", GpuVendor::Nvidia, false) + ); + assert!(g[1].nvidia_open_capable()); + assert_eq!( + gpus(&fixture("gpu-intel-arc"))[0].driver.as_deref(), + Some("i915") + ); + } + + #[test] + fn no_gpu_and_no_drm_directory() { + assert!(gpus(&fixture("gpu-none")).is_empty()); + assert!(gpus(&fixture("vm")).is_empty()); + assert!(gpus(Path::new("/definitely/not/here")).is_empty()); + } + + #[test] + fn uevent_and_lspci_text() { + assert_eq!( + parse_uevent("DRIVER=amdgpu\nPCI_SLOT_NAME=0000:03:00.0\n"), + (Some("amdgpu".into()), Some("0000:03:00.0".into())) + ); + assert_eq!(parse_uevent("PCI_CLASS=30000\n"), (None, None)); + let out = "03:00.0 \"VGA compatible controller\" \"Advanced Micro Devices, Inc. [AMD/ATI]\" \"Navi 31 [Radeon RX 7900 XT/7900 XTX]\" -rc8 \"Sapphire\" \"Nitro\"\n"; + assert_eq!( + parse_lspci_name(out).as_deref(), + Some("Advanced Micro Devices, Inc. [AMD/ATI] Navi 31 [Radeon RX 7900 XT/7900 XTX]") + ); + assert_eq!(parse_lspci_name(""), None); + assert_eq!(parse_lspci_name("03:00.0 \"VGA\""), None); + } + + #[test] + fn describe_falls_back_to_the_vendor() { + let mut g = gpus(&fixture("gpu-amd"))[0].clone(); + g.slot = None; + assert_eq!(describe(&g), "AMD GPU 744c"); + } + + #[test] + fn kernel_names_and_versions() { + assert_eq!(kernel_version("7.2.11-zen1-1-zen"), Some((7, 2))); + assert_eq!(kernel_version("6.14.0-arch1-1\n"), Some((6, 14))); + assert_eq!(kernel_version("garbage"), None); + let t = sys::testutil::TempDir::new(); + t.put("proc/sys/kernel/osrelease", "7.2.11-zen1-1-zen\n"); + t.put("usr/lib/modules/7.2.11-zen1-1-zen/pkgbase", "linux-zen\n"); + assert_eq!(kernel_package(t.path()).as_deref(), Some("linux-zen")); + t.put( + "usr/lib/modules/7.2.11-zen1-1-zen/pkgbase", + "linux zen; rm\n", + ); + assert_eq!(kernel_package(t.path()), None); + } + + #[test] + fn cpufreq_policies_are_listed_in_cpu_order() { + let t = sys::testutil::TempDir::new(); + for n in [10, 2, 0] { + t.put( + &format!("sys/devices/system/cpu/cpu{n}/cpufreq/scaling_governor"), + "powersave\n", + ); + } + t.put("sys/devices/system/cpu/cpu1/online", "1\n"); + t.put("sys/devices/system/cpu/cpufreq/boost", "1\n"); + let dirs = cpufreq_dirs(t.path()); + let names: Vec<_> = dirs + .iter() + .map(|d| { + d.parent() + .and_then(|p| p.file_name()) + .unwrap() + .to_string_lossy() + .into_owned() + }) + .collect(); + assert_eq!(names, ["cpu0", "cpu2", "cpu10"]); + } +} diff --git a/tools/seg/segctl/src/game/mod.rs b/tools/seg/segctl/src/game/mod.rs new file mode 100644 index 0000000..12dc687 --- /dev/null +++ b/tools/seg/segctl/src/game/mod.rs @@ -0,0 +1,194 @@ +//! `seg game`: what a game needs from the system, and the commands around it. + +pub mod apply; +pub mod audio; +pub mod boost; +pub mod check; +pub mod dbus; +pub mod hw; +pub mod proc; +pub mod profile; +pub mod run; +pub mod setup; +pub mod toml; +pub mod unsafe_boost; + +use crate::error::{Error, Result}; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game + + run [options] -- COMMAND run a game with the computer set up for it + (Steam launch option: seg game run -- %command%) + check is this computer ready for games? PASS/FAIL with fixes + setup [--yes] the graphics driver packages this computer needs + profiles [FILE] check and list the per-game profiles + apply apply the gaming sysctls and udev rules (seg-game service) + audio on|off|status low-latency PipeWire settings for games + unsafe-boost on|off|status mitigations=off: faster, less safe (read the warning) + +`seg game --help` tells more. +"; + +/// Dispatch `seg game ...`. Returns the exit status. +pub fn run(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let command = match parser.next()? { + None => { + out!("{HELP}"); + return Ok(0); + } + Some(Short('h') | Long("help")) => { + out!("{HELP}"); + return Ok(0); + } + Some(Value(v)) => v.string()?, + Some(other) => return Err(other.unexpected().into()), + }; + match command.as_str() { + "run" => run::run(run::parse_args(parser)?), + "check" => { + match parser.next()? { + None => {} + Some(lexopt::Arg::Short('h') | lexopt::Arg::Long("help")) => { + out!("{}", check::HELP); + return Ok(0); + } + Some(other) => return Err(other.unexpected().into()), + } + check::run() + } + "setup" => setup::run(setup::parse_args(parser)?), + "apply" => { + match parser.next()? { + None => {} + Some(lexopt::Arg::Short('h') | lexopt::Arg::Long("help")) => { + out!("{}", apply::HELP); + return Ok(0); + } + Some(other) => return Err(other.unexpected().into()), + } + apply::run() + } + "audio" => audio::run(audio::parse_args(parser)?), + "unsafe-boost" => unsafe_boost::run(unsafe_boost::parse_args(parser)?), + "boost" => boost_command(parser), + "profiles" => profiles_command(parser), + other => Err(Error::Usage(format!("unknown game command `{other}`"))), + } +} + +/// The root helper `seg game run` calls through sudo: +/// `boost on PID [GOVERNOR]`, `boost off PID`, `boost status`. Nothing the +/// caller says reaches a file name; the governor must be one the CPU lists. +fn boost_command(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut words: Vec = Vec::new(); + while let Some(arg) = parser.next()? { + match arg { + Value(v) => words.push(v.string()?), + Short('h') | Long("help") => { + out!( + "Usage: seg game boost on PID [GOVERNOR] | off PID | status\n\nThe root helper behind `seg game run`; use that instead.\n" + ); + return Ok(0); + } + other => return Err(other.unexpected().into()), + } + } + let pid = |w: Option<&String>| -> Result { + w.and_then(|s| s.parse::().ok()) + .filter(|p| *p > 1) + .ok_or_else(|| Error::Usage("a process id is needed".into())) + }; + let valid_governor = |g: &str| { + !g.is_empty() + && g.len() <= 32 + && g.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') + }; + let words: Vec<&str> = words.iter().map(String::as_str).collect(); + sys::require_root("`seg game boost`")?; + let boost = boost::Boost::new("/"); + let report = match words.as_slice() { + ["on", pid_word] => boost.on(pid(Some(&pid_word.to_string()))?, "performance")?, + ["on", pid_word, governor] if valid_governor(governor) => { + boost.on(pid(Some(&pid_word.to_string()))?, governor)? + } + ["off", pid_word] => boost.off(pid(Some(&pid_word.to_string()))?)?, + ["status"] => { + out!("{}", boost.status()); + return Ok(0); + } + _ => { + return Err(Error::Usage( + "seg game boost on PID [GOVERNOR] | off PID | status".into(), + )); + } + }; + for line in &report.lines { + outln!("{line}"); + } + for w in &report.warnings { + sys::emit_err(format_args!("seg: {w}")); + } + Ok(0) +} + +fn profiles_command(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut file: Option = None; + while let Some(arg) = parser.next()? { + match arg { + Value(v) if file.is_none() => file = Some(v.into()), + Short('h') | Long("help") => { + out!( + "Usage: seg game profiles [FILE]\n\nChecks ~/.config/seg/games.toml (or FILE) and lists what it says.\n" + ); + return Ok(0); + } + other => return Err(other.unexpected().into()), + } + } + let path = file + .or_else(profile::default_path) + .ok_or_else(|| Error::other("cannot tell where the profiles are (no HOME)"))?; + let profiles = profile::load(&path)?; + if profiles.is_empty() { + outln!( + "{}: no profiles (it is read when a game starts)", + path.display() + ); + return Ok(0); + } + outln!( + "{}: {} profile{}", + path.display(), + profiles.len(), + if profiles.len() == 1 { "" } else { "s" } + ); + for p in &profiles { + let governor = match &p.governor { + profile::Governor::Performance => "performance".to_string(), + profile::Governor::Keep => "keep".to_string(), + profile::Governor::Named(n) => n.clone(), + }; + let pin = match &p.pin { + profile::Pin::Off => "off".to_string(), + profile::Pin::VCache => "vcache".to_string(), + profile::Pin::Cpus(c) => hw::format_cpulist(c), + }; + outln!( + " {:<24} governor {governor}, pin {pin}, mangohud {}, gamescope {}", + p.matcher, + if p.mangohud { "on" } else { "off" }, + if p.gamescope.is_empty() { + "off".to_string() + } else { + p.gamescope.join(" ") + } + ); + } + Ok(0) +} diff --git a/tools/seg/segctl/src/game/proc.rs b/tools/seg/segctl/src/game/proc.rs new file mode 100644 index 0000000..8635ae6 --- /dev/null +++ b/tools/seg/segctl/src/game/proc.rs @@ -0,0 +1,280 @@ +//! The few system calls std does not wrap, in one place: waiting for the +//! game while signals arrive, priorities, CPU pinning and file locks. Every +//! `unsafe` block of seg is in this file. + +#![allow(unsafe_code)] + +use std::fs::File; +use std::io; +use std::os::fd::AsRawFd; +use std::sync::atomic::{AtomicI32, Ordering}; +use std::time::{Duration, Instant}; + +/// The game's process id, for the signal handler to forward to. +static CHILD: AtomicI32 = AtomicI32::new(0); +/// The first signal that asked seg to stop (0: none yet). +static SIGNALLED: AtomicI32 = AtomicI32::new(0); + +extern "C" fn on_signal(sig: libc::c_int) { + // Only async-signal-safe work: atomics and kill(2). + let _ = SIGNALLED.compare_exchange(0, sig, Ordering::SeqCst, Ordering::SeqCst); + let child = CHILD.load(Ordering::SeqCst); + // Ctrl+C already reached the game through the terminal; the others + // were sent to seg alone. + if child > 0 && sig != libc::SIGINT { + // SAFETY: kill is async-signal-safe and takes plain integers. + unsafe { libc::kill(child, sig) }; + } +} + +/// Tell the signal handler where to forward signals. +pub fn set_child(pid: u32) { + CHILD.store(pid as i32, Ordering::SeqCst); +} + +/// Make INT, TERM, HUP and QUIT stop the wait (and reach the game) instead +/// of killing seg before it has put the system back. +pub fn install_signal_handlers() -> io::Result<()> { + for sig in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP, libc::SIGQUIT] { + // SAFETY: a zeroed sigaction is valid; the handler is an + // `extern "C" fn(c_int)` that only does async-signal-safe work. + let rc = unsafe { + let mut action: libc::sigaction = std::mem::zeroed(); + action.sa_sigaction = on_signal as extern "C" fn(libc::c_int) as usize; + // No SA_RESTART: waitpid must return so the loop sees the flag. + action.sa_flags = 0; + libc::sigemptyset(&mut action.sa_mask); + libc::sigaction(sig, &action, std::ptr::null_mut()) + }; + if rc != 0 { + return Err(io::Error::last_os_error()); + } + } + Ok(()) +} + +/// Start `command` with the given process settings applied between fork and +/// exec, so the game and everything it starts inherit them. Failures to +/// apply a setting (no right to a negative nice value, say) are ignored: +/// the game should start slower rather than not at all. +pub fn spawn_prepared( + command: &mut std::process::Command, + nice: Option, + io_priority: bool, + cpus: Option>, +) -> io::Result { + use std::os::unix::process::CommandExt; + // SAFETY: the closure runs in the forked child before exec and only makes + // system calls (setpriority, ioprio_set, sched_setaffinity); it does not + // allocate or take locks. + unsafe { + command.pre_exec(move || { + if let Some(n) = nice { + let _ = set_nice(n); + } + if io_priority { + let _ = set_ioprio_best_effort_top(); + } + if let Some(cpus) = &cpus { + let _ = set_affinity(cpus); + } + Ok(()) + }); + } + command.spawn() +} + +pub fn pending_signal() -> Option { + match SIGNALLED.load(Ordering::SeqCst) { + 0 => None, + sig => Some(sig), + } +} + +pub fn send_signal(pid: u32, sig: i32) { + // SAFETY: kill with a process id and a signal number. + unsafe { libc::kill(pid as i32, sig) }; +} + +/// The exit code of a wait status, as a shell reports it: the code, or 128 +/// plus the signal that ended the process. +pub fn exit_code(status: i32) -> i32 { + if libc::WIFEXITED(status) { + libc::WEXITSTATUS(status) + } else if libc::WIFSIGNALED(status) { + 128 + libc::WTERMSIG(status) + } else { + 1 + } +} + +/// Wait for `pid` to end while signals may arrive. After one does, the +/// game gets 5 seconds to leave, then SIGTERM, then at 15 SIGKILL, so seg is +/// never stuck behind a game that ignores it. Returns the exit code. +pub fn wait_for(pid: u32) -> io::Result { + let mut stopping: Option = None; + let mut terminated = false; + loop { + if stopping.is_none() && pending_signal().is_some() { + stopping = Some(Instant::now()); + } + let flags = if stopping.is_some() { libc::WNOHANG } else { 0 }; + let mut status = 0; + // SAFETY: waitpid writes one int through a valid pointer. + let rc = unsafe { libc::waitpid(pid as i32, &mut status, flags) }; + if rc == pid as i32 { + return Ok(exit_code(status)); + } + if rc == -1 { + let e = io::Error::last_os_error(); + match e.raw_os_error() { + Some(libc::EINTR) => continue, + // Somebody else reaped it: nothing left to wait for. + Some(libc::ECHILD) => return Ok(0), + _ => return Err(e), + } + } + if let Some(since) = stopping { + let waited = since.elapsed(); + if waited > Duration::from_secs(5) && !terminated { + send_signal(pid, libc::SIGTERM); + terminated = true; + } + if waited > Duration::from_secs(15) { + send_signal(pid, libc::SIGKILL); + } + std::thread::sleep(Duration::from_millis(100)); + } + } +} + +/// Ask for the nice value `nice` for this process (and so for what it starts). +/// Negative values need the `nice` limit pam_limits gives the gamemode group. +pub fn set_nice(nice: i32) -> io::Result<()> { + // SAFETY: setpriority takes plain integers. + let rc = unsafe { libc::setpriority(libc::PRIO_PROCESS, 0, nice) }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +/// The highest priority within the best-effort I/O class. +pub fn set_ioprio_best_effort_top() -> io::Result<()> { + const IOPRIO_WHO_PROCESS: libc::c_long = 1; + const IOPRIO_CLASS_BE: libc::c_long = 2; + const IOPRIO_CLASS_SHIFT: libc::c_long = 13; + // SAFETY: ioprio_set(2) takes three plain integers. + let rc = unsafe { + libc::syscall( + libc::SYS_ioprio_set, + IOPRIO_WHO_PROCESS, + 0 as libc::c_long, + IOPRIO_CLASS_BE << IOPRIO_CLASS_SHIFT, + ) + }; + if rc == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } +} + +/// Restrict this process (and what it starts) to `cpus`. +pub fn set_affinity(cpus: &[usize]) -> io::Result<()> { + // SAFETY: cpu_set_t is plain data; CPU_SET only sets bits inside it and + // is skipped for numbers it cannot hold. + unsafe { + let mut set: libc::cpu_set_t = std::mem::zeroed(); + libc::CPU_ZERO(&mut set); + let capacity = 8 * std::mem::size_of::(); + let mut any = false; + for &cpu in cpus { + if cpu < capacity { + libc::CPU_SET(cpu, &mut set); + any = true; + } + } + if !any { + return Err(io::Error::from(io::ErrorKind::InvalidInput)); + } + if libc::sched_setaffinity(0, std::mem::size_of::(), &set) == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } +} + +/// An exclusive lock on `file`, released when it is closed. +pub fn lock_exclusive(file: &File) -> io::Result<()> { + loop { + // SAFETY: flock on a file descriptor that stays open for the call. + let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) }; + if rc == 0 { + return Ok(()); + } + let e = io::Error::last_os_error(); + if e.raw_os_error() != Some(libc::EINTR) { + return Err(e); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command; + + #[test] + fn wait_status_becomes_a_shell_exit_code() { + let exited = |code: i32| code << 8; + assert_eq!(exit_code(exited(0)), 0); + assert_eq!(exit_code(exited(3)), 3); + // Killed by SIGKILL (9). + assert_eq!(exit_code(9), 137); + // Killed by SIGTERM (15). + assert_eq!(exit_code(15), 143); + } + + // wait_for reaps the children itself (waitpid), which clippy cannot see. + #[test] + #[allow(clippy::zombie_processes)] + fn waiting_returns_the_childs_exit_code() { + let child = Command::new("sh").args(["-c", "exit 7"]).spawn().unwrap(); + assert_eq!(wait_for(child.id()).unwrap(), 7); + let child = Command::new("sh") + .args(["-c", "kill -9 $$"]) + .spawn() + .unwrap(); + assert_eq!(wait_for(child.id()).unwrap(), 137); + } + + #[test] + fn a_prepared_child_gets_its_nice_value_and_cpus() { + let mut cmd = std::process::Command::new("sh"); + // nice can always be raised; the child reports its own values. + cmd.args(["-c", "ps -o ni= -p $$ | tr -d ' ' >&2; exit 3"]) + .stderr(std::process::Stdio::piped()); + let child = spawn_prepared(&mut cmd, Some(5), true, Some(vec![0])).unwrap(); + let out = child.wait_with_output().unwrap(); + assert_eq!(out.status.code(), Some(3)); + assert_eq!(String::from_utf8_lossy(&out.stderr).trim(), "5"); + } + + #[test] + fn affinity_needs_at_least_one_cpu() { + assert!(set_affinity(&[]).is_err()); + assert!(set_affinity(&[1_000_000]).is_err()); + } + + #[test] + fn locks_can_be_taken_twice_in_one_process() { + // flock is per open file description: a second open blocks, but the + // same description may lock again. + let f = File::open("/proc/self/status").unwrap(); + lock_exclusive(&f).unwrap(); + lock_exclusive(&f).unwrap(); + } +} diff --git a/tools/seg/segctl/src/game/profile.rs b/tools/seg/segctl/src/game/profile.rs new file mode 100644 index 0000000..1dfca61 --- /dev/null +++ b/tools/seg/segctl/src/game/profile.rs @@ -0,0 +1,411 @@ +//! Per-game profiles: ~/.config/seg/games.toml, one `[[game]]` per program, +//! matched by the name of the executable. +//! +//! ```toml +//! [[game]] +//! match = "eldenring.exe" # glob; compared with the file names in the command +//! governor = "performance" # or "keep" to leave the CPU alone, or another governor +//! pin = "vcache" # "vcache", "off" or a CPU list such as "0-7,16-23" +//! mangohud = true +//! gamescope = ["-W", "2560", "-H", "1440", "-r", "165", "--adaptive-sync"] +//! ``` + +use std::fs; +use std::path::{Path, PathBuf}; + +use super::hw::parse_cpulist; +use super::toml::{self, Table, Value}; +use crate::error::{Error, Result}; + +/// What the CPU governor does while the game runs. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Governor { + /// `performance`. + #[default] + Performance, + /// Leave it as it is: no boost for this game. + Keep, + Named(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum Pin { + #[default] + Off, + /// The complex with the biggest L3 cache, on an X3D part with two. + VCache, + Cpus(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Profile { + pub matcher: String, + pub governor: Governor, + pub pin: Pin, + pub mangohud: bool, + /// A MangoHud config file; `preset` means the one SegLinux ships. + pub mangohud_config: Option, + pub gamescope: Vec, + pub nice: Option, + /// Line of the `[[game]]` header, for messages. + pub line: usize, +} + +pub const KNOWN_KEYS: [&str; 7] = [ + "match", + "governor", + "pin", + "mangohud", + "mangohud_config", + "gamescope", + "nice", +]; + +pub fn parse_pin(text: &str) -> std::result::Result { + match text.trim() { + "off" | "none" | "" => Ok(Pin::Off), + "vcache" | "v-cache" | "auto" => Ok(Pin::VCache), + list => parse_cpulist(list).map(Pin::Cpus).ok_or_else(|| { + format!("`{list}` is not `vcache`, `off` or a CPU list such as 0-7,16-23") + }), + } +} + +fn valid_governor(name: &str) -> bool { + !name.is_empty() + && name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-') +} + +fn one(table: &Table) -> std::result::Result { + for (key, _, line) in &table.entries { + if !KNOWN_KEYS.contains(&key.as_str()) { + return Err(format!( + "line {line}: unknown setting `{key}` (known: {})", + KNOWN_KEYS.join(", ") + )); + } + } + let text = |key: &str| -> std::result::Result, String> { + match table.get(key) { + None => Ok(None), + Some(Value::Str(s)) => Ok(Some(s.clone())), + Some(_) => Err(format!( + "`{key}` must be a string (table at line {})", + table.line + )), + } + }; + let matcher = text("match")? + .filter(|m| !m.trim().is_empty()) + .ok_or_else(|| format!("the [[game]] at line {} has no `match`", table.line))?; + let governor = match text("governor")?.as_deref() { + None | Some("performance") => Governor::Performance, + Some("keep") => Governor::Keep, + Some(name) if valid_governor(name) => Governor::Named(name.to_string()), + Some(other) => { + return Err(format!( + "`{other}` is not a governor (table at line {})", + table.line + )); + } + }; + let pin = match text("pin")? { + None => Pin::Off, + Some(p) => parse_pin(&p).map_err(|e| format!("pin: {e} (table at line {})", table.line))?, + }; + let mangohud = match table.get("mangohud") { + None => false, + Some(Value::Bool(b)) => *b, + Some(_) => { + return Err(format!( + "`mangohud` must be true or false (table at line {})", + table.line + )); + } + }; + let gamescope = match table.get("gamescope") { + None => Vec::new(), + Some(Value::Array(items)) => items + .iter() + .map(|v| match v { + Value::Str(s) => Ok(s.clone()), + Value::Int(n) => Ok(n.to_string()), + _ => Err(format!( + "`gamescope` must list strings (table at line {})", + table.line + )), + }) + .collect::>()?, + Some(_) => { + return Err(format!( + "`gamescope` must be a list of strings (table at line {})", + table.line + )); + } + }; + let nice = match table.get("nice") { + None => None, + Some(Value::Int(n)) if (-20..=19).contains(n) => Some(*n as i32), + Some(_) => { + return Err(format!( + "`nice` must be a number from -20 to 19 (table at line {})", + table.line + )); + } + }; + Ok(Profile { + matcher, + governor, + pin, + mangohud, + mangohud_config: text("mangohud_config")?, + gamescope, + nice, + line: table.line, + }) +} + +pub fn parse(text: &str) -> std::result::Result, String> { + let tables = toml::parse(text).map_err(|e| e.to_string())?; + tables + .iter() + .map(|t| { + if t.name == "game" { + one(t) + } else { + Err(format!( + "line {}: unknown table [[{}]] (only [[game]])", + t.line, t.name + )) + } + }) + .collect() +} + +/// Where the profiles are: `$XDG_CONFIG_HOME/seg/games.toml`, else +/// `~/.config/seg/games.toml` of the user who ran `sudo`, if any. +pub fn default_path() -> Option { + let base = match std::env::var_os("XDG_CONFIG_HOME").filter(|v| !v.is_empty()) { + Some(dir) => PathBuf::from(dir), + None => crate::sys::invoking_home()?.join(".config"), + }; + Some(base.join("seg/games.toml")) +} + +/// The profiles of a file; a missing file has none. +pub fn load(path: &Path) -> Result> { + let text = match fs::read_to_string(path) { + Ok(t) => t, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(Error::io(format!("reading {}", path.display()), e)), + }; + parse(&text).map_err(|e| Error::other(format!("{}: {e}", path.display()))) +} + +/// `*` and `?` globbing, without regard to case (Windows programs are +/// written both ways). +pub fn glob(pattern: &str, name: &str) -> bool { + let p: Vec = pattern.to_lowercase().chars().collect(); + let n: Vec = name.to_lowercase().chars().collect(); + let (mut pi, mut ni) = (0, 0); + let mut star: Option<(usize, usize)> = None; + while ni < n.len() { + match p.get(pi) { + Some('*') => { + star = Some((pi, ni)); + pi += 1; + } + Some(&c) if c == '?' || Some(&c) == n.get(ni) => { + pi += 1; + ni += 1; + } + _ => match star { + Some((sp, sn)) => { + pi = sp + 1; + ni = sn + 1; + star = Some((sp, sn + 1)); + } + None => return false, + }, + } + } + while p.get(pi) == Some(&'*') { + pi += 1; + } + pi == p.len() +} + +/// The last component of a path, or of a Windows path. +pub fn file_name(arg: &str) -> &str { + arg.rsplit(['/', '\\']).next().unwrap_or(arg) +} + +/// The first profile whose `match` fits the name of any program in the +/// command: `seg game run -- %command%` becomes a Proton command line +/// (`.../proton waitforexitandrun /games/Game/game.exe`), so every argument +/// counts, not just the first. +pub fn find<'a>(profiles: &'a [Profile], command: &[String]) -> Option<&'a Profile> { + profiles.iter().find(|p| { + command + .iter() + .any(|arg| glob(&p.matcher, file_name(arg)) || glob(&p.matcher, arg)) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cmd(args: &[&str]) -> Vec { + args.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn a_full_profile() { + let p = parse( + r#" +[[game]] +match = "eldenring.exe" +governor = "performance" +pin = "vcache" +mangohud = true +mangohud_config = "preset" +gamescope = ["-W", "2560", "-H", 1440, "--adaptive-sync"] +nice = -8 +"#, + ) + .unwrap(); + assert_eq!(p.len(), 1); + assert_eq!(p[0].matcher, "eldenring.exe"); + assert_eq!(p[0].governor, Governor::Performance); + assert_eq!(p[0].pin, Pin::VCache); + assert!(p[0].mangohud); + assert_eq!(p[0].mangohud_config.as_deref(), Some("preset")); + assert_eq!( + p[0].gamescope, + ["-W", "2560", "-H", "1440", "--adaptive-sync"] + ); + assert_eq!(p[0].nice, Some(-8)); + assert_eq!(p[0].line, 2); + } + + #[test] + fn defaults_are_a_plain_boost() { + let p = parse("[[game]]\nmatch = \"x\"\n").unwrap(); + assert_eq!(p[0].governor, Governor::Performance); + assert_eq!(p[0].pin, Pin::Off); + assert!(!p[0].mangohud); + assert!(p[0].gamescope.is_empty()); + assert_eq!(p[0].nice, None); + } + + #[test] + fn governors_and_pins() { + let p = parse("[[game]]\nmatch=\"a\"\ngovernor=\"keep\"\npin=\"0-7,16-23\"\n[[game]]\nmatch=\"b\"\ngovernor=\"schedutil\"\npin=\"off\"\n").unwrap(); + assert_eq!(p[0].governor, Governor::Keep); + assert_eq!(p[0].pin, Pin::Cpus((0..8).chain(16..24).collect())); + assert_eq!(p[1].governor, Governor::Named("schedutil".into())); + assert_eq!(p[1].pin, Pin::Off); + assert_eq!(parse_pin("auto"), Ok(Pin::VCache)); + assert!(parse_pin("3-1").is_err()); + } + + #[test] + fn mistakes_are_reported_not_ignored() { + let cases = [ + ("[[game]]\ngovernor = \"keep\"\n", "no `match`"), + ("[[game]]\nmatch = \"\"\n", "no `match`"), + ( + "[[game]]\nmatch = \"x\"\ngovenor = \"keep\"\n", + "unknown setting `govenor`", + ), + ( + "[[game]]\nmatch = \"x\"\nmangohud = \"yes\"\n", + "must be true or false", + ), + ("[[game]]\nmatch = \"x\"\nnice = 40\n", "from -20 to 19"), + ( + "[[game]]\nmatch = \"x\"\ngovernor = \"Per formance\"\n", + "is not a governor", + ), + ("[[game]]\nmatch = \"x\"\npin = \"left\"\n", "pin:"), + ( + "[[game]]\nmatch = \"x\"\ngamescope = \"-W 100\"\n", + "list of strings", + ), + ("[[other]]\nmatch = \"x\"\n", "unknown table [[other]]"), + ("[[game]]\nmatch = x\n", "line 2"), + ]; + for (doc, fragment) in cases { + let e = parse(doc).unwrap_err(); + assert!(e.contains(fragment), "{doc:?} -> {e}"); + } + } + + #[test] + fn globs() { + for (pat, name, want) in [ + ("eldenring.exe", "EldenRing.exe", true), + ("*.exe", "game.EXE", true), + ("cs2*", "cs2", true), + ("cs2*", "cs2.sh", true), + ("g?me", "game", true), + ("g?me", "gme", false), + ("*launcher*", "My Launcher v2.exe", true), + ("a*b*c", "aXXbYYc", true), + ("a*b*c", "aXXbYY", false), + ("*", "", true), + ("", "", true), + ("x", "", false), + ] { + assert_eq!(glob(pat, name), want, "{pat} vs {name}"); + } + } + + #[test] + fn a_proton_command_line_matches_its_exe() { + let profiles = + parse("[[game]]\nmatch = \"eldenring.exe\"\n[[game]]\nmatch = \"cs2\"\nnice = 5\n") + .unwrap(); + let proton = cmd(&[ + "/home/u/.steam/root/compatibilitytools.d/GE-Proton/proton", + "waitforexitandrun", + "/games/ELDEN RING/Game/eldenring.exe", + ]); + assert_eq!(find(&profiles, &proton).map(|p| p.line), Some(1)); + // A Windows-style path (an installer script passes one) too. + assert!( + find( + &profiles, + &cmd(&["wine", "C:\\Games\\EldenRing\\eldenring.exe"]) + ) + .is_some() + ); + let native = cmd(&[ + "/home/u/.steam/steamapps/common/CS2/game/bin/linuxsteamrt64/cs2", + "-novid", + ]); + assert_eq!(find(&profiles, &native).and_then(|p| p.nice), Some(5)); + assert!(find(&profiles, &cmd(&["glxgears"])).is_none()); + assert!(find(&[], &native).is_none()); + } + + #[test] + fn the_first_matching_profile_wins() { + let p = + parse("[[game]]\nmatch = \"*.exe\"\nnice = 1\n[[game]]\nmatch = \"a.exe\"\nnice = 2\n") + .unwrap(); + assert_eq!(find(&p, &cmd(&["a.exe"])).and_then(|x| x.nice), Some(1)); + } + + #[test] + fn a_missing_file_has_no_profiles_and_a_broken_one_names_itself() { + let t = crate::sys::testutil::TempDir::new(); + assert_eq!(load(&t.path().join("none.toml")).unwrap(), vec![]); + let broken = t.put("games.toml", "[[game]]\nmatch = oops\n"); + let e = load(&broken).unwrap_err().to_string(); + assert!(e.contains("games.toml: line 2"), "{e}"); + } +} diff --git a/tools/seg/segctl/src/game/run.rs b/tools/seg/segctl/src/game/run.rs new file mode 100644 index 0000000..821cb4c --- /dev/null +++ b/tools/seg/segctl/src/game/run.rs @@ -0,0 +1,607 @@ +//! `seg game run -- COMMAND`: a lightweight GameMode for the length of one +//! game. Steam launch option: `seg game run -- %command%`. +//! +//! While the game runs: CPU governor and energy preference `performance`, +//! an AMD GPU at its high power level (through the root helper, see +//! boost.rs), a better CPU and I/O priority for the game, the screen saver +//! held off, and, if asked, the game on the V-Cache cores of an X3D CPU. +//! When it ends, or seg is interrupted, or seg itself fails, every setting +//! goes back. Nothing is left changed. + +use std::path::PathBuf; +use std::process::Command; +use std::sync::atomic::{AtomicBool, Ordering}; + +use super::boost::Boost; +use super::dbus; +use super::hw; +use super::proc; +use super::profile::{self, Governor, Pin, Profile}; +use crate::error::{Error, Result}; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game run [options] -- COMMAND [ARGS...] + +Runs COMMAND with the computer set up for a game, and puts everything back +when it ends (also on Ctrl+C, SIGTERM and a crash of seg). In Steam, set the +game's launch options to: seg game run -- %command% + +While the game runs: + - CPU governor and energy preference: performance; an AMD GPU: high power + level (needs the gamemode group, or root) + - the game's CPU priority is raised a little and its I/O priority is the + best of its class + - the screen saver and idle blanking are held off (session bus) + - with --pin vcache, or pin = \"vcache\" in the game's profile: only the + cores of the V-Cache complex of an X3D CPU with two + +Options: + --no-boost do not change the governor or the GPU + --pin WHERE vcache, off, or a CPU list such as 0-7,16-23 + --nice N CPU priority of the game, -20..19 (default -4) + --profiles FILE game profiles (default ~/.config/seg/games.toml) + --dry-run show what would be done and stop +"; + +const DEFAULT_NICE: i32 = -4; +const SEG: &str = "/usr/bin/seg"; + +#[derive(Debug, Default)] +pub struct Options { + pub no_boost: bool, + pub pin: Option, + pub nice: Option, + pub profiles: Option, + pub dry_run: bool, + pub command: Vec, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut o = Options::default(); + while let Some(arg) = parser.next()? { + match arg { + Long("no-boost") => o.no_boost = true, + Long("dry-run") => o.dry_run = true, + Long("pin") => { + o.pin = Some(profile::parse_pin(&parser.value()?.string()?).map_err(Error::Usage)?); + } + Long("nice") => { + let n: i32 = parser.value()?.parse()?; + if !(-20..=19).contains(&n) { + return Err(Error::Usage("--nice must be from -20 to 19".into())); + } + o.nice = Some(n); + } + Long("profiles") => o.profiles = Some(parser.value()?.into()), + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + // Everything after `--` is the command, whatever it looks like. + Value(first) => { + o.command.push(first.string()?); + o.command + .extend(parser.raw_args()?.map(|a| a.to_string_lossy().into_owned())); + break; + } + other => return Err(other.unexpected().into()), + } + } + if o.command.is_empty() { + return Err(Error::Usage( + "seg game run needs a command: seg game run -- COMMAND".into(), + )); + } + Ok(o) +} + +/// What the machine offers, for planning. +#[derive(Debug, Default, Clone)] +pub struct Machine { + /// CPUs of the V-Cache complex, when there is one to choose. + pub vcache: Option>, + pub mangohud: bool, + pub gamescope: bool, +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Plan { + /// The governor for the boost; None: no boost. + pub governor: Option, + pub cpus: Option>, + pub nice: i32, + /// The command to start, with gamescope and MangoHud wrapped around it. + pub argv: Vec, + /// Environment the game gets unless it is already set. + pub env: Vec<(String, String)>, + pub notes: Vec, +} + +pub const PRESET: &str = "/usr/share/seg/mangohud-gaming.conf"; + +/// Decide what to do. `is_set(name)` tells whether the user already set an +/// environment variable (theirs wins). +pub fn plan( + o: &Options, + profile: Option<&Profile>, + machine: &Machine, + is_set: &dyn Fn(&str) -> bool, +) -> Plan { + let mut notes = Vec::new(); + let governor = if o.no_boost { + None + } else { + match profile.map(|p| &p.governor) { + None | Some(Governor::Performance) => Some("performance".to_string()), + Some(Governor::Keep) => None, + Some(Governor::Named(n)) => Some(n.clone()), + } + }; + let wanted_pin = o + .pin + .clone() + .or_else(|| profile.map(|p| p.pin.clone())) + .unwrap_or_default(); + let cpus = match wanted_pin { + Pin::Off => None, + Pin::Cpus(list) => Some(list), + Pin::VCache => match &machine.vcache { + Some(cpus) => Some(cpus.clone()), + None => { + notes.push( + "no V-Cache complex to pin to (not an X3D CPU with two); not pinning".into(), + ); + None + } + }, + }; + let mut argv = o.command.clone(); + let mut env: Vec<(String, String)> = Vec::new(); + if let Some(p) = profile { + if p.mangohud { + if machine.mangohud { + argv.insert(0, "mangohud".into()); + let config = match p.mangohud_config.as_deref() { + Some("preset") => Some(PRESET.to_string()), + Some(path) => Some(path.to_string()), + None => None, + }; + if let Some(config) = config + && !is_set("MANGOHUD_CONFIGFILE") + { + env.push(("MANGOHUD_CONFIGFILE".into(), config)); + } + } else { + notes.push("MangoHud is not installed; running without it".into()); + } + } + if !p.gamescope.is_empty() { + if machine.gamescope { + let mut wrapped = vec!["gamescope".to_string()]; + wrapped.extend(p.gamescope.iter().cloned()); + wrapped.push("--".into()); + wrapped.extend(argv); + argv = wrapped; + } else { + notes.push("gamescope is not installed; running without it".into()); + } + } + } + // Shader caches: room for the compiled shaders of many games (the 1 GB + // defaults make games recompile them and stutter). + for (name, value) in [ + ("MESA_SHADER_CACHE_MAX_SIZE", "10G"), + ("__GL_SHADER_DISK_CACHE_SIZE", "10737418240"), + ("__GL_SHADER_DISK_CACHE_SKIP_CLEANUP", "1"), + ] { + if !is_set(name) { + env.push((name.into(), value.into())); + } + } + Plan { + governor, + cpus, + nice: o + .nice + .or(profile.and_then(|p| p.nice)) + .unwrap_or(DEFAULT_NICE), + argv, + env, + notes, + } +} + +/// The root helper, as seen from the process that asks for the boost. +static BOOST_HELD: AtomicBool = AtomicBool::new(false); + +fn as_root() -> bool { + sys::euid().is_ok_and(|u| u == 0) +} + +/// Switch the boost on for this process. Failure is a warning: the game runs. +fn boost_on(governor: &str) { + let pid = std::process::id(); + let failure: Option = if as_root() { + match Boost::new("/").on(pid, governor) { + Ok(report) => report.warnings.into_iter().next(), + Err(e) => Some(e.to_string()), + } + } else { + match sys::run( + "sudo", + &["-n", SEG, "game", "boost", "on", &pid.to_string(), governor], + ) { + Ok(out) if out.ok => None, + Ok(out) => Some(out.stderr.trim().to_string()), + Err(e) => Some(e.to_string()), + } + }; + match failure { + None => BOOST_HELD.store(true, Ordering::SeqCst), + Some(why) => { + // A partial boost is still a boost to give back. + BOOST_HELD.store(true, Ordering::SeqCst); + sys::emit_err(format_args!( + "seg: the performance boost is not (fully) on: {}\nseg: run `seg game check` for the fix", + if why.is_empty() { + "the helper failed" + } else { + &why + } + )); + } + } +} + +/// Put everything back. Safe to call twice, and from the panic hook. +pub fn boost_off() { + if !BOOST_HELD.swap(false, Ordering::SeqCst) { + return; + } + let pid = std::process::id(); + if as_root() { + let _ = Boost::new("/").off(pid); + } else { + let _ = sys::run( + "sudo", + &["-n", SEG, "game", "boost", "off", &pid.to_string()], + ); + } +} + +/// Puts the system back when it goes out of scope. +struct Session { + _idle: Option, +} + +impl Drop for Session { + fn drop(&mut self) { + boost_off(); + } +} + +/// Can `program` be started? A path must be a file you may run; a bare name +/// must be found in PATH. +fn program_exists(program: &str) -> bool { + use std::os::unix::fs::PermissionsExt; + if program.contains('/') { + std::fs::metadata(program).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0) + } else { + sys::which(program).is_some() + } +} + +fn describe(plan: &Plan, profile: Option<&Profile>) -> String { + let mut text = String::new(); + match profile { + Some(p) => text.push_str(&format!( + "profile: match \"{}\" (line {})\n", + p.matcher, p.line + )), + None => text.push_str("profile: none matched\n"), + } + text.push_str(&format!( + "boost: {}\n", + plan.governor + .as_deref() + .map_or("off".to_string(), |g| format!( + "governor {g}, energy preference and GPU level" + )) + )); + text.push_str(&format!( + "pin: {}\n", + plan.cpus + .as_deref() + .map_or("no".to_string(), hw::format_cpulist) + )); + text.push_str(&format!("nice: {}\n", plan.nice)); + for (k, v) in &plan.env { + text.push_str(&format!("env: {k}={v}\n")); + } + text.push_str(&format!("command: {}\n", plan.argv.join(" "))); + for n in &plan.notes { + text.push_str(&format!("note: {n}\n")); + } + text +} + +pub fn run(o: Options) -> Result { + let profiles = match o.profiles.clone().or_else(profile::default_path) { + Some(path) => profile::load(&path).unwrap_or_else(|e| { + // A typo in games.toml must not keep anyone from playing. + sys::emit_err(format_args!("seg: {e}\nseg: playing without profiles")); + Vec::new() + }), + None => Vec::new(), + }; + let found = profile::find(&profiles, &o.command); + let machine = Machine { + vcache: hw::vcache_group(&hw::l3_groups(std::path::Path::new("/"))).map(|g| g.cpus.clone()), + mangohud: sys::which("mangohud").is_some(), + gamescope: sys::which("gamescope").is_some(), + }; + let plan = plan(&o, found, &machine, &|name| { + std::env::var_os(name).is_some() + }); + if o.dry_run { + out!("{}", describe(&plan, found)); + return Ok(0); + } + for n in &plan.notes { + sys::emit_err(format_args!("seg: {n}")); + } + // A program that is not there fails before anything is changed, with the + // status a shell gives. + if let Some(program) = plan.argv.first() + && !program_exists(program) + { + sys::emit_err(format_args!("seg: `{program}`: command not found")); + return Ok(127); + } + + // From here on, everything changed is changed back: when the game ends, + // on a signal (the wait returns) and on a crash of seg (the hook). + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + boost_off(); + previous(info); + })); + if let Err(e) = proc::install_signal_handlers() { + sys::emit_err(format_args!( + "seg: cannot catch signals ({e}); Ctrl+C may leave the boost on" + )); + } + let session = Session { + _idle: dbus::inhibit_screensaver("seg", "playing a game").ok(), + }; + if let Some(governor) = &plan.governor { + boost_on(governor); + } + + let Some((program, args)) = plan.argv.split_first() else { + return Err(Error::Usage("seg game run needs a command".into())); + }; + let mut command = Command::new(program); + command.args(args); + for (k, v) in &plan.env { + command.env(k, v); + } + let child = proc::spawn_prepared(&mut command, Some(plan.nice), true, plan.cpus.clone()); + let code = match child { + Ok(child) => { + proc::set_child(child.id()); + proc::wait_for(child.id()).unwrap_or(1) + } + Err(e) => { + drop(session); + return Err(Error::Command { + program: program.clone(), + detail: format!("cannot start it: {e}"), + }); + } + }; + drop(session); + Ok((code & 0xff) as u8) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::profile::parse; + + fn opts(cmd: &[&str]) -> Options { + Options { + command: cmd.iter().map(|s| s.to_string()).collect(), + ..Options::default() + } + } + + fn machine() -> Machine { + Machine { + vcache: Some(vec![0, 1, 2, 3]), + mangohud: true, + gamescope: true, + } + } + + fn none_set(_: &str) -> bool { + false + } + + #[test] + fn a_plain_game_gets_the_boost_and_shader_caches() { + let p = plan( + &opts(&["./game", "-fullscreen"]), + None, + &machine(), + &none_set, + ); + assert_eq!(p.governor.as_deref(), Some("performance")); + assert_eq!(p.cpus, None); + assert_eq!(p.nice, -4); + assert_eq!(p.argv, ["./game", "-fullscreen"]); + let env: Vec<_> = p.env.iter().map(|(k, _)| k.as_str()).collect(); + assert_eq!( + env, + [ + "MESA_SHADER_CACHE_MAX_SIZE", + "__GL_SHADER_DISK_CACHE_SIZE", + "__GL_SHADER_DISK_CACHE_SKIP_CLEANUP" + ] + ); + assert!(p.notes.is_empty()); + } + + #[test] + fn what_the_user_set_is_not_overridden() { + let set = |n: &str| n == "MESA_SHADER_CACHE_MAX_SIZE"; + let p = plan(&opts(&["g"]), None, &machine(), &set); + assert!(!p.env.iter().any(|(k, _)| k == "MESA_SHADER_CACHE_MAX_SIZE")); + assert!( + p.env + .iter() + .any(|(k, _)| k == "__GL_SHADER_DISK_CACHE_SIZE") + ); + } + + #[test] + fn no_boost_and_keep_leave_the_governor_alone() { + let mut o = opts(&["g"]); + o.no_boost = true; + assert_eq!(plan(&o, None, &machine(), &none_set).governor, None); + let profiles = parse("[[game]]\nmatch = \"g\"\ngovernor = \"keep\"\n").unwrap(); + assert_eq!( + plan(&opts(&["g"]), profiles.first(), &machine(), &none_set).governor, + None + ); + let profiles = parse("[[game]]\nmatch = \"g\"\ngovernor = \"schedutil\"\n").unwrap(); + assert_eq!( + plan(&opts(&["g"]), profiles.first(), &machine(), &none_set) + .governor + .as_deref(), + Some("schedutil") + ); + } + + #[test] + fn pinning_to_the_vcache_complex() { + let profiles = parse("[[game]]\nmatch = \"g\"\npin = \"vcache\"\n").unwrap(); + let p = plan(&opts(&["g"]), profiles.first(), &machine(), &none_set); + assert_eq!(p.cpus, Some(vec![0, 1, 2, 3])); + // No such complex: a note, no pin. + let none = Machine { + vcache: None, + ..machine() + }; + let p = plan(&opts(&["g"]), profiles.first(), &none, &none_set); + assert_eq!(p.cpus, None); + assert!(p.notes[0].contains("no V-Cache"), "{:?}", p.notes); + // The command line beats the profile. + let mut o = opts(&["g"]); + o.pin = Some(Pin::Off); + assert_eq!(plan(&o, profiles.first(), &machine(), &none_set).cpus, None); + o.pin = Some(Pin::Cpus(vec![8, 9])); + assert_eq!(plan(&o, None, &machine(), &none_set).cpus, Some(vec![8, 9])); + } + + #[test] + fn mangohud_and_gamescope_wrap_the_game() { + let profiles = parse("[[game]]\nmatch = \"g\"\nmangohud = true\nmangohud_config = \"preset\"\ngamescope = [\"-W\", \"2560\", \"--adaptive-sync\"]\n").unwrap(); + let p = plan(&opts(&["g", "-x"]), profiles.first(), &machine(), &none_set); + assert_eq!( + p.argv, + [ + "gamescope", + "-W", + "2560", + "--adaptive-sync", + "--", + "mangohud", + "g", + "-x" + ] + ); + assert!( + p.env + .contains(&("MANGOHUD_CONFIGFILE".into(), PRESET.into())) + ); + // Missing tools are said, not fatal. + let bare = Machine { + mangohud: false, + gamescope: false, + ..machine() + }; + let p = plan(&opts(&["g"]), profiles.first(), &bare, &none_set); + assert_eq!(p.argv, ["g"]); + assert_eq!(p.notes.len(), 2); + // The user's own MangoHud config wins. + let set = |n: &str| n == "MANGOHUD_CONFIGFILE"; + let p = plan(&opts(&["g"]), profiles.first(), &machine(), &set); + assert!(!p.env.iter().any(|(k, _)| k == "MANGOHUD_CONFIGFILE")); + } + + #[test] + fn nice_comes_from_the_flag_then_the_profile_then_the_default() { + let profiles = parse("[[game]]\nmatch = \"g\"\nnice = -9\n").unwrap(); + assert_eq!( + plan(&opts(&["g"]), profiles.first(), &machine(), &none_set).nice, + -9 + ); + let mut o = opts(&["g"]); + o.nice = Some(2); + assert_eq!(plan(&o, profiles.first(), &machine(), &none_set).nice, 2); + assert_eq!(plan(&opts(&["g"]), None, &machine(), &none_set).nice, -4); + } + + #[test] + fn the_command_after_dashes_is_taken_whole() { + let mut p = + lexopt::Parser::from_args(["--no-boost", "--", "proton", "run", "--weird", "-x"]); + let o = parse_args(&mut p).unwrap(); + assert!(o.no_boost); + assert_eq!(o.command, ["proton", "run", "--weird", "-x"]); + let mut p = lexopt::Parser::from_args(["--pin", "0-3", "game"]); + let o = parse_args(&mut p).unwrap(); + assert_eq!(o.pin, Some(Pin::Cpus(vec![0, 1, 2, 3]))); + assert_eq!(o.command, ["game"]); + } + + #[test] + fn usage_mistakes() { + for args in [ + vec![], + vec!["--no-boost"], + vec!["--nice", "99", "--", "g"], + vec!["--pin", "left", "--", "g"], + vec!["--bogus", "--", "g"], + ] { + let mut p = lexopt::Parser::from_args(args.clone()); + assert!( + matches!(parse_args(&mut p), Err(Error::Usage(_))), + "{args:?}" + ); + } + } + + #[test] + fn programs_are_found_before_anything_is_changed() { + assert!(program_exists("sh")); + assert!(program_exists("/bin/sh")); + assert!(!program_exists("definitely-not-a-game-seg")); + assert!(!program_exists("/no/such/game")); + // A file that cannot be run. + assert!(!program_exists("/etc/hostname")); + } + + #[test] + fn the_plan_reads_well() { + let profiles = parse("[[game]]\nmatch = \"g\"\npin = \"vcache\"\n").unwrap(); + let p = plan(&opts(&["g"]), profiles.first(), &machine(), &none_set); + let text = describe(&p, profiles.first()); + assert!(text.contains("profile: match \"g\" (line 1)"), "{text}"); + assert!(text.contains("pin: 0-3"), "{text}"); + assert!(text.contains("command: g"), "{text}"); + } +} diff --git a/tools/seg/segctl/src/game/setup.rs b/tools/seg/segctl/src/game/setup.rs new file mode 100644 index 0000000..e0c1c25 --- /dev/null +++ b/tools/seg/segctl/src/game/setup.rs @@ -0,0 +1,488 @@ +//! `seg game setup`: reads the graphics cards from /sys/class/drm (and their +//! names from lspci), says which packages this computer needs for games, and +//! with `--yes` installs them. Only drivers for hardware that is there. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use super::check::installed_packages; +use super::hw::{self, Gpu, GpuVendor}; +use crate::error::{Error, Result}; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game setup [--yes] [--games] [--lutris] + +Finds the graphics cards in this computer (/sys/class/drm, names from lspci) +and lists the packages they need to play: the Mesa and Vulkan drivers and +their 32-bit versions for AMD and Intel, NVIDIA's open kernel driver (with +the headers of the running kernel, for DKMS) and utilities for GeForce GTX 16 +and RTX cards, the open-source driver for older NVIDIA cards. + +Without --yes it only prints them and the command that installs them. With +--yes (as root) it runs pacman -S --needed --noconfirm with that list. + + --games also Steam, gamescope, MangoHud (64-bit: the Artix repositories + have no 32-bit MangoHud), Wine Staging and winetricks + --lutris also Lutris (a big package; Steam does not need it) +"; + +#[derive(Debug, Default)] +pub struct Options { + pub yes: bool, + pub games: bool, + pub lutris: bool, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut o = Options::default(); + while let Some(arg) = parser.next()? { + match arg { + Long("yes") | Short('y') => o.yes = true, + Long("games") => o.games = true, + Long("lutris") => o.lutris = true, + Short('h') | Long("help") => { + out!("{HELP}"); + std::process::exit(0); + } + other => return Err(other.unexpected().into()), + } + } + Ok(o) +} + +#[derive(Debug, PartialEq, Eq)] +pub struct Plan { + /// (what it is called, the packages for it) + pub per_gpu: Vec<(String, Vec)>, + /// Everything to install, in order, without repeats. + pub packages: Vec, + pub notes: Vec, + pub needs_nvidia_modprobe: bool, +} + +fn strings(list: &[&str]) -> Vec { + list.iter().map(|s| s.to_string()).collect() +} + +/// The packages for one card. `kernel` is the package of the running kernel. +pub fn packages_for(gpu: &Gpu, kernel: &str) -> (Vec, Option<&'static str>) { + match gpu.vendor { + GpuVendor::Amd => ( + strings(&["mesa", "lib32-mesa", "vulkan-radeon", "lib32-vulkan-radeon"]), + None, + ), + GpuVendor::Intel => ( + strings(&["mesa", "lib32-mesa", "vulkan-intel", "lib32-vulkan-intel"]), + None, + ), + GpuVendor::Nvidia if gpu.nvidia_open_capable() => ( + vec![ + format!("{kernel}-headers"), + "nvidia-open-dkms".into(), + "nvidia-utils".into(), + "lib32-nvidia-utils".into(), + "nvidia-settings".into(), + ], + None, + ), + GpuVendor::Nvidia => ( + strings(&["mesa", "lib32-mesa", "vulkan-nouveau"]), + Some( + "this NVIDIA card is older than Turing, which NVIDIA's current driver no longer supports; the open-source NVK driver is used", + ), + ), + GpuVendor::Other => (Vec::new(), Some("no driver known for this graphics card")), + } +} + +/// `[lib32]` enabled in pacman.conf? +pub fn lib32_enabled(pacman_conf: &str) -> bool { + pacman_conf.lines().any(|l| l.trim() == "[lib32]") +} + +pub fn plan( + gpus: &[Gpu], + describe: &dyn Fn(&Gpu) -> String, + kernel: Option<&str>, + games: bool, + lutris: bool, +) -> Plan { + let kernel = kernel.unwrap_or("linux"); + let mut plan = Plan { + per_gpu: Vec::new(), + packages: Vec::new(), + notes: Vec::new(), + needs_nvidia_modprobe: false, + }; + let mut seen = BTreeSet::new(); + let mut add = |plan: &mut Plan, pkg: &str| { + if seen.insert(pkg.to_string()) { + plan.packages.push(pkg.to_string()); + } + }; + if gpus.is_empty() { + plan.notes.push("no graphics card found in /sys/class/drm (a virtual machine without GPU?); no driver to install".into()); + } + for gpu in gpus { + let (pkgs, note) = packages_for(gpu, kernel); + if let Some(n) = note { + plan.notes.push(format!("{}: {n}", describe(gpu))); + } + if gpu.nvidia_open_capable() { + plan.needs_nvidia_modprobe = true; + } + for p in &pkgs { + add(&mut plan, p); + } + plan.per_gpu.push((describe(gpu), pkgs)); + } + if !gpus.is_empty() { + for p in [ + "vulkan-icd-loader", + "lib32-vulkan-icd-loader", + "vulkan-tools", + ] { + add(&mut plan, p); + } + } + if games { + for p in [ + "steam", + "gamescope", + "mangohud", + "wine-staging", + "winetricks", + ] { + add(&mut plan, p); + } + } + if lutris { + add(&mut plan, "lutris"); + } + plan +} + +pub fn render(plan: &Plan, installed: &BTreeSet) -> String { + let mut text = String::new(); + for (name, pkgs) in &plan.per_gpu { + text.push_str(&format!( + "{name}\n {}\n", + if pkgs.is_empty() { + "(nothing)".to_string() + } else { + pkgs.join(" ") + } + )); + } + for n in &plan.notes { + text.push_str(&format!("note: {n}\n")); + } + let todo: Vec<&String> = plan + .packages + .iter() + .filter(|p| !installed.contains(*p)) + .collect(); + if plan.packages.is_empty() { + text.push_str("nothing to install\n"); + } else if todo.is_empty() { + text.push_str(&format!( + "all {} packages are installed already\n", + plan.packages.len() + )); + } else { + text.push_str(&format!( + "\nto install ({} of {}):\n sudo pacman -S --needed {}\n", + todo.len(), + plan.packages.len(), + todo.iter() + .map(|s| s.as_str()) + .collect::>() + .join(" ") + )); + } + text +} + +/// The modprobe options NVIDIA's DRM driver wants for Wayland and a +/// framebuffer console. +pub const NVIDIA_MODPROBE: &str = "# Written by seg game setup: kernel mode setting for Wayland, and the\n# framebuffer console on NVIDIA's driver.\noptions nvidia_drm modeset=1 fbdev=1\n"; + +/// Is a modprobe file with these options already there (the seglinux package +/// ships one)? +pub fn has_nvidia_modprobe(root: &Path) -> bool { + ["usr/lib/modprobe.d", "etc/modprobe.d"].iter().any(|dir| { + fs::read_dir(root.join(dir)).is_ok_and(|d| { + d.flatten().any(|e| { + fs::read_to_string(e.path()).is_ok_and(|t| { + t.lines().any(|l| { + l.trim().starts_with("options nvidia_drm") && l.contains("modeset=1") + }) + }) + }) + }) + }) +} + +pub fn run(o: Options) -> Result { + let root = Path::new("/"); + let gpus = hw::gpus(root); + let kernel = hw::kernel_package(root); + let p = plan(&gpus, &hw::describe, kernel.as_deref(), o.games, o.lutris); + let installed = installed_packages(&root.join("var/lib/pacman/local")); + out!("{}", render(&p, &installed)); + let todo: Vec<&str> = p + .packages + .iter() + .filter(|x| !installed.contains(*x)) + .map(String::as_str) + .collect(); + let needs_modprobe = p.needs_nvidia_modprobe && !has_nvidia_modprobe(root); + if !o.yes { + if !todo.is_empty() || needs_modprobe { + outln!("\nRun `sudo seg game setup --yes` to install them."); + } + return Ok(0); + } + sys::require_root("`seg game setup --yes`")?; + let conf = fs::read_to_string("/etc/pacman.conf").unwrap_or_default(); + if p.packages.iter().any(|x| x.starts_with("lib32-")) && !lib32_enabled(&conf) { + return Err(Error::other( + "the [lib32] repository is not enabled in /etc/pacman.conf: add\n\n [lib32]\n Include = /etc/pacman.d/mirrorlist\n\nand run `sudo pacman -Sy`, then try again", + )); + } + if !todo.is_empty() { + let mut args = vec!["-S", "--needed", "--noconfirm"]; + args.extend(todo.iter().copied()); + sys::run_inherit("pacman", &args)?; + } + if needs_modprobe { + sys::write("/etc/modprobe.d/seg-nvidia.conf", NVIDIA_MODPROBE)?; + outln!("wrote /etc/modprobe.d/seg-nvidia.conf"); + } + if p.needs_nvidia_modprobe { + outln!("\nReboot to use NVIDIA's driver."); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::game::hw::{fixture, gpus}; + + fn name(g: &Gpu) -> String { + format!("{} {:04x}", g.card, g.device_id) + } + + fn p(fixture_name: &str) -> Plan { + plan( + &gpus(&fixture(fixture_name)), + &name, + Some("linux-zen"), + false, + false, + ) + } + + /// Every package `seg game setup` can ever pick, for the CI check that + /// they exist in the Artix repositories (check-packages.sh -x). + #[test] + fn every_package_setup_can_choose_is_in_the_ci_list() { + let mut all = BTreeSet::new(); + for card in [ + "gpu-amd", + "gpu-intel-arc", + "gpu-nvidia-ada", + "gpu-nvidia-pascal", + "gpu-hybrid-laptop", + ] { + let plan = plan(&gpus(&fixture(card)), &name, Some("linux-zen"), true, true); + all.extend(plan.packages); + } + let mut expected = String::from( + "# Every package `seg game setup` can choose, one per line. A test keeps this in step\n\ + # with src/game/setup.rs; CI resolves them on Artix (check-packages.sh -x).\n", + ); + for pkg in &all { + expected.push_str(pkg); + expected.push('\n'); + } + let path = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/setup-packages.txt"); + if std::env::var_os("SEG_WRITE_FIXTURES").is_some() { + std::fs::write(&path, &expected).unwrap(); + } + assert_eq!( + std::fs::read_to_string(&path).unwrap_or_default(), + expected, + "run the tests once with SEG_WRITE_FIXTURES=1 to rewrite {}", + path.display() + ); + } + + #[test] + fn amd_gets_mesa_and_radv_in_both_bitnesses() { + let plan = p("gpu-amd"); + assert_eq!( + plan.packages, + [ + "mesa", + "lib32-mesa", + "vulkan-radeon", + "lib32-vulkan-radeon", + "vulkan-icd-loader", + "lib32-vulkan-icd-loader", + "vulkan-tools" + ] + ); + assert!(!plan.needs_nvidia_modprobe); + assert!(plan.notes.is_empty()); + } + + #[test] + fn intel_gets_anv() { + let plan = p("gpu-intel-arc"); + assert!(plan.packages.contains(&"vulkan-intel".to_string())); + assert!(plan.packages.contains(&"lib32-vulkan-intel".to_string())); + assert!( + !plan + .packages + .iter() + .any(|x| x.contains("nvidia") || x.contains("radeon")) + ); + } + + #[test] + fn a_turing_or_newer_nvidia_gets_the_open_dkms_driver_and_the_running_kernels_headers() { + let plan = p("gpu-nvidia-ada-nouveau"); + assert_eq!( + &plan.packages[..5], + [ + "linux-zen-headers", + "nvidia-open-dkms", + "nvidia-utils", + "lib32-nvidia-utils", + "nvidia-settings" + ] + ); + assert!(plan.needs_nvidia_modprobe); + // No Mesa packages the proprietary driver does not use. + assert!(!plan.packages.contains(&"vulkan-radeon".to_string())); + } + + #[test] + fn an_old_nvidia_card_stays_on_nouveau_and_says_why() { + let plan = p("gpu-nvidia-pascal"); + assert!(plan.packages.contains(&"vulkan-nouveau".to_string())); + assert!(!plan.packages.iter().any(|x| x.contains("nvidia"))); + assert!(!plan.needs_nvidia_modprobe); + assert!( + plan.notes[0].contains("older than Turing"), + "{:?}", + plan.notes + ); + } + + #[test] + fn a_hybrid_laptop_gets_both_without_repeats() { + let plan = p("gpu-hybrid-laptop"); + assert_eq!(plan.per_gpu.len(), 2); + let count = |x: &str| plan.packages.iter().filter(|p| *p == x).count(); + assert_eq!(count("lib32-mesa"), 1); + assert_eq!(count("vulkan-icd-loader"), 1); + assert_eq!(count("nvidia-open-dkms"), 1); + assert_eq!(count("vulkan-intel"), 1); + } + + #[test] + fn no_gpu_installs_no_driver() { + let plan = p("gpu-none"); + assert!(plan.packages.is_empty()); + assert!( + plan.notes[0].contains("no graphics card"), + "{:?}", + plan.notes + ); + let text = render(&plan, &BTreeSet::new()); + assert!(text.contains("nothing to install"), "{text}"); + // The games stack can still be asked for. + let with = plan_games(); + assert!(with.packages.contains(&"steam".to_string())); + } + + fn plan_games() -> Plan { + plan(&[], &name, None, true, true) + } + + #[test] + fn the_games_stack_and_lutris_are_opt_in() { + let base = plan(&gpus(&fixture("gpu-amd")), &name, None, false, false); + assert!(!base.packages.contains(&"steam".to_string())); + assert!(!base.packages.contains(&"lutris".to_string())); + let all = plan(&gpus(&fixture("gpu-amd")), &name, None, true, true); + for pkg in [ + "steam", + "gamescope", + "mangohud", + "wine-staging", + "winetricks", + "lutris", + ] { + assert!(all.packages.contains(&pkg.to_string()), "{pkg}"); + } + // Not in the Artix repositories (CI's pacman -Sp said so): do not add + // it back without checking. + assert!(!all.packages.contains(&"lib32-mangohud".to_string())); + let games_only = plan_games(); + assert_eq!( + games_only.packages.last().map(String::as_str), + Some("lutris") + ); + } + + #[test] + fn an_unknown_running_kernel_means_the_stock_headers() { + let plan = plan(&gpus(&fixture("gpu-nvidia-ada")), &name, None, false, false); + assert_eq!(plan.packages[0], "linux-headers"); + } + + #[test] + fn the_report_lists_only_what_is_missing() { + let plan = p("gpu-amd"); + let have: BTreeSet = ["mesa", "lib32-mesa"] + .iter() + .map(|s| s.to_string()) + .collect(); + let text = render(&plan, &have); + assert!(text.contains("to install (5 of 7)"), "{text}"); + assert!( + text.contains("sudo pacman -S --needed vulkan-radeon lib32-vulkan-radeon"), + "{text}" + ); + let all: BTreeSet = plan.packages.iter().cloned().collect(); + assert!(render(&plan, &all).contains("all 7 packages are installed already")); + } + + #[test] + fn the_lib32_repository_and_the_nvidia_options() { + assert!(lib32_enabled("[options]\n[system]\n[lib32]\nInclude = x\n")); + assert!(!lib32_enabled("[options]\n#[lib32]\n")); + let t = sys::testutil::TempDir::new(); + assert!(!has_nvidia_modprobe(t.path())); + t.put( + "usr/lib/modprobe.d/seglinux-nvidia.conf", + "options nvidia_drm modeset=1 fbdev=1\n", + ); + assert!(has_nvidia_modprobe(t.path())); + let t = sys::testutil::TempDir::new(); + t.put( + "etc/modprobe.d/other.conf", + "options nvidia_drm modeset=0\n", + ); + assert!(!has_nvidia_modprobe(t.path())); + assert!(NVIDIA_MODPROBE.contains("options nvidia_drm modeset=1 fbdev=1")); + } +} diff --git a/tools/seg/segctl/src/game/toml.rs b/tools/seg/segctl/src/game/toml.rs new file mode 100644 index 0000000..1078945 --- /dev/null +++ b/tools/seg/segctl/src/game/toml.rs @@ -0,0 +1,371 @@ +//! The small part of TOML that ~/.config/seg/games.toml uses: `[[game]]` +//! tables with string, integer, boolean and string-array values, comments +//! and blank lines. Anything else is an error that names the line, so a typo +//! is not silently ignored. + +use std::fmt; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Value { + Str(String), + Int(i64), + Bool(bool), + Array(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct Table { + /// The `[[name]]` it belongs to; empty before the first header. + pub name: String, + pub line: usize, + pub entries: Vec<(String, Value, usize)>, +} + +impl Table { + pub fn get(&self, key: &str) -> Option<&Value> { + self.entries + .iter() + .find(|(k, _, _)| k == key) + .map(|(_, v, _)| v) + } +} + +#[derive(Debug, PartialEq, Eq)] +pub struct ParseError { + pub line: usize, + pub message: String, +} + +impl fmt::Display for ParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "line {}: {}", self.line, self.message) + } +} + +fn err(line: usize, message: impl Into) -> Result { + Err(ParseError { + line, + message: message.into(), + }) +} + +/// Cut a `#` comment off a line, unless the `#` is inside a string. +fn strip_comment(line: &str) -> &str { + let mut in_basic = false; + let mut in_literal = false; + let mut escaped = false; + for (i, c) in line.char_indices() { + match c { + '\\' if in_basic && !escaped => { + escaped = true; + continue; + } + '"' if !in_literal && !escaped => in_basic = !in_basic, + '\'' if !in_basic => in_literal = !in_literal, + '#' if !in_basic && !in_literal => return line.get(..i).unwrap_or(line), + _ => {} + } + escaped = false; + } + line +} + +fn parse_string(text: &str, line: usize) -> Result<(String, &str), ParseError> { + let mut chars = text.char_indices(); + let quote = match chars.next() { + Some((_, q @ ('"' | '\''))) => q, + _ => return err(line, "expected a string"), + }; + let mut out = String::new(); + let mut escaped = false; + for (i, c) in chars { + if quote == '\'' { + if c == '\'' { + return Ok((out, text.get(i + 1..).unwrap_or(""))); + } + out.push(c); + } else if escaped { + out.push(match c { + 'n' => '\n', + 't' => '\t', + '"' => '"', + '\\' => '\\', + other => return err(line, format!("unknown escape \\{other}")), + }); + escaped = false; + } else if c == '\\' { + escaped = true; + } else if c == '"' { + return Ok((out, text.get(i + 1..).unwrap_or(""))); + } else { + out.push(c); + } + } + err(line, "a string is not closed") +} + +/// One value from the start of `text`, and what follows it. +fn parse_value(text: &str, line: usize) -> Result<(Value, &str), ParseError> { + let text = text.trim_start(); + match text.chars().next() { + Some('"' | '\'') => { + let (s, rest) = parse_string(text, line)?; + Ok((Value::Str(s), rest)) + } + Some('[') => { + let mut items = Vec::new(); + let mut rest = text.get(1..).unwrap_or("").trim_start(); + loop { + if let Some(after) = rest.strip_prefix(']') { + return Ok((Value::Array(items), after)); + } + let (item, after) = parse_value(rest, line)?; + if matches!(item, Value::Array(_)) { + return err(line, "arrays inside arrays are not supported"); + } + items.push(item); + rest = after.trim_start(); + if let Some(after) = rest.strip_prefix(',') { + rest = after.trim_start(); + } else if !rest.starts_with(']') { + return err(line, "expected `,` or `]` in an array"); + } + } + } + Some(_) => { + let end = text + .find(|c: char| c == ',' || c == ']' || c.is_whitespace()) + .unwrap_or(text.len()); + let (word, rest) = (text.get(..end).unwrap_or(""), text.get(end..).unwrap_or("")); + let value = match word { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + _ => match word.replace('_', "").parse::() { + Ok(n) => Value::Int(n), + Err(_) => { + return err( + line, + format!("`{word}` is not a value (strings need quotes)"), + ); + } + }, + }; + Ok((value, rest)) + } + None => err(line, "a value is missing"), + } +} + +fn valid_key(key: &str) -> bool { + !key.is_empty() + && key + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') +} + +/// Parse a document into its tables, in order. +pub fn parse(text: &str) -> Result, ParseError> { + let mut tables = vec![Table::default()]; + // A multi-line array being read: key, the text so far, its first line. + let mut pending: Option<(String, String, usize)> = None; + for (index, raw) in text.lines().enumerate() { + let line_no = index + 1; + let line = strip_comment(raw).trim(); + let (key, value_text, first_line) = match pending.take() { + Some((key, mut so_far, first)) => { + so_far.push(' '); + so_far.push_str(line); + (key, so_far, first) + } + None => { + if line.is_empty() { + continue; + } + if let Some(name) = line.strip_prefix("[[") { + let Some(name) = name.strip_suffix("]]") else { + return err(line_no, "a table header must end with `]]`"); + }; + let name = name.trim(); + if !valid_key(name) { + return err(line_no, format!("`{name}` is not a table name")); + } + tables.push(Table { + name: name.to_string(), + line: line_no, + entries: Vec::new(), + }); + continue; + } + if line.starts_with('[') { + return err(line_no, "only [[table]] headers are supported"); + } + let Some((key, value)) = line.split_once('=') else { + return err(line_no, "expected `key = value`"); + }; + let key = key.trim(); + if !valid_key(key) { + return err(line_no, format!("`{key}` is not a key")); + } + (key.to_string(), value.trim().to_string(), line_no) + } + }; + // An array whose `]` has not come yet continues on the next line. + if value_text.starts_with('[') && !array_is_closed(&value_text) { + pending = Some((key, value_text, first_line)); + continue; + } + let (value, rest) = parse_value(&value_text, first_line)?; + if !rest.trim().is_empty() { + return err( + first_line, + format!("unexpected `{}` after the value", rest.trim()), + ); + } + let Some(table) = tables.last_mut() else { + return err(first_line, "no table"); + }; + if table.get(&key).is_some() { + return err(first_line, format!("`{key}` is set twice")); + } + table.entries.push((key, value, first_line)); + } + if let Some((_, _, first)) = pending { + return err(first, "an array is not closed"); + } + // The part before the first header must be empty. + if let Some(first) = tables.first() + && let Some((key, _, line)) = first.entries.first() + { + return err(*line, format!("`{key}` is outside a [[game]] table")); + } + tables.remove(0); + Ok(tables) +} + +/// Whether the `[` at the start of `text` has its closing `]` (brackets in +/// strings do not count). +fn array_is_closed(text: &str) -> bool { + let mut depth = 0i32; + let mut quote: Option = None; + let mut escaped = false; + for c in text.chars() { + match quote { + Some(q) => { + if q == '"' && c == '\\' && !escaped { + escaped = true; + continue; + } + if c == q && !escaped { + quote = None; + } + escaped = false; + } + None => match c { + '"' | '\'' => quote = Some(c), + '[' => depth += 1, + ']' => depth -= 1, + _ => {} + }, + } + } + depth <= 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn s(v: &str) -> Value { + Value::Str(v.into()) + } + + #[test] + fn tables_and_values() { + let doc = r#" +# my games +[[game]] +match = "eldenring.exe" # the executable +governor = 'performance' +nice = -5 +mangohud = true +gamescope = ["-W", "2560", "-H", "1440"] + +[[game]] +match = "cs2" +pin = "0-7,16-23" +"#; + let t = parse(doc).unwrap(); + assert_eq!(t.len(), 2); + assert_eq!(t[0].name, "game"); + assert_eq!(t[0].line, 3); + assert_eq!(t[0].get("match"), Some(&s("eldenring.exe"))); + assert_eq!(t[0].get("governor"), Some(&s("performance"))); + assert_eq!(t[0].get("nice"), Some(&Value::Int(-5))); + assert_eq!(t[0].get("mangohud"), Some(&Value::Bool(true))); + assert_eq!( + t[0].get("gamescope"), + Some(&Value::Array(vec![s("-W"), s("2560"), s("-H"), s("1440")])) + ); + assert_eq!(t[1].get("pin"), Some(&s("0-7,16-23"))); + assert_eq!(t[1].get("governor"), None); + } + + #[test] + fn strings_keep_hashes_and_escapes() { + let t = parse("[[game]]\nmatch = \"a#b\" # comment\nname = \"say \\\"hi\\\"\\n\"\nlit = 'C:\\path # x'\n").unwrap(); + assert_eq!(t[0].get("match"), Some(&s("a#b"))); + assert_eq!(t[0].get("name"), Some(&s("say \"hi\"\n"))); + assert_eq!(t[0].get("lit"), Some(&s("C:\\path # x"))); + } + + #[test] + fn arrays_may_span_lines_and_end_with_a_comma() { + let doc = "[[game]]\nmatch = \"x\"\ngamescope = [\n \"-W\", \"1920\", # width\n \"--adaptive-sync\",\n]\nempty = []\n"; + let t = parse(doc).unwrap(); + assert_eq!( + t[0].get("gamescope"), + Some(&Value::Array(vec![ + s("-W"), + s("1920"), + s("--adaptive-sync") + ])) + ); + assert_eq!(t[0].get("empty"), Some(&Value::Array(vec![]))); + assert_eq!( + t[0].entries + .iter() + .find(|e| e.0 == "gamescope") + .map(|e| e.2), + Some(3) + ); + } + + #[test] + fn mistakes_name_their_line() { + let cases = [ + ("[[game]]\nmatch = eldenring\n", 2, "strings need quotes"), + ("[[game]]\nmatch\n", 2, "expected `key = value`"), + ("[game]\n", 1, "only [[table]] headers"), + ("[[game]\n", 1, "must end with `]]`"), + ("[[game]]\nmatch = \"a\nx = 1\n", 2, "not closed"), + ("[[game]]\na = 1\na = 2\n", 3, "set twice"), + ("[[game]]\na = [1, 2\n", 2, "not closed"), + ("[[game]]\na = [[1]]\n", 2, "inside arrays"), + ("[[game]]\na = 1 2\n", 2, "unexpected"), + ("a = 1\n", 1, "outside a [[game]] table"), + ("[[game]]\n= 1\n", 2, "is not a key"), + ("[[game]]\na = \"\\q\"\n", 2, "unknown escape"), + ]; + for (doc, line, fragment) in cases { + let e = parse(doc).unwrap_err(); + assert_eq!(e.line, line, "{doc:?} -> {e}"); + assert!(e.to_string().contains(fragment), "{doc:?} -> {e}"); + } + } + + #[test] + fn empty_documents_have_no_tables() { + assert_eq!(parse("").unwrap(), vec![]); + assert_eq!(parse("# only a comment\n\n").unwrap(), vec![]); + } +} diff --git a/tools/seg/segctl/src/game/unsafe_boost.rs b/tools/seg/segctl/src/game/unsafe_boost.rs new file mode 100644 index 0000000..78c8ba1 --- /dev/null +++ b/tools/seg/segctl/src/game/unsafe_boost.rs @@ -0,0 +1,311 @@ +//! `seg game unsafe-boost`: `mitigations=off` on the kernel command line. +//! Opt-in, with a warning, never done by anything else in SegLinux. + +use std::fs; +use std::path::Path; + +use crate::error::{Error, Result}; +use crate::sys; + +pub const HELP: &str = "\ +Usage: seg game unsafe-boost on|off|status [--i-understand] + +Adds or removes `mitigations=off` in GRUB_CMDLINE_LINUX_DEFAULT of +/etc/default/grub and rebuilds the boot menu. It takes effect at the next +boot. `on` only runs with --i-understand, after you have read this: +"; + +pub const WARNING: &str = "\ +WARNING: mitigations=off switches off the kernel's protections against CPU +security flaws (Spectre, Meltdown, MDS, Retbleed and the others). While it is +on, any program on this computer, including JavaScript in a web page, may be +able to read memory it must not read: passwords, keys, other programs' data. + +The speed it gives back is usually small, bigger on older CPUs, and nothing at +all in many games. Measure first (docs/gaming.md has a method) and keep it +only if the numbers are worth it to you. Do not use it on a computer you share, +or use for banking or work. + +SegLinux never turns this on by itself. `seg game unsafe-boost off` undoes it. +"; + +const PARAM: &str = "mitigations=off"; +const KEY: &str = "GRUB_CMDLINE_LINUX_DEFAULT"; + +/// The words inside the quotes of `KEY="..."`, and the quote used. +fn split_value(value: &str) -> Option<(char, Vec<&str>)> { + let quote = value.chars().next().filter(|c| *c == '"' || *c == '\'')?; + let inner = value.strip_prefix(quote)?.strip_suffix(quote)?; + Some((quote, inner.split_whitespace().collect())) +} + +/// /etc/default/grub with `param` added (`add`) or removed. The bool says +/// whether anything changed. +pub fn edit_grub(text: &str, param: &str, add: bool) -> (String, bool) { + let mut changed = false; + let mut found = false; + let mut lines: Vec = text + .lines() + .map(|line| { + let Some(value) = line + .trim_start() + .strip_prefix(KEY) + .and_then(|r| r.strip_prefix('=')) + else { + return line.to_string(); + }; + let Some((quote, mut words)) = split_value(value.trim()) else { + return line.to_string(); + }; + found = true; + let present = words.contains(¶m); + if add && !present { + words.push(param); + changed = true; + } else if !add && present { + words.retain(|w| *w != param); + changed = true; + } else { + return line.to_string(); + } + format!("{KEY}={quote}{}{quote}", words.join(" ")) + }) + .collect(); + if add && !found { + lines.push(format!("{KEY}=\"{param}\"")); + changed = true; + } + let mut out = lines.join("\n"); + if text.ends_with('\n') || (add && !found && !text.is_empty()) { + out.push('\n'); + } + (out, changed) +} + +/// Whether the configured and the running command lines have the parameter. +pub fn status(grub: &str, cmdline: &str) -> (bool, bool) { + let configured = grub + .lines() + .filter_map(|l| l.trim_start().strip_prefix(KEY)?.strip_prefix('=')) + .filter_map(|v| split_value(v.trim())) + .any(|(_, words)| words.contains(&PARAM)); + let running = cmdline.split_whitespace().any(|w| w == PARAM); + (configured, running) +} + +pub enum Action { + On { understood: bool }, + Off, + Status, +} + +pub fn parse_args(parser: &mut lexopt::Parser) -> Result { + use lexopt::prelude::*; + let mut word: Option = None; + let mut understood = false; + while let Some(arg) = parser.next()? { + match arg { + Long("i-understand") => understood = true, + Short('h') | Long("help") => { + out!("{HELP}\n{WARNING}"); + std::process::exit(0); + } + Value(v) if word.is_none() => word = Some(v.string()?), + other => return Err(other.unexpected().into()), + } + } + match word.as_deref() { + Some("on") => Ok(Action::On { understood }), + Some("off") => Ok(Action::Off), + None | Some("status") => Ok(Action::Status), + Some(other) => Err(Error::Usage(format!( + "unknown unsafe-boost command `{other}` (on, off or status)" + ))), + } +} + +/// Edit the file under `root` and keep the old one next to it. +pub fn apply(root: &Path, add: bool) -> Result { + let path = root.join("etc/default/grub"); + let text = sys::read(&path)?; + let (new, changed) = edit_grub(&text, PARAM, add); + if changed { + let mut backup = path.as_os_str().to_owned(); + backup.push(".seg-bak"); + fs::write(&backup, &text) + .map_err(|e| Error::io("saving a copy of /etc/default/grub", e))?; + sys::write_atomic(&path, &new)?; + } + Ok(changed) +} + +pub fn run(action: Action) -> Result { + let root = Path::new("/"); + match action { + Action::Status => { + let grub = sys::read_trimmed("/etc/default/grub").unwrap_or_default(); + let cmdline = sys::read_trimmed("/proc/cmdline").unwrap_or_default(); + let (configured, running) = status(&grub, &cmdline); + outln!( + "mitigations=off in /etc/default/grub: {}", + if configured { "yes" } else { "no" } + ); + outln!( + "mitigations=off on the running kernel: {}", + if running { + "yes (the security mitigations are OFF)" + } else { + "no" + } + ); + Ok(0) + } + Action::On { understood } => { + out!("{WARNING}\n"); + if !understood { + outln!( + "Nothing was changed. To turn it on anyway:\n sudo seg game unsafe-boost on --i-understand" + ); + return Ok(1); + } + sys::require_root("`seg game unsafe-boost on`")?; + rebuild_menu(root, true) + } + Action::Off => { + sys::require_root("`seg game unsafe-boost off`")?; + rebuild_menu(root, false) + } + } +} + +fn rebuild_menu(root: &Path, add: bool) -> Result { + if apply(root, add)? { + if sys::which("grub-mkconfig").is_some() { + sys::run_inherit("grub-mkconfig", &["-o", "/boot/grub/grub.cfg"])?; + } else { + outln!("grub-mkconfig is not installed: rebuild your boot menu yourself."); + } + outln!( + "mitigations=off is now {} in the boot menu. Reboot for it to take effect. (A copy of the old /etc/default/grub is /etc/default/grub.seg-bak.)", + if add { "ON" } else { "off" } + ); + } else { + outln!( + "nothing to change: mitigations=off is already {}.", + if add { "on" } else { "off" } + ); + } + Ok(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + const GRUB: &str = "GRUB_DEFAULT=0\n#GRUB_CMDLINE_LINUX_DEFAULT=\"old\"\nGRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog\"\nGRUB_CMDLINE_LINUX=\"\"\n"; + + #[test] + fn adding_keeps_the_rest_of_the_line_and_file() { + let (new, changed) = edit_grub(GRUB, PARAM, true); + assert!(changed); + assert!( + new.contains( + "GRUB_CMDLINE_LINUX_DEFAULT=\"loglevel=3 quiet nowatchdog mitigations=off\"\n" + ), + "{new}" + ); + assert!( + new.contains("#GRUB_CMDLINE_LINUX_DEFAULT=\"old\"\n"), + "comments are untouched" + ); + assert!(new.contains("GRUB_CMDLINE_LINUX=\"\"\n")); + assert!(new.ends_with('\n')); + } + + #[test] + fn it_is_idempotent_both_ways() { + let (on, _) = edit_grub(GRUB, PARAM, true); + assert_eq!(edit_grub(&on, PARAM, true), (on.clone(), false)); + let (off, changed) = edit_grub(&on, PARAM, false); + assert!(changed); + assert_eq!(off, GRUB); + assert_eq!(edit_grub(GRUB, PARAM, false), (GRUB.to_string(), false)); + } + + #[test] + fn single_quotes_and_a_missing_line() { + let (new, _) = edit_grub("GRUB_CMDLINE_LINUX_DEFAULT='quiet'\n", PARAM, true); + assert_eq!(new, "GRUB_CMDLINE_LINUX_DEFAULT='quiet mitigations=off'\n"); + let (new, changed) = edit_grub("GRUB_DEFAULT=0\n", PARAM, true); + assert!(changed); + assert_eq!( + new, + "GRUB_DEFAULT=0\nGRUB_CMDLINE_LINUX_DEFAULT=\"mitigations=off\"\n" + ); + // Removing from a file without the line does nothing. + assert_eq!( + edit_grub("GRUB_DEFAULT=0\n", PARAM, false), + ("GRUB_DEFAULT=0\n".to_string(), false) + ); + // A value that is not quoted is not guessed at. + assert_eq!( + edit_grub("GRUB_CMDLINE_LINUX_DEFAULT=quiet\n", PARAM, false), + ("GRUB_CMDLINE_LINUX_DEFAULT=quiet\n".to_string(), false) + ); + } + + #[test] + fn a_similar_word_is_not_the_parameter() { + let g = "GRUB_CMDLINE_LINUX_DEFAULT=\"quiet nomitigations=off2 xmitigations=off\"\n"; + assert_eq!(edit_grub(g, PARAM, false), (g.to_string(), false)); + assert_eq!(status(g, "quiet"), (false, false)); + } + + #[test] + fn status_tells_configured_from_running() { + let (on, _) = edit_grub(GRUB, PARAM, true); + assert_eq!(status(&on, "root=/dev/sda quiet"), (true, false)); + assert_eq!(status(GRUB, "quiet mitigations=off"), (false, true)); + assert_eq!(status("", ""), (false, false)); + } + + #[test] + fn the_file_is_backed_up_and_replaced() { + let t = sys::testutil::TempDir::new(); + let path = t.put("etc/default/grub", GRUB); + assert!(apply(t.path(), true).unwrap()); + assert!(fs::read_to_string(&path).unwrap().contains(PARAM)); + assert_eq!( + fs::read_to_string(t.path().join("etc/default/grub.seg-bak")).unwrap(), + GRUB + ); + assert!(!apply(t.path(), true).unwrap(), "already on"); + assert!(apply(t.path(), false).unwrap()); + assert_eq!(fs::read_to_string(&path).unwrap(), GRUB); + assert!(apply(&t.path().join("nowhere"), true).is_err()); + } + + #[test] + fn the_warning_says_what_it_risks_and_how_to_undo() { + assert!(WARNING.contains("Spectre")); + assert!(WARNING.contains("JavaScript in a web page")); + assert!(WARNING.contains("unsafe-boost off")); + assert!(!WARNING.contains('%'), "no invented percentages"); + } + + #[test] + fn arguments() { + let parse = |a: &[&str]| parse_args(&mut lexopt::Parser::from_args(a.iter().copied())); + assert!(matches!( + parse(&["on"]), + Ok(Action::On { understood: false }) + )); + assert!(matches!( + parse(&["on", "--i-understand"]), + Ok(Action::On { understood: true }) + )); + assert!(matches!(parse(&["off"]), Ok(Action::Off))); + assert!(matches!(parse(&[]), Ok(Action::Status))); + assert!(matches!(parse(&["maybe"]), Err(Error::Usage(_)))); + } +} diff --git a/tools/seg/segctl/src/main.rs b/tools/seg/segctl/src/main.rs index e2fae1c..7393917 100644 --- a/tools/seg/segctl/src/main.rs +++ b/tools/seg/segctl/src/main.rs @@ -24,6 +24,7 @@ macro_rules! outln { mod clean; mod doctor; mod error; +mod game; mod info; mod init; mod svc; @@ -46,9 +47,10 @@ Commands: svc list|enable|disable|start|stop [name] services, on whichever init is running clean [--apply] orphans, old cached packages, old logs (dry run by default) - tune light|balanced|performance|apply|status + tune light|balanced|gaming|performance|apply|status memory, CPU and disk settings zram on|off|status compressed swap in memory + game run|check|setup|... play: boost while a game runs, readiness check, drivers doctor look for problems and print their fixes Options: @@ -74,8 +76,8 @@ stop need root; list does not. fn main() -> ExitCode { match run() { - Ok(true) => ExitCode::SUCCESS, - Ok(false) => ExitCode::from(1), + Ok(0) => ExitCode::SUCCESS, + Ok(code) => ExitCode::from(code), Err(e) => { sys::emit_err(format_args!("seg: {e}")); ExitCode::from(e.exit_code()) @@ -83,22 +85,22 @@ fn main() -> ExitCode { } } -/// Returns whether everything was fine (`doctor` reports problems this way). -fn run() -> Result { +/// The exit status: 0, 1 when `doctor` found problems, or the game's own. +fn run() -> Result { use lexopt::prelude::*; let mut parser = lexopt::Parser::from_env(); let command = match parser.next()? { None => { out!("{HELP}"); - return Ok(true); + return Ok(0); } Some(Short('h') | Long("help")) => { out!("{HELP}"); - return Ok(true); + return Ok(0); } Some(Short('V') | Long("version")) => { outln!("seg {}", env!("CARGO_PKG_VERSION")); - return Ok(true); + return Ok(0); } Some(Value(v)) => v.string()?, Some(other) => return Err(other.unexpected().into()), @@ -112,15 +114,16 @@ fn run() -> Result { "clean" => clean::run(clean::parse_args(&mut parser)?)?, "tune" => tune::run(tune::parse_args(&mut parser)?)?, "zram" => zram::run(zram::parse_args(&mut parser)?)?, + "game" => return game::run(&mut parser), "doctor" => { no_more_args(&mut parser, "doctor")?; - return doctor::run(); + return Ok(u8::from(!doctor::run()?)); } other => { return Err(Error::Usage(format!("unknown command `{other}`"))); } } - Ok(true) + Ok(0) } /// Commands without options of their own: `--help` or nothing. diff --git a/tools/seg/segctl/src/svc.rs b/tools/seg/segctl/src/svc.rs index 21974bb..d263969 100644 --- a/tools/seg/segctl/src/svc.rs +++ b/tools/seg/segctl/src/svc.rs @@ -37,6 +37,10 @@ impl State { pub struct Service { pub name: String, pub enabled: bool, + /// Enabled in a runlevel the boot goes through. OpenRC also has runlevels + /// that are not entered at boot (`shutdown`, `nonetwork`): a service + /// there is enabled, and not running while the system is up, by design. + pub runs_at_boot: bool, pub state: State, } @@ -224,6 +228,7 @@ impl Svc { /// init can be asked, whether they run. pub fn list(&self) -> Result> { let (available, enabled) = self.available_and_enabled(); + let boot_levels = self.openrc_boot_services(); let states = if self.live() { self.states(&enabled) } else { @@ -233,12 +238,32 @@ impl Svc { .into_iter() .map(|name| Service { enabled: enabled.contains(&name), + runs_at_boot: match self.init { + Init::OpenRc => boot_levels.contains(&name), + _ => enabled.contains(&name), + }, state: states.get(&name).copied().unwrap_or(State::Unknown), name, }) .collect()) } + /// What OpenRC starts on the way up: the `sysinit`, `boot` and `default` + /// runlevels, in that order. + fn openrc_boot_services(&self) -> BTreeSet { + let mut out = BTreeSet::new(); + for level in ["sysinit", "boot", "default"] { + if let Ok(entries) = fs::read_dir(self.p(&format!("/etc/runlevels/{level}"))) { + out.extend( + entries + .flatten() + .filter_map(|e| e.file_name().into_string().ok()), + ); + } + } + out + } + fn available_and_enabled(&self) -> (BTreeSet, BTreeSet) { let names = |dir: &str, want_dirs: bool| -> BTreeSet { let Ok(entries) = fs::read_dir(self.p(dir)) else { @@ -590,6 +615,41 @@ mod tests { assert!(!t.path().join("etc/runlevels").exists()); } + #[test] + fn openrc_shutdown_and_nonetwork_services_do_not_run_at_boot() { + let t = TempDir::new(); + for name in ["agetty", "killprocs", "local", "udev"] { + t.put(&format!("etc/init.d/{name}"), ""); + } + for (level, name) in [ + ("default", "agetty"), + ("shutdown", "killprocs"), + ("nonetwork", "local"), + ("sysinit", "udev"), + ] { + fs::create_dir_all(t.path().join("etc/runlevels").join(level)).unwrap(); + symlink( + format!("/etc/init.d/{name}"), + t.path().join("etc/runlevels").join(level).join(name), + ) + .unwrap(); + } + let list = Svc::new(Init::OpenRc, t.path()).list().unwrap(); + let flags: Vec<_> = list + .iter() + .map(|s| (s.name.as_str(), s.enabled, s.runs_at_boot)) + .collect(); + assert_eq!( + flags, + [ + ("agetty", true, true), + ("killprocs", true, false), + ("local", true, false), + ("udev", true, true), + ] + ); + } + #[test] fn list_combines_installed_and_enabled() { let t = TempDir::new(); @@ -604,11 +664,13 @@ mod tests { Service { name: "a".into(), enabled: false, + runs_at_boot: false, state: State::Unknown }, Service { name: "b".into(), enabled: true, + runs_at_boot: true, state: State::Unknown }, ] diff --git a/tools/seg/segctl/src/tune.rs b/tools/seg/segctl/src/tune.rs index 49a5d81..e42b994 100644 --- a/tools/seg/segctl/src/tune.rs +++ b/tools/seg/segctl/src/tune.rs @@ -1,4 +1,4 @@ -//! `seg tune light|balanced|performance`: memory and writeback sysctls, the +//! `seg tune light|balanced|gaming|performance`: memory and writeback sysctls, the //! CPU governor and the I/O scheduler of every disk, applied now and saved //! for the next boot (/etc/sysctl.d/99-seg.conf, /etc/seg/tune.conf, and the //! `seg-tune` init service that runs `seg tune apply`). @@ -15,23 +15,30 @@ use crate::error::{Context, Error, Result}; use crate::sys::{self, bold}; pub const HELP: &str = "\ -Usage: seg tune [light|balanced|performance|apply|status] [--dry-run] +Usage: seg tune [light|balanced|gaming|performance|apply|status] [--dry-run] light save memory and battery: aggressive reclaim, small dirty limits, the most frugal CPU governor balanced kernel defaults for memory, a dynamic governor - performance keep caches, short writeback bursts, governor `performance` + gaming keep caches, short writeback bursts, low swappiness; the CPU + stays dynamic (seg game run raises it while a game runs) + performance the same, and the governor `performance` all the time apply apply the saved profile (the seg-tune service does this at boot) status show the saved profile and the current values (default) Also sets the I/O scheduler of every disk: none for NVMe, mq-deadline for -SSDs, bfq for spinning disks. With zram swap on, swappiness is at least 100 +SSDs, bfq for spinning disks. With zram swap on, swappiness is at least 180 (compressed memory is cheaper to swap to than disk is to read from). The profile is saved in /etc/seg/tune.conf and /etc/sysctl.d/99-seg.conf. Needs root, except `status` and --dry-run. "; +/// Swappiness with zram swap on: swapping to compressed memory is cheaper +/// than re-reading files, so swap early. The value SegLinux Setup writes to +/// /etc/sysctl.d/90-seglinux-zram.conf (ArchWiki, Fedora, Pop!_OS). +pub const ZRAM_SWAPPINESS: u32 = 180; + pub const CONF: &str = "/etc/seg/tune.conf"; pub const SYSCTL_CONF: &str = "/etc/sysctl.d/99-seg.conf"; @@ -39,7 +46,12 @@ pub const SYSCTL_CONF: &str = "/etc/sysctl.d/99-seg.conf"; pub enum Mode { Light, Balanced, + /// Everything fast, all the time: the CPU on `performance` too. Performance, + /// For playing: caches kept, writeback in small bursts, low swappiness + /// without zram. The CPU governor stays dynamic: `seg game run` raises it + /// only while a game runs. + Gaming, } impl Mode { @@ -48,6 +60,7 @@ impl Mode { Mode::Light => "light", Mode::Balanced => "balanced", Mode::Performance => "performance", + Mode::Gaming => "gaming", } } } @@ -66,8 +79,9 @@ impl FromStr for Mode { "light" => Ok(Mode::Light), "balanced" => Ok(Mode::Balanced), "performance" => Ok(Mode::Performance), + "gaming" => Ok(Mode::Gaming), other => Err(Error::Usage(format!( - "unknown profile `{other}` (light, balanced or performance)" + "unknown profile `{other}` (light, balanced, gaming or performance)" ))), } } @@ -112,6 +126,14 @@ fn profile(mode: Mode) -> Profile { governors: &["performance"], epp: "performance", }, + Mode::Gaming => Profile { + swappiness: 10, + vfs_cache_pressure: 50, + dirty_ratio: 15, + dirty_background_ratio: 5, + governors: &["schedutil", "ondemand", "powersave"], + epp: "balance_performance", + }, } } @@ -119,7 +141,7 @@ fn profile(mode: Mode) -> Profile { pub fn sysctl_values(mode: Mode, zram_active: bool) -> [(&'static str, u32); 4] { let p = profile(mode); let swappiness = if zram_active { - p.swappiness.max(100) + p.swappiness.max(ZRAM_SWAPPINESS) } else { p.swappiness }; @@ -133,7 +155,7 @@ pub fn sysctl_values(mode: Mode, zram_active: bool) -> [(&'static str, u32); 4] pub fn sysctl_conf(mode: Mode, zram_active: bool) -> String { let mut text = format!( - "# Written by `seg tune {mode}`; change it with `sudo seg tune `.\n" + "# Written by `seg tune {mode}`; change it with `sudo seg tune `.\n" ); for (key, value) in sysctl_values(mode, zram_active) { text.push_str(&format!("{key} = {value}\n")); @@ -220,6 +242,33 @@ impl Fs { self.root.join(rel.trim_start_matches('/')) } + /// Apply the `key = value` lines of a sysctl.d file now. + pub fn apply_sysctl_text(&self, text: &str) -> Result { + let mut report = Report::default(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + let Some((key, value)) = line.split_once('=') else { + continue; + }; + let (key, value) = (key.trim().trim_start_matches('-'), value.trim()); + // A key is dots and word characters, nothing that could leave /proc/sys. + if key.is_empty() + || !key + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-')) + || key.contains("..") + { + report.warnings.push(format!("{key}: not a sysctl name")); + continue; + } + self.set(key, &self.sysctl_path(key), value, false, &mut report)?; + } + Ok(report) + } + pub fn zram_active(&self) -> bool { sys::read_trimmed(self.p("/proc/swaps")) .is_some_and(|t| t.lines().any(|l| l.starts_with("/dev/zram"))) @@ -553,6 +602,7 @@ mod tests { for (text, mode) in [ ("light", Mode::Light), ("balanced", Mode::Balanced), + ("gaming", Mode::Gaming), ("performance", Mode::Performance), ] { assert_eq!(text.parse::().unwrap(), mode); @@ -560,7 +610,10 @@ mod tests { } let e = "turbo".parse::().unwrap_err(); assert!(matches!(e, Error::Usage(_))); - assert!(e.to_string().contains("light, balanced or performance")); + assert!( + e.to_string() + .contains("light, balanced, gaming or performance") + ); } #[test] @@ -575,21 +628,100 @@ mod tests { } #[test] - fn zram_raises_swappiness_to_at_least_100() { + fn gaming_keeps_the_cpu_dynamic_and_the_caches_warm() { + let g = profile(Mode::Gaming); + // Never the performance governor: that is for the length of a game. + assert!(!g.governors.contains(&"performance")); + assert_eq!( + pick_governor("performance schedutil powersave", g.governors), + Some("schedutil") + ); + // intel_pstate offers only performance and powersave. + assert_eq!( + pick_governor("performance powersave", g.governors), + Some("powersave") + ); + assert_eq!(pick_governor("performance", g.governors), None); + assert_eq!( + sysctl_values(Mode::Gaming, false), + [ + ("vm.swappiness", 10), + ("vm.vfs_cache_pressure", 50), + ("vm.dirty_ratio", 15), + ("vm.dirty_background_ratio", 5), + ] + ); + // With zram, swapping early is right even for gaming. + assert_eq!( + sysctl_values(Mode::Gaming, true)[0], + ("vm.swappiness", ZRAM_SWAPPINESS) + ); + } + + #[test] + fn gaming_saves_like_the_others_and_leaves_the_governor_dynamic() { + let t = fake_system(); + Fs::new(t.path()).apply(Mode::Gaming, true, false).unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); + assert_eq!( + read(&t, "sys/devices/system/cpu/cpu0/cpufreq/scaling_governor"), + "schedutil" + ); + assert_eq!( + read(&t, "etc/seg/tune.conf").lines().last(), + Some("mode = gaming") + ); + assert_eq!(Fs::new(t.path()).saved_mode(), Some(Mode::Gaming)); + } + + #[test] + fn a_sysctl_file_is_applied_line_by_line() { + let t = fake_system(); + let report = Fs::new(t.path()) + .apply_sysctl_text( + "# comment + +vm.swappiness = 33 +-vm.dirty_ratio=7 +not a line +vm/../../etc.x = 1 +vm.nonexistent = 1 +", + ) + .unwrap(); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "33"); + assert_eq!(read(&t, "proc/sys/vm/dirty_ratio"), "7"); + assert!( + report + .warnings + .iter() + .any(|w| w.contains("not a sysctl name")), + "{:?}", + report.warnings + ); + assert!( + report.warnings.iter().any(|w| w.contains("vm.nonexistent")), + "{:?}", + report.warnings + ); + } + + #[test] + fn zram_raises_swappiness_to_the_zram_value() { assert_eq!( sysctl_values(Mode::Performance, true)[0], - ("vm.swappiness", 100) + ("vm.swappiness", ZRAM_SWAPPINESS) ); assert_eq!( sysctl_values(Mode::Balanced, true)[0], - ("vm.swappiness", 100) + ("vm.swappiness", ZRAM_SWAPPINESS) ); assert_eq!( sysctl_values(Mode::Performance, false)[0], ("vm.swappiness", 10) ); // Never lowered. - assert_eq!(sysctl_values(Mode::Light, true)[0].1, 100); + assert_eq!(sysctl_values(Mode::Light, true)[0].1, ZRAM_SWAPPINESS); } #[test] @@ -821,8 +953,8 @@ mod tests { let fs_ = Fs::new(t.path()); assert!(fs_.zram_active()); fs_.apply(Mode::Performance, true, false).unwrap(); - assert_eq!(read(&t, "proc/sys/vm/swappiness"), "100"); - assert!(read(&t, "etc/sysctl.d/99-seg.conf").contains("vm.swappiness = 100")); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "180"); + assert!(read(&t, "etc/sysctl.d/99-seg.conf").contains("vm.swappiness = 180")); // zram goes away: the saved profile's own value comes back. t.put("proc/swaps", "Filename Type Size Used Priority\n"); fs_.reapply_memory().unwrap(); diff --git a/tools/seg/segctl/src/zram.rs b/tools/seg/segctl/src/zram.rs index 15ee644..536abf4 100644 --- a/tools/seg/segctl/src/zram.rs +++ b/tools/seg/segctl/src/zram.rs @@ -12,13 +12,15 @@ use crate::sys::{self, human_bytes}; use crate::tune; pub const HELP: &str = "\ -Usage: seg zram on|off|status [--size SIZE] +Usage: seg zram on|off|status [--size SIZE] [--if-unconfigured] on set up /dev/zram0 as swap: zstd, half of the RAM (at most 8G) off stop using it and free its memory status show whether it is on, its size and how well it compresses ---size SIZE size of the device instead of half the RAM: 2G, 4096M, 512M +--size SIZE size of the device instead of half the RAM: 2G, 4096M, 512M +--if-unconfigured do nothing when /dev/zram0 already has a size (set by a + udev rule: used by SegLinux's own rule, which runs at boot) `on` and `off` need root. The seg-zram service runs `seg zram on` at boot. "; @@ -34,7 +36,16 @@ pub enum Action { pub struct Options { pub action: Action, + pub on: OnOptions, +} + +#[derive(Default)] +pub struct OnOptions { + /// Size of the device instead of half the RAM. pub size: Option, + /// Do nothing when /dev/zram0 has been given a size already: udev rules + /// (SegLinux Setup writes one) and fstab set it up in that case. + pub if_unconfigured: bool, } /// `2G`, `4096M`, `512m`, `1048576` (bytes) -> bytes. @@ -173,7 +184,7 @@ impl Zram { )) } - pub fn on(&self, runner: &mut dyn Runner, size: Option, wait: Duration) -> Result { + pub fn on(&self, runner: &mut dyn Runner, opts: &OnOptions, wait: Duration) -> Result { if let Some(swap) = self.swap() { return Ok(format!( "zram swap is already on ({})", @@ -181,27 +192,37 @@ impl Zram { )); } self.ensure_device(runner, wait)?; - let bytes = match size { - Some(b) => b, - None => { - let meminfo = sys::read(self.p("/proc/meminfo"))?; - auto_size(mem_total_kib(&meminfo).ok_or_else(|| { - Error::other("cannot read the memory size from /proc/meminfo") - })?) - } - }; - // A configured but unused device must be reset before it can change. - if sys::read_trimmed(self.dev("disksize")).is_some_and(|s| s != "0") { - sys::write(self.dev("reset"), "1")?; + let configured = sys::read_trimmed(self.dev("disksize")).is_some_and(|s| s != "0"); + if configured && opts.if_unconfigured { + return Ok("zram0 is set up by another rule already; leaving it".into()); } let mut note = String::new(); - let algorithms = sys::read_trimmed(self.dev("comp_algorithm")).unwrap_or_default(); - if parse_algorithms(&algorithms).0.iter().any(|a| a == "zstd") { - sys::write(self.dev("comp_algorithm"), "zstd")?; - } else { - note = " (zstd is not available: using the kernel's default compression)".into(); + let mut bytes = sys::read_trimmed(self.dev("disksize")) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + // A device somebody sized keeps its size (and compression) unless a + // size is asked for; an unused one must be reset to change. + if !configured || opts.size.is_some() { + bytes = match opts.size { + Some(b) => b, + None => { + let meminfo = sys::read(self.p("/proc/meminfo"))?; + auto_size(mem_total_kib(&meminfo).ok_or_else(|| { + Error::other("cannot read the memory size from /proc/meminfo") + })?) + } + }; + if configured { + sys::write(self.dev("reset"), "1")?; + } + let algorithms = sys::read_trimmed(self.dev("comp_algorithm")).unwrap_or_default(); + if parse_algorithms(&algorithms).0.iter().any(|a| a == "zstd") { + sys::write(self.dev("comp_algorithm"), "zstd")?; + } else { + note = " (zstd is not available: using the kernel's default compression)".into(); + } + sys::write(self.dev("disksize"), &bytes.to_string())?; } - sys::write(self.dev("disksize"), &bytes.to_string())?; runner.run("mkswap", &["-U", "clear", DEVICE])?; runner.run("swapon", &["-p", "100", DEVICE])?; @@ -213,15 +234,18 @@ impl Zram { // Swapping to memory needs no read-ahead. let _ = fs::write(self.p("/proc/sys/vm/page-cluster"), "0"); // A saved `seg tune` profile decides the swappiness; without one, - // swap to memory early, as SteamOS and Fedora do. + // swap to memory early, as Setup's 90-seglinux-zram.conf does. let tuned = tune::Fs::new(&self.root); if tuned.saved_mode().is_some() { tuned.reapply_memory()?; } else { - let _ = fs::write(self.p("/proc/sys/vm/swappiness"), "100"); + let _ = fs::write( + self.p("/proc/sys/vm/swappiness"), + tune::ZRAM_SWAPPINESS.to_string(), + ); } Ok(format!( - "zram swap is on: {} of zstd-compressed memory{note}", + "zram swap is on: {} of compressed memory{note}", human_bytes(bytes) )) } @@ -286,10 +310,11 @@ impl Zram { pub fn parse_args(parser: &mut lexopt::Parser) -> Result { use lexopt::prelude::*; let mut action = None; - let mut size = None; + let mut on = OnOptions::default(); while let Some(arg) = parser.next()? { match arg { - Long("size") => size = Some(parse_size(&parser.value()?.string()?)?), + Long("size") => on.size = Some(parse_size(&parser.value()?.string()?)?), + Long("if-unconfigured") => on.if_unconfigured = true, Short('h') | Long("help") => { out!("{HELP}"); std::process::exit(0); @@ -311,7 +336,7 @@ pub fn parse_args(parser: &mut lexopt::Parser) -> Result { } Ok(Options { action: action.unwrap_or(Action::Status), - size, + on, }) } @@ -323,7 +348,7 @@ pub fn run(o: Options) -> Result<()> { sys::require_root("`seg zram on`")?; outln!( "{}", - zram.on(&mut RealRunner, o.size, Duration::from_millis(100))? + zram.on(&mut RealRunner, &o.on, Duration::from_millis(100))? ); } Action::Off => { @@ -440,7 +465,9 @@ mod tests { fn on_configures_zstd_half_the_ram_and_swaps_to_it() { let t = fake("0\n", "lzo lzo-rle [lz4] zstd\n"); let mut r = Recorder::default(); - let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + let msg = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); assert!(msg.starts_with("zram swap is on: "), "{msg}"); assert_eq!(read(&t, "sys/block/zram0/comp_algorithm"), "zstd"); assert_eq!( @@ -454,7 +481,7 @@ mod tests { assert_eq!(read(&t, "sys/module/zswap/parameters/enabled"), "0"); assert_eq!(read(&t, "proc/sys/vm/page-cluster"), "0"); // No saved `seg tune` profile: swap to memory early. - assert_eq!(read(&t, "proc/sys/vm/swappiness"), "100"); + assert_eq!(read(&t, "proc/sys/vm/swappiness"), "180"); } #[test] @@ -462,7 +489,14 @@ mod tests { let t = fake("1048576\n", "[lzo] zstd\n"); let mut r = Recorder::default(); Zram::new(t.path()) - .on(&mut r, Some(2 << 30), NO_WAIT) + .on( + &mut r, + &OnOptions { + size: Some(2 << 30), + ..OnOptions::default() + }, + NO_WAIT, + ) .unwrap(); assert_eq!(read(&t, "sys/block/zram0/reset"), "1"); assert_eq!( @@ -471,11 +505,52 @@ mod tests { ); } + #[test] + fn a_device_another_rule_sized_is_left_alone() { + // SegLinux Setup's udev rule gives zram0 its size and formats it; the + // package's rule must not reset it under that rule's feet. + let t = fake("4294967296\n", "[zstd]\n"); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()) + .on( + &mut r, + &OnOptions { + if_unconfigured: true, + ..OnOptions::default() + }, + NO_WAIT, + ) + .unwrap(); + assert!(msg.contains("leaving it"), "{msg}"); + assert!(r.0.is_empty()); + assert_eq!(read(&t, "sys/block/zram0/reset"), ""); + assert_eq!(read(&t, "sys/block/zram0/disksize"), "4294967296"); + } + + #[test] + fn a_sized_device_that_is_not_swap_keeps_its_size_for_a_plain_on() { + let t = fake("4294967296\n", "[zstd]\n"); + let mut r = Recorder::default(); + let msg = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); + assert!(msg.starts_with("zram swap is on: 4.0 GiB"), "{msg}"); + // No reset, same size; just formatted and switched on. + assert_eq!(read(&t, "sys/block/zram0/reset"), ""); + assert_eq!(read(&t, "sys/block/zram0/disksize"), "4294967296"); + assert_eq!( + r.0, + ["mkswap -U clear /dev/zram0", "swapon -p 100 /dev/zram0"] + ); + } + #[test] fn on_falls_back_when_the_kernel_lacks_zstd() { let t = fake("0\n", "[lzo] lzo-rle lz4\n"); let mut r = Recorder::default(); - let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + let msg = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); assert!(msg.contains("zstd is not available"), "{msg}"); assert_eq!( read(&t, "sys/block/zram0/comp_algorithm"), @@ -491,7 +566,9 @@ mod tests { "Filename Type Size Used Priority\n/dev/zram0 partition 4194300 0 100\n", ); let mut r = Recorder::default(); - let msg = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + let msg = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); assert!(msg.starts_with("zram swap is already on"), "{msg}"); assert!(r.0.is_empty()); } @@ -504,7 +581,9 @@ mod tests { let mut r = Recorder::default(); // The recorder does not create the device, so this gives up after // asking for the module. - let e = Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap_err(); + let e = Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap_err(); assert_eq!(r.0, ["modprobe zram"]); assert!(matches!(e, Error::Unsupported(_)), "{e}"); } @@ -517,7 +596,9 @@ mod tests { t.put("proc/sys/vm/dirty_ratio", "20\n"); t.put("proc/sys/vm/dirty_background_ratio", "10\n"); let mut r = Recorder::default(); - Zram::new(t.path()).on(&mut r, None, NO_WAIT).unwrap(); + Zram::new(t.path()) + .on(&mut r, &OnOptions::default(), NO_WAIT) + .unwrap(); // The recorder cannot add the swap, so the profile sees no zram and // wants its own value; what matters is that tune decided, not 100. assert_eq!(read(&t, "proc/sys/vm/swappiness"), "10"); diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0-DP-1/status b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0-DP-1/status new file mode 100644 index 0000000..da707df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0-DP-1/status @@ -0,0 +1 @@ +connected diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/device new file mode 100644 index 0000000..6b8aa72 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x744c diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/power_dpm_force_performance_level b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/power_dpm_force_performance_level new file mode 100644 index 0000000..865faf1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/power_dpm_force_performance_level @@ -0,0 +1 @@ +auto diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..1547d4e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=amdgpu +PCI_CLASS=30000 +PCI_ID=1002:744C +PCI_SLOT_NAME=0000:03:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..7f97d85 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-amd/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x1002 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/device new file mode 100644 index 0000000..614e481 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x46a6 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..d7b3dd8 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=i915 +PCI_CLASS=30000 +PCI_ID=8086:46A6 +PCI_SLOT_NAME=0000:00:02.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..ce6dc4d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x8086 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/boot_vga new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/boot_vga @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/device b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/device new file mode 100644 index 0000000..a87a297 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/device @@ -0,0 +1 @@ +0x25a2 diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/uevent new file mode 100644 index 0000000..7fb46e5 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/uevent @@ -0,0 +1,5 @@ +DRIVER=nvidia +PCI_CLASS=30000 +PCI_ID=10DE:25A2 +PCI_SLOT_NAME=0000:01:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/vendor new file mode 100644 index 0000000..466e5df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-hybrid-laptop/sys/class/drm/card1/device/vendor @@ -0,0 +1 @@ +0x10de diff --git a/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/boot_vga new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/boot_vga @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/device b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/device new file mode 100644 index 0000000..1f178e9 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/device @@ -0,0 +1 @@ +0x56a0 diff --git a/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/uevent new file mode 100644 index 0000000..54e978e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/uevent @@ -0,0 +1,5 @@ +DRIVER=i915 +PCI_CLASS=30000 +PCI_ID=8086:56A0 +PCI_SLOT_NAME=0000:03:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/vendor new file mode 100644 index 0000000..ce6dc4d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-intel-arc/sys/class/drm/card1/device/vendor @@ -0,0 +1 @@ +0x8086 diff --git a/tools/seg/segctl/tests/fixtures/gpu-none/sys/class/drm/.keep b/tools/seg/segctl/tests/fixtures/gpu-none/sys/class/drm/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/device new file mode 100644 index 0000000..d4e74fa --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x2684 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..a14f61c --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=nouveau +PCI_CLASS=30000 +PCI_ID=10DE:2684 +PCI_SLOT_NAME=0000:01:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..466e5df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada-nouveau/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x10de diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/device new file mode 100644 index 0000000..d4e74fa --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x2684 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..fbe336c --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=nvidia +PCI_CLASS=30000 +PCI_ID=10DE:2684 +PCI_SLOT_NAME=0000:01:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..466e5df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-ada/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x10de diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/boot_vga b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/boot_vga new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/boot_vga @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/device b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/device new file mode 100644 index 0000000..47b6ccd --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/device @@ -0,0 +1 @@ +0x1b80 diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/uevent b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/uevent new file mode 100644 index 0000000..4234a72 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/uevent @@ -0,0 +1,5 @@ +DRIVER=nouveau +PCI_CLASS=30000 +PCI_ID=10DE:1B80 +PCI_SLOT_NAME=0000:01:00.0 +MODALIAS=pci:x diff --git a/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/vendor b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/vendor new file mode 100644 index 0000000..466e5df --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/gpu-nvidia-pascal/sys/class/drm/card0/device/vendor @@ -0,0 +1 @@ +0x10de diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/intel-12700/proc/cpuinfo new file mode 100644 index 0000000..f44eb79 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : GenuineIntel +cpu family : 25 +model : 97 +model name : 12th Gen Intel(R) Core(TM) i7-12700 +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : GenuineIntel +model name : 12th Gen Intel(R) Core(TM) i7-12700 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..60f488a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-19 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..9ecd9a0 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +25600K diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/level b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/shared_cpu_list new file mode 100644 index 0000000..b8626c4 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +4 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/size b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/type b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/level b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/shared_cpu_list new file mode 100644 index 0000000..60f488a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-19 diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/size b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/size new file mode 100644 index 0000000..9ecd9a0 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/size @@ -0,0 +1 @@ +25600K diff --git a/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/type b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/intel-12700/sys/devices/system/cpu/cpu4/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/ryzen-5950x/proc/cpuinfo new file mode 100644 index 0000000..70e077c --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : AuthenticAMD +cpu family : 25 +model : 97 +model name : AMD Ryzen 9 5950X 16-Core Processor +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : AuthenticAMD +model name : AMD Ryzen 9 5950X 16-Core Processor diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..20128cf --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-7,16-23 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +8 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list new file mode 100644 index 0000000..277d78d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +8-15,24-31 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-5950x/sys/devices/system/cpu/cpu8/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/proc/cpuinfo new file mode 100644 index 0000000..5ec6c0c --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : AuthenticAMD +cpu family : 25 +model : 97 +model name : AMD Ryzen 7 7800X3D 8-Core Processor +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : AuthenticAMD +model name : AMD Ryzen 7 7800X3D 8-Core Processor diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..580a385 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-15 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +8 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list new file mode 100644 index 0000000..580a385 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-15 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7800x3d/sys/devices/system/cpu/cpu8/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/proc/cpuinfo new file mode 100644 index 0000000..dfbf2b6 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : AuthenticAMD +cpu family : 25 +model : 97 +model name : AMD Ryzen 9 7950X3D 16-Core Processor +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : AuthenticAMD +model name : AMD Ryzen 9 7950X3D 16-Core Processor diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..20128cf --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-7,16-23 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/shared_cpu_list new file mode 100644 index 0000000..b6a7d89 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +16 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/shared_cpu_list new file mode 100644 index 0000000..20128cf --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-7,16-23 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu16/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/shared_cpu_list new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +24 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/shared_cpu_list new file mode 100644 index 0000000..277d78d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +8-15,24-31 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu24/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +8 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list new file mode 100644 index 0000000..277d78d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +8-15,24-31 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-7950x3d/sys/devices/system/cpu/cpu8/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/proc/cpuinfo new file mode 100644 index 0000000..cf47cb1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/proc/cpuinfo @@ -0,0 +1,11 @@ +processor : 0 +vendor_id : AuthenticAMD +cpu family : 25 +model : 97 +model name : AMD Ryzen 9 9950X3D 16-Core Processor +stepping : 2 +cache size : 1024 KB + +processor : 1 +vendor_id : AuthenticAMD +model name : AMD Ryzen 9 9950X3D 16-Core Processor diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +0 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list new file mode 100644 index 0000000..20128cf --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +0-7,16-23 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/size new file mode 100644 index 0000000..f6f9d5d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/size @@ -0,0 +1 @@ +32768K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu0/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/level b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/level new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/level @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list new file mode 100644 index 0000000..45a4fb7 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/shared_cpu_list @@ -0,0 +1 @@ +8 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/size b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/size new file mode 100644 index 0000000..56b868b --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/size @@ -0,0 +1 @@ +32K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/type b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/type new file mode 100644 index 0000000..0b7088e --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index0/type @@ -0,0 +1 @@ +Data diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/level b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/level new file mode 100644 index 0000000..00750ed --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/level @@ -0,0 +1 @@ +3 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list new file mode 100644 index 0000000..277d78d --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/shared_cpu_list @@ -0,0 +1 @@ +8-15,24-31 diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/size b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/size new file mode 100644 index 0000000..f25a4b1 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/size @@ -0,0 +1 @@ +98304K diff --git a/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/type b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/type new file mode 100644 index 0000000..e4fd9dc --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/ryzen-second-ccd-vcache/sys/devices/system/cpu/cpu8/cache/index3/type @@ -0,0 +1 @@ +Unified diff --git a/tools/seg/segctl/tests/fixtures/vm/proc/cpuinfo b/tools/seg/segctl/tests/fixtures/vm/proc/cpuinfo new file mode 100644 index 0000000..dff7565 --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/vm/proc/cpuinfo @@ -0,0 +1,3 @@ +processor : 0 +vendor_id : AuthenticAMD +model name : QEMU Virtual CPU version 2.5+ diff --git a/tools/seg/segctl/tests/fixtures/vm/sys/devices/system/cpu/cpu0/online b/tools/seg/segctl/tests/fixtures/vm/sys/devices/system/cpu/cpu0/online new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tools/seg/segctl/tests/fixtures/vm/sys/devices/system/cpu/cpu0/online @@ -0,0 +1 @@ +1 diff --git a/tools/seg/segctl/tests/setup-packages.txt b/tools/seg/segctl/tests/setup-packages.txt new file mode 100644 index 0000000..e9c0700 --- /dev/null +++ b/tools/seg/segctl/tests/setup-packages.txt @@ -0,0 +1,23 @@ +# Every package `seg game setup` can choose, one per line. A test keeps this in step +# with src/game/setup.rs; CI resolves them on Artix (check-packages.sh -x). +gamescope +lib32-mesa +lib32-nvidia-utils +lib32-vulkan-icd-loader +lib32-vulkan-intel +lib32-vulkan-radeon +linux-zen-headers +lutris +mangohud +mesa +nvidia-open-dkms +nvidia-settings +nvidia-utils +steam +vulkan-icd-loader +vulkan-intel +vulkan-nouveau +vulkan-radeon +vulkan-tools +wine-staging +winetricks