All of the custom code needed to produce ashe-surfussy
  • Shell 70.3%
  • C++ 17.5%
  • QML 10.3%
  • CMake 1.9%
Find a file
Ashe Sterling d28f6eb247 Let the GTX 1060 run at full speed on battery
On battery, nvidia.ko tells RM the system is on DC and the closed RM applies
the VBIOS battery policy, which locks the 1060 to P8. Patch the DKMS source
to add a force_ac_power module parameter that reports AC regardless of _PSR,
enable it via modprobe.d, and re-apply the patch from a pacman hook that runs
before DKMS builds each new nvidia source tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 13:55:48 -07:00
applets/gpu-launch Add a Plasma widget that switches new apps between the GTX 1060 and the iGPU 2026-10-05 19:10:11 -07:00
home Add a Plasma widget that switches new apps between the GTX 1060 and the iGPU 2026-10-05 19:10:11 -07:00
pkgbuilds Add a Plasma widget that switches new apps between the GTX 1060 and the iGPU 2026-10-05 19:10:11 -07:00
system Let the GTX 1060 run at full speed on battery 2026-10-06 13:55:48 -07:00
.gitignore Add all Surface Book 2 customizations 2026-10-04 18:04:19 -07:00
install.sh Add a Plasma widget that switches new apps between the GTX 1060 and the iGPU 2026-10-05 19:10:11 -07:00
README.md Let the GTX 1060 run at full speed on battery 2026-10-06 13:55:48 -07:00

surface-mods

Customizations for a Surface Book 2 (Arch + linux-surface, KDE Plasma 6 Wayland, nvidia-580xx for the GTX 1060 in the base).

pkgbuilds/   patched packages, built and installed under -local names
system/      files that live under / (mirrors absolute paths)
home/        files that live under ~ (mirrors paths relative to $HOME)
install.sh   deploys everything; safe to re-run

Install / deploy

./install.sh             # copy system/ to / (root-owned), symlink home/ into ~,
                         # add IgnorePkg, reload systemd/udev, enable services
./install.sh --packages  # also build and install the -local packages (fresh machine)

This repo is the source of truth.

  • home/ files are symlinked, so editing them here takes effect right away.
  • system/ files are copied, because root must never run files that a normal user can edit. After editing one, re-run ./install.sh.

The bbdx-rebuild service builds out of pkgbuilds/kwin-effects-better-blur-dx-local in place, so the repo must stay at ~/repos/surface-mods. If you move it, update PKGDIR in system/usr/local/bin/bbdx-rebuild.

Build artefacts and cached sources are gitignored, as is pkgbuilds/old-builds/: the pre-rename -1.1 packages, kept locally for rollback only.

Customization Files
Hybrid GPU: KWin on iGPU, apps on GTX 1060 home/.config/environment.d/gpu.conf, home/.config/systemd/user/*/igpu-only.conf, system/etc/udev/rules.d/61-intel-igpu.rules, system/etc/mkinitcpio.conf.d/surface.conf, system/etc/modprobe.d/nvidia-no-fbdev.conf, home/Scripts/kitty.zsh, home/.local/share/applications/kitty.desktop, system/etc/surface-dtx/{detach,attach}.sh, system/usr/local/bin/dgpu-{detach-guard,relaunch-offer,window-state,as-user}
GPU launch switch (Plasma widget) applets/gpu-launch/, pkgbuilds/plasma-applet-gpu-launch-local/, system/usr/local/bin/gpu-launch, home/.config/systemd/user/app-.service.d/gpu-launch.conf, home/.config/systemd/user/gpu-launch-sync.service, home/.config/systemd/user/graphical-session-pre.target.d/gpu-launch.conf
GTX 1060 full speed on battery system/usr/local/share/surface-mods/nvidia-force-ac.patch, system/usr/local/bin/nvidia-force-ac-patch, system/etc/pacman.d/hooks/69-nvidia-force-ac.hook, system/etc/modprobe.d/nvidia-force-ac.conf
egl-wayland PRIME offload fix pkgbuilds/egl-wayland-local/
Better Blur DX repaint-loop fix pkgbuilds/kwin-effects-better-blur-dx-local/, system/usr/local/bin/bbdx-rebuild, system/etc/systemd/system/bbdx-rebuild.service, system/etc/pacman.d/hooks/90-bbdx-rebuild.hook
Studio-mode lid switch guard system/usr/local/bin/surface-studio-lid, system/etc/systemd/system/surface-studio-lid.service
iptsd restart on boot race system/etc/systemd/system/iptsd@.service.d/restart.conf
Audio pin retask system/etc/modprobe.d/hda-jack-retask.conf, system/usr/lib/firmware/hda-jack-retask.fw
Other pacman hooks system/etc/pacman.d/hooks/{kmix-cleanup,odin-build-stb}.hook

Hybrid GPU

Goal: run Wayland-native apps on the GTX 1060 while the desktop stays on the Intel UHD 620, so detaching the clipboard from the base never takes the session down.

  • KWin is pinned to the iGPU. environment.d/gpu.conf sets KWIN_DRM_DEVICES=/dev/dri/intel-igpu. That path is a colon-free symlink created by 61-intel-igpu.rules; it has to be colon-free because KWIN_DRM_DEVICES is a colon-separated list, so a /dev/dri/by-path/pci-0000:00:02.0-card path would break it.
  • Apps are offloaded to the 1060, unless switched to the iGPU with the GPU launch switch. In nvidia mode, gpu-launch sets __NV_PRIME_RENDER_OFFLOAD=1 and __GLX_VENDOR_LIBRARY_NAME=nvidia, but only while the 1060 is bound to nvidia.
    • It deliberately doesn't set __VK_LAYER_NV_optimus=NVIDIA_only, which would hide the iGPU from Vulkan after a detach.
  • The desktop itself stays on the iGPU. The igpu-only.conf drop-ins on plasma-*, xdg-desktop-portal and xdg-desktop-portal-* UnsetEnvironment those variables. Without them, plasmashell never recovers from a detach.
    • The drop-ins also cover KWin, the lock screen, plasmashell, KRunner and the portals.
    • Unsetting the variables isn't enough on its own. glvnd tries the NVIDIA EGL vendor (10_nvidia.json) before Mesa, and loading it opens /dev/nvidia* either way: KWin ended up holding an NVIDIA context and detaching failed. So the drop-ins also set __EGL_VENDOR_LIBRARY_FILENAMES to Mesa only and VK_DRIVER_FILES to the Intel ICDs.
    • To check, nvidia-smi must not list kwin_wayland or plasmashell, and grep nvidia /proc/$(pgrep -x plasmashell)/maps must print nothing.
    • Apps started from Plasma run as their own app-*.service units. They inherit plasmashell's environment, including the Mesa/Intel-only restriction, so app-.service.d/gpu-launch.conf overrides it per launch (see GPU launch switch).
  • kitty follows the launch mode wherever it is started from. kitty.desktop runs ~/Scripts/kitty.zsh, which takes the variables from gpu-launch env, so shells in it (and what they start) get the same mode.
  • Nothing pins the 1060 from boot.
    • mkinitcpio.conf.d/surface.conf keeps the nvidia modules out of the initramfs. udev loads them from the root fs when the base appears, including on reattach. install.sh reruns mkinitcpio -P when this file changes.
    • nvidia-no-fbdev.conf sets nvidia_drm fbdev=0. The default is 1, so dropping nvidia_drm.fbdev=1 from the kernel command line isn't enough. A nvidia_drm.fbdev= there also overrides this file, so leave it out of the boot entry.
    • nvidia-persistenced is disabled (install.sh). It holds /dev/nvidia0 open, so on detach nv_pci_remove waits forever ("non-zero usage count"), wedging pciehp, and the 1060 is never re-enumerated on reattach.
  • Detach guard. surface-dtx/detach.sh is stock plus a call to dgpu-detach-guard.
    • First, it switches new launches to the iGPU (gpu-launch suspend), so nothing new starts on the 1060 during the prompt or the kill. If the detach is aborted, it undoes that (gpu-launch resume).
    • It looks for processes holding /dev/nvidia* or the 1060's /dev/dri nodes open. Any of them would leave nv_pci_remove stuck the same way persistenced does.
    • If there are some, it shows a critical notification listing them with Kill and detach / Cancel. Cancel, or no answer within 40 s, aborts the detach (the latch stays shut). The daemon kills handlers after 60 s, so keep PROMPT_SECS under that.
    • Kill closes whole apps, not just the GPU-holding processes. For each holder it walks up to the topmost ancestor below the systemd user manager (the app's main process) and takes that plus all its descendants and every cgroup they live in. This matters for Electron: VS Code's main process moves itself out of the app-code\x2doss@….service Plasma started into app-code-oss-<pid>.scope, so killing one cgroup left a half-dead instance that the relaunch then handed off to. Only apps under app.slice are treated this way; anything else only has the holder itself killed.
    • It first sends SIGTERM to each app's main process, so it can quit cleanly and save its session. If the GPU is still held or any of the app is still running 3 s later (including a respawned GPU process), every one of its cgroups is killed via cgroup.kill, plus SIGKILL for its processes. The detach goes ahead once nothing holds the GPU, or is aborted if something still does after another 2 s (stuck in the kernel).
    • After a kill, it starts dgpu-relaunch-offer in a transient systemd --user unit. That waits for the detach to finish (or be canceled), then offers to Relaunch the killed apps via kstart --application, after the 1060 is gone. Only apps started from Plasma are offered (desktop id from their app-<id>@….service, or from an app-<id>-<pid>.scope they moved into); programs started from a terminal close with their terminal, and the terminal is offered instead.
    • Before the kill, dgpu-window-state save asks KWin (through a throwaway KWin script whose output is read back from the journal) which virtual desktops, activities and screen each of those apps' windows were on, and whether they were minimized, maximized, fullscreen or kept above. On relaunch, dgpu-window-state restore loads a KWin script for 60 s that puts each new window of those apps back. Geometry isn't restored, because Krohnkite retiles the windows anyway.
    • It only kills processes; custom scripts that unloaded the nvidia modules didn't work.
    • The attach handler (surface-dtx/attach.sh, also run when a detach is aborted after the latch opened) only runs gpu-launch resume.
    • dgpu-as-user runs a command as the seat0 user, in their session bus and user manager. The handlers use it to reach the desktop.
  • Native Wayland offload also depends on the egl-wayland patch below.

GPU launch switch

A Plasma widget picks the GPU that apps launched from then on render on: the GTX 1060 (green circle) or the Intel UHD 620 (blue circle). Click it to switch. Apps that are already running keep their GPU.

  • Widget. applets/gpu-launch/ is a C++ Plasma applet (local.surfacemods.gpulaunch, "GPU Launch Mode") with a QML front end. It appears in the system tray by itself (EnabledByDefault) after plasmashell restarts. To hide it, use System Tray Settings → Entries. It can also go on the desktop or a panel, where it shows the name as well. Right-click for explicit Launch New Apps on NVIDIA / Intel.

    • A ring in the other colour means that GPU is picked, but apps can't launch on it yet. A blue circle with a green ring means "NVIDIA once the base is back".

    • The applet is only the UI and a watcher. It reads gpu-launch's state files (an inotify watch on their directories) and runs gpu-launch to switch.

    • It checks every 5 s whether nvidia has the 1060, and every second for a minute after surface-dtx reports the base or latch changing. When the result disagrees with the current mode (the 1060 came back after a reattach), it runs gpu-launch sync.

    • pkgbuilds/plasma-applet-gpu-launch-local builds it from the repo, so pacman owns the plugin in /usr/lib/qt6/plugins/plasma/applets/. It links only public libPlasma/Qt APIs, so it doesn't need rebuilding for every Plasma or Qt update the way the KWin effect does. Its QML is built with NO_CACHEGEN, because ahead-of-time compiled QML links Qt private API. Rebuild after editing it, then restart plasmashell (systemctl --user restart plasma-plasmashell):

      cd pkgbuilds/plasma-applet-gpu-launch-local && makepkg -f && sudo pacman -U "$(makepkg --packagelist | head -n1)"
      
  • gpu-launch does the switching, and works without the widget:

    gpu-launch               # mode=, preferred=, available=, detaching=
    gpu-launch nvidia|intel  # pick a GPU (kept across logins)
    gpu-launch toggle
    
    • The preference is kept in ~/.local/state/gpu-launch/preferred, defaulting to nvidia. Apps launch on nvidia only when it's preferred, the 1060 is bound to nvidia, and no detach is under way. Otherwise they launch on intel.
    • Each mode is applied in two places:
      1. $XDG_RUNTIME_DIR/gpu-launch/app.env, the EnvironmentFile= of every app-*.service (app-.service.d/gpu-launch.conf), so every app Plasma starts. Those units inherit plasmashell's iGPU-only restriction through Environment=, and EnvironmentFile= is the only setting that overrides it. (UnsetEnvironment= runs last, so it would also drop the file's own values.) In nvidia mode the file sets the offload variables and lists every EGL vendor and Vulkan ICD manifest. In intel mode it sets the same Mesa/Intel-only lists as igpu-only.conf. It also sets VK_LOADER_DRIVERS_SELECT=*intel*, overriding the *nvidia* filter that switcheroo-control has KIO add for apps that prefer the dedicated GPU (Steam's PrefersNonDefaultGPU, Run on dedicated GPU). Without that override, such apps would get no Vulkan driver at all. systemd reads the file at each start, so a switch applies to the next launch.
      2. The systemd user manager environment, for everything else it starts. That means D-Bus-activated apps and services: nvidia mode sets the offload variables there, and intel mode sets the iGPU-only restriction instead.
    • gpu-launch-sync.service applies the mode at login, before Plasma starts apps. It's pulled in by a graphical-session-pre.target drop-in, so it doesn't need enabling. It replaces the old 50-nvidia-offload user environment generator.
    • Detach and reattach. dgpu-detach-guard runs gpu-launch suspend when a detach starts. That creates $XDG_RUNTIME_DIR/gpu-launch/detaching and switches to intel, while keeping the preference.
      • An aborted detach runs gpu-launch resume, either from the guard or from attach.sh via the daemon's detach_abort handler.
      • After a real detach, the marker is dropped by the next sync once the 1060 is gone, or by attach.sh on reattach.
      • When nvidia binds the 1060 again, the widget's poll notices and syncs back to nvidia (a few seconds after the latch closes).
    • Terminals and shells keep the variables they started with. Run eval "$(gpu-launch env)" to bring a shell up to date.

GTX 1060 full speed on battery

Problem: on battery, the 1060 is locked to P8 (139 MHz core, 405 MHz memory) even at full load. nvidia-smi -q -d PERFORMANCE reports SW Power Cap and SW Thermal Slowdown, at about 6 W and 40 °C against a 72 W limit.

  • The cap comes from NVIDIA's own driver, not from the Surface firmware. nvidia.ko reads _PSR on the ACPI AC device (ADP1, answered by SAM) at init and on every AC change. When it reads 0, it tells RM the GPU is on battery (NV2080_CTRL_CMD_PERF_SET_POWERSTATE).
  • For Pascal, the part of RM that handles this is closed source. It applies the VBIOS battery policy, and that policy is P8.
  • Ruled out: the ACPI tables push no cap (no D-notifiers, and GPS _DSM only shares CPU P-state limits). The Surface platform profile and the 1060's temperature don't affect it either.
  • The Arch wiki blames this throttle on fan control. That doesn't fit a GPU at 40 °C.

Patch: nvidia-force-ac.patch adds a force_ac_power parameter to nvidia.ko. When set, nv_acpi_get_powersource() reports AC whatever _PSR says, so RM never enters battery mode. nvidia-force-ac.conf turns it on. The kernel, upower and Plasma still see the real power source.

  • With the parameter on, the 1060 boosts to P0 (1404 MHz core, 4006 MHz memory) on battery.
  • Battery cost: the 1060 can draw up to 72 W, and the CPU adds about 25 W, so heavy GPU use drains the batteries fast. The hardware power brake still works as a backstop.
  • nv-acpi.c is part of the source that DKMS builds, not the closed binary blob, so no package fork is needed.

Keeping it applied: 69-nvidia-force-ac.hook runs nvidia-force-ac-patch on every new /usr/src/nvidia-* tree. It sorts before 70-dkms-install.hook, which then builds the patched tree.

  • If the patch stops applying, the script sends a critical notification and DKMS builds stock. The modprobe option then only logs "unknown parameter".
  • Kernel updates rebuild from the already-patched tree.
  • pacman -Qkk nvidia-580xx-dkms reports nv-acpi.c as altered. That's expected.
cat /sys/module/nvidia/parameters/force_ac_power         # Y = battery cap bypassed
echo N | sudo tee /sys/module/nvidia/parameters/force_ac_power  # battery policy back, live
sudo nvidia-force-ac-patch --rebuild                       # patch + DKMS rebuild by hand

Writing the parameter re-sends the power source to RM immediately if the GPU is in use. If the GPU is idle, the new value takes effect when it is next opened.

Patched packages

Both patched packages are renamed <pkg>-local, with provides=/conflicts= on the original name:

  • Dependents are still satisfied (for example nvidia-580xx-utils → egl-wayland).
  • pacman -Syu and paru -Sua never "upgrade" them to an unpatched build.

Both names are also in IgnorePkg in /etc/pacman.conf (install.sh adds it), so an AUR upload under the same name can't replace them. That line also lists plasma-applet-gpu-launch-local, which is not a fork but is built from this repo (GPU launch switch). kwin-effects-better-blur-dx-patched already exists in the AUR, which is why the suffix is -local. Note that IgnorePkg doesn't affect pacman -U.

Rebuild and reinstall one by hand:

cd pkgbuilds/<name> && makepkg -f && sudo pacman -U "$(makepkg --packagelist | head -n1)"

Upstream packaging, for diffing against future releases:

egl-wayland-local: NVIDIA/egl-wayland#203

Problem: egl-wayland 1.1.23 added linux-dmabuf v6 support and broke PRIME detection on compositors with dmabuf ≤ v5, which includes KWin 6.7.

  • Wayland clients offloaded to the GTX 1060 send NVIDIA-tiled dmabufs to the Intel-driven KWin.
  • KWin fails with importing the supplied dmabufs failed, and the window renders black.
  • Upstream issue: NVIDIA/egl-wayland#202.

Patch: pr203.patch is NVIDIA's own fix (PR #203, Austin Shafer). It restores a primeRenderOffload flag that dmabuf < 6 uses to pick PRIME mode when the render device differs from the compositor's.

Why it's frozen rather than handled by a hook: egl-wayland is an Arch [extra] package, so the next Arch bump would replace a pkgrel 1.1 build with an unpatched one.

  • A pacman hook can't re-patch it, because hooks can't run pacman.
  • A hook that aborts the transaction would block every other update.
  • nvidia-580xx is end-of-life, so nothing should need a newer egl-wayland. The package is renamed and frozen instead.

Returning to stock once an extra/egl-wayland release contains #203:

sudo pacman -S --ask=4 egl-wayland   # --ask=4 = yes, remove the conflicting egl-wayland-local

Then drop egl-wayland-local from IgnorePkg. If something ever needs a newer egl-wayland, rebase pr203.patch onto the new tag instead, unless the fix is already in it.

kwin-effects-better-blur-dx-local: BlurCache damage-tracking fix

Problem: Better Blur DX's BlurCache marked every painted region as dirty and re-injected it 33 ms later. With buffer-age repaints the loop sustained itself: the whole 3240×2160 screen recomposited at 60 Hz and re-blurred at 30 Hz, forever.

GPU cost on the UHD 620 Frame rate
Before the fix 22–72 Mcycles/frame 16–47 fps
With the fix about 5 Mcycles/frame steady 60 fps

Patch: damage-tracking.patch tracks real damage via Item::hasRepaints() in prePaintScreen. The source is branch fix/damage-tracking (based on tag v2.5.1) in ashe-dextop:~/repos/kwin-effects-better-blur-dx.

Interference 1, AUR bumps: the rename freezes it, so paru won't rebuild it from the unpatched AUR PKGBUILD.

Interference 2, every kwin upgrade breaks it: this happens with or without the patch.

  • KWin embeds its exact version in the effect plugin interface ID (org.kde.kwin.EffectPluginFactory6.7.5) and won't load a plugin built for another version.
  • That includes Arch pkgrel rebuilds of kwin.

This is handled automatically:

  • 90-bbdx-rebuild.hook runs on kwin install or upgrade and calls bbdx-rebuild --queue. That starts bbdx-rebuild.service without blocking, since pacman still holds its lock while hooks run.
  • bbdx-rebuild, run by the service as root, works like this:
    1. Waits for pacman's lock to clear.
    2. Compares the installed kwin version with /var/lib/bbdx-rebuild/kwin-version.
    3. If they differ, runs makepkg as ashe in this repo's package directory, then pacman -U.
    4. Writes the stamp and sends a desktop notification. A failure sends a critical notification, with details in the journal.
  • The service is also enabled at boot, which catches a rebuild interrupted by a reboot.

The source tarball is cached in the package directory (gitignored), so rebuilds work offline. The build deps cmake extra-cmake-modules qt6-tools are marked explicitly installed, so orphan cleanup won't remove them.

After a rebuild, log out and back in: Qt never unloads a plugin library.

sudo journalctl -u bbdx-rebuild -b      # what the last rebuild did
sudo bbdx-rebuild --force               # rebuild now

If upstream releases a version newer than 2.5.1, check whether the BlurCache loop is fixed upstream:

  • Fixed upstream: switch back.
    1. Run paru -S --ask=4 kwin-effects-better-blur-dx.
    2. Disable the service and delete the hook.
    3. Drop the name from IgnorePkg.
  • Still broken: rebase the patch on dextop, bump pkgver, and rebuild.

Display settings that go with this: "Color power preference" is set to prefer efficiency, because prefer accuracy costs about 2.5× the compositor work (FP16 offscreen buffer plus a shader color pass).

Studio-mode lid switch guard

With the clipboard attached backwards (studio mode), folding the hinge shut still fires an ACPI lid-close event, which suspends the machine.

  • A logind inhibitor wouldn't help, because PowerDevil already holds a block inhibitor on handle-lid-switch.
  • Instead, surface-studio-lid watches surface-dtx's DeviceMode over D-Bus.
    • In studio mode it unbinds the ACPI lid button (PNP0C0D:00).
    • In laptop or tablet mode it rebinds it, and it always rebinds on exit.
  • Deliberate suspends keep working.

iptsd boot race

iptsd exits 1 if it sends its SET_MODE feature report while the IPTS sensor is inside the 1 s restart window in ipts_control_restart(), where the driver returns -ENODEV. The shipped unit has Restart=no, so losing that race at boot was permanent (no touch or pen until iptsd was restarted by hand). The restart.conf drop-in retries past the window.

Audio pin retask

The hda-jack-retask GUI generated these files:

  • hda-jack-retask.fw overrides the Realtek ALC298 (0x10ec0298) pin configuration.
  • hda-jack-retask.conf loads the override via snd-hda-intel patch=.

Changes take effect after a reboot. To revert, delete both files from the system, remove them here, and reboot.

Other pacman hooks

  • kmix-cleanup.hook deletes kmix's XDG autostart entry after every kmix install or upgrade.
  • odin-build-stb.hook builds Odin's vendored stb after odin updates.

Not in this repo

  • VS Code product.json and sharedProcessMain.js are patched by the pacman hooks of the AUR code-marketplace / code-features-insiders packages, which re-apply them after every code upgrade. Nothing to track.
  • KDE settings, such as kwinrc effects and blur parameters, and display color preference, are left in place in ~/.config.